"""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[^\]]+)\] X:(?P-?\d+),Y:(?P-?\d+)," r"C:(?P-?\d+),V:(?P-?\d+),P:(?P-?\d+)," r"S:(?P\d+),er:(?P\d+)" ) TEL = re.compile( r"LOG:\[(?P[^\]]+)\] TEL: t=(?P\d+)s, " r"steps=(?P\d+)\(\+?(?P\d+)\)" ) EVENT = re.compile(r"LOG:\[(?P[^\]]+)\] (?P.*)") 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()