# -*- coding: utf-8 -*- """Figuras para log_2026-10-30.txt (MicrotrackingFer v1.1.1-ota). Dos versiones por figura: full : todo el dia 10:00-18:21 (incluye horas basura >= 14:00, deriva F5) pre14 : filtrado, solo 10:00-14:00 (rastreo sano, post-correccion F5) Calibracion (PCB V2, ver docker_tracker_hub/app/config.py): ADC122C04 24-bit, Vref 2.048 V. C = canal1 fotocorriente (shunt 0.260 ohm = 260 mOhm, gain 20) -> mA P = canal2 piranometro (24-bit signed, escala 0.2 mV por W/m2) -> W/m2 V = canal3 Voc Salida: figures/2026-10-30/*.png + *.pdf Uso: py -3.12 figures_2026_10_30.py """ import io import os import re from datetime import datetime, timedelta as _timedelta import numpy as np import matplotlib matplotlib.use("Agg") import matplotlib.pyplot as plt import matplotlib.dates as mdates BASE = r"G:\Mi unidad\microtracking\MicrotrackingFer" LOG = os.path.join(BASE, "logs_nuevos", "log_2026-10-30.txt") OUT = os.path.join(BASE, "figures", "2026-10-30") os.makedirs(OUT, exist_ok=True) CUT = datetime(2026, 10, 30, 14, 0, 0) # "horas basura" empiezan a las 2 pm CUT12 = datetime(2026, 10, 30, 12, 0, 0) # ventana limpia: post-fix F5, pre-deriva ADC_MAX = 8388607.0 GAIN_SHUNT = 20.0 R_SHUNT = 0.260 # PCB V2 (260 mOhm) PYRANO_DIV = 0.2 # W/m2 por (mV/1000) en el canal del piranometro RAW2MA = ADC_MAX * GAIN_SHUNT * R_SHUNT / 2048.0 # counts de C (o dC) -> mA def isc_ma(c_raw): vsh_mV = c_raw / ADC_MAX * 2048.0 / GAIN_SHUNT return (vsh_mV / 1000.0) / R_SHUNT * 1000.0 def g_wm2(p_signed): return p_signed / ADC_MAX * 2048.0 / PYRANO_DIV def sign24(raw): raw &= 0xFFFFFF if raw & 0x00800000: raw -= 0x1000000 return raw RE_MAIN = re.compile( r"LOG:\[(?P[^\]]+)\]\s*X:(?P-?\d+),Y:(?P-?\d+),C:(?P-?\d+)," r"V:(?P-?\d+),P:(?P

