Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 95 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,9 @@ program. `negforge-py` is a thin binding layer on top of it.
- `tridiag` — real and complex Thomas-algorithm tridiagonal solvers, used
by both the electrostatics and NEGF modules.
- `device` — device geometry/parameters and the electrostatic
(`calc_potential`) and ballistic-current (`calc_current`) calculations.
(`calc_potential`) and ballistic-current (`calc_current`) calculations,
including the `GateGeometry`/`planar`/`fin_fet`/`nanoribbon` presets —
see "Planar, FinFET and nanoribbon devices" below.
- `negf` — the NEGF retarded Green's-function calculation, with two
interchangeable algorithms (`GreenFunctionAlgorithm::{Recursive,
Dense}`) and a parallel energy loop — see "Performance" below.
Expand Down Expand Up @@ -93,6 +95,89 @@ screened contact regions:
Units follow the original code throughout: lengths in nm, energies and
potentials in eV, temperature in Kelvin.

## Planar, FinFET and nanoribbon devices

`geo` in the natural-length formula above (`lambda = sqrt(k_si/k_ox *
d_ch * d_ox / geo)`) is the number of gates electrostatically controlling
the channel — the "generalized scale length" model from the multi-gate
MOSFET literature (Auth & Plummer 1997). It's the one parameter that
actually distinguishes device architectures in this 1D model: more gates
gives a smaller natural length, i.e. tighter electrostatic control and
better short-channel-effect immunity, for the same body thickness.
[`GateGeometry`] enumerates the standard cases (`SingleGate`=1,
`DoubleGate`=2, `TriGate`=3, `GateAllAround`=4), and `DeviceParams` has
matching presets with realistic body/oxide dimensions:

| Preset (Rust `DeviceParams::`, Python `negforge.Device.`) | Architecture | `geo` | Notes |
|---|---|---|---|
| `planar()` | Planar bulk/SOI MOSFET | 1 | Same as `default()` |
| `fin_fet()` | Double-gate FinFET | 2 | Thin fin (`d_ch` = fin width), sidewall gates only; `.with_gate_geometry(GateGeometry::TriGate)` if the fin top is also gated |
| `nanoribbon()` | Gate-all-around nanoribbon/nanowire | 4 | Narrow body (`d_ch` = ribbon width/diameter), gate wraps all sides |

This reproduces the textbook result
(`crates/negforge-core/examples/geometry_comparison.rs`, cross-checked in
`crates/negforge-core/tests/multigate_geometry.rs`): at a channel length
short enough for the planar device to show real short-channel-effect
degradation, FinFET and especially gate-all-around do much better —
subthreshold swing at `l_ch = 10 nm` (ideal thermal limit at 300 K is
59.6 mV/decade):

```
planar lambda= 8.473 nm S= 147.3 mV/decade
fin_fet lambda= 4.151 nm S= 86.4 mV/decade
nanoribbon lambda= 1.895 nm S= 64.3 mV/decade
```

All three architectures also run through the full self-consistent
Poisson↔NEGF loop and both NEGF algorithms without issue — see the
test file above.

**What this is not**: `d_ch` here is a single effective body-thickness
number, not an actually-resolved cross-section — there's no transverse-
mode/subband quantization, so it can't capture, say, the difference
between a wide-and-thin FinFET fin and a narrow-and-thick one with the
same `geo` and cross-sectional area, or volume-inversion effects specific
to very narrow gate-all-around wires. Modeling that requires resolving the
cross-section, which is what a real 3D (or mode-space) extension would
add — see below.

## Extending to a real 3D solver

Nothing here — every geometry preset above still uses the same 1D
natural-length electrostatics and 1D NEGF transport. A genuine 3D (or
"mode-space", the standard middle ground) solver is a substantially
different piece of software, not an incremental change to this one, and
wasn't built as part of this pass. Rough shape of what it would take, for
context if this becomes a real ask later:

