From e50dee9cccc1adb2a9fab2e235d187bbaef8fa82 Mon Sep 17 00:00:00 2001 From: Justin Fisher Date: Sun, 2 Aug 2026 11:12:12 +0200 Subject: [PATCH] Loading a production PDK model library (a deck that pulls in roughly 50,000 parameter definitions and expands to about 70,000 lines) took ngspice over 15 minutes -- before a single timestep was simulated. The circuit itself was small; the time went entirely into reading the input. This branch attempts to rectify that. This new parser now load the same deck in 5.3s. The parser itself takes about 0.7s with the rest of the time taken by ngspice itself --- .gitignore | 3 +- configure.ac | 56 + examples/tclspice/tcl-testbench2/example.cir | 4 +- examples/tclspice/tcl-testbench4/example.cir | 4 +- ng_existing_code_changes.txt | 8 +- ng_parse/Cargo.lock | 7 + ng_parse/Cargo.toml | 16 + ng_parse/changes.txt | 35 + ng_parse/comparison.txt | 99 + ng_parse/include/ngparse.h | 86 + ng_parse/install.txt | 65 + ng_parse/license.txt | 56 + ng_parse/parser/Cargo.toml | 16 + ng_parse/parser/src/config.rs | 153 + ng_parse/parser/src/expr.rs | 901 ++++ ng_parse/parser/src/ffi.rs | 340 ++ ng_parse/parser/src/lib.rs | 23 + ng_parse/parser/src/main.rs | 269 ++ ng_parse/parser/src/params.rs | 544 +++ ng_parse/parser/src/preprocess.rs | 629 +++ ng_parse/parser/src/reader.rs | 277 ++ ng_parse/parser/src/subckt.rs | 4193 ++++++++++++++++++ ng_parse/parser/src/table.rs | 298 ++ src/Makefile.am | 20 + src/frontend/Makefile.am | 3 +- src/frontend/inp.c | 67 +- src/frontend/inpcom.c | 10 +- src/frontend/ngparse_glue.c | 264 ++ src/frontend/numparam/spicenum.c | 2 +- src/frontend/numparam/table_param.c | 2 +- src/frontend/numparam/table_param.h | 4 +- src/frontend/numparam/xpressn.c | 4 +- src/frontend/subckt.c | 4 +- src/include/ngspice/ngparse_glue.h | 64 + src/include/ngspice/osdi_defer.h | 6 +- src/include/ngspice/smpdefs.h | 1 + src/include/ngspice/spmatrix.h | 1 + src/main.c | 23 +- src/maths/KLU/klusmp.c | 22 + src/maths/ni/niiter.c | 82 +- src/maths/sparse/spsmp.c | 12 + src/maths/sparse/sputils.c | 64 + src/osdi/osdi_defer.c | 2 +- src/osdi/osdisetup.c | 6 +- src/osdi/osditrunc.c | 2 +- src/spicelib/analysis/dctran.c | 18 +- src/spicelib/analysis/dctrcurv.c | 2 +- src/spicelib/devices/hisimhv1/hsmhvld.c | 6 + src/spicelib/devices/hisimhv2/hsmhv2ld.c | 14 +- src/spicelib/parser/inpdpar.c | 2 +- src/spicelib/parser/inpgmod.c | 14 +- 51 files changed, 8744 insertions(+), 59 deletions(-) create mode 100644 ng_parse/Cargo.lock create mode 100644 ng_parse/Cargo.toml create mode 100644 ng_parse/changes.txt create mode 100644 ng_parse/comparison.txt create mode 100644 ng_parse/include/ngparse.h create mode 100644 ng_parse/install.txt create mode 100644 ng_parse/license.txt create mode 100644 ng_parse/parser/Cargo.toml create mode 100644 ng_parse/parser/src/config.rs create mode 100644 ng_parse/parser/src/expr.rs create mode 100644 ng_parse/parser/src/ffi.rs create mode 100644 ng_parse/parser/src/lib.rs create mode 100644 ng_parse/parser/src/main.rs create mode 100644 ng_parse/parser/src/params.rs create mode 100644 ng_parse/parser/src/preprocess.rs create mode 100644 ng_parse/parser/src/reader.rs create mode 100644 ng_parse/parser/src/subckt.rs create mode 100644 ng_parse/parser/src/table.rs create mode 100644 src/frontend/ngparse_glue.c create mode 100644 src/include/ngspice/ngparse_glue.h diff --git a/.gitignore b/.gitignore index baf091d01..e43605bd7 100644 --- a/.gitignore +++ b/.gitignore @@ -93,4 +93,5 @@ test_cases/diode/test_osdi/* test_cases/diode/test_built_in/* build*/ -prof/ \ No newline at end of file +prof/ +ng_parse/target/ diff --git a/configure.ac b/configure.ac index 78c51dae0..09fae73d6 100644 --- a/configure.ac +++ b/configure.ac @@ -147,6 +147,18 @@ AC_ARG_ENABLE([xspice], AC_ARG_ENABLE([osdi], [AS_HELP_STRING([--disable-osdi], [Disable OSDI integration])]) +# --enable-ngparse: use the ngparse Rust deck expander (opt-in at runtime with +# the --ngparse flag). Needs libngparse.a and ngparse.h from the ng_parse repo. +AC_ARG_ENABLE([ngparse], + [AS_HELP_STRING([--enable-ngparse], [Enable the ngparse deck expander])]) +# Default: ng_parse ships inside the ngspice tree, so a plain --enable-ngparse +# needs no second flag. A relative DIR resolves against the source tree, an +# absolute one is used as given -- see the resolution below. +AC_ARG_WITH([ngparse-dir], + [AS_HELP_STRING([--with-ngparse-dir=DIR], + [path to the ng_parse tree (default: ng_parse, inside this tree)])], + [NGPARSE_DIR="$withval"], [NGPARSE_DIR="ng_parse"]) + # --enable-cider: define CIDER in the code. This is for CIDER support AC_ARG_ENABLE([cider], [AS_HELP_STRING([--enable-cider], [Enable CIDER enhancements])]) @@ -1212,6 +1224,50 @@ fi AM_CONDITIONAL([OSDI_WANTED], [test "x$enable_osdi" != xno]) +# ngparse: link the Rust staticlib and switch on the glue. +if test "x$enable_ngparse" = xyes; then + # Resolve --with-ngparse-dir to an absolute path. It may be given absolute + # OR relative to the source tree, and the build may be out-of-tree, so + # neither $(top_srcdir) nor the build dir is safe to prefix blindly. + case "$NGPARSE_DIR" in + /*) ;; # already absolute + *) ngparse_abs=`cd "$srcdir/$NGPARSE_DIR" 2>/dev/null && pwd` + if test -z "$ngparse_abs"; then + AC_MSG_ERROR([--with-ngparse-dir: $srcdir/$NGPARSE_DIR does not exist]) + fi + NGPARSE_DIR="$ngparse_abs" + ;; + esac + + NGPARSE_LIB="$NGPARSE_DIR/target/release/libngparse.a" + # The staticlib is built by `make` (see src/Makefile.am), so it need NOT + # exist yet -- only the ngparse SOURCE and a Rust toolchain do. This is what + # lets the whole thing build from the ngspice top directory with a plain + # `make`, no separate cargo step. + AC_CHECK_FILE([$NGPARSE_DIR/Cargo.toml], [], + [AC_MSG_ERROR([$NGPARSE_DIR/Cargo.toml not found. + --with-ngparse-dir should name the ng_parse checkout itself.])]) + AC_CHECK_FILE([$NGPARSE_DIR/include/ngparse.h], [], + [AC_MSG_ERROR([$NGPARSE_DIR/include/ngparse.h not found. + --with-ngparse-dir should name the ng_parse checkout itself.])]) + AC_CHECK_PROG([CARGO], [cargo], [cargo]) + if test "x$CARGO" = x; then + AC_MSG_ERROR([cargo (the Rust toolchain) is required to build ngparse. + Install rustc/cargo (>= 1.70; e.g. your distro package or https://rustup.rs), + or configure without --enable-ngparse.]) + fi + AC_MSG_RESULT([ngparse deck expander included (built by make via cargo)]) + AC_DEFINE([USE_NGPARSE], [1], [Use the ngparse deck expander]) + NGPARSE_CFLAGS="-I$NGPARSE_DIR/include" + NGPARSE_LIBS="$NGPARSE_LIB -lpthread -ldl -lm" + AC_SUBST([NGPARSE_CFLAGS]) + AC_SUBST([NGPARSE_LIBS]) + AC_SUBST([NGPARSE_DIR]) + AC_SUBST([NGPARSE_LIB]) +fi + +AM_CONDITIONAL([NGPARSE_WANTED], [test "x$enable_ngparse" = xyes]) + # Add CIDER enhancements to ngspice. if test "x$enable_cider" = xyes; then AC_MSG_RESULT([CIDER features enabled]) diff --git a/examples/tclspice/tcl-testbench2/example.cir b/examples/tclspice/tcl-testbench2/example.cir index 34008f547..01ecf6565 100644 --- a/examples/tclspice/tcl-testbench2/example.cir +++ b/examples/tclspice/tcl-testbench2/example.cir @@ -443,9 +443,9 @@ RCROSS2 B0 A24 0.001 ** **INCLUDING FILE: ./proj1/process.models.... * -* Typical N Typical P - from process corners (taken from tsmc025_corners.bsim3 fron NCSU) +* Typical N Typical P - from process corners (taken from foundry_a.bsim3 fron NCSU) * -* TSMC 0.25u 5M 1P process. 2.5V transistor models +* foundry_a 0.25u 5M 1P process. 2.5V transistor models .MODEL Nmod NMOS LEVEL=8 diff --git a/examples/tclspice/tcl-testbench4/example.cir b/examples/tclspice/tcl-testbench4/example.cir index b5aeaf426..0f994a13d 100644 --- a/examples/tclspice/tcl-testbench4/example.cir +++ b/examples/tclspice/tcl-testbench4/example.cir @@ -441,9 +441,9 @@ RCROSS2 B0 A24 0.001 ** **INCLUDING FILE: ./proj1/process.models.... * -* Typical N Typical P - from process corners (taken from tsmc025_corners.bsim3 fron NCSU) +* Typical N Typical P - from process corners (taken from foundry_a.bsim3 fron NCSU) * -* TSMC 0.25u 5M 1P process. 2.5V transistor models +* foundry_a 0.25u 5M 1P process. 2.5V transistor models .MODEL Nmod NMOS LEVEL=8 diff --git a/ng_existing_code_changes.txt b/ng_existing_code_changes.txt index 990404e70..bfedd3d75 100644 --- a/ng_existing_code_changes.txt +++ b/ng_existing_code_changes.txt @@ -192,7 +192,7 @@ WHY: rule is kept outside HS mode. (8) The single-to-brace quote conversion now skips `.del`, `.include`, and `.inc ` lines (which take literal file paths), matching the existing `.lib` skip, to avoid handing paths to numparam as - expressions (STEP 24, observed on GF55 bcd55 .alter decks). + expressions (STEP 24, observed on foundry_c bcd55 .alter decks). CHANGE: @@ -1072,9 +1080,22 @@ @@ -582,10 +582,10 @@ WHY: midpoint 1.3999999999999999e-08), while integer comparisons remain unambiguous (STEP 12). (4) When an identifier matches a function keyword, it is now treated as a function call ONLY if followed by `(`; otherwise it is treated as - a parameter name, so GF55 decks that use `var` as a subckt parameter aren't + a parameter name, so foundry_c decks that use `var` as a subckt parameter aren't shadowed by the built-in `var` function (STEP 17). (5) A unary `+` following a binary operator (e.g. `0.67*+2e-8`) is now accepted as a no-op sign, symmetric - with the existing unary-minus handling, fixing GF55 "Misplaced operator" + with the existing unary-minus handling, fixing foundry_c "Misplaced operator" failures. (6) Added the `nupa_eval_with_scope()` implementation: pushes a fresh symbol-table scope, populates it via attrib(), runs formula(), then frees the scope with del_attrib WITHOUT promoting locals to globals, used by @@ -662,7 +662,7 @@ CHANGE: + * `vec`, `min`, `max`, `pow`, `table_param`). Only treat + * it as a function call if it's followed by `(` (after + * optional whitespace). Otherwise fall back to treating -+ * it as a parameter name — foundry decks (GF55 bcd55 ++ * it as a parameter name — foundry decks (foundry_c bcd55 + * diode_rr.inc) use `var` as a subckt parameter, and + * shadowing it with the built-in function broke + * `vrb='var'` and similar chains. */ diff --git a/ng_parse/Cargo.lock b/ng_parse/Cargo.lock new file mode 100644 index 000000000..94aed8fe9 --- /dev/null +++ b/ng_parse/Cargo.lock @@ -0,0 +1,7 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ngparse" +version = "0.1.0" diff --git a/ng_parse/Cargo.toml b/ng_parse/Cargo.toml new file mode 100644 index 000000000..c9a96b9a6 --- /dev/null +++ b/ng_parse/Cargo.toml @@ -0,0 +1,16 @@ +[workspace] +resolver = "2" +members = [ + "parser", +] + +[workspace.package] +version = "0.1.0" +edition = "2021" +license = "MIT OR Apache-2.0" +authors = ["jfisher"] + +[profile.release] +opt-level = 3 +lto = "thin" +codegen-units = 1 diff --git a/ng_parse/changes.txt b/ng_parse/changes.txt new file mode 100644 index 000000000..e8a386350 --- /dev/null +++ b/ng_parse/changes.txt @@ -0,0 +1,35 @@ +ngparse -- ngspice changes +========================== + +ngparse replaces the slow part of ngspice's frontend (.lib/.inc extraction, +numparam substitution, .subckt expansion). It links in as a static library +(libngparse.a) driven by a thin glue layer; ngspice's own parser is untouched. +It is the default in an --enable-ngparse build; --no-ngparse falls back to the +old frontend for a run. Without --enable-ngparse the glue is a no-op. + +All changes live on branch jfisher/ng_parse. Seven files -- 2 new, 5 modified. + +NEW + src/frontend/ngparse_glue.c + The glue. Decides whether ngparse should handle a source, reads ngspice's + ngbehavior (to pass PSpice mode through), calls the ngparse ABI, and + writes the expanded deck to a temp netlist. A no-op without USE_NGPARSE. + src/include/ngspice/ngparse_glue.h + Glue interface. + +MODIFIED + src/frontend/inp.c + The seam: in inp_spsource(), just before inp_readall(), divert it to read + ngparse's expanded deck instead of the original file. Kept shallow so the + rest of inp_readall (compat passes, numparam, renumbering) still runs. + src/main.c + Adds --no-ngparse (and a redundant --ngparse) plus the --help line. + configure.ac + Adds --enable-ngparse and --with-ngparse-dir; checks for libngparse.a and + ngparse.h; defines USE_NGPARSE. + src/frontend/Makefile.am + Builds ngparse_glue.c; adds the ngparse include path. + src/Makefile.am + Links libngparse.a into ngspice and libngspice. + +Enabling, PSpice mode, and measured results: see install.txt and comparison.txt. diff --git a/ng_parse/comparison.txt b/ng_parse/comparison.txt new file mode 100644 index 000000000..0638d176a --- /dev/null +++ b/ng_parse/comparison.txt @@ -0,0 +1,99 @@ +=============================================================================== + ng_parse — old frontend vs ngparse: measured comparison +=============================================================================== +Date: 2026-07-18 +Binary: ngspice built --enable-ngparse (same binary runs both arms; the only + difference is the --no-ngparse flag, which selects the old text frontend) +Host: 12-core Linux; all runs SERIAL (concurrent ngspice runs each grab ~4.5 + OpenMP cores and skew both timing and, via convergence, results) + +ng_parse replaces ngspice's text-expansion front end — .lib/.inc extraction, +numparam substitution, and .subckt expansion — with a Rust parser that hands +ngspice the same flat, fully-resolved card stream its own frontend would have +produced, just far faster. It changes NOTHING downstream (device model setup, +INPpas1/2/3, the solver), so it changes NOTHING about simulation results. + + +------------------------------------------------------------------------------- + 1. THE STORY: foundry model-load time (what ng_parse actually replaces) +------------------------------------------------------------------------------- +Parse + model-load ONLY — each deck is sourced, a marker is echoed, and ngspice +quits before any analysis. This is exactly the work ng_parse replaces; it is NOT +total run time (which is dominated by simulation and is untouched). Both arms are +measured warm (an untimed run warms the file cache first), so this is not a +disk-speed contest. + + deck old (s) ngparse (s) speedup + ---- ------- ----------- ------- + foundry_b 1149.31 2.18 527x <-- 14LPU PDK + foundry_a 32.84 0.58 57x + foundry_a 4.85 0.28 17x + bandgap 2.74 0.88 3x (SMIC 90nm) + foundry_a 37.18 15.87 2x + +The foundry_b 14LPU deck is the headline: ~19 minutes of model-load collapses to +~2 seconds — a 527x reduction — with bit-identical downstream behavior. The +smaller nodes gain less in absolute terms because their load was already short; +the win scales with how much .lib/.inc/.subckt text the deck drags in. + +Why the speedup varies: it tracks the size and shape of the resolved deck, not +the transistor count. foundry_a's load is dominated by ngspice's own OSDI/BSIM-CMG +model setup (which runs AFTER ng_parse and is not replaced), so its ratio is +smaller even though the deck is large. + + +------------------------------------------------------------------------------- + 2. CORRECTNESS: functional regression, ngparse vs the old parser +------------------------------------------------------------------------------- +Every corpus below was run on the hs install. Mode is selected per deck by a +directory-local .spiceinit (e.g. examples/pton and examples/optran carry +"set ngbehavior=ps"), which ngspice sources and the ngparse glue honors — so one +hs run exercises each deck in its intended dialect (hs / ps). + + corpus decks result + ------ ----- ------ + paranoia (ngspice examples) 209 206 same / 2 diff (see note A) + foundry_c sample netlists 26 26/26 expand clean, 0 dropped params + ngspice regression suite 56 56/56 pass (11 categories: lib- + processing, parser, subckt- + processing, temper, osdi, func, + misc, model, pz, sens, pipe) + OSDI / Verilog-A (in the above) 14 14/14 identical printed values + OpenVA spec/va 2 2/2 identical + foundry load (foundry_b/foundry_a/ 6 identical downstream; validated by + bandgap) expansion match + Monte-Carlo + distribution match (see note B) + +Note A — the 2 paranoia diffs are NOT ngparse regressions: + optran/F5TurboV2thermal-ic-in.cir and optran/HiPass3opamps_optran.cir + both hardcode Windows include paths (c:\Spice64\bin\...). The OLD parser + fails them too; the difference is only in how each parser reports the missing + file. No deck the old parser handles is mishandled by ngparse. + +Note B — foundry decks simulate for minutes to hours, so functional equivalence + is established by (1) byte-comparing the resolved card stream against the old + frontend's output, and (2) confirming Monte-Carlo runs reproduce the SAME + distribution (agauss/gauss/limit stay symbolic so ngspice draws per run with + its own PRNG — seeded runs are bit-identical, batches match mean and sigma). + + +------------------------------------------------------------------------------- + 3. KNOWN LIMITATIONS +------------------------------------------------------------------------------- + * PSpice compatibility (ngbehavior=ps): ngparse replicates the pspice_compat + conversions the corpus exercises — if()->ternary_fcn, VSWITCH->sw/pswitch, + VALUE={TABLE()}->pwl, pwr/pwrs/stp/int. It does NOT implement LTspice- or + KiCad-specific (ngbehavior=lt/ki) conversions; no deck in the corpus needs + them. + * examples/pton/relax_osc_st.cir: fails in BOTH parsers (it redefines the + VCCAP_PSPICE subckt many times); ngparse's failure mode differs from the + reference's but neither produces a usable result. + + +------------------------------------------------------------------------------- + 4. HOW TO REPRODUCE +------------------------------------------------------------------------------- + Timing (foundry): harness/reg/bench_parser.sh + Paranoia functional: harness/paranoia_compare.sh + ngspice regression: harness/conformance.sh + All are serial by construction; see each script's header for details. diff --git a/ng_parse/include/ngparse.h b/ng_parse/include/ngparse.h new file mode 100644 index 000000000..bc7770c81 --- /dev/null +++ b/ng_parse/include/ngparse.h @@ -0,0 +1,86 @@ +/* + * ngparse — fast SPICE/HSPICE deck expansion for ngspice. + * + * Replaces ngspice's text-expansion frontend (.lib/.inc extraction, numparam + * substitution, .subckt expansion) and hands back the flat, fully-resolved card + * list that if_inpdeck/INPpas1 expects. Everything downstream of expansion — + * eval_agauss(), ENHtranslate_poly(), inp_dodeck() — remains ngspice's job. + * + * Link against libngparse.a (plus -lpthread -ldl -lm). + * + * NgpDeck *d = ngparse_expand_file(path, 1, 0); + * if (!d) { fprintf(stderr, "%s\n", ngparse_last_error()); return 1; } + * for (size_t i = 0; i < ngparse_deck_len(d); i++) + * puts(ngparse_deck_card(d, i)); // card 0 is the TITLE + * ngparse_deck_free(d); + * + * Ownership: every pointer returned belongs to the NgpDeck it came from and is + * valid until ngparse_deck_free(). Do not free or modify them. Copy anything + * that must outlive the deck (ngspice's struct card owns its own line, so the + * glue copies). + */ +#ifndef NGPARSE_H +#define NGPARSE_H + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/* An expanded deck. Opaque. */ +typedef struct NgpDeck NgpDeck; + +/* + * Expand the deck at `path` (the top-level netlist) into resolved cards. + * + * cores: worker-core budget. PASS 1. Values > 1 are accepted for forward + * compatibility but are not honored yet — expansion is single-threaded, and is + * only ~10% of deck-load time (the rest is ngspice's own model ingest). 0 is + * treated as 1. + * + * compat: input dialect. 1 = PSpice (ngspice's ngbehavior=ps); anything else = + * default/HSPICE. ngparse has taken over expansion, so ngspice's pspice_compat + * pass no longer runs on the deck; in PSpice mode ngparse applies those + * conversions (if->ternary_fcn, VSWITCH->sw, VALUE={TABLE()}->pwl, pwr/pwrs/ + * stp/int) itself. The glue reads ngbehavior and passes it here. + * + * Returns NULL on failure; call ngparse_last_error() for the reason. + * Release with ngparse_deck_free(). + */ +NgpDeck *ngparse_expand_file(const char *path, int cores, int compat); + +/* Number of cards, including the title at index 0. 0 if d is NULL. */ +size_t ngparse_deck_len(const NgpDeck *d); + +/* + * Card i, or NULL if out of range. INDEX 0 IS THE TITLE: SPICE consumes the + * first card of a deck as the title and starts the netlist at index 1, so this + * list must be handed over whole — dropping index 0 silently eats a real card. + */ +const char *ngparse_deck_card(const NgpDeck *d, size_t i); + +/* + * Parameters that could not be resolved. + * + * A drop is never harmless: the affected device or model silently falls back to + * its DEFAULT, which yields a wrong-but-converging answer rather than an error. + * Surface these; refuse the deck if you want the CLI's --strict behavior. + */ +size_t ngparse_deck_drop_count(const NgpDeck *d); +const char *ngparse_deck_drop(const NgpDeck *d, size_t i); + +/* Release a deck. NULL-safe. Invalidates every pointer taken from it. */ +void ngparse_deck_free(NgpDeck *d); + +/* Last error on this thread, or NULL. Owned by ngparse; do not free. */ +const char *ngparse_last_error(void); + +/* ngparse version string. Static storage. */ +const char *ngparse_version(void); + +#ifdef __cplusplus +} +#endif + +#endif /* NGPARSE_H */ diff --git a/ng_parse/install.txt b/ng_parse/install.txt new file mode 100644 index 000000000..ace83fb40 --- /dev/null +++ b/ng_parse/install.txt @@ -0,0 +1,65 @@ + +The parser in NGSPICE as it is today is not particularly suitable for modern semiconductor processes. + +When doing process evaluation using 40nm, 28nm, 22nm (all planar) and 14nm FF, the parse time for the foundry libraries got slower as the node size reduces. I have a 14nm FinFet with thousands of interconnected parameters and the parse time was in the ballpark of 20 minutes, just to read the library. This is unacceptable when all I need it for is to look at a few inverters and logic gates with a simulation time of a few seconds. + +It became necessary to create a quicker parser. + +The work here parses my library deck in just under 2 seconds, so for my requirements, it works. + +The intended audience for this is frankly just me. If it works for you, great. If you don't want to use it because you didn't invent it - also fine with me. + +Now - if your work is primarily of the behavioral variety, or system stuff using Verilog-A or XSpice, where the parser is not a bottleneck, then this is probably not for you. + +I did use the paranoia tests for regression (not all of which work with the old parser anyway) and I do get like for like results. That said, my focus is for Hspice compatibility, not Pspice or any other flavor. + +When this work started, the old parser being so extremely slow, I made the assumption that a new parser would have to be multi-core. It turned out that this wasn't true - but I added the hooks, so I left it there. And anyway, who knows how crazy future processes will get? Perhaps one day a multi-core parser will be needed. + +I assume that if you DO install this parser, then you DO intend to use it. In that case, you just use ngspice as normal + +ngspice circuit.net + +If you have installed this parser and you DON'T want to use it, then + +ngspice --no-ngparse circuit.net + +If you want more cores: + +ngparse_cores=4 ngspice circuit.net + +PREREQUISITES +------------- +A Rust toolchain -- rustc and cargo, 1.70 or newer. Built and tested with 1.85.0 +If you do not have Rust, either install your distribution's rust/cargo package or use rustup (https://rustup.rs). Rust is needed only to BUILD; the result is a static library that is linked into ngspice + +BUILD AND INSTALL NGSPICE +------------------------- +Much like before only with "--enable-ngparse" see below: + + cd ngspice + ./autogen.sh + mkdir -p release && cd release + ../configure --enable-ngparse \ + --with-x ....... and so on + + +CHECK IT WORKS +-------------- + +For a real check, run a deck both ways and compare. + + ngspice -b --no-ngparse circuit.net > old.log 2>&1 + ngspice -b circuit.net > new.log 2>&1 + diff old.log new.log + +The results should be identical + + +ngparse reports any parameter it could not resolve, rather than falling back to a default: + +ngparse: WARNING: n parameter(s) could not be resolved and were dropped; the affected device/model falls back to its DEFAULT value: + +A dropped parameter does not stop the simulation -- it converges to a wrong answer instead. None of the PDK decks this was developed against produce any. + + + diff --git a/ng_parse/license.txt b/ng_parse/license.txt new file mode 100644 index 000000000..de631e41e --- /dev/null +++ b/ng_parse/license.txt @@ -0,0 +1,56 @@ +ngparse Licensing + +Copyright (c) 2026 Justin Fisher +All rights reserved. + +ngparse is licensed under the `Modified BSD' license -- the same license +ngspice adopts for its own source code, so that the two may be combined and +redistributed together without friction. ngparse links into ngspice as a +static library, and the ngspice glue that calls it (see changes.txt) is +likewise Modified BSD. + +This license applies to all of ngparse: parser/, include/ and the +build files. + +**************************** ngparse license ********************************** + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, +this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, +this list of conditions and the following disclaimer in the documentation +and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its +contributors may be used to endorse or promote products derived from this +software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. + +******************************* dependencies ********************************** + +ngparse has no third-party dependencies. The parser crate declares none, and +the built static library links only against the Rust standard library and the +platform C runtime (-lpthread -ldl -lm). + +******************************* ngspice ************************************* + +ngspice itself is not covered by this file. ngspice is a mixture of licenses +-- Modified BSD for the bulk of it, with exceptions (its KLU and tclspice parts +are LGPLv2, src/osdi is MPLv2.0, src/xspice is largely public domain, and so +on). See ngspice's own COPYING. The files ngparse adds to or modifies in the +ngspice tree are listed in changes.txt; all of them sit in parts of ngspice that +are Modified BSD. diff --git a/ng_parse/parser/Cargo.toml b/ng_parse/parser/Cargo.toml new file mode 100644 index 000000000..fb3420dbe --- /dev/null +++ b/ng_parse/parser/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "ngparse" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +description = "Fast SPICE/HSPICE deck preprocessor and parser for ngspice model loading" + +[lib] +name = "ngparse" +# rlib: the CLI and unit tests. staticlib: the ngspice C glue links this. +crate-type = ["rlib", "staticlib"] + +[dependencies] + +[dev-dependencies] diff --git a/ng_parse/parser/src/config.rs b/ng_parse/parser/src/config.rs new file mode 100644 index 000000000..b62e9ab57 --- /dev/null +++ b/ng_parse/parser/src/config.rs @@ -0,0 +1,153 @@ +//! Run configuration — the core count and the compatibility dialect. + +use std::num::NonZeroUsize; + +/// Which input dialect ngparse should emulate when it emits the flat deck. +/// +/// ngparse replaces ngspice's expansion front end, so the PSpice conversions that +/// ngspice's own `pspice_compat` (inpcompat.c) applies — `if`→`ternary_fcn`, +/// `VSWITCH`→`sw`, `VALUE={TABLE(..)}`→native `TABLE`, `pwr`/`pwrs`/`stp`/`int` +/// → native — never run on our output: that pass is triggered per `.include`d +/// file, and we have already inlined every include. In [`Compat::Pspice`] we do +/// those conversions ourselves so a `ngbehavior=ps` run matches the reference. +/// +/// The mode is not discoverable from the deck (it lives in `ngbehavior`, set in +/// spinit), so the C glue reads it via `cp_getvar("ngbehavior", ..)` and passes +/// it across the FFI. [`Compat::Default`] is ngparse's long-standing behavior and +/// covers the HSPICE/standard decks. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum Compat { + /// Standard / HSPICE-style — ngparse's original behavior. No PSpice rewrites. + #[default] + Default, + /// PSpice (`ngbehavior=ps`): apply the `pspice_compat` conversions on emit. + Pspice, +} + +impl Compat { + /// Map ngspice's `ngbehavior` string to a mode. Only an exact/leading `ps` + /// selects PSpice; everything else (hs, spe, unset) is [`Compat::Default`], + /// matching how ngspice itself keys `newcompat.ps`. + pub fn from_ngbehavior(s: &str) -> Compat { + if s.trim().to_ascii_lowercase().starts_with("ps") { + Compat::Pspice + } else { + Compat::Default + } + } + + /// From the FFI integer: 1 = PSpice, anything else = Default. + pub fn from_ffi(v: i32) -> Compat { + if v == 1 { + Compat::Pspice + } else { + Compat::Default + } + } +} + +/// How ngparse should run. Carried by [`crate::Expander`] and +/// [`crate::subckt::SubcktExpander`] so the parallel seams have somewhere to read +/// their budget from without an API change later. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Config { + /// Worker cores for subckt expansion. Defaults to 1; more fans the + /// independent top-level cards across threads (see + /// `SubcktExpander::expand_parallel`), byte-identical to single-core. + /// + /// Single-core is the default because on today's decks expansion is small: + /// the whole foundry_b expand is ~0.21s of a ~2.0s load, the rest being + /// ngspice's own `INPpas1/2/3` and BSIM-CMG/OSDI model setup, which run after + /// us and which parser cores cannot speed up. Multi-core is built and tested + /// for the decks not seen yet — a library an order of magnitude larger could + /// put real time into expansion, where more cores pay off. + pub cores: NonZeroUsize, + + /// Input dialect to emulate on emit — see [`Compat`]. Defaults to + /// [`Compat::Default`]; the C glue raises it to [`Compat::Pspice`] when + /// `ngbehavior=ps`. + pub compat: Compat, + + /// Remove dangling passives (two-terminal R/C whose far node is referenced + /// nowhere else) from the expanded deck. This is the "reduce EARLY, remove + /// the device entirely" scheme ngspice's own during-setup attempt (commit + /// aac195, since reverted) could not deliver: done here, `.probe`/`.save` + /// and AC see only surviving devices and the matrix shrinks. OFF by + /// default — the emitted deck stays byte-identical unless requested via + /// `--topo-reduce` (CLI) or `NGPARSE_TOPO_REDUCE=1` (integrated build). + pub topo_reduce: bool, +} + +impl Default for Config { + fn default() -> Self { + Config { + cores: NonZeroUsize::new(1).unwrap(), + compat: Compat::Default, + topo_reduce: false, + } + } +} + +impl Config { + /// Sequential — the default. + pub fn single() -> Self { + Config::default() + } + + /// Request `cores` worker threads for subckt expansion. 1 (the default) runs + /// inline; more fans the independent top-level cards across that many threads. + pub fn with_cores(cores: NonZeroUsize) -> Self { + Config { + cores, + ..Config::default() + } + } + + /// Set the compatibility dialect, keeping other settings. + pub fn with_compat(mut self, compat: Compat) -> Self { + self.compat = compat; + self + } + + /// Enable/disable dangling-passive removal, keeping other settings. + pub fn with_topo_reduce(mut self, on: bool) -> Self { + self.topo_reduce = on; + self + } + + /// True in PSpice dialect ([`Compat::Pspice`]). + pub fn is_pspice(&self) -> bool { + self.compat == Compat::Pspice + } + + /// The number of worker cores this run will actually use for expansion. + /// + /// Subckt expansion ([`crate::subckt`]) fans the independent top-level cards + /// across this many threads (see `SubcktExpander::expand_parallel`); the + /// default is 1. The `.lib`/`.inc` walk ([`crate::preprocess`]) stays + /// sequential — a file must be read and indexed before we learn what it pulls + /// in, so the reference chain is discovered as it is walked. + pub fn effective_cores(&self) -> usize { + self.cores.get() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_is_single_core() { + let c = Config::default(); + assert_eq!(c.cores.get(), 1); + assert_eq!(c.effective_cores(), 1); + } + + /// A multi-core request is honored: effective_cores reflects it. + #[test] + fn extra_cores_are_honored() { + let c = Config::with_cores(NonZeroUsize::new(4).unwrap()); + assert_eq!(c.cores.get(), 4); + assert_eq!(c.effective_cores(), 4); + } +} diff --git a/ng_parse/parser/src/expr.rs b/ng_parse/parser/src/expr.rs new file mode 100644 index 000000000..a8c220c09 --- /dev/null +++ b/ng_parse/parser/src/expr.rs @@ -0,0 +1,901 @@ +//! SPICE/HSPICE numeric expression engine: number parsing, a Pratt expression +//! parser, and an evaluator. This is the computational core of the numparam +//! replacement — `.param` values and inline `{...}` expressions are parsed here +//! into an AST and evaluated against an [`Env`] (which supplies parameter values +//! and user-defined functions). +//! +//! Values are `f64` (SPICE numbers are doubles). String-valued constructs +//! (`str(...)`, table string keys) are not modeled yet. +//! +//! Grammar (loosest → tightest binding): +//! ternary ?: (right assoc) +//! logical || && +//! equality == != < > <= >= +//! additive + - +//! multiplicative * / % +//! power ** ^ (right assoc) +//! unary - + ! +//! atom number | ident | ident(args) | ( expr ) +//! +//! Comparisons/booleans yield 1.0 (true) / 0.0 (false), matching ngspice. + +use std::fmt; + +/// Expression AST. +#[derive(Debug, Clone, PartialEq)] +pub enum Expr { + Num(f64), + Var(String), + Unary(UnOp, Box), + Binary(BinOp, Box, Box), + Ternary(Box, Box, Box), + Call(String, Vec), + /// A string literal. SPICE expressions are numeric, but HSPICE's + /// `table_param(str("file"), ...)` takes a filename — the one place a string + /// reaches the evaluator. It never participates in arithmetic: `eval` rejects + /// it, and only `table_param` reads it (via `str_arg`). + Str(String), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum UnOp { + Neg, + Pos, + Not, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BinOp { + Add, + Sub, + Mul, + Div, + Rem, + Pow, + Eq, + Ne, + Lt, + Gt, + Le, + Ge, + And, + Or, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum EvalError { + UnknownVar(String), + UnknownFunc(String), + Arity { func: String, got: usize }, + Parse(String), +} + +impl fmt::Display for EvalError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + EvalError::UnknownVar(n) => write!(f, "unknown parameter `{n}`"), + EvalError::UnknownFunc(n) => write!(f, "unknown function `{n}`"), + EvalError::Arity { func, got } => { + write!(f, "function `{func}` called with {got} args") + } + EvalError::Parse(m) => write!(f, "expression parse error: {m}"), + } + } +} +impl std::error::Error for EvalError {} + +/// Environment supplying parameter values and user functions to the evaluator. +/// +/// Methods take `&self`; implementations use interior mutability (RefCell) for +/// memoization so hierarchical scopes with parent chains compose without `&mut` +/// aliasing headaches during recursive evaluation. +pub trait Env { + /// Resolve a bare identifier to a value (parameter lookup, possibly memoized). + fn var(&self, name: &str) -> Result; + /// Call a user-defined function. Return `Ok(None)` to signal "not a user + /// function" so the evaluator can fall back to built-ins. + fn call_user(&self, name: &str, args: &[f64]) -> Result, EvalError>; +} + +// --------------------------------------------------------------------------- +// Number parsing (SPICE engineering suffixes) +// --------------------------------------------------------------------------- + +/// Parse a SPICE number at the start of `s`, returning (value, bytes_consumed). +/// +/// Handles a leading float (with optional `e`/`E` exponent) followed by an +/// optional engineering suffix. `meg`/`MEG` (1e6) is checked before `m` (1e-3). +/// Any trailing alphabetic characters after the suffix are part of the token but +/// ignored for the value (e.g. `1kohm` -> 1000, `10uF` -> 1e-5), matching SPICE. +pub fn parse_number(s: &str) -> Option<(f64, usize)> { + let b = s.as_bytes(); + let mut i = 0; + + // optional sign + if i < b.len() && (b[i] == b'+' || b[i] == b'-') { + i += 1; + } + let digits_start = i; + while i < b.len() && b[i].is_ascii_digit() { + i += 1; + } + if i < b.len() && b[i] == b'.' { + i += 1; + while i < b.len() && b[i].is_ascii_digit() { + i += 1; + } + } + // need at least one digit in the mantissa + if s[digits_start..i].bytes().filter(|c| c.is_ascii_digit()).count() == 0 { + return None; + } + // exponent + if i < b.len() && (b[i] == b'e' || b[i] == b'E') { + let mut j = i + 1; + if j < b.len() && (b[j] == b'+' || b[j] == b'-') { + j += 1; + } + let exp_digits = { + let start = j; + while j < b.len() && b[j].is_ascii_digit() { + j += 1; + } + j > start + }; + if exp_digits { + i = j; + } + } + + let mant_str = &s[..i]; + let mantissa: f64 = mant_str.parse().ok()?; + + // Engineering suffix. For the power-of-10 suffixes, fold the exponent into the + // mantissa's decimal string and re-parse (`14n` -> `"14e-9"`) so the result is + // the correctly-rounded f64 that a direct decimal (`0.014e-6`) would produce. + // Doing `mantissa * 1e-9` instead loses a ULP and breaks the exact geometry + // equality tests PDK models rely on (e.g. `l == 0.014e-6`). + let rest = &s[i..]; + let lower = rest.to_ascii_lowercase(); + // The micro sign as a suffix: ngspice accepts `µ`/`μ` for `u` (1e-6), and PDK + // and hand-written decks use it (`2µ` = 2uA). Both are 2-byte UTF-8 chars, so + // the ASCII byte match below would miss them and stop the number at the digit, + // turning `2µ` into a bare 2 -- 1e6x wrong. Handle them first, by char. + let (pow10, suf_len): (Option, usize) = if rest.starts_with('\u{00B5}') { + (Some(-6), '\u{00B5}'.len_utf8()) + } else if rest.starts_with('\u{03BC}') { + (Some(-6), '\u{03BC}'.len_utf8()) + } else if lower.starts_with("meg") { + (Some(6), 3) + } else if lower.starts_with("mil") { + (None, 3) // 25.4e-6, not a power of ten + } else { + match lower.bytes().next() { + Some(b't') => (Some(12), 1), + Some(b'g') => (Some(9), 1), + Some(b'k') => (Some(3), 1), + Some(b'm') => (Some(-3), 1), + Some(b'u') => (Some(-6), 1), + Some(b'n') => (Some(-9), 1), + Some(b'p') => (Some(-12), 1), + Some(b'f') => (Some(-15), 1), + Some(b'a') => (Some(-18), 1), + _ => (Some(0), 0), + } + }; + let value = match (suf_len, pow10) { + (0, _) => mantissa, + (_, None) => mantissa * 25.4e-6, // mil + (_, Some(0)) => mantissa, + (_, Some(p)) => { + // if the mantissa already carries an exponent, fall back to a multiply + if mant_str.contains(['e', 'E']) { + mantissa * 10f64.powi(p) + } else { + format!("{mant_str}e{p}") + .parse() + .unwrap_or_else(|_| mantissa * 10f64.powi(p)) + } + } + }; + i += suf_len; + // consume trailing alphanumerics of the unit (ignored), e.g. "ohm" in "1kohm" + let rb = s.as_bytes(); + while i < rb.len() && (rb[i].is_ascii_alphanumeric() || rb[i] == b'_') { + i += 1; + } + + Some((value, i)) +} + +// --------------------------------------------------------------------------- +// Lexer +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, PartialEq)] +enum Tok { + Num(f64), + Ident(String), + Str(String), + Op(&'static str), + LParen, + RParen, + Comma, + Question, + Colon, +} + +fn lex(input: &str) -> Result, EvalError> { + let mut toks = Vec::new(); + let bytes = input.as_bytes(); + let mut i = 0; + let mut depth = 0usize; // paren nesting depth + // Depth of an open `v(`/`i(` argument list. Inside it, arguments are NODE + // NAMES, not expressions, so `+`/`-` are part of the name. + let mut vi_paren: Option = None; + while i < bytes.len() { + let c = bytes[i]; + if c.is_ascii_whitespace() { + i += 1; + continue; + } + // Inside a v()/i() argument list the args are node/source names, not + // arithmetic: `v(vin+,vin-)` names two differential nodes and the trailing + // `+`/`-` belong to the name (opamp macromodels are full of these). Lex + // each argument as one raw token so such names parse instead of blowing up + // the whole expression (which would then be kept verbatim, unsubstituted + // and unrenamed -- e.g. an undefined `gain` reaching ngspice). + if vi_paren == Some(depth) { + match c { + b')' => { + vi_paren = None; + depth = depth.saturating_sub(1); + toks.push(Tok::RParen); + i += 1; + } + b',' => { + toks.push(Tok::Comma); + i += 1; + } + _ => { + let start = i; + while i < bytes.len() { + let d = bytes[i]; + if d.is_ascii_whitespace() || d == b',' || d == b')' || d == b'(' { + break; + } + i += 1; + } + toks.push(Tok::Ident(input[start..i].to_string())); + } + } + continue; + } + // `'...'` delimits an EXPRESSION in SPICE, not a string, so the quotes are + // transparent to the lexer: `agauss('1-mc_sw',1,3)` is agauss(1-mc_sw,1,3). + // Nested quoting like this is common in foundry statistical blocks (foundry_c's + // fet_dist.inc), and rejecting it made the whole `.param` unparsable, so + // `aclv_nest_ags` never resolved and every dependent parameter was dropped + // across all 26 foundry_c decks. String LITERALS use `"` (see Tok::Str) — no + // deck in the corpus writes `str('...')`. + if c == b'\'' { + i += 1; + continue; + } + // `{...}` is the OTHER SPICE expression delimiter, so a `{` nested inside an + // already-braced value is transparent too: `{TABLE(v(a,b),2,{64e-6-iee},...)}` + // carries an inner braced sub-expression, and rejecting it made the whole + // behavioral source unparsable (kept verbatim with nested braces, which + // ngspice then reports as a "mal formed E source"). Treated exactly like the + // `'...'` case above -- the outer span is stripped by subst_exprs before we + // get here, so any brace we see now is a nested sub-expression. + if c == b'{' || c == b'}' { + i += 1; + continue; + } + // number (digit, or leading '.' followed by digit). A leading sign is + // handled as a unary operator by the parser, not the number lexer, so + // that `a-1` lexes as `a`,`-`,`1`. + if c.is_ascii_digit() || (c == b'.' && i + 1 < bytes.len() && bytes[i + 1].is_ascii_digit()) + { + if let Some((v, used)) = parse_number(&input[i..]) { + toks.push(Tok::Num(v)); + i += used; + continue; + } + } + // identifier + if c.is_ascii_alphabetic() || c == b'_' { + let start = i; + while i < bytes.len() + && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'_' ) + { + i += 1; + } + toks.push(Tok::Ident(input[start..i].to_string())); + continue; + } + // multi-char operators + let two = if i + 1 < bytes.len() { &input[i..i + 2] } else { "" }; + match two { + "**" => { toks.push(Tok::Op("**")); i += 2; continue; } + "==" => { toks.push(Tok::Op("==")); i += 2; continue; } + "!=" => { toks.push(Tok::Op("!=")); i += 2; continue; } + "<=" => { toks.push(Tok::Op("<=")); i += 2; continue; } + ">=" => { toks.push(Tok::Op(">=")); i += 2; continue; } + "&&" => { toks.push(Tok::Op("&&")); i += 2; continue; } + "||" => { toks.push(Tok::Op("||")); i += 2; continue; } + _ => {} + } + match c { + b'(' => { + depth += 1; + // A `(` immediately preceded by a `v`/`i` identifier opens a + // node-argument list (voltage/current probe), lexed name-wise above. + if let Some(Tok::Ident(name)) = toks.last() { + if name.eq_ignore_ascii_case("v") || name.eq_ignore_ascii_case("i") { + vi_paren = Some(depth); + } + } + toks.push(Tok::LParen); + } + b')' => { + depth = depth.saturating_sub(1); + toks.push(Tok::RParen); + } + b',' => toks.push(Tok::Comma), + b'?' => toks.push(Tok::Question), + b':' => toks.push(Tok::Colon), + b'+' => toks.push(Tok::Op("+")), + b'-' => toks.push(Tok::Op("-")), + b'*' => toks.push(Tok::Op("*")), + b'/' => toks.push(Tok::Op("/")), + b'%' => toks.push(Tok::Op("%")), + b'^' => toks.push(Tok::Op("^")), + b'<' => toks.push(Tok::Op("<")), + b'>' => toks.push(Tok::Op(">")), + b'!' => toks.push(Tok::Op("!")), + // SPICE behavioral expressions use single `|`/`&` as logical or/and + // (PSpice `IF((V(a)>0 | V(b)>0),1,0)`); treat them as the boolean + // operators, same as `||`/`&&` (matched above when doubled). + b'|' => toks.push(Tok::Op("||")), + b'&' => toks.push(Tok::Op("&&")), + b'"' => { + // string literal: `str("./x.table")`. No escapes — SPICE paths + // never contain quotes, and neither HSPICE nor ngspice's reader + // define an escape here. + let start = i + 1; + let mut j = start; + while j < bytes.len() && bytes[j] != b'"' { + j += 1; + } + if j >= bytes.len() { + return Err(EvalError::Parse("unterminated string literal".into())); + } + toks.push(Tok::Str(input[start..j].to_string())); + i = j; // the trailing `i += 1` below steps past the closing quote + } + _ => return Err(EvalError::Parse(format!("unexpected char {:?}", c as char))), + } + i += 1; + } + Ok(toks) +} + +// --------------------------------------------------------------------------- +// Parser (Pratt) +// --------------------------------------------------------------------------- + +struct Parser { + toks: Vec, + pos: usize, +} + +impl Parser { + fn peek(&self) -> Option<&Tok> { + self.toks.get(self.pos) + } + fn next(&mut self) -> Option { + let t = self.toks.get(self.pos).cloned(); + if t.is_some() { + self.pos += 1; + } + t + } + fn eat(&mut self, t: &Tok) -> Result<(), EvalError> { + if self.peek() == Some(t) { + self.pos += 1; + Ok(()) + } else { + Err(EvalError::Parse(format!("expected {t:?}, found {:?}", self.peek()))) + } + } + + /// left binding power for binary operators; higher binds tighter. + fn bin_bp(op: &str) -> Option<(u8, BinOp, bool)> { + // (bp, op, right_assoc) + Some(match op { + "||" => (1, BinOp::Or, false), + "&&" => (2, BinOp::And, false), + "==" => (3, BinOp::Eq, false), + "!=" => (3, BinOp::Ne, false), + "<" => (4, BinOp::Lt, false), + ">" => (4, BinOp::Gt, false), + "<=" => (4, BinOp::Le, false), + ">=" => (4, BinOp::Ge, false), + "+" => (5, BinOp::Add, false), + "-" => (5, BinOp::Sub, false), + "*" => (6, BinOp::Mul, false), + "/" => (6, BinOp::Div, false), + "%" => (6, BinOp::Rem, false), + "**" | "^" => (8, BinOp::Pow, true), + _ => return None, + }) + } + + fn parse_expr(&mut self, min_bp: u8) -> Result { + // Prefix operators bind looser than `**`/`^` (bp 8) but tighter than the + // multiplicative operators (bp 6), so `-2**2` == `-(2**2)` and + // `-2*3` == `(-2)*3`, matching HSPICE. + const PREFIX_BP: u8 = 7; + let mut lhs = match self.peek() { + Some(Tok::Op("-")) => { self.pos += 1; Expr::Unary(UnOp::Neg, Box::new(self.parse_expr(PREFIX_BP)?)) } + Some(Tok::Op("+")) => { self.pos += 1; Expr::Unary(UnOp::Pos, Box::new(self.parse_expr(PREFIX_BP)?)) } + Some(Tok::Op("!")) => { self.pos += 1; Expr::Unary(UnOp::Not, Box::new(self.parse_expr(PREFIX_BP)?)) } + _ => self.parse_atom()?, + }; + loop { + let op = match self.peek() { + Some(Tok::Op(o)) => *o, + _ => break, + }; + let Some((bp, binop, right)) = Self::bin_bp(op) else { break }; + if bp < min_bp { + break; + } + self.pos += 1; + let next_min = if right { bp } else { bp + 1 }; + let rhs = self.parse_expr(next_min)?; + lhs = Expr::Binary(binop, Box::new(lhs), Box::new(rhs)); + } + // ternary has the lowest precedence; bind it once lhs is complete. + if min_bp == 0 { + if let Some(Tok::Question) = self.peek() { + self.pos += 1; + let then_e = self.parse_expr(0)?; + self.eat(&Tok::Colon)?; + let else_e = self.parse_expr(0)?; + lhs = Expr::Ternary(Box::new(lhs), Box::new(then_e), Box::new(else_e)); + } + } + Ok(lhs) + } + + fn parse_atom(&mut self) -> Result { + match self.next() { + Some(Tok::Num(v)) => Ok(Expr::Num(v)), + Some(Tok::LParen) => { + let e = self.parse_expr(0)?; + self.eat(&Tok::RParen)?; + Ok(e) + } + Some(Tok::Ident(name)) => { + if self.peek() == Some(&Tok::LParen) { + self.pos += 1; + let mut args = Vec::new(); + if self.peek() != Some(&Tok::RParen) { + loop { + args.push(self.parse_expr(0)?); + match self.peek() { + Some(Tok::Comma) => { self.pos += 1; } + _ => break, + } + } + } + self.eat(&Tok::RParen)?; + Ok(Expr::Call(name, args)) + } else { + Ok(Expr::Var(name)) + } + } + Some(Tok::Str(v)) => Ok(Expr::Str(v)), + other => Err(EvalError::Parse(format!("unexpected token {other:?}"))), + } + } +} + +/// Parse an expression string into an AST. +pub fn parse(input: &str) -> Result { + let toks = lex(input)?; + let mut p = Parser { toks, pos: 0 }; + let e = p.parse_expr(0)?; + if p.pos != p.toks.len() { + return Err(EvalError::Parse(format!( + "trailing tokens from {:?}", + &p.toks[p.pos..] + ))); + } + Ok(e) +} + +// --------------------------------------------------------------------------- +// Evaluation +// --------------------------------------------------------------------------- + +fn as_bool(v: f64) -> bool { + v != 0.0 +} +fn from_bool(b: bool) -> f64 { + if b { 1.0 } else { 0.0 } +} + +/// Evaluate a built-in function. Returns `None` if `name` is not a built-in. +/// Extract a string argument: either a bare literal or `str("...")`, which is how +/// HSPICE decks spell a filename (`table_param(str("./x.table"), ...)`). +/// HSPICE's `str()` is a to-string coercion; on a literal it is the identity, so +/// unwrapping it here is exact — and it is the only context a string can appear in. +pub fn str_arg(e: &Expr) -> Option<&str> { + match e { + Expr::Str(s) => Some(s), + Expr::Call(f, a) if f.eq_ignore_ascii_case("str") && a.len() == 1 => str_arg(&a[0]), + _ => None, + } +} + +/// Evaluate a `table_param(file, N_int, ints.., N_real, reals.., col)` call from +/// its argument ASTs. `evala` evaluates a numeric argument. +/// +/// Returns `None` if this is not a well-formed `table_param` call, so the caller +/// can fall through to the ordinary builtin path. +pub fn eval_table_param( + name: &str, + args: &[Expr], + mut evala: F, +) -> Option> +where + F: FnMut(&Expr) -> Result, +{ + if !name.eq_ignore_ascii_case("table_param") { + return None; + } + let bad = |m: &str| Some(Err(EvalError::Parse(format!("table_param: {m}")))); + let Some(path) = args.first().and_then(str_arg) else { + return bad("first argument must be a file name"); + }; + + // file, N_int, , N_real, , output_col + let mut i = 1; + // `count(i)` reads a key-count argument: a non-negative integer. + macro_rules! count { + () => {{ + let Some(a) = args.get(i) else { + return bad("missing key count"); + }; + let v = match evala(a) { + Ok(v) => v, + Err(e) => return Some(Err(e)), + }; + i += 1; + if v < 0.0 || v.fract() != 0.0 { + return bad("key count must be a non-negative integer"); + } + v as usize + }}; + } + let n_int = count!(); + let mut ints = Vec::with_capacity(n_int); + for _ in 0..n_int { + match args.get(i).map(&mut evala) { + Some(Ok(v)) => ints.push(v), + Some(Err(e)) => return Some(Err(e)), + None => return bad("too few integer keys"), + } + i += 1; + } + let n_real = count!(); + let mut reals = Vec::with_capacity(n_real); + for _ in 0..n_real { + match args.get(i).map(&mut evala) { + Some(Ok(v)) => reals.push(v), + Some(Err(e)) => return Some(Err(e)), + None => return bad("too few real keys"), + } + i += 1; + } + let col = match args.get(i).map(&mut evala) { + Some(Ok(v)) => v as i64, + Some(Err(e)) => return Some(Err(e)), + None => return bad("missing output column"), + }; + if i + 1 != args.len() { + return bad("too many arguments"); + } + + match crate::table::lookup(path, &ints, &reals, col) { + Some(v) => Some(Ok(v)), + None => Some(Err(EvalError::Parse(format!( + "table_param: lookup failed in {path:?}" + )))), + } +} + +pub fn eval_builtin(name: &str, a: &[f64]) -> Option> { + let n = name.to_ascii_lowercase(); + let one = |f: fn(f64) -> f64| -> Option> { + if a.len() == 1 { Some(Ok(f(a[0]))) } + else { Some(Err(EvalError::Arity { func: n.clone(), got: a.len() })) } + }; + match n.as_str() { + "sqrt" => one(f64::sqrt), + "exp" => one(f64::exp), + "ln" | "log" => one(f64::ln), + "log10" => one(f64::log10), + "abs" => one(f64::abs), + "sin" => one(f64::sin), + "cos" => one(f64::cos), + "tan" => one(f64::tan), + "asin" => one(f64::asin), + "acos" => one(f64::acos), + "atan" => one(f64::atan), + "sinh" => one(f64::sinh), + "cosh" => one(f64::cosh), + "tanh" => one(f64::tanh), + "asinh" => one(f64::asinh), + "acosh" => one(f64::acosh), + "atanh" => one(f64::atanh), + // Step/ramp and compare-to-zero helpers, matching ngspice + // `spicelib/parser/ptfuncs.c` exactly (PTustep/PTustep2/PTuramp/PTeq0...). + "u" => one(|x| if x < 0.0 { 0.0 } else if x > 0.0 { 1.0 } else { 0.5 }), + "u2" => one(|x| if x <= 0.0 { 0.0 } else if x <= 1.0 { x } else { 1.0 }), + "uramp" => one(|x| if x < 0.0 { 0.0 } else { x }), + "eq0" => one(|x| if x == 0.0 { 1.0 } else { 0.0 }), + "ne0" => one(|x| if x != 0.0 { 1.0 } else { 0.0 }), + "gt0" => one(|x| if x > 0.0 { 1.0 } else { 0.0 }), + "lt0" => one(|x| if x < 0.0 { 1.0 } else { 0.0 }), + "ge0" => one(|x| if x >= 0.0 { 1.0 } else { 0.0 }), + "le0" => one(|x| if x <= 0.0 { 1.0 } else { 0.0 }), + "int" => one(f64::trunc), + // ngspice PTnint uses nearbyint(): round half-integers to the nearest EVEN + // integer (banker's rounding), NOT away-from-zero like f64::round(). + // e.g. nint(2.5)=2, nint(0.5)=0, nint(-0.5)=0, nint(-2.5)=-2. + "nint" => one(f64::round_ties_even), + "ceil" => one(f64::ceil), + "floor" => one(f64::floor), + "sgn" | "sign" => one(|x| if x > 0.0 { 1.0 } else if x < 0.0 { -1.0 } else { 0.0 }), + "pow" | "pwr" => { + if a.len() == 2 { Some(Ok(a[0].powf(a[1]))) } + else { Some(Err(EvalError::Arity { func: n, got: a.len() })) } + } + "min" => { + if a.len() == 2 { Some(Ok(a[0].min(a[1]))) } + else { Some(Err(EvalError::Arity { func: n, got: a.len() })) } + } + "max" => { + if a.len() == 2 { Some(Ok(a[0].max(a[1]))) } + else { Some(Err(EvalError::Arity { func: n, got: a.len() })) } + } + // Statistical draws: in a non-Monte-Carlo run these resolve to the + // nominal (first argument). agauss(nom,var,sigma), aunif(nom,var), etc. + // + // `limit(nominal, abs_variation)` belongs to the same family — ngspice + // lists it alongside the others (`inp.c:979` iterates + // {agauss, gauss, aunif, unif, limit}) and implements it as + // nominal + (drand() > 0 ? abs_variation : -abs_variation) + // with `drand()` uniform on [-1,+1), i.e. a true coin flip about + // `nominal`. So its mean is the first argument, like the rest. Unseeded + // and therefore non-reproducible run-to-run, which is why we return the + // nominal rather than chase a bit-match (the accepted agauss policy). + // + // foundry_c's PDK needs this: `limit(-0.5, 0.5)` appears inside every + // `prdsw_` chain, and without it the whole expression failed to fold + // and the parameter was DROPPED (silently defaulting) on all 26 decks. + "agauss" | "gauss" | "aunif" | "unif" | "limit" => { + if a.is_empty() { Some(Err(EvalError::Arity { func: n, got: 0 })) } + // 3-arg `limit(x,lo,hi)` is PSpice's clamp (HSPICE's MC `limit` takes + // 2 args) — the arity alone disambiguates the dialects. + else if n == "limit" && a.len() == 3 { + let (lo, hi) = if a[1] < a[2] { (a[1], a[2]) } else { (a[2], a[1]) }; + Some(Ok(a[0].max(lo).min(hi))) + } + else { Some(Ok(a[0])) } + } + // `if(cond, a, b)` functional form of the ternary. + "if" => { + if a.len() == 3 { Some(Ok(if as_bool(a[0]) { a[1] } else { a[2] })) } + else { Some(Err(EvalError::Arity { func: n, got: a.len() })) } + } + _ => None, + } +} + +/// Evaluate an expression against an environment. +pub fn eval(e: &Expr, env: &dyn Env) -> Result { + match e { + Expr::Num(v) => Ok(*v), + Expr::Var(name) => env.var(name), + Expr::Unary(op, x) => { + let v = eval(x, env)?; + Ok(match op { + UnOp::Neg => -v, + UnOp::Pos => v, + UnOp::Not => from_bool(!as_bool(v)), + }) + } + Expr::Binary(op, l, r) => { + let a = eval(l, env)?; + let b = eval(r, env)?; + Ok(match op { + BinOp::Add => a + b, + BinOp::Sub => a - b, + BinOp::Mul => a * b, + BinOp::Div => a / b, + BinOp::Rem => a % b, + BinOp::Pow => a.powf(b), + BinOp::Eq => from_bool(a == b), + BinOp::Ne => from_bool(a != b), + BinOp::Lt => from_bool(a < b), + BinOp::Gt => from_bool(a > b), + BinOp::Le => from_bool(a <= b), + BinOp::Ge => from_bool(a >= b), + BinOp::And => from_bool(as_bool(a) && as_bool(b)), + BinOp::Or => from_bool(as_bool(a) || as_bool(b)), + }) + } + Expr::Ternary(c, t, f) => { + if as_bool(eval(c, env)?) { eval(t, env) } else { eval(f, env) } + } + Expr::Str(_) => Err(EvalError::Parse( + "string literal used where a number is required".into(), + )), + Expr::Call(name, args) => { + // table_param needs its args unevaluated: the first is a string. + if let Some(r) = eval_table_param(name, args, |a| eval(a, env)) { + return r; + } + let mut vals = Vec::with_capacity(args.len()); + for a in args { + vals.push(eval(a, env)?); + } + // user functions take precedence, then built-ins + if let Some(v) = env.call_user(name, &vals)? { + return Ok(v); + } + match eval_builtin(name, &vals) { + Some(r) => r, + None => Err(EvalError::UnknownFunc(name.clone())), + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// HSPICE spells a table filename `str("...")`; the parser must accept the + /// string literal, and `str_arg` must see through the `str()` wrapper. + #[test] + fn parses_string_literals_and_str_wrapper() { + let e = parse(r#"str("./RF_COMPONENTS/egnfet_SHE.table")"#).unwrap(); + assert_eq!(str_arg(&e), Some("./RF_COMPONENTS/egnfet_SHE.table")); + let e = parse(r#""bare.table""#).unwrap(); + assert_eq!(str_arg(&e), Some("bare.table")); + // a string is not a number + assert!(eval(&parse(r#""x""#).unwrap(), &MapEnv(HashMap::new())).is_err()); + assert!(parse(r#"str("unterminated"#).is_err()); + } + + /// The full foundry_b call shape must parse: file, N_int, ints, N_real, reals, col. + #[test] + fn parses_table_param_call() { + let e = parse( + r#"table_param(str("./x.table"),2, xnf_clamp, nfin_clamp, 1, l_clamp, 1)"#, + ) + .unwrap(); + match &e { + Expr::Call(n, a) => { + assert!(n.eq_ignore_ascii_case("table_param")); + assert_eq!(a.len(), 7); + assert_eq!(str_arg(&a[0]), Some("./x.table")); + } + other => panic!("expected a call, got {other:?}"), + } + } + + /// A failed lookup must be an ERROR, never a silent default — a wrong rth0 + /// would quietly change self-heating and converge to the wrong answer. + #[test] + fn failed_lookup_is_an_error_not_a_default() { + let e = parse(r#"table_param(str("/nonexistent/x.table"), 1, 1, 0, 1)"#).unwrap(); + assert!(eval(&e, &MapEnv(HashMap::new())).is_err()); + } + use std::collections::HashMap; + + struct MapEnv(HashMap); + impl Env for MapEnv { + fn var(&self, name: &str) -> Result { + self.0 + .get(&name.to_ascii_lowercase()) + .copied() + .ok_or_else(|| EvalError::UnknownVar(name.to_string())) + } + fn call_user(&self, _n: &str, _a: &[f64]) -> Result, EvalError> { + Ok(None) + } + } + + fn ev(s: &str, vars: &[(&str, f64)]) -> f64 { + let map = vars.iter().map(|(k, v)| (k.to_string(), *v)).collect(); + let env = MapEnv(map); + eval(&parse(s).unwrap(), &env).unwrap() + } + + #[test] + fn numbers_with_suffixes() { + assert_eq!(parse_number("1k").unwrap().0, 1000.0); + // micro sign: `µ` (U+00B5) and Greek `μ` (U+03BC) both mean u = 1e-6. + assert_eq!(parse_number("2\u{00B5}").unwrap().0, 2e-6); + assert_eq!(parse_number("2\u{03BC}").unwrap().0, 2e-6); + assert_eq!(parse_number("2.5e-08").unwrap().0, 2.5e-8); + assert_eq!(parse_number("550n").unwrap().0, 550e-9); + assert_eq!(parse_number("16u").unwrap().0, 16e-6); + assert_eq!(parse_number("1meg").unwrap().0, 1e6); + assert_eq!(parse_number("1mil").unwrap().0, 25.4e-6); + assert_eq!(parse_number("2p").unwrap().0, 2e-12); + assert_eq!(parse_number("1kohm").unwrap().0, 1000.0); // trailing unit ignored + } + + #[test] + fn precedence_and_power() { + assert_eq!(ev("2+3*4", &[]), 14.0); + assert_eq!(ev("(2+3)*4", &[]), 20.0); + assert_eq!(ev("2**3**2", &[]), 512.0); // right assoc + assert_eq!(ev("-2**2", &[]), -4.0); // unary binds looser than ** -> -(2**2) + assert_eq!(ev("-2*3", &[]), -6.0); + } + + #[test] + fn comparisons_and_ternary() { + assert_eq!(ev("3==3", &[]), 1.0); + assert_eq!(ev("m==m1", &[("m", 5.0), ("m1", 5.0)]), 1.0); + assert_eq!(ev("a>b ? 10 : 20", &[("a", 1.0), ("b", 2.0)]), 20.0); + assert_eq!(ev("5*(x==m2)", &[("x", 2.0), ("m2", 2.0)]), 5.0); + } + + #[test] + fn builtins_and_params() { + assert_eq!(ev("max(3,7)", &[]), 7.0); + assert_eq!(ev("sqrt(16)", &[]), 4.0); + assert_eq!(ev("abs(0-5)", &[]), 5.0); + assert_eq!(ev("agauss(1.5, 0.2, 3)", &[]), 1.5); // nominal + // `limit(nominal, abs_variation)` is the same family: ngspice returns + // nominal +/- abs_variation on an unseeded coin flip, so the nominal is + // the first argument. foundry_c's prdsw_* chains use limit(-0.5, 0.5). + assert_eq!(ev("limit(-0.5, 0.5)", &[]), -0.5); + assert_eq!(ev("limit(2.0, 0.25)", &[]), 2.0); + assert_eq!(ev("0*(corner_sigma/3)", &[("corner_sigma", 3.0)]), 0.0); + assert_eq!(ev("if(1>0, 2, 3)", &[]), 2.0); + } + + /// Inside v()/i(), a `+`/`-` suffix is part of the node name (differential + /// pins like VIN+/VIN-), not an operator. Such names must parse -- otherwise + /// the whole behavioral expression is kept verbatim, unsubstituted (an opamp + /// macromodel's `gain` param then reaches ngspice undefined). + #[test] + fn node_names_with_sign() { + // node names with +/- parse as single Var arguments to v()/i() + match parse("v(vin+,vin-)").unwrap() { + Expr::Call(f, args) => { + assert_eq!(f, "v"); + assert!(matches!(&args[0], Expr::Var(n) if n == "vin+")); + assert!(matches!(&args[1], Expr::Var(n) if n == "vin-")); + } + other => panic!("expected v() call, got {other:?}"), + } + // the outer expression still parses (was previously a hard parse error) + assert!(parse("limit(g*v(vin+,vin-),v(vp-,vin-),v(vp+,vin-))").is_ok()); + // arithmetic OUTSIDE a v()/i() call is unaffected: `a-1` is still `a` `-` `1` + assert_eq!(ev("a-1", &[("a", 5.0)]), 4.0); + assert_eq!(ev("2*(x-y)", &[("x", 3.0), ("y", 1.0)]), 4.0); + } +} diff --git a/ng_parse/parser/src/ffi.rs b/ng_parse/parser/src/ffi.rs new file mode 100644 index 000000000..509a6059b --- /dev/null +++ b/ng_parse/parser/src/ffi.rs @@ -0,0 +1,340 @@ +//! C ABI for the ngspice glue. +//! +//! The contract is deliberately narrow: hand ngspice the same thing its own +//! frontend would have produced by the time it reaches `if_inpdeck` — a flat, +//! fully-resolved list of card texts, title first — and let it get on with +//! `INPpas1/2/3`. Everything downstream of expansion (`eval_agauss`, +//! `ENHtranslate_poly`, `inp_dodeck`) stays ngspice's job. +//! +//! Ownership: every pointer handed out belongs to the [`NgpDeck`] it came from and +//! stays valid until [`ngparse_deck_free`]. Nothing is copied out; the C side must +//! not free or mutate any of it. +//! +//! ```c +//! NgpDeck *d = ngparse_expand_file("tb_driver.net", 1, 0); +//! if (!d) { fprintf(stderr, "%s\n", ngparse_last_error()); return 1; } +//! for (size_t i = 0; i < ngparse_deck_len(d); i++) +//! puts(ngparse_deck_card(d, i)); /* card 0 is the title */ +//! ngparse_deck_free(d); +//! ``` +//! +//! Panics never cross the boundary (that would be UB): every entry point traps +//! them and reports through [`ngparse_last_error`]. + +use std::cell::RefCell; +use std::ffi::{c_char, c_int, CStr, CString}; +use std::num::NonZeroUsize; +use std::path::Path; + +use crate::config::Config; +use crate::subckt::SubcktExpander; +use crate::Expander; + +/// An expanded deck: the card texts, title first, plus the drops the expansion +/// could not resolve. Opaque to C. +pub struct NgpDeck { + /// NUL-terminated card texts, kept alive for the deck's lifetime. + cards: Vec, + /// Parameters that could not be resolved (see `Expanded::drops`). + drops: Vec, +} + +thread_local! { + static LAST_ERROR: RefCell> = const { RefCell::new(None) }; +} + +fn set_error(msg: impl Into>) { + let c = CString::new(msg).unwrap_or_else(|_| CString::new("error").unwrap()); + LAST_ERROR.with(|e| *e.borrow_mut() = Some(c)); +} + +/// Run `f`, converting a panic into a `NULL`/error return. A panic unwinding into +/// C is undefined behavior, so this must wrap every entry point. +fn guard(default: T, f: impl FnOnce() -> T + std::panic::UnwindSafe) -> T { + match std::panic::catch_unwind(f) { + Ok(v) => v, + Err(_) => { + set_error("ngparse: internal panic (this is a bug; please report the deck)"); + default + } + } +} + +/// The last error on this thread, or `NULL` if none. Owned by ngparse; valid until +/// the next failing call on this thread. +/// +/// # Safety +/// The returned pointer must not be freed or retained across further ngparse calls. +#[no_mangle] +pub extern "C" fn ngparse_last_error() -> *const c_char { + LAST_ERROR.with(|e| match &*e.borrow() { + Some(c) => c.as_ptr(), + None => std::ptr::null(), + }) +} + +/// Expand `path` into a flat, resolved card list. +/// +/// `cores` is the worker-core budget; **pass 1**. Values above 1 are accepted for +/// forward compatibility but are not yet honored — the parser is sequential (see +/// `Config::effective_cores`). 0 is treated as 1. +/// +/// `compat` selects the input dialect: `1` = PSpice (`ngbehavior=ps`), anything +/// else = default/HSPICE. The glue reads ngspice's `ngbehavior` via `cp_getvar` +/// and passes it here so PSpice decks get the same conversions the reference +/// applies (see [`crate::config::Compat`]). +/// +/// Returns `NULL` on failure, with the reason in [`ngparse_last_error`]. The result +/// must be released with [`ngparse_deck_free`]. +/// +/// # Safety +/// `path` must be a valid NUL-terminated C string. +#[no_mangle] +pub unsafe extern "C" fn ngparse_expand_file( + path: *const c_char, + cores: c_int, + compat: c_int, +) -> *mut NgpDeck { + guard(std::ptr::null_mut(), || { + if path.is_null() { + set_error("ngparse_expand_file: path is NULL"); + return std::ptr::null_mut(); + } + let path = match unsafe { CStr::from_ptr(path) }.to_str() { + Ok(p) => p, + Err(_) => { + set_error("ngparse_expand_file: path is not valid UTF-8"); + return std::ptr::null_mut(); + } + }; + let cfg = Config::with_cores( + NonZeroUsize::new(cores.max(1) as usize).unwrap_or(NonZeroUsize::MIN), + ) + .with_compat(crate::config::Compat::from_ffi(compat)) + // Dangling-passive removal for the integrated build, without an ABI + // change: NGPARSE_TOPO_REDUCE=1 in the environment turns it on. + .with_topo_reduce( + std::env::var("NGPARSE_TOPO_REDUCE").map(|v| v != "0" && !v.is_empty()).unwrap_or(false), + ); + + let mut ex = Expander::with_config(cfg); + let flat = match ex.expand_file(Path::new(path)) { + Ok(f) => f, + Err(e) => { + set_error(format!("ngparse: {path}: {e}")); + return std::ptr::null_mut(); + } + }; + let expanded = SubcktExpander::with_config(&flat, cfg).expand(); + + // Card 0 is the TITLE. SPICE consumes the first card of a deck as the + // title and starts the netlist at card 1 — `if_inpdeck` walks straight + // into INPpas1 from card 0 with no title skip of its own, so the title has + // to be here or the first real card is silently eaten. + let mut cards = Vec::with_capacity(expanded.cards.len() + 1); + let title = ex.title(); + let title = if title.trim().is_empty() { "*" } else { title }; + let mut push = |s: &str| { + // A NUL cannot appear in a SPICE deck; if one somehow does, cut there + // rather than fail the whole load. + cards.push(CString::new(s).unwrap_or_else(|e| { + let n = e.nul_position(); + CString::new(&e.into_vec()[..n]).unwrap() + })); + }; + push(title); + for c in &expanded.cards { + push(c); + } + let drops = expanded + .drops + .iter() + .filter_map(|d| CString::new(d.as_str()).ok()) + .collect(); + + Box::into_raw(Box::new(NgpDeck { cards, drops })) + }) +} + +/// Number of cards, including the title at index 0. +/// +/// # Safety +/// `d` must be a live deck from [`ngparse_expand_file`], or NULL. +#[no_mangle] +pub unsafe extern "C" fn ngparse_deck_len(d: *const NgpDeck) -> usize { + if d.is_null() { + return 0; + } + unsafe { &*d }.cards.len() +} + +/// Card `i` as a NUL-terminated string, or `NULL` if out of range. Index 0 is the +/// title. Borrowed from the deck — do not free. +/// +/// # Safety +/// `d` must be a live deck from [`ngparse_expand_file`], or NULL. +#[no_mangle] +pub unsafe extern "C" fn ngparse_deck_card(d: *const NgpDeck, i: usize) -> *const c_char { + if d.is_null() { + return std::ptr::null(); + } + match unsafe { &*d }.cards.get(i) { + Some(c) => c.as_ptr(), + None => std::ptr::null(), + } +} + +/// Number of parameters that could not be resolved. +/// +/// A drop is never harmless: it means a device or model silently fell back to a +/// DEFAULT, which yields a wrong-but-converging answer. The caller should surface +/// these (and may refuse the deck, as the CLI's `--strict` does). +/// +/// # Safety +/// `d` must be a live deck from [`ngparse_expand_file`], or NULL. +#[no_mangle] +pub unsafe extern "C" fn ngparse_deck_drop_count(d: *const NgpDeck) -> usize { + if d.is_null() { + return 0; + } + unsafe { &*d }.drops.len() +} + +/// Drop `i` as a human-readable message, or `NULL` if out of range. Borrowed. +/// +/// # Safety +/// `d` must be a live deck from [`ngparse_expand_file`], or NULL. +#[no_mangle] +pub unsafe extern "C" fn ngparse_deck_drop(d: *const NgpDeck, i: usize) -> *const c_char { + if d.is_null() { + return std::ptr::null(); + } + match unsafe { &*d }.drops.get(i) { + Some(c) => c.as_ptr(), + None => std::ptr::null(), + } +} + +/// Release a deck. Safe to call with NULL. All pointers previously handed out for +/// this deck become dangling. +/// +/// # Safety +/// `d` must be a deck from [`ngparse_expand_file`] that has not already been +/// freed, or NULL. +#[no_mangle] +pub unsafe extern "C" fn ngparse_deck_free(d: *mut NgpDeck) { + if d.is_null() { + return; + } + drop(unsafe { Box::from_raw(d) }); +} + +/// ngparse's version string, for banners and bug reports. Static; never freed. +#[no_mangle] +pub extern "C" fn ngparse_version() -> *const c_char { + concat!(env!("CARGO_PKG_VERSION"), "\0").as_ptr() as *const c_char +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + + fn deck(body: &str, tag: &str) -> String { + let p = std::env::temp_dir().join(format!("ngparse_ffi_{}_{tag}.cir", std::process::id())); + let mut f = std::fs::File::create(&p).unwrap(); + f.write_all(body.as_bytes()).unwrap(); + p.to_string_lossy().into_owned() + } + + fn card(d: *const NgpDeck, i: usize) -> String { + let p = unsafe { ngparse_deck_card(d, i) }; + assert!(!p.is_null(), "card {i} missing"); + unsafe { CStr::from_ptr(p) }.to_string_lossy().into_owned() + } + + #[test] + fn expands_a_deck_title_first() { + let p = deck("* my title\n.param r=2k\nr1 n1 0 {r}\nv1 n1 0 1\n.end\n", "ok"); + let cp = CString::new(p).unwrap(); + let d = unsafe { ngparse_expand_file(cp.as_ptr(), 1, 0) }; + assert!(!d.is_null(), "expand failed"); + // index 0 is the title; SPICE eats it and starts the netlist at 1 + assert_eq!(card(d, 0), "* my title"); + let all: Vec = (0..unsafe { ngparse_deck_len(d) }).map(|i| card(d, i)).collect(); + // top-level `.param` cards are kept deliberately, so `.control` blocks can + // still reference them; the device value is resolved regardless + assert!(all.iter().any(|c| c.starts_with(".param")), "{all:?}"); + assert!( + all.iter().any(|c| c.starts_with("r1 n1 0 2")), + "resolved r1 not found: {all:?}" + ); + assert_eq!(unsafe { ngparse_deck_drop_count(d) }, 0); + assert!(unsafe { ngparse_deck_card(d, 999) }.is_null()); + unsafe { ngparse_deck_free(d) }; + } + + /// A deck whose first line is a real card: SPICE still consumes it as the + /// title, so ngparse must emit one and not lose the card. + #[test] + fn bare_title_is_not_a_card() { + let p = deck("check scoping\nr1 n1 0 1k\nv1 n1 0 1\n.end\n", "bare"); + let cp = CString::new(p).unwrap(); + let d = unsafe { ngparse_expand_file(cp.as_ptr(), 1, 0) }; + assert!(!d.is_null()); + assert_eq!(card(d, 0), "check scoping"); + assert!(card(d, 1).starts_with("r1")); + unsafe { ngparse_deck_free(d) }; + } + + #[test] + fn reports_drops() { + // An EXPRESSION referencing an undefined param drops (a bare identifier is + // instead kept verbatim -- see is_bare_word). `1+nosuch` can't resolve. + let p = deck("* t\n.model dm d (is='1.0+nosuch')\nd1 n1 0 dm\nv1 n1 0 1\n.end\n", "drop"); + let cp = CString::new(p).unwrap(); + let d = unsafe { ngparse_expand_file(cp.as_ptr(), 1, 0) }; + assert!(!d.is_null()); + assert!(unsafe { ngparse_deck_drop_count(d) } > 0, "drop not reported"); + assert!(!unsafe { ngparse_deck_drop(d, 0) }.is_null()); + assert!(unsafe { ngparse_deck_drop(d, 999) }.is_null()); + unsafe { ngparse_deck_free(d) }; + } + + #[test] + fn errors_are_reported_not_crashed() { + let cp = CString::new("/nonexistent/deck.net").unwrap(); + let d = unsafe { ngparse_expand_file(cp.as_ptr(), 1, 0) }; + assert!(d.is_null(), "expected failure"); + let e = ngparse_last_error(); + assert!(!e.is_null(), "no error message"); + let msg = unsafe { CStr::from_ptr(e) }.to_string_lossy().into_owned(); + assert!(msg.contains("nonexistent"), "unhelpful message: {msg}"); + + // NULL path must not crash + assert!(unsafe { ngparse_expand_file(std::ptr::null(), 1, 0) }.is_null()); + } + + #[test] + fn null_and_free_are_safe() { + unsafe { + assert_eq!(ngparse_deck_len(std::ptr::null()), 0); + assert!(ngparse_deck_card(std::ptr::null(), 0).is_null()); + assert_eq!(ngparse_deck_drop_count(std::ptr::null()), 0); + ngparse_deck_free(std::ptr::null_mut()); // no-op + } + } + + /// cores > 1 is accepted (forward-compatible) and must not change the result. + #[test] + fn cores_argument_is_accepted() { + let p = deck("* t\nr1 n1 0 1k\nv1 n1 0 1\n.end\n", "cores"); + let cp = CString::new(p).unwrap(); + for c in [0, 1, 4] { + let d = unsafe { ngparse_expand_file(cp.as_ptr(), c, 0) }; + assert!(!d.is_null(), "cores={c} failed"); + assert_eq!(card(d, 1), "r1 n1 0 1.000000000000000e3"); + unsafe { ngparse_deck_free(d) }; + } + } +} diff --git a/ng_parse/parser/src/lib.rs b/ng_parse/parser/src/lib.rs new file mode 100644 index 000000000..61c2480d0 --- /dev/null +++ b/ng_parse/parser/src/lib.rs @@ -0,0 +1,23 @@ +//! ngparse — a fast SPICE/HSPICE deck preprocessor and parser for ngspice. +//! +//! Goal: replace ngspice's O(n^2) text-expansion frontend (`.lib` extraction + +//! `numparam` substitution + `.subckt` expansion) with a single-pass, optionally +//! parallel parser, and hand ngspice a fully-resolved card stream. See the project +//! README and `docs/` for architecture. +//! +//! This crate is developed standalone-first: it can emit the flattened, resolved +//! deck as text so its output can be validated against ngspice's own expanded-deck +//! dump before any C glue is wired up. + +pub mod config; +pub mod expr; +pub mod ffi; +pub mod params; +pub mod preprocess; +pub mod reader; +pub mod subckt; +pub mod table; + +pub use config::{Compat, Config}; +pub use preprocess::{ExpandError, Expander}; +pub use reader::{logical_lines, LogicalLine}; diff --git a/ng_parse/parser/src/main.rs b/ng_parse/parser/src/main.rs new file mode 100644 index 000000000..4204b43dd --- /dev/null +++ b/ng_parse/parser/src/main.rs @@ -0,0 +1,269 @@ +//! `ngparse` CLI — standalone driver for developing and validating the parser +//! before it is linked into ngspice. +//! +//! Subcommands: +//! ngparse lines dump logical lines (continuations joined, comments stripped) +//! ngparse flatten expand all .inc/.lib sections into a flat card list +//! +//! `flatten` output is what gets diffed against ngspice's expanded-deck dump. + +use std::path::Path; +use std::process::ExitCode; +use std::sync::Arc; +use std::num::NonZeroUsize; +use std::time::Instant; + +fn usage(prog: &str) -> ExitCode { + eprintln!("usage:"); + eprintln!(" {prog} lines dump logical lines"); + eprintln!(" {prog} flatten expand .inc/.lib into a flat card list"); + eprintln!(" {prog} resolve flatten + evaluate params -> numeric deck"); + eprintln!(""); + eprintln!("flags:"); + eprintln!(" --strict fail if any parameter could not be resolved"); + eprintln!(" --topo-reduce remove dangling passives (two-terminal R/C on a"); + eprintln!(" node referenced nowhere else) from the deck"); + eprintln!(" --cores worker cores (default 1; >1 not yet implemented)"); + eprintln!("env:"); + eprintln!(" NGPARSE_DEBUG_DROP=1 list every dropped parameter"); + ExitCode::from(2) +} + +/// The title to emit as line 1 of an expanded deck. +/// +/// Passed through verbatim so a round-trip through ngspice reproduces the original +/// deck's title exactly. An empty title (unreadable/empty entry file) still has to +/// occupy line 1 — something will be consumed as the title regardless, so it had +/// better be a placeholder and not the first real card. +fn title_line(title: &str) -> String { + if title.trim().is_empty() { + "*".to_string() + } else { + title.to_string() + } +} + +/// Parse `--cores ` (or `--cores=`) from anywhere in argv. +/// +/// Defaults to 1. A request for more is ACCEPTED but not yet honored, and says so +/// loudly — never let a user believe they got 4 cores when they got 1. See +/// `Config::effective_cores` for why the parser is sequential: it is 0.21s of a +/// 2.0s load, the rest being ngspice's own model ingest. +fn parse_cores(argv: &[String]) -> Result { + let mut val: Option<&str> = None; + for (i, a) in argv.iter().enumerate() { + if let Some(v) = a.strip_prefix("--cores=") { + val = Some(v); + } else if a == "--cores" { + val = Some(argv.get(i + 1).map(String::as_str).unwrap_or("")); + } + } + let Some(v) = val else { + return Ok(ngparse::Config::single()); + }; + let n: usize = v + .parse() + .map_err(|_| format!("--cores: expected a positive integer, got {v:?}"))?; + let n = NonZeroUsize::new(n).ok_or_else(|| "--cores: must be >= 1".to_string())?; + Ok(ngparse::Config::with_cores(n)) +} + +fn main() -> ExitCode { + let argv: Vec = std::env::args().collect(); + // flags may appear anywhere after the subcommand + let strict = argv.iter().any(|a| a == "--strict"); + // `--pspice` mirrors ngspice's `ngbehavior=ps`: apply the PSpice conversions + // (if->ternary_fcn, VSWITCH->sw, VALUE={TABLE(..)}->native TABLE, pwr/pwrs/ + // stp/int) on emit. The glue sets this from ngbehavior; the flag lets the CLI + // reproduce a ps-mode run. + let compat = if argv.iter().any(|a| a == "--pspice") { + ngparse::config::Compat::Pspice + } else { + ngparse::config::Compat::Default + }; + // `--topo-reduce` removes dangling passives from the expanded deck (see + // Config::topo_reduce). Off by default. + let topo = argv.iter().any(|a| a == "--topo-reduce"); + let cfg = match parse_cores(&argv) { + Ok(c) => c.with_compat(compat).with_topo_reduce(topo), + Err(e) => { + eprintln!("ngparse: {e}"); + return ExitCode::from(2); + } + }; + // `--cores ` puts a bare number in argv; drop it so it is not read as a path. + let args: Vec = { + let mut out = Vec::new(); + let mut skip = false; + for a in &argv { + if skip { + skip = false; + continue; + } + if a == "--cores" { + skip = true; + continue; + } + if !a.starts_with("--") { + out.push(a.clone()); + } + } + out + }; + if args.len() < 3 { + return usage(&argv[0]); + } + let (cmd, path) = (args[1].as_str(), args[2].as_str()); + + match cmd { + "lines" => { + let src = match std::fs::read_to_string(path) { + Ok(s) => s, + Err(e) => { + eprintln!("ngparse: cannot read {path}: {e}"); + return ExitCode::FAILURE; + } + }; + let lines = ngparse::logical_lines(&src, Arc::from(path)); + for l in &lines { + println!("{}", l.text); + } + eprintln!("ngparse: {} logical lines", lines.len()); + ExitCode::SUCCESS + } + "flatten" => { + let t0 = Instant::now(); + let mut ex = ngparse::Expander::new(); + match ex.expand_file(Path::new(path)) { + Ok(lines) => { + for l in &lines { + println!("{}", l.text); + } + let dt = t0.elapsed(); + eprintln!( + "ngparse: flattened to {} cards in {:.3}s", + lines.len(), + dt.as_secs_f64() + ); + ExitCode::SUCCESS + } + Err(e) => { + eprintln!("ngparse: flatten failed: {e}"); + ExitCode::FAILURE + } + } + } + "getp" => { + // getp — flatten, collect, resolve one param (debug) + let name = args.get(3).map(String::as_str).unwrap_or(""); + let mut ex = ngparse::Expander::new(); + let flat = match ex.expand_file(Path::new(path)) { + Ok(l) => l, + Err(e) => { eprintln!("flatten failed: {e}"); return ExitCode::FAILURE; } + }; + let mut table = ngparse::params::ParamTable::new(); + table.collect(&flat); + eprintln!("collected {} params", table.param_count()); + match table.eval_str(name) { + Ok(v) => println!("{name} = {v}"), + Err(e) => println!("{name} : ERROR {e}"), + } + ExitCode::SUCCESS + } + "expand" => { + // full pipeline: flatten -> subckt-expand + param-resolve -> flat numeric deck + let t0 = Instant::now(); + let mut ex = ngparse::Expander::with_config(cfg); + let flat = match ex.expand_file(Path::new(path)) { + Ok(l) => l, + Err(e) => { eprintln!("ngparse: flatten failed: {e}"); return ExitCode::FAILURE; } + }; + let t_flat = t0.elapsed(); + let se = ngparse::subckt::SubcktExpander::with_config(&flat, cfg); + let r = se.expand(); + let dt = t0.elapsed(); + // Line 1 must be the title: whoever reads this deck back — ngspice via + // `source`, or the C glue via if_inpdeck — consumes it and starts the + // netlist at line 2. Without it the first real card is silently eaten. + println!("{}", title_line(ex.title())); + for c in &r.cards { + println!("{c}"); + } + eprintln!( + "ngparse: expand done in {:.3}s (flatten {:.3}s) -> {} cards", + dt.as_secs_f64(), + t_flat.as_secs_f64(), + r.cards.len() + ); + // A dropped parameter is never silent: it means a device/model quietly + // falls back to a DEFAULT, which yields a wrong-but-converging answer + // (this exact failure mode gave foundry_a a dead transistor via u0=0, and + // bxpressn-1 a malformed B source via a dropped `v=`). + if !r.drops.is_empty() { + eprintln!( + "ngparse: WARNING: {} parameter(s) could not be resolved and were DROPPED;", + r.drops.len() + ); + eprintln!(" the affected device/model silently falls back to its DEFAULT value."); + let show = 10.min(r.drops.len()); + for d in r.drops.iter().take(show) { + eprintln!(" {d}"); + } + if r.drops.len() > show { + eprintln!( + " ... and {} more (NGPARSE_DEBUG_DROP=1 to see every one)", + r.drops.len() - show + ); + } + if strict { + eprintln!("ngparse: --strict: refusing to emit a deck with dropped parameters"); + return ExitCode::FAILURE; + } + eprintln!(" (use --strict to make this an error)"); + } + ExitCode::SUCCESS + } + "resolve" => { + let t0 = Instant::now(); + let mut ex = ngparse::Expander::new(); + let flat = match ex.expand_file(Path::new(path)) { + Ok(l) => l, + Err(e) => { + eprintln!("ngparse: flatten failed: {e}"); + return ExitCode::FAILURE; + } + }; + let t_flat = t0.elapsed(); + + let mut table = ngparse::params::ParamTable::new(); + table.collect(&flat); + let t_collect = t0.elapsed(); + + let mut stats = ngparse::params::SubstStats::default(); + let mut nparam = 0usize; + println!("{}", title_line(ex.title())); // line 1 is the title — see `expand` + for l in &flat { + let t = l.text.trim_start(); + if t.len() >= 6 && t[..6].eq_ignore_ascii_case(".param") { + nparam += 1; + continue; // .param lines are dropped from the resolved netlist + } + let out = ngparse::params::substitute_line(&table, &l.text, "0", &mut stats); + println!("{out}"); + } + let dt = t0.elapsed(); + eprintln!( + "ngparse: resolve done in {:.3}s (flatten {:.3}s, collect {:.3}s)", + dt.as_secs_f64(), + t_flat.as_secs_f64(), + (t_collect - t_flat).as_secs_f64() + ); + eprintln!( + "ngparse: {nparam} .param cards, {} inline exprs ({} failed to evaluate)", + stats.exprs_total, stats.exprs_failed + ); + ExitCode::SUCCESS + } + _ => usage(&args[0]), + } +} diff --git a/ng_parse/parser/src/params.rs b/ng_parse/parser/src/params.rs new file mode 100644 index 000000000..52d48008a --- /dev/null +++ b/ng_parse/parser/src/params.rs @@ -0,0 +1,544 @@ +//! Parameter collection, resolution, and `{...}`/`'...'` substitution — the +//! numparam replacement. Given the flattened card stream, this: +//! 1. collects every `.param` definition (multi-assignment lines and +//! `name(args)=expr` function definitions), +//! 2. resolves parameter values lazily with memoization + cycle detection +//! (so forward references and inter-parameter dependencies just work), +//! 3. substitutes inline `{expr}` / `'expr'` on device/model lines with the +//! evaluated numeric value, +//! and emits a fully-numeric card stream plus resolution statistics. +//! +//! Values are `f64`. Anything that fails to evaluate is reported and (in numeric +//! output mode) replaced with a placeholder so downstream ngspice sees no +//! leftover `{}` — this is what lets us measure the model-ingest floor before the +//! evaluator is fully complete. + +use std::cell::RefCell; +use std::collections::{HashMap, HashSet}; +use std::rc::Rc; + +use crate::expr::{eval, parse, Env, EvalError, Expr}; +use crate::reader::LogicalLine; + +/// One `.param` assignment parsed from a card. +pub(crate) struct Assign { + pub(crate) name: String, + pub(crate) args: Option>, + pub(crate) rhs: String, +} + +/// Case-insensitive key. +pub(crate) fn key(s: &str) -> String { + s.to_ascii_lowercase() +} + +/// Parse the text after `.func`: `name(args) [=] body`. Returns +/// `(name, arg names, body)` with the body's surrounding `'…'`/`{…}` stripped. +/// `.func` bodies are always a single function, so unlike `.param` there is no +/// multi-assignment to handle. +pub(crate) fn parse_func_def(rest: &str) -> Option<(String, Vec, String)> { + let s = rest.trim(); + let open = s.find('(')?; + let name = s[..open].trim().to_string(); + if name.is_empty() { + return None; + } + // Match the args paren (they never nest in a func header). + let close = s[open..].find(')')? + open; + let argnames: Vec = s[open + 1..close] + .split(',') + .map(str::trim) + .filter(|a| !a.is_empty()) + .map(str::to_string) + .collect(); + // Body: skip an optional `=`, then strip one layer of quotes/braces. + let mut body = s[close + 1..].trim(); + if let Some(rest) = body.strip_prefix('=') { + body = rest.trim(); + } + let body = body + .strip_prefix('\'') + .and_then(|b| b.strip_suffix('\'')) + .or_else(|| body.strip_prefix('{').and_then(|b| b.strip_suffix('}'))) + .unwrap_or(body) + .trim() + .to_string(); + if body.is_empty() { + return None; + } + Some((name, argnames, body)) +} + +/// Parse the text following `.param` into assignments. Handles: +/// * multiple `name = value` pairs on one line +/// * whitespace around `=` +/// * values quoted with `'...'`, braced `{...}`, or a bare token (with +/// paren-balancing so `max(a, b)` survives even unquoted) +/// * `name(a,b) = ...` function-definition headers +pub(crate) fn parse_assignments(rest: &str) -> Vec { + let b = rest.as_bytes(); + let mut i = 0; + let mut out = Vec::new(); + let skip_ws = |b: &[u8], mut i: usize| { + while i < b.len() && (b[i] as char).is_whitespace() { + i += 1; + } + i + }; + + while i < b.len() { + i = skip_ws(b, i); + if i >= b.len() { + break; + } + // name + let name_start = i; + while i < b.len() && (b[i].is_ascii_alphanumeric() || b[i] == b'_') { + i += 1; + } + if i == name_start { + // not an identifier start; skip a char to avoid infinite loop + i += 1; + continue; + } + let name = rest[name_start..i].to_string(); + + // optional (args) + let mut args = None; + let j = skip_ws(b, i); + if j < b.len() && b[j] == b'(' { + let mut depth = 0; + let mut k = j; + while k < b.len() { + match b[k] { + b'(' => depth += 1, + b')' => { + depth -= 1; + if depth == 0 { + k += 1; + break; + } + } + _ => {} + } + k += 1; + } + let arglist = &rest[j + 1..k - 1]; + args = Some( + arglist + .split(',') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect(), + ); + i = k; + } + + // expect '=' + i = skip_ws(b, i); + if i >= b.len() || b[i] != b'=' { + // malformed; bail on this assignment + break; + } + i += 1; // consume '=' + i = skip_ws(b, i); + if i >= b.len() { + break; + } + + // value + let rhs = match b[i] { + b'\'' | b'{' => { + let close = if b[i] == b'\'' { b'\'' } else { b'}' }; + let start = i + 1; + let mut k = start; + let mut depth = 1; + while k < b.len() { + if b[i] == b'{' && b[k] == b'{' { + depth += 1; + } else if b[k] == close { + depth -= 1; + if depth == 0 { + break; + } + } + k += 1; + } + let v = rest[start..k.min(b.len())].to_string(); + i = (k + 1).min(b.len()); + v + } + b'[' => { + // Bracketed vector value (XSPICE array parameters): + // `cntl_array = [-2 -1 1 2]`. The whole `[...]` group, spaces + // included, is ONE value token; cutting it at the first space + // truncated the model body and every remaining parameter with + // it ("Too few values for parameter 'cntl_array'"). + let start = i; + let mut depth = 0; + while i < b.len() { + match b[i] { + b'[' => depth += 1, + b']' => { + depth -= 1; + if depth == 0 { + i += 1; + break; + } + } + _ => {} + } + i += 1; + } + rest[start..i].to_string() + } + _ => { + // Bare token, paren-balanced. A `,` ends it just like whitespace: + // ngspice's INPgetTok (inpgtok.c) treats `=`, `(`, `)` and `,` as + // separators — skipped before a token, terminating after one — so + // `.model dm d (a=500.0, b=-500.0)` has TWO parameters, not a value + // of `"500.0,"`. Depth-guarded, so an unquoted `max(a, b)` keeps its + // internal commas. + let start = i; + let mut depth = 0; + while i < b.len() { + let c = b[i]; + if c == b'(' { + depth += 1; + } else if c == b')' { + depth -= 1; + } else if ((c as char).is_whitespace() || c == b',') && depth == 0 { + break; + } + i += 1; + } + rest[start..i].to_string() + } + }; + + out.push(Assign { name, args, rhs }); + } + out +} + +/// The resolved/lazy parameter + function table. +pub struct ParamTable { + raw: HashMap, + funcs: HashMap, Rc)>, + parsed: RefCell>>, + values: RefCell>, + resolving: RefCell>, +} + +impl ParamTable { + pub fn new() -> Self { + ParamTable { + raw: HashMap::new(), + funcs: HashMap::new(), + parsed: RefCell::new(HashMap::new()), + values: RefCell::new(HashMap::new()), + resolving: RefCell::new(HashSet::new()), + } + } + + /// Collect all `.param` and `.func` definitions from the flattened deck. + pub fn collect(&mut self, lines: &[LogicalLine]) { + for l in lines { + let t = l.text.trim_start(); + if t.len() >= 6 && t[..6].eq_ignore_ascii_case(".param") { + let rest = &t[6..]; + for a in parse_assignments(rest) { + let k = key(&a.name); + match a.args { + Some(argnames) => { + if let Ok(body) = parse(&a.rhs) { + self.funcs.insert(k, (argnames, Rc::new(body))); + } + } + None => { + // last definition wins (matches ngspice override order) + self.raw.insert(k, a.rhs); + } + } + } + } else if t.len() >= 5 && t[..5].eq_ignore_ascii_case(".func") { + // `.func name(args) [=] body` -- ngspice's dedicated spelling for + // a user function, equivalent to `.param name(args) = body`, which + // it in fact rewrites .func into. The `=` is optional and the body + // may be 'quoted', {braced} or bare. Collected the same way, so a + // B-source `v='bar2(17.0)'` can inline bar2 instead of dropping it. + if let Some((name, argnames, body)) = parse_func_def(&t[5..]) { + if let Ok(b) = parse(&body) { + self.funcs.insert(key(&name), (argnames, Rc::new(b))); + } + } + } + } + } + + /// Resolve a parameter to its value (memoized; detects cycles). + fn resolve(&self, name: &str) -> Result { + let k = key(name); + if let Some(v) = self.values.borrow().get(&k) { + return Ok(*v); + } + if self.resolving.borrow().contains(&k) { + return Err(EvalError::Parse(format!("cyclic parameter `{k}`"))); + } + let raw = self + .raw + .get(&k) + .ok_or_else(|| EvalError::UnknownVar(name.to_string()))? + .clone(); + + // parse (cache). Clone the Rc out of the borrow before any borrow_mut. + let cached = self.parsed.borrow().get(&k).cloned(); + let expr: Rc = match cached { + Some(e) => e, + None => { + let e = Rc::new(parse(&raw)?); + self.parsed.borrow_mut().insert(k.clone(), Rc::clone(&e)); + e + } + }; + + self.resolving.borrow_mut().insert(k.clone()); + let scope = Scope { + table: self, + locals: HashMap::new(), + }; + let res = eval(&expr, &scope); + self.resolving.borrow_mut().remove(&k); + + let v = res?; + self.values.borrow_mut().insert(k, v); + Ok(v) + } + + /// Number of scalar parameters collected (for diagnostics). + pub fn param_count(&self) -> usize { + self.raw.len() + } + + /// Evaluate an inline expression string against this table. + pub fn eval_str(&self, s: &str) -> Result { + let e = parse(s)?; + let scope = Scope { + table: self, + locals: HashMap::new(), + }; + eval(&e, &scope) + } +} + +impl Default for ParamTable { + fn default() -> Self { + Self::new() + } +} + +/// `ParamTable` is itself an environment (the global scope). +impl Env for ParamTable { + fn var(&self, name: &str) -> Result { + self.resolve(name) + } + fn call_user(&self, name: &str, args: &[f64]) -> Result, EvalError> { + let k = key(name); + let Some((argnames, body)) = self.funcs.get(&k) else { + return Ok(None); + }; + if argnames.len() != args.len() { + return Err(EvalError::Arity { + func: name.to_string(), + got: args.len(), + }); + } + let mut locals = HashMap::new(); + for (n, v) in argnames.iter().zip(args) { + locals.insert(key(n), *v); + } + let inner = Scope { + table: self, + locals, + }; + Ok(Some(eval(body, &inner)?)) + } +} + +/// An evaluation scope: the global table plus function-argument locals. +struct Scope<'a> { + table: &'a ParamTable, + locals: HashMap, +} + +impl Env for Scope<'_> { + fn var(&self, name: &str) -> Result { + let k = key(name); + if let Some(v) = self.locals.get(&k) { + return Ok(*v); + } + self.table.resolve(name) + } + + fn call_user(&self, name: &str, args: &[f64]) -> Result, EvalError> { + let k = key(name); + let Some((argnames, body)) = self.table.funcs.get(&k) else { + return Ok(None); + }; + if argnames.len() != args.len() { + return Err(EvalError::Arity { + func: name.to_string(), + got: args.len(), + }); + } + let mut locals = HashMap::new(); + for (n, v) in argnames.iter().zip(args) { + locals.insert(key(n), *v); + } + let body = Rc::clone(body); + let inner = Scope { + table: self.table, + locals, + }; + Ok(Some(eval(&body, &inner)?)) + } +} + +/// Statistics from a substitution pass. +#[derive(Debug, Default, Clone)] +pub struct SubstStats { + pub exprs_total: usize, + pub exprs_failed: usize, +} + +/// Format a resolved value the way ngspice prints numeric params. +pub(crate) fn fmt_num(v: f64) -> String { + format!("{v:.15e}") +} + +/// Substitute every `{expr}` (and single-quoted `'expr'`) in a device/model line +/// with its evaluated value against `env`. On failure the original delimited +/// expression is kept intact (so behavioral `v()`/`i()` refs and not-yet-resolved +/// params survive for a later stage or for ngspice). +pub fn substitute_line( + env: &dyn Env, + line: &str, + placeholder: &str, + stats: &mut SubstStats, +) -> String { + let table = env; + let b = line.as_bytes(); + let mut out = String::with_capacity(line.len()); + let mut i = 0; + while i < b.len() { + let c = b[i]; + if c == b'{' || c == b'\'' { + let close = if c == b'{' { b'}' } else { b'\'' }; + let start = i + 1; + let mut k = start; + let mut depth = 1; + while k < b.len() { + if c == b'{' && b[k] == b'{' { + depth += 1; + } else if b[k] == close { + depth -= 1; + if depth == 0 { + break; + } + } + k += 1; + } + if k <= b.len() { + let inner = &line[start..k.min(b.len())]; + stats.exprs_total += 1; + match parse(inner).and_then(|e| eval(&e, table)) { + Ok(v) => out.push_str(&fmt_num(v)), + Err(_) => { + // Keep the original delimited expression so ngspice can + // resolve it (e.g. behavioral `v()`/`i()` refs, or + // subckt-local params resolved during expansion). Zeroing + // these would corrupt behavioral sources. `placeholder` is + // reserved for a future strict/numeric-only mode. + let _ = placeholder; + stats.exprs_failed += 1; + out.push(c as char); + out.push_str(inner); + out.push(close as char); + } + } + i = (k + 1).min(b.len()); + continue; + } + } + out.push(c as char); + i += 1; + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn func_def_parses_all_body_forms() { + // .func name(args) 'body' | {body} | = body | bare + assert_eq!(parse_func_def("bar2(p) 'v(p)+p'"), + Some(("bar2".into(), vec!["p".into()], "v(p)+p".into()))); + assert_eq!(parse_func_def("foo0() '1013.0'"), + Some(("foo0".into(), vec![], "1013.0".into()))); + assert_eq!(parse_func_def("baz1(n,vp) 'n+i(vp)+vp'"), + Some(("baz1".into(), vec!["n".into(),"vp".into()], "n+i(vp)+vp".into()))); + assert_eq!(parse_func_def("g(x) = {x*2}"), + Some(("g".into(), vec!["x".into()], "x*2".into()))); + } + + use std::sync::Arc; + + fn lines(src: &str) -> Vec { + crate::reader::logical_lines(src, Arc::from("t")) + } + + #[test] + fn multi_value_param_line() { + let a = parse_assignments(" a=0 b = 1 c=nonfet_mm d = 'a+b' "); + assert_eq!(a.len(), 4); + assert_eq!(a[0].name, "a"); + assert_eq!(a[2].rhs, "nonfet_mm"); + assert_eq!(a[3].rhs, "a+b"); + } + + #[test] + fn resolves_dependencies_and_forward_refs() { + let mut t = ParamTable::new(); + t.collect(&lines(".param x={2*y} y=10\n.param z=nonfet_mm nonfet_mm=1\n")); + assert_eq!(t.resolve("x").unwrap(), 20.0); + assert_eq!(t.resolve("z").unwrap(), 1.0); + } + + #[test] + fn user_function_definition_and_call() { + let mut t = ParamTable::new(); + t.collect(&lines( + ".param sel(m)='ka*(m==1)+kb*(m==2)'\n.param ka=10 kb=20\n", + )); + assert_eq!(t.eval_str("sel(1)").unwrap(), 10.0); + assert_eq!(t.eval_str("sel(2)").unwrap(), 20.0); + } + + #[test] + fn substitutes_inline_exprs() { + let mut t = ParamTable::new(); + t.collect(&lines(".param fclk=150e6\n")); + let mut s = SubstStats::default(); + let out = substitute_line(&t, "L1 a b {(2.7e-9)*(150e6/fclk)}", "0", &mut s); + assert!(out.starts_with("L1 a b 2.7"), "got {out}"); + assert_eq!(s.exprs_failed, 0); + } + + #[test] + fn cycle_is_reported_not_hung() { + let mut t = ParamTable::new(); + t.collect(&lines(".param p=q q=p\n")); + assert!(t.resolve("p").is_err()); + } +} diff --git a/ng_parse/parser/src/preprocess.rs b/ng_parse/parser/src/preprocess.rs new file mode 100644 index 000000000..ba9f06547 --- /dev/null +++ b/ng_parse/parser/src/preprocess.rs @@ -0,0 +1,629 @@ +//! Section/include expansion — the replacement for ngspice `inpcom.c`'s +//! `.lib`/`.inc` handling, which is the O(n^2) hotspot on large PDK decks. +//! +//! Semantics mirrored from `src/frontend/inpcom.c` (`read_a_lib`, +//! `find_section_definition`, `expand_section_ref`, `expand_section_references`), +//! targeting **hs (HSPICE) compatibility** — the mode the foundry_b 14nm deck uses: +//! +//! * `.inc ` / `.include ` — textually include the whole file +//! (path resolved relative to the *referencing* file's directory). +//! * `.lib
` — reference: splice the body of the `.lib
` +//! definition found in , from just after the definition line up to (but +//! excluding) the first matching `.endl`. Nested `.lib
` +//! references inside the body are expanded recursively. `.endl` nesting is NOT +//! counted — the first `.endl` ends the section (matches ngspice). +//! * `.lib
` (one token) — a section *definition* boundary. Indexed; +//! never emitted on its own. +//! +//! Each source file is read and indexed exactly once, then cached (keyed by +//! canonical path) — the same reuse ngspice's global `libraries[]` gives, but with +//! an O(1) section-name index instead of a linear scan per reference. +//! +//! Deliberately NOT handled yet (absent from the foundry_b tree — see project notes): +//! `.alter` block skipping, `.title`, `.hdl`, `.biaschk`, `.del`, `$ENV`/`$var` +//! path expansion, old-style one-token `.lib ` includes (lt/ps modes). + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use crate::config::Config; +use crate::reader::{logical_lines, LogicalLine}; + +/// Errors surfaced while expanding a deck. +#[derive(Debug)] +pub enum ExpandError { + Read { path: PathBuf, err: std::io::Error }, + Resolve { token: String, base: PathBuf }, + Section { file: PathBuf, name: String }, + MissingEndl { file: PathBuf, name: String }, + Recursion { path: PathBuf }, +} + +impl std::fmt::Display for ExpandError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ExpandError::Read { path, err } => { + write!(f, "cannot read {}: {err}", path.display()) + } + ExpandError::Resolve { token, base } => write!( + f, + "cannot resolve include/library path {token:?} relative to {}", + base.display() + ), + ExpandError::Section { file, name } => write!( + f, + "library file {}: section .lib {name} not found", + file.display() + ), + ExpandError::MissingEndl { file, name } => write!( + f, + "library file {}: section .lib {name} has no matching .endl", + file.display() + ), + ExpandError::Recursion { path } => { + write!(f, "include/library recursion detected at {}", path.display()) + } + } + } +} +impl std::error::Error for ExpandError {} + +/// A source file loaded once: its logical lines plus a name->index map of the +/// `.lib
` definitions it contains. +struct LoadedFile { + lines: Vec, + /// lowercased section name -> index in `lines` of its `.lib ` line. + sections: HashMap, +} + +/// Quote-aware token splitter: whitespace-separated, with `'`/`"` grouping and +/// stripped. SPICE only uses quotes to hold paths together, never for escaping. +fn split_tokens(s: &str) -> Vec { + let mut out = Vec::new(); + let mut cur = String::new(); + let mut quote: Option = None; + let mut has = false; + for ch in s.chars() { + match quote { + Some(q) => { + if ch == q { + quote = None; + } else { + cur.push(ch); + } + } + None => { + if ch == '\'' || ch == '"' { + quote = Some(ch); + has = true; + } else if ch.is_whitespace() { + if has { + out.push(std::mem::take(&mut cur)); + has = false; + } + } else { + cur.push(ch); + has = true; + } + } + } + } + if has { + out.push(cur); + } + out +} + +/// Lowercased first token of a line, for dot-command dispatch. +fn keyword(line: &str) -> String { + line.split_whitespace() + .next() + .unwrap_or("") + .to_ascii_lowercase() +} + +fn is_kw(line: &str, kw: &str) -> bool { + let lstart = line.trim_start(); + lstart.len() >= kw.len() + && lstart.as_bytes()[..kw.len()].eq_ignore_ascii_case(kw.as_bytes()) + && matches!( + lstart.as_bytes().get(kw.len()), + None | Some(b' ') | Some(b'\t') + ) +} + +/// Rewrite HSPICE spellings into the canonical SPICE ones, mirroring ngspice's +/// `inpcom.c::inp_fix_macro_param_func_paren_io` (line 2952): +/// +/// ```text +/// .macro name ... -> .subckt name ... +/// .eom [name] -> .ends [name] +/// .subckt name (a b c) -> .subckt name a b c +/// x1 (a b c) sub -> x1 a b c sub +/// ``` +/// +/// Done once, centrally, so no later stage has to know these aliases exist. +/// +/// Without it a `.macro` body is emitted verbatim and its `x` instances are never +/// expanded, and parenthesized ports parse as the tokens `(a` and `c)` — which +/// silently leaves the subckt's devices wired to its INTERNAL node names instead +/// of the caller's. foundry_c's PDK needs both: `.macro`/`.eom` x5, `.subckt name (…)` +/// x20 and `x… (…)` x31 in one deck. +fn normalize_hspice(l: &mut LogicalLine) { + let t = l.text.trim_start(); + // `.macro`/`.eom` -> `.subckt`/`.ends`, keeping the rest of the line verbatim. + for (from, to) in [(".macro", ".subckt"), (".eom", ".ends")] { + if is_kw(t, from) { + l.text = format!("{to}{}", &t[from.len()..]); + break; + } + } + + // Strip the parentheses around a port/connection list. ngspice blanks the + // first `(` and its matching `)` on `.subckt` and `x` cards only. + let t = l.text.trim_start(); + let is_sub = is_kw(t, ".subckt"); + let is_x = t.as_bytes().first().is_some_and(|c| c.eq_ignore_ascii_case(&b'x')); + if !(is_sub || is_x) { + return; + } + // Skip the leading keyword, plus the subckt's name. + let mut rest = t.split_at(t.find(char::is_whitespace).unwrap_or(t.len())).1; + if is_sub { + let s = rest.trim_start(); + rest = s.split_at(s.find(char::is_whitespace).unwrap_or(s.len())).1; + } + let head_len = l.text.len() - rest.len(); + let Some(open) = rest.find('(') else { return }; + // Only a port list — never touch a `(` that belongs to an expression or a + // value, which always follows a `=` or other text on these cards. + if !rest[..open].trim().is_empty() { + return; + } + let Some(close) = rest.find(')') else { return }; + if close < open { + return; + } + let mut body = rest.to_string(); + body.replace_range(open..open + 1, " "); + body.replace_range(close..close + 1, " "); + l.text = format!("{}{}", &l.text[..head_len], body); +} + +/// Resolve relative `table_param(str("./x.table"), ...)` paths against the +/// directory of the file the line came from, rewriting them in place to absolute. +/// +/// HSPICE resolves such a path against the *referencing* file's directory, and +/// ngspice's `table_param_lookup` takes a `dir_hint` for exactly this ("the +/// directory of the file that originated this call — typically the .lib file +/// containing the table_param() invocation"). foundry_b's `.param rth0_n` lives in +/// `/fets_hp.lib` and asks for `./RF_COMPONENTS/egnfet_SHE.table`, which +/// exists only relative to the PDK dir — never relative to the simulation cwd. +/// +/// Doing it here, during expansion, is what keeps the later stages simple: only +/// `LogicalLine` knows its source file, and once the path is absolute the lookup +/// is a pure function that `ParamTable` and `SubcktExpander` can both evaluate +/// without carrying provenance. +/// +/// A path that does not resolve to an existing file is left untouched, so the +/// error names what the deck actually wrote. +fn rewrite_table_paths(l: &mut LogicalLine) { + if !l.text.to_ascii_lowercase().contains("table_param") { + return; + } + let Some(dir) = Path::new(l.file.as_ref()).parent().map(Path::to_path_buf) else { + return; + }; + let mut out = String::with_capacity(l.text.len()); + let mut rest = l.text.as_str(); + while let Some(open) = rest.find('"') { + let after = &rest[open + 1..]; + let Some(close) = after.find('"') else { break }; + let (path, tail) = (&after[..close], &after[close + 1..]); + out.push_str(&rest[..=open]); + let abs = dir.join(path); + match (Path::new(path).is_absolute(), abs.canonicalize()) { + (false, Ok(p)) => out.push_str(&p.to_string_lossy()), + _ => out.push_str(path), + } + out.push('"'); + rest = tail; + } + if !out.is_empty() { + out.push_str(rest); + l.text = out; + } +} + +/// The deck expander. Owns the file cache for one expansion run. +pub struct Expander { + cache: HashMap>, + /// Paths currently on the expansion stack — cheap cycle guard. + active: Vec, + /// The top deck's title line (see [`Expander::title`]). + title: String, + /// Run configuration. The `.lib` walk below is the first parallel seam — see + /// `Config::effective_cores` for why it is sequential today. + cfg: Config, +} + +impl Expander { + pub fn new() -> Self { + Expander::with_config(Config::default()) + } + + /// An expander running under `cfg`. + pub fn with_config(cfg: Config) -> Self { + Expander { + cache: HashMap::new(), + active: Vec::new(), + title: String::new(), + cfg, + } + } + + /// This expander's run configuration. + pub fn config(&self) -> Config { + self.cfg + } + + /// The top deck's title: its first physical line, whatever it contains. + /// + /// SPICE unconditionally consumes line 1 of the top deck as the title — it is + /// never a card, even when it looks exactly like one. Verified against the + /// reference: a deck whose first line is `v1 n1 0 1` reports `v(n1) = 0` + /// (no such source exists); prepend a comment line and it reports `v(n1) = 1`. + /// + /// The same applies to a netlist pulled in by `source` from a `.control` block + /// — the PDK harness path — but NOT to `.include`/`.lib` files, whose first + /// line stays an ordinary card. + /// + /// Emitting this back as line 1 is what makes `expand` output re-readable: + /// ngspice will eat the title again, leaving the cards intact. It equally + /// satisfies the C glue, where `if_inpdeck` walks straight into `INPpas1` from + /// card #1 with no title skip of its own (`inp.c` does the skipping, at 1146). + pub fn title(&self) -> &str { + &self.title + } + + /// Expand a top-level deck file into a flat list of logical lines with all + /// `.inc`/`.lib` references resolved. `.param`/`{}`/`.subckt` are left intact + /// for later stages. + pub fn expand_file(&mut self, entry: &Path) -> Result, ExpandError> { + let mut out = Vec::new(); + let file = self.load(entry)?; + let dir = entry.parent().unwrap_or(Path::new(".")).to_path_buf(); + + // Split off the title before walking: line 1 of the TOP deck is never a + // card (see `title()`). A comment-style title (`* foo`) has already been + // dropped by the reader; a bare-text one (`check scoping of ...`, which is + // what tests/regression/subckt-processing/model-scope-5.cir uses) is still + // in `lines` and would otherwise be emitted as a bogus card — there, a + // capacitor, since it happens to start with `c`. + self.title = self.first_physical_line(entry); + let body: Vec = file + .lines + .iter() + .filter(|l| l.line_no != 1) + .cloned() + .collect(); + self.walk(&body, &dir, &mut out)?; + for l in &mut out { + normalize_hspice(l); + rewrite_table_paths(l); + } + Ok(out) + } + + /// The entry deck's first physical line, verbatim (minus the trailing newline). + /// Read from source rather than taken from the logical lines because the reader + /// has already stripped comments, and a title is usually a comment. + fn first_physical_line(&self, entry: &Path) -> String { + let Ok(bytes) = std::fs::read(entry) else { + return String::new(); + }; + String::from_utf8_lossy(&bytes) + .lines() + .next() + .unwrap_or("") + .trim_end() + .to_string() + } + + /// Resolve a path token relative to `base_dir`, canonicalizing it. + fn resolve(&self, token: &str, base_dir: &Path) -> Result { + let raw = Path::new(token); + let joined = if raw.is_absolute() { + raw.to_path_buf() + } else { + base_dir.join(raw) + }; + joined.canonicalize().map_err(|_| ExpandError::Resolve { + token: token.to_string(), + base: base_dir.to_path_buf(), + }) + } + + /// Load + index a file (cached by canonical path). + fn load(&mut self, path: &Path) -> Result, ExpandError> { + let key = path + .canonicalize() + .unwrap_or_else(|_| path.to_path_buf()); + if let Some(f) = self.cache.get(&key) { + return Ok(Arc::clone(f)); + } + // Read as bytes and decode lossily: PDK model files are frequently + // Latin-1 / not strictly UTF-8 (special chars in comments, etc.), and + // `read_to_string` would reject them. SPICE syntax is ASCII, so any + // replacement of stray non-UTF-8 bytes only affects comment text. + let bytes = std::fs::read(&key).map_err(|err| ExpandError::Read { + path: key.clone(), + err, + })?; + let src = String::from_utf8_lossy(&bytes); + let lines = logical_lines(&src, Arc::from(key.to_string_lossy().as_ref())); + + // Index `.lib ` definitions (exactly one token after `.lib`). + let mut sections = HashMap::new(); + for (i, l) in lines.iter().enumerate() { + if is_kw(&l.text, ".lib") { + let toks = split_tokens(&l.text); + if toks.len() == 2 { + sections + .entry(toks[1].to_ascii_lowercase()) + .or_insert(i); // first definition wins + } + } + } + let loaded = Arc::new(LoadedFile { lines, sections }); + self.cache.insert(key, Arc::clone(&loaded)); + Ok(loaded) + } + + /// Walk a body of lines at "deck level" (top file or an `.inc`'d file), + /// resolving includes and section references into `out`. + fn walk( + &mut self, + lines: &[LogicalLine], + base_dir: &Path, + out: &mut Vec, + ) -> Result<(), ExpandError> { + for l in lines { + let kw = keyword(&l.text); + match kw.as_str() { + ".inc" | ".include" => { + let toks = split_tokens(&l.text); + if let Some(file) = toks.get(1) { + let resolved = self.resolve(file, base_dir)?; + self.include(&resolved, out)?; + } + } + ".lib" => { + let toks = split_tokens(&l.text); + if toks.len() >= 3 { + // reference: .lib
+ let resolved = self.resolve(&toks[1], base_dir)?; + self.expand_section(&resolved, &toks[2], out)?; + } + // one-token `.lib ` at deck level (hs): definition + // boundary — drop it (nothing to emit). + } + ".endl" => { /* stray at deck level — drop */ } + _ => out.push(l.clone()), + } + } + Ok(()) + } + + /// Include a whole file at deck level (`.inc`). + fn include(&mut self, path: &Path, out: &mut Vec) -> Result<(), ExpandError> { + if self.active.iter().any(|p| p == path) { + return Err(ExpandError::Recursion { + path: path.to_path_buf(), + }); + } + let file = self.load(path)?; + let dir = path.parent().unwrap_or(Path::new(".")).to_path_buf(); + self.active.push(path.to_path_buf()); + let r = self.walk(&file.lines, &dir, out); + self.active.pop(); + r + } + + /// Expand a `.lib
` reference: splice the section body from + /// just after `.lib
` up to the first `.endl`, recursing on nested + /// references. + fn expand_section( + &mut self, + file: &Path, + section: &str, + out: &mut Vec, + ) -> Result<(), ExpandError> { + let loaded = self.load(file)?; + let start = *loaded + .sections + .get(§ion.to_ascii_lowercase()) + .ok_or_else(|| ExpandError::Section { + file: file.to_path_buf(), + name: section.to_string(), + })?; + let dir = file.parent().unwrap_or(Path::new(".")).to_path_buf(); + + let mut i = start + 1; + let mut saw_endl = false; + while i < loaded.lines.len() { + let l = &loaded.lines[i]; + let kw = keyword(&l.text); + match kw.as_str() { + ".endl" => { + saw_endl = true; + break; + } + ".inc" | ".include" => { + let toks = split_tokens(&l.text); + if let Some(f) = toks.get(1) { + let resolved = self.resolve(f, &dir)?; + self.include(&resolved, out)?; + } + } + ".lib" => { + let toks = split_tokens(&l.text); + if toks.len() >= 3 { + let resolved = self.resolve(&toks[1], &dir)?; + self.expand_section(&resolved, &toks[2], out)?; + } else { + // nested one-token `.lib ` definition inside a body: + // ngspice leaves it literal. Preserve to match. + out.push(l.clone()); + } + } + _ => out.push(l.clone()), + } + i += 1; + } + if !saw_endl { + return Err(ExpandError::MissingEndl { + file: file.to_path_buf(), + name: section.to_string(), + }); + } + Ok(()) + } +} + +impl Default for Expander { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + + fn write(dir: &Path, name: &str, body: &str) -> PathBuf { + let p = dir.join(name); + let mut f = std::fs::File::create(&p).unwrap(); + f.write_all(body.as_bytes()).unwrap(); + p + } + + /// A scratch directory of its own per test — tests share a process, so a + /// single fixed name would let them clobber each other's files. + fn tmpdir(tag: &str) -> PathBuf { + let d = std::env::temp_dir().join(format!("ngparse_pp_{}_{tag}", std::process::id())); + let _ = std::fs::create_dir_all(&d); + d + } + + #[test] + fn tokens_strip_quotes() { + assert_eq!(split_tokens(".lib './a b.lib' TT"), vec![".lib", "./a b.lib", "TT"]); + } + + /// SPICE consumes line 1 of the TOP deck as the title, whatever it holds. + /// Verified against the reference: a deck whose line 1 is `v1 n1 0 1` reports + /// `v(n1)=0` (the source does not exist); add a title line and it reports 1. + #[test] + fn title_line_is_never_a_card() { + let dir = tmpdir("title_card"); + // a bare-text title that would otherwise parse as a device (leading `c`) + let top = write( + &dir, + "top.net", + "check scoping of nested .model definitions\nR1 1 0 1k\n.end\n", + ); + let mut ex = Expander::new(); + let out = ex.expand_file(&top).unwrap(); + let texts: Vec<&str> = out.iter().map(|l| l.text.as_str()).collect(); + assert_eq!(ex.title(), "check scoping of nested .model definitions"); + assert_eq!(texts, vec!["R1 1 0 1k", ".end"]); + } + + /// A comment title is stripped by the reader, but must still be recoverable so + /// `expand` can re-emit it as line 1. + #[test] + fn comment_title_is_captured() { + let dir = tmpdir("title_comment"); + let top = write(&dir, "top.net", "* my title\nR1 1 0 1k\n.end\n"); + let mut ex = Expander::new(); + let out = ex.expand_file(&top).unwrap(); + assert_eq!(ex.title(), "* my title"); + assert_eq!(out[0].text, "R1 1 0 1k"); + } + + /// Only the TOP deck has a title. An `.include`d file's first line stays an + /// ordinary card — confirmed against the reference, where a resistor on line 1 + /// of an included file is present and measurable. + #[test] + fn included_file_keeps_its_first_line() { + let dir = tmpdir("title_inc"); + write(&dir, "sub.inc", "R_from_inc 1 0 2k\nC_sub 1 0 1p\n"); + let top = write(&dir, "top.net", "* title\n.inc './sub.inc'\n.end\n"); + let mut ex = Expander::new(); + let out = ex.expand_file(&top).unwrap(); + let texts: Vec<&str> = out.iter().map(|l| l.text.as_str()).collect(); + assert_eq!(texts, vec!["R_from_inc 1 0 2k", "C_sub 1 0 1p", ".end"]); + } + + #[test] + fn expands_sections_includes_and_nesting() { + let dir = std::env::temp_dir().join(format!("ngparse_pp_{}", std::process::id())); + let _ = std::fs::create_dir_all(&dir); + + // models.lib: section TT includes a sub-file and references a nested section. + write( + &dir, + "models.lib", + "\ +.lib TT +.inc './sub.inc' +.lib './corner.lib' TT_core +R_end 1 0 1 +.endl TT +", + ); + write(&dir, "sub.inc", "* sub\nC_sub 1 0 1p\n"); + write( + &dir, + "corner.lib", + "\ +.lib TT_core +.param vth=0.3 +.endl TT_core +.lib OTHER +.param unused=1 +.endl OTHER +", + ); + // Top deck references models.lib section TT. Line 1 is the title — SPICE + // consumes it unconditionally, so a real deck never starts with a card. + let top = write( + &dir, + "top.net", + "* top title\nV1 1 0 1\n.lib './models.lib' TT\n.end\n", + ); + + let mut ex = Expander::new(); + let out = ex.expand_file(&top).unwrap(); + let texts: Vec<&str> = out.iter().map(|l| l.text.as_str()).collect(); + assert_eq!(ex.title(), "* top title"); + + assert_eq!( + texts, + vec![ + "V1 1 0 1", + "C_sub 1 0 1p", // from .inc + ".param vth=0.3", // from nested .lib corner.lib TT_core + "R_end 1 0 1", // rest of TT body + ".end", + ] + ); + // OTHER section must not leak in. + assert!(!texts.iter().any(|t| t.contains("unused"))); + + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/ng_parse/parser/src/reader.rs b/ng_parse/parser/src/reader.rs new file mode 100644 index 000000000..6e02b3efa --- /dev/null +++ b/ng_parse/parser/src/reader.rs @@ -0,0 +1,277 @@ +//! Logical-line reader: turns raw deck text into logical lines with provenance. +//! +//! Responsibilities (mirrors the earliest stage of ngspice `inpcom.c`): +//! * strip full-line comments (`*` as the first non-blank character) +//! * strip inline `$` comments (HSPICE style) +//! * join continuation lines (a line whose first non-blank char is `+`) +//! * drop blank lines +//! * keep source provenance (file + physical line number) for diagnostics +//! and for the eventual `struct card.linesource` / `linenum`. +//! +//! SPICE is case-insensitive for keywords; we preserve original case in the text +//! and lower-case only where semantics require it (done by later stages). + +use std::borrow::Cow; +use std::sync::Arc; + +/// One logical line of a deck: continuations joined, comments stripped. +#[derive(Debug, Clone)] +pub struct LogicalLine { + /// The joined, comment-stripped text (no trailing newline). + pub text: String, + /// Source file this line originated from. + pub file: Arc, + /// 1-based physical line number of the first physical line of this logical line. + pub line_no: usize, +} + +/// Return the byte offset at which an inline comment begins, if any. +/// +/// Mirrors ngspice `inpcom.c::inp_stripcomments_line` (hs/HSPICE mode, which is +/// what the PDK decks select). `cs` is true inside a `.control ... .endc` section. +/// +/// * quoted strings (`"..."` / `'...'`, with `\` escapes) are skipped over, so a +/// `$`/`;` inside them is never a comment (e.g. `echo "v = $&x"`), +/// * `;` and `//` always start a comment, +/// * OUTSIDE `.control`, `$` starts a comment regardless of the preceding +/// character — foundry decks write `...=10u$ comment` with no separator, +/// * INSIDE `.control`, `$` is a comment ONLY when followed by a space, so +/// ngspice variable substitutions (`$&tests`, `$n_test`) survive. +fn inline_comment_start(s: &str, cs: bool) -> Option { + let b = s.as_bytes(); + let mut i = 0; + while i < b.len() { + let c = b[i]; + if c == b'"' || c == b'\'' { + // skip the quoted string + let q = c; + i += 1; + while i < b.len() && !(b[i] == q && b[i - 1] != b'\\') { + i += 1; + } + i += 1; // step past the closing quote (or off the end) + continue; + } + if c == b';' { + return Some(i); + } + if c == b'/' && i + 1 < b.len() && b[i + 1] == b'/' { + return Some(i); + } + if c == b'$' { + if !cs { + return Some(i); + } + if i + 1 < b.len() && b[i + 1] == b' ' { + return Some(i); + } + } + i += 1; + } + None +} + +/// Is this physical line a full-line comment? `*` is the SPICE comment marker; +/// ngspice also converts a leading `#` into one (inpcom.c::inp_stripcomments_line). +fn is_full_comment(s: &str) -> bool { + matches!(s.trim_start().as_bytes().first(), Some(b'*') | Some(b'#')) +} + +/// Lowercased first token, for `.control`/`.endc` tracking. +fn first_kw(s: &str) -> String { + s.split_whitespace().next().unwrap_or("").to_ascii_lowercase() +} + +/// Apply HSPICE/shell-style `\\` end-of-line continuation, which happens *before* +/// `+` stitching. Mirrors ngspice `inpcom.c::chk_for_line_continuation`: a line +/// whose last two non-blank chars are `\\` (and which does not start with `*`/`$`) +/// continues on the next physical line. ngspice implements this by blanking the +/// `\\` and prepending `+` to the following line, turning it into an ordinary +/// continuation — so we do exactly that and let `+` stitching finish the job. +/// +/// Line numbering is preserved: each output line still corresponds 1:1 to an input +/// physical line (we never add or remove lines here, only rewrite their content). +fn splice_shell_continuations(src: &str) -> String { + let mut out = String::with_capacity(src.len() + 16); + let mut prev_continues = false; + for raw in src.lines() { + // A line following a `\\` continuation gets a leading `+` (ngspice does + // this unconditionally, before checking the line itself). + let line: Cow = if prev_continues { + Cow::Owned(format!("+{raw}")) + } else { + Cow::Borrowed(raw) + }; + prev_continues = false; + + let trimmed_end = line.trim_end(); + let first = trimmed_end.trim_start().as_bytes().first().copied(); + let emit: &str = if first != Some(b'*') + && first != Some(b'$') + && trimmed_end.ends_with("\\\\") + { + prev_continues = true; + &trimmed_end[..trimmed_end.len() - 2] // drop the trailing `\\` + } else { + &line + }; + out.push_str(emit); + out.push('\n'); + } + out +} + +/// Split raw deck text into logical lines. +/// +/// `file` is the provenance label attached to every produced line. +pub fn logical_lines(src: &str, file: Arc) -> Vec { + let mut out: Vec = Vec::new(); + + let spliced = splice_shell_continuations(src); + // Comment rules differ inside a `.control ... .endc` section (ngspice passes + // `found_control` as the `cs` flag to inp_stripcomments_line), so track it. + let mut in_control = false; + for (idx, raw) in spliced.lines().enumerate() { + let line_no = idx + 1; + + // Full-line comments never contribute text and never break continuation + // joining (a `+` line after a comment still continues the last real line). + if is_full_comment(raw) { + continue; + } + + match first_kw(raw).as_str() { + ".control" => in_control = true, + ".endc" => in_control = false, + _ => {} + } + + // Strip inline comment (`$`/`;`/`//`), honoring quotes and .control rules. + let content = match inline_comment_start(raw, in_control) { + Some(pos) => &raw[..pos], + None => raw, + }; + + let trimmed = content.trim_end(); + if trimmed.trim_start().is_empty() { + continue; // blank after stripping + } + + let lstripped = trimmed.trim_start(); + if let Some(rest) = lstripped.strip_prefix('+') { + // Continuation: append to the previous logical line, replacing the + // leading `+` with a single space (ngspice behavior). + if let Some(last) = out.last_mut() { + last.text.push(' '); + last.text.push_str(rest.trim_start()); + continue; + } + // No previous line to continue: treat the remainder as its own line. + out.push(LogicalLine { + text: rest.trim_start().to_string(), + file: Arc::clone(&file), + line_no, + }); + } else { + // Emit fully-trimmed: leading whitespace is insignificant in SPICE, and + // downstream code identifies a card by its FIRST character (device-type + // letter, `.` for dot-cards). Indented body lines (common in nested + // subckts) would otherwise present an empty instance name and be + // misparsed — e.g. ` x31 41a 41b sub3` would not be seen as an X call. + out.push(LogicalLine { + text: lstripped.to_string(), + file: Arc::clone(&file), + line_no, + }); + } + } + + out +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ll(src: &str) -> Vec { + logical_lines(src, Arc::from("test")) + } + + #[test] + fn joins_continuations() { + let out = ll("R1 a b 1k\n.param\n+ x = 1\n+ y = 2\n"); + assert_eq!(out.len(), 2); + assert_eq!(out[0].text, "R1 a b 1k"); + assert_eq!(out[1].text, ".param x = 1 y = 2"); + assert_eq!(out[1].line_no, 2); + } + + #[test] + fn strips_full_and_inline_comments() { + let out = ll("* a comment\nR1 a b 1k $ inline note\n"); + assert_eq!(out.len(), 1); + assert_eq!(out[0].text, "R1 a b 1k"); + assert_eq!(out[0].line_no, 2); + } + + #[test] + fn comment_between_line_and_continuation() { + // A comment line between a line and its `+` continuation must not break joining. + let out = ll("V1 n 0 1\n* note\n+ ac 1\n"); + assert_eq!(out.len(), 1); + assert_eq!(out[0].text, "V1 n 0 1 ac 1"); + } + + #[test] + fn dollar_comment_hs_mode() { + // hs/HSPICE mode: `$` ends the line regardless of the preceding char — + // foundry decks write `l=10u$ comment` with no separator. + let out = ll("X1 a c $ tail\n"); + assert_eq!(out[0].text, "X1 a c"); + let out = ll("M1 d g s b nch l=10u$ no separator\n"); + assert_eq!(out[0].text, "M1 d g s b nch l=10u"); + } + + #[test] + fn semicolon_and_slash_comments() { + assert_eq!(ll("R1 a b 1k ; trailing\n")[0].text, "R1 a b 1k"); + assert_eq!(ll("R1 a b 1k // trailing\n")[0].text, "R1 a b 1k"); + } + + #[test] + fn hash_line_is_a_comment() { + assert!(ll("# a comment\nR1 a b 1k\n").len() == 1); + } + + #[test] + fn quotes_protect_comment_chars() { + // a `$` inside a quoted string is not a comment (ngspice skips strings) + let out = ll("echo \"value = $&x\" $ real comment\n"); + assert_eq!(out[0].text, "echo \"value = $&x\""); + } + + #[test] + fn control_block_keeps_dollar_substitutions() { + // Inside `.control`, `$` is only a comment when followed by a space, so + // ngspice variable substitutions survive. + let out = ll(".control\nforeach n $&tests $ note\nset x = $n_test\n.endc\n"); + let texts: Vec<&str> = out.iter().map(|l| l.text.as_str()).collect(); + assert!(texts.contains(&"foreach n $&tests"), "got {texts:?}"); + assert!(texts.contains(&"set x = $n_test"), "got {texts:?}"); + } + + #[test] + fn skips_blank_lines() { + let out = ll("\n \nR1 a b 1k\n\n"); + assert_eq!(out.len(), 1); + } + + #[test] + fn joins_backslash_continuation() { + // HSPICE quoted-expression continuation via trailing `\\`. + let out = ll(".param f(x)='a*(x==m1)+\\\\\n b*(x==m2)+\\\\\n c*(x==m3)'\n"); + assert_eq!(out.len(), 1); + assert_eq!(out[0].text, ".param f(x)='a*(x==m1)+ b*(x==m2)+ c*(x==m3)'"); + assert!(!out[0].text.contains('\\')); + } +} diff --git a/ng_parse/parser/src/subckt.rs b/ng_parse/parser/src/subckt.rs new file mode 100644 index 000000000..253bd3bcd --- /dev/null +++ b/ng_parse/parser/src/subckt.rs @@ -0,0 +1,4193 @@ +//! Subcircuit expansion with hierarchical parameter scoping — the `subckt.c` +//! replacement. Given the flattened, section-resolved card stream, this: +//! * registers every `.subckt` definition (ports + default params + body), +//! * walks the top-level circuit, and for each `X` instance recursively +//! expands the subckt: binds instance params into a child [`Scope`], +//! evaluates `.if/.elseif/.else/.endif` conditionals, renames internal nodes +//! (`prefix.node`) and subckt-local models (`prefix:model`), and substitutes +//! `{...}`/`'...'` param expressions to numbers, +//! * emits a flat, resolved card stream ready for ngspice `INPpas1`. +//! +//! Scoping: a `Scope` holds this level's `.param` defs plus pre-bound instance +//! params, chained to its parent. Parameter lookup walks the chain, so a subckt's +//! local `.param leff='l-2*dl'` resolves against the instance's bound `l`. + +use std::cell::RefCell; +use std::collections::{HashMap, HashSet}; +use std::rc::Rc; +use std::sync::Arc; + +use crate::config::Config; +use crate::expr::{eval, eval_builtin, parse, BinOp, Env, EvalError, Expr, UnOp}; +use crate::params::{fmt_num, key, parse_assignments, parse_func_def, Assign, SubstStats}; +use crate::reader::LogicalLine; + +type FuncMap = HashMap, Arc)>; + +/// Result of partial evaluation: either a folded constant or a rewritten symbolic +/// expression string (with runtime refs — `v()`/`i()`/`temper`/`time` — preserved). +#[derive(Clone)] +enum Part { + Const(f64), + Sym(String), +} + +fn part_str(p: &Part) -> String { + match p { + Part::Const(v) => fmt_num(*v), + Part::Sym(s) => s.clone(), + } +} + +fn binop_str(op: BinOp) -> &'static str { + match op { + BinOp::Add => "+", + BinOp::Sub => "-", + BinOp::Mul => "*", + BinOp::Div => "/", + BinOp::Rem => "%", + BinOp::Pow => "**", + BinOp::Eq => "==", + BinOp::Ne => "!=", + BinOp::Lt => "<", + BinOp::Gt => ">", + BinOp::Le => "<=", + BinOp::Ge => ">=", + BinOp::And => "&&", + BinOp::Or => "||", + } +} + +/// A lexical scope in the subckt hierarchy. +pub struct Scope { + /// param name (lower) -> raw rhs, evaluated lazily in THIS scope. + raw: RefCell>, + /// pre-bound values (instance params evaluated in the parent scope). + locals: HashMap, + parsed: RefCell>>, + values: RefCell>, + resolving: RefCell>, + /// Memoized partial-evaluation verdict per parameter (see partial()'s Var + /// arm). The transitive-runtime check re-partials a param's raw definition + /// at EVERY reference; on PDK decks whose params form deep chains that is + /// exponential without this cache (foundry_b load went 2s -> 67s when the + /// check landed). Sound per Scope instance: a Scope is only ever used with + /// one (nmap, prefix) pair, so the rendered Sym strings cannot differ. + /// Only consulted/populated when no function-arg locals are in effect. + partial_cache: RefCell>, + parent: Option>, + funcs: Arc, +} + +impl Scope { + fn root(funcs: Arc) -> Rc { + Rc::new(Scope { + raw: RefCell::new(HashMap::new()), + locals: HashMap::new(), + parsed: RefCell::new(HashMap::new()), + values: RefCell::new(HashMap::new()), + resolving: RefCell::new(HashSet::new()), + partial_cache: RefCell::new(HashMap::new()), + parent: None, + funcs, + }) + } + + fn child(parent: &Rc, locals: HashMap) -> Rc { + Rc::new(Scope { + raw: RefCell::new(HashMap::new()), + locals, + parsed: RefCell::new(HashMap::new()), + values: RefCell::new(HashMap::new()), + resolving: RefCell::new(HashSet::new()), + partial_cache: RefCell::new(HashMap::new()), + parent: Some(Rc::clone(parent)), + funcs: Arc::clone(&parent.funcs), + }) + } + + fn add_raw(&self, name: &str, rhs: String) { + self.raw.borrow_mut().insert(key(name), rhs); + } + + /// Look up a parameter's RAW (unevaluated) definition, walking the scope chain. + /// Used by partial evaluation: when a param can't be folded to a constant + /// (because its definition transitively depends on a runtime quantity such as + /// `temper`), we partial-evaluate its definition instead of giving up. + fn raw_lookup(&self, name: &str) -> Option { + let k = key(name); + if let Some(r) = self.raw.borrow().get(&k) { + return Some(r.clone()); + } + match &self.parent { + Some(p) => p.raw_lookup(name), + None => None, + } + } +} + +impl Env for Rc { + fn var(&self, name: &str) -> Result { + let k = key(name); + if let Some(v) = self.locals.get(&k) { + return Ok(*v); + } + if let Some(v) = self.values.borrow().get(&k) { + return Ok(*v); + } + let raw = self.raw.borrow().get(&k).cloned(); + if let Some(raw) = raw { + if self.resolving.borrow().contains(&k) { + return Err(EvalError::Parse(format!("cyclic parameter `{k}`"))); + } + let cached = self.parsed.borrow().get(&k).cloned(); + let expr = match cached { + Some(e) => e, + None => { + let e = Arc::new(parse(&raw)?); + self.parsed.borrow_mut().insert(k.clone(), Arc::clone(&e)); + e + } + }; + self.resolving.borrow_mut().insert(k.clone()); + let r = eval(&expr, self); + self.resolving.borrow_mut().remove(&k); + let v = r?; + self.values.borrow_mut().insert(k, v); + return Ok(v); + } + match &self.parent { + Some(p) => p.var(name), + None => Err(EvalError::UnknownVar(name.to_string())), + } + } + + fn call_user(&self, name: &str, args: &[f64]) -> Result, EvalError> { + let k = key(name); + let Some((argnames, body)) = self.funcs.get(&k).cloned() else { + return Ok(None); + }; + if argnames.len() != args.len() { + return Err(EvalError::Arity { + func: name.to_string(), + got: args.len(), + }); + } + let mut locals = HashMap::new(); + for (n, v) in argnames.iter().zip(args) { + locals.insert(key(n), *v); + } + let child = Scope::child(self, locals); + Ok(Some(eval(&body, &child)?)) + } +} + +/// Result of an expansion run. +pub struct Expanded { + /// The resolved, flattened card stream. + pub cards: Vec, + /// Parameters that could not be resolved and were dropped (device/model falls + /// back to a DEFAULT). Non-empty means the output may be silently wrong. + pub drops: Vec, +} + +/// A parsed `.subckt` definition. +struct SubcktDef { + ports: Vec, + defaults: Vec<(String, String)>, + body: Vec, +} + +/// Split a card's token stream (after the leading name) into leading positional +/// tokens (nodes / subckt name / model) and the trailing `name=value` assignment +/// text. The split point is just before the first top-level `=`'s parameter name. +fn split_positional(rest: &str) -> (Vec, &str) { + let b = rest.as_bytes(); + let mut depth = 0i32; + let mut q = 0u8; + let mut eq = None; + let mut i = 0; + while i < b.len() { + let c = b[i]; + if q != 0 { + if c == q { + q = 0; + } + i += 1; + continue; + } + match c { + b'\'' | b'"' => q = c, + b'(' | b'{' => depth += 1, + b')' | b'}' => depth -= 1, + b'=' if depth == 0 => { + eq = Some(i); + break; + } + _ => {} + } + i += 1; + } + let split = match eq { + None => rest.len(), + Some(e) => { + let mut j = e; + while j > 0 && (b[j - 1] as char).is_whitespace() { + j -= 1; + } + while j > 0 && (b[j - 1].is_ascii_alphanumeric() || b[j - 1] == b'_') { + j -= 1; + } + j + } + }; + let positional = rest[..split].split_whitespace().map(str::to_string).collect(); + (positional, &rest[split..]) +} + +/// Is `s` a single identifier-like token — a string/keyword model value such as +/// an identifier or keyword — rather than an arithmetic expression? Used to tell a +/// literal token (keep verbatim) from a failed computation (drop). A leading digit +/// is excluded so a stray number never counts as a word. +fn is_bare_word(s: &str) -> bool { + let s = s.trim(); + // A double-quoted string is a literal too: A-device file names such as + // `file="my-source.txt"`, `input_file="./stim.txt"`. + if s.len() >= 2 && s.starts_with('"') && s.ends_with('"') { + return true; + } + let mut cs = s.chars(); + matches!(cs.next(), Some(c) if c.is_ascii_alphabetic() || c == '_') + && cs.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '.') +} + +/// First whitespace-delimited token, lowercased. +fn kw(line: &str) -> String { + line.split_whitespace().next().unwrap_or("").to_ascii_lowercase() +} + +/// Split off the first whitespace-delimited token; returns (token, remainder). +fn split_first(s: &str) -> (&str, &str) { + let s = s.trim_start(); + match s.find(char::is_whitespace) { + Some(i) => (&s[..i], &s[i..]), + None => (s, ""), + } +} + +/// How a device card's positional tokens divide into nodes and controlling-device +/// references. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct DevShape { + /// Leading positional tokens that are node names (renamed `prefix.node`). + nodes: usize, + /// Tokens directly after the nodes that name a *controlling device* — renamed + /// like an instance (`v.prefix.vsen`), not like a node. + ctrl: usize, +} + +/// A bare numeric literal (`1e-6`, `2.0`, `10u`) is a value — never a node and +/// never a model name. This is the test ngspice reaches for in `get_number_terminals` +/// ("AREA may be assumed if we have a token with only digits"), except ngspice +/// implements it as "contains no alpha character", which misfires on `1e-6` +/// because of the `e`. Parsing the token is the same idea done correctly. +fn is_value_token(t: &str) -> bool { + matches!(parse(t), Ok(Expr::Num(_))) +} + +/// Index of the model-name token: the last positional token that is not a value, +/// a trailing flag, or a delimited expression. Everything before it is a node. +/// +/// This is ngspice's own stated rule — "MNAME has to contain at least one alpha +/// character" (`inpcom.c::get_number_terminals`, case 'q') — and it is what the +/// per-device parsers do directly, by walking tokens until one resolves as a model +/// (`inp2m.c`/`inp2q.c`/`inp2d.c`: `if (i >= N && INPlookMod(token)) break`). +fn model_pos(positional: &[String]) -> Option { + positional.iter().rposition(|t| { + !matches!( + t.to_ascii_lowercase().as_str(), + "off" | "thermal" | "tnodeout" + ) && !t.starts_with(['{', '\'', '"', '(']) + && !is_value_token(t) + }) +} + +/// What `emit_device` must do with a single positional token. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Role { + /// A node name — rename `prefix.node`. + Node, + /// A controlling device's instance name — rename `v.prefix.vsen`. + Inst, + /// The `POLY(n)` group — re-emitted verbatim in ngspice's normalized spelling. + Poly(usize), + /// Consume and emit nothing (the redundant HSPICE source-type marker). + Drop, +} + +/// Recognize a `POLY(dim)` group at the head of `toks` → `(dim, tokens_consumed)`. +/// +/// ngspice tokenizes parens separately, so the group reaches us in any spelling: +/// `POLY(2)`, `POLY( 2 )`, `POLY (2)`, `POLY ( 2 )`. +/// +/// Matching is deliberately CASE-SENSITIVE on exactly `POLY`/`poly`: both readers +/// of this form use `strcmp`, not a case-insensitive compare — `subckt.c`'s +/// `translate()` and `enhtrans.c::get_poly_dimension()`. To ngspice, `Poly(2)` is +/// simply not a POLY, and we match that rather than silently building a working +/// circuit where the reference builds a broken one. +fn parse_poly(toks: &[String]) -> Option<(usize, usize)> { + let head = toks.first()?; + let word = head.split_once('(').map_or(head.as_str(), |(w, _)| w); + if word != "POLY" && word != "poly" { + return None; + } + let mut text = String::new(); + for (n, t) in toks.iter().enumerate().take(4) { + text.push_str(t); + if let (Some(o), Some(c)) = (text.find('('), text.find(')')) { + return Some((text[o + 1..c].trim().parse().ok()?, n + 1)); + } + } + None +} + +/// Tokenize an XSPICE A-device connection list exactly as ngspice's `MIFgettok` +/// does (`xspice/mif/mifutil.c`): +/// +/// * whitespace and `=` `(` `)` `,` are delimiters and are DISCARDED — which is +/// why `%vd(a b)` expands to `%vd a b` with the parens gone; +/// * `<` `>` `[` `]` `~` `%` are single-character tokens; +/// * `"..."` yields the quoted contents, without the quotes; +/// * anything else runs to the next delimiter. +fn mif_tokens(s: &str) -> Vec { + const DELIM: [char; 4] = ['=', '(', ')', ',']; + let is_delim = |c: char| c.is_whitespace() || DELIM.contains(&c); + let mut out = Vec::new(); + let mut it = s.chars().peekable(); + loop { + while it.peek().is_some_and(|&c| is_delim(c)) { + it.next(); + } + let Some(&c) = it.peek() else { return out }; + if matches!(c, '<' | '>' | '[' | ']' | '~' | '%') { + it.next(); + out.push(c.to_string()); + } else if c == '"' { + it.next(); + let mut t = String::new(); + for ch in it.by_ref() { + if ch == '"' { + break; + } + t.push(ch); + } + out.push(t); + } else { + let mut t = String::new(); + while let Some(&ch) = it.peek() { + if is_delim(ch) || matches!(ch, '<' | '>' | '[' | ']' | '~' | '%' | '"') { + break; + } + t.push(ch); + it.next(); + } + out.push(t); + } + } +} + +/// The role of every positional token on a device card. +/// +/// `e`/`f`/`g`/`h` get their own layout because ngspice does too: `subckt.c` +/// handles them in a dedicated branch (line 1587) that never calls `numnodes()` — +/// "Control nodes for E and G sources are not counted as they vary in the case of +/// POLY". That branch is: +/// 1. 2 output nodes; +/// 2. optionally consume an HSPICE source-type marker (`vcvs`/`vccs`/`cccs`/`ccvs`) +/// — redundant with the device letter, so ngspice drops it; +/// 3. optionally `POLY(dim)`, else `dim = 1`; +/// 4. `dim * numdevs()` controlling tokens — E/G take 2 each as *nodes*, +/// F/H take 1 each as an *instance* name; +/// 5. the rest are polynomial coefficients. +/// +/// A POLY source is later rewritten into an XSPICE A-device by +/// `ENHtranslate_poly()` (`inp.c:994`), which runs AFTER expansion and before +/// `INPpas1` — so that stays ngspice's job; ours is only to hand it a correctly +/// expanded line. +fn dev_roles(first: u8, positional: &[String]) -> Vec { + let f = first.to_ascii_lowercase(); + if !matches!(f, b'e' | b'f' | b'g' | b'h') { + let s = dev_shape(f, positional); + let mut roles = vec![Role::Node; s.nodes]; + roles.resize(s.nodes + s.ctrl, Role::Inst); + return roles; + } + + let mut roles = vec![Role::Node, Role::Node]; + let mut i = 2; + + let marker = match f { + b'e' => "vcvs", + b'g' => "vccs", + b'f' => "cccs", + _ => "ccvs", + }; + if positional.get(i).is_some_and(|t| t.eq_ignore_ascii_case(marker)) { + roles.push(Role::Drop); + i += 1; + } + + let dim = match positional.get(i..).and_then(parse_poly) { + Some((dim, n)) => { + roles.push(Role::Poly(dim)); + roles.resize(roles.len() + n - 1, Role::Drop); + dim + } + None => 1, + }; + + // subckt.c::numdevs(): E/G sense 2 nodes per term, F/H sense 1 source per term. + let (per_term, role) = match f { + b'e' | b'g' => (2, Role::Node), + _ => (1, Role::Inst), + }; + roles.resize(roles.len() + dim * per_term, role); + roles.truncate(positional.len()); + roles +} + +/// Split a device card's positional tokens into nodes / controlling-device names, +/// mirroring the three functions ngspice uses when it renames a subckt body: +/// +/// * `frontend/subckt.c::numnodes()` — the node count itself. It overrides +/// `e`/`g`/`w` to 2 and `k` to 0, resolves `x` from the `.subckt` header, and +/// otherwise defers to: +/// * `frontend/inpcom.c::get_number_terminals()` — the general table, including +/// the token scans for `d`/`m`/`q`/`p`/`n`; +/// * `frontend/subckt.c::numdevs()` — the trailing controlling V-source (`f`/`h`/`w`) +/// or coupled-inductor pair (`k`), which are renamed as instances. +/// +/// `positional` is the tokens after the instance name, already cut at the first +/// `name=value` — which is exactly where ngspice's scans stop. +/// +/// Why this is worth the precision: a node we fail to rename keeps the subckt's +/// *internal* name, so every instance of that subckt silently shorts together on +/// it. There is no error — just a wrong answer. (`t`/`o`/`s`/`y`/`u` were missing +/// from the old guess table entirely, and a 4-node `q` lost its substrate.) +fn dev_shape(first: u8, positional: &[String]) -> DevShape { + let f = first.to_ascii_lowercase(); + let p = positional.len(); + + // subckt.c::numdevs() — controlling sources / coupled inductors. + let ctrl = match f { + b'f' | b'h' | b'w' => 1, // one controlling V-source name + b'k' => 2, // two inductor instance names + _ => 0, + }; + + let nodes = match f { + b'r' | b'c' | b'l' | b'v' | b'i' | b'b' => 2, + // 2 nodes + a controlling source. get_number_terminals says 3 for `w`, + // but numnodes() overrides it to 2 and numdevs() takes the third token. + b'f' | b'h' | b'w' => 2, + // No nodes at all — just the two inductor names. + b'k' => 0, + // numnodes() -> 2 output nodes, numdevs() -> 2 controlling nodes; both are + // node-translated, so 4 node tokens in a row. + b'e' | b'g' => 4, + b'j' | b'u' | b'z' => 3, + b't' | b'o' | b's' | b'y' => 4, + // Variable-node devices: nodes run up to the model name. + // d: 2, or 3 with a self-heating thermal node + // m: 3 (VDMOS d,g,s) .. 7 (B4SOI/B3SOI*) -- inp2m.c::model_numnodes + // q: 3 .. 5 (substrate, then VBIC/hicum2 thermal) -- inp2q.c + // n: OSDI, p: coupled lines -- fully variable + b'd' | b'm' | b'q' | b'n' | b'p' => { + let (min, max) = match f { + b'd' => (2, 3), + b'm' => (3, 7), + b'q' => (3, 5), + _ => (1, p), + }; + model_pos(positional) + .unwrap_or(min) + .clamp(min.min(p), max.min(p)) + } + _ => 0, + }; + + DevShape { + nodes: nodes.min(p), + ctrl: ctrl.min(p - nodes.min(p)), + } +} + +/// The subckt expander. +pub struct SubcktExpander { + /// Subckt definitions. `Arc` so a multi-core run can share them read-only + /// across worker threads (they are immutable after construction). + defs: Arc>, + /// Nodes declared `.global`: shared across the whole hierarchy, so they are + /// never prefixed during subckt expansion (like ground `0`). `Arc` for the + /// same reason as `defs`. + globals: Arc>, + funcs: Arc, + /// The global `.param` definitions (name -> raw rhs), as seeded into the root + /// scope. Kept so a worker thread can rebuild an equivalent fresh root scope + /// (the scope's memoization caches cannot cross threads, so each worker gets + /// its own — same values, empty caches). + global_raw: Arc>, + root: Rc, + out: Vec, + /// Parameters that could not be resolved and were therefore DROPPED from the + /// emitted card. A drop is NEVER silent: it means the device/model silently + /// falls back to a DEFAULT, which has repeatedly produced wrong-but-converging + /// results (foundry_a `u0` -> dead transistor; bxpressn-1 `v=` -> malformed + /// B source). Reported by the CLI; `--strict` turns them into an error. + drops: RefCell>, + /// `.option scale` (default 1) — the GLOBAL instance/model geometry scale. + /// + /// Model binning multiplies the X line's DRAWN l/w by this before comparing + /// against the bins' post-shrink ranges (subckt.c:907 `csl = scale * c->l`). + /// ngspice reads it with `cp_getvar("scale", ...)`, which `.option scale=X` + /// sets via `cp_vset` (inp.c:913, hs/spe modes) and which defaults to 1 when + /// no deck sets it. + /// + /// Held separately, NOT looked up through the active scope, because a subckt's + /// own `scale` PARAM is a different mechanism — the HSPICE element scale, which + /// scales that subckt's body geometry (see `geo_scale`). foundry_a's + /// `pch_lvt_mac ... scale='scale_mos_lvt'` (0.9) would shadow the option and + /// silently rebin every device if this were resolved lexically. + option_scale: f64, + /// Run configuration. Independent top-level cards are the fan-out unit for + /// multi-core expansion (`expand_parallel`); `cfg.effective_cores()` drives it. + cfg: Config, + pub stats: SubstStats, + pub inst_count: usize, + depth_guard: usize, + top_cards: Vec, +} + +impl SubcktExpander { + /// Build from the flattened card stream: separate subckt defs, collect global + /// `.param`s and functions into the root scope. + pub fn new(lines: &[LogicalLine]) -> Self { + SubcktExpander::with_config(lines, Config::default()) + } + + /// An expander running under `cfg`. + pub fn with_config(lines: &[LogicalLine], cfg: Config) -> Self { + // PSpice line-level conversions (AKO inheritance, d/q positional area + // factors) run before anything else looks at the cards, as ngspice's + // pspice_compat does. + let ps_owned: Vec; + let lines = if cfg.is_pspice() { + ps_owned = pspice_line_rewrites(lines); + &ps_owned[..] + } else { + lines + }; + // Behavioral E/G split (every dialect, like inp_compat) — must happen + // before subckt bodies are captured so the pair expands with ngspice's + // own names. Gated on a cheap scan: decks without such cards keep the + // exact same line vector. + let eg_owned: Vec; + let lines = if lines + .iter() + .any(|l| split_eg_value(&l.text).is_some() || split_eg_table(&l.text).is_some()) + { + eg_owned = eg_value_rewrite(lines); + &eg_owned[..] + } else { + lines + }; + let mut defs = HashMap::new(); + let mut top: Vec = Vec::new(); + let mut funcs: FuncMap = HashMap::new(); + + // 1. split out subckt definitions (track nesting depth). + let mut i = 0; + let cards: Vec<&str> = lines.iter().map(|l| l.text.as_str()).collect(); + while i < cards.len() { + let line = cards[i]; + if kw(line) == ".subckt" { + let (name, ports, defaults) = parse_subckt_header(line); + // capture body until matching .ends (respecting nesting) + let mut body = Vec::new(); + let mut depth = 1; + i += 1; + while i < cards.len() && depth > 0 { + let bl = cards[i]; + let k = kw(bl); + if k == ".subckt" { + depth += 1; + } else if k == ".ends" || k == ".eom" { + depth -= 1; + if depth == 0 { + i += 1; + break; + } + } + body.push(bl.to_string()); + i += 1; + } + // Pull nested definitions out into scoped entries (`name/inner`) + // and strip them from the body. + let path = key(&name); + let body = extract_nested(body, &path, &mut defs); + defs.insert(path, SubcktDef { ports, defaults, body }); + continue; + } + top.push(line.to_string()); + i += 1; + } + + // 2. collect global params/functions from top-level .param lines. + let root_raw: HashMap = HashMap::new(); + let mut global_params: Vec<(String, String)> = Vec::new(); + for line in &top { + if kw(line) == ".param" { + for a in parse_assignments(&line[".param".len()..]) { + match a.args { + Some(argnames) => { + if let Ok(body) = parse(&a.rhs) { + funcs.insert(key(&a.name), (argnames, Arc::new(body))); + } + } + None => global_params.push((a.name, a.rhs)), + } + } + } else if kw(line) == ".func" { + // `.func` is ngspice's dedicated user-function spelling, equivalent + // to `.param name(args)=body`. Without this, a B-source calling one + // (`v='bar2(17.0)'`) cannot inline it and drops the whole `v=`. + if let Some((name, argnames, body)) = parse_func_def(&line[".func".len()..]) { + if let Ok(b) = parse(&body) { + funcs.insert(key(&name), (argnames, Arc::new(b))); + } + } + } + } + // functions can also be defined inside subckts; harvest them globally too. + for d in defs.values() { + for line in &d.body { + if kw(line) == ".param" { + for a in parse_assignments(&line[".param".len()..]) { + if let Some(argnames) = a.args { + if let Ok(body) = parse(&a.rhs) { + funcs.entry(key(&a.name)).or_insert((argnames, Arc::new(body))); + } + } + } + } else if kw(line) == ".func" { + if let Some((name, argnames, body)) = parse_func_def(&line[".func".len()..]) { + if let Ok(b) = parse(&body) { + funcs.entry(key(&name)).or_insert((argnames, Arc::new(b))); + } + } + } + } + } + + // `.global a b c` cards accumulate across the deck. + let mut globals: HashSet = HashSet::new(); + for line in &top { + if kw(line) == ".global" { + for n in line.split_whitespace().skip(1) { + globals.insert(key(n)); + } + } + } + + let funcs = Arc::new(funcs); + let root = Scope::root(Arc::clone(&funcs)); + let _ = root_raw; + // ngspice built-in parameters, seeded BEFORE the deck's own `.param`s so a + // deck definition overrides them. `scale` (from `.option scale`, default 1) + // is referenced by PDK LOD/stress expressions such as + // `inv_sa='1/(sa*scale+0.5*l*scale)'`; without it the whole chain + // (fu0_sa -> fu0_lod -> u0) fails to resolve and the param gets dropped. + root.add_raw("scale", scale_option(&top).unwrap_or_else(|| "1".to_string())); + // PSpice mode: ngspice's pspice_compat prepends `.param temp='temper'`, + // `vt`, and `gmin` to the deck (inpcompat.c). PSpice libs reference TEMP + // in behavioral sources (`VALUE={DC+POL*DRIFT*(TEMP-27)}`); without this + // the expression cannot resolve and the whole VALUE= is dropped, leaving + // a malformed E/G card. `temper` is runtime, so anything referencing + // `temp` stays correctly symbolic. + if cfg.is_pspice() { + root.add_raw("temp", "temper".to_string()); + root.add_raw("vt", "(temper + 273.15) * 8.6173303e-5".to_string()); + root.add_raw("gmin", "1e-12".to_string()); + } + for (n, rhs) in global_params { + root.add_raw(&n, rhs); + } + // Snapshot the root's raw params (scale + globals) so worker threads can + // rebuild an equivalent fresh root; the caches are intentionally not + // captured (they are per-thread memoization, rebuilt on demand). + let global_raw = Arc::new(root.raw.borrow().clone()); + + // Resolved once, against the root, where `.option scale` cannot be shadowed + // by any subckt's own `scale` param. + let option_scale = root.var("scale").unwrap_or(1.0); + + let exp = SubcktExpander { + defs: Arc::new(defs), + globals: Arc::new(globals), + funcs, + global_raw, + root: Rc::clone(&root), + out: Vec::new(), + drops: RefCell::new(Vec::new()), + option_scale, + cfg, + stats: SubstStats::default(), + inst_count: 0, + depth_guard: 0, + top_cards: top, + }; + exp + } + + /// This expander's run configuration (see [`Config`]). + pub fn config(&self) -> Config { + self.cfg + } + + /// Resolve a subckt name to its registry key using LEXICAL scoping: try the + /// enclosing definition's scope first, then walk outwards, then global. + /// (`def_path` is the definition path of the subckt currently being expanded, + /// e.g. `sub1` or `sub1/sub`; empty at top level.) + fn resolve_def(&self, name: &str, def_path: &str) -> Option { + let n = key(name); + let mut p = def_path.to_string(); + loop { + let cand = if p.is_empty() { + n.clone() + } else { + format!("{p}/{n}") + }; + if self.defs.contains_key(&cand) { + return Some(cand); + } + if p.is_empty() { + return None; + } + match p.rfind('/') { + Some(i) => p.truncate(i), + None => p.clear(), + } + } + } + + fn process_top(&mut self) { + let top = std::mem::take(&mut self.top_cards); + let root = Rc::clone(&self.root); + // Top-level local models (rare) + process. + let active = resolve_conditionals(&top, &root); + let local_models = collect_models(&active); + self.process_lines(&active, &local_models); + } + + /// Expand a slice of RESOLVED top-level cards into `self.out`. Split out from + /// `process_top` so a multi-core run can give each worker its own slice — + /// every card here expands independently EXCEPT a `.control ... .endc` block, + /// which is stateful and so must lie wholly within one slice (the partitioner + /// guarantees this). `.if`/`.param`/`.subckt` are already handled: conditionals + /// were resolved away above, global params live in the root, and defs were + /// extracted at registration. + fn process_lines(&mut self, lines: &[String], local_models: &HashSet) { + let root = Rc::clone(&self.root); + let empty_map: HashMap = HashMap::new(); + // No instance geometry at top level -> no bin pruning (ngspice bins). + let drop_bins: HashSet = HashSet::new(); + // `.control ... .endc` is an ngspice command script, NOT netlist: emit it + // verbatim. (Otherwise `let total_count = 0` would be parsed as a device — + // leading `l` reads as an inductor — and silently mangled.) + let mut in_control = false; + for line in lines { + let k = kw(line); + if k == ".control" { + in_control = true; + self.out.push(line.clone()); + continue; + } + if k == ".endc" { + in_control = false; + self.out.push(line.clone()); + continue; + } + if in_control { + self.out.push(line.clone()); + continue; + } + self.process_card(line, "", &root, &empty_map, local_models, &drop_bins, 1.0, 1.0, ""); + } + } + + /// Run expansion, returning the resolved flat card stream plus any parameters + /// that had to be dropped (see [`Expanded::drops`]). + pub fn expand(mut self) -> Expanded { + let cores = self.cfg.effective_cores(); + // Multi-core is worth the thread setup only when there is enough + // independent top-level work to divide; below that, run inline. + if cores > 1 && self.top_cards.len() >= cores * 2 { + self.expand_parallel(cores); + } else { + self.process_top(); + } + // Dangling-passive removal (opt-in) runs first, so models used only by + // removed devices fall to the unused-model prune below. + let out_cards = std::mem::take(&mut self.out); + let out_cards = if self.cfg.topo_reduce { + reduce_dangling_passives(out_cards, &self.globals) + } else { + out_cards + }; + // Prune unused models on the FLAT deck (see prune_unused_models): every + // buried use is an explicit by-name reference by now, so this is sound — + // and a wrong prune fails LOUDLY in ngspice ("can't find model X") rather + // than silently using defaults. + let (cards, pruned) = prune_unused_models(out_cards); + // PSpice-dialect card-level rewrites, mirroring ngspice's pspice_compat + // (inpcompat.c). ngspice runs that pass per `.include`d file, but we have + // inlined every include, so it never fires on our output -- we do it here. + let mut cards = if self.cfg.is_pspice() { + pspice_rewrites(cards) + } else { + cards + }; + // PSpice mode: emit the predefined params/funcs ngspice's pspice_compat + // prepends to the deck. Kept-verbatim top-level `.param` cards may + // reference them (`.param nz={0.3/(vt*log(1+5.0m/isz))}`); without the + // definitions the downstream numparam pass hits "Undefined parameter + // [vt]" and exits fatally. Inserted at position 0: `cards` does NOT + // include the title (the reader consumed it; the CLI/glue prepend it), + // so 0 is "right after the title" in the assembled deck. Inserting any + // later can land INSIDE a leading `.control` block — the deck's first + // real card may be `.control` — which corrupts the script. Unused + // definitions are inert. + if self.cfg.is_pspice() { + let inject = [ + ".param temp = 'temper'", + ".param vt = '(temper + 273.15) * 8.6173303e-5'", + ".param gmin = 1e-12", + ".func limit(x, a, b) { ternary_fcn(a > b, max(min(x, a), b), max(min(x, b), a)) }", + ".func pwr(x, a) { pow(x, a) }", + ".func pwrs(x, a) { sgn(x) * pow(x, a) }", + ".func stp(x) { u(x) }", + ".func if(a, b, c) {ternary_fcn( a , b , c )}", + ".func int(x) { sgn(x)*floor(abs(x)) }", + ]; + for (k, s) in inject.iter().enumerate() { + cards.insert(k, s.to_string()); + } + } + let drops = self + .drops + .into_inner() + .into_iter() + .filter(|(ctx, _)| !pruned.contains(&key(ctx))) + .map(|(_, msg)| msg) + .collect(); + Expanded { cards, drops } + } + + /// Multi-core expansion. Resolve conditionals and collect the top-level model + /// set ONCE (so every worker sees identical inputs), split the resolved cards + /// into per-worker slices, expand them on scoped threads, and merge in order. + /// + /// The result is byte-identical to the single-core path: each top-level card + /// expands independently of its siblings (global params live in the root, + /// `.if` is already resolved, and the only cross-card state — a `.control` + /// block — is kept whole in one slice by the partitioner). Counters like + /// `inst_count` do not feed emitted text, so merging them is unnecessary for + /// correctness. Each worker rebuilds its own root scope because the scope's + /// memoization caches (RefCell) cannot cross threads; the values are the same. + fn expand_parallel(&mut self, cores: usize) { + let top = std::mem::take(&mut self.top_cards); + let active = resolve_conditionals(&top, &self.root); + let local_models = collect_models(&active); + let chunks = partition_top(&active, cores); + + // Immutable shared state, borrowed into the worker threads. + let defs = &self.defs; + let globals = &self.globals; + let funcs = &self.funcs; + let global_raw = &self.global_raw; + let local_models = &local_models; + let option_scale = self.option_scale; + let cfg = self.cfg; + + let results: Vec<(Vec, Vec<(String, String)>)> = std::thread::scope(|s| { + let handles: Vec<_> = chunks + .into_iter() + .map(|chunk| { + s.spawn(move || { + // Fresh per-worker root from the shared raw params. + let root = Scope::root(Arc::clone(funcs)); + for (n, r) in global_raw.iter() { + root.add_raw(n, r.clone()); + } + let mut w = SubcktExpander { + defs: Arc::clone(defs), + globals: Arc::clone(globals), + funcs: Arc::clone(funcs), + global_raw: Arc::clone(global_raw), + root, + out: Vec::new(), + drops: RefCell::new(Vec::new()), + option_scale, + cfg, + stats: SubstStats::default(), + inst_count: 0, + depth_guard: 0, + top_cards: Vec::new(), + }; + w.process_lines(&chunk, local_models); + (w.out, w.drops.into_inner()) + }) + }) + .collect(); + handles.into_iter().map(|h| h.join().unwrap()).collect() + }); + + // Merge in chunk order -> identical order to a single-core run. + for (out, drops) in results { + self.out.extend(out); + self.drops.get_mut().extend(drops); + } + } + + fn process_card( + &mut self, + line: &str, + prefix: &str, + scope: &Rc, + node_map: &HashMap, + local_models: &HashSet, + drop_bins: &HashSet, + mult: f64, + geo_scale: f64, + def_path: &str, + ) { + let k = kw(line); + if k == ".param" { + // Subckt-internal params are already folded into the expanded devices, + // so drop them. But KEEP top-level `.param` lines: a `.control` block + // (let/alter/print) or ngspice numparam may reference them by name. + if prefix.is_empty() { + self.out.push(line.to_string()); + } + return; + } + if k == ".ends" || k == ".eom" || k == ".subckt" { + return; // nested defs are extracted at registration; never emit these + } + if k == ".model" { + // Drop non-selected bins of a binned set (pruned per instance geometry), + // so ngspice bins the device to the single survivor and applies the + // BSIM L/W/P binning coefficients. + if let Some(name) = line.split_whitespace().nth(1) { + if drop_bins.contains(&key(name)) { + return; + } + } + self.out.push(self.emit_model(line, prefix, scope, local_models)); + return; + } + if k.starts_with(".if") || k.starts_with(".elseif") || k.starts_with(".else") || k.starts_with(".endif") { + return; // handled by resolve_conditionals + } + if k == ".ic" || k == ".nodeset" { + // Inside a subckt body ngspice renames the `v(node)` arguments + // during expansion (ports map to the caller's nodes, internals get + // the instance prefix). Left verbatim, every entry hits "IC on + // non-existent node" and is silently ignored. + let renamed = rename_vnode_args(line, node_map, prefix, &self.globals); + let out = self.subst_exprs(&renamed, scope, node_map, prefix); + self.out.push(out); + return; + } + if k.starts_with('.') { + // other dot-cards: partial-substitute, emit as-is + let out = self.subst_exprs(line, scope, node_map, prefix); + self.out.push(out); + return; + } + // instance or device + let first = line.as_bytes()[0].to_ascii_lowercase(); + if first == b'x' { + self.expand_x(line, prefix, scope, node_map, local_models, mult, def_path); + } else { + // A behavioral resistor expands to several cards (B-source + noise + // B/R/V), returned newline-joined; push each as its own card. + let emitted = self.emit_device(line, prefix, scope, node_map, local_models, mult, geo_scale); + if emitted.contains('\n') { + for card in emitted.split('\n') { + self.out.push(card.to_string()); + } + } else { + self.out.push(emitted); + } + } + } + + fn expand_x( + &mut self, + line: &str, + prefix: &str, + scope: &Rc, + node_map: &HashMap, + _local_models: &HashSet, + mult: f64, + def_path: &str, + ) { + self.inst_count += 1; + if self.depth_guard > 100 { + self.out.push(format!("* ngparse: recursion limit at {line}")); + return; + } + let mut parts = line.splitn(2, char::is_whitespace); + let inst = parts.next().unwrap_or(""); + let rest = parts.next().unwrap_or(""); + // `x1 1 2 sub PARAMS: l=1` -> drop the keyword (see strip_params_kw). + let rest_owned; + let rest = if rest.to_ascii_lowercase().contains("params:") { + rest_owned = strip_params_kw(rest); + rest_owned.as_str() + } else { + rest + }; + let (positional, assign_str) = split_positional(rest); + if positional.is_empty() { + return; + } + let subckt = positional.last().unwrap().clone(); + let nodes = &positional[..positional.len() - 1]; + // Lexical resolution: an inner definition of the same name wins over an + // outer/global one (ngspice scopes subckt defs to their parent). + let Some(def_key) = self.resolve_def(&subckt, def_path) else { + // unknown subckt: best-effort emit (partial-substitute params) + let out = self.subst_exprs(line, scope, node_map, prefix); + self.out.push(out); + return; + }; + let Some(def) = self.defs.get(&def_key) else { + let out = self.subst_exprs(line, scope, node_map, prefix); + self.out.push(out); + return; + }; + // map the instance's actual nodes into the current namespace + let actual: Vec = nodes + .iter() + .map(|n| map_node_with(n, node_map, prefix, &self.globals)) + .collect(); + // build child scope: overrides evaluated in current scope + let mut locals = HashMap::new(); + let mut override_raw: Vec<(String, String)> = Vec::new(); + for a in parse_assignments(assign_str) { + match eval(&parse(&a.rhs).unwrap_or(Expr::Num(0.0)), scope) { + Ok(v) => { + locals.insert(key(&a.name), v); + } + Err(_) => override_raw.push((a.name, a.rhs)), + } + } + // instance geometry passed on the X line (used to prune binned models, + // matching ngspice subckt.c which bins on the X-instance's l/w). + let xl = locals.get("l").copied(); + let xw = locals.get("w").copied(); + let xnf = locals.get("nf").copied().unwrap_or(1.0); + // `m` on an X line means one of two things, decided by the definition: + // * the subckt DECLARES an `m` parameter (e.g. SMIC `.subckt n12ll ... m=1` + // whose device does `m=m`, and whose mismatch uses `geo_fac='1/sqrt(lef*wef*m)'`): + // then `m` is an ordinary parameter — bind it and do NOT multiply, or we'd + // double-count (and stripping it would break `geo_fac`). + // * the subckt does NOT declare `m` (e.g. foundry_a `.subckt pch_lvt_mac ... multi='1'` + // whose device does `m=multi`): then `m` is the SPICE instance MULTIPLICITY, + // which ngspice multiplies into every device inside; remove it from the + // bindings and fold it into the multiplier instead. + // An outer instance's multiplicity keeps propagating in both cases. + let declares_m = def.defaults.iter().any(|(n, _)| key(n) == "m"); + let x_m = if declares_m { + 1.0 + } else { + locals.remove("m").unwrap_or(1.0) + }; + let child_mult = mult * x_m; + let child = Scope::child(scope, locals); + for (n, rhs) in &def.defaults { + child.add_raw(n, rhs.clone()); + } + for (n, rhs) in override_raw { + child.add_raw(&n, rhs); + } + // node map: ports -> actual (extra ports without a node are ignored) + let mut child_map = HashMap::new(); + for (p, a) in def.ports.iter().zip(actual.iter()) { + child_map.insert(key(p), a.clone()); + } + let child_prefix = if prefix.is_empty() { + inst.to_string() + } else { + format!("{prefix}.{inst}") + }; + + // pre-add body .param to child scope so forward refs resolve + for bl in &def.body { + if kw(bl) == ".param" { + for a in parse_assignments(&bl[".param".len()..]) { + if a.args.is_none() { + child.add_raw(&a.name, a.rhs); + } + } + } + } + // HSPICE element scale: a subckt that DECLARES a `scale` parameter scales + // its OWN body's geometry (ngspice subckt.c::inp_apply_subckt_scale). It is + // NOT inherited by nested subckts — each wraps its own body — so this is + // computed per-definition rather than accumulated. + let geo_scale = if def.defaults.iter().any(|(n, _)| key(n) == "scale") { + child.var("scale").unwrap_or(1.0) + } else { + 1.0 + }; + let active = resolve_conditionals(&def.body, &child); + let local_models = collect_models(&active); + let bins = collect_bins(&active, &child); + let drop_bins = bins_to_drop(&bins, xl, xw, xnf, self.option_scale); + self.depth_guard += 1; + for bl in &active { + self.process_card( + bl, + &child_prefix, + &child, + &child_map, + &local_models, + &drop_bins, + child_mult, + geo_scale, + &def_key, + ); + } + self.depth_guard -= 1; + } + + fn emit_model( + &self, + line: &str, + prefix: &str, + scope: &Rc, + _local_models: &HashSet, + ) -> String { + // .model (whitespace runs may be doubled). + // Split by tokens (collapsing whitespace) to get name/type; params get + // their own value-evaluation pass just like device instance params. + let after_dot = line.trim_start()[".model".len()..].trim_start(); + let (name, rest) = split_first(after_dot); + // The type token ends at whitespace OR at `(` — ngspice's INPgetTok + // (inpgtok.c) treats `(`/`)`/`,`/`=` as separators, so `d(a=1 b=2)` is a + // type of `d` with a parameter list, exactly like `d (a=1 b=2)`. Splitting + // on whitespace alone made the type `d(a=1` and silently ate the params. + let rest = rest.trim_start(); + let tend = rest + .find(|c: char| c.is_whitespace() || c == '(') + .unwrap_or(rest.len()); + let (mtype, params) = (&rest[..tend], rest[tend..].trim()); + // model params are often wrapped in `( ... )` + let (open, inner, close) = + if params.starts_with('(') && params.ends_with(')') && params.len() >= 2 { + ("(", ¶ms[1..params.len() - 1], ")") + } else { + ("", params, "") + }; + let newname = if prefix.is_empty() { + name.to_string() + } else { + format!("{prefix}:{name}") + }; + // Leading bare KEYWORDS carry no `=` and must be kept verbatim: VDMOS + // polarity is `.model M VDMOS nchan` (nchan/pchan), and there are similar + // flags elsewhere. eval_assignments only understands `name=value`, so it + // would drop them — and dropping `nchan` flips the device polarity and the + // circuit's output by ~30x. Split them off; params start at the first + // `name=value` token. + let inner_t = inner.trim(); + let (keywords, param_part) = match inner_t.find('=') { + Some(eq) => { + // Back up over any whitespace before `=` (PDK models write + // `level = 54`), THEN over the parameter name, to find where the + // first name=value begins. Everything before it is keywords. + // (Backing up only to the previous whitespace would wrongly treat + // the name `level` as a keyword when there are spaces around `=`.) + let name_end = inner_t[..eq].trim_end().len(); + let name_start = inner_t[..name_end] + .rfind(char::is_whitespace) + .map(|w| w + 1) + .unwrap_or(0); + (inner_t[..name_start].trim_end(), &inner_t[name_start..]) + } + None => (inner_t, ""), // no assignments at all — all keywords + }; + let resolved = self.eval_assignments(scope, &HashMap::new(), prefix, param_part, 1.0, 1.0, &newname); + let body = match (keywords.is_empty(), resolved.is_empty()) { + (true, _) => resolved, + (false, true) => keywords.to_string(), + (false, false) => format!("{keywords} {resolved}"), + }; + format!(".model {newname} {mtype} {open}{body}{close}") + } + + fn emit_device( + &self, + line: &str, + prefix: &str, + scope: &Rc, + node_map: &HashMap, + local_models: &HashSet, + mult: f64, + geo_scale: f64, + ) -> String { + let mut it = line.splitn(2, char::is_whitespace); + let inst = it.next().unwrap_or(""); + let rest = it.next().unwrap_or(""); + let first = inst.as_bytes().first().copied().unwrap_or(b'?'); + // XSPICE A-devices have a connection syntax of their own and their own + // tokenizer; ngspice gives them a dedicated branch, and so do we. + if first.to_ascii_lowercase() == b'a' { + return self.emit_a_device(inst, rest, prefix, node_map, local_models); + } + // Behavioral resistor `R n1 n2 {eq}` with a runtime equation: expand it to + // ngspice's own B-source (+ noise B/R/V) form HERE, so the aux devices and + // internal node take the resistor's LOCAL name (then our normal renaming + // prefixes them). See emit_behavioral_resistor. + if first.to_ascii_lowercase() == b'r' { + if let Some(s) = self.emit_behavioral_resistor(inst, rest, prefix, scope, node_map) { + return s; + } + } + // E/G-source TABLE form: `E n+ n- TABLE {ctrl} = (x0,y0)(x1,y1)...`, a + // piecewise-linear VCVS/VCCS common in op-amp models. The `= (points)` + // is NOT a name=value assignment, and `TABLE`/`{ctrl}` are not nodes, so + // the normal positional/assignment split mangles it. Handle it directly: + // rename the two output nodes, then let subst_exprs rename the nodes + // inside the `{ctrl}` expression while leaving TABLE/=/(points) verbatim. + if matches!(first.to_ascii_lowercase(), b'e' | b'g') { + let mut t = rest.split_whitespace(); + let (n1, n2, kw3) = (t.next(), t.next(), t.next()); + if let (Some(n1), Some(n2), Some(kw3)) = (n1, n2, kw3) { + if kw3.eq_ignore_ascii_case("table") { + let head = format!( + "{} {} {} TABLE", + rename_inst(inst, prefix), + map_node_with(n1, node_map, prefix, &self.globals), + map_node_with(n2, node_map, prefix, &self.globals), + ); + // Everything after the TABLE keyword: the {ctrl} + = + points. + let tail_start = rest.to_ascii_lowercase().find("table").unwrap() + 5; + let tail = self.subst_exprs(&rest[tail_start..], scope, node_map, prefix); + return format!("{head}{tail}"); + } + } + } + let (positional, assign_str) = split_positional(rest); + + let roles = dev_roles(first, &positional); + let nc = roles.len(); + let mut toks: Vec = Vec::new(); + // Renamed instance name. Inside a subckt, ngspice names an expanded device + // `..` so the card still begins with the device + // type letter (e.g. `m.x1.xmp1.main`) rather than the path's `x`. + toks.push(if prefix.is_empty() { + inst.to_string() + } else { + format!("{}.{}.{}", (first as char), prefix, inst) + }); + // Nodes, controlling-device references, and the POLY group. + // + // A controlling reference (F/H/W sense a V-source; K names its two + // inductors) is renamed as an INSTANCE, not a node — inside `x1`, `vsen` + // becomes `v.x1.vsen` (subckt.c::numdevs + translate_inst_name). Left + // alone it would resolve to some unrelated top-level device, or to + // nothing at all. + for (t, role) in positional.iter().zip(&roles) { + match *role { + Role::Node => toks.push(map_node_with(t, node_map, prefix, &self.globals)), + Role::Inst => toks.push(rename_inst(t, prefix)), + // Normalized to the exact spelling ngspice writes + // (`bxx_printf("POLY( %d ) ")`), which is what the downstream + // ENHtranslate_poly reads back. + Role::Poly(dim) => toks.push(format!("POLY( {dim} )")), + Role::Drop => {} + } + } + // tail positional (value / model name): + // * subckt-local model name -> scope-rename (`prefix:model`) + // * bare token that folds to a constant (R/C/L value param like `prgate`) + // -> the number + // * delimited ({..}/'..') exprs -> left for subst_exprs (they may span + // whitespace once rejoined, e.g. V-source pulse args) + // * anything else (global model name, `dc`, `pulse`, node) -> kept + for t in &positional[nc..] { + let kt = key(t); + if local_models.contains(&kt) { + // A defined model name is kept verbatim, NEVER folded as a value. + // This matters when the name looks like a number: `1n4002` (a real + // diode part) parses as 1n = 1e-9, and folding it both corrupts the + // device and orphans the `.model 1n4002` card (then pruned as + // "unused"). Inside a subckt the reference is scope-renamed to the + // BASE name so bin pruning + ngspice's L/W/P binning still apply; + // at top level it stays as written. + if prefix.is_empty() { + toks.push(t.clone()); + } else { + toks.push(format!("{prefix}:{t}")); + } + } else if t.contains(['{', '}', '\'', '"']) { + toks.push(t.clone()); + } else { + match parse(t) { + Ok(e) => match self.partial(&e, scope, node_map, prefix, &HashMap::new(), 0) { + Part::Const(v) => toks.push(fmt_num(v)), + Part::Sym(_) => toks.push(t.clone()), + }, + Err(_) => toks.push(t.clone()), + } + } + } + // resolve delimited exprs in the head, partial-aware (folds params, keeps + // runtime v()/temper, renames nodes inside v()). + let mut base = self.subst_exprs(&toks.join(" "), scope, node_map, prefix); + // trailing `name=value` params. + let assigns = self.eval_assignments(scope, node_map, prefix, assign_str, mult, geo_scale, &toks[0]); + if !assigns.is_empty() { + base.push(' '); + base.push_str(&assigns); + } + base + } + + /// A behavioral resistor `R n1 n2 {eq}` / `'eq'` whose equation references a + /// runtime quantity (`v()`, `i()`, `temper`, `time`, `hertz`) is turned by + /// ngspice (inpcom.c) into a B-source, plus a noise B/R/V triple when + /// `noisy=1`. ngspice does this BEFORE subckt expansion, so the aux devices + /// carry the resistor's LOCAL name and the internal node has no leading type + /// letter -- e.g. `b.x1.xr1.br1`, node `x1.xr1.r1_3`. We expand first, so if we + /// handed ngspice a flat `R.x1.xr1.R1` it would name the internals + /// `br.x1.xr1.r1` / `r.x1.xr1.r1_3` instead, reordering the matrix and shifting + /// sensitive analog startups (bandgap: ~16 mV). So we emit ngspice's exact + /// form here using the local `inst` name and let normal renaming prefix it. + /// + /// Returns None for a plain (numeric) resistor or one whose equation folds to a + /// constant -- those go through the ordinary device path. + fn emit_behavioral_resistor( + &self, + inst: &str, + rest: &str, + prefix: &str, + scope: &Rc, + node_map: &HashMap, + ) -> Option { + let (positional, assign_str) = split_positional(rest); + // Need n1, n2, and a value token. + if positional.len() < 3 { + return None; + } + let value = &positional[2]; + // Only an expression value (braced/quoted) can be behavioral. + if !value.contains(['{', '\'', '"']) { + return None; + } + // Resolve the equation (folds params, keeps runtime symbolic, renames the + // nodes inside its v()/i()), then strip the surrounding delimiter. + let resolved = self.subst_exprs(value, scope, node_map, prefix); + let eq = resolved.trim(); + let eq = eq + .strip_prefix('\'') + .and_then(|s| s.strip_suffix('\'')) + .or_else(|| eq.strip_prefix('{').and_then(|s| s.strip_suffix('}'))) + .unwrap_or(eq) + .trim(); + // ngspice's b_transformation_wanted: transform only a runtime equation. + if !Self::has_runtime(eq) { + return None; + } + let n1 = map_node_with(&positional[0], node_map, prefix, &self.globals); + let n2 = map_node_with(&positional[1], node_map, prefix, &self.globals); + // tc1/tc2 and m pass through (rare on behavioral R, absent in the PDK); the + // resolved noisy flag decides the noise triple. + let assigns = self.eval_assignments(scope, node_map, prefix, &assign_str, 1.0, 1.0, inst); + let (tc, m, noisy) = split_resistor_params(&assigns); + + // Device name: `..` (the normal renaming), NODE name: + // `.` (no leading letter) -- matching ngspice post-expansion. + let dev = |letter: char, name: &str| -> String { + if prefix.is_empty() { + name.to_string() + } else { + format!("{letter}.{prefix}.{name}") + } + }; + let node = |name: &str| -> String { + if prefix.is_empty() { + name.to_string() + } else { + format!("{prefix}.{name}") + } + }; + let mut cards = vec![format!( + "{} {n1} {n2} i=v({n1},{n2})/({eq}){tc}{m} reciproctc=1 reciprocm=0", + dev('b', &format!("b{inst}")) + )]; + if noisy { + let vsense = dev('v', &format!("v{inst}_3")); + let n3 = node(&format!("{inst}_3")); + cards.push(format!( + "{} {n1} {n2} i=i({vsense})/sqrt({eq})", + dev('b', &format!("b{inst}_1")) + )); + cards.push(format!("{} {n3} 0 1.0{tc}", dev('r', &format!("r{inst}_2")))); + cards.push(format!("{vsense} {n3} 0 0")); + } + Some(cards.join("\n")) + } + + /// Partial evaluation of an expression AST: fold parameter/constant subtrees + /// to numbers, inline user functions, and keep runtime references — `v()`, + /// `i()`, `temper`, `time`, `hertz`, and any identifier that isn't a defined + /// parameter — symbolic. Node names inside `v()`/`i()` are renamed via the + /// current `nmap`/`prefix`. `locals` binds inlined function arguments. + fn partial( + &self, + e: &Expr, + scope: &Rc, + nmap: &HashMap, + prefix: &str, + locals: &HashMap, + depth: usize, + ) -> Part { + match e { + Expr::Num(n) => Part::Const(*n), + // A string is a `table_param` filename, never a number. It can only be + // consumed by the `table_param` fold below; if that fold fails, the + // call stays symbolic and the string is re-emitted as written. + Expr::Str(s) => Part::Sym(format!("\"{s}\"")), + Expr::Var(name) => { + let k = key(name); + if let Some(p) = locals.get(&k) { + return p.clone(); + } + // Memoized verdict (see Scope::partial_cache). Skipped whenever + // function-arg locals are live: a raw definition partial-evaluated + // under non-empty `locals` can bind identifiers to those args, so + // its result is not a property of the scope alone. + if locals.is_empty() { + if let Some(p) = scope.partial_cache.borrow().get(&k) { + return p.clone(); + } + } + let out = match scope.var(name) { + Ok(v) => { + // A parameter whose definition is (transitively) a + // statistical draw must not fold to its nominal — that + // would collapse the MC distribution just as surely as + // folding the call directly. A textual `has_runtime` on the + // raw definition is not enough: a PDK mismatch param reads + // `dvth='avth*geo_fac*sigma_b*mismatchflag'`, which mentions + // the draw only by NAME (`sigma_b`, itself `=agauss(...)` + // globally) — the literal `agauss(` is one level down. So + // partial-evaluate the raw definition (which resolves + // `sigma_b`→`agauss(...)`) and, if the RESOLVED form is + // genuinely runtime, keep it symbolic instead of folding to + // the nominal `v`. Folding it collapses the MC distribution + // to a single point (σ=0), the exact bug this guards. + let mut out = Part::Const(v); + if depth < 60 { + if let Some(raw) = scope.raw_lookup(name) { + if let Ok(e) = parse(&raw) { + let p = self.partial( + &e, scope, nmap, prefix, locals, depth + 1, + ); + if matches!(&p, Part::Sym(s) if Self::has_runtime(s)) { + out = p; + } + } + } + } + out + } + Err(_e) => { + // Full evaluation failed — typically because the definition + // transitively depends on a runtime quantity (`temper`, + // `v()`, ...). Don't give up: partial-evaluate the param's + // own definition so the constant parts fold and the runtime + // parts survive symbolically (PDK LOD/stress params like + // `fu0_lod` are temperature-dependent and MUST stay symbolic). + // genuinely undefined (or too deep) -> keep symbolic + let mut out = Part::Sym(name.clone()); + if depth < 60 { + if let Some(raw) = scope.raw_lookup(name) { + if let Ok(e) = parse(&raw) { + out = self.partial(&e, scope, nmap, prefix, locals, depth + 1); + } + } + } + out + } + }; + if locals.is_empty() { + scope.partial_cache.borrow_mut().insert(k, out.clone()); + } + out + } + Expr::Unary(op, x) => { + match self.partial(x, scope, nmap, prefix, locals, depth) { + Part::Const(v) => Part::Const(match op { + UnOp::Neg => -v, + UnOp::Pos => v, + UnOp::Not => if v == 0.0 { 1.0 } else { 0.0 }, + }), + Part::Sym(s) => { + let o = match op { UnOp::Neg => "-", UnOp::Pos => "+", UnOp::Not => "!" }; + Part::Sym(format!("{o}({s})")) + } + } + } + Expr::Binary(op, l, r) => { + let pl = self.partial(l, scope, nmap, prefix, locals, depth); + let pr = self.partial(r, scope, nmap, prefix, locals, depth); + if let (Part::Const(a), Part::Const(b)) = (&pl, &pr) { + // reuse the evaluator's semantics for a folded binary op + if let Ok(v) = eval( + &Expr::Binary(*op, Box::new(Expr::Num(*a)), Box::new(Expr::Num(*b))), + scope, + ) { + return Part::Const(v); + } + } + // A runtime/statistical term multiplied by a LITERAL 0 folds to 0. + // A PDK mismatch param reads `...*sigma_b*mismatchflag`; with the + // flag OFF (0) the whole term is nominal, and ngspice emits an + // identical (unvaried) model for it. Without this fold ngparse would + // keep a live `agauss(...)*0` — value-correct (finite draw * 0 == 0) + // but it still DRAWS at runtime, needlessly perturbing the shared MC + // PRNG stream and leaving the model spuriously "varied". Only an + // exact literal-0 factor folds; a symbolic 0 is left alone (it could + // carry a non-finite value where x*0 != 0). + if matches!(op, BinOp::Mul) + && (matches!(&pl, Part::Const(z) if *z == 0.0) + || matches!(&pr, Part::Const(z) if *z == 0.0)) + { + return Part::Const(0.0); + } + // numparam's evaluator rejects a binary operator immediately + // followed by a unary sign (`...e0+-(agauss(...))`, reported as + // "wrongly determined negation"). Our right operand can start + // with `-`/`+` — a Unary Neg renders `-(...)` and a negative + // constant renders `-1.2e0`. Parenthesize such an operand so the + // sign lands in a valid unary context (`... + (-(...))`) instead + // of butting against the binary op. A leading-sign LEFT operand + // is already safe: it sits right after the group's `(`. + let rs = part_str(&pr); + let rs = if rs.starts_with('-') || rs.starts_with('+') { + format!("({rs})") + } else { + rs + }; + Part::Sym(format!("({}{}{})", part_str(&pl), binop_str(*op), rs)) + } + Expr::Ternary(c, t, f) => match self.partial(c, scope, nmap, prefix, locals, depth) { + Part::Const(cv) => { + if cv != 0.0 { + self.partial(t, scope, nmap, prefix, locals, depth) + } else { + self.partial(f, scope, nmap, prefix, locals, depth) + } + } + Part::Sym(cs) => { + let pt = part_str(&self.partial(t, scope, nmap, prefix, locals, depth)); + let pf = part_str(&self.partial(f, scope, nmap, prefix, locals, depth)); + Part::Sym(format!("(({cs}) ? ({pt}) : ({pf}))")) + } + }, + Expr::Call(name, args) => { + let lname = key(name); + // node-voltage / branch-current refs: keep symbolic, rename nodes. + if lname == "v" || lname == "i" { + // `i(...)` measures current THROUGH A DEVICE, so its argument + // is a device/instance name, renamed with the device-letter + // prefix (`i(rs2)` -> `i(r.x1.rs2)`), exactly like an F/H + // controlling source. `v(...)` is a NODE, renamed as a node. + // Getting i() wrong left it pointing at a nonexistent node and + // ngspice failed with "unknown controlling source". + let is_i = lname == "i"; + let rn: Vec = args + .iter() + .map(|a| match a { + Expr::Var(n) if is_i => rename_inst(n, prefix), + Expr::Var(n) => map_node_with(n, nmap, prefix, &self.globals), + // A numeric node name — `v(1)` — parses as a Num. It is + // a node NAME, not a value: keep it (renamed), never + // fold it. `v(1)` folded to `v(1.0e0)` references a + // DIFFERENT, nonexistent node (ngspice matches nodes by + // string), which broke a B-source's operating point. + Expr::Num(x) if x.fract() == 0.0 && x.is_finite() && *x >= 0.0 => { + map_node_with(&format!("{}", *x as i64), nmap, prefix, &self.globals) + } + other => part_str(&self.partial(other, scope, nmap, prefix, locals, depth)), + }) + .collect(); + return Part::Sym(format!("{lname}({})", rn.join(","))); + } + let pargs: Vec = args + .iter() + .map(|a| self.partial(a, scope, nmap, prefix, locals, depth)) + .collect(); + // user function: inline body with args bound (partial), even when + // some args are symbolic (e.g. tcoef(temper)). + if depth < 60 { + if let Some((argnames, body)) = self.funcs.get(&lname).cloned() { + if argnames.len() == pargs.len() { + let mut loc = HashMap::new(); + for (an, pv) in argnames.iter().zip(&pargs) { + loc.insert(key(an), pv.clone()); + } + return self.partial(&body, scope, nmap, prefix, &loc, depth + 1); + } + } + } + // PSpice `LIMIT(x,lo,hi)` (3 args) is a CLAMP, not HSPICE's 2-arg + // MC distribution. ngspice handles it via pspice_compat's injected + // `.func limit(x,a,b) {ternary_fcn(a>b, max(min(x,a),b), + // max(min(x,b),a))}`, expanded by numparam before the B-source + // parser (which has no `limit`). That injection never reaches our + // flat deck, so emit the same form here; fold when all-const. + if self.cfg.is_pspice() && lname == "limit" && pargs.len() == 3 { + if let [Part::Const(x), Part::Const(a), Part::Const(b)] = pargs[..] { + let (lo, hi) = if a < b { (a, b) } else { (b, a) }; + return Part::Const(x.max(lo).min(hi)); + } + let ss: Vec = pargs.iter().map(part_str).collect(); + let (x, a, b) = (&ss[0], &ss[1], &ss[2]); + return Part::Sym(format!( + "ternary_fcn({a}>{b},max(min({x},{a}),{b}),max(min({x},{b}),{a}))" + )); + } + // Statistical draws are NEVER folded — ngspice must draw a fresh + // value per MC run. Resolve the arguments (so the distribution + // parameters are numeric) but keep the call itself symbolic. + if matches!( + lname.as_str(), + "agauss" | "gauss" | "aunif" | "unif" | "limit" + ) { + let ss: Vec = pargs.iter().map(part_str).collect(); + return Part::Sym(format!("{lname}({})", ss.join(","))); + } + // builtin: fold if all args are constant. + if pargs.iter().all(|p| matches!(p, Part::Const(_))) { + let vals: Vec = pargs + .iter() + .map(|p| if let Part::Const(v) = p { *v } else { 0.0 }) + .collect(); + if let Some(Ok(v)) = eval_builtin(&lname, &vals) { + return Part::Const(v); + } + } + let ss: Vec = pargs.iter().map(part_str).collect(); + // PSpice/HSPICE `if(cond,a,b)` is the ternary selector. ngspice's + // behavioral parser has no `if()` function -- its native name is + // `ternary_fcn` (exactly what ngspice's own pspice-compat rewrites + // `if` to). Emit the native name so the source parses on its own, + // instead of relying on compat-mode injection that ngparse's + // expanded deck may no longer trigger. `if` is the ternary in every + // dialect, so this one is unconditional. + if lname == "if" && ss.len() == 3 { + return Part::Sym(format!("ternary_fcn({})", ss.join(","))); + } + // The remaining PSpice behavioral functions are injected by ngspice + // only via pspice_compat (`.func pwr/pwrs/stp/int`), which never + // reaches our flat deck. In PSpice mode emit their native + // equivalents directly, matching inpcompat.c exactly. + if self.cfg.is_pspice() { + match (lname.as_str(), ss.len()) { + // pwr(x,a) -> pow(x,a) + ("pwr", 2) => return Part::Sym(format!("pow({},{})", ss[0], ss[1])), + // pwrs(x,a) -> sgn(x) * pow(x,a) + ("pwrs", 2) => { + return Part::Sym(format!("(sgn({0})*pow({0},{1}))", ss[0], ss[1])) + } + // stp(x) -> u(x) + ("stp", 1) => return Part::Sym(format!("u({})", ss[0])), + // int(x) -> sgn(x) * floor(abs(x)) + ("int", 1) => { + return Part::Sym(format!("(sgn({0})*floor(abs({0})))", ss[0])) + } + _ => {} + } + } + Part::Sym(format!("{lname}({})", ss.join(","))) + } + } + } + + /// Whether a symbolic string genuinely references runtime quantities (and so + /// must be preserved for the simulator) vs. being merely an unresolved + /// parameter (which we drop as a stopgap, e.g. `table_param`). + fn has_runtime(s: &str) -> bool { + let low = s.to_ascii_lowercase(); + low.contains("v(") + || low.contains("i(") + || low.contains("temper") + || low.contains("time") + || low.contains("hertz") + // ngspice B-source functions that are inherently time-dependent and + // can never be folded (inpptree.c): keep them for the simulator. + || low.contains("ddt(") + || low.contains("sdt(") + || low.contains("pwl(") + // Statistical draws MUST reach ngspice unfolded: it draws a fresh + // value per Monte Carlo iteration, and that per-run variation IS the + // point of MC. Folding them to nominal would collapse the whole + // distribution to a point and make ngparse useless for MC. Kept + // symbolic here (args still resolved) so ngspice's PRNG does the draw, + // giving the same distribution as its own parser. + || low.contains("agauss(") + || low.contains("gauss(") // also matches agauss (harmless) + || low.contains("aunif(") + || low.contains("unif(") // also matches aunif (harmless) + || low.contains("limit(") + } + + /// Partial-substitute every `{..}`/`'..'` span in a line. + fn subst_exprs( + &self, + line: &str, + scope: &Rc, + nmap: &HashMap, + prefix: &str, + ) -> String { + let b = line.as_bytes(); + let mut out = String::with_capacity(line.len()); + let mut i = 0; + while i < b.len() { + let c = b[i]; + if c == b'{' || c == b'\'' { + let close = if c == b'{' { b'}' } else { b'\'' }; + let start = i + 1; + let mut k = start; + let mut depth = 1; + while k < b.len() { + if c == b'{' && b[k] == b'{' { + depth += 1; + } else if b[k] == close { + depth -= 1; + if depth == 0 { + break; + } + } + k += 1; + } + let inner = &line[start..k.min(b.len())]; + match parse(inner) { + Ok(e) => match self.partial(&e, scope, nmap, prefix, &HashMap::new(), 0) { + Part::Const(v) => out.push_str(&fmt_num(v)), + Part::Sym(s) => { + out.push(c as char); + out.push_str(&s); + out.push(close as char); + } + }, + Err(_) => { + out.push(c as char); + out.push_str(inner); + out.push(close as char); + } + } + i = (k + 1).min(b.len()); + continue; + } + out.push(c as char); + i += 1; + } + out + } + + /// Expand an XSPICE A-device (code model) card. + /// + /// ngspice gives these their own branch — subckt.c:1504, *"process A devices + /// specially ... since they have a more involved and variable length node + /// syntax"* — driven by a one-token look-ahead so the LAST token is always the + /// model name. Per connection token: + /// + /// * `[` `]` `~` — emitted verbatim (vector brackets, inversion); + /// * `%` — the following word is a port type (`vd`, `id`, `vnam`, …) and is + /// emitted glued to it as `%vd`. A port type of `vnam` means the NEXT token + /// names a V-source, so it is renamed as an INSTANCE, not a node; + /// * anything else — a node name (`null` and `0` pass through, being globals); + /// * the trailing model name is subckt-scoped (`prefix:model`), which ngspice + /// does separately in `devmodtranslate` (subckt.c:2105 — *"the name of the + /// model is always last"*). + /// + /// Unlike the other device branches this one never param-substitutes: ngspice's + /// `case 'a'` consumes the whole line itself and never calls `finishLine`. + fn emit_a_device( + &self, + inst: &str, + rest: &str, + prefix: &str, + node_map: &HashMap, + local_models: &HashSet, + ) -> String { + let toks = mif_tokens(rest); + + let mut out: Vec = vec![if prefix.is_empty() { + inst.to_string() + } else { + format!("{}.{}.{}", inst.chars().next().unwrap_or('a'), prefix, inst) + }]; + + let n = toks.len(); + let mut got_vnam = false; + let mut i = 0; + // Every token but the last is a connection; the last one is the model. + while i + 1 < n { + match toks[i].as_str() { + "[" | "]" | "~" => { + out.push(toks[i].clone()); + i += 1; + } + "%" => { + // NB: ngspice clears got_vnam after ONE translated token, so in + // `%vnam [ v1 v2 ]` only v1 is instance-renamed and v2 falls + // through to node renaming. Faithfully reproduced. + let pt = &toks[i + 1]; + got_vnam = pt.eq_ignore_ascii_case("vnam"); + out.push(format!("%{pt}")); + i += 2; + } + t => { + out.push(if got_vnam { + got_vnam = false; + rename_inst(t, prefix) + } else { + map_node_with(t, node_map, prefix, &self.globals) + }); + i += 1; + } + } + } + if let Some(m) = toks.last() { + out.push(if local_models.contains(&key(m)) && !prefix.is_empty() { + format!("{prefix}:{m}") + } else { + m.clone() + }); + } + out.join(" ") + } + + /// Evaluate `name=value` device/instance/model parameters, partial-aware. + fn eval_assignments( + &self, + scope: &Rc, + nmap: &HashMap, + prefix: &str, + assign_str: &str, + mult: f64, + geo_scale: f64, + context: &str, + ) -> String { + let mut out: Vec = Vec::new(); + let mut saw_m = false; + for a in parse_assignments(assign_str) { + let kn = key(&a.name); + // An RHS that is not a numeric expression is a literal token — a + // version string like `version=3.3.0`, or a keyword. ngspice passes + // such values straight to the device/model parser, so we keep them + // VERBATIM rather than dropping them (which would silently default the + // parameter). Genuinely malformed text still surfaces loudly, as an + // ngspice parse error, not a silent ngparse default. (The trailing-comma + // bug that once justified dropping here is fixed upstream in + // parse_assignments, so a `,` no longer reaches this point.) + let e = match parse(&a.rhs) { + Ok(e) => e, + Err(_) => { + out.push(format!("{}={}", a.name, a.rhs)); + continue; + } + }; + if kn == "m" { + saw_m = true; + } + match self.partial(&e, scope, nmap, prefix, &HashMap::new(), 0) { + Part::Const(v) => { + // `m` picks up the accumulated subckt-instance multiplicity; + // geometry picks up the enclosing subckt's HSPICE element scale. + let v = if kn == "m" { + v * mult + } else if let Some(p) = geo_power(&kn) { + v * geo_scale.powi(p) + } else { + v + }; + out.push(format!("{}={}", a.name, fmt_num(v))); + } + Part::Sym(s) => { + // behavioral value -> keep braced; pure-unresolved param -> drop + if Self::has_runtime(&s) { + // scale/multiply symbolically so runtime exprs stay correct + let s = if kn == "m" && mult != 1.0 { + format!("({s})*{}", fmt_num(mult)) + } else if let Some(p) = geo_power(&kn) { + if geo_scale != 1.0 { + format!("({s})*{}", fmt_num(geo_scale.powi(p))) + } else { + s + } + } else { + s + }; + out.push(format!("{}={{{}}}", a.name, s)); + } else if is_bare_word(&s) { + // A single unresolved identifier is a literal token, not a + // failed computation: a model string value (`mfg=acme_corp`), + // a keyword (`fraction=false`), a type name. ngspice passes + // these straight through, so keep it VERBATIM. If it is in + // fact a mistyped parameter name, ngspice reports the + // unknown parameter — loud, not a silent default. + out.push(format!("{}={}", a.name, a.rhs)); + } else { + // A genuine EXPRESSION that could not resolve (references an + // undefined parameter inside arithmetic): the param is + // DROPPED and the device/model silently falls back to its + // DEFAULT. Never let that pass unreported — record it so the + // caller can warn (or fail under --strict). + let scope_name = if prefix.is_empty() { "" } else { prefix }; + self.drops.borrow_mut().push(( + context.to_string(), + format!( + "{scope_name} {context}: {}={:?} (unresolved: {:?})", + a.name, a.rhs, s + ), + )); + if std::env::var_os("NGPARSE_DEBUG_DROP").is_some() { + eprintln!( + "ngparse DROP: prefix={prefix:?} {}={:?} -> unresolved {:?}", + a.name, a.rhs, s + ); + } + } + } + } + } + // A device with no explicit `m` still inherits the instance multiplicity. + if !saw_m && mult != 1.0 { + out.push(format!("m={}", fmt_num(mult))); + } + out.join(" ") + } +} + +/// Power of the enclosing subckt's `scale` that a MOSFET geometry parameter takes +/// (ngspice subckt.c::inp_apply_subckt_scale): lengths/perimeters/spacings by +/// `scale`, areas by `scale^2`. `m`/`nf` (multipliers) and `nrd/nrs/sca-scc` +/// (dimensionless) are deliberately absent — they are never scaled. +fn geo_power(name: &str) -> Option { + match name { + "w" | "l" | "pd" | "ps" | "sa" | "sb" | "sc" | "sd" => Some(1), + "ad" | "as" => Some(2), + _ => None, + } +} + + +/// Rename a device/instance name for a subckt body: `rs2` in `x1` -> `r.x1.rs2`, +/// keeping ngspice's `..` form. At top level +/// (empty prefix) the name is unchanged. Used for F/H/W/K controlling sources +/// and for the argument of `i()`. +fn rename_inst(name: &str, prefix: &str) -> String { + if prefix.is_empty() { + name.to_string() + } else { + format!("{}.{}.{}", name.chars().next().unwrap_or('?'), prefix, name) + } +} + +/// Map a node name through the port map, else prefix it (internal node). Ground +/// (`0`) is global and never renamed. +/// Rename the node names inside every `v(...)` group of an `.ic`/`.nodeset` +/// card (the only node references such cards carry). Text outside `v(...)` +/// is preserved untouched; a comma-separated argument renames each part. +fn rename_vnode_args( + line: &str, + node_map: &HashMap, + prefix: &str, + globals: &HashSet, +) -> String { + let b = line.as_bytes(); + let mut out = String::with_capacity(line.len()); + let mut i = 0; + while i < b.len() { + let is_v = (b[i] == b'v' || b[i] == b'V') + && i + 1 < b.len() + && b[i + 1] == b'(' + && (i == 0 || !(b[i - 1].is_ascii_alphanumeric() || b[i - 1] == b'_')); + if !is_v { + out.push(b[i] as char); + i += 1; + continue; + } + let start = i + 2; + let close = match line[start..].find(')') { + Some(p) => start + p, + None => { + out.push_str(&line[i..]); + break; + } + }; + let renamed: Vec = line[start..close] + .split(',') + .map(|n| map_node_with(n.trim(), node_map, prefix, globals)) + .collect(); + out.push(b[i] as char); + out.push('('); + out.push_str(&renamed.join(",")); + out.push(')'); + i = close + 1; + } + out +} + +fn map_node_with( + n: &str, + node_map: &HashMap, + prefix: &str, + globals: &HashSet, +) -> String { + // Ground, `null`, and `.global` nodes are hierarchy-wide: never renamed. + // ngspice seeds its global-node table with exactly these two before adding + // the user's `.global` names (subckt.c::collect_global_nodes): + // nghash_insert(glonodes, "0", ...); + // nghash_insert(glonodes, "null", ...); /* #ifdef XSPICE */ + // `null` marks an unconnected XSPICE port (`adiv2 d clk NULL NULL NULL q dff`); + // renaming it to `x1.null` would invent a real node per subckt instance. + if n == "0" || n.eq_ignore_ascii_case("null") || globals.contains(&key(n)) { + return n.to_string(); + } + if let Some(a) = node_map.get(&key(n)) { + return a.clone(); + } + if prefix.is_empty() { + n.to_string() + } else { + format!("{prefix}.{n}") + } +} + +/// Collect the set of model names (lowercased) defined by active `.model` cards. +/// For length-binned models (`nfet.0`, `nfet.1`, …) the base name (`nfet`) is +/// also registered, so a device that references the base gets scoped the same +/// way and ngspice's bin matching still finds `prefix:nfet.0` … from `prefix:nfet`. +fn collect_models(active: &[String]) -> HashSet { + let mut s = HashSet::new(); + for line in active { + if kw(line) == ".model" { + if let Some(name) = line.split_whitespace().nth(1) { + let k = key(name); + // strip a trailing `.` bin suffix to get the base name + if let Some(dot) = k.rfind('.') { + if k[dot + 1..].chars().all(|c| c.is_ascii_digit()) && dot > 0 { + s.insert(k[..dot].to_string()); + } + } + s.insert(k); + } + } + } + s +} + +/// A length/width bin of a binned model set. +struct BinDef { + name: String, // lowercased full model name, e.g. `nfet.1` + lmin: f64, + lmax: f64, + wmin: f64, + wmax: f64, +} + +/// Extract a named parameter's value from a `.model` card's params, evaluated in +/// `scope`. Returns None if absent/unevaluable. +fn model_param(params: &[Assign], name: &str, scope: &Rc) -> Option { + let k = key(name); + for a in params { + if key(&a.name) == k { + return parse(&a.rhs).ok().and_then(|e| eval(&e, scope).ok()); + } + } + None +} + +/// Group binned `.model .` cards among the active lines by base name, +/// resolving each bin's `lmin/lmax/wmin/wmax` in `scope`. Only sets whose cards +/// carry all four bounds are recorded (unbinned models are ignored). +fn collect_bins(active: &[String], scope: &Rc) -> HashMap> { + let mut m: HashMap> = HashMap::new(); + for line in active { + if kw(line) != ".model" { + continue; + } + let mut tok = line.split_whitespace(); + let _dot = tok.next(); + let name = match tok.next() { + Some(n) => key(n), + None => continue, + }; + // base = name minus a trailing `.` bin suffix + let base = match name.rfind('.') { + Some(d) if d > 0 && name[d + 1..].chars().all(|c| c.is_ascii_digit()) => { + name[..d].to_string() + } + _ => continue, + }; + // params start after `.model ` + let after = line.trim_start()[".model".len()..].trim_start(); + let (_n, rest) = split_first(after); + let (_ty, params_txt) = split_first(rest.trim_start()); + let params_txt = params_txt.trim().trim_start_matches('(').trim_end_matches(')'); + let params = parse_assignments(params_txt); + if let (Some(lmin), Some(lmax), Some(wmin), Some(wmax)) = ( + model_param(¶ms, "lmin", scope), + model_param(¶ms, "lmax", scope), + model_param(¶ms, "wmin", scope), + model_param(¶ms, "wmax", scope), + ) { + m.entry(base).or_default().push(BinDef { + name, + lmin, + lmax, + wmin, + wmax, + }); + } + } + m +} + +/// From a behavioral resistor's resolved `name=value` tail, pull out the +/// temperature-coefficient string (` tc1=.. tc2=..`), the multiplier (` m=..`), +/// and the `noisy` flag -- the pieces ngspice's inpcom.c resistor transform +/// consumes. `noisy`/`noise` is consumed (not re-emitted); everything else is +/// ignored (ngspice's transform handles only these). +fn split_resistor_params(assigns: &str) -> (String, String, bool) { + let (mut tc1, mut tc2, mut m, mut noisy) = (None, None, None, false); + for tok in assigns.split_whitespace() { + if let Some((k, v)) = tok.split_once('=') { + match k.to_ascii_lowercase().as_str() { + "tc1" => tc1 = Some(v.to_string()), + "tc2" => tc2 = Some(v.to_string()), + "m" => m = Some(v.to_string()), + "noisy" | "noise" => noisy = v.parse::().map_or(false, |x| x != 0.0), + _ => {} + } + } + } + let tc = match (tc1, tc2) { + (Some(a), Some(b)) => format!(" tc1={a} tc2={b}"), + (Some(a), None) => format!(" tc1={a}"), + _ => String::new(), + }; + let m = m.map(|z| format!(" m={z}")).unwrap_or_default(); + (tc, m, noisy) +} + +/// Split resolved top-level cards into up to `parts` ordered slices for parallel +/// expansion. Preserves order and NEVER splits a `.control ... .endc` block: that +/// block is stateful (a `let`/`print`/`alter` inside would be mangled if cut), so +/// a chunk boundary is only taken at `.control` nesting depth 0. Chunks are kept +/// roughly equal; at most `parts` are produced. +fn partition_top(active: &[String], parts: usize) -> Vec> { + let n = active.len(); + // roughly equal, rounding up (avoid usize::div_ceil for a lower MSRV) + let target = if parts == 0 { n } else { (n + parts - 1) / parts }; + let mut chunks: Vec> = Vec::new(); + let mut cur: Vec = Vec::new(); + let mut in_control = false; + for line in active { + let k = kw(line); + if k == ".control" { + in_control = true; + } + cur.push(line.clone()); + if k == ".endc" { + in_control = false; + } + // Close only at a safe boundary, once big enough, while still leaving + // room for the final chunk (so we never exceed `parts`). + if !in_control && cur.len() >= target && chunks.len() + 1 < parts { + chunks.push(std::mem::take(&mut cur)); + } + } + if !cur.is_empty() { + chunks.push(cur); + } + chunks +} + +/// ngspice inp_compat's behavioral E/G split, done PRE-expansion so the +/// derived names match ngspice's own expansion exactly: +/// +/// Exxx n1 n2 VALUE|VOL = {expr} -> Exxx n1 n2 Exxx_int1 0 1 +/// bExxx Exxx_int1 0 v = {expr} +/// Gxxx n1 n2 VALUE|CUR = {expr} [m=X] -> Gxxx n1 n2 Gxxx_int1 0 X +/// bGxxx Gxxx_int1 0 v = {expr} +/// +/// Downstream ngspice performs this same split (inpcom.c inp_compat, every +/// dialect but s3) — but on ngparse's output it ran on the FLAT names, so the +/// internal node came out `e.x1.e1_int1` where ngspice's own expansion makes +/// `x1.e1_int1`, and the B source `be.x1.e1` instead of `b.x1.be1`. The value +/// set is identical; the difference reorders the sparse matrix, which flips +/// convergence-marginal decks (same class as the behavioral-resistor naming). +/// Split here, inside the subckt body, and expansion renames both cards the +/// way ngspice's own flow does; downstream inp_compat then finds nothing left +/// to convert. A `VALUE={TABLE(...)}` card is NOT split — the TABLE form has +/// its own conversion (replace_table_fn in ps mode; inp_compat's otherwise). +fn eg_value_rewrite(lines: &[LogicalLine]) -> Vec { + let mut out: Vec = Vec::with_capacity(lines.len()); + let mut in_ctl = false; + for l in lines { + let k = kw(&l.text); + if k == ".control" { + in_ctl = true; + } + let first = l.text.trim_start().as_bytes().first().map(|b| b.to_ascii_lowercase()); + let candidate = !in_ctl && (first == Some(b'e') || first == Some(b'g')); + if k == ".endc" { + in_ctl = false; + } + if candidate { + if let Some((c1, c2)) = split_eg_value(&l.text) { + for text in [c1, c2] { + let mut nl = l.clone(); + nl.text = text; + out.push(nl); + } + continue; + } + if let Some(cards) = split_eg_table(&l.text) { + for text in cards { + let mut nl = l.clone(); + nl.text = text; + out.push(nl); + } + continue; + } + } + out.push(l.clone()); + } + out +} + +/// Split one `E/G n1 n2 TABLE {expr} = (x0,y0) (x1,y1) ..` card into ngspice +/// inp_compat's four-card XSPICE pwl form (same pre-expansion naming argument +/// as eg_value_rewrite): +/// +/// Exxx n1 n2 Exxx_int1 0 1 +/// bExxx Exxx_int2 0 v={expr} +/// aExxx %v(Exxx_int2) %v(Exxx_int1) xfer_Exxx +/// .model xfer_Exxx pwl(x_array=[..] y_array=[..] input_domain=0.1 fraction=TRUE) +/// +/// The 4-node `nc1 nc2 TABLE = (..)` variant is left alone (downstream +/// converts it as before). Returns None when the card is not of this form. +fn split_eg_table(line: &str) -> Option> { + let t = line.trim_start(); + let first = t.as_bytes().first()?.to_ascii_lowercase(); + if first != b'e' && first != b'g' { + return None; + } + let (name, rest) = split_first(t); + let (n1, rest) = split_first(rest); + let (n2, rest) = split_first(rest); + let rest = rest.trim_start(); + if n2.is_empty() || !rest.to_ascii_lowercase().starts_with("table") { + return None; + } + let after = rest["table".len()..].trim_start(); + let after = after.strip_prefix('=').unwrap_or(after).trim_start(); + // expression in braces, then `= (x,y) (x,y) ..` pairs + if !after.starts_with('{') { + return None; + } + let close = matching_brace(after.as_bytes(), 0)?; + let expr = after[1..close].trim(); + // pairs: strip separators, tokens then alternate x,y (as inp_compat does) + let pairs: Vec<&str> = after[close + 1..] + .split(|c: char| c.is_whitespace() || matches!(c, '(' | ')' | ',' | '=')) + .filter(|s| !s.is_empty()) + .collect(); + if pairs.len() < 4 || pairs.len() % 2 != 0 { + return None; + } + let xs: Vec<&str> = pairs.iter().step_by(2).copied().collect(); + let ys: Vec<&str> = pairs.iter().skip(1).step_by(2).copied().collect(); + Some(vec![ + format!("{name} {n1} {n2} {name}_int1 0 1"), + format!("b{name} {name}_int2 0 v={{{expr}}}"), + format!("a{name} %v({name}_int2) %v({name}_int1) xfer_{name}"), + // `limit=TRUE` clamps the pwl beyond the table endpoints — inpcom.c's + // ACTUAL tprintf (6532) emits it even though its comment blocks don't. + // Without it a DC sweep past the table range extrapolates unbounded + // and a marginal deck loses the operating point. + format!( + ".model xfer_{name} pwl(x_array=[{}] y_array=[{}] input_domain=0.1 fraction=TRUE limit=TRUE)", + xs.join(" "), + ys.join(" ") + ), + ]) +} + +/// Byte offset of the `}` matching the `{` at `open`. +fn matching_brace(b: &[u8], open: usize) -> Option { + let mut depth = 0; + for (i, &c) in b.iter().enumerate().skip(open) { + match c { + b'{' => depth += 1, + b'}' => { + depth -= 1; + if depth == 0 { + return Some(i); + } + } + _ => {} + } + } + None +} + +/// Split one `E/G ... VALUE|VOL|CUR = {expr}` card (see eg_value_rewrite). +/// Returns None when the card is not of that form. +fn split_eg_value(line: &str) -> Option<(String, String)> { + let t = line.trim_start(); + let first = t.as_bytes().first()?.to_ascii_lowercase(); + if first != b'e' && first != b'g' { + return None; + } + let (name, rest) = split_first(t); + let (n1, rest) = split_first(rest); + let (n2, rest) = split_first(rest); + let rest = rest.trim_start(); + if n2.is_empty() || rest.is_empty() { + return None; + } + // keyword directly after the nodes, '=' attached or spaced (ngspice + // matches the token in front of the line's first '=') + let low = rest.to_ascii_lowercase(); + let kws: &[&str] = if first == b'e' { &["value", "vol"] } else { &["value", "cur"] }; + if !kws + .iter() + .any(|w| low.starts_with(w) && low[w.len()..].trim_start().starts_with('=')) + { + return None; + } + // equation: from the first '{' to end of line (as inp_compat takes it) + let open = rest.find('{')?; + let mut equation = rest[open..].trim(); + // TABLE form has its own conversion path — leave it alone + if equation.to_ascii_lowercase().contains("table(") { + return None; + } + // G only: a trailing multiplier is moved onto the VCCS gain + let mut gain = "1".to_string(); + if first == b'g' { + if let Some(mp) = equation.to_ascii_lowercase().rfind(" m=") { + gain = equation[mp + 3..].trim().to_string(); + equation = equation[..mp].trim_end(); + } + } + Some(( + format!("{name} {n1} {n2} {name}_int1 0 {gain}"), + format!("b{name} {name}_int1 0 v = {equation}"), + )) +} + +/// Pre-expansion PSpice line rewrites: AKO model inheritance and the d/q +/// positional area factor. `.control` blocks pass through untouched. +fn pspice_line_rewrites(lines: &[LogicalLine]) -> Vec { + let mut out = if lines.iter().any(|l| l.text.to_ascii_lowercase().contains("ako:")) { + ako_rewrite(lines) + } else { + lines.to_vec() + }; + let mut in_control = false; + for l in &mut out { + let k = kw(&l.text); + if k == ".control" { + in_control = true; + } else if k == ".endc" { + in_control = false; + } else if !in_control { + if let Some(nl) = pspice_dq_area(&l.text) { + l.text = nl; + } + } + } + out +} + +/// PSpice diodes/BJTs take a bare positional AREA factor after the model name +/// (`d1 n1 n2 dmod 7`, `q2 n1 n2 n3 [n4] bjtmod 1.35`); ngspice's device +/// parser instead mistakes the number for the model name. Convert it to the +/// named form `area=` and strip the `[..]` substrate brackets, mirroring +/// inpcompat.c. Returns None when the card needs no change. +fn pspice_dq_area(line: &str) -> Option { + let t = line.trim_start(); + let first = t.as_bytes().first()?.to_ascii_lowercase(); + if first != b'd' && first != b'q' { + return None; + } + let mut toks: Vec = t.split_whitespace().map(str::to_string).collect(); + // name + nodes; a `[sub]` group may itself contain spaces ("[ 100 ]"). + let mut i = 1 + if first == b'd' { 2 } else { 3 }; + if first == b'q' && i < toks.len() { + if toks[i].starts_with('[') { + // substrate node in brackets: strip them (ngspice blanks the chars) + while i < toks.len() && !toks[i].ends_with(']') { + toks[i] = toks[i].trim_start_matches('[').to_string(); + i += 1; + } + if i < toks.len() { + toks[i] = toks[i] + .trim_start_matches('[') + .trim_end_matches(']') + .to_string(); + i += 1; + } + // emptied bracket tokens ("[ sub ]") just join as extra spaces + } else if !toks[i].is_empty() && toks[i].bytes().all(|c| c.is_ascii_digit()) { + i += 1; // an all-digit token is the (numeric) substrate node + } + } + i += 1; // model name + if i >= toks.len() { + return None; + } + let a = &toks[i]; + if a.parse::().map(|v| v > 0.0).unwrap_or(false) || a.starts_with('{') { + toks[i] = format!("area={a}"); + Some(toks.join(" ")) + } else { + None + } +} + +/// Split a plain `.model ` card. `body` is everything after +/// the type token (usually `()`), trimmed. Returns None on malformed. +fn split_model_card(line: &str) -> Option<(String, String, String)> { + let t = line.trim_start(); + if t.len() < ".model".len() || !t[..".model".len()].eq_ignore_ascii_case(".model") { + return None; + } + let after = &t[".model".len()..]; + let (name, rest) = split_first(after); + let rest = rest.trim_start(); + let type_end = rest + .find(|c: char| c == '(' || c.is_whitespace()) + .unwrap_or(rest.len()); + if name.is_empty() || type_end == 0 { + return None; + } + Some(( + name.to_string(), + rest[..type_end].to_string(), + rest[type_end..].trim().to_string(), + )) +} + +/// Strip one outer `(...)` layer, if present. +fn strip_outer_parens(s: &str) -> &str { + let t = s.trim(); + t.strip_prefix('(') + .and_then(|u| u.strip_suffix(')')) + .map(str::trim) + .unwrap_or(t) +} + +/// Resolve PSpice `.MODEL AKO: ()` inheritance, +/// mirroring inpcompat.c ako_model/find_model: the base model is looked up +/// among models of the SAME enclosing subckt first, then at top level; the +/// resolved card is `.model ( )` — a +/// duplicated parameter's LAST occurrence (the override) wins downstream. +/// Cards processed in deck order, so an AKO of an earlier AKO resolves too. +/// A card whose base cannot be found (or whose type disagrees) is left +/// untouched — ngspice then reports it loudly. +fn ako_rewrite(lines: &[LogicalLine]) -> Vec { + // innermost enclosing .subckt line index per line (usize::MAX = top level) + let mut scopes = Vec::with_capacity(lines.len()); + let mut stack: Vec = Vec::new(); + for (i, l) in lines.iter().enumerate() { + let k = kw(&l.text); + if k == ".subckt" { + stack.push(i); + } + scopes.push(stack.last().copied().unwrap_or(usize::MAX)); + if k == ".ends" || k == ".eom" { + stack.pop(); + } + } + // plain models: (name, scope) -> (type, body-inside-parens) + let mut models: HashMap<(String, usize), (String, String)> = HashMap::new(); + for (i, l) in lines.iter().enumerate() { + if kw(&l.text) != ".model" || l.text.to_ascii_lowercase().contains("ako:") { + continue; + } + if let Some((name, ty, body)) = split_model_card(&l.text) { + models + .entry((key(&name), scopes[i])) + .or_insert((ty, strip_outer_parens(&body).to_string())); + } + } + let mut out = lines.to_vec(); + for (i, l) in lines.iter().enumerate() { + if kw(&l.text) != ".model" { + continue; + } + // `.MODEL AKO: ()` + let after = match l.text.trim_start().get(".model".len()..) { + Some(a) => a, + None => continue, + }; + let (newname, rest) = split_first(after); + let (akotok, over_rest) = split_first(rest); + if !akotok.to_ascii_lowercase().starts_with("ako:") { + continue; + } + let base = key(&akotok[4..]); + let over_rest = over_rest.trim_start(); + let type_end = over_rest + .find(|c: char| c == '(' || c.is_whitespace()) + .unwrap_or(over_rest.len()); + let (ty, overrides) = (&over_rest[..type_end], &over_rest[type_end..]); + let found = models + .get(&(base.clone(), scopes[i])) + .or_else(|| models.get(&(base.clone(), usize::MAX))) + .cloned(); + let Some((base_ty, base_body)) = found else { continue }; + if !base_ty.eq_ignore_ascii_case(ty) { + continue; // type disagreement: leave for ngspice to report + } + let merged = format!( + ".model {newname} {ty} ({} {})", + base_body, + strip_outer_parens(overrides) + ); + out[i].text = merged.clone(); + // resolved AKO models can themselves serve as later bases + if let Some((name, ty2, body)) = split_model_card(&merged) { + models + .entry((key(&name), scopes[i])) + .or_insert((ty2, strip_outer_parens(&body).to_string())); + } + } + out +} + +/// PSpice card-level rewrites applied to the flat deck in [`Compat::Pspice`]. +/// +/// ngspice's `pspice_compat` (inpcompat.c) runs these per `.include`d file; we +/// inline every include, so we replicate the ones the corpus needs here. Kept as +/// a card-list pass, exactly as ngspice does it, so the output matches. +fn pspice_rewrites(cards: Vec) -> Vec { + let cards = replace_table_fn(cards); + let cards = replace_vswitch(cards); + rename_pspice_model_temps(cards) +} + +/// PSpice thermal .model parameters -> ngspice names, mirroring inpcompat.c: +/// `T_ABS`->`temp`, `T_REL_GLOBAL`->`dtemp`, `T_MEASURED`->`tnom`. Left alone, +/// ngspice warns "unrecognized parameter - ignored" and e.g. a noiseless +/// resistor (`T_ABS=-273.15`) silently becomes a noisy one at circuit temp. +fn rename_pspice_model_temps(mut cards: Vec) -> Vec { + for card in &mut cards { + if kw(card) != ".model" { + continue; + } + let low = card.to_ascii_lowercase(); + if !(low.contains("t_abs") || low.contains("t_rel_global") || low.contains("t_measured")) + { + continue; + } + for (from, to) in + [("t_abs", "temp"), ("t_rel_global", "dtemp"), ("t_measured", "tnom")] + { + *card = replace_word_ci(card, from, to); + } + } + cards +} + +/// Replace whole-word, case-insensitive occurrences of `from` (an identifier) +/// with `to`. +fn replace_word_ci(s: &str, from: &str, to: &str) -> String { + let low = s.to_ascii_lowercase(); + let b = low.as_bytes(); + let is_ident = |c: u8| c.is_ascii_alphanumeric() || c == b'_'; + let mut out = String::with_capacity(s.len()); + let mut i = 0; + while let Some(pos) = low[i..].find(from) { + let start = i + pos; + let end = start + from.len(); + let bounded = (start == 0 || !is_ident(b[start - 1])) + && (end == b.len() || !is_ident(b[end])); + out.push_str(&s[i..start]); + out.push_str(if bounded { to } else { &s[start..end] }); + i = end; + } + out.push_str(&s[i..]); + out +} + +/// PSpice `VSWITCH` voltage switches -> ngspice equivalents, mirroring +/// inpcompat.c. Two forms: +/// +/// * `.model M VSWITCH(vt=.. vh=..)` (short-transition) -> the classical +/// voltage-controlled switch `.model M sw(..)`; the `S` instance is unchanged. +/// * `.model M VSWITCH(von=.. voff=..)` -> the `pswitch` code model +/// `.model aM pswitch(log=TRUE ..)` with `von/voff/ron/roff` remapped to +/// `cntl_on/cntl_off/r_on/r_off`; every `S` instance calling it becomes an +/// `A` device `aS.. %gd(nc+ nc-) %gd(n+ n-) aM`. +/// +/// Missing parameters get PSpice's defaults, exactly as inpcompat.c fills them. +fn replace_vswitch(cards: Vec) -> Vec { + let mut pswitch: HashSet = HashSet::new(); + let mut out: Vec = Vec::with_capacity(cards.len()); + for card in cards { + if kw(&card) == ".model" && card.to_ascii_lowercase().contains("vswitch") { + if let Some((newcard, needs_inst)) = convert_vswitch_model(&card) { + if let Some(name) = needs_inst { + pswitch.insert(key(&name)); + } + out.push(newcard); + continue; + } + } + out.push(card); + } + // No pswitch models -> no instance rewrites needed (sw-form S stays as-is). + if pswitch.is_empty() { + return out; + } + for card in &mut out { + if let Some(nc) = rewrite_switch_instance(card, &pswitch) { + *card = nc; + } + } + out +} + +/// Locate an `Assign` by case-insensitive name. +fn find_assign<'a>(assigns: &'a [Assign], name: &str) -> Option<&'a Assign> { + assigns.iter().find(|a| a.name.eq_ignore_ascii_case(name)) +} + +/// Convert one `.model .. VSWITCH(..)` card. Returns the rewritten card and, for +/// the `pswitch` (von/voff) form, the ORIGINAL model name whose `S` instances must +/// then be rewritten to `A` devices. Returns None if the card is not a VSWITCH +/// model we understand (left untouched by the caller). +fn convert_vswitch_model(card: &str) -> Option<(String, Option)> { + let t = card.trim_start(); + let after = t[".model".len()..].trim_start(); + let (name, after) = split_first(after); + let after = after.trim_start(); + // model type, up to '(' or whitespace + let type_end = after + .find(|c: char| c == '(' || c.is_whitespace()) + .unwrap_or(after.len()); + if !after[..type_end].eq_ignore_ascii_case("vswitch") { + return None; + } + let params_raw = after[type_end..].trim(); + let params = params_raw + .strip_prefix('(') + .map(|s| s.strip_suffix(')').unwrap_or(s)) + .unwrap_or(params_raw) + .trim(); + let assigns = parse_assignments(params); + + // vt/vh -> sw (model only); von/voff -> pswitch (model + instance). Prefer the + // vt/vh test first, matching inpcompat.c's order. + if find_assign(&assigns, "vt").is_some() || find_assign(&assigns, "vh").is_some() { + // ron, roff, vt, vh -- native names unchanged, fill defaults. + let body = build_switch_params( + &assigns, + &[ + ("ron", "ron", "1.0"), + ("roff", "roff", "1.0e12"), + ("vt", "vt", "0"), + ("vh", "vh", "0"), + ], + "", + ); + Some((format!(".model {name} sw ({body})"), None)) + } else if find_assign(&assigns, "von").is_some() || find_assign(&assigns, "voff").is_some() { + // ron->r_on, roff->r_off, von->cntl_on, voff->cntl_off; add log=TRUE. + let body = build_switch_params( + &assigns, + &[ + ("ron", "r_on", "1.0"), + ("roff", "r_off", "1.0e6"), + ("von", "cntl_on", "1"), + ("voff", "cntl_off", "0"), + ], + "log=TRUE", + ); + Some((format!(".model a{name} pswitch({body})"), Some(name.to_string()))) + } else { + None + } +} + +/// Rebuild a switch model's parameter body: for each (pspice, native, default) +/// mapping emit `native=value` using the found value or the default, then append +/// any leftover params (unmapped) verbatim, then `extra` (e.g. `log=TRUE`). +fn build_switch_params(assigns: &[Assign], maps: &[(&str, &str, &str)], extra: &str) -> String { + let mut parts: Vec = Vec::new(); + for (ps, native, default) in maps { + let val = find_assign(assigns, ps).map(|a| a.rhs.as_str()).unwrap_or(default); + parts.push(format!("{native}={val}")); + } + // carry through any params not covered by the mapping (e.g. td is dropped by + // ngspice today, but anything else the model set should survive) + for a in assigns { + if !maps.iter().any(|(ps, _, _)| a.name.eq_ignore_ascii_case(ps)) { + parts.push(format!("{}={}", a.name, a.rhs)); + } + } + if !extra.is_empty() { + parts.push(extra.to_string()); + } + parts.join(" ") +} + +/// If `card` is an `S` instance calling a `pswitch`-converted model, rewrite it to +/// the `A`-device form `a %gd(nc+ nc-) %gd(n+ n-) a`. Returns None if +/// the card is not such an instance. +fn rewrite_switch_instance(card: &str, pswitch: &HashSet) -> Option { + let t = card.trim_start(); + let first = t.as_bytes().first().copied().unwrap_or(0); + if first != b's' && first != b'S' { + return None; + } + // S instance: inst n+ n- nc+ nc- model [on|off]. Need at least 6 tokens. + let toks: Vec<&str> = t.split_whitespace().collect(); + if toks.len() < 6 { + return None; + } + let model = toks[5]; + if !pswitch.contains(&key(model)) { + return None; + } + Some(format!( + "a{inst} %gd({ncp} {ncn}) %gd({np} {nn}) a{model}", + inst = toks[0], + ncp = toks[3], + ncn = toks[4], + np = toks[1], + nn = toks[2], + model = model, + )) +} + +/// Index of the `)` matching the `(` at `open`, honoring nesting (`v(a,b)` inside +/// the table args has its own parens). None if unbalanced. +fn matching_paren(b: &[u8], open: usize) -> Option { + let mut depth = 0i32; + let mut i = open; + while i < b.len() { + match b[i] { + b'(' => depth += 1, + b')' => { + depth -= 1; + if depth == 0 { + return Some(i); + } + } + _ => {} + } + i += 1; + } + None +} + +/// PSpice `E/G ... {.. TABLE(ctrl, x1,y1, ..) ..}` -> a helper node driven by a +/// B-source using ngspice's native `pwl()`, mirroring inpcompat.c::replace_table: +/// +/// e1 a b value={.. v(table_new_0) ..} +/// btable_new_0 table_new_0 0 v=pwl(ctrl, x1,y1, ..) +/// +/// The `TABLE()` *function* (paren immediately after) is the PSpice interpolation +/// form; ngspice's behavioral parser has no such function, but `pwl()` takes the +/// identical `(ctrl, x1,y1, ..)` argument list. The native `E .. TABLE {ctrl}=(..)` +/// keyword form (handled elsewhere) is untouched -- it has no `table(`. +fn replace_table_fn(cards: Vec) -> Vec { + let mut out = Vec::with_capacity(cards.len()); + let mut n = 0usize; + for card in cards { + let first = card + .trim_start() + .as_bytes() + .first() + .copied() + .unwrap_or(0) + .to_ascii_lowercase(); + let low = card.to_ascii_lowercase(); + // Only e/g behavioral sources carrying a table() function. + if !(first == b'e' || first == b'g') || !low.contains("table(") { + out.push(card); + continue; + } + let mut line = card; + let mut blines: Vec = Vec::new(); + loop { + let ll = line.to_ascii_lowercase(); + let Some(pos) = ll.find("table(") else { break }; + let open = pos + 5; // the '(' after "table" + let Some(close) = matching_paren(line.as_bytes(), open) else { + break; + }; + let begline = &line[..pos]; + let args = &line[open..=close]; // "(ctrl, x1,y1, ..)" incl. parens + let rest = &line[close + 1..]; + blines.push(format!("btable_new_{n} table_new_{n} 0 v=pwl{args}")); + line = format!("{begline}v(table_new_{n}){rest}"); + n += 1; + } + out.push(line); + out.extend(blines); + } + out +} + +/// Prune `.model` cards never referenced by any device, so a deck that pulls in a +/// 5000-model PDK library but instantiates ten of them does not carry the other +/// 4990 into ngspice, where each would be set up and waste time and memory. A +/// model kept only because ngparse could not prove it unused is a safe, cheap +/// defaults. +/// Dangling-passive topology reduction (opt-in via [`Config::topo_reduce`]). +/// +/// Removes a two-terminal R/C when (a) its NAME is referenced by no other card +/// (`.save @r1[i]`, `i(r1)`, an F/H controlling source, `.probe r1` all +/// protect it) and (b) one of its terminals — other than ground, `null`, or a +/// `.global` node — is referenced by no other card in the whole FLAT deck. +/// References are counted conservatively: every whitespace token AND every +/// identifier run inside expression text (`v(x)`, `.ic v(x)=..`, `.control` +/// script lines) protects a name, so over-protection is possible but a +/// wrong removal is not. Repeats to a fixpoint, so a dead-end chain +/// (`in -- R -- x -- C -- y`, nothing else on x or y) collapses entirely. +/// +/// Runs on the flat deck BEFORE unused-model pruning, so a model used only by +/// removed devices is pruned along with them. ngspice tried this during +/// circuit setup (commit aac195, since reverted) and hit `.probe`/AC/XSPICE +/// ordering problems; done at parse time the simulator only ever sees the +/// surviving devices and the matrix shrinks. Never silent: removals are +/// summarized in a `* ngparse:` comment card. +fn reduce_dangling_passives(cards: Vec, globals: &HashSet) -> Vec { + // Every way a card can reference a name: normalized whitespace tokens plus + // maximal runs of node-name characters (so `v(a,b)` yields `a` and `b`). + fn refs(c: &str) -> HashSet { + let mut s = HashSet::new(); + for tok in c.split_whitespace() { + let t = key(tok.trim_matches(|ch: char| { + matches!(ch, '{' | '}' | '\'' | '"' | '(' | ')' | ',' | '=') + })); + if !t.is_empty() { + s.insert(t); + } + } + let mut run = String::new(); + for ch in c.chars() { + if ch.is_ascii_alphanumeric() || matches!(ch, '_' | '.' | '+' | '-') { + run.push(ch.to_ascii_lowercase()); + } else if !run.is_empty() { + s.insert(std::mem::take(&mut run)); + } + } + if !run.is_empty() { + s.insert(run); + } + s + } + let toksets: Vec> = cards.iter().map(|c| refs(c)).collect(); + // name -> number of CARDS referencing it (presence, not multiplicity) + let mut count: HashMap = HashMap::new(); + for ts in &toksets { + for t in ts { + *count.entry(t.clone()).or_insert(0) += 1; + } + } + // `.control` script lines are never device candidates (`reset`, `run`, ..) + let mut in_ctl = vec![false; cards.len()]; + let mut ctl = false; + for (i, c) in cards.iter().enumerate() { + let k = kw(c); + if k == ".control" { + ctl = true; + } + in_ctl[i] = ctl; + if k == ".endc" { + ctl = false; + } + } + let mut removed = vec![false; cards.len()]; + let mut removed_names: Vec = Vec::new(); + loop { + let mut changed = false; + for i in 0..cards.len() { + if removed[i] || in_ctl[i] { + continue; + } + let c = &cards[i]; + let first = c.as_bytes().first().map(|b| b.to_ascii_lowercase()); + if first != Some(b'r') && first != Some(b'c') { + continue; + } + // behavioral / still-symbolic values: leave alone + if c.contains('{') || c.contains('\'') { + continue; + } + let toks: Vec<&str> = c.split_whitespace().collect(); + if toks.len() < 4 { + continue; + } + if count.get(&key(toks[0])).copied().unwrap_or(0) > 1 { + continue; // the device itself is referenced somewhere + } + let dangling = toks[1..3].iter().any(|n| { + let nk = key(n); + nk != "0" + && nk != "null" + && !globals.contains(&nk) + && count.get(&nk).copied().unwrap_or(0) <= 1 + }); + if !dangling { + continue; + } + removed[i] = true; + changed = true; + removed_names.push(toks[0].to_string()); + for t in &toksets[i] { + if let Some(n) = count.get_mut(t) { + *n -= 1; + } + } + } + if !changed { + break; + } + } + if removed_names.is_empty() { + return cards; + } + let list = removed_names.iter().take(10).cloned().collect::>().join(" "); + let more = if removed_names.len() > 10 { + format!(" (+{} more)", removed_names.len() - 10) + } else { + String::new() + }; + let note = format!( + "* ngparse: topo-reduce removed {} dangling passive(s): {list}{more}", + removed_names.len() + ); + let mut out: Vec = Vec::with_capacity(cards.len()); + for (i, c) in cards.into_iter().enumerate() { + if !removed[i] { + out.push(c); + } + if i == 0 { + // after the first card, so a deck-leading title line stays first + out.push(note.clone()); + } + } + out +} + +fn prune_unused_models(cards: Vec) -> (Vec, HashSet) { + // model name -> defined + let mut defined: HashSet = HashSet::new(); + // base name -> its binned members (nfet -> {nfet.0, nfet.1, ...}) + let mut bins: HashMap> = HashMap::new(); + for c in &cards { + if kw(c) == ".model" { + if let Some(n) = c.split_whitespace().nth(1) { + let k = key(n); + if let Some(d) = k.rfind('.') { + if d > 0 && k[d + 1..].chars().all(|ch| ch.is_ascii_digit()) { + bins.entry(k[..d].to_string()).or_default().push(k.clone()); + } + } + defined.insert(k); + } + } + } + if defined.is_empty() { + return (cards, HashSet::new()); + } + + let mut used: HashSet = HashSet::new(); + for c in &cards { + if kw(c) == ".model" { + continue; // a model card doesn't "use" a model + } + for tok in c.split_whitespace() { + // strip delimiters an expression/value might carry + let t = key(tok.trim_matches(|ch: char| { + matches!(ch, '{' | '}' | '\'' | '"' | '(' | ')' | ',' | '=') + })); + if t.is_empty() { + continue; + } + if defined.contains(&t) { + used.insert(t.clone()); + } + // a reference to the BASE of a binned set keeps every bin + if let Some(members) = bins.get(&t) { + for m in members { + used.insert(m.clone()); + } + } + } + } + + let pruned: HashSet = defined.difference(&used).cloned().collect(); + if pruned.is_empty() { + return (cards, pruned); + } + let kept = cards + .into_iter() + .filter(|c| { + if kw(c) != ".model" { + return true; + } + match c.split_whitespace().nth(1) { + Some(n) => !pruned.contains(&key(n)), + None => true, + } + }) + .collect(); + (kept, pruned) +} + +/// Parse a `.subckt` header line into (name, ports, defaults). +/// Remove PSpice's `PARAMS:` keyword from a `.subckt` header or `X` instance +/// line. ngspice strips it in EVERY dialect (inpcom.c inp_fix_params: +/// `.subckt name 1 2 3 params: l=1 w=2` -> `.subckt name 1 2 3 l=1 w=2`); +/// left in place it reads as a positional token, so an X line resolves the +/// subckt name as literally `params:` ("unknown subckt") and a header gains a +/// phantom port. Case-insensitive, outside quotes/braces only. +fn strip_params_kw(line: &str) -> String { + let low = line.to_ascii_lowercase(); + let b = line.as_bytes(); + let mut out: Vec = Vec::with_capacity(b.len()); + let (mut depth, mut q, mut i) = (0i32, 0u8, 0usize); + while i < b.len() { + let c = b[i]; + if q != 0 { + if c == q { + q = 0; + } + } else { + match c { + b'\'' | b'"' => q = c, + b'(' | b'{' => depth += 1, + b')' | b'}' => depth -= 1, + _ => {} + } + if depth == 0 + && q == 0 + && low[i..].starts_with("params:") + && (i == 0 || !(b[i - 1].is_ascii_alphanumeric() || b[i - 1] == b'_')) + { + i += "params:".len(); + continue; + } + } + out.push(c); + i += 1; + } + String::from_utf8(out).unwrap_or_else(|_| line.to_string()) +} + +fn parse_subckt_header(line: &str) -> (String, Vec, Vec<(String, String)>) { + let line = if line.to_ascii_lowercase().contains("params:") { + strip_params_kw(line) + } else { + line.to_string() + }; + let after = &line.trim_start()[".subckt".len()..]; + let (hdr, assign_str) = split_positional(after); + let name = hdr.first().cloned().unwrap_or_default(); + let ports = hdr[1.min(hdr.len())..].to_vec(); + let defaults = parse_assignments(assign_str) + .into_iter() + .map(|a| (a.name, a.rhs)) + .collect(); + (name, ports, defaults) +} + +/// Extract nested `.subckt ... .ends` blocks out of `body`, registering each in +/// `defs` under a scoped path `{path}/{name}` (recursively), and return the body +/// with those blocks REMOVED. +/// +/// ngspice scopes a subckt definition to its enclosing subckt, so two different +/// parents may define the same name with different contents (see +/// tests/regression/lib-processing/scope-1.cir, where `sub1` and `sub2` each +/// define their own `sub`). Registering them all in one flat namespace would let +/// the last definition win — silently wrong. Leaving them in the body would also +/// emit stray `.subckt`/`.ends` cards and unbalance the deck. +fn extract_nested( + body: Vec, + path: &str, + defs: &mut HashMap, +) -> Vec { + let mut out = Vec::new(); + let mut i = 0; + while i < body.len() { + if kw(&body[i]) == ".subckt" { + let (name, ports, defaults) = parse_subckt_header(&body[i]); + let mut inner = Vec::new(); + let mut depth = 1; + i += 1; + while i < body.len() && depth > 0 { + let k = kw(&body[i]); + if k == ".subckt" { + depth += 1; + } else if k == ".ends" || k == ".eom" { + depth -= 1; + if depth == 0 { + i += 1; + break; + } + } + inner.push(body[i].clone()); + i += 1; + } + let child_path = if path.is_empty() { + key(&name) + } else { + format!("{path}/{}", key(&name)) + }; + let inner = extract_nested(inner, &child_path, defs); + defs.insert( + child_path, + SubcktDef { + ports, + defaults, + body: inner, + }, + ); + continue; + } + out.push(body[i].clone()); + i += 1; + } + out +} + +/// Find `scale=` on any top-level `.option`/`.options` card. ngspice's +/// device-geometry scale factor; defaults to 1 when absent. +fn scale_option(cards: &[String]) -> Option { + for line in cards { + let k = kw(line); + if k == ".option" || k == ".options" { + let (_kw, rest) = split_first(line.trim_start()); + for a in parse_assignments(rest) { + if key(&a.name) == "scale" { + return Some(a.rhs); + } + } + } + } + None +} + +/// Select the bin for a device geometry, replicating ngspice `subckt.c`: +/// `csl = scale*l`, `csw = scale*w/nf`, match `csl>=lmin && csl=wmin && csw Option<&str> { + let csl = scale * l; + let csw = if nf != 0.0 { scale * w / nf } else { scale * w }; + bins.iter() + .find(|b| csl >= b.lmin && csl < b.lmax && csw >= b.wmin && csw < b.wmax) + .map(|b| b.name.as_str()) +} + +/// For each binned model set, select the bin for the instance geometry and +/// return the names of all the OTHER (non-selected) bins, so they can be pruned. +/// A set with no matching bin is left intact (ngspice bins it after any shrink). +fn bins_to_drop( + bins: &HashMap>, + l: Option, + w: Option, + nf: f64, + scale: f64, +) -> HashSet { + let mut drop = HashSet::new(); + let (Some(l), Some(w)) = (l, w) else { + return drop; + }; + for set in bins.values() { + if let Some(sel) = select_bin(set, l, w, nf, scale) { + let sel = sel.to_string(); + for b in set { + if b.name != sel { + drop.insert(b.name.clone()); + } + } + } + } + drop +} + +/// Resolve `.if/.elseif/.else/.endif` blocks against `scope`, returning only the +/// active (kept) lines. Conditions that fail to evaluate are treated as false. +fn resolve_conditionals(body: &[String], scope: &Rc) -> Vec { + struct Frame { + active: bool, + taken: bool, + parent: bool, + } + let mut stack: Vec = Vec::new(); + let mut out = Vec::new(); + let cur = |st: &[Frame]| st.last().map(|f| f.active).unwrap_or(true); + + for line in body { + let k = kw(line); + // `.if(sel == 1)` — the paren may be attached to the keyword, so the + // first whitespace token is `.if(sel`; match on the prefix. + let k = if k.starts_with(".if(") { + ".if".to_string() + } else if k.starts_with(".elseif(") { + ".elseif".to_string() + } else { + k + }; + if k == ".if" { + let parent = cur(&stack); + let cond = parent && eval_cond(line, scope); + stack.push(Frame { active: cond, taken: cond, parent }); + } else if k == ".elseif" { + if let Some(f) = stack.last_mut() { + if f.taken { + f.active = false; + } else { + let c = f.parent && eval_cond(line, scope); + f.active = c; + f.taken = c; + } + } + } else if k == ".else" { + if let Some(f) = stack.last_mut() { + f.active = f.parent && !f.taken; + f.taken = true; + } + } else if k == ".endif" { + stack.pop(); + } else if cur(&stack) { + out.push(line.clone()); + } + } + out +} + +/// Evaluate the `(...)` condition of a `.if`/`.elseif` line. Missing/failed -> false. +fn eval_cond(line: &str, scope: &Rc) -> bool { + let Some(open) = line.find('(') else { return false }; + let Some(close) = line.rfind(')') else { return false }; + if close <= open { + return false; + } + let cond = normalize_eq(&line[open + 1..close]); + match parse(&cond).and_then(|e| eval(&e, scope)) { + Ok(v) => v != 0.0, + Err(_) => false, + } +} + +/// In a `.if`/`.elseif` condition a lone `=` means equality, not assignment: +/// `.elseif (select2 = 3)` is `select2 == 3`. ngspice accepts this because it +/// hands the condition to numparam, which treats `=` as `==`; a condition is a +/// boolean expression with no assignments, so every `=` that is not already part +/// of `==`/`!=`/`<=`/`>=` is an equality. Doubles those, leaving the compound +/// operators untouched. +fn normalize_eq(cond: &str) -> String { + let b = cond.as_bytes(); + let mut out = String::with_capacity(cond.len()); + let mut i = 0; + while i < b.len() { + if b[i] == b'=' { + let prev = i.checked_sub(1).map(|j| b[j]); + let next = b.get(i + 1).copied(); + // already `==`: copy both and skip past. + if next == Some(b'=') { + out.push_str("=="); + i += 2; + continue; + } + // tail of `!=`/`<=`/`>=`: leave as-is. + if matches!(prev, Some(b'!') | Some(b'<') | Some(b'>')) { + out.push('='); + i += 1; + continue; + } + // lone `=` used as equality -> `==`. + out.push_str("=="); + i += 1; + continue; + } + out.push(b[i] as char); + i += 1; + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A LogicalLine from literal text, for building small decks in tests. + fn ll(text: &str) -> LogicalLine { + LogicalLine { + text: text.to_string(), + file: std::sync::Arc::from("test"), + line_no: 1, + } + } + + /// Roles of a device card's positional tokens, exactly as `emit_device` computes them. + fn roles(line: &str) -> Vec { + let (inst, rest) = split_first(line); + let (positional, _) = split_positional(rest); + dev_roles(inst.as_bytes()[0], &positional) + } + + /// Tokens renamed as nodes. + fn nodes(line: &str) -> usize { + roles(line).iter().filter(|r| **r == Role::Node).count() + } + + /// Tokens renamed as controlling-device instance names. + fn ctrl(line: &str) -> usize { + roles(line).iter().filter(|r| **r == Role::Inst).count() + } + + /// Every expectation below was read off the reference ngspice's own + /// `listing expand` output for the same card inside a subckt. + /// `.option scale` multiplies the DRAWN l/w before the bin comparison + /// (subckt.c:907 `csl = scale * c->l`), defaulting to 1 when unset. + #[test] + fn option_scale_shifts_bin_selection() { + let bins = vec![ + BinDef { name: "n.0".into(), lmin: 0.0, lmax: 1e-7, wmin: 0.0, wmax: 1.0 }, + BinDef { name: "n.1".into(), lmin: 1e-7, lmax: 1e-6, wmin: 0.0, wmax: 1.0 }, + ]; + // scale=1: a drawn l of 1.2e-7 lands in the upper bin + assert_eq!(select_bin(&bins, 1.2e-7, 1e-6, 1.0, 1.0), Some("n.1")); + // scale=0.5: the same drawn l scales to 0.6e-7 -> the LOWER bin + assert_eq!(select_bin(&bins, 1.2e-7, 1e-6, 1.0, 0.5), Some("n.0")); + // upper bound is exclusive, lower inclusive + assert_eq!(select_bin(&bins, 1e-7, 1e-6, 1.0, 1.0), Some("n.1")); + } + + /// A subckt's own `scale` param is the HSPICE ELEMENT scale — a different + /// mechanism from `.option scale`, and it must NOT affect bin selection. + /// foundry_a's `pch_lvt_mac ... scale='scale_mos_lvt'` (0.9) would otherwise + /// silently rebin every device in the deck. + #[test] + fn subckt_scale_param_does_not_leak_into_binning() { + let lines = vec![ + ll("* t"), + ll(".param scale_mos=0.5"), + ll(".subckt m1 d g s scale='scale_mos'"), + ll("mn d g s s nch l=1u w=1u"), + ll(".ends"), + ll("x1 a b 0 m1"), + ]; + let se = SubcktExpander::new(&lines); + // no `.option scale` anywhere -> the global stays 1, despite the subckt + // declaring scale=0.5 + assert_eq!(se.option_scale, 1.0); + } + + /// A numeric node name inside `v()`/`i()` must be kept as the node, not + /// folded to a float: `v(1)` stays `v(1)`, never `v(1.000...e0)` (which is a + /// different, nonexistent node — ngspice matches nodes by string). + #[test] + fn numeric_node_in_v_is_not_folded() { + let lines = vec![ + ll("* t"), + ll("v1 1 0 dc 2.7"), + ll("b1 b1 0 v=ln(v(1))"), + ll("r1 b1 0 1k"), + ]; + let out = SubcktExpander::new(&lines).expand(); + let b = out.cards.iter().find(|c| c.starts_with("b1 ")).unwrap(); + assert!(b.contains("v(1)"), "node folded: {b}"); + assert!(!b.contains("v(1."), "node folded to float: {b}"); + } + + /// PSpice/HSPICE `if(cond,a,b)` is the ternary selector; ngspice's behavioral + /// parser has no `if()` (it only knows `ternary_fcn`, which is what ngspice's + /// own pspice-compat rewrites `if` to). We must emit the native name because + /// that compat pass does not reach ngparse's already-flat deck. + #[test] + fn if_becomes_ternary_fcn() { + let out = SubcktExpander::new(&[ + ll("* t"), + ll(".subckt s 1 2"), + ll(".param g=2"), + ll("g1 1 2 value={if(v(1,2)>0, g*v(1,2), 0)}"), + ll(".ends"), + ll("xs a b s"), + ]) + .expand(); + // the VALUE= expression now lives on the split-off B source + // (eg_value_rewrite): g.xs.g1 is the linear VCCS, b.xs.bg1 the equation + let g = out.cards.iter().find(|c| c.starts_with("b.xs.bg1")).unwrap(); + assert!(g.contains("ternary_fcn("), "if not rewritten: {g}"); + assert!(!g.contains("if("), "stray if( left: {g}"); + // a user function or variable literally named `if`-prefixed is untouched; + // only the exact 3-arg `if` selector is rewritten. + } + + /// PSpice behavioral functions (pwr/pwrs/stp/int) have no ngspice equivalent + /// except via pspice_compat's injected .func, which never reaches our flat + /// deck; in Pspice mode we emit the native form. Gated on the mode -- default + /// mode leaves them alone. + #[test] + fn pspice_functions_rewritten() { + let expand = |compat| { + let cfg = crate::config::Config::default().with_compat(compat); + SubcktExpander::with_config( + &[ + ll("* t"), + ll(".subckt s 1 2"), + ll("b1 1 2 v={pwr(v(1),2)+pwrs(v(1),3)+stp(v(1))+int(v(1))}"), + ll(".ends"), + ll("xs a b s"), + ], + cfg, + ) + .expand() + }; + let ps = expand(crate::config::Compat::Pspice); + let b = ps.cards.iter().find(|c| c.starts_with("b")).unwrap(); + assert!(b.contains("pow("), "pwr/pwrs not rewritten: {b}"); + assert!(b.contains("u(") && b.contains("floor("), "stp/int not rewritten: {b}"); + assert!(!b.contains("pwr"), "stray pwr left: {b}"); + // default mode is untouched -- the functions pass through verbatim + let df = expand(crate::config::Compat::Default); + let b = df.cards.iter().find(|c| c.starts_with("b")).unwrap(); + assert!(b.contains("pwr("), "default mode should not rewrite: {b}"); + } + + /// Opt-in dangling-passive reduction: removes two-terminal R/C on nodes + /// referenced nowhere else (the sourceforge thread's reproducer), cascades + /// down dead-end chains, and NEVER touches a device or node referenced + /// anywhere — `.save`, `i()`, `v()` in a B-source, `.control` text, + /// `.global`. Off by default (deck unchanged). + #[test] + fn topo_reduce_dangling_passives() { + let deck = [ + ll("* t"), + ll("r2 0 R2_2 10e3"), + ll("r1 R1_1 R1_2 1000"), + ll("v1 IN 0 SIN(0 1 1000 0 0 0) AC 1"), + ll("rload IN 0 1k"), + ll("rchain IN chx 1k"), + ll("cchain chx chy 1p"), + ll("rsaved IN saved_n 1k"), + ll("rcurr IN curr_n 1k"), + ll("bsrc bs 0 v='2*v(bref_n)'"), + ll("rbref IN bref_n 1k"), + ll(".save v(saved_n)"), + ll(".probe i(rcurr)"), + ll(".ac dec 20 10 100e3"), + ]; + // default: OFF, nothing removed + let off = SubcktExpander::new(&deck).expand(); + assert!(off.cards.iter().any(|c| c.starts_with("r1 ")), "default must not reduce"); + // on: r1/r2 and the dead-end chain go; everything referenced stays + let cfg = crate::config::Config::default().with_topo_reduce(true); + let on = SubcktExpander::with_config(&deck, cfg).expand(); + let has = |p: &str| on.cards.iter().any(|c| c.starts_with(p)); + assert!(!has("r1 ") && !has("r2 "), "dangling r1/r2 kept: {:?}", on.cards); + assert!(!has("rchain") && !has("cchain"), "dead-end chain kept: {:?}", on.cards); + assert!(has("rload") && has("v1"), "connected devices removed"); + assert!(has("rsaved"), ".save-referenced node not protected"); + assert!(has("rcurr"), ".probe i()-referenced device not protected"); + assert!(has("rbref"), "B-source v()-referenced node not protected"); + assert!( + on.cards.iter().any(|c| c.starts_with("* ngparse: topo-reduce removed 4 ")), + "removal note missing/wrong: {:?}", + on.cards + ); + } + + /// `.if(cond)` with the paren attached to the keyword resolves like + /// `.if (cond)`. Unhandled, EVERY branch's contents leaked into the deck + /// ("device already exists" on same-named instances per branch). + #[test] + fn if_with_attached_paren() { + let out = SubcktExpander::new(&[ + ll("* t"), + ll(".param sel = 0"), + ll(".if(sel == 1)"), + ll("r1 a 0 111"), + ll(".elseif(sel == 2)"), + ll("r1 a 0 222"), + ll(".else"), + ll("r1 a 0 333"), + ll(".endif"), + ]) + .expand(); + let rs: Vec<&String> = out.cards.iter().filter(|c| c.starts_with("r1")).collect(); + assert_eq!(rs.len(), 1, "exactly one branch must survive: {rs:?}"); + assert!(rs[0].contains("3.33"), "else branch expected: {}", rs[0]); + } + + /// PSpice `AKO:` model inheritance (ps mode): base params inherited, the + /// override appended so its value wins; scoped lookup (same subckt first). + /// And the d/q positional area factor becomes `area=`. + #[test] + fn pspice_ako_and_area() { + let cfg = crate::config::Config::default().with_compat(crate::config::Compat::Pspice); + let out = SubcktExpander::with_config( + &[ + ll("* t"), + ll(".subckt amp 1 2 3"), + ll(".MODEL QP350 PNP(IS=1.4E-15 BF=70 RB=350)"), + ll(".MODEL QP AKO:QP350 PNP(BF=150 VA=100)"), + ll("Q1 1 2 3 QP 2"), + ll(".ends"), + ll("x1 a b c amp"), + ll("d1 a b dm 7"), + ll(".model dm d(is=1e-14)"), + ], + cfg, + ) + .expand(); + let m = out + .cards + .iter() + .find(|c| c.to_ascii_lowercase().starts_with(".model x1:qp ")) + .unwrap_or_else(|| panic!("AKO model missing: {:?}", out.cards)); + assert!(m.contains("PNP") || m.contains("pnp"), "type lost: {m}"); + assert!(m.to_lowercase().contains("rb=") && m.to_lowercase().contains("va="), + "base/override params missing: {m}"); + // override BF must come AFTER the inherited BF (last wins in ngspice) + let low = m.to_ascii_lowercase(); + let b70 = low.find("bf=7").expect("inherited bf"); + let b150 = low.find("bf=1.5").expect("override bf"); + assert!(b150 > b70, "override must follow base: {m}"); + let q = out.cards.iter().find(|c| c.to_ascii_lowercase().starts_with("q.x1.q1")).unwrap(); + assert!(q.to_lowercase().contains("area=2"), "q area factor: {q}"); + let d = out.cards.iter().find(|c| c.to_ascii_lowercase().starts_with("d1")).unwrap(); + assert!(d.to_lowercase().contains("area=7"), "d area factor: {d}"); + } + + /// PSpice `PARAMS:` in `.SUBCKT` headers and X lines is stripped in every + /// dialect (ngspice inp_fix_params). Left in place, the X line resolves + /// the subckt name as literally `params:` -> "unknown subckt", including + /// for NESTED calls inside another subckt. + #[test] + fn params_keyword_stripped() { + let out = SubcktExpander::new(&[ + ll("* t"), + ll(".SUBCKT inner 1 2 PARAMS: r=1k"), + ll("r1 1 2 'r'"), + ll(".ENDS"), + ll(".SUBCKT outer a b PARAMS: rr=2k"), + ll("x1 a b inner PARAMS: r={rr}"), + ll(".ENDS"), + ll("xo n1 n2 outer PARAMS: rr=5k"), + ]) + .expand(); + assert!( + !out.cards.iter().any(|c| c.to_ascii_lowercase().contains("params:")), + "params: leaked: {:?}", + out.cards + ); + let r = out + .cards + .iter() + .find(|c| c.to_ascii_lowercase().starts_with("r.xo.x1")) + .unwrap_or_else(|| panic!("nested inner not expanded: {:?}", out.cards)); + assert!(r.contains("5.0") || r.contains("5e3") || r.contains("5.000"), "rr not bound: {r}"); + } + + /// XSPICE bracketed vector parameters (`cntl_array = [-2 -1 1 2]`) are ONE + /// value token, spaces included; and an `.ic`/`.nodeset` inside a subckt + /// body gets its `v(node)` arguments renamed like any other node reference + /// (ports to caller nodes, internals prefixed). + #[test] + fn bracketed_arrays_and_scoped_ic() { + let out = SubcktExpander::new(&[ + ll("* t"), + ll(".model var_clock d_osc(cntl_array = [-2 -1 1 2] freq_array = [1e3 1e3 10e3 10e3]"), + ll("+ duty_cycle = 0.1)"), + ll("a5 cntl clk var_clock"), + ll(".subckt filt in out"), + ll("r1 in mid 1k"), + ll("r2 mid out 1k"), + ll(".ic v(out)=2.5 v(mid)=1.25"), + ll(".ends"), + ll("x1 a b filt"), + ]) + .expand(); + // NB the reader joins `+` continuations before SubcktExpander runs; join + // manually here to keep the fixture faithful to one logical card. + let out2 = SubcktExpander::new(&[ + ll("* t"), + ll(".model var_clock d_osc(cntl_array = [-2 -1 1 2] freq_array = [1e3 1e3 10e3 10e3] duty_cycle = 0.1)"), + ll("a5 cntl clk var_clock"), + ]) + .expand(); + let m = out2.cards.iter().find(|c| c.starts_with(".model var_clock")).unwrap(); + assert!(m.contains("[-2 -1 1 2]"), "cntl_array mangled: {m}"); + assert!(m.contains("[1e3 1e3 10e3 10e3]"), "freq_array mangled: {m}"); + assert!(m.contains("duty_cycle"), "params after array lost: {m}"); + let ic = out.cards.iter().find(|c| c.starts_with(".ic")).unwrap(); + assert!( + ic.contains("v(b)") && ic.contains("v(x1.mid)"), + "scoped .ic not renamed: {ic}" + ); + } + + /// PSpice mode predefines `temp='temper'`/`vt`/`gmin` (as ngspice's + /// pspice_compat does), rewrites 3-arg `LIMIT` to the ternary clamp, and + /// renames thermal .model params (`t_abs`->`temp` etc). Without the first, + /// a `VALUE={..TEMP..}` cannot resolve and the whole VALUE= is dropped. + #[test] + fn pspice_temp_limit_and_model_thermals() { + let cfg = crate::config::Config::default().with_compat(crate::config::Compat::Pspice); + let out = SubcktExpander::with_config( + &[ + ll("* t"), + ll(".param drift='2e-6*(TEMP-27)'"), + ll(".param clamped='limit(5,0,3)'"), + ll("e1 a 0 VALUE={0.5+drift}"), + ll("g1 a 0 VALUE={LIMIT(v(a),-1,1)}"), + ll("r2 a 0 'clamped'"), + ll(".model rn res(t_abs=-273.15)"), + ll("r1 a 0 rn 1k"), + ], + cfg, + ) + .expand(); + assert!(out.drops.is_empty(), "drops: {:?}", out.drops); + // VALUE= cards are split (eg_value_rewrite); the expressions live on + // the be1/bg1 sources + let e = out.cards.iter().find(|c| c.starts_with("be1")).unwrap(); + assert!(e.contains("temper"), "TEMP not mapped to temper: {e}"); + let g = out.cards.iter().find(|c| c.starts_with("bg1")).unwrap(); + assert!( + g.contains("ternary_fcn") && !g.to_lowercase().contains("limit("), + "3-arg LIMIT not rewritten: {g}" + ); + let r2 = out.cards.iter().find(|c| c.starts_with("r2")).unwrap(); + assert!(r2.contains("3"), "const LIMIT clamp wrong: {r2}"); + let m = out.cards.iter().find(|c| c.starts_with(".model rn")).unwrap(); + assert!( + m.contains("temp=") && !m.to_lowercase().contains("t_abs"), + "t_abs not renamed: {m}" + ); + // default (hs) mode: TEMP stays an ordinary (undefined) param and + // 2-arg HSPICE limit() remains a symbolic MC distribution. + let df = SubcktExpander::with_config( + &[ + ll("* t"), + ll(".param lm='limit(0.5,0.1)'"), + ll(".model nch nmos (vth0=lm)"), + ll("m1 d g s b nch"), + ], + crate::config::Config::default(), + ) + .expand(); + let m = df.cards.iter().find(|c| c.starts_with(".model nch")).unwrap(); + assert!(m.contains("limit("), "hs-mode MC limit must stay symbolic: {m}"); + } + + /// PSpice `TABLE()` E/G form -> a helper node driven by a `pwl()` B-source, + /// matching inpcompat.c::replace_table. Only in Pspice mode. + #[test] + fn pspice_table_to_pwl() { + let cfg = crate::config::Config::default().with_compat(crate::config::Compat::Pspice); + let out = SubcktExpander::with_config( + &[ + ll("* t"), + ll("e1 out 0 value={table(v(a,b), 1, 10, 3, 30)}"), + ll("va a 0 1"), + ll("vb b 0 0"), + ], + cfg, + ) + .expand(); + let e = out.cards.iter().find(|c| c.to_lowercase().starts_with("e1")).unwrap(); + assert!(e.contains("v(table_new_0)"), "e-source not repointed: {e}"); + let b = out.cards.iter().find(|c| c.starts_with("btable_new_0")).unwrap(); + assert!(b.contains("v=pwl(v(a,b)"), "pwl b-source wrong: {b}"); + assert!(!b.to_lowercase().contains("table("), "table( left in b: {b}"); + } + + /// PSpice `VSWITCH` von/voff form -> pswitch code model + S-device becomes an + /// A-device with %gd ports (inpcompat.c). vt/vh form -> `sw`, instance kept. + #[test] + fn pspice_vswitch() { + let cfg = crate::config::Config::default().with_compat(crate::config::Compat::Pspice); + // von/voff -> pswitch, and its S instance -> A device + let out = SubcktExpander::with_config( + &[ + ll("* t"), + ll(".model msw VSWITCH(Ron=1 Roff=1e9 Von=0.9 Voff=0.8)"), + ll("s1 outp outn ctlp ctln msw"), + ll("v1 ctlp 0 1"), + ], + cfg, + ) + .expand(); + let m = out.cards.iter().find(|c| c.to_lowercase().contains("pswitch")).unwrap(); + assert!(m.contains(".model amsw pswitch("), "model not converted: {m}"); + assert!(m.contains("cntl_on=") && m.contains("r_on=") && m.contains("log=TRUE"), "remap wrong: {m}"); + let a = out.cards.iter().find(|c| c.starts_with("as1")).unwrap(); + assert_eq!(a, "as1 %gd(ctlp ctln) %gd(outp outn) amsw", "instance rewrite wrong: {a}"); + + // vt/vh -> sw, instance unchanged + let out = SubcktExpander::with_config( + &[ + ll("* t"), + ll(".model msw2 VSWITCH(vt=1.5 vh=0.3 ron=1 roff=1e9)"), + ll("s1 a b c d msw2"), + ll("v1 c 0 1"), + ], + cfg, + ) + .expand(); + let m = out.cards.iter().find(|c| c.contains(" sw ")).unwrap(); + assert!(m.contains(".model msw2 sw (") && m.contains("vt=") && m.contains("vh="), "sw model wrong: {m}"); + assert!(out.cards.iter().any(|c| c.starts_with("s1 ")), "sw instance should stay an S device"); + } + + /// Multi-core expansion must be byte-identical to single-core: the whole + /// project rests on it. Many independent top-level instances + a .control + /// block (which must stay whole across the split). + #[test] + fn parallel_matches_single_core() { + let mut lines = vec![ + ll("* t"), + ll(".param g=2"), + ll(".subckt cell a b"), + ll("r1 a b {1k*g}"), + ll("c1 a b 1p"), + ll(".ends"), + ]; + for i in 0..50 { + lines.push(ll(&format!("x{i} n{i} 0 cell"))); + lines.push(ll(&format!("v{i} n{i} 0 {i}"))); + } + lines.push(ll(".control")); + lines.push(ll("let x = 0")); + lines.push(ll("run")); + lines.push(ll(".endc")); + let one = SubcktExpander::with_config(&lines, Config::default()).expand(); + for cores in [2usize, 3, 4, 8] { + let cfg = Config::with_cores(std::num::NonZeroUsize::new(cores).unwrap()); + let many = SubcktExpander::with_config(&lines, cfg).expand(); + assert_eq!(one.cards, many.cards, "cores={cores} output differs from single-core"); + assert_eq!(one.drops, many.drops, "cores={cores} drops differ"); + } + } + + /// A behavioral resistor is expanded to ngspice's own B-source + noise B/R/V + /// form using the resistor's LOCAL name, so after subckt-prefixing the names + /// match ngspice's during-expansion transform (b.x1.xr1.br1, node x1.xr1.r1_3) + /// rather than the flat-name form (br.x1.xr1.r1). A plain resistor is untouched. + #[test] + fn behavioral_resistor_matches_ngspice_form() { + let out = SubcktExpander::new(&[ + ll("* t"), + ll(".subckt rmac a b"), + ll("r1 a b 'v(a,b)*10+50' noisy=1"), + ll(".ends"), + ll("xr1 n1 0 rmac"), + ll("v1 n1 0 1"), + ]) + .expand(); + let has = |p: &str| out.cards.iter().any(|c| c.to_lowercase().starts_with(p)); + assert!(has("b.xr1.br1 "), "main B-source missing/misnamed: {:?}", out.cards); + assert!(has("b.xr1.br1_1 "), "noise B-source missing: {:?}", out.cards); + assert!(has("r.xr1.rr1_2 "), "noise R missing: {:?}", out.cards); + assert!(has("v.xr1.vr1_3 "), "sense V missing: {:?}", out.cards); + // internal node is x1.xr1.r1_3 style -- no leading type letter + let vcard = out.cards.iter().find(|c| c.to_lowercase().starts_with("v.xr1.vr1_3")).unwrap(); + assert!(vcard.to_lowercase().contains("xr1.r1_3 0 0"), "internal node wrong: {vcard}"); + // no leftover plain behavioral R card + assert!(!out.cards.iter().any(|c| c.to_lowercase().starts_with("r.xr1.r1 ")), "stray R: {:?}", out.cards); + + // a plain (numeric) resistor is NOT transformed + let out2 = SubcktExpander::new(&[ll("* t"), ll(".subckt s a b"), ll("r1 a b 1k"), ll(".ends"), ll("xr1 n 0 s")]).expand(); + assert!(out2.cards.iter().any(|c| c.to_lowercase().starts_with("r.xr1.r1 ")), "plain R changed: {:?}", out2.cards); + } + + /// Single `|`/`&` are logical or/and in SPICE behavioral expressions. + #[test] + fn single_pipe_amp_are_logical() { + use crate::expr::parse; + assert!(parse("(a>0 | b>0)").is_ok()); + assert!(parse("(a>0 & b>0)").is_ok()); + } + + /// A bare keyword in a model card (no `=`) is a flag, kept verbatim, not + /// dropped: `.model M VDMOS nchan` -- dropping `nchan` flips VDMOS polarity. + #[test] + fn model_bare_keyword_kept() { + for (name, spec, kw) in [ + ("mn", ".model mn VDMOS nchan Vto=4 Kp=5.9", "nchan"), + ("mp", ".model mp VDMOS pchan Vto=-4", "pchan"), + ("mb", ".model mb VDMOS nchan", "nchan"), + ] { + let dev = format!("m1 d g s {name}"); + let out = SubcktExpander::new(&[ll("* t"), ll(spec), ll(&dev)]).expand(); + let m = out.cards.iter().find(|c| c.starts_with(".model")).unwrap(); + assert!(m.contains(kw), "keyword {kw} dropped: {m}"); + } + // normal name=value models are unaffected + let out = SubcktExpander::new(&[ll("* t"), ll(".model dm d (is=1e-14 n=2)"), ll("d1 1 0 dm")]).expand(); + let m = out.cards.iter().find(|c| c.starts_with(".model")).unwrap(); + assert!(m.contains("is=") && m.contains("n="), "params lost: {m}"); + } + + /// E/G-source TABLE form: `E n+ n- TABLE {ctrl} = (pts)` is split + /// pre-expansion into ngspice inp_compat's four-card XSPICE pwl form, so + /// the derived names match ngspice's own expansion (e1_int1/e1_int2 under + /// the SUBCKT path, no type-letter prefix; b/a devices named be1/ae1). + #[test] + fn e_source_table_form_split() { + let lines = vec![ + ll("* t"), + ll(".subckt s a b"), + ll("e1 a b TABLE {v(a)} = (0,0) (1,2)"), + ll(".ends"), + ll("x1 n1 n2 s"), + ll("v1 n1 0 1"), + ]; + let out = SubcktExpander::new(&lines).expand(); + let find = |p: &str| { + out.cards + .iter() + .find(|c| c.to_lowercase().starts_with(p)) + .unwrap_or_else(|| panic!("missing {p}: {:?}", out.cards)) + }; + let e = find("e.x1.e1 "); + assert!(e.to_lowercase().contains("x1.e1_int1 0 1"), "gain card wrong: {e}"); + let b = find("b.x1.be1 "); + assert!( + b.to_lowercase().contains("x1.e1_int2") && b.contains("v(n1)"), + "b card wrong (ctrl node must rename): {b}" + ); + // `%v(n)` may render as `%v n` — equivalent XSPICE port syntax + let a = find("a.x1.ae1 ").to_lowercase(); + let i2 = a.find("x1.e1_int2").expect("int2 port missing"); + let i1 = a.find("x1.e1_int1").expect("int1 port missing"); + assert!(a.matches("%v").count() == 2 && i2 < i1, "a-device ports wrong: {a}"); + let m = find(".model x1:xfer_e1 "); + assert!( + m.contains("[0 1]") && m.contains("[0 2]") && m.contains("fraction=TRUE"), + "pwl model wrong: {m}" + ); + } + + /// i(dev) inside a subckt renames its argument as a DEVICE (with the + /// device-letter prefix), not a node -- it measures current through a device. + /// v(node) stays a node. Getting i() wrong left ngspice with an "unknown + /// controlling source". + #[test] + fn i_of_device_renamed_as_instance() { + let lines = vec![ + ll("* t"), + ll(".subckt s a b"), + ll("rs1 a b '6*i(rs2)'"), + ll("rs2 a b 1"), + ll(".ends"), + ll("x1 n1 0 s"), + ll("v1 n1 0 1"), + ]; + let out = SubcktExpander::new(&lines).expand(); + let r = out.cards.iter().find(|c| c.to_lowercase().contains("rs1")).unwrap(); + assert!(r.contains("i(r.x1.rs2)"), "i() arg not instance-renamed: {r}"); + assert!(!r.contains("i(x1.rs2)"), "i() arg renamed as a node: {r}"); + assert_eq!(rename_inst("rs2", "x1.x2"), "r.x1.x2.rs2"); + assert_eq!(rename_inst("rs2", ""), "rs2"); + } + + /// Statistical draws must be kept SYMBOLIC, args resolved, so ngspice draws + /// per Monte Carlo run -- folding them to nominal would collapse the + /// distribution. Both the direct call and a `.param` reference to one. + #[test] + fn agauss_kept_symbolic_for_monte_carlo() { + // direct in a model card + let lines = vec![ + ll("* t"), + ll(".model nch nmos (vth0=agauss(0.5,0.01,3))"), + ll("m1 d g s b nch"), + ]; + let out = SubcktExpander::new(&lines).expand(); + let m = out.cards.iter().find(|c| c.starts_with(".model nch")).unwrap(); + assert!(m.contains("agauss("), "agauss folded away: {m}"); + assert!(!m.contains("vth0=5") && !m.contains("vth0=0.5"), "folded to nominal: {m}"); + + // via a .param reference + let lines = vec![ + ll("* t"), + ll(".param vm=agauss(0.5,0.01,3)"), + ll(".model nch nmos (vth0=vm)"), + ll("m1 d g s b nch"), + ]; + let out = SubcktExpander::new(&lines).expand(); + let m = out.cards.iter().find(|c| c.starts_with(".model nch")).unwrap(); + assert!(m.contains("agauss("), "param-referenced agauss folded away: {m}"); + + assert!(SubcktExpander::has_runtime("agauss(0.5,0.01,3)")); + assert!(SubcktExpander::has_runtime("aunif(1,2)")); + assert!(SubcktExpander::has_runtime("limit(1,0.1)")); + } + + /// A negated statistical term must not emit `-` (`...e0+-(agauss(...))`). + /// numparam rejects the operator-then-unary-sign sequence ("wrongly + /// determined negation"); the right operand's leading sign must be + /// parenthesized so it reads `... + (-(agauss(...)))`. + #[test] + fn no_binop_followed_by_unary_sign() { + // vth0 = base + (a NEGATED statistical draw): 0.5 + -(dvth) + let lines = vec![ + ll("* t"), + ll(".param dvth=-agauss(0,0.01,3)"), + ll(".param vm='0.5+dvth'"), + ll(".model nch nmos (vth0=vm)"), + ll("m1 d g s b nch"), + ]; + let out = SubcktExpander::new(&lines).expand(); + let m = out.cards.iter().find(|c| c.starts_with(".model nch")).unwrap(); + assert!(m.contains("agauss("), "statistical draw folded away: {m}"); + assert!(!m.contains("+-"), "emitted binop-then-unary-sign `+-`: {m}"); + assert!(!m.contains("--"), "emitted `--`: {m}"); + // sanity: the sign survives, just parenthesized + assert!(m.contains("(-("), "expected parenthesized unary sign: {m}"); + } + + /// String / keyword / version model-parameter values are literals kept + /// verbatim, not folded or dropped: `version=3.3.0`, `mfg=acme_corp`, + /// `fraction=false`, `file="x.txt"`. Only a failed arithmetic EXPRESSION drops. + #[test] + fn literal_param_values_kept_verbatim() { + for (spec, want) in [ + ("d (version=3.3.0)", "version=3.3.0"), + ("d (mfg=acme_corp)", "mfg=acme_corp"), + ("d (fraction=false)", "fraction=false"), + ] { + let lines = vec![ + ll("* t"), + ll(&format!(".model dm {spec}")), + ll("d1 n1 0 dm"), + ]; + let out = SubcktExpander::new(&lines).expand(); + let m = out.cards.iter().find(|c| c.starts_with(".model dm")).unwrap(); + assert!(m.contains(want), "want {want:?} in {m:?}"); + assert!(out.drops.is_empty(), "unexpected drop for {spec}: {:?}", out.drops); + } + // is_bare_word covers identifiers and quoted strings; `3.3.0` is kept via + // the separate unparsable path (it starts with a digit), as the version + // case above confirms. + assert!(is_bare_word("acme_corp") && is_bare_word("false")); + assert!(is_bare_word("\"x.txt\"")); + assert!(!is_bare_word("3.3.0") && !is_bare_word("1+nosuch") && !is_bare_word("a*b")); + } + + /// In a `.if` condition a lone `=` is equality; `==`/`!=`/`<=`/`>=` are left. + #[test] + fn normalize_eq_doubles_lone_equals() { + assert_eq!(normalize_eq("select2 = 3"), "select2 == 3"); + assert_eq!(normalize_eq("a==b"), "a==b"); + assert_eq!(normalize_eq("a != b"), "a != b"); + assert_eq!(normalize_eq("a <= b"), "a <= b"); + assert_eq!(normalize_eq("a>=b && c=d"), "a>=b && c==d"); + } + + #[test] + fn fixed_node_counts() { + assert_eq!(nodes("r1 a b 1k"), 2); + assert_eq!(nodes("c1 a b 1p"), 2); + assert_eq!(nodes("l1 a b 1n"), 2); + assert_eq!(nodes("v1 a b dc 1"), 2); + assert_eq!(nodes("i1 a b dc 1"), 2); + assert_eq!(nodes("b1 a b v=1"), 2); + assert_eq!(nodes("j1 d g s jmod"), 3); + assert_eq!(nodes("z1 d g s zmod"), 3); + assert_eq!(nodes("u1 a b umod"), 3); + // e/g: 2 output nodes + 2 controlling nodes, all node-translated + assert_eq!(nodes("e1 a b c d 2.0"), 4); + assert_eq!(nodes("g1 a b c d 3.0"), 4); + // these four were missing from the old table entirely -> 0 nodes renamed + assert_eq!(nodes("t1 a b c d z0=50"), 4); + assert_eq!(nodes("o1 a b c d omod"), 4); + assert_eq!(nodes("s1 a b c d smod"), 4); + assert_eq!(nodes("y1 a b c d ymod"), 4); + } + + #[test] + fn controlling_devices_are_instances_not_nodes() { + // F/H/W sense a V-source; K names two inductors. subckt.c::numdevs(). + for l in ["f1 a b vsen 1.0", "h1 a b vsen 1.0", "w1 a b vsen wmod"] { + assert_eq!((nodes(l), ctrl(l)), (2, 1), "{l}"); + } + assert_eq!((nodes("k1 l1 l2 0.5"), ctrl("k1 l1 l2 0.5")), (0, 2)); + } + + /// ngspice: 2 output nodes, then `dim * numdevs()` controlling terms — + /// E/G take 2 nodes per term, F/H take 1 source name per term. + #[test] + fn poly_controlling_terms_scale_with_dim() { + assert_eq!(nodes("e1 a b poly(2) c1 c2 c3 c4 0 1 1"), 2 + 4); + assert_eq!(nodes("g1 a b POLY(2) c1 c2 c3 c4 0 1 1"), 2 + 4); + assert_eq!(nodes("e2 a b poly(3) c1 c2 c3 c4 c5 c6 0 1"), 2 + 6); + // F/H: the controlling terms are V-source names, not nodes + assert_eq!((nodes("f1 a b poly(2) vs1 vs2 0 1 1"), ctrl("f1 a b poly(2) vs1 vs2 0 1 1")), (2, 2)); + assert_eq!((nodes("h1 a b poly(1) vs1 0 1"), ctrl("h1 a b poly(1) vs1 0 1")), (2, 1)); + // non-POLY => dim 1 + assert_eq!(nodes("e5 a b c1 c2 2.0"), 4); + } + + /// ngspice's tokenizer splits parens off, so every spelling must work. + #[test] + fn poly_spellings() { + for l in [ + "e1 a b poly(2) c1 c2 c3 c4 0 1 1", + "e1 a b poly( 2 ) c1 c2 c3 c4 0 1 1", + "e1 a b POLY (2) c1 c2 c3 c4 0 1 1", + "e1 a b POLY ( 2 ) c1 c2 c3 c4 0 1 1", + ] { + assert_eq!(nodes(l), 6, "{l}"); + assert!(roles(l).contains(&Role::Poly(2)), "{l}"); + } + // Case-sensitive, like subckt.c and enhtrans.c: `Poly` is not POLY. + assert!(!roles("e1 a b Poly(2) c1 c2 c3 c4 0 1 1") + .contains(&Role::Poly(2))); + } + + /// The HSPICE source-type marker is redundant with the device letter, so + /// ngspice consumes it and it does not appear in the expanded card. + #[test] + fn source_type_marker_is_dropped() { + for (l, n, c) in [ + ("e4 a b vcvs c1 c2 2.0", 4, 0), + ("g4 a b vccs c1 c2 3.0", 4, 0), + ("f4 a b cccs vs1 2.0", 2, 1), + ("h4 a b ccvs vs1 2.0", 2, 1), + ] { + assert_eq!((nodes(l), ctrl(l)), (n, c), "{l}"); + assert!(roles(l).contains(&Role::Drop), "{l}"); + } + // a marker only counts for its own device letter + assert!(!roles("e4 a b vccs c1 c2 2.0").contains(&Role::Drop)); + assert_eq!(nodes("e6 a b vcvs poly(2) c1 c2 c3 c4 0 1 1"), 6); + } + + #[test] + fn bjt_is_three_four_or_five_nodes() { + assert_eq!(nodes("q1 c b e qmod"), 3); + // the old table hard-coded 3, so the substrate kept its subckt-internal + // name and every instance shorted together on it + assert_eq!(nodes("q2 c b e s qmod"), 4); + assert_eq!(nodes("q3 c b e s t qmod"), 5); // VBIC/hicum2 thermal + // a trailing area value is not a node + assert_eq!(nodes("q4 c b e s qmod 2.0"), 4); + assert_eq!(nodes("q5 c b e qmod 2.0"), 3); + assert_eq!(nodes("q6 c b e qmod off"), 3); + // `1e-6` fools ngspice's own "contains no alpha" area test; we parse it + assert_eq!(nodes("q7 c b e s qmod 1e-6"), 4); + } + + #[test] + fn mos_is_four_to_seven_nodes() { + assert_eq!(nodes("m1 d g s b nch l=1u w=2u"), 4); + assert_eq!(nodes("m2 d g s b nch"), 4); + assert_eq!(nodes("m3 d g s b nch off"), 4); + assert_eq!(nodes("m4 d g s e t bsimbulk"), 5); // bsimbulk/bsimcmg thermal + assert_eq!(nodes("m5 d g s e p1 p2 hv2"), 6); // HiSIMHV/SOI3 + assert_eq!(nodes("m6 d g s e p1 p2 p3 b4soi"), 7); // B4SOI/B3SOI* + assert_eq!(nodes("m7 d g s nch"), 3); // VDMOS + assert_eq!(nodes("m8 d g s b nch.1 l=1u"), 4); // binned model name + } + + #[test] + fn diode_is_two_or_three_nodes() { + assert_eq!(nodes("d1 a b dmod"), 2); + assert_eq!(nodes("d2 a b dmod area=2e-6"), 2); + assert_eq!(nodes("d3 a b t dmod"), 3); // self-heating + assert_eq!(nodes("d4 a b t dmod thermal"), 3); + assert_eq!(nodes("d5 a b dmod off"), 2); + // ngspice miscounts this one as 3 nodes and dies at pass 2 with + // "could not find a valid modelname", so no working deck contains it; + // we read the bare positional area correctly instead. + assert_eq!(nodes("d6 a b dmod 1e-6"), 2); + } + + #[test] + fn numeric_node_names_are_still_nodes() { + // node "0"/"5" are numbers; only the token *after* the last node is the model + assert_eq!(nodes("d1 1 2 dmod"), 2); + assert_eq!(nodes("q1 1 2 3 qmod"), 3); + assert_eq!(nodes("m1 1 2 3 4 nch"), 4); + } + + /// MIFgettok (xspice/mif/mifutil.c) treats `=` `(` `)` `,` as whitespace and + /// makes `[ ] ~ % < >` single-character tokens. + #[test] + fn mif_tokenizer() { + assert_eq!(mif_tokens("%vd(a b) %vd(c d) amod"), + ["%","vd","a","b","%","vd","c","d","amod"]); + assert_eq!(mif_tokens("%vnam ( Vsin1 ) %id ( out 0 ) m"), + ["%","vnam","Vsin1","%","id","out","0","m"]); + assert_eq!(mif_tokens("[p1] [enable] atod"), + ["[","p1","]","[","enable","]","atod"]); + assert_eq!(mif_tokens("~in out inv"), ["~","in","out","inv"]); + assert_eq!(mif_tokens("d clk NULL NULL NULL q dff"), + ["d","clk","NULL","NULL","NULL","q","dff"]); + assert_eq!(mif_tokens("a \"quoted str\" b"), ["a","quoted str","b"]); + } + + /// `null` is a built-in global in ngspice (subckt.c::collect_global_nodes + /// seeds the table with "0" and "null"), so it is never renamed. + #[test] + fn null_and_ground_are_global() { + let nm = HashMap::new(); + let g = HashSet::new(); + for n in ["0", "null", "NULL", "Null"] { + assert_eq!(map_node_with(n, &nm, "x1", &g), n, "{n}"); + } + assert_eq!(map_node_with("foo", &nm, "x1", &g), "x1.foo"); + } + + #[test] + fn k_has_no_nodes() { + assert_eq!(nodes("k1 l1 l2 0.5"), 0); + } + + #[test] + fn xspice_a_device_claims_no_nodes() { + // ngspice's get_number_terminals also returns 0 here; A-devices are + // handled by a dedicated branch we don't implement, so emit_device + // records a loud drop instead of silently shorting the connections. + assert_eq!(nodes("a1 %v(a b) %v(c d) amod"), 0); + } +} diff --git a/ng_parse/parser/src/table.rs b/ng_parse/parser/src/table.rs new file mode 100644 index 000000000..7238e7e74 --- /dev/null +++ b/ng_parse/parser/src/table.rs @@ -0,0 +1,298 @@ +//! HSPICE `table_param()` — table-file parameter lookup. +//! +//! Foundry PDKs use it for self-heating thermal resistance and RF parasitics; +//! foundry_b's TT corner alone calls it ~1300 times (1,289 in the flattened +//! `tb_driver` deck). +//! +//! ```text +//! table_param(file, N_int, int_1..int_N, N_real, real_1..real_M, output_col) +//! ``` +//! +//! Integer keys match exactly; real keys are multilinearly interpolated; +//! `output_col` is a 1-based index into the *value* columns (those after the +//! keys). The file is `#`-header-first: +//! +//! ```text +//! #nf nfin l rth0 <- header names the columns: 3 keys, 1 value +//! 1 1 1e-07 0.0105 <- data rows +//! ``` +//! +//! This mirrors ngspice's own implementation +//! (`src/frontend/numparam/table_param.c`, added in commit 7131b97e0), which is +//! compiled into the reference binary — so its results are directly diffable. +//! The lookup is a pure function of `(path, int keys, real keys, column)`; +//! relative paths are resolved to absolute earlier, during deck expansion, where +//! the defining file is known (see `preprocess::rewrite_table_paths`). + +use std::collections::HashMap; +use std::sync::{Mutex, OnceLock}; + +/// A parsed table file: `n_cols`-wide rows of `f64`. +struct Table { + n_cols: usize, + rows: Vec, +} + +impl Table { + fn n_rows(&self) -> usize { + self.rows.len() / self.n_cols + } + fn at(&self, row: usize, col: usize) -> f64 { + self.rows[row * self.n_cols + col] + } +} + +/// Process-wide cache: foundry decks reference the same table file hundreds of +/// times, and ngspice caches for the process lifetime for the same reason. +fn cache() -> &'static Mutex>> { + static C: OnceLock>>> = OnceLock::new(); + C.get_or_init(|| Mutex::new(HashMap::new())) +} + +/// Load and parse a table file. The first non-blank line must be the `#` header; +/// its token count (minus the `#`) fixes the column count. `*`/`#` lines and +/// blanks are skipped; a row with the wrong arity is skipped. +fn load(path: &str) -> Option { + let text = std::fs::read(path).ok()?; + let text = String::from_utf8_lossy(&text); + let mut lines = text.lines(); + + let n_cols = loop { + let l = lines.next()?; + let t = l.trim_start(); + if t.is_empty() { + continue; + } + // ngspice insists on a header and errors out otherwise. + let hdr = t.strip_prefix('#')?; + break hdr.split_whitespace().count(); + }; + if n_cols == 0 { + return None; + } + + let mut rows = Vec::new(); + for l in lines { + let t = l.trim_start(); + if t.is_empty() || t.starts_with('*') || t.starts_with('#') { + continue; + } + let vals: Vec = t + .split_whitespace() + .filter_map(|tok| tok.parse::().ok()) + .collect(); + if vals.len() == n_cols { + rows.extend(vals); + } + } + if rows.is_empty() { + return None; + } + Some(Table { n_cols, rows }) +} + +fn get(path: &str) -> Option<&'static Table> { + let mut c = cache().lock().ok()?; + if let Some(hit) = c.get(path) { + return *hit; + } + // Leaked deliberately: the cache lives for the process, exactly as ngspice's + // does, and this hands out plain `&'static` refs without a lock on every read. + let loaded = load(path).map(|t| &*Box::leak(Box::new(t))); + c.insert(path.to_string(), loaded); + loaded +} + +/// Do this row's integer keys match? ngspice compares with a 0.5 tolerance — +/// these are integers stored as doubles. +fn int_keys_match(t: &Table, row: usize, ints: &[f64]) -> bool { + ints.iter() + .enumerate() + .all(|(i, v)| (t.at(row, i) - v).abs() <= 0.5) +} + +/// Evaluate a `table_param()` lookup. Returns `None` on any failure (missing +/// file, no matching rows, column out of range) — the caller then leaves the +/// expression symbolic and reports a drop rather than inventing a value. +pub fn lookup(path: &str, ints: &[f64], reals: &[f64], output_col: i64) -> Option { + let t = get(path)?; + let n_keys = ints.len() + reals.len(); + + // output_col is 1-based within the VALUE columns, which start after the keys. + if output_col < 1 { + return None; + } + let data_col = n_keys + (output_col as usize - 1); + if data_col >= t.n_cols { + return None; + } + + // Per real-key dimension, find the values bracketing the target among rows + // whose integer keys match, and the weight between them. Out-of-range targets + // clamp to the nearest endpoint — no extrapolation. + let mut v_lo = vec![0.0; reals.len()]; + let mut v_hi = vec![0.0; reals.len()]; + let mut w = vec![0.0; reals.len()]; + let mut found_any = false; + + for (d, &target) in reals.iter().enumerate() { + let (mut best_lo, mut best_hi) = (f64::NEG_INFINITY, f64::INFINITY); + let (mut have_lo, mut have_hi) = (false, false); + for r in 0..t.n_rows() { + if !int_keys_match(t, r, ints) { + continue; + } + found_any = true; + let v = t.at(r, ints.len() + d); + if v <= target && v > best_lo { + best_lo = v; + have_lo = true; + } + if v >= target && v < best_hi { + best_hi = v; + have_hi = true; + } + } + if !have_lo && !have_hi { + return None; // no rows matched the integer keys + } + if !have_lo { + best_lo = best_hi; + } + if !have_hi { + best_hi = best_lo; + } + v_lo[d] = best_lo; + v_hi[d] = best_hi; + w[d] = if best_hi == best_lo { + 0.0 + } else { + ((target - best_lo) / (best_hi - best_lo)).clamp(0.0, 1.0) + }; + } + + // With no real keys the integer keys alone select the row, so confirm a match. + if reals.is_empty() { + let row = (0..t.n_rows()).find(|&r| int_keys_match(t, r, ints))?; + return Some(t.at(row, data_col)); + } + if !found_any { + return None; + } + + // Sum the 2^n_real corners of the bracketing hyper-rectangle. + let mut sum = 0.0; + let mut wsum = 0.0; + for c in 0..(1usize << reals.len()) { + let mut weight = 1.0; + let mut rk = vec![0.0; reals.len()]; + for d in 0..reals.len() { + if c & (1 << d) != 0 { + rk[d] = v_hi[d]; + weight *= w[d]; + } else { + rk[d] = v_lo[d]; + weight *= 1.0 - w[d]; + } + } + if weight == 0.0 { + continue; + } + for r in 0..t.n_rows() { + if !int_keys_match(t, r, ints) { + continue; + } + let hit = rk.iter().enumerate().all(|(d, &want)| { + (t.at(r, ints.len() + d) - want).abs() <= 1e-12 * (want.abs() + 1e-30) + }); + if hit { + sum += weight * t.at(r, data_col); + wsum += weight; + break; + } + } + } + if wsum == 0.0 { + return None; + } + Some(sum) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + + fn tbl(body: &str, tag: &str) -> String { + let p = std::env::temp_dir().join(format!("ngparse_tbl_{}_{tag}", std::process::id())); + let mut f = std::fs::File::create(&p).unwrap(); + f.write_all(body.as_bytes()).unwrap(); + p.to_string_lossy().into_owned() + } + + /// Shape of foundry_b's egnfet_SHE.table: 2 int keys (nf, nfin), 1 real key (l), + /// 1 value column (rth0). + const SHE: &str = "\ +#nf nfin l rth0 +1 1 1e-07 0.0105 +1 1 1.5e-07 0.0108 +1 1 2e-07 0.0083 +1 2 1e-07 0.0146 +1 2 2e-07 0.0200 +2 1 1e-07 0.0500 +"; + + #[test] + fn exact_hit_on_a_grid_point() { + let p = tbl(SHE, "exact"); + assert_eq!(lookup(&p, &[1.0, 1.0], &[1e-7], 1), Some(0.0105)); + assert_eq!(lookup(&p, &[1.0, 1.0], &[2e-7], 1), Some(0.0083)); + // integer keys select the row set + assert_eq!(lookup(&p, &[2.0, 1.0], &[1e-7], 1), Some(0.05)); + assert_eq!(lookup(&p, &[1.0, 2.0], &[1e-7], 1), Some(0.0146)); + } + + #[test] + fn interpolates_real_key() { + let p = tbl(SHE, "interp"); + // midway between 1e-7 (0.0105) and 1.5e-7 (0.0108) + let v = lookup(&p, &[1.0, 1.0], &[1.25e-7], 1).unwrap(); + assert!((v - 0.01065).abs() < 1e-12, "got {v}"); + // 1e-7 (0.0146) .. 2e-7 (0.0200), quarter of the way + let v = lookup(&p, &[1.0, 2.0], &[1.25e-7], 1).unwrap(); + assert!((v - 0.01595).abs() < 1e-12, "got {v}"); + } + + /// Out-of-range clamps to the nearest endpoint — ngspice never extrapolates. + #[test] + fn clamps_instead_of_extrapolating() { + let p = tbl(SHE, "clamp"); + assert_eq!(lookup(&p, &[1.0, 1.0], &[1e-9], 1), Some(0.0105)); // below min + assert_eq!(lookup(&p, &[1.0, 1.0], &[1.0], 1), Some(0.0083)); // above max + } + + #[test] + fn rejects_bad_lookups() { + let p = tbl(SHE, "bad"); + assert_eq!(lookup(&p, &[9.0, 9.0], &[1e-7], 1), None); // no such int keys + assert_eq!(lookup(&p, &[1.0, 1.0], &[1e-7], 2), None); // only 1 value column + assert_eq!(lookup(&p, &[1.0, 1.0], &[1e-7], 0), None); // 1-based + assert_eq!(lookup("/nonexistent/x.table", &[1.0], &[1.0], 1), None); + } + + #[test] + fn header_is_required() { + let p = tbl("1 1 1e-07 0.0105\n", "nohdr"); + assert_eq!(lookup(&p, &[1.0, 1.0], &[1e-7], 1), None); + } + + /// Multiple value columns: output_col indexes them 1-based, after the keys. + #[test] + fn selects_output_column() { + let p = tbl("#k a b c\n1 10 20 30\n2 40 50 60\n", "cols"); + assert_eq!(lookup(&p, &[1.0], &[], 1), Some(10.0)); + assert_eq!(lookup(&p, &[1.0], &[], 2), Some(20.0)); + assert_eq!(lookup(&p, &[1.0], &[], 3), Some(30.0)); + assert_eq!(lookup(&p, &[2.0], &[], 3), Some(60.0)); + } +} diff --git a/src/Makefile.am b/src/Makefile.am index 76dad79db..14a206d89 100644 --- a/src/Makefile.am +++ b/src/Makefile.am @@ -144,6 +144,21 @@ if NDEV_WANTED ngspice_LDADD += spicelib/devices/ndev/libndev.la endif +if NGPARSE_WANTED +# The ngparse Rust staticlib. Must come after libfte.la, which references it. +ngspice_LDADD += @NGPARSE_LIBS@ +# Build (or refresh) libngparse.a as part of `make`, from the ngspice top +# directory -- there is no separate cargo step. It is a BUILT_SOURCES entry so +# it is made before anything links against it; the phony prerequisite makes +# cargo run on every build (cargo is incremental, so it is a fast no-op when +# nothing changed), which also stops a stale .a from being silently linked. +BUILT_SOURCES = @NGPARSE_LIB@ +@NGPARSE_LIB@: ngparse-cargo-build +ngparse-cargo-build: + cd @NGPARSE_DIR@ && @CARGO@ build --release +.PHONY: ngparse-cargo-build +endif + if NUMDEV_WANTED ngspice_LDADD += \ spicelib/devices/nbjt/libnbjt.la \ @@ -514,6 +529,11 @@ libngspice_la_LIBADD = \ libngspice_la_LIBADD += \ frontend/plotting/libplotting.la +if NGPARSE_WANTED +# The shared library pulls in libfte.la too, so it needs ngparse as well. +libngspice_la_LIBADD += @NGPARSE_LIBS@ +endif + if XSPICE_WANTED libngspice_la_LIBADD += \ xspice/cm/libcmxsp.la \ diff --git a/src/frontend/Makefile.am b/src/frontend/Makefile.am index c7936e4b6..022fb15e0 100644 --- a/src/frontend/Makefile.am +++ b/src/frontend/Makefile.am @@ -136,6 +136,7 @@ libfte_la_SOURCES = \ inpcompat.c \ inpcompat.h \ inpc_probe.c \ + ngparse_glue.c \ interp.c \ interp.h \ inventory.c \ @@ -208,7 +209,7 @@ libfte_la_SOURCES = \ # testcommands_LDADD = libfte.a plotting/libplotting.a ../misc/libmisc.a -AM_CPPFLAGS = @AM_CPPFLAGS@ -I$(top_srcdir)/src/include @X_CFLAGS@ +AM_CPPFLAGS = @AM_CPPFLAGS@ -I$(top_srcdir)/src/include @X_CFLAGS@ @NGPARSE_CFLAGS@ AM_CFLAGS = $(STATIC) AM_YFLAGS = -d diff --git a/src/frontend/inp.c b/src/frontend/inp.c index ed8acce18..f80e0f270 100644 --- a/src/frontend/inp.c +++ b/src/frontend/inp.c @@ -13,6 +13,7 @@ Author: 1985 Wayne A. Christopher #include "ngspice/cktdefs.h" #include "ngspice/cpdefs.h" #include "ngspice/inpdefs.h" +#include "ngspice/ngparse_glue.h" #include "ngspice/ftedefs.h" #include "ngspice/dvec.h" #include "ngspice/fteinp.h" @@ -529,7 +530,55 @@ inp_spsource(FILE *fp, bool comfile, char *filename, bool intfile) inp_source() called with fp: load circuit netlist from file, */ /* called with *fp == NULL and intfile: we want to load circuit from circarray */ if (fp || intfile) { - deck = inp_readall(fp, dir_name, filename, comfile, intfile, &expr_w_temper); + /* ngparse (opt-in, NGSPICE_NGPARSE=1): expand .lib/.inc, the parameters + * and the subcircuits up front, then read the RESULT below. Everything + * inp_readall() does besides section extraction still runs over it -- + * the compatibility passes, and inp_subcktexpand()'s numparam over the + * expressions ngparse deliberately leaves symbolic (temper, v(), ...). + * .lib/.inc expansion simply finds nothing left to expand. + * See ngspice/ngparse_glue.h. */ + char *ngp_deck = NULL; + FILE *ngp_fp = NULL; + /* Feed ngparse the file ngspice actually opened (resolved via sourcepath/ + * inputdir), not the possibly-relative `filename`: a *ng_script deck that + * `source`s a netlist from a `set sourcepath` directory hands us a bare + * name that does not exist in the cwd. */ + char *ngp_path = ngparse_glue_realpath(fp, filename); + if (ngparse_glue_enabled(comfile, intfile, ngp_path)) { + ngp_deck = ngparse_glue_expand(ngp_path); + if (!ngp_deck) { + tfree(ngp_path); + /* ngparse already said why. Close fp as the !deck path below + * does -- inp_spsource owns it -- and report failure. */ + if (!intfile && fp) + fclose(fp); + return 1; + } + ngp_fp = fopen(ngp_deck, "r"); + if (!ngp_fp) { + fprintf(cp_err, "Error: ngparse: cannot reopen %s\n", ngp_deck); + remove(ngp_deck); + tfree(ngp_deck); + tfree(ngp_path); + if (!intfile && fp) + fclose(fp); + return 1; + } + /* `fp` is deliberately left open and untouched: the code below still + * uses it to set `inputdir` (relative paths resolve against it) and + * closes it on the normal path. We only divert what inp_readall + * READS, to the expanded deck. */ + } + tfree(ngp_path); + + deck = inp_readall(ngp_fp ? ngp_fp : fp, dir_name, filename, + comfile, ngp_fp ? FALSE : intfile, &expr_w_temper); + + if (ngp_fp) { + fclose(ngp_fp); + remove(ngp_deck); + tfree(ngp_deck); + } /* files starting with *ng_script are user supplied command files. * Walk past any leading blank cards (see same logic in @@ -731,9 +780,21 @@ inp_spsource(FILE *fp, bool comfile, char *filename, bool intfile) if (!ciprefix(".control", dd->line) && !ciprefix(".endc", dd->line)) { if (dd->line[0] == '*') cp_evloop(dd->line + 2); - /* option line stored but not processed */ - else if (ciprefix("option", dd->line)) + /* option line stored for the next circuit load ... */ + else if (ciprefix("option", dd->line)) { com_options = inp_getoptsc(dd->line, com_options); + /* ... and ALSO applied immediately when a circuit is + * already loaded. Without this, option lines placed + * AFTER the `source` command in a script are silently + * dropped: the com_options list is only merged into a + * circuit at load time (see the line_nconc merge in + * inp_dodeck), so post-source options never reached + * the task. com_option handles name/value parsing + * and routes through if_option to the default task, + * which analysis commands inherit from. */ + if (ft_curckt && ft_curckt->ci_ckt) + cp_evloop(dd->line); + } else cp_evloop(dd->line); } diff --git a/src/frontend/inpcom.c b/src/frontend/inpcom.c index c27d192bb..7800290a0 100644 --- a/src/frontend/inpcom.c +++ b/src/frontend/inpcom.c @@ -100,7 +100,7 @@ struct function_env const char *accept; } *functions; /* Hash on `name` for O(1) lookup in find_function. Foundry PDKs - * register hundreds-to-thousands of .funcs (Samsung 14LPU's TT + * register hundreds-to-thousands of .funcs (foundry_b 14LPU's TT * corner has ~1300), and find_function is called once per `(` in * every line during macro expansion — the linear scan was billions * of strcmp on real decks. */ @@ -3725,8 +3725,8 @@ static void inp_stripcomments_line(char *s, bool cs, bool inc) /* outside of .control section, and not in PS mode */ else if (!cs && (c == '$') && !newcompat.ps) { /* HSPICE treats '$' as an end-of-line comment regardless of - * the preceding character — foundry decks (Samsung 14LPU, - * TSMC, GF) routinely write `...)'$ comment` or `...=10u$ ...` + * the preceding character — foundry decks (foundry_b 14LPU, + * foundry_a, GF) routinely write `...)'$ comment` or `...=10u$ ...` * with no separator. In ngbehavior=hs / hsa, accept that. * * Outside HS mode keep the original conservative rule (only @@ -4000,7 +4000,7 @@ static void inp_fix_for_numparam( * conversion that would otherwise hand the path to * numparam as an expression and trigger * Number format error: "../path...}
" - * Observed on GF55 bcd55 sample_netlist/isoednfet_5p0_lr.sp + * Observed on foundry_c bcd55 sample_netlist/isoednfet_5p0_lr.sp * which uses `.del lib '../models/design_wrapper.lib'` in * .alter blocks. Matches the existing `.lib` skip above. */ if (ciprefix(".del", c->line) || @@ -5644,7 +5644,7 @@ static void inp_sort_params(struct card *param_cards, * scanning every other param's expression for occurrences of each * param's name * Both passes are now O(N) + O(total expression chars), enabling real - * PDK use (Samsung 14LPU's TT corner had N~2700 per subckt, called + * PDK use (foundry_b 14LPU's TT corner had N~2700 per subckt, called * ~500 times, totalling many billions of ops in the old code). * * Encoding: hash stores `(void *)(intptr_t)(i + 1)` so that the NULL diff --git a/src/frontend/ngparse_glue.c b/src/frontend/ngparse_glue.c new file mode 100644 index 000000000..86aa75e3d --- /dev/null +++ b/src/frontend/ngparse_glue.c @@ -0,0 +1,264 @@ +/* + * ngparse glue. See ngspice/ngparse_glue.h for the design. + */ +#include "ngspice/ngspice.h" +#include "ngspice/cpdefs.h" +#include "ngspice/ftedefs.h" +#include "ngspice/ngparse_glue.h" + +#ifdef USE_NGPARSE + +#include +#include +#include +#include +#include +#include + +#include "../misc/mktemp.h" +#include "ngparse.h" /* the ngparse Rust ABI */ + +/* Worker cores for ngparse. One core unless ngparse_cores asks for + * more -- there is never a reason to set it to 1, that being the default. + * + * Single-threaded is the measured right answer, not an omission: expansion is + * ~10% of deck-load time, the rest being INPpas1/2/3 and model setup here in + * ngspice, which this does not touch. The knob exists so that if that ever + * changes, parallelism lands without a surface change; ngparse accepts a larger + * value and warns that it is not honored yet. */ +static int ngparse_glue_cores(void) +{ + const char *s = getenv("ngparse_cores"); + if (!s || !*s) + return 1; + long n = strtol(s, NULL, 10); + if (n < 1) { + fprintf(stderr, + "ngparse: ngparse_cores=%s is not a positive integer; using 1\n", + s); + return 1; + } + return (int) n; +} + +/* Compatibility dialect for ngparse, read from ngspice's `ngbehavior` variable + * (set in spinit / .spiceinit). ngparse has taken over expansion, so ngspice's + * own pspice_compat pass -- which is what rewrites if()/VSWITCH/TABLE and injects + * the PSpice funcs -- never runs on our already-flat deck. We therefore tell + * ngparse the mode so it can apply those conversions itself and a `ngbehavior=ps` + * run matches the reference. 1 = PSpice; 0 = default/HSPICE. */ +static int ngparse_glue_compat(void) +{ + char behaviour[128]; + if (cp_getvar("ngbehavior", CP_STRING, behaviour, sizeof(behaviour))) { + /* leading "ps" selects PSpice, matching how ngspice keys newcompat.ps */ + if (behaviour[0] == 'p' && behaviour[1] == 's') + return 1; + } + return 0; +} + +/* ON by default in a build configured --enable-ngparse: enabling it at build + * time is already the deliberate choice, so there is nothing to opt into again + * on every run. `--no-ngparse` turns it off for a run -- an escape hatch if + * some deck ever trips ngparse up, without needing a rebuild. */ +static bool ngparse_requested = TRUE; + +void ngparse_glue_request(bool on) +{ + ngparse_requested = on; +} + +/* Does `filename` start with *ng_script -- i.e. is it a command file rather than + * a netlist? + * + * inp_spsource's `comfile` argument cannot answer this for us: it detects + * *ng_script by probing the deck AFTER inp_readall() has read it, which is well + * past the point where we must decide. So run the same test directly on the + * file, with the same "blank card" rule as that probe. + * + * Getting this wrong is not subtle: ngparse expands the .control script as if it + * were a netlist, producing an empty circuit ("Circuit: *" / "incomplete or + * empty netlist") before the script's own `source` line ever runs. */ +static bool file_is_ng_script(const char *filename) +{ + char buf[BSIZE_SP + 1]; + bool is_script = FALSE; + FILE *f = fopen(filename, "r"); + + if (!f) + return FALSE; /* let the normal path report the open failure */ + while (fgets(buf, sizeof buf, f)) { + if (buf[0] == '\0' || buf[0] == '\n' || + (buf[0] == '\r' && buf[1] == '\n')) + continue; /* leading blank card */ + is_script = ciprefix("*ng_script", buf) ? TRUE : FALSE; + break; + } + fclose(f); + return is_script; +} + +bool ngparse_glue_enabled(bool comfile, bool intfile, const char *filename) +{ + if (!ngparse_requested) + return FALSE; + /* Command files (*ng_script) are .control scripts, not netlists, and an + * internal/array deck has no file for ngparse to read. */ + if (comfile || intfile || !filename) + return FALSE; + if (file_is_ng_script(filename)) + return FALSE; + return TRUE; +} + +/* Resolve the real filesystem path of the file ngspice already opened: `fp` was + * located via `sourcepath`/inputdir, whereas `filename` may be a bare relative + * name that does NOT exist in the process cwd -- which is exactly the case when a + * *ng_script control deck does `source foo.net` with `set sourcepath = (dir)`. + * ngparse must read the file ngspice found, not re-open the relative name, so we + * read the open descriptor's path from /proc. Falls back to a copy of `filename` + * if that is unavailable (an internal deck with no fp, or a non-Linux host); the + * caller then behaves exactly as before. Returned string is owned by the caller. */ +char *ngparse_glue_realpath(FILE *fp, const char *filename) +{ + /* A NULL filename is the caller opting out (internal/array deck, or a + * multi-file `source` concatenated into a temp): keep it NULL so the glue + * skips, exactly as before. Only resolve a real, named file. */ + if (!filename) + return NULL; + if (fp) { + char proc[64]; + char buf[PATH_MAX]; + snprintf(proc, sizeof proc, "/proc/self/fd/%d", fileno(fp)); + ssize_t n = readlink(proc, buf, sizeof buf - 1); + if (n > 0) { + buf[n] = '\0'; + /* Trust it ONLY if it names a real, readable file. In batch mode + * ngspice slurps the top-level deck into an UNLINKED temp, whose + * descriptor reads back as "/tmp/#NNN (deleted)" -- not openable, and + * handing it to ngparse would break the *ng_script check and the + * expand. Fall back to `filename` (the pre-resolution behavior) then; + * the real win is the `source foo.net` case, where fp IS the file + * ngspice found via sourcepath and access() succeeds. */ + if (access(buf, R_OK) == 0) + return copy(buf); + } + } + return copy(filename ? filename : ""); +} + +char *ngparse_glue_expand(const char *filename) +{ + NgpDeck *deck; + size_t i, n, drops; + char *tmp_path; + FILE *out; + + deck = ngparse_expand_file(filename, ngparse_glue_cores(), ngparse_glue_compat()); + if (!deck) { + const char *err = ngparse_last_error(); + fprintf(stderr, "ngparse: %s\n", err ? err : "expansion failed"); + return NULL; + } + + n = ngparse_deck_len(deck); + if (n == 0) { + fprintf(stderr, "ngparse: %s expanded to an empty deck\n", filename); + ngparse_deck_free(deck); + return NULL; + } + + /* A dropped parameter is never harmless: the device or model silently falls + * back to its DEFAULT, which converges to a wrong answer rather than + * erroring. Report every one -- do not let the user find out from a bad + * waveform. */ + drops = ngparse_deck_drop_count(deck); + if (drops > 0) { + fprintf(stderr, + "ngparse: WARNING: %zu parameter(s) could not be resolved and were dropped;\n" + " the affected device/model falls back to its DEFAULT value:\n", + drops); + for (i = 0; i < drops && i < 10; i++) + fprintf(stderr, " %s\n", ngparse_deck_drop(deck, i)); + if (drops > 10) + fprintf(stderr, " ... and %zu more\n", drops - 10); + } + + /* smktemp() is ngspice's portable temp-name helper (see com_xgraph.c). */ + tmp_path = smktemp("ngp"); + out = fopen(tmp_path, "w"); + if (!out) { + fprintf(stderr, "ngparse: cannot write the temporary deck %s: %s\n", + tmp_path, strerror(errno)); + tfree(tmp_path); + ngparse_deck_free(deck); + return NULL; + } + + /* Card 0 is the TITLE, and is written first: whatever reads this deck back + * consumes line 1 as the title and starts the netlist at line 2. Dropping + * it would silently eat the first real card. */ + for (i = 0; i < n; i++) { + const char *card = ngparse_deck_card(deck, i); + if (fprintf(out, "%s\n", card ? card : "") < 0) { + fprintf(stderr, "ngparse: writing the temporary deck failed: %s\n", + strerror(errno)); + fclose(out); + remove(tmp_path); + tfree(tmp_path); + ngparse_deck_free(deck); + return NULL; + } + } + if (fclose(out) != 0) { + fprintf(stderr, "ngparse: closing the temporary deck failed: %s\n", + strerror(errno)); + remove(tmp_path); + tfree(tmp_path); + ngparse_deck_free(deck); + return NULL; + } + + if (ft_ngdebug) + fprintf(stdout, "ngparse: %s -> %s (%zu cards, %zu drops)\n", + filename, tmp_path, n, drops); + + ngparse_deck_free(deck); + return tmp_path; +} + +#else /* !USE_NGPARSE */ + +void ngparse_glue_request(bool on) +{ + /* --no-ngparse (on == FALSE) asks for the old parser, which is all this + * build has, so say nothing. Only an explicit --ngparse is worth a word. */ + if (on) + fprintf(stderr, + "ngspice: --ngparse: this build has no ngparse support " + "(configure with --enable-ngparse); parsing normally.\n"); +} + +bool ngparse_glue_enabled(bool comfile, bool intfile, const char *filename) +{ + NG_IGNORE(comfile); + NG_IGNORE(intfile); + NG_IGNORE(filename); + return FALSE; +} + +char *ngparse_glue_expand(const char *filename) +{ + NG_IGNORE(filename); + return NULL; +} + +char *ngparse_glue_realpath(FILE *fp, const char *filename) +{ + NG_IGNORE(fp); + NG_IGNORE(filename); + return NULL; +} + +#endif /* USE_NGPARSE */ diff --git a/src/frontend/numparam/spicenum.c b/src/frontend/numparam/spicenum.c index 5eebe6c75..bf02532d5 100644 --- a/src/frontend/numparam/spicenum.c +++ b/src/frontend/numparam/spicenum.c @@ -749,7 +749,7 @@ nupa_eval(struct card *card) } else if (c == 'B') { /* substitute braces line */ /* nupa_substitute() may reallocate line buffer. */ - /* HSPICE-style OSDI model cards (Samsung 14LPU et al.) embed + /* HSPICE-style OSDI model cards (foundry_b 14LPU et al.) embed * `{...}` expressions on the right-hand side of `.model` * params that reference per-instance geometry symbols (`l`, * `w`, `nf`, `m`, `xnf`). These cannot be evaluated at diff --git a/src/frontend/numparam/table_param.c b/src/frontend/numparam/table_param.c index 9320f9448..3ced74a84 100644 --- a/src/frontend/numparam/table_param.c +++ b/src/frontend/numparam/table_param.c @@ -205,7 +205,7 @@ static Table *load_table_file(const char *path) { * a malloc'd absolute path the caller must free, or a copy of the * input if no search is needed. * - * Foundry PDKs (Samsung 14LPU) reference tables by paths relative to + * Foundry PDKs (foundry_b 14LPU) reference tables by paths relative to * the .lib file that uses them — `./RF_COMPONENTS/foo.table` is * relative to the directory of fets_rf.lib (or wherever the call * originated), NOT to the user's cwd or ngspice's sourcepath. diff --git a/src/frontend/numparam/table_param.h b/src/frontend/numparam/table_param.h index f063bec38..bd062a2a3 100644 --- a/src/frontend/numparam/table_param.h +++ b/src/frontend/numparam/table_param.h @@ -1,9 +1,9 @@ /* * HSPICE table_param() implementation. * - * Foundry PDKs (Samsung 14LPU, TSMC, GF) use HSPICE's table_param() + * Foundry PDKs (foundry_b 14LPU, foundry_a, GF) use HSPICE's table_param() * extensively for table-file-based parameter lookup — self-heating - * thermal resistance, RF parasitics, etc. Samsung's TT corner alone + * thermal resistance, RF parasitics, etc. foundry_b's TT corner alone * references it ~1300 times. * * Syntax: diff --git a/src/frontend/numparam/xpressn.c b/src/frontend/numparam/xpressn.c index 49d2f42a7..c824b2142 100644 --- a/src/frontend/numparam/xpressn.c +++ b/src/frontend/numparam/xpressn.c @@ -1210,7 +1210,7 @@ formula(dico_t *dico, const char *s, const char *s_end, bool *perror) * `vec`, `min`, `max`, `pow`, `table_param`). Only treat * it as a function call if it's followed by `(` (after * optional whitespace). Otherwise fall back to treating - * it as a parameter name — foundry decks (GF55 bcd55 + * it as a parameter name — foundry decks (foundry_c bcd55 * diode_rr.inc) use `var` as a subckt parameter, and * shadowing it with the built-in function broke * `vrb='var'` and similar chains. */ @@ -1260,7 +1260,7 @@ formula(dico_t *dico, const char *s, const char *s_end, bool *perror) /* Symmetric to the `c == '-'` case above: a unary `+` directly * following a binary operator (e.g. `0.67*+2e-8`) is a no-op * sign — drop it and re-read the next token. Foundry decks - * (GF55 bcd55 fixed_corner_bcdlite.inc) use this idiom for + * (foundry_c bcd55 fixed_corner_bcdlite.inc) use this idiom for * explicit-sign literals: `(sw5)*(0.67*+2e-8)`. Without this, * the unary-plus form failed with "Misplaced operator" while * the unary-minus form parsed fine. */ diff --git a/src/frontend/subckt.c b/src/frontend/subckt.c index 50aaf1299..f534667dd 100644 --- a/src/frontend/subckt.c +++ b/src/frontend/subckt.c @@ -442,7 +442,7 @@ inp_subcktexpand(struct card *deck) { * name (typically the 5th token, after the 4 node terminals) names * the type. ngspice's subckt expander only handles the .subckt * case; if no .subckt matches, we'd error here. But foundry PDKs - * (Samsung 14LPU, TSMC, GF) routinely embed VA-module diagnostic + * (foundry_b 14LPU, foundry_a, GF) routinely embed VA-module diagnostic * instances like `xesd_monitor d g s b esd_nfet_monitor ...` * inside their FET subckts, expecting HSPICE-style dispatch. * @@ -605,7 +605,7 @@ get_model_bins(char *curr_line, float *fwmin, float *fwmax, parameter. HSPICE applies a subcircuit's `scale` parameter as the element scale factor for the MOSFETs inside it, multiplying their W/L before the model bins and simulates. Foundry MOS macro subckts - rely on this (e.g. TSMC `nch_mac ... scale='scale_mos'`). Detected + rely on this (e.g. foundry_a `nch_mac ... scale='scale_mos'`). Detected by a word-boundary "scale" immediately followed (after optional whitespace) by '=', so names like `l_scale=` / `noscale=` don't match. */ diff --git a/src/include/ngspice/ngparse_glue.h b/src/include/ngspice/ngparse_glue.h new file mode 100644 index 000000000..351ce7b4c --- /dev/null +++ b/src/include/ngspice/ngparse_glue.h @@ -0,0 +1,64 @@ +/* + * ngparse glue — use the ngparse Rust parser for deck expansion. + * + * ngparse replaces the slow part of the frontend: .lib/.inc section extraction, + * numparam substitution and .subckt expansion. On the foundry_b 14LPU PDK deck + * that takes model load from 15m27s to ~2s. + * + * The glue is deliberately shallow. ngparse expands the top deck into a flat, + * resolved netlist; inp_readall() is then run over THAT, so every compatibility + * pass it performs (inp_compat, inp_bsource_compat, inp_dot_if, + * inp_temper_compat, inp_meas_control, inp_add_series_resistor, renumbering) + * still happens, and inp_subcktexpand() still drives numparam over whatever + * expressions ngparse deliberately left symbolic (anything depending on + * `temper`, v(), i(), time, ...). .lib/.inc expansion simply finds nothing left + * to do. Nothing downstream of expansion changes. + * + * Opt-in, and OFF by default: + * + * ngspice deck.cir parses as it always has + * ngspice --ngparse deck.cir uses ngparse (single core) + * ngparse_cores=4 ngspice --ngparse deck.cir asks for 4 cores + * + * ngparse_cores asks for more than one core; there is no reason to set + * it to 1, which is the default. It is a forward-compatibility hook: expansion + * is single-threaded today and says so if asked for more. + */ +#ifndef NGPARSE_GLUE_H +#define NGPARSE_GLUE_H + +#include /* FILE (ngparse_glue_realpath) */ + +#include "ngspice/bool.h" + +/* Turn ngparse on/off. Called by main.c for `--ngparse`; off by default. */ +void ngparse_glue_request(bool on); + +/* TRUE if ngparse was requested AND can handle this source. Command files + * (*ng_script) are .control scripts, not netlists, and an internal/array deck + * has no file to read, so both take the normal path. */ +bool ngparse_glue_enabled(bool comfile, bool intfile, const char *filename); + +/* + * Expand `filename` with ngparse into a temporary netlist. + * + * Returns a malloc'd path the caller must unlink() and tfree(), or NULL on + * failure (reported to stderr). The caller opens it and passes it to + * inp_readall() in place of the original file. + * + * A temporary file rather than an in-memory hand-off because it reproduces + * exactly the `source ` path that ngparse is validated against; + * the write/read costs a few ms against a ~2s load. + */ +char *ngparse_glue_expand(const char *filename); + +/* + * Resolve the real path of the file ngspice already opened (`fp`, located via + * sourcepath/inputdir) so ngparse reads THAT rather than re-opening the possibly + * relative `filename` against the process cwd. Returns a malloc'd path the caller + * must tfree(); falls back to a copy of `filename` when the fp path is + * unavailable. Pass the result to ngparse_glue_enabled()/ngparse_glue_expand(). + */ +char *ngparse_glue_realpath(FILE *fp, const char *filename); + +#endif /* NGPARSE_GLUE_H */ diff --git a/src/include/ngspice/osdi_defer.h b/src/include/ngspice/osdi_defer.h index a5b9d5ff0..97769d9a2 100644 --- a/src/include/ngspice/osdi_defer.h +++ b/src/include/ngspice/osdi_defer.h @@ -1,7 +1,7 @@ /* * OSDI deferred-evaluation side table. * - * HSPICE-style PDK model cards (Samsung 14LPU et al.) embed expressions + * HSPICE-style PDK model cards (foundry_b 14LPU et al.) embed expressions * like * cgbn={((l<=1e-07)*(1e-012)+(l>1e-07)*(...))} * directly on the right-hand side of `.model` parameters. The expression @@ -44,7 +44,7 @@ * `snap_*` capture the subckt-instance scope visible at register time. * HSPICE-style PDKs put `.model` cards INSIDE a `.subckt` body and let * the model's expressions reference subckt-scope `.params` (e.g. - * Samsung 14LPU's `vsat1=...*(1+vsat_nfet/...)*velsat_mult` where + * foundry_b 14LPU's `vsat1=...*(1+vsat_nfet/...)*velsat_mult` where * `vsat_nfet`/`velsat_mult`/`xl_nfet` are passed to the subckt via * its `params:` list). By register time the subckt scope has been * resolved per-instance (subckt expansion produced model names like @@ -90,7 +90,7 @@ void osdi_defer_clear(void); * INPgetModBin once the model line's lmin/lmax tokens have been * parsed, so that OSDIsetup's pre-eval pass can pick a default * L within the bin's range (midpoint). Without this, default - * L=30nm makes Samsung-PDK expressions like + * L=30nm makes foundry_b-PDK expressions like * `vsat1='(l==14n)*X + (l==16n)*Y'` evaluate to 0 and BSIM-CMG * rejects "vsat1 = 0". Matched against the runtime model name * (after subckt-path prefix is stripped). */ diff --git a/src/include/ngspice/smpdefs.h b/src/include/ngspice/smpdefs.h index f44fc58ac..2589c69d9 100644 --- a/src/include/ngspice/smpdefs.h +++ b/src/include/ngspice/smpdefs.h @@ -90,6 +90,7 @@ int SMPcAddCol(SMPmatrix *Matrix, int Accum_Col, int Addend_Col); int SMPzeroRow(SMPmatrix *Matrix, int Row); void SMPconstMult(SMPmatrix *, double); void SMPmultiply(SMPmatrix *, double *, double *, double *, double *); +void SMPmultiplyAbs(SMPmatrix *, double *, double *, double *, double *); #ifdef CIDER void SMPcSolveForCIDER (SMPmatrix *, double [], double [], double [], double []) ; diff --git a/src/include/ngspice/spmatrix.h b/src/include/ngspice/spmatrix.h index 5c23e34d4..fcbfa25d6 100644 --- a/src/include/ngspice/spmatrix.h +++ b/src/include/ngspice/spmatrix.h @@ -292,6 +292,7 @@ extern void spConstMult(MatrixPtr, double); extern void spDeterminant ( MatrixPtr, int*, spREAL*, spREAL* ); extern int spFileVector( MatrixPtr, char * , spREAL*, spREAL*); extern void spMultiply( MatrixPtr, spREAL*, spREAL*, spREAL*, spREAL* ); +extern void spMultiplyAbs( MatrixPtr, spREAL*, spREAL*, spREAL*, spREAL* ); extern void spMultTransposed(MatrixPtr,spREAL*,spREAL*,spREAL*,spREAL*); extern void spSolve( MatrixPtr, spREAL*, spREAL*, spREAL*, spREAL* ); extern void spSolveTransposed(MatrixPtr,spREAL*,spREAL*,spREAL*,spREAL*); diff --git a/src/main.c b/src/main.c index 67ada5d8e..c2055beee 100644 --- a/src/main.c +++ b/src/main.c @@ -8,6 +8,7 @@ */ #include "ngspice/ngspice.h" +#include "ngspice/ngparse_glue.h" #include "ngspice/const.h" #include "ngspice/dstring.h" @@ -751,6 +752,11 @@ show_help(void) " -p, --pipe run in I/O pipe mode\n" " -r, --rawfile=FILE set the rawfile output\n" " --soa-log=FILE set the outputfile for SOA warnings\n" +#ifdef USE_NGPARSE + /* This build has the ngparse expander, and uses it by default. */ + " --no-ngparse parse decks with ngspice's own parser\n" + " instead of ngparse\n" +#endif " -s, --server run spice as a server process\n" " -t, --term=TERM set the terminal type\n" " -h, --help display this help and exit\n" @@ -948,7 +954,7 @@ int main(int argc, char **argv) /* --- Process command line options --- */ for (;;) { - enum { soa_log = 1001, }; + enum { soa_log = 1001, ngparse_opt = 1002, no_ngparse_opt = 1003, }; static struct option long_options[] = { {"define", required_argument, NULL, 'D'}, @@ -968,6 +974,8 @@ int main(int argc, char **argv) {"server", no_argument, NULL, 's'}, {"terminal", required_argument, NULL, 't'}, {"soa-log", required_argument, NULL, soa_log}, + {"ngparse", no_argument, NULL, ngparse_opt}, + {"no-ngparse", no_argument, NULL, no_ngparse_opt}, {NULL, 0, NULL, 0} }; @@ -1105,6 +1113,19 @@ int main(int argc, char **argv) } break; + case ngparse_opt: + /* Accepted and redundant in an --enable-ngparse build, where the + * expander is already on; kept so it can be written explicitly, and + * so it can say something useful in a build without ngparse. */ + ngparse_glue_request(TRUE); + break; + + case no_ngparse_opt: + /* Fall back to ngspice's own parser for this run -- the escape + * hatch if a deck ever trips ngparse up, no rebuild needed. */ + ngparse_glue_request(FALSE); + break; + case '?': break; diff --git a/src/maths/KLU/klusmp.c b/src/maths/KLU/klusmp.c index 47748f0fc..3a9d16969 100644 --- a/src/maths/KLU/klusmp.c +++ b/src/maths/KLU/klusmp.c @@ -2030,3 +2030,25 @@ SMPmultiply (SMPmatrix *Matrix, double *RHS, double *Solution, double *iRHS, dou } } +/* + * SMPmultiplyAbs() + * Real-only: RHS_n = sum_j G_nj*x_j, AbsRHS_n = sum_j |G_nj*x_j|, + * AbsRow_n = sum_j |G_nj|. + * Sparse-mode only — the axis-4 residual check in NIiter skips KLU mode, + * so the KLU branch just zeroes the outputs. + */ +void +SMPmultiplyAbs (SMPmatrix *Matrix, double *RHS, double *AbsRHS, double *AbsRow, + double *Solution) +{ + if (Matrix->CKTkluMODE) + { + size_t i, n = (size_t) Matrix->SMPkluMatrix->KLUmatrixN ; + for (i = 0 ; i <= n ; i++) + RHS [i] = AbsRHS [i] = AbsRow [i] = 0.0 ; + NG_IGNORE (Solution) ; + } else { + spMultiplyAbs (Matrix->SPmatrix, RHS, AbsRHS, AbsRow, Solution) ; + } +} + diff --git a/src/maths/ni/niiter.c b/src/maths/ni/niiter.c index b8af31209..50260cd11 100644 --- a/src/maths/ni/niiter.c +++ b/src/maths/ni/niiter.c @@ -160,6 +160,71 @@ NIiter(CKTcircuit *ckt, int maxIter) return (E_ITERLIM); } + /* Axis 4 — row-relative KCL residual convergence check. + * f = G*x - b is computed here, in the load->factor window + * where the matrix still holds the device Jacobian, and each + * row is compared against its own current scale + * s_n = sum_j |G_nj*x_j| + |b_n| (plus the gmin-stepping + * diagonal term for rows that receive it, so continuation + * systems are measured against what is actually solved): + * + * |f_n| <= 10*reltol * s_n + abstol + * + * The row-relative form is what makes this enforceable where + * the earlier absolute-norm attempt was not: lenient SPICE3 + * |dx|-only accepts that real PDK operating points depend on + * carry roundoff-scale RELATIVE residuals and pass untouched, + * while a false solution (e.g. a multi-million-fin OSDI + * device whose hundreds-of-siemens row satisfies |dx|CKTresidConverged = 1; +#ifdef KLU + if (!ckt->CKTresidCheckDisabled && !ckt->CKTmatrix->CKTkluMODE) +#else + if (!ckt->CKTresidCheckDisabled) +#endif + { + int rn, rsize = SMPmatSize(ckt->CKTmatrix); + double *rf = TMALLOC(double, (size_t) rsize + 1); + double *rs = TMALLOC(double, (size_t) rsize + 1); + double *rg = TMALLOC(double, (size_t) rsize + 1); + double rtol = 10.0 * ckt->CKTreltol; + double rvtol = 10.0 * ckt->CKTvoltTol; + char *rskip = ckt->CKTmatrix->gmin_skip; + SMPmultiplyAbs(ckt->CKTmatrix, rf, rs, rg, ckt->CKTrhsOld); + for (rn = 1; rn <= rsize; rn++) { + double gterm = (rskip && rskip[rn]) ? 0.0 : + ckt->CKTdiagGmin * ckt->CKTrhsOld[rn]; + double fr = rf[rn] + gterm - ckt->CKTrhs[rn]; + /* Tolerance = what a solution within per-unknown + * tolerance could produce on this row: the relative + * part scales with the row's own current magnitude, + * the row-norm part (|G|_1 * vntol) is the attainable + * precision of a stiff row — an LU solve cannot push + * a row residual below rownorm * solution-roundoff, + * and demanding it burns iterations at breakpoint + * corners where row values pass through zero. */ + double tol_ = rtol * (rs[rn] + fabs(gterm) + + fabs(ckt->CKTrhs[rn])) + + rvtol * rg[rn] + ckt->CKTabstol; + if (fabs(fr) > tol_) { + ckt->CKTresidConverged = 0; + break; + } + } + FREE(rf); + FREE(rs); + FREE(rg); + } + /* printf("after loading, before solving\n"); */ /* CKTdump(ckt); */ @@ -322,18 +387,9 @@ NIiter(CKTcircuit *ckt, int maxIter) memcpy(OldCKTstate0, ckt->CKTstate0, (size_t) ckt->CKTnumStates * sizeof(double)); - /* Axis 4 placeholder — the |f|-magnitude (residual-norm) half - * of dual-norm convergence stays disabled. A correct true-KCL - * residual check (f = G*x - b via SMPmultiply in the pre-factor - * window) was implemented and verified CORRECT, but enforcing it - * by default over-rejects real PDK operating points that rely on - * ngspice's lenient SPICE3 |dx|-only convergence (TSMC22 OP - * diverged, Samsung slowed badly) while a clean circuit (0.9V - * inverter) was byte-identical. So treat residual as always- - * passed; NIconvTest gates on |dx| only. The CKTresidConverged - * / CKTresidCheckDisabled fields + niconv.c gate + `.option - * noresidcheck` plumbing remain for a future opt-in form. */ - ckt->CKTresidConverged = 1; + /* Axis 4: CKTresidConverged was computed in the load->factor + * window above (row-relative KCL residual); by this point the + * matrix is factored and no longer holds the Jacobian. */ startTime = SPfrontEnd->IFseconds(); SMPsolve(ckt->CKTmatrix, ckt->CKTrhs, ckt->CKTrhsSpare); @@ -403,7 +459,7 @@ NIiter(CKTcircuit *ckt, int maxIter) * Stagnation = current max|Δv| not dropping by at * least 30% from prev iteration. Unconditional * halving past iter 3 was preventing convergence - * on the TSMC22 ULP driver_lv_2v5_tb VSN node: the + * on the foundry_a ULP driver_lv_2v5_tb VSN node: the * inductor-coupled supply (L1 = 2.7 nH) naturally * needs ~100 mV swings to track each switching * transition, but dv_max would collapse to 8 mV by diff --git a/src/maths/sparse/spsmp.c b/src/maths/sparse/spsmp.c index 96bb570f9..75460aa20 100644 --- a/src/maths/sparse/spsmp.c +++ b/src/maths/sparse/spsmp.c @@ -625,3 +625,15 @@ SMPmultiply(SMPmatrix *Matrix, double *RHS, double *Solution, double *iRHS, doub { spMultiply(Matrix->SPmatrix, RHS, Solution, iRHS, iSolution); } + +/* + * SMPmultiplyAbs() + * Real-only: RHS_n = sum_j G_nj*x_j, AbsRHS_n = sum_j |G_nj*x_j|, + * AbsRow_n = sum_j |G_nj|. + */ +void +SMPmultiplyAbs(SMPmatrix *Matrix, double *RHS, double *AbsRHS, double *AbsRow, + double *Solution) +{ + spMultiplyAbs(Matrix->SPmatrix, RHS, AbsRHS, AbsRow, Solution); +} diff --git a/src/maths/sparse/sputils.c b/src/maths/sparse/sputils.c index 32e7e9f5b..18153f618 100644 --- a/src/maths/sparse/sputils.c +++ b/src/maths/sparse/sputils.c @@ -611,6 +611,70 @@ spMultiply(MatrixPtr Matrix, RealVector RHS, RealVector Solution, } return; } + + + + +/* + * MATRIX MULTIPLICATION WITH ROW MAGNITUDES + * + * Real-only companion to spMultiply: computes, in a single traversal, + * the product RHS_n = sum_j G_nj * x_j, the row magnitude + * AbsRHS_n = sum_j |G_nj * x_j|, and the row 1-norm + * AbsRow_n = sum_j |G_nj|. The magnitudes give each row's natural + * current scale and its attainable precision (row norm times the + * per-unknown solution tolerance), so a caller can form a + * dimensionless residual test — the basis of the axis-4 KCL residual + * convergence check. Assumes the matrix is real and not factored. + */ + +void +spMultiplyAbs(MatrixPtr Matrix, RealVector RHS, RealVector AbsRHS, + RealVector AbsRow, RealVector Solution) +{ + ElementPtr pElement; + RealVector Vector; + RealNumber Sum, AbsSum, RowSum, Term; + int I, *pExtOrder; + + /* Begin `spMultiplyAbs'. */ + /* Note: Matrix->Complex may still carry its creation-time value before + * the first factor calls spSetReal; each element's Real field is the + * stamped real value either way, so only Factored matters here. */ + assert( IS_SPARSE( Matrix ) && !Matrix->Factored ); + if (!Matrix->RowsLinked) + spcLinkRows(Matrix); + if (!Matrix->InternalVectorsAllocated) + spcCreateInternalVectors( Matrix ); + + /* Initialize Intermediate vector with reordered Solution vector. */ + Vector = Matrix->Intermediate; + pExtOrder = &Matrix->IntToExtColMap[Matrix->Size]; + for (I = Matrix->Size; I > 0; I--) + Vector[I] = Solution[*(pExtOrder--)]; + + pExtOrder = &Matrix->IntToExtRowMap[Matrix->Size]; + for (I = Matrix->Size; I > 0; I--) + { + pElement = Matrix->FirstInRow[I]; + Sum = 0.0; + AbsSum = 0.0; + RowSum = 0.0; + + while (pElement != NULL) + { + Term = pElement->Real * Vector[pElement->Col]; + Sum += Term; + AbsSum += ABS(Term); + RowSum += ABS(pElement->Real); + pElement = pElement->NextInRow; + } + RHS[*pExtOrder] = Sum; + AbsRHS[*pExtOrder] = AbsSum; + AbsRow[*pExtOrder--] = RowSum; + } + return; +} #endif /* MULTIPLICATION */ diff --git a/src/osdi/osdi_defer.c b/src/osdi/osdi_defer.c index c2ca874f6..a6ca1680f 100644 --- a/src/osdi/osdi_defer.c +++ b/src/osdi/osdi_defer.c @@ -215,7 +215,7 @@ static int is_ident_cont(int c) { * * Reserved set: * l, w, nf, m, xnf — standard HSPICE instance params - * l_calc — Samsung/foundry "computed length" (= l + p_la + * l_calc — foundry_b/foundry "computed length" (= l + p_la * in the foundry subckt; defaults to l alone) */ static bool expr_refs_instance_geom(const char *expr) { static const char *const RES[] = { diff --git a/src/osdi/osdisetup.c b/src/osdi/osdisetup.c index a03507a72..6558ba70e 100644 --- a/src/osdi/osdisetup.c +++ b/src/osdi/osdisetup.c @@ -338,12 +338,12 @@ int OSDIsetup(SMPmatrix *matrix, GENmodel *inModel, CKTcircuit *ckt, * ranges and would otherwise reject the `0` placeholders that * osdi_defer_preprocess_line writes onto deferred-param slots. * - * Per-model default L: BSIM-CMG/Samsung 14LPU expressions look like + * Per-model default L: BSIM-CMG/foundry_b 14LPU expressions look like * vsat1 = '((l==0.014e-6)*X + (l==0.016e-6)*Y) * ...' * For these to evaluate to a NON-ZERO valid value at pre-eval time * (so setup_model accepts), the chosen L must satisfy at least one * of the `(l==...)` checks. Read the model's lmin/lmax (already - * populated by the .model parse) and pick the midpoint. Samsung's + * populated by the .model parse) and pick the midpoint. foundry_b's * 14LPU nfet.0 has lmin=10nm, lmax=18nm → midpoint=14nm, exactly * the L the expression looks for. For models without lmin/lmax, * fall back to 30nm (the original constant default). */ @@ -528,7 +528,7 @@ extern int OSDItemp(GENmodel *inModel, CKTcircuit *ckt) { * setup_instance so the model sees the actual computed values. * Skipped entirely (no map lookup, no syscalls) when this * model has no deferred entries — the common case for non- - * HSPICE-style OSDI PDKs like TSMC22 BSIM-BULK. */ + * HSPICE-style OSDI PDKs like foundry_a BSIM-BULK. */ if (has_deferred) { defer_apply_ctx ctx = { .descr = descr, diff --git a/src/osdi/osditrunc.c b/src/osdi/osditrunc.c index 84af3b7ac..e35f489a6 100644 --- a/src/osdi/osditrunc.c +++ b/src/osdi/osditrunc.c @@ -69,7 +69,7 @@ int OSDItrunc(GENmodel *in_model, CKTcircuit *ckt, double *timestep) { * gradually. * * Was 2.0× originally, tightened to 1.5× for pinb/net_7 oscillation - * (commit 143a0805f) and now to 1.2× for TSMC22 ULP driver_lv_2v5_tb + * (commit 143a0805f) and now to 1.2× for foundry_a ULP driver_lv_2v5_tb * to address residual pinb failure at t ≈ 1.369 µs. At 1.5×, dt * could grow from ~10 ps post-breakpoint to ~5 ns (the user's max * step) in ~15 accepted steps; once Newton had to land a 0.5 V swing diff --git a/src/spicelib/analysis/dctran.c b/src/spicelib/analysis/dctran.c index 76d447e64..c0908f592 100644 --- a/src/spicelib/analysis/dctran.c +++ b/src/spicelib/analysis/dctran.c @@ -695,7 +695,7 @@ resume: * non-uniform x-spacings produces a nonsensical slope at * the extrapolation end of the fit window, and Newton then * has to undo a 0.5-1 V starting offset on the affected - * nodes within itl4 iterations. Observed on TSMC22 ULP + * nodes within itl4 iterations. Observed on foundry_a ULP * driver_lv_2v5_tb: VSN (an L1-coupled supply driving * 500-finger BSIM-BULK drivers) catches a ~-0.7 V predictor * over-shoot and Newton can't undo it inside Stage A's @@ -762,6 +762,22 @@ resume: /* If no convergence in Central solver step */ if(converged != 0) { + /* A nonconverged attempt returns with CKTstate0 still holding + * the last failed Newton iterate's device states (limiter + * voltage history, charge states, OSDI LimitState slots) — + * NIiter does not restore them. The retry then evaluates + * devices against that garbage reference, lands further from + * the solution, and fails worse; the contamination compounds + * geometrically across retries at a single timepoint + * (observed: v6#branch −32 A → −58 → −310 → 3e10 A over ~25 + * retries) until the state explodes and dt collapses to + * delmin. Re-prime the working state from the last ACCEPTED + * point so every retry starts from physical values, exactly + * like a first attempt does. */ + if (ckt->CKTstate0 && ckt->CKTstate1) + memcpy(ckt->CKTstate0, ckt->CKTstate1, + (size_t) ckt->CKTnumStates * sizeof(double)); + #ifndef SHARED_MODULE ckt->CKTtime = ckt->CKTtime -ckt->CKTdelta; ckt->CKTstat->STATrejected ++; diff --git a/src/spicelib/analysis/dctrcurv.c b/src/spicelib/analysis/dctrcurv.c index ac1fe4e1d..19ad898a3 100644 --- a/src/spicelib/analysis/dctrcurv.c +++ b/src/spicelib/analysis/dctrcurv.c @@ -672,7 +672,7 @@ DCtrCurv(CKTcircuit *ckt, int restart) * On the LAST accepted iteration (the one whose NEXT * would trigger the stop check), snap cur_val to stop * exactly so the saved row's X-value is bit-exact. - * GF55 bcd55 isoednfet's `.measure when v(n2)=3.3` + * foundry_c bcd55 isoednfet's `.measure when v(n2)=3.3` * relies on this. */ job->TRCVstepCount[i]++; double cur_val = job->TRCVvStart[i] + diff --git a/src/spicelib/devices/hisimhv1/hsmhvld.c b/src/spicelib/devices/hisimhv1/hsmhvld.c index 77d524a48..32c230924 100644 --- a/src/spicelib/devices/hisimhv1/hsmhvld.c +++ b/src/spicelib/devices/hisimhv1/hsmhvld.c @@ -1991,6 +1991,12 @@ line755: /* standard entry if HSMHVevaluate is bypassed */ /* Preset vectors and matrix for dynamic part */ cq_d = cq_dP = cq_g = cq_gP = cq_s = cq_sP = cq_bP = cq_b = cq_db = cq_sb = cq_t = cq_qi = cq_qb = 0.0 ; + /* Reset the external-charge displacement currents per instance too: + * they are function-scope locals only ever added to below, so without + * this each instance inherits the accumulated cq_gE/cq_bE/cq_sE of + * all previously processed instances of the same model (same defect + * as in hisimhv2/hsmhv2ld.c). */ + cq_dE = cq_gE = cq_sE = cq_bE = 0.0 ; for (i = 0; i < XDIM ; i++) { ydyn_d[i] = ydyn_dP[i] = ydyn_g[i] = ydyn_gP[i] = ydyn_s[i] = ydyn_sP[i] = ydyn_bP[i] = ydyn_b[i] = ydyn_db[i] = ydyn_sb[i] = ydyn_t[i] = 0.0; diff --git a/src/spicelib/devices/hisimhv2/hsmhv2ld.c b/src/spicelib/devices/hisimhv2/hsmhv2ld.c index f8f9908c1..1a40a3a5e 100644 --- a/src/spicelib/devices/hisimhv2/hsmhv2ld.c +++ b/src/spicelib/devices/hisimhv2/hsmhv2ld.c @@ -651,7 +651,7 @@ int HSMHV2load( /* NaN-recovery (sanity damping). * - * Symptom: on big PDKs (Samsung 14LPU 3.3 V LDMOS as `ld3nfet`) + * Symptom: on big PDKs (foundry_b 14LPU 3.3 V LDMOS as `ld3nfet`) * the initial DC operating-point Newton iteration occasionally * lands on bias values that make the HiSIM_HV physics produce * NaN currents/conductances. Those NaN entries are written @@ -2320,6 +2320,18 @@ line755: /* standard entry if HSMHV2evaluate is bypassed */ /* Preset vectors and matrix for dynamic part */ cq_d = cq_dP = cq_g = cq_gP = cq_s = cq_sP = cq_bP = cq_b = cq_db = cq_sb = cq_t = cq_qi = cq_qb = 0.0 ; + /* The external-charge displacement currents must be reset per instance + * as well: they are function-scope locals that are only ever added to + * below, so without this reset each instance inherits the accumulated + * cq_gE/cq_bE/cq_sE of all previously processed instances of the same + * model -- a leaked gate/bulk current with no matrix counterpart that + * grows with the instance count and (at the huge ag0 of the small + * opening transient timesteps) walks parallel power devices away from + * the operating point until the model's internal solvers blow up. + * (cq_dE is re-read from the state vector further below, but is + * included here for symmetry and for the !ChargeComputationNeeded + * path.) */ + cq_dE = cq_gE = cq_sE = cq_bE = 0.0 ; for (i = 0; i < XDIM ; i++) { ydyn_d[i] = ydyn_dP[i] = ydyn_g[i] = ydyn_gP[i] = ydyn_s[i] = ydyn_sP[i] = ydyn_bP[i] = ydyn_b[i] = ydyn_db[i] = ydyn_sb[i] = ydyn_t[i] = 0.0; diff --git a/src/spicelib/parser/inpdpar.c b/src/spicelib/parser/inpdpar.c index 36e7786da..bbd8dcacd 100644 --- a/src/spicelib/parser/inpdpar.c +++ b/src/spicelib/parser/inpdpar.c @@ -202,7 +202,7 @@ INPdevParse(char **line, CKTcircuit *ckt, int dev, GENinstance *fast, goto quit; } /* OSDI models may receive extra instance parameters from PDK - * subcircuits (e.g. 'total' from TSMC nch_mac) that the model + * subcircuits (e.g. 'total' from foundry_a nch_mac) that the model * does not define. Skip the parameter and its value rather than * aborting; this matches HSPICE/Spectre behaviour. */ if (device->registry_entry != NULL) { diff --git a/src/spicelib/parser/inpgmod.c b/src/spicelib/parser/inpgmod.c index 895e986ec..4c8b2c3cb 100644 --- a/src/spicelib/parser/inpgmod.c +++ b/src/spicelib/parser/inpgmod.c @@ -305,7 +305,7 @@ INPgetModBin(CKTcircuit *ckt, char *name, INPmodel **model, INPtables *tab, char *model = NULL; - /* Read L (required) and W (optional). FinFET PDKs (Samsung 14LPU + /* Read L (required) and W (optional). FinFET PDKs (foundry_b 14LPU * et al.) have no W on the instance line — only `l=` and * `nfin=` — and their `.model` cards bin on `lmin/lmax/nfinmin/ * nfinmax`, not on W. Previously this function bailed out as @@ -348,8 +348,8 @@ INPgetModBin(CKTcircuit *ckt, char *name, INPmodel **model, INPtables *tab, char /* OSDI bin selection: compare per-finger W (w / nf, already applied * above) against the bin's wmin/wmax. Do NOT divide by m — `m` is a * parallel-instance multiplier (the whole device is replicated m - * times), so the per-finger geometry is unchanged by it. TSMC and - * GlobalFoundries BSIM-BULK decks author their wmin/wmax tables + * times), so the per-finger geometry is unchanged by it. foundry_a and + * foundry_c BSIM-BULK decks author their wmin/wmax tables * against the HSPICE/Spectre convention of per-finger W only, so an * additional /m here would push the effective W below every bin's * lower bound for any moderately-large multi-instance device. */ @@ -399,9 +399,9 @@ INPgetModBin(CKTcircuit *ckt, char *name, INPmodel **model, INPtables *tab, char } /* Compare the per-finger W (already w/nf above) and L against - * the bin's wmin/wmax and lmin/lmax. Foundry .lib files (TSMC, - * GlobalFoundries, Samsung) author bin boundaries in POST-SHRINK - * (EFFECTIVE) dimensions: e.g. TSMC 22nm ULP nch.1 wmax=2.5651µm + * the bin's wmin/wmax and lmin/lmax. Foundry .lib files (foundry_a, + * foundry_c, foundry_b) author bin boundaries in POST-SHRINK + * (EFFECTIVE) dimensions: e.g. foundry_a 22nm ULP nch.1 wmax=2.5651µm * = drawn 3µm × shrink 0.855. The shrink is applied upstream in * subckt expansion (the subcircuit's `scale` parameter multiplies * the device W/L) or by the model's own dimension expressions, so @@ -425,7 +425,7 @@ INPgetModBin(CKTcircuit *ckt, char *name, INPmodel **model, INPtables *tab, char *model = modtmp; /* Stash the bin's (lmin, lmax) for OSDIsetup's deferred-eval * pre-pass, which needs a representative L within the bin's - * range to make Samsung-PDK expressions like + * range to make foundry_b-PDK expressions like * `(l==14n)*X + (l==16n)*Y` evaluate to non-zero. */ if (is_osdi) osdi_defer_record_bin_range(modtmp->INPmodName, lmin, lmax);