The confession
I wrote the original article in May, about a thermometer I'd built around an ATtiny85, an AD620 instrumentation amp, and a thermistor — one that had already been running for a while on a straightforward degree-3 polynomial correction. At some point I wondered whether inference could do better than the polynomial, so I put Hasaki to work on it. And to be fair to it: it did work. That part isn't the confession.
What happened next is. Once inference was running, I started noticing a pause in the display refresh — not flickering, QUAD7SHIFT handles that side of things fine, but an actual delay before the number updated. To isolate the cause, I commented out the predict() call and dropped back to the degree-3 formula running directly on voltage, just to confirm the delay came from the inference step and not from somewhere else. It did. Delay gone the moment inference was out of the loop.
So I went back in to re-enable it properly. I uncommented predict() — but never updated the return statement to actually use its output. The function ended up looking like this:
predict(input, output); // runs the inference...
return voltage * 45 / 0.85; // ...but the return statement still points at the old formula
I thought I'd restored inference. What I'd actually done was leave the network running on every reading, paying its full cost in cycles, while the return statement quietly kept pointing at a value I'd forgotten to change to output[1] * 45 / 0.85. The delay I'd already diagnosed and "fixed" was still there — just no longer visible to me, because I assumed the fix had worked and stopped watching for it.
Nobody caught it after that — no code review, and the readings stayed plausible enough that I never went looking again. It sat there, inference running and going nowhere, until I opened the enclosure for this recalibration session and actually traced the function line by line.
Why I didn't just fix the return statement
The obvious move once I found the gap was to write return output[1] * 45 / 0.85; and call it done — that was the original intent anyway. I didn't do that either. Sitting with the code again, I kept coming back to the fact that a thermistor is a single-variable, smooth, well-characterized nonlinear relationship. It's a curve-fitting problem, not one that needs a model built to approximate arbitrary multivariate functions. Hasaki did produce a working correction here — I want to be clear about that — but "working" and "the right tool for this specific job" turned out to be different questions, and the delay was the symptom that made me ask the second one.
So instead of restoring the inference path, I replaced it with something closer to the polynomial approach that started this whole detour, but done properly: a degree-5 regression fit against real calibration data, quantized into a 32-point lookup table with linear interpolation, generated by a small tool I've been building called kigu-quant. Correlation on the fit came out to 0.99993, and the quantization error sits well below the ADC's own noise floor. No inference step at runtime, no delay, nothing to forget to wire up.
This isn't a case against Hasaki — it's still the right tool when the problem is genuinely multivariate or has a nonlinearity that resists a clean fit. This wasn't that. The lesson was less about neural networks in general and more about not noticing when a fix I thought I'd made was actually only half-made.
The bigger discovery: the datasheet was wrong for my part
While I was in there, I decided to stop trusting the thermistor's nominal Beta and Ro values and actually measure them. The datasheet says Beta = 4570 and Ro = 10,000Ω at 25°C. I collected 13 real calibration points from 0°C to 48°C — ice bath, fridge, warm and hot water, ambient — and fit against the actual component.
Real values: Beta = 3679.3, Ro = 9145.1Ω. That's -19.5% and -8.5% off datasheet, respectively. Every correction curve I'd ever run on this thermometer, including the neural network nobody used, was built on synthetic data generated from numbers that didn't match the physical part sitting on the board.
Getting those 13 points clean took longer than I expected, and the failure modes were more interesting than the calibration itself:
- Wet multimeter probes gave repeated, identical readings that looked stable but were actually stuck.
- Tap water in the ice bath acted as a parallel resistance path and skewed readings by as much as 40% before I switched to distilled water.
- A gel ice pack never reached true 0°C equilibrium — readings came in about 2°C warmer than they should have.
- Two multimeters, same component, ~1% disagreement with each other.
- Several points had to be retaken because I hadn't actually waited for thermal equilibrium before reading.
./kigu-quant --method lut --func "-9.0938212442471654e-004 + 1.1866767734242596e+000 * x^1 + -9.9935344368437351e-001 * x^2 + 1.5063532416825729e+000 * x^3 + -1.1978962057273923e+000 * x^4 + 5.0338562797337316e-001 * x^5" --size 32 --fmt q15 -o thermo-5.h
[OK] Written to: thermo-5.h
None of that shows up in a schematic. It only shows up when you sit with a component for an afternoon and watch it lie to you in small, specific ways.
/**
* Generated by kigu-quant — Rosito Bench
* Tiny tools. Big solutions.
*
* Backend : LUT
* Function : thermo_5
* Points : 32
* Array : thermo_5
* Range : [0, 1]
* Type : int16_t
* Q-format : Q1.15
*
* Calibration: real data, Beta=3679.3, Ro=9145.1 Ohm @ 25C
* Polinomial correction degree-5 (13 real points measured)
* Correlation = 0.9999254893298957, standard error = 0.002593
*
* Lookup usage:
* float val = thermo_5_lookup(x);
*/
#ifndef THERMO_5_H
#define THERMO_5_H
#include <stdint.h>
#define THERMO_5_H_SIZE 32
#define THERMO_5_H_START 0.0000000f
#define THERMO_5_H_END 1.0000000f
#define THERMO_5_H_STEP 0.032258065f
#define THERMO_5_H_INV_STEP 31.000000f
#define THERMO_5_H_SHIFT 15
#define THERMO_5_H_SCALE 32768LL
static const int16_t thermo_5[THERMO_5_H_SIZE] = {
-39, 1075, 2151, 3194, 4212, 5209, 6190, 7159,
8120, 9075, 10028, 10981, 11935, 12892, 13853, 14821,
15797, 16781, 17776, 18782, 19801, 20836, 21888, 22960,
24054, 25175, 26326, 27512, 28737, 30007, 31329, 32711
};
/**
* Interpolated lookup — O(1), no branches on hot path.
* Input is clamped to [0.000000, 1.000000].
*/
static inline float thermo_5_lookup(float x) {
if (x <= THERMO_5_H_START) return (float)thermo_5[0] / (float)THERMO_5_H_SCALE;
if (x >= THERMO_5_H_END) return (float)thermo_5[THERMO_5_H_SIZE - 1] / (float)THERMO_5_H_SCALE;
float pos = (x - THERMO_5_H_START) * THERMO_5_H_INV_STEP;
int idx = (int)pos;
float frac = pos - (float)idx;
float y0 = (float)thermo_5[idx];
float y1 = (float)thermo_5[idx + 1];
return (y0 + frac * (y1 - y0)) / (float)THERMO_5_H_SCALE;
}
#endif /* THERMO_5_H */
The scaling bug that wasn't a bug
The firmware also had this:
return voltage * 45 / 0.85;
That factor wasn't a mistake when it was written — it matched a synthetic model from years earlier, before the degree-3 polynomial and the neural network experiment. When the project got shelved (a battery charging module failed right as I was about to bring the inference path online) that constant froze in place and nobody — meaning me, eventually with less context than I had at the time — ever revisited it. Once the correction model changed, the scaling should have changed with it. It didn't, and the result was a consistent ~6% bias: 45°C real reading showing up as 47.65°C.
The fix was voltage * 50, matching the new polynomial's normalization. Simple change. Took an afternoon of tracing to find only because there was no note anywhere explaining why 45/0.85 existed in the first place.
What actually cost the time
If I'm honest, none of the interesting bugs here were hard once found. What ate the day was the complete absence of any record of why past decisions were made. The 45/0.85 constant was correct once, for a model I no longer had context for. The trimmer resistor for the AD620's gain didn't match the schematic — someone (me) had swapped it for two resistors in series at some point and never updated the drawing. A second calibration trimmer is sitting at a value I suspect but can't confirm, because I never wrote it down when I installed it.
The thermometer itself is about four years old. The inference detour was only a couple of months, start to finish. Even that was enough silence for a piece of the project to become archaeology, including to its own author.
Where it landed
Enclosure ventilation turned out to matter more than any of the math — with the back cover off, the reading matched a reference multimeter almost exactly; with it on, there was a consistent few-degree bias from trapped air inside the case. Once that was accounted for, and the real Beta/Ro, real gain, and real scaling factor were all in place, the thermometer now reads within 1°C of a reference probe across the full range I calibrated.
The neural network is gone. The thermometer is faster, the correction is a 32-entry table instead of a forward pass, and for the first time since I built this thing, four years ago, I actually know what values are in it and why.
If you're doing sensor correction on a microcontroller and reaching for a model, it might be worth asking first whether the problem is actually one variable and one smooth curve. If it is, a lookup table will probably outperform a network on every axis that matters for embedded work: latency, memory, and — as I found out the hard way — whether anyone can tell if it's actually running.
kigu-quant, the LUT generator used here, is on Lemon Squeezy. Hasaki, the neural network tool from the original article, is free on GitHub, with a pro version also on Lemon Squeezy — it's just not the right hammer for every screw, including, it turns out, the one I built it for.



Top comments (0)