Files
Microtracking/heatmap_extremos_p80.py

427 lines
17 KiB
Python

# -*- coding: utf-8 -*-
"""Mapas de calor de extremos de corriente de celda (MicrotrackingFer).
Para cada uno de los N logs mas nuevos de logs_nuevos/ genera una figura con:
Panel A Heatmap X-Y construido EXCLUSIVAMENTE con los puntos cuyo valor de
corriente de celda C supera el percentil 80 de la sesion (solo dia).
El color es la Isc en mA. Las celdas sin punto extremo quedan vacias,
de modo que el mapa muestra unicamente la region de maxima corriente.
Encima se dibuja el campo de irradiancia G_POA medido por el
piranometro (isolineas) y su propia region > percentil 80, para
correlacionar espacialmente corriente extrema vs. intensidad.
Panel B Correlacion C (Isc) vs G_POA de ese mismo subconjunto extremo,
con regresion, Pearson r, Spearman rho y la linea de corte P80.
Mapeo adc_manager.cpp: C=canal1(fotocorriente), V=canal3(Voc), P=canal2(piranometro,
Apogee SP-212 on-board, AIN2). Calibracion ADS122C04 24-bit, Vref 2.048 V
(identica a metrica1.py y parse_log.py). El canal P se registra en complemento a 2
de 24 bits sin signo: se extiende el signo y los valores negativos (noche) se apodan a 0.
Uso: py -3.12 heatmap_extremos_p80.py [n_logs] (por defecto n_logs = 5)
"""
import glob
import os
import re
import sys
import numpy as np
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from matplotlib.colors import Normalize
ROOT = os.path.dirname(os.path.abspath(__file__))
LOGS_DIR = os.path.join(ROOT, "logs_nuevos")
OUT_DIR = os.path.join(ROOT, "figures", "heatmap_extremos_p80")
ADC_MAX = 8388607.0
GAIN_SHUNT = 20.0
R_SHUNT = 0.075
DEG_PER_USTEP = 80.0 / 53000.0
PCT = 80.0
DAY_G_MIN = 20.0
PAD_PCT = 0.5
RE = re.compile(
r"LOG:\[(?P<t>[^\]]+)\]\s*"
r"X:(?P<x>-?\d+),Y:(?P<y>-?\d+),C:(?P<c>-?\d+),V:(?P<v>-?\d+),"
r"P:(?P<p>-?\d+),S:(?P<s>-?\d+),er:(?P<er>-?\d+)"
)
def sign24(raw):
raw &= 0xFFFFFF
return raw - 0x1000000 if raw & 0x800000 else raw
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 load_log(path):
ts, xs, ys, cs, ps = [], [], [], [], []
with open(path, "r", encoding="utf-8", errors="replace") as f:
for line in f:
m = RE.search(line)
if m:
ts.append(m.group("t"))
xs.append(int(m.group("x")))
ys.append(int(m.group("y")))
cs.append(int(m.group("c")))
ps.append(sign24(int(m.group("p"))))
return (np.array(ts), np.array(xs, dtype=float), np.array(ys, dtype=float),
np.array(cs, dtype=float), np.array(ps, dtype=float))
def bin_stat(x, y, vals, xbins, ybins, stat="mean"):
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)
shape = (len(ybins) - 1, len(xbins) - 1)
flat_sum = np.zeros(shape[0] * shape[1])
flat_cnt = np.zeros(shape[0] * shape[1])
for a, b, v in zip(yi, xi, vals):
k = a * shape[1] + b
flat_sum[k] += v
flat_cnt[k] += 1.0
with np.errstate(invalid="ignore"):
flat = flat_sum / np.where(flat_cnt == 0, np.nan, flat_cnt)
return flat.reshape(shape)
def robust_limits(arr, lo=PAD_PCT, hi=100.0 - PAD_PCT):
"""Limites con clipping de outliers (unos pocos puntos con Y saltando a 0 o
a 116000 estiran los ejes y dejan el mapa vacio)."""
a = np.asarray(arr, dtype=float)
return float(np.percentile(a, lo)), float(np.percentile(a, hi))
def bin_max(x, y, vals, xbins, ybins):
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
def nan_smooth(grid, k=3):
"""Media movil nan-aware k x k sobre una rejilla con celdas vacias."""
out = np.full_like(grid, np.nan)
r = k // 2
for i in range(grid.shape[0]):
for j in range(grid.shape[1]):
blk = grid[max(0, i - r):i + r + 1, max(0, j - r):j + r + 1]
if np.isfinite(blk).any():
out[i, j] = np.nanmean(blk)
return out
def deg_axis(ax):
"""Ejes secundarios en grados equivalentes (80 deg = 53000 micro-pasos)."""
tx = ax.secondary_xaxis("top", functions=(lambda s: s * DEG_PER_USTEP,
lambda d: d / DEG_PER_USTEP))
tx.set_xlabel("X [deg]", fontsize=8, labelpad=2)
tx.tick_params(labelsize=7)
ty = ax.secondary_yaxis("right", functions=(lambda s: s * DEG_PER_USTEP,
lambda d: d / DEG_PER_USTEP))
ty.set_ylabel("Y [deg]", fontsize=8, labelpad=2, color="dimgray")
ty.tick_params(labelsize=7, colors="dimgray")
def process(path):
"""Devuelve dict con datos y estadisticos, o None si no hay datos de dia."""
ts, xs, ys, cs, ps = load_log(path)
g = np.maximum(0.0, ps) / ADC_MAX * 2048.0 / 2.0
isc = isc_ma(cs)
day = g > DAY_G_MIN
if day.sum() < 50:
return None
x0d, x1d = robust_limits(xs[day])
y0d, y1d = robust_limits(ys[day])
inside = day & (xs >= x0d) & (xs <= x1d) & (ys >= y0d) & (ys <= y1d)
n_drop = int(day.sum() - inside.sum())
if inside.sum() < 50:
return None
x, y, c, gg = xs[inside], ys[inside], isc[inside], g[inside]
t_str = ts[inside]
thr_c = np.percentile(c, PCT)
thr_g = np.percentile(gg, PCT)
ext = c > thr_c
n_ext = int(ext.sum())
if n_ext < 10:
return None
xe, ye, ce, ge, te = x[ext], y[ext], c[ext], gg[ext], t_str[ext]
r = float(np.corrcoef(ce, ge)[0, 1]) if ce.std() > 0 and ge.std() > 0 else np.nan
ce_rank = np.argsort(np.argsort(ce))
ge_rank = np.argsort(np.argsort(ge))
rho = float(np.corrcoef(ce_rank, ge_rank)[0, 1]) if n_ext > 2 else np.nan
if ce.std() > 0 and ge.std() > 0:
slope, intercept = np.polyfit(ge, ce, 1)
else:
slope, intercept = np.nan, np.nan
x0e, x1e = robust_limits(xe, 1.0, 99.0)
y0e, y1e = robust_limits(ye, 1.0, 99.0)
m_x = 0.15 * max(1.0, x1e - x0e)
m_y = 0.15 * max(1.0, y1e - y0e)
in_win = (x >= x0e - m_x) & (x <= x1e + m_x) & (y >= y0e - m_y) & (y <= y1e + m_y)
if in_win.sum() < 20:
return None
xbins = np.linspace(x0e - m_x, x1e + m_x, 61)
ybins = np.linspace(y0e - m_y, y1e + m_y, 46)
xc = (xbins[:-1] + xbins[1:]) / 2
yc = (ybins[:-1] + ybins[1:]) / 2
grid_c = bin_max(xe, ye, ce, xbins, ybins)
grid_g = bin_stat(x[in_win], y[in_win], gg[in_win], xbins, ybins)
grid_g_s = nan_smooth(grid_g, 3)
cell_ext_c = ~np.isnan(grid_c)
cell_ext_g = ~np.isnan(grid_g) & (grid_g > thr_g)
inter = cell_ext_c & cell_ext_g
union = cell_ext_c | cell_ext_g
iou = float(inter.sum()) / float(union.sum()) if union.sum() else np.nan
xi = np.clip(np.searchsorted(xbins, xe, side="right") - 1, 0, len(xbins) - 2)
yi = np.clip(np.searchsorted(ybins, ye, side="right") - 1, 0, len(ybins) - 2)
contained = float(cell_ext_g[yi, xi].mean())
# pico del campo G suavizado, restringido a celdas con dato crudo
valid = np.isfinite(grid_g) & np.isfinite(grid_g_s)
gi, gj = np.unravel_index(np.argmax(np.where(valid, grid_g_s, -np.inf)),
grid_g_s.shape)
g_peak = float(grid_g[gi, gj])
wx = ce / ce.sum()
com_x = float((wx * xe).sum())
com_y = float((wx * ye).sum())
i_cmax = int(np.argmax(ce))
return dict(
name=os.path.splitext(os.path.basename(path))[0],
n_day=int(day.sum()), n_used=int(inside.sum()), n_drop=n_drop, n_ext=n_ext,
x0d=x0d, x1d=x1d, y0d=y0d, y1d=y1d,
t0=t_str[0], t1=t_str[-1],
xe=xe, ye=ye, ce=ce, ge=ge, te=te,
thr_c=thr_c, thr_g=thr_g,
xbins=xbins, ybins=ybins, xc=xc, yc=yc,
grid_c=grid_c, grid_g=grid_g, grid_g_s=grid_g_s,
iou=iou, contained=contained,
com_x=com_x, com_y=com_y,
c_max=float(ce[i_cmax]), c_max_x=float(xe[i_cmax]), c_max_y=float(ye[i_cmax]),
g_max=float(ge.max()), g_peak=g_peak,
g_max_x=float(xc[gj]), g_max_y=float(yc[gi]),
r=r, rho=rho, slope=float(slope), intercept=float(intercept),
)
def make_figure(d):
fig, axes = plt.subplots(1, 2, figsize=(15.5, 6.6))
axA, axB = axes
im = axA.imshow(
np.ma.masked_invalid(d["grid_c"]),
extent=[d["xbins"][0], d["xbins"][-1], d["ybins"][0], d["ybins"][-1]],
origin="lower", aspect="auto", cmap="inferno",
norm=Normalize(vmin=np.nanmin(d["grid_c"]), vmax=np.nanmax(d["grid_c"])),
interpolation="nearest",
)
cb = fig.colorbar(im, ax=axA, pad=0.16)
cb.set_label("Isc maxima por celda [mA] (solo puntos > P%d)" % int(PCT),
fontsize=9)
cb.ax.tick_params(labelsize=8)
cs = axA.contour(d["xc"], d["yc"], d["grid_g"], levels=5, colors="white",
linewidths=0.8, alpha=0.7)
axA.clabel(cs, inline=True, fontsize=6.5, fmt="%.0f")
axA.contour(d["xc"], d["yc"], d["grid_g"], levels=[d["thr_g"]],
colors="deepskyblue", linewidths=2.0, linestyles="--")
axA.plot(d["xe"], d["ye"], ".", ms=1.6, color="cyan", alpha=0.35, zorder=3,
label="puntos > P%d de Isc" % int(PCT))
axA.plot(d["g_max_x"], d["g_max_y"], "H", ms=14, mfc="none", mec="deepskyblue",
mew=2.4, zorder=6,
label="piranometro: pico de G_POA (%.0f W/m2)" % d["g_peak"])
axA.plot(d["c_max_x"], d["c_max_y"], "*", ms=19, color="lime", mec="black",
mew=0.8, zorder=7,
label="celda: max Isc (%.1f mA)" % d["c_max"])
axA.plot(d["com_x"], d["com_y"], "x", ms=13, color="yellow", mew=2.4, zorder=7,
label="centroide ponderado por Isc")
axA.annotate(
"", xy=(d["c_max_x"], d["c_max_y"]), xytext=(d["g_max_x"], d["g_max_y"]),
arrowprops=dict(arrowstyle="-|>", color="lime", lw=1.8, alpha=0.9), zorder=6)
dx = d["c_max_x"] - d["g_max_x"]
dy = d["c_max_y"] - d["g_max_y"]
axA.set_title("A) Zonas de corriente extrema (Isc > P%d) y campo del piranometro"
% int(PCT), fontsize=11)
axA.set_xlabel("X (micro-pasos)")
axA.set_ylabel("Y (micro-pasos)")
axA.legend(loc="upper center", bbox_to_anchor=(0.5, -0.13), ncol=2,
fontsize=7.5, framealpha=0.95, borderpad=0.4, handletextpad=0.5,
labelspacing=0.4, columnspacing=1.0)
deg_axis(axA)
sc = axB.scatter(d["ge"], d["ce"], c=np.arange(len(d["ce"])), cmap="viridis",
s=9, alpha=0.75, edgecolors="none")
if np.isfinite(d["slope"]):
gx = np.linspace(d["ge"].min(), d["ge"].max(), 50)
axB.plot(gx, d["slope"] * gx + d["intercept"], "-", color="crimson", lw=1.8,
label="ajuste lineal: Isc = %.3f*G + %.1f" % (d["slope"], d["intercept"]))
axB.axvline(d["thr_g"], color="deepskyblue", ls="--", lw=1.6,
label="P%d de G_POA = %.0f W/m2" % (int(PCT), d["thr_g"]))
axB.axhline(d["thr_c"], color="black", ls=":", lw=1.6,
label="P%d de Isc = %.1f mA" % (int(PCT), d["thr_c"]))
cb2 = fig.colorbar(sc, ax=axB, pad=0.02)
cb2.set_label("indice temporal (temprano -> tarde)")
axB.set_title("B) Correlacion espacial Isc vs G_POA en el subconjunto extremo",
fontsize=11)
axB.set_xlabel("G_POA del piranometro [W/m2]")
axB.set_ylabel("Isc de la celda [mA]")
axB.grid(True, ls="--", alpha=0.35)
axB.legend(loc="upper left", fontsize=7.5, framealpha=0.9)
axB.text(0.985, 0.02,
"n = %d Pearson r = %.3f Spearman rho = %.3f\n"
"extremos dentro del area G > P%d: %.0f%% IoU = %.3f"
% (d["n_ext"], d["r"], d["rho"], int(PCT),
100.0 * d["contained"], d["iou"]),
transform=axB.transAxes, ha="right", va="bottom", fontsize=8,
bbox=dict(fc="white", alpha=0.9, ec="0.6"))
fig.suptitle(
"%s - %d muestras de dia, %d extremas (%.0f%%) - %s a %s\n"
"recorrido de la sesion: X %d..%d Y %d..%d upaso "
"(el marco del panel A se recorta a la zona de los puntos extremos)\n"
"desfase pico piranometro -> pico celda: dX=%+.0f dY=%+.0f upaso "
"(%.1f deg, %.1f deg)"
% (d["name"], d["n_day"], d["n_ext"], 100.0 - PCT, d["t0"][11:], d["t1"][11:],
d["x0d"], d["x1d"], d["y0d"], d["y1d"],
dx, dy, dx * DEG_PER_USTEP, dy * DEG_PER_USTEP),
fontsize=12, y=1.06)
fig.tight_layout()
out = os.path.join(OUT_DIR, "extremos_p80_%s.png" % d["name"])
fig.savefig(out, dpi=150, bbox_inches="tight")
plt.close(fig)
return out
def make_summary(ds):
fig, axes = plt.subplots(2, 3, figsize=(20, 11))
axs = axes.ravel()
for ax, d in zip(axs, ds):
im = ax.imshow(
np.ma.masked_invalid(d["grid_c"]),
extent=[d["xbins"][0], d["xbins"][-1], d["ybins"][0], d["ybins"][-1]],
origin="lower", aspect="auto", cmap="inferno",
norm=Normalize(vmin=np.nanmin(d["grid_c"]), vmax=np.nanmax(d["grid_c"])),
interpolation="nearest")
fig.colorbar(im, ax=ax, pad=0.02).set_label("Isc max [mA]", fontsize=8)
ax.contour(d["xc"], d["yc"], d["grid_g"],
levels=[d["thr_g"]], colors="deepskyblue", linewidths=1.8,
linestyles="--")
ax.contour(d["xc"], d["yc"], d["grid_g"], levels=6, colors="white",
linewidths=0.6, alpha=0.6)
ax.plot(d["g_max_x"], d["g_max_y"], "H", ms=12, mfc="none", mec="deepskyblue",
mew=2.2)
ax.plot(d["c_max_x"], d["c_max_y"], "*", ms=17, color="lime", mec="black",
mew=0.8)
ax.set_title("%s\nn_ext=%d r=%.2f rho=%.2f cont=%.0f%% IoU=%.2f"
% (d["name"], d["n_ext"], d["r"], d["rho"],
100.0 * d["contained"], d["iou"]), fontsize=10)
ax.set_xlabel("X (micro-pasos)", fontsize=8)
ax.set_ylabel("Y (micro-pasos)", fontsize=8)
ax.tick_params(labelsize=7)
ax = axs[len(ds)]
ax.axis("off")
lines = ["Resumen: extremos de Isc (percentil %d) y piranometro\n" % int(PCT),
"regla: solo puntos con Isc > P%d (dia G>%.0f W/m2, ventana P%.1f-P%.1f\n"
" en X e Y para descartar outliers de la trayectoria)\n"
% (int(PCT), DAY_G_MIN, PAD_PCT, 100.0 - PAD_PCT),
"%-12s %6s %6s %6s %6s %6s %6s" %
("log", "n_dia", "n_ext", "r", "rho", "cont", "IoU"),
"-" * 56]
for d in ds:
lines.append("%-12s %6d %6d %6.3f %6.3f %5.0f%% %6.3f"
% (d["name"].replace("log_", ""), d["n_used"], d["n_ext"],
d["r"], d["rho"], 100.0 * d["contained"], d["iou"]))
lines.append("")
lines.append("%-12s %10s %10s %10s %8s" % ("log", "Isc_max_mA", "G_max", "P80_mA", "atipic"))
lines.append("-" * 56)
for d in ds:
lines.append("%-12s %10.1f %10.0f %10.1f %8d"
% (d["name"].replace("log_", ""), d["c_max"], d["g_max"],
d["thr_c"], d["n_drop"]))
lines.append("")
lines.append("G_max = maximo instantaneo de G_POA; el hexagono marca el pico")
lines.append(" del campo G suavizado (3x3 celdas), mas robusto")
lines.append("cont = % de puntos extremos de Isc que caen dentro del area")
lines.append(" > P%d medida por el piranometro (G_POA)")
lines.append("IoU = solapamiento de areas en la rejilla (celda a celda)")
lines.append("atipic = muestras de dia descartadas por la ventana robusta")
lines.append("")
lines.append("marca hexagono = piranometro (pico del campo G_POA)")
lines.append("marca estrella = celda (maximo de corriente)")
lines.append("linea cian --- = frontera del area > P%d de G_POA" % int(PCT))
lines.append("(aqui se destacan las zonas de mayor intensidad medida")
lines.append(" por el piranometro, sobre la nube de puntos extremos de Isc)")
ax.text(0.0, 0.98, "\n".join(lines), va="top", ha="left", fontsize=9,
family="monospace", transform=ax.transAxes)
fig.suptitle("Extremos de corriente de celda (percentil %d) vs piranometro - "
"5 logs mas nuevos" % int(PCT), fontsize=14, y=0.995)
fig.tight_layout()
out = os.path.join(OUT_DIR, "extremos_p80_resumen.png")
fig.savefig(out, dpi=150, bbox_inches="tight")
plt.close(fig)
return out
def main():
n = int(sys.argv[1]) if len(sys.argv) > 1 else 5
os.makedirs(OUT_DIR, exist_ok=True)
paths = sorted(glob.glob(os.path.join(LOGS_DIR, "log_*.txt")),
key=lambda p: re.search(r"(\d{4}-\d{2}-\d{2})", p).group(1))
paths = paths[-n:]
print("Logs seleccionados (%d mas nuevos):" % len(paths))
for p in paths:
print(" ", os.path.basename(p))
ds = []
for p in paths:
d = process(p)
if d is None:
print("!! %s: sin datos de dia suficientes, se omite" % os.path.basename(p))
continue
ds.append(d)
out = make_figure(d)
print("%s -> n_dia=%d (usados=%d, atipicos=%d) n_ext=%d r=%.3f rho=%.3f "
"cont=%.2f IoU=%.3f | %s"
% (d["name"], d["n_day"], d["n_used"], d["n_drop"], d["n_ext"],
d["r"], d["rho"], d["contained"], d["iou"], os.path.basename(out)))
if not ds:
print("Sin figuras generadas.")
return
print("Resumen ->", os.path.basename(make_summary(ds)))
if __name__ == "__main__":
main()