Moon Temp Statistical Analysis — 2026-07-05

—title: “Moon Temp Statistical Analysis — 2026-07-05”format: html: theme: [darkly, ../../../_templates/site-override.scss] toc: false code-fold: true include-before-body: ../../../_templates/moon-temp-results-header.htmlexecute: freeze: false—

2026-07-05_Moon-Temp_001H₀ (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.| Parameter | Value ||—|—|| Experiment | 2026-07-05_Moon-Temp_001 || Date | 2026-07-05 || Shadow event (ADC2 enters full shadow) | 02:21:31 MDT || Sunrise (analysis cutoff −1 hr) | 05:35 MDT || GoPro start | 22:32:28 MDT |

Code
import pandas as pd
import numpy as np
import scipy.stats as stats
import statsmodels.api as sm
import plotly.graph_objects as go
import plotly.io as pio; pio.renderers.default = 'notebook'
from plotly.subplots import make_subplots
from pathlib import Path
from datetime import datetime, timedelta, date

# ── rooster.ninja colour palette ─────────────────────────────────────────────
BG       = '#0d1117'
PLOT_BG  = '#161b22'
GRID     = '#30363d'
TEXT     = '#e6edf3'
MUTED    = '#8b949e'
ACCENT   = '#58a6ff'
C0       = '#5b9cf6'   # ADC0 — blue
C1       = '#f6a542'   # ADC1 — amber
C2       = '#63d68c'   # ADC2 — green (shadow channel)
C_SHADOW = '#e05c5c'   # shadow event line — red
C_SUN    = '#ffd166'   # sunrise line — gold
FONT_FAM = "-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"

def rooster_layout(**kw):
    """Base Plotly layout matching rooster.ninja theme."""
    base = dict(
        paper_bgcolor=BG, plot_bgcolor=PLOT_BG,
        font=dict(color=TEXT, family=FONT_FAM),
        xaxis=dict(gridcolor=GRID, linecolor=GRID, zerolinecolor=GRID),
        yaxis=dict(gridcolor=GRID, linecolor=GRID, zerolinecolor=GRID),
        legend=dict(bgcolor='rgba(0,0,0,0)', bordercolor=GRID, borderwidth=1),
        margin=dict(l=60, r=40, t=60, b=60),
    )
    base.update(kw)
    return base

# ── experiment parameters (pre-filled from template) ─────────────────────────
EXPERIMENT_DIR  = Path('.')
experiment_id   = '2026-07-05_Moon-Temp_001'
LOG_DATE        = '2026-07-05'     # YYYY-MM-DD (morning / end date)
SHADOW_TIME_STR = '02:21:31'    # HH:MM:SS MDT
SUNRISE_STR     = '05:35'        # HH:MM MDT
gopro_start_str = '22:32:28'         # HH:MM:SS MDT (prev evening)

LOG_DATE_PREV = (date.fromisoformat(LOG_DATE) - timedelta(days=1)).isoformat()

def parse_local(date_str, time_str):
    t = time_str.replace(' MDT','').replace(' MST','').strip()
    fmt = '%Y-%m-%d %H:%M:%S' if t.count(':') == 2 else '%Y-%m-%d %H:%M'
    return datetime.strptime(f'{date_str} {t}', fmt)

gopro_start = parse_local(LOG_DATE_PREV, gopro_start_str)
shadow_dt   = parse_local(LOG_DATE,      SHADOW_TIME_STR)
sunrise_dt  = parse_local(LOG_DATE,      SUNRISE_STR)
cutoff_dt   = sunrise_dt + timedelta(hours=1)

# Steinhart-Hart constants (calibrated values from sensor_offset_analysis.ipynb)
VCC = 3.3; R_SERIES = 10_000; R0 = 10_000; B = 3950; T0_K = 298.15
NOISE_FLOOR = 3.0   # counts, from calibration residual std

def counts_to_celsius(counts):
    V = counts * 0.000125
    R = V * R_SERIES / (VCC - V)
    return 1 / (1/T0_K + (1/B) * np.log(R / R0)) - 273.15

print(f'Experiment  : {experiment_id}')
print(f'GoPro start : {gopro_start}')
print(f'Shadow event: {shadow_dt}')
print(f'Sunrise     : {sunrise_dt}')
print(f'Cutoff      : {cutoff_dt}')
Experiment  : 2026-07-05_Moon-Temp_001
GoPro start : 2026-07-04 22:32:28
Shadow event: 2026-07-05 02:21:31
Sunrise     : 2026-07-05 05:35:00
Cutoff      : 2026-07-05 06:35:00
Code
# Load raw overnight CSVs and trim to analysis window
import subprocess

def ensure_csv(date_str):
    path = EXPERIMENT_DIR / f'{date_str}.csv'
    if not path.exists():
        remote = f'rooster@10.0.10.20:~/claude-code/data/logs/{date_str}.csv'
        print(f'Fetching {remote} ...')
        subprocess.run(['scp', remote, str(path)], check=True)
    return path

df = pd.concat([
    pd.read_csv(ensure_csv(LOG_DATE_PREV), parse_dates=['timestamp']),
    pd.read_csv(ensure_csv(LOG_DATE),      parse_dates=['timestamp']),
]).sort_values('timestamp').drop_duplicates('timestamp').reset_index(drop=True)

# Trim to analysis window
df = df[(df.timestamp >= gopro_start) & (df.timestamp <= cutoff_dt)].copy()

# Temperature conversion
for ch in ['adc0', 'adc1', 'adc2']:
    df[ch.replace('adc','temp')] = counts_to_celsius(df[ch])

# Difference signal: ADC2 vs moonlit reference
df['ref']       = (df.adc0 + df.adc1) / 2
df['delta']     = df.adc2 - df.ref
df['temp_ref']  = (df.temp0 + df.temp1) / 2
df['temp_delta']= df.temp2 - df.temp_ref

# Period labels
df['period'] = np.where(df.timestamp < shadow_dt, 'pre-shadow (moonlit)', 'post-shadow')

pre  = df[df.timestamp < shadow_dt]
post = df[df.timestamp >= shadow_dt]

print(f'Loaded {len(df)} rows  |  {df.timestamp.iloc[0]}{df.timestamp.iloc[-1]}')
print(f'Pre-shadow rows : {len(pre)}')
print(f'Post-shadow rows: {len(post)}')
df[['adc0','adc1','adc2','temp0','temp1','temp2','delta']].describe().round(2)
Loaded 482 rows  |  2026-07-04 22:33:25  →  2026-07-05 06:34:25
Pre-shadow rows : 229
Post-shadow rows: 253
adc0 adc1 adc2 temp0 temp1 temp2 delta
count 482.00 482.00 482.00 482.00 482.00 482.00 482.00
mean 12724.65 12691.31 12524.18 26.66 26.77 27.35 -183.80
std 700.42 709.87 707.51 2.44 2.48 2.48 28.94
min 10240.00 10294.00 10144.00 23.26 23.46 23.91 -291.00
25% 12288.00 12278.00 12096.00 24.52 24.65 25.22 -195.00
50% 12680.00 12662.00 12480.00 26.78 26.85 27.48 -179.00
75% 13340.00 13302.00 13136.00 28.15 28.18 28.82 -171.00
max 13712.00 13654.00 13520.00 35.63 35.43 36.00 117.00
Code
# Interactive overview: all 3 ADC channels + temperature
fig = make_subplots(rows=2, cols=1, shared_xaxes=True, vertical_spacing=0.06,
                    subplot_titles=['ADC counts (offset-corrected)', 'Temperature (°C)'])

for col, color, name in [('adc0',C0,'ADC0'),('adc1',C1,'ADC1'),('adc2',C2,'ADC2 (shadow ch)')]:
    fig.add_trace(go.Scatter(x=df.timestamp, y=df[col], name=name,
                             line=dict(color=color, width=1.2), legendgroup=name), row=1, col=1)

for col, color, name in [('temp0',C0,'ADC0'),('temp1',C1,'ADC1'),('temp2',C2,'ADC2')]:
    fig.add_trace(go.Scatter(x=df.timestamp, y=df[col], name=name,
                             line=dict(color=color, width=1.2),
                             legendgroup=name, showlegend=False), row=2, col=1)

# Event markers
for row 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=row, 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=row, col=1)

