Skip to main content

pedalkernel/
models.rs

1//! SPICE `.MODEL` file parser and model registry.
2//!
3//! Parses standard SPICE `.MODEL` statements from embedded model files
4//! and provides lookup by model name. Supports engineering suffixes
5//! (f, p, n, u, m, k, MEG) for parameter values.
6//!
7//! Model files are included at compile time via `include_str!` and parsed
8//! into a lazy-initialized registry on first access.
9
10use hashbrown::HashMap;
11use std::sync::LazyLock;
12
13// ---------------------------------------------------------------------------
14// Raw model files embedded at compile time
15// ---------------------------------------------------------------------------
16
17static TRANSISTOR_MODELS_SRC: &str = include_str!("../models/transistors.model");
18static JFET_MODELS_SRC: &str = include_str!("../models/jfets.model");
19static TRIODE_MODELS_SRC: &str = include_str!("../models/triodes.model");
20static PENTODE_MODELS_SRC: &str = include_str!("../models/pentodes.model");
21static DIODE_MODELS_SRC: &str = include_str!("../models/diodes.model");
22static LED_MODELS_SRC: &str = include_str!("../models/leds.model");
23static SCHOTTKY_MODELS_SRC: &str = include_str!("../models/schottky.model");
24static ZENER_MODELS_SRC: &str = include_str!("../models/zeners.model");
25static OPAMP_MODELS_SRC: &str = include_str!("../models/opamps.model");
26static TRANSFORMER_MODELS_SRC: &str = include_str!("../models/transformers.model");
27
28// ---------------------------------------------------------------------------
29// Parsed model registry (lazy-initialized)
30// ---------------------------------------------------------------------------
31
32/// All BJT models parsed from the embedded transistors.model file.
33pub static BJT_MODELS: LazyLock<HashMap<String, SpiceBjtModel>> =
34    LazyLock::new(|| parse_bjt_models(TRANSISTOR_MODELS_SRC));
35
36/// All JFET models parsed from the embedded jfets.model file.
37pub static JFET_MODELS: LazyLock<HashMap<String, SpiceJfetModel>> =
38    LazyLock::new(|| parse_jfet_models(JFET_MODELS_SRC));
39
40/// All triode models parsed from the embedded triodes.model file.
41pub static TRIODE_MODELS: LazyLock<HashMap<String, SpiceTriodeModel>> =
42    LazyLock::new(|| parse_triode_models(TRIODE_MODELS_SRC));
43
44/// All pentode models parsed from the embedded pentodes.model file.
45pub static PENTODE_MODELS: LazyLock<HashMap<String, SpicePentodeModel>> =
46    LazyLock::new(|| parse_pentode_models(PENTODE_MODELS_SRC));
47
48/// All diode models parsed from the embedded diodes.model file.
49pub static DIODE_MODELS: LazyLock<HashMap<String, ShockleyDiodeModel>> =
50    LazyLock::new(|| parse_diode_models(DIODE_MODELS_SRC));
51
52/// All LED models parsed from the embedded leds.model file.
53pub static LED_MODELS: LazyLock<HashMap<String, ShockleyDiodeModel>> =
54    LazyLock::new(|| parse_diode_models(LED_MODELS_SRC));
55
56/// All Schottky diode models parsed from the embedded schottky.model file.
57pub static SCHOTTKY_MODELS: LazyLock<HashMap<String, ShockleyDiodeModel>> =
58    LazyLock::new(|| parse_diode_models(SCHOTTKY_MODELS_SRC));
59
60/// All Zener diode models parsed from the embedded zeners.model file.
61pub static ZENER_MODELS: LazyLock<HashMap<String, ShockleyDiodeModel>> =
62    LazyLock::new(|| parse_diode_models(ZENER_MODELS_SRC));
63
64/// All op-amp and OTA models parsed from the embedded opamps.model file.
65pub static OPAMP_MODELS: LazyLock<HashMap<String, SpiceOpAmpModel>> =
66    LazyLock::new(|| parse_opamp_models(OPAMP_MODELS_SRC));
67
68/// All transformer models parsed from the embedded transformers.model file.
69pub static TRANSFORMER_MODELS: LazyLock<HashMap<String, SpiceTransformerModel>> =
70    LazyLock::new(|| parse_transformer_models(TRANSFORMER_MODELS_SRC));
71
72// ---------------------------------------------------------------------------
73// Parsed SPICE BJT model
74// ---------------------------------------------------------------------------
75
76/// A parsed SPICE BJT model with all Gummel-Poon parameters.
77///
78/// Parameters not present in the `.MODEL` line get SPICE defaults.
79#[derive(Debug, Clone)]
80pub struct SpiceBjtModel {
81    pub name: String,
82    pub is_pnp: bool,
83
84    // DC parameters
85    pub is: f64,
86    pub bf: f64,
87    pub br: f64,
88    pub nf: f64,
89    pub nr: f64,
90
91    // Early effect
92    pub vaf: f64,
93    pub var: f64,
94
95    // High injection
96    pub ikf: f64,
97    pub ikr: f64,
98
99    // Leakage
100    pub ise: f64,
101    pub ne: f64,
102    pub isc: f64,
103    pub nc: f64,
104
105    // Parasitic resistances
106    pub rb: f64,
107    pub re: f64,
108    pub rc: f64,
109
110    // Junction capacitances
111    pub cje: f64,
112    pub vje: f64,
113    pub mje: f64,
114    pub cjc: f64,
115    pub vjc: f64,
116    pub mjc: f64,
117
118    // Transit time
119    pub tf: f64,
120    pub tr: f64,
121}
122
123impl SpiceBjtModel {
124    /// SPICE defaults for parameters not specified in the .MODEL line.
125    fn defaults(name: &str, is_pnp: bool) -> Self {
126        Self {
127            name: name.to_uppercase(),
128            is_pnp,
129            is: 1e-16,
130            bf: 100.0,
131            br: 1.0,
132            nf: 1.0,
133            nr: 1.0,
134            vaf: f64::INFINITY,
135            var: f64::INFINITY,
136            ikf: f64::INFINITY,
137            ikr: f64::INFINITY,
138            ise: 0.0,
139            ne: 1.5,
140            isc: 0.0,
141            nc: 2.0,
142            rb: 0.0,
143            re: 0.0,
144            rc: 0.0,
145            cje: 0.0,
146            vje: 0.75,
147            mje: 0.33,
148            cjc: 0.0,
149            vjc: 0.75,
150            mjc: 0.33,
151            tf: 0.0,
152            tr: 0.0,
153        }
154    }
155}
156
157// ---------------------------------------------------------------------------
158// Parsed SPICE JFET model
159// ---------------------------------------------------------------------------
160
161/// A parsed SPICE JFET model (NJF/PJF).
162///
163/// Parameters not present in the `.MODEL` line get SPICE defaults.
164#[derive(Debug, Clone)]
165pub struct SpiceJfetModel {
166    pub name: String,
167    pub is_n_channel: bool,
168
169    // DC parameters
170    pub vto: f64,    // Threshold (pinch-off) voltage (V)
171    pub beta: f64,   // Transconductance coefficient (A/V²)
172    pub lambda: f64, // Channel-length modulation (1/V)
173
174    // Gate junction
175    pub is: f64, // Gate junction saturation current (A)
176    pub n: f64,  // Gate junction ideality factor
177
178    // Parasitic resistances
179    pub rd: f64, // Drain ohmic resistance (Ω)
180    pub rs: f64, // Source ohmic resistance (Ω)
181
182    // Junction capacitances
183    pub cgs: f64, // Gate-source zero-bias capacitance (F)
184    pub cgd: f64, // Gate-drain zero-bias capacitance (F)
185    pub pb: f64,  // Gate junction potential (V)
186}
187
188impl SpiceJfetModel {
189    fn defaults(name: &str, is_n_channel: bool) -> Self {
190        Self {
191            name: name.to_uppercase(),
192            is_n_channel,
193            vto: -2.0,
194            beta: 1e-4, // 0.1 mA/V²
195            lambda: 0.0,
196            is: 1e-14,
197            n: 1.0,
198            rd: 0.0,
199            rs: 0.0,
200            cgs: 0.0,
201            cgd: 0.0,
202            pb: 1.0,
203        }
204    }
205}
206
207// ---------------------------------------------------------------------------
208// Parsed SPICE Diode model
209// ---------------------------------------------------------------------------
210
211/// Shockley diode equation model parameters.
212///
213/// Models the I-V characteristic: `I = IS * (exp(V / (N * Vt)) - 1)`
214/// with series resistance RS and junction capacitance CJO.
215///
216/// Used for standard diodes, LEDs, Schottky diodes, and Zener diodes.
217/// Parameters not present in the `.MODEL` line get SPICE defaults.
218#[derive(Debug, Clone)]
219pub struct ShockleyDiodeModel {
220    pub name: String,
221
222    // DC parameters
223    /// Saturation current (A). Typically 1e-14 to 1e-6.
224    pub is: f64,
225    /// Series resistance (Ω).
226    pub rs: f64,
227    /// Emission coefficient (ideality factor). Typically 1.0-2.0 for Si, higher for LEDs.
228    pub n: f64,
229    /// Transit time (s).
230    pub tt: f64,
231
232    // Junction capacitance
233    /// Zero-bias junction capacitance (F).
234    pub cjo: f64,
235    /// Junction potential (V). Typically 0.7 for Si.
236    pub vj: f64,
237    /// Grading coefficient. Typically 0.33-0.5.
238    pub m: f64,
239
240    // Breakdown
241    /// Reverse breakdown voltage (V). For zeners, this is the zener voltage.
242    pub bv: f64,
243    /// Current at breakdown voltage (A).
244    pub ibv: f64,
245
246    // Temperature
247    /// Bandgap energy (eV): 1.11=Si, 0.69=Schottky, 0.67=Ge.
248    pub eg: f64,
249    /// IS temperature exponent: 3=Si junction, 2=Schottky.
250    pub xti: f64,
251}
252
253impl ShockleyDiodeModel {
254    /// SPICE defaults for parameters not specified in the .MODEL line.
255    fn defaults(name: &str) -> Self {
256        Self {
257            name: name.to_uppercase(),
258            is: 1e-14,
259            rs: 0.0,
260            n: 1.0,
261            tt: 0.0,
262            cjo: 0.0,
263            vj: 1.0,
264            m: 0.5,
265            bv: f64::INFINITY,
266            ibv: 1e-3,
267            eg: 1.11,
268            xti: 3.0,
269        }
270    }
271}
272
273// ---------------------------------------------------------------------------
274// Parsed SPICE Triode model
275// ---------------------------------------------------------------------------
276
277/// A parsed triode tube model with Koren equation parameters.
278///
279/// Format: `.TRIODE <name> MU= EX= KG1= KP= KVB= RP=`
280#[derive(Debug, Clone)]
281pub struct SpiceTriodeModel {
282    pub name: String,
283    /// Amplification factor
284    pub mu: f64,
285    /// Exponent (transfer curve shape)
286    pub ex: f64,
287    /// Plate current scaling factor (A)
288    pub kg1: f64,
289    /// Plate resistance factor
290    pub kp: f64,
291    /// Knee voltage constant (V)
292    pub kvb: f64,
293    /// Plate resistance at typical operating point (Ω). Datasheet rp.
294    /// 12AX7 ≈ 62.5kΩ, 12AU7 ≈ 7.7kΩ, 12AT7 ≈ 10.9kΩ.
295    pub rp: f64,
296}
297
298// ---------------------------------------------------------------------------
299// Parsed SPICE Pentode model
300// ---------------------------------------------------------------------------
301
302/// A parsed pentode tube model with screen-referenced Koren equation parameters.
303///
304/// Format: `.PENTODE <name> MU= EX= KG1= KG2= KP= KVB= KVB2= VG2= RP=`
305#[derive(Debug, Clone)]
306pub struct SpicePentodeModel {
307    pub name: String,
308    /// Amplification factor (screen-referenced)
309    pub mu: f64,
310    /// Exponent (transfer curve shape)
311    pub ex: f64,
312    /// Plate current scaling factor (A)
313    pub kg1: f64,
314    /// Screen current scaling factor (A)
315    pub kg2: f64,
316    /// Plate resistance factor
317    pub kp: f64,
318    /// Knee voltage constant (V)
319    pub kvb: f64,
320    /// Knee voltage for pentode plate saturation (V)
321    pub kvb2: f64,
322    /// Default screen grid voltage (V) for typical operating point
323    pub vg2_default: f64,
324    /// Plate resistance at typical operating point (Ω). Datasheet rp.
325    /// Power pentodes: 15k–50kΩ. Signal pentodes (EF86): ~2.5MΩ.
326    pub rp: f64,
327}
328
329// ---------------------------------------------------------------------------
330// Parsed op-amp / OTA model
331// ---------------------------------------------------------------------------
332
333/// Compact behavioral op-amp and OTA parameters.
334///
335/// Format:
336/// - `.OPAMP <name> A0= GBW= SR= VPOS= VNEG= RO= COUT=`
337/// - `.OTA <name> A0= GBW= SR= VPOS= VNEG= RO= COUT= IABC= VT= GM= RLOAD=`
338#[derive(Debug, Clone)]
339pub struct SpiceOpAmpModel {
340    pub name: String,
341    pub is_ota: bool,
342    pub open_loop_gain: f64,
343    pub gbw: f64,
344    pub slew_rate: f64,
345    pub v_rail_pos: f64,
346    pub v_rail_neg: f64,
347    pub output_impedance: f64,
348    pub output_capacitance: f64,
349    pub ota_iabc: f64,
350    pub ota_vt: f64,
351    pub ota_gm: f64,
352    pub ota_r_load: f64,
353}
354
355// ---------------------------------------------------------------------------
356// Parsed transformer model
357// ---------------------------------------------------------------------------
358
359/// Compact behavioral audio transformer parameters.
360///
361/// Format:
362/// `.TRANSFORMER <name> LP= K= RP= RS= LLP= LLS= LM= RC= CP= N1= N2= ...`
363#[derive(Debug, Clone)]
364pub struct SpiceTransformerModel {
365    pub name: String,
366
367    // Linear electrical skeleton.
368    pub primary_inductance: f64,
369    pub coupling: f64,
370    pub primary_dcr: f64,
371    pub secondary_dcr: f64,
372    pub primary_leakage: f64,
373    pub secondary_leakage: f64,
374    pub magnetizing_inductance: f64,
375    pub core_loss_resistance: f64,
376    pub capacitance: f64,
377
378    // Geometry metadata for nonlinear core work.
379    pub primary_turns: f64,
380    pub secondary_turns: f64,
381    pub core_area: f64,
382    pub magnetic_path_length: f64,
383    pub gap_length: f64,
384    pub dc_bias_current: f64,
385
386    // Native Jiles-Atherton parameters.
387    pub ja_ms: f64,
388    pub ja_a: f64,
389    pub ja_alpha: f64,
390    pub ja_k: f64,
391    pub ja_c: f64,
392
393    // Datasheet hysteresis hints.
394    pub hc: f64,
395    pub br: f64,
396    pub bs: f64,
397}
398
399impl SpiceTransformerModel {
400    fn defaults(name: &str) -> Self {
401        Self {
402            name: name.to_uppercase(),
403            primary_inductance: 1.0,
404            coupling: 0.99,
405            primary_dcr: 0.0,
406            secondary_dcr: 0.0,
407            primary_leakage: 0.01,
408            secondary_leakage: 0.01,
409            magnetizing_inductance: 0.99,
410            core_loss_resistance: 0.0,
411            capacitance: 0.0,
412            primary_turns: 1.0,
413            secondary_turns: 1.0,
414            core_area: 0.0,
415            magnetic_path_length: 0.0,
416            gap_length: 0.0,
417            dc_bias_current: 0.0,
418            ja_ms: 0.0,
419            ja_a: 0.0,
420            ja_alpha: 0.0,
421            ja_k: 0.0,
422            ja_c: 0.0,
423            hc: 0.0,
424            br: 0.0,
425            bs: 0.0,
426        }
427    }
428}
429
430impl SpiceOpAmpModel {
431    fn defaults(name: &str, is_ota: bool) -> Self {
432        Self {
433            name: name.to_uppercase(),
434            is_ota,
435            open_loop_gain: 100_000.0,
436            gbw: 1e6,
437            slew_rate: 1.0,
438            v_rail_pos: 12.0,
439            v_rail_neg: 12.0,
440            output_impedance: 75.0,
441            output_capacitance: 20e-12,
442            ota_iabc: 100e-6,
443            ota_vt: pedalkernel_rt::SPICE_VT_27C as f64,
444            ota_gm: 0.0,
445            ota_r_load: 10_000.0,
446        }
447    }
448}
449
450trait NamedModel {
451    fn model_name(&self) -> &str;
452}
453
454macro_rules! impl_named_model {
455    ($($ty:ty),+ $(,)?) => {
456        $(
457            impl NamedModel for $ty {
458                fn model_name(&self) -> &str {
459                    &self.name
460                }
461            }
462        )+
463    };
464}
465
466impl_named_model!(
467    SpiceBjtModel,
468    SpiceJfetModel,
469    ShockleyDiodeModel,
470    SpiceTriodeModel,
471    SpicePentodeModel,
472    SpiceOpAmpModel,
473    SpiceTransformerModel,
474);
475
476// ---------------------------------------------------------------------------
477// SPICE engineering suffix parser
478// ---------------------------------------------------------------------------
479
480/// Parse a numeric value with optional SPICE engineering suffix.
481///
482/// Supports: `T`=1e12, `G`=1e9, `MEG`=1e6, `k`=1e3, `m`=1e-3,
483///           `u`=1e-6, `n`=1e-9, `p`=1e-12, `f`=1e-15
484fn parse_spice_value(s: &str) -> Option<f64> {
485    let s = s.trim();
486    if s.is_empty() {
487        return None;
488    }
489
490    // Try to find where the numeric part ends and the suffix begins.
491    // Numbers can contain digits, '.', '+', '-', 'e'/'E' (for scientific notation).
492    let mut split_pos = s.len();
493    let bytes = s.as_bytes();
494    for i in 0..bytes.len() {
495        let c = bytes[i] as char;
496        if c.is_ascii_alphabetic() {
497            // Check if this is part of scientific notation (e.g., "1e-3")
498            if (c == 'e' || c == 'E') && i > 0 {
499                // Look ahead: if followed by digit or +/-, it's scientific notation
500                if i + 1 < bytes.len() {
501                    let next = bytes[i + 1] as char;
502                    if next.is_ascii_digit() || next == '+' || next == '-' {
503                        continue;
504                    }
505                }
506            }
507            split_pos = i;
508            break;
509        }
510    }
511
512    let num_part = &s[..split_pos];
513    let suffix = &s[split_pos..];
514
515    let base: f64 = num_part.parse().ok()?;
516
517    let multiplier = match suffix.to_uppercase().as_str() {
518        "" => 1.0,
519        "T" => 1e12,
520        "G" => 1e9,
521        "MEG" => 1e6,
522        "K" => 1e3,
523        "M" => 1e-3,
524        "MIL" => 25.4e-6,
525        "U" => 1e-6,
526        "N" => 1e-9,
527        "P" => 1e-12,
528        "F" => 1e-15,
529        _ => return None,
530    };
531
532    Some(base * multiplier)
533}
534
535fn parse_model_registry<T, F>(src: &str, mut parse_line: F) -> HashMap<String, T>
536where
537    T: NamedModel,
538    F: FnMut(&str) -> Option<T>,
539{
540    let mut models = HashMap::new();
541
542    for line in src.lines() {
543        let trimmed = line.trim();
544        if trimmed.is_empty() || trimmed.starts_with('*') || trimmed.starts_with('#') {
545            continue;
546        }
547
548        if let Some(model) = parse_line(trimmed) {
549            models.insert(model.model_name().to_string(), model);
550        }
551    }
552
553    models
554}
555
556fn for_each_param<F>(params: &str, mut apply: F)
557where
558    F: FnMut(&str, f64),
559{
560    for pair in params.split_whitespace() {
561        if let Some((key, val_str)) = pair.split_once('=') {
562            if let Some(val) = parse_spice_value(val_str) {
563                let key_upper = key.to_uppercase();
564                apply(&key_upper, val);
565            }
566        }
567    }
568}
569
570fn strip_directive<'a>(line: &'a str, directive: &str) -> Option<&'a str> {
571    let trimmed = line.trim_start();
572    let prefix = trimmed.get(..directive.len())?;
573    if !prefix.eq_ignore_ascii_case(directive) {
574        return None;
575    }
576
577    let rest = &trimmed[directive.len()..];
578    if rest
579        .chars()
580        .next()
581        .is_some_and(|c| !c.is_ascii_whitespace())
582    {
583        return None;
584    }
585
586    Some(rest.trim_start())
587}
588
589fn lookup_model<'a, T>(models: &'a HashMap<String, T>, name: &str) -> Option<&'a T> {
590    models.get(&name.to_uppercase())
591}
592
593fn model_names<T>(models: &'static HashMap<String, T>) -> Vec<&'static str> {
594    models.keys().map(|s| s.as_str()).collect()
595}
596
597// ---------------------------------------------------------------------------
598// .MODEL line parser
599// ---------------------------------------------------------------------------
600
601/// Parse all `.MODEL` BJT entries from a SPICE model file string.
602fn parse_bjt_models(src: &str) -> HashMap<String, SpiceBjtModel> {
603    parse_model_registry(src, parse_model_line)
604}
605
606/// Parse a single `.MODEL <name> <type>(<params>)` line.
607fn parse_model_line(line: &str) -> Option<SpiceBjtModel> {
608    let rest = strip_directive(line, ".MODEL")?;
609
610    // Extract model name (first token)
611    let (name, rest) = rest.split_once(|c: char| c.is_whitespace())?;
612    let rest = rest.trim_start();
613
614    // Extract type and parameter block
615    // Format: "NPN(...)" or "PNP(...)"
616    let (type_str, params_block) = if let Some(paren_pos) = rest.find('(') {
617        let type_str = rest[..paren_pos].trim();
618        let close_paren = rest.rfind(')')?;
619        let params = &rest[paren_pos + 1..close_paren];
620        (type_str, params)
621    } else {
622        // No parameters
623        (rest.trim(), "")
624    };
625
626    let is_pnp = match type_str.to_uppercase().as_str() {
627        "NPN" => false,
628        "PNP" => true,
629        _ => return None, // Not a BJT
630    };
631
632    let mut model = SpiceBjtModel::defaults(name, is_pnp);
633
634    for_each_param(params_block, |key, val| match key {
635        "IS" => model.is = val,
636        "BF" => model.bf = val,
637        "BR" => model.br = val,
638        "NF" => model.nf = val,
639        "NR" => model.nr = val,
640        "VT" => {} // We compute VT from temperature, ignore file value
641        "VAF" | "VA" => model.vaf = val,
642        "VAR" | "VB" => model.var = val,
643        "IKF" | "JBF" => model.ikf = val,
644        "IKR" | "JBR" => model.ikr = val,
645        "ISE" => model.ise = val,
646        "NE" => model.ne = val,
647        "ISC" => model.isc = val,
648        "NC" => model.nc = val,
649        "RB" => model.rb = val,
650        "RE" => model.re = val,
651        "RC" => model.rc = val,
652        "CJE" | "CEB" => model.cje = val,
653        "VJE" | "PE" => model.vje = val,
654        "MJE" | "ME" => model.mje = val,
655        "CJC" | "CCB" => model.cjc = val,
656        "VJC" | "PC" => model.vjc = val,
657        "MJC" | "MC" => model.mjc = val,
658        "TF" => model.tf = val,
659        "TR" => model.tr = val,
660        _ => {} // Ignore unknown parameters
661    });
662
663    Some(model)
664}
665
666// ---------------------------------------------------------------------------
667// JFET .MODEL line parser
668// ---------------------------------------------------------------------------
669
670/// Parse all `.MODEL` JFET entries (NJF/PJF) from a SPICE model file string.
671fn parse_jfet_models(src: &str) -> HashMap<String, SpiceJfetModel> {
672    parse_model_registry(src, parse_jfet_model_line)
673}
674
675/// Parse a single `.MODEL <name> NJF|PJF(<params>)` line.
676fn parse_jfet_model_line(line: &str) -> Option<SpiceJfetModel> {
677    let rest = strip_directive(line, ".MODEL")?;
678    let (name, rest) = rest.split_once(|c: char| c.is_whitespace())?;
679    let rest = rest.trim_start();
680
681    // Extract type and parameter block
682    let (type_str, params_block) = if let Some(paren_pos) = rest.find('(') {
683        let type_str = rest[..paren_pos].trim();
684        let close_paren = rest.rfind(')')?;
685        let params = &rest[paren_pos + 1..close_paren];
686        (type_str, params)
687    } else {
688        // Some models use space-separated params without parens
689        // Split on first whitespace after type
690        let type_end = rest.find(|c: char| c.is_whitespace()).unwrap_or(rest.len());
691        let type_str = &rest[..type_end];
692        let params = if type_end < rest.len() {
693            &rest[type_end..]
694        } else {
695            ""
696        };
697        (type_str.trim(), params)
698    };
699
700    let is_n_channel = match type_str.to_uppercase().as_str() {
701        "NJF" => true,
702        "PJF" => false,
703        _ => return None, // Not a JFET
704    };
705
706    let mut model = SpiceJfetModel::defaults(name, is_n_channel);
707
708    for_each_param(params_block, |key, val| match key {
709        "VTO" => model.vto = val,
710        "BETA" => model.beta = val,
711        "LAMBDA" => model.lambda = val,
712        "IS" => model.is = val,
713        "N" => model.n = val,
714        "RD" => model.rd = val,
715        "RS" => model.rs = val,
716        "CGS" => model.cgs = val,
717        "CGD" => model.cgd = val,
718        "PB" => model.pb = val,
719        _ => {} // Ignore unknown parameters (AF, FC, BETATCE, etc.)
720    });
721
722    Some(model)
723}
724
725// ---------------------------------------------------------------------------
726// Diode .MODEL D(...) line parser
727// ---------------------------------------------------------------------------
728
729/// Parse all `.MODEL <name> D(...)` entries from a SPICE model file string.
730///
731/// Handles standard diodes, LEDs, Schottky, and Zener diodes — they all
732/// share the same SPICE `D` type and parameter set.
733fn parse_diode_models(src: &str) -> HashMap<String, ShockleyDiodeModel> {
734    parse_model_registry(src, parse_diode_model_line)
735}
736
737/// Parse a single `.MODEL <name> D(<params>)` line.
738fn parse_diode_model_line(line: &str) -> Option<ShockleyDiodeModel> {
739    let rest = strip_directive(line, ".MODEL")?;
740    let (name, rest) = rest.split_once(|c: char| c.is_whitespace())?;
741    let rest = rest.trim_start();
742
743    // Must be type D
744    let (type_str, params_block) = if let Some(paren_pos) = rest.find('(') {
745        let type_str = rest[..paren_pos].trim();
746        let close_paren = rest.rfind(')')?;
747        let params = &rest[paren_pos + 1..close_paren];
748        (type_str, params)
749    } else {
750        return None;
751    };
752
753    if !type_str.eq_ignore_ascii_case("D") {
754        return None;
755    }
756
757    let mut model = ShockleyDiodeModel::defaults(name);
758
759    for_each_param(params_block, |key, val| match key {
760        "IS" => model.is = val,
761        "RS" => model.rs = val,
762        "N" => model.n = val,
763        "TT" => model.tt = val,
764        "CJO" => model.cjo = val,
765        "VJ" => model.vj = val,
766        "M" => model.m = val,
767        "BV" => model.bv = val,
768        "IBV" => model.ibv = val,
769        "EG" => model.eg = val,
770        "XTI" => model.xti = val,
771        _ => {} // Ignore unknown parameters (KF, AF, FC, etc.)
772    });
773
774    Some(model)
775}
776
777// ---------------------------------------------------------------------------
778// Triode .TRIODE line parser
779// ---------------------------------------------------------------------------
780
781/// Parse all `.TRIODE` entries from a model file string.
782fn parse_triode_models(src: &str) -> HashMap<String, SpiceTriodeModel> {
783    parse_model_registry(src, parse_triode_model_line)
784}
785
786/// Parse a single `.TRIODE <name> MU= EX= KG1= KP= KVB= RP=` line.
787fn parse_triode_model_line(line: &str) -> Option<SpiceTriodeModel> {
788    let rest = strip_directive(line, ".TRIODE")?;
789    let (name, params) = rest.split_once(|c: char| c.is_whitespace())?;
790
791    let mut mu = 100.0;
792    let mut ex = 1.4;
793    let mut kg1 = 1060.0;
794    let mut kp = 600.0;
795    let mut kvb = 300.0;
796    let mut rp = 62500.0; // Default: 12AX7 plate resistance
797
798    for_each_param(params, |key, val| match key {
799        "MU" => mu = val,
800        "EX" => ex = val,
801        "KG1" => kg1 = val,
802        "KP" => kp = val,
803        "KVB" => kvb = val,
804        "RP" => rp = val,
805        _ => {}
806    });
807
808    Some(SpiceTriodeModel {
809        name: name.to_uppercase(),
810        mu,
811        ex,
812        kg1,
813        kp,
814        kvb,
815        rp,
816    })
817}
818
819// ---------------------------------------------------------------------------
820// Pentode .PENTODE line parser
821// ---------------------------------------------------------------------------
822
823/// Parse all `.PENTODE` entries from a model file string.
824fn parse_pentode_models(src: &str) -> HashMap<String, SpicePentodeModel> {
825    parse_model_registry(src, parse_pentode_model_line)
826}
827
828/// Parse a single `.PENTODE <name> MU= EX= KG1= KG2= KP= KVB= KVB2= VG2= RP=` line.
829fn parse_pentode_model_line(line: &str) -> Option<SpicePentodeModel> {
830    let rest = strip_directive(line, ".PENTODE")?;
831    let (name, params) = rest.split_once(|c: char| c.is_whitespace())?;
832
833    let mut mu = 10.0;
834    let mut ex = 1.35;
835    let mut kg1 = 1000.0;
836    let mut kg2 = 4200.0;
837    let mut kp = 60.0;
838    let mut kvb = 24.0;
839    let mut kvb2 = 20.0;
840    let mut vg2_default = 250.0;
841    let mut rp = 50000.0; // Default: mid-range power pentode
842
843    for_each_param(params, |key, val| match key {
844        "MU" => mu = val,
845        "EX" => ex = val,
846        "KG1" => kg1 = val,
847        "KG2" => kg2 = val,
848        "KP" => kp = val,
849        "KVB" => kvb = val,
850        "KVB2" => kvb2 = val,
851        "VG2" => vg2_default = val,
852        "RP" => rp = val,
853        _ => {}
854    });
855
856    Some(SpicePentodeModel {
857        name: name.to_uppercase(),
858        mu,
859        ex,
860        kg1,
861        kg2,
862        kp,
863        kvb,
864        kvb2,
865        vg2_default,
866        rp,
867    })
868}
869
870// ---------------------------------------------------------------------------
871// Op-amp / OTA model parser
872// ---------------------------------------------------------------------------
873
874/// Parse all `.OPAMP` and `.OTA` entries from a model file string.
875fn parse_opamp_models(src: &str) -> HashMap<String, SpiceOpAmpModel> {
876    parse_model_registry(src, parse_opamp_model_line)
877}
878
879/// Parse a single `.OPAMP <name> ...` or `.OTA <name> ...` line.
880fn parse_opamp_model_line(line: &str) -> Option<SpiceOpAmpModel> {
881    let (rest, is_ota) = if let Some(rest) = strip_directive(line, ".OPAMP") {
882        (rest, false)
883    } else if let Some(rest) = strip_directive(line, ".OTA") {
884        (rest, true)
885    } else {
886        return None;
887    };
888
889    let (name, params) = rest.split_once(|c: char| c.is_whitespace())?;
890    let mut model = SpiceOpAmpModel::defaults(name, is_ota);
891
892    for_each_param(params, |key, val| match key {
893        "A0" | "AOL" | "OPEN_LOOP_GAIN" => model.open_loop_gain = val,
894        "GBW" | "GBP" => model.gbw = val,
895        "SR" | "SLEW" | "SLEW_RATE" => model.slew_rate = val,
896        "VPOS" | "VRAILPOS" | "V_RAIL_POS" => model.v_rail_pos = val,
897        "VNEG" | "VRAILNEG" | "V_RAIL_NEG" => model.v_rail_neg = val,
898        "RO" | "ROUT" | "OUTPUT_IMPEDANCE" => model.output_impedance = val,
899        "COUT" | "OUTPUT_CAPACITANCE" => model.output_capacitance = val,
900        "IABC" => model.ota_iabc = val,
901        "VT" => model.ota_vt = val,
902        "GM" | "GMO" => model.ota_gm = val,
903        "RLOAD" | "RL" => model.ota_r_load = val,
904        _ => {}
905    });
906
907    Some(model)
908}
909
910// ---------------------------------------------------------------------------
911// Transformer model parser
912// ---------------------------------------------------------------------------
913
914/// Parse all `.TRANSFORMER` entries from a model file string.
915fn parse_transformer_models(src: &str) -> HashMap<String, SpiceTransformerModel> {
916    parse_model_registry(src, parse_transformer_model_line)
917}
918
919/// Parse a single `.TRANSFORMER <name> ...` line.
920fn parse_transformer_model_line(line: &str) -> Option<SpiceTransformerModel> {
921    let rest = strip_directive(line, ".TRANSFORMER")?;
922    let (name, params) = rest.split_once(|c: char| c.is_whitespace())?;
923    let mut model = SpiceTransformerModel::defaults(name);
924
925    for_each_param(params, |key, val| match key {
926        "LP" | "LPRI" | "PRIMARY_INDUCTANCE" => model.primary_inductance = val,
927        "K" | "COUPLING" => model.coupling = val,
928        "RP" | "RPRI" | "PRIMARY_DCR" => model.primary_dcr = val,
929        "RS" | "RSEC" | "SECONDARY_DCR" => model.secondary_dcr = val,
930        "LLP" | "PRIMARY_LEAKAGE" => model.primary_leakage = val,
931        "LLS" | "SECONDARY_LEAKAGE" => model.secondary_leakage = val,
932        "LM" | "MAGNETIZING_INDUCTANCE" => model.magnetizing_inductance = val,
933        "RC" | "CORE_LOSS" | "CORE_LOSS_RESISTANCE" => model.core_loss_resistance = val,
934        "CP" | "CW" | "CAPACITANCE" | "INTERWINDING_CAPACITANCE" => model.capacitance = val,
935        "N1" | "NP" | "PRIMARY_TURNS" => model.primary_turns = val,
936        "N2" | "NS" | "SECONDARY_TURNS" => model.secondary_turns = val,
937        "AE" | "CORE_AREA" => model.core_area = val,
938        "LE" | "MAGNETIC_PATH_LENGTH" => model.magnetic_path_length = val,
939        "GAP" | "LG" | "GAP_LENGTH" => model.gap_length = val,
940        "IDC" | "BIAS_CURRENT" | "DC_BIAS_CURRENT" => model.dc_bias_current = val,
941        "MS" | "JA_MS" => model.ja_ms = val,
942        "A" | "JA_A" => model.ja_a = val,
943        "ALPHA" | "JA_ALPHA" => model.ja_alpha = val,
944        "JA_K" | "KJA" => model.ja_k = val,
945        "C" | "JA_C" => model.ja_c = val,
946        "HC" => model.hc = val,
947        "BR" => model.br = val,
948        "BS" => model.bs = val,
949        _ => {}
950    });
951
952    Some(model)
953}
954
955// ---------------------------------------------------------------------------
956// Public lookup API
957// ---------------------------------------------------------------------------
958
959/// Look up a BJT model by name (case-insensitive).
960///
961/// Returns `None` if the model name is not found in the embedded model file.
962pub fn bjt_by_name(name: &str) -> Option<&'static SpiceBjtModel> {
963    lookup_model(&BJT_MODELS, name)
964}
965
966/// Check if a BJT model is germanium based on its saturation current.
967///
968/// Germanium transistors have IS in the µA range (typ. 1–100 µA),
969/// while silicon transistors have IS in the fA–pA range.
970pub fn bjt_is_germanium(name: &str) -> bool {
971    bjt_by_name(name).map(|m| m.is > 1e-6).unwrap_or(false)
972}
973
974/// Check if a BJT model is PNP.
975pub fn bjt_is_pnp(name: &str) -> bool {
976    bjt_by_name(name).map(|m| m.is_pnp).unwrap_or(false)
977}
978
979/// List all available BJT model names.
980pub fn bjt_model_names() -> Vec<&'static str> {
981    model_names(&BJT_MODELS)
982}
983
984/// Look up a JFET model by name (case-insensitive).
985///
986/// Returns `None` if the model name is not found in the embedded model file.
987pub fn jfet_by_name(name: &str) -> Option<&'static SpiceJfetModel> {
988    lookup_model(&JFET_MODELS, name)
989}
990
991/// List all available JFET model names.
992pub fn jfet_model_names() -> Vec<&'static str> {
993    model_names(&JFET_MODELS)
994}
995
996/// Look up a triode model by name (case-insensitive).
997pub fn triode_by_name(name: &str) -> Option<&'static SpiceTriodeModel> {
998    lookup_model(&TRIODE_MODELS, name)
999}
1000
1001/// List all available triode model names.
1002pub fn triode_model_names() -> Vec<&'static str> {
1003    model_names(&TRIODE_MODELS)
1004}
1005
1006/// Look up a pentode model by name (case-insensitive).
1007pub fn pentode_by_name(name: &str) -> Option<&'static SpicePentodeModel> {
1008    lookup_model(&PENTODE_MODELS, name)
1009}
1010
1011/// List all available pentode model names.
1012pub fn pentode_model_names() -> Vec<&'static str> {
1013    model_names(&PENTODE_MODELS)
1014}
1015
1016/// Look up a diode model by name (case-insensitive).
1017///
1018/// Searches the standard diodes library (1N34, 1N914, 1N4148, 1N4001–1N4007, etc.)
1019pub fn diode_by_name(name: &str) -> Option<&'static ShockleyDiodeModel> {
1020    lookup_model(&DIODE_MODELS, name)
1021}
1022
1023/// List all available diode model names.
1024pub fn diode_model_names() -> Vec<&'static str> {
1025    model_names(&DIODE_MODELS)
1026}
1027
1028/// Look up an LED model by name (case-insensitive).
1029///
1030/// Available names: LED_IR, LED_RED, LED_GREEN, LED_YELLOW, LED_AMBER, LED_BLUE,
1031/// DLED0–DLED3.
1032pub fn led_by_name(name: &str) -> Option<&'static ShockleyDiodeModel> {
1033    lookup_model(&LED_MODELS, name)
1034}
1035
1036/// List all available LED model names.
1037pub fn led_model_names() -> Vec<&'static str> {
1038    model_names(&LED_MODELS)
1039}
1040
1041/// Look up a Schottky diode model by name (case-insensitive).
1042pub fn schottky_by_name(name: &str) -> Option<&'static ShockleyDiodeModel> {
1043    lookup_model(&SCHOTTKY_MODELS, name)
1044}
1045
1046/// List all available Schottky diode model names.
1047pub fn schottky_model_names() -> Vec<&'static str> {
1048    model_names(&SCHOTTKY_MODELS)
1049}
1050
1051/// Look up a Zener diode model by name (case-insensitive).
1052///
1053/// Searches the zener library (1N746–1N759, 1N4728–1N4764, 1N5221–1N5267B, etc.)
1054pub fn zener_by_name(name: &str) -> Option<&'static ShockleyDiodeModel> {
1055    lookup_model(&ZENER_MODELS, name)
1056}
1057
1058/// List all available Zener diode model names.
1059pub fn zener_model_names() -> Vec<&'static str> {
1060    model_names(&ZENER_MODELS)
1061}
1062
1063/// Look up an op-amp or OTA model by name (case-insensitive).
1064pub fn opamp_by_name(name: &str) -> Option<&'static SpiceOpAmpModel> {
1065    lookup_model(&OPAMP_MODELS, name)
1066}
1067
1068/// List all available op-amp and OTA model names.
1069pub fn opamp_model_names() -> Vec<&'static str> {
1070    model_names(&OPAMP_MODELS)
1071}
1072
1073/// Look up a transformer model by name (case-insensitive).
1074pub fn transformer_by_name(name: &str) -> Option<&'static SpiceTransformerModel> {
1075    lookup_model(&TRANSFORMER_MODELS, name)
1076}
1077
1078/// List all available transformer model names.
1079pub fn transformer_model_names() -> Vec<&'static str> {
1080    model_names(&TRANSFORMER_MODELS)
1081}
1082
1083/// Look up any diode-type model by name across all registries.
1084///
1085/// Searches in order: diodes → LEDs → Schottky → zeners.
1086/// Use the specific `*_by_name()` functions when the category is known.
1087pub fn any_diode_by_name(name: &str) -> Option<&'static ShockleyDiodeModel> {
1088    let upper = name.to_uppercase();
1089    DIODE_MODELS
1090        .get(&upper)
1091        .or_else(|| LED_MODELS.get(&upper))
1092        .or_else(|| SCHOTTKY_MODELS.get(&upper))
1093        .or_else(|| ZENER_MODELS.get(&upper))
1094}
1095
1096// ---------------------------------------------------------------------------
1097// Tests
1098// ---------------------------------------------------------------------------
1099
1100#[cfg(test)]
1101mod tests {
1102    use super::*;
1103
1104    fn assert_close(a: Option<f64>, b: Option<f64>) {
1105        match (a, b) {
1106            (Some(a), Some(b)) => {
1107                let rel = if b.abs() > 1e-30 {
1108                    ((a - b) / b).abs()
1109                } else {
1110                    (a - b).abs()
1111                };
1112                assert!(rel < 1e-10, "expected {b}, got {a}");
1113            }
1114            _ => assert_eq!(a, b),
1115        }
1116    }
1117
1118    #[test]
1119    fn parse_spice_suffixes() {
1120        assert_close(parse_spice_value("1.0"), Some(1.0));
1121        assert_close(parse_spice_value("6.734f"), Some(6.734e-15));
1122        assert_close(parse_spice_value("4.493p"), Some(4.493e-12));
1123        assert_close(parse_spice_value("301.2p"), Some(301.2e-12));
1124        assert_close(parse_spice_value("24n"), Some(24e-9));
1125        assert_close(parse_spice_value("20.66u"), Some(20.66e-6));
1126        assert_close(parse_spice_value("66.78m"), Some(66.78e-3));
1127        assert_close(parse_spice_value("10"), Some(10.0));
1128        assert_close(parse_spice_value("1k"), Some(1e3));
1129        assert_close(parse_spice_value("1MEG"), Some(1e6));
1130    }
1131
1132    #[test]
1133    fn parse_scientific_notation() {
1134        // Scientific notation should NOT be confused with suffixes
1135        assert_eq!(parse_spice_value("1e-14"), Some(1e-14));
1136        assert_eq!(parse_spice_value("6.734e-15"), Some(6.734e-15));
1137        assert_eq!(parse_spice_value("3.295E-14"), Some(3.295e-14));
1138        assert_eq!(parse_spice_value("1.41e-15"), Some(1.41e-15));
1139    }
1140
1141    #[test]
1142    fn parse_model_line_npn() {
1143        let line = ".MODEL 2N3904 NPN(IS=6.734f BF=416.4 BR=0.7371 VAF=74.03)";
1144        let model = parse_model_line(line).unwrap();
1145        assert_eq!(model.name, "2N3904");
1146        assert!(!model.is_pnp);
1147        assert!((model.is - 6.734e-15).abs() < 1e-30);
1148        assert!((model.bf - 416.4).abs() < 1e-10);
1149        assert!((model.br - 0.7371).abs() < 1e-10);
1150        assert!((model.vaf - 74.03).abs() < 1e-10);
1151    }
1152
1153    #[test]
1154    fn parse_model_line_pnp() {
1155        let line = ".MODEL AC128 PNP(IS=20.66u BF=229.6 BR=14.66 NF=1.133 NR=1.140 VAF=19.68)";
1156        let model = parse_model_line(line).unwrap();
1157        assert_eq!(model.name, "AC128");
1158        assert!(model.is_pnp);
1159        assert!((model.is - 20.66e-6).abs() < 1e-15);
1160        assert!((model.bf - 229.6).abs() < 1e-10);
1161        assert!((model.nf - 1.133).abs() < 1e-10);
1162    }
1163
1164    #[test]
1165    fn embedded_models_load() {
1166        // Verify all expected models are present
1167        let names = [
1168            "2N3904",
1169            "2N2222",
1170            "BC108",
1171            "BC109",
1172            "2N5088",
1173            "2N5089",
1174            "2N3906",
1175            "AC128",
1176            "OC44",
1177            "NKT275",
1178            "GENERIC_NPN",
1179            "GENERIC_PNP",
1180        ];
1181        for name in &names {
1182            assert!(
1183                bjt_by_name(name).is_some(),
1184                "Model '{}' not found in embedded transistors.model",
1185                name
1186            );
1187        }
1188    }
1189
1190    #[test]
1191    fn case_insensitive_lookup() {
1192        assert!(bjt_by_name("2n3904").is_some());
1193        assert!(bjt_by_name("2N3904").is_some());
1194        assert!(bjt_by_name("ac128").is_some());
1195        assert!(bjt_by_name("AC128").is_some());
1196    }
1197
1198    #[test]
1199    fn model_polarity() {
1200        assert!(!bjt_by_name("2N3904").unwrap().is_pnp);
1201        assert!(!bjt_by_name("2N2222").unwrap().is_pnp);
1202        assert!(bjt_by_name("2N3906").unwrap().is_pnp);
1203        assert!(bjt_by_name("AC128").unwrap().is_pnp);
1204        assert!(bjt_by_name("OC44").unwrap().is_pnp);
1205        assert!(bjt_by_name("NKT275").unwrap().is_pnp);
1206    }
1207
1208    #[test]
1209    fn germanium_has_high_leakage() {
1210        let ac128 = bjt_by_name("AC128").unwrap();
1211        let n2n3904 = bjt_by_name("2N3904").unwrap();
1212        // Germanium IS should be orders of magnitude higher than silicon
1213        assert!(ac128.is > n2n3904.is * 1e6);
1214    }
1215
1216    #[test]
1217    fn defaults_for_missing_params() {
1218        let line = ".MODEL MINIMAL NPN(IS=1f BF=100)";
1219        let model = parse_model_line(line).unwrap();
1220        // Unspecified params should get SPICE defaults
1221        assert_eq!(model.br, 1.0);
1222        assert_eq!(model.nf, 1.0);
1223        assert!(model.vaf.is_infinite());
1224        assert_eq!(model.ise, 0.0);
1225    }
1226
1227    // -----------------------------------------------------------------------
1228    // JFET tests
1229    // -----------------------------------------------------------------------
1230
1231    #[test]
1232    fn parse_jfet_njf() {
1233        let line = ".MODEL 2N5457 NJF VTO=-1.25 BETA=1.04m LAMBDA=30.8m CGS=3.58p CGD=3.58p PB=3.99 IS=95.6f";
1234        let model = parse_jfet_model_line(line).unwrap();
1235        assert_eq!(model.name, "2N5457");
1236        assert!(model.is_n_channel);
1237        assert!((model.vto - (-1.25)).abs() < 1e-10);
1238        assert!((model.beta - 1.04e-3).abs() < 1e-12);
1239        assert!((model.lambda - 30.8e-3).abs() < 1e-10);
1240    }
1241
1242    #[test]
1243    fn parse_jfet_pjf() {
1244        let line = ".MODEL 2N5460 PJF VTO=-2.16 BETA=517u LAMBDA=12.6m CGS=5.32p CGD=5.32p PB=722m IS=147f";
1245        let model = parse_jfet_model_line(line).unwrap();
1246        assert_eq!(model.name, "2N5460");
1247        assert!(!model.is_n_channel);
1248        assert!((model.vto - (-2.16)).abs() < 1e-10);
1249        assert_close(Some(model.beta), Some(517e-6));
1250    }
1251
1252    #[test]
1253    fn parse_jfet_space_separated() {
1254        // Some models in the file use space-separated params without parens
1255        let line = ".MODEL TEST-NJF NJF VTO=-2.0 BETA=1.5m LAMBDA=9m RD=1 RS=1 CGS=2.9p CGD=2.8p";
1256        let model = parse_jfet_model_line(line).unwrap();
1257        assert_eq!(model.name, "TEST-NJF");
1258        assert!(model.is_n_channel);
1259        assert!((model.vto - (-2.0)).abs() < 1e-10);
1260    }
1261
1262    #[test]
1263    fn jfet_embedded_models_load() {
1264        let names = [
1265            "J201",
1266            "2N5457",
1267            "2N5460",
1268            "2N5952",
1269            "2SK30A",
1270            "2SK30A-GR",
1271            "2SK30A-Y",
1272            "2SK30A-BL",
1273        ];
1274        for name in &names {
1275            assert!(
1276                jfet_by_name(name).is_some(),
1277                "JFET model '{}' not found in embedded jfets.model",
1278                name
1279            );
1280        }
1281    }
1282
1283    #[test]
1284    fn jfet_case_insensitive_lookup() {
1285        assert!(jfet_by_name("j201").is_some());
1286        assert!(jfet_by_name("J201").is_some());
1287        assert!(jfet_by_name("2n5457").is_some());
1288        assert!(jfet_by_name("2N5457").is_some());
1289    }
1290
1291    #[test]
1292    fn jfet_polarity() {
1293        assert!(jfet_by_name("2N5457").unwrap().is_n_channel);
1294        assert!(jfet_by_name("J201").unwrap().is_n_channel);
1295        assert!(!jfet_by_name("2N5460").unwrap().is_n_channel);
1296    }
1297
1298    #[test]
1299    fn jfet_rejects_bjt() {
1300        assert!(parse_jfet_model_line(".MODEL 2N3904 NPN(IS=6.734f BF=416.4)").is_none());
1301    }
1302
1303    // -----------------------------------------------------------------------
1304    // Triode tests
1305    // -----------------------------------------------------------------------
1306
1307    #[test]
1308    fn parse_triode_line() {
1309        let line = ".TRIODE 12AX7 MU=100 EX=1.4 KG1=1060 KP=600 KVB=300";
1310        let model = parse_triode_model_line(line).unwrap();
1311        assert_eq!(model.name, "12AX7");
1312        assert!((model.mu - 100.0).abs() < 1e-10);
1313        assert!((model.ex - 1.4).abs() < 1e-10);
1314        assert!((model.kg1 - 1060.0).abs() < 1e-10);
1315        assert!((model.kp - 600.0).abs() < 1e-10);
1316        assert!((model.kvb - 300.0).abs() < 1e-10);
1317    }
1318
1319    #[test]
1320    fn triode_embedded_models_load() {
1321        let names = [
1322            "12AX7", "12AT7", "12AU7", "12AY7", "12BH7", "6386", "6DJ8", "2A3", "300B", "6C33C",
1323            "6AN8T", "ECC83", "ECC81", "ECC82", "6072",
1324        ];
1325        for name in &names {
1326            assert!(
1327                triode_by_name(name).is_some(),
1328                "Triode model '{}' not found in embedded triodes.model",
1329                name
1330            );
1331        }
1332    }
1333
1334    #[test]
1335    fn triode_case_insensitive_lookup() {
1336        assert!(triode_by_name("12ax7").is_some());
1337        assert!(triode_by_name("12AX7").is_some());
1338        assert!(triode_by_name("ecc83").is_some());
1339    }
1340
1341    #[test]
1342    fn triode_koren_values_correct() {
1343        // Verify Koren parameters match the Tube.lib reference
1344        let ax7 = triode_by_name("12AX7").unwrap();
1345        assert!((ax7.mu - 100.0).abs() < 1e-10);
1346        assert!((ax7.ex - 1.4).abs() < 1e-10);
1347        assert!((ax7.kg1 - 1060.0).abs() < 1e-10);
1348
1349        let at7 = triode_by_name("12AT7").unwrap();
1350        assert!((at7.mu - 60.0).abs() < 1e-10);
1351        assert!((at7.ex - 1.35).abs() < 1e-10);
1352
1353        let au7 = triode_by_name("12AU7").unwrap();
1354        assert!((au7.mu - 21.5).abs() < 1e-10);
1355        assert!((au7.ex - 1.3).abs() < 1e-10);
1356    }
1357
1358    #[test]
1359    fn triode_aliases_match() {
1360        let ax7 = triode_by_name("12AX7").unwrap();
1361        let ecc83 = triode_by_name("ECC83").unwrap();
1362        assert!((ax7.mu - ecc83.mu).abs() < 1e-10);
1363        assert!((ax7.kp - ecc83.kp).abs() < 1e-10);
1364    }
1365
1366    // -----------------------------------------------------------------------
1367    // Pentode tests
1368    // -----------------------------------------------------------------------
1369
1370    #[test]
1371    fn parse_pentode_line() {
1372        let line = ".PENTODE 6550 MU=7.9 EX=1.35 KG1=890 KG2=4200 KP=60 KVB=24 KVB2=28 VG2=450";
1373        let model = parse_pentode_model_line(line).unwrap();
1374        assert_eq!(model.name, "6550");
1375        assert!((model.mu - 7.9).abs() < 1e-10);
1376        assert!((model.ex - 1.35).abs() < 1e-10);
1377        assert!((model.kg1 - 890.0).abs() < 1e-10);
1378        assert!((model.kg2 - 4200.0).abs() < 1e-10);
1379        assert!((model.kp - 60.0).abs() < 1e-10);
1380        assert!((model.kvb - 24.0).abs() < 1e-10);
1381        assert!((model.kvb2 - 28.0).abs() < 1e-10);
1382        assert!((model.vg2_default - 450.0).abs() < 1e-10);
1383    }
1384
1385    #[test]
1386    fn pentode_embedded_models_load() {
1387        let names = [
1388            "6550", "EL34", "6L6GC", "KT88", "6AN8P", "EF86", "EL84", "6AQ5A", "6973", "6CA7",
1389            "KT77", "5881", "KT66", "KT90", "6L6", "6BQ5", "6267", "6AQ5",
1390        ];
1391        for name in &names {
1392            assert!(
1393                pentode_by_name(name).is_some(),
1394                "Pentode model '{}' not found in embedded pentodes.model",
1395                name
1396            );
1397        }
1398    }
1399
1400    #[test]
1401    fn pentode_case_insensitive_lookup() {
1402        assert!(pentode_by_name("el34").is_some());
1403        assert!(pentode_by_name("EL34").is_some());
1404        assert!(pentode_by_name("kt88").is_some());
1405    }
1406
1407    #[test]
1408    fn pentode_koren_values_correct() {
1409        // Verify Koren parameters match the Tube.lib reference
1410        let p6550 = pentode_by_name("6550").unwrap();
1411        assert!((p6550.mu - 7.9).abs() < 1e-10);
1412        assert!((p6550.kp - 60.0).abs() < 1e-10);
1413        assert!((p6550.kvb - 24.0).abs() < 1e-10);
1414
1415        let kt88 = pentode_by_name("KT88").unwrap();
1416        assert!((kt88.mu - 8.8).abs() < 1e-10);
1417        assert!((kt88.kp - 32.0).abs() < 1e-10);
1418    }
1419
1420    #[test]
1421    fn pentode_kt88_is_distinct_from_6550() {
1422        let p6550 = pentode_by_name("6550").unwrap();
1423        let kt88 = pentode_by_name("KT88").unwrap();
1424        // KT88 is a distinct tube — different KG1, KP, KVB values
1425        assert!((p6550.kp - kt88.kp).abs() > 1.0);
1426    }
1427
1428    #[test]
1429    fn pentode_aliases_match() {
1430        let el34 = pentode_by_name("EL34").unwrap();
1431        let p6ca7 = pentode_by_name("6CA7").unwrap();
1432        assert!((el34.mu - p6ca7.mu).abs() < 1e-10);
1433        assert!((el34.kp - p6ca7.kp).abs() < 1e-10);
1434    }
1435
1436    // -----------------------------------------------------------------------
1437    // Op-amp / OTA tests
1438    // -----------------------------------------------------------------------
1439
1440    #[test]
1441    fn parse_opamp_line() {
1442        let line = ".OPAMP TL072 A0=200k GBW=3MEG SR=13 VPOS=12 VNEG=12 RO=75 COUT=20p";
1443        let model = parse_opamp_model_line(line).unwrap();
1444        assert_eq!(model.name, "TL072");
1445        assert!(!model.is_ota);
1446        assert!((model.open_loop_gain - 200_000.0).abs() < 1e-10);
1447        assert!((model.gbw - 3e6).abs() < 1e-3);
1448        assert!((model.slew_rate - 13.0).abs() < 1e-10);
1449        assert!((model.output_capacitance - 20e-12).abs() < 1e-20);
1450    }
1451
1452    #[test]
1453    fn parse_ota_line() {
1454        let line = ".OTA CA3080 A0=100k GBW=2MEG SR=50 IABC=100u VT=25.85m GM=2m RLOAD=10k";
1455        let model = parse_opamp_model_line(line).unwrap();
1456        assert_eq!(model.name, "CA3080");
1457        assert!(model.is_ota);
1458        assert!((model.gbw - 2e6).abs() < 1e-3);
1459        assert!((model.ota_iabc - 100e-6).abs() < 1e-15);
1460        assert!((model.ota_vt - 25.85e-3).abs() < 1e-12);
1461        assert!((model.ota_gm - 2e-3).abs() < 1e-15);
1462        assert!((model.ota_r_load - 10_000.0).abs() < 1e-10);
1463    }
1464
1465    #[test]
1466    fn opamp_embedded_models_load() {
1467        let names = [
1468            "GENERIC", "TL072", "TL082", "JRC4558", "RC4558", "LM308", "LM741", "NE5532", "OP07",
1469            "CA3080",
1470        ];
1471        for name in &names {
1472            assert!(
1473                opamp_by_name(name).is_some(),
1474                "Op-amp model '{}' not found in embedded opamps.model",
1475                name
1476            );
1477        }
1478    }
1479
1480    #[test]
1481    fn opamp_case_insensitive() {
1482        assert!(opamp_by_name("tl072").is_some());
1483        assert!(opamp_by_name("jrc4558").is_some());
1484        assert!(opamp_by_name("ca3080").is_some());
1485    }
1486
1487    // -----------------------------------------------------------------------
1488    // Transformer tests
1489    // -----------------------------------------------------------------------
1490
1491    #[test]
1492    fn parse_transformer_directive_line() {
1493        let line = ".TRANSFORMER TEST LP=2 K=0.99 RP=75 RS=50 LLP=20m LLS=200u LM=1.98 RC=500k CP=200p N1=10 N2=1";
1494        let model = parse_transformer_model_line(line).unwrap();
1495        assert_eq!(model.name, "TEST");
1496        assert!((model.primary_inductance - 2.0).abs() < 1e-12);
1497        assert!((model.coupling - 0.99).abs() < 1e-12);
1498        assert!((model.primary_dcr - 75.0).abs() < 1e-12);
1499        assert!((model.secondary_dcr - 50.0).abs() < 1e-12);
1500        assert!((model.primary_leakage - 20e-3).abs() < 1e-15);
1501        assert!((model.secondary_leakage - 200e-6).abs() < 1e-18);
1502        assert!((model.magnetizing_inductance - 1.98).abs() < 1e-12);
1503        assert!((model.core_loss_resistance - 500_000.0).abs() < 1e-6);
1504        assert!((model.capacitance - 200e-12).abs() < 1e-21);
1505        assert!((model.primary_turns - 10.0).abs() < 1e-12);
1506        assert!((model.secondary_turns - 1.0).abs() < 1e-12);
1507    }
1508
1509    #[test]
1510    fn transformer_embedded_models_load() {
1511        for name in &["GENERIC_600_600", "JT11P1", "JT10KB-D", "A262A2E"] {
1512            assert!(
1513                transformer_by_name(name).is_some(),
1514                "Transformer model '{}' not found in embedded transformers.model",
1515                name
1516            );
1517        }
1518    }
1519
1520    #[test]
1521    fn transformer_case_insensitive_lookup() {
1522        assert!(transformer_by_name("jt11p1").is_some());
1523        assert!(transformer_by_name("JT11P1").is_some());
1524    }
1525
1526    // -----------------------------------------------------------------------
1527    // Diode tests
1528    // -----------------------------------------------------------------------
1529
1530    #[test]
1531    fn parse_diode_line() {
1532        let line = ".MODEL 1N914 D(IS=7.075E-9 RS=0.78 N=1.95 TT=7.2E-9 CJO=4E-12 VJ=0.657 M=0.4 BV=100 IBV=0.0001)";
1533        let model = parse_diode_model_line(line).unwrap();
1534        assert_eq!(model.name, "1N914");
1535        assert!((model.is - 7.075e-9).abs() < 1e-20);
1536        assert!((model.rs - 0.78).abs() < 1e-10);
1537        assert!((model.n - 1.95).abs() < 1e-10);
1538        assert!((model.bv - 100.0).abs() < 1e-10);
1539    }
1540
1541    #[test]
1542    fn diode_embedded_models_load() {
1543        let names = [
1544            "1N34", "1N914", "1N4001", "1N4002", "1N4003", "1N4004", "1N4005", "1N4006", "1N4007",
1545            "1N4148", "1N5400",
1546        ];
1547        for name in &names {
1548            assert!(
1549                diode_by_name(name).is_some(),
1550                "Diode model '{}' not found in embedded diodes.model",
1551                name
1552            );
1553        }
1554    }
1555
1556    #[test]
1557    fn diode_case_insensitive() {
1558        assert!(diode_by_name("1n914").is_some());
1559        assert!(diode_by_name("1N914").is_some());
1560        assert!(diode_by_name("1n4148").is_some());
1561    }
1562
1563    #[test]
1564    fn diode_germanium_higher_is() {
1565        let ge = diode_by_name("1N34").unwrap();
1566        let si = diode_by_name("1N914").unwrap();
1567        // Germanium has much higher IS (typ ~200pA vs ~7nA but GE N is higher)
1568        assert!(
1569            ge.n > si.n,
1570            "Ge diode should have higher N: Ge={} Si={}",
1571            ge.n,
1572            si.n
1573        );
1574    }
1575
1576    // -----------------------------------------------------------------------
1577    // LED tests
1578    // -----------------------------------------------------------------------
1579
1580    #[test]
1581    fn led_embedded_models_load() {
1582        let names = [
1583            "DLED0",
1584            "DLED1",
1585            "DLED2",
1586            "DLED3",
1587            "LED_IR",
1588            "LED_RED",
1589            "LED_GREEN",
1590            "LED_BLUE",
1591        ];
1592        for name in &names {
1593            assert!(
1594                led_by_name(name).is_some(),
1595                "LED model '{}' not found in embedded leds.model",
1596                name
1597            );
1598        }
1599    }
1600
1601    #[test]
1602    fn led_blue_higher_n() {
1603        let red = led_by_name("LED_RED").unwrap();
1604        let blue = led_by_name("LED_BLUE").unwrap();
1605        // Blue LED has higher Vf → higher N
1606        assert!(
1607            blue.n > red.n,
1608            "Blue LED should have higher N (Vf): {} vs {}",
1609            blue.n,
1610            red.n
1611        );
1612    }
1613
1614    // -----------------------------------------------------------------------
1615    // Schottky tests
1616    // -----------------------------------------------------------------------
1617
1618    #[test]
1619    fn schottky_embedded_models_load() {
1620        let names = ["11DQ03", "11DQ04", "1N5828"];
1621        for name in &names {
1622            assert!(
1623                schottky_by_name(name).is_some(),
1624                "Schottky model '{}' not found in embedded schottky.model",
1625                name
1626            );
1627        }
1628    }
1629
1630    #[test]
1631    fn schottky_has_low_bandgap() {
1632        let s = schottky_by_name("11DQ03").unwrap();
1633        assert!(
1634            (s.eg - 0.69).abs() < 0.01,
1635            "Schottky EG should be ~0.69: {}",
1636            s.eg
1637        );
1638        assert!(
1639            (s.xti - 2.0).abs() < 0.1,
1640            "Schottky XTI should be ~2: {}",
1641            s.xti
1642        );
1643    }
1644
1645    // -----------------------------------------------------------------------
1646    // Zener tests
1647    // -----------------------------------------------------------------------
1648
1649    #[test]
1650    fn zener_embedded_models_load() {
1651        let names = [
1652            "1N746", "1N750", "1N753", "1N758", "1N759", "1N4728", "1N4733", "1N4742", "1N4751",
1653        ];
1654        for name in &names {
1655            assert!(
1656                zener_by_name(name).is_some(),
1657                "Zener model '{}' not found in embedded zeners.model",
1658                name
1659            );
1660        }
1661    }
1662
1663    #[test]
1664    fn zener_voltage_from_bv() {
1665        let z4v7 = zener_by_name("1N750").unwrap();
1666        // 1N750 is a 4.7V zener
1667        assert!(
1668            z4v7.bv > 4.0 && z4v7.bv < 5.0,
1669            "1N750 BV should be ~4.7V: {}",
1670            z4v7.bv
1671        );
1672
1673        let z12v = zener_by_name("1N759").unwrap();
1674        // 1N759 is a 12V zener
1675        assert!(
1676            z12v.bv > 11.0 && z12v.bv < 13.0,
1677            "1N759 BV should be ~12V: {}",
1678            z12v.bv
1679        );
1680    }
1681
1682    #[test]
1683    fn any_diode_cross_registry() {
1684        // Should find across all registries
1685        assert!(any_diode_by_name("1N914").is_some(), "Standard diode");
1686        assert!(any_diode_by_name("LED_RED").is_some(), "LED");
1687        assert!(any_diode_by_name("11DQ03").is_some(), "Schottky");
1688        assert!(any_diode_by_name("1N750").is_some(), "Zener");
1689    }
1690}