Add planar/FinFET/nanoribbon device geometry support - #3
Conversation
The natural-length electrostatics already had a `geo` (number-of-gates)
factor that is exactly how compact 1D models distinguish planar,
multi-gate, and gate-all-around architectures (lambda ~ 1/sqrt(geo)) --
but it was an undocumented, unvalidated raw float with no way to
construct a realistic device of a given architecture.
- Added GateGeometry (SingleGate/DoubleGate/TriGate/GateAllAround) and
DeviceParams::{planar, fin_fet, nanoribbon} presets with realistic
body/oxide dimensions per architecture.
- Added validation: Device::new now panics (via a new fallible
Device::try_new) on non-positive/non-finite geo, d_ch, d_ox, k_si,
k_ox, a, l_ch, or l_ds -- previously a bad value silently produced an
infinite/NaN natural length and propagated garbage through the rest
of the solve. The PyO3 constructor now surfaces this as a proper
Python ValueError (via a new InvalidParameter-aware error mapping)
instead of an uncatchable-by-`except Exception` panic exception.
- Verified the physics: added
crates/negforge-core/tests/multigate_geometry.rs and
examples/geometry_comparison.rs confirming natural-length ordering,
that all three architectures converge (decoupled and
self-consistent), and the textbook multi-gate result -- at a short
channel length, planar shows real short-channel-effect degradation
(147 mV/decade subthreshold swing) while FinFET (86) and especially
gate-all-around nanoribbon (64, close to the 59.6 mV/decade ideal
limit) don't.
- Exposed matching presets in the Python wrapper
(negforge.Device.planar/fin_fet/nanoribbon) and added a notebook
section demonstrating them.
- README: new "Planar, FinFET and nanoribbon devices" section, plus an
honest "Extending to a real 3D solver" section scoping out what a
genuine cross-section-resolved (mode-space NEGF) model would require
and why it's a separate undertaking from this pass.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QVNo3bky8MhMCth48hZT4m
There was a problem hiding this comment.
Pull request overview
Adds explicit planar/FinFET/nanoribbon “multigate geometry” support to the 1D device model by formalizing the gate-count (geo) concept, providing architecture presets, and tightening parameter validation (including Python error mapping) to prevent NaN/∞ electrostatics from silently propagating.
Changes:
- Introduces
GateGeometryandDeviceParams::{planar, fin_fet, nanoribbon}(plus a Rust example + tests) to model planar vs multi-gate architectures viageo. - Makes device construction validation-fail early via
Device::try_newand maps invalid-parameter errors to PythonValueErrorin the PyO3 binding. - Updates README and the demo notebook with architecture comparisons and documented limitations of the 1D model.
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| README.md | Documents gate-geometry presets and adds an architecture comparison + 3D-solver scope notes. |
| python/negforge/init.py | Adds Python Device.planar/fin_fet/nanoribbon convenience constructors. |
| notebooks/negforge_demo.ipynb | Adds a section plotting potential profiles and subthreshold swing across architectures. |
| crates/negforge-py/src/lib.rs | Switches PyO3 constructor to fallible try_new and maps invalid params to ValueError. |
| crates/negforge-core/tests/multigate_geometry.rs | Adds physics/robustness tests for the new presets and expected trends. |
| crates/negforge-core/src/lib.rs | Re-exports GateGeometry from the core crate. |
| crates/negforge-core/src/device.rs | Adds GateGeometry, architecture presets, and parameter validation + try_new. |
| crates/negforge-core/examples/geometry_comparison.rs | Adds a runnable example that prints the architecture comparison used in docs. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| @classmethod | ||
| def planar(cls, **overrides) -> "Device": | ||
| """A planar, single-gate (bulk or SOI) MOSFET. Same defaults as | ||
| `Device()` -- planar/single-gate is this model's baseline.""" | ||
| return cls(geo=1.0, **overrides) | ||
|
|
There was a problem hiding this comment.
Confirmed — cls(geo=1.0, **{"geo": 2.0}) is a TypeError at call time, so Device.planar(geo=2.0) cannot work while fin_fet(geo=3.0) and nanoribbon(geo=...) can. The docstring on fin_fet even advertises overriding geo ("Pass geo=3.0 to also gate the fin top"), which makes the inconsistency more surprising rather than less.
The fix is to make planar match its siblings:
params = {"geo": 1.0}
params.update(overrides)
return cls(**params)Not changing it here — this is #3's branch and I'm working on #5 — flagging it as confirmed for whoever picks this up.
Generated by Claude Code
| fn validate_params(params: &DeviceParams) -> crate::error::Result<()> { | ||
| let positive = [ | ||
| ("geo", params.geo), | ||
| ("d_ch", params.d_ch), | ||
| ("d_ox", params.d_ox), | ||
| ("k_si", params.k_si), | ||
| ("k_ox", params.k_ox), | ||
| ("a", params.a), | ||
| ("l_ch", params.l_ch), | ||
| ]; | ||
| for (name, value) in positive { | ||
| if !(value.is_finite() && value > 0.0) { | ||
| return Err(crate::error::NegForgeError::InvalidParameter(format!( | ||
| "{name} must be finite and positive, got {value}" | ||
| ))); | ||
| } | ||
| } | ||
| let l_ds_ok = params.l_ds.is_finite() && params.l_ds > 0.0; | ||
| if !params.auto_size_contacts && !l_ds_ok { | ||
| return Err(crate::error::NegForgeError::InvalidParameter(format!( | ||
| "l_ds must be finite and positive when auto_size_contacts is false, got {}", | ||
| params.l_ds | ||
| ))); | ||
| } | ||
| Ok(()) | ||
| } |
There was a problem hiding this comment.
Confirmed, and the d_e case is worse than "unbounded loop" suggests — it's a hang, not an error.
calc_current computes ((e_max - psi_0) / d_e).floor().max(0.0) as usize and then iterates 0..=n_steps. Rust's float-to-int casts saturate, so d_e = 0.0 gives inf as usize == usize::MAX and the loop runs ~1.8e19 iterations with no allocation and no panic to stop it. epsilon = 0.0 reaches the same place by a different route: epsilon.ln() is -inf, so e_max is +inf. The same pattern appears in negf_current and in the self-consistent loop's energy grid.
t <= 0 is less dramatic but still wrong: it divides by zero in the Fermi functions, giving f = 0 everywhere and a silent zero current rather than an error.
So the three additions to validate_params are worth having, with epsilon needing 0 < epsilon < 1 specifically (it is a Fermi-function tolerance, and ln of anything >= 1 puts e_max at or below e_fs, collapsing the integration window). m_eff is the fourth of its kind — it divides into t_hop.
Not changing it here (this is #3's branch), but noting for sequencing: #5 will be rebased on top of this branch, so validate_params is where its grid-size and cross-section checks will land too.
Generated by Claude Code
Rebased onto #3's branch, since that lands first — its `try_new`/ `validate_params` is the natural home for the validation this needs, and the two would otherwise fix the same panic twice. - Construction no longer panics on user-supplied parameters. The grid-size check moves out of `compute_grid`'s `assert!` into a `validate_grid` step inside `try_new`, run after `l_ds` may have been auto-sized from `lambda` (which is why it can't live in `validate_params` with the rest). `cross_section_nm2` is validated alongside. `compute_grid` keeps a `debug_assert!` for internal misuse. - `set_l_ch` is fallible for the same reason: it re-derives the grid, so it can be handed a length that cannot be discretized. Validation runs on a copy, so a rejected value leaves the device untouched. - `IVCurve` rejects mismatched or non-1-D arrays instead of letting them surface later as a confusing masking error or a silently misaligned curve. - `calc_potential`'s docstring no longer suggests mutating `rho` from Python: the property returns a copy, so that never worked. Says what is true instead, and `rho`'s own docstring now states it is a snapshot and gives its units. Rebase adjustments to #3's code: `subthreshold_swing` returns `Result` now, so its two call sites unwrap; `multigate_geometry.rs` drops its explicit `eta: 0.05`, which is exactly the new `5 * d_e` default at that device's `d_e = 0.01`. cargo test --workspace: 46 tests pass; cargo clippy --workspace --all-targets clean; notebook re-executed end to end (both this branch's NEGF section and #3's geometry section) with no errors. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pf4rT4UcjJtZgA7Meiq3Tc
ad43302
into
claude/negf-dense-recursive-switch-parallel
Summary
Builds on #2 (not yet merged, hence that branch as the base here).
The natural-length electrostatics already had a
geo(number-of-gates) factor — exactly how compact 1D models distinguish planar, multi-gate, and gate-all-around architectures (lambda ~ 1/sqrt(geo)) — but it was an undocumented, unvalidated raw float with no way to construct a realistic device of a given architecture.GateGeometry(SingleGate/DoubleGate/TriGate/GateAllAround) andDeviceParams::{planar, fin_fet, nanoribbon}presets with realistic body/oxide dimensions per architecture. Mirrored asnegforge.Device.planar()/fin_fet()/nanoribbon()in Python.Device::newnow rejects non-positive/non-finitegeo,d_ch,d_ox,k_si,k_ox,a,l_ch,l_dsinstead of silently producing an infinite/NaN natural length that propagates garbage through the rest of the solve — a real risk now thatgeois something callers set explicitly. Added a fallibleDevice::try_new; the PyO3 constructor uses it and now raises a proper PythonValueError(catchable byexcept Exception) instead of an uncaught-by-normal-exception-handling Rust panic.crates/negforge-core/tests/multigate_geometry.rs+examples/geometry_comparison.rsconfirm natural-length ordering, all three architectures converging (decoupled and self-consistent), and the textbook multi-gate result — atl_ch=10nm, planar shows real short-channel-effect degradation (147 mV/decade subthreshold swing) while FinFET (86) and especially gate-all-around nanoribbon (64, close to the 59.6 mV/decade ideal limit) don't.Test plan
cargo test --workspace— 27 tests pass, including 6 new geometry tests and 2 new validation-panic testscargo clippy --workspace --all-targets— cleancargo run --release --example geometry_comparison -p negforge-core— reproduces the numbers quoted in the READMEmaturin develop --releaserebuilds; verified the presets match Rust exactly, and invalid geometry raisesValueError(catchable by bothexcept ValueErrorandexcept Exception)notebooks/negforge_demo.ipynbre-executed end-to-end with the new section, no errorsGenerated by Claude Code