fig.update_layout(**rooster_layout(
    title=f'{experiment_id} — Full session overview',
    height=600,
    xaxis2=dict(gridcolor=GRID, linecolor=GRID),
    yaxis2=dict(gridcolor=GRID, linecolor=GRID),
))
fig.show()
Code
# Difference signal: ADC2 − mean(ADC0, ADC1)
# H₀ implies a constant offset. H₁ implies a slope change (progressive divergence)
# after shadow onset — not a step. Rolling slope panel shows rate of change over time.

ROLL_MEAN = 30   # rolling mean window (minutes)
ROLL_SLOPE = 45  # rolling slope window (minutes) — wider for stable slope estimate

df['delta_roll'] = df['delta'].rolling(ROLL_MEAN, center=True, min_periods=10).mean()

# Rolling OLS slope: fit delta ~ time in sliding window, report counts/min
def compute_rolling_slope(df_in, col, window_minutes):
    from datetime import timedelta
    half = timedelta(minutes=window_minutes / 2)
    slopes = []
    ts = df_in.timestamp.values
    vals = df_in[col].values
    for i, t in enumerate(df_in.timestamp):
        mask = (df_in.timestamp >= t - half) & (df_in.timestamp <= t + half)
        seg = df_in[mask]
        if len(seg) >= 5:
            x = (seg.timestamp - t).dt.total_seconds().values / 60
            b = np.polyfit(x, seg[col].values, 1)[0]
        else:
            b = np.nan
        slopes.append(b)
    return slopes