1. **Mode-space NEGF** (the tractable approach real nanoscale-FET
simulators use, e.g. nanoMOS/OMEN/NEMO — as opposed to full real-space
3D NEGF, which is a research-grade undertaking on its own): at each
slice along the transport direction, solve a 2D (FinFET) or
cross-sectional Schrodinger equation for the transverse confinement
eigenvalues (subbands) and eigenvectors, given the local potential.
2. Run this crate's existing 1D NEGF machinery once per subband, using
each subband's confinement energy as an added effective potential
floor, and sum the resulting charge/current across subbands.
3. Couple that to a 2D or 3D Poisson solve (cross-section x length, or a
full 3D mesh) instead of the current 1D natural-length formula, and
iterate to self-consistency the same way `selfconsistent.rs` already
does for the 1D case.
4. None of the current O(N) tridiagonal tricks carry over directly — the
Poisson operator is no longer tridiagonal in 2D/3D (a sparse
iterative solver, e.g. conjugate gradient, would replace the Thomas
algorithm), and the NEGF energy-loop parallelism generalizes but now
has a subband dimension to parallelize over too.

This is weeks-to-months of numerical-methods and validation work, not a
follow-on patch, and getting it subtly wrong (e.g. a sign error in the
subband coupling) is easy to do and hard to notice without a trusted 2D/3D
reference to check against — unlike the 1D engine here, which could be
(and was) validated by cross-checking algorithms against each other and
against known physical trends. If this is wanted, it's worth scoping and
staffing as its own effort rather than folding into this codebase's
existing architecture.

## Deviations from the original MATLAB code

The user requested a faithful port plus closing the missing
Expand Down Expand Up @@ -266,9 +351,18 @@ dev.sweep_v_g(0.0, 0.4, 0.05, self_consistent=True, algorithm="dense")
integration test at the model's default (non-toy) device scale,
confirming the self-consistent loop converges and reproduces the
expected ballistic-MOSFET trend (current increasing with gate bias).
- `crates/negforge-core/tests/multigate_geometry.rs` — the
planar/FinFET/nanoribbon physical sanity checks: natural-length
ordering, all three architectures converging (decoupled and
self-consistent), and subthreshold swing improving with more gates at a
short channel length.
- `crates/negforge-core/examples/benchmark.rs` — the performance audit
behind the numbers quoted above; run it with `cargo run --release
--example benchmark -p negforge-core`.
- `crates/negforge-core/examples/geometry_comparison.rs` — the
planar/FinFET/nanoribbon subthreshold-swing comparison quoted above; run
it with `cargo run --release --example geometry_comparison -p
negforge-core`.
- `notebooks/negforge_demo.ipynb` has been executed end-to-end
(`jupyter nbconvert --execute`) to confirm the full frontend path works,
including the dense-vs-recursive comparison cell; outputs are cleared
Expand Down
36 changes: 36 additions & 0 deletions crates/negforge-core/examples/geometry_comparison.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
//! Demonstrates the planar/FinFET/nanoribbon presets and the textbook
//! result they're expected to reproduce: at a fixed (short) channel
//! length, more gates means better electrostatic control and a
//! subthreshold swing closer to the ideal thermal limit
//! (`ln(10)*kT/e = 59.6 mV/decade` at 300 K).
//!
//! Run with `cargo run --release --example geometry_comparison -p
//! negforge-core`. Numbers from this example are quoted in the top-level
//! README's "Planar, FinFET and nanoribbon devices" section.

use negforge_core::sweep::{subthreshold_swing, sweep_v_g};
use negforge_core::{Device, DeviceParams};

fn report(name: &str, mut params: DeviceParams, l_ch: f64) {
params.l_ch = l_ch;
params.v_ds = 0.3;
let dev = Device::new(params);
let points = sweep_v_g(&dev, 0.0, 0.4, 0.02, None).unwrap();
let s = subthreshold_swing(&points, 0.0, 0.4);
println!(
"{name:<11} lambda={:>6.3} nm S={:>6.1} mV/decade",
dev.lambda,
s * 1000.0
);
}

