Files
Microtracking/analyze_2026_11_03.py

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()