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.
Hardware change notes: - Single ADC sample per minute (pre-averaging firmware) produced noise spikes of ±1–2 °C — not actual temperature events; seen 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
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()
Ch Old Gap (cts) Gap (mV) New
ADC0 +0 +0.00 +0.000 +0
ADC1 +22 +26.89 +3.524 +49
ADC2 -64 -8.97 -1.176 -73
Post-Adjustment Verification — ADC1=+49, ADC2=−73
Analysis of the new offset era only (2026-07-13 11:00 MDT onwards). Mirrors the 33-day calibration format for direct comparison.
Sensors entered Ready For Experiment mode 2026-07-26 — calibration confirmed stable across all prior days. First night session: Moon-Temp-002 (full moon 99.2%).