fix(ntc): the temperature sensor read wrong on 3.3V boards, and three examples decoded backwards

Two independent defects, both visible as "the NTC reports the wrong
temperature" (issue #233).

1. The part built its divider against a hard-coded 5 V rail, while the SPICE
   path used the board's real rail. They only agreed on AVR. On a 3.3 V board
   the injected node sat above the ADC's full scale: the slider at 25 C read
   ~1.4 C, and every slider value below ~11 C saturated to the same number, so
   -40, 0 and 10 C were indistinguishable. The rail now comes from the board
   (analogRailVolts), matching setAdcVoltage's own AVR/RP2040/ESP32 split.

2. The divider convention was corrected in 608c2538 (VCC -> R_pull -> OUT ->
   R_NTC -> GND) but data/examples.ts was not updated with it, so three of the
   four bundled NTC sketches still decoded the OTHER topology. Their readings
   came out mirrored about 25 C: the uno-ntc example printed -3.42 C with the
   slider at 60 C - the exact symptom dd22bcfe had recorded as the bug. Fixed
   in uno-ntc, pico-ntc and attiny85-ntc-sensor; examples-circuits.ts was
   already right.

The sibling analog sensors (gas, flame, heart-beat, sound) map their sliders
0..1023 onto the same hard-coded 5 V and will saturate the same way on 3.3 V
boards. Left alone here: that is not what #233 reports, and each one needs its
examples re-checked before changing.
This commit is contained in:
David Montero Crespo 2026-08-03 07:33:20 +02:00
parent 0d9a26d951
commit d4ec4fe04d
4 changed files with 76 additions and 13 deletions

View File

@ -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 ──────────────────────────────────────────────────────────────

View File

@ -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);

View File

@ -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

View File

@ -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.
*