685 lines
38 KiB
Python
685 lines
38 KiB
Python
"""Reproducible, quality-flagged analysis of the 3 November 2026 field log.
|
|
|
|
Run with Python 3.12. Use --inspect for a read-only diagnostic summary.
|
|
Raw ADC observations are never overwritten or reconstructed from clipped data.
|
|
"""
|
|
|
|
import argparse
|
|
import hashlib
|
|
import json
|
|
import re
|
|
import sys
|
|
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-03.txt"
|
|
OUT = BASE / "figures" / "2026-11-03"
|
|
ADC_MAX = 8388607
|
|
VREF = 2.048
|
|
GAIN = 20.0
|
|
AREA = 0.002497
|
|
FF = 0.82
|
|
CHANGE_BEFORE = pd.Timestamp("2026-11-03 12:38:07")
|
|
CHANGE_AFTER = pd.Timestamp("2026-11-03 12:46:01")
|
|
READY = pd.Timestamp("2026-11-03 10:56:04")
|
|
MAX_GAP_S = 15.0
|
|
MAIN = re.compile(
|
|
r"LOG:\[(?P<timestamp>[^\]]+)\] X:(?P<x_steps>-?\d+),Y:(?P<y_steps>-?\d+),"
|
|
r"C:(?P<c_raw>-?\d+),V:(?P<v_raw>-?\d+),P:(?P<p_raw>-?\d+),"
|
|
r"S:(?P<uptime_s>\d+),er:(?P<er>\d+)"
|
|
)
|
|
TEL = re.compile(
|
|
r"LOG:\[(?P<timestamp>[^\]]+)\] TEL: t=(?P<uptime_s>\d+)s, "
|
|
r"steps=(?P<steps>\d+)\(\+?(?P<delta>\d+)\)"
|
|
)
|
|
EVENT = re.compile(r"LOG:\[(?P<timestamp>[^\]]+)\] (?P<message>.*)")
|
|
|
|
|
|
def sign24(value):
|
|
value = np.asarray(value, dtype=np.int64) & 0xFFFFFF
|
|
return np.where(value & 0x800000, value - 0x1000000, value)
|
|
|
|
|
|
def parse_log(path):
|
|
samples, telemetry, events = [], [], []
|
|
with path.open(encoding="utf-8-sig") as stream:
|
|
for line_number, line in enumerate(stream, 1):
|
|
match = MAIN.search(line)
|
|
if match:
|
|
record = match.groupdict()
|
|
record = {k: pd.Timestamp(v) if k == "timestamp" else int(v)
|
|
for k, v in record.items()}
|
|
record["source_line"] = line_number
|
|
samples.append(record)
|
|
continue
|
|
match = TEL.search(line)
|
|
if match:
|
|
record = match.groupdict()
|
|
record = {k: pd.Timestamp(v) if k == "timestamp" else int(v)
|
|
for k, v in record.items()}
|
|
record["source_line"] = line_number
|
|
telemetry.append(record)
|
|
continue
|
|
match = EVENT.search(line)
|
|
if match:
|
|
message = match["message"]
|
|
if not message.startswith("FineTune:"):
|
|
events.append({"timestamp": pd.Timestamp(match["timestamp"]),
|
|
"source_line": line_number, "message": message})
|
|
frame = pd.DataFrame(samples)
|
|
if frame.empty or not frame.timestamp.is_monotonic_increasing:
|
|
raise ValueError("Missing samples or non-monotonic source timestamps")
|
|
return frame, pd.DataFrame(telemetry), pd.DataFrame(events)
|
|
|
|
|
|
def calibrate(frame):
|
|
frame = frame.copy()
|
|
frame["c_signed"] = sign24(frame.c_raw)
|
|
frame["v_signed"] = sign24(frame.v_raw)
|
|
frame["p_signed"] = sign24(frame.p_raw)
|
|
frame["shunt_ohm"] = np.where(frame.timestamp >= CHANGE_AFTER, 0.130, 0.260)
|
|
frame["isc_observed_A"] = frame.c_signed / ADC_MAX * VREF / GAIN / frame.shunt_ohm
|
|
frame["voc_V"] = frame.v_signed / ADC_MAX * VREF * 2
|
|
frame["irradiance_W_m2"] = frame.p_signed / ADC_MAX * VREF * 1000 / 0.2
|
|
frame["adc_clipped"] = (frame.c_signed.abs() >= ADC_MAX) | (frame.v_signed.abs() >= ADC_MAX) | (frame.p_signed.abs() >= ADC_MAX)
|
|
frame["near_current_rail"] = frame.c_signed.abs() >= 0.99 * ADC_MAX
|
|
frame["setup"] = frame.timestamp < READY
|
|
frame["transition_uncertain"] = (frame.timestamp > CHANGE_BEFORE) & (frame.timestamp < CHANGE_AFTER)
|
|
frame["invalid_polarity"] = (frame.c_signed < 0) | (frame.v_signed < 0) | (frame.p_signed < 0)
|
|
frame["electrical_valid"] = ~(frame.adc_clipped | frame.near_current_rail | frame.setup |
|
|
frame.transition_uncertain | frame.invalid_polarity)
|
|
frame["isc_A"] = frame.isc_observed_A.where(frame.electrical_valid)
|
|
frame["power_FF_W"] = frame.isc_A * frame.voc_V * FF
|
|
frame["illuminated"] = frame.irradiance_W_m2 > 50
|
|
frame["aperture_efficiency_pct"] = (
|
|
100 * frame.power_FF_W / (AREA * frame.irradiance_W_m2)
|
|
).where(frame.electrical_valid & frame.illuminated)
|
|
frame["responsivity_A_m2_W"] = (frame.isc_A / frame.irradiance_W_m2).where(frame.illuminated)
|
|
frame["quality_flags"] = [
|
|
";".join(name for name in ("setup", "transition_uncertain", "adc_clipped",
|
|
"near_current_rail", "invalid_polarity") if row[name]) or "accepted"
|
|
for row in frame.to_dict("records")
|
|
]
|
|
return frame
|
|
|
|
|
|
def interval_mask(frame, selected, max_gap=MAX_GAP_S):
|
|
"""Select edges, not filtered rows: never bridge missing or rejected samples."""
|
|
dt = frame.timestamp.diff().dt.total_seconds().to_numpy()[1:]
|
|
selected = np.asarray(selected, dtype=bool)
|
|
same_shunt = frame.shunt_ohm.to_numpy()[1:] == frame.shunt_ohm.to_numpy()[:-1]
|
|
reboot = frame.uptime_s.to_numpy()[1:] < frame.uptime_s.to_numpy()[:-1]
|
|
return selected[:-1] & selected[1:] & (dt > 0) & (dt <= max_gap) & same_shunt & ~reboot
|
|
|
|
|
|
def integrate(frame, values, edges):
|
|
values = np.asarray(values, dtype=float)
|
|
dt = frame.timestamp.diff().dt.total_seconds().to_numpy()[1:]
|
|
return float(np.sum(((values[:-1] + values[1:]) * 0.5 * dt)[edges]) / 3600)
|
|
|
|
|
|
def window_metrics(frame, selected, reference, telemetry, events, max_gap=MAX_GAP_S):
|
|
selected = np.asarray(selected, dtype=bool) & frame.electrical_valid & frame.illuminated
|
|
observed = frame.loc[selected]
|
|
if len(observed) < 2:
|
|
raise ValueError("Insufficient valid illuminated data")
|
|
edges = interval_mask(frame, selected, max_gap)
|
|
seconds = frame.timestamp.diff().dt.total_seconds().to_numpy()[1:]
|
|
duration = float(seconds[edges].sum())
|
|
power_ref = reference * frame.irradiance_W_m2 * frame.voc_V * FF
|
|
energy = integrate(frame, frame.power_FF_W, edges)
|
|
irradiation = integrate(frame, frame.irradiance_W_m2, edges)
|
|
ref_energy = integrate(frame, power_ref, edges)
|
|
peak_current = observed.isc_A.max()
|
|
on = frame.isc_A >= 0.8 * peak_current
|
|
normalized_on = frame.responsivity_A_m2_W >= 0.8 * reference
|
|
# Occupancy time uses trapezoidal interpolation of endpoint indicators only.
|
|
on_hours = integrate(frame, on.astype(float), edges)
|
|
normalized_on_hours = integrate(frame, normalized_on.astype(float), edges)
|
|
dx = frame.x_steps.diff().abs().to_numpy()[1:]
|
|
dy = frame.y_steps.diff().abs().to_numpy()[1:]
|
|
sx, sy = int(dx[edges].sum()), int(dy[edges].sum())
|
|
motion_wh = (sx * 1.2 / 6000 + sy * 2.5 / 6000) / 3600
|
|
# TEL increments describe fine-tune motion only, and are not a total-travel counter.
|
|
t0, t1 = observed.timestamp.iloc[0], observed.timestamp.iloc[-1]
|
|
tel_selected = (telemetry.timestamp >= t0) & (telemetry.timestamp <= t1)
|
|
td = telemetry.timestamp.diff().dt.total_seconds().to_numpy()[1:]
|
|
tel_pair = tel_selected.to_numpy()[:-1] & tel_selected.to_numpy()[1:]
|
|
tel_pair &= (td > 0) & (td <= max_gap)
|
|
tel_pair &= telemetry.uptime_s.diff().to_numpy()[1:] >= 0
|
|
increments = telemetry.steps.diff().to_numpy()[1:]
|
|
tel_pair &= increments >= 0
|
|
# Require both TEL endpoints to lie inside an accepted coordinate edge.
|
|
ts_ns = frame.timestamp.astype("int64").to_numpy()
|
|
tel_ns = telemetry.timestamp.astype("int64").to_numpy()
|
|
covered = np.zeros(len(telemetry) - 1, dtype=bool)
|
|
starts = np.flatnonzero(edges & ~np.r_[False, edges[:-1]])
|
|
stops = np.flatnonzero(edges & ~np.r_[edges[1:], False]) + 1
|
|
for start, stop in zip(starts, stops):
|
|
covered |= (tel_ns[:-1] >= ts_ns[start]) & (tel_ns[1:] <= ts_ns[stop])
|
|
tel_steps = int(increments[tel_pair & covered].sum())
|
|
f5 = events.loc[(events.timestamp >= t0) & (events.timestamp <= t1) & events.message.str.startswith("F5:")]
|
|
span = (t1 - t0).total_seconds()
|
|
peak_power = observed.loc[observed.power_FF_W.idxmax()]
|
|
return {
|
|
"start": str(t0), "end": str(t1), "accepted_illuminated_samples": len(observed),
|
|
"integrated_intervals": int(edges.sum()), "integrated_duration_h": duration / 3600,
|
|
"elapsed_span_h": span / 3600, "valid_interval_coverage_pct": 100 * duration / span,
|
|
"estimated_energy_Wh": energy, "irradiation_Wh_m2": irradiation,
|
|
"incident_aperture_energy_Wh": AREA * irradiation,
|
|
"energy_weighted_aperture_efficiency_pct": 100 * energy / (AREA * irradiation),
|
|
"reference_energy_Wh": ref_energy, "empirical_tracking_efficiency_pct": 100 * energy / ref_energy,
|
|
"power_MAE_W": float(np.abs(power_ref[selected] - observed.power_FF_W).mean()),
|
|
"peak_isc_A": float(peak_current), "peak_voc_V": float(observed.voc_V.max()),
|
|
"peak_irradiance_W_m2": float(observed.irradiance_W_m2.max()),
|
|
"peak_estimated_power_W": float(observed.power_FF_W.max()),
|
|
"peak_power_timestamp": str(peak_power.timestamp),
|
|
"peak_power_isc_A": float(peak_power.isc_A), "peak_power_voc_V": float(peak_power.voc_V),
|
|
"peak_power_irradiance_W_m2": float(peak_power.irradiance_W_m2),
|
|
"median_aperture_efficiency_pct": float(observed.aperture_efficiency_pct.median()),
|
|
"peak_aperture_efficiency_pct": float(observed.aperture_efficiency_pct.max()),
|
|
"current_threshold_on_target_sample_pct": float(100 * on[selected].mean()),
|
|
"current_threshold_on_target_time_pct": 100 * on_hours / (duration / 3600),
|
|
"responsivity_on_target_sample_pct": float(100 * normalized_on[selected].mean()),
|
|
"responsivity_on_target_time_pct": 100 * normalized_on_hours / (duration / 3600),
|
|
"sampled_x_travel_steps": sx, "sampled_y_travel_steps": sy,
|
|
"estimated_sampled_motion_energy_Wh": motion_wh,
|
|
"fine_tune_TEL_steps_in_accepted_edges": tel_steps,
|
|
"F5_events_in_elapsed_span": len(f5),
|
|
}
|
|
|
|
|
|
def calculate(frame, telemetry, events):
|
|
valid = frame.electrical_valid & frame.illuminated
|
|
reference = float(frame.loc[valid, "responsivity_A_m2_W"].max())
|
|
reference_p99 = float(frame.loc[valid, "responsivity_A_m2_W"].quantile(0.99))
|
|
frame = frame.copy()
|
|
frame["reference_power_W"] = (reference * frame.irradiance_W_m2 * frame.voc_V * FF).where(valid)
|
|
frame["normalized_response"] = frame.responsivity_A_m2_W / reference
|
|
frame["integrate_from_previous"] = np.r_[False, interval_mask(frame, valid)]
|
|
frame["integration_duration_s"] = frame.timestamp.diff().dt.total_seconds().where(frame.integrate_from_previous)
|
|
windows = {
|
|
"all_valid": pd.Series(True, index=frame.index),
|
|
"pre_change_unclipped": frame.timestamp <= CHANGE_BEFORE,
|
|
"post_change": frame.timestamp >= CHANGE_AFTER,
|
|
"fixed_hour_13_14": (frame.timestamp >= pd.Timestamp("2026-11-03 13:00")) &
|
|
(frame.timestamp < pd.Timestamp("2026-11-03 14:00")),
|
|
}
|
|
metrics = {name: window_metrics(frame, mask, reference, telemetry, events)
|
|
for name, mask in windows.items()}
|
|
for name, mask in windows.items():
|
|
sensitivity = window_metrics(frame, mask, reference_p99, telemetry, events)
|
|
metrics[name]["tracking_efficiency_p99_reference_pct"] = sensitivity["empirical_tracking_efficiency_pct"]
|
|
sensitivity = []
|
|
for limit in (10, 15, 30, 60):
|
|
result = window_metrics(frame, windows["post_change"], reference, telemetry, events, limit)
|
|
sensitivity.append({"max_gap_s": limit, "duration_h": result["integrated_duration_h"],
|
|
"energy_Wh": result["estimated_energy_Wh"],
|
|
"aperture_efficiency_pct": result["energy_weighted_aperture_efficiency_pct"],
|
|
"tracking_efficiency_pct": result["empirical_tracking_efficiency_pct"]})
|
|
return frame, metrics, {"maximum_A_m2_W": reference, "p99_A_m2_W": reference_p99}, sensitivity
|
|
|
|
|
|
def save_figure(fig, name):
|
|
for extension in ("pdf", "png"):
|
|
fig.savefig(OUT / f"{name}.{extension}", dpi=220, bbox_inches="tight")
|
|
plt.close(fig)
|
|
|
|
|
|
def time_axes(ax):
|
|
ax.xaxis.set_major_formatter(mdates.DateFormatter("%H:%M"))
|
|
ax.xaxis.set_major_locator(mdates.MinuteLocator(byminute=[0, 30]))
|
|
ax.set_xlabel("Local log time")
|
|
ax.grid(alpha=0.2)
|
|
|
|
|
|
def figures(frame, metrics, reference):
|
|
plt.rcParams.update({"font.family": "DejaVu Sans", "font.size": 9,
|
|
"axes.spines.top": False, "axes.spines.right": False,
|
|
"pdf.fonttype": 42, "ps.fonttype": 42})
|
|
t = frame.timestamp
|
|
gap_breaks = frame.timestamp.diff().dt.total_seconds() > MAX_GAP_S
|
|
fig, axes = plt.subplots(3, 1, figsize=(9, 8), sharex=True, layout="constrained")
|
|
axes[0].plot(t, (frame.c_signed / 1e6).mask(gap_breaks), color="#425466", lw=0.65)
|
|
axes[0].axhline(ADC_MAX / 1e6, color="#b84335", ls="--", label="ADC positive rail")
|
|
axes[0].scatter(t[frame.adc_clipped], frame.loc[frame.adc_clipped, "c_signed"] / 1e6,
|
|
s=5, color="#b84335", label="Clipped observations", zorder=3)
|
|
axes[0].set_ylabel("Current channel\n[$10^6$ ADC counts]")
|
|
axes[0].legend(loc="lower right", fontsize=8)
|
|
axes[1].plot(t, (1000 * frame.isc_A).mask(gap_breaks), color="#167d8d", lw=0.7, label="Accepted current")
|
|
axes[1].plot(t[frame.adc_clipped], 1000 * frame.loc[frame.adc_clipped, "isc_observed_A"],
|
|
".", color="#b84335", ms=2, label="Clipped lower bounds (not corrected)")
|
|
axes[1].set_ylabel("$I_{sc}$ [mA]")
|
|
axes[1].legend(loc="lower left", fontsize=8)
|
|
axes[2].plot(t, frame.irradiance_W_m2.mask(gap_breaks), color="#c17a20", lw=0.7)
|
|
axes[2].set_ylabel("Irradiance\n[W m$^{-2}$]")
|
|
for ax in axes:
|
|
ax.axvspan(CHANGE_BEFORE, CHANGE_AFTER, color="#c17a20", alpha=0.18)
|
|
time_axes(ax)
|
|
axes[0].set_title("3 November 2026: acquisition quality and piecewise shunt calibration", loc="left")
|
|
axes[1].text(0.02, 0.94, "260 m$\\Omega$ before gap; 130 m$\\Omega$ after gap",
|
|
transform=axes[1].transAxes, va="top", fontsize=8)
|
|
save_figure(fig, "quality_and_calibration")
|
|
|
|
post = frame.timestamp >= CHANGE_AFTER
|
|
light = frame.electrical_valid & frame.illuminated & post
|
|
# Insert NaNs at rejected edges so lines do not visually bridge acquisition gaps.
|
|
displayed = frame.copy()
|
|
breaks = np.r_[False, ~interval_mask(frame, frame.electrical_valid)]
|
|
cols = ["power_FF_W", "reference_power_W", "normalized_response", "aperture_efficiency_pct"]
|
|
displayed.loc[breaks, cols] = np.nan
|
|
fig, axes = plt.subplots(3, 1, figsize=(9, 8), sharex=True, layout="constrained")
|
|
axes[0].plot(t[post], displayed.loc[post, "reference_power_W"], color="#8995a2", lw=0.8,
|
|
label="Empirical same-record reference")
|
|
axes[0].plot(t[post], displayed.loc[post, "power_FF_W"], color="#167d8d", lw=0.8,
|
|
label="Estimated MPP power ($FF=0.82$)")
|
|
axes[0].set_ylabel("Estimated power [W]")
|
|
axes[0].legend(loc="lower left", fontsize=8)
|
|
axes[1].plot(t[post], displayed.loc[post, "normalized_response"], color="#5367a0", lw=0.7)
|
|
axes[1].axhline(0.8, ls="--", color="#b84335", lw=0.8, label="80% normalized-response threshold")
|
|
axes[1].set_ylabel("$(I_{sc}/G)/R_{ref}$")
|
|
axes[1].set_ylim(0, 1.08)
|
|
axes[1].legend(loc="lower left", fontsize=8)
|
|
axes[2].plot(t[post], displayed.loc[post, "aperture_efficiency_pct"], color="#19734f", lw=0.7)
|
|
axes[2].set_ylabel("Aperture efficiency [%]")
|
|
for ax in axes:
|
|
time_axes(ax)
|
|
axes[0].set_title("Post-change performance, including the late low-output period", loc="left")
|
|
save_figure(fig, "post_change_performance")
|
|
|
|
fig, axes = plt.subplots(1, 2, figsize=(10, 4), layout="constrained")
|
|
sc = axes[0].scatter(frame.loc[light, "irradiance_W_m2"], frame.loc[light, "isc_A"] * 1000,
|
|
c=mdates.date2num(t[light]), cmap="viridis", s=8, alpha=0.7, rasterized=True)
|
|
g = np.linspace(50, frame.loc[light, "irradiance_W_m2"].max(), 200)
|
|
axes[0].plot(g, 1000 * g * reference["maximum_A_m2_W"], "--", color="#b84335", lw=1,
|
|
label="Same-record maximum responsivity")
|
|
axes[0].set(xlabel="Irradiance [W m$^{-2}$]", ylabel="$I_{sc}$ [mA]", title="Irradiance-normalized response")
|
|
axes[0].legend(fontsize=7)
|
|
bar = fig.colorbar(sc, ax=axes[0], label="Log time")
|
|
bar.ax.yaxis.set_major_formatter(mdates.DateFormatter("%H:%M"))
|
|
binned = frame.loc[post].copy()
|
|
binned["on"] = (binned.normalized_response >= 0.8).where(binned.electrical_valid & binned.illuminated)
|
|
grouped = binned.set_index("timestamp").on.resample("15min").agg(["mean", "count"])
|
|
axes[1].bar(grouped.index + pd.Timedelta(minutes=7.5), grouped["mean"] * 100,
|
|
width=12 / 1440, color="#167d8d")
|
|
axes[1].plot(grouped.index + pd.Timedelta(minutes=7.5), grouped["mean"] * 100,
|
|
"o", color="#167d8d", ms=3)
|
|
axes[1].set(ylabel="Normalized on-target samples [%]", ylim=(0, 105), title="15-minute bins; illuminated samples")
|
|
time_axes(axes[1])
|
|
axes[0].grid(alpha=0.2)
|
|
save_figure(fig, "response_and_occupancy")
|
|
|
|
|
|
def write_outputs(frame, telemetry, events, metrics, reference, sensitivity):
|
|
OUT.mkdir(parents=True, exist_ok=True)
|
|
curated = SOURCE.with_name("log_2026-11-03_curated.csv")
|
|
frame.to_csv(curated, index=False, float_format="%.12g")
|
|
source_lines = SOURCE.read_text(encoding="utf-8-sig").splitlines()
|
|
headers = [
|
|
"# Curated observations from log_2026-11-03.txt; original raw ADC codes retained.",
|
|
"# Calibration: Vref=2.048 V; AD8418 gain=20; Voc divider=2; pyranometer=0.2 mV/(W/m2).",
|
|
"# Shunt=0.260 ohm through 12:38:07; shunt=0.130 ohm from 12:46:01; change occurred in missing interval.",
|
|
"# Setup before 10:56:04, clipping, >=99% current rail and invalid polarity excluded.",
|
|
"# No clipped current reconstruction, no interpolation over gaps; see curated CSV for rejected rows and source lines.",
|
|
"# Use the full CSV integrate_from_previous flag; accepted-only rows cannot identify every rejected interval.",
|
|
"# Do not apply a single-shunt legacy converter to this file. Low-output observations are retained.",
|
|
]
|
|
accepted_lines = [source_lines[int(i) - 1] for i in frame.loc[frame.electrical_valid, "source_line"]]
|
|
SOURCE.with_name("log_2026-11-03_curated.txt").write_text(
|
|
"\n".join(headers + accepted_lines) + "\n", encoding="utf-8")
|
|
events.to_csv(OUT / "source_events.csv", index=False)
|
|
pd.DataFrame(metrics).T.to_csv(OUT / "metrics.csv", float_format="%.10g")
|
|
pd.DataFrame(sensitivity).to_csv(OUT / "gap_sensitivity.csv", index=False)
|
|
source_info = {
|
|
"source": str(SOURCE.relative_to(BASE)), "sha256": hashlib.sha256(SOURCE.read_bytes()).hexdigest(),
|
|
"analysis_sha256": hashlib.sha256(Path(__file__).read_bytes()).hexdigest(),
|
|
"source_line_count": len(source_lines), "main_observations": len(frame),
|
|
"accepted_electrical_observations": int(frame.electrical_valid.sum()),
|
|
"flags_nonexclusive": {name: int(frame[name].sum()) for name in
|
|
("setup", "adc_clipped", "near_current_rail", "invalid_polarity", "transition_uncertain")},
|
|
"current_clip_first": str(frame.loc[frame.adc_clipped, "timestamp"].min()),
|
|
"current_clip_last": str(frame.loc[frame.adc_clipped, "timestamp"].max()),
|
|
"shunt_change_bracket": [str(CHANGE_BEFORE), str(CHANGE_AFTER)],
|
|
"change_bracket_is_inferred": True,
|
|
"setup_exclusion_before": str(READY),
|
|
"firmware_diagnostic_caveat": "Logged irradiance diagnostics use about one tenth of the corrected offline scale; controller decisions were not recalculated.",
|
|
"software": {"python": sys.version.split()[0], "numpy": np.__version__,
|
|
"pandas": pd.__version__, "matplotlib": matplotlib.__version__},
|
|
"calibration": {"vref_V": VREF, "adc_positive_full_scale": ADC_MAX, "current_amplifier_gain": GAIN,
|
|
"voc_divider": 2, "pyranometer_mV_per_W_m2": 0.2,
|
|
"shunt_before_ohm": 0.260, "shunt_after_ohm": 0.130,
|
|
"aperture_area_m2": AREA, "assumed_fill_factor": FF,
|
|
"current_rail_rejection_fraction": 0.99, "max_gap_s": MAX_GAP_S},
|
|
"empirical_reference": reference, "metrics": metrics, "gap_sensitivity": sensitivity,
|
|
}
|
|
(OUT / "analysis.json").write_text(json.dumps(source_info, indent=2, allow_nan=False) + "\n", encoding="utf-8")
|
|
return source_info
|
|
|
|
|
|
def write_report(frame, metrics, info):
|
|
post = metrics["post_change"]
|
|
noon = metrics["fixed_hour_13_14"]
|
|
pre = metrics["pre_change_unclipped"]
|
|
labels = {"all_valid": "All accepted segments", "pre_change_unclipped": "Before change (unclipped only)",
|
|
"post_change": "After change (all illuminated)", "fixed_hour_13_14": "Fixed hour, 13:00--14:00"}
|
|
rows = []
|
|
for key, m in metrics.items():
|
|
rows.append(
|
|
f"{labels[key]} & {m['accepted_illuminated_samples']} & {m['integrated_duration_h']:.3f} & "
|
|
f"{m['estimated_energy_Wh']:.3f} & {m['energy_weighted_aperture_efficiency_pct']:.2f} & "
|
|
f"{m['empirical_tracking_efficiency_pct']:.2f} \\\\"
|
|
)
|
|
table_rows = "\n".join(rows)
|
|
first = frame.loc[frame.adc_clipped, "timestamp"].min().strftime("%H:%M:%S")
|
|
last = frame.loc[frame.adc_clipped, "timestamp"].max().strftime("%H:%M:%S")
|
|
reference = info["empirical_reference"]
|
|
text = rf"""% Generated by analyze_2026_11_03.py. Do not hand-edit numerical results.
|
|
% Intended compilation directory: articulo. This file does not modify PRINCIPAL.tex.
|
|
\subsection{{Quality-Controlled Outdoor Evaluation on 3 November 2026}}
|
|
\label{{subsec:nov03_curated}}
|
|
|
|
\paragraph{{Acquisition quality and shunt intervention}}
|
|
The firmware 1.1.3-ws record contains {len(frame)} coordinate/electrical observations
|
|
between 10:44:20 and 15:40:42 on 3 November 2026. Times are the local log clock;
|
|
the file does not encode a time zone. Initial commissioning includes manual
|
|
coordinate changes and parameter commands. Observations before 10:56:04, the
|
|
logged initial sampling-delay configuration, are conservatively marked as setup.
|
|
Subsequent controller parameter changes through 11:41:03 are retained and disclosed;
|
|
the pre-intervention segment is not a fixed-parameter benchmark.
|
|
|
|
The experimenter reported adding a second resistor in parallel with the
|
|
260~m$\Omega$ current shunt, reducing its nominal resistance to 130~m$\Omega$.
|
|
The exact soldering time is not recorded. The change is assigned to the
|
|
unobserved interval between 12:38:07 and 12:46:01 (474~s), supported by the
|
|
reset of the uptime/counter and the approximately halved current-channel codes
|
|
after the gap, while irradiance remains comparable. No sample is assigned an
|
|
invented intervention timestamp inside this interval. The first returned
|
|
observations, including reacquisition at low current, are retained rather than
|
|
discarded to improve performance.
|
|
|
|
The current channel reaches the positive ADC rail of 8,388,607 counts in
|
|
{int(frame.adc_clipped.sum())} observations, from {first} to {last}.
|
|
Clipped values provide only lower bounds on current; they cannot be restored by
|
|
multiplying the trace or changing the shunt calibration. A conservative
|
|
99\%-of-rail guard additionally excludes near-rail current observations
|
|
({int((frame.near_current_rail & ~frame.adc_clipped).sum())} beyond the clipped set).
|
|
The audit CSV retains all observations, original ADC codes, source line numbers,
|
|
the applicable nominal shunt value and nonexclusive quality flags; rejected
|
|
current/power estimates are blank. An explicit \texttt{{integrate\_from\_previous}}
|
|
column marks usable full-record integration edges; additional window filtering
|
|
must still require both endpoints inside the chosen window.
|
|
The companion curated text log contains
|
|
accepted raw observations only, with calibration metadata, and is not suitable
|
|
for a legacy single-shunt converter. Low-output, cloud/standby and later
|
|
drift-correction periods are not removed solely for poor performance.
|
|
|
|
\paragraph{{Calibration and modeled electrical output}}
|
|
Using signed 24-bit codes $C$, $V$ and $P$, positive full scale $D=8,388,607$,
|
|
and the 2.048-V reference, the offline conversions are
|
|
\begin{{align}}
|
|
I_{{sc}} &= \frac{{2.048 C}}{{20 D R_s}}, &
|
|
V_{{oc}} &= \frac{{2(2.048)V}}{{D}}, &
|
|
G &= \frac{{2048 P}}{{0.2 D}}.
|
|
\end{{align}}
|
|
Here $I_{{sc}}$ is in A, $V_{{oc}}$ in V, $G$ in W\,m$^{{-2}}$, and $R_s$
|
|
is 0.260~$\Omega$ before the intervention gap and 0.130~$\Omega$ after it.
|
|
The factor 20 is the external current-amplifier gain; the factor 2 accounts
|
|
for the open-circuit voltage divider. The pyranometer scale is
|
|
0.2~mV/(W\,m$^{{-2}}$), not the obsolete divisor 2.0.
|
|
The corresponding nominal current full scales are 393.85 and 787.69~mA.
|
|
This is an offline calibration correction, not a modification of the firmware
|
|
or of the control decisions actually made during the experiment.
|
|
The log's firmware irradiance diagnostics remain on a different scale: at
|
|
10:51:15 the recorded $P=689473$ converts to approximately
|
|
842~W\,m$^{{-2}}$, whereas the accompanying auto-start message reports
|
|
84~W\,m$^{{-2}}$. Thus, logged firmware irradiance thresholds and standby messages
|
|
must not be interpreted directly on the corrected offline axis. Correcting
|
|
the exported irradiance does not retroactively correct those control decisions.
|
|
A nominal firmware threshold of 10~W\,m$^{{-2}}$ therefore corresponds to
|
|
approximately 100~W\,m$^{{-2}}$ on the corrected axis. The primary evaluation
|
|
includes returned low-light observations even when the controller is in standby.
|
|
|
|
Electrical output is estimated as
|
|
\begin{{equation}}
|
|
\widehat{{P}}_{{\mathrm{{MPP}}}}=V_{{oc}} I_{{sc}} FF,\qquad FF=0.82.
|
|
\end{{equation}}
|
|
The fixed fill factor follows the manuscript model, not a new outdoor I--V
|
|
measurement. Since the device alternates open- and short-circuit sensing,
|
|
this quantity estimates available MPP power under approximately unchanged
|
|
conditions across the sampling cycle; it is not measured power delivered to
|
|
a load or harvested energy. Fast irradiance changes can violate that
|
|
approximation. No cell-temperature, spectral-mismatch or fill-factor correction
|
|
is inferred from this log.
|
|
|
|
With lens aperture $A_L=0.002497$~m$^2$, the aperture-referenced estimate is
|
|
\begin{{equation}}
|
|
\widehat{{\eta}}_{{\mathrm{{ap}}}}(t)=
|
|
\frac{{\widehat{{P}}_{{\mathrm{{MPP}}}}(t)}}{{A_L G(t)}}\,100\%,\qquad
|
|
\widehat{{\eta}}_{{\mathrm{{ap}},E}}=
|
|
\frac{{\int\widehat{{P}}_{{\mathrm{{MPP}}}}\,dt}}{{A_L\int G\,dt}}\,100\%.
|
|
\end{{equation}}
|
|
This describes the combined lens/receiver system relative to the measured
|
|
pyranometric irradiance. It is not intrinsic cell conversion efficiency:
|
|
separating the cell requires the optical power incident on its active area
|
|
(or validated optical throughput), which was not recorded. The pyranometer's
|
|
global irradiance is not a direct-normal irradiance measurement and includes
|
|
diffuse light that the concentrator may not collect. Reference-sensor alignment,
|
|
incidence geometry and optical losses therefore remain part of this estimate.
|
|
|
|
\paragraph{{Accepted integration support and tracking indicators}}
|
|
All energy, irradiation and reference-power integrals use the same
|
|
adjacent accepted illuminated observations ($G>50$~W\,m$^{{-2}}$). An interval is
|
|
included only if both endpoints are accepted, its duration is at most 15~s,
|
|
the shunt does not change and uptime does not decrease. Trapezoidal integration
|
|
never bridges rejected rows, reboots or long missing intervals. The tabulated
|
|
duration is the sum of included intervals, not the elapsed window length.
|
|
Unobserved or clipped periods are neither zero-filled nor reconstructed;
|
|
the all-segments result is not a full-session energy estimate.
|
|
In particular, selecting only unclipped pre-change points preferentially
|
|
removes high-current periods and biases that subset; it cannot establish a
|
|
causal performance improvement from the resistor intervention.
|
|
|
|
For comparison with the manuscript's responsivity approach, an empirical
|
|
same-record reference is defined by
|
|
\begin{{equation}}
|
|
R_{{\mathrm{{ref}}}}=\max_{{\mathrm{{accepted}},\,G>50}}(I_{{sc}}/G)
|
|
={reference['maximum_A_m2_W']:.7f}~\mathrm{{A\,m^2\,W^{{-1}}}},\quad
|
|
\widehat{{P}}_{{\mathrm{{ref}}}}=R_{{\mathrm{{ref}}}}G V_{{oc}} FF.
|
|
\end{{equation}}
|
|
The empirical tracking indicator is
|
|
$\widehat{{\eta}}_{{\mathrm{{tr}}}}=100\int\widehat{{P}}_{{\mathrm{{MPP}}}}dt/
|
|
\int\widehat{{P}}_{{\mathrm{{ref}}}}dt$ on the same accepted support.
|
|
It is an internal relative-yield benchmark, not independently validated ideal
|
|
alignment efficiency or angular pointing accuracy. Irradiance geometry,
|
|
temperature, spectrum and electrical offsets can also change $I_{{sc}}/G$.
|
|
The reference reuses the measured $V_{{oc}}$, so it does not independently
|
|
quantify voltage losses relative to a perfectly aligned receiver.
|
|
No astronomical-angle accuracy or unambiguous cloud-recovery time is claimed.
|
|
Replacing the maximum by the 99th-percentile responsivity
|
|
({reference['p99_A_m2_W']:.7f}~A\,m$^2$\,W$^{{-1}}$) provides a reference sensitivity
|
|
check; the resulting ratio is not clipped to 100\% and is not a strict ceiling.
|
|
|
|
Two sample-occupancy measures are kept separate:
|
|
(i) $I_{{sc}}\geq0.8 I_{{sc,\max}}$ within each specified window, and
|
|
(ii) $(I_{{sc}}/G)/R_{{\mathrm{{ref}}}}\geq0.8$ using the common reference.
|
|
Both use accepted illuminated samples as their denominator, unlike the October
|
|
criterion whose denominator includes every observation in its selected window.
|
|
Neither is interchangeable with energy-based tracking efficiency.
|
|
|
|
\begin{{table}}[htbp]
|
|
\centering
|
|
\small
|
|
\caption{{Results on accepted illuminated integration support. Energy and aperture
|
|
efficiency are FF-based estimates; tracking efficiency uses a same-record
|
|
empirical reference. Missing and clipped intervals are not integrated.}}
|
|
\label{{tab:nov03_metrics}}
|
|
\begin{{tabular}}{{@{{}}lrrrrr@{{}}}}
|
|
\toprule
|
|
Window & Samples & Hours & $\widehat{{E}}$ [Wh] & $\widehat{{\eta}}_{{\mathrm{{ap}},E}}$ [\%] & $\widehat{{\eta}}_{{\mathrm{{tr}}}}$ [\%] \\
|
|
\midrule
|
|
{table_rows}
|
|
\bottomrule
|
|
\end{{tabular}}
|
|
\end{{table}}
|
|
|
|
\paragraph{{Post-intervention results and limitations}}
|
|
The primary post-change evaluation retains the entire returned record,
|
|
{post['start'].split()[1]}--{post['end'].split()[1]}, including the late low-output period.
|
|
It contains {post['accepted_illuminated_samples']} accepted illuminated samples and
|
|
{post['integrated_duration_h']:.3f}~h of integrated support
|
|
({post['valid_interval_coverage_pct']:.2f}\% of its elapsed span).
|
|
The estimated electrical energy is {post['estimated_energy_Wh']:.3f}~Wh for
|
|
{post['irradiation_Wh_m2'] / 1000:.3f}~kWh\,m$^{{-2}}$ of irradiation on that support,
|
|
giving {post['energy_weighted_aperture_efficiency_pct']:.2f}\% energy-weighted
|
|
aperture efficiency. Peak current is {post['peak_isc_A'] * 1000:.1f}~mA, and
|
|
peak modeled power is {post['peak_estimated_power_W']:.3f}~W; maxima of different
|
|
variables need not be simultaneous. At the modeled power peak
|
|
({post['peak_power_timestamp'].split()[1]}), the simultaneous log record gives
|
|
$I_{{sc}}={post['peak_power_isc_A'] * 1000:.1f}$~mA,
|
|
$V_{{oc}}={post['peak_power_voc_V']:.3f}$~V and
|
|
$G={post['peak_power_irradiance_W_m2']:.1f}$~W\,m$^{{-2}}$.
|
|
The median instantaneous aperture estimate
|
|
is {post['median_aperture_efficiency_pct']:.2f}\%, and its peak is
|
|
{post['peak_aperture_efficiency_pct']:.2f}\%, neither being a daily efficiency.
|
|
The empirical tracking ratio is {post['empirical_tracking_efficiency_pct']:.2f}\%,
|
|
with a sample-mean absolute power deviation of {post['power_MAE_W'] * 1000:.1f}~mW
|
|
from the internal reference. The 99th-percentile reference changes this ratio
|
|
to {post['tracking_efficiency_p99_reference_pct']:.2f}\%.
|
|
Current-threshold and irradiance-normalized on-target sample fractions are
|
|
{post['current_threshold_on_target_sample_pct']:.2f}\% and
|
|
{post['responsivity_on_target_sample_pct']:.2f}\%, respectively; the corresponding
|
|
endpoint-interpolated time fractions are
|
|
{post['current_threshold_on_target_time_pct']:.2f}\% and
|
|
{post['responsivity_on_target_time_pct']:.2f}\% on accepted intervals.
|
|
|
|
For a conventional fixed one-hour comparison rather than an automatically
|
|
selected best interval, 13:00--14:00 gives
|
|
{noon['energy_weighted_aperture_efficiency_pct']:.2f}\% aperture efficiency and
|
|
{noon['empirical_tracking_efficiency_pct']:.2f}\% empirical tracking efficiency,
|
|
with {noon['estimated_energy_Wh']:.3f}~Wh over {noon['integrated_duration_h']:.3f}~h
|
|
of integrated support. This favourable subset must not replace the full
|
|
post-change result. The pre-change unclipped subset has only
|
|
{pre['valid_interval_coverage_pct']:.2f}\% valid interval coverage of its elapsed
|
|
span and is reported for audit, not as an unbiased control experiment.
|
|
|
|
Sampled coordinate differences on post-change accepted illuminated intervals
|
|
total {post['sampled_x_travel_steps']:,} $X$ steps and
|
|
{post['sampled_y_travel_steps']:,} $Y$ steps. With the manuscript's bench costs
|
|
(1.2~W and 2.5~W at approximately 6000 commanded steps/s), the corresponding
|
|
sampled-motion estimate is {post['estimated_sampled_motion_energy_Wh']:.4f}~Wh.
|
|
The independently differenced TEL counter contributes
|
|
{post['fine_tune_TEL_steps_in_accepted_edges']:,} fine-tune steps on intervals
|
|
fully covered by accepted coordinate runs. TEL does not count all motion.
|
|
Sparse coordinate differences can miss intermediate reversals; neither count
|
|
measures total motor/board consumption, and a total parasitic ratio or net
|
|
electrical yield cannot be inferred. There are
|
|
{post['F5_events_in_elapsed_span']} logged F5 corrections in the elapsed
|
|
post-change span, including low-light operation outside some integration support.
|
|
|
|
All estimated powers, energies and aperture efficiencies scale linearly with
|
|
the assumed $FF$: multiply by $FF'/0.82$ for another assumption. A common
|
|
constant $FF$ cancels from the tracking ratio. No statistical or calibration
|
|
confidence interval is assigned without resistance tolerances, reference-sensor
|
|
uncertainty and outdoor I--V data. Results at 10-, 15-, 30- and 60-s maximum
|
|
integration gaps are exported as a gap-policy sensitivity check.
|
|
|
|
\begin{{figure}}[p]
|
|
\centering
|
|
\includegraphics[width=0.95\linewidth,height=0.78\textheight,keepaspectratio]{{../figures/2026-11-03/quality_and_calibration.pdf}}
|
|
\caption{{Quality control and piecewise current calibration on 3 November 2026.
|
|
Red marks identify ADC-clipped observations, not recovered measurements.
|
|
The shaded interval brackets the unobserved 260-to-130-m$\Omega$ shunt intervention.
|
|
Accepted low-output observations remain in the record.}}
|
|
\label{{fig:nov03_quality}}
|
|
\end{{figure}}
|
|
|
|
\begin{{figure}}[p]
|
|
\centering
|
|
\includegraphics[width=0.95\linewidth,height=0.78\textheight,keepaspectratio]{{../figures/2026-11-03/post_change_performance.pdf}}
|
|
\caption{{Post-intervention modeled MPP power, same-record normalized current
|
|
response and lens-aperture-referenced efficiency. $FF=0.82$ and
|
|
$A_L=0.002497$~m$^2$ are assumed. The late low-output period is retained;
|
|
these are not direct load-power or intrinsic-cell-efficiency measurements.}}
|
|
\label{{fig:nov03_performance}}
|
|
\end{{figure}}
|
|
|
|
\begin{{figure}}[p]
|
|
\centering
|
|
\includegraphics[width=\linewidth]{{../figures/2026-11-03/response_and_occupancy.pdf}}
|
|
\caption{{Post-change current versus irradiance (colour encodes log time),
|
|
and occupancy of the 80\%-of-reference responsivity criterion in 15-min bins.
|
|
Only accepted observations with $G>50$~W\,m$^{{-2}}$ enter the sample denominator;
|
|
the reference is the maximum accepted responsivity of the same record.}}
|
|
\label{{fig:nov03_response}}
|
|
\end{{figure}}
|
|
"""
|
|
report_dir = BASE / "articulo"
|
|
(report_dir / "results_2026_11_03.tex").write_text(text, encoding="utf-8")
|
|
wrapper = r"""\documentclass[11pt]{article}
|
|
\usepackage[margin=22mm]{geometry}
|
|
\usepackage{graphicx,amsmath,amssymb,booktabs}
|
|
\usepackage[T1]{fontenc}
|
|
\usepackage{lmodern}
|
|
\title{Quality-Controlled Field Analysis: 3 November 2026}
|
|
\author{WiT-CPV experimental analysis}
|
|
\date{}
|
|
\begin{document}
|
|
\maketitle
|
|
\noindent Reproducible analysis of \texttt{log\_2026-11-03.txt}.
|
|
Generated with \texttt{analyze\_2026\_11\_03.py}. The following subsection is
|
|
provided as a standalone insertion for the manuscript; the principal article
|
|
has not been modified.
|
|
\section{Field Evaluation}
|
|
\input{results_2026_11_03.tex}
|
|
\clearpage
|
|
\end{document}
|
|
"""
|
|
(report_dir / "analysis_2026_11_03.tex").write_text(wrapper, encoding="utf-8")
|
|
|
|
|
|
def inspect(frame, telemetry, events):
|
|
print("Samples:", len(frame), "TEL:", len(telemetry))
|
|
print("Span:", frame.timestamp.min(), frame.timestamp.max())
|
|
for name in ("adc_clipped", "near_current_rail", "setup", "invalid_polarity"):
|
|
print(name, int(frame[name].sum()))
|
|
print("First rail:", frame.loc[frame.adc_clipped, "timestamp"].min())
|
|
columns = ["c_signed", "isc_observed_A", "voc_V", "irradiance_W_m2", "aperture_efficiency_pct", "responsivity_A_m2_W"]
|
|
print(frame.set_index("timestamp")[columns].resample("15min").median().to_string())
|
|
gaps = frame.timestamp.diff().dt.total_seconds()
|
|
print("GAPS >15 s:")
|
|
print(frame.loc[gaps > 15, ["timestamp", "uptime_s", "source_line"]].assign(gap_s=gaps[gaps > 15]).to_string(index=False))
|
|
print("EVENTS:")
|
|
print(events.loc[events.message.str.contains("CLOUD|Standby|F5|Auto-arranque")].to_string(index=False))
|
|
resets = telemetry.uptime_s.diff() < 0
|
|
print("TEL resets:", telemetry.loc[resets].to_string(index=False))
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--inspect", action="store_true")
|
|
args = parser.parse_args()
|
|
frame, telemetry, events = parse_log(SOURCE)
|
|
frame = calibrate(frame)
|
|
if args.inspect:
|
|
inspect(frame, telemetry, events)
|
|
return
|
|
frame, metrics, reference, sensitivity = calculate(frame, telemetry, events)
|
|
info = write_outputs(frame, telemetry, events, metrics, reference, sensitivity)
|
|
figures(frame, metrics, reference)
|
|
write_report(frame, metrics, info)
|
|
columns = ["accepted_illuminated_samples", "integrated_duration_h", "estimated_energy_Wh",
|
|
"energy_weighted_aperture_efficiency_pct", "empirical_tracking_efficiency_pct"]
|
|
print(pd.DataFrame(metrics).T[columns].to_string())
|
|
print("Outputs:", OUT)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|