df['delta_slope'] = compute_rolling_slope(df, 'delta', ROLL_SLOPE)

fig = make_subplots(
    rows=2, cols=1, shared_xaxes=True, vertical_spacing=0.08,
    subplot_titles=[f'ADC2 − reference (Δ counts)  ·  {ROLL_MEAN}-min rolling mean',
                    f'Rolling slope of Δ  ({ROLL_SLOPE}-min window, counts/min)']
)

# Panel 1: delta signal
fig.add_trace(go.Scatter(
    x=df.timestamp, y=df.delta,
    name='ADC2 − ref (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.delta_roll,
    name=f'{ROLL_MEAN}-min mean', mode='lines',
    line=dict(color=C2, width=2.5),
), row=1, col=1)

# Panel 2: rolling slope
fig.add_trace(go.Scatter(
    x=df.timestamp, y=df.delta_slope,
    name='slope (counts/min)', mode='lines',
    line=dict(color=ACCENT, width=2.0),
    showlegend=True,
), row=2, col=1)
fig.add_hline(y=0, line_dash='dot', line_color=MUTED, row=2, col=1)

# Event lines on both panels
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 and rolling slope',
    height=560,
    xaxis2=dict(gridcolor=GRID, linecolor=GRID),
    yaxis=dict(title='Δ ADC counts', gridcolor=GRID, linecolor=GRID),
    yaxis2=dict(title='counts/min', gridcolor=GRID, linecolor=GRID,
                zeroline=True, zerolinecolor=MUTED),
))
fig.show()
Code
# Pre-shadow baseline: inter-channel statistics
# Describes the calibration state and ADC noise floor during the pre-shadow window.

print('Pre-shadow baseline')
print(f'  Period: {pre.timestamp.iloc[0]}{pre.timestamp.iloc[-1]}')
print(f'  n = {len(pre)} rows')
print()
print(f'  {"Signal":<20}  {"mean":>8}  {"std":>6}  {"median":>8}  {"mV equiv":>10}')
print('  ' + '-'*60)
for label, series in [
    ('ADC0 − ADC1', pre.adc0 - pre.adc1),
    ('ADC0 − ADC2', pre.adc0 - pre.adc2),
    ('ADC1 − ADC2', pre.adc1 - pre.adc2),
    ('delta (ADC2−ref)', pre.delta),
]:
    print(f'  {label:<20}  {series.mean():>+8.1f}  {series.std():>6.1f}  {series.median():>+8.1f}  {series.mean()*0.125:>+10.3f} mV')
