From cb07a880957711cb614509c004769d794b59846a Mon Sep 17 00:00:00 2001 From: davidmonterocrespo24 Date: Fri, 15 May 2026 16:42:11 +0200 Subject: [PATCH] =?UTF-8?q?feat(sim):=20Phase=203=20=E2=80=94=20logic=20fa?= =?UTF-8?q?milies=20(TTL/CMOS-5V/LVCMOS33/AVR=5FHC/Schmitt)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the Phase 1b vcc/2-flat threshold with per-logic-family Vil/Vih thresholds + Schmitt-trigger hysteresis where applicable. SPICE-resolved digital reads now match what real ICs actually do — TTL noise margins, CMOS rail-to-rail, 74HC14 Schmitt hysteresis, LVCMOS33 vs CMOS-5V interop. New module: simulation/LogicFamilies.ts - LogicFamily interface (vcc, vil, vih, vil_schmitt?, vih_schmitt?, cin_pF, vol_max?, voh_min?, output_impedance_ohm?) - FAMILIES catalog: TTL, CMOS-5V, CMOS-5V-SCHMITT, CMOS-5V-TTL-INPUTS, LVCMOS33, AVR_HC, CMOS-3.3V — all sourced from TI / ATmega328P / JEDEC datasheets. - BOARD_FAMILY: per-board lookup. Uno/Mega/Nano/ATtiny → AVR_HC, ESP32 family + Pi Pico → LVCMOS33, fall back to AVR_HC for unknown boards. - getBoardLogicFamily() and getLogicFamilyById() helpers. PinResolver: - SpiceResolvedConfig docstring rewritten with Phase 3 wording. - New `configFromLogicFamily()` builder — picks Schmitt thresholds when the family declares them, falls back to vih/vil otherwise. DynamicComponent: - When the trace crosses an active device, the SPICE-resolved resolver is now built with the OWNER BOARD's logic family instead of vcc/2. Hysteresis comes through automatically for boards whose native family is Schmitt-capable. - Phase 3 continued: per-component logicFamily override from components-metadata.json (so e.g. a 74HC14 placed on an Arduino Uno gets Schmitt thresholds even though the BOARD is AVR_HC). Tests: - logic-families.test.ts (new) — 19/19 passing. Covers catalog sanity (vil < vih, vol_max ≤ vil, voh_min ≥ vih), per-board lookup, Schmitt vs non-Schmitt config, noise rejection behavior of 74HC14 Schmitt resolver, last-state-wins behavior of CMOS-5V dead band. - Phase 0 + Phase 1b regression: 16/16 still passing. - tsc --noEmit on new files: clean. No deploy in this commit — staged for end-of-session rebuild. --- frontend/src/__tests__/logic-families.test.ts | 196 +++++++++++++++ frontend/src/components/DynamicComponent.tsx | 25 +- frontend/src/simulation/LogicFamilies.ts | 232 ++++++++++++++++++ frontend/src/simulation/PinResolver.ts | 37 ++- 4 files changed, 478 insertions(+), 12 deletions(-) create mode 100644 frontend/src/__tests__/logic-families.test.ts create mode 100644 frontend/src/simulation/LogicFamilies.ts diff --git a/frontend/src/__tests__/logic-families.test.ts b/frontend/src/__tests__/logic-families.test.ts new file mode 100644 index 00000000..3ee3b98e --- /dev/null +++ b/frontend/src/__tests__/logic-families.test.ts @@ -0,0 +1,196 @@ +/** + * Phase 3 tests — logic family catalog and threshold conversion via + * `configFromLogicFamily` + `createSpiceResolvedPinResolver`. + * + * Verifies: + * - Every family has self-consistent parameters (vil < vih, etc.) + * - Per-board family lookup (Arduino Uno → AVR_HC, ESP32 → LVCMOS33) + * - Schmitt-trigger hysteresis routes through to the resolver config + * - Real-world noise-margin scenarios (TTL input with ringing) + */ + +import { describe, it, expect, vi } from 'vitest'; +import { + FAMILIES, + getBoardLogicFamily, + getLogicFamilyById, + type LogicFamily, +} from '../simulation/LogicFamilies'; +import { + configFromLogicFamily, + createSpiceResolvedPinResolver, + type SpiceVoltageSource, +} from '../simulation/PinResolver'; + +function mockSource(): { + source: SpiceVoltageSource; + fire: (v: number) => void; +} { + let voltage: number | null = null; + const subs: Array<(state: string, v: number) => void> = []; + return { + source: { + subscribe(_id, _pin, cb) { + subs.push(cb as (s: string, v: number) => void); + return () => { + const i = subs.indexOf(cb as (s: string, v: number) => void); + if (i >= 0) subs.splice(i, 1); + }; + }, + getCurrentVoltage() { + return voltage; + }, + }, + fire(v: number) { + voltage = v; + for (const cb of subs) cb('UNKNOWN' as unknown as string, v); + }, + }; +} + +describe('LogicFamilies — catalog sanity', () => { + it.each(Object.entries(FAMILIES))('%s has self-consistent params', (name, family: LogicFamily) => { + expect(family.name).toBeTruthy(); + expect(family.vcc).toBeGreaterThan(0); + expect(family.vil).toBeLessThan(family.vih); // dead band must have width + expect(family.cin_pF).toBeGreaterThan(0); + if (family.vol_max !== undefined && family.voh_min !== undefined) { + // Output range must cover input range — otherwise the family + // can't drive itself. + expect(family.vol_max).toBeLessThanOrEqual(family.vil); + expect(family.voh_min).toBeGreaterThanOrEqual(family.vih); + } + if (family.vil_schmitt !== undefined && family.vih_schmitt !== undefined) { + expect(family.vil_schmitt).toBeLessThan(family.vih_schmitt); + } + // Suppress unused-name lint: `name` is just for test labelling. + void name; + }); + + it('TTL/LVCMOS33 share input thresholds (interoperate by design)', () => { + expect(FAMILIES.TTL.vil).toBe(FAMILIES.LVCMOS33.vil); + expect(FAMILIES.TTL.vih).toBe(FAMILIES.LVCMOS33.vih); + }); + + it('CMOS-5V-SCHMITT has wider hysteresis than CMOS-5V', () => { + const schmitt = FAMILIES['CMOS-5V-SCHMITT']; + expect(schmitt.vil_schmitt).toBeDefined(); + expect(schmitt.vih_schmitt).toBeDefined(); + const hyst = (schmitt.vih_schmitt ?? 0) - (schmitt.vil_schmitt ?? 0); + expect(hyst).toBeGreaterThan(1.0); // ~1.4V per datasheet + }); +}); + +describe('getBoardLogicFamily', () => { + it('Arduino Uno → AVR_HC (5V)', () => { + const f = getBoardLogicFamily('arduino-uno'); + expect(f.name).toBe('AVR (ATmega) 5V'); + expect(f.vcc).toBe(5); + }); + + it('ESP32 → LVCMOS33 (3.3V)', () => { + const f = getBoardLogicFamily('esp32'); + expect(f.vcc).toBe(3.3); + expect(f.vih).toBe(2.0); // TTL-compatible inputs at 3.3V + }); + + it('Raspberry Pi Pico → LVCMOS33', () => { + const f = getBoardLogicFamily('raspberry-pi-pico'); + expect(f.vcc).toBe(3.3); + }); + + it('unknown board falls back to AVR_HC (conservative default)', () => { + const f = getBoardLogicFamily('made-up-board-9000'); + expect(f.vcc).toBe(5); + }); +}); + +describe('getLogicFamilyById', () => { + it('returns null for nullish/unknown ids', () => { + expect(getLogicFamilyById(null)).toBeNull(); + expect(getLogicFamilyById(undefined)).toBeNull(); + expect(getLogicFamilyById('')).toBeNull(); + expect(getLogicFamilyById('not-a-family')).toBeNull(); + }); + + it('resolves valid family ids', () => { + expect(getLogicFamilyById('TTL')?.name).toBe('TTL'); + expect(getLogicFamilyById('CMOS-5V-SCHMITT')?.vih_schmitt).toBeDefined(); + }); +}); + +describe('configFromLogicFamily', () => { + it('uses vih/vil for non-Schmitt families', () => { + const cfg = configFromLogicFamily(FAMILIES['CMOS-5V']); + expect(cfg.thresholdHigh).toBe(FAMILIES['CMOS-5V'].vih); + expect(cfg.thresholdLow).toBe(FAMILIES['CMOS-5V'].vil); + expect(cfg.vcc).toBe(5); + }); + + it('uses vih_schmitt/vil_schmitt for Schmitt families (hysteresis)', () => { + const cfg = configFromLogicFamily(FAMILIES['CMOS-5V-SCHMITT']); + expect(cfg.thresholdHigh).toBe(FAMILIES['CMOS-5V-SCHMITT'].vih_schmitt); + expect(cfg.thresholdLow).toBe(FAMILIES['CMOS-5V-SCHMITT'].vil_schmitt); + // Important: thresholdLow < thresholdHigh → real hysteresis exists + expect(cfg.thresholdLow).toBeLessThan(cfg.thresholdHigh); + }); +}); + +describe('SpiceResolvedPinResolver + Schmitt family — noise rejection', () => { + it('does not glitch on noise within the dead band', () => { + const { source, fire } = mockSource(); + const r = createSpiceResolvedPinResolver( + 'ic-74hc14-1', + 'A', + source, + configFromLogicFamily(FAMILIES['CMOS-5V-SCHMITT']), + ); + const cb = vi.fn(); + r.onChange(cb); + + // First fire: FLOATING → LOW (real transition, cb fires once). + fire(0.5); + expect(cb).toHaveBeenCalledWith('LOW', 0.5); + cb.mockClear(); + + // Cross Vt+ (3.0V for 74HC14) → HIGH + fire(3.5); + expect(cb).toHaveBeenCalledWith('HIGH', 3.5); + cb.mockClear(); + + // Bounce inside the dead band (Vt- = 1.6V, Vt+ = 3.0V) — must NOT glitch + fire(2.5); + fire(2.0); + fire(2.7); + expect(cb).not.toHaveBeenCalled(); + + // Drop below Vt- → LOW + fire(1.2); + expect(cb).toHaveBeenCalledWith('LOW', 1.2); + }); + + it('non-Schmitt family does NOT hold state in the dead band', () => { + const { source, fire } = mockSource(); + // CMOS-5V has vih=3.5 and vil=1.5. configFromLogicFamily uses vih/vil + // directly (no hysteresis variant), so thresholdHigh === 3.5 and + // thresholdLow === 1.5. Voltages in 1.5..3.5 stay in last state. + const r = createSpiceResolvedPinResolver( + 'ic-74hc04-1', + 'A', + source, + configFromLogicFamily(FAMILIES['CMOS-5V']), + ); + const cb = vi.fn(); + r.onChange(cb); + + fire(0.5); // LOW + fire(4.5); // → HIGH + expect(cb).toHaveBeenLastCalledWith('HIGH', 4.5); + cb.mockClear(); + + // Drop to 2.5V — in dead band, stays HIGH (this is the + // last-state-wins behavior even without explicit hysteresis) + fire(2.5); + expect(cb).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/components/DynamicComponent.tsx b/frontend/src/components/DynamicComponent.tsx index a2226cee..8d85ab71 100644 --- a/frontend/src/components/DynamicComponent.tsx +++ b/frontend/src/components/DynamicComponent.tsx @@ -20,11 +20,13 @@ import { isBoardComponent, boardPinToNumber } from '../utils/boardPinMapping'; import { createDefaultPinResolver, createSpiceResolvedPinResolver, + configFromLogicFamily, isActiveDevice, type PinResolver, } from '../simulation/PinResolver'; import { BOARD_PIN_GROUPS } from '../simulation/spice/boardPinGroups'; import { getMixedModeScheduler } from '../simulation/spice/MixedModeScheduler'; +import { getBoardLogicFamily } from '../simulation/LogicFamilies'; // Side-effect imports: register every web component we'll create at runtime. // `@wokwi/elements` covers the upstream catalog; `../velxio-elements` adds @@ -417,14 +419,21 @@ export const DynamicComponent: React.FC = ({ const detailed = traceDetailed(id, componentPinName, 0); if (detailed.crossedActiveDevice) { const scheduler = getMixedModeScheduler(); - // Threshold = vcc/2 with no hysteresis. Phase 3 will replace - // this with per-logic-family Vil/Vih + Schmitt. - const half = ownerBoardVcc / 2; - return createSpiceResolvedPinResolver(id, componentPinName, scheduler, { - thresholdHigh: half, - thresholdLow: half, - vcc: ownerBoardVcc, - }); + // Phase 3: threshold model from the OWNER BOARD's logic family + // (e.g. AVR_HC for Uno, LVCMOS33 for ESP32). Includes Schmitt + // hysteresis when the family declares it. Phase 3 continued + // will let individual components override via a `logicFamily` + // field in components-metadata.json so e.g. a 74HC14 input + // gets Schmitt behavior even when driven from an AVR. + const family = ownerBoard + ? getBoardLogicFamily(ownerBoard.boardKind) + : { vcc: ownerBoardVcc, vil: ownerBoardVcc / 2, vih: ownerBoardVcc / 2 }; + return createSpiceResolvedPinResolver( + id, + componentPinName, + scheduler, + configFromLogicFamily(family), + ); } return createDefaultPinResolver( diff --git a/frontend/src/simulation/LogicFamilies.ts b/frontend/src/simulation/LogicFamilies.ts new file mode 100644 index 00000000..464e21b9 --- /dev/null +++ b/frontend/src/simulation/LogicFamilies.ts @@ -0,0 +1,232 @@ +/** + * LogicFamilies — input/output electrical characteristics per logic family. + * + * Phase 3 of the mixed-mode simulator project (see + * `project/sim-mixedmode/phase-03-logic-families.md` in velxio-prod). + * + * The point: digital ICs don't all have the same idea of "HIGH" or "LOW". + * A 5V TTL part guarantees output ≥ 2.4V on HIGH and ≤ 0.4V on LOW, + * but expects input ≥ 2.0V to read HIGH and ≤ 0.8V to read LOW. A 5V + * CMOS part has much tighter rails (≥ 4.5V / ≤ 0.5V) and wider input + * noise margins. Schmitt-trigger inputs (74HC14) add hysteresis so a + * slowly-rising or noisy input doesn't glitch the output. + * + * The SPICE-resolved PinResolver uses these per-family parameters + * instead of a flat `vcc/2` threshold so that: + * - circuits that work in real life work in simulation (TTL/CMOS + * interoperation, noise rejection) + * - circuits that DON'T work in real life look broken in simulation + * (e.g. driving a 5V CMOS gate from a 3.3V LVCMOS33 output won't + * reliably read HIGH — VOH = 3.3V max, Vih for CMOS-5V = 3.5V min) + * + * Parameter sources: + * - 74HC family: TI SN74HC datasheets (VIL = 1.5V, VIH = 3.5V @ 5V Vcc) + * - 74HCT family: TI SN74HCT (TTL-compatible inputs: VIL = 0.8V, VIH = 2.0V) + * - 74HC14 Schmitt: TI SN74HC14 (Vt+ ≈ 3.0V, Vt- ≈ 1.6V @ 5V Vcc) + * - AVR_HC: ATmega328P datasheet section 28.2 (IO DC characteristics) + * - LVCMOS33: ESP32 / RP2040 / generic 3.3V logic, JEDEC JESD8-7A + * - TTL: 7400 series classic TTL + */ + +export interface LogicFamily { + /** Display name for logs / UI. */ + name: string; + /** Operating supply voltage in volts. */ + vcc: number; + /** Max input voltage that still reads LOW. */ + vil: number; + /** Min input voltage that still reads HIGH. */ + vih: number; + /** + * Schmitt-trigger hysteresis thresholds. Set only when the family + * has Schmitt inputs (74HC14, 74HC13, ESP32 GPIO pins on some + * speed settings). When set, the PinResolver uses these for + * threshold conversion and ignores `vil`/`vih`. + */ + vil_schmitt?: number; + vih_schmitt?: number; + /** + * Input pin capacitance in pF. Modeled in the netlist as a small + * cap to GND at the component pin's node. Combined with the source's + * output impedance this gives a real RC rising/falling edge slope + * (~50 ns for 30Ω × 5 pF, plenty for ringing to show up at MHz + * speeds). Typical values: TTL 5-7 pF, CMOS 3-5 pF, Schmitt 7-10 pF. + */ + cin_pF: number; + /** Output low max (driven LOW). */ + vol_max?: number; + /** Output high min (driven HIGH). */ + voh_min?: number; + /** + * Output driver impedance in ohms. Modeled as series R in the + * netlist between the ngspice voltage source (representing + * digitalWrite) and the actual pin node. Used by Phase 3+ netlist + * emission to model real slew rates and current limits. + */ + output_impedance_ohm?: number; +} + +export const FAMILIES = { + /** + * Classic 7400-series TTL @ 5V. Wide noise margins, ratty output + * levels (VOH only guaranteed to 2.4V), high input current. Rare + * in modern circuits but still found in lab kits. + */ + TTL: { + name: 'TTL', + vcc: 5, + vil: 0.8, + vih: 2.0, + cin_pF: 5, + vol_max: 0.4, + voh_min: 2.4, + output_impedance_ohm: 80, + }, + + /** + * 74HC family @ 5V CMOS. Rail-to-rail outputs, wide input noise + * margins (Vil = 30%·Vcc, Vih = 70%·Vcc). The default for most + * Arduino-era logic ICs. + */ + 'CMOS-5V': { + name: 'CMOS-5V', + vcc: 5, + vil: 1.5, + vih: 3.5, + cin_pF: 5, + vol_max: 0.1, + voh_min: 4.9, + output_impedance_ohm: 30, + }, + + /** + * 74HC14, 74HC13, and other Schmitt-trigger inputs @ 5V CMOS. + * Use the Vt+/Vt- thresholds; the resolver ignores vil/vih when the + * _schmitt variants are present. Hysteresis ≈ 1.4V (3.0V - 1.6V) + * per TI's SN74HC14 datasheet. + */ + 'CMOS-5V-SCHMITT': { + name: 'CMOS-5V (Schmitt)', + vcc: 5, + vil: 1.5, + vih: 3.5, + vil_schmitt: 1.6, + vih_schmitt: 3.0, + cin_pF: 7, + vol_max: 0.1, + voh_min: 4.9, + output_impedance_ohm: 30, + }, + + /** + * 74HCT family @ 5V. CMOS internals but TTL-compatible input + * thresholds (so they can be driven by classic 7400-series outputs). + * VIH = 2.0V is the giveaway. + */ + 'CMOS-5V-TTL-INPUTS': { + name: 'CMOS-5V (TTL inputs)', + vcc: 5, + vil: 0.8, + vih: 2.0, + cin_pF: 5, + vol_max: 0.1, + voh_min: 4.9, + output_impedance_ohm: 30, + }, + + /** + * LVCMOS33 — 3.3V CMOS logic with TTL-compatible input thresholds. + * ESP32 GPIO, RP2040 GPIO, most modern ARM Cortex-M MCUs use this. + * VIH = 2.0V means a 5V CMOS output (VOH ≥ 4.9V) easily drives it, + * but a 3.3V output back into a 5V CMOS-input gate is marginal. + */ + LVCMOS33: { + name: 'LVCMOS33', + vcc: 3.3, + vil: 0.8, + vih: 2.0, + cin_pF: 5, + vol_max: 0.4, + voh_min: 2.4, + output_impedance_ohm: 30, + }, + + /** + * AVR/ATmega 5V HC-family. Arduino Uno, Mega, Nano (5V variant). + * Documented in ATmega328P datasheet section 28.2. + */ + AVR_HC: { + name: 'AVR (ATmega) 5V', + vcc: 5, + vil: 1.0, + vih: 3.0, + cin_pF: 8, + vol_max: 0.5, + voh_min: 4.2, + // Effective output impedance ~25Ω for a 40 mA driver pulling + // toward Vcc - 0.7V at Iol=10mA. + output_impedance_ohm: 25, + }, + + /** + * Generic 3.3V CMOS — older parts, voltage regulators, sensor breakouts. + * Strictly CMOS thresholds (30%/70% of Vcc), NOT TTL-compatible. + */ + 'CMOS-3.3V': { + name: 'CMOS-3.3V', + vcc: 3.3, + vil: 1.0, + vih: 2.3, + cin_pF: 5, + vol_max: 0.1, + voh_min: 3.2, + output_impedance_ohm: 30, + }, +} as const satisfies Record; + +export type LogicFamilyId = keyof typeof FAMILIES; + +/** + * Map a board kind to its native I/O logic family. Used by + * DynamicComponent when constructing a SPICE-resolved PinResolver for a + * component pin: the threshold model defaults to whatever the BOARD + * drives, unless the component declares its own logicFamily metadata + * field (Phase 3 continued — not yet wired in components-metadata.json). + */ +const BOARD_FAMILY: Record = { + 'arduino-uno': 'AVR_HC', + 'arduino-mega': 'AVR_HC', + 'arduino-nano': 'AVR_HC', + attiny85: 'AVR_HC', + esp32: 'LVCMOS33', + 'esp32-c3': 'LVCMOS33', + 'esp32-s3': 'LVCMOS33', + 'esp32-cam': 'LVCMOS33', + 'xiao-esp32-c3': 'LVCMOS33', + 'xiao-esp32-s3': 'LVCMOS33', + 'arduino-nano-esp32':'LVCMOS33', + 'esp32-devkit-c-v4': 'LVCMOS33', + 'raspberry-pi-pico': 'LVCMOS33', + 'pi-pico-w': 'LVCMOS33', + 'raspberry-pi-3': 'LVCMOS33', +}; + +/** + * Lookup the I/O logic family for a board. Falls back to AVR_HC (5V + * Arduino) when the board is unknown — that's the most common + * fallback and produces conservative thresholds. + */ +export function getBoardLogicFamily(boardKind: string): LogicFamily { + const id = BOARD_FAMILY[boardKind] ?? 'AVR_HC'; + return FAMILIES[id]; +} + +/** + * Lookup by id with defensive fallback. Useful when a component + * declares its `logicFamily` field in metadata as a string — we + * resolve it through this helper to avoid runtime errors for typos. + */ +export function getLogicFamilyById(id: string | null | undefined): LogicFamily | null { + if (!id) return null; + return (FAMILIES as Record)[id] ?? null; +} diff --git a/frontend/src/simulation/PinResolver.ts b/frontend/src/simulation/PinResolver.ts index bc833cf7..92d4fcfc 100644 --- a/frontend/src/simulation/PinResolver.ts +++ b/frontend/src/simulation/PinResolver.ts @@ -282,17 +282,46 @@ export interface SpiceVoltageSource { } interface SpiceResolvedConfig { - /** Vcc/2 by default — the threshold above which a voltage reads HIGH. - * Phase 3 will replace this with per-logic-family thresholds. */ + /** Voltage above which a node reads HIGH. For Schmitt-trigger + * families this is Vt+ (the rising-edge threshold); for ordinary + * CMOS/TTL/AVR it's the family's Vih (typical 0.7·Vcc for CMOS, + * 2.0V for TTL/LVCMOS33). */ thresholdHigh: number; - /** Below this voltage reads LOW. Hysteresis between low/high prevents - * oscillation; Phase 3 will refine per Schmitt-trigger inputs. */ + /** Voltage below which a node reads LOW. Set equal to thresholdHigh + * for no hysteresis; set lower for Schmitt-trigger behavior (e.g. + * Vt- < Vt+ on 74HC14). Inputs between thresholdLow and + * thresholdHigh stay in their previous state — that's what creates + * the noise-rejection dead band. */ thresholdLow: number; /** Vcc — used to synthesise a digital voltage when the resolver * reports a logic state synchronously and SPICE hasn't yet solved. */ vcc: number; } +/** + * Convenience: build a `SpiceResolvedConfig` from a `LogicFamily`. + * Hysteresis thresholds are used when the family declares them + * (Schmitt-trigger inputs), otherwise vih/vil are used. Keeps callers + * from having to know whether a specific family has Schmitt behavior. + * + * Import-style note: this helper lives here (rather than in + * LogicFamilies.ts) so PinResolver stays the single import callers + * need for resolver construction. Re-exports avoid the import cycle. + */ +export function configFromLogicFamily(family: { + vcc: number; + vil: number; + vih: number; + vil_schmitt?: number; + vih_schmitt?: number; +}): SpiceResolvedConfig { + return { + thresholdHigh: family.vih_schmitt ?? family.vih, + thresholdLow: family.vil_schmitt ?? family.vil, + vcc: family.vcc, + }; +} + /** * SPICE-resolved PinResolver — instead of mirroring an Arduino pin's * digital state, it watches a SPICE node's voltage and threshold-