velxio/frontend/src/simulation/spice/probes.ts

230 lines
7.7 KiB
TypeScript

/**
* Measurement probes (voltmeters, ammeters, scope probes).
*
* Probes are Velxio components with `metadataId` starting with `instr-`.
* They do NOT contribute SPICE cards themselves (so `componentToSpice`
* returns null for them). Instead, they read the already-solved result
* via net names derived from the same Union-Find used in NetlistBuilder.
*
* The ammeter is special: it *does* inject a "V_name_sense 0" voltage
* source (0 V) in series with one of its pins so ngspice reports a
* branch current for it. Phase 8.5 MVP: emit the sense source by extending
* componentToSpice with an ammeter mapper.
*/
import type { ComponentForSpice, ElectricalSolveResult, TimeWaveforms } from './types';
import type { Wire } from '../../types/wire';
import { UnionFind } from './unionFind';
import { rms, mean, peak, subtract, isAC } from './waveformStats';
export type ProbeKind = 'voltmeter' | 'ammeter' | 'multimeter';
/**
* Time-domain statistics computed from a `.tran` waveform.
* Present only when the probed net has AC content.
*/
export interface ProbeAcStats {
/** Root-mean-square (what a real RMS meter reads for AC). */
rms: number;
/** DC component (mean). */
dc: number;
/** Peak absolute value. */
peak: number;
/** Pre-formatted "RMS X.XX V" / "RMS X.XX mA" line. */
rmsDisplay: string;
/** Pre-formatted "pk X.XX V" line. */
peakDisplay: string;
/** Pre-formatted "DC X.XX V" line. */
dcDisplay: string;
}
export interface ProbeReading {
kind: ProbeKind;
/** Primary numeric value (volts for voltmeter, amps for ammeter). */
value: number;
/** Pretty string for UI display (legacy single-line). */
display: string;
/** Unit label shown next to the value. */
unit: 'V' | 'mV' | 'µV' | 'A' | 'mA' | 'µA' | 'nA' | 'Ω' | 'kΩ' | 'MΩ' | '—';
/** Whether the reading is stale / invalid. */
stale: boolean;
/** Time-domain statistics when the probed net has AC content. */
ac?: ProbeAcStats;
}
/** True if a component is an instrument (doesn't stamp SPICE cards itself). */
export function isInstrument(metadataId: string): boolean {
return metadataId.startsWith('instr-');
}
function formatV(v: number): { value: number; unit: ProbeReading['unit']; display: string } {
const abs = Math.abs(v);
if (abs < 1e-6) return { value: v * 1e6, unit: 'µV', display: `${(v * 1e6).toFixed(2)} µV` };
if (abs < 1) return { value: v * 1e3, unit: 'mV', display: `${(v * 1e3).toFixed(2)} mV` };
return { value: v, unit: 'V', display: `${v.toFixed(3)} V` };
}
function formatI(i: number): { value: number; unit: ProbeReading['unit']; display: string } {
const abs = Math.abs(i);
if (abs < 1e-9) return { value: 0, unit: 'nA', display: `0 nA` };
if (abs < 1e-6) return { value: i * 1e9, unit: 'nA', display: `${(i * 1e9).toFixed(1)} nA` };
if (abs < 1e-3) return { value: i * 1e6, unit: 'µA', display: `${(i * 1e6).toFixed(2)} µA` };
if (abs < 1) return { value: i * 1e3, unit: 'mA', display: `${(i * 1e3).toFixed(2)} mA` };
return { value: i, unit: 'A', display: `${i.toFixed(3)} A` };
}
/**
* Build a pin → net lookup identical to what NetlistBuilder uses. Used by
* probe readings so we can map `voltmeter:Vin` / `voltmeter:Vout` etc. to
* actual net names in the solve result.
*/
export function buildPinNetLookup(
wires: Wire[],
groundPins: Array<{ componentId: string; pinName: string }>,
vccPins: Array<{ componentId: string; pinName: string }>,
): (componentId: string, pinName: string) => string | null {
const uf = new UnionFind();
for (const w of wires) {
const a = `${w.start.componentId}:${w.start.pinName}`;
const b = `${w.end.componentId}:${w.end.pinName}`;
uf.union(a, b);
}
for (const g of groundPins) uf.setCanonical(`${g.componentId}:${g.pinName}`, '0');
for (const v of vccPins) uf.setCanonical(`${v.componentId}:${v.pinName}`, 'vcc_rail');
// Build deterministic net names matching NetlistBuilder's scheme (n0, n1, …)
const reps = [...uf.nets()].sort();
const netNames = new Map<string, string>();
let counter = 0;
for (const rep of reps) {
if (rep === '0' || rep === 'vcc_rail') netNames.set(rep, rep);
else netNames.set(rep, `n${counter++}`);
}
return (componentId, pinName) => {
const key = `${componentId}:${pinName}`;
if (!uf.has(key)) return null;
return netNames.get(uf.find(key)) ?? null;
};
}
function buildAcStatsV(samples: readonly number[]): ProbeAcStats {
const r = rms(samples);
const dc = mean(samples);
const pk = peak(samples);
const rmsFmt = formatV(r);
const dcFmt = formatV(dc);
const pkFmt = formatV(pk);
return {
rms: r,
dc,
peak: pk,
rmsDisplay: `RMS ${rmsFmt.display}`,
peakDisplay: `pk ${pkFmt.display}`,
dcDisplay: `DC ${dcFmt.display}`,
};
}
function buildAcStatsI(samples: readonly number[]): ProbeAcStats {
const r = rms(samples);
const dc = mean(samples);
const pk = peak(samples);
const rmsFmt = formatI(r);
const dcFmt = formatI(dc);
const pkFmt = formatI(pk);
return {
rms: r,
dc,
peak: pk,
rmsDisplay: `RMS ${rmsFmt.display}`,
peakDisplay: `pk ${pkFmt.display}`,
dcDisplay: `DC ${dcFmt.display}`,
};
}
export function readVoltmeter(
comp: ComponentForSpice,
netLookup: (componentId: string, pinName: string) => string | null,
solve: ElectricalSolveResult,
timeWaveforms?: TimeWaveforms,
): ProbeReading {
const plusNet = netLookup(comp.id, 'V+');
const minusNet = netLookup(comp.id, 'V-');
if (!plusNet || !minusNet) {
return {
kind: 'voltmeter',
value: 0,
unit: '—',
display: '— probe not connected',
stale: true,
};
}
const vp = solve.nodeVoltages[plusNet] ?? 0;
const vm = solve.nodeVoltages[minusNet] ?? 0;
const diff = vp - vm;
const fmt = formatV(diff);
let ac: ProbeAcStats | undefined;
if (timeWaveforms) {
const plusSamples = timeWaveforms.nodes.get(plusNet);
const minusSamples = plusNet === '0' ? undefined : timeWaveforms.nodes.get(minusNet);
const plusArr = plusSamples ?? (plusNet === '0' ? [] : undefined);
const minusArr = minusSamples ?? (minusNet === '0' ? [] : undefined);
if (plusArr !== undefined && minusArr !== undefined) {
// subtract() tolerates a zero-length array and returns []; guard with a length check.
const diffSamples =
plusArr.length === 0
? minusArr.map((v) => -v)
: minusArr.length === 0
? [...plusArr]
: subtract(plusArr, minusArr);
if (diffSamples.length > 0 && isAC(diffSamples)) {
ac = buildAcStatsV(diffSamples);
}
}
}
return {
kind: 'voltmeter',
value: fmt.value,
unit: fmt.unit,
display: ac ? ac.rmsDisplay : fmt.display,
stale: !solve.converged,
ac,
};
}
export function readAmmeter(
comp: ComponentForSpice,
solve: ElectricalSolveResult,
timeWaveforms?: TimeWaveforms,
): ProbeReading {
const key = `v_${comp.id}_sense`;
const i = solve.branchCurrents[key];
if (i == null) {
return { kind: 'ammeter', value: 0, unit: '—', display: '— no sense reading', stale: true };
}
// Convention: ngspice reports positive current flowing from + terminal into
// the source (which is our "entry" terminal). Ammeters typically want the
// current through, with a sign convention of + out of the "+" probe.
const signed = -i;
const fmt = formatI(signed);
let ac: ProbeAcStats | undefined;
if (timeWaveforms) {
const raw = timeWaveforms.branches.get(key);
if (raw && raw.length > 0) {
const signedSamples = raw.map((v) => -v);
if (isAC(signedSamples)) {
ac = buildAcStatsI(signedSamples);
}
}
}
return {
kind: 'ammeter',
value: fmt.value,
unit: fmt.unit,
display: ac ? ac.rmsDisplay : fmt.display,
stale: !solve.converged,
ac,
};
}