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);