From 59011cf4d21796e17bccd93b198102a1baca0806 Mon Sep 17 00:00:00 2001 From: Robb Walters Date: Mon, 5 Oct 2026 12:49:25 -0700 Subject: [PATCH] feat(inp): read FastHenry .units spellings, missing and repeated .units under --fasthenry-compat Under --fasthenry-compat only: a missing .units is metres with a warning on the first line that needs a unit; repeated .units apply forward only; the long spellings FastHenry reads correctly (meter(s), metre(s), kilometer(s), kilometre(s), inch(es)) are accepted with a warning; spellings FastHenry misreads (millimeter*/milli* as mils, micron*/micrometer* as metres) are an error naming the misreading. Native mode is unchanged. Closes #144 Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 9 ++ docs/fasthenry-compat.md | 7 +- fasterhenry-cli/src/inp.rs | 257 ++++++++++++++++++++++++++++++++++++- 3 files changed, 265 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a4c1bca..8c6db79 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,15 @@ breaking changes to the API or the command-line interface; patch releases `--fasthenry-compat`, one empty coordinate field in a node reference (`N1 (, 2, 0)`) now reads as 0 (before the offset) with a line-numbered warning; natively it is still an error. +- `--fasthenry-compat`: `.units` reads as FastHenry does (issue #144). A + deck with no `.units` is in metres, with a line-numbered warning; several + `.units` lines are honoured, each from its own line onward (values already + read, `.default` fields included, keep their unit); and the long spellings + FastHenry reads correctly (`meter(s)`, `metre(s)`, `kilometer(s)`, + `kilometre(s)`, `inch`, `inches`) are accepted with a warning. Spellings + FastHenry silently misreads — `millimeter…`/`milli…` (as mils) and + `micron…`/`micrometer…` (as metres) — are an error naming the misreading + and the documented unit. Default mode is unchanged. - Deck reader: a line-numbered **warning** for a corner-point `G` statement's `hole point`, `hole circle`, `hole rect`, `contact rect` or `contact decay_rect` clause that lies **wholly outside** the plane's diff --git a/docs/fasthenry-compat.md b/docs/fasthenry-compat.md index 1b478ca..a8ca5e2 100644 --- a/docs/fasthenry-compat.md +++ b/docs/fasthenry-compat.md @@ -43,14 +43,17 @@ Status key: | Field | Status | Notes | |---|---|---| -| `.units ` | Supported, differs (mandatory) | The engine takes the presence of `.units` as mandatory rather than defaulting silently — a deliberate safety choice, so a missing unit line can never scale every length (and therefore every impedance) by a factor of 10 or 100 without a diagnostic. A deck without `.units` is rejected with a line-numbered error naming the missing directive rather than assuming a default unit. | +| `.units ` | Supported, differs (mandatory) | The engine takes the presence of `.units` as mandatory rather than defaulting silently — a deliberate safety choice, so a missing unit line can never scale every length (and therefore every impedance) by a factor of 10 or 100 without a diagnostic. A deck without `.units` is rejected with a line-numbered error naming the missing directive rather than assuming a default unit. Under `--fasthenry-compat` (issue #144) a missing `.units` reads as metres, as FastHenry does, with a line-numbered warning on the first line that needs a unit (`no .units directive before this line; reading lengths in metres, …`). | +| Several `.units` lines | Supported, differs (opt-in) | The public guide honours each `.units` from its own line onward. By default a second `.units` is a line-numbered error (`duplicate .units directive`). Under `--fasthenry-compat` (issue #144) each applies forward only — to lengths and explicit conductivities (`sigma=`/`rho=`) on later lines — never retroactively: every value already read, `.default` fields included, is stored in metres and S/m at the unit in force on its own line, so a `.default sigma=5.8e4` under `.units mm` stays 5.8e7 S/m after a later `.units m`. | | Full documented unit list: `km`, `m`, `cm`, `mm`, `um`, `in`, `mils` | Supported | Case-insensitive (issue #70); `mil` is also accepted as a synonym for `mils`. | +| Long spellings FastHenry reads correctly: `meter(s)`, `metre(s)`, `kilometer(s)`, `kilometre(s)`, `inch`, `inches` | Supported, differs (opt-in) | Issue #144. FastHenry matches `.units` by prefix, so it reads these at the right scale. Rejected by default (`unknown length unit`); under `--fasthenry-compat` accepted at the documented unit's factor with a line-numbered warning naming the documented spelling. Case is ignored. | +| Spellings FastHenry misreads: `millimeter…`/`millimetre…`/`milli…`, `micron…`/`micrometer…`/`micrometre…` | Rejected, differs (deliberate) | Issue #144. FastHenry's prefix match reads the first family as **mils** (2.54e-5 m) and the second as **metres**, silently solving at the wrong scale. This reader rejects them in both modes, with a line-numbered error that, under `--fasthenry-compat`, names FastHenry's misreading and the documented spelling (`mm`, `um`): matching FastHenry here would reproduce a wrong answer. Other spellings FastHenry rejects (`centimeter`, `nm`, `ft`, bare `c`/`u`/`i`) are the ordinary unknown-unit error. | ## `.default` | Field | Status | Notes | |---|---|---| -| `.default x= y= z= w= h= nwinc= nhinc= rw= rh= wx= wy= wz= sigma=` | Supported | Applies to every `N`/`E` line parsed after it; not retroactive. Requires `.units` first (lengths need a scale factor before they can be stored). | +| `.default x= y= z= w= h= nwinc= nhinc= rw= rh= wx= wy= wz= sigma=` | Supported | Applies to every `N`/`E` line parsed after it; not retroactive. Requires `.units` first (lengths need a scale factor before they can be stored); under `--fasthenry-compat` a deck with none is in metres, with a warning (see `.units`). | | `.default rho=` | Supported | Issue #70. Per deck unit, the exact reciprocal of `sigma=`; must be positive. Naming both `sigma=` and `rho=` on one line is a line-numbered error; a later per-line value in either form overrides a `.default` in either form. | ## Nodes (`N`) diff --git a/fasterhenry-cli/src/inp.rs b/fasterhenry-cli/src/inp.rs index 342a07c..f53d457 100644 --- a/fasterhenry-cli/src/inp.rs +++ b/fasterhenry-cli/src/inp.rs @@ -459,7 +459,15 @@ //! (coordinates, `w`, `h`) scale with the unit; **conductivity and //! resistivity are per deck unit** — `sigma=5.8e4` under `.units mm` is //! copper (5.8e4 S/mm = 5.8e7 S/m), and so is `rho=1.7241e-5` -//! (Ω·mm = 1.7241e-8 Ω·m). `rho=r` is exactly `sigma=1/r`. +//! (Ω·mm = 1.7241e-8 Ω·m). `rho=r` is exactly `sigma=1/r`. Under +//! [`ParseOptions::fasthenry_compat`] (issue #144) a missing `.units` is +//! metres with a [`ParseWarning`]; `.units` may repeat, each applying +//! from its own line onward (a value already read — `.default` fields +//! included — keeps the unit it was written in); and the long spellings +//! FastHenry reads correctly (`meter(s)`, `metre(s)`, `kilometer(s)`, +//! `kilometre(s)`, `inch`, `inches`) are accepted with a warning, while +//! those it misreads (`millimeter…`/`milli…` as mils, `micron…`/ +//! `micrometer…` as metres) are an error naming the misreading. //! * **`nwinc`/`nhinc` and `rw`/`rh` cut the cross-section into //! filaments.** `nwinc` filaments go across the width and `nhinc` across //! the height (default 1 each). `rw` and `rh` are the ratio of adjacent @@ -692,6 +700,49 @@ fn unit_factor(unit: &str, line: usize) -> Result { /// The accepted `.units` spellings, for error messages. const UNITS: &str = "km, m, cm, mm, um, in, mils"; +/// [`unit_factor`] under [`ParseOptions::fasthenry_compat`] (issue #144): +/// also the long spellings FastHenry reads *correctly* — `meter(s)`, +/// `metre(s)`, `kilometer(s)`, `kilometre(s)`, `inch`, `inches` — each with +/// a [`ParseWarning`] naming the documented spelling. Spellings FastHenry +/// silently *misreads* are an error naming the misreading, so a deck that +/// FastHenry solves at the wrong scale is not solved at it here either: +/// `millimeter`/`millimetre`/`milli…` (FastHenry reads mils) and +/// `micron…`/`micrometer`/`micrometre…` (FastHenry reads metres). +fn compat_unit_factor(unit: &str, line: usize) -> Result<(f64, Option), ParseError> { + let unknown = match unit_factor(unit, line) { + Ok(factor) => return Ok((factor, None)), + Err(error) => error, + }; + let lower = unit.to_ascii_lowercase(); + let misread = |reads: &str, documented: &str| { + err( + line, + format!( + "'.units {unit}' is not a documented unit, and FastHenry misreads it as {reads}, which would scale every length wrongly; write '.units {documented}' (supported: {UNITS})" + ), + ) + }; + if lower.starts_with("milli") { + return Err(misread("mils (2.54e-5 m)", "mm")); + } + if lower.starts_with("micron") || lower.starts_with("micromet") { + return Err(misread("metres", "um")); + } + let (factor, documented) = match lower.as_str() { + "meter" | "meters" | "metre" | "metres" => (1.0, "m"), + "kilometer" | "kilometers" | "kilometre" | "kilometres" => (1e3, "km"), + "inch" | "inches" => (0.0254, "in"), + _ => return Err(unknown), + }; + let warning = ParseWarning { + line, + message: format!( + "'.units {unit}' is not one of the documented units ({UNITS}); reading it as '{documented}', as FastHenry does" + ), + }; + Ok((factor, Some(warning))) +} + /// Rejects a line that gives its conductivity both ways: `sigma=` and /// `rho=` among the same line's `=` tokens. fn one_conductivity(fields: &[&str], line: usize) -> Result<(), ParseError> { @@ -3122,6 +3173,13 @@ pub struct ParseOptions { /// independent of `.units` (unlike an explicit `sigma=`, which is per /// deck unit), and raises a [`ParseWarning`] on the statement's line. /// Off, that is a line-numbered error (issue #142). + /// + /// On, `.units` reads as FastHenry does (issue #144): a deck without + /// one is in metres, with a [`ParseWarning`] on the first line that + /// needs a unit; a repeated `.units` applies from its own line onward; + /// and FastHenry's correctly read long spellings (`meters`, `inches`, …) + /// are accepted with a warning, while the spellings FastHenry misreads + /// (`millimeter`, `micron`, …) are rejected naming the misreading. pub fasthenry_compat: bool, } @@ -3292,6 +3350,9 @@ struct DeckBuilder { /// Whether the deck is read under [`ParseOptions::fasthenry_compat`]: /// among other things, a conductor naming no conductivity is copper /// (with a warning) rather than an error. + /// + /// Likewise: `.units` is optional (metres, with a warning), repeatable, + /// and reads FastHenry's long spellings. compat: bool, } @@ -3313,6 +3374,23 @@ impl DeckBuilder { } let head = tokens[0]; let keyword = head.to_ascii_lowercase(); + // Under compat, a deck with no `.units` before its first line that + // needs a unit is in metres, as FastHenry reads it — said out loud + // on that line (issue #144). A later `.units` still applies from + // its own line onward. + if self.compat + && self.unit.is_none() + && (matches!(keyword.as_str(), ".default" | ".hole" | ".contact") + || matches!(keyword.chars().next(), Some('n' | 'e' | 'g'))) + { + self.unit = Some(1.0); + self.warnings.push(ParseWarning { + line: number, + message: format!( + "no .units directive before this line; reading lengths in metres, as FastHenry does (write '.units m' to say so, or one of {UNITS})" + ), + }); + } // The unit in force for *this* line: a line that needs one and was // read before `.units` is rejected by the method that reads it. let factor = self.unit.unwrap_or(1.0); @@ -3382,11 +3460,23 @@ impl DeckBuilder { } } - /// `.units km|m|cm|mm|um|in|mils` — mandatory, and at most once. + /// `.units km|m|cm|mm|um|in|mils` — mandatory, and at most once; under + /// [`ParseOptions::fasthenry_compat`], any number of times, each from + /// its own line onward, and in the long spellings of + /// [`compat_unit_factor`]. fn apply_units(&mut self, tokens: &[&str], number: usize) -> Result<(), ParseError> { if tokens.len() != 2 { return Err(err(number, "expected .units ")); } + if self.compat { + // Forward only: every value read so far (nodes, `.default` + // fields, segments, planes) is already stored in metres and + // S/m, so a new factor cannot reach back to it. + let (factor, warning) = compat_unit_factor(tokens[1], number)?; + self.warnings.extend(warning); + self.unit = Some(factor); + return Ok(()); + } if self.unit.is_some() { return Err(err(number, "duplicate .units directive")); } @@ -3948,7 +4038,10 @@ impl DeckBuilder { if !self.ended { return Err(err(last_number, "deck has no .end directive")); } - if self.unit.is_none() { + // Under compat a missing `.units` is metres, set (with a warning) + // by the first line that needed a unit; a deck with no such line + // has nothing for a unit to scale. + if self.unit.is_none() && !self.compat { return Err(err( last_number, format!( @@ -4469,9 +4562,12 @@ e1 n1 n2 w=1e-3 h=1e-4 sigma=5.8e7 #[test] fn compat_mode_ignores_directive_like_first_line() { // Line 1 is skipped whatever it holds: a `.units` there is swallowed, - // so the deck then lacks `.units` — the documented cost of the mode. - let error = parse_compat(COMPAT_BODY).expect_err(".units on line 1 is the title"); - assert!(error.message.contains(".units"), "{error}"); + // so the deck then lacks `.units` and reads in metres, with a warning + // (issue #144) — the documented cost of the mode. + let (deck, warnings) = units_compat(COMPAT_BODY).expect(".units on line 1 is the title"); + assert_eq!(deck.title.as_deref(), Some(".units m")); + assert_eq!(warnings.len(), 1, "{warnings:?}"); + assert!(warnings[0].message.contains("no .units"), "{warnings:?}"); // A `*` comment or a `.title` on line 1 is likewise just title text. let deck = parse_compat(&format!("* comment-looking title\n{COMPAT_BODY}")).unwrap(); assert_eq!(deck.title.as_deref(), Some("* comment-looking title")); @@ -8495,4 +8591,153 @@ G1 x1=0 y1=0 z1=0 x2=16 y2=0 z2=0 x3=16 y3=8 z3=0 ); } } + + /// Compat mode, reporting (issue #144's tests). + fn units_compat(text: &str) -> Result<(Deck, Vec), ParseError> { + parse_with_options_reporting(text, COMPAT) + } + + /// A titled compat deck: a 10-unit bar under `.units {unit}`. + fn units_deck(unit: &str) -> String { + format!("title\n{}", conductivity_deck(unit, "sigma=1", "")) + } + + /// Issue #144: under compat the long spellings FastHenry reads + /// correctly are accepted, at the documented unit's factor, each with a + /// warning on the `.units` line naming the documented spelling; case + /// is ignored. + #[test] + fn compat_units_accepts_long_spellings_with_a_warning() { + for (spelling, documented) in [ + ("meter", "m"), + ("Meters", "m"), + ("metre", "m"), + ("METRES", "m"), + ("kilometer", "km"), + ("kilometres", "km"), + ("inch", "in"), + ("Inches", "in"), + ] { + let (deck, warnings) = + units_compat(&units_deck(spelling)).unwrap_or_else(|error| panic!("{error}")); + let (expected, none) = units_compat(&units_deck(documented)).unwrap(); + assert!(none.is_empty(), "{documented}: {none:?}"); + assert_eq!(deck.geometry, expected.geometry, "{spelling}"); + assert_eq!(warnings.len(), 1, "{spelling}: {warnings:?}"); + assert_eq!(warnings[0].line, 2, "{spelling}"); + assert!( + warnings[0] + .message + .contains(&format!("reading it as '{documented}'")), + "{spelling}: {}", + warnings[0].message + ); + } + // The documented seven (and `mil`) warn about nothing. + for unit in ["km", "m", "cm", "mm", "um", "in", "mils", "mil", "MM"] { + let (_, warnings) = units_compat(&units_deck(unit)).unwrap(); + assert!(warnings.is_empty(), "{unit}: {warnings:?}"); + } + let (deck, _) = units_compat(&units_deck("inches")).unwrap(); + let segment = deck.geometry.segment(0).unwrap(); + assert!((segment.length() - 0.254).abs() < 1e-15); + } + + /// Spellings FastHenry silently misreads are errors naming the + /// misreading and the documented spelling, not reproduced. + #[test] + fn compat_units_rejects_spellings_fasthenry_misreads() { + for (spelling, reads, documented) in [ + ("millimeter", "mils", "mm"), + ("Millimeters", "mils", "mm"), + ("millimetre", "mils", "mm"), + ("milli", "mils", "mm"), + ("micron", "metres", "um"), + ("microns", "metres", "um"), + ("micrometer", "metres", "um"), + ("micrometres", "metres", "um"), + ] { + let error = units_compat(&units_deck(spelling)).expect_err(spelling); + assert_eq!(error.line, 2, "{spelling}"); + assert!( + error + .message + .contains(&format!("FastHenry misreads it as {reads}")), + "{spelling}: {error}" + ); + assert!( + error.message.contains(&format!("'.units {documented}'")), + "{spelling}: {error}" + ); + } + // Anything else stays the native unknown-unit error. + for spelling in ["centimeter", "nm", "ft", "c", "u", "i"] { + let error = units_compat(&units_deck(spelling)).expect_err(spelling); + assert!( + error.message.contains("unknown length unit"), + "{spelling}: {error}" + ); + } + } + + /// Under compat a deck with no `.units` is in metres, with one warning + /// on the first line that needs a unit. + #[test] + fn compat_missing_units_is_metres_with_a_warning() { + let with = units_deck("m"); + let without = with.replacen(".units m\n", "", 1); + let (deck, warnings) = units_compat(&without).unwrap_or_else(|error| panic!("{error}")); + assert_eq!(deck.geometry, units_compat(&with).unwrap().0.geometry); + assert_eq!(warnings.len(), 1, "{warnings:?}"); + assert_eq!(warnings[0].line, 2); + assert!(warnings[0].message.contains("metres"), "{warnings:?}"); + } + + /// Under compat each `.units` applies from its own line onward: values + /// already read — nodes, `.default` fields, segments — keep the unit + /// they were written in, and later lines take the new one. + #[test] + fn compat_repeated_units_apply_forward() { + let text = "\ +title +.units mm +.default z=0 w=1 h=1 sigma=5.8e4 +n1 x=0 y=0 +.units m +n2 x=0.01 y=0 +e1 n1 n2 +e2 n1 n2 sigma=5.8e7 w=1e-3 h=1e-3 +.units cm +n3 x=2 y=0 +e3 n2 n3 +.external n1 n3 +.freq fmin=1 fmax=1 ndec=1 +.end +"; + let (deck, warnings) = units_compat(text).unwrap_or_else(|error| panic!("{error}")); + assert!(warnings.is_empty(), "{warnings:?}"); + let e1 = deck.geometry.segment(0).unwrap(); + // n2 at 0.01 m, w/h and sigma from the mm-era `.default`: 1 mm and + // 5.8e4 S/mm = 5.8e7 S/m, not re-scaled by the later `.units m`. + assert!((e1.length() - 0.01).abs() < 1e-15); + assert!((e1.width - 1e-3).abs() < 1e-18); + assert!((e1.sigma - 5.8e7).abs() < 1e-3); + // An explicit sigma after `.units m` is per metre. + let e2 = deck.geometry.segment(1).unwrap(); + assert!((e2.sigma - 5.8e7).abs() < 1e-3); + // n3 at 2 cm. + let e3 = deck.geometry.segment(2).unwrap(); + assert!((e3.length() - 0.01).abs() < 1e-15); + + // Native mode is unchanged: a second `.units`, a missing `.units` + // and a long spelling are each still an error. + let error = parse(text.replacen("title\n", "", 1).as_str()).unwrap_err(); + assert_eq!(error.line, 4); + assert!(error.message.contains("duplicate .units"), "{error}"); + let native = conductivity_deck("m", "sigma=1", ""); + let error = parse(&native.replacen(".units m\n", "", 1)).unwrap_err(); + assert!(error.message.contains("before .units"), "{error}"); + let error = parse(&native.replacen(".units m", ".units meters", 1)).unwrap_err(); + assert!(error.message.contains("unknown length unit"), "{error}"); + } }