Outdoor thermistor array for moon observation sessions. Three MF58 10 kΩ NTC sensors read simultaneously by an ADS1115 ADC on an ESP32-C3. Device publishes raw 16-bit ADC counts over MQTT and logs to daily CSVs on NAS (/mnt/nas/rooster/moon-temp-logs/).
Converted temperature CSVs (pre-computed °C) live in converted_logs/.
Code
%matplotlib inlineimport os, platformimport pandas as pdimport numpy as npimport matplotlib.pyplot as pltimport matplotlib.dates as mdatesNAS_BASE = ("/Volumes/AlphaDoom_6/rooster/moon-temp-logs"if platform.system() =='Darwin'else"/mnt/nas/rooster/moon-temp-logs")plt.rcParams['figure.figsize'] = (14, 4)plt.rcParams['figure.dpi'] =110
Offset Methodology
Calibration aligns three channels against ADC0 (reference, offset always 0).
Step 1 — Burst alignment
Start the logger at a 5-second interval: moon_temp_logger --interval 5
Manually send offset commands to ADC1 and ADC2 until all three channels match ADC0. Example: mosquitto_pub -h 10.0.0.50 -t moon-temp-001/offset/cmd -m '{"adc":1,"offset":16}'
Three-day post-adjustment analysis (Jun 15–17) confirms ADC2 correction. ADC1 (offset +16) had a median gap of +6.8 counts (0.85 mV) — within spec but drifting.
Jun 21–22 verification: ADC1 mean gap = −0.20 mV, std = 0.91 mV — PASS.
✓ Calibration Complete — 2026-06-28
All three channels are within specification. Sensors ready for deployment.
Channel
Final Offset
Status
ADC0
0 (reference)
—
ADC1
+22 counts (+2.75 mV)
PASS
ADC2
−64 counts (−8.00 mV)
PASS
Calibration period: May 26 → June 28, 2026 (33 days) Pass criterion: std(adc0 − adcN) ≤ 4.0 mV Pit temperature drift: +2.1 °C over the calibration period (seasonal ground warming)
Inter-channel spread stabilised to < 0.05 °C by mid-June. The residual ±5–7 count diurnal swing on ADC1 is a thermal gradient between sensor placements — a DC offset cannot eliminate it, but it is centred around zero.
Next step: Reprogram ESP32-C3 MQTT broker address for field deployment.
Long-Term Stability & Seasonal Warming — May 26 → Jun 28
Loads all 33 days of valid data. Shows the pit temperature rising ~2.1 °C as summer heat diffuses into the ground, and confirms inter-channel offsets held stable throughout.
Code
# Load all valid log days — both 2.3 V era and new v_rail eraR_SERIES =10_000; R0 =10_000; B =3950; T0_K =298.15ADC_LSB =0.00013108# calibrated (applies to all channels on this ADS1115)def counts_to_celsius(counts, v_rail=None):"""Convert ADS1115 raw count to °C. - v_rail present (Jul 2026+): use measured rail voltage - v_rail absent (pre-Jul 2026, 2.3 V era): use VCC=2.3 """ vcc = v_rail if (v_rail isnotNoneand np.isscalar(v_rail) and v_rail >0.5) else2.3 V = counts * ADC_LSBif V <=0or V >= vcc:return np.nan R = V * R_SERIES / (vcc - V)return1/ (1/T0_K + (1/B) * np.log(R / R0)) -273.15# Collect CSVs from both erasold_csvs = [f for f insorted(glob.glob(os.path.join(LOGS_2_3V, "????-??-??.csv")))if"2026-05-26.csv"<= os.path.basename(f) <="2026-06-28.csv"]new_csvs = [f for f insorted(glob.glob(os.path.join(LOGS_NEW, "????-??-??.csv")))if os.path.basename(f) <="2026-06-28.csv"]all_csvs = old_csvs + new_csvsdaily = {}for path in all_csvs: date = os.path.basename(path).replace('.csv','') d = pd.read_csv(path, parse_dates=['timestamp']) d = d.sort_values('timestamp').reset_index(drop=True) has_rail ='v_rail'in d.columnsfor ch in ['adc0','adc1','adc2']: col = ch.replace('adc','temp')if has_rail: d[col] = d.apply(lambda r, c=ch: counts_to_celsius(r[c], r['v_rail']), axis=1)else: d[col] = d[ch].apply(counts_to_celsius) # falls back to VCC=2.3if date =='2026-05-27': d = d[~((d['timestamp'] >='2026-05-27 09:51:00') & (d['timestamp'] <='2026-05-27 09:58:15'))] daily[date] = drows = []for date, d in daily.items(): rows.append({'date': pd.Timestamp(date),'t0': d['temp0'].mean(),'t1': d['temp1'].mean(),'t2': d['temp2'].mean(),'d01': (d['adc0'] - d['adc1']).mean() * ADC_MV,'d02': (d['adc0'] - d['adc2']).mean() * ADC_MV,'d01_std': (d['adc0'] - d['adc1']).std() * ADC_MV,'d02_std': (d['adc0'] - d['adc2']).std() * ADC_MV,'era': 'v_rail'if'v_rail'in d.columns else'2.3V', })prog = pd.DataFrame(rows).set_index('date')print(f"Loaded {len(prog)} days: {prog.index[0].date()} → {prog.index[-1].date()}")print(f" 2.3V era : {(prog['era']=='2.3V').sum()} days")print(f" v_rail era: {(prog['era']=='v_rail').sum()} days")print(prog[['t0','t1','t2','d01','d02','era']].round(3).to_string())
Hardware updated July 2026: variable regulator + dedicated buck converter for ADS1115 VDD, rail voltage monitor on AIN3. Sensors returned to the static thermal chamber for post-update calibration verification.
Averaging time: ADS1115 at 128 SPS, 9 ms conversion wait per channel, 4 channels × 64 samples: > 4 channels × 64 samples × 9 ms = 2,304 ms ≈ 2.3 seconds per published reading
Hardware change notes: - Single ADC sample per minute (pre-averaging firmware) produced noise spikes of ±1–2 °C on individual reads — not actual temperature events; observed during field experimental runs - 64-sample averaging per publish implemented in firmware to suppress these spikes - Rail voltage (AIN3) averaged identically; v_rail tracks ADS1115 VDD
Code
# Load all converted_logs CSVs (post-update calibration era)field_csvs =sorted(glob.glob(os.path.join(LOGS_CONV, "????-??-??.csv")))ifnot field_csvs:raiseFileNotFoundError(f"No CSVs found in {LOGS_CONV}")dfs = []for path in field_csvs: d = pd.read_csv(path, parse_dates=['timestamp']) d = d.sort_values('timestamp').reset_index(drop=True) dfs.append(d)field = pd.concat(dfs, ignore_index=True)field['d01_mV'] = (field['adc0'] - field['adc1']) * ADC_MVfield['d02_mV'] = (field['adc0'] - field['adc2']) * ADC_MVfield['dt01'] = field['t0_c'] - field['t1_c']field['dt02'] = field['t0_c'] - field['t2_c']field['date'] = field['timestamp'].dt.dateprint(f"Rows : {len(field)}")print(f"Span : {field['timestamp'].iloc[0]} → {field['timestamp'].iloc[-1]}")print(f"v_rail: mean={field['v_rail'].mean():.4f} V min={field['v_rail'].min():.4f} V max={field['v_rail'].max():.4f} V")
Rows : 3047
Span : 2026-07-11 10:00:33 → 2026-07-13 12:38:02
v_rail: mean=3.5170 V min=3.5110 V max=3.5345 V
Sensors sit in a high-thermal-mass concrete pit: temperature should be extremely stable (< 0.05 °C variation per sample under normal conditions). Any sample deviating > 1.5 °C from its 5-sample rolling median is flagged as a suspect read.
Code
# Flag samples that deviate more than 1.5 °C from a 5-point rolling medianSPIKE_THRESH =0.25# °Cfield_s = field.sort_values('timestamp').reset_index(drop=True)spike_rows = pd.DataFrame()for ch in ['t0_c', 't1_c', 't2_c']: roll = field_s[ch].rolling(5, center=True, min_periods=1).median() mask = (field_s[ch] - roll).abs() > SPIKE_THRESHif mask.any(): bad = field_s[mask][['timestamp', 'adc0', 'adc1', 'adc2', 'v_rail', ch]].copy() bad['channel'] = ch bad['deviation_C'] = (field_s[ch] - roll)[mask] spike_rows = pd.concat([spike_rows, bad])if spike_rows.empty:print(f"No spikes detected (threshold: >{SPIKE_THRESH} °C from 5-sample rolling median)")print("Signal is stable — consistent with high-thermal-mass static chamber environment.")else:print(f"{len(spike_rows)} suspect samples found:")print(spike_rows[['timestamp','channel','deviation_C']].to_string(index=False))# Plot all channels with spike threshold bandsfig, ax = plt.subplots(figsize=(16, 5))for col, color, label in [('t0_c','steelblue','ch0'), ('t1_c','darkorange','ch1'), ('t2_c','crimson','ch2')]: ax.plot(field_s['timestamp'], field_s[col], color=color, linewidth=0.7, alpha=0.85, label=label)ax.xaxis.set_major_formatter(mdates.DateFormatter('%b %d %H:%M'))ax.set_ylabel('°C')ax.set_title(f'Stability check — all channels (spike threshold ±{SPIKE_THRESH} °C, none detected)')ax.legend()fig.autofmt_xdate()plt.tight_layout()plt.show()
No spikes detected (threshold: >0.25 °C from 5-sample rolling median)
Signal is stable — consistent with high-thermal-mass static chamber environment.
Offset Recommendation
ADC0 is the reference channel (offset always 0). Recommended adjustments derived from mean inter-channel gap over Jul 11–13 (2,928 samples).
Channel
Current offset
Mean gap (counts)
Mean gap (mV)
Recommended offset
ADC0
0 (ref)
—
—
0
ADC1
+22
+26.9 (reads low)
+3.52 mV
+49
ADC2
−64
−9.0 (reads high)
−1.18 mV
−73
Send to device (JSON payload on moon-temp-001/offset/cmd):
{"adc":1,"offset":49}{"adc":2,"offset":-73}
Code
# Offset recommendation — visualise the gap vs current offsetADC_LSB =0.00013108ADC_MV = ADC_LSB *1000offsets = {'ADC0': {'current': 0, 'gap_counts': 0.0},'ADC1': {'current': 22, 'gap_counts': field['d01_mV'].mean() / ADC_MV},'ADC2': {'current': -64, 'gap_counts': -field['d02_mV'].mean() / ADC_MV},}print("Offset summary:")print(f" {'Channel':6s}{'Current':>8s}{'Gap (counts)':>13s}{'Gap (mV)':>10s}{'Recommended':>12s}")for ch, v in offsets.items(): new =round(v['current'] + v['gap_counts']) gap_mv = v['gap_counts'] * ADC_MVprint(f" {ch:6s}{v['current']:>+8d}{v['gap_counts']:>+13.2f}{gap_mv:>+10.3f}{new:>+12d}")
Offset summary:
Channel Current Gap (counts) Gap (mV) Recommended
ADC0 +0 +0.00 +0.000 +0
ADC1 +22 +25.93 +3.399 +48
ADC2 -64 +8.72 +1.143 -55