diff --git a/frontend/src/__tests__/sensor-parts.test.ts b/frontend/src/__tests__/sensor-parts.test.ts index f5369c24..25387add 100644 --- a/frontend/src/__tests__/sensor-parts.test.ts +++ b/frontend/src/__tests__/sensor-parts.test.ts @@ -152,6 +152,43 @@ describe('ntc-temperature-sensor — attachEvents', () => { // ADC should remain zeroed expect(adc.channelValues[0]).toBe(0); }); + + /** + * Issue #233. The divider used to be computed against a hard-coded 5 V rail, + * so on a 3.3 V board it handed the ADC a voltage above full scale: 25 C came + * back as ~1.4 C and everything below ~11 C saturated at the same reading. + * The rail now follows the board, so the midpoint of a 10k/10k divider is + * half of THAT rail. + */ + it('scales the divider to a 3.3V board instead of assuming 5V', () => { + const logic = PartSimulationRegistry.get('ntc-temperature-sensor')!; + const injected: number[] = []; + // A simulator exposing setAdcVoltage is the ESP32 bridge shim. + const sim = { + ...makeSimulator(makeADC()), + setAdcVoltage: (_pin: number, volts: number) => { + injected.push(volts); + return true; + }, + }; + + logic.attachEvents!(makeElement(), sim as any, pinMap({ OUT: 34 })); + + expect(injected).toHaveLength(1); + // R_ntc(25 C) == R_pull == 10k, so OUT sits at exactly half the rail. + expect(injected[0]).toBeCloseTo(1.65, 2); + expect(injected[0]).toBeLessThan(3.3); // never above the board's full scale + }); + + it('keeps the 5V midpoint on an AVR board', () => { + const logic = PartSimulationRegistry.get('ntc-temperature-sensor')!; + const adc = makeADC(); + const sim = makeSimulator(adc); + + logic.attachEvents!(makeElement(), sim as any, pinMap({ OUT: 14 })); + + expect(adc.channelValues[0]).toBeCloseTo(2.5, 2); + }); }); // ─── Gas Sensor ────────────────────────────────────────────────────────────── diff --git a/frontend/src/data/examples.ts b/frontend/src/data/examples.ts index 2ed10293..7d2c2612 100644 --- a/frontend/src/data/examples.ts +++ b/frontend/src/data/examples.ts @@ -7921,13 +7921,11 @@ const float NOM_TEMP_K = 298.15; // 25 °C in Kelvin float readTempC() { int raw = analogRead(NTC_PIN); float v = raw * (VCC / 1023.0); - // Voltage divider topology used by standard NTC modules and by Velxio's - // wokwi-ntc-temperature-sensor: VCC → R_NTC → A1 → R_pull (10k) → GND. - // Higher temperature → R_NTC drops → V rises, so the NTC resistance is - // r = R_pull * (VCC - v) / v - // Using the inverted form (r = R_pull * v / (VCC - v)) gives the wrong - // sign and Steinhart-Hart returns negative temperatures for hot inputs. - float r = SERIES_R * (VCC - v) / v; + // Divider modelled by Velxio's ntc-temperature-sensor: + // VCC → R_pull (10k) → A1 → R_NTC → GND. + // Higher temperature → R_NTC drops → V FALLS, so + // r = R_pull * v / (VCC - v) + float r = SERIES_R * v / (VCC - v); // Steinhart–Hart simplified equation float st = log(r / NOM_R) / B_COEFF + 1.0 / NOM_TEMP_K; return (1.0 / st) - 273.15; @@ -8256,8 +8254,8 @@ const float NOM_TEMP_K = 298.15; // 25 °C float readTempC() { int raw = analogRead(NTC_PIN); float v = raw * (VCC / 1023.0); - // VCC → R_NTC → A1 → R_pull → GND (standard NTC module topology) - float r = SERIES_R * (VCC - v) / v; + // VCC → R_pull → A1 → R_NTC → GND (the divider Velxio models) + float r = SERIES_R * v / (VCC - v); float st = log(r / NOM_R) / B_COEFF + 1.0 / NOM_TEMP_K; return (1.0 / st) - 273.15; } @@ -9954,7 +9952,9 @@ const float B_COEFF = 3950.0; float readTemperature() { int raw = analogRead(NTC_PIN); - float resistance = SERIES_R * (1023.0 / raw - 1.0); + // R_NTC sits on the LOW side, so raw/1023 = R_NTC / (R_NTC + SERIES_R): + // R_NTC = SERIES_R / (1023/raw - 1) + float resistance = SERIES_R / (1023.0 / raw - 1.0); float steinhart = resistance / NOM_R; steinhart = log(steinhart) / B_COEFF; steinhart += 1.0 / (NOM_TEMP + 273.15); diff --git a/frontend/src/simulation/parts/SensorParts.ts b/frontend/src/simulation/parts/SensorParts.ts index a1bb7da1..758f851c 100644 --- a/frontend/src/simulation/parts/SensorParts.ts +++ b/frontend/src/simulation/parts/SensorParts.ts @@ -18,7 +18,7 @@ */ import { PartSimulationRegistry } from './PartSimulationRegistry'; -import { setAdcVoltage, emitPropertyChange } from './partUtils'; +import { setAdcVoltage, emitPropertyChange, analogRailVolts } from './partUtils'; import { registerSensorUpdate, unregisterSensorUpdate } from '../SensorUpdateRegistry'; // ─── Tilt Switch ───────────────────────────────────────────────────────────── @@ -64,11 +64,17 @@ PartSimulationRegistry.register('tilt-switch', { * * 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 + * from OUT to GND, so V_OUT = VCC · 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. + * + * VCC is the rail of the board the sensor is wired to, not a constant 5. The + * divider used to assume 5 V everywhere, which on a 3.3 V board put the node + * above the ADC's full scale: the slider read 1.4°C where it said 25, and + * everything below ~11°C pinned at the same saturated value (issue #233). The + * SPICE path already used the board's rail, so the two only agreed on AVR. */ PartSimulationRegistry.register('ntc-temperature-sensor', { attachEvents: (_element, simulator, getArduinoPinHelper, componentId) => { @@ -77,9 +83,10 @@ PartSimulationRegistry.register('ntc-temperature-sensor', { const NTC_R0 = 10_000; const NTC_BETA = 3950; const R_PULL = 10_000; + const vcc = analogRailVolts(simulator); 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)))); + return Math.max(0, Math.min(vcc, vcc * (rNtc / (rNtc + R_PULL)))); }; // Room temperature default diff --git a/frontend/src/simulation/parts/partUtils.ts b/frontend/src/simulation/parts/partUtils.ts index 3dce0826..d22bab7a 100644 --- a/frontend/src/simulation/parts/partUtils.ts +++ b/frontend/src/simulation/parts/partUtils.ts @@ -36,6 +36,25 @@ export function getADC(avrSimulator: AnySimulator): any | null { return (avrSimulator as any).getADC?.() ?? null; } +/** + * Supply rail the analog front end of THIS board runs on, in volts. + * + * A part that builds a resistive divider has to use the rail it is actually + * wired to. Assuming 5 V everywhere is not a small error on a 3.3 V board: it + * pushes the computed node voltage above the ADC's full scale, so the reading + * saturates and a whole stretch of the control slider reports the same value. + * (An NTC divider assumed at 5 V reads 1.4 C on an ESP32 when the slider says + * 25 C, and pins at full scale for everything below ~11 C — issue #233.) + * + * The split matches setAdcVoltage's own branches: AVR converts against a 5 V + * reference, RP2040 and the ESP32 bridge against 3.3 V. + */ +export function analogRailVolts(simulator: AnySimulator): number { + if (typeof (simulator as any).setAdcVoltage === 'function') return 3.3; // ESP32 bridge + if (simulator instanceof RP2040Simulator) return 3.3; + return 5.0; // AVR +} + /** * Write an analog voltage to an ADC channel, supporting AVR, RP2040, and ESP32. *