print()
print(f'  Noise floor reference (calibration residual std): ±{NOISE_FLOOR:.1f} counts')
Pre-shadow baseline
  Period: 2026-07-04 22:33:25  →  2026-07-05 02:21:25
  n = 229 rows

  Signal                    mean     std    median    mV equiv
  ------------------------------------------------------------
  ADC0 − ADC1              +30.0    47.6     +26.0      +3.748 mV
  ADC0 − ADC2             +198.3    49.3    +192.0     +24.786 mV
  ADC1 − ADC2             +168.3    28.5    +166.0     +21.038 mV
  delta (ADC2−ref)        -183.3    32.5    -179.0     -22.912 mV

  Noise floor reference (calibration residual std): ±3.0 counts
Code
# Distribution comparison: pre-shadow vs post-shadow delta

all_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'])

# Histogram
for 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 plots
for 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)
    return max(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, m

t0 = 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_pre
se_diff    = np.sqrt(se_pre**2 + se_post**2)
t_stat     = slope_diff / se_diff
df_t       = neff_pre + neff_post - 4
p_val      = 2 * stats.t.sf(abs(t_stat), df_t)

sig_label = lambda p: '*** p<0.001' if p<0.001 else '** p<0.01' if p<0.01 else '* p<0.05' if p<0.05 else '(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 > 0 else '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.1713 counts/min  SE=0.0449  n=229  n_eff=166
  Post-shadow slope: +0.2290 counts/min  SE=0.0248  n=253  n_eff=136

  Slope change (post − pre): +0.4002 counts/min
  t = 7.801  df = 298  p = 0.0000  *** p<0.001

  Noise floor reference: ±3.0 counts total

  ✓ Reject H₀ — slope change of +0.4002 counts/min is significant.
    ADC2 delta is rising relative to reference at 0.4002 counts/min post-shadow.
Code
# Interrupted time-series regression
# Model: delta ~ time + shadow_on + shadow_on*time
# Primary result: interaction coefficient (slope change at shadow event)
# Secondary result: shadow_on level shift (may capture pre-shadow transition effect)

reg = df[['timestamp','delta']].dropna().copy()
t0r = reg.timestamp.iloc[0]
reg['time_min']    = (reg.timestamp - t0r).dt.total_seconds() / 60
reg['shadow_on']   = (reg.timestamp >= shadow_dt).astype(float)
reg['interaction'] = reg.time_min * reg.shadow_on

X     = sm.add_constant(reg[['time_min','shadow_on','interaction']])
model = sm.OLS(reg.delta, X).fit(cov_type='HC3')

i_coef = model.params['interaction']
i_ci   = model.conf_int().loc['interaction']
i_p    = model.pvalues['interaction']

l_coef = model.params['shadow_on']
l_ci   = model.conf_int().loc['shadow_on']
l_p    = model.pvalues['shadow_on']

print('── Segmented OLS regression (HC3 robust standard errors) ──')
print()
print('  PRIMARY — slope change (interaction term):')
print(f'    interaction coef : {i_coef:+.4f} counts/min')
print(f'    95% CI           : [{i_ci[0]:+.4f}, {i_ci[1]:+.4f}] counts/min')
print(f'    p-value          : {i_p:.4f}  {"*** p<0.001" if i_p<0.001 else "** p<0.01" if i_p<0.01 else "* p<0.05" if i_p<0.05 else "(ns)"}')
print()
print('  secondary — level shift at shadow_dt (shadow_on term):')
print(f'    shadow_on coef   : {l_coef:+.2f} counts  ({l_coef*0.125:+.3f} mV)')
print(f'    95% CI           : [{l_ci[0]:+.2f}, {l_ci[1]:+.2f}] counts')
print(f'    p-value          : {l_p:.4f}  {"*** p<0.001" if l_p<0.001 else "** p<0.01" if l_p<0.01 else "* p<0.05" if l_p<0.05 else "(ns)"}')
print()
print(f'  Pre-shadow trend   : {model.params["time_min"]:+.4f} counts/min')
print(f'  Post-shadow trend  : {model.params["time_min"] + i_coef:+.4f} counts/min')
print(f'  R²                 : {model.rsquared:.4f}')

# Visualization: two separate segment trend lines (makes slope difference visible)
pre_reg  = reg[reg.shadow_on == 0].copy()
post_reg = reg[reg.shadow_on == 1].copy()

pre_fit  = sm.OLS(pre_reg.delta,  sm.add_constant(pre_reg.time_min)).fit()
post_fit = sm.OLS(post_reg.delta, sm.add_constant(post_reg.time_min)).fit()

fig = go.Figure()
fig.add_trace(go.Scatter(x=reg.timestamp, y=reg.delta,
                         name='Δ (raw)', mode='lines',
                         line=dict(color=C2, width=0.8), opacity=0.35))
fig.add_trace(go.Scatter(x=pre_reg.timestamp,  y=pre_fit.fittedvalues,
                         name=f'Pre-shadow trend ({pre_fit.params['time_min']:+.4f} cnt/min)',
                         mode='lines', line=dict(color=MUTED, width=2.5, dash='solid')))
fig.add_trace(go.Scatter(x=post_reg.timestamp, y=post_fit.fittedvalues,
                         name=f'Post-shadow trend ({post_fit.params['time_min']:+.4f} cnt/min)',
                         mode='lines', line=dict(color=ACCENT, width=2.5, dash='solid')))
fig.add_vline(x=shadow_dt,  line_dash='dash', line_color=C_SHADOW,
              annotation_text='Shadow event', annotation_font_color=C_SHADOW)
fig.add_vline(x=sunrise_dt, line_dash='dot',  line_color=C_SUN,
              annotation_text='Sunrise', annotation_font_color=C_SUN)
fig.update_layout(**rooster_layout(
    title=f'Segmented trends  |  interaction (slope Δ) = {i_coef:+.4f} cnt/min  p={i_p:.4f}',
    yaxis_title='Δ ADC counts', height=420,
))
fig.show()
── Segmented OLS regression (HC3 robust standard errors) ──

  PRIMARY — slope change (interaction term):
    interaction coef : +0.4002 counts/min
    95% CI           : [+0.3173, +0.4832] counts/min
    p-value          : 0.0000  *** p<0.001

  secondary — level shift at shadow_dt (shadow_on term):
    shadow_on coef   : -101.76 counts  (-12.720 mV)
    95% CI           : [-119.39, -84.12] counts
    p-value          : 0.0000  *** p<0.001

  Pre-shadow trend   : -0.1713 counts/min
  Post-shadow trend  : +0.2290 counts/min
  R²                 : 0.2488
Code
# 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, display

decision = (
    f'Reject H\u2080 \u2014 slope change of {slope_diff:+.4f} counts/min is significant ({sig_label(p_val)}). '
    f'ADC2 delta is {"rising" if slope_diff > 0 else "falling"} relative to reference post-shadow.'
    if p_val < 0.05
    else f'Fail to reject H\u2080 \u2014 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 &nbsp; p={p_val:.4f} &nbsp; {sig_label(p_val)} | **primary test of H\u2081** |
| OLS interaction coef | {i_coef:+.4f} counts/min &nbsp; p={i_p:.4f} &nbsp; {sig_label(i_p)} | confirms slope change |
| OLS level shift (shadow_on) | {l_coef:+.2f} counts &nbsp; p={l_p:.4f} &nbsp; {sig_label(l_p)} | 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:** {decision}

Notes: *(partial shadow transition, lag, confounders, etc.)*

### 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.1713 counts/min (n_eff=166) baseline rate of change
Post-shadow slope +0.2290 counts/min (n_eff=136) rate after shadow onset
Slope change (post − pre) +0.4002 counts/min   p=0.0000   *** p<0.001 primary test of H₁
OLS interaction coef +0.4002 counts/min   p=0.0000   *** p<0.001 confirms slope change
OLS level shift (shadow_on) -101.76 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: Reject H₀ — slope change of +0.4002 counts/min is significant (*** p<0.001). ADC2 delta is rising relative to reference post-shadow.

Notes: (partial shadow transition, lag, confounders, etc.)

Companion video

  • GoPro timelapse source: 2026-07-05_Moon-Temp_001
  • DaVinci overlay: davinci/adc_bar_chart_overlay.mov
  • Key timestamp: shadow event at 02:21:31 MDT