-?\d+),S:(?P-?\d+),er:(?P-?\d+)") RE_TEL = re.compile( r"LOG:\[(?P[^\]]+)\]\s*TEL: t=(?P\d+)s, steps=(?P\d+)\(\+?(?P\d+)\)") RE_FT = re.compile( r"LOG:\[(?P[^\]]+)\]\s*FineTune: dC=(?P\d+), dynE=\d+, e=\d+, near=(?P\d+)") RE_F5 = re.compile( r"LOG:\[(?P[^\]]+)\]\s*F5: Deriva detectada (?P\d+) pasos " r"\(dX=(-?\d+), dY=(-?\d+)\), re-alineando a \((-?\d+), (-?\d+)\)") RE_CLOUD = re.compile(r"LOG:\[(?P[^\]]+)\]\s*\[CLOUD\] (?P.+)") def parse_ts(s): s = s.strip() for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M"): try: return datetime.strptime(s, fmt) except ValueError: pass return None def parse_log(path): M, T, F, F5, CL = [], [], [], [], [] with open(path, "r", encoding="utf-8", errors="replace") as f: for line in f: m = RE_MAIN.search(line) if m: ts = parse_ts(m.group("t")) if ts: M.append((ts, int(m.group("x")), int(m.group("y")), int(m.group("c")), int(m.group("v")), sign24(int(m.group("p"))), int(m.group("er")))) continue m = RE_TEL.search(line) if m: ts = parse_ts(m.group("t")) if ts: T.append((ts, int(m.group("steps")))) continue m = RE_FT.search(line) if m: ts = parse_ts(m.group("t")) if ts: F.append((ts, int(m.group("dc")), int(m.group("near")))) continue m = RE_F5.search(line) if m: ts = parse_ts(m.group("t")) if ts: F5.append((ts, int(m.group("drift")))) continue m = RE_CLOUD.search(line) if m: ts = parse_ts(m.group("t")) if ts: CL.append((ts, m.group("msg").strip())) return M, T, F, F5, CL def unwrap_steps(tel): """El contador de steps (TEL) se reinicia en cada reboot/OTA: 10:27:58->10:30:08 y 11:43:29->11:45:37. Lo desenrolla a una serie continua.""" out = [] off = 0 prev = None for (ts, st) in tel: if prev is not None and st < prev: off += prev out.append((ts, st + off)) prev = st return out def to_arrays(M): ts = np.array([r[0] for r in M], dtype=object) X = np.array([r[1] for r in M]) Y = np.array([r[2] for r in M]) C = np.array([r[3] for r in M], dtype=float) V = np.array([r[4] for r in M], dtype=float) P = np.array([r[5] for r in M], dtype=float) er = np.array([r[6] for r in M]) return dict(t=ts, X=X, Y=Y, C=C, V=V, P=np.maximum(0, P), er=er) def subset(L, lo=None, hi=None): if lo is None and hi is None: return L sel = np.array([(t >= lo if lo is not None else True) and (t < hi if hi is not None else True) for t in L["t"]]) return {k: L[k][sel] for k in ("t", "X", "Y", "C", "V", "P", "er")} def reversals(arr): d = np.diff(arr.astype(float)) s = np.sign(d) s = s[s != 0] if len(s) < 2: return 0 return int((s[1:] != s[:-1]).sum()) def metrics(L, tel, f5): n = len(L["t"]) out = {"n": n} if n == 0: return out t0, t1 = L["t"][0], L["t"][-1] mins = max((t1 - t0).total_seconds() / 60.0, 1e-9) out["t0"], out["t1"], out["mins"] = t0, t1, mins out["Cpeak_mA"] = isc_ma(L["C"].max()) out["Ppeak_W"] = g_wm2(L["P"].max()) out["distinct"] = len(set(zip(L["X"].tolist(), L["Y"].tolist()))) # on-target: C >= 80% del pico del dia (con luz) cmax = L["C"].max() p_w = g_wm2(L["P"]) on = (L["C"] >= 0.8 * cmax) & (p_w > 50.0) out["ontarget_pct"] = 100.0 * on.mean() ft = [(ts, dc) for (ts, dc, _nr) in FT if L["t"][0] <= ts <= t1] out["ndC"] = float(np.mean([dc for _, dc in ft])) / RAW2MA if ft else float("nan") # raw->mA out["revX"] = reversals(L["X"]) out["revY"] = reversals(L["Y"]) telw = [(ts, st) for (ts, st) in tel if L["t"][0] <= ts <= t1] # delta de la ventana (la serie es acumulada desde el boot) out["steps_total"] = (telw[-1][1] - telw[0][1]) if len(telw) > 1 else 0 out["steps_min"] = out["steps_total"] / mins f5w = [(ts, d) for (ts, d) in f5 if L["t"][0] <= ts <= t1] out["nF5"] = len(f5w) out["drift_steps"] = sum(d for _, d in f5w) gaps = [int((b - a).total_seconds()) for (a, b) in zip(L["t"][:-1], L["t"][1:]) if (b - a).total_seconds() > 15] out["gaps"] = gaps return out M, TEL, FT, F5, CLOUD = parse_log(LOG) TEL = unwrap_steps(TEL) print("Muestras: %d TEL: %d FineTune: %d F5: %d CLOUD: %d" % (len(M), len(TEL), len(FT), len(F5), len(CLOUD))) L_FULL = to_arrays(M) L_PRE = subset(L_FULL, hi=CUT) L_NOON = subset(L_FULL, lo=CUT12, hi=CUT) for name, L in (("FULL ", L_FULL), ("PRE14", L_PRE), ("NOON ", L_NOON)): m = metrics(L, TEL, F5) print("[%s] %s -> %s (%.0f min) n=%d pos.distinct=%d Cpeak=%.0f mA Ppeak=%.0f W/m2" % ( name, m["t0"].strftime("%H:%M:%S"), m["t1"].strftime("%H:%M:%S"), m["mins"], m["n"], m["distinct"], m["Cpeak_mA"], m["Ppeak_W"])) print(" on-target=%.1f%% dC medio=%.2f mA revX=%d revY=%d steps=%d (%.1f/min) F5=%d (drift %d pasos) gaps>15s=%d" % ( m["ontarget_pct"], m["ndC"], m["revX"], m["revY"], m["steps_total"], m["steps_min"], m["nF5"], m["drift_steps"], len(m["gaps"]))) m_full = metrics(L_FULL, TEL, F5) m_pre = metrics(L_PRE, TEL, F5) m_noon = metrics(L_NOON, TEL, F5) def savefig(fig, base): for ext in ("pdf", "png"): buf = io.BytesIO() fig.savefig(buf, format=ext) dest = os.path.join(OUT, base + "." + ext) if os.path.exists(dest): try: os.remove(dest) except OSError: pass with open(dest, "wb") as f: f.write(buf.getvalue()) def fmt_ax(ax, fmt="%H:%M"): ax.xaxis.set_major_formatter(mdates.DateFormatter(fmt)) ax.xaxis.set_major_locator(mdates.AutoDateLocator()) ax.grid(alpha=0.3) # ---------------------------------------------------------------- heatmap def build_heatmap(tag, label, L, tel, f5): xs, ys, c_mA, p_w = L["X"], L["Y"], isc_ma(L["C"]), g_wm2(L["P"]) xlo, xhi = 0, int(np.ceil(xs.max() / 5000.0) * 5000) + 5000 ylo, yhi = 0, int(np.ceil(ys.max() / 5000.0) * 5000) + 5000 xbins = np.linspace(xlo, xhi, 61) ybins = np.linspace(ylo, yhi, 46) def bin_max(x, y, vals): xi = np.clip(np.searchsorted(xbins, x, side="right") - 1, 0, len(xbins) - 2) yi = np.clip(np.searchsorted(ybins, y, side="right") - 1, 0, len(ybins) - 2) grid = np.full((len(ybins) - 1, len(xbins) - 1), -np.inf) np.maximum.at(grid, (yi, xi), vals) grid[grid == -np.inf] = np.nan return grid maxC = bin_max(xs, ys, c_mA) maxP = bin_max(xs, ys, p_w) nC = np.nan_to_num(maxC / np.nanmax(maxC)) nP = np.nan_to_num(maxP / np.nanmax(maxP)) co = np.ma.masked_invalid(np.minimum(nC, nP)) xc = (xbins[:-1] + xbins[1:]) / 2 yc = (ybins[:-1] + ybins[1:]) / 2 pi, pj = np.unravel_index(np.nanargmax(co), co.shape) co_x, co_y = xc[pj], yc[pi] w = np.where(co > 0.5 * np.nanmax(co), co, 0.0) if w.sum() > 0: com_x = np.nansum(w * xc[None, :]) / np.nansum(w) com_y = np.nansum(w * yc[:, None]) / np.nansum(w) else: com_x = com_y = np.nan fig, axes = plt.subplots(1, 3, figsize=(20, 6.4), sharex=True, sharey=True) def draw_heat(ax, grid, title, cunit): im = ax.imshow(np.ma.masked_invalid(grid), extent=[xbins[0], xbins[-1], ybins[0], ybins[-1]], origin="lower", aspect="auto", cmap="inferno", norm=matplotlib.colors.Normalize(vmin=0, vmax=np.nanmax(grid) if np.isfinite(np.nanmax(grid)) else 1)) ax.set_title(title, fontsize=11) ax.set_xlabel("X [steps]") ax.set_ylabel("Y [steps]") fig.colorbar(im, ax=ax, label=cunit) draw_heat(axes[0], maxC, "Photocurrent (maximum per bin)", "Isc [mA]") draw_heat(axes[1], maxP, "Irradiance (maximum per bin)", "G [W/m$^2$]") draw_heat(axes[2], co, "Normalized overlap min(C*, P*)", "Normalized overlap") ci, cj = np.unravel_index(np.nanargmax(maxC), maxC.shape) axes[0].plot(xc[cj], yc[ci], "w*", ms=16, mec="white", mew=1.2, zorder=5) pi_, pj_ = np.unravel_index(np.nanargmax(maxP), maxP.shape) axes[1].plot(xc[pj_], yc[pi_], "w*", ms=16, mec="white", mew=1.2, zorder=5) axes[2].plot(xs, ys, "-", color="cyan", lw=0.25, alpha=0.35, zorder=2, label="Recorded trajectory") axes[2].plot(co_x, co_y, "o", ms=16, mfc="none", mec="lime", mew=2.2, zorder=6, label="Overlap maximum") axes[2].plot(com_x, com_y, "x", ms=14, color="yellow", mew=2.4, zorder=7, label="Weighted centroid") axes[2].plot(xc[cj], yc[ci], "+", ms=14, color="red", mew=2.0, zorder=7, label="Current maximum") axes[2].plot(xc[pj_], yc[pi_], "+", ms=14, color="orange", mew=2.0, zorder=7, label="Irradiance maximum") axes[2].legend(loc="upper left", fontsize=8, framealpha=0.8) fig.suptitle("Stage-coordinate maps - %s (%d samples, %s)" % (label, len(L["t"]), L["t"][0].strftime("%d/%m %H:%M") + "-" + L["t"][-1].strftime("%H:%M")), fontsize=13, y=1.00) fig.tight_layout() savefig(fig, tag + "_heatmap") plt.close(fig) print(" co-pico: X~%.0f Y~%.0f | centro de masa: X~%.0f Y~%.0f" % (co_x, co_y, com_x, com_y)) # ------------------------------------------------------- tracking figures def build_tracking(tag, label, L, tel, f5, show_cut): t = L["t"] tx = mdates.date2num([t[i] for i in range(len(t))]) c_mA = isc_ma(L["C"]) p_w = g_wm2(L["P"]) fig, axes = plt.subplots(2, 2, figsize=(16, 9), dpi=200) fig.suptitle("Tracking performance - %s" % label, fontsize=14, fontweight="bold") ax = axes[0, 0] ax.plot(tx, p_w, color="#ff7f0e", lw=0.6, label="P [W/m$^2$]") ax.set_xlabel("Time"); ax.set_ylabel("G [W/m$^2$]", color="#ff7f0e") ax2 = ax.twinx() ax2.plot(tx, c_mA, color="#d62728", lw=0.6, label="C [mA]") ax2.set_ylabel("C [mA]", color="#d62728") for (fts, _d) in f5: if t[0] <= fts <= t[-1]: ax.axvline(mdates.date2num(fts), color="red", ls="--", lw=0.8, alpha=0.6) ax.text(mdates.date2num(t[0]) + (tx[-1] - tx[0]) * 0.01, ax2.get_ylim()[1] * 0.9, "F5 (drift)", color="red", fontsize=8) if show_cut: ax.axvline(mdates.date2num(CUT), color="purple", ls="-", lw=1.4) ax.text(mdates.date2num(CUT) + (tx[-1] - tx[0]) * 0.01, ax2.get_ylim()[1] * 0.98, "14:00 (degraded tracking)", color="purple", fontsize=8, rotation=90, va="top") fmt_ax(ax) h1, l1 = ax.get_legend_handles_labels(); h2, l2 = ax2.get_legend_handles_labels() ax.legend(h1 + h2, l1 + l2, fontsize=8, loc="upper right") ax.set_title("Irradiance and photocurrent (red dashed: F5 events)") ax = axes[0, 1] ftw = [(ts, dc) for (ts, dc, _nr) in FT if t[0] <= ts <= t[-1]] if ftw: ftx = mdates.date2num([x[0] for x in ftw]) ax.plot(ftx, [x[1] for x in ftw], color="tab:blue", lw=0.5) ax.set_xlabel("Time"); ax.set_ylabel("dC [ADC counts]") if show_cut: ax.axvline(mdates.date2num(CUT), color="purple", ls="-", lw=1.4) fmt_ax(ax) ax.set_title("Fine-tune current-change diagnostic") ax = axes[1, 0] ax.step(tx, L["er"], where="post", color="tab:purple", lw=1.2) ax.set_xlabel("Time"); ax.set_ylabel("er (adaptive threshold)") if show_cut: ax.axvline(mdates.date2num(CUT), color="purple", ls="-", lw=1.4) fmt_ax(ax) ax.set_title("Adaptive threshold er") ax = axes[1, 1] wins = 15 * 60.0 tsec = np.array([(x - t[0]).total_seconds() for x in t]) cmax = L["C"].max() on = ((L["C"] >= 0.8 * cmax) & (g_wm2(L["P"]) > 50.0)).astype(float) wi = (tsec // wins).astype(int) frac = [] for w in range(wi.max() + 1): sel = wi == w frac.append(on[sel].mean() if sel.any() else np.nan) wmid = [t[0] + _timedelta(seconds=(w + 0.5) * wins) for w in range(wi.max() + 1)] ax.step(mdates.date2num(wmid), frac, where="post", color="tab:green", lw=1.4) ax.set_ylim(-0.05, 1.05) ax.set_ylabel("On-target sample fraction") ax.set_xlabel("Time") if show_cut: ax.axvline(mdates.date2num(CUT), color="purple", ls="-", lw=1.4) ax.xaxis.set_major_formatter(mdates.DateFormatter("%H:%M")) ax.xaxis.set_major_locator(mdates.AutoDateLocator()) ax.grid(alpha=0.3) ax.set_title("On-target occupancy (15-min bins)") fig.tight_layout(rect=[0, 0, 1, 0.96]) savefig(fig, tag + "_tracking") plt.close(fig) # --------------------------------------------------------- energy figures def build_energy(tag, label, L, tel, f5, show_cut): t = L["t"] tx = mdates.date2num([t[i] for i in range(len(t))]) telw = [(ts, st) for (ts, st) in TEL if t[0] <= ts <= t[-1]] f5w = [(ts, d) for (ts, d) in F5 if t[0] <= ts <= t[-1]] m = metrics(L, tel, f5) fig, axes = plt.subplots(2, 2, figsize=(16, 9), dpi=200) fig.suptitle("Motion activity - %s" % label, fontsize=14, fontweight="bold") ax = axes[0, 0] if telw: ax.plot(mdates.date2num([x[0] for x in telw]), [x[1] for x in telw], color="tab:blue", lw=1.2) ymax = max(x[1] for x in telw) else: ymax = 1 for rb, rbl in ((datetime(2026, 10, 30, 10, 30, 8), "OTA reboot"), (datetime(2026, 10, 30, 11, 45, 37), "OTA reboot (F5 update)")): if t[0] <= rb <= t[-1]: ax.axvline(mdates.date2num(rb), color="green", ls=":", lw=1.2) ax.text(mdates.date2num(rb), ymax * 0.03, rbl, color="green", fontsize=7, rotation=90, va="bottom") ax.set_xlabel("Time"); ax.set_ylabel("Cumulative steps") if show_cut: ax.axvline(mdates.date2num(CUT), color="purple", ls="-", lw=1.4) fmt_ax(ax) ax.set_title("Cumulative fine-tune steps (TEL; reboot-adjusted)") ax = axes[0, 1] if len(telw) > 1: tdx = np.diff(mdates.date2num([x[0] for x in telw])) * 86400.0 dsteps = np.diff([x[1] for x in telw]) rate = dsteps / (tdx / 60.0) tm = mdates.date2num([x[0] for x in telw][1:]) ax.plot(tm, rate, color="tab:orange", lw=0.8) med = np.median(rate) ax.axhline(med, color="gray", ls=":", lw=1) ax.text(tm[0], med * 1.05, "Median %.0f steps/min" % med, fontsize=8, color="gray") ax.set_xlabel("Time"); ax.set_ylabel("steps/min") if show_cut: ax.axvline(mdates.date2num(CUT), color="purple", ls="-", lw=1.4) fmt_ax(ax) ax.set_title("Movement rate") ax = axes[1, 0] if f5w: fx = mdates.date2num([x[0] for x in f5w]) ax.bar(fx, [x[1] for x in f5w], width=0.004, color="red", alpha=0.75) ax.set_yscale("log") ax.set_xlabel("Time"); ax.set_ylabel("Corrected drift [steps] (log)") if show_cut: ax.axvline(mdates.date2num(CUT), color="purple", ls="-", lw=1.4) fmt_ax(ax) ax.set_title("F5 drift corrections (%d events, %d steps)" % (m["nF5"], m["drift_steps"])) ax = axes[1, 1] ax.axis("off") txt = [ label, "", "Window: %s -> %s (%.0f min)" % (m["t0"].strftime("%H:%M:%S"), m["t1"].strftime("%H:%M:%S"), m["mins"]), "X/Y samples: %d (distinct positions: %d)" % (m["n"], m["distinct"]), "Peak current: %.0f mA | Peak irradiance: %.0f W/m2" % (m["Cpeak_mA"], m["Ppeak_W"]), "On-target: %.1f%% (C >= 80%% of window peak; G > 50 W/m2)" % m["ontarget_pct"], "Mean dC: %.1f mA" % m["ndC"], "Reversals: X=%d Y=%d" % (m["revX"], m["revY"]), "Total steps: %d (%.1f steps/min)" % (m["steps_total"], m["steps_min"]), "F5 events: %d (corrected drift: %d steps = %.1f%% of counted steps)" % ( m["nF5"], m["drift_steps"], 100.0 * m["drift_steps"] / max(m["steps_total"], 1)), "Gaps > 15 s: %d" % len(m["gaps"]), ] ax.text(0.0, 1.0, "\n".join(txt), family="monospace", fontsize=10, va="top", bbox=dict(boxstyle="round,pad=0.6", fc="#f7f7f7", ec="gray")) fig.tight_layout(rect=[0, 0, 1, 0.96]) savefig(fig, tag + "_energy") plt.close(fig) # ---------------------------------------------------- comparacion (corroborar) def build_compare(): rows = [ ("Total counted steps", m_full["steps_total"], m_pre["steps_total"], m_noon["steps_total"]), ("steps/min", m_full["steps_min"], m_pre["steps_min"], m_noon["steps_min"]), ("F5 events", m_full["nF5"], m_pre["nF5"], m_noon["nF5"]), ("Corrected drift [steps]", m_full["drift_steps"], m_pre["drift_steps"], m_noon["drift_steps"]), ("On-target [%]", m_full["ontarget_pct"], m_pre["ontarget_pct"], m_noon["ontarget_pct"]), ("Mean dC [mA]", m_full["ndC"], m_pre["ndC"], m_noon["ndC"]), ("Direction reversals (X+Y)", m_full["revX"] + m_full["revY"], m_pre["revX"] + m_pre["revY"], m_noon["revX"] + m_noon["revY"]), ("Distinct positions", m_full["distinct"], m_pre["distinct"], m_noon["distinct"]), ] fig, axes = plt.subplots(2, 4, figsize=(18, 8.5), dpi=200) fig.suptitle("Window comparison: full record (10:00-18:21), pre-14:00 (10:00-14:00), midday (12:00-14:00)", fontsize=14, fontweight="bold") for ax, (name, vfull, vpre, vnoon) in zip(axes.ravel(), rows): vals = (vfull, vpre, vnoon) bars = ax.bar([0, 1, 2], vals, color=["#d62728", "#ff7f0e", "#2ca02c"], alpha=0.85) ax.set_xticks([0, 1, 2]) ax.set_xticklabels(["Full", "Pre-14:00", "Midday"]) ax.set_title(name, fontsize=10) ax.grid(axis="y", alpha=0.3) for b, v in zip(bars, vals): ax.annotate("%.3g" % v, (b.get_x() + b.get_width() / 2, v), ha="center", va="bottom", fontsize=8) ax.set_yscale("symlog" if max(abs(v) for v in vals) > 1e4 else "linear") fig.tight_layout(rect=[0, 0, 1, 0.94]) savefig(fig, "compare_full_vs_pre14_vs_noon") plt.close(fig) VERSIONS = [ ("2026-10-30_full", "Full record, 30 October 2026 (10:00-18:21)", L_FULL, True), ("2026-10-30_pre14", "Pre-14:00 window, 30 October 2026 (10:00-14:00)", L_PRE, False), ("2026-10-30_noon", "Midday window, 30 October 2026 (12:00-14:00)", L_NOON, False), ] for tag, label, L, show_cut in VERSIONS: print("Generando", tag, "...") build_heatmap(tag, label, L, TEL, F5) build_tracking(tag, label, L, TEL, F5, show_cut) build_energy(tag, label, L, TEL, F5, show_cut) print("Generando comparacion ...") build_compare() print("DONE ->", OUT)