fix(sim): correct NTC temperature sensor divider + reset to default on restart

The NTC breakout's SPICE topology was inverted relative to the example
sketch's decode formula (rNtc = R_PULL * v / (5 - v)), which assumes a 10k
pull-up from VCC to OUT and the NTC from OUT to GND. The mapper had the NTC
on top (VCC->OUT) and the pull-down on the bottom, so the recovered
temperature ran backwards: dragging the slider to 100C made the sketch
print -25C. Swap the two resistors so V_OUT = 5 * Rntc / (Rntc + Rpull),
matching the sketch and the hand-built reference netlist in
spice-avr-mixed.test.ts (T=0 -> ADC 789, T=25 -> 511, T=50 -> 270).

Also replace the SensorParts linear approximation (2.5 - (t-25)*0.02) with
the same beta-model divider so the non-SPICE ADC injection decodes back to
the slider value, and drop the dead onInput path that treated the element's
value as a raw ADC count.

Reset now restores interactive sensors (temperature/lux/gas sliders) to
their configured defaults: resetBoard re-dispatches each sensor's default
into the running sim and bumps sensorResetNonce so the open
SensorControlPanel remounts and the slider snaps back. Previously a restart
left the NTC frozen at the last dragged temperature.

Updated the examples netlist snapshot for the swapped NTC cards.
This commit is contained in:
David Montero 2026-06-15 23:21:36 +02:00
parent e51d26ce33
commit 608c2538c9
5 changed files with 58 additions and 23 deletions

View File