fn main() {
println!("(ideal thermal limit at 300 K: 59.6 mV/decade)\n");
for l_ch in [10.0, 20.0, 40.0] {
println!("=== l_ch = {l_ch} nm ===");
report("planar", DeviceParams::planar(), l_ch);
report("fin_fet", DeviceParams::fin_fet(), l_ch);
report("nanoribbon", DeviceParams::nanoribbon(), l_ch);
println!();
}
}
178 changes: 175 additions & 3 deletions crates/negforge-core/src/device.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,66 @@
//! temperature in Kelvin. `Psi_g`/`Psi_bi`/`Psi_f` store *potential energy*
//! (already in eV), not electrostatic potential in volts — consistent with
//! `E_f`/`E_g` also being in eV.
//!
//! ## Planar, FinFET and nanoribbon devices
//!
//! `lambda = sqrt(k_si/k_ox * d_ch * d_ox / geo)` is the standard
//! "generalized scale length" (natural length) model used throughout the
//! multi-gate MOSFET literature (Auth & Plummer 1997; see also the
//! Ferain/Colinge/Colinge review of multigate transistors). `geo` is the
//! number of gates electrostatically controlling the channel, and is the
//! one parameter that actually distinguishes a planar, FinFET, or
//! gate-all-around (nanoribbon) architecture in this model — more gates
//! means a smaller natural length, i.e. tighter electrostatic control and
//! better short-channel-effect immunity, for the same body thickness. See
//! [`GateGeometry`] and the `DeviceParams::planar`/`fin_fet`/`nanoribbon`
//! constructors below, and
//! `crates/negforge-core/tests/multigate_geometry.rs` for the physical
//! sanity checks (natural length ordering, subthreshold swing improving
//! with more gates).
//!
//! This is still a 1D model along the transport direction: `d_ch` is a
//! single effective body-thickness number (fin width, film thickness, or
//! ribbon diameter, depending on architecture) rather than an actual
//! resolved cross-section, and there's no transverse-mode/subband
//! quantization. See the top-level README's "Extending to a real 3D
//! solver" section for what a genuine cross-section-resolved model would
//! require.

use crate::constants::{E, EPS_0, H_BAR, K_B, M_E};
use crate::tridiag;

/// Number of gates electrostatically controlling the channel, used to pick
/// `DeviceParams::geo` for a given device architecture. See the module
/// docs for the underlying scale-length model and its literature basis.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GateGeometry {
/// One gate (planar bulk MOSFET, or a single-gate SOI film).
SingleGate,
/// Two gates (planar double-gate SOI, or a FinFET with an inactive —
/// e.g. hard-mask-capped — fin top, so only the two sidewalls conduct).
DoubleGate,
/// Three active gates (a FinFET with an active top gate as well as
/// both sidewalls).
TriGate,
/// The gate fully wraps the channel (gate-all-around nanowire /
/// nanoribbon), idealized as a rectangular cross-section with all four
/// sides gated.
GateAllAround,
}

impl GateGeometry {
/// The `geo` (number-of-gates) factor for [`DeviceParams`].
pub fn geo_factor(self) -> f64 {
match self {
GateGeometry::SingleGate => 1.0,
GateGeometry::DoubleGate => 2.0,
GateGeometry::TriGate => 3.0,
GateGeometry::GateAllAround => 4.0,
}
}
}

/// Configuration for a [`Device`]. Field defaults match the original
/// `quantumsim.m` `properties` block.
#[derive(Debug, Clone, Copy, PartialEq)]
Expand All @@ -41,7 +97,9 @@ pub struct DeviceParams {
pub k_si: f64,
/// Relative permittivity of the oxide.
pub k_ox: f64,
/// Gate geometry factor (number of gates).
/// Gate geometry factor (number of gates). See [`GateGeometry`] and
/// the module docs for how this distinguishes planar/FinFET/nanoribbon
/// architectures.
pub geo: f64,
/// Channel length, nm.
pub l_ch: f64,
Expand Down Expand Up @@ -93,6 +151,52 @@ impl Default for DeviceParams {
}
}

