#!/usr/bin/env python # -*- coding: utf-8 -*- """ Estimacion de la irradiancia global REAL (correccion de la saturacion del piranometro en 1024 W/m2) para una sesion de campo. Ubicacion de las pruebas: Aguascalientes, Aguascalientes, Mexico. lat = +21.8756, lon = -102.2854, alt = 1896 m, TZ = UTC-6 (sin DST) Metodo: 1) Posicion solar por formulas estandar (declinacion de Cooper, ecuacion de tiempo de Spencer, correccion de longitud respecto al meridiano del huso UTC-6 a 90 W, masa de aire de Kasten-Young). 2) Modelo de cielo despejado G_cs = A*exp(-B*AM) + C (transmisividad de Beer sobre la masa de aire + difuso de cielo claro), ajustado SOLO a muestras de cielo claro SIN saturar (el clipping a 1024 W/m2 sesgaria el ajuste hacia abajo). Para anclar la forma de la curva, el ajuste se toma sobre el sobre-ensvolvente de la medida (muestra cercana al maximo movil de 30 min), porque las nubes solo bajan la curva de cielo despejado, nunca la suben. 3) La correccion es MONOTONICA: a las muestras saturadas (G >= 1024) se les asigna G_est = max(1024, G_cs) (la correccion nunca baja la meda); al resto se les conserva la medida (nubes incluidas). 4) Se recalculan con la misma ventana que el articulo (G > 50 W/m2, y G > 600 para las estadisticas de cielo claro): H, R_opt, P_ref, eta_tr, mediana P/Pref, fraccion P >= 0.95 P_ref y t95. Uso: py -3.12 clearsky.py [nofig] Salida: articulo/resultados_/preview_irradiance_est.png articulo/resultados_/irradiance_est_.json """ import json import sys import tempfile from pathlib import Path import numpy as np import pandas as pd import matplotlib matplotlib.use("Agg") import matplotlib.dates as mdates import matplotlib.pyplot as plt import parse_log as pl ROOT = Path(__file__).resolve().parent # Aguascalientes, Ags. LAT = 21.8756 LON_W = 102.2854 # longitud oeste positiva ALT_M = 1896.0 L_STD_W = 90.0 # meridiano del huso UTC-6 G_CLIP = 1023.9 # ADC a tope => G = 1024.0 W/m2 def solar_geometry(ts: pd.Series): """Elevacion solar y masa de aire (Kasten-Young) a partir de la hora local.""" n = (ts.dt.dayofyear.values * 86400.0 + ts.dt.hour.values * 3600.0 + ts.dt.minute.values * 60.0 + ts.dt.second.values) / 86400.0 rad = np.pi / 180.0 phi = LAT * rad B = 2 * np.pi / 365.0 * (n - 81.0) delta = 23.45 * rad * np.sin(2 * np.pi / 365.0 * (n + 284.0)) eot_min = 229.18 * (0.000075 + 0.001868 * np.cos(B) - 0.032077 * np.sin(B) - 0.014615 * np.cos(2 * B) - 0.040849 * np.sin(2 * B)) clock_min = (ts.dt.hour.values * 3600 + ts.dt.minute.values * 60 + ts.dt.second.values) / 60.0 l_corr = 4.0 * (LON_W - L_STD_W) # minutos (+ si esta al oeste) hour_angle = 15.0 * ((clock_min - l_corr + eot_min) / 60.0 - 12.0) sin_el = (np.sin(phi) * np.sin(delta) + np.cos(phi) * np.cos(delta) * np.cos(hour_angle * rad)) el = np.degrees(np.arcsin(np.clip(sin_el, -1, 1))) z = 90.0 - el m_air = np.where(z < 75.0, 1.0 / (np.cos(z * rad) + 0.14 * (93.85 - z) ** (-1.375)), np.inf) return el, m_air def fit_clearsky(el_deg, am, G, cloud_mask, t_idx): """Ajusta el modelo G_cs = A*exp(-B*AM) + C a las muestras claras no saturadas. Conjunto de ajuste: el sobre-ensvolvente de la medida (muestra >= 0.98 del maximo movil de 30 min), fuera de transitorios nubosos, sin saturar (G < 1015) y con el sol por encima de 12 grados. Al anclarse en la parte alta de la curva, la extrapolacion al pico (que cae en la zona saturada del piranometro) es corta y confiable. """ roll = pd.Series(G, index=pd.DatetimeIndex(t_idx)).rolling( "30min", center=True, min_periods=5).max().values fitm = ((el_deg > 12.0) & (G > 500.0) & (G < 1015.0) & (~cloud_mask) & (G >= 0.98 * roll)) Gs = G[fitm] AMs = am[fitm] n_fit = int(fitm.sum()) if n_fit < 20: raise RuntimeError("Muy pocas muestras claras para ajustar el modelo " f"(n={n_fit}).") def sse_of(A, Bc, C): return float(np.sum((Gs - (A * np.exp(-Bc * AMs) + C)) ** 2)) best = None for A in range(900, 1501, 50): for Bc in np.arange(0.05, 0.61, 0.02): for C in range(0, 301, 10): s = sse_of(A, Bc, C) if best is None or s < best[0]: best = (s, float(A), float(Bc), float(C)) # refinamiento local _, A0, B0, C0 = best for A in range(int(A0) - 50, int(A0) + 51, 10): for Bc in np.arange(B0 - 0.02, B0 + 0.021, 0.005): for C in range(int(C0) - 10, int(C0) + 11, 5): if A <= 0 or Bc <= 0 or C < 0: continue s = sse_of(A, Bc, C) if best is not None and s < best[0]: best = (s, float(A), float(Bc), float(C)) sse, A, Bc, C = best rms = float(np.sqrt(sse / n_fit)) am_finite = np.isfinite(am) am_safe = np.where(am_finite, am, 0.0) # La curva solo se evalua donde la masa de aire es finita (el>=15). Para # 0 0.0) & am_finite, A * np.exp(-Bc * am_safe) + C, 0.0) # Pico del modelo a mediodia (masa de aire minima) = irradiancia maxima # de cielo despejado en el sitio. if am_finite.any(): am_min = float(np.min(am[am_finite])) else: am_min = 1.0 model_peak = A * np.exp(-Bc * am_min) + C return A, Bc, C, rms, n_fit, curve, model_peak def run(tag, nofig=False): from compare_sessions import normalize src = ROOT / f"{tag}.txt" if not src.exists(): raise SystemExit(f"Log {src.name} no encontrado en la raiz.") tmp = Path(tempfile.gettempdir()) / (tag + "_clearsky.txt") normalize(src, tmp) df_s, df_e = pl.parse_log(tmp) df, ph = pl.build_physical(df_s, df_e) G = df["G_POA"].values Voc = df["Voc"].values Isc = df["Isc"].values Pc = df["P_CPV"].values t = df["timestamp"] el, am = solar_geometry(df["timestamp"]) ces = pl.cloud_events(df) cloud_mask = np.zeros(len(df), dtype=bool) for c in ces: t0 = pd.Timestamp(c["t_start"]) t1 = t0 + pd.Timedelta(seconds=c["dur_s"]) cloud_mask |= (df["timestamp"] >= t0) & (df["timestamp"] <= t1) A, Bc, C, rms, n_fit, curve, model_peak = fit_clearsky( el, am, G, cloud_mask, t.values) clipped = G >= G_CLIP n_clip = int(clipped.sum()) dt_med = float(np.median(df["dt_s"])) # correccion monotona: lo saturado toma el modelo (nunca por debajo # de los 1024 medidos); lo demas conserva la medida G_est = np.where(clipped, np.maximum(1024.0, curve), G) # R_opt y P_ref: con y sin correccion lit = G > 600 R_now = float((Isc[lit] / G[lit]).max()) P_ref_now = df["P_ref"].values litc = G_est > 600 R_corr = float((Isc[litc] / G_est[litc]).max()) P_ref_corr = R_corr * G_est * Voc * pl.FF # ventanas identicas a las del articulo (metrics.json) m_full = pl.window_metrics(df, "Full (G>50)") m_clear = pl.window_metrics(df[df["G_POA"] > 600], "Clear (G>600)") df_c = df.copy() df_c["G_POA"] = G_est df_c["P_ref"] = P_ref_corr m_full_c = pl.window_metrics(df_c, "Full (G>50)") m_clear_c = pl.window_metrics(df_c[df_c["G_POA"] > 600], "Clear (G>600)") df_mod = df.copy() df_mod["P_ref"] = P_ref_corr t95_now = pl.recovery_t95(df, df_e) t95_corr = pl.recovery_t95(df_mod, df_e) # MAE con la referencia corregida (ventana de cielo claro del articulo) Isc_ref_corr = R_corr * G_est wc = (G > 600) MAE_I_now = float(np.mean(np.abs((R_now * G) - Isc)) * 1000.0) MAE_I_corr = float(np.mean(np.abs(Isc_ref_corr - Isc)[wc])) * 1000.0 MAE_P_now = m_clear["MAE_P_mW"] MAE_P_corr = float(np.mean(np.abs(P_ref_corr - Pc)[wc])) * 1000.0 print("\n" + "=" * 72) print(f" CORRECCION DE SATURACION (1024 W/m2) - {tag}") print(f" Aguascalientes, Ags., Mexico (21.8756 N, 102.2854 W, 1896 m)") print("=" * 72) print(f" Modelo cielo despejado: G_cs = {A:.0f} * exp(-{Bc:.3f} * AM) + {C:.0f}") print(f" rms sobre n={n_fit} muestras claras no saturadas: {rms:.0f} W/m2") print(f" Pico: medido = {G.max():.0f} W/m2 (tope del ADC) | " f"estimado = {G_est.max():.0f} W/m2") print(f" Muestras saturadas: {n_clip} " f"({100.0 * n_clip / len(df):.1f} % del log, ~{n_clip * dt_med / 60:.0f} min)") print(f" H (kWh/m2, G>50): {m_full['H_kWh_m2']} -> " f"{m_full_c['H_kWh_m2']} " f"(+{100.0 * (m_full_c['H_kWh_m2'] / m_full['H_kWh_m2'] - 1):.1f} %)") print(f" R_opt (uA/W/m2): {R_now * 1e6:.0f} -> {R_corr * 1e6:.0f}") print(f" eta_tr (G>50): {m_full['eta_tr_pct']} % -> " f"{m_full_c['eta_tr_pct']} %") print(f" eta_tr (G>600): {m_clear['eta_tr_pct']} % -> " f"{m_clear_c['eta_tr_pct']} %") print(f" MAE_I (G>600): {MAE_I_now:.1f} mA -> " f"{MAE_I_corr:.1f} mA") print(f" MAE_P (G>600): {MAE_P_now} mW -> {MAE_P_corr:.1f} mW") print(f" med P/Pref (G>600): {m_clear['ratio_P_Pref_median'] if 'ratio_P_Pref_median' in m_clear else float(np.median(Pc[wc] / P_ref_now[wc])):.3f} -> " f"{float(np.median(Pc[wc] / P_ref_corr[wc])):.3f}") if len(t95_now) == len(t95_corr) and len(t95_now): print("\n t95 (s): antes -> corregido") for a, b in zip(t95_now.itertuples(), t95_corr.itertuples()): print(f" {a.trigger:>14} @ {str(a.t_trigger)[11:19]}: " f"{a.t95_s} -> {b.t95_s}") if not nofig: fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(12.5, 4.4), sharex=True) ax1.plot(t, Pc * 1000, color="tab:red", lw=0.8, label="P_CPV") ax1.plot(t, P_ref_now * 1000, color="k", lw=1.1, label="P_ref (G medida)") ax1.plot(t, P_ref_corr * 1000, color="tab:blue", ls="--", lw=1.1, label="P_ref (G corregida)") ax1.set_ylabel("Potencia (mW)") ax1.set_xlabel("Hora local (UTC-6)") ax1.legend(fontsize=8) ax1.grid(alpha=0.3) if n_clip: idx = np.where(clipped)[0] start = 0 for j in range(1, len(idx) + 1): if j == len(idx) or idx[j] != idx[j - 1] + 1: ax2.axvspan(t.iloc[idx[start]], t.iloc[idx[j - 1]], color="tab:orange", alpha=0.18) start = j ax2.plot(t, G, color="tab:orange", lw=0.8, label="G medida") ax2.plot(t, G_est, color="tab:orange", ls="--", lw=1.3, label="G estimada (corregida)") ax2.plot(t, curve, color="gray", ls=":", lw=0.8, label="Modelo cielo despejado") ax2.axhline(1024, color="gray", ls="-.", lw=0.8) ax2.text(t.iloc[1], 1030, "saturacion 1024 W/m2", fontsize=8, color="dimgray") ax2.set_ylabel("Irradiancia global (W/m2)") ax2.set_xlabel("Hora local (UTC-6)") ax2.xaxis.set_major_formatter(mdates.DateFormatter("%H:%M")) ax2.legend(fontsize=8, loc="upper right") ax2.grid(alpha=0.3) fig.tight_layout() outdir = ROOT / "articulo" / f"resultados_{tag}" outdir.mkdir(parents=True, exist_ok=True) fig.savefig(outdir / "preview_irradiance_est.png", dpi=150) res = { "tag": tag, "model": "G_cs = A*exp(-B*AM) + C, Ajustado al sobre-ensvolvente " "claro no saturado", "model_scale_A_Wm2": round(A, 1), "model_B": round(Bc, 4), "model_C": C, "model_rms_clear_Wm2": round(rms, 1), "n_fit_clear": n_fit, "model_peak_Wm2": round(model_peak, 0), "G_max_measured_Wm2": round(float(G.max()), 1), "G_max_estimated_Wm2": round(float(G_est.max()), 1), "n_clipped": n_clip, "clipped_pct_log": round(100 * n_clip / len(df), 2), "H_kWh_m2_full_measured": m_full["H_kWh_m2"], "H_kWh_m2_full_corrected": m_full_c["H_kWh_m2"], "R_opt_uA_per_Wm2_measured": round(R_now * 1e6, 1), "R_opt_uA_per_Wm2_corrected": round(R_corr * 1e6, 1), "eta_tr_pct_full_measured": m_full["eta_tr_pct"], "eta_tr_pct_full_corrected": m_full_c["eta_tr_pct"], "eta_tr_pct_clear_measured": m_clear["eta_tr_pct"], "eta_tr_pct_clear_corrected": m_clear_c["eta_tr_pct"], "MAE_I_mA_clear_measured": round(MAE_I_now, 2), "MAE_I_mA_clear_corrected": round(MAE_I_corr, 2), "MAE_P_mW_clear_measured": MAE_P_now, "MAE_P_mW_clear_corrected": round(MAE_P_corr, 2), "ratio_P_Pref_median_clear_measured": round(float(np.median(Pc[wc] / P_ref_now[wc])), 3), "ratio_P_Pref_median_clear_corrected": round(float(np.median(Pc[wc] / P_ref_corr[wc])), 3), "location": "Aguascalientes, Ags., Mexico " "(21.8756 N, 102.2854 W, 1896 m, UTC-6)", } outdir = ROOT / "articulo" / f"resultados_{tag}" outdir.mkdir(parents=True, exist_ok=True) (outdir / f"irradiance_est_{tag}.json").write_text( json.dumps(res, indent=2, ensure_ascii=False), encoding="utf-8") print(f"\n[ok] JSON (y figura) en {outdir}") if __name__ == "__main__": tag = sys.argv[1] if len(sys.argv) > 1 else "21092025" nofig = len(sys.argv) > 2 and sys.argv[2] == "nofig" run(tag, nofig=nofig)