@ -1416,8 +1416,8 @@ R_autopull_n6 n6 0 100Meg
exports[`netlist snapshot — circuits (41 examples) > nano-sensor-station 1`] = `
"* Velxio circuit
V_VCC_RAIL vcc_rail 0 DC 5
R_ntc_ntc vcc_rail n1 11441.484668193882
R_ntc_pull n1 0 10000
R_ntc_pull vcc_rail n1 10000
R_ntc_ntc n1 0 11441.484668193882
R_ldr_ldr vcc_rail n0 400000
R_ldr_pull n0 0 10000
.op
@ -1446,8 +1446,8 @@ R_autopull_n5 n5 0 100Meg
exports[`netlist snapshot — circuits (41 examples) > ntc-temperature 1`] = `
"* Velxio circuit
V_VCC_RAIL vcc_rail 0 DC 5
R_ntc_ntc vcc_rail n0 10000
R_ntc_pull n0 0 10000
R_ntc_pull vcc_rail n0 10000
R_ntc_ntc n0 0 10000
.op
.end"
`;

View File

@ -118,6 +118,7 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => {
addBoard,
components,
running,
sensorResetNonce,
pinManager,
initSimulator,
updateComponentState,
@ -2490,14 +2491,15 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => {
key={sensorControlComponentId} forces a fresh mount when the user clicks a
different instance of the same sensor type (e.g. a second photoresistor); the
slider state is local and would otherwise show the previously-clicked sensor's
value until the user manually moved it. */}
value until the user manually moved it. The sensorResetNonce suffix also remounts
it on Reset, so the slider snaps back to the sensor's default value. */}
{sensorControlComponentId &&
sensorControlMetadataId &&
(() => {
const meta = registry.getById(sensorControlMetadataId);
return (
<SensorControlPanel
key={sensorControlComponentId}
key={`${sensorControlComponentId}:${sensorResetNonce}`}
componentId={sensorControlComponentId}
metadataId={sensorControlMetadataId}
sensorName={meta?.name ?? sensorControlMetadataId}

View File

@ -62,26 +62,29 @@ PartSimulationRegistry.register('tilt-switch', {
* NTC thermistor sensor injects analog voltage representing temperature.
* Default 25°C 2.5V. SensorControlPanel slider adjusts temperature.
*
* Linear approximation: volts = clamp(2.5 - (temp - 25) * 0.02, 0, 5)
* (25°C = 2.5V; lower temp = higher voltage, higher temp = lower voltage)
* The injected OUT voltage uses the SAME β-model voltage divider the circuit
* (and the example sketch) assume: a 10k pull-up from VCC to OUT and the NTC
* from OUT to GND, so V_OUT = 5 · R_ntc / (R_ntc + R_pull) with
* R_ntc(T) = R0 · exp(β · (1/T 1/T0)). This makes the injected ADC voltage
* decode straight back to the slider value (set 50°C read 50°C). When the
* electrical (SPICE) engine is active it drives A0 from the ngspice solve
* instead, using the matching topology in componentToSpice.ts both agree.
*/
PartSimulationRegistry.register('ntc-temperature-sensor', {
attachEvents: (element, simulator, getArduinoPinHelper, componentId) => {
attachEvents: (_element, simulator, getArduinoPinHelper, componentId) => {
const pin = getArduinoPinHelper('OUT');
const tempToVolts = (temp: number) => Math.max(0, Math.min(5, 2.5 - (temp - 25) * 0.02));
const NTC_R0 = 10_000;
const NTC_BETA = 3950;
const R_PULL = 10_000;
const tempToVolts = (temp: number) => {
const rNtc = NTC_R0 * Math.exp(NTC_BETA * (1 / (temp + 273.15) - 1 / 298.15));
return Math.max(0, Math.min(5, 5 * (rNtc / (rNtc + R_PULL))));
};
// Room temperature default
if (pin !== null) setAdcVoltage(simulator, pin, tempToVolts(25));
const onInput = () => {
const val = (element as any).value;
if (val !== undefined && pin !== null) {
setAdcVoltage(simulator, pin, (val / 1023.0) * 5.0);
}
};
element.addEventListener('input', onInput);
registerSensorUpdate(componentId, (values) => {
if ('temperature' in values) {
if (pin !== null) {
@ -94,7 +97,6 @@ PartSimulationRegistry.register('ntc-temperature-sensor', {
});
return () => {
element.removeEventListener('input', onInput);
unregisterSensorUpdate(componentId);
};
},

View File

@ -700,9 +700,11 @@ const MAPPERS: Record<string, Mapper> = {
},
// NTC temperature sensor — 3-pin breakout module (VCC, GND, OUT).
// Internal topology: NTC thermistor between VCC and OUT, plus an internal
// 10k pull-down from OUT to GND. Temperature up → R_ntc down → V_OUT up.
// Matches the β-model math used in the ntc-temperature example.
// Internal topology: a 10k pull-up from VCC to OUT, with the NTC thermistor
// from OUT to GND, so V_OUT = Vcc · R_ntc / (R_ntc + R_pull). Temperature up
// → R_ntc down → V_OUT down. This is the exact divider the ntc-temperature
// example sketch inverts to recover R_ntc (rNtc = R_PULL · v / (5 v)); with
// VCC and GND swapped (NTC on top) the decoded temperature ran backwards.
'ntc-temperature-sensor': (comp, netLookup) => {
const vcc = netLookup('VCC');
const gnd = netLookup('GND');
@ -719,7 +721,7 @@ const MAPPERS: Record<string, Mapper> = {
}
const Rpull = parseValueWithUnits(comp.properties.pullup, 10_000);
return {
cards: [`R_${comp.id}_ntc ${vcc} ${out} ${Rntc}`, `R_${comp.id}_pull ${out} ${gnd} ${Rpull}`],
cards: [`R_${comp.id}_pull ${vcc} ${out} ${Rpull}`, `R_${comp.id}_ntc ${out} ${gnd} ${Rntc}`],
modelsUsed: new Set(),
};
},

View File

@ -37,6 +37,8 @@ import {
updateWires as icUpdateWires,
setInterconnectRuntime,
} from '../simulation/Interconnect';
import { SENSOR_CONTROLS } from '../simulation/sensorControlConfig';
import { dispatchSensorUpdate } from '../simulation/SensorUpdateRegistry';
// ── Sensor pre-registration ──────────────────────────────────────────────────
// Maps component metadataId → { sensorType, dataPinName, propertyKeys }
@ -817,6 +819,9 @@ interface SimulatorState {
running: boolean;
compiledHex: string | null;
hexEpoch: number;
/** Bumped on every Reset so the open SensorControlPanel remounts and
* re-reads each interactive sensor's freshly-defaulted value. */
sensorResetNonce: number;
serialOutput: string;
serialBaudRate: number;
serialMonitorOpen: boolean;
@ -1914,6 +1919,29 @@ export const useSimulatorStore = create<SimulatorState>((set, get) => {
...(isActive ? { running: false, serialOutput: '', serialBaudRate: 0 } : {}),
};
});
// Reset interactive sensors (temperature / lux / gas sliders, etc.) back
// to their configured defaults so a restart starts from a clean state
// instead of freezing on the last slider position the user dragged to.
// dispatchSensorUpdate re-injects the default into the running sim (so the
// NTC's injected ADC voltage and the SPICE solve both return to 25°C /
// 2.5V) and refreshes the panel's cached value; bumping sensorResetNonce
// remounts the open SensorControlPanel so its slider snaps back too.
const sensorComps = get().components.filter(
(c) => c.metadataId && SENSOR_CONTROLS[c.metadataId],
);
if (sensorComps.length > 0) {
set((s) => ({
components: s.components.map((c) => {
const def = c.metadataId ? SENSOR_CONTROLS[c.metadataId] : undefined;
return def ? { ...c, properties: { ...c.properties, ...def.defaultValues } } : c;
}),
sensorResetNonce: s.sensorResetNonce + 1,
}));
for (const c of sensorComps) {
dispatchSensorUpdate(c.id, SENSOR_CONTROLS[c.metadataId].defaultValues);
}
}
},
// ── Legacy single-board API ───────────────────────────────────────────
@ -1924,6 +1952,7 @@ export const useSimulatorStore = create<SimulatorState>((set, get) => {
running: false,
compiledHex: null,
hexEpoch: 0,
sensorResetNonce: 0,
serialOutput: '',
serialBaudRate: 0,
serialMonitorOpen: false,