Moon Temp experiment 2026-09-28_Moon-Temp_007 — statistical analysis of the shadow-transition temperature signal.
2026-09-28_Moon-Temp_007
H₀ (null): The rate of change of the difference signal (ADC2 − reference) does not change at the shadow event — any observed trend differences are explained by sensor noise and environmental drift alone.
H₁ (alternate): The rate of change differs after shadow onset — ADC2 progressively diverges from the moonlit reference channels (ADC0, ADC1) in a sustained, accumulating way.
Analysis approach: Due to sensor thermal mass acting as a thermal sink, the moonlight effect is expected to manifest as a gradual slope change in the difference signal, not an instantaneous step. The analysis looks for a change in trend rate, with possible lag relative to the recorded shadow event time. Note: higher ADC counts = cooler; lower ADC counts = warmer.
# Distribution comparison: pre-shadow vs post-shadow deltaall_delta = pd.concat([pre.delta, post.delta])bins = np.linspace(all_delta.min(), all_delta.max(), 50)fig = make_subplots(rows=1, cols=2, subplot_titles=['Distribution of Δ counts', 'Box plot by period'])# Histogramfor df_part, color, name in [(pre, MUTED, 'Pre-shadow'), (post, ACCENT, 'Post-shadow')]: fig.add_trace(go.Histogram( x=df_part.delta, name=name, opacity=0.7, xbins=dict(start=bins[0], end=bins[-1], size=bins[1]-bins[0]), marker_color=color, ), row=1, col=1)# Box plotsfor df_part, color, name in [(pre, MUTED, 'Pre-shadow'), (post, ACCENT, 'Post-shadow')]: fig.add_trace(go.Box( y=df_part.delta, name=name, marker_color=color, boxmean='sd', showlegend=False, ), row=1, col=2)fig.update_layout(**rooster_layout( title=f'{experiment_id} — Pre vs post shadow delta distributions', barmode='overlay', height=420, xaxis=dict(title='Δ ADC counts', gridcolor=GRID, linecolor=GRID), yaxis=dict(title='count', gridcolor=GRID, linecolor=GRID), xaxis2=dict(gridcolor=GRID, linecolor=GRID), yaxis2=dict(title='Δ ADC counts', gridcolor=GRID, linecolor=GRID),))fig.show()
Code
# Hypothesis test: slope comparison (rate of change of delta, pre vs post shadow)# H₀: slope of delta is the same in both periods# H₁: slope differs after shadow onset (sustained divergence)## Autocorrelation-corrected SE: HC3 residual std scaled by sqrt(n/n_eff)def n_eff(series):"""Effective sample size correcting for lag-1 autocorrelation.""" rho = series.autocorr(lag=1) n =len(series)returnmax(int(n * (1- rho) / (1+ rho)), 2)def slope_and_corrected_se(df_seg, t0): x = (df_seg.timestamp - t0).dt.total_seconds() /60 y = df_seg.delta.values X = sm.add_constant(x.values) m = sm.OLS(y, X).fit(cov_type='HC3') rho = pd.Series(m.resid).autocorr(lag=1) ne =max(int(len(y) * (1- rho) / (1+ rho)), 2) se_c = m.bse[1] * np.sqrt(len(y) / ne)return m.params[1], se_c, ne, mt0 = df.timestamp.iloc[0]slope_pre, se_pre, neff_pre, m_pre = slope_and_corrected_se(pre.dropna(subset=['delta']), t0)slope_post, se_post, neff_post, m_post = slope_and_corrected_se(post.dropna(subset=['delta']), t0)slope_diff = slope_post - slope_prese_diff = np.sqrt(se_pre**2+ se_post**2)t_stat = slope_diff / se_diffdf_t = neff_pre + neff_post -4p_val =2* stats.t.sf(abs(t_stat), df_t)sig_label =lambda p: '*** p<0.001'if p<0.001else'** p<0.01'if p<0.01else'* p<0.05'if p<0.05else'(ns)'print('── Slope comparison test: H₀ = same rate of change pre vs post shadow ──')print()print(f' Pre-shadow slope: {slope_pre:+.4f} counts/min SE={se_pre:.4f} n={len(pre)} n_eff={neff_pre}')print(f' Post-shadow slope: {slope_post:+.4f} counts/min SE={se_post:.4f} n={len(post)} n_eff={neff_post}')print()print(f' Slope change (post − pre): {slope_diff:+.4f} counts/min')print(f' t = {t_stat:.3f} df = {df_t} p = {p_val:.4f}{sig_label(p_val)}')print()print(f' Noise floor reference: ±{NOISE_FLOOR:.1f} counts total')print()if p_val <0.05: direction ='rising'if slope_diff >0else'falling'print(f' ✓ Reject H₀ — slope change of {slope_diff:+.4f} counts/min is significant.')print(f' ADC2 delta is {direction} relative to reference at {abs(slope_diff):.4f} counts/min post-shadow.')else:print(f' ✗ Fail to reject H₀ — no significant slope change detected (p={p_val:.3f}).')print(f' The rate of divergence is not distinguishable from pre-shadow trend.')
── Slope comparison test: H₀ = same rate of change pre vs post shadow ──
Pre-shadow slope: +0.0915 counts/min SE=0.1990 n=1089 n_eff=2
Post-shadow slope: -0.0666 counts/min SE=0.2321 n=3083 n_eff=2
Slope change (post − pre): -0.1581 counts/min
t = -0.517 df = 0 p = nan (ns)
Noise floor reference: ±3.0 counts total
✗ Fail to reject H₀ — no significant slope change detected (p=nan).
The rate of divergence is not distinguishable from pre-shadow trend.
# Difference signal in °C scale# Same slope-change analysis as ADC counts, expressed in °C-equivalent units.# Note: higher ADC = cooler, lower ADC = warmer — °C conversion reflects this.df['tdelta_roll'] = df.temp_delta.rolling(ROLL_MEAN, center=True, min_periods=10).mean()df['tdelta_slope'] = compute_rolling_slope(df, 'temp_delta', ROLL_SLOPE)fig = make_subplots( rows=2, cols=1, shared_xaxes=True, vertical_spacing=0.08, subplot_titles=['ADC2 − reference in °C · rolling mean','Rolling slope (°C/min)'])fig.add_trace(go.Scatter(x=df.timestamp, y=df.temp_delta, name='Δ °C (raw)', mode='lines', line=dict(color=C2, width=0.8), opacity=0.30), row=1, col=1)fig.add_trace(go.Scatter(x=df.timestamp, y=df.tdelta_roll, name=f'{ROLL_MEAN}-min mean', mode='lines', line=dict(color=C2, width=2.5)), row=1, col=1)fig.add_trace(go.Scatter(x=df.timestamp, y=df.tdelta_slope, name='slope (°C/min)', mode='lines', line=dict(color=ACCENT, width=2.0)), row=2, col=1)fig.add_hline(y=0, line_dash='dot', line_color=MUTED, row=2, col=1)for r in [1, 2]: fig.add_vline(x=shadow_dt, line_dash='dash', line_color=C_SHADOW, annotation_text='Shadow event', annotation_font_color=C_SHADOW, annotation_position='top right', row=r, col=1) fig.add_vline(x=sunrise_dt, line_dash='dot', line_color=C_SUN, annotation_text='Sunrise', annotation_font_color=C_SUN, annotation_position='top left', row=r, col=1)fig.update_layout(**rooster_layout( title=f'{experiment_id} — Difference signal in °C scale', height=560, xaxis2=dict(gridcolor=GRID, linecolor=GRID), yaxis=dict(title='Δ °C', gridcolor=GRID, linecolor=GRID), yaxis2=dict(title='°C/min', gridcolor=GRID, linecolor=GRID, zeroline=True, zerolinecolor=MUTED),))fig.show()
Code
from IPython.display import Markdown, displaydecision = (f'Reject H₀ — slope change of {slope_diff:+.4f} counts/min is significant ({sig_label(p_val)}). 'f'ADC2 delta is {"rising"if slope_diff >0else"falling"} relative to reference post-shadow.'if p_val <0.05elsef'Fail to reject H₀ — no significant slope change detected (p={p_val:.3f}).')md =f"""## Conclusions### Hypothesis test result| Test | Result | Interpretation ||---|---|---|| Pre-shadow slope | {slope_pre:+.4f} counts/min (n_eff={neff_pre}) | baseline rate of change || Post-shadow slope | {slope_post:+.4f} counts/min (n_eff={neff_post}) | rate after shadow onset || Slope change (post − pre) | {slope_diff:+.4f} counts/min p={p_val:.4f} {sig_label(p_val)} | **primary test of H₁** || OLS interaction coef | {i_coef:+.4f} counts/min p={i_p:.4f} {sig_label(i_p)} | confirms slope change || OLS level shift (shadow_on) | {l_coef:+.2f} counts p={l_p:.4f} {sig_label(l_p)} | transition-period offset |### Rolling slope chartOnset timing relative to shadow_dt: *leads / coincides / lags by ~___ min*Shape: *gradual ramp / abrupt shift / no visible change*### Interpretation**Decision:** {decision}Notes: The recorded shadow event marks when the shadow line touches ADC2 (the sensor under test), not ADC0/ADC1 (the moonlit reference channels). The shadow continues moving across the rig and crosses ADC0 and ADC1 afterward. If moonlight is genuinely driving the temperature effect, ADC0/ADC1 should show a corresponding response — a slope change or slight wobble — when the shadow reaches *them*, at a time later than `shadow_dt`. This run's analysis only tests the ADC2 transition; it does not yet check for a matching delayed response in ADC0/ADC1, which would be corroborating evidence for the moonlight hypothesis vs. sensor drift/noise.### Companion video- GoPro timelapse source: `{experiment_id}`- DaVinci overlay: `davinci/adc_bar_chart_overlay.mov`- Key timestamp: shadow event at **{SHADOW_TIME_STR} MDT**"""display(Markdown(md))
Conclusions
Hypothesis test result
Test
Result
Interpretation
Pre-shadow slope
+0.0915 counts/min (n_eff=2)
baseline rate of change
Post-shadow slope
-0.0666 counts/min (n_eff=2)
rate after shadow onset
Slope change (post − pre)
-0.1581 counts/min p=nan (ns)
primary test of H₁
OLS interaction coef
-0.1581 counts/min p=0.0000 *** p<0.001
confirms slope change
OLS level shift (shadow_on)
+76.62 counts p=0.0000 *** p<0.001
transition-period offset
Rolling slope chart
Onset timing relative to shadow_dt: leads / coincides / lags by ~___ min
Shape: gradual ramp / abrupt shift / no visible change
Interpretation
Decision: Fail to reject H₀ — no significant slope change detected (p=nan).
Notes: The recorded shadow event marks when the shadow line touches ADC2 (the sensor under test), not ADC0/ADC1 (the moonlit reference channels). The shadow continues moving across the rig and crosses ADC0 and ADC1 afterward. If moonlight is genuinely driving the temperature effect, ADC0/ADC1 should show a corresponding response — a slope change or slight wobble — when the shadow reaches them, at a time later than shadow_dt. This run’s analysis only tests the ADC2 transition; it does not yet check for a matching delayed response in ADC0/ADC1, which would be corroborating evidence for the moonlight hypothesis vs. sensor drift/noise.