Files
Microtracking/analyze_2026_11_04.py

699 lines
41 KiB
Python

"""Reproducible read-and-report analysis of the 4 November 2026 field log."""
from __future__ import annotations
import hashlib
import json
import re
import sys
from datetime import datetime
from pathlib import Path
import matplotlib
matplotlib.use("Agg")
import matplotlib.dates as mdates
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
BASE = Path(__file__).resolve().parent
SOURCE = BASE / "logs_nuevos" / "log_2026-11-04.txt"
SUNCALC_SOURCE = BASE / "logs_nuevos" / "suncalc_log_2026-11-04.pdf"
OUT = BASE / "figures" / "2026-11-04"
RESULT_TEX = BASE / "articulo" / "results_2026_11_04.tex"
METRICS_TEX = BASE / "articulo" / "metrics_2026_11_04.tex"
ADC_MAX = 8_388_607
VREF_V = 2.048
CURRENT_GAIN = 20.0
SHUNT_OHM = 0.130
PYRANOMETER_MV_PER_W_M2 = 0.2
APERTURE_M2 = 0.002497
FILL_FACTOR = 0.82
MAX_GAP_S = 15.0
MIN_IRRADIANCE_W_M2 = 50.0
SAMPLE_RE = re.compile(
r"LOG:\[(?P<timestamp>[^\]]+)\] X:(?P<x>-?\d+),Y:(?P<y>-?\d+),"
r"C:(?P<c>-?\d+),V:(?P<v>-?\d+),P:(?P<p>-?\d+),"
r"S:(?P<uptime>\d+),er:(?P<error>\d+)"
)
TEL_RE = re.compile(
r"LOG:\[(?P<timestamp>[^\]]+)\] TEL: t=(?P<uptime>\d+)s, "
r"steps=(?P<steps>\d+)\(\+?(?P<delta>\d+)\)"
)
EVENT_RE = re.compile(r"LOG:\[(?P<timestamp>[^\]]+)\] (?P<message>.*)")
# Exact 15-minute values transcribed from the supplied SunCalc PDF.
SUNCALC_ROWS = [
("07:00",106.87,.82),("07:15",108.31,3.91),("07:30",109.82,7.12),("07:45",111.41,10.34),
("08:00",113.08,13.54),("08:15",114.86,16.70),("08:30",116.76,19.83),("08:45",118.80,22.90),
("09:00",120.98,25.91),("09:15",123.34,28.85),("09:30",125.90,31.71),("09:45",128.67,34.47),
("10:00",131.69,37.13),("10:15",134.98,39.66),("10:30",138.57,42.04),("10:45",142.49,44.24),
("11:00",146.75,46.26),("11:15",151.37,48.04),("11:30",156.34,49.57),("11:45",161.64,50.82),
("12:00",167.22,51.75),("12:15",173.01,52.35),("12:30",178.93,52.59),("12:45",184.86,52.47),
("13:00",190.71,51.99),("13:15",196.37,51.18),("13:30",201.77,50.04),("13:45",206.86,48.60),
("14:00",211.60,46.90),("14:15",215.98,44.96),("14:30",220.01,42.81),("14:45",223.71,40.49),
("15:00",227.10,38.01),("15:15",230.21,35.40),("15:30",233.06,32.67),("15:45",235.69,29.84),
("16:00",238.11,26.93),("16:15",240.35,23.94),("16:30",242.43,20.89),("16:45",244.37,17.78),
("17:00",246.18,14.63),("17:15",247.89,11.44),("17:30",249.50,8.23),("17:45",251.03,5.01),
("18:00",252.49,1.85),("18:15",253.88,-1.05),
]
def signed24(value: int) -> int:
value &= 0xFFFFFF
return value - 0x1000000 if value & 0x800000 else value
def parse_log(path: Path = SOURCE):
samples, telemetry, events = [], [], []
raw = path.read_bytes()
for line_number, line in enumerate(raw.decode("utf-8-sig").splitlines(), 1):
match = SAMPLE_RE.search(line)
if match:
d = match.groupdict()
samples.append({
"timestamp": pd.Timestamp(d["timestamp"]),
"x_steps": int(d["x"]), "y_steps": int(d["y"]),
"c_raw": int(d["c"]), "v_raw": int(d["v"]), "p_raw": int(d["p"]),
"uptime_s": int(d["uptime"]), "error": int(d["error"]),
"source_line": line_number,
})
continue
match = TEL_RE.search(line)
if match:
d = match.groupdict()
telemetry.append({"timestamp": pd.Timestamp(d["timestamp"]),
"uptime_s": int(d["uptime"]), "steps": int(d["steps"]),
"delta": int(d["delta"]), "source_line": line_number})
continue
match = EVENT_RE.search(line)
if match:
events.append({"timestamp": pd.Timestamp(match["timestamp"]),
"message": match["message"], "source_line": line_number})
frame = pd.DataFrame(samples)
if frame.empty or not frame.timestamp.is_monotonic_increasing:
raise ValueError("No electrical samples or non-monotonic source timestamps")
frame.attrs["firmware_versions"] = sorted(set(re.findall(r"VER:([\w.-]+)", raw.decode("utf-8-sig"))))
return frame, pd.DataFrame(telemetry), pd.DataFrame(events), hashlib.sha256(raw).hexdigest()
def calibrate(frame: pd.DataFrame, fill_factor: float = FILL_FACTOR) -> pd.DataFrame:
if not 0 < fill_factor <= 1:
raise ValueError("Fill factor must be in (0, 1]")
result = frame.copy()
for raw_col, signed_col in (("c_raw", "c_signed"), ("v_raw", "v_signed"), ("p_raw", "p_signed")):
result[signed_col] = result[raw_col].map(signed24)
result["isc_A"] = result.c_signed / ADC_MAX * VREF_V / CURRENT_GAIN / SHUNT_OHM
result["voc_V"] = result.v_signed / ADC_MAX * VREF_V * 2
result["irradiance_W_m2"] = result.p_signed / ADC_MAX * VREF_V * 1000 / PYRANOMETER_MV_PER_W_M2
result["assumed_fill_factor"] = fill_factor
result["adc_clipped"] = result[["c_signed", "v_signed", "p_signed"]].abs().ge(ADC_MAX).any(axis=1)
result["near_current_rail"] = result.c_signed.abs().ge(0.99 * ADC_MAX)
result["invalid_polarity"] = result[["c_signed", "v_signed", "p_signed"]].lt(0).any(axis=1)
result["illuminated"] = result.irradiance_W_m2 > MIN_IRRADIANCE_W_M2
result["accepted"] = ~(result.adc_clipped | result.near_current_rail | result.invalid_polarity)
result["quality_flag"] = np.select(
[result.adc_clipped, result.near_current_rail, result.invalid_polarity],
["adc_clipped", "near_current_rail", "invalid_polarity"], default="accepted")
result["estimated_mpp_W"] = (result.isc_A * result.voc_V * fill_factor).where(result.accepted)
valid_response = result.accepted & result.illuminated
result["instant_aperture_efficiency_pct"] = (
100 * result.estimated_mpp_W / (APERTURE_M2 * result.irradiance_W_m2)
).where(valid_response)
result["responsivity_A_m2_W"] = (result.isc_A / result.irradiance_W_m2).where(valid_response)
return result
def accepted_edges(frame: pd.DataFrame, selected=None, max_gap_s: float = MAX_GAP_S):
"""Return a boolean mask for adjacent accepted sample pairs only."""
n = len(frame)
keep = np.ones(n, dtype=bool) if selected is None else np.asarray(selected, dtype=bool)
dt = frame.timestamp.diff().dt.total_seconds().to_numpy()
reboot = frame.uptime_s.diff().lt(0).to_numpy()
edge = np.zeros(n, dtype=bool)
edge[1:] = (keep[1:] & keep[:-1] & frame.accepted.to_numpy()[1:] & frame.accepted.to_numpy()[:-1]
& frame.illuminated.to_numpy()[1:] & frame.illuminated.to_numpy()[:-1]
& (dt[1:] > 0) & (dt[1:] <= max_gap_s) & ~reboot[1:])
return edge
def integrate_edges(frame: pd.DataFrame, values, edge_mask) -> float:
values = np.asarray(values, dtype=float)
seconds = frame.timestamp.diff().dt.total_seconds().to_numpy()
right = np.flatnonzero(edge_mask)
left = right - 1
return float(np.sum((values[left] + values[right]) * 0.5 * seconds[right]) / 3600)
def metrics(frame: pd.DataFrame, start=None, end=None, max_gap_s=MAX_GAP_S):
t = frame.timestamp
selected = np.ones(len(frame), dtype=bool)
if start is not None:
selected &= (t >= pd.Timestamp(start)).to_numpy()
if end is not None:
selected &= (t < pd.Timestamp(end)).to_numpy()
edges = accepted_edges(frame, selected, max_gap_s)
accepted = selected & frame.accepted.to_numpy() & frame.illuminated.to_numpy()
obs = frame.loc[accepted]
support_s = float(frame.timestamp.diff().dt.total_seconds().to_numpy()[edges].sum())
elapsed_s = ((min(t.max(), pd.Timestamp(end)) if end is not None else t.max()) -
(max(t.min(), pd.Timestamp(start)) if start is not None else t.min())).total_seconds()
energy = integrate_edges(frame, frame.estimated_mpp_W, edges)
irradiation = integrate_edges(frame, frame.irradiance_W_m2, edges)
ref_resp = float(frame.loc[frame.accepted & frame.illuminated, "responsivity_A_m2_W"].max())
p99_resp = float(frame.loc[frame.accepted & frame.illuminated, "responsivity_A_m2_W"].quantile(.99))
# Reference powers use observed Voc and the same common irradiance support.
max_ref_power = ref_resp * frame.irradiance_W_m2 * frame.voc_V * frame.assumed_fill_factor
p99_ref_power = p99_resp * frame.irradiance_W_m2 * frame.voc_V * frame.assumed_fill_factor
max_ref_energy = integrate_edges(frame, max_ref_power, edges)
p99_ref_energy = integrate_edges(frame, p99_ref_power, edges)
peak = obs.loc[obs.estimated_mpp_W.idxmax()] if not obs.empty else None
peak_isc = obs.loc[obs.isc_A.idxmax()] if not obs.empty else None
peak_g = obs.loc[obs.irradiance_W_m2.idxmax()] if not obs.empty else None
si_19 = irradiation * APERTURE_M2 * .19
si_21 = irradiation * APERTURE_M2 * .21
return {
"start": str(obs.timestamp.min()) if not obs.empty else "",
"end": str(obs.timestamp.max()) if not obs.empty else "",
"sample_count": int(selected.sum()), "accepted_illuminated_samples": int(accepted.sum()),
"integrated_intervals": int(edges.sum()), "elapsed_span_h": elapsed_s / 3600,
"integrated_support_h": support_s / 3600,
"coverage_pct": 100 * support_s / elapsed_s if elapsed_s > 0 else float("nan"),
"irradiation_Wh_m2": irradiation, "incident_aperture_energy_Wh": irradiation * APERTURE_M2,
"estimated_mpp_energy_Wh": energy,
"energy_weighted_aperture_efficiency_pct": 100 * energy / (APERTURE_M2 * irradiation) if irradiation else float("nan"),
"median_instant_aperture_efficiency_pct": float(obs.instant_aperture_efficiency_pct.median()) if not obs.empty else float("nan"),
"peak_instant_aperture_efficiency_pct": float(obs.instant_aperture_efficiency_pct.max()) if not obs.empty else float("nan"),
"empirical_max_reference_A_m2_W": ref_resp, "empirical_p99_reference_A_m2_W": p99_resp,
"max_reference_ratio_pct": 100 * energy / max_ref_energy if max_ref_energy else float("nan"),
"p99_reference_ratio_pct": 100 * energy / p99_ref_energy if p99_ref_energy else float("nan"),
"silicon_19_energy_Wh": si_19, "silicon_21_energy_Wh": si_21,
"cpv_to_silicon_19_ratio_pct": 100 * energy / si_19 if si_19 else float("nan"),
"cpv_to_silicon_21_ratio_pct": 100 * energy / si_21 if si_21 else float("nan"),
"peak_power_timestamp": str(peak.timestamp) if peak is not None else "",
"peak_power_W": float(peak.estimated_mpp_W) if peak is not None else float("nan"),
"aperture_efficiency_at_peak_power_pct": float(peak.instant_aperture_efficiency_pct) if peak is not None else float("nan"),
"peak_power_isc_A": float(peak.isc_A) if peak is not None else float("nan"),
"peak_power_voc_V": float(peak.voc_V) if peak is not None else float("nan"),
"peak_power_irradiance_W_m2": float(peak.irradiance_W_m2) if peak is not None else float("nan"),
"peak_isc_timestamp": str(peak_isc.timestamp) if peak_isc is not None else "",
"peak_isc_A": float(peak_isc.isc_A) if peak_isc is not None else float("nan"),
"peak_irradiance_timestamp": str(peak_g.timestamp) if peak_g is not None else "",
"peak_irradiance_W_m2": float(peak_g.irradiance_W_m2) if peak_g is not None else float("nan"),
}
def suncalc_frame():
day = pd.Timestamp("2026-11-04")
return pd.DataFrame([{"timestamp": day + pd.Timedelta(hours=int(t[:2]), minutes=int(t[3:])),
"azimuth_deg": az, "solar_elevation_deg": el,
"latitude_deg": 21.87304, "longitude_deg": -102.28282,
"source": "SunCalc PDF, printed local time; 2026-11-04"}
for t, az, el in SUNCALC_ROWS])
def make_events(events: pd.DataFrame, frame: pd.DataFrame, telemetry: pd.DataFrame):
relevant = events[events.message.str.contains(
r"\[CLOUD\]|F1:|F5:|Standby|Auto-arranque|Ejecutando comando|VER:", regex=True, na=False)].copy()
relevant["event_type"] = np.select(
[relevant.message.str.contains("Set Coords", na=False),
relevant.message.str.contains("[CLOUD]", regex=False, na=False),
relevant.message.str.contains(r"F1:|F5:", regex=True, na=False)],
["manual_coordinate_command", "remote_parameter_or_status_command", "controller_event"], default="logged_event")
# Add algorithmic gaps with exact endpoints and source-line references.
dt = frame.timestamp.diff().dt.total_seconds()
gaps = []
for i in np.flatnonzero(dt.to_numpy() > MAX_GAP_S):
gaps.append({"timestamp": frame.timestamp.iloc[i], "message": f"Sampling gap: {dt.iloc[i]:.0f} s; no bridge in integration",
"source_line": int(frame.source_line.iloc[i]), "event_type": "sampling_gap",
"previous_source_line": int(frame.source_line.iloc[i - 1]),
"uptime_decreased": bool(frame.uptime_s.iloc[i] < frame.uptime_s.iloc[i - 1])})
if not telemetry.empty:
tel_dt = telemetry.timestamp.diff().dt.total_seconds()
for i in np.flatnonzero(tel_dt.to_numpy() > MAX_GAP_S):
gaps.append({"timestamp": telemetry.timestamp.iloc[i], "message": f"TEL telemetry gap: {tel_dt.iloc[i]:.0f} s",
"source_line": int(telemetry.source_line.iloc[i]), "event_type": "tel_sampling_gap",
"previous_source_line": int(telemetry.source_line.iloc[i - 1]),
"uptime_decreased": bool(telemetry.uptime_s.iloc[i] < telemetry.uptime_s.iloc[i - 1])})
for i in np.flatnonzero(telemetry.uptime_s.diff().lt(0).to_numpy()):
gaps.append({"timestamp": telemetry.timestamp.iloc[i], "message": "TEL uptime decreased",
"source_line": int(telemetry.source_line.iloc[i]), "event_type": "tel_uptime_decrease",
"previous_source_line": int(telemetry.source_line.iloc[i - 1]),
"uptime_decreased": True})
relevant = relevant.assign(previous_source_line=np.nan, uptime_decreased=np.nan)
return pd.concat([relevant, pd.DataFrame(gaps)], ignore_index=True, sort=False).sort_values("timestamp")
def add_solar_context(frame: pd.DataFrame, sun: pd.DataFrame):
# Linear interpolation is only for plotting context; no extrapolation outside table.
result = frame.copy()
x = sun.timestamp.astype("int64").to_numpy()
tq = result.timestamp.astype("int64").to_numpy()
for col in ("azimuth_deg", "solar_elevation_deg"):
result[col] = np.interp(tq, x, sun[col].to_numpy(), left=np.nan, right=np.nan)
result.loc[(tq < x[0]) | (tq > x[-1]), col] = np.nan
return result
def gap_breaks(frame):
dt = frame.timestamp.diff().dt.total_seconds().to_numpy()
return np.r_[False, (dt[1:] > MAX_GAP_S)] | ~frame.accepted.to_numpy()
def plot_outputs(frame, events, sun, summary):
OUT.mkdir(parents=True, exist_ok=True)
plt.rcParams.update({"font.family": "DejaVu Sans", "font.size": 9,
"axes.spines.top": False, "axes.spines.right": False,
"pdf.fonttype": 42, "ps.fonttype": 42})
time = frame.timestamp
breaks = gap_breaks(frame)
fig, ax = plt.subplots(5, 1, figsize=(10, 10), sharex=True, layout="constrained")
ax[0].plot(time, frame.irradiance_W_m2.mask(breaks), color="#d09128", lw=.65)
ax[0].set_ylabel("Aperture-plane\nirradiance [W m$^{-2}$]")
ax[1].plot(time, (1000 * frame.isc_A).mask(breaks), color="#167d8d", lw=.65)
ax[1].set_ylabel("Short-circuit\ncurrent [mA]")
ax[2].plot(time, frame.voc_V.mask(breaks), color="#7356a5", lw=.65)
ax[2].set_ylabel("Open-circuit\nvoltage [V]")
ax[3].plot(time, frame.estimated_mpp_W.mask(breaks), color="#167d8d", lw=.65, label="Estimated MPP power")
ax[3].set_ylabel("Estimated power\n[W]")
ax2 = ax[3].twinx(); ax2.plot(time, frame.instant_aperture_efficiency_pct.mask(breaks), color="#27824e", lw=.55, alpha=.8)
ax2.set_ylabel("Aperture estimate [%]", color="#27824e")
ax[4].plot(time, frame.x_steps.mask(breaks), color="#285b9a", lw=.6, label="X (azimuth axis)")
ax[4].plot(time, frame.y_steps.mask(breaks), color="#c05b43", lw=.6, label="Y (elevation axis)")
ax[4].set_ylabel("Logged coordinate\n[steps]"); ax[4].legend(ncol=2, frameon=False)
for a in ax:
a.grid(alpha=.2); a.xaxis.set_major_formatter(mdates.DateFormatter("%H:%M"))
for g in pd.to_datetime(summary["gap_starts"]): a.axvline(g, color="#555555", lw=.6, ls=":", alpha=.6)
for ts in pd.to_datetime(summary["command_times"]):
for a in ax: a.axvline(ts, color="#ad3e38", lw=.55, ls="--", alpha=.5)
ax[-1].set_xlabel("Local log clock (time-zone offset not encoded)")
fig.suptitle("4 November 2026 field record: complete acquired span", x=.02, ha="left", fontsize=12)
save_figure(fig, "field_day_overview")
fig, ax = plt.subplots(2, 1, figsize=(9, 6), sharex=True, layout="constrained")
ax[0].plot(sun.timestamp, sun.azimuth_deg, "o-", color="#3468a8", ms=2.5, lw=1)
ax[0].set_ylabel("Sun azimuth [deg]")
ax[1].plot(sun.timestamp, sun.solar_elevation_deg, "o-", color="#d27c25", ms=2.5, lw=1)
ax[1].set_ylabel("Solar elevation [deg]"); ax[1].set_xlabel("SunCalc printed local time (15-min entries)")
for a in ax:
a.axvspan(frame.timestamp.min(), frame.timestamp.max(), color="#555555", alpha=.08, label="Acquired interval")
a.grid(alpha=.2); a.xaxis.set_major_formatter(mdates.DateFormatter("%H:%M"))
ax[0].set_title("SunCalc context, Aguascalientes (21.87304° N, 102.28282° W)", loc="left")
fig.text(.01, -.01, "Solar direction is not measured receiver pointing error or collector incidence.", fontsize=8)
save_figure(fig, "solar_position_context")
ctx = add_solar_context(frame, sun)
colors = plt.colormaps["viridis"].with_extremes(over="#ad3e38")
color_limit = summary["reference_p99"] * 1e3
fig, ax = plt.subplots(2, 1, figsize=(10, 7), sharex=True, layout="constrained")
sc = ax[0].scatter(ctx.timestamp, ctx.azimuth_deg, c=ctx.responsivity_A_m2_W * 1e3,
cmap=colors, vmin=0, vmax=color_limit, s=7, rasterized=True)
ax[0].set_ylabel("Sun azimuth [deg]")
cb = fig.colorbar(sc, ax=ax, pad=.01, extend="max"); cb.set_label("$I_{sc}/G$ [mA m$^2$ W$^{-1}$]")
ax[1].scatter(ctx.timestamp, ctx.solar_elevation_deg, c=ctx.responsivity_A_m2_W * 1e3,
cmap=colors, vmin=0, vmax=color_limit, s=7, rasterized=True)
ax[1].set_ylabel("Solar elevation [deg]"); ax[1].set_xlabel("Local log clock; SunCalc values linearly interpolated")
for a in ax:
a.grid(alpha=.2); a.xaxis.set_major_formatter(mdates.DateFormatter("%H:%M"))
ax[0].set_title("Solar position and measured normalized response", loc="left")
fig.text(.01, -.01, "Ephemeris context only; not angular error. Red values exceed the P99 color scale; observations are retained.", fontsize=8)
save_figure(fig, "solar_response_alignment")
fig, ax = plt.subplots(2, 1, figsize=(9, 6), sharex=True, layout="constrained")
response = frame.responsivity_A_m2_W * 1e3
ax[0].plot(time, response.mask(breaks), color="#285b9a", lw=.65, label="$I_{sc}/G$")
max_ref = summary["reference_max"] * 1e3; p99_ref = summary["reference_p99"] * 1e3
for a in ax:
a.axhline(max_ref, color="#b53d35", ls="--", lw=1, label="Full-record maximum reference")
a.axhline(p99_ref, color="#27824e", ls="--", lw=1, label="Full-record P99 sensitivity")
ax[1].plot(time, response.mask(breaks), color="#285b9a", lw=.65)
anomaly = summary["anomaly_time"]
ax[0].scatter([pd.Timestamp(anomaly)], [summary["anomaly_resp"] * 1e3], color="#b53d35", zorder=4, s=28)
ax[0].set_ylim(0, max_ref * 1.08); ax[1].set_ylim(0, p99_ref * 1.35)
ax[0].set_ylabel("Full range\n[mA m$^2$ W$^{-1}$]")
ax[1].set_ylabel("Typical range\n[mA m$^2$ W$^{-1}$]"); ax[1].set_xlabel("Local log clock")
ax[0].set_title("Irradiance-normalized response; full record retained", loc="left")
for a in ax:
for g in pd.to_datetime(summary["gap_starts"]): a.axvline(g, color="#555", lw=.6, ls=":")
a.xaxis.set_major_formatter(mdates.DateFormatter("%H:%M")); a.grid(alpha=.2)
ax[0].legend(frameon=False, ncol=2, fontsize=8)
save_figure(fig, "response_reference_sensitivity")
fig, ax = plt.subplots(figsize=(8, 5), layout="constrained")
labels = ["WiT-CPV estimated\nFF-based output", "Silicon scenario\n19% constant", "Silicon scenario\n21% constant"]
vals = [summary["full"]["estimated_mpp_energy_Wh"], summary["full"]["silicon_19_energy_Wh"], summary["full"]["silicon_21_energy_Wh"]]
colors = ["#167d8d", "#8798aa", "#b0bac5"]
bars = ax.bar(labels, vals, color=colors, width=.62)
ax.bar_label(bars, fmt="%.2f Wh", padding=3)
ax.set_ylabel("Integrated scenario energy [Wh]"); ax.set_ylim(0, max(vals) * 1.2)
ax.set_title("Equal-area scenarios in the measured aperture plane", loc="left")
ax.text(.02, .96, "A = 0.002497 m²; coplanar irradiance; constant ηSi.\nNo temperature correction; not a measured panel comparison.",
transform=ax.transAxes, va="top", fontsize=8)
ax.grid(axis="y", alpha=.2)
save_figure(fig, "fixed_silicon_scenario")
def save_figure(fig, stem):
fig.savefig(OUT / f"{stem}.pdf", bbox_inches="tight")
fig.savefig(OUT / f"{stem}.png", dpi=220, bbox_inches="tight")
plt.close(fig)
def write_result_tex(summary, gap_text):
m = summary["full"]
macros = {
"NovSamples": f"{summary['sample_count']:,}".replace(",", "{,}"),
"NovHours": f"{m['integrated_support_h']:.3f}",
"NovEnergy": f"{m['estimated_mpp_energy_Wh']:.3f}",
"NovIrradiation": f"{m['irradiation_Wh_m2'] / 1000:.3f}",
"NovEfficiency": f"{m['energy_weighted_aperture_efficiency_pct']:.2f}",
"NovPeakPower": f"{m['peak_power_W']:.3f}",
"NovApertureAtPeakPower": f"{m['aperture_efficiency_at_peak_power_pct']:.2f}",
"NovPeakCurrent": f"{m['peak_power_isc_A'] * 1000:.1f}",
"NovPeakVoltage": f"{m['peak_power_voc_V']:.3f}",
"NovPeakIrradiance": f"{m['peak_power_irradiance_W_m2']:.1f}",
"NovRelativeMax": f"{m['max_reference_ratio_pct']:.2f}",
"NovRelativePercentile": f"{m['p99_reference_ratio_pct']:.2f}",
"NovSiliconStandardRatio": f"{m['cpv_to_silicon_19_ratio_pct'] / 100:.3f}",
"NovSiliconPremiumRatio": f"{m['cpv_to_silicon_21_ratio_pct'] / 100:.3f}",
"NovSteadyEfficiency": f"{summary['phases'][2]['energy_weighted_aperture_efficiency_pct']:.2f}",
}
METRICS_TEX.write_text(
"% Generated by analyze_2026_11_04.py.\n" + "\n".join(
"\\newcommand{\\" + name + "}{" + value + "}" for name, value in macros.items()
) + "\n", encoding="utf-8")
tex = rf"""% Generated by analyze_2026_11_04.py; do not hand-edit numerical values.
\subsection{{Acquisition coverage and operating chronology}}
The acquired span is 09:52:50--16:05:13, comprising \NovSamples{{}}
electrical/coordinate observations and {summary['tel_records']:,} separate
telemetry messages. All main observations pass the ADC quality checks and
exceed the analysis illumination threshold. There are no duplicate main
timestamps or decreasing main-sample uptimes. Five gaps exceed 15~s:
{gap_text}. Excluding these gaps gives \NovHours{{}}~h of integration
support out of {m['elapsed_span_h']:.3f}~h elapsed, or {m['coverage_pct']:.2f}\%
coverage. The audit retains every acquired observation; no clipped samples
or unobserved intervals are reconstructed.
The complete overview in Fig.~\ref{{fig:nov04_overview}} connects electrical
response with stage motion. There are {summary['cloud_commands']} logged remote
commands, including early tuning and repositioning. For example,
\texttt{{Delay=4}} was logged at 12:27:20, before the 12:32 boundary. Three event boundaries
divide the record without selecting a favorable performance threshold:
\texttt{{ErrMax=1100}} at 12:32:02, \texttt{{ErrMax=1000}} at 13:53:00,
and the first late manual-coordinate command at 15:12:42. All four sequential
phases are retained in Table~\ref{{tab:nov04_phases}}. No additional remote
commands are recorded inside the 12:32--13:53 phase; periodic controller
reference resets continue. This is an interval without logged operator
interventions, not a controlled comparison of firmware or settings.
\subsection{{Electrical output and aperture estimate}}
Applying Eqs.~\eqref{{eq:power_estimate}}--\eqref{{eq:aperture}} over the
complete accepted support yields \NovEnergy{{}}~Wh of estimated available-MPP
energy from \NovIrradiation{{}}~kWh\,m$^{{-2}}$ of measured aperture-plane irradiation,
with an energy-weighted aperture estimate of \NovEfficiency\%.
Table~\ref{{tab:nov04_hourly}} shows every acquired clock-hour bin, including
the initial low response and late decline, rather than replacing the complete
record with a midday-only integral.
At the maximum of the power estimate ({m['peak_power_timestamp']}), the
sequentially recorded values in that observation were
$I_{{sc}}={m['peak_power_isc_A']*1000:.1f}$ mA,
$V_{{oc}}={m['peak_power_voc_V']:.3f}$ V and $G={m['peak_power_irradiance_W_m2']:.1f}$ W m$^{{-2}}$,
giving {m['peak_power_W']:.3f}~W estimated output and an instantaneous aperture
estimate of \NovApertureAtPeakPower\% at that observation. Separate maxima are not combined
into a synthetic operating point. The 12:32--13:53 phase contributes
{summary['phases'][2]['estimated_mpp_energy_Wh']:.3f}~Wh over
{summary['phases'][2]['integrated_support_h']:.3f}~h, with an aperture estimate
of \NovSteadyEfficiency\%. The 15:00--16:00 clock-hour estimate falls to
{summary['hourly'][6]['energy_weighted_aperture_efficiency_pct']:.2f}\%, and
the phase after the first 15:12 manual command gives
{summary['phases'][4]['energy_weighted_aperture_efficiency_pct']:.2f}\%.
The record shows deterioration before and after that command, not evidence
that the command alone caused it.
\begin{{table}}[htbp]
\centering\small
\caption{{Complete acquisition and fixed clock-hour summaries. $H$ is aperture-plane
irradiation, $\widehat E$ is FF-based available energy and
$\widehat\eta_{{\mathrm{{ap}},E}}$ is the energy-weighted aperture estimate.
Bins contain only acquired observations. Both edge endpoints must lie inside a
bin, so subdivided integrals omit boundary-crossing edges and need not sum
exactly to the full-record integral.}}
\label{{tab:nov04_hourly}}
\begin{{tabular}}{{lrrrrr}}
\toprule
Window & $N$ & Support [h] & $H$ [kWh\,m$^{{-2}}$] & $\widehat E$ [Wh] & $\widehat\eta_{{\mathrm{{ap}},E}}$ [\%] \\
\midrule
{summary['table_rows']}
\bottomrule
\end{{tabular}}
\end{{table}}
\begin{{table}}[htbp]
\centering\small
\caption{{All sequential event-defined phases. Internal command boundaries
belong to the following phase; the final acquired sample is retained.
As in Table~\ref{{tab:nov04_hourly}},
edges crossing a boundary are omitted. Phases describe operation; they are not
matched experimental treatments.}}
\label{{tab:nov04_phases}}
\begin{{tabular}}{{lrrrr}}
\toprule
Phase boundary clocks & $N$ & Support [h] & $\widehat E$ [Wh] & $\widehat\eta_{{\mathrm{{ap}},E}}$ [\%] \\
\midrule
{summary['phase_table_rows']}
\bottomrule
\end{{tabular}}
\end{{table}}
\subsection{{Relative response and solar-position context}}
An empirical same-record $I_{{sc}}/G$ reference is highly sensitive to one point:
the maximum is {summary['reference_max']:.6f} A m$^2$ W$^{{-1}}$ at
{summary['anomaly_time']}, where $G={summary['anomaly_G']:.1f}$ W m$^{{-2}}$ and
$I_{{sc}}={summary['anomaly_I']*1000:.1f}$ mA; the P99 reference is
{summary['reference_p99']:.6f} A m$^2$ W$^{{-1}}$. The anomalous sample is retained.
In its four-minute neighborhood, median irradiance is
{summary['anomaly_neighborhood']['irradiance_median']:.1f} W m$^{{-2}}$
({summary['anomaly_neighborhood']['count']} samples), consistent with an isolated
irradiance dip rather than sustained low irradiance; the cause remains unresolved.
Across the accepted illuminated samples, median $I_{{sc}}/G$ is
{summary['responsivity_distribution']['median']:.6f} A m$^2$ W$^{{-1}}$
(interquartile range {summary['responsivity_distribution']['q25']:.6f}--
{summary['responsivity_distribution']['q75']:.6f}).
Full-record integrated relative-yield indicators are
{m['max_reference_ratio_pct']:.1f}\% (maximum reference) and
{m['p99_reference_ratio_pct']:.1f}\% (P99 sensitivity). These are internal,
reference-dependent indicators rather than independently validated tracking
efficiencies. Figure~\ref{{fig:nov04_response}} displays both the full
responsivity range and a typical-range view; the anomalous observation remains
in the analysis.
Interpolated SunCalc positions at the first and last observations are
{summary['solar_context']['first_azimuth_deg']:.2f}$^\circ$ and
{summary['solar_context']['last_azimuth_deg']:.2f}$^\circ$ azimuth, with elevations
{summary['solar_context']['first_elevation_deg']:.2f}$^\circ$ and
{summary['solar_context']['last_elevation_deg']:.2f}$^\circ$, respectively.
The printed table reaches {summary['solar_context']['maximum_elevation_deg']:.2f}$^\circ$
elevation at 12:30. Figure~\ref{{fig:nov04_solar_response}} relates this
trajectory to the measured normalized response. Logged coordinates span
$X={summary['coordinates']['x_min']:,}$--${summary['coordinates']['x_max']:,}$
and $Y={summary['coordinates']['y_min']:,}$--${summary['coordinates']['y_max']:,}$
steps. These are observed solar and coordinate ranges, not independently
validated acceptance angles or rated mechanical limits.
\subsection{{Conditional fixed-silicon reference}}
The coplanar, equal-area scenarios of Eq.~\eqref{{eq:silicon_scenario}}
yield
{m['silicon_19_energy_Wh']:.3f} and {m['silicon_21_energy_Wh']:.3f} Wh, respectively;
the corresponding estimated-CPV-to-scenario ratios are
\NovSiliconStandardRatio{{}} and \NovSiliconPremiumRatio{{}}.
Figure~\ref{{fig:nov04_silicon}} illustrates these assumptions, not measured
comparative yields. Changing the assumed CPV fill factor from 0.82 to 0.70 or
0.90 would change the full-record energy estimate to
{m['estimated_mpp_energy_Wh']*.70/FILL_FACTOR:.3f} or
{m['estimated_mpp_energy_Wh']*.90/FILL_FACTOR:.3f}~Wh, respectively.
This sensitivity range is illustrative, not a measured uncertainty interval.
\begin{{figure}}[htbp]\centering
\includegraphics[width=0.98\linewidth]{{../figures/2026-11-04/field_day_overview.pdf}}
\caption{{Complete 4 November acquisition. Vertical dotted lines mark sampling
gaps; dashed lines mark the three selected phase-boundary commands. Raw trends are not smoothed, and gaps are
not bridged. Electrical output is estimated using $FF=0.82$.}}
\label{{fig:nov04_overview}}\end{{figure}}
\begin{{figure}}[htbp]\centering
\includegraphics[width=0.92\linewidth]{{../figures/2026-11-04/solar_response_alignment.pdf}}
\caption{{SunCalc azimuth/elevation context aligned to the measured response.
SunCalc entries are spaced 15 minutes; intermediate positions are linearly
interpolated for plotting. Color encodes $I_{{sc}}/G$, not pointing error;
values above P99 remain plotted in the red over-range color.}}
\label{{fig:nov04_solar_response}}\end{{figure}}
\begin{{figure}}[htbp]\centering
\includegraphics[width=0.88\linewidth]{{../figures/2026-11-04/response_reference_sensitivity.pdf}}
\caption{{Irradiance-normalized response for the full record. The maximum
reference is dominated by a retained anomalous observation; P99 is shown only as
a sensitivity analysis. Neither line is an independent ideal-tracking standard.}}
\label{{fig:nov04_response}}\end{{figure}}
\begin{{figure}}[htbp]\centering
\includegraphics[width=0.75\linewidth]{{../figures/2026-11-04/fixed_silicon_scenario.pdf}}
\caption{{Conditional equal-area scenarios using the measured aperture-plane irradiance.
Silicon efficiency is held constant at 19\% or 21\%; no panel,
temperature correction or measured head-to-head comparison is represented.}}
\label{{fig:nov04_silicon}}\end{{figure}}
"""
RESULT_TEX.write_text(tex, encoding="utf-8")
def run():
frame, telemetry, events, source_hash = parse_log()
frame = calibrate(frame)
sun = suncalc_frame()
frame = add_solar_context(frame, sun)
frame["integrate_from_previous"] = accepted_edges(frame)
frame["interval_from_previous_s"] = frame.timestamp.diff().dt.total_seconds()
event_table = make_events(events, frame, telemetry)
OUT.mkdir(parents=True, exist_ok=True)
summary_full = metrics(frame)
gaps = event_table[event_table.event_type == "sampling_gap"]
commands = events[events.message.str.contains(r"\[CLOUD\]", regex=True, na=False)]
# Phases use logged cut points, not a performance-selected window.
milestones = []
for message in ("[CLOUD] ErrMax=1100", "[CLOUD] ErrMax=1000",
"[CLOUD] Set Coords X:115000, Y:79000"):
matches = events.loc[events.message.eq(message)]
if len(matches) != 1:
raise ValueError(f"Expected exactly one phase-boundary event: {message}")
milestones.append(matches.iloc[0])
boundaries = [event.timestamp for event in milestones]
end = frame.timestamp.max() + pd.Timedelta(seconds=1)
phase_defs = [("Full acquired record", frame.timestamp.min(), end),
("Initial tuning and repositioning", frame.timestamp.min(), boundaries[0]),
("After 12:32 ErrMax update", boundaries[0], boundaries[1]),
("After 13:53 ErrMax update", boundaries[1], boundaries[2]),
("After 15:12 manual coordinates", boundaries[2], end)]
rows = []
for name, start, end in phase_defs:
m = metrics(frame, start, end)
m["window"] = name
m["window_start"] = str(start)
m["window_end"] = str(min(end, frame.timestamp.max()))
rows.append(m)
# Fixed clock-hour bins retain late-session deterioration and expose support.
hourly = []
for h in range(9, 17):
start = pd.Timestamp(f"2026-11-04 {h:02d}:00:00")
m = metrics(frame, start, start + pd.Timedelta(hours=1))
m["window"] = f"{h:02d}:00--{h+1:02d}:00"
hourly.append(m)
# Common thresholds and gap policies are explicit sensitivity checks.
sensitivity = []
for gap_limit in (10, 15, 30, 60):
for light_limit in (0, 50, 100):
saved = frame["illuminated"].copy()
frame["illuminated"] = frame.irradiance_W_m2 > light_limit
m = metrics(frame, max_gap_s=gap_limit)
sensitivity.append({"max_gap_s": gap_limit, "minimum_irradiance_W_m2": light_limit,
"support_h": m["integrated_support_h"], "energy_Wh": m["estimated_mpp_energy_Wh"],
"aperture_efficiency_pct": m["energy_weighted_aperture_efficiency_pct"]})
frame["illuminated"] = saved
reference = frame.loc[frame.accepted & frame.illuminated, "responsivity_A_m2_W"]
anomaly_idx = frame.responsivity_A_m2_W.idxmax()
anomaly = frame.loc[anomaly_idx]
anomaly_neighbor = frame.loc[(frame.timestamp >= anomaly.timestamp - pd.Timedelta(minutes=2)) &
(frame.timestamp <= anomaly.timestamp + pd.Timedelta(minutes=2))]
anomaly_neighbor_stats = {"count": len(anomaly_neighbor), "irradiance_min": float(anomaly_neighbor.irradiance_W_m2.min()),
"irradiance_median": float(anomaly_neighbor.irradiance_W_m2.median()),
"irradiance_max": float(anomaly_neighbor.irradiance_W_m2.max()),
"responsivity_median": float(anomaly_neighbor.responsivity_A_m2_W.median())}
summary = {
"source": str(SOURCE.relative_to(BASE)), "source_sha256": source_hash,
"suncalc_source_sha256": hashlib.sha256(SUNCALC_SOURCE.read_bytes()).hexdigest(),
"analysis_source_sha256": hashlib.sha256(Path(__file__).read_bytes()).hexdigest(),
"source_bytes": SOURCE.stat().st_size, "source_lines": len(SOURCE.read_text(encoding="utf-8-sig").splitlines()),
"sample_count": len(frame), "first_time": str(frame.timestamp.min()), "last_time": str(frame.timestamp.max()),
"firmware": ", ".join(frame.attrs["firmware_versions"]), "cloud_commands": len(commands),
"phase_boundary_events": [{"timestamp": str(event.timestamp), "message": event.message,
"source_line": int(event.source_line)} for event in milestones],
"tel_records": len(telemetry), "uptime_decreases": int(frame.uptime_s.diff().lt(0).sum()),
"tel_uptime_decreases": int(telemetry.uptime_s.diff().lt(0).sum()),
"tel_gaps_over_15s": int(telemetry.timestamp.diff().dt.total_seconds().gt(MAX_GAP_S).sum()),
"tel_negative_step_deltas": int(telemetry.delta.lt(0).sum()),
"duplicate_timestamps": int(frame.timestamp.duplicated().sum()),
"gap_starts": [str(x) for x in gaps.timestamp],
"gap_records": gaps[["timestamp", "message", "source_line", "previous_source_line", "uptime_decreased"]].to_dict("records"),
"flags": {k: int(frame[k].sum()) for k in ("adc_clipped", "near_current_rail", "invalid_polarity", "illuminated", "accepted")},
"full": summary_full, "phases": rows, "hourly": hourly,
"solar_context": {"first_azimuth_deg": float(frame.azimuth_deg.iloc[0]),
"last_azimuth_deg": float(frame.azimuth_deg.iloc[-1]),
"first_elevation_deg": float(frame.solar_elevation_deg.iloc[0]),
"last_elevation_deg": float(frame.solar_elevation_deg.iloc[-1]),
"maximum_elevation_deg": float(sun.solar_elevation_deg.max()),
"clock_alignment": "Assumed log clock matches printed SunCalc local time; UTC offset unencoded"},
"coordinates": {"x_min": int(frame.x_steps.min()), "x_max": int(frame.x_steps.max()),
"y_min": int(frame.y_steps.min()), "y_max": int(frame.y_steps.max())},
"reference_max": float(reference.max()), "reference_p99": float(reference.quantile(.99)),
"anomaly_time": str(anomaly.timestamp), "anomaly_line": int(anomaly.source_line),
"anomaly_G": float(anomaly.irradiance_W_m2), "anomaly_I": float(anomaly.isc_A),
"anomaly_resp": float(anomaly.responsivity_A_m2_W), "anomaly_neighborhood": anomaly_neighbor_stats,
"responsivity_distribution": {
"median": float(reference.median()), "q25": float(reference.quantile(.25)),
"q75": float(reference.quantile(.75)), "p95": float(reference.quantile(.95)),
},
"calibration": {"adc_positive_full_scale": ADC_MAX, "vref_V": VREF_V, "current_gain": CURRENT_GAIN,
"shunt_ohm": SHUNT_OHM, "pyranometer_mV_per_W_m2": PYRANOMETER_MV_PER_W_M2,
"aperture_m2": APERTURE_M2, "fill_factor": FILL_FACTOR,
"max_gap_s": MAX_GAP_S, "minimum_irradiance_W_m2": MIN_IRRADIANCE_W_M2},
"irradiance_geometry": {"pyranometer_coplanar_with_lens": True,
"provenance": "Experimenter confirmation on 2026-11-04",
"measured_quantity": "Global irradiance in the fixed lens-aperture plane",
"extra_cosine_factor_applied": False},
"silicon_scenario": {"area_m2": APERTURE_M2, "efficiencies": [.19, .21],
"basis": "constant-efficiency equal-area coplanar silicon scenario with measured aperture-plane irradiance; no temperature correction; not measured panel"},
"software": {"python": sys.version.split()[0], "numpy": np.__version__, "pandas": pd.__version__, "matplotlib": matplotlib.__version__},
}
# Text rows show complete record plus fixed clock bins without selecting a best interval.
tab = [summary_full] + hourly
summary["table_rows"] = "\n".join(
f"{r['window'] if 'window' in r else 'Full acquired record'} & {r['accepted_illuminated_samples']} & "
f"{r['integrated_support_h']:.3f} & {r['irradiation_Wh_m2'] / 1000:.3f} & {r['estimated_mpp_energy_Wh']:.4f} & "
f"{r['energy_weighted_aperture_efficiency_pct']:.2f} " + "\\" * 2 for r in tab)
phase_rows = rows[1:]
summary["phase_table_rows"] = "\n".join(
f"{pd.Timestamp(r['window_start']).strftime('%H:%M:%S')}--{pd.Timestamp(r['window_end']).strftime('%H:%M:%S')} & "
f"{r['accepted_illuminated_samples']} & {r['integrated_support_h']:.3f} & "
f"{r['estimated_mpp_energy_Wh']:.3f} & {r['energy_weighted_aperture_efficiency_pct']:.2f} " + "\\" * 2
for r in phase_rows)
# Mark command milestones relevant to phase boundaries without obscuring traces.
summary["command_times"] = [str(t) for t in boundaries]
summary["reference_max"] = float(reference.max()); summary["reference_p99"] = float(reference.quantile(.99))
frame.to_csv(OUT / "audit_samples.csv", index=False, float_format="%.12g")
event_table.to_csv(OUT / "event_chronology.csv", index=False)
sun = suncalc_frame()
sun.to_csv(OUT / "suncalc_2026-11-04.csv", index=False, float_format="%.5f")
pd.DataFrame(rows + hourly).to_csv(OUT / "window_metrics.csv", index=False, float_format="%.10g")
pd.DataFrame(sensitivity).to_csv(OUT / "sensitivity.csv", index=False, float_format="%.10g")
(OUT / "analysis_summary.json").write_text(json.dumps(summary, indent=2, default=str, allow_nan=False) + "\n", encoding="utf-8")
gap_text = "; ".join(
f"{frame.loc[frame.source_line == int(g.previous_source_line), 'timestamp'].iloc[0].strftime('%H:%M:%S')}--"
f"{pd.Timestamp(g.timestamp).strftime('%H:%M:%S')} ({int(float(g.message.split()[2]))} s)"
for g in gaps.itertuples() if g.event_type == "sampling_gap")
write_result_tex(summary, gap_text)
plot_outputs(frame, event_table, sun, summary)
return summary
if __name__ == "__main__":
result = run()
print(json.dumps({"samples": result["sample_count"], "full": result["full"],
"gaps": result["gap_records"], "source_sha256": result["source_sha256"]},
indent=2, default=str))