impl DeviceParams {
/// Set `geo` from a [`GateGeometry`], leaving every other field
/// unchanged. Note this only changes the *number of gates*; a real
/// FinFET or nanoribbon also has a much thinner body than a planar
/// device — see `fin_fet()`/`nanoribbon()` below for presets that set
/// realistic dimensions too.
pub fn with_gate_geometry(mut self, geometry: GateGeometry) -> Self {
self.geo = geometry.geo_factor();
self
}

/// Typical parameters for a planar, single-gate (bulk or SOI) MOSFET.
/// Identical to `Default::default()` — planar/single-gate is this
/// model's baseline architecture.
pub fn planar() -> Self {
Self::default().with_gate_geometry(GateGeometry::SingleGate)
}

/// Typical parameters for a double-gate FinFET: a thin fin (`d_ch` is
/// the fin width) with two active gates on the sidewalls (see
/// [`GateGeometry::DoubleGate`]; use
/// `.with_gate_geometry(GateGeometry::TriGate)` on the result if the
/// fin top is also gated) and a thin oxide, both narrower than the
/// planar preset for realistic electrostatic control.
pub fn fin_fet() -> Self {
Self {
d_ch: 8.0,
d_ox: 1.5,
..Self::default()
}
.with_gate_geometry(GateGeometry::DoubleGate)
}

/// Typical parameters for a gate-all-around nanoribbon/nanowire FET:
/// a narrow body (`d_ch` is the ribbon width/diameter) fully wrapped
/// by the gate ([`GateGeometry::GateAllAround`]), with a thin oxide.
pub fn nanoribbon() -> Self {
Self {
d_ch: 5.0,
d_ox: 1.0,
..Self::default()
}
.with_gate_geometry(GateGeometry::GateAllAround)
}
}

/// A 1D ballistic-MOSFET device: geometry, electrostatics and derived
/// transport quantities.
#[derive(Debug, Clone)]
Expand Down Expand Up @@ -133,7 +237,30 @@ impl Device {
/// constructor: `lambda` is computed first, then (if
/// `auto_size_contacts`) `l_ds` is overwritten from it before the grid
/// is sized.
pub fn new(mut params: DeviceParams) -> Self {
///
/// # Panics
///
/// Panics if `params` fails [`Self::try_new`]'s validation — see
/// there for what that covers. Use `try_new` instead if you're
/// constructing a device from values you don't already trust (e.g.
/// user-supplied parameters).
pub fn new(params: DeviceParams) -> Self {
Self::try_new(params).unwrap_or_else(|e| panic!("{e}"))
}

/// Fallible version of [`Self::new`]. Returns
/// `Err(NegForgeError::InvalidParameter)` if any of the
/// geometry/material parameters that the electrostatic solve divides
/// by (`geo`, `d_ch`, `d_ox`, `k_si`, `k_ox`, `a`) or the grid sizing
/// (`l_ch`, and `l_ds` when `auto_size_contacts` is `false`) are
/// non-positive or non-finite. Silently letting one of these through
/// would produce an infinite or NaN natural length and propagate
/// garbage through the rest of the solve instead of failing at the
/// point of misconfiguration — a real risk once `geo` is something a
/// caller sets explicitly (e.g. via [`GateGeometry`]) rather than only
/// ever the hardcoded default.
pub fn try_new(mut params: DeviceParams) -> crate::error::Result<Self> {
Self::validate_params(&params)?;
let lambda = Self::compute_lambda(&params);
if params.auto_size_contacts {
params.l_ds = (lambda.floor()) * 15.0;
Expand All @@ -160,7 +287,34 @@ impl Device {
t_hop,
};
device.init_vectors();
device
Ok(device)
}

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(())
}
Comment on lines +293 to 318

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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


fn compute_lambda(params: &DeviceParams) -> f64 {
Expand Down Expand Up @@ -340,4 +494,22 @@ mod tests {
"current should increase with gate bias: {i0} -> {i1}"
);
}

#[test]
#[should_panic(expected = "geo")]
fn rejects_zero_geo() {
Device::new(DeviceParams {
geo: 0.0,
..Default::default()
});
}

#[test]
#[should_panic(expected = "d_ch")]
fn rejects_negative_d_ch() {
Device::new(DeviceParams {
d_ch: -1.0,
..Default::default()
});
}
}
2 changes: 1 addition & 1 deletion crates/negforge-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ pub mod selfconsistent;
pub mod sweep;
mod tridiag;

pub use device::{Device, DeviceParams};
pub use device::{Device, DeviceParams, GateGeometry};
pub use error::{NegForgeError, Result};
pub use negf::{GreenFunctionAlgorithm, GreenFunctionResult};
pub use selfconsistent::{SelfConsistentOptions, SelfConsistentResult};
Expand Down
Loading