Initial import of Microtracking project

This commit is contained in:
Fernando Dávalos Hernández committed 2026-11-04 19:43:33 -06:00
commit 60720c388c
2265 files changed
+1464696

No files matched your search

+35
View File
@@ -0,0 +1,35 @@
# Local credentials and agent state
.kilo/
.env
.env.*
!.env.example
wifi_credentials.h
cloud_credentials.h
*.token
*.secret
# Generated files and dependencies
__pycache__/
*.py[cod]
.pio/
build/
node_modules/
.venv/
venv/
*.tmp
*.swp
# Private hub state and generated firmware
/docker_tracker_hub/state/
/docker_tracker_hub/data_logs/
/docker_tracker_hub/firmware/*
!/docker_tracker_hub/firmware/README.md
# LaTeX intermediates; keep PDFs and experimental logs
/articulo/*.aux
/articulo/*.log
/articulo/*.bcf
/articulo/*.blg
/articulo/*.run.xml
/articulo/*.spl
/articulo/*.synctex.gz
+18156
View File
File diff suppressed because it is too large. Load diff
+9811
View File
File diff suppressed because it is too large. Load diff
+4
View File
@@ -0,0 +1,4 @@
LOG:[2025-09-19 16:22:11] X:50334,Y:105000,C:10434,V:429442,P:459304,S:24330,er:1000
LOG:[2025-09-19 16:22:11] FineTune: dC=1856, dynE=1000, e=1000, near=0, Cmax=15494
LOG:[2025-09-19 16:22:13] X:50334,Y:105000,C:10409,V:429667,P:463251,S:24332,er:1000
LOG:[2025-09-19 16:22:13] FineTune: dC=1881, dynE=1000, e=1000, near=0, Cmax=15494
+22810
View File
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+25618
View File
File diff suppressed because it is too large. Load diff
+11651
View File
File diff suppressed because it is too large. Load diff
+17402
View File
File diff suppressed because it is too large. Load diff
View File
Whitespace-only changes.
Binary file not shown.
File diff suppressed because it is too large. Load diff
Binary file not shown.
Binary file not shown.
File diff suppressed because it is too large. Load diff
+1
View File
@@ -0,0 +1 @@
-38.0800 mm -52.7105 mm 0 mm -38.0400 mm -52.7739 mm 0 mm -38.0000 mm -52.8371 mm 0 mm -37.9600 mm -52.9003 mm 0 mm -37.9200 mm -52.9633 mm 0 mm -37.8800 mm -53.0261 mm 0 mm -37.8400 mm -53.0889 mm 0 mm -37.8000 mm -53.1515 mm 0 mm -37.7600 mm -53.2139 mm 0 mm -37.7200 mm -53.2762 mm 0 mm -37.6800 mm -53.3384 mm 0 mm -37.6400 mm -53.4005 mm 0 mm -37.6000 mm -53.4624 mm 0 mm -37.5600 mm -53.5242 mm 0 mm -37.5200 mm -53.5858 mm 0 mm -37.4800 mm -53.6474 mm 0 mm -37.4400 mm -53.7087 mm 0 mm -37.4000 mm -53.7700 mm 0 mm -37.3600 mm -53.8311 mm 0 mm -37.3200 mm -53.8921 mm 0 mm -37.2800 mm -53.9530 mm 0 mm -37.2400 mm -54.0137 mm 0 mm -37.2000 mm -54.0743 mm 0 mm -37.1600 mm -54.1347 mm 0 mm -37.1200 mm -54.1950 mm 0 mm -37.0800 mm -54.2552 mm 0 mm -37.0400 mm -54.3153 mm 0 mm -37.0000 mm -54.3752 mm 0 mm -36.9600 mm -54.4350 mm 0 mm -36.9200 mm -54.4947 mm 0 mm -36.8800 mm -54.5542 mm 0 mm -36.8400 mm -54.6136 mm 0 mm -36.8000 mm -54.6729 mm 0 mm -36.7600 mm -54.7320 mm 0 mm -36.7200 mm -54.7910 mm 0 mm -36.6800 mm -54.8499 mm 0 mm -36.6400 mm -54.9087 mm 0 mm -36.6000 mm -54.9673 mm 0 mm -36.5600 mm -55.0258 mm 0 mm -36.5200 mm -55.0841 mm 0 mm -36.4800 mm -55.1424 mm 0 mm -36.4400 mm -55.2005 mm 0 mm -36.4000 mm -55.2584 mm 0 mm -36.3600 mm -55.3163 mm 0 mm -36.3200 mm -55.3740 mm 0 mm -36.2800 mm -55.4316 mm 0 mm -36.2400 mm -55.4891 mm 0 mm -36.2000 mm -55.5464 mm 0 mm -36.1600 mm -55.6036 mm 0 mm -36.1200 mm -55.6607 mm 0 mm -36.0800 mm -55.7176 mm 0 mm -36.0400 mm -55.7744 mm 0 mm -36.0000 mm -55.8311 mm 0 mm -35.9600 mm -55.8877 mm 0 mm -35.9200 mm -55.9441 mm 0 mm -35.8800 mm -56.0004 mm 0 mm -35.8400 mm -56.0566 mm 0 mm -35.8000 mm -56.1127 mm 0 mm -35.7600 mm -56.1686 mm 0 mm -35.7200 mm -56.2244 mm 0 mm -35.6800 mm -56.2801 mm 0 mm -35.6400 mm -56.3356 mm 0 mm -35.6000 mm -56.3911 mm 0 mm -35.5600 mm -56.4464 mm 0 mm -35.5200 mm -56.5016 mm 0 mm -35.4800 mm -56.5566 mm 0 mm -35.4400 mm -56.6115 mm 0 mm -35.4000 mm -56.6663 mm 0 mm -35.3600 mm -56.7210 mm 0 mm -35.3200 mm -56.7756 mm 0 mm -35.2800 mm -56.8300 mm 0 mm -35.2400 mm -56.8843 mm 0 mm -35.2000 mm -56.9385 mm 0 mm -35.1600 mm -56.9925 mm 0 mm -35.1200 mm -57.0465 mm 0 mm -35.0800 mm -57.1003 mm 0 mm -35.0400 mm -57.1540 mm 0 mm -35.0000 mm -57.2075 mm 0 mm -34.9600 mm -57.2610 mm 0 mm -34.9200 mm -57.3143 mm 0 mm -34.8800 mm -57.3675 mm 0 mm -34.8400 mm -57.4205 mm 0 mm -34.8000 mm -57.4735 mm 0 mm -34.7600 mm -57.5263 mm 0 mm -34.7200 mm -57.5790 mm 0 mm -34.6800 mm -57.6316 mm 0 mm -34.6400 mm -57.6841 mm 0 mm -34.6000 mm -57.7364 mm 0 mm -34.5600 mm -57.7886 mm 0 mm -34.5200 mm -57.8407 mm 0 mm -34.4800 mm -57.8927 mm 0 mm -34.4400 mm -57.9446 mm 0 mm -34.4000 mm -57.9963 mm 0 mm -34.3600 mm -58.0479 mm 0 mm -34.3200 mm -58.0994 mm 0 mm -34.2800 mm -58.1508 mm 0 mm -34.2400 mm -58.2021 mm 0 mm -34.2000 mm -58.2532 mm 0 mm -34.1600 mm -58.3042 mm 0 mm -34.1200 mm -58.3551 mm 0 mm -34.0800 mm -58.4059 mm 0 mm -34.0400 mm -58.4566 mm 0 mm -34.0000 mm -58.5071 mm 0 mm -33.9600 mm -58.5575 mm 0 mm -33.9200 mm -58.6078 mm 0 mm -33.8800 mm -58.6580 mm 0 mm -33.8400 mm -58.7081 mm 0 mm -33.8000 mm -58.7580 mm 0 mm -33.7600 mm -58.8079 mm 0 mm -33.7200 mm -58.8576 mm 0 mm -33.6800 mm -58.9072 mm 0 mm -33.6400 mm -58.9567 mm 0 mm -33.6000 mm -59.0060 mm 0 mm -33.5600 mm -59.0553 mm 0 mm -33.5200 mm -59.1044 mm 0 mm -33.4800 mm -59.1534 mm 0 mm -33.4400 mm -59.2023 mm 0 mm -33.4000 mm -59.2511 mm 0 mm -33.3600 mm -59.2997 mm 0 mm -33.3200 mm -59.3483 mm 0 mm -33.2800 mm -59.3967 mm 0 mm -33.2400 mm -59.4450 mm 0 mm -33.2000 mm -59.4932 mm 0 mm -33.1600 mm -59.5413 mm 0 mm -33.1200 mm -59.5893 mm 0 mm -33.0800 mm -59.6371 mm 0 mm -33.0400 mm -59.6849 mm 0 mm -33.0000 mm -59.7325 mm 0 mm -32.9600 mm -59.7800 mm 0 mm -32.9200 mm -59.8274 mm 0 mm -32.8800 mm -59.8747 mm 0 mm -32.8400 mm -59.9219 mm 0 mm -32.8000 mm -59.9689 mm 0 mm -32.7600 mm -60.0159 mm 0 mm -32.7200 mm -60.0627 mm 0 mm -32.6800 mm -60.1094 mm 0 mm -32.6400 mm -60.1560 mm 0 mm -32.6000 mm -60.2025 mm 0 mm -32.5600 mm -60.2489 mm 0 mm -32.5200 mm -60.2952 mm 0 mm -32.4800 mm -60.3413 mm 0 mm -32.4400 mm -60.3874 mm 0 mm -32.4000 mm -60.4333 mm 0 mm -32.3600 mm -60.4791 mm 0 mm -32.3200 mm -60.5248 mm 0 mm -32.2800 mm -60.5704 mm 0 mm -32.2400 mm -60.6159 mm 0 mm -32.2000 mm -60.6613 mm 0 mm -32.1600 mm -60.7065 mm 0 mm -32.1200 mm -60.7517 mm 0 mm -32.0800 mm -60.7967 mm 0 mm -32.0400 mm -60.8416 mm 0 mm -32.0000 mm -60.8865 mm 0 mm -31.9600 mm -60.9312 mm 0 mm -31.9200 mm -60.9758 mm 0 mm -31.8800 mm -61.0202 mm 0 mm -31.8400 mm -61.0646 mm 0 mm -31.8000 mm -61.1089 mm 0 mm -31.7600 mm -61.1531 mm 0 mm -31.7200 mm -61.1971 mm 0 mm -31.6800 mm -61.2410 mm 0 mm -31.6400 mm -61.2849 mm 0 mm -31.6000 mm -61.3286 mm 0 mm -31.5600 mm -61.3722 mm 0 mm -31.5200 mm -61.4157 mm 0 mm -31.4800 mm -61.4591 mm 0 mm -31.4400 mm -61.5024 mm 0 mm -31.4000 mm -61.5456 mm 0 mm -31.3600 mm -61.5887 mm 0 mm -31.3200 mm -61.6317 mm 0 mm -31.2800 mm -61.6745 mm 0 mm -31.2400 mm -61.7173 mm 0 mm -31.2000 mmLine truncated
Binary file not shown.
+37
View File
@@ -0,0 +1,37 @@
import re, statistics
path = r'logs_nuevos/log_2026-10-14.txt'
lines = open(path, encoding='utf-8', errors='ignore').read().splitlines()
pat = re.compile(r'\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\] X:(-?\d+),Y:(-?\d+),C:(\d+),V:(\d+)')
rows = [] # (time, x, y, c, v)
for l in lines:
m = pat.search(l)
if m:
rows.append((m.group(1), int(m.group(2)), int(m.group(3)), int(m.group(4)), int(m.group(5))))
# Ventana donde X casi no se mueve pero C cae lentamente -> evidencia deriva termica
# buscar tramo donde X dentro de +/-500 pasos durante muchos samples
print('--- ventana con poca variacion en X pero caida de C ---')
best_start = None; best_len = 0
i = 0
n = len(rows)
while i < n - 60:
window = rows[i:i+60]
xs = [r[1] for r in window]; cs = [r[3] for r in window]
x_spread = max(xs)-min(xs)
c_drop = statistics.mean(cs[:15]) - statistics.mean(cs[-15:])
if x_spread < 1500 and c_drop > 3000:
if best_len < 60:
best_len = 60; best_start = i
i += 1
else:
i += 60
if best_start is not None:
w = rows[best_start:best_start+60]
print('inicio:', w[0][0], 'X rango:', min(r[1] for r in w), max(r[1] for r in w))
print('C primero/ultimo:', w[0][3], w[-1][3])
for j, r in enumerate(w):
if j % 6 == 0:
print('%s X:%7d Y:%7d C:%8d V:%8d' % (r[0][11:], r[1], r[2], r[3], r[4]))
+95
View File
@@ -0,0 +1,95 @@
import re, statistics
from datetime import datetime
path = r'logs_nuevos/log_2026-10-15.txt'
lines = open(path, encoding='utf-8', errors='ignore').read().splitlines()
# X/Y/C/V/P/er samples
samp = re.compile(r'\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\] X:(-?\d+),Y:(-?\d+),C:(-?\d+),V:(\d+),P:(\d+),S:(\d+),er:(-?\d+)')
# FineTune lines
ft = re.compile(r'\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\] FineTune: dC=(-?\d+), dynE=(-?\d+), e=(-?\d+), near=(\d+), CmaxN=(\d+), norm=(-?\d+), prox=([\d.]+)')
rows = []
fts = []
for l in lines:
m = samp.search(l)
if m:
rows.append((m.group(1), int(m.group(2)), int(m.group(3)), int(m.group(4)), int(m.group(5)), int(m.group(6)), int(m.group(7))))
continue
m = ft.search(l)
if m:
fts.append((m.group(1), int(m.group(2)), int(m.group(3)), int(m.group(4)), int(m.group(5)), int(m.group(6)), int(m.group(7)), float(m.group(8))))
print("samples=%d finetune=%d" % (len(rows), len(fts)))
print("rango:", rows[0][0], "->", rows[-1][0])
def parse(t):
return datetime.strptime(t, '%Y-%m-%d %H:%M:%S')
# --- 1) Deteccion dia/noche por piranometro (P raw) ---
# P raw ~ 16777000 = saturado negativo (noche). Dia: P pequeno (<~2M?)
print("\n=== P (piranometro raw) estadisticas por hora ===")
byhour = {}
for r in rows:
h = r[0][11:13]
byhour.setdefault(h, []).append(r[5])
for h in sorted(byhour):
vals = byhour[h]
print(" %s: n=%5d P_med=%9d P_min=%9d P_max=%9d" % (h, len(vals), int(statistics.median(vals)), min(vals), max(vals)))
# --- 2) CmaxN evolucion (valor de pico normalizado) ---
print("\n=== CmaxN: transiciones a nuevo maximo (solo cambios) ===")
prev = None
changes = 0
for t, dC, dynE, e, near, cmax, norm, prox in fts:
if prev is None or cmax != prev:
if cmax >= 5000000 or (prev is not None and prev >= 5000000 and cmax < prev):
print(" %s CmaxN %s -> %d norm=%d prox=%.2f" % (t[11:], str(prev), cmax, norm, prox))
changes += 1
prev = cmax
print(" (transiciones grandes mostradas: %d)" % changes)
# --- 3) Encontrar cuando CmaxN explota ---
print("\n=== Primer momento en que CmaxN supera 5M / 10M / 18M ===")
for thr in (5000000, 10000000, 18400000):
for t, dC, dynE, e, near, cmax, norm, prox in fts:
if cmax >= thr:
print(" CmaxN>=%d en %s (norm=%d prox=%.2f dC=%d)" % (thr, t[11:], norm, prox, dC))
break
# --- 4) Contexto: irradiancia (P) en la explosion ---
# buscar la muestra mas cercana antes de 14:41:57
t_exp = '14:41:57'
close = None
for r in rows:
if r[0][11:] <= t_exp:
close = r
print("\n=== Contexto poco antes de la explosion (14:41:57) ===")
idx = rows.index(close)
for r in rows[max(0, idx-6):idx+3]:
print(" %s C=%6d V=%8d P=%9d" % (r[0][11:], r[3], r[4], r[5]))
# --- 5) Movimiento nocturno: pasos acumulados cuando no hay sol (P saturado) ---
print("\n=== Movimiento por hora (pasos = |dX|+|dY| entre muestras) ===")
def steps_hour():
res = {}
for i in range(1, len(rows)):
h = rows[i][0][11:13]
d = abs(rows[i][1]-rows[i-1][1]) + abs(rows[i][2]-rows[i-1][2])
res.setdefault(h, 0)
res[h] += d
return res
sh = steps_hour()
for h in sorted(sh):
tag = ""
med = int(statistics.median(byhour.get(h, [0])))
if med > 16000000: tag = " <== NOCHE (piranometro saturado)"
print(" %s: pasos=%9d P_med=%9d%s" % (h, sh[h], med, tag))
# --- 6) Dinamica del error el dia (resumen) ---
print("\n=== Error dinamico: distribucion de e segun prox ===")
bins = [(0,0.05),(0.05,0.15),(0.15,0.30),(0.30,0.50),(0.50,1.01)]
for lo,hi in bins:
es = [e for (_,_,_,e,_,_,_,prox) in fts if lo<=prox<hi]
if es:
print(" prox[%.2f-%.2f): n=%5d e_med=%5d e_min=%d e_max=%d" % (lo,hi,len(es),int(statistics.median(es)),min(es),max(es)))
+684
View File
@@ -0,0 +1,684 @@
"""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()
+698
View File
@@ -0,0 +1,698 @@
"""Reproducible read-and-report analysis of the 4 November 2026 field log."""
from __future__ import annotations
import hashlib
import json
import re
import sys
from datetime import datetime
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-04.txt"
SUNCALC_SOURCE = BASE / "logs_nuevos" / "suncalc_log_2026-11-04.pdf"
OUT = BASE / "figures" / "2026-11-04"
RESULT_TEX = BASE / "articulo" / "results_2026_11_04.tex"
METRICS_TEX = BASE / "articulo" / "metrics_2026_11_04.tex"
ADC_MAX = 8_388_607
VREF_V = 2.048
CURRENT_GAIN = 20.0
SHUNT_OHM = 0.130
PYRANOMETER_MV_PER_W_M2 = 0.2
APERTURE_M2 = 0.002497
FILL_FACTOR = 0.82
MAX_GAP_S = 15.0
MIN_IRRADIANCE_W_M2 = 50.0
SAMPLE_RE = re.compile(
r"LOG:\[(?P<timestamp>[^\]]+)\] X:(?P<x>-?\d+),Y:(?P<y>-?\d+),"
r"C:(?P<c>-?\d+),V:(?P<v>-?\d+),P:(?P<p>-?\d+),"
r"S:(?P<uptime>\d+),er:(?P<error>\d+)"
)
TEL_RE = re.compile(
r"LOG:\[(?P<timestamp>[^\]]+)\] TEL: t=(?P<uptime>\d+)s, "
r"steps=(?P<steps>\d+)\(\+?(?P<delta>\d+)\)"
)
EVENT_RE = re.compile(r"LOG:\[(?P<timestamp>[^\]]+)\] (?P<message>.*)")
# Exact 15-minute values transcribed from the supplied SunCalc PDF.
SUNCALC_ROWS = [
("07:00",106.87,.82),("07:15",108.31,3.91),("07:30",109.82,7.12),("07:45",111.41,10.34),
("08:00",113.08,13.54),("08:15",114.86,16.70),("08:30",116.76,19.83),("08:45",118.80,22.90),
("09:00",120.98,25.91),("09:15",123.34,28.85),("09:30",125.90,31.71),("09:45",128.67,34.47),
("10:00",131.69,37.13),("10:15",134.98,39.66),("10:30",138.57,42.04),("10:45",142.49,44.24),
("11:00",146.75,46.26),("11:15",151.37,48.04),("11:30",156.34,49.57),("11:45",161.64,50.82),
("12:00",167.22,51.75),("12:15",173.01,52.35),("12:30",178.93,52.59),("12:45",184.86,52.47),
("13:00",190.71,51.99),("13:15",196.37,51.18),("13:30",201.77,50.04),("13:45",206.86,48.60),
("14:00",211.60,46.90),("14:15",215.98,44.96),("14:30",220.01,42.81),("14:45",223.71,40.49),
("15:00",227.10,38.01),("15:15",230.21,35.40),("15:30",233.06,32.67),("15:45",235.69,29.84),
("16:00",238.11,26.93),("16:15",240.35,23.94),("16:30",242.43,20.89),("16:45",244.37,17.78),
("17:00",246.18,14.63),("17:15",247.89,11.44),("17:30",249.50,8.23),("17:45",251.03,5.01),
("18:00",252.49,1.85),("18:15",253.88,-1.05),
]
def signed24(value: int) -> int:
value &= 0xFFFFFF
return value - 0x1000000 if value & 0x800000 else value
def parse_log(path: Path = SOURCE):
samples, telemetry, events = [], [], []
raw = path.read_bytes()
for line_number, line in enumerate(raw.decode("utf-8-sig").splitlines(), 1):
match = SAMPLE_RE.search(line)
if match:
d = match.groupdict()
samples.append({
"timestamp": pd.Timestamp(d["timestamp"]),
"x_steps": int(d["x"]), "y_steps": int(d["y"]),
"c_raw": int(d["c"]), "v_raw": int(d["v"]), "p_raw": int(d["p"]),
"uptime_s": int(d["uptime"]), "error": int(d["error"]),
"source_line": line_number,
})
continue
match = TEL_RE.search(line)
if match:
d = match.groupdict()
telemetry.append({"timestamp": pd.Timestamp(d["timestamp"]),
"uptime_s": int(d["uptime"]), "steps": int(d["steps"]),
"delta": int(d["delta"]), "source_line": line_number})
continue
match = EVENT_RE.search(line)
if match:
events.append({"timestamp": pd.Timestamp(match["timestamp"]),
"message": match["message"], "source_line": line_number})
frame = pd.DataFrame(samples)
if frame.empty or not frame.timestamp.is_monotonic_increasing:
raise ValueError("No electrical samples or non-monotonic source timestamps")
frame.attrs["firmware_versions"] = sorted(set(re.findall(r"VER:([\w.-]+)", raw.decode("utf-8-sig"))))
return frame, pd.DataFrame(telemetry), pd.DataFrame(events), hashlib.sha256(raw).hexdigest()
def calibrate(frame: pd.DataFrame, fill_factor: float = FILL_FACTOR) -> pd.DataFrame:
if not 0 < fill_factor <= 1:
raise ValueError("Fill factor must be in (0, 1]")
result = frame.copy()
for raw_col, signed_col in (("c_raw", "c_signed"), ("v_raw", "v_signed"), ("p_raw", "p_signed")):
result[signed_col] = result[raw_col].map(signed24)
result["isc_A"] = result.c_signed / ADC_MAX * VREF_V / CURRENT_GAIN / SHUNT_OHM
result["voc_V"] = result.v_signed / ADC_MAX * VREF_V * 2
result["irradiance_W_m2"] = result.p_signed / ADC_MAX * VREF_V * 1000 / PYRANOMETER_MV_PER_W_M2
result["assumed_fill_factor"] = fill_factor
result["adc_clipped"] = result[["c_signed", "v_signed", "p_signed"]].abs().ge(ADC_MAX).any(axis=1)
result["near_current_rail"] = result.c_signed.abs().ge(0.99 * ADC_MAX)
result["invalid_polarity"] = result[["c_signed", "v_signed", "p_signed"]].lt(0).any(axis=1)
result["illuminated"] = result.irradiance_W_m2 > MIN_IRRADIANCE_W_M2
result["accepted"] = ~(result.adc_clipped | result.near_current_rail | result.invalid_polarity)
result["quality_flag"] = np.select(
[result.adc_clipped, result.near_current_rail, result.invalid_polarity],
["adc_clipped", "near_current_rail", "invalid_polarity"], default="accepted")
result["estimated_mpp_W"] = (result.isc_A * result.voc_V * fill_factor).where(result.accepted)
valid_response = result.accepted & result.illuminated
result["instant_aperture_efficiency_pct"] = (
100 * result.estimated_mpp_W / (APERTURE_M2 * result.irradiance_W_m2)
).where(valid_response)
result["responsivity_A_m2_W"] = (result.isc_A / result.irradiance_W_m2).where(valid_response)
return result
def accepted_edges(frame: pd.DataFrame, selected=None, max_gap_s: float = MAX_GAP_S):
"""Return a boolean mask for adjacent accepted sample pairs only."""
n = len(frame)
keep = np.ones(n, dtype=bool) if selected is None else np.asarray(selected, dtype=bool)
dt = frame.timestamp.diff().dt.total_seconds().to_numpy()
reboot = frame.uptime_s.diff().lt(0).to_numpy()
edge = np.zeros(n, dtype=bool)
edge[1:] = (keep[1:] & keep[:-1] & frame.accepted.to_numpy()[1:] & frame.accepted.to_numpy()[:-1]
& frame.illuminated.to_numpy()[1:] & frame.illuminated.to_numpy()[:-1]
& (dt[1:] > 0) & (dt[1:] <= max_gap_s) & ~reboot[1:])
return edge
def integrate_edges(frame: pd.DataFrame, values, edge_mask) -> float:
values = np.asarray(values, dtype=float)
seconds = frame.timestamp.diff().dt.total_seconds().to_numpy()
right = np.flatnonzero(edge_mask)
left = right - 1
return float(np.sum((values[left] + values[right]) * 0.5 * seconds[right]) / 3600)
def metrics(frame: pd.DataFrame, start=None, end=None, max_gap_s=MAX_GAP_S):
t = frame.timestamp
selected = np.ones(len(frame), dtype=bool)
if start is not None:
selected &= (t >= pd.Timestamp(start)).to_numpy()
if end is not None:
selected &= (t < pd.Timestamp(end)).to_numpy()
edges = accepted_edges(frame, selected, max_gap_s)
accepted = selected & frame.accepted.to_numpy() & frame.illuminated.to_numpy()
obs = frame.loc[accepted]
support_s = float(frame.timestamp.diff().dt.total_seconds().to_numpy()[edges].sum())
elapsed_s = ((min(t.max(), pd.Timestamp(end)) if end is not None else t.max()) -
(max(t.min(), pd.Timestamp(start)) if start is not None else t.min())).total_seconds()
energy = integrate_edges(frame, frame.estimated_mpp_W, edges)
irradiation = integrate_edges(frame, frame.irradiance_W_m2, edges)
ref_resp = float(frame.loc[frame.accepted & frame.illuminated, "responsivity_A_m2_W"].max())
p99_resp = float(frame.loc[frame.accepted & frame.illuminated, "responsivity_A_m2_W"].quantile(.99))
# Reference powers use observed Voc and the same common irradiance support.
max_ref_power = ref_resp * frame.irradiance_W_m2 * frame.voc_V * frame.assumed_fill_factor
p99_ref_power = p99_resp * frame.irradiance_W_m2 * frame.voc_V * frame.assumed_fill_factor
max_ref_energy = integrate_edges(frame, max_ref_power, edges)
p99_ref_energy = integrate_edges(frame, p99_ref_power, edges)
peak = obs.loc[obs.estimated_mpp_W.idxmax()] if not obs.empty else None
peak_isc = obs.loc[obs.isc_A.idxmax()] if not obs.empty else None
peak_g = obs.loc[obs.irradiance_W_m2.idxmax()] if not obs.empty else None
si_19 = irradiation * APERTURE_M2 * .19
si_21 = irradiation * APERTURE_M2 * .21
return {
"start": str(obs.timestamp.min()) if not obs.empty else "",
"end": str(obs.timestamp.max()) if not obs.empty else "",
"sample_count": int(selected.sum()), "accepted_illuminated_samples": int(accepted.sum()),
"integrated_intervals": int(edges.sum()), "elapsed_span_h": elapsed_s / 3600,
"integrated_support_h": support_s / 3600,
"coverage_pct": 100 * support_s / elapsed_s if elapsed_s > 0 else float("nan"),
"irradiation_Wh_m2": irradiation, "incident_aperture_energy_Wh": irradiation * APERTURE_M2,
"estimated_mpp_energy_Wh": energy,
"energy_weighted_aperture_efficiency_pct": 100 * energy / (APERTURE_M2 * irradiation) if irradiation else float("nan"),
"median_instant_aperture_efficiency_pct": float(obs.instant_aperture_efficiency_pct.median()) if not obs.empty else float("nan"),
"peak_instant_aperture_efficiency_pct": float(obs.instant_aperture_efficiency_pct.max()) if not obs.empty else float("nan"),
"empirical_max_reference_A_m2_W": ref_resp, "empirical_p99_reference_A_m2_W": p99_resp,
"max_reference_ratio_pct": 100 * energy / max_ref_energy if max_ref_energy else float("nan"),
"p99_reference_ratio_pct": 100 * energy / p99_ref_energy if p99_ref_energy else float("nan"),
"silicon_19_energy_Wh": si_19, "silicon_21_energy_Wh": si_21,
"cpv_to_silicon_19_ratio_pct": 100 * energy / si_19 if si_19 else float("nan"),
"cpv_to_silicon_21_ratio_pct": 100 * energy / si_21 if si_21 else float("nan"),
"peak_power_timestamp": str(peak.timestamp) if peak is not None else "",
"peak_power_W": float(peak.estimated_mpp_W) if peak is not None else float("nan"),
"aperture_efficiency_at_peak_power_pct": float(peak.instant_aperture_efficiency_pct) if peak is not None else float("nan"),
"peak_power_isc_A": float(peak.isc_A) if peak is not None else float("nan"),
"peak_power_voc_V": float(peak.voc_V) if peak is not None else float("nan"),
"peak_power_irradiance_W_m2": float(peak.irradiance_W_m2) if peak is not None else float("nan"),
"peak_isc_timestamp": str(peak_isc.timestamp) if peak_isc is not None else "",
"peak_isc_A": float(peak_isc.isc_A) if peak_isc is not None else float("nan"),
"peak_irradiance_timestamp": str(peak_g.timestamp) if peak_g is not None else "",
"peak_irradiance_W_m2": float(peak_g.irradiance_W_m2) if peak_g is not None else float("nan"),
}
def suncalc_frame():
day = pd.Timestamp("2026-11-04")
return pd.DataFrame([{"timestamp": day + pd.Timedelta(hours=int(t[:2]), minutes=int(t[3:])),
"azimuth_deg": az, "solar_elevation_deg": el,
"latitude_deg": 21.87304, "longitude_deg": -102.28282,
"source": "SunCalc PDF, printed local time; 2026-11-04"}
for t, az, el in SUNCALC_ROWS])
def make_events(events: pd.DataFrame, frame: pd.DataFrame, telemetry: pd.DataFrame):
relevant = events[events.message.str.contains(
r"\[CLOUD\]|F1:|F5:|Standby|Auto-arranque|Ejecutando comando|VER:", regex=True, na=False)].copy()
relevant["event_type"] = np.select(
[relevant.message.str.contains("Set Coords", na=False),
relevant.message.str.contains("[CLOUD]", regex=False, na=False),
relevant.message.str.contains(r"F1:|F5:", regex=True, na=False)],
["manual_coordinate_command", "remote_parameter_or_status_command", "controller_event"], default="logged_event")
# Add algorithmic gaps with exact endpoints and source-line references.
dt = frame.timestamp.diff().dt.total_seconds()
gaps = []
for i in np.flatnonzero(dt.to_numpy() > MAX_GAP_S):
gaps.append({"timestamp": frame.timestamp.iloc[i], "message": f"Sampling gap: {dt.iloc[i]:.0f} s; no bridge in integration",
"source_line": int(frame.source_line.iloc[i]), "event_type": "sampling_gap",
"previous_source_line": int(frame.source_line.iloc[i - 1]),
"uptime_decreased": bool(frame.uptime_s.iloc[i] < frame.uptime_s.iloc[i - 1])})
if not telemetry.empty:
tel_dt = telemetry.timestamp.diff().dt.total_seconds()
for i in np.flatnonzero(tel_dt.to_numpy() > MAX_GAP_S):
gaps.append({"timestamp": telemetry.timestamp.iloc[i], "message": f"TEL telemetry gap: {tel_dt.iloc[i]:.0f} s",
"source_line": int(telemetry.source_line.iloc[i]), "event_type": "tel_sampling_gap",
"previous_source_line": int(telemetry.source_line.iloc[i - 1]),
"uptime_decreased": bool(telemetry.uptime_s.iloc[i] < telemetry.uptime_s.iloc[i - 1])})
for i in np.flatnonzero(telemetry.uptime_s.diff().lt(0).to_numpy()):
gaps.append({"timestamp": telemetry.timestamp.iloc[i], "message": "TEL uptime decreased",
"source_line": int(telemetry.source_line.iloc[i]), "event_type": "tel_uptime_decrease",
"previous_source_line": int(telemetry.source_line.iloc[i - 1]),
"uptime_decreased": True})
relevant = relevant.assign(previous_source_line=np.nan, uptime_decreased=np.nan)
return pd.concat([relevant, pd.DataFrame(gaps)], ignore_index=True, sort=False).sort_values("timestamp")
def add_solar_context(frame: pd.DataFrame, sun: pd.DataFrame):
# Linear interpolation is only for plotting context; no extrapolation outside table.
result = frame.copy()
x = sun.timestamp.astype("int64").to_numpy()
tq = result.timestamp.astype("int64").to_numpy()
for col in ("azimuth_deg", "solar_elevation_deg"):
result[col] = np.interp(tq, x, sun[col].to_numpy(), left=np.nan, right=np.nan)
result.loc[(tq < x[0]) | (tq > x[-1]), col] = np.nan
return result
def gap_breaks(frame):
dt = frame.timestamp.diff().dt.total_seconds().to_numpy()
return np.r_[False, (dt[1:] > MAX_GAP_S)] | ~frame.accepted.to_numpy()
def plot_outputs(frame, events, sun, summary):
OUT.mkdir(parents=True, exist_ok=True)
plt.rcParams.update({"font.family": "DejaVu Sans", "font.size": 9,
"axes.spines.top": False, "axes.spines.right": False,
"pdf.fonttype": 42, "ps.fonttype": 42})
time = frame.timestamp
breaks = gap_breaks(frame)
fig, ax = plt.subplots(5, 1, figsize=(10, 10), sharex=True, layout="constrained")
ax[0].plot(time, frame.irradiance_W_m2.mask(breaks), color="#d09128", lw=.65)
ax[0].set_ylabel("Aperture-plane\nirradiance [W m$^{-2}$]")
ax[1].plot(time, (1000 * frame.isc_A).mask(breaks), color="#167d8d", lw=.65)
ax[1].set_ylabel("Short-circuit\ncurrent [mA]")
ax[2].plot(time, frame.voc_V.mask(breaks), color="#7356a5", lw=.65)
ax[2].set_ylabel("Open-circuit\nvoltage [V]")
ax[3].plot(time, frame.estimated_mpp_W.mask(breaks), color="#167d8d", lw=.65, label="Estimated MPP power")
ax[3].set_ylabel("Estimated power\n[W]")
ax2 = ax[3].twinx(); ax2.plot(time, frame.instant_aperture_efficiency_pct.mask(breaks), color="#27824e", lw=.55, alpha=.8)
ax2.set_ylabel("Aperture estimate [%]", color="#27824e")
ax[4].plot(time, frame.x_steps.mask(breaks), color="#285b9a", lw=.6, label="X (azimuth axis)")
ax[4].plot(time, frame.y_steps.mask(breaks), color="#c05b43", lw=.6, label="Y (elevation axis)")
ax[4].set_ylabel("Logged coordinate\n[steps]"); ax[4].legend(ncol=2, frameon=False)
for a in ax:
a.grid(alpha=.2); a.xaxis.set_major_formatter(mdates.DateFormatter("%H:%M"))
for g in pd.to_datetime(summary["gap_starts"]): a.axvline(g, color="#555555", lw=.6, ls=":", alpha=.6)
for ts in pd.to_datetime(summary["command_times"]):
for a in ax: a.axvline(ts, color="#ad3e38", lw=.55, ls="--", alpha=.5)
ax[-1].set_xlabel("Local log clock (time-zone offset not encoded)")
fig.suptitle("4 November 2026 field record: complete acquired span", x=.02, ha="left", fontsize=12)
save_figure(fig, "field_day_overview")
fig, ax = plt.subplots(2, 1, figsize=(9, 6), sharex=True, layout="constrained")
ax[0].plot(sun.timestamp, sun.azimuth_deg, "o-", color="#3468a8", ms=2.5, lw=1)
ax[0].set_ylabel("Sun azimuth [deg]")
ax[1].plot(sun.timestamp, sun.solar_elevation_deg, "o-", color="#d27c25", ms=2.5, lw=1)
ax[1].set_ylabel("Solar elevation [deg]"); ax[1].set_xlabel("SunCalc printed local time (15-min entries)")
for a in ax:
a.axvspan(frame.timestamp.min(), frame.timestamp.max(), color="#555555", alpha=.08, label="Acquired interval")
a.grid(alpha=.2); a.xaxis.set_major_formatter(mdates.DateFormatter("%H:%M"))
ax[0].set_title("SunCalc context, Aguascalientes (21.87304° N, 102.28282° W)", loc="left")
fig.text(.01, -.01, "Solar direction is not measured receiver pointing error or collector incidence.", fontsize=8)
save_figure(fig, "solar_position_context")
ctx = add_solar_context(frame, sun)
colors = plt.colormaps["viridis"].with_extremes(over="#ad3e38")
color_limit = summary["reference_p99"] * 1e3
fig, ax = plt.subplots(2, 1, figsize=(10, 7), sharex=True, layout="constrained")
sc = ax[0].scatter(ctx.timestamp, ctx.azimuth_deg, c=ctx.responsivity_A_m2_W * 1e3,
cmap=colors, vmin=0, vmax=color_limit, s=7, rasterized=True)
ax[0].set_ylabel("Sun azimuth [deg]")
cb = fig.colorbar(sc, ax=ax, pad=.01, extend="max"); cb.set_label("$I_{sc}/G$ [mA m$^2$ W$^{-1}$]")
ax[1].scatter(ctx.timestamp, ctx.solar_elevation_deg, c=ctx.responsivity_A_m2_W * 1e3,
cmap=colors, vmin=0, vmax=color_limit, s=7, rasterized=True)
ax[1].set_ylabel("Solar elevation [deg]"); ax[1].set_xlabel("Local log clock; SunCalc values linearly interpolated")
for a in ax:
a.grid(alpha=.2); a.xaxis.set_major_formatter(mdates.DateFormatter("%H:%M"))
ax[0].set_title("Solar position and measured normalized response", loc="left")
fig.text(.01, -.01, "Ephemeris context only; not angular error. Red values exceed the P99 color scale; observations are retained.", fontsize=8)
save_figure(fig, "solar_response_alignment")
fig, ax = plt.subplots(2, 1, figsize=(9, 6), sharex=True, layout="constrained")
response = frame.responsivity_A_m2_W * 1e3
ax[0].plot(time, response.mask(breaks), color="#285b9a", lw=.65, label="$I_{sc}/G$")
max_ref = summary["reference_max"] * 1e3; p99_ref = summary["reference_p99"] * 1e3
for a in ax:
a.axhline(max_ref, color="#b53d35", ls="--", lw=1, label="Full-record maximum reference")
a.axhline(p99_ref, color="#27824e", ls="--", lw=1, label="Full-record P99 sensitivity")
ax[1].plot(time, response.mask(breaks), color="#285b9a", lw=.65)
anomaly = summary["anomaly_time"]
ax[0].scatter([pd.Timestamp(anomaly)], [summary["anomaly_resp"] * 1e3], color="#b53d35", zorder=4, s=28)
ax[0].set_ylim(0, max_ref * 1.08); ax[1].set_ylim(0, p99_ref * 1.35)
ax[0].set_ylabel("Full range\n[mA m$^2$ W$^{-1}$]")
ax[1].set_ylabel("Typical range\n[mA m$^2$ W$^{-1}$]"); ax[1].set_xlabel("Local log clock")
ax[0].set_title("Irradiance-normalized response; full record retained", loc="left")
for a in ax:
for g in pd.to_datetime(summary["gap_starts"]): a.axvline(g, color="#555", lw=.6, ls=":")
a.xaxis.set_major_formatter(mdates.DateFormatter("%H:%M")); a.grid(alpha=.2)
ax[0].legend(frameon=False, ncol=2, fontsize=8)
save_figure(fig, "response_reference_sensitivity")
fig, ax = plt.subplots(figsize=(8, 5), layout="constrained")
labels = ["WiT-CPV estimated\nFF-based output", "Silicon scenario\n19% constant", "Silicon scenario\n21% constant"]
vals = [summary["full"]["estimated_mpp_energy_Wh"], summary["full"]["silicon_19_energy_Wh"], summary["full"]["silicon_21_energy_Wh"]]
colors = ["#167d8d", "#8798aa", "#b0bac5"]
bars = ax.bar(labels, vals, color=colors, width=.62)
ax.bar_label(bars, fmt="%.2f Wh", padding=3)
ax.set_ylabel("Integrated scenario energy [Wh]"); ax.set_ylim(0, max(vals) * 1.2)
ax.set_title("Equal-area scenarios in the measured aperture plane", loc="left")
ax.text(.02, .96, "A = 0.002497 m²; coplanar irradiance; constant ηSi.\nNo temperature correction; not a measured panel comparison.",
transform=ax.transAxes, va="top", fontsize=8)
ax.grid(axis="y", alpha=.2)
save_figure(fig, "fixed_silicon_scenario")
def save_figure(fig, stem):
fig.savefig(OUT / f"{stem}.pdf", bbox_inches="tight")
fig.savefig(OUT / f"{stem}.png", dpi=220, bbox_inches="tight")
plt.close(fig)
def write_result_tex(summary, gap_text):
m = summary["full"]
macros = {
"NovSamples": f"{summary['sample_count']:,}".replace(",", "{,}"),
"NovHours": f"{m['integrated_support_h']:.3f}",
"NovEnergy": f"{m['estimated_mpp_energy_Wh']:.3f}",
"NovIrradiation": f"{m['irradiation_Wh_m2'] / 1000:.3f}",
"NovEfficiency": f"{m['energy_weighted_aperture_efficiency_pct']:.2f}",
"NovPeakPower": f"{m['peak_power_W']:.3f}",
"NovApertureAtPeakPower": f"{m['aperture_efficiency_at_peak_power_pct']:.2f}",
"NovPeakCurrent": f"{m['peak_power_isc_A'] * 1000:.1f}",
"NovPeakVoltage": f"{m['peak_power_voc_V']:.3f}",
"NovPeakIrradiance": f"{m['peak_power_irradiance_W_m2']:.1f}",
"NovRelativeMax": f"{m['max_reference_ratio_pct']:.2f}",
"NovRelativePercentile": f"{m['p99_reference_ratio_pct']:.2f}",
"NovSiliconStandardRatio": f"{m['cpv_to_silicon_19_ratio_pct'] / 100:.3f}",
"NovSiliconPremiumRatio": f"{m['cpv_to_silicon_21_ratio_pct'] / 100:.3f}",
"NovSteadyEfficiency": f"{summary['phases'][2]['energy_weighted_aperture_efficiency_pct']:.2f}",
}
METRICS_TEX.write_text(
"% Generated by analyze_2026_11_04.py.\n" + "\n".join(
"\\newcommand{\\" + name + "}{" + value + "}" for name, value in macros.items()
) + "\n", encoding="utf-8")
tex = rf"""% Generated by analyze_2026_11_04.py; do not hand-edit numerical values.
\subsection{{Acquisition coverage and operating chronology}}
The acquired span is 09:52:50--16:05:13, comprising \NovSamples{{}}
electrical/coordinate observations and {summary['tel_records']:,} separate
telemetry messages. All main observations pass the ADC quality checks and
exceed the analysis illumination threshold. There are no duplicate main
timestamps or decreasing main-sample uptimes. Five gaps exceed 15~s:
{gap_text}. Excluding these gaps gives \NovHours{{}}~h of integration
support out of {m['elapsed_span_h']:.3f}~h elapsed, or {m['coverage_pct']:.2f}\%
coverage. The audit retains every acquired observation; no clipped samples
or unobserved intervals are reconstructed.
The complete overview in Fig.~\ref{{fig:nov04_overview}} connects electrical
response with stage motion. There are {summary['cloud_commands']} logged remote
commands, including early tuning and repositioning. For example,
\texttt{{Delay=4}} was logged at 12:27:20, before the 12:32 boundary. Three event boundaries
divide the record without selecting a favorable performance threshold:
\texttt{{ErrMax=1100}} at 12:32:02, \texttt{{ErrMax=1000}} at 13:53:00,
and the first late manual-coordinate command at 15:12:42. All four sequential
phases are retained in Table~\ref{{tab:nov04_phases}}. No additional remote
commands are recorded inside the 12:32--13:53 phase; periodic controller
reference resets continue. This is an interval without logged operator
interventions, not a controlled comparison of firmware or settings.
\subsection{{Electrical output and aperture estimate}}
Applying Eqs.~\eqref{{eq:power_estimate}}--\eqref{{eq:aperture}} over the
complete accepted support yields \NovEnergy{{}}~Wh of estimated available-MPP
energy from \NovIrradiation{{}}~kWh\,m$^{{-2}}$ of measured aperture-plane irradiation,
with an energy-weighted aperture estimate of \NovEfficiency\%.
Table~\ref{{tab:nov04_hourly}} shows every acquired clock-hour bin, including
the initial low response and late decline, rather than replacing the complete
record with a midday-only integral.
At the maximum of the power estimate ({m['peak_power_timestamp']}), the
sequentially recorded values in that observation were
$I_{{sc}}={m['peak_power_isc_A']*1000:.1f}$ mA,
$V_{{oc}}={m['peak_power_voc_V']:.3f}$ V and $G={m['peak_power_irradiance_W_m2']:.1f}$ W m$^{{-2}}$,
giving {m['peak_power_W']:.3f}~W estimated output and an instantaneous aperture
estimate of \NovApertureAtPeakPower\% at that observation. Separate maxima are not combined
into a synthetic operating point. The 12:32--13:53 phase contributes
{summary['phases'][2]['estimated_mpp_energy_Wh']:.3f}~Wh over
{summary['phases'][2]['integrated_support_h']:.3f}~h, with an aperture estimate
of \NovSteadyEfficiency\%. The 15:00--16:00 clock-hour estimate falls to
{summary['hourly'][6]['energy_weighted_aperture_efficiency_pct']:.2f}\%, and
the phase after the first 15:12 manual command gives
{summary['phases'][4]['energy_weighted_aperture_efficiency_pct']:.2f}\%.
The record shows deterioration before and after that command, not evidence
that the command alone caused it.
\begin{{table}}[htbp]
\centering\small
\caption{{Complete acquisition and fixed clock-hour summaries. $H$ is aperture-plane
irradiation, $\widehat E$ is FF-based available energy and
$\widehat\eta_{{\mathrm{{ap}},E}}$ is the energy-weighted aperture estimate.
Bins contain only acquired observations. Both edge endpoints must lie inside a
bin, so subdivided integrals omit boundary-crossing edges and need not sum
exactly to the full-record integral.}}
\label{{tab:nov04_hourly}}
\begin{{tabular}}{{lrrrrr}}
\toprule
Window & $N$ & Support [h] & $H$ [kWh\,m$^{{-2}}$] & $\widehat E$ [Wh] & $\widehat\eta_{{\mathrm{{ap}},E}}$ [\%] \\
\midrule
{summary['table_rows']}
\bottomrule
\end{{tabular}}
\end{{table}}
\begin{{table}}[htbp]
\centering\small
\caption{{All sequential event-defined phases. Internal command boundaries
belong to the following phase; the final acquired sample is retained.
As in Table~\ref{{tab:nov04_hourly}},
edges crossing a boundary are omitted. Phases describe operation; they are not
matched experimental treatments.}}
\label{{tab:nov04_phases}}
\begin{{tabular}}{{lrrrr}}
\toprule
Phase boundary clocks & $N$ & Support [h] & $\widehat E$ [Wh] & $\widehat\eta_{{\mathrm{{ap}},E}}$ [\%] \\
\midrule
{summary['phase_table_rows']}
\bottomrule
\end{{tabular}}
\end{{table}}
\subsection{{Relative response and solar-position context}}
An empirical same-record $I_{{sc}}/G$ reference is highly sensitive to one point:
the maximum is {summary['reference_max']:.6f} A m$^2$ W$^{{-1}}$ at
{summary['anomaly_time']}, where $G={summary['anomaly_G']:.1f}$ W m$^{{-2}}$ and
$I_{{sc}}={summary['anomaly_I']*1000:.1f}$ mA; the P99 reference is
{summary['reference_p99']:.6f} A m$^2$ W$^{{-1}}$. The anomalous sample is retained.
In its four-minute neighborhood, median irradiance is
{summary['anomaly_neighborhood']['irradiance_median']:.1f} W m$^{{-2}}$
({summary['anomaly_neighborhood']['count']} samples), consistent with an isolated
irradiance dip rather than sustained low irradiance; the cause remains unresolved.
Across the accepted illuminated samples, median $I_{{sc}}/G$ is
{summary['responsivity_distribution']['median']:.6f} A m$^2$ W$^{{-1}}$
(interquartile range {summary['responsivity_distribution']['q25']:.6f}--
{summary['responsivity_distribution']['q75']:.6f}).
Full-record integrated relative-yield indicators are
{m['max_reference_ratio_pct']:.1f}\% (maximum reference) and
{m['p99_reference_ratio_pct']:.1f}\% (P99 sensitivity). These are internal,
reference-dependent indicators rather than independently validated tracking
efficiencies. Figure~\ref{{fig:nov04_response}} displays both the full
responsivity range and a typical-range view; the anomalous observation remains
in the analysis.
Interpolated SunCalc positions at the first and last observations are
{summary['solar_context']['first_azimuth_deg']:.2f}$^\circ$ and
{summary['solar_context']['last_azimuth_deg']:.2f}$^\circ$ azimuth, with elevations
{summary['solar_context']['first_elevation_deg']:.2f}$^\circ$ and
{summary['solar_context']['last_elevation_deg']:.2f}$^\circ$, respectively.
The printed table reaches {summary['solar_context']['maximum_elevation_deg']:.2f}$^\circ$
elevation at 12:30. Figure~\ref{{fig:nov04_solar_response}} relates this
trajectory to the measured normalized response. Logged coordinates span
$X={summary['coordinates']['x_min']:,}$--${summary['coordinates']['x_max']:,}$
and $Y={summary['coordinates']['y_min']:,}$--${summary['coordinates']['y_max']:,}$
steps. These are observed solar and coordinate ranges, not independently
validated acceptance angles or rated mechanical limits.
\subsection{{Conditional fixed-silicon reference}}
The coplanar, equal-area scenarios of Eq.~\eqref{{eq:silicon_scenario}}
yield
{m['silicon_19_energy_Wh']:.3f} and {m['silicon_21_energy_Wh']:.3f} Wh, respectively;
the corresponding estimated-CPV-to-scenario ratios are
\NovSiliconStandardRatio{{}} and \NovSiliconPremiumRatio{{}}.
Figure~\ref{{fig:nov04_silicon}} illustrates these assumptions, not measured
comparative yields. Changing the assumed CPV fill factor from 0.82 to 0.70 or
0.90 would change the full-record energy estimate to
{m['estimated_mpp_energy_Wh']*.70/FILL_FACTOR:.3f} or
{m['estimated_mpp_energy_Wh']*.90/FILL_FACTOR:.3f}~Wh, respectively.
This sensitivity range is illustrative, not a measured uncertainty interval.
\begin{{figure}}[htbp]\centering
\includegraphics[width=0.98\linewidth]{{../figures/2026-11-04/field_day_overview.pdf}}
\caption{{Complete 4 November acquisition. Vertical dotted lines mark sampling
gaps; dashed lines mark the three selected phase-boundary commands. Raw trends are not smoothed, and gaps are
not bridged. Electrical output is estimated using $FF=0.82$.}}
\label{{fig:nov04_overview}}\end{{figure}}
\begin{{figure}}[htbp]\centering
\includegraphics[width=0.92\linewidth]{{../figures/2026-11-04/solar_response_alignment.pdf}}
\caption{{SunCalc azimuth/elevation context aligned to the measured response.
SunCalc entries are spaced 15 minutes; intermediate positions are linearly
interpolated for plotting. Color encodes $I_{{sc}}/G$, not pointing error;
values above P99 remain plotted in the red over-range color.}}
\label{{fig:nov04_solar_response}}\end{{figure}}
\begin{{figure}}[htbp]\centering
\includegraphics[width=0.88\linewidth]{{../figures/2026-11-04/response_reference_sensitivity.pdf}}
\caption{{Irradiance-normalized response for the full record. The maximum
reference is dominated by a retained anomalous observation; P99 is shown only as
a sensitivity analysis. Neither line is an independent ideal-tracking standard.}}
\label{{fig:nov04_response}}\end{{figure}}
\begin{{figure}}[htbp]\centering
\includegraphics[width=0.75\linewidth]{{../figures/2026-11-04/fixed_silicon_scenario.pdf}}
\caption{{Conditional equal-area scenarios using the measured aperture-plane irradiance.
Silicon efficiency is held constant at 19\% or 21\%; no panel,
temperature correction or measured head-to-head comparison is represented.}}
\label{{fig:nov04_silicon}}\end{{figure}}
"""
RESULT_TEX.write_text(tex, encoding="utf-8")
def run():
frame, telemetry, events, source_hash = parse_log()
frame = calibrate(frame)
sun = suncalc_frame()
frame = add_solar_context(frame, sun)
frame["integrate_from_previous"] = accepted_edges(frame)
frame["interval_from_previous_s"] = frame.timestamp.diff().dt.total_seconds()
event_table = make_events(events, frame, telemetry)
OUT.mkdir(parents=True, exist_ok=True)
summary_full = metrics(frame)
gaps = event_table[event_table.event_type == "sampling_gap"]
commands = events[events.message.str.contains(r"\[CLOUD\]", regex=True, na=False)]
# Phases use logged cut points, not a performance-selected window.
milestones = []
for message in ("[CLOUD] ErrMax=1100", "[CLOUD] ErrMax=1000",
"[CLOUD] Set Coords X:115000, Y:79000"):
matches = events.loc[events.message.eq(message)]
if len(matches) != 1:
raise ValueError(f"Expected exactly one phase-boundary event: {message}")
milestones.append(matches.iloc[0])
boundaries = [event.timestamp for event in milestones]
end = frame.timestamp.max() + pd.Timedelta(seconds=1)
phase_defs = [("Full acquired record", frame.timestamp.min(), end),
("Initial tuning and repositioning", frame.timestamp.min(), boundaries[0]),
("After 12:32 ErrMax update", boundaries[0], boundaries[1]),
("After 13:53 ErrMax update", boundaries[1], boundaries[2]),
("After 15:12 manual coordinates", boundaries[2], end)]
rows = []
for name, start, end in phase_defs:
m = metrics(frame, start, end)
m["window"] = name
m["window_start"] = str(start)
m["window_end"] = str(min(end, frame.timestamp.max()))
rows.append(m)
# Fixed clock-hour bins retain late-session deterioration and expose support.
hourly = []
for h in range(9, 17):
start = pd.Timestamp(f"2026-11-04 {h:02d}:00:00")
m = metrics(frame, start, start + pd.Timedelta(hours=1))
m["window"] = f"{h:02d}:00--{h+1:02d}:00"
hourly.append(m)
# Common thresholds and gap policies are explicit sensitivity checks.
sensitivity = []
for gap_limit in (10, 15, 30, 60):
for light_limit in (0, 50, 100):
saved = frame["illuminated"].copy()
frame["illuminated"] = frame.irradiance_W_m2 > light_limit
m = metrics(frame, max_gap_s=gap_limit)
sensitivity.append({"max_gap_s": gap_limit, "minimum_irradiance_W_m2": light_limit,
"support_h": m["integrated_support_h"], "energy_Wh": m["estimated_mpp_energy_Wh"],
"aperture_efficiency_pct": m["energy_weighted_aperture_efficiency_pct"]})
frame["illuminated"] = saved
reference = frame.loc[frame.accepted & frame.illuminated, "responsivity_A_m2_W"]
anomaly_idx = frame.responsivity_A_m2_W.idxmax()
anomaly = frame.loc[anomaly_idx]
anomaly_neighbor = frame.loc[(frame.timestamp >= anomaly.timestamp - pd.Timedelta(minutes=2)) &
(frame.timestamp <= anomaly.timestamp + pd.Timedelta(minutes=2))]
anomaly_neighbor_stats = {"count": len(anomaly_neighbor), "irradiance_min": float(anomaly_neighbor.irradiance_W_m2.min()),
"irradiance_median": float(anomaly_neighbor.irradiance_W_m2.median()),
"irradiance_max": float(anomaly_neighbor.irradiance_W_m2.max()),
"responsivity_median": float(anomaly_neighbor.responsivity_A_m2_W.median())}
summary = {
"source": str(SOURCE.relative_to(BASE)), "source_sha256": source_hash,
"suncalc_source_sha256": hashlib.sha256(SUNCALC_SOURCE.read_bytes()).hexdigest(),
"analysis_source_sha256": hashlib.sha256(Path(__file__).read_bytes()).hexdigest(),
"source_bytes": SOURCE.stat().st_size, "source_lines": len(SOURCE.read_text(encoding="utf-8-sig").splitlines()),
"sample_count": len(frame), "first_time": str(frame.timestamp.min()), "last_time": str(frame.timestamp.max()),
"firmware": ", ".join(frame.attrs["firmware_versions"]), "cloud_commands": len(commands),
"phase_boundary_events": [{"timestamp": str(event.timestamp), "message": event.message,
"source_line": int(event.source_line)} for event in milestones],
"tel_records": len(telemetry), "uptime_decreases": int(frame.uptime_s.diff().lt(0).sum()),
"tel_uptime_decreases": int(telemetry.uptime_s.diff().lt(0).sum()),
"tel_gaps_over_15s": int(telemetry.timestamp.diff().dt.total_seconds().gt(MAX_GAP_S).sum()),
"tel_negative_step_deltas": int(telemetry.delta.lt(0).sum()),
"duplicate_timestamps": int(frame.timestamp.duplicated().sum()),
"gap_starts": [str(x) for x in gaps.timestamp],
"gap_records": gaps[["timestamp", "message", "source_line", "previous_source_line", "uptime_decreased"]].to_dict("records"),
"flags": {k: int(frame[k].sum()) for k in ("adc_clipped", "near_current_rail", "invalid_polarity", "illuminated", "accepted")},
"full": summary_full, "phases": rows, "hourly": hourly,
"solar_context": {"first_azimuth_deg": float(frame.azimuth_deg.iloc[0]),
"last_azimuth_deg": float(frame.azimuth_deg.iloc[-1]),
"first_elevation_deg": float(frame.solar_elevation_deg.iloc[0]),
"last_elevation_deg": float(frame.solar_elevation_deg.iloc[-1]),
"maximum_elevation_deg": float(sun.solar_elevation_deg.max()),
"clock_alignment": "Assumed log clock matches printed SunCalc local time; UTC offset unencoded"},
"coordinates": {"x_min": int(frame.x_steps.min()), "x_max": int(frame.x_steps.max()),
"y_min": int(frame.y_steps.min()), "y_max": int(frame.y_steps.max())},
"reference_max": float(reference.max()), "reference_p99": float(reference.quantile(.99)),
"anomaly_time": str(anomaly.timestamp), "anomaly_line": int(anomaly.source_line),
"anomaly_G": float(anomaly.irradiance_W_m2), "anomaly_I": float(anomaly.isc_A),
"anomaly_resp": float(anomaly.responsivity_A_m2_W), "anomaly_neighborhood": anomaly_neighbor_stats,
"responsivity_distribution": {
"median": float(reference.median()), "q25": float(reference.quantile(.25)),
"q75": float(reference.quantile(.75)), "p95": float(reference.quantile(.95)),
},
"calibration": {"adc_positive_full_scale": ADC_MAX, "vref_V": VREF_V, "current_gain": CURRENT_GAIN,
"shunt_ohm": SHUNT_OHM, "pyranometer_mV_per_W_m2": PYRANOMETER_MV_PER_W_M2,
"aperture_m2": APERTURE_M2, "fill_factor": FILL_FACTOR,
"max_gap_s": MAX_GAP_S, "minimum_irradiance_W_m2": MIN_IRRADIANCE_W_M2},
"irradiance_geometry": {"pyranometer_coplanar_with_lens": True,
"provenance": "Experimenter confirmation on 2026-11-04",
"measured_quantity": "Global irradiance in the fixed lens-aperture plane",
"extra_cosine_factor_applied": False},
"silicon_scenario": {"area_m2": APERTURE_M2, "efficiencies": [.19, .21],
"basis": "constant-efficiency equal-area coplanar silicon scenario with measured aperture-plane irradiance; no temperature correction; not measured panel"},
"software": {"python": sys.version.split()[0], "numpy": np.__version__, "pandas": pd.__version__, "matplotlib": matplotlib.__version__},
}
# Text rows show complete record plus fixed clock bins without selecting a best interval.
tab = [summary_full] + hourly
summary["table_rows"] = "\n".join(
f"{r['window'] if 'window' in r else 'Full acquired record'} & {r['accepted_illuminated_samples']} & "
f"{r['integrated_support_h']:.3f} & {r['irradiation_Wh_m2'] / 1000:.3f} & {r['estimated_mpp_energy_Wh']:.4f} & "
f"{r['energy_weighted_aperture_efficiency_pct']:.2f} " + "\\" * 2 for r in tab)
phase_rows = rows[1:]
summary["phase_table_rows"] = "\n".join(
f"{pd.Timestamp(r['window_start']).strftime('%H:%M:%S')}--{pd.Timestamp(r['window_end']).strftime('%H:%M:%S')} & "
f"{r['accepted_illuminated_samples']} & {r['integrated_support_h']:.3f} & "
f"{r['estimated_mpp_energy_Wh']:.3f} & {r['energy_weighted_aperture_efficiency_pct']:.2f} " + "\\" * 2
for r in phase_rows)
# Mark command milestones relevant to phase boundaries without obscuring traces.
summary["command_times"] = [str(t) for t in boundaries]
summary["reference_max"] = float(reference.max()); summary["reference_p99"] = float(reference.quantile(.99))
frame.to_csv(OUT / "audit_samples.csv", index=False, float_format="%.12g")
event_table.to_csv(OUT / "event_chronology.csv", index=False)
sun = suncalc_frame()
sun.to_csv(OUT / "suncalc_2026-11-04.csv", index=False, float_format="%.5f")
pd.DataFrame(rows + hourly).to_csv(OUT / "window_metrics.csv", index=False, float_format="%.10g")
pd.DataFrame(sensitivity).to_csv(OUT / "sensitivity.csv", index=False, float_format="%.10g")
(OUT / "analysis_summary.json").write_text(json.dumps(summary, indent=2, default=str, allow_nan=False) + "\n", encoding="utf-8")
gap_text = "; ".join(
f"{frame.loc[frame.source_line == int(g.previous_source_line), 'timestamp'].iloc[0].strftime('%H:%M:%S')}--"
f"{pd.Timestamp(g.timestamp).strftime('%H:%M:%S')} ({int(float(g.message.split()[2]))} s)"
for g in gaps.itertuples() if g.event_type == "sampling_gap")
write_result_tex(summary, gap_text)
plot_outputs(frame, event_table, sun, summary)
return summary
if __name__ == "__main__":
result = run()
print(json.dumps({"samples": result["sample_count"], "full": result["full"],
"gaps": result["gap_records"], "source_sha256": result["source_sha256"]},
indent=2, default=str))
+126
View File
@@ -0,0 +1,126 @@
$ErrorActionPreference='Stop'
$root='c:\Users\fdavalos\OneDrive - up.edu.mx\microtracking\MicrotrackingFer'
$logRe='^(?:LOG:)?\[([\d\-/ :]+)\] X:(-?\d+),Y:(-?\d+),C:(\d+),V:(\d+),P:(\d+),S:(\d+),er:(\d+)$'
$ftRe='^(?:LOG:)?\[([\d\-/ :]+)\] FineTune: dC=(\d+), dynE=(\d+), e=(\d+), near=(\d), Cmax=(\d+)$'
function ParseLog([string]$path){
$o=@{}
foreach($t in @('X','Y','C','V','P','S','E','ftDc','ftDynE','ftE','ftNear','ftCmax')){$o[$t]=New-Object 'System.Collections.Generic.List[long]'}
$o.ts=New-Object 'System.Collections.Generic.List[string]'
$o.ftTs=New-Object 'System.Collections.Generic.List[string]'
$o.spec=@{}
$o.other=New-Object 'System.Collections.Generic.List[string]'
$o.posVals=@{}
$o.posSamples=New-Object 'System.Collections.Generic.List[string]'
$o.cloud=New-Object 'System.Collections.Generic.List[string]'
$lines=[IO.File]::ReadAllLines($path)
$o.total=$lines.Count
$o.lines_ref=$lines[0]; $o.last_ref=$lines[$lines.Count-1]
foreach($ln in $lines){
if($ln -match $logRe){
$o.ts.Add($matches[1])
$o.X.Add([long]$matches[2]); $o.Y.Add([long]$matches[3]); $o.C.Add([long]$matches[4])
$o.V.Add([long]$matches[5]); $o.P.Add([long]$matches[6]); $o.S.Add([long]$matches[7]); $o.E.Add([long]$matches[8])
} elseif($ln -match $ftRe){
$o.ftTs.Add($matches[1])
$o.ftDc.Add([long]$matches[2]); $o.ftDynE.Add([long]$matches[3]); $o.ftE.Add([long]$matches[4]); $o.ftNear.Add([long]$matches[5]); $o.ftCmax.Add([long]$matches[6])
} else {
$t2=$ln.Trim()
if($t2 -eq ''){ $o.spec['empty']=1+[int]$o.spec['empty'] }
elseif($ln -match 'COM READY'){ $o.spec['COM READY']=1+[int]$o.spec['COM READY']; $o.cloud.Add(('COMREADY | '+$ln)) }
elseif($ln -match 'WS cerrado'){ $o.spec['WS cerrado']=1+[int]$o.spec['WS cerrado']; $o.cloud.Add(('WSCLOSED | '+$ln)) }
elseif($ln -match 'Desconectado'){ $o.spec['Desconectado']=1+[int]$o.spec['Desconectado']; $o.cloud.Add(('DISCON | '+$ln)) }
elseif($ln -match 'Servidor'){ $o.spec['Servidor']=1+[int]$o.spec['Servidor']; $o.cloud.Add(('SERV | '+$ln)) }
elseif($ln -match '^POS:(\d+):(\d+)$'){ $o.spec['POS:']=1+[int]$o.spec['POS:']; $v=$matches[1]+':'+$matches[2]; $o.posVals[$v]=1+[int]$o.posVals[$v]; if($o.posSamples.Count -lt 4){$o.posSamples.Add($ln)} }
else { $o.spec['other']=1+[int]$o.spec['other']; if($o.other.Count -lt 6){ $o.other.Add($ln) } }
}
}
return $o
}
#MORE1
function Analyze($o){
$n=$o.ts.Count
if($n -eq 0){ Write-Host ' (sin muestras LOG)'; return }
$t=@($o.ts | %{ [datetime]::Parse($_) })
Write-Host (" lineasTotales={0} LOG={1} FT={2}" -f $o.total,$n,$o.ftTs.Count)
Write-Host (" ventana: {0} -> {1} (span {2} min)" -f $t[0].ToString('yyyy-MM-dd HH:mm:ss'),$t[$n-1].ToString('HH:mm:ss'),[int]($t[$n-1]-$t[0]).TotalMinutes)
Write-Host (" 1a linea: "+$o.lines_ref)
Write-Host (" ul linea: "+$o.last_ref)
$dts=New-Object 'double[]' ($n-1)
for($i=1;$i -lt $n;$i++){$dts[$i-1]=($t[$i]-$t[$i-1]).TotalSeconds}
$med=$dts|Sort-Object|Select-Object -Index [int]($dts.Count/2)
$g=1..($n-1) | ?{ ($t[$_] - $t[$_-1]).TotalSeconds -gt 15 }
Write-Host (" dt(s): min={0} med={1} max={2} gaps>15s: {3}" -f [int]($dts|Measure-Object -Minimum).Minimum,[int]$med,[int]($dts|Measure-Object -Maximum).Maximum,$g.Count)
if($g.Count -gt 0){ Write-Host (" gaps: "+(($g|select -First 10)|%{ $t[$_].ToString('HH:mm:ss')+"(+"+[int]($t[$_]-$t[$_-1]).TotalSeconds+"s)" }) -join ' | ') }
$back=1..($n-1) | ?{ $o.S[$_] -lt $o.S[$_-1] }
$dup=1..($n-1) | ?{ $o.S[$_] -eq $o.S[$_-1] }
Write-Host (" S: {0} -> {1} ({2} u/s) S-back={3} S-dup={4}" -f $o.S[0],$o.S[$n-1],([math]::Round(($o.S[$n-1]-$o.S[0])/($t[$n-1]-$t[0]).TotalSeconds,3)),$back.Count,$dup.Count)
Write-Host (" spec: "+(($o.spec.GetEnumerator() | ?{$_.Value -gt 0} | %{$_.Key+"="+$_.Value}) -join ', '))
if($o.posVals.Count -gt 0){
$top=$o.posVals.GetEnumerator()|Sort-Object Value -Descending|Select-Object -First 12
Write-Host (" POS: distintos={0} top: {1}" -f $o.posVals.Count,(($top|%{"$($_.Key)x$($_.Value)"}) -join ' '))
foreach($s in $o.posSamples){ Write-Host (" sample: "+$s) }
}
if($o.other.Count -gt 0){ foreach($s in $o.other){ Write-Host (" other: "+$s.Substring(0,[Math]::Min(90,$s.Length))) } }
if($o.cloud.Count -gt 0){
Write-Host (" cloud ({0} eventos) 1ro: "+$o.cloud[0])
Write-Host (" cloud ultimo: "+$o.cloud[$o.cloud.Count-1])
}
$runs=New-Object 'System.Collections.Generic.List[string]'
$cur=$o.E[0]; $st=0
for($i=1;$i -le $n;$i++){
if($i -lt $n -and $o.E[$i] -eq $cur){ continue }
$runs.Add( ("er="+$cur+": "+$t[$st].ToString('HH:mm:ss')+"->"+$t[$i-1].ToString('HH:mm:ss')+" (n="+($i-$st)+")") )
if($i -lt $n){ $cur=$o.E[$i]; $st=$i }
}
Write-Host (" er(t): "+($runs -join ' '))
$nPos=@{}
for($i=0;$i -lt $n;$i++){$k=$o.X[$i].ToString()+':'+$o.Y[$i].ToString(); $nPos[$k]=1+[int]$nPos[$k]}
$fm=(1..($n-1) | ?{ $o.X[$_] -ne $o.X[0] -or $o.Y[$_] -ne $o.Y[0] } | select -First 1)
$fmS=if($null -ne $fm){ $t[$fm].ToString('HH:mm:ss') } else { 'nunca' }
Write-Host (" X: {0}..{1} Y: {2}..{3} pos.distintas={4} 1er.mov={5}" -f ($o.X|Measure-Object -Minimum).Minimum,($o.X|Measure-Object -Maximum).Maximum,($o.Y|Measure-Object -Minimum).Minimum,($o.Y|Measure-Object -Maximum).Maximum,$nPos.Count,$fmS)
$topPos=$nPos.GetEnumerator()|Sort-Object Value -Descending|Select-Object -First 5
Write-Host (" pos.mas estacionadas: "+(($topPos|%{"$($_.Key) x$($_.Value)"}) -join ' '))
$idx=@(0,[int]($n/4),[int]($n/2),[int](3*$n/4),($n-1))
Write-Host ' checkpoints (hora | X:Y | C | V | P | er):'
foreach($i in $idx){ Write-Host (" "+$t[$i].ToString('HH:mm:ss')+" | "+$o.X[$i]+":"+$o.Y[$i]+" | C="+$o.C[$i]+" V="+$o.V[$i]+" P="+$o.P[$i]+" er="+$o.E[$i]) }
Write-Host (" C: min={0} max={1} mean={2} P max={3}" -f ($o.C|Measure-Object -Minimum).Minimum,($o.C|Measure-Object -Maximum).Maximum,[int]($o.C|Measure-Object -Average).Average,($o.P|Measure-Object -Maximum).Maximum)
$drop=1..($n-1) | ?{ ($o.C[$_] - $o.C[$_-1]) -lt -10000 }
Write-Host (" saltos grandes C (>10k uA): "+$drop.Count)
if($drop.Count -gt 0){ $drop | Sort-Object { $o.C[$_] - $o.C[$_-1] } | select -First 6 | %{ Write-Host (" "+$t[$_].ToString('HH:mm:ss')+" C "+$o.C[$_-1]+" -> "+$o.C[$_]+" (d="+($o.C[$_]-$o.C[$_-1])+")") } }
$m=$o.ftTs.Count
if($m -gt 0){
$near0=(0..($m-1) | ?{ $o.ftNear[$_] -eq 0 }).Count
Write-Host (" FT: near=0 en {0}/{1} ({2}%) dC max={3} dynE: {4}..{5} e top: {6}" -f $near0,$m,[math]::Round(100*$near0/$m,1),($o.ftDc|Measure-Object -Maximum).Maximum,($o.ftDynE|Measure-Object -Minimum).Minimum,($o.ftDynE|Measure-Object -Maximum).Maximum,((($o.ftE|Group-Object|Sort-Object {$_.Count} -Descending|select -First 6)|%{"$($_.Name)x$($_.Count)"}) -join ', '))
}
}
#MORE2
$files=@('full_log_21082025.txt','log(3).txt','gyuggyhoy.txt')
$logs=@{}
foreach($f in $files){
Write-Host ("########## $f ##########")
$o=ParseLog (Join-Path $root $f)
$logs[$f]=$o
Analyze $o
Write-Host ''
}
Write-Host '########## CROSS-CHECK log(3) vs gyuggyhoy (ambos 14/09) ##########'
$a=$logs['log(3).txt']; $b=$logs['gyuggyhoy.txt']
$da=@{}; for($i=0;$i -lt $a.ts.Count;$i++){$da[$a.ts[$i]]=@($a.X[$i],$a.Y[$i],$a.C[$i])}
$db=@{}; for($i=0;$i -lt $b.ts.Count;$i++){$db[$b.ts[$i]]=@($b.X[$i],$b.Y[$i],$b.C[$i])}
$common=@($da.Keys | ?{ $db.ContainsKey($_) } | Sort-Object)
Write-Host (" log(3): "+$a.ts[0]+" -> "+$a.ts[$a.ts.Count-1])
Write-Host (" gyuggyhoy: "+$b.ts[0]+" -> "+$b.ts[$b.ts.Count-1])
if($common.Count -gt 0){
Write-Host (" ventana comun: "+$common.Count+" ts ("+$common[0]+" -> "+$common[$common.Count-1]+")")
$xm=0; $cm=0; $samps=@()
foreach($ts in $common){
if($da[$ts][0] -ne $db[$ts][0] -or $da[$ts][1] -ne $db[$ts][1]){$xm++}
if($da[$ts][2] -ne $db[$ts][2]){$cm++; if($samps.Count -lt 5){ $samps.Add(("$ts log3 X/Y="+$da[$ts][0]+":"+$da[$ts][1]+" C="+$da[$ts][2]+" | guggy X/Y="+$db[$ts][0]+":"+$db[$ts][1]+" C="+$db[$ts][2])) } }
}
Write-Host (" mismatches en ventana comun: X/Y="+$xm+" C="+$cm)
foreach($s in $samps){ Write-Host (" "+$s) }
} else { Write-Host ' ventana comun: vacia' }
Write-Host (" boot estimado log(3): "+([datetime]::Parse($a.ts[0]).AddSeconds(-[long]$a.S[0])).ToString('HH:mm:ss')+" (S0="+$a.S[0]+" @ "+$a.ts[0]+")")
Write-Host (" boot estimado gyuggyhoy: "+([datetime]::Parse($b.ts[0]).AddSeconds(-[long]$b.S[0])).ToString('HH:mm:ss')+" (S0="+$b.S[0]+" @ "+$b.ts[0]+")")
Write-Host '########## FIN ##########'
+718
View File
@@ -0,0 +1,718 @@
% $ biblatex auxiliary file $
% $ biblatex bbl format version 3.3 $
% Do not modify the above lines!
%
% This is an auxiliary file used by the 'biblatex' package.
% This file may safely be deleted. It will be recreated by
% biber as required.
%
\begingroup
\makeatletter
\@ifundefined{ver@biblatex.sty}
{\@latex@error
{Missing 'biblatex' package}
{The bibliography requires the 'biblatex' package.}
\aftergroup\endinput}
{}
\endgroup
\refsection{0}
\datalist[entry]{none/global//global/global/global}
\entry{mousazadeh2009review}{article}{}{}
\name{author}{6}{}{%
{{hash=e824e52aa2a3a8bff5ea2902854bf63d}{%
family={Mousazadeh},
familyi={M\bibinitperiod},
given={Hossein},
giveni={H\bibinitperiod}}}%
{{hash=26b8818d27f155119b45b2a5e91f30ac}{%
family={Keyhani},
familyi={K\bibinitperiod},
given={Alireza},
giveni={A\bibinitperiod}}}%
{{hash=9df8b66950e0739f6a6557717db5b72c}{%
family={Javadi},
familyi={J\bibinitperiod},
given={Arzhang},
giveni={A\bibinitperiod}}}%
{{hash=fb9779cd36e2f0c80f6d020040cf6192}{%
family={Mobli},
familyi={M\bibinitperiod},
given={Hossein},
giveni={H\bibinitperiod}}}%
{{hash=633d0f4a21f11db8f62f0cb171c317a6}{%
family={Abrinia},
familyi={A\bibinitperiod},
given={Karen},
giveni={K\bibinitperiod}}}%
{{hash=11d0654599e5980c42cc0479e6dcaf94}{%
family={Sharifi},
familyi={S\bibinitperiod},
given={Ahmad},
giveni={A\bibinitperiod}}}%
}
\list{publisher}{1}{%
{Elsevier}%
}
\strng{namehash}{e01e813d810d7b09516ad937cfdb1fed}
\strng{fullhash}{57454e01159eff45a9685706add92d5c}
\strng{fullhashraw}{57454e01159eff45a9685706add92d5c}
\strng{bibnamehash}{e01e813d810d7b09516ad937cfdb1fed}
\strng{authorbibnamehash}{e01e813d810d7b09516ad937cfdb1fed}
\strng{authornamehash}{e01e813d810d7b09516ad937cfdb1fed}
\strng{authorfullhash}{57454e01159eff45a9685706add92d5c}
\strng{authorfullhashraw}{57454e01159eff45a9685706add92d5c}
\field{sortinit}{1}
\field{sortinithash}{4f6aaa89bab872aa0999fec09ff8e98a}
\field{labelnamesource}{author}
\field{labeltitlesource}{title}
\field{journaltitle}{Renewable and sustainable energy reviews}
\field{number}{8}
\field{title}{A review of principle and sun-tracking methods for maximizing solar systems output}
\field{volume}{13}
\field{year}{2009}
\field{pages}{1800\bibrangedash 1818}
\range{pages}{19}
\verb{doi}
\verb 10.1016/j.rser.2009.01.022
\endverb
\endentry
\entry{hafez2018solar}{article}{}{}
\name{author}{3}{}{%
{{hash=14459a81e11881614a7d934b4b9adf2b}{%
family={Hafez},
familyi={H\bibinitperiod},
given={AZ},
giveni={A\bibinitperiod}}}%
{{hash=fd20674dda510067db72990da4f158ae}{%
family={Yousef},
familyi={Y\bibinitperiod},
given={AM},
giveni={A\bibinitperiod}}}%
{{hash=da8e48203a77557994878bf7ab9fa218}{%
family={Harag},
familyi={H\bibinitperiod},
given={NM},
giveni={N\bibinitperiod}}}%
}
\list{publisher}{1}{%
{Elsevier}%
}
\strng{namehash}{4242dd0d0ceb3a95d29da028f8a5cd78}
\strng{fullhash}{4242dd0d0ceb3a95d29da028f8a5cd78}
\strng{fullhashraw}{4242dd0d0ceb3a95d29da028f8a5cd78}
\strng{bibnamehash}{4242dd0d0ceb3a95d29da028f8a5cd78}
\strng{authorbibnamehash}{4242dd0d0ceb3a95d29da028f8a5cd78}
\strng{authornamehash}{4242dd0d0ceb3a95d29da028f8a5cd78}
\strng{authorfullhash}{4242dd0d0ceb3a95d29da028f8a5cd78}
\strng{authorfullhashraw}{4242dd0d0ceb3a95d29da028f8a5cd78}
\field{sortinit}{1}
\field{sortinithash}{4f6aaa89bab872aa0999fec09ff8e98a}
\field{labelnamesource}{author}
\field{labeltitlesource}{title}
\field{journaltitle}{Renewable and Sustainable Energy Reviews}
\field{title}{Solar tracking systems: Technologies and trackers drive types--A review}
\field{volume}{91}
\field{year}{2018}
\field{pages}{754\bibrangedash 782}
\range{pages}{29}
\verb{doi}
\verb 10.1016/j.rser.2018.03.094
\endverb
\endentry
\entry{fuentes2020control}{article}{}{}
\name{author}{7}{}{%
{{hash=1133ace3429bb36f13169ed3d661afc2}{%
family={Fuentes-Morales},
familyi={F\bibinithyphendelim M\bibinitperiod},
given={Rosa\bibnamedelima F},
giveni={R\bibinitperiod\bibinitdelim F\bibinitperiod}}}%
{{hash=c94df6c668b35d33713dbd905b19b2bc}{%
family={Diaz-Ponce},
familyi={D\bibinithyphendelim P\bibinitperiod},
given={Arturo},
giveni={A\bibinitperiod}}}%
{{hash=80b889c4aa49e0b027f7ee85491abe71}{%
family={Peña-Cruz},
familyi={P\bibinithyphendelim C\bibinitperiod},
given={Manuel\bibnamedelima I},
giveni={M\bibinitperiod\bibinitdelim I\bibinitperiod}}}%
{{hash=d3dcaed7cf262d228067a02aefbb76c7}{%
family={Rodrigo},
familyi={R\bibinitperiod},
given={Pedro\bibnamedelima M},
giveni={P\bibinitperiod\bibinitdelim M\bibinitperiod}}}%
{{hash=9c7ab6b9c905d154778a136eb26b39e4}{%
family={Valentín-Coronado},
familyi={V\bibinithyphendelim C\bibinitperiod},
given={Luis\bibnamedelima M},
giveni={L\bibinitperiod\bibinitdelim M\bibinitperiod}}}%
{{hash=4cfcb402b18ac4f325b0e3e8053a7aa0}{%
family={Martell-Chavez},
familyi={M\bibinithyphendelim C\bibinitperiod},
given={Fernando},
giveni={F\bibinitperiod}}}%
{{hash=53a221f00e6e9a37ecd95557510eab0c}{%
family={Pineda-Arellano},
familyi={P\bibinithyphendelim A\bibinitperiod},
given={Carlos\bibnamedelima A},
giveni={C\bibinitperiod\bibinitdelim A\bibinitperiod}}}%
}
\list{publisher}{1}{%
{Elsevier}%
}
\strng{namehash}{1ca5f0acced4dd867c7c1aee44e4d5d1}
\strng{fullhash}{6ff42d0b8ef2ac128cfcfffdeead20e3}
\strng{fullhashraw}{6ff42d0b8ef2ac128cfcfffdeead20e3}
\strng{bibnamehash}{1ca5f0acced4dd867c7c1aee44e4d5d1}
\strng{authorbibnamehash}{1ca5f0acced4dd867c7c1aee44e4d5d1}
\strng{authornamehash}{1ca5f0acced4dd867c7c1aee44e4d5d1}
\strng{authorfullhash}{6ff42d0b8ef2ac128cfcfffdeead20e3}
\strng{authorfullhashraw}{6ff42d0b8ef2ac128cfcfffdeead20e3}
\field{sortinit}{1}
\field{sortinithash}{4f6aaa89bab872aa0999fec09ff8e98a}
\field{labelnamesource}{author}
\field{labeltitlesource}{title}
\field{journaltitle}{Solar Energy}
\field{title}{Control algorithms applied to active solar tracking systems: A review}
\field{volume}{212}
\field{year}{2020}
\field{pages}{203\bibrangedash 219}
\range{pages}{17}
\verb{doi}
\verb 10.1016/j.solener.2020.10.071
\endverb
\endentry
\entry{iqbal2023optical}{article}{}{}
\name{author}{3}{}{%
{{hash=0664aba4d6f9510dfc715503ff1e668d}{%
family={Iqbal},
familyi={I\bibinitperiod},
given={Waseem},
giveni={W\bibinitperiod}}}%
{{hash=a65894b864a4056158acdf72bcb84931}{%
family={Ullah},
familyi={U\bibinitperiod},
given={Irfan},
giveni={I\bibinitperiod}}}%
{{hash=b5bd6aa8e334de72638830aa8eae18d1}{%
family={Shin},
familyi={S\bibinitperiod},
given={Seoyong},
giveni={S\bibinitperiod}}}%
}
\list{publisher}{1}{%
{MDPI}%
}
\strng{namehash}{2aa4a75613f1c0d1ef4ec00803b87bec}
\strng{fullhash}{2aa4a75613f1c0d1ef4ec00803b87bec}
\strng{fullhashraw}{2aa4a75613f1c0d1ef4ec00803b87bec}
\strng{bibnamehash}{2aa4a75613f1c0d1ef4ec00803b87bec}
\strng{authorbibnamehash}{2aa4a75613f1c0d1ef4ec00803b87bec}
\strng{authornamehash}{2aa4a75613f1c0d1ef4ec00803b87bec}
\strng{authorfullhash}{2aa4a75613f1c0d1ef4ec00803b87bec}
\strng{authorfullhashraw}{2aa4a75613f1c0d1ef4ec00803b87bec}
\field{sortinit}{2}
\field{sortinithash}{8b555b3791beccb63322c22f3320aa9a}
\field{labelnamesource}{author}
\field{labeltitlesource}{title}
\field{journaltitle}{Sustainability}
\field{number}{13}
\field{title}{Optical developments in concentrator photovoltaic systems—a review}
\field{volume}{15}
\field{year}{2023}
\field{pages}{10554}
\range{pages}{1}
\verb{doi}
\verb 10.3390/su151310554
\endverb
\endentry
\entry{ceballos2023tracking}{article}{}{}
\name{author}{4}{}{%
{{hash=16075f6fc9e32e8a67a5313c3c2d4b44}{%
family={Ceballos},
familyi={C\bibinitperiod},
given={Maria\bibnamedelima A},
giveni={M\bibinitperiod\bibinitdelim A\bibinitperiod}}}%
{{hash=1d3faf0e9e6d4a15bdb1ae6e7205557a}{%
family={Pérez-Higueras},
familyi={P\bibinithyphendelim H\bibinitperiod},
given={Pedro\bibnamedelima J},
giveni={P\bibinitperiod\bibinitdelim J\bibinitperiod}}}%
{{hash=79c6ddf92e15ea523ac21a2e5290da74}{%
family={Fernández},
familyi={F\bibinitperiod},
given={Eduardo\bibnamedelima F},
giveni={E\bibinitperiod\bibinitdelim F\bibinitperiod}}}%
{{hash=a3e2a878cce2df0c6b6edfbc24184226}{%
family={Almonacid},
familyi={A\bibinitperiod},
given={Florencia\bibnamedelima M},
giveni={F\bibinitperiod\bibinitdelim M\bibinitperiod}}}%
}
\list{publisher}{1}{%
{MDPI}%
}
\strng{namehash}{266476a2d420f95ffda9a64e45ccd50d}
\strng{fullhash}{842406545db43052c4d4606de65b73d9}
\strng{fullhashraw}{842406545db43052c4d4606de65b73d9}
\strng{bibnamehash}{266476a2d420f95ffda9a64e45ccd50d}
\strng{authorbibnamehash}{266476a2d420f95ffda9a64e45ccd50d}
\strng{authornamehash}{266476a2d420f95ffda9a64e45ccd50d}
\strng{authorfullhash}{842406545db43052c4d4606de65b73d9}
\strng{authorfullhashraw}{842406545db43052c4d4606de65b73d9}
\field{sortinit}{3}
\field{sortinithash}{ad6fe7482ffbd7b9f99c9e8b5dccd3d7}
\field{labelnamesource}{author}
\field{labeltitlesource}{title}
\field{journaltitle}{Energies}
\field{number}{15}
\field{title}{Tracking-Integrated {CPV} Technology: State-of-the-Art and Classification}
\field{volume}{16}
\field{year}{2023}
\field{pages}{5605}
\range{pages}{1}
\verb{doi}
\verb 10.3390/en16155605
\endverb
\endentry
\entry{ito2018optical}{article}{}{}
\name{author}{3}{}{%
{{hash=7833f50d7b9aede33f9d21810a75dd5a}{%
family={Ito},
familyi={I\bibinitperiod},
given={Atsushi},
giveni={A\bibinitperiod}}}%
{{hash=9a7a08541977b1e11d75e3ebb43d4b36}{%
family={Sato},
familyi={S\bibinitperiod},
given={Daisuke},
giveni={D\bibinitperiod}}}%
{{hash=048deb855fbfc7f3ff923fe0e25c2003}{%
family={Yamada},
familyi={Y\bibinitperiod},
given={Noboru},
giveni={N\bibinitperiod}}}%
}
\list{publisher}{1}{%
{OSA}%
}
\strng{namehash}{9d15280672af3b7051108e703777a881}
\strng{fullhash}{9d15280672af3b7051108e703777a881}
\strng{fullhashraw}{9d15280672af3b7051108e703777a881}
\strng{bibnamehash}{9d15280672af3b7051108e703777a881}
\strng{authorbibnamehash}{9d15280672af3b7051108e703777a881}
\strng{authornamehash}{9d15280672af3b7051108e703777a881}
\strng{authorfullhash}{9d15280672af3b7051108e703777a881}
\strng{authorfullhashraw}{9d15280672af3b7051108e703777a881}
\field{sortinit}{4}
\field{sortinithash}{9381316451d1b9788675a07e972a12a7}
\field{labelnamesource}{author}
\field{labeltitlesource}{title}
\field{journaltitle}{Optics Express}
\field{number}{18}
\field{title}{Optical design and demonstration of microtracking {CPV} module with bi-convex aspheric lens array}
\field{volume}{26}
\field{year}{2018}
\field{pages}{A879\bibrangedash A891}
\range{pages}{-1}
\verb{doi}
\verb 10.1364/OE.26.00A879
\endverb
\endentry
\entry{askins2019performance}{inproceedings}{}{}
\name{author}{16}{}{%
{{hash=d9e4abb484d3ac8ac7767c205bde9134}{%
family={Askins},
familyi={A\bibinitperiod},
given={S.},
giveni={S\bibinitperiod}}}%
{{hash=1d4250c7262befb5c8df3f43f7aef9d4}{%
family={Petri},
familyi={P\bibinitperiod},
given={D.},
giveni={D\bibinitperiod}}}%
{{hash=4ed0e43c1b83d5c55567443665e33cfd}{%
family={Levrat},
familyi={L\bibinitperiod},
given={J.},
giveni={J\bibinitperiod}}}%
{{hash=22501a89301dffef8c8beb2ca300b035}{%
family={Faes},
familyi={F\bibinitperiod},
given={A.},
giveni={A\bibinitperiod}}}%
{{hash=759607db8b5e1d24af5f48397ddc4b43}{%
family={Champliaud},
familyi={C\bibinitperiod},
given={J.},
giveni={J\bibinitperiod}}}%
{{hash=1dd3b1ef6d2b61106dbaca42d343f969}{%
family={Despeisse},
familyi={D\bibinitperiod},
given={M.},
giveni={M\bibinitperiod}}}%
{{hash=35040e6bffd6afb17927b7fafc3ff74b}{%
family={Dominguez},
familyi={D\bibinitperiod},
given={C.},
giveni={C\bibinitperiod}}}%
{{hash=0e270bf8a1638bc9413e8c47f75733f7}{%
family={Anton},
familyi={A\bibinitperiod},
given={I.},
giveni={I\bibinitperiod}}}%
{{hash=0228001ef8dee404b0a31aee230fdaf3}{%
family={Jost},
familyi={J\bibinitperiod},
given={N.},
giveni={N\bibinitperiod}}}%
{{hash=1ddda2bb28ba45a6f98f7d1669c6c52a}{%
family={Aguilar},
familyi={A\bibinitperiod},
given={A.\bibnamedelimi F.},
giveni={A\bibinitperiod\bibinitdelim F\bibinitperiod}}}%
{{hash=e358134f591b7520473501ff87b244ab}{%
family={Anglade},
familyi={A\bibinitperiod},
given={L.},
giveni={L\bibinitperiod}}}%
{{hash=c3ce12809a4a47a60a0733416c4cf02e}{%
family={Nardin},
familyi={N\bibinitperiod},
given={G.},
giveni={G\bibinitperiod}}}%
{{hash=0bb08137926da4a9ee26863849b82e4f}{%
family={Duchemin},
familyi={D\bibinitperiod},
given={M.},
giveni={M\bibinitperiod}}}%
{{hash=e614b2f7a4668c5faa5d54a7a550af1b}{%
family={Gerlich},
familyi={G\bibinitperiod},
given={F.},
giveni={F\bibinitperiod}}}%
{{hash=91353ea0d9417cb0cc06262547f901c4}{%
family={Ackerman},
familyi={A\bibinitperiod},
given={M.},
giveni={M\bibinitperiod}}}%
{{hash=9384db376fff9ce57ab5d5c458e55fb2}{%
family={Coulot},
familyi={C\bibinitperiod},
given={L.},
giveni={L\bibinitperiod}}}%
}
\list{organization}{1}{%
{IEEE}%
}
\strng{namehash}{1c7b6c72ec44dbfad671098f718edfc3}
\strng{fullhash}{e9f6739285a61fe7b73f2876d3c64c34}
\strng{fullhashraw}{e9f6739285a61fe7b73f2876d3c64c34}
\strng{bibnamehash}{1c7b6c72ec44dbfad671098f718edfc3}
\strng{authorbibnamehash}{1c7b6c72ec44dbfad671098f718edfc3}
\strng{authornamehash}{1c7b6c72ec44dbfad671098f718edfc3}
\strng{authorfullhash}{e9f6739285a61fe7b73f2876d3c64c34}
\strng{authorfullhashraw}{e9f6739285a61fe7b73f2876d3c64c34}
\field{sortinit}{4}
\field{sortinithash}{9381316451d1b9788675a07e972a12a7}
\field{labelnamesource}{author}
\field{labeltitlesource}{title}
\field{booktitle}{2019 IEEE 46th Photovoltaic Specialists Conference (PVSC)}
\field{title}{Performance of hybrid micro-concentrator module with integrated planar tracking and diffuse light collection}
\field{year}{2019}
\field{pages}{2507\bibrangedash 2512}
\range{pages}{6}
\verb{doi}
\verb 10.1109/PVSC40753.2019.8980519
\endverb
\endentry
\entry{younes2022realizing}{article}{}{}
\name{author}{5}{}{%
{{hash=b83866d9725c3d7823c5574e193c5c5d}{%
family={Younes},
familyi={Y\bibinitperiod},
given={Kareem},
giveni={K\bibinitperiod}}}%
{{hash=c2a0d5fd5e3517ef3bb93622e64f4a2c}{%
family={Apostoleris},
familyi={A\bibinitperiod},
given={Harry},
giveni={H\bibinitperiod}}}%
{{hash=28bf933452f28f75847101e1e0bd702c}{%
family={Bin\bibnamedelima Saad},
familyi={B\bibinitperiod\bibinitdelim S\bibinitperiod},
given={Majed},
giveni={M\bibinitperiod}}}%
{{hash=67602f79265e6108c733fc9d300b4fe6}{%
family={Al\bibnamedelima Ghaferi},
familyi={A\bibinitperiod\bibinitdelim G\bibinitperiod},
given={Amal},
giveni={A\bibinitperiod}}}%
{{hash=ce6b64aa2105c8d521d368ae47d47a16}{%
family={Chiesa},
familyi={C\bibinitperiod},
given={Matteo},
giveni={M\bibinitperiod}}}%
}
\list{publisher}{1}{%
{Frontiers Media SA}%
}
\strng{namehash}{93ac5752446c1882d6cb5978fef96da8}
\strng{fullhash}{b9fcf87d3eb941813263a453b3b34b42}
\strng{fullhashraw}{b9fcf87d3eb941813263a453b3b34b42}
\strng{bibnamehash}{93ac5752446c1882d6cb5978fef96da8}
\strng{authorbibnamehash}{93ac5752446c1882d6cb5978fef96da8}
\strng{authornamehash}{93ac5752446c1882d6cb5978fef96da8}
\strng{authorfullhash}{b9fcf87d3eb941813263a453b3b34b42}
\strng{authorfullhashraw}{b9fcf87d3eb941813263a453b3b34b42}
\field{sortinit}{4}
\field{sortinithash}{9381316451d1b9788675a07e972a12a7}
\field{labelnamesource}{author}
\field{labeltitlesource}{title}
\field{journaltitle}{Frontiers in Energy Research}
\field{title}{Realizing high photovoltaic power densities with tracking-integrated concentrator photovoltaics}
\field{volume}{10}
\field{year}{2022}
\field{pages}{842201}
\range{pages}{1}
\verb{doi}
\verb 10.3389/fenrg.2022.842201
\endverb
\endentry
\entry{diasponce2025development}{article}{}{}
\name{author}{8}{}{%
{{hash=16b10c578acbbdff1db63ca41559f13f}{%
family={Díaz-Ponce},
familyi={D\bibinithyphendelim P\bibinitperiod},
given={Arturo},
giveni={A\bibinitperiod}}}%
{{hash=38a31a2f87fbd47d26b0cffb12afaaab}{%
family={Trejo-Zúñiga},
familyi={T\bibinithyphendelim Z\bibinitperiod},
given={Iván},
giveni={I\bibinitperiod}}}%
{{hash=42e5ff6dcab36e23c6374024a6d2aa63}{%
family={Dávalos\bibnamedelima Hernández},
familyi={D\bibinitperiod\bibinitdelim H\bibinitperiod},
given={Fernando},
giveni={F\bibinitperiod}}}%
{{hash=d3dcaed7cf262d228067a02aefbb76c7}{%
family={Rodrigo},
familyi={R\bibinitperiod},
given={Pedro\bibnamedelima M},
giveni={P\bibinitperiod\bibinitdelim M\bibinitperiod}}}%
{{hash=4d0c217b441e12ca8bd1a6077e2c7ff1}{%
family={Ceballos},
familyi={C\bibinitperiod},
given={María\bibnamedelima A},
giveni={M\bibinitperiod\bibinitdelim A\bibinitperiod}}}%
{{hash=2682e6a0e5992d8d43109a29a4e70192}{%
family={Valera-Albacete},
familyi={V\bibinithyphendelim A\bibinitperiod},
given={Álvaro},
giveni={Á\bibinitperiod}}}%
{{hash=8c36773e57051c599e1c671a1753a1c7}{%
family={Almonacid},
familyi={A\bibinitperiod},
given={Florencia},
giveni={F\bibinitperiod}}}%
{{hash=79c6ddf92e15ea523ac21a2e5290da74}{%
family={Fernández},
familyi={F\bibinitperiod},
given={Eduardo\bibnamedelima F},
giveni={E\bibinitperiod\bibinitdelim F\bibinitperiod}}}%
}
\list{publisher}{1}{%
{Elsevier}%
}
\strng{namehash}{898da86d435308304e44b93dbef14acd}
\strng{fullhash}{639845e765f0bff1eef80ddffc2aad90}
\strng{fullhashraw}{639845e765f0bff1eef80ddffc2aad90}
\strng{bibnamehash}{898da86d435308304e44b93dbef14acd}
\strng{authorbibnamehash}{898da86d435308304e44b93dbef14acd}
\strng{authornamehash}{898da86d435308304e44b93dbef14acd}
\strng{authorfullhash}{639845e765f0bff1eef80ddffc2aad90}
\strng{authorfullhashraw}{639845e765f0bff1eef80ddffc2aad90}
\field{sortinit}{5}
\field{sortinithash}{20e9b4b0b173788c5dace24730f47d8c}
\field{labelnamesource}{author}
\field{labeltitlesource}{title}
\field{journaltitle}{Results in Engineering}
\field{title}{Development of a proof-of-concept concentrating photovoltaic module with integrated tracking and azimuthal cell rotation}
\field{volume}{27}
\field{year}{2025}
\field{pages}{106735}
\range{pages}{1}
\verb{doi}
\verb 10.1016/j.rineng.2025.106735
\endverb
\endentry
\entry{suncalc2026}{misc}{}{}
\name{author}{1}{}{%
{{hash=1d5bcf629c7430ee2d0445d9216eda23}{%
family={Hoffmann},
familyi={H\bibinitperiod},
given={Torsten},
giveni={T\bibinitperiod}}}%
}
\strng{namehash}{1d5bcf629c7430ee2d0445d9216eda23}
\strng{fullhash}{1d5bcf629c7430ee2d0445d9216eda23}
\strng{fullhashraw}{1d5bcf629c7430ee2d0445d9216eda23}
\strng{bibnamehash}{1d5bcf629c7430ee2d0445d9216eda23}
\strng{authorbibnamehash}{1d5bcf629c7430ee2d0445d9216eda23}
\strng{authornamehash}{1d5bcf629c7430ee2d0445d9216eda23}
\strng{authorfullhash}{1d5bcf629c7430ee2d0445d9216eda23}
\strng{authorfullhashraw}{1d5bcf629c7430ee2d0445d9216eda23}
\field{sortinit}{7}
\field{sortinithash}{108d0be1b1bee9773a1173443802c0a3}
\field{labelnamesource}{author}
\field{labeltitlesource}{title}
\field{note}{Supplied printout at latitude 21.87304 degrees N and longitude 102.28282 degrees W; local-time azimuth and elevation at 15-minute intervals}
\field{title}{{SunCalc}: Daily Solar-Position Data for Aguascalientes, 4 November 2026}
\field{urlday}{4}
\field{urlmonth}{11}
\field{urlyear}{2026}
\field{year}{2026}
\field{urldateera}{ce}
\verb{urlraw}
\verb https://www.suncalc.org/
\endverb
\verb{url}
\verb https://www.suncalc.org/
\endverb
\endentry
\entry{apogee_sp212_manual}{manual}{}{}
\name{author}{1}{}{%
{{hash=9ceeaf39bcf8c0a9953e16aec2598ba1}{%
family={{Apogee Instruments}},
familyi={A\bibinitperiod}}}%
}
\strng{namehash}{9ceeaf39bcf8c0a9953e16aec2598ba1}
\strng{fullhash}{9ceeaf39bcf8c0a9953e16aec2598ba1}
\strng{fullhashraw}{9ceeaf39bcf8c0a9953e16aec2598ba1}
\strng{bibnamehash}{9ceeaf39bcf8c0a9953e16aec2598ba1}
\strng{authorbibnamehash}{9ceeaf39bcf8c0a9953e16aec2598ba1}
\strng{authornamehash}{9ceeaf39bcf8c0a9953e16aec2598ba1}
\strng{authorfullhash}{9ceeaf39bcf8c0a9953e16aec2598ba1}
\strng{authorfullhashraw}{9ceeaf39bcf8c0a9953e16aec2598ba1}
\field{sortinit}{8}
\field{sortinithash}{a231b008ebf0ecbe0b4d96dcc159445f}
\field{labelnamesource}{author}
\field{labeltitlesource}{title}
\field{title}{{SP-212/SP-215} Pyranometer Instruction Manual}
\field{urlday}{4}
\field{urlmonth}{11}
\field{urlyear}{2026}
\field{urldateera}{ce}
\verb{urlraw}
\verb https://www.apogeeinstruments.com/content/SP-212-215-manual.pdf
\endverb
\verb{url}
\verb https://www.apogeeinstruments.com/content/SP-212-215-manual.pdf
\endverb
\endentry
\entry{pvwatts_module_type}{online}{}{}
\name{author}{1}{}{%
{{hash=a988a3d1fc8b0d60e14ba26fe95fbeec}{%
family={{PVWatts}},
familyi={P\bibinitperiod}}}%
}
\strng{namehash}{a988a3d1fc8b0d60e14ba26fe95fbeec}
\strng{fullhash}{a988a3d1fc8b0d60e14ba26fe95fbeec}
\strng{fullhashraw}{a988a3d1fc8b0d60e14ba26fe95fbeec}
\strng{bibnamehash}{a988a3d1fc8b0d60e14ba26fe95fbeec}
\strng{authorbibnamehash}{a988a3d1fc8b0d60e14ba26fe95fbeec}
\strng{authornamehash}{a988a3d1fc8b0d60e14ba26fe95fbeec}
\strng{authorfullhash}{a988a3d1fc8b0d60e14ba26fe95fbeec}
\strng{authorfullhashraw}{a988a3d1fc8b0d60e14ba26fe95fbeec}
\field{sortinit}{9}
\field{sortinithash}{0a5ebc79d83c96b6579069544c73c7d4}
\field{labelnamesource}{author}
\field{labeltitlesource}{title}
\field{note}{Standard and Premium crystalline-silicon module assumptions}
\field{title}{Module Type}
\field{urlday}{4}
\field{urlmonth}{11}
\field{urlyear}{2026}
\field{urldateera}{ce}
\verb{urlraw}
\verb https://pvwatts.nlr.gov/docs/help_module_type.php
\endverb
\verb{url}
\verb https://pvwatts.nlr.gov/docs/help_module_type.php
\endverb
\endentry
\entry{sandia_poa}{online}{}{}
\name{author}{1}{}{%
{{hash=ae9b7482c36c996765c231757ec6c684}{%
family={{Sandia National Laboratories}},
familyi={S\bibinitperiod}}}%
}
\list{organization}{1}{%
{PV Performance Modeling Collaborative}%
}
\strng{namehash}{ae9b7482c36c996765c231757ec6c684}
\strng{fullhash}{ae9b7482c36c996765c231757ec6c684}
\strng{fullhashraw}{ae9b7482c36c996765c231757ec6c684}
\strng{bibnamehash}{ae9b7482c36c996765c231757ec6c684}
\strng{authorbibnamehash}{ae9b7482c36c996765c231757ec6c684}
\strng{authornamehash}{ae9b7482c36c996765c231757ec6c684}
\strng{authorfullhash}{ae9b7482c36c996765c231757ec6c684}
\strng{authorfullhashraw}{ae9b7482c36c996765c231757ec6c684}
\field{extraname}{1}
\field{sortinit}{1}
\field{sortinithash}{4f6aaa89bab872aa0999fec09ff8e98a}
\field{labelnamesource}{author}
\field{labeltitlesource}{title}
\field{title}{Plane of Array ({POA}) Irradiance}
\field{urlday}{4}
\field{urlmonth}{11}
\field{urlyear}{2026}
\field{urldateera}{ce}
\verb{urlraw}
\verb https://pvpmc.sandia.gov/modeling-guide/1-weather-design-inputs/plane-of-array-poa-irradiance/
\endverb
\verb{url}
\verb https://pvpmc.sandia.gov/modeling-guide/1-weather-design-inputs/plane-of-array-poa-irradiance/
\endverb
\endentry
\entry{sandia_pvwatts}{online}{}{}
\name{author}{1}{}{%
{{hash=ae9b7482c36c996765c231757ec6c684}{%
family={{Sandia National Laboratories}},
familyi={S\bibinitperiod}}}%
}
\list{organization}{1}{%
{PV Performance Modeling Collaborative}%
}
\strng{namehash}{ae9b7482c36c996765c231757ec6c684}
\strng{fullhash}{ae9b7482c36c996765c231757ec6c684}
\strng{fullhashraw}{ae9b7482c36c996765c231757ec6c684}
\strng{bibnamehash}{ae9b7482c36c996765c231757ec6c684}
\strng{authorbibnamehash}{ae9b7482c36c996765c231757ec6c684}
\strng{authornamehash}{ae9b7482c36c996765c231757ec6c684}
\strng{authorfullhash}{ae9b7482c36c996765c231757ec6c684}
\strng{authorfullhashraw}{ae9b7482c36c996765c231757ec6c684}
\field{extraname}{2}
\field{sortinit}{1}
\field{sortinithash}{4f6aaa89bab872aa0999fec09ff8e98a}
\field{labelnamesource}{author}
\field{labeltitlesource}{title}
\field{title}{{PVWatts}}
\field{urlday}{4}
\field{urlmonth}{11}
\field{urlyear}{2026}
\field{urldateera}{ce}
\verb{urlraw}
\verb https://pvpmc.sandia.gov/modeling-guide/2-dc-module-iv/point-value-models/pvwatts/
\endverb
\verb{url}
\verb https://pvpmc.sandia.gov/modeling-guide/2-dc-module-iv/point-value-models/pvwatts/
\endverb
\endentry
\enddatalist
\endrefsection
\endinput
Binary file not shown.
+467
View File
@@ -0,0 +1,467 @@
\documentclass[final,1p,times,nonatbib]{elsarticle}
\usepackage{amssymb,amsmath,booktabs}
\usepackage{tikz}
\usetikzlibrary{positioning,arrows.meta}
\usepackage[backend=biber,style=numeric,sorting=none]{biblatex}
\addbibresource{cas-refs.bib}
\setlength{\emergencystretch}{1em}
\input{metrics_2026_11_04}
\begin{document}
\begin{frontmatter}
\title{Outdoor Characterization of Photocurrent-Based Focal Tracking in a Tracking-Integrated Micro-Concentrator Photovoltaic System}
\author[inst1]{Fernando Dávalos Hernández\corref{cor1}}
\author[inst2,inst3]{Arturo Díaz-Ponce}
\author[inst3]{Iván Trejo-Zúñiga}
\author[inst4]{Rosa C. Anguiano Cota}
\author[inst1]{Pedro M. Rodrigo}
\author[inst5]{María A. Ceballos}
\author[inst5]{Álvaro Valera-Albacete}
\author[inst5]{Florencia Almonacid}
\author[inst5]{Eduardo F. Fernández}
\affiliation[inst1]{organization={Universidad Panamericana, Facultad de Ingeniería},
addressline={Jose María Escrivá de Balaguer 101, Villas Bonaterra},city={Aguascalientes},
postcode={20296},state={Aguascalientes},country={Mexico}}
\affiliation[inst2]{organization={SECIHTI -- Centro de Investigaciones en Óptica, A.C., Unidad Aguascalientes},
addressline={Prol. Constitución 607, Fracc. Reserva Loma Bonita},city={Aguascalientes},
postcode={20200},state={Aguascalientes},country={Mexico}}
\affiliation[inst3]{organization={Laboratory of Energy Innovation and Intelligent and Sustainable Agriculture (LEIISA), Universidad Tecnológica de San Juan del Río},
addressline={Av La Palma No 125 Vista Hermosa},city={San Juan del Río},postcode={76800},
state={Querétaro},country={Mexico}}
\affiliation[inst4]{organization={Facultad de Ingeniería, Universidad Autónoma de Baja California (UABC)},
addressline={Blvd. Benito Juárez SN},city={Mexicali},postcode={21280},
state={Baja California},country={Mexico}}
\affiliation[inst5]{organization={Advances in Photovoltaic Technology (AdPVTech), CEACTEMA, University of Jaén},
addressline={Las Lagunillas Campus},city={Jaén},postcode={23071},state={Jaén},country={Spain}}
\cortext[cor1]{Corresponding author. Email: fdavalos@up.edu.mx}
\begin{abstract}
Tracking-integrated concentrator photovoltaics preserve fixed external optics
while moving the receiver to follow the evolving focal region. Their field
performance therefore depends on both optical collection and internal
positioning, rather than on external solar pointing alone. This work
characterizes a photocurrent-feedback controller and custom ESP32 acquisition
system for a wide-acceptance-angle tracking-integrated micro-concentrator
photovoltaic (WiT-CPV) prototype. The principal experiment is the 4 November
2026 outdoor record in Aguascalientes, Mexico: \NovSamples{} observations
between 09:52 and 16:05, with \NovHours{} h of accepted integration support
and no ADC clipping. Sequential short-circuit-current and open-circuit-voltage
measurements yield an estimated available maximum-power-point energy of
\NovEnergy{} Wh for an assumed fill factor of 0.82. Relative to the
0.002497-m$^2$ lens aperture and the coplanar pyranometric irradiation of
\NovIrradiation{} kWh\,m$^{-2}$, the energy-weighted efficiency estimate is
\NovEfficiency\%. The command-delimited 12:32--13:53 post-tuning phase yields
an aperture estimate of \NovSteadyEfficiency\% without additional logged
operator commands. Solar-position data contextualize the operating trajectory,
while the complete record retains tuning interventions and late response
degradation. Reference sensitivity is examined rather than treated as an
independent tracking-accuracy measurement. An equal-aperture silicon comparison
is reported as a conditional constant-efficiency scenario, not a simultaneous
panel experiment. The results establish an aperture-referenced field
characterization and identify the measurements needed to validate delivered
power, net energy and angular tracking accuracy.
\end{abstract}
\begin{highlights}
\item A \NovSamples{}-sample field record characterizes internal focal tracking
\item Estimated aperture efficiency is \NovEfficiency\% over \NovHours{} h of accepted support
\item A post-tuning phase yields a \NovSteadyEfficiency\% aperture estimate
\item Solar-position context and late response losses define observed operation
\item Fixed-silicon scenarios use equal aperture and explicitly stated assumptions
\end{highlights}
\begin{keyword}
Tracking-integrated CPV \sep Focal tracking \sep Photocurrent feedback \sep
Aperture efficiency \sep Outdoor characterization \sep Silicon photovoltaic benchmark
\end{keyword}
\end{frontmatter}
\section{Introduction}
\label{sec:introduction}
Photovoltaic energy collection depends on the relationship between solar
direction, collector orientation and device response. Conventional
one- and two-axis trackers improve this relationship by moving the module
or concentrator as a whole, at the cost of actuation, support and control
requirements \cite{mousazadeh2009review,hafez2018solar,fuentes2020control}.
In concentrating photovoltaics (CPV), optical alignment is especially
important because displacement of the focal distribution can reduce the
fraction intercepted by a small receiver \cite{iqbal2023optical}.
Tracking-integrated CPV transfers part or all of the required motion inside
the module \cite{ceballos2023tracking}. Microtracking lens arrays, internally
translated receivers and hybrid diffuse-light collection illustrate different
ways of decoupling focal alignment from large external motion
\cite{ito2018optical,askins2019performance,younes2022realizing}.
This distinction matters when interpreting field performance: moving a
receiver beneath fixed optics does not make the optical aperture normal to
the Sun, nor does it eliminate the finite angular and mechanical operating
range of the assembly.
The wide-acceptance-angle tracking-integrated micro-concentrator photovoltaic
(WiT-CPV) prototype studied here was previously reported with its optical and
mechanical design, azimuthal cell rotation and first-generation controller
\cite{diasponce2025development}. Its receiver follows the moving focal region
of a biconvex aspheric lens along a mechanically constrained trajectory.
Knowledge of solar position alone does not establish the corresponding
receiver coordinates: optical geometry, mounting and kinematics must also
be known. Photocurrent provides a useful local feedback signal, although
irradiance, temperature and spectrum can change it independently of alignment.
The present contribution is a field characterization of this coupled system
using the complete acquired record of 4 November 2026. It connects measured
electrical signals and stage coordinates to an explicitly estimated
aperture-level output, places the response in solar-position context, and
discloses data gaps and operational interventions. A fixed-silicon scenario
provides an area-normalized engineering reference without substituting a
simulation for an unperformed experiment. This focus replaces comparisons
between heterogeneous development sessions with a traceable principal case;
it does not constitute a controlled comparison of controller versions or
a validation of net energy production.
\section{System and Control}
\label{sec:system}
\subsection{Optical and mechanical architecture}
The prototype combines fixed primary optics, a multi-junction photovoltaic
receiver and a two-axis actuated stage. Guided mechanical motion follows
the curved focal trajectory associated with the Petzval surface; the detailed
optomechanical construction is described in \cite{diasponce2025development}.
The lens aperture used in the present analysis is
$A_L=0.002497$~m$^2$. Coordinates $X$ and $Y$ denote the project's azimuth
and elevation axes, respectively. They are commanded step coordinates,
not independently measured solar or receiver angles. The acquisition does
not provide a calibrated steps-to-angle transformation or an independent
measurement of focal-position error.
\subsection{Controller and measurement chain}
An ESP32-based controller provides wireless command/telemetry interfaces
and commands two TMC2209 stepper drivers. A 24-bit ADS122C04 converter
acquires the photocurrent, pyranometer and cell-voltage channels. The
photocurrent chain uses a nominal 0.130-$\Omega$ shunt for the evaluated
PCB V2 configuration, an external gain of 20 and a 2.048-V reference.
Open-circuit voltage is acquired through a factor-of-two divider, and an
Apogee SP-212 provides the irradiance reference. The functional measurement
and control paths are summarized in Fig.~\ref{fig:architecture}.
\begin{figure}[htbp]
\centering
\begin{tikzpicture}[>=Latex,font=\small,
box/.style={draw,rounded corners=2pt,align=center,minimum height=11mm,text width=40mm},
node distance=11mm and 20mm]
\node[box] (optics) {Fixed primary lens\\$A_L=0.002497$ m$^2$};
\node[box,right=of optics] (cell) {Moving receiver\\$X$: azimuth; $Y$: elevation};
\node[box,below=of cell] (adc) {Sequential ADC acquisition\\$I_{sc}$, $G$, $V_{oc}$};
\node[box,left=of adc] (control) {ESP32 and motor drivers\\Photocurrent feedback};
\node[box,below=of adc] (analysis) {Logged observations\\Offline calibration\\and analysis};
\draw[->] (optics) -- node[above]{Focal region} (cell);
\draw[->] (cell) -- (adc);
\draw[->] (adc) -- node[above]{$C$ feedback} (control);
\draw[->] (control.west) -- ++(-7mm,0) |- ([yshift=8mm]cell.north) -- (cell.north);
\node[above=8mm of cell,font=\scriptsize] {Internal motion};
\draw[->] (adc) -- (analysis);
\end{tikzpicture}
\caption{Functional architecture of the evaluated system. Only the receiver
is repositioned; internal tracking does not reorient the external aperture.
Electrical channels are acquired sequentially, and the offline power estimate
is not feedback from a measured MPP load.}
\label{fig:architecture}
\end{figure}
The cell is switched between short-circuit-current and open-circuit-voltage
measurement states. Current, irradiance and voltage therefore represent
sequential conversions rather than a simultaneous electrical operating
point. The periodic \texttt{TEL} messages contain separate telemetry averages
and fine-tuning counters; they are not interchangeable with the coordinate/
electrical observations used for the quantitative analysis.
\subsection{Evaluated control and implementation provenance}
\label{sec:control_provenance}
The raw record identifies the deployed firmware as \texttt{1.1.3-ws}.
The controller performs photocurrent-feedback fine tuning, with configurable
step bounds, current-change thresholds, peak-proximity settings and
axis-search cycles. Its log reports adaptive-step diagnostics and periodic
reference resets. Pyranometer input supports illumination-related operating
logic, but the offline $I_{sc}/G$ ratio used below must not be interpreted
as a verified samplewise feedback law of the evaluated firmware.
The available development source has subsequently changed; in particular,
later revisions address current-change, near-peak and deadband behavior.
Those later equations and features are not attributed to the recorded
deployment. Accordingly, this article describes the evaluated controller at
the level supported by version identifiers and logged behavior, rather than
presenting unverified branch equations as an exact specification of its binary.
The experiment includes parameter updates and manual positioning commands.
It is an operational field characterization with disclosed tuning, not a
fixed-parameter autonomous trial, an ablation study or proof that each
protective control regime operates as intended.
\section{Experimental and Analytical Methods}
\label{sec:methods}
\subsection{Site, acquisition and solar-position reference}
The principal acquisition took place on 4 November 2026 in Aguascalientes,
Mexico. The supplied SunCalc printout identifies latitude
$21.87304^\circ$ N and longitude $102.28282^\circ$ W and tabulates solar
azimuth and elevation at 15-min intervals \cite{suncalc2026}.
The field conditions were described as clear by the experimenter; this is
a field observation, not an independently measured classification of direct
and diffuse irradiance. Brief irradiance anomalies are retained and examined.
Time plots use the local acquisition clock. The log has no encoded time-zone
offset, so matching to the supplied local-time SunCalc table is an explicit
clock-alignment assumption, not an independent timing calibration.
Linear interpolation between the printed solar positions supplies context
at observation times. It does not improve the ephemeris resolution or measure
stage accuracy. Azimuth is measured clockwise from north and elevation above
the horizon. Neither absolute solar azimuth nor its span equals the collector
incidence angle. The acquisition package does not establish absolute external-optics
tilt/azimuth, so conversion to a validated
optical acceptance envelope is not attempted.
\subsection{Offline calibration and data-quality rules}
Let $C$, $V$ and $P$ denote the signed 24-bit photocurrent, voltage and
pyranometer codes, with positive full scale $D=8{,}388{,}607$. Offline conversion is
\begin{align}
I_{sc}&=\frac{2.048 C}{20D R_s}, &
V_{oc}&=\frac{2(2.048)V}{D}, &
G&=\frac{2048P}{0.2D},\quad R_s=0.130~\Omega.
\label{eq:calibration}
\end{align}
The resulting units are A, V and W\,m$^{-2}$. The pyranometer sensitivity
is 0.2~mV/(W\,m$^{-2}$); using 2.0 would understate irradiance tenfold.
The raw channels are not exchanged: \texttt{C} is photocurrent and
\texttt{P} is pyranometer output. The same physical shunt applies throughout
this record; a software conversion update at the hub does not define a
physical shunt change in the raw acquisition.
The experimenter confirms that the SP-212 was mounted in the same plane as
the concentration lens. Thus $G$ is measured global irradiance in the fixed
lens-aperture plane, and $G A_L$ estimates incident solar power on that
aperture. No additional cosine projection is applied: the beam's geometric
projection onto the receiving plane is already represented in the coplanar
irradiance measurement. This quantity is not direct-normal irradiance.
The SP-212's spectral and angular responses differ from those of concentrating
multi-junction optics \cite{apogee_sp212_manual}; its calibration and angular
response, and the concentrator's limited collection of diffuse light, remain
relevant to the uncertainty and interpretation.
Offline scaling also does not retroactively correct firmware irradiance
thresholds or change historical control decisions.
No experiment-specific uncertainty budget is available for shunt resistance,
amplifier gain, voltage reference or irradiance calibration. Reported decimal
places identify reproducible calculations, not a demonstrated metrological
precision or confidence interval.
Every observation retains its source line, raw codes and quality flags.
Checks include signed conversion, channel clipping, a 99\%-of-current-rail
guard, negative channels, timestamps and uptime discontinuities. For integration,
both adjacent endpoints must be electrically accepted and illuminated
($G>50$~W\,m$^{-2}$), their separation must be positive and at most 15~s,
and uptime must not decrease. Rejected observations and long gaps are never
bridged. Low output and manual interventions are retained, not excluded
because they reduce a performance score. Clock-hour subdivisions are
descriptive and accompany, rather than replace, the complete acquired record.
\subsection{Estimated output and aperture-referenced performance}
The instantaneous available-MPP proxy and its energy integral are
\begin{equation}
\widehat P_{\mathrm{MPP}}=I_{sc}V_{oc}FF,\qquad
\widehat E=\int_{\mathcal S}\widehat P_{\mathrm{MPP}}\,dt,
\qquad FF=0.82,
\label{eq:power_estimate}
\end{equation}
where $\mathcal S$ is the union of accepted adjacent intervals and time is
expressed in hours for Wh. A fixed fill factor is assumed; no outdoor I--V
curve, MPP converter or delivered-load power is inferred. Sequential sensing
further assumes approximately stable conditions across the conversion cycle,
which can fail during rapid changes. Thus $\widehat E$ estimates available
electrical energy, not harvested or net system energy.
On the identical temporal support, define
\begin{equation}
H=\int_{\mathcal S}G\,dt,\qquad
\widehat\eta_{\mathrm{ap},E}=\frac{\widehat E}{A_L H},\qquad
\widehat\eta_{\mathrm{ap}}(t)=\frac{\widehat P_{\mathrm{MPP}}(t)}{A_LG(t)}.
\label{eq:aperture}
\end{equation}
The integrated ratio, reported as a percentage, is not the mean of the
instantaneous ratios. With the confirmed coplanar irradiance measurement,
it estimates the complete lens/receiver combination's electrical conversion
relative to solar energy incident on its aperture, not intrinsic cell efficiency.
Optical losses that reduce the measured cell response are already reflected
in the numerator; no additional lens-transmission factor is applied to that output.
For any alternative constant $FF'$, $\widehat E$ and
$\widehat\eta_{\mathrm{ap},E}$ scale by $FF'/0.82$; this is sensitivity
to an assumption, not a calibrated uncertainty interval.
\subsection{Relative response without an ideal-alignment claim}
To describe electrical response relative to a common empirical reference,
let $R(t)=I_{sc}(t)/G(t)$ and consider both its full-record maximum
$R_{\max}$ and 99th percentile $R_{99}$. For either reference $R_*$,
\begin{equation}
\widehat P_{\mathrm{ref}}(t;R_*)=R_*G(t)V_{oc}(t)FF,\qquad
\rho_E(R_*)=\frac{\int_{\mathcal S}\widehat P_{\mathrm{MPP}}dt}
{\int_{\mathcal S}\widehat P_{\mathrm{ref}}(t;R_*)dt}.
\label{eq:relative_yield}
\end{equation}
This is a record-specific relative-yield indicator, not independently
validated tracking efficiency or angular accuracy. Neither reference removes
temperature, spectral, geometry or asynchronous-sensing effects. The P99
reference is not a strict ceiling; ratios are not clipped to 100\%.
Sensitivity to reference choice is part of the result, not a reason to select
the reference that produces the most favorable number.
\subsection{Equal-area fixed-silicon reference scenario}
\label{sec:silicon_methods}
No simultaneous silicon reference measurement accompanies the acquisition.
We therefore define a conditional equal-area scenario for a hypothetical fixed
silicon surface coplanar with the lens, using the measured plane-of-aperture
irradiance and the accepted temporal support of the CPV estimate:
\begin{equation}
P_{\mathrm{Si,scenario}}(t)=A_L\eta_{\mathrm{Si}}G(t),\qquad
E_{\mathrm{Si,scenario}}=A_L\eta_{\mathrm{Si}}H,\qquad
B=\frac{\widehat E}{E_{\mathrm{Si,scenario}}}.
\label{eq:silicon_scenario}
\end{equation}
PVWatts lists approximate nominal module efficiencies of 19\% and 21\%
for its Standard and Premium crystalline-silicon categories, respectively
\cite{pvwatts_module_type}. These two assumptions provide model-based context,
not upper/lower bounds on silicon technology or specifications of a tested
panel. The equivalent area is a normalization, not a fabricated 0.002497-m$^2$
module. This scenario does not assume equal rated power or equal cell area.
Since $B=\widehat\eta_{\mathrm{ap},E}/\eta_{\mathrm{Si}}$ under these
assumptions, the comparison is an algebraic restatement of the aperture
estimate, not independent experimental evidence.
Holding silicon efficiency constant isolates the area/irradiation comparison;
it does not predict temperature-corrected field yield. A physical module model
would additionally require module-specific optical corrections to the measured
plane irradiance, cell temperature and thermal mounting conditions
\cite{sandia_poa,sandia_pvwatts}. PVWatts' corresponding nominal
power-temperature coefficients are $-0.37$ and $-0.35$\%/K, but no unmeasured
silicon temperature is assigned here. Coplanarity supports the common incident
irradiance basis, but does not equate absorbed effective irradiance or measured
electrical output for the two technologies. Values of $B$ are conditional
arithmetic comparisons, not measured energy gains over silicon.
\section{Field Results}
\label{sec:results}
\input{results_2026_11_04}
\section{Discussion}
\label{sec:discussion}
\subsection{What the principal experiment establishes}
The principal result is a single, traceable operating characterization that
connects internal motion, electrical response and solar-position context.
The unsaturated current channel permits analysis of the acquired daytime
span without reconstructing clipped high-current observations. The
\NovEfficiency\% integrated aperture estimate retains initialization,
tuning and degraded response; it is therefore more informative about this
record than a favorable midday peak alone. The useful output during changing
solar direction supports the feasibility of internal receiver tracking,
but does not isolate the controller's contribution from manual intervention
or establish an ideal focal trajectory.
The lower late response is part of the demonstrated behavior, not a discarded
outlier. Finite travel, optical acceptance, alignment, temperature and
control settings could all contribute. Solar ephemerides and commanded
coordinates alone cannot separate them. Establishing an angular operating
envelope requires known external-optics orientation and an independent
mapping between receiver position, incidence angle and optical response.
\subsection{Why relative tracking and aperture efficiency differ}
The aperture metric compares estimated electrical output with measured
reference irradiation; the relative-yield metric instead compares against
a same-record electrical responsivity. They answer different questions.
An anomalously low irradiance reading can inflate both instantaneous aperture
efficiency and a maximum-responsivity reference while depressing the
maximum-referenced full-record ratio. A stable-looking current trace does
not resolve whether a brief discrepancy arose from weather, sensor shading,
electrical acquisition or different sensor/device response.
Consequently, the reference sensitivity must not be compressed into one
unqualified tracking-efficiency headline. Even a robust percentile reference
remains empirical, reuses the measured voltage and cannot independently
measure perfect alignment. Irradiance normalization also cannot distinguish
thermal drift from small alignment-induced changes. The record does not
validate cloud-recovery time or the causal benefit of a specific protective
branch; those require controlled perturbations or independently characterized
disturbances.
\subsection{Interpreting the silicon comparison and next validation}
The shared-reference scenario places the aperture estimate alongside
transparent nominal silicon-efficiency assumptions, without treating the
CPV aperture as externally tracked at normal incidence. Its dependencies are
the assumed fill factor and silicon efficiency, together with unmeasured
temperature and module-specific optical response. The confirmed coplanar sensor
provides the incident-irradiance basis for the equal-plane scenario. Different diffuse,
spectral and angular responses prevent extrapolating the scenario to annual
yield or arbitrary weather. No economic or lifecycle advantage is inferred.
A simultaneous experiment should measure silicon DC output at MPP, document
module area and datasheet parameters, and record tilt, azimuth, coplanar
irradiance and module temperature. Equivalent delivered-energy claims also
require CPV operation into a measured load rather than alternating
$I_{sc}/V_{oc}$ sensing. For net energy, the complete board and motor supply
must be logged. Fine-tuning counters omit other moves, and sampled coordinate
differences can miss reversals; neither substitutes for supply-energy
measurement. The most useful next campaign therefore combines those
measurements with fixed documented settings and repeated days, followed by
controlled controller-version comparisons.
\section{Conclusions}
\label{sec:conclusions}
The 4 November 2026 acquisition provides a coherent principal field case for
the tracking-integrated WiT-CPV prototype: \NovSamples{} observations,
\NovHours{} h of accepted integration support, and no ADC clipping. With
a nominal 0.130-$\Omega$ shunt, corrected pyranometer scaling and an assumed
$FF=0.82$, the available-MPP energy estimate is \NovEnergy{} Wh for
\NovIrradiation{} kWh\,m$^{-2}$ of reference irradiation. The resulting
energy-weighted, aperture-referenced estimate is \NovEfficiency\%.
These quantities characterize the lens/receiver system against the recorded
irradiance, rather than intrinsic cell efficiency or measured harvested energy.
Linking the response to the solar-position reference and stage trajectory
reveals useful operation as well as late degradation. The event-defined
12:32--13:53 phase has no additional logged remote commands and yields an
aperture estimate of \NovSteadyEfficiency\%, providing a post-tuning operating
case alongside the complete-record result. Retaining tuning
commands and low-output periods makes the result a characterization of
observed operation, not a claim of autonomous full-day lock. A pronounced
irradiance anomaly also demonstrates why a same-record maximum cannot be
treated as an independently validated ideal-tracking reference.
Under the shared-pyranometer, constant-efficiency assumptions, the estimated
CPV energy corresponds to ratios of \NovSiliconStandardRatio{} and
\NovSiliconPremiumRatio{} relative to the 19\% and 21\% equal-area silicon
scenarios. These are arithmetic scenario ratios, not measured comparative
yields or evidence of superiority over a fixed panel. The decisive next validation is
simultaneous delivered-MPP and parasitic-power measurement, with matched receiving
planes, measured temperature and fixed control settings. This would turn the present
aperture-level characterization into a test of repeatable net system benefit.
\section*{Reproducibility}
The companion analysis retains the raw observations, source-line references,
quality flags, accepted integration edges and command chronology. Its
machine-readable summary records source hashes and assumptions; the supplied
SunCalc table is transcribed separately. Numerical tests cover calibration,
gap handling, integration and scenario scaling. All result tables and field
figures are regenerated from these inputs, without modifying the raw log.
\printbibliography
\end{document}
+718
View File
@@ -0,0 +1,718 @@
% $ biblatex auxiliary file $
% $ biblatex bbl format version 3.3 $
% Do not modify the above lines!
%
% This is an auxiliary file used by the 'biblatex' package.
% This file may safely be deleted. It will be recreated by
% biber as required.
%
\begingroup
\makeatletter
\@ifundefined{ver@biblatex.sty}
{\@latex@error
{Missing 'biblatex' package}
{The bibliography requires the 'biblatex' package.}
\aftergroup\endinput}
{}
\endgroup
\refsection{0}
\datalist[entry]{none/global//global/global/global}
\entry{mousazadeh2009review}{article}{}{}
\name{author}{6}{}{%
{{hash=e824e52aa2a3a8bff5ea2902854bf63d}{%
family={Mousazadeh},
familyi={M\bibinitperiod},
given={Hossein},
giveni={H\bibinitperiod}}}%
{{hash=26b8818d27f155119b45b2a5e91f30ac}{%
family={Keyhani},
familyi={K\bibinitperiod},
given={Alireza},
giveni={A\bibinitperiod}}}%
{{hash=9df8b66950e0739f6a6557717db5b72c}{%
family={Javadi},
familyi={J\bibinitperiod},
given={Arzhang},
giveni={A\bibinitperiod}}}%
{{hash=fb9779cd36e2f0c80f6d020040cf6192}{%
family={Mobli},
familyi={M\bibinitperiod},
given={Hossein},
giveni={H\bibinitperiod}}}%
{{hash=633d0f4a21f11db8f62f0cb171c317a6}{%
family={Abrinia},
familyi={A\bibinitperiod},
given={Karen},
giveni={K\bibinitperiod}}}%
{{hash=11d0654599e5980c42cc0479e6dcaf94}{%
family={Sharifi},
familyi={S\bibinitperiod},
given={Ahmad},
giveni={A\bibinitperiod}}}%
}
\list{publisher}{1}{%
{Elsevier}%
}
\strng{namehash}{e01e813d810d7b09516ad937cfdb1fed}
\strng{fullhash}{57454e01159eff45a9685706add92d5c}
\strng{fullhashraw}{57454e01159eff45a9685706add92d5c}
\strng{bibnamehash}{e01e813d810d7b09516ad937cfdb1fed}
\strng{authorbibnamehash}{e01e813d810d7b09516ad937cfdb1fed}
\strng{authornamehash}{e01e813d810d7b09516ad937cfdb1fed}
\strng{authorfullhash}{57454e01159eff45a9685706add92d5c}
\strng{authorfullhashraw}{57454e01159eff45a9685706add92d5c}
\field{sortinit}{1}
\field{sortinithash}{4f6aaa89bab872aa0999fec09ff8e98a}
\field{labelnamesource}{author}
\field{labeltitlesource}{title}
\field{journaltitle}{Renewable and sustainable energy reviews}
\field{number}{8}
\field{title}{A review of principle and sun-tracking methods for maximizing solar systems output}
\field{volume}{13}
\field{year}{2009}
\field{pages}{1800\bibrangedash 1818}
\range{pages}{19}
\verb{doi}
\verb 10.1016/j.rser.2009.01.022
\endverb
\endentry
\entry{hafez2018solar}{article}{}{}
\name{author}{3}{}{%
{{hash=14459a81e11881614a7d934b4b9adf2b}{%
family={Hafez},
familyi={H\bibinitperiod},
given={AZ},
giveni={A\bibinitperiod}}}%
{{hash=fd20674dda510067db72990da4f158ae}{%
family={Yousef},
familyi={Y\bibinitperiod},
given={AM},
giveni={A\bibinitperiod}}}%
{{hash=da8e48203a77557994878bf7ab9fa218}{%
family={Harag},
familyi={H\bibinitperiod},
given={NM},
giveni={N\bibinitperiod}}}%
}
\list{publisher}{1}{%
{Elsevier}%
}
\strng{namehash}{4242dd0d0ceb3a95d29da028f8a5cd78}
\strng{fullhash}{4242dd0d0ceb3a95d29da028f8a5cd78}
\strng{fullhashraw}{4242dd0d0ceb3a95d29da028f8a5cd78}
\strng{bibnamehash}{4242dd0d0ceb3a95d29da028f8a5cd78}
\strng{authorbibnamehash}{4242dd0d0ceb3a95d29da028f8a5cd78}
\strng{authornamehash}{4242dd0d0ceb3a95d29da028f8a5cd78}
\strng{authorfullhash}{4242dd0d0ceb3a95d29da028f8a5cd78}
\strng{authorfullhashraw}{4242dd0d0ceb3a95d29da028f8a5cd78}
\field{sortinit}{1}
\field{sortinithash}{4f6aaa89bab872aa0999fec09ff8e98a}
\field{labelnamesource}{author}
\field{labeltitlesource}{title}
\field{journaltitle}{Renewable and Sustainable Energy Reviews}
\field{title}{Solar tracking systems: Technologies and trackers drive types--A review}
\field{volume}{91}
\field{year}{2018}
\field{pages}{754\bibrangedash 782}
\range{pages}{29}
\verb{doi}
\verb 10.1016/j.rser.2018.03.094
\endverb
\endentry
\entry{fuentes2020control}{article}{}{}
\name{author}{7}{}{%
{{hash=1133ace3429bb36f13169ed3d661afc2}{%
family={Fuentes-Morales},
familyi={F\bibinithyphendelim M\bibinitperiod},
given={Rosa\bibnamedelima F},
giveni={R\bibinitperiod\bibinitdelim F\bibinitperiod}}}%
{{hash=c94df6c668b35d33713dbd905b19b2bc}{%
family={Diaz-Ponce},
familyi={D\bibinithyphendelim P\bibinitperiod},
given={Arturo},
giveni={A\bibinitperiod}}}%
{{hash=80b889c4aa49e0b027f7ee85491abe71}{%
family={Peña-Cruz},
familyi={P\bibinithyphendelim C\bibinitperiod},
given={Manuel\bibnamedelima I},
giveni={M\bibinitperiod\bibinitdelim I\bibinitperiod}}}%
{{hash=d3dcaed7cf262d228067a02aefbb76c7}{%
family={Rodrigo},
familyi={R\bibinitperiod},
given={Pedro\bibnamedelima M},
giveni={P\bibinitperiod\bibinitdelim M\bibinitperiod}}}%
{{hash=9c7ab6b9c905d154778a136eb26b39e4}{%
family={Valentín-Coronado},
familyi={V\bibinithyphendelim C\bibinitperiod},
given={Luis\bibnamedelima M},
giveni={L\bibinitperiod\bibinitdelim M\bibinitperiod}}}%
{{hash=4cfcb402b18ac4f325b0e3e8053a7aa0}{%
family={Martell-Chavez},
familyi={M\bibinithyphendelim C\bibinitperiod},
given={Fernando},
giveni={F\bibinitperiod}}}%
{{hash=53a221f00e6e9a37ecd95557510eab0c}{%
family={Pineda-Arellano},
familyi={P\bibinithyphendelim A\bibinitperiod},
given={Carlos\bibnamedelima A},
giveni={C\bibinitperiod\bibinitdelim A\bibinitperiod}}}%
}
\list{publisher}{1}{%
{Elsevier}%
}
\strng{namehash}{1ca5f0acced4dd867c7c1aee44e4d5d1}
\strng{fullhash}{6ff42d0b8ef2ac128cfcfffdeead20e3}
\strng{fullhashraw}{6ff42d0b8ef2ac128cfcfffdeead20e3}
\strng{bibnamehash}{1ca5f0acced4dd867c7c1aee44e4d5d1}
\strng{authorbibnamehash}{1ca5f0acced4dd867c7c1aee44e4d5d1}
\strng{authornamehash}{1ca5f0acced4dd867c7c1aee44e4d5d1}
\strng{authorfullhash}{6ff42d0b8ef2ac128cfcfffdeead20e3}
\strng{authorfullhashraw}{6ff42d0b8ef2ac128cfcfffdeead20e3}
\field{sortinit}{1}
\field{sortinithash}{4f6aaa89bab872aa0999fec09ff8e98a}
\field{labelnamesource}{author}
\field{labeltitlesource}{title}
\field{journaltitle}{Solar Energy}
\field{title}{Control algorithms applied to active solar tracking systems: A review}
\field{volume}{212}
\field{year}{2020}
\field{pages}{203\bibrangedash 219}
\range{pages}{17}
\verb{doi}
\verb 10.1016/j.solener.2020.10.071
\endverb
\endentry
\entry{iqbal2023optical}{article}{}{}
\name{author}{3}{}{%
{{hash=0664aba4d6f9510dfc715503ff1e668d}{%
family={Iqbal},
familyi={I\bibinitperiod},
given={Waseem},
giveni={W\bibinitperiod}}}%
{{hash=a65894b864a4056158acdf72bcb84931}{%
family={Ullah},
familyi={U\bibinitperiod},
given={Irfan},
giveni={I\bibinitperiod}}}%
{{hash=b5bd6aa8e334de72638830aa8eae18d1}{%
family={Shin},
familyi={S\bibinitperiod},
given={Seoyong},
giveni={S\bibinitperiod}}}%
}
\list{publisher}{1}{%
{MDPI}%
}
\strng{namehash}{2aa4a75613f1c0d1ef4ec00803b87bec}
\strng{fullhash}{2aa4a75613f1c0d1ef4ec00803b87bec}
\strng{fullhashraw}{2aa4a75613f1c0d1ef4ec00803b87bec}
\strng{bibnamehash}{2aa4a75613f1c0d1ef4ec00803b87bec}
\strng{authorbibnamehash}{2aa4a75613f1c0d1ef4ec00803b87bec}
\strng{authornamehash}{2aa4a75613f1c0d1ef4ec00803b87bec}
\strng{authorfullhash}{2aa4a75613f1c0d1ef4ec00803b87bec}
\strng{authorfullhashraw}{2aa4a75613f1c0d1ef4ec00803b87bec}
\field{sortinit}{2}
\field{sortinithash}{8b555b3791beccb63322c22f3320aa9a}
\field{labelnamesource}{author}
\field{labeltitlesource}{title}
\field{journaltitle}{Sustainability}
\field{number}{13}
\field{title}{Optical developments in concentrator photovoltaic systems—a review}
\field{volume}{15}
\field{year}{2023}
\field{pages}{10554}
\range{pages}{1}
\verb{doi}
\verb 10.3390/su151310554
\endverb
\endentry
\entry{ceballos2023tracking}{article}{}{}
\name{author}{4}{}{%
{{hash=16075f6fc9e32e8a67a5313c3c2d4b44}{%
family={Ceballos},
familyi={C\bibinitperiod},
given={Maria\bibnamedelima A},
giveni={M\bibinitperiod\bibinitdelim A\bibinitperiod}}}%
{{hash=1d3faf0e9e6d4a15bdb1ae6e7205557a}{%
family={Pérez-Higueras},
familyi={P\bibinithyphendelim H\bibinitperiod},
given={Pedro\bibnamedelima J},
giveni={P\bibinitperiod\bibinitdelim J\bibinitperiod}}}%
{{hash=79c6ddf92e15ea523ac21a2e5290da74}{%
family={Fernández},
familyi={F\bibinitperiod},
given={Eduardo\bibnamedelima F},
giveni={E\bibinitperiod\bibinitdelim F\bibinitperiod}}}%
{{hash=a3e2a878cce2df0c6b6edfbc24184226}{%
family={Almonacid},
familyi={A\bibinitperiod},
given={Florencia\bibnamedelima M},
giveni={F\bibinitperiod\bibinitdelim M\bibinitperiod}}}%
}
\list{publisher}{1}{%
{MDPI}%
}
\strng{namehash}{266476a2d420f95ffda9a64e45ccd50d}
\strng{fullhash}{842406545db43052c4d4606de65b73d9}
\strng{fullhashraw}{842406545db43052c4d4606de65b73d9}
\strng{bibnamehash}{266476a2d420f95ffda9a64e45ccd50d}
\strng{authorbibnamehash}{266476a2d420f95ffda9a64e45ccd50d}
\strng{authornamehash}{266476a2d420f95ffda9a64e45ccd50d}
\strng{authorfullhash}{842406545db43052c4d4606de65b73d9}
\strng{authorfullhashraw}{842406545db43052c4d4606de65b73d9}
\field{sortinit}{3}
\field{sortinithash}{ad6fe7482ffbd7b9f99c9e8b5dccd3d7}
\field{labelnamesource}{author}
\field{labeltitlesource}{title}
\field{journaltitle}{Energies}
\field{number}{15}
\field{title}{Tracking-Integrated {CPV} Technology: State-of-the-Art and Classification}
\field{volume}{16}
\field{year}{2023}
\field{pages}{5605}
\range{pages}{1}
\verb{doi}
\verb 10.3390/en16155605
\endverb
\endentry
\entry{ito2018optical}{article}{}{}
\name{author}{3}{}{%
{{hash=7833f50d7b9aede33f9d21810a75dd5a}{%
family={Ito},
familyi={I\bibinitperiod},
given={Atsushi},
giveni={A\bibinitperiod}}}%
{{hash=9a7a08541977b1e11d75e3ebb43d4b36}{%
family={Sato},
familyi={S\bibinitperiod},
given={Daisuke},
giveni={D\bibinitperiod}}}%
{{hash=048deb855fbfc7f3ff923fe0e25c2003}{%
family={Yamada},
familyi={Y\bibinitperiod},
given={Noboru},
giveni={N\bibinitperiod}}}%
}
\list{publisher}{1}{%
{OSA}%
}
\strng{namehash}{9d15280672af3b7051108e703777a881}
\strng{fullhash}{9d15280672af3b7051108e703777a881}
\strng{fullhashraw}{9d15280672af3b7051108e703777a881}
\strng{bibnamehash}{9d15280672af3b7051108e703777a881}
\strng{authorbibnamehash}{9d15280672af3b7051108e703777a881}
\strng{authornamehash}{9d15280672af3b7051108e703777a881}
\strng{authorfullhash}{9d15280672af3b7051108e703777a881}
\strng{authorfullhashraw}{9d15280672af3b7051108e703777a881}
\field{sortinit}{4}
\field{sortinithash}{9381316451d1b9788675a07e972a12a7}
\field{labelnamesource}{author}
\field{labeltitlesource}{title}
\field{journaltitle}{Optics Express}
\field{number}{18}
\field{title}{Optical design and demonstration of microtracking {CPV} module with bi-convex aspheric lens array}
\field{volume}{26}
\field{year}{2018}
\field{pages}{A879\bibrangedash A891}
\range{pages}{-1}
\verb{doi}
\verb 10.1364/OE.26.00A879
\endverb
\endentry
\entry{askins2019performance}{inproceedings}{}{}
\name{author}{16}{}{%
{{hash=d9e4abb484d3ac8ac7767c205bde9134}{%
family={Askins},
familyi={A\bibinitperiod},
given={S.},
giveni={S\bibinitperiod}}}%
{{hash=1d4250c7262befb5c8df3f43f7aef9d4}{%
family={Petri},
familyi={P\bibinitperiod},
given={D.},
giveni={D\bibinitperiod}}}%
{{hash=4ed0e43c1b83d5c55567443665e33cfd}{%
family={Levrat},
familyi={L\bibinitperiod},
given={J.},
giveni={J\bibinitperiod}}}%
{{hash=22501a89301dffef8c8beb2ca300b035}{%
family={Faes},
familyi={F\bibinitperiod},
given={A.},
giveni={A\bibinitperiod}}}%
{{hash=759607db8b5e1d24af5f48397ddc4b43}{%
family={Champliaud},
familyi={C\bibinitperiod},
given={J.},
giveni={J\bibinitperiod}}}%
{{hash=1dd3b1ef6d2b61106dbaca42d343f969}{%
family={Despeisse},
familyi={D\bibinitperiod},
given={M.},
giveni={M\bibinitperiod}}}%
{{hash=35040e6bffd6afb17927b7fafc3ff74b}{%
family={Dominguez},
familyi={D\bibinitperiod},
given={C.},
giveni={C\bibinitperiod}}}%
{{hash=0e270bf8a1638bc9413e8c47f75733f7}{%
family={Anton},
familyi={A\bibinitperiod},
given={I.},
giveni={I\bibinitperiod}}}%
{{hash=0228001ef8dee404b0a31aee230fdaf3}{%
family={Jost},
familyi={J\bibinitperiod},
given={N.},
giveni={N\bibinitperiod}}}%
{{hash=1ddda2bb28ba45a6f98f7d1669c6c52a}{%
family={Aguilar},
familyi={A\bibinitperiod},
given={A.\bibnamedelimi F.},
giveni={A\bibinitperiod\bibinitdelim F\bibinitperiod}}}%
{{hash=e358134f591b7520473501ff87b244ab}{%
family={Anglade},
familyi={A\bibinitperiod},
given={L.},
giveni={L\bibinitperiod}}}%
{{hash=c3ce12809a4a47a60a0733416c4cf02e}{%
family={Nardin},
familyi={N\bibinitperiod},
given={G.},
giveni={G\bibinitperiod}}}%
{{hash=0bb08137926da4a9ee26863849b82e4f}{%
family={Duchemin},
familyi={D\bibinitperiod},
given={M.},
giveni={M\bibinitperiod}}}%
{{hash=e614b2f7a4668c5faa5d54a7a550af1b}{%
family={Gerlich},
familyi={G\bibinitperiod},
given={F.},
giveni={F\bibinitperiod}}}%
{{hash=91353ea0d9417cb0cc06262547f901c4}{%
family={Ackerman},
familyi={A\bibinitperiod},
given={M.},
giveni={M\bibinitperiod}}}%
{{hash=9384db376fff9ce57ab5d5c458e55fb2}{%
family={Coulot},
familyi={C\bibinitperiod},
given={L.},
giveni={L\bibinitperiod}}}%
}
\list{organization}{1}{%
{IEEE}%
}
\strng{namehash}{1c7b6c72ec44dbfad671098f718edfc3}
\strng{fullhash}{e9f6739285a61fe7b73f2876d3c64c34}
\strng{fullhashraw}{e9f6739285a61fe7b73f2876d3c64c34}
\strng{bibnamehash}{1c7b6c72ec44dbfad671098f718edfc3}
\strng{authorbibnamehash}{1c7b6c72ec44dbfad671098f718edfc3}
\strng{authornamehash}{1c7b6c72ec44dbfad671098f718edfc3}
\strng{authorfullhash}{e9f6739285a61fe7b73f2876d3c64c34}
\strng{authorfullhashraw}{e9f6739285a61fe7b73f2876d3c64c34}
\field{sortinit}{4}
\field{sortinithash}{9381316451d1b9788675a07e972a12a7}
\field{labelnamesource}{author}
\field{labeltitlesource}{title}
\field{booktitle}{2019 IEEE 46th Photovoltaic Specialists Conference (PVSC)}
\field{title}{Performance of hybrid micro-concentrator module with integrated planar tracking and diffuse light collection}
\field{year}{2019}
\field{pages}{2507\bibrangedash 2512}
\range{pages}{6}
\verb{doi}
\verb 10.1109/PVSC40753.2019.8980519
\endverb
\endentry
\entry{younes2022realizing}{article}{}{}
\name{author}{5}{}{%
{{hash=b83866d9725c3d7823c5574e193c5c5d}{%
family={Younes},
familyi={Y\bibinitperiod},
given={Kareem},
giveni={K\bibinitperiod}}}%
{{hash=c2a0d5fd5e3517ef3bb93622e64f4a2c}{%
family={Apostoleris},
familyi={A\bibinitperiod},
given={Harry},
giveni={H\bibinitperiod}}}%
{{hash=28bf933452f28f75847101e1e0bd702c}{%
family={Bin\bibnamedelima Saad},
familyi={B\bibinitperiod\bibinitdelim S\bibinitperiod},
given={Majed},
giveni={M\bibinitperiod}}}%
{{hash=67602f79265e6108c733fc9d300b4fe6}{%
family={Al\bibnamedelima Ghaferi},
familyi={A\bibinitperiod\bibinitdelim G\bibinitperiod},
given={Amal},
giveni={A\bibinitperiod}}}%
{{hash=ce6b64aa2105c8d521d368ae47d47a16}{%
family={Chiesa},
familyi={C\bibinitperiod},
given={Matteo},
giveni={M\bibinitperiod}}}%
}
\list{publisher}{1}{%
{Frontiers Media SA}%
}
\strng{namehash}{93ac5752446c1882d6cb5978fef96da8}
\strng{fullhash}{b9fcf87d3eb941813263a453b3b34b42}
\strng{fullhashraw}{b9fcf87d3eb941813263a453b3b34b42}
\strng{bibnamehash}{93ac5752446c1882d6cb5978fef96da8}
\strng{authorbibnamehash}{93ac5752446c1882d6cb5978fef96da8}
\strng{authornamehash}{93ac5752446c1882d6cb5978fef96da8}
\strng{authorfullhash}{b9fcf87d3eb941813263a453b3b34b42}
\strng{authorfullhashraw}{b9fcf87d3eb941813263a453b3b34b42}
\field{sortinit}{4}
\field{sortinithash}{9381316451d1b9788675a07e972a12a7}
\field{labelnamesource}{author}
\field{labeltitlesource}{title}
\field{journaltitle}{Frontiers in Energy Research}
\field{title}{Realizing high photovoltaic power densities with tracking-integrated concentrator photovoltaics}
\field{volume}{10}
\field{year}{2022}
\field{pages}{842201}
\range{pages}{1}
\verb{doi}
\verb 10.3389/fenrg.2022.842201
\endverb
\endentry
\entry{diasponce2025development}{article}{}{}
\name{author}{8}{}{%
{{hash=16b10c578acbbdff1db63ca41559f13f}{%
family={Díaz-Ponce},
familyi={D\bibinithyphendelim P\bibinitperiod},
given={Arturo},
giveni={A\bibinitperiod}}}%
{{hash=38a31a2f87fbd47d26b0cffb12afaaab}{%
family={Trejo-Zúñiga},
familyi={T\bibinithyphendelim Z\bibinitperiod},
given={Iván},
giveni={I\bibinitperiod}}}%
{{hash=42e5ff6dcab36e23c6374024a6d2aa63}{%
family={Dávalos\bibnamedelima Hernández},
familyi={D\bibinitperiod\bibinitdelim H\bibinitperiod},
given={Fernando},
giveni={F\bibinitperiod}}}%
{{hash=d3dcaed7cf262d228067a02aefbb76c7}{%
family={Rodrigo},
familyi={R\bibinitperiod},
given={Pedro\bibnamedelima M},
giveni={P\bibinitperiod\bibinitdelim M\bibinitperiod}}}%
{{hash=4d0c217b441e12ca8bd1a6077e2c7ff1}{%
family={Ceballos},
familyi={C\bibinitperiod},
given={María\bibnamedelima A},
giveni={M\bibinitperiod\bibinitdelim A\bibinitperiod}}}%
{{hash=2682e6a0e5992d8d43109a29a4e70192}{%
family={Valera-Albacete},
familyi={V\bibinithyphendelim A\bibinitperiod},
given={Álvaro},
giveni={Á\bibinitperiod}}}%
{{hash=8c36773e57051c599e1c671a1753a1c7}{%
family={Almonacid},
familyi={A\bibinitperiod},
given={Florencia},
giveni={F\bibinitperiod}}}%
{{hash=79c6ddf92e15ea523ac21a2e5290da74}{%
family={Fernández},
familyi={F\bibinitperiod},
given={Eduardo\bibnamedelima F},
giveni={E\bibinitperiod\bibinitdelim F\bibinitperiod}}}%
}
\list{publisher}{1}{%
{Elsevier}%
}
\strng{namehash}{898da86d435308304e44b93dbef14acd}
\strng{fullhash}{639845e765f0bff1eef80ddffc2aad90}
\strng{fullhashraw}{639845e765f0bff1eef80ddffc2aad90}
\strng{bibnamehash}{898da86d435308304e44b93dbef14acd}
\strng{authorbibnamehash}{898da86d435308304e44b93dbef14acd}
\strng{authornamehash}{898da86d435308304e44b93dbef14acd}
\strng{authorfullhash}{639845e765f0bff1eef80ddffc2aad90}
\strng{authorfullhashraw}{639845e765f0bff1eef80ddffc2aad90}
\field{sortinit}{5}
\field{sortinithash}{20e9b4b0b173788c5dace24730f47d8c}
\field{labelnamesource}{author}
\field{labeltitlesource}{title}
\field{journaltitle}{Results in Engineering}
\field{title}{Development of a proof-of-concept concentrating photovoltaic module with integrated tracking and azimuthal cell rotation}
\field{volume}{27}
\field{year}{2025}
\field{pages}{106735}
\range{pages}{1}
\verb{doi}
\verb 10.1016/j.rineng.2025.106735
\endverb
\endentry
\entry{suncalc2026}{misc}{}{}
\name{author}{1}{}{%
{{hash=1d5bcf629c7430ee2d0445d9216eda23}{%
family={Hoffmann},
familyi={H\bibinitperiod},
given={Torsten},
giveni={T\bibinitperiod}}}%
}
\strng{namehash}{1d5bcf629c7430ee2d0445d9216eda23}
\strng{fullhash}{1d5bcf629c7430ee2d0445d9216eda23}
\strng{fullhashraw}{1d5bcf629c7430ee2d0445d9216eda23}
\strng{bibnamehash}{1d5bcf629c7430ee2d0445d9216eda23}
\strng{authorbibnamehash}{1d5bcf629c7430ee2d0445d9216eda23}
\strng{authornamehash}{1d5bcf629c7430ee2d0445d9216eda23}
\strng{authorfullhash}{1d5bcf629c7430ee2d0445d9216eda23}
\strng{authorfullhashraw}{1d5bcf629c7430ee2d0445d9216eda23}
\field{sortinit}{7}
\field{sortinithash}{108d0be1b1bee9773a1173443802c0a3}
\field{labelnamesource}{author}
\field{labeltitlesource}{title}
\field{note}{Supplied printout at latitude 21.87304 degrees N and longitude 102.28282 degrees W; local-time azimuth and elevation at 15-minute intervals}
\field{title}{{SunCalc}: Daily Solar-Position Data for Aguascalientes, 4 November 2026}
\field{urlday}{4}
\field{urlmonth}{11}
\field{urlyear}{2026}
\field{year}{2026}
\field{urldateera}{ce}
\verb{urlraw}
\verb https://www.suncalc.org/
\endverb
\verb{url}
\verb https://www.suncalc.org/
\endverb
\endentry
\entry{apogee_sp212_manual}{manual}{}{}
\name{author}{1}{}{%
{{hash=9ceeaf39bcf8c0a9953e16aec2598ba1}{%
family={{Apogee Instruments}},
familyi={A\bibinitperiod}}}%
}
\strng{namehash}{9ceeaf39bcf8c0a9953e16aec2598ba1}
\strng{fullhash}{9ceeaf39bcf8c0a9953e16aec2598ba1}
\strng{fullhashraw}{9ceeaf39bcf8c0a9953e16aec2598ba1}
\strng{bibnamehash}{9ceeaf39bcf8c0a9953e16aec2598ba1}
\strng{authorbibnamehash}{9ceeaf39bcf8c0a9953e16aec2598ba1}
\strng{authornamehash}{9ceeaf39bcf8c0a9953e16aec2598ba1}
\strng{authorfullhash}{9ceeaf39bcf8c0a9953e16aec2598ba1}
\strng{authorfullhashraw}{9ceeaf39bcf8c0a9953e16aec2598ba1}
\field{sortinit}{8}
\field{sortinithash}{a231b008ebf0ecbe0b4d96dcc159445f}
\field{labelnamesource}{author}
\field{labeltitlesource}{title}
\field{title}{{SP-212/SP-215} Pyranometer Instruction Manual}
\field{urlday}{4}
\field{urlmonth}{11}
\field{urlyear}{2026}
\field{urldateera}{ce}
\verb{urlraw}
\verb https://www.apogeeinstruments.com/content/SP-212-215-manual.pdf
\endverb
\verb{url}
\verb https://www.apogeeinstruments.com/content/SP-212-215-manual.pdf
\endverb
\endentry
\entry{pvwatts_module_type}{online}{}{}
\name{author}{1}{}{%
{{hash=a988a3d1fc8b0d60e14ba26fe95fbeec}{%
family={{PVWatts}},
familyi={P\bibinitperiod}}}%
}
\strng{namehash}{a988a3d1fc8b0d60e14ba26fe95fbeec}
\strng{fullhash}{a988a3d1fc8b0d60e14ba26fe95fbeec}
\strng{fullhashraw}{a988a3d1fc8b0d60e14ba26fe95fbeec}
\strng{bibnamehash}{a988a3d1fc8b0d60e14ba26fe95fbeec}
\strng{authorbibnamehash}{a988a3d1fc8b0d60e14ba26fe95fbeec}
\strng{authornamehash}{a988a3d1fc8b0d60e14ba26fe95fbeec}
\strng{authorfullhash}{a988a3d1fc8b0d60e14ba26fe95fbeec}
\strng{authorfullhashraw}{a988a3d1fc8b0d60e14ba26fe95fbeec}
\field{sortinit}{9}
\field{sortinithash}{0a5ebc79d83c96b6579069544c73c7d4}
\field{labelnamesource}{author}
\field{labeltitlesource}{title}
\field{note}{Standard and Premium crystalline-silicon module assumptions}
\field{title}{Module Type}
\field{urlday}{4}
\field{urlmonth}{11}
\field{urlyear}{2026}
\field{urldateera}{ce}
\verb{urlraw}
\verb https://pvwatts.nlr.gov/docs/help_module_type.php
\endverb
\verb{url}
\verb https://pvwatts.nlr.gov/docs/help_module_type.php
\endverb
\endentry
\entry{sandia_poa}{online}{}{}
\name{author}{1}{}{%
{{hash=ae9b7482c36c996765c231757ec6c684}{%
family={{Sandia National Laboratories}},
familyi={S\bibinitperiod}}}%
}
\list{organization}{1}{%
{PV Performance Modeling Collaborative}%
}
\strng{namehash}{ae9b7482c36c996765c231757ec6c684}
\strng{fullhash}{ae9b7482c36c996765c231757ec6c684}
\strng{fullhashraw}{ae9b7482c36c996765c231757ec6c684}
\strng{bibnamehash}{ae9b7482c36c996765c231757ec6c684}
\strng{authorbibnamehash}{ae9b7482c36c996765c231757ec6c684}
\strng{authornamehash}{ae9b7482c36c996765c231757ec6c684}
\strng{authorfullhash}{ae9b7482c36c996765c231757ec6c684}
\strng{authorfullhashraw}{ae9b7482c36c996765c231757ec6c684}
\field{extraname}{1}
\field{sortinit}{1}
\field{sortinithash}{4f6aaa89bab872aa0999fec09ff8e98a}
\field{labelnamesource}{author}
\field{labeltitlesource}{title}
\field{title}{Plane of Array ({POA}) Irradiance}
\field{urlday}{4}
\field{urlmonth}{11}
\field{urlyear}{2026}
\field{urldateera}{ce}
\verb{urlraw}
\verb https://pvpmc.sandia.gov/modeling-guide/1-weather-design-inputs/plane-of-array-poa-irradiance/
\endverb
\verb{url}
\verb https://pvpmc.sandia.gov/modeling-guide/1-weather-design-inputs/plane-of-array-poa-irradiance/
\endverb
\endentry
\entry{sandia_pvwatts}{online}{}{}
\name{author}{1}{}{%
{{hash=ae9b7482c36c996765c231757ec6c684}{%
family={{Sandia National Laboratories}},
familyi={S\bibinitperiod}}}%
}
\list{organization}{1}{%
{PV Performance Modeling Collaborative}%
}
\strng{namehash}{ae9b7482c36c996765c231757ec6c684}
\strng{fullhash}{ae9b7482c36c996765c231757ec6c684}
\strng{fullhashraw}{ae9b7482c36c996765c231757ec6c684}
\strng{bibnamehash}{ae9b7482c36c996765c231757ec6c684}
\strng{authorbibnamehash}{ae9b7482c36c996765c231757ec6c684}
\strng{authornamehash}{ae9b7482c36c996765c231757ec6c684}
\strng{authorfullhash}{ae9b7482c36c996765c231757ec6c684}
\strng{authorfullhashraw}{ae9b7482c36c996765c231757ec6c684}
\field{extraname}{2}
\field{sortinit}{1}
\field{sortinithash}{4f6aaa89bab872aa0999fec09ff8e98a}
\field{labelnamesource}{author}
\field{labeltitlesource}{title}
\field{title}{{PVWatts}}
\field{urlday}{4}
\field{urlmonth}{11}
\field{urlyear}{2026}
\field{urldateera}{ce}
\verb{urlraw}
\verb https://pvpmc.sandia.gov/modeling-guide/2-dc-module-iv/point-value-models/pvwatts/
\endverb
\verb{url}
\verb https://pvpmc.sandia.gov/modeling-guide/2-dc-module-iv/point-value-models/pvwatts/
\endverb
\endentry
\enddatalist
\endrefsection
\endinput
Binary file not shown.
+850
View File
@@ -0,0 +1,850 @@
% $ biblatex auxiliary file $
% $ biblatex bbl format version 3.3 $
% Do not modify the above lines!
%
% This is an auxiliary file used by the 'biblatex' package.
% This file may safely be deleted. It will be recreated by
% biber as required.
%
\begingroup
\makeatletter
\@ifundefined{ver@biblatex.sty}
{\@latex@error
{Missing 'biblatex' package}
{The bibliography requires the 'biblatex' package.}
\aftergroup\endinput}
{}
\endgroup
\refsection{0}
\datalist[entry]{none/global//global/global/global}
\entry{frydrychowicz2013influence}{article}{}{}
\name{author}{2}{}{%
{{hash=a5fe7802a098bd22903575dcad0d75ba}{%
family={Frydrychowicz-Jastrzębska},
familyi={F\bibinithyphendelim J\bibinitperiod},
given={Grażyna},
giveni={G\bibinitperiod}}}%
{{hash=e9553dd4370c6f21f6d8d75ea0184201}{%
family={Buga{ł}a},
familyi={B\bibinitperiod},
given={Artur},
giveni={A\bibinitperiod}}}%
}
\list{publisher}{1}{%
{Politechnika Poznańska. Wydawnictwo Politechniki Poznańskiej}%
}
\strng{namehash}{ca091b2b943770b887d184af65c9279e}
\strng{fullhash}{ca091b2b943770b887d184af65c9279e}
\strng{fullhashraw}{ca091b2b943770b887d184af65c9279e}
\strng{bibnamehash}{ca091b2b943770b887d184af65c9279e}
\strng{authorbibnamehash}{ca091b2b943770b887d184af65c9279e}
\strng{authornamehash}{ca091b2b943770b887d184af65c9279e}
\strng{authorfullhash}{ca091b2b943770b887d184af65c9279e}
\strng{authorfullhashraw}{ca091b2b943770b887d184af65c9279e}
\field{sortinit}{1}
\field{sortinithash}{4f6aaa89bab872aa0999fec09ff8e98a}
\field{labelnamesource}{author}
\field{labeltitlesource}{title}
\field{journaltitle}{Computer Applications in Electrical Engineering}
\field{title}{The influence of external conditions and orientation of PV module on the energetic gain}
\field{volume}{11}
\field{year}{2013}
\field{pages}{423\bibrangedash 439}
\range{pages}{17}
\endentry
\entry{nadia2018advances}{article}{}{}
\name{author}{3}{}{%
{{hash=f3741fcf31c46bbc76105eb979b58c25}{%
family={Nadia},
familyi={N\bibinitperiod},
given={AL-Rousan},
giveni={A\bibinithyphendelim R\bibinitperiod}}}%
{{hash=9c258aec30132d70087e4b9fc38a456b}{%
family={Isa},
familyi={I\bibinitperiod},
given={Nor\bibnamedelimb Ashidi\bibnamedelima Mat},
giveni={N\bibinitperiod\bibinitdelim A\bibinitperiod\bibinitdelim M\bibinitperiod}}}%
{{hash=b7d88225c056f6a4c34ba9190adc8147}{%
family={Desa},
familyi={D\bibinitperiod},
given={Mohd\bibnamedelimb Khairunaz\bibnamedelima Mat},
giveni={M\bibinitperiod\bibinitdelim K\bibinitperiod\bibinitdelim M\bibinitperiod}}}%
}
\list{publisher}{1}{%
{Elsevier}%
}
\strng{namehash}{0a6fd5e7498d92c42a6b586f0ef6403f}
\strng{fullhash}{0a6fd5e7498d92c42a6b586f0ef6403f}
\strng{fullhashraw}{0a6fd5e7498d92c42a6b586f0ef6403f}
\strng{bibnamehash}{0a6fd5e7498d92c42a6b586f0ef6403f}
\strng{authorbibnamehash}{0a6fd5e7498d92c42a6b586f0ef6403f}
\strng{authornamehash}{0a6fd5e7498d92c42a6b586f0ef6403f}
\strng{authorfullhash}{0a6fd5e7498d92c42a6b586f0ef6403f}
\strng{authorfullhashraw}{0a6fd5e7498d92c42a6b586f0ef6403f}
\field{sortinit}{2}
\field{sortinithash}{8b555b3791beccb63322c22f3320aa9a}
\field{labelnamesource}{author}
\field{labeltitlesource}{title}
\field{journaltitle}{Renewable and sustainable energy reviews}
\field{title}{Advances in solar photovoltaic tracking systems: A review}
\field{volume}{82}
\field{year}{2018}
\field{pages}{2548\bibrangedash 2569}
\range{pages}{22}
\endentry
\entry{mousazadeh2009review}{article}{}{}
\name{author}{6}{}{%
{{hash=e824e52aa2a3a8bff5ea2902854bf63d}{%
family={Mousazadeh},
familyi={M\bibinitperiod},
given={Hossein},
giveni={H\bibinitperiod}}}%
{{hash=26b8818d27f155119b45b2a5e91f30ac}{%
family={Keyhani},
familyi={K\bibinitperiod},
given={Alireza},
giveni={A\bibinitperiod}}}%
{{hash=9df8b66950e0739f6a6557717db5b72c}{%
family={Javadi},
familyi={J\bibinitperiod},
given={Arzhang},
giveni={A\bibinitperiod}}}%
{{hash=fb9779cd36e2f0c80f6d020040cf6192}{%
family={Mobli},
familyi={M\bibinitperiod},
given={Hossein},
giveni={H\bibinitperiod}}}%
{{hash=633d0f4a21f11db8f62f0cb171c317a6}{%
family={Abrinia},
familyi={A\bibinitperiod},
given={Karen},
giveni={K\bibinitperiod}}}%
{{hash=11d0654599e5980c42cc0479e6dcaf94}{%
family={Sharifi},
familyi={S\bibinitperiod},
given={Ahmad},
giveni={A\bibinitperiod}}}%
}
\list{publisher}{1}{%
{Elsevier}%
}
\strng{namehash}{e01e813d810d7b09516ad937cfdb1fed}
\strng{fullhash}{57454e01159eff45a9685706add92d5c}
\strng{fullhashraw}{57454e01159eff45a9685706add92d5c}
\strng{bibnamehash}{e01e813d810d7b09516ad937cfdb1fed}
\strng{authorbibnamehash}{e01e813d810d7b09516ad937cfdb1fed}
\strng{authornamehash}{e01e813d810d7b09516ad937cfdb1fed}
\strng{authorfullhash}{57454e01159eff45a9685706add92d5c}
\strng{authorfullhashraw}{57454e01159eff45a9685706add92d5c}
\field{sortinit}{3}
\field{sortinithash}{ad6fe7482ffbd7b9f99c9e8b5dccd3d7}
\field{labelnamesource}{author}
\field{labeltitlesource}{title}
\field{journaltitle}{Renewable and sustainable energy reviews}
\field{number}{8}
\field{title}{A review of principle and sun-tracking methods for maximizing solar systems output}
\field{volume}{13}
\field{year}{2009}
\field{pages}{1800\bibrangedash 1818}
\range{pages}{19}
\endentry
\entry{hafez2018solar}{article}{}{}
\name{author}{3}{}{%
{{hash=14459a81e11881614a7d934b4b9adf2b}{%
family={Hafez},
familyi={H\bibinitperiod},
given={AZ},
giveni={A\bibinitperiod}}}%
{{hash=fd20674dda510067db72990da4f158ae}{%
family={Yousef},
familyi={Y\bibinitperiod},
given={AM},
giveni={A\bibinitperiod}}}%
{{hash=da8e48203a77557994878bf7ab9fa218}{%
family={Harag},
familyi={H\bibinitperiod},
given={NM},
giveni={N\bibinitperiod}}}%
}
\list{publisher}{1}{%
{Elsevier}%
}
\strng{namehash}{4242dd0d0ceb3a95d29da028f8a5cd78}
\strng{fullhash}{4242dd0d0ceb3a95d29da028f8a5cd78}
\strng{fullhashraw}{4242dd0d0ceb3a95d29da028f8a5cd78}
\strng{bibnamehash}{4242dd0d0ceb3a95d29da028f8a5cd78}
\strng{authorbibnamehash}{4242dd0d0ceb3a95d29da028f8a5cd78}
\strng{authornamehash}{4242dd0d0ceb3a95d29da028f8a5cd78}
\strng{authorfullhash}{4242dd0d0ceb3a95d29da028f8a5cd78}
\strng{authorfullhashraw}{4242dd0d0ceb3a95d29da028f8a5cd78}
\field{sortinit}{3}
\field{sortinithash}{ad6fe7482ffbd7b9f99c9e8b5dccd3d7}
\field{labelnamesource}{author}
\field{labeltitlesource}{title}
\field{journaltitle}{Renewable and Sustainable Energy Reviews}
\field{title}{Solar tracking systems: Technologies and trackers drive types--A review}
\field{volume}{91}
\field{year}{2018}
\field{pages}{754\bibrangedash 782}
\range{pages}{29}
\endentry
\entry{awasthi2020review}{article}{}{}
\name{author}{7}{}{%
{{hash=649210f7c2ee071a0c0b21ff24ba0a1f}{%
family={Awasthi},
familyi={A\bibinitperiod},
given={Anshul},
giveni={A\bibinitperiod}}}%
{{hash=75f80e4d4704a12447628ae35807ae6a}{%
family={Shukla},
familyi={S\bibinitperiod},
given={Akash\bibnamedelima Kumar},
giveni={A\bibinitperiod\bibinitdelim K\bibinitperiod}}}%
{{hash=80ced7d55180b9f2000bf8dcab5ed656}{%
family={SR},
familyi={S\bibinitperiod},
given={Murali\bibnamedelima Manohar},
giveni={M\bibinitperiod\bibinitdelim M\bibinitperiod}}}%
{{hash=28aff1cc3895d3dd3d397adfc6353471}{%
family={Dondariya},
familyi={D\bibinitperiod},
given={Chandrakant},
giveni={C\bibinitperiod}}}%
{{hash=4771676c9f9c278dc4536fce937bb2d5}{%
family={Shukla},
familyi={S\bibinitperiod},
given={KN},
giveni={K\bibinitperiod}}}%
{{hash=76ab3beb064bc7ccd3c1849ce2b38378}{%
family={Porwal},
familyi={P\bibinitperiod},
given={Deepak},
giveni={D\bibinitperiod}}}%
{{hash=03105f531883bd0142678f0a9288d8b0}{%
family={Richhariya},
familyi={R\bibinitperiod},
given={Geetam},
giveni={G\bibinitperiod}}}%
}
\list{publisher}{1}{%
{Elsevier}%
}
\strng{namehash}{ece2d746be2b5fd48fcf6da94f83a616}
\strng{fullhash}{913772538e770f006b352aa891973859}
\strng{fullhashraw}{913772538e770f006b352aa891973859}
\strng{bibnamehash}{ece2d746be2b5fd48fcf6da94f83a616}
\strng{authorbibnamehash}{ece2d746be2b5fd48fcf6da94f83a616}
\strng{authornamehash}{ece2d746be2b5fd48fcf6da94f83a616}
\strng{authorfullhash}{913772538e770f006b352aa891973859}
\strng{authorfullhashraw}{913772538e770f006b352aa891973859}
\field{sortinit}{3}
\field{sortinithash}{ad6fe7482ffbd7b9f99c9e8b5dccd3d7}
\field{labelnamesource}{author}
\field{labeltitlesource}{title}
\field{journaltitle}{Energy Reports}
\field{title}{Review on sun tracking technology in solar PV system}
\field{volume}{6}
\field{year}{2020}
\field{pages}{392\bibrangedash 405}
\range{pages}{14}
\endentry
\entry{sadeghi2025review}{article}{}{}
\name{author}{5}{}{%
{{hash=32987465a3359bd8fdccce2a5b9461e7}{%
family={Sadeghi},
familyi={S\bibinitperiod},
given={Reza},
giveni={R\bibinitperiod}}}%
{{hash=551b3ae839d35465dc0f9b9950f48590}{%
family={Parenti},
familyi={P\bibinitperiod},
given={Mattia},
giveni={M\bibinitperiod}}}%
{{hash=c5169de9ca06c4d7dafdf9e5985ca65c}{%
family={Memme},
familyi={M\bibinitperiod},
given={Samuele},
giveni={S\bibinitperiod}}}%
{{hash=916d023e3931b4ef05ccec8101e0e482}{%
family={Fossa},
familyi={F\bibinitperiod},
given={Marco},
giveni={M\bibinitperiod}}}%
{{hash=235c4181b50e6c229ab23917c79915df}{%
family={Morchio},
familyi={M\bibinitperiod},
given={Stefano},
giveni={S\bibinitperiod}}}%
}
\list{publisher}{1}{%
{MDPI}%
}
\strng{namehash}{df9ed2054b44ea632f5822858c048608}
\strng{fullhash}{4aea374bad48dd78c66234ec00d015a8}
\strng{fullhashraw}{4aea374bad48dd78c66234ec00d015a8}
\strng{bibnamehash}{df9ed2054b44ea632f5822858c048608}
\strng{authorbibnamehash}{df9ed2054b44ea632f5822858c048608}
\strng{authornamehash}{df9ed2054b44ea632f5822858c048608}
\strng{authorfullhash}{4aea374bad48dd78c66234ec00d015a8}
\strng{authorfullhashraw}{4aea374bad48dd78c66234ec00d015a8}
\field{sortinit}{3}
\field{sortinithash}{ad6fe7482ffbd7b9f99c9e8b5dccd3d7}
\field{labelnamesource}{author}
\field{labeltitlesource}{title}
\field{journaltitle}{Energies}
\field{number}{10}
\field{title}{A review and comparative analysis of solar tracking systems}
\field{volume}{18}
\field{year}{2025}
\field{pages}{2553}
\range{pages}{1}
\endentry
\entry{fuentes2020control}{article}{}{}
\name{author}{7}{}{%
{{hash=1133ace3429bb36f13169ed3d661afc2}{%
family={Fuentes-Morales},
familyi={F\bibinithyphendelim M\bibinitperiod},
given={Rosa\bibnamedelima F},
giveni={R\bibinitperiod\bibinitdelim F\bibinitperiod}}}%
{{hash=c94df6c668b35d33713dbd905b19b2bc}{%
family={Diaz-Ponce},
familyi={D\bibinithyphendelim P\bibinitperiod},
given={Arturo},
giveni={A\bibinitperiod}}}%
{{hash=80b889c4aa49e0b027f7ee85491abe71}{%
family={Peña-Cruz},
familyi={P\bibinithyphendelim C\bibinitperiod},
given={Manuel\bibnamedelima I},
giveni={M\bibinitperiod\bibinitdelim I\bibinitperiod}}}%
{{hash=d3dcaed7cf262d228067a02aefbb76c7}{%
family={Rodrigo},
familyi={R\bibinitperiod},
given={Pedro\bibnamedelima M},
giveni={P\bibinitperiod\bibinitdelim M\bibinitperiod}}}%
{{hash=9c7ab6b9c905d154778a136eb26b39e4}{%
family={Valentín-Coronado},
familyi={V\bibinithyphendelim C\bibinitperiod},
given={Luis\bibnamedelima M},
giveni={L\bibinitperiod\bibinitdelim M\bibinitperiod}}}%
{{hash=4cfcb402b18ac4f325b0e3e8053a7aa0}{%
family={Martell-Chavez},
familyi={M\bibinithyphendelim C\bibinitperiod},
given={Fernando},
giveni={F\bibinitperiod}}}%
{{hash=53a221f00e6e9a37ecd95557510eab0c}{%
family={Pineda-Arellano},
familyi={P\bibinithyphendelim A\bibinitperiod},
given={Carlos\bibnamedelima A},
giveni={C\bibinitperiod\bibinitdelim A\bibinitperiod}}}%
}
\list{publisher}{1}{%
{Elsevier}%
}
\strng{namehash}{1ca5f0acced4dd867c7c1aee44e4d5d1}
\strng{fullhash}{6ff42d0b8ef2ac128cfcfffdeead20e3}
\strng{fullhashraw}{6ff42d0b8ef2ac128cfcfffdeead20e3}
\strng{bibnamehash}{1ca5f0acced4dd867c7c1aee44e4d5d1}
\strng{authorbibnamehash}{1ca5f0acced4dd867c7c1aee44e4d5d1}
\strng{authornamehash}{1ca5f0acced4dd867c7c1aee44e4d5d1}
\strng{authorfullhash}{6ff42d0b8ef2ac128cfcfffdeead20e3}
\strng{authorfullhashraw}{6ff42d0b8ef2ac128cfcfffdeead20e3}
\field{sortinit}{5}
\field{sortinithash}{20e9b4b0b173788c5dace24730f47d8c}
\field{labelnamesource}{author}
\field{labeltitlesource}{title}
\field{journaltitle}{Solar Energy}
\field{title}{Control algorithms applied to active solar tracking systems: A review}
\field{volume}{212}
\field{year}{2020}
\field{pages}{203\bibrangedash 219}
\range{pages}{17}
\endentry
\entry{sumathi2017solar}{article}{}{}
\name{author}{4}{}{%
{{hash=a70a10b3e0c6c792ecfcabc702631a8f}{%
family={Sumathi},
familyi={S\bibinitperiod},
given={Vijayan},
giveni={V\bibinitperiod}}}%
{{hash=ab25267fe218d7091543bb3214f720ef}{%
family={Jayapragash},
familyi={J\bibinitperiod},
given={R},
giveni={R\bibinitperiod}}}%
{{hash=b27c74852ac6d6f1f90d423bb2fb231d}{%
family={Bakshi},
familyi={B\bibinitperiod},
given={Abhinav},
giveni={A\bibinitperiod}}}%
{{hash=b2ead12dffcc5e746dbc6a1db4ed7825}{%
family={Akella},
familyi={A\bibinitperiod},
given={Praveen\bibnamedelima Kumar},
giveni={P\bibinitperiod\bibinitdelim K\bibinitperiod}}}%
}
\list{publisher}{1}{%
{Elsevier}%
}
\strng{namehash}{facaa3a2774c1b733de9e3497e484755}
\strng{fullhash}{bd7e41e62e64f481316c302d020f8692}
\strng{fullhashraw}{bd7e41e62e64f481316c302d020f8692}
\strng{bibnamehash}{facaa3a2774c1b733de9e3497e484755}
\strng{authorbibnamehash}{facaa3a2774c1b733de9e3497e484755}
\strng{authornamehash}{facaa3a2774c1b733de9e3497e484755}
\strng{authorfullhash}{bd7e41e62e64f481316c302d020f8692}
\strng{authorfullhashraw}{bd7e41e62e64f481316c302d020f8692}
\field{sortinit}{6}
\field{sortinithash}{b33bc299efb3c36abec520a4c896a66d}
\field{labelnamesource}{author}
\field{labeltitlesource}{title}
\field{journaltitle}{Renewable and Sustainable Energy Reviews}
\field{title}{Solar tracking methods to maximize PV system output--A review of the methods adopted in recent decade}
\field{volume}{74}
\field{year}{2017}
\field{pages}{130\bibrangedash 138}
\range{pages}{9}
\endentry
\entry{antonini2010photovoltaic}{article}{}{}
\name{author}{1}{}{%
{{hash=f89457357b8ad40e93206f5fc54d77f8}{%
family={Antonini},
familyi={A\bibinitperiod},
given={Andrea},
giveni={A\bibinitperiod}}}%
}
\strng{namehash}{f89457357b8ad40e93206f5fc54d77f8}
\strng{fullhash}{f89457357b8ad40e93206f5fc54d77f8}
\strng{fullhashraw}{f89457357b8ad40e93206f5fc54d77f8}
\strng{bibnamehash}{f89457357b8ad40e93206f5fc54d77f8}
\strng{authorbibnamehash}{f89457357b8ad40e93206f5fc54d77f8}
\strng{authornamehash}{f89457357b8ad40e93206f5fc54d77f8}
\strng{authorfullhash}{f89457357b8ad40e93206f5fc54d77f8}
\strng{authorfullhashraw}{f89457357b8ad40e93206f5fc54d77f8}
\field{sortinit}{7}
\field{sortinithash}{108d0be1b1bee9773a1173443802c0a3}
\field{labelnamesource}{author}
\field{labeltitlesource}{title}
\field{journaltitle}{Sol. Collect. Panels, Theory Appl., Sciyo}
\field{title}{Photovoltaic Concentrators-Fundamentals, Applications, Market \& Prospective}
\field{year}{2010}
\field{pages}{31\bibrangedash 54}
\range{pages}{24}
\endentry
\entry{iqbal2023optical}{article}{}{}
\name{author}{3}{}{%
{{hash=0664aba4d6f9510dfc715503ff1e668d}{%
family={Iqbal},
familyi={I\bibinitperiod},
given={Waseem},
giveni={W\bibinitperiod}}}%
{{hash=a65894b864a4056158acdf72bcb84931}{%
family={Ullah},
familyi={U\bibinitperiod},
given={Irfan},
giveni={I\bibinitperiod}}}%
{{hash=b5bd6aa8e334de72638830aa8eae18d1}{%
family={Shin},
familyi={S\bibinitperiod},
given={Seoyong},
giveni={S\bibinitperiod}}}%
}
\list{publisher}{1}{%
{MDPI}%
}
\strng{namehash}{2aa4a75613f1c0d1ef4ec00803b87bec}
\strng{fullhash}{2aa4a75613f1c0d1ef4ec00803b87bec}
\strng{fullhashraw}{2aa4a75613f1c0d1ef4ec00803b87bec}
\strng{bibnamehash}{2aa4a75613f1c0d1ef4ec00803b87bec}
\strng{authorbibnamehash}{2aa4a75613f1c0d1ef4ec00803b87bec}
\strng{authornamehash}{2aa4a75613f1c0d1ef4ec00803b87bec}
\strng{authorfullhash}{2aa4a75613f1c0d1ef4ec00803b87bec}
\strng{authorfullhashraw}{2aa4a75613f1c0d1ef4ec00803b87bec}
\field{sortinit}{7}
\field{sortinithash}{108d0be1b1bee9773a1173443802c0a3}
\field{labelnamesource}{author}
\field{labeltitlesource}{title}
\field{journaltitle}{Sustainability}
\field{number}{13}
\field{title}{Optical developments in concentrator photovoltaic systems—a review}
\field{volume}{15}
\field{year}{2023}
\field{pages}{10554}
\range{pages}{1}
\endentry
\entry{ceballos2023tracking}{article}{}{}
\name{author}{4}{}{%
{{hash=16075f6fc9e32e8a67a5313c3c2d4b44}{%
family={Ceballos},
familyi={C\bibinitperiod},
given={Maria\bibnamedelima A},
giveni={M\bibinitperiod\bibinitdelim A\bibinitperiod}}}%
{{hash=1d3faf0e9e6d4a15bdb1ae6e7205557a}{%
family={Pérez-Higueras},
familyi={P\bibinithyphendelim H\bibinitperiod},
given={Pedro\bibnamedelima J},
giveni={P\bibinitperiod\bibinitdelim J\bibinitperiod}}}%
{{hash=79c6ddf92e15ea523ac21a2e5290da74}{%
family={Fernández},
familyi={F\bibinitperiod},
given={Eduardo\bibnamedelima F},
giveni={E\bibinitperiod\bibinitdelim F\bibinitperiod}}}%
{{hash=a3e2a878cce2df0c6b6edfbc24184226}{%
family={Almonacid},
familyi={A\bibinitperiod},
given={Florencia\bibnamedelima M},
giveni={F\bibinitperiod\bibinitdelim M\bibinitperiod}}}%
}
\list{publisher}{1}{%
{Preprints}%
}
\strng{namehash}{266476a2d420f95ffda9a64e45ccd50d}
\strng{fullhash}{842406545db43052c4d4606de65b73d9}
\strng{fullhashraw}{842406545db43052c4d4606de65b73d9}
\strng{bibnamehash}{266476a2d420f95ffda9a64e45ccd50d}
\strng{authorbibnamehash}{266476a2d420f95ffda9a64e45ccd50d}
\strng{authornamehash}{266476a2d420f95ffda9a64e45ccd50d}
\strng{authorfullhash}{842406545db43052c4d4606de65b73d9}
\strng{authorfullhashraw}{842406545db43052c4d4606de65b73d9}
\field{sortinit}{8}
\field{sortinithash}{a231b008ebf0ecbe0b4d96dcc159445f}
\field{labelnamesource}{author}
\field{labeltitlesource}{title}
\field{title}{Tracking-integrated cpv technology: State-of-the-art and classification}
\field{year}{2023}
\endentry
\entry{angulo2022development}{article}{}{}
\name{author}{6}{}{%
{{hash=589a0d83f97f290911daafa2b4860b35}{%
family={Angulo-Calderón},
familyi={A\bibinithyphendelim C\bibinitperiod},
given={Marthoz},
giveni={M\bibinitperiod}}}%
{{hash=86700da7ebbedfe9e7e865ce2f66aee1}{%
family={Salgado-Tránsito},
familyi={S\bibinithyphendelim T\bibinitperiod},
given={Iván},
giveni={I\bibinitperiod}}}%
{{hash=38a31a2f87fbd47d26b0cffb12afaaab}{%
family={Trejo-Zúñiga},
familyi={T\bibinithyphendelim Z\bibinitperiod},
given={Iván},
giveni={I\bibinitperiod}}}%
{{hash=cdbf5537b348112cf1dbcf912115b0ba}{%
family={Paredes-Orta},
familyi={P\bibinithyphendelim O\bibinitperiod},
given={Carlos},
giveni={C\bibinitperiod}}}%
{{hash=32cc4c12a7ad94859aa4f1fa0d39c4e2}{%
family={Kesthkar},
familyi={K\bibinitperiod},
given={Sajjad},
giveni={S\bibinitperiod}}}%
{{hash=16b10c578acbbdff1db63ca41559f13f}{%
family={Díaz-Ponce},
familyi={D\bibinithyphendelim P\bibinitperiod},
given={Arturo},
giveni={A\bibinitperiod}}}%
}
\list{publisher}{1}{%
{MDPI}%
}
\strng{namehash}{61714dc468c589f73d18a9c4b89d4f53}
\strng{fullhash}{959eb1593ea81acfab49e6d659f50373}
\strng{fullhashraw}{959eb1593ea81acfab49e6d659f50373}
\strng{bibnamehash}{61714dc468c589f73d18a9c4b89d4f53}
\strng{authorbibnamehash}{61714dc468c589f73d18a9c4b89d4f53}
\strng{authornamehash}{61714dc468c589f73d18a9c4b89d4f53}
\strng{authorfullhash}{959eb1593ea81acfab49e6d659f50373}
\strng{authorfullhashraw}{959eb1593ea81acfab49e6d659f50373}
\field{sortinit}{9}
\field{sortinithash}{0a5ebc79d83c96b6579069544c73c7d4}
\field{labelnamesource}{author}
\field{labeltitlesource}{title}
\field{journaltitle}{Applied Sciences}
\field{number}{5}
\field{title}{Development and accuracy assessment of a high-precision dual-axis pre-commercial solar tracker for concentrating photovoltaic modules}
\field{volume}{12}
\field{year}{2022}
\field{pages}{2625}
\range{pages}{1}
\endentry
\entry{ito2018optical}{article}{}{}
\name{author}{3}{}{%
{{hash=7833f50d7b9aede33f9d21810a75dd5a}{%
family={Ito},
familyi={I\bibinitperiod},
given={Atsushi},
giveni={A\bibinitperiod}}}%
{{hash=9a7a08541977b1e11d75e3ebb43d4b36}{%
family={Sato},
familyi={S\bibinitperiod},
given={Daisuke},
giveni={D\bibinitperiod}}}%
{{hash=048deb855fbfc7f3ff923fe0e25c2003}{%
family={Yamada},
familyi={Y\bibinitperiod},
given={Noboru},
giveni={N\bibinitperiod}}}%
}
\list{publisher}{1}{%
{OSA}%
}
\strng{namehash}{9d15280672af3b7051108e703777a881}
\strng{fullhash}{9d15280672af3b7051108e703777a881}
\strng{fullhashraw}{9d15280672af3b7051108e703777a881}
\strng{bibnamehash}{9d15280672af3b7051108e703777a881}
\strng{authorbibnamehash}{9d15280672af3b7051108e703777a881}
\strng{authornamehash}{9d15280672af3b7051108e703777a881}
\strng{authorfullhash}{9d15280672af3b7051108e703777a881}
\strng{authorfullhashraw}{9d15280672af3b7051108e703777a881}
\field{sortinit}{1}
\field{sortinithash}{4f6aaa89bab872aa0999fec09ff8e98a}
\field{labelnamesource}{author}
\field{labeltitlesource}{title}
\field{journaltitle}{Optics Express}
\field{number}{18}
\field{title}{Optical design and demonstration of microtracking CPV module with bi-convex aspheric lens array}
\field{volume}{26}
\field{year}{2018}
\field{pages}{A879\bibrangedash A891}
\range{pages}{-1}
\endentry
\entry{askins2019performance}{inproceedings}{}{}
\true{moreauthor}
\true{morelabelname}
\name{author}{10}{}{%
{{hash=d9e4abb484d3ac8ac7767c205bde9134}{%
family={Askins},
familyi={A\bibinitperiod},
given={S},
giveni={S\bibinitperiod}}}%
{{hash=55e02ea4d856f5310144f6773cad3fc5}{%
family={Jost},
familyi={J\bibinitperiod},
given={Norman},
giveni={N\bibinitperiod}}}%
{{hash=1ddda2bb28ba45a6f98f7d1669c6c52a}{%
family={Aguilar},
familyi={A\bibinitperiod},
given={AF},
giveni={A\bibinitperiod}}}%
{{hash=65fb998167ca53d481c82c832b9119ce}{%
family={Anglade},
familyi={A\bibinitperiod},
given={Laetitia},
giveni={L\bibinitperiod}}}%
{{hash=6849a75fb683b055712a699f336cc6df}{%
family={Nardin},
familyi={N\bibinitperiod},
given={Gael},
giveni={G\bibinitperiod}}}%
{{hash=021c65f65b0dd33f5f576b8bf1475470}{%
family={Duchemin},
familyi={D\bibinitperiod},
given={Mathilde},
giveni={M\bibinitperiod}}}%
{{hash=321220104e7c25eb6016319d94ff0d27}{%
family={Gerlich},
familyi={G\bibinitperiod},
given={Florian},
giveni={F\bibinitperiod}}}%
{{hash=91353ea0d9417cb0cc06262547f901c4}{%
family={Ackerman},
familyi={A\bibinitperiod},
given={M},
giveni={M\bibinitperiod}}}%
{{hash=46fa0fd68c6440eb1d9df2c23b70a8b6}{%
family={Coulot},
familyi={C\bibinitperiod},
given={Laurent},
giveni={L\bibinitperiod}}}%
{{hash=1d4250c7262befb5c8df3f43f7aef9d4}{%
family={Petri},
familyi={P\bibinitperiod},
given={D},
giveni={D\bibinitperiod}}}%
}
\list{organization}{1}{%
{IEEE}%
}
\strng{namehash}{1c7b6c72ec44dbfad671098f718edfc3}
\strng{fullhash}{c9dd5b20477ba9fc66c7c1b086e0b155}
\strng{fullhashraw}{c9dd5b20477ba9fc66c7c1b086e0b155}
\strng{bibnamehash}{1c7b6c72ec44dbfad671098f718edfc3}
\strng{authorbibnamehash}{1c7b6c72ec44dbfad671098f718edfc3}
\strng{authornamehash}{1c7b6c72ec44dbfad671098f718edfc3}
\strng{authorfullhash}{c9dd5b20477ba9fc66c7c1b086e0b155}
\strng{authorfullhashraw}{c9dd5b20477ba9fc66c7c1b086e0b155}
\field{sortinit}{1}
\field{sortinithash}{4f6aaa89bab872aa0999fec09ff8e98a}
\field{labelnamesource}{author}
\field{labeltitlesource}{title}
\field{booktitle}{2019 IEEE 46th Photovoltaic Specialists Conference (PVSC)}
\field{title}{Performance of hybrid micro-concentrator module with integrated planar tracking and diffuse light collection}
\field{year}{2019}
\field{pages}{2507\bibrangedash 2512}
\range{pages}{6}
\endentry
\entry{younes2022realizing}{article}{}{}
\name{author}{5}{}{%
{{hash=b83866d9725c3d7823c5574e193c5c5d}{%
family={Younes},
familyi={Y\bibinitperiod},
given={Kareem},
giveni={K\bibinitperiod}}}%
{{hash=c2a0d5fd5e3517ef3bb93622e64f4a2c}{%
family={Apostoleris},
familyi={A\bibinitperiod},
given={Harry},
giveni={H\bibinitperiod}}}%
{{hash=28bf933452f28f75847101e1e0bd702c}{%
family={Bin\bibnamedelima Saad},
familyi={B\bibinitperiod\bibinitdelim S\bibinitperiod},
given={Majed},
giveni={M\bibinitperiod}}}%
{{hash=67602f79265e6108c733fc9d300b4fe6}{%
family={Al\bibnamedelima Ghaferi},
familyi={A\bibinitperiod\bibinitdelim G\bibinitperiod},
given={Amal},
giveni={A\bibinitperiod}}}%
{{hash=ce6b64aa2105c8d521d368ae47d47a16}{%
family={Chiesa},
familyi={C\bibinitperiod},
given={Matteo},
giveni={M\bibinitperiod}}}%
}
\list{publisher}{1}{%
{Frontiers Media SA}%
}
\strng{namehash}{93ac5752446c1882d6cb5978fef96da8}
\strng{fullhash}{b9fcf87d3eb941813263a453b3b34b42}
\strng{fullhashraw}{b9fcf87d3eb941813263a453b3b34b42}
\strng{bibnamehash}{93ac5752446c1882d6cb5978fef96da8}
\strng{authorbibnamehash}{93ac5752446c1882d6cb5978fef96da8}
\strng{authornamehash}{93ac5752446c1882d6cb5978fef96da8}
\strng{authorfullhash}{b9fcf87d3eb941813263a453b3b34b42}
\strng{authorfullhashraw}{b9fcf87d3eb941813263a453b3b34b42}
\field{sortinit}{1}
\field{sortinithash}{4f6aaa89bab872aa0999fec09ff8e98a}
\field{labelnamesource}{author}
\field{labeltitlesource}{title}
\field{journaltitle}{Frontiers in Energy Research}
\field{title}{Realizing high photovoltaic power densities with tracking-integrated concentrator photovoltaics}
\field{volume}{10}
\field{year}{2022}
\field{pages}{842201}
\range{pages}{1}
\endentry
\entry{maestro2025fabrication}{article}{}{}
\name{author}{3}{}{%
{{hash=3b9abc181e3d39c21b091ab49fb889e4}{%
family={Maestro},
familyi={M\bibinitperiod},
given={Santiago},
giveni={S\bibinitperiod}}}%
{{hash=84012732bfc3e9f2f6111fbdabd3a6f0}{%
family={Chemisana},
familyi={C\bibinitperiod},
given={D},
giveni={D\bibinitperiod}}}%
{{hash=32a68e23e3063683e627fddd6ecff803}{%
family={Baig},
familyi={B\bibinitperiod},
given={Hasan},
giveni={H\bibinitperiod}}}%
}
\list{publisher}{1}{%
{Elsevier}%
}
\strng{namehash}{5dd23e5968f59ecb6aa5684aab5c28d3}
\strng{fullhash}{5dd23e5968f59ecb6aa5684aab5c28d3}
\strng{fullhashraw}{5dd23e5968f59ecb6aa5684aab5c28d3}
\strng{bibnamehash}{5dd23e5968f59ecb6aa5684aab5c28d3}
\strng{authorbibnamehash}{5dd23e5968f59ecb6aa5684aab5c28d3}
\strng{authornamehash}{5dd23e5968f59ecb6aa5684aab5c28d3}
\strng{authorfullhash}{5dd23e5968f59ecb6aa5684aab5c28d3}
\strng{authorfullhashraw}{5dd23e5968f59ecb6aa5684aab5c28d3}
\field{sortinit}{1}
\field{sortinithash}{4f6aaa89bab872aa0999fec09ff8e98a}
\field{labelnamesource}{author}
\field{labeltitlesource}{title}
\field{journaltitle}{Renewable Energy}
\field{title}{Fabrication and experimental validation of a building-integrated microtracking CPV system}
\field{year}{2025}
\field{pages}{124508}
\range{pages}{1}
\endentry
\entry{diasponce2025development}{article}{}{}
\name{author}{8}{}{%
{{hash=16b10c578acbbdff1db63ca41559f13f}{%
family={Díaz-Ponce},
familyi={D\bibinithyphendelim P\bibinitperiod},
given={Arturo},
giveni={A\bibinitperiod}}}%
{{hash=38a31a2f87fbd47d26b0cffb12afaaab}{%
family={Trejo-Zúñiga},
familyi={T\bibinithyphendelim Z\bibinitperiod},
given={Iván},
giveni={I\bibinitperiod}}}%
{{hash=42e5ff6dcab36e23c6374024a6d2aa63}{%
family={Dávalos\bibnamedelima Hernández},
familyi={D\bibinitperiod\bibinitdelim H\bibinitperiod},
given={Fernando},
giveni={F\bibinitperiod}}}%
{{hash=d3dcaed7cf262d228067a02aefbb76c7}{%
family={Rodrigo},
familyi={R\bibinitperiod},
given={Pedro\bibnamedelima M},
giveni={P\bibinitperiod\bibinitdelim M\bibinitperiod}}}%
{{hash=4d0c217b441e12ca8bd1a6077e2c7ff1}{%
family={Ceballos},
familyi={C\bibinitperiod},
given={María\bibnamedelima A},
giveni={M\bibinitperiod\bibinitdelim A\bibinitperiod}}}%
{{hash=2682e6a0e5992d8d43109a29a4e70192}{%
family={Valera-Albacete},
familyi={V\bibinithyphendelim A\bibinitperiod},
given={Álvaro},
giveni={Á\bibinitperiod}}}%
{{hash=8c36773e57051c599e1c671a1753a1c7}{%
family={Almonacid},
familyi={A\bibinitperiod},
given={Florencia},
giveni={F\bibinitperiod}}}%
{{hash=79c6ddf92e15ea523ac21a2e5290da74}{%
family={Fernández},
familyi={F\bibinitperiod},
given={Eduardo\bibnamedelima F},
giveni={E\bibinitperiod\bibinitdelim F\bibinitperiod}}}%
}
\list{publisher}{1}{%
{Elsevier}%
}
\strng{namehash}{898da86d435308304e44b93dbef14acd}
\strng{fullhash}{639845e765f0bff1eef80ddffc2aad90}
\strng{fullhashraw}{639845e765f0bff1eef80ddffc2aad90}
\strng{bibnamehash}{898da86d435308304e44b93dbef14acd}
\strng{authorbibnamehash}{898da86d435308304e44b93dbef14acd}
\strng{authornamehash}{898da86d435308304e44b93dbef14acd}
\strng{authorfullhash}{639845e765f0bff1eef80ddffc2aad90}
\strng{authorfullhashraw}{639845e765f0bff1eef80ddffc2aad90}
\field{sortinit}{1}
\field{sortinithash}{4f6aaa89bab872aa0999fec09ff8e98a}
\field{labelnamesource}{author}
\field{labeltitlesource}{title}
\field{journaltitle}{Results in Engineering}
\field{title}{Development of a proof-of-concept concentrating photovoltaic module with integrated tracking and azimuthal cell rotation}
\field{volume}{27}
\field{year}{2025}
\field{pages}{106735}
\range{pages}{1}
\verb{doi}
\verb 10.1016/j.rineng.2025.106735
\endverb
\endentry
\enddatalist
\endrefsection
\endinput
Binary file not shown.
Binary file not shown.
+18
View File
@@ -0,0 +1,18 @@
\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}
BIN
View File
Binary file not shown.
+3
View File
@@ -0,0 +1,3 @@
\documentclass[final,1p,times]{elsarticle}
\usepackage{amssymb}
\begin{document}Bisect 0\end{document}
BIN
View File
Binary file not shown.
+4
View File
@@ -0,0 +1,4 @@
\documentclass[final,1p,times]{elsarticle}
\usepackage{amssymb}
\usepackage{amsmath}
\begin{document}Bisect 1\end{document}
BIN
View File
Binary file not shown.
+5
View File
@@ -0,0 +1,5 @@
\documentclass[final,1p,times]{elsarticle}
\usepackage{amssymb}
\usepackage{amsmath}
\usepackage{booktabs}
\begin{document}Bisect 2\end{document}
BIN
View File
Binary file not shown.
+6
View File
@@ -0,0 +1,6 @@
\documentclass[final,1p,times]{elsarticle}
\usepackage{amssymb}
\usepackage{amsmath}
\usepackage{booktabs}
\usepackage{tikz}
\begin{document}Bisect 3\end{document}
BIN
View File
Binary file not shown.
+8
View File
@@ -0,0 +1,8 @@
\documentclass[final,1p,times]{elsarticle}
\usepackage{amssymb}
\usepackage{amsmath}
\usepackage{booktabs}
\usepackage{tikz}
\usepackage[american]{circuitikz}
\usetikzlibrary{positioning, calc, arrows.meta}
\begin{document}Bisect 4\end{document}
+10
View File
@@ -0,0 +1,10 @@
\documentclass[final,1p,times]{elsarticle}
\usepackage{amssymb}
\usepackage{amsmath}
\usepackage{booktabs}
\usepackage{tikz}
\usepackage[american]{circuitikz}
\usetikzlibrary{positioning, calc, arrows.meta}
\usepackage[backend=biber, style=numeric, sorting=none]{biblatex}
\addbibresource{cas-refs.bib}
\begin{document}Bisect 5\end{document}
+511
View File
@@ -0,0 +1,511 @@
@ARTICLE{Fortunato2010,
author = {Fortunato, S.},
title = {Community detection in graphs},
journal = {Phys. Rep.-Rev. Sec. Phys. Lett.},
volume = {486},
year = {2010},
pages = {75-174}
}
@ARTICLE{NewmanGirvan2004,
author = {Newman, M. E. J. and Girvan, M.},
title = {Finding and evaluating community structure in networks},
journal = {Phys. Rev. E.},
volume = {69},
year = {2004},
pages = {026113}
}
@ARTICLE{Vehlowetal2013,
author = {Vehlow, C. and Reinhardt, T. and Weiskopf, D.},
title = {Visualizing Fuzzy Overlapping Communities in Networks},
journal = {IEEE Trans. Vis. Comput. Graph.},
volume = {19},
year = {2013},
pages = {2486-2495}
}
@ARTICLE{Raghavanetal2007,
author = {Raghavan, U. and Albert, R. and Kumara, S.},
title = {Near linear time algorithm to detect community structures in large-scale networks},
journal = {Phys. Rev E.},
volume = {76},
year = {2007},
pages = {036106}
}
@ARTICLE{SubeljBajec2011a,
author = {\v{S}ubelj, L. and Bajec, M.},
title = {Robust network community detection using balanced propagation},
journal = {Eur. Phys. J. B.},
volume = {81},
year = {2011},
pages = {353-362}
}
@ARTICLE{Louetal2013,
author = {Lou, H. and Li, S. and Zhao, Y.},
title = {Detecting community structure using label propagation with weighted coherent neighborhood propinquity},
journal = {Physica A.},
volume = {392},
year = {2013},
pages = {3095-3105}
}
@ARTICLE{Clausetetal2004,
author = {Clauset, A. and Newman, M. E. J. and Moore, C.},
title = {Finding community structure in very large networks},
journal = {Phys. Rev. E.},
volume = {70},
year = {2004},
pages = {066111}
}
@ARTICLE{Blondeletal2008,
author = {Blondel, V. D. and Guillaume, J. L. and Lambiotte, R. and Lefebvre, E.},
title = {Fast unfolding of communities in large networks},
journal = {J. Stat. Mech.-Theory Exp.},
volume = {2008},
year = {2008},
pages = {P10008}
}
@ARTICLE{SobolevskyCampari2014,
author = {Sobolevsky, S. and Campari, R.},
title = {General optimization technique for high-quality community detection in complex networks},
journal = {Phys. Rev. E.},
volume = {90},
year = {2014},
pages = {012811}
}
@ARTICLE{FortunatoBarthelemy2007,
author = {Fortunato, S. and Barthelemy, M.},
title = {Resolution limit in community detection},
journal = {Proc. Natl. Acad. Sci. U. S. A.},
volume = {104},
year = {2007},
pages = {36-41}
}
@ARTICLE{SubeljBajec2011b,
author = {\v{S}ubelj, L. and Bajec, M.},
title = {Unfolding communities in large complex networks: Combining defensive and offensive label propagation for core extraction},
journal = {Phys. Rev. E.},
volume = {83},
year = {2011},
pages = {036103}
}
@ARTICLE{WangLi2013,
author = {Wang, X. and Li, J.},
title = {Detecting communities by the core-vertex and intimate degree in complex networks},
journal = {Physica A.},
volume = {392},
year = {2013},
pages = {2555-2563}
}
@ARTICLE{Lietal2013,
author = {Li, J. and Wang, X. and Eustace, J.},
title = {Detecting overlapping communities by seed community in weighted complex networks},
journal = {Physica A.},
volume = {392},
year = {2013},
pages = {6125-6134}
}
@ARTICLE{Fabioetal2013,
author = {Fabio, D. R. and Fabio, D. and Carlo, P.},
title = {Profiling core-periphery network structure by random walkers},
journal = {Sci. Rep.},
volume = {3},
year = {2013},
pages = {1467}
}
@ARTICLE{Chenetal2013,
author = {Chen, Q. and Wu, T. T. and Fang, M.},
title = {Detecting local community structure in complex networks based on local degree central nodes},
journal = {Physica A.},
volume = {392},
year = {2013},
pages = {529-537}
}
@ARTICLE{Zhangetal2007,
author = {Zhang, S. and Wang, R. and Zhang, X.},
title = {Identification of overlapping community structure in complex networks using fuzzy c-means clustering},
journal = {Physica A.},
volume = {374},
year = {2007},
pages = {483-490}
}
@ARTICLE{Nepuszetal2008,
author = {Nepusz, T. and Petr\'oczi, A. and N\'egyessy, L. and Bazs\'o, F.},
title = {Fuzzy communities and the concept of bridgeness in complex networks},
journal = {Phys. Rev. E.},
volume = {77},
year = {2008},
pages = {016107}
}
@ARTICLE{FabricioLiang2013,
author = {Fabricio, B. and Liang, Z.},
title = {Fuzzy community structure detection by particle competition and cooperation},
journal = {Soft Comput.},
volume = {17},
year = {2013},
pages = {659-673}
}
@ARTICLE{Sunetal2011,
author = {Sun, P. and Gao, L. and Han, S.},
title = {Identification of overlapping and non-overlapping community structure by fuzzy clustering in complex networks},
journal = {Inf. Sci.},
volume = {181},
year = {2011},
pages = {1060-1071}
}
@ARTICLE{Wangetal2013,
author = {Wang, W. and Liu, D. and Liu, X. and Pan, L.},
title = {Fuzzy overlapping community detection based on local random walk and multidimensional scaling},
journal = {Physica A.},
volume = {392},
year = {2013},
pages = {6578-6586}
}
@ARTICLE{Psorakisetal2011,
author = {Psorakis, I. and Roberts, S. and Ebden, M. and Sheldon, B.},
title = {Overlapping community detection using Bayesian non-negative matrix factorization},
journal = {Phys. Rev. E.},
volume = {83},
year = {2011},
pages = {066114}
}
@CONFERENCE{ZhangYeung2012,
author = {Zhang, Y. and Yeung, D.},
title = {Overlapping Community Detection via Bounded Nonnegative Matrix Tri-Factorization},
booktitle = {In Proc. ACM SIGKDD Conf.},
year = {2012},
pages = {606-614}
}
@ARTICLE{Liu2010,
author = {Liu, J.},
title = {Fuzzy modularity and fuzzy community structure in networks},
journal = {Eur. Phys. J. B.},
volume = {77},
year = {2010},
pages = {547-557}
}
@ARTICLE{Havensetal2013,
author = {Havens, T. C. and Bezdek, J. C. and Leckie, C., Ramamohanarao, K. and Palaniswami, M.},
title = {A Soft Modularity Function For Detecting Fuzzy Communities in Social Networks},
journal = {IEEE Trans. Fuzzy Syst.},
volume = {21},
year = {2013},
pages = {1170-1175}
}
@misc{Newman2013,
author = {Newman, M. E. J.},
title = {Network data},
howpublished = "\url{http://www-personal.umich.edu/~mejn/netdata/}",
year = {2013}
}
@ARTICLE{SubeljBajec2012,
author = {\v{S}ubelj, L. and Bajec, M.},
title = {Ubiquitousness of link-density and link-pattern communities in real-world networks},
journal = {Eur. Phys. J. B.},
volume = {85},
year = {2012},
pages = {1-11}
}
@ARTICLE{Lancichinettietal2008,
author = {Lancichinetti, A. and Fortunato, S. and Radicchi, F.},
title = {Benchmark graphs for testing community detection algorithms},
journal = {Phys. Rev. E.},
volume = {78},
year = {2008},
pages = {046110}
}
@ARTICLE{Liuetal2014,
author = {Liu, W. and Pellegrini, M. and Wang, X.},
title = {Detecting Communities Based on Network Topology},
journal = {Sci. Rep.},
volume = {4},
year = {2014},
pages = {5739}
}
@ARTICLE{Danonetal2005,
author = {Danon, L. and Diaz-Guilera, A. and Duch, J. and Arenas, A.},
title = {Comparing community structure identification},
journal = {J. Stat. Mech.-Theory Exp.},
volume = {},
year = {2005},
pages = {P09008}
}
@ARTICLE{Gregory2011,
author = {Gregory, S.},
title = {Fuzzy overlapping communities in networks},
journal = {J. Stat. Mech.-Theory Exp.},
volume = {},
year = {2011},
pages = {P02017}
}
@ARTICLE{LancichinettiFortunato2009,
author = {Lancichinetti, A. and Fortunato, S.},
title = {Benchmarks for testing community detection algorithms on directed and weighted graphs with overlapping communities},
journal = {Phys. Rev. E.},
volume = {80},
year = {2009},
pages = {016118}
}
@CONFERENCE{HullermeierRifqi2009,
author = {Hullermeier, E. and Rifqi, M.},
title = {A Fuzzy Variant of the Rand Index for Comparing Clustering Structures},
booktitle = {in Proc. IFSA/EUSFLAT Conf.},
year = {2009},
pages = {1294-1298}
}
%%%
@article{frydrychowicz2013influence,
title={The influence of external conditions and orientation of PV module on the energetic gain},
author={Frydrychowicz-Jastrz{\k{e}}bska, Gra{\.z}yna and Buga{\l}a, Artur},
journal={Computer Applications in Electrical Engineering},
volume={11},
pages={423--439},
year={2013},
publisher={Politechnika Pozna{\'n}ska. Wydawnictwo Politechniki Pozna{\'n}skiej}
}
@article{nadia2018advances,
title={Advances in solar photovoltaic tracking systems: A review},
author={Nadia, AL-Rousan and Isa, Nor Ashidi Mat and Desa, Mohd Khairunaz Mat},
journal={Renewable and sustainable energy reviews},
volume={82},
pages={2548--2569},
year={2018},
publisher={Elsevier}
}
@article{mousazadeh2009review,
title={A review of principle and sun-tracking methods for maximizing solar systems output},
author={Mousazadeh, Hossein and Keyhani, Alireza and Javadi, Arzhang and Mobli, Hossein and Abrinia, Karen and Sharifi, Ahmad},
journal={Renewable and sustainable energy reviews},
volume={13},
number={8},
pages={1800--1818},
year={2009},
publisher={Elsevier},
doi={10.1016/j.rser.2009.01.022}
}
@article{hafez2018solar,
title={Solar tracking systems: Technologies and trackers drive types--A review},
author={Hafez, AZ and Yousef, AM and Harag, NM},
journal={Renewable and Sustainable Energy Reviews},
volume={91},
pages={754--782},
year={2018},
publisher={Elsevier},
doi={10.1016/j.rser.2018.03.094}
}
@article{awasthi2020review,
title={Review on sun tracking technology in solar PV system},
author={Awasthi, Anshul and Shukla, Akash Kumar and SR, Murali Manohar and Dondariya, Chandrakant and Shukla, KN and Porwal, Deepak and Richhariya, Geetam},
journal={Energy Reports},
volume={6},
pages={392--405},
year={2020},
publisher={Elsevier}
}
@article{sadeghi2025review,
title={A review and comparative analysis of solar tracking systems},
author={Sadeghi, Reza and Parenti, Mattia and Memme, Samuele and Fossa, Marco and Morchio, Stefano},
journal={Energies},
volume={18},
number={10},
pages={2553},
year={2025},
publisher={MDPI}
}
@article{fuentes2020control,
title={Control algorithms applied to active solar tracking systems: A review},
author={Fuentes-Morales, Rosa F and Diaz-Ponce, Arturo and Pe{\~n}a-Cruz, Manuel I and Rodrigo, Pedro M and Valent{\'\i}n-Coronado, Luis M and Martell-Chavez, Fernando and Pineda-Arellano, Carlos A},
journal={Solar Energy},
volume={212},
pages={203--219},
year={2020},
publisher={Elsevier},
doi={10.1016/j.solener.2020.10.071}
}
@article{sumathi2017solar,
title={Solar tracking methods to maximize PV system output--A review of the methods adopted in recent decade},
author={Sumathi, Vijayan and Jayapragash, R and Bakshi, Abhinav and Akella, Praveen Kumar},
journal={Renewable and Sustainable Energy Reviews},
volume={74},
pages={130--138},
year={2017},
publisher={Elsevier}
}
@article{antonini2010photovoltaic,
title={Photovoltaic Concentrators-Fundamentals, Applications, Market \& Prospective},
author={Antonini, Andrea},
journal={Sol. Collect. Panels, Theory Appl., Sciyo},
pages={31--54},
year={2010}
}
@article{iqbal2023optical,
title={Optical developments in concentrator photovoltaic systems—a review},
author={Iqbal, Waseem and Ullah, Irfan and Shin, Seoyong},
journal={Sustainability},
volume={15},
number={13},
pages={10554},
year={2023},
publisher={MDPI},
doi={10.3390/su151310554}
}
@article{ceballos2023tracking,
title={Tracking-Integrated {CPV} Technology: State-of-the-Art and Classification},
author={Ceballos, Maria A and P{\'e}rez-Higueras, Pedro J and Fern{\'a}ndez, Eduardo F and Almonacid, Florencia M},
journal={Energies},
volume={16},
number={15},
pages={5605},
year={2023},
publisher={MDPI},
doi={10.3390/en16155605}
}
@article{angulo2022development,
title={Development and accuracy assessment of a high-precision dual-axis pre-commercial solar tracker for concentrating photovoltaic modules},
author={Angulo-Calder{\'o}n, Marthoz and Salgado-Tr{\'a}nsito, Iv{\'a}n and Trejo-Z{\'u}{\~n}iga, Iv{\'a}n and Paredes-Orta, Carlos and Kesthkar, Sajjad and D{\'\i}az-Ponce, Arturo},
journal={Applied Sciences},
volume={12},
number={5},
pages={2625},
year={2022},
publisher={MDPI}
}
@article{ito2018optical,
title={Optical design and demonstration of microtracking {CPV} module with bi-convex aspheric lens array},
author={Ito, Atsushi and Sato, Daisuke and Yamada, Noboru},
journal={Optics Express},
volume={26},
number={18},
pages={A879--A891},
year={2018},
publisher={OSA},
doi={10.1364/OE.26.00A879}
}
@inproceedings{askins2019performance,
title={Performance of hybrid micro-concentrator module with integrated planar tracking and diffuse light collection},
author={Askins, S. and Petri, D. and Levrat, J. and Faes, A. and
Champliaud, J. and Despeisse, M. and Dominguez, C. and Anton, I. and
Jost, N. and Aguilar, A. F. and Anglade, L. and Nardin, G. and
Duchemin, M. and Gerlich, F. and Ackerman, M. and Coulot, L.},
booktitle={2019 IEEE 46th Photovoltaic Specialists Conference (PVSC)},
pages={2507--2512},
year={2019},
organization={IEEE},
doi={10.1109/PVSC40753.2019.8980519}
}
@article{younes2022realizing,
title={Realizing high photovoltaic power densities with tracking-integrated concentrator photovoltaics},
author={Younes, Kareem and Apostoleris, Harry and Bin Saad, Majed and Al Ghaferi, Amal and Chiesa, Matteo},
journal={Frontiers in Energy Research},
volume={10},
pages={842201},
year={2022},
publisher={Frontiers Media SA},
doi={10.3389/fenrg.2022.842201}
}
@article{maestro2025fabrication,
title={Fabrication and experimental validation of a building-integrated microtracking CPV system},
author={Maestro, Santiago and Chemisana, D and Baig, Hasan},
journal={Renewable Energy},
pages={124508},
year={2025},
publisher={Elsevier}
}
@article{diasponce2025development,
title={Development of a proof-of-concept concentrating photovoltaic module with integrated tracking and azimuthal cell rotation},
author={D{\'i}az-Ponce, Arturo and Trejo-Z{\'u}{\~n}iga, Iv{\'a}n and D{\'a}valos Hern{\'a}ndez, Fernando and Rodrigo, Pedro M and Ceballos, Mar{\'\i}a A and Valera-Albacete, {\'A}lvaro and Almonacid, Florencia and Fern{\'a}ndez, Eduardo F},
journal={Results in Engineering},
volume={27},
pages={106735},
year={2025},
publisher={Elsevier},
doi={10.1016/j.rineng.2025.106735}
}
@online{pvwatts_module_type,
author = {{PVWatts}},
title = {Module Type},
url = {https://pvwatts.nlr.gov/docs/help_module_type.php},
urldate = {2026-11-04},
note = {Standard and Premium crystalline-silicon module assumptions}
}
@online{sandia_poa,
author = {{Sandia National Laboratories}},
title = {Plane of Array ({POA}) Irradiance},
organization = {PV Performance Modeling Collaborative},
url = {https://pvpmc.sandia.gov/modeling-guide/1-weather-design-inputs/plane-of-array-poa-irradiance/},
urldate = {2026-11-04}
}
@online{sandia_pvwatts,
author = {{Sandia National Laboratories}},
title = {{PVWatts}},
organization = {PV Performance Modeling Collaborative},
url = {https://pvpmc.sandia.gov/modeling-guide/2-dc-module-iv/point-value-models/pvwatts/},
urldate = {2026-11-04}
}
@manual{apogee_sp212_manual,
author = {{Apogee Instruments}},
title = {{SP-212/SP-215} Pyranometer Instruction Manual},
url = {https://www.apogeeinstruments.com/content/SP-212-215-manual.pdf},
urldate = {2026-11-04}
}
@misc{suncalc2026,
author = {Hoffmann, Torsten},
title = {{SunCalc}: Daily Solar-Position Data for Aguascalientes, 4 November 2026},
year = {2026},
url = {https://www.suncalc.org/},
urldate = {2026-11-04},
note = {Supplied printout at latitude 21.87304 degrees N and longitude
102.28282 degrees W; local-time azimuth and elevation at 15-minute intervals}
}
+418
View File
@@ -0,0 +1,418 @@
[
{
"file": "hasta la 1pm 020925.txt",
"label": "02 Sep 11:27-13:00",
"fmt": "new",
"t_inicio": "2025-09-02 11:28:46",
"t_fin": "2025-09-02 13:30:54",
"n_samples": 2289,
"eta_tr_pct": 33.59,
"MAE_P_mW": 85.04,
"E_Wh": 0.089,
"H_kWh_m2": 0.9187,
"ratio_P_Pref_median": 0.131,
"frac_P_above_095Pref": 0.018,
"G_max_Wm2": 1007.7,
"n_clip": 0,
"clip_pct_day": 0.0,
"clip_min": 0.0,
"R_opt_uA_per_Wm2": 157.3,
"R_opt_unc_uA_per_Wm2": 157.3,
"inflation_factor": 1.0,
"eta_tr_pct_Ropt_corrected": 33.59,
"n_cloud_events": 4
},
{
"file": "esp_1223_02092025.txt",
"label": "02 Sep 12:23-..",
"fmt": "old",
"error": "KeyError: 'cmd'"
},
{
"file": "log(3).txt",
"label": "14 Sep (manana)",
"fmt": "new",
"t_inicio": "2025-09-14 10:03:36",
"t_fin": "2025-09-14 14:15:37",
"n_samples": 7544,
"eta_tr_pct": 49.36,
"MAE_P_mW": 138.77,
"E_Wh": 0.56,
"H_kWh_m2": 2.5917,
"ratio_P_Pref_median": 0.288,
"frac_P_above_095Pref": 0.029,
"G_max_Wm2": 1024.0,
"n_clip": 1189,
"clip_pct_day": 15.8,
"clip_min": 39.6,
"R_opt_uA_per_Wm2": 232.7,
"R_opt_unc_uA_per_Wm2": 213.0,
"inflation_factor": 1.0929,
"eta_tr_pct_Ropt_corrected": 53.94,
"n_cloud_events": 10
},
{
"file": "gyuggyhoy.txt",
"label": "14 Sep 10:28-..",
"fmt": "old",
"t_inicio": "2025-09-14 10:28:12",
"t_fin": "2025-09-14 14:57:34",
"n_samples": 8076,
"eta_tr_pct": 52.33,
"MAE_P_mW": 136.72,
"E_Wh": 0.674,
"H_kWh_m2": 2.9318,
"ratio_P_Pref_median": 0.418,
"frac_P_above_095Pref": 0.009,
"G_max_Wm2": 1024.0,
"n_clip": 1189,
"clip_pct_day": 14.7,
"clip_min": 39.6,
"R_opt_uA_per_Wm2": 232.7,
"R_opt_unc_uA_per_Wm2": 213.0,
"inflation_factor": 1.0929,
"eta_tr_pct_Ropt_corrected": 57.19,
"n_cloud_events": 10
},
{
"file": "10092025.txt",
"label": "10 Sep",
"fmt": "new",
"t_inicio": "2025-09-10 10:17:05",
"t_fin": "2025-09-10 16:39:04",
"n_samples": 9055,
"eta_tr_pct": 48.3,
"MAE_P_mW": 109.38,
"E_Wh": 0.659,
"H_kWh_m2": 3.0884,
"ratio_P_Pref_median": 0.092,
"frac_P_above_095Pref": 0.012,
"G_max_Wm2": 1024.0,
"n_clip": 1577,
"clip_pct_day": 17.4,
"clip_min": 52.6,
"R_opt_uA_per_Wm2": 250.9,
"R_opt_unc_uA_per_Wm2": 250.9,
"inflation_factor": 1.0,
"eta_tr_pct_Ropt_corrected": 48.3,
"n_cloud_events": 7
},
{
"file": "15092025.txt",
"label": "15 Sep",
"fmt": "new",
"t_inicio": "2025-09-15 11:40:37",
"t_fin": "2025-09-15 14:53:18",
"n_samples": 4641,
"eta_tr_pct": 56.0,
"MAE_P_mW": 73.47,
"E_Wh": 0.3,
"H_kWh_m2": 1.3408,
"ratio_P_Pref_median": 0.075,
"frac_P_above_095Pref": 0.077,
"G_max_Wm2": 1024.0,
"n_clip": 1186,
"clip_pct_day": 25.6,
"clip_min": 39.5,
"R_opt_uA_per_Wm2": 231.5,
"R_opt_unc_uA_per_Wm2": 200.3,
"inflation_factor": 1.1556,
"eta_tr_pct_Ropt_corrected": 64.71,
"n_cloud_events": 5
},
{
"file": "19092025_.txt",
"label": "19 Sep",
"fmt": "new",
"t_inicio": "2025-09-19 09:36:54",
"t_fin": "2025-09-19 16:34:12",
"n_samples": 11395,
"eta_tr_pct": 33.94,
"MAE_P_mW": 222.87,
"E_Wh": 0.804,
"H_kWh_m2": 3.6224,
"ratio_P_Pref_median": 0.081,
"frac_P_above_095Pref": 0.016,
"G_max_Wm2": 1024.0,
"n_clip": 2212,
"clip_pct_day": 19.4,
"clip_min": 73.7,
"R_opt_uA_per_Wm2": 373.2,
"R_opt_unc_uA_per_Wm2": 373.2,
"inflation_factor": 1.0,
"eta_tr_pct_Ropt_corrected": 33.94,
"n_cloud_events": 7
},
{
"file": "20092025_desde1pm.txt",
"label": "20 Sep (desde 13:44)",
"fmt": "new",
"t_inicio": "2025-09-20 13:44:52",
"t_fin": "2025-09-20 15:08:30",
"n_samples": 3640,
"eta_tr_pct": 69.65,
"MAE_P_mW": 72.21,
"E_Wh": 0.243,
"H_kWh_m2": 1.0505,
"ratio_P_Pref_median": 0.66,
"frac_P_above_095Pref": 0.018,
"G_max_Wm2": 979.7,
"n_clip": 0,
"clip_pct_day": 0.0,
"clip_min": 0.0,
"R_opt_uA_per_Wm2": 181.6,
"R_opt_unc_uA_per_Wm2": 181.6,
"inflation_factor": 1.0,
"eta_tr_pct_Ropt_corrected": 69.65,
"n_cloud_events": 1
},
{
"file": "21092025.txt",
"label": "21 Sep (paper)",
"fmt": "new",
"t_inicio": "2025-09-21 10:40:58",
"t_fin": "2025-09-21 17:02:34",
"n_samples": 11206,
"eta_tr_pct": 67.28,
"MAE_P_mW": 72.39,
"E_Wh": 0.927,
"H_kWh_m2": 3.3291,
"ratio_P_Pref_median": 0.433,
"frac_P_above_095Pref": 0.102,
"G_max_Wm2": 1024.0,
"n_clip": 3011,
"clip_pct_day": 26.9,
"clip_min": 100.4,
"R_opt_uA_per_Wm2": 228.9,
"R_opt_unc_uA_per_Wm2": 221.7,
"inflation_factor": 1.0324,
"eta_tr_pct_Ropt_corrected": 69.46,
"n_cloud_events": 5
},
{
"file": "21092025_2PM.txt",
"label": "21 Sep (parcial, dup.)",
"fmt": "new",
"t_inicio": "2025-09-21 10:40:58",
"t_fin": "2025-09-21 13:56:04",
"n_samples": 5830,
"eta_tr_pct": 72.43,
"MAE_P_mW": 91.74,
"E_Wh": 0.788,
"H_kWh_m2": 2.5026,
"ratio_P_Pref_median": 0.636,
"frac_P_above_095Pref": 0.186,
"G_max_Wm2": 1024.0,
"n_clip": 2614,
"clip_pct_day": 44.8,
"clip_min": 87.1,
"R_opt_uA_per_Wm2": 228.9,
"R_opt_unc_uA_per_Wm2": 221.7,
"inflation_factor": 1.0324,
"eta_tr_pct_Ropt_corrected": 74.77,
"n_cloud_events": 1
},
{
"file": "23092025.txt",
"label": "23 Sep",
"fmt": "new",
"t_inicio": "2025-09-23 10:27:26",
"t_fin": "2025-09-23 15:18:57",
"n_samples": 8694,
"eta_tr_pct": 60.91,
"MAE_P_mW": 146.42,
"E_Wh": 1.11,
"H_kWh_m2": 3.9028,
"ratio_P_Pref_median": 0.555,
"frac_P_above_095Pref": 0.0,
"G_max_Wm2": 1024.0,
"n_clip": 3043,
"clip_pct_day": 35.0,
"clip_min": 101.4,
"R_opt_uA_per_Wm2": 248.6,
"R_opt_unc_uA_per_Wm2": 248.6,
"inflation_factor": 1.0,
"eta_tr_pct_Ropt_corrected": 60.91,
"n_cloud_events": 7
},
{
"file": "full_log_21082025.txt",
"label": "21 Ago (log completo)",
"fmt": "old",
"t_inicio": "2025-08-21 11:24:01",
"t_fin": "2025-08-21 13:17:47",
"n_samples": 5533,
"eta_tr_pct": 57.17,
"MAE_P_mW": 92.05,
"E_Wh": 0.223,
"H_kWh_m2": 0.991,
"ratio_P_Pref_median": 0.353,
"frac_P_above_095Pref": 0.057,
"G_max_Wm2": 1024.0,
"n_clip": 316,
"clip_pct_day": 5.7,
"clip_min": 5.3,
"R_opt_uA_per_Wm2": 207.1,
"R_opt_unc_uA_per_Wm2": 206.1,
"inflation_factor": 1.0047,
"eta_tr_pct_Ropt_corrected": 57.44,
"n_cloud_events": 5
},
{
"file": "esp32_log_20250821_102525.txt",
"label": "21 Ago 10:25",
"fmt": "old",
"t_inicio": "2025-08-21 11:24:01",
"t_fin": "2025-08-21 11:54:47",
"n_samples": 1732,
"eta_tr_pct": 71.07,
"MAE_P_mW": 63.16,
"E_Wh": 0.086,
"H_kWh_m2": 0.3349,
"ratio_P_Pref_median": 0.715,
"frac_P_above_095Pref": 0.238,
"G_max_Wm2": 915.9,
"n_clip": 0,
"clip_pct_day": 0.0,
"clip_min": 0.0,
"R_opt_uA_per_Wm2": 181.3,
"R_opt_unc_uA_per_Wm2": 181.3,
"inflation_factor": 1.0,
"eta_tr_pct_Ropt_corrected": 71.07,
"n_cloud_events": 3
},
{
"file": "esp32_log_20250821_1143.txt",
"label": "21 Ago 11:43",
"fmt": "old",
"error": "ValueError: Sin muestras X,Y,C,V,P en el log."
},
{
"file": "esp32_log_20250826_114833.txt",
"label": "26 Ago 11:48",
"fmt": "old",
"error": "KeyError: 'cmd'"
},
{
"file": "esp32_log_20250827_095607.txt",
"label": "27 Ago 09:56",
"fmt": "old",
"t_inicio": "2025-08-27 09:58:22",
"t_fin": "2025-08-27 10:37:00",
"n_samples": 983,
"eta_tr_pct": 21.3,
"MAE_P_mW": 29.22,
"E_Wh": 0.005,
"H_kWh_m2": 0.234,
"ratio_P_Pref_median": 0.159,
"frac_P_above_095Pref": 0.002,
"G_max_Wm2": 948.4,
"n_clip": 0,
"clip_pct_day": 0.0,
"clip_min": 0.0,
"R_opt_uA_per_Wm2": 61.5,
"R_opt_unc_uA_per_Wm2": 61.5,
"inflation_factor": 1.0,
"eta_tr_pct_Ropt_corrected": 21.3,
"n_cloud_events": 1
},
{
"file": "esp32_log_20250827_110728.txt",
"label": "27 Ago 11:07",
"fmt": "old",
"t_inicio": "2025-08-27 11:07:56",
"t_fin": "2025-08-27 14:02:46",
"n_samples": 5104,
"eta_tr_pct": 40.63,
"MAE_P_mW": 158.59,
"E_Wh": 0.317,
"H_kWh_m2": 2.0504,
"ratio_P_Pref_median": 0.035,
"frac_P_above_095Pref": 0.154,
"G_max_Wm2": 1024.0,
"n_clip": 2175,
"clip_pct_day": 42.6,
"clip_min": 72.5,
"R_opt_uA_per_Wm2": 229.5,
"R_opt_unc_uA_per_Wm2": 206.4,
"inflation_factor": 1.112,
"eta_tr_pct_Ropt_corrected": 45.18,
"n_cloud_events": 9
},
{
"file": "esp32_log_20250827_449pm.txt",
"label": "27 Ago 16:49",
"fmt": "old",
"t_inicio": "2025-08-27 16:37:21",
"t_fin": "2025-08-27 16:47:54",
"n_samples": 309,
"eta_tr_pct": 38.35,
"MAE_P_mW": 13.15,
"E_Wh": 0.001,
"H_kWh_m2": 0.074,
"ratio_P_Pref_median": 0.342,
"frac_P_above_095Pref": 0.023,
"G_max_Wm2": 891.9,
"n_clip": 0,
"clip_pct_day": 0.0,
"clip_min": 0.0,
"R_opt_uA_per_Wm2": 32.4,
"R_opt_unc_uA_per_Wm2": 32.4,
"inflation_factor": 1.0,
"eta_tr_pct_Ropt_corrected": 38.35,
"n_cloud_events": 1
},
{
"file": "log_26082025.txt",
"label": "26 Ago (corto)",
"fmt": "old",
"error": "KeyError: 'cmd'"
},
{
"file": "log_2608205_2.txt",
"label": "26 Ago (corto)",
"fmt": "old",
"t_inicio": "2025-08-26 11:48:51",
"t_fin": "2025-08-26 13:26:53",
"n_samples": 928,
"eta_tr_pct": 31.01,
"MAE_P_mW": 92.79,
"E_Wh": 0.067,
"H_kWh_m2": 0.6262,
"ratio_P_Pref_median": 0.107,
"frac_P_above_095Pref": 0.088,
"G_max_Wm2": 1024.0,
"n_clip": 103,
"clip_pct_day": 11.1,
"clip_min": 3.4,
"R_opt_uA_per_Wm2": 208.6,
"R_opt_unc_uA_per_Wm2": 204.2,
"inflation_factor": 1.0215,
"eta_tr_pct_Ropt_corrected": 31.68,
"n_cloud_events": 2
},
{
"file": "nuevolog}.txt",
"label": "26 Ago 13:28",
"fmt": "old",
"error": "KeyError: 'cmd'"
},
{
"file": "test_2108.txt",
"label": "21 Ago (test)",
"fmt": "old",
"error": "KeyError: 'cmd'"
},
{
"file": "test_3.txt",
"label": "21 Ago (test)",
"fmt": "old",
"error": "KeyError: 'cmd'"
},
{
"file": "test_$.txt",
"label": "21 Ago (test)",
"fmt": "old",
"error": "KeyError: 'cmd'"
}
]
+1
View File
@@ -0,0 +1 @@
Tectonic 0.17.0
Binary file not shown.
File diff suppressed because it is too large. Load diff
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 597 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+363
View File
@@ -0,0 +1,363 @@
**
(pcbtoplvl.tex
LaTeX2e <2021-11-15> patch level 1
L3 programming layer <2022-02-24> (standalone.cls
Document Class: standalone 2018/03/26 v1.3a Class to compile TeX sub-files stan
dalone
(shellesc.sty
Package: shellesc 2019/11/08 v1.0c unified shell escape interface for LaTeX
Package shellesc Warning: Shell escape disabled on input line 73.
) (ifluatex.sty
Package: ifluatex 2019/10/25 v1.5 ifluatex legacy package. Use iftex instead.
(iftex.sty
Package: iftex 2022/02/03 v1.0f TeX engine tests
)) (xkeyval.sty
Package: xkeyval 2020/11/20 v2.8 package option processing (HA)
(xkeyval (xkvutils
\XKV@toks=\toks16
\XKV@tempa@toks=\toks17
(keyval))
\XKV@depth=\count181
File: xkeyval.tex 2014/12/03 v2.7a key=value parser (HA)
))
\sa@internal=\count182
\c@sapage=\count183
(standalone.cfg
File: standalone.cfg 2018/03/26 v1.3a Default configuration file for 'standalon
e' class
) (article.cls
Document Class: article 2021/10/04 v1.4n Standard LaTeX document class
(size10.clo
File: size10.clo 2021/10/04 v1.4n Standard LaTeX file (size option)
)
\c@part=\count184
\c@section=\count185
\c@subsection=\count186
\c@subsubsection=\count187
\c@paragraph=\count188
\c@subparagraph=\count189
\c@figure=\count190
\c@table=\count191
\abovecaptionskip=\skip47
\belowcaptionskip=\skip48
\bibindent=\dimen138
)
\sa@box=\box50
) (inputenc.sty
Package: inputenc 2021/02/14 v1.3d Input encoding file
\inpenc@prehook=\toks18
\inpenc@posthook=\toks19
Package inputenc Warning: inputenc package ignored with utf8 based engines.
) (tikz.sty (pgf.sty (pgfrcs.sty (pgfutil-common.tex
\pgfutil@everybye=\toks20
\pgfutil@tempdima=\dimen139
\pgfutil@tempdimb=\dimen140
(pgfutil-common-lists.tex)) (pgfutil-latex.def
\pgfutil@abb=\box51
) (pgfrcs.code.tex (pgf.revision.tex)
Package: pgfrcs 2021/05/15 v3.1.9a (3.1.9a)
))
Package: pgf 2021/05/15 v3.1.9a (3.1.9a)
(pgfcore.sty
(graphicx.sty
Package: graphicx 2021/09/16 v1.2d Enhanced LaTeX Graphics (DPC,SPQR)
(graphics.sty
Package: graphics 2021/03/04 v1.4d Standard LaTeX Graphics (DPC,SPQR)
(trig.sty
Package: trig 2021/08/11 v1.11 sin cos tan (DPC)
) (graphics.cfg
File: graphics.cfg 2016/06/04 v1.11 sample graphics configuration
)
Package graphics Info: Driver file: xetex.def on input line 107.
(xetex.def
File: xetex.def 2021/03/18 v5.0k Graphics/color driver for xetex
))
\Gin@req@height=\dimen141
\Gin@req@width=\dimen142
) (pgfsys.sty (pgfsys.code.tex
Package: pgfsys 2021/05/15 v3.1.9a (3.1.9a)
(pgfkeys.code.tex
\pgfkeys@pathtoks=\toks21
\pgfkeys@temptoks=\toks22
(pgfkeysfiltered.code.tex
\pgfkeys@tmptoks=\toks23
))
\pgf@x=\dimen143
\pgf@y=\dimen144
\pgf@xa=\dimen145
\pgf@ya=\dimen146
\pgf@xb=\dimen147
\pgf@yb=\dimen148
\pgf@xc=\dimen149
\pgf@yc=\dimen150
\pgf@xd=\dimen151
\pgf@yd=\dimen152
\w@pgf@writea=\write3
\r@pgf@reada=\read2
\c@pgf@counta=\count192
\c@pgf@countb=\count193
\c@pgf@countc=\count194
\c@pgf@countd=\count195
\t@pgf@toka=\toks24
\t@pgf@tokb=\toks25
\t@pgf@tokc=\toks26
\pgf@sys@id@count=\count196
(pgf.cfg
File: pgf.cfg 2021/05/15 v3.1.9a (3.1.9a)
)
Driver file for pgf: pgfsys-xetex.def
(pgfsys-xetex.def
File: pgfsys-xetex.def 2021/05/15 v3.1.9a (3.1.9a)
(pgfsys-dvipdfmx.def
File: pgfsys-dvipdfmx.def 2021/05/15 v3.1.9a (3.1.9a)
(pgfsys-common-pdf.def
File: pgfsys-common-pdf.def 2021/05/15 v3.1.9a (3.1.9a)
)
\pgfsys@objnum=\count197
)))
(pgfsyssoftpath.code.tex
File: pgfsyssoftpath.code.tex 2021/05/15 v3.1.9a (3.1.9a)
\pgfsyssoftpath@smallbuffer@items=\count198
\pgfsyssoftpath@bigbuffer@items=\count199
) (pgfsysprotocol.code.tex
File: pgfsysprotocol.code.tex 2021/05/15 v3.1.9a (3.1.9a)
)) (xcolor.sty
Package: xcolor 2021/10/31 v2.13 LaTeX color extensions (UK)
(color.cfg
File: color.cfg 2016/01/02 v1.6 sample color configuration
)
Package xcolor Info: Driver file: xetex.def on input line 227.
Package xcolor Info: Model `cmy' substituted by `cmy0' on input line 1352.
Package xcolor Info: Model `RGB' extended on input line 1368.
Package xcolor Info: Model `HTML' substituted by `rgb' on input line 1370.
Package xcolor Info: Model `Hsb' substituted by `hsb' on input line 1371.
Package xcolor Info: Model `tHsb' substituted by `hsb' on input line 1372.
Package xcolor Info: Model `HSB' substituted by `hsb' on input line 1373.
Package xcolor Info: Model `Gray' substituted by `gray' on input line 1374.
Package xcolor Info: Model `wave' substituted by `hsb' on input line 1375.
)
(pgfcore.code.tex
Package: pgfcore 2021/05/15 v3.1.9a (3.1.9a)
(pgfmath.code.tex (pgfmathcalc.code.tex (pgfmathutil.code.tex) (pgfmathparser.
code.tex
\pgfmath@dimen=\dimen153
\pgfmath@count=\count266
\pgfmath@box=\box52
\pgfmath@toks=\toks27
\pgfmath@stack@operand=\toks28
\pgfmath@stack@operation=\toks29
) (pgfmathfunctions.code.tex
(pgfmathfunctions.basic.code.tex) (pgfmathfunctions.trigonometric.code.tex)
(pgfmathfunctions.random.code.tex) (pgfmathfunctions.comparison.code.tex)
(pgfmathfunctions.base.code.tex) (pgfmathfunctions.round.code.tex)
(pgfmathfunctions.misc.code.tex) (pgfmathfunctions.integerarithmetics.code.tex)
)) (pgfmathfloat.code.tex
\c@pgfmathroundto@lastzeros=\count267
)) (pgfint.code.tex) (pgfcorepoints.code.tex
File: pgfcorepoints.code.tex 2021/05/15 v3.1.9a (3.1.9a)
\pgf@picminx=\dimen154
\pgf@picmaxx=\dimen155
\pgf@picminy=\dimen156
\pgf@picmaxy=\dimen157
\pgf@pathminx=\dimen158
\pgf@pathmaxx=\dimen159
\pgf@pathminy=\dimen160
\pgf@pathmaxy=\dimen161
\pgf@xx=\dimen162
\pgf@xy=\dimen163
\pgf@yx=\dimen164
\pgf@yy=\dimen165
\pgf@zx=\dimen166
\pgf@zy=\dimen167
)
(pgfcorepathconstruct.code.tex
File: pgfcorepathconstruct.code.tex 2021/05/15 v3.1.9a (3.1.9a)
\pgf@path@lastx=\dimen168
\pgf@path@lasty=\dimen169
) (pgfcorepathusage.code.tex
File: pgfcorepathusage.code.tex 2021/05/15 v3.1.9a (3.1.9a)
\pgf@shorten@end@additional=\dimen170
\pgf@shorten@start@additional=\dimen171
)
(pgfcorescopes.code.tex
File: pgfcorescopes.code.tex 2021/05/15 v3.1.9a (3.1.9a)
\pgfpic=\box53
\pgf@hbox=\box54
\pgf@layerbox@main=\box55
\pgf@picture@serial@count=\count268
) (pgfcoregraphicstate.code.tex
File: pgfcoregraphicstate.code.tex 2021/05/15 v3.1.9a (3.1.9a)
\pgflinewidth=\dimen172
)
(pgfcoretransformations.code.tex
File: pgfcoretransformations.code.tex 2021/05/15 v3.1.9a (3.1.9a)
\pgf@pt@x=\dimen173
\pgf@pt@y=\dimen174
\pgf@pt@temp=\dimen175
) (pgfcorequick.code.tex
File: pgfcorequick.code.tex 2021/05/15 v3.1.9a (3.1.9a)
)
(pgfcoreobjects.code.tex
File: pgfcoreobjects.code.tex 2021/05/15 v3.1.9a (3.1.9a)
) (pgfcorepathprocessing.code.tex
File: pgfcorepathprocessing.code.tex 2021/05/15 v3.1.9a (3.1.9a)
)
(pgfcorearrows.code.tex
File: pgfcorearrows.code.tex 2021/05/15 v3.1.9a (3.1.9a)
\pgfarrowsep=\dimen176
) (pgfcoreshade.code.tex
File: pgfcoreshade.code.tex 2021/05/15 v3.1.9a (3.1.9a)
\pgf@max=\dimen177
\pgf@sys@shading@range@num=\count269
\pgf@shadingcount=\count270
) (pgfcoreimage.code.tex
File: pgfcoreimage.code.tex 2021/05/15 v3.1.9a (3.1.9a)
(pgfcoreexternal.code.tex
File: pgfcoreexternal.code.tex 2021/05/15 v3.1.9a (3.1.9a)
\pgfexternal@startupbox=\box56
)) (pgfcorelayers.code.tex
File: pgfcorelayers.code.tex 2021/05/15 v3.1.9a (3.1.9a)
)
(pgfcoretransparency.code.tex
File: pgfcoretransparency.code.tex 2021/05/15 v3.1.9a (3.1.9a)
) (pgfcorepatterns.code.tex
File: pgfcorepatterns.code.tex 2021/05/15 v3.1.9a (3.1.9a)
) (pgfcorerdf.code.tex
File: pgfcorerdf.code.tex 2021/05/15 v3.1.9a (3.1.9a)
))) (pgfmoduleshapes.code.tex
File: pgfmoduleshapes.code.tex 2021/05/15 v3.1.9a (3.1.9a)
\pgfnodeparttextbox=\box57
) (pgfmoduleplot.code.tex
File: pgfmoduleplot.code.tex 2021/05/15 v3.1.9a (3.1.9a)
)
(pgfcomp-version-0-65.sty
Package: pgfcomp-version-0-65 2021/05/15 v3.1.9a (3.1.9a)
\pgf@nodesepstart=\dimen178
\pgf@nodesepend=\dimen179
) (pgfcomp-version-1-18.sty
Package: pgfcomp-version-1-18 2021/05/15 v3.1.9a (3.1.9a)
)) (pgffor.sty (pgfkeys.sty (pgfkeys.code.tex)) (pgfmath.sty (pgfmath.code.tex)
) (pgffor.code.tex
Package: pgffor 2021/05/15 v3.1.9a (3.1.9a)
(pgfmath.code.tex)
\pgffor@iter=\dimen180
\pgffor@skip=\dimen181
\pgffor@stack=\toks30
\pgffor@toks=\toks31
)) (tikz.code.tex
Package: tikz 2021/05/15 v3.1.9a (3.1.9a)
(pgflibraryplothandlers.code.tex
File: pgflibraryplothandlers.code.tex 2021/05/15 v3.1.9a (3.1.9a)
\pgf@plot@mark@count=\count271
\pgfplotmarksize=\dimen182
)
\tikz@lastx=\dimen183
\tikz@lasty=\dimen184
\tikz@lastxsaved=\dimen185
\tikz@lastysaved=\dimen186
\tikz@lastmovetox=\dimen187
\tikz@lastmovetoy=\dimen188
\tikzleveldistance=\dimen189
\tikzsiblingdistance=\dimen190
\tikz@figbox=\box58
\tikz@figbox@bg=\box59
\tikz@tempbox=\box60
\tikz@tempbox@bg=\box61
\tikztreelevel=\count272
\tikznumberofchildren=\count273
\tikznumberofcurrentchild=\count274
\tikz@fig@count=\count275
(pgfmodulematrix.code.tex
File: pgfmodulematrix.code.tex 2021/05/15 v3.1.9a (3.1.9a)
\pgfmatrixcurrentrow=\count276
\pgfmatrixcurrentcolumn=\count277
\pgf@matrix@numberofcolumns=\count278
)
\tikz@expandcount=\count279
(tikzlibrarytopaths.code.tex
File: tikzlibrarytopaths.code.tex 2021/05/15 v3.1.9a (3.1.9a)
)))
(tikzlibraryshapes.geometric.code.tex
File: tikzlibraryshapes.geometric.code.tex 2021/05/15 v3.1.9a (3.1.9a)
(pgflibraryshapes.geometric.code.tex
File: pgflibraryshapes.geometric.code.tex 2021/05/15 v3.1.9a (3.1.9a)
))
(tikzlibrarypositioning.code.tex
File: tikzlibrarypositioning.code.tex 2021/05/15 v3.1.9a (3.1.9a)
) (pgflibraryarrows.meta.code.tex
File: pgflibraryarrows.meta.code.tex 2021/05/15 v3.1.9a (3.1.9a)
\pgfarrowinset=\dimen191
\pgfarrowlength=\dimen192
\pgfarrowwidth=\dimen193
\pgfarrowlinewidth=\dimen194
)
(tikzlibrarycalc.code.tex
File: tikzlibrarycalc.code.tex 2021/05/15 v3.1.9a (3.1.9a)
) (tikzlibraryfit.code.tex
File: tikzlibraryfit.code.tex 2021/05/15 v3.1.9a (3.1.9a)
)
(tikzlibrarybackgrounds.code.tex
File: tikzlibrarybackgrounds.code.tex 2021/05/15 v3.1.9a (3.1.9a)
\pgf@layerbox@background=\box62
\pgf@layerboxsaved@background=\box63
) (l3backend-xetex.def
File: l3backend-xetex.def 2022-02-07 L3 backend support: XeTeX
\c__kernel_sys_dvipdfmx_version_int=\count280
\l__color_backend_stack_int=\count281
\g__color_backend_stack_int=\count282
\g__graphics_track_int=\count283
\l__pdf_internal_box=\box64
\g__pdf_backend_object_int=\count284
\g__pdf_backend_annotation_int=\count285
\g__pdf_backend_link_int=\count286
)
No file pcbtoplvl.aux.
\openout1 = `pcbtoplvl.aux'.
LaTeX Font Info: Checking defaults for OML/cmm/m/it on input line 7.
LaTeX Font Info: ... okay on input line 7.
LaTeX Font Info: Checking defaults for OMS/cmsy/m/n on input line 7.
LaTeX Font Info: ... okay on input line 7.
LaTeX Font Info: Checking defaults for OT1/cmr/m/n on input line 7.
LaTeX Font Info: ... okay on input line 7.
LaTeX Font Info: Checking defaults for T1/cmr/m/n on input line 7.
LaTeX Font Info: ... okay on input line 7.
LaTeX Font Info: Checking defaults for TS1/cmr/m/n on input line 7.
LaTeX Font Info: Trying to load font information for TS1+cmr on input line 7
.
(ts1cmr.fd
File: ts1cmr.fd 2019/12/16 v2.5j Standard LaTeX font definitions
)
LaTeX Font Info: ... okay on input line 7.
LaTeX Font Info: Checking defaults for TU/lmr/m/n on input line 7.
LaTeX Font Info: ... okay on input line 7.
LaTeX Font Info: Checking defaults for OMX/cmex/m/n on input line 7.
LaTeX Font Info: ... okay on input line 7.
LaTeX Font Info: Checking defaults for U/cmr/m/n on input line 7.
LaTeX Font Info: ... okay on input line 7.
LaTeX Font Info: External font `cmex10' loaded for size
(Font) <8> on input line 27.
LaTeX Font Info: External font `cmex10' loaded for size
(Font) <6> on input line 27.
LaTeX Font Info: External font `cmex10' loaded for size
(Font) <5> on input line 27.
LaTeX Font Info: External font `cmex10' loaded for size
(Font) <7> on input line 30.
! Undefined control sequence.
<recently read> \text
l.30 ...\text{I}^2\text{C}$ (SDA, SCL)} (esp.west)
;
No pages of output.
+16
View File
@@ -0,0 +1,16 @@
% Generated by analyze_2026_11_04.py.
\newcommand{\NovSamples}{5{,}049}
\newcommand{\NovHours}{6.121}
\newcommand{\NovEnergy}{3.746}
\newcommand{\NovIrradiation}{6.408}
\newcommand{\NovEfficiency}{23.41}
\newcommand{\NovPeakPower}{0.972}
\newcommand{\NovApertureAtPeakPower}{33.21}
\newcommand{\NovPeakCurrent}{502.0}
\newcommand{\NovPeakVoltage}{2.361}
\newcommand{\NovPeakIrradiance}{1172.1}
\newcommand{\NovRelativeMax}{28.90}
\newcommand{\NovRelativePercentile}{74.07}
\newcommand{\NovSiliconStandardRatio}{1.232}
\newcommand{\NovSiliconPremiumRatio}{1.115}
\newcommand{\NovSteadyEfficiency}{28.47}
Binary file not shown.
+72
View File
@@ -0,0 +1,72 @@
% !TEX program = pdflatex
\documentclass{standalone}
\usepackage{amsmath}
\usepackage[utf8]{inputenc}
\usepackage[american]{circuitikz}
\usetikzlibrary{positioning, calc, arrows.meta}
\begin{document}
\begin{tikzpicture}[american, font=\sffamily\small, >=Latex]
% ==========================================
% 1. ENTRADAS A LA IZQUIERDA (CPV Cell)
% ==========================================
\draw (0,4) node[left, font=\bfseries] {HCPV Cell (+)} to[short, o-*] (2,4)
(0,0) node[left, font=\bfseries] {HCPV Cell (-)} to[short, o-*] (2,0) node[ground]{};
% ==========================================
% 2. PRIMERA ETAPA: DIVISOR DE VOLTAJE (Voc)
% ==========================================
\draw (2,4) to[R, l=$R_1\ (10\text{ k}\Omega)$, *-*] (2,2)
to[R, l=$R_2\ (10\text{ k}\Omega)$, *-*] (2,0);
% Salida de Voc directamente hacia la derecha
\draw (2,2) to[short, *-o] (11.5,2)
node[right, font=\bfseries\color{blue!80!black}] {To ADS122C04 AIN3 ($V_{oc}$)};
% ==========================================
% 3. SEGUNDA ETAPA: TRANSISTOR Q1 (INVERTIDO EN Y) Y GATE DRIVER
% ==========================================
% MOSFET Q1 invertido en el eje Y mediante yscale=-1
\draw (5,3) node[nmos, xscale=-1 ,yscale=-1] (Q1) {};
\node[right=1cm of Q1] {\textbf{$Q_1$}\\[-2pt]\scriptsize DMN62D0UW-7};
% Gate Driver MIC5019 (Directamente ARRIBA del MOSFET)
\node[draw, rectangle, rounded corners=3pt, fill=blue!10, minimum width=2.4cm, minimum height=1.2cm, align=center, line width=0.8pt]
(mic) at (5.95,5.5) {\textbf{Gate Driver}\\\footnotesize MIC5019};
% Entrada de señal de control (A LA DERECHA del Gate Driver)
\draw[<-, line width=1pt, color=blue!80!black] (mic.east) -- (10.0, 5.5)
node[right, font=\bfseries] {From ESP32 (\texttt{PIN\_VOC})};
% Conexión de salida del Driver a la Gate del MOSFET
\draw[->, line width=0.8pt] (mic.south) |- (Q1.G);
% Bus CPV(+) conectado al terminal superior del MOSFET
\draw (2,4) -- (5,4) -- (Q1.S);
% Conexión del terminal inferior del MOSFET a la Resistencia Shunt
\draw (Q1.D) -- (5,1.8)
to[R, l=$R_{\text{shunt}}\ (75\text{ m}\Omega)$, *-*] (5,0) -- (2,0);
% ==========================================
% 4. TERCERA ETAPA: SHUNT Y AMPLIFICADOR AD8418
% ==========================================
% Amplificador AD8418 (A LA DERECHA de la Shunt)
\node[draw, rectangle, rounded corners=3pt, fill=orange!10, minimum width=2.5cm, minimum height=1.4cm, align=center, line width=0.8pt]
(ad) at (8.8,0.9) {\textbf{AD8418}\\[-2pt]\footnotesize Current Sense Amp};
% Lectura Diferencial sobre la Shunt
\draw (5,1.8) -- (7.0,1.8) |- (ad.west |- 0,1.2);
\draw (5,0.2) -- (7.0,0.2) |- (ad.west |- 0,0.6);
% Salida de Isc directamente hacia la derecha
\draw[->, line width=1pt] (ad.east) -- (11.5,0.9)
node[right, font=\bfseries\color{orange!80!black}] {To ADS122C04 AIN0/1 ($I_{sc}$)};
\end{tikzpicture}
\end{document}
+93
View File
@@ -0,0 +1,93 @@
% !TEX program = pdflatex
\documentclass{standalone}
\usepackage[utf8]{inputenc}
\usepackage{amsmath}
\usepackage{tikz}
\usetikzlibrary{shapes.geometric, positioning, arrows.meta, calc, fit, backgrounds}
\begin{document}
\begin{tikzpicture}[
font=\sffamily\small,
>=Latex,
mcu/.style={draw=blue!80!black, rectangle, rounded corners=4pt, fill=blue!10, minimum width=3.6cm, minimum height=4.2cm, align=center, line width=1.2pt},
adc/.style={draw=orange!80!black, rectangle, rounded corners=4pt, fill=orange!10, minimum width=2.8cm, minimum height=3.6cm, align=center, line width=1pt},
block/.style={draw, rectangle, rounded corners=3pt, fill=yellow!10, minimum width=3.4cm, minimum height=1.1cm, align=center, line width=0.8pt},
driver/.style={draw=green!60!black, rectangle, rounded corners=3pt, fill=green!10, minimum width=2.6cm, minimum height=1.1cm, align=center, line width=0.8pt},
cloud/.style={draw=cyan!80!black, ellipse, fill=cyan!10, minimum width=3.6cm, minimum height=1.4cm, align=center, line width=1pt},
motor/.style={draw=purple!80!black, circle, fill=purple!10, minimum size=1.1cm, align=center, line width=0.8pt},
bus/.style={line width=1.2pt, <->},
sig/.style={line width=0.8pt, ->}
]
% ==========================================
% 1. NÚCLEO CENTRAL (ESP32 y ADC)
% ==========================================
\node[mcu] (esp) {\textbf{ESP32 MCU}\\[3pt]\footnotesize Dual-Core 32-bit\\[2pt]\scriptsize WebServer \& WebSockets\\[1pt]\scriptsize NVS Memory};
\node[adc, below=-3.14cm of esp, left=2.26cm of esp] (adc) {\textbf{ADS122C04}\\[2pt]\footnotesize 24-Bit $\Delta\Sigma$ ADC};
% Bus I2C entre ADC y ESP32
\draw[bus] (-4.1,0) -- node[above, font=\scriptsize] {$\text{I}^2\text{C}$ (SDA, SCL)} (esp.west);
% ==========================================
% 2. NUBE Y TELEMETRÍA (ARRIBA)
% ==========================================
\node[cloud, above=1.8cm of esp] (cloud) {\textbf{Cloud Platform}\\[-1pt]\footnotesize Monitoring \& Backup};
\draw[bus, dashed, cyan!80!black] (esp.north) -- node[right, font=\scriptsize, text=cyan!80!black, near end] {WiFi / HTTP / WS} (cloud.south);
% ==========================================
% 3. SENSORES E INSUMOS (COLUMNA IZQUIERDA)
% ==========================================
\node[block, anchor=north east, below=2.09cm of adc.north west, left=2.18cm of adc.north west] (pira) {\textbf{Pyranometer}\\[-1pt]\scriptsize (Apogee SP-212)};
\node[block, fill=red!10, draw=red!70!black, above=0.31cm of pira] (cpv) {\textbf{HCPV Sensing Circuit}\\[-2pt]\scriptsize $I_{sc}$ / $V_{oc}$ Switching \& Cond.};
% Conexiones analógicas al ADC
\draw[sig] (pira.east) -- node[above=1pt, font=\scriptsize, pos=0.42] {AIN2 ($G_{POA}$)} (adc.west |- pira.east);
\draw[sig] ($(cpv.east)+(0,0.2)$) -- node[above, font=\scriptsize, pos=0.44] {AIN0/1 ($I_{sc}$)} ($(adc.west |- cpv.east)+(0,0.2)$);
\draw[sig] ($(cpv.east)+(0,-0.2)$) -- node[below, font=\scriptsize, pos=0.43] {AIN3 ($V_{oc}$)} ($(adc.west |- cpv.east)+(0,-0.2)$);
% \draw[sig, gray, dashed] (curr.east) -- node[above, font=\scriptsize, gray, pos=0.45] {AINx (N.C.)} (adc.west |- curr.east);
% (disabled: no 'curr' node exists in this schematic)
% LÍNEA DE CONTROL PIN_VOC (Limpia y bordeando por abajo)
\draw[sig, red!80!black, line width=1pt]
(esp.south west) -- (esp.south west |- 0,-2.6) -- node[below, pos=0.4, font=\scriptsize, text=red!80!black] {\texttt{PIN\_VOC} (Gate Driver Control)} ($(cpv.west)+(-0.35,0)$) -- (cpv.west);
% ==========================================
% 4. DRIVERS Y MOTORES (ABAJO)
% ==========================================
\node[driver, below=1.36cm of esp.south, left=0.15cm of esp.south] (tmcX) {\textbf{TMC2209}\\[-2pt]\scriptsize Driver (X-Axis)};
\node[driver, below=1.34cm of esp.south, right=0.12cm of esp.south] (tmcY) {\textbf{TMC2209}\\[-2pt]\scriptsize Driver (Y-Axis)};
\node[motor, below=0.8cm of tmcX] (motX) {\textbf{M1}\\[-3pt]\tiny Zenith};
\node[motor, below=0.8cm of tmcY] (motY) {\textbf{M2}\\[-3pt]\tiny Azimuth};
% Conexiones de Control a los Drivers
\draw[sig] (esp.south -| tmcX.north) -- node[left, font=\scriptsize, align=right] {UART /\\STEP / DIR} (tmcX.north);
\draw[sig] (esp.south -| tmcY.north) -- node[right, font=\scriptsize, align=left] {UART /\\STEP / DIR} (tmcY.north);
% Conexiones a Motores
\draw[sig, line width=1pt] (tmcX.south) -- node[right, font=\scriptsize] {NEMA 11} (motX.north);
\draw[sig, line width=1pt] (tmcY.south) -- node[right, font=\scriptsize] {NEMA 11} (motY.north);
% ==========================================
% 5. MARCO DE LA TARJETA
% ==========================================
\begin{scope}[
on background layer,
yshift=6pt,
yscale=0.9
]
\node[draw=blue!50, line width=1pt, dashed, rounded corners=6pt, fill=blue!2,
fit=(esp) (adc) (tmcX) (tmcY),
inner sep=14pt,
label={[blue!80!black, font=\bfseries\small, label distance=0pt]north west:Controller Board (WiT-CPV)}] (board) {};
\end{scope}
\draw[thick, solid] (-1.8,1.5) -- (-10.81,1.5)node[font=\scriptsize, pos=0.31, above, north] {PIN-CTRL (HIGH $\rightarrow I_{sc}$, LOW $\rightarrow V_{oc}$)};
\draw[->, thick, solid] (-10.81,1.5) -- (cpv.north);
\end{tikzpicture}
\end{document}
@@ -0,0 +1,29 @@
{
"tag": "10092025",
"model": "G_cs = A*exp(-B*AM) + C, Ajustado al sobre-ensvolvente claro no saturado",
"model_scale_A_Wm2": 920.0,
"model_B": 0.03,
"model_C": 115.0,
"model_rms_clear_Wm2": 57.2,
"n_fit_clear": 138,
"model_peak_Wm2": 1006.0,
"G_max_measured_Wm2": 1024.0,
"G_max_estimated_Wm2": 1024.0,
"n_clipped": 1577,
"clipped_pct_log": 17.4,
"H_kWh_m2_full_measured": 3.0884,
"H_kWh_m2_full_corrected": 3.0884,
"R_opt_uA_per_Wm2_measured": 250.9,
"R_opt_uA_per_Wm2_corrected": 250.9,
"eta_tr_pct_full_measured": 48.3,
"eta_tr_pct_full_corrected": 48.3,
"eta_tr_pct_clear_measured": 42.24,
"eta_tr_pct_clear_corrected": 42.24,
"MAE_I_mA_clear_measured": 66.63,
"MAE_I_mA_clear_corrected": 96.45,
"MAE_P_mW_clear_measured": 182.97,
"MAE_P_mW_clear_corrected": 182.97,
"ratio_P_Pref_median_clear_measured": 0.649,
"ratio_P_Pref_median_clear_corrected": 0.649,
"location": "Aguascalientes, Ags., Mexico (21.8756 N, 102.2854 W, 1896 m, UTC-6)"
}
@@ -0,0 +1,29 @@
{
"tag": "15092025",
"model": "G_cs = A*exp(-B*AM) + C, Ajustado al sobre-ensvolvente claro no saturado",
"model_scale_A_Wm2": 1550.0,
"model_B": 0.61,
"model_C": 130.0,
"model_rms_clear_Wm2": 51.2,
"n_fit_clear": 66,
"model_peak_Wm2": 940.0,
"G_max_measured_Wm2": 1024.0,
"G_max_estimated_Wm2": 1024.0,
"n_clipped": 1186,
"clipped_pct_log": 25.55,
"H_kWh_m2_full_measured": 1.3408,
"H_kWh_m2_full_corrected": 1.3408,
"R_opt_uA_per_Wm2_measured": 231.5,
"R_opt_uA_per_Wm2_corrected": 231.5,
"eta_tr_pct_full_measured": 56.0,
"eta_tr_pct_full_corrected": 56.0,
"eta_tr_pct_clear_measured": 42.34,
"eta_tr_pct_clear_corrected": 42.34,
"MAE_I_mA_clear_measured": 47.19,
"MAE_I_mA_clear_corrected": 63.39,
"MAE_P_mW_clear_measured": 119.76,
"MAE_P_mW_clear_corrected": 119.76,
"ratio_P_Pref_median_clear_measured": 0.799,
"ratio_P_Pref_median_clear_corrected": 0.799,
"location": "Aguascalientes, Ags., Mexico (21.8756 N, 102.2854 W, 1896 m, UTC-6)"
}
@@ -0,0 +1,29 @@
{
"tag": "19092025_",
"model": "G_cs = A*exp(-B*AM) + C, Ajustado al sobre-ensvolvente claro no saturado",
"model_scale_A_Wm2": 1550.0,
"model_B": 0.57,
"model_C": 170.0,
"model_rms_clear_Wm2": 21.0,
"n_fit_clear": 197,
"model_peak_Wm2": 1010.0,
"G_max_measured_Wm2": 1024.0,
"G_max_estimated_Wm2": 1024.0,
"n_clipped": 2212,
"clipped_pct_log": 19.41,
"H_kWh_m2_full_measured": 3.6224,
"H_kWh_m2_full_corrected": 3.6224,
"R_opt_uA_per_Wm2_measured": 373.2,
"R_opt_uA_per_Wm2_corrected": 373.2,
"eta_tr_pct_full_measured": 33.94,
"eta_tr_pct_full_corrected": 33.94,
"eta_tr_pct_clear_measured": 31.97,
"eta_tr_pct_clear_corrected": 31.97,
"MAE_I_mA_clear_measured": 130.83,
"MAE_I_mA_clear_corrected": 220.57,
"MAE_P_mW_clear_measured": 408.12,
"MAE_P_mW_clear_corrected": 408.12,
"ratio_P_Pref_median_clear_measured": 0.391,
"ratio_P_Pref_median_clear_corrected": 0.391,
"location": "Aguascalientes, Ags., Mexico (21.8756 N, 102.2854 W, 1896 m, UTC-6)"
}
@@ -0,0 +1,29 @@
{
"tag": "20092025_desde1pm",
"model": "G_cs = A*exp(-B*AM) + C, Ajustado al sobre-ensvolvente claro no saturado",
"model_scale_A_Wm2": 1550.0,
"model_B": 0.61,
"model_C": 170.0,
"model_rms_clear_Wm2": 18.8,
"n_fit_clear": 306,
"model_peak_Wm2": 960.0,
"G_max_measured_Wm2": 979.7,
"G_max_estimated_Wm2": 979.7,
"n_clipped": 0,
"clipped_pct_log": 0.0,
"H_kWh_m2_full_measured": 1.0505,
"H_kWh_m2_full_corrected": 1.0505,
"R_opt_uA_per_Wm2_measured": 181.6,
"R_opt_uA_per_Wm2_corrected": 181.6,
"eta_tr_pct_full_measured": 69.65,
"eta_tr_pct_full_corrected": 69.65,
"eta_tr_pct_clear_measured": 70.71,
"eta_tr_pct_clear_corrected": 70.71,
"MAE_I_mA_clear_measured": 43.0,
"MAE_I_mA_clear_corrected": 48.56,
"MAE_P_mW_clear_measured": 89.24,
"MAE_P_mW_clear_corrected": 89.24,
"ratio_P_Pref_median_clear_measured": 0.737,
"ratio_P_Pref_median_clear_corrected": 0.737,
"location": "Aguascalientes, Ags., Mexico (21.8756 N, 102.2854 W, 1896 m, UTC-6)"
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,29 @@
{
"tag": "21092025",
"model": "G_cs = A*exp(-B*AM) + C, Ajustado al sobre-ensvolvente claro no saturado",
"model_scale_A_Wm2": 1480.0,
"model_B": 0.33,
"model_C": 0.0,
"model_rms_clear_Wm2": 23.3,
"n_fit_clear": 261,
"model_peak_Wm2": 1036.0,
"G_max_measured_Wm2": 1024.0,
"G_max_estimated_Wm2": 1036.2,
"n_clipped": 3011,
"clipped_pct_log": 23.5,
"H_kWh_m2_full_measured": 3.3291,
"H_kWh_m2_full_corrected": 3.3424,
"R_opt_uA_per_Wm2_measured": 228.9,
"R_opt_uA_per_Wm2_corrected": 226.3,
"eta_tr_pct_full_measured": 67.28,
"eta_tr_pct_full_corrected": 67.76,
"eta_tr_pct_clear_measured": 64.59,
"eta_tr_pct_clear_corrected": 65.05,
"MAE_I_mA_clear_measured": 38.96,
"MAE_I_mA_clear_corrected": 60.85,
"MAE_P_mW_clear_measured": 118.29,
"MAE_P_mW_clear_corrected": 115.56,
"ratio_P_Pref_median_clear_measured": 0.728,
"ratio_P_Pref_median_clear_corrected": 0.73,
"location": "Aguascalientes, Ags., Mexico (21.8756 N, 102.2854 W, 1896 m, UTC-6)"
}
+231
View File
@@ -0,0 +1,231 @@
{
"tag": "21092025",
"log": "21092025.txt",
"session_start": "2025-09-21 10:40:58",
"session_end": "2025-09-21 17:49:22",
"R_opt_max_mA_per_Wm2": 0.229,
"FF_used": 0.82,
"full_session": {
"label": "Full session (G > 50 W/m2)",
"n_samples": 11206,
"t_inicio": "2025-09-21 10:40:58",
"t_fin": "2025-09-21 17:02:34",
"E_Wh": 0.927,
"H_kWh_m2": 3.3291,
"E_norm_Wh_per_kWhm2": 0.278,
"eta_tr_pct": 67.28,
"MAE_I_mA": 43.37,
"MAE_P_mW": 72.39,
"P_CPV_mean_mW": 148.6,
"P_ref_mean_mW": 220.5,
"S_el_usteps": 1667716,
"S_az_usteps": 1615691,
"S_tot_usteps": 3283407,
"S_tot_deg": 4956.1
},
"daylight": {
"label": "Daylight (G > 200 W/m2)",
"n_samples": 7062,
"t_inicio": "2025-09-21 10:40:58",
"t_fin": "2025-09-21 15:19:27",
"E_Wh": 0.945,
"H_kWh_m2": 3.2573,
"E_norm_Wh_per_kWhm2": 0.29,
"eta_tr_pct": 67.55,
"MAE_I_mA": 55.87,
"MAE_P_mW": 105.35,
"P_CPV_mean_mW": 234.6,
"P_ref_mean_mW": 339.3,
"S_el_usteps": 1364762,
"S_az_usteps": 1319052,
"S_tot_usteps": 2683814,
"S_tot_deg": 4051.0
},
"tracking_active": {
"label": "Tracking active (status=3)",
"n_samples": 11174,
"t_inicio": "2025-09-21 10:42:24",
"t_fin": "2025-09-21 17:02:34",
"E_Wh": 0.925,
"H_kWh_m2": 3.3069,
"E_norm_Wh_per_kWhm2": 0.28,
"eta_tr_pct": 67.6,
"MAE_I_mA": 42.98,
"MAE_P_mW": 71.7,
"P_CPV_mean_mW": 148.9,
"P_ref_mean_mW": 220.1,
"S_el_usteps": 1614679,
"S_az_usteps": 1615691,
"S_tot_usteps": 3230370,
"S_tot_deg": 4876.0
},
"clear_sky": {
"label": "Clear-sky (G > 600 W/m2)",
"n_samples": 4919,
"t_inicio": "2025-09-21 10:40:58",
"t_fin": "2025-09-21 14:53:49",
"E_Wh": 1.095,
"H_kWh_m2": 3.8488,
"E_norm_Wh_per_kWhm2": 0.285,
"eta_tr_pct": 64.59,
"MAE_I_mA": 62.28,
"MAE_P_mW": 118.29,
"P_CPV_mean_mW": 314.3,
"P_ref_mean_mW": 432.5,
"S_el_usteps": 1009725,
"S_az_usteps": 968261,
"S_tot_usteps": 1977986,
"S_tot_deg": 2985.6,
"frac_P_above_095Pref": 0.22,
"ratio_P_Pref_median": 0.728
},
"phases": [
{
"label": "Phase: Solar tracking",
"n_samples": 11172,
"t_inicio": "2025-09-21 10:42:24",
"t_fin": "2025-09-21 17:02:34",
"E_Wh": 0.925,
"H_kWh_m2": 3.3069,
"E_norm_Wh_per_kWhm2": 0.28,
"eta_tr_pct": 67.6,
"MAE_I_mA": 42.95,
"MAE_P_mW": 71.67,
"P_CPV_mean_mW": 148.9,
"P_ref_mean_mW": 220.1,
"S_el_usteps": 1604533,
"S_az_usteps": 1571288,
"S_tot_usteps": 3175821,
"S_tot_deg": 4793.7
},
{
"label": "Phase: Recentering",
"n_samples": 2,
"t_inicio": "2025-09-21 10:43:07",
"t_fin": "2025-09-21 10:43:18",
"E_Wh": 0.0,
"H_kWh_m2": 0.0029,
"E_norm_Wh_per_kWhm2": 0.092,
"eta_tr_pct": 26.16,
"MAE_I_mA": 170.16,
"MAE_P_mW": 241.2,
"P_CPV_mean_mW": 85.4,
"P_ref_mean_mW": 326.6,
"S_el_usteps": 10146,
"S_az_usteps": 44403,
"S_tot_usteps": 54549,
"S_tot_deg": 82.3
}
],
"t95_recovery": [
{
"trigger": "Track solar",
"t_trigger": "2025-09-21 10:42:10",
"t95_s": null,
"t95_late_s": 5397.0,
"dropped": true,
"P_min_mW": 2.2
},
{
"trigger": "Recentering",
"t_trigger": "2025-09-21 10:43:07",
"t95_s": null,
"t95_late_s": 5340.0,
"dropped": true,
"P_min_mW": 2.2
},
{
"trigger": "Track solar",
"t_trigger": "2025-09-21 10:43:19",
"t95_s": null,
"t95_late_s": 5328.0,
"dropped": true,
"P_min_mW": 2.2
}
],
"cloud_events": [
{
"t_start": "2025-09-21 13:41:55",
"dur_s": 178,
"G_base_Wm2": 989.0,
"G_min_Wm2": 125.0,
"depth_pct": 87.4,
"t95_G_s": 180.0
},
{
"t_start": "2025-09-21 14:04:37",
"dur_s": 166,
"G_base_Wm2": 1024.0,
"G_min_Wm2": 133.0,
"depth_pct": 87.0,
"t95_G_s": 170.0
},
{
"t_start": "2025-09-21 15:06:55",
"dur_s": 132,
"G_base_Wm2": 148.0,
"G_min_Wm2": 80.0,
"depth_pct": 46.1,
"t95_G_s": 136.0
},
{
"t_start": "2025-09-21 15:47:11",
"dur_s": 120,
"G_base_Wm2": 103.0,
"G_min_Wm2": 58.0,
"depth_pct": 43.8,
"t95_G_s": 124.0
},
{
"t_start": "2025-09-21 15:59:53",
"dur_s": 154,
"G_base_Wm2": 70.0,
"G_min_Wm2": 44.0,
"depth_pct": 36.6,
"t95_G_s": 160.0
}
],
"position_errors": [
{
"t_reticle": "2025-09-21 10:42:23",
"az_corr": 118.0,
"alt_corr": 77.5,
"errX0_deg": 0.15,
"errY0_deg": 0.0,
"errX0_usteps": 100,
"errY0_usteps": 0,
"errX_ss_deg": 2.558,
"errY_ss_deg": 4.906
},
{
"t_reticle": "2025-09-21 10:43:29",
"az_corr": 118.4,
"alt_corr": 77.7,
"errX0_deg": 1.21,
"errY0_deg": 0.0,
"errX0_usteps": 800,
"errY0_usteps": 0,
"errX_ss_deg": 2.34,
"errY_ss_deg": 4.19
}
],
"er_progression": [
{
"t": "2025-09-21T10:41:00.000000",
"e": 1500
},
{
"t": "2025-09-21T10:43:18.000000",
"e": 800
}
],
"at_recommendations": [
{
"timestamp": "2025-09-21 13:56:04",
"small": 2844.0,
"abrupt": 53443.0,
"reset": 196226.0,
"peak": 0.01
}
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 208 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

File diff suppressed because it is too large. Load diff
@@ -0,0 +1,29 @@
{
"tag": "21092025_2PM",
"model": "G_cs = A*exp(-B*AM) + C, Ajustado al sobre-ensvolvente claro no saturado",
"model_scale_A_Wm2": 900.0,
"model_B": 0.04,
"model_C": 150.0,
"model_rms_clear_Wm2": 3.8,
"n_fit_clear": 126,
"model_peak_Wm2": 1012.0,
"G_max_measured_Wm2": 1024.0,
"G_max_estimated_Wm2": 1024.0,
"n_clipped": 2614,
"clipped_pct_log": 44.84,
"H_kWh_m2_full_measured": 2.5026,
"H_kWh_m2_full_corrected": 2.5026,
"R_opt_uA_per_Wm2_measured": 228.9,
"R_opt_uA_per_Wm2_corrected": 228.9,
"eta_tr_pct_full_measured": 72.43,
"eta_tr_pct_full_corrected": 72.43,
"eta_tr_pct_clear_measured": 69.53,
"eta_tr_pct_clear_corrected": 69.53,
"MAE_I_mA_clear_measured": 48.41,
"MAE_I_mA_clear_corrected": 53.6,
"MAE_P_mW_clear_measured": 101.87,
"MAE_P_mW_clear_corrected": 101.87,
"ratio_P_Pref_median_clear_measured": 0.85,
"ratio_P_Pref_median_clear_corrected": 0.85,
"location": "Aguascalientes, Ags., Mexico (21.8756 N, 102.2854 W, 1896 m, UTC-6)"
}
@@ -0,0 +1,29 @@
{
"tag": "23092025",
"model": "G_cs = A*exp(-B*AM) + C, Ajustado al sobre-ensvolvente claro no saturado",
"model_scale_A_Wm2": 1550.0,
"model_B": 0.605,
"model_C": 230.0,
"model_rms_clear_Wm2": 11.3,
"n_fit_clear": 857,
"model_peak_Wm2": 1033.0,
"G_max_measured_Wm2": 1024.0,
"G_max_estimated_Wm2": 1033.2,
"n_clipped": 3043,
"clipped_pct_log": 35.0,
"H_kWh_m2_full_measured": 3.9028,
"H_kWh_m2_full_corrected": 3.9095,
"R_opt_uA_per_Wm2_measured": 248.6,
"R_opt_uA_per_Wm2_corrected": 248.6,
"eta_tr_pct_full_measured": 60.91,
"eta_tr_pct_full_corrected": 60.8,
"eta_tr_pct_clear_measured": 60.54,
"eta_tr_pct_clear_corrected": 60.44,
"MAE_I_mA_clear_measured": 78.81,
"MAE_I_mA_clear_corrected": 90.23,
"MAE_P_mW_clear_measured": 171.75,
"MAE_P_mW_clear_corrected": 172.59,
"ratio_P_Pref_median_clear_measured": 0.617,
"ratio_P_Pref_median_clear_corrected": 0.617,
"location": "Aguascalientes, Ags., Mexico (21.8756 N, 102.2854 W, 1896 m, UTC-6)"
}
File diff suppressed because it is too large. Load diff
+229
View File
@@ -0,0 +1,229 @@
{
"tag": "gyuggyhoy",
"log": "gyuggyhoy.txt",
"session_start": "2025-09-14 10:28:12",
"session_end": "2025-09-14 14:57:34",
"R_opt_max_mA_per_Wm2": 0.233,
"FF_used": 0.82,
"full_session": {
"label": "Full session (G > 50 W/m2)",
"n_samples": 8076,
"t_inicio": "2025-09-14 10:28:12",
"t_fin": "2025-09-14 14:57:34",
"E_Wh": 0.674,
"H_kWh_m2": 2.9318,
"E_norm_Wh_per_kWhm2": 0.23,
"eta_tr_pct": 52.33,
"MAE_I_mA": 74.8,
"MAE_P_mW": 136.72,
"P_CPV_mean_mW": 150.1,
"P_ref_mean_mW": 286.8,
"S_el_usteps": 755649,
"S_az_usteps": 720689,
"S_tot_usteps": 1476338,
"S_tot_deg": 2228.4
},
"daylight": {
"label": "Daylight (G > 200 W/m2)",
"n_samples": 6961,
"t_inicio": "2025-09-14 10:28:12",
"t_fin": "2025-09-14 14:57:34",
"E_Wh": 0.676,
"H_kWh_m2": 2.9512,
"E_norm_Wh_per_kWhm2": 0.229,
"eta_tr_pct": 51.99,
"MAE_I_mA": 80.82,
"MAE_P_mW": 150.61,
"P_CPV_mean_mW": 173.5,
"P_ref_mean_mW": 324.1,
"S_el_usteps": 698706,
"S_az_usteps": 662026,
"S_tot_usteps": 1360732,
"S_tot_deg": 2053.9
},
"tracking_active": {
"label": "Tracking active (status=3)",
"n_samples": 3382,
"t_inicio": "2025-09-14 13:04:46",
"t_fin": "2025-09-14 14:57:34",
"E_Wh": 0.334,
"H_kWh_m2": 1.3109,
"E_norm_Wh_per_kWhm2": 0.254,
"eta_tr_pct": 58.11,
"MAE_I_mA": 70.64,
"MAE_P_mW": 127.96,
"P_CPV_mean_mW": 177.5,
"P_ref_mean_mW": 305.5,
"S_el_usteps": 275668,
"S_az_usteps": 261558,
"S_tot_usteps": 537226,
"S_tot_deg": 810.9
},
"clear_sky": {
"label": "Clear-sky (G > 600 W/m2)",
"n_samples": 4979,
"t_inicio": "2025-09-14 10:28:12",
"t_fin": "2025-09-14 14:57:34",
"E_Wh": 0.888,
"H_kWh_m2": 3.6861,
"E_norm_Wh_per_kWhm2": 0.241,
"eta_tr_pct": 53.53,
"MAE_I_mA": 91.01,
"MAE_P_mW": 172.25,
"P_CPV_mean_mW": 230.6,
"P_ref_mean_mW": 402.9,
"S_el_usteps": 390768,
"S_az_usteps": 366029,
"S_tot_usteps": 756797,
"S_tot_deg": 1142.3,
"frac_P_above_095Pref": 0.014,
"ratio_P_Pref_median": 0.624
},
"phases": [
{
"label": "Phase: Solar tracking",
"n_samples": 3382,
"t_inicio": "2025-09-14 13:04:46",
"t_fin": "2025-09-14 14:57:34",
"E_Wh": 0.334,
"H_kWh_m2": 1.3109,
"E_norm_Wh_per_kWhm2": 0.254,
"eta_tr_pct": 58.11,
"MAE_I_mA": 70.64,
"MAE_P_mW": 127.96,
"P_CPV_mean_mW": 177.5,
"P_ref_mean_mW": 305.5,
"S_el_usteps": 275668,
"S_az_usteps": 261558,
"S_tot_usteps": 537226,
"S_tot_deg": 810.9
}
],
"t95_recovery": [
{
"trigger": "Track solar",
"t_trigger": "2025-09-14 13:04:44",
"t95_s": null,
"t95_late_s": null,
"dropped": true,
"P_min_mW": 1.5
}
],
"cloud_events": [
{
"t_start": "2025-09-14 11:02:46",
"dur_s": 194,
"G_base_Wm2": 759.0,
"G_min_Wm2": 160.0,
"depth_pct": 78.9,
"t95_G_s": 216.0
},
{
"t_start": "2025-09-14 11:15:04",
"dur_s": 150,
"G_base_Wm2": 379.0,
"G_min_Wm2": 154.0,
"depth_pct": 59.2,
"t95_G_s": 212.0
},
{
"t_start": "2025-09-14 11:26:44",
"dur_s": 170,
"G_base_Wm2": 231.0,
"G_min_Wm2": 183.0,
"depth_pct": 20.8,
"t95_G_s": 170.0
},
{
"t_start": "2025-09-14 11:35:46",
"dur_s": 180,
"G_base_Wm2": 301.0,
"G_min_Wm2": 200.0,
"depth_pct": 33.5,
"t95_G_s": 194.0
},
{
"t_start": "2025-09-14 12:00:02",
"dur_s": 256,
"G_base_Wm2": 219.0,
"G_min_Wm2": 190.0,
"depth_pct": 13.5,
"t95_G_s": 256.0
},
{
"t_start": "2025-09-14 12:13:48",
"dur_s": 132,
"G_base_Wm2": 994.0,
"G_min_Wm2": 263.0,
"depth_pct": 73.5,
"t95_G_s": 136.0
},
{
"t_start": "2025-09-14 12:42:00",
"dur_s": 126,
"G_base_Wm2": 252.0,
"G_min_Wm2": 244.0,
"depth_pct": 3.4,
"t95_G_s": 126.0
},
{
"t_start": "2025-09-14 13:27:56",
"dur_s": 128,
"G_base_Wm2": 679.0,
"G_min_Wm2": 228.0,
"depth_pct": 66.4,
"t95_G_s": 128.0
},
{
"t_start": "2025-09-14 14:06:28",
"dur_s": 120,
"G_base_Wm2": 328.0,
"G_min_Wm2": 189.0,
"depth_pct": 42.2,
"t95_G_s": 172.0
},
{
"t_start": "2025-09-14 14:12:38",
"dur_s": 132,
"G_base_Wm2": 169.0,
"G_min_Wm2": 152.0,
"depth_pct": 9.9,
"t95_G_s": 132.0
}
],
"position_errors": [
{
"t_reticle": "2025-09-14 13:04:46",
"az_corr": 195.6,
"alt_corr": 93.6,
"errX0_deg": 0.0,
"errY0_deg": 0.0,
"errX0_usteps": 0,
"errY0_usteps": 0,
"errX_ss_deg": 0.376,
"errY_ss_deg": 2.711
}
],
"er_progression": [
{
"t": "2025-09-14T12:50:59.000000",
"e": 800
}
],
"at_recommendations": [
{
"timestamp": "2025-09-14 12:50:58",
"small": 3561.0,
"abrupt": 10683.0,
"reset": 21366.0,
"peak": 0.01
},
{
"timestamp": "2025-09-14 12:51:07",
"small": 3561.0,
"abrupt": 10683.0,
"reset": 21366.0,
"peak": 0.01
}
]
}
File diff suppressed because it is too large. Load diff
+242
View File
@@ -0,0 +1,242 @@
% 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 3429 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
334 observations, from 11:36:55 to 12:35:37.
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
(24 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)
=0.0004651~\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
(0.0004567~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
All accepted segments & 2921 & 4.034 & 2.067 & 27.31 & 79.80 \\
Before change (unclipped only) & 836 & 1.144 & 0.659 & 25.32 & 70.63 \\
After change (all illuminated) & 2085 & 2.890 & 1.408 & 28.34 & 84.97 \\
Fixed hour, 13:00--14:00 & 714 & 0.989 & 0.746 & 30.67 & 89.62 \\
\bottomrule
\end{tabular}
\end{table}
\paragraph{Post-intervention results and limitations}
The primary post-change evaluation retains the entire returned record,
12:46:01--15:40:42, including the late low-output period.
It contains 2085 accepted illuminated samples and
2.890~h of integrated support
(99.27\% of its elapsed span).
The estimated electrical energy is 1.408~Wh for
1.989~kWh\,m$^{-2}$ of irradiation on that support,
giving 28.34\% energy-weighted
aperture efficiency. Peak current is 477.6~mA, and
peak modeled power is 0.891~W; maxima of different
variables need not be simultaneous. At the modeled power peak
(12:53:11), the simultaneous log record gives
$I_{sc}=477.6$~mA,
$V_{oc}=2.274$~V and
$G=1027.9$~W\,m$^{-2}$.
The median instantaneous aperture estimate
is 28.70\%, and its peak is
34.70\%, neither being a daily efficiency.
The empirical tracking ratio is 84.97\%,
with a sample-mean absolute power deviation of 86.4~mW
from the internal reference. The 99th-percentile reference changes this ratio
to 86.54\%.
Current-threshold and irradiance-normalized on-target sample fractions are
37.31\% and
58.94\%, respectively; the corresponding
endpoint-interpolated time fractions are
37.34\% and
59.02\% on accepted intervals.
For a conventional fixed one-hour comparison rather than an automatically
selected best interval, 13:00--14:00 gives
30.67\% aperture efficiency and
89.62\% empirical tracking efficiency,
with 0.746~Wh over 0.989~h
of integrated support. This favourable subset must not replace the full
post-change result. The pre-change unclipped subset has only
67.28\% 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 656,576 $X$ steps and
643,068 $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 0.1109~Wh.
The independently differenced TEL counter contributes
1,293,457 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
2 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}
+175
View File
@@ -0,0 +1,175 @@
% Generated by analyze_2026_11_04.py; do not hand-edit numerical values.
\subsection{Acquisition coverage and operating chronology}
The acquired span is 09:52:50--16:05:13, comprising \NovSamples{}
electrical/coordinate observations and 5,026 separate
telemetry messages. All main observations pass the ADC quality checks and
exceed the analysis illumination threshold. There are no duplicate main
timestamps or decreasing main-sample uptimes. Five gaps exceed 15~s:
09:53:10--09:53:51 (41 s); 09:54:11--09:57:17 (186 s); 10:34:38--10:34:59 (21 s); 16:03:43--16:04:23 (40 s); 16:04:43--16:05:01 (18 s). Excluding these gaps gives \NovHours{}~h of integration
support out of 6.206~h elapsed, or 98.63\%
coverage. The audit retains every acquired observation; no clipped samples
or unobserved intervals are reconstructed.
The complete overview in Fig.~\ref{fig:nov04_overview} connects electrical
response with stage motion. There are 61 logged remote
commands, including early tuning and repositioning. For example,
\texttt{Delay=4} was logged at 12:27:20, before the 12:32 boundary. Three event boundaries
divide the record without selecting a favorable performance threshold:
\texttt{ErrMax=1100} at 12:32:02, \texttt{ErrMax=1000} at 13:53:00,
and the first late manual-coordinate command at 15:12:42. All four sequential
phases are retained in Table~\ref{tab:nov04_phases}. No additional remote
commands are recorded inside the 12:32--13:53 phase; periodic controller
reference resets continue. This is an interval without logged operator
interventions, not a controlled comparison of firmware or settings.
\subsection{Electrical output and aperture estimate}
Applying Eqs.~\eqref{eq:power_estimate}--\eqref{eq:aperture} over the
complete accepted support yields \NovEnergy{}~Wh of estimated available-MPP
energy from \NovIrradiation{}~kWh\,m$^{-2}$ of measured aperture-plane irradiation,
with an energy-weighted aperture estimate of \NovEfficiency\%.
Table~\ref{tab:nov04_hourly} shows every acquired clock-hour bin, including
the initial low response and late decline, rather than replacing the complete
record with a midday-only integral.
At the maximum of the power estimate (2026-11-04 12:27:40), the
sequentially recorded values in that observation were
$I_{sc}=502.0$ mA,
$V_{oc}=2.361$ V and $G=1172.1$ W m$^{-2}$,
giving 0.972~W estimated output and an instantaneous aperture
estimate of \NovApertureAtPeakPower\% at that observation. Separate maxima are not combined
into a synthetic operating point. The 12:32--13:53 phase contributes
1.126~Wh over
1.349~h, with an aperture estimate
of \NovSteadyEfficiency\%. The 15:00--16:00 clock-hour estimate falls to
11.51\%, and
the phase after the first 15:12 manual command gives
6.97\%.
The record shows deterioration before and after that command, not evidence
that the command alone caused it.
\begin{table}[htbp]
\centering\small
\caption{Complete acquisition and fixed clock-hour summaries. $H$ is aperture-plane
irradiation, $\widehat E$ is FF-based available energy and
$\widehat\eta_{\mathrm{ap},E}$ is the energy-weighted aperture estimate.
Bins contain only acquired observations. Both edge endpoints must lie inside a
bin, so subdivided integrals omit boundary-crossing edges and need not sum
exactly to the full-record integral.}
\label{tab:nov04_hourly}
\begin{tabular}{lrrrrr}
\toprule
Window & $N$ & Support [h] & $H$ [kWh\,m$^{-2}$] & $\widehat E$ [Wh] & $\widehat\eta_{\mathrm{ap},E}$ [\%] \\
\midrule
Full acquired record & 5049 & 6.121 & 6.408 & 3.7463 & 23.41 \\
09:00--10:00 & 41 & 0.056 & 0.046 & 0.0002 & 0.21 \\
10:00--11:00 & 717 & 0.993 & 0.945 & 0.4629 & 19.61 \\
11:00--12:00 & 718 & 0.998 & 1.100 & 0.7387 & 26.90 \\
12:00--13:00 & 807 & 1.000 & 1.173 & 0.8094 & 27.65 \\
13:00--14:00 & 900 & 0.999 & 1.165 & 0.8004 & 27.51 \\
14:00--15:00 & 900 & 0.999 & 1.104 & 0.6887 & 24.97 \\
15:00--16:00 & 900 & 0.999 & 0.838 & 0.2409 & 11.51 \\
16:00--17:00 & 66 & 0.070 & 0.029 & 0.0004 & 0.58 \\
\bottomrule
\end{tabular}
\end{table}
\begin{table}[htbp]
\centering\small
\caption{All sequential event-defined phases. Internal command boundaries
belong to the following phase; the final acquired sample is retained.
As in Table~\ref{tab:nov04_hourly},
edges crossing a boundary are omitted. Phases describe operation; they are not
matched experimental treatments.}
\label{tab:nov04_phases}
\begin{tabular}{lrrrr}
\toprule
Phase boundary clocks & $N$ & Support [h] & $\widehat E$ [Wh] & $\widehat\eta_{\mathrm{ap},E}$ [\%] \\
\midrule
09:52:50--12:32:02 & 1863 & 2.584 & 1.601 & 23.61 \\
12:32:02--13:53:00 & 1215 & 1.349 & 1.126 & 28.47 \\
13:53:00--15:12:42 & 1195 & 1.327 & 0.903 & 24.95 \\
15:12:42--16:05:13 & 776 & 0.859 & 0.114 & 6.97 \\
\bottomrule
\end{tabular}
\end{table}
\subsection{Relative response and solar-position context}
An empirical same-record $I_{sc}/G$ reference is highly sensitive to one point:
the maximum is 0.001078 A m$^2$ W$^{-1}$ at
2026-11-04 13:01:07, where $G=313.0$ W m$^{-2}$ and
$I_{sc}=337.2$ mA; the P99 reference is
0.000420 A m$^2$ W$^{-1}$. The anomalous sample is retained.
In its four-minute neighborhood, median irradiance is
1186.6 W m$^{-2}$
(61 samples), consistent with an isolated
irradiance dip rather than sustained low irradiance; the cause remains unresolved.
Across the accepted illuminated samples, median $I_{sc}/G$ is
0.000335 A m$^2$ W$^{-1}$
(interquartile range 0.000250--
0.000376).
Full-record integrated relative-yield indicators are
28.9\% (maximum reference) and
74.1\% (P99 sensitivity). These are internal,
reference-dependent indicators rather than independently validated tracking
efficiencies. Figure~\ref{fig:nov04_response} displays both the full
responsivity range and a typical-range view; the anomalous observation remains
in the analysis.
Interpolated SunCalc positions at the first and last observations are
130.25$^\circ$ and
238.89$^\circ$ azimuth, with elevations
35.86$^\circ$ and
25.89$^\circ$, respectively.
The printed table reaches 52.59$^\circ$
elevation at 12:30. Figure~\ref{fig:nov04_solar_response} relates this
trajectory to the measured normalized response. Logged coordinates span
$X=0$--$114,000$
and $Y=1,800$--$104,965$
steps. These are observed solar and coordinate ranges, not independently
validated acceptance angles or rated mechanical limits.
\subsection{Conditional fixed-silicon reference}
The coplanar, equal-area scenarios of Eq.~\eqref{eq:silicon_scenario}
yield
3.040 and 3.360 Wh, respectively;
the corresponding estimated-CPV-to-scenario ratios are
\NovSiliconStandardRatio{} and \NovSiliconPremiumRatio{}.
Figure~\ref{fig:nov04_silicon} illustrates these assumptions, not measured
comparative yields. Changing the assumed CPV fill factor from 0.82 to 0.70 or
0.90 would change the full-record energy estimate to
3.198 or
4.112~Wh, respectively.
This sensitivity range is illustrative, not a measured uncertainty interval.
\begin{figure}[htbp]\centering
\includegraphics[width=0.98\linewidth]{../figures/2026-11-04/field_day_overview.pdf}
\caption{Complete 4 November acquisition. Vertical dotted lines mark sampling
gaps; dashed lines mark the three selected phase-boundary commands. Raw trends are not smoothed, and gaps are
not bridged. Electrical output is estimated using $FF=0.82$.}
\label{fig:nov04_overview}\end{figure}
\begin{figure}[htbp]\centering
\includegraphics[width=0.92\linewidth]{../figures/2026-11-04/solar_response_alignment.pdf}
\caption{SunCalc azimuth/elevation context aligned to the measured response.
SunCalc entries are spaced 15 minutes; intermediate positions are linearly
interpolated for plotting. Color encodes $I_{sc}/G$, not pointing error;
values above P99 remain plotted in the red over-range color.}
\label{fig:nov04_solar_response}\end{figure}
\begin{figure}[htbp]\centering
\includegraphics[width=0.88\linewidth]{../figures/2026-11-04/response_reference_sensitivity.pdf}
\caption{Irradiance-normalized response for the full record. The maximum
reference is dominated by a retained anomalous observation; P99 is shown only as
a sensitivity analysis. Neither line is an independent ideal-tracking standard.}
\label{fig:nov04_response}\end{figure}
\begin{figure}[htbp]\centering
\includegraphics[width=0.75\linewidth]{../figures/2026-11-04/fixed_silicon_scenario.pdf}
\caption{Conditional equal-area scenarios using the measured aperture-plane irradiance.
Silicon efficiency is held constant at 19\% or 21\%; no panel,
temperature correction or measured head-to-head comparison is represented.}
\label{fig:nov04_silicon}\end{figure}
+1
View File
@@ -0,0 +1 @@
Tectonic 0.17.0
+317
View File
@@ -0,0 +1,317 @@
#!/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 <tag> [nofig]
Salida: articulo/resultados_<tag>/preview_irradiance_est.png
articulo/resultados_<tag>/irradiance_est_<tag>.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<el<15 la formula de Kasten-Young diverge (am=inf); reportar
# A*exp(-B*0)=A ahi seria fisicamente incorrecto.
curve = np.where((el_deg > 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)
+171
View File
@@ -0,0 +1,171 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
Comparacion de todas las sesiones de campo disponibles (logs en la raiz
del repositorio).
Por log se reporta:
* ventana temporal y numero de muestras
* eta_tr, MAE_P, energia, irradiancia acumulada (metricas del parser)
* mediana P/Pref y fraccion de muestras con P >= 0.95*P_ref
* SATURACION del piranometro: el ADC se topa a 1024 W/m2
- n / fraccion de muestras saturadas (G >= 1023.9)
- minutos acumulados saturados
- R_opt (max Isc/G) del pipeline actual vs R_opt sin muestras
saturadas -> factor de inflacion de P_ref por el clipping
- eta_tr estimado si R_opt se calcula sin muestras saturadas
* n transitorios nubosos detectados
Uso: py -3.12 compare_sessions.py
Salida: articulo/comparacion_sesiones.json + tabla en consola
"""
import json
import re
import sys
import tempfile
from pathlib import Path
import numpy as np
import pandas as pd
import parse_log as pl
ROOT = Path(__file__).resolve().parent
# (archivo en la raiz, etiqueta, formato)
LOGS = [
("hasta la 1pm 020925.txt", "02 Sep 11:27-13:00", "new"),
("esp_1223_02092025.txt", "02 Sep 12:23-..", "old"),
("log(3).txt", "14 Sep (manana)", "new"),
("gyuggyhoy.txt", "14 Sep 10:28-..", "old"),
("10092025.txt", "10 Sep", "new"),
("15092025.txt", "15 Sep", "new"),
("19092025_.txt", "19 Sep", "new"),
("20092025_desde1pm.txt", "20 Sep (desde 13:44)", "new"),
("21092025.txt", "21 Sep (paper)", "new"),
("21092025_2PM.txt", "21 Sep (parcial, dup.)", "new"),
("23092025.txt", "23 Sep", "new"),
("full_log_21082025.txt", "21 Ago (log completo)", "old"),
("esp32_log_20250821_102525.txt", "21 Ago 10:25", "old"),
("esp32_log_20250821_1143.txt", "21 Ago 11:43", "old"),
("esp32_log_20250826_114833.txt", "26 Ago 11:48", "old"),
("esp32_log_20250827_095607.txt", "27 Ago 09:56", "old"),
("esp32_log_20250827_110728.txt", "27 Ago 11:07", "old"),
("esp32_log_20250827_449pm.txt", "27 Ago 16:49", "old"),
("log_26082025.txt", "26 Ago (corto)", "old"),
("log_2608205_2.txt", "26 Ago (corto)", "old"),
("nuevolog}.txt", "26 Ago 13:28", "old"),
("test_2108.txt", "21 Ago (test)", "old"),
("test_3.txt", "21 Ago (test)", "old"),
("test_$.txt", "21 Ago (test)", "old"),
]
G_CLIP = 1023.9 # umbral: ADC en tope => G = 1024.0 W/m2
def normalize(path: Path, tmp: Path):
"""Copia el log normalizando el prefijo 'LOG:' del firmware antiguo."""
with open(path, "r", encoding="utf-8", errors="replace") as f:
lines = f.readlines()
out = []
for line in lines:
line = line.rstrip("\n")
if line.startswith("LOG:"):
line = line[4:]
out.append(line)
tmp.write_text("\n".join(out) + "\n", encoding="utf-8")
def session_report(path: Path, label: str, fmt: str):
if not path.exists():
return {"file": path.name, "label": label, "error": "archivo inexistente"}
tmp = Path(tempfile.gettempdir()) / (path.stem + "_norm.txt")
try:
normalize(path, tmp)
df_s, df_e = pl.parse_log(tmp)
df, ph = pl.build_physical(df_s, df_e)
except Exception as exc:
return {"file": path.name, "label": label, "fmt": fmt,
"error": f"{type(exc).__name__}: {exc}"}
m = pl.window_metrics(df, "full")
if m["n_samples"] == 0:
return {"file": path.name, "label": label, "fmt": fmt,
"error": "sin muestras validas"}
day = df[df["G_POA"] > 50]
n_clip = int((df["G_POA"] >= G_CLIP).sum())
dt_med = float(np.median(df["dt_s"])) if "dt_s" in df else 2.0
clip_min = n_clip * dt_med / 60.0 if n_clip else 0.0
lit = df[df["G_POA"] > 600]
R_full = float((lit["Isc"] / lit["G_POA"]).max()) if len(lit) else float("nan")
unc = df[(df["G_POA"] > 600) & (df["G_POA"] < 1015)]
R_unc = float((unc["Isc"] / unc["G_POA"]).max()) if len(unc) else float("nan")
infl = (R_full / R_unc) if (R_unc == R_unc and R_unc > 0) else None
w = df[df["G_POA"] > 50]
ratio = (w["P_CPV"] / w["P_ref"]).values
return {
"file": path.name,
"label": label,
"fmt": fmt,
"t_inicio": m["t_inicio"],
"t_fin": m["t_fin"],
"n_samples": m["n_samples"],
"eta_tr_pct": m["eta_tr_pct"],
"MAE_P_mW": m["MAE_P_mW"],
"E_Wh": m["E_Wh"],
"H_kWh_m2": m["H_kWh_m2"],
"ratio_P_Pref_median": round(float(np.median(ratio)), 3),
"frac_P_above_095Pref": round(float(np.mean(w["P_CPV"].values >= 0.95 * w["P_ref"].values)), 3),
"G_max_Wm2": round(float(df["G_POA"].max()), 1),
"n_clip": n_clip,
"clip_pct_day": round(100.0 * n_clip / len(day), 1) if len(day) else 0.0,
"clip_min": round(clip_min, 1),
"R_opt_uA_per_Wm2": round(R_full * 1e6, 1),
"R_opt_unc_uA_per_Wm2": round(R_unc * 1e6, 1) if R_unc == R_unc else None,
"inflation_factor": round(infl, 4) if infl else None,
"eta_tr_pct_Ropt_corrected": round(m["eta_tr_pct"] * infl, 2) if (infl and m["eta_tr_pct"]) else None,
"n_cloud_events": len(pl.cloud_events(df)),
}
def main():
rows = []
for name, label, fmt in LOGS:
rep = session_report(ROOT / name, label, fmt)
rows.append(rep)
if "error" in rep:
print(f"[x] {label:<26} {rep['error']}")
ok = [r for r in rows if r.get("eta_tr_pct")]
ok.sort(key=lambda r: r["eta_tr_pct"], reverse=True)
print("\n" + "=" * 118)
print(f"{'sesion':<24} {'horario':<19} {'n':>5} {'eta%':>6} {'MAE_P':>7} "
f"{'E Wh':>6} {'H kWh/m2':>9} {'med P/Pref':>10} {'%>=0.95':>8} "
f"{'Gmax':>5} {'nClip':>5} {'%clip':>5} {'minClip':>7} {'Ropt(uA/Wm2)':>13} {'infl':>6} {'nNub':>4}")
print("=" * 118)
for r in ok:
infl = f"{r['inflation_factor']}x" if r["inflation_factor"] else " -"
print(f"{r['label']:<24} {r['t_inicio'][11:16]}-{r['t_fin'][11:16]} "
f"{r['n_samples']:>5} {r['eta_tr_pct']:>6} {r['MAE_P_mW']:>7.1f} "
f"{r['E_Wh']:>6} {r['H_kWh_m2']:>9} {r['ratio_P_Pref_median']:>10} "
f"{100 * r['frac_P_above_095Pref']:>7.0f}% {r['G_max_Wm2']:>5.0f} "
f"{r['n_clip']:>5} {r['clip_pct_day']:>5.1f} {r['clip_min']:>7.0f} "
f"{r['R_opt_uA_per_Wm2']:>13.0f} {infl:>6} {r['n_cloud_events']:>4}")
print("=" * 118)
print("NOTA: infl = R_opt(clips incluidos)/R_opt(sin clips); el clipping "
"INFLA P_ref y subestima eta, por lo que eta (corregido) = eta * infl\n")
for r in ok:
if r.get("eta_tr_pct_Ropt_corrected"):
print(f" {r['label']:<24} eta corregido (solo R_opt) ~ {r['eta_tr_pct_Ropt_corrected']} %")
out = ROOT / "articulo" / "comparacion_sesiones.json"
out.write_text(json.dumps(rows, indent=2, ensure_ascii=False), encoding="utf-8")
print(f"\n[ok] tabla guardada en {out}")
if __name__ == "__main__":
main()
Binary file not shown.
+37
View File
@@ -0,0 +1,37 @@
# -*- coding: utf-8 -*-
# diag_x0.py — characterize the spurious X~0 points in the 14/09 captures
from datetime import datetime
import numpy as np
import plot_logs as pl
A = pl.LOGS[1][1] # log(3)
B = pl.LOGS[2][1] # gyuggyhoy
print('log(3) window: 10:03:36 -> 14:15:37 | gyuggyhoy window: 10:28:12 -> 14:57:34')
print('common/overlap window: 10:28:12 -> 14:15:37')
g_lo = datetime(2025, 9, 14, 10, 28, 12)
g_hi = datetime(2025, 9, 14, 14, 57, 34)
for name, path in [('21aug', pl.LOGS[0][1]), ('log(3)', A), ('gyuggyhoy', B)]:
L = pl.parse_log(path)
X, Y = L['X'], L['Y']
print('\n== %s (n=%d) ==' % (name, len(X)))
print(' X==0: %d X<1000: %d X<5000: %d min X=%d' % ((X == 0).sum(), (X < 1000).sum(), (X < 5000).sum(), int(X.min())))
print(' Y==0: %d Y<1000: %d min Y=%d' % ((Y == 0).sum(), (Y < 1000).sum(), int(Y.min())))
print('\n--- detail: log(3) samples with X<1000 (max 30 shown) ---')
L = pl.parse_log(A)
X, Y, C, er, t = L['X'], L['Y'], L['C'], L['er'], L['t']
z = np.where(X < 1000)[0]
print('total X<1000 :', len(z))
print('inside gyuggyhoy overlap [10:28:12..14:57:34]:', int(sum(1 for i in z if g_lo <= t[i] <= g_hi)))
print('in pre-overlap tail [10:03:36..10:28:12] :', int(sum(1 for i in z if t[i] < g_lo)))
shown = 0
for i in z:
if shown >= 30:
break
win = 'OVERLAP' if (g_lo <= t[i] <= g_hi) else ('pre-10:28' if t[i] < g_lo else 'post-14:15')
pv = '(X=%d,Y=%d)' % (X[i - 1], Y[i - 1]) if i > 0 else '-'
nx = '(X=%d,Y=%d)' % (X[i + 1], Y[i + 1]) if i + 1 < len(X) else '-'
print(' %s X=%-6d Y=%-6d C=%-7d er=%-4d [%-10s] prev %s next %s' % (
t[i].strftime('%H:%M:%S'), X[i], Y[i], C[i], er[i], win, pv, nx))
shown += 1
+16
View File
@@ -0,0 +1,16 @@
state/
.deploy_backups/
data_logs/
firmware/
**/__pycache__/
**/*.pyc
.pytest_cache/
.git/
.env
.env.*
*.sqlite3*
*.token
*.secret
*.bak
*_test.py
*_test.cjs
+25
View File
@@ -0,0 +1,25 @@
FROM python:3.11-slim
ENV TZ=Etc/GMT+6
RUN apt-get update && apt-get install -y --no-install-recommends tzdata \
&& ln -snf /usr/share/zoneinfo/$TZ /etc/localtime \
&& echo $TZ > /etc/timezone \
&& rm -rf /var/lib/apt-get/lists/*
WORKDIR /app
COPY requirements.txt /app/requirements.txt
RUN pip install --no-cache-dir -r requirements.txt
# Copiar el paquete modular
COPY app /app/app
COPY static /app/static
RUN mkdir -p /app/data_logs /app/firmware /app/state
VOLUME ["/app/data_logs", "/app/firmware", "/app/state"]
EXPOSE 8000
# Socket ownership is in memory; multiple workers need an external coordinator.
HEALTHCHECK --interval=10s --timeout=3s --start-period=10s --retries=3 CMD ["python", "-c", "import json,urllib.request; raise SystemExit(not json.load(urllib.request.urlopen('http://127.0.0.1:8000/health',timeout=2))['ok'])"]
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1", "--ws-max-size", "16384"]
+212
View File
@@ -0,0 +1,212 @@
// Dependency-free state/routing tests for the existing inline dashboard script.
const assert = require('node:assert/strict');
const fs = require('node:fs');
const vm = require('node:vm');
const path = require('node:path');
class Element {
constructor() {
this.children = []; this.style = {}; this.value = ''; this.files = [];
this.disabled = false; this.checked = false; this._text = '';
this.classList = {toggle() {}};
}
set textContent(value) { this._text = String(value); this.children = []; }
get textContent() { return this._text; }
get firstChild() { return this.children[0] || null; }
appendChild(child) { this.children.push(child); return child; }
removeChild(child) { this.children.splice(this.children.indexOf(child), 1); }
}
const elements = new Map();
const element = id => {
if (!elements.has(id)) elements.set(id, new Element());
return elements.get(id);
};
const sockets = [], requests = [], timers = [], confirmations = [];
let responseOverride = null;
class Socket {
constructor(url) { this.url = url; this.readyState = 1; this.sent = []; sockets.push(this); }
send(message) { this.sent.push(message); }
close() { this.readyState = 3; if (this.onclose) this.onclose(); }
emit(message) { this.onmessage({data: message}); }
}
const context = vm.createContext({
console, AbortController, DOMException, Date, Number, JSON,
document: {
getElementById: element,
createElement: () => new Element(),
querySelectorAll: () => [element('calPyrano'), element('pcbSel'), element('otaFile'), element('otaVersion')],
},
window: {addEventListener() {}},
location: {protocol: 'https:', host: 'test.invalid'},
localStorage: {setItem() {}, getItem() { return ''; }},
WebSocket: Socket,
fetch: async (url, options = {}) => {
requests.push({url, options});
if (responseOverride) {
const override = responseOverride(url, options);
if (override) return override;
}
const body = url.endsWith('/logs') || url === '/api/devices' ? [] : {ok: true};
return {ok: true, json: async () => body};
},
setTimeout: callback => { timers.push(callback); return timers.length; },
clearTimeout() {}, setInterval() {},
confirm: message => { confirmations.push(message); return true; },
alert: message => { throw new Error('Unexpected alert: ' + message); },
});
const html = fs.readFileSync(path.join(__dirname, 'static', 'index.html'), 'utf8');
const scripts = [...html.matchAll(/<script(?:\s[^>]*)?>([\s\S]*?)<\/script>/g)];
vm.runInContext(scripts.map(match => match[1]).join('\n'), context);
const A = 'esp32-001122aabbcc', B = 'esp32-001122aabbdd';
context.fleet = [{device_id: A, alias: 'Tracker A'}, {device_id: B, alias: 'Tracker B'}];
context.charts.traj = {options: {scales: {x: {type: 'linear', min: 0, max: 106000}}}};
const config = id => 'CONFIG:' + JSON.stringify({device_id: id, pyrano: 0.2, azdev: 5, pcb_version: 'V1'});
const axisAck = (id, x, y, base) => 'AXISCFG:' + JSON.stringify({device_id:id, x, y, base, x_budget:x*base, y_budget:y*base});
const flush = async () => { for (let i = 0; i < 5; i++) await new Promise(resolve => setImmediate(resolve)); };
async function run() {
context.selectDevice(A);
assert.equal(context.charts.traj.options.scales.x.max, 106000, 'trajectory uses motor steps, not time');
const old = sockets.at(-1);
assert(old.url.endsWith('device_id=' + A));
assert.equal(context.deviceReady, false);
old.emit(config(A));
assert.equal(context.deviceReady, true);
old.emit('DATA:' + JSON.stringify({device_id: A, ts: 100, pcpv_mw: 590, x: 12, y: 34}));
assert.equal(context.samples.length, 1);
old.emit('DATA:' + JSON.stringify({device_id: B, ts: 101}));
assert.equal(context.samples.length, 1, 'foreign payload must be ignored');
// Axis controls retain desired values while partial ACKs describe actual board state.
old.emit('STATUS:ONLINE');
old.emit(axisAck(A, 3, 1, 4));
assert(element('axisSchedule').textContent.includes('12 X y luego 4 Y'));
element('axisXFactor').value = '5'; element('axisYFactor').value = '2';
context.sendAxisFactors();
assert.deepEqual(old.sent, ['SET_PARAM:axisXFactor=5', 'SET_PARAM:axisYFactor=2']);
assert(context.axisRequested);
old.emit(axisAck(A, 5, 1, 4));
assert(context.axisRequested, 'first ACK must not confirm the second, still-pending factor');
assert(element('axisSchedule').textContent.includes('Solicitud pendiente'));
assert.equal(element('axisYFactor').value, '2', 'partial ACK must not overwrite requested Y');
old.emit(axisAck(B, 5, 2, 4));
assert(context.axisRequested, 'foreign ACK must not confirm a request');
old.emit('AXISCFG:' + JSON.stringify({device_id:A,x:5,y:2,base:4,x_budget:1,y_budget:8}));
assert(context.axisRequested, 'inconsistent budgets must be rejected');
old.emit(axisAck(A, 5, 2, 4));
assert.equal(context.axisRequested, null);
assert(element('axisSchedule').textContent.includes('20 X y luego 8 Y'));
element('inCicleMax').value = '3'; context.sendParam('cicleMax', 'inCicleMax');
old.emit(axisAck(A, 5, 2, 4));
assert(context.axisRequested, 'a stale base ACK must not confirm the newly requested base');
old.emit(axisAck(A, 5, 2, 3));
assert.equal(context.axisRequested, null);
element('inCicleMax').value = '0';
assert.throws(() => context.sendParam('cicleMax', 'inCicleMax'), /entero entre 1 y 64/);
element('inCicleMax').value = '64';
assert.throws(() => context.sendParam('cicleMax', 'inCicleMax'), /superar 64 ciclos/);
element('axisXFactor').value = '2.5';
assert.throws(() => context.sendAxisFactors(), /enteros entre 1 y 8/);
old.emit('STATUS:OFFLINE');
assert.equal(context.axisConfirmed, null, 'board disconnect must clear confirmation even while browser WS stays open');
const beforeOffline = old.sent.length;
element('axisXFactor').value = '3'; element('axisYFactor').value = '1';
context.sendAxisFactors();
assert.equal(old.sent.length, beforeOffline, 'axis request must not be sent to an offline board');
old.emit(axisAck(A, 3, 1, 4));
assert.equal(context.axisConfirmed, null, 'late ACK while board is offline must be ignored');
old.emit('STATUS:ONLINE');
context.sendAxisFactors();
assert.equal(context.axisConfirmed, null, 'old firmware without ACK must remain unconfirmed');
assert(context.axisRequested);
old.emit(axisAck(A, 3, 1, 4));
old.sent.length = 0;
await flush();
// An HTTP response initiated for A must never populate B after switching.
let finish;
responseOverride = url => url === '/api/devices/' + A + '/calib'
? new Promise(resolve => { finish = resolve; }) : null;
element('calPyrano').value = '0.3';
context.applyCalib();
const request = requests.at(-1);
context.selectDevice(B);
assert.equal(context.axisConfirmed, null, 'device selection must clear the previous board confirmation');
assert.equal(context.axisRequested, null);
assert.equal(context.samples.length, 0);
assert.equal(context.telSamples.length, 0);
assert.equal(element('t_power').textContent, '--');
assert.equal(element('inPosX').value, '');
assert.equal(context.deviceReady, false);
assert.equal(request.options.signal.aborted, true);
const current = sockets.at(-1);
old.emit(config(A));
old.emit('POS:999:999');
old.close();
assert.equal(element('posX').textContent, '--');
assert.equal(context.ws, current);
current.emit(config(B));
current.emit('STATUS:ONLINE');
current.emit(axisAck(A, 3, 1, 4));
assert.equal(context.axisConfirmed, null, 'selected B must not accept an A acknowledgement');
current.emit(axisAck(B, 1, 1, 4));
assert(element('axisSchedule').textContent.includes('4 X y luego 4 Y'));
finish({ok: true, json: async () => ({mV_per_WM2: 12345})});
await flush();
assert(!element('calNote').textContent.includes('12345'));
assert.equal(context.cfg.device_id, B);
responseOverride = null;
// Manual OTA explicitly captures selected ID and the image that was confirmed.
const sha256 = 'a'.repeat(64);
current.emit('OTA_STATE:' + JSON.stringify({device_id: B, connected: true,
device: '1.0', available: {version: '2.0', sha256}, auto: false}));
assert.equal(element('otaPush').disabled, false);
context.pushOta();
const push = requests.at(-1);
assert.equal(push.url, '/api/devices/' + B + '/ota/push');
assert.equal(JSON.parse(push.options.body).sha256, sha256);
assert(confirmations.at(-1).includes('Tracker B [' + B + ']'));
assert(confirmations.at(-1).includes('2.0'));
await flush();
assert(element('otaStatus').textContent.includes('Pendiente de confirmar'));
context.sendRaw('CMD:10');
assert.deepEqual(current.sent, ['CMD:10']);
assert.deepEqual(old.sent, []);
assert(requests.every(r => !['/api/calib', '/api/pcb', '/ota/push'].includes(r.url)));
responseOverride = url => url.endsWith('/pcb') || url.endsWith('/ota/auto')
? Promise.resolve({ok: false, status: 503, json: async () => ({detail: 'storage unavailable'})}) : null;
element('pcbSel').value = 'V2';
context.applyPcb();
await flush();
assert.equal(element('pcbSel').value, 'V1', 'failed PCB save must not display an unpersisted version');
assert(element('pcbNote').textContent.includes('storage unavailable'));
element('otaAuto').checked = true;
context.saveOtaAuto();
await flush();
assert.equal(element('otaAuto').checked, false, 'failed auto-OTA save must restore server state');
assert(element('otaStatus').textContent.includes('storage unavailable'));
responseOverride = null;
// Failed loads must not retain controls for an old connection or overwrite a new one.
current.close();
assert.equal(context.axisConfirmed, null, 'browser disconnect also clears the board confirmation');
assert.equal(context.deviceReady, false);
context.sendRaw('CMD:4');
assert.deepEqual(current.sent, ['CMD:10']);
context.selectDevice('');
current.emit('FW:old-firmware');
assert.equal(element('otaDevice').textContent, 'sin informar');
assert.equal(context.activeDevice, '');
assert.equal(element('otaPush').disabled, true);
await flush();
const repeated = Array.from({length: 16000}, () => ({ts: 100, x: 1, y: 2}));
context.seedHistory(repeated);
context.seedTel(repeated);
assert.equal(context.samples.length, 15000, 'sample cap must hold even for repeated timestamps');
assert.equal(context.telSamples.length, 15000, 'telemetry cap must hold even for repeated timestamps');
console.log('OK: dashboard selection, stale HTTP/WS isolation, axis ACK/pending/bounds/offline state, control guards and targeted OTA confirmation.');
}
run().catch(error => { console.error(error); process.exitCode = 1; });
+674
View File
@@ -0,0 +1,674 @@
"""Isolated integration tests. Requires httpx in the test environment only.
Run: python -m unittest -v _fleet_test
All devices, credentials, logs and OTA files are disposable simulations.
"""
import json
import asyncio
import ast
import io
import logging
import re
import os
from pathlib import Path
import subprocess
import sqlite3
import sys
import tempfile
import time
import unittest
from unittest.mock import patch
from urllib.parse import urlsplit
import anyio
from fastapi.testclient import TestClient
from starlette.websockets import WebSocketDisconnect
ROOT = Path(__file__).resolve().parent
A = "esp32-001122aabbcc"
B = "esp32-001122aabbdd"
ORIGIN = "http://localhost:4000"
TEL = ("LOG:[2026-10-30 10:34:03] TEL: t=246s, steps=29825(+1000), "
"avgC=5469365, avgV=5160269, avgP=679686, LPF=4338597, "
"prox=0.00, CmaxN=53643680, X=-9827, Y=-65788")
SAMPLE = "LOG:[2026-10-30 10:34:07] X:54810,Y:48890,C:5469365,V:5160269,P:679686,S:17,er:1500"
class FleetTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.temp = tempfile.TemporaryDirectory(prefix="fleet-test-")
cls.directory = Path(cls.temp.name)
cls.environment = {
"STATE_DIR": str(cls.directory / "state"),
"LOGS_DIR": str(cls.directory / "logs"),
"FIRMWARE_DIR": str(cls.directory / "firmware"),
"ALLOWED_ORIGINS": ORIGIN,
"PRIMARY_DEVICE_ID": "",
"CREDENTIAL_POLL_SECONDS": "0.1",
}
cls.original_env = {key: os.environ.get(key) for key in cls.environment}
os.environ.update(cls.environment)
cls.tokens = {}
for device, alias in ((A, "Tracker A"), (B, "Tracker B")):
token_file = cls.directory / "state" / "provision" / (device + ".v1.token")
cls.cli("add", "--device-id", device, "--alias", alias)
cls.tokens[device] = token_file.read_text(encoding="utf-8").strip()
# Import only after redirecting every persistent path.
from app import config
for key in ("STATE_DIR", "LOGS_DIR", "FIRMWARE_DIR"):
assert Path(getattr(config, key)).resolve() == Path(cls.environment[key]).resolve()
from app.main import app
cls.client_context = TestClient(app)
cls.client = cls.client_context.__enter__()
@classmethod
def tearDownClass(cls):
cls.client_context.__exit__(None, None, None)
for key, value in cls.original_env.items():
if value is None:
os.environ.pop(key, None)
else:
os.environ[key] = value
cls.temp.cleanup()
@classmethod
def cli(cls, *arguments):
result = subprocess.run(
[sys.executable, "-m", "app.devices", *arguments], cwd=ROOT,
env=os.environ.copy(), capture_output=True, text=True, timeout=15,
)
if result.returncode:
raise AssertionError("CLI failed: " + result.stderr)
return result
def api(self, device, suffix):
return "/api/devices/" + device + suffix
def esp(self, device, token=None):
return self.client.websocket_connect(
"/ws/esp32?device_id=" + device,
headers={"Authorization": "Bearer " + (token or self.tokens[device])},
)
def browser(self, device):
return self.client.websocket_connect(
"/ws/client?device_id=" + device, headers={"Origin": ORIGIN},
)
def receive_text(self, socket, timeout=8):
# Starlette's synchronous receive has no deadline; bound its async stream.
async def bounded():
with anyio.fail_after(timeout):
return await socket._send_rx.receive()
message = socket.portal.call(bounded)
socket._raise_on_close(message)
return message["text"]
def until(self, socket, prefix, containing=None):
deadline = time.monotonic() + 8
for _ in range(60):
remaining = deadline - time.monotonic()
self.assertGreater(remaining, 0, "Deadline waiting for " + prefix)
message = self.receive_text(socket, remaining)
if message.startswith(prefix) and (containing is None or containing in message):
return message[len(prefix):]
self.fail("Expected browser prefix " + prefix)
def test_01_registry_and_rejections(self):
rows = self.client.get("/api/devices").json()
self.assertEqual({r["device_id"] for r in rows}, {A, B})
self.assertTrue(all("token" not in key and "hash" not in key for r in rows for key in r))
security_log = io.StringIO()
handler = logging.StreamHandler(security_log)
logger = logging.getLogger("tracker_hub")
logger.addHandler(handler)
try:
for device, token in ((A, "invalid"), (B, self.tokens[A]), ("esp32-000000000000", self.tokens[A])):
with self.assertRaises(WebSocketDisconnect):
with self.esp(device, token):
self.fail("unauthenticated connection accepted")
finally:
logger.removeHandler(handler)
self.assertIn("esp32_auth_failed", security_log.getvalue())
for token in self.tokens.values():
self.assertNotIn(token, security_log.getvalue())
with self.assertRaises(WebSocketDisconnect):
with self.client.websocket_connect("/ws/esp32?device_id=" + A):
self.fail("anonymous device accepted")
with self.assertRaises(WebSocketDisconnect):
with self.client.websocket_connect("/ws/client?device_id=" + A, headers={"Origin": "https://evil.invalid"}):
self.fail("foreign browser origin accepted")
self.assertEqual(self.client.get(self.api("esp32-000000000000", "/status")).status_code, 404)
self.assertGreaterEqual(self.client.get("/api/status").status_code, 400)
def test_02_two_devices_and_scoped_commands(self):
with self.esp(A) as a, self.esp(B) as b, self.browser(A) as ca, self.browser(B) as cb:
self.assertEqual(json.loads(self.until(ca, "CONFIG:"))["device_id"], A)
self.assertEqual(json.loads(self.until(cb, "CONFIG:"))["device_id"], B)
a.send_text(TEL)
point_a = json.loads(self.until(ca, "TEL:"))
b.send_text(TEL.replace("X=-9827", "X=-100"))
point_b = json.loads(self.until(cb, "TEL:"))
self.assertEqual((point_a["device_id"], point_a["x"]), (A, 9827))
self.assertEqual((point_b["device_id"], point_b["x"]), (B, 100))
a.send_text(SAMPLE)
self.assertEqual(json.loads(self.until(ca, "DATA:"))["device_id"], A)
b.send_text("VER:test-B")
self.assertEqual(self.until(cb, "FW:"), "test-B")
ca.send_text("CMD:10")
self.assertEqual(self.receive_text(a), "CMD:10")
for invalid in ("OTA:https://evil.invalid/payload.bin", "SET_COORDS:nan:0", "SET_PARAM:errMax=inf"):
ca.send_text(invalid)
self.until(ca, "LOG:", containing="ERROR")
ca.send_text("CMD:10")
self.assertEqual(self.receive_text(a), "CMD:10", "invalid commands must not reach the device")
cb.send_text("SET_COORDS:12:34")
self.assertEqual(self.receive_text(b), "SET_COORDS:12:34")
status_a = self.client.get(self.api(A, "/status")).json()
status_b = self.client.get(self.api(B, "/status")).json()
self.assertNotEqual(status_a["firmware_device"], status_b["firmware_device"])
self.assertEqual(status_b["firmware_device"], "test-B")
for device in (A, B):
rows = self.client.get(self.api(device, "/logs")).json()
self.assertTrue(rows)
response = self.client.get(self.api(device, "/logs/" + rows[0]["filename"]))
self.assertEqual(response.status_code, 200)
if device == A:
self.assertNotIn("test-B", response.text)
def test_03_configuration_isolation_and_validation(self):
with self.browser(A) as ca, self.browser(B) as cb:
self.until(ca, "CONFIG:")
self.until(cb, "CONFIG:")
before_b = self.client.get(self.api(B, "/calib")).json()
with self.esp(A) as a:
a.send_text(SAMPLE)
before = json.loads(self.until(ca, "DATA:"))["isc_ma"]
self.assertAlmostEqual(before, 5469365 / 8388607 * 2048 / 20 / 0.075, delta=0.01)
response = self.client.post(self.api(A, "/pcb"), json={"version": "V2"})
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()["r_shunt"], 0.130)
history = json.loads(self.until(ca, "HISTORY:"))
# The first HISTORY is the initial replay; consume until recalculated history.
if not history or history[-1]["isc_ma"] == before:
history = json.loads(self.until(ca, "HISTORY:"))
self.assertAlmostEqual(history[-1]["isc_ma"] / before, 0.075 / 0.130, delta=0.001)
self.assertAlmostEqual(history[-1]["isc_ma"], 5469365 / 8388607 * 2048 / 20 / 0.130, delta=0.01)
pcb_b = self.client.get(self.api(B, "/pcb")).json()
self.assertEqual((pcb_b["version"], pcb_b["r_shunt"]), ("V1", 0.075))
self.assertEqual(self.client.post(self.api(A, "/calib"), json={"mV_per_WM2": 0.3, "az_dev": 18}).status_code, 200)
self.assertEqual(self.client.get(self.api(B, "/calib")).json(), before_b)
self.assertEqual(self.client.get(self.api(A, "/pcb")).json()["version"], "V2")
self.assertEqual(self.client.post(self.api(A, "/pcb"), json={"version": "V9"}).status_code, 400)
for value in (0, -1, "nan", "inf"):
self.assertEqual(self.client.post(self.api(A, "/calib"), json={"mV_per_WM2": value}).status_code, 400)
def test_04_reconnect_old_socket_cannot_disconnect_new(self):
with self.esp(A) as old, self.browser(A) as browser:
self.until(browser, "CONFIG:")
with self.esp(A) as new:
old.close()
new.send_text("VER:new-session")
self.assertEqual(self.until(browser, "FW:"), "new-session")
self.assertTrue(self.client.get(self.api(A, "/status")).json()["esp32_connected"])
browser.send_text("CMD:10")
self.assertEqual(self.receive_text(new), "CMD:10")
def test_05_ota_is_manual_and_scoped_to_confirmed_image(self):
with self.esp(A) as a, self.esp(B) as b:
# A simulated ESP32 is simply a WebSocket inbox; no hardware is touched.
response = self.client.post("/ota/upload", files={"file": ("test.bin", b"test-firmware-1")}, data={"version": "test-2"})
self.assertEqual(response.status_code, 200)
manifest = response.json()["manifest"]
self.assertEqual(self.client.post(self.api(A, "/ota/push"), json={"sha256": "wrong"}).status_code, 409)
sent = self.client.post(self.api(B, "/ota/push"), json={"sha256": manifest["sha256"]})
self.assertEqual(sent.status_code, 200)
self.assertEqual(sent.json()["device_id"], B)
ota_command = self.receive_text(b)
self.assertTrue(ota_command.startswith("OTA:"))
# A receives only its explicitly requested status command, not B's OTA.
with self.browser(A) as browser:
self.until(browser, "CONFIG:")
browser.send_text("CMD:10")
self.assertEqual(self.receive_text(a), "CMD:10")
self.assertFalse(self.client.get(self.api(A, "/status")).json()["ota"]["auto"])
self.assertFalse(self.client.get(self.api(B, "/status")).json()["ota"]["auto"])
self.assertEqual(self.client.get("/ota/firmware").content, b"test-firmware-1")
def test_06_log_paths_and_disconnected_command(self):
self.assertEqual(self.client.get(self.api(A, "/logs/log_1990-01-01.txt")).status_code, 404)
self.assertEqual(self.client.get(self.api(A, "/logs/missing.txt")).status_code, 400)
self.assertEqual(self.client.get(self.api(A, "/logs/..%2F..%2Fstate%2Fdevices.sqlite3")).status_code, 404)
with self.browser(A) as browser:
self.until(browser, "CONFIG:")
# Drain initial replay before checking the explicit command failure.
self.until(browser, "OTA_STATE:")
browser.send_text("CMD:10")
self.assertIn("ERROR", self.until(browser, "LOG:", containing="ERROR"))
def test_07_new_process_persists_settings_but_not_connections(self):
script = (
"import json\nfrom fastapi.testclient import TestClient\nfrom app.main import app\n"
"with TestClient(app) as client:\n"
f" base='/api/devices/{A}'\n"
" print('__RESULT__'+json.dumps({'pcb':client.get(base+'/pcb').json(),"
"'calib':client.get(base+'/calib').json(),'status':client.get(base+'/status').json()}))"
)
result = subprocess.run([sys.executable, "-c", script], cwd=ROOT,
env=os.environ.copy(), capture_output=True, text=True, timeout=15)
self.assertEqual(result.returncode, 0, result.stderr)
payload = json.loads(next(line[len("__RESULT__"):] for line in result.stdout.splitlines()
if line.startswith("__RESULT__")))
self.assertEqual(payload["pcb"]["version"], "V2")
self.assertEqual(payload["calib"]["mV_per_WM2"], 0.3)
self.assertEqual(payload["calib"]["az_dev"], 18)
self.assertFalse(payload["status"]["esp32_connected"])
self.assertEqual(payload["status"]["samples_in_buffer"], 0)
for token in self.tokens.values():
self.assertNotIn(token, result.stdout + result.stderr)
listing = self.cli("list")
for token in self.tokens.values():
self.assertNotIn(token, listing.stdout + listing.stderr)
def test_08_upload_never_triggers_ota_even_when_device_opted_in(self):
original = self.client.get("/ota/manifest").json()
with self.esp(A) as a, self.browser(A) as browser:
self.until(browser, "CONFIG:")
a.send_text("VER:" + original["version"])
self.until(browser, "FW:")
self.assertEqual(self.client.post(self.api(A, "/ota/auto"), json={"enabled": True}).status_code, 200)
response = self.client.post("/ota/upload", files={"file": ("test.bin", b"test-firmware-2")}, data={"version": "test-3"})
self.assertEqual(response.status_code, 200)
try:
# Outwait the legacy 3-second auto-update delay to catch accidental push-on-upload.
time.sleep(4)
browser.send_text("CMD:10")
self.assertEqual(self.receive_text(a), "CMD:10", "upload unexpectedly pushed firmware")
self.assertEqual(self.client.post(self.api(A, "/ota/push"), json={"sha256": original["sha256"]}).status_code, 409)
self.assertFalse(self.client.get(self.api(B, "/status")).json()["ota"]["auto"])
old_url = urlsplit(original["url"])
self.assertEqual(self.client.get(old_url.path).content, b"test-firmware-1",
"an already confirmed image URL must remain immutable")
finally:
self.client.post(self.api(A, "/ota/auto"), json={"enabled": False})
def test_09_rotation_revocation_and_database_secrecy(self):
previous = self.tokens[B]
self.cli("rotate", "--device-id", B, "--grace-hours", "1")
token_file = self.directory / "state" / "provision" / (B + ".v2.token")
current = token_file.read_text(encoding="utf-8").strip()
self.assertNotEqual(previous, current)
with self.esp(B, previous):
pass
with self.esp(B, current):
pass
self.cli("retire-previous", "--device-id", B)
with self.assertRaises(WebSocketDisconnect):
with self.esp(B, previous):
self.fail("retired token accepted")
with self.esp(B, current) as active:
self.cli("revoke", "--device-id", B)
with self.assertRaises(WebSocketDisconnect):
self.receive_text(active)
with self.assertRaises(WebSocketDisconnect):
with self.esp(B, current):
self.fail("revoked token accepted")
self.tokens[B] = current
for database in (self.directory / "state").glob("*.sqlite3*"):
content = database.read_bytes()
for token in (previous, current, self.tokens[A]):
self.assertFalse(token.encode() in content, "plaintext token leaked into SQLite")
with self.esp(A):
pass # Revoking B must leave A's credential valid.
def test_10_explicit_idempotent_primary_migration(self):
c = "esp32-001122aabbee"
self.cli("add", "--device-id", c, "--alias", "Migration target")
legacy = self.directory / "firmware" / "pcb_state.json"
legacy.write_text(json.dumps({"version": "V2"}), encoding="utf-8")
(legacy.parent / "ota_state.json").write_text(json.dumps({"auto": True}), encoding="utf-8")
self.assertEqual(self.client.get(self.api(c, "/pcb")).json()["version"], "V1")
self.cli("migrate-primary", "--device-id", c)
self.assertEqual(self.client.get(self.api(c, "/pcb")).json()["version"], "V2")
self.assertEqual(self.client.get(self.api(B, "/pcb")).json()["version"], "V1")
self.assertFalse(self.client.get(self.api(c, "/status")).json()["ota"]["auto"])
# A repeated migration must not overwrite a later explicit setting.
self.client.post(self.api(c, "/pcb"), json={"version": "V1"})
self.cli("migrate-primary", "--device-id", c)
self.assertEqual(self.client.get(self.api(c, "/pcb")).json()["version"], "V1")
self.assertEqual(json.loads(legacy.read_text(encoding="utf-8"))["version"], "V2")
def test_11_oversized_session_isolated_and_corrupt_database_fails_closed(self):
with self.esp(A) as a:
a.send_text("x" * 16385)
with self.assertRaises(WebSocketDisconnect):
self.receive_text(a)
self.assertEqual(self.client.get(self.api(B, "/status")).status_code, 200)
broken = self.directory / "broken-state"
broken.mkdir()
(broken / "devices.sqlite3").write_bytes(b"not a sqlite database")
env = dict(os.environ, STATE_DIR=str(broken))
result = subprocess.run([sys.executable, "-c", "from app.main import app"],
cwd=ROOT, env=env, capture_output=True, text=True, timeout=15)
self.assertNotEqual(result.returncode, 0, "corrupt registry must not fall back to anonymous access")
def test_12_ended_sessions_and_failed_accept_are_inert(self):
from app.hub import HubManager
class Socket:
def __init__(self, fail=False):
self.fail = fail
self.closed = False
async def accept(self):
if self.fail:
raise RuntimeError("simulated accept failure")
async def close(self, *args, **kwargs):
self.closed = True
async def send_text(self, message):
pass
async def check():
manager = HubManager(A)
original = Socket()
connection = await manager.connect_esp32(original)
await manager.handle_esp32_message("POS:1:2", connection)
with self.assertRaises(RuntimeError):
await manager.connect_esp32(Socket(fail=True))
self.assertIs(manager.esp32_socket, original)
self.assertEqual(manager.connection_id, connection)
manager.disconnect_esp32(connection)
await manager.handle_esp32_message("POS:999:999", connection)
self.assertEqual((manager.last_pos_x, manager.last_pos_y), (1, 2))
self.assertIsNone(manager.connection_id)
connection = await manager.connect_esp32(Socket())
await manager.force_close()
await manager.handle_esp32_message("POS:888:888", connection)
self.assertEqual((manager.last_pos_x, manager.last_pos_y), (1, 2))
self.assertFalse(manager.esp32_connected)
asyncio.run(check())
def test_13_failed_commit_rolls_back_before_next_write(self):
from app.device_store import DeviceStore
store = DeviceStore(str(self.directory / "rollback.sqlite3"))
store.add_device(A, "rollback fixture")
before = store.get_settings(A)
connection = store._conn
class FailedCommit:
attempted = False
def __getattr__(self, name):
return getattr(connection, name)
def commit(self):
self.attempted = True
raise sqlite3.OperationalError("simulated full disk")
def __enter__(self):
return self
def __exit__(self, kind, error, traceback):
if kind is not None:
connection.rollback()
return False
try:
self.commit()
except Exception:
connection.rollback()
raise
try:
failure = FailedCommit()
store._conn = failure
with self.assertRaises(Exception):
store.save_settings(A, az_dev=77)
self.assertTrue(failure.attempted, "fixture did not reach the failing commit")
store._conn = connection
self.assertFalse(connection.in_transaction, "failed write left a pending transaction")
self.assertEqual(store.get_settings(A), before)
store.save_settings(A, pyrano=0.4)
self.assertEqual(store.get_settings(A)["az_dev"], before["az_dev"])
finally:
store._conn = connection
connection.rollback()
store.close()
def test_14_unknown_id_cannot_allocate_rate_limit_state(self):
from app.main import fleet
from app.config import is_valid_device_id
self.assertFalse(is_valid_device_id(A + "\n"))
before = len(fleet._buckets)
self.assertFalse(fleet.check_rate("not-a-device"))
self.assertFalse(fleet.check_rate("esp32-ffffffffffff"))
self.assertEqual(len(fleet._buckets), before)
def test_15_source_remains_compatible_with_python_311_image(self):
for source in (ROOT / "app").glob("*.py"):
with self.subTest(source=source.name):
ast.parse(source.read_text(encoding="utf-8"), filename=str(source), feature_version=(3, 11))
def test_16_retiring_previous_token_closes_that_active_session_only(self):
previous = self.tokens[A]
with self.esp(A, previous) as active:
self.cli("rotate", "--device-id", A, "--grace-hours", "1")
current = (self.directory / "state" / "provision" / (A + ".v2.token")).read_text(encoding="utf-8").strip()
# The old credential is still valid during the grace period.
self.assertTrue(self.client.get(self.api(A, "/status")).json()["esp32_connected"])
self.cli("retire-previous", "--device-id", A)
with self.assertRaises(WebSocketDisconnect):
self.receive_text(active)
with self.assertRaises(WebSocketDisconnect):
with self.esp(A, previous):
self.fail("retired credential accepted")
self.tokens[A] = current
with self.esp(A, current):
pass
def test_17_log_quota_is_visible_without_stopping_telemetry(self):
from app.hub import HubManager
from app.config import LOGS_DIR, get_local_now
device = "esp32-001122aabbee"
path = Path(LOGS_DIR) / device / f"log_{get_local_now():%Y-%m-%d}.txt"
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(b"sentinel\n")
manager = HubManager(device)
with patch("app.hub.LOG_DAILY_MAX_BYTES", 9):
asyncio.run(manager.handle_esp32_message(SAMPLE))
self.assertEqual(path.read_bytes(), b"sentinel\n")
self.assertEqual(len(manager.samples), 1)
self.assertTrue(manager.status_payload()["log_degraded"])
self.assertEqual(manager.status_payload()["log_error_count"], 1)
def test_18_rate_retention_and_slow_subscriber_are_bounded(self):
from app.device_store import DeviceStore
from app.fleet import FleetManager
from app.hub import HubManager
from app.config import LOGS_DIR
store = DeviceStore(str(self.directory / "limits.sqlite3"))
try:
store.add_device(A)
fleet = FleetManager(store, rate=0, burst=2)
self.assertTrue(fleet.check_rate(A))
self.assertTrue(fleet.check_rate(A))
self.assertFalse(fleet.check_rate(A))
self.assertFalse(fleet.check_rate("esp32-ffffffffffff"))
self.assertEqual(len(fleet._buckets), 1)
old = Path(LOGS_DIR) / A / "log_2000-01-01.txt"
legacy = Path(LOGS_DIR) / "log_2000-01-01.txt"
for path in (old, legacy):
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("fixture", encoding="utf-8")
os.utime(path, (1, 1))
self.assertEqual(fleet.prune_logs(30), 1)
self.assertFalse(old.exists())
self.assertTrue(legacy.exists())
finally:
store.close()
class Subscriber:
def __init__(self, slow=False):
self.slow, self.closed, self.received = slow, False, []
async def send_text(self, message):
if self.slow:
await asyncio.sleep(10)
self.received.append(message)
async def close(self, **kwargs):
self.closed = True
async def check():
manager = HubManager()
slow, fast = Subscriber(True), Subscriber()
manager.client_sockets.extend([slow, fast])
with patch("app.hub.BROADCAST_TIMEOUT", 0.01):
await manager._broadcast("test")
self.assertEqual(fast.received, ["test"])
self.assertTrue(slow.closed)
self.assertEqual(manager.client_sockets, [fast])
asyncio.run(check())
def test_19_cross_origin_http_writes_are_rejected_before_mutation(self):
before = self.client.get(self.api(A, "/calib")).json()
rejected = self.client.post(self.api(A, "/calib"), json={"mV_per_WM2": 99},
headers={"Origin": "https://evil.invalid"})
self.assertEqual(rejected.status_code, 403)
self.assertEqual(self.client.get(self.api(A, "/calib")).json(), before)
manifest = self.client.get("/ota/manifest").json()
upload = self.client.post("/ota/upload", files={"file": ("bad.bin", b"not-approved")},
headers={"Origin": "null"})
self.assertEqual(upload.status_code, 403)
self.assertEqual(self.client.get("/ota/manifest").json(), manifest)
accepted = self.client.post(self.api(A, "/calib"),
json={"mV_per_WM2": before["mV_per_WM2"]},
headers={"Origin": ORIGIN})
self.assertEqual(accepted.status_code, 200)
def test_20_auto_ota_pending_task_cancels_when_disabled_or_already_installed(self):
from app.hub import HubManager
class Socket:
async def accept(self):
pass
async def send_text(self, text):
raise AssertionError("Cancelled OTA must not send any command")
async def check():
manager = HubManager(A)
cid = await manager.connect_esp32(Socket())
manager.ota_manifest = {"version": "target", "sha256": "a" * 64, "url": "https://test.invalid/image"}
manager.esp32_firmware_version = "old"
manager._version_session_id = cid
manager.ota_auto_enabled = True
manager._maybe_auto_update()
pending = manager._auto_task
self.assertIsNotNone(pending)
await manager.set_ota_auto(False)
await asyncio.gather(pending, return_exceptions=True)
self.assertTrue(pending.cancelled())
self.assertIsNone(manager._auto_task)
manager.ota_auto_enabled = True
manager._maybe_auto_update()
pending = manager._auto_task
manager.esp32_firmware_version = "target"
manager._maybe_auto_update()
await asyncio.gather(pending, return_exceptions=True)
self.assertTrue(pending.cancelled())
self.assertIsNone(manager._auto_task)
manager.disconnect_esp32(cid)
asyncio.run(check())
def test_21_docker_healthcheck_targets_a_live_endpoint(self):
dockerfile = (ROOT / "Dockerfile").read_text(encoding="utf-8")
path = re.search(r"http://127\.0\.0\.1:8000([^'\" ]+)", dockerfile).group(1)
response = self.client.get(path)
self.assertEqual(response.status_code, 200)
self.assertTrue(response.json()["ok"])
def test_22_tracker_settings_reach_selected_device(self):
with self.esp(A) as device, self.browser(A) as browser:
self.until(browser, "CONFIG:")
for command in (
"SET_PARAM:minErrMax=193", "SET_PARAM:lpfBeta=0.70",
"SET_PARAM:smallThreshold=4500", "SET_PARAM:peakProximity=0.30",
"SET_PARAM:cicleMax=2",
"SET_PARAM:axisXFactor=3", "SET_PARAM:axisYFactor=1",
):
with self.subTest(command=command):
browser.send_text(command)
browser.send_text("CMD:10")
# The status sentinel also bounds a rejected setting without waiting for it.
self.assertEqual(self.receive_text(device), command)
self.assertEqual(self.receive_text(device), "CMD:10")
def test_23_command_validation_covers_dashboard_settings_and_keeps_guards(self):
from app.main import COMMAND_MAX_LEN, _valid_command
html = (ROOT / "static" / "index.html").read_text(encoding="utf-8")
parameters = set(re.findall(r"sendParam\('([^']+)'\s*,\s*'[^']+'\)", html))
self.assertTrue(parameters, "dashboard parameter controls were not found")
for name in parameters | {"errMin", "coarseStep", "fineStep", "angleThreshold"}:
with self.subTest(parameter=name):
self.assertTrue(_valid_command(f"SET_PARAM:{name}=1"))
for value in ("1", "3", "8"):
self.assertTrue(_valid_command("SET_PARAM:axisXFactor=" + value))
for value in ("0", "9", "-1", "1.5", "nan", "inf"):
self.assertFalse(_valid_command("SET_PARAM:axisXFactor=" + value))
for value in ("1", "4", "64"):
self.assertTrue(_valid_command("SET_PARAM:cicleMax=" + value))
for value in ("0", "-1", "1.5", "65", "999999999999"):
self.assertFalse(_valid_command("SET_PARAM:cicleMax=" + value))
html_controls = ("axisXFactor", "axisYFactor", "axisRequested", "device_id", "x_budget")
for text in html_controls:
self.assertIn(text, html)
prefix = "SET_PARAM:minErrMax="
at_limit = prefix + "0" * (COMMAND_MAX_LEN - len(prefix))
self.assertTrue(_valid_command(at_limit))
for command in (
at_limit + "0", "SET_PARAM:unknown=1", "SET_PARAM:minerrmax=193",
"SET_PARAM:minErrMax=", "SET_PARAM:minErrMax=abc",
"SET_PARAM:lpfBeta=nan", "SET_PARAM:lpfBeta=inf",
"SET_PARAM:lpfBeta=1.2.3", "SET_PARAM:lpfBeta=0,70",
"SET_PARAM:lpfBeta=0.70\nCMD:4", "SET_PARAM:lpfBeta=0.70\x00",
"SET_PARAM:lpfBeta=1e309",
"OTA:https://evil.invalid/payload.bin",
):
with self.subTest(command=command):
self.assertFalse(_valid_command(command))
def test_24_axis_confirmation_is_device_scoped_and_reports_effective_budgets(self):
from app.hub import HubManager
async def check():
sent_by_device = {}
managers = {}
for device in (A, B):
manager = HubManager(device_id=device)
managers[device] = manager
sent = sent_by_device[device] = []
async def capture(message, sent=sent):
sent.append(message)
manager._broadcast = capture
await manager._process_line("LOG:[2026-11-04 12:00:00] AXISCFG:3:1:4")
ack = json.loads(sent[0][len("AXISCFG:"):])
self.assertEqual(ack, {"x": 3, "y": 1, "base": 4, "device_id": device,
"x_budget": 12, "y_budget": 4})
for invalid in (
"junk AXISCFG:3:1:4", "AXISCFG:3:1:4 junk",
"LOG:[bad timestamp] AXISCFG:3:1:4", "LOG:[2026-11-04 12:00:00] prefix AXISCFG:3:1:4",
"AXISCFG:8:1:9", "AXISCFG:3:1:2147483647", "AXISCFG:3:-1:4",
):
before = len(sent_by_device[A])
await managers[A]._process_line(invalid)
self.assertFalse(any(m.startswith("AXISCFG:") for m in sent_by_device[A][before:]))
asyncio.run(check())
if __name__ == "__main__":
unittest.main(verbosity=2)
+102
View File
@@ -0,0 +1,102 @@
# Throwaway smoke test: validate the conversion math + log parsers in server.py
# without needing FastAPI installed (FastAPI is stubbed before the import).
import sys, types
def _install_stub():
fa = types.ModuleType("fastapi")
class FastAPI:
def __init__(self, *a, **k): pass
def add_middleware(self, *a, **k): pass
def get(self, *a, **k): return (lambda f: f)
def post(self, *a, **k): return (lambda f: f)
def websocket(self, *a, **k): return (lambda f: f)
def mount(self, *a, **k): pass
class WebSocket: pass
class WebSocketDisconnect(Exception): pass
class HTTPException(Exception):
def __init__(self, status_code=None, detail=None):
self.status_code, self.detail = status_code, detail
super().__init__(detail)
fa.FastAPI, fa.WebSocket, fa.WebSocketDisconnect, fa.HTTPException = \
FastAPI, WebSocket, WebSocketDisconnect, HTTPException
resp = types.ModuleType("fastapi.responses")
class HTMLResponse: pass
class FileResponse: pass
resp.HTMLResponse, resp.FileResponse = HTMLResponse, FileResponse
mid = types.ModuleType("fastapi.middleware")
cors = types.ModuleType("fastapi.middleware.cors")
class CORSMiddleware: pass
cors.CORSMiddleware, mid.cors = CORSMiddleware, cors
sf = types.ModuleType("fastapi.staticfiles")
class StaticFiles:
def __init__(self, *a, **k): pass
sf.StaticFiles = StaticFiles
sys.modules.update({"fastapi": fa, "fastapi.responses": resp,
"fastapi.middleware": mid, "fastapi.middleware.cors": cors,
"fastapi.staticfiles": sf})
_install_stub()
import server # noqa: E401 (imported after stubbing fastapi)
# A real periodic sample captured on 21/09/2025 (21092025.txt, line 4)
line = "[2025-09-21 10:41:00] X:53037,Y:0,C:219838,V:4699627,P:7601002,S:19,er:1500"
# --- regex must match BOTH the bare and the legacy 'LOG-to prefix ---
m = server.RE_SAMPLE.match(line)
assert m, "RE_SAMPLE failed to match a standard sample line"
m2 = server.RE_SAMPLE.match("LOG:" + line)
assert m2, "RE_SAMPLE failed to match a LOG:-prefixed sample line"
pm = server.RE_PARAM.match("LOG:[2025-09-21 10:42:09] Az_deviation=5.00")
assert pm and abs(float(pm.group("val")) - 5.0) < 1e-9, "RE_PARAM failed"
h = server.hub
h.azdev = 5.0
# --- independent reference using the article / parse_log.py formulas ---
ADC_MAX = 8388607.0; VREF_MV = 2048.0; VREF_V = 2.048
GAIN_SHUNT = 20.0; R_SHUNT = 0.075; FF = 0.82; VOC_DIV = 2.0
p, v, c, x, y = (int(m.group(k)) for k in ("p", "v", "c", "x", "y"))
azdev = 5.0
voc_raw = v / ADC_MAX * VREF_V * VOC_DIV
isc_raw = (c / ADC_MAX * (VREF_MV / GAIN_SHUNT)) / 1000.0 / R_SHUNT
gpoa_20 = round(p / ADC_MAX * (VREF_MV / 2.0), 1)
voc_exp = round(voc_raw, 3)
isc_exp = round(isc_raw * 1000, 2)
pcpv_exp = round(voc_raw * isc_raw * FF * 1000, 2)
x_base = x - azdev / 90.0 * y
el_exp = round(90.0 - (x_base - 53000) / 53000 * 80.0, 2)
az_exp = round(180.0 + (y - 53000) / 53000 * 80.0, 2)
h.pyrano = 2.0
pt20 = h.on_sample(m.groupdict())
for got, exp, tag in [(pt20["gpoa"], gpoa_20, "G_POA"),
(pt20["voc"], voc_exp, "Voc"),
(pt20["isc_ma"], isc_exp, "Isc"),
(pt20["pcpv_mw"], pcpv_exp, "P_CPV"),
(pt20["az"], az_exp, "az"),
(pt20["el"], el_exp, "el")]:
assert got == exp or abs(got - exp) < 1e-9, (tag, got, exp)
print("OLD 2.0 mV/Wm2 : G_POA=%6.1f W/m2 Voc=%5.3f V Isc=%7.2f mA P_CPV=%6.2f mW az=%6.2f el=%6.2f"
% (pt20["gpoa"], pt20["voc"], pt20["isc_ma"], pt20["pcpv_mw"], pt20["az"], pt20["el"]))
h.pyrano = 0.2
pt02 = h.on_sample(m.groupdict())
print("NEW 0.2 mV/Wm2 : G_POA=%6.1f W/m2 (ratio vs 2.0 = %.3fx)"
% (pt02["gpoa"], pt02["gpoa"] / pt20["gpoa"]))
assert abs(pt02["gpoa"] / pt20["gpoa"] - 10.0) < 0.05, "0.2 must be 10x the 2.0 irradiance"
# az_dev change must re-derive elevation when the Y axis is non-zero.
# (The 10:41 sample has Y:0, so it cannot exercise the az_dev coupling;
# use a SunPos-style sample with a non-zero Y instead.)
d = {"ts": "2025-09-21 10:43:00", "x": "62299", "y": "9444",
"c": "200000", "v": "4600000", "p": "7600000", "er": "1500"}
h.azdev = 5.0
h.on_sample(d)
el_at_5 = h.samples[-1]["el"]
h.azdev = 0.0
h._recompute_position()
el_at_0 = h.samples[-1]["el"]
assert el_at_5 != el_at_0, "az_dev change should alter the elevation estimate when y!=0"
print("az_dev recompute: el(azdev=5)=%.2f el(azdev=0)=%.2f (differ as expected)" % (el_at_5, el_at_0))
print("OK: unit conversion, geometry and log parsers are all consistent.")
+184
View File
@@ -0,0 +1,184 @@
# Reproducible regression test for the "TEL:" telemetry parser in app/hub.py.
#
# History: the firmware 1.1.1 build (F6) added two diagnostic fields between
# LPF and X in the telemetry line:
# ... LPF=<lpf>, prox=<0..1>, CmaxN=<counts>, X=<x>, Y=<y>
# The original RE_TEL expected X immediately after LPF, so the new line stopped
# parsing (the "TEL ya no se parsea" regression). This test locks the contract:
#
# * parses the NEW line (with prox/CmaxN) -- incl. the real 2026-10-30 line
# * still parses the OLD line (without prox/CmaxN) -> backward compatible
# * handles signed (negative) X/Y coordinates
# * captures prox/cmaxn into on_tel() points (None on old-format lines)
# * negates X/Y in on_tel() so telemetry overlays the sample trajectory
# * does NOT cross-match (sample / FineTune / SunPos lines -> no TEL)
# * feeds the tel_samples buffer and the TEL_HISTORY replay on connect
# * dispatches handle_esp32_message -> "TEL:" (new fw) / "DATA:" (old fw)
#
# It mirrors _smoke_test.py: fastapi is stubbed before the import so this runs
# on a machine where fastapi is NOT installed.
import sys, types, json, asyncio, tempfile, shutil
def _install_stub():
try:
import fastapi # noqa: F401 (real one present -> nothing to do)
return
except Exception:
pass
fa = types.ModuleType("fastapi")
class WebSocket: pass
fa.WebSocket = WebSocket
sys.modules["fastapi"] = fa
_install_stub()
import app.hub as hubmod # noqa: E402 (imported after stubbing)
from app.hub import HubManager, RE_TEL, RE_SAMPLE # noqa: E402
# Redirect log writes to a temp dir so the test never touches data_logs/.
_tmp = tempfile.mkdtemp(prefix="teltest_")
hubmod.LOGS_DIR = _tmp
hub = HubManager()
class FakeClient:
def __init__(self):
self.sent = []
async def accept(self): pass
async def send_text(self, msg):
self.sent.append(msg)
# --- Fixtures -----------------------------------------------------------------
# Real line captured from the field device (2026-10-30 10:34:03), new firmware.
TEL_REAL = ("LOG:[2026-10-30 10:34:03] TEL: t=246s, steps=29825(+1000), "
"avgC=5469365, avgV=5160269, avgP=679686, LPF=4338597, "
"prox=0.00, CmaxN=53643680, X=-9827, Y=-65788")
# Same layout, signed coordinates and a non-trivial proximity value.
TEL_NEG = ("LOG:[2026-10-02 09:34:05] TEL: t=125s, steps=5100(-20), "
"avgC=-100, avgV=18000, avgP=1400, LPF=-50, "
"prox=0.85, CmaxN=123456, X=-54810, Y=-48890")
# OLD firmware (pre-F6): no prox/CmaxN. Must keep parsing.
TEL_OLD = ("LOG:[2026-10-02 09:34:03] TEL: t=123s, steps=5000(+100), "
"avgC=9900, avgV=18380, avgP=1490, LPF=9900, X=54810, Y=48890")
# Legacy periodic sample (RE_SAMPLE) and a plain event line.
OLD_SMP = "LOG:[2026-10-02 09:34:07] X:54810,Y:48890,C:9898,V:18263,P:1450,S:17,er:1500"
FINE = "LOG:[2026-10-02 09:34:09] FineTune: inicio"
def test_parsers():
# NEW format (real line) -- every field present.
m = RE_TEL.match(TEL_REAL)
assert m, "RE_TEL failed to match the real new-format TEL line"
g = m.groupdict()
assert g["ts"] == "2026-10-30 10:34:03"
assert g["t"] == "246" and g["steps"] == "29825" and g["stepsDelta"] == "+1000"
assert g["avgC"] == "5469365" and g["avgV"] == "5160269" and g["avgP"] == "679686"
assert g["lpf"] == "4338597"
assert g["prox"] == "0.00" and g["cmaxN"] == "53643680"
assert g["x"] == "-9827" and g["y"] == "-65788"
# Signed coords + non-trivial prox.
mn = RE_TEL.match(TEL_NEG)
assert mn, "RE_TEL failed to match a TEL line with signed X/Y"
assert mn.group("x") == "-54810" and mn.group("y") == "-48890"
assert mn.group("avgC") == "-100" and mn.group("stepsDelta") == "-20"
assert mn.group("prox") == "0.85" and mn.group("cmaxN") == "123456"
# OLD format (backward compatibility): prox/cmaxN optional -> None.
mo = RE_TEL.match(TEL_OLD)
assert mo, "RE_TEL failed to match the old (pre-F6) TEL line"
assert mo.group("x") == "54810" and mo.group("y") == "48890"
assert mo.group("prox") is None and mo.group("cmaxN") is None
# Bare line without the leading LOG: prefix must also match.
assert RE_TEL.match(TEL_OLD[4:]), "RE_TEL must match a bare (no LOG:) line"
# No cross-matching with sample / event lines.
assert RE_TEL.match(OLD_SMP) is None, "legacy sample line must NOT match RE_TEL"
assert RE_TEL.match(FINE) is None, "event line must NOT match RE_TEL"
assert RE_SAMPLE.match(OLD_SMP), "legacy sample line must still match RE_SAMPLE"
def test_on_tel():
hub.tel_samples.clear()
# New format: negation of X/Y + prox/cmaxn captured.
pt = hub.on_tel(RE_TEL.match(TEL_REAL).groupdict())
assert pt["x"] == 9827 and pt["y"] == 65788, "on_tel must negate X/Y"
assert pt["t"] == 246 and pt["steps"] == 29825 and pt["steps_delta"] == 1000
assert pt["lpf"] == 4338597
assert pt["prox"] == 0.0 and pt["cmaxn"] == 53643680
for k in ("gpoa", "voc", "isc_ma", "pcpv_mw", "az", "el"):
assert k in pt, "on_tel point missing field %s" % k
# Old format: prox/cmaxn absent -> None, no crash.
pt_old = hub.on_tel(RE_TEL.match(TEL_OLD).groupdict())
assert pt_old["x"] == -54810 and pt_old["y"] == -48890
assert pt_old["prox"] is None and pt_old["cmaxn"] is None
assert len(hub.tel_samples) == 2
def test_buffer_and_history():
hub.tel_samples.clear()
for line in (TEL_REAL, TEL_NEG, TEL_OLD):
hub.on_tel(RE_TEL.match(line).groupdict())
assert len(hub.tel_samples) == 3, "buffer must hold all three TEL points"
# TEL_HISTORY replay must deliver the buffered points to a new client.
async def _run():
client = FakeClient()
await hub.connect_client(client)
hist = [x for x in client.sent if x.startswith("TEL_HISTORY:")]
assert len(hist) == 1, "connect_client must send exactly one TEL_HISTORY"
payload = json.loads(hist[0][len("TEL_HISTORY:"):])
assert isinstance(payload, list) and len(payload) == 3
# The real line's negated coords must be in the replayed history.
assert any(p.get("x") == 9827 and p.get("y") == 65788 for p in payload), \
"TEL_HISTORY must include the negated real-line coords"
# Old-format point carried prox=None through the round trip.
assert any(p.get("prox") is None for p in payload), \
"old-format point must keep prox=None in history"
hub.disconnect_client(client)
asyncio.run(_run())
def test_dispatch():
async def _run():
client = FakeClient()
hub.client_sockets.append(client)
await hub.handle_esp32_message(TEL_REAL)
tel_msgs = [x for x in client.sent if x.startswith("TEL:")]
assert len(tel_msgs) == 1, "expected one TEL: broadcast, got %d" % len(tel_msgs)
assert json.loads(tel_msgs[0][4:])["x"] == 9827
assert json.loads(tel_msgs[0][4:])["cmaxn"] == 53643680
client.sent.clear()
await hub.handle_esp32_message(OLD_SMP)
assert any(x.startswith("DATA:") for x in client.sent), "old line must emit DATA:"
assert not any(x.startswith("TEL:") for x in client.sent), "old line must NOT emit TEL:"
client.sent.clear()
await hub.handle_esp32_message(FINE)
assert not any(x.startswith(("DATA:", "TEL:")) for x in client.sent), "event line must only log"
hub.disconnect_client(client)
asyncio.run(_run())
def test_position_mapping():
previous = hub.azdev
try:
hub.azdev = 18.0
# Firmware mapping: X is azimuth; Y is elevation plus X coupling.
x, y = 39750, 87450
az, el = hub._pos_deg(x, y)
assert abs(az - 160.0) < 1e-9
assert abs(el - 50.0) < 1e-9
sample = RE_SAMPLE.match(OLD_SMP).groupdict()
sample.update(x=str(x), y=str(y))
tel = RE_TEL.match(TEL_REAL).groupdict()
tel.update(x=str(-x), y=str(-y))
for point in (hub.on_sample(sample), hub.on_tel(tel)):
assert point['az'] == 160.0 and point['el'] == 50.0
finally:
hub.azdev = previous
def _run():
test_parsers()
test_on_tel()
test_buffer_and_history()
test_dispatch()
test_position_mapping()
print("OK: RE_TEL (new+old format), on_tel conversion, buffer, TEL_HISTORY and dispatch are all consistent.")
_run()
shutil.rmtree(_tmp, ignore_errors=True)
+95
View File
@@ -0,0 +1,95 @@
import os
import re
from datetime import datetime, timezone, timedelta
from zoneinfo import ZoneInfo
# Aguascalientes está en UTC-6 todo el año (sin horario de verano).
# Usamos un offset fijo para evitar que tzdata desactualizado aplique DST en octubre.
LOCAL_TZ = timezone(timedelta(hours=-6))
def get_local_now() -> datetime:
"""Devuelve la fecha/hora actual fijada en UTC-6."""
return datetime.now(LOCAL_TZ)
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# Directorios configurables por env (permite tests con rutas temporales sin
# tocar datos reales).
LOGS_DIR = os.environ.get("LOGS_DIR") or os.path.join(BASE_DIR, "data_logs")
STATIC_DIR = os.environ.get("STATIC_DIR") or os.path.join(BASE_DIR, "static")
FIRMWARE_DIR = os.environ.get("FIRMWARE_DIR") or os.path.join(BASE_DIR, "firmware")
# Estado multitarjeta (identidades, tokens hasheados, ajustes) en SQLite.
STATE_DIR = os.environ.get("STATE_DIR") or os.path.join(BASE_DIR, "state")
DATABASE_PATH = os.path.join(STATE_DIR, "devices.sqlite3")
PROVISION_DIR = os.path.join(STATE_DIR, "provision")
FIRMWARE_BIN = os.path.join(FIRMWARE_DIR, "firmware.bin")
OTA_MANIFEST_FILE = os.path.join(FIRMWARE_DIR, "manifest.json")
OTA_STATE_FILE = os.path.join(FIRMWARE_DIR, "ota_state.json")
OTA_SLOT_MAX = 0x640000
OTA_PUBLIC_URL = os.environ.get("OTA_PUBLIC_URL", "https://micro.ferrasdh.uk").rstrip("/")
PCB_STATE_FILE = os.path.join(FIRMWARE_DIR, "pcb_state.json")
# Constantes hardware / calibración
ADC_MAX = 8388607.0
VREF_MV = 2048.0
VREF_V = 2.048
GAIN_SHUNT = 20.0
R_SHUNT = 0.075 # legacy / valor por defecto; el hub usa PCB_VERSIONS[pcb_version]
PCB_VERSIONS = {"V1": 0.075, "V2": 0.130} # r_shunt en ohms por versión de PCB
DEFAULT_PCB_VERSION = "V1"
FF = 0.82
VOC_DIVIDER = 2.0
PYRANO_MV_PER_WM2 = 0.2
AZ_DEV_DEFAULT = 5.0
STEPS_80 = 53000.0
DEG_SPAN = 80.0
MAX_SAMPLES = max(1, int(os.environ.get("MAX_SAMPLES", "15000")))
HISTORY_LIMIT = MAX_SAMPLES
# ----------------------- Límites y seguridad -----------------------
# Máximo de tarjetas registradas (buffers solo se reservan para IDs conocidos).
MAX_DEVICES = int(os.environ.get("MAX_DEVICES", "16"))
# Canal de ingesta ESP32: límite por mensaje (bytes) y tasa por tarjeta.
WS_MESSAGE_MAX = int(os.environ.get("WS_MESSAGE_MAX", str(16 * 1024)))
WS_RATE_PER_SEC = float(os.environ.get("WS_RATE_PER_SEC", "100"))
WS_RATE_BURST = int(os.environ.get("WS_RATE_BURST", "200"))
# Timeout de envío a un suscriptor de navegador lento (solo corta a ese cliente).
BROADCAST_TIMEOUT = float(os.environ.get("BROADCAST_TIMEOUT", "5"))
# Retención de logs diarios por tarjeta (días). No toca archivos legacy en la
# raíz de LOGS_DIR.
LOG_RETENTION_DAYS = int(os.environ.get("LOG_RETENTION_DAYS", "30"))
LOG_DAILY_MAX_BYTES = max(1, int(os.environ.get("LOG_DAILY_MAX_BYTES", str(50 * 1024 * 1024))))
# Sondeo periódico de credenciales (la revocación desde CLI, proceso separado,
# cierra la sesión activa en este intervalo).
CREDENTIAL_POLL_SECONDS = float(os.environ.get("CREDENTIAL_POLL_SECONDS", "5"))
# Caducidad del token anterior tras rotar (horas).
TOKEN_GRACE_HOURS = int(os.environ.get("TOKEN_GRACE_HOURS", "24"))
# Aliases legacy: solo se habilitan con una tarjeta primaria explícita.
PRIMARY_DEVICE_ID = os.environ.get("PRIMARY_DEVICE_ID", "").strip() or None
# Orígenes permitidos para el WebSocket de navegador (reemplaza CORS "*").
# El canal ESP32 no usa Origin: se autentica por token.
_DEFAULT_ORIGINS = "https://micro.ferrasdh.uk,http://localhost:4000,http://127.0.0.1:4000"
ALLOWED_ORIGINS = {
o.strip().rstrip("/")
for o in os.environ.get("ALLOWED_ORIGINS", _DEFAULT_ORIGINS).split(",")
if o.strip()
}
# Identidad: PCB_ID configurado o MAC eFuse (12 hex minúsculos con prefijo estable).
DEVICE_ID_RE = re.compile(r"^esp32-[0-9a-f]{12}$")
def is_valid_device_id(value) -> bool:
return isinstance(value, str) and bool(DEVICE_ID_RE.fullmatch(value))
+433
View File
@@ -0,0 +1,433 @@
"""Almacenamiento de identidades, tokens (solo hashes SHA-256) y ajustes por
tarjeta. SQLite de biblioteca estándar, sin ORM.
Reglas:
- Nunca se guardan tokens en claro: solo SHA-256 del token aleatorio.
- Máximo un token actual + un token anterior con caducidad explícita.
- Los cambios de ajustes se escriben en transacción antes de confirmarse.
"""
import hashlib
import hmac
import json
import math
import os
import secrets
import sqlite3
import threading
from contextlib import contextmanager
from datetime import datetime, timedelta, timezone
from app.config import (
DATABASE_PATH, MAX_DEVICES, PCB_VERSIONS, DEFAULT_PCB_VERSION,
PYRANO_MV_PER_WM2, AZ_DEV_DEFAULT, TOKEN_GRACE_HOURS, is_valid_device_id,
)
_TS_FMT = "%Y-%m-%d %H:%M:%S"
class StoreError(Exception):
"""Error de estado/validación del almacén (sin detalle de rutas)."""
def _now() -> datetime:
return datetime.now(timezone.utc)
def _iso(dt: datetime) -> str:
return dt.strftime(_TS_FMT)
def _parse_ts(s: str) -> datetime:
return datetime.strptime(s, _TS_FMT).replace(tzinfo=timezone.utc)
def _hash_token(token: str) -> str:
return hashlib.sha256(token.encode("utf-8")).hexdigest()
def _gen_token() -> str:
"""Token aleatorio criptográfico de 256 bits (64 hex)."""
return secrets.token_hex(32)
class DeviceStore:
def __init__(self, path: str = DATABASE_PATH):
self.path = path
self._lock = threading.RLock()
self.last_write_error = None
parent = os.path.dirname(os.path.abspath(path))
if parent:
os.makedirs(parent, mode=0o700, exist_ok=True)
self._conn = sqlite3.connect(path, check_same_thread=False, timeout=10)
self._conn.row_factory = sqlite3.Row
self._conn.execute("PRAGMA journal_mode=WAL")
self._conn.execute("PRAGMA foreign_keys=ON")
self._init_schema()
@contextmanager
def _transaction(self):
with self._lock:
try:
self._conn.execute("BEGIN IMMEDIATE")
yield
self._conn.commit()
self.last_write_error = None
except BaseException as error:
self.last_write_error = type(error).__name__
self._conn.rollback()
raise
def _init_schema(self):
with self._lock:
self._conn.executescript(
"""
CREATE TABLE IF NOT EXISTS devices (
device_id TEXT PRIMARY KEY,
alias TEXT NOT NULL DEFAULT '',
enabled INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS tokens (
device_id TEXT PRIMARY KEY REFERENCES devices(device_id),
current_hash TEXT,
current_version INTEGER NOT NULL DEFAULT 1,
previous_hash TEXT,
previous_expires_at TEXT,
rotated_at TEXT
);
CREATE TABLE IF NOT EXISTS device_settings (
device_id TEXT PRIMARY KEY REFERENCES devices(device_id),
pcb_version TEXT NOT NULL,
pyrano REAL NOT NULL,
az_dev REAL NOT NULL,
ota_auto INTEGER NOT NULL DEFAULT 0,
config_revision INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS migration_state (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TEXT NOT NULL
);
"""
)
self._conn.commit()
def close(self):
with self._lock:
try:
self._conn.close()
except Exception:
pass
# ----------------------------- devices -----------------------------
def list_devices(self):
with self._lock:
rows = self._conn.execute(
"""SELECT d.device_id, d.alias, d.enabled, d.created_at,
t.current_version, t.previous_expires_at
FROM devices d
LEFT JOIN tokens t ON t.device_id = d.device_id
ORDER BY d.created_at, d.device_id"""
).fetchall()
out = []
for r in rows:
out.append({
"device_id": r["device_id"],
"alias": r["alias"],
"enabled": bool(r["enabled"]),
"created_at": r["created_at"],
"token_version": r["current_version"],
"previous_expires_at": r["previous_expires_at"],
})
return out
def get_device(self, device_id: str):
with self._lock:
row = self._conn.execute(
"SELECT device_id, alias, enabled, created_at FROM devices WHERE device_id = ?",
(device_id,),
).fetchone()
if row is None:
return None
return {"device_id": row["device_id"], "alias": row["alias"], "enabled": bool(row["enabled"])}
def add_device(self, device_id: str, alias: str = "") -> str:
"""Alta de tarjeta. Devuelve el token EXACTAMENTE UNA VEZ (el llamador
lo entrega por el canal local seguro). No en claro en la BD."""
if not is_valid_device_id(device_id):
raise StoreError("device_id inválido")
alias = (alias or "").strip()[:64]
token = _gen_token()
with self._transaction():
exists = self._conn.execute(
"SELECT 1 FROM devices WHERE device_id = ?", (device_id,)
).fetchone()
if exists:
raise StoreError("device_id ya registrado")
count = self._conn.execute("SELECT COUNT(*) FROM devices").fetchone()[0]
if count >= MAX_DEVICES:
raise StoreError("máximo de tarjetas alcanzado")
now = _iso(_now())
self._conn.execute(
"INSERT INTO devices (device_id, alias, enabled, created_at, updated_at) "
"VALUES (?, ?, 1, ?, ?)", (device_id, alias, now, now)
)
self._conn.execute(
"INSERT INTO tokens (device_id, current_hash, current_version) VALUES (?, ?, 1)",
(device_id, _hash_token(token)),
)
self._conn.execute(
"INSERT INTO device_settings (device_id, pcb_version, pyrano, az_dev, ota_auto, config_revision, updated_at) "
"VALUES (?, ?, ?, ?, 0, 0, ?)",
(device_id, DEFAULT_PCB_VERSION, PYRANO_MV_PER_WM2, AZ_DEV_DEFAULT, now),
)
return token
def set_alias(self, device_id: str, alias: str):
alias = (alias or "").strip()[:64]
with self._transaction():
cur = self._conn.execute(
"UPDATE devices SET alias = ?, updated_at = ? WHERE device_id = ?",
(alias, _iso(_now()), device_id),
)
if cur.rowcount == 0:
raise StoreError("device_id no registrado")
def set_enabled(self, device_id: str, enabled: bool):
with self._transaction():
cur = self._conn.execute(
"UPDATE devices SET enabled = ?, updated_at = ? WHERE device_id = ?",
(1 if enabled else 0, _iso(_now()), device_id),
)
if cur.rowcount == 0:
raise StoreError("device_id no registrado")
# ----------------------------- tokens ------------------------------
def _get_token_row(self, device_id: str):
return self._conn.execute(
"SELECT * FROM tokens WHERE device_id = ?", (device_id,)
).fetchone()
def verify_token(self, device_id: str, token: str) -> bool:
"""Verificación constante en tiempo. Token válido solo para su ID."""
if not is_valid_device_id(device_id) or not token:
return False
return self.credential_active(device_id, _hash_token(token))
def credential_active(self, device_id: str, digest: str) -> bool:
"""Validate the credential used by this session, not just any card key."""
dev = self.get_device(device_id)
if dev is None or not dev["enabled"]:
return False
with self._lock:
row = self._get_token_row(device_id)
if row is None:
return False
if row["current_hash"] and hmac.compare_digest(digest, row["current_hash"]):
return True
if row["previous_hash"] and row["previous_expires_at"]:
return (_parse_ts(row["previous_expires_at"]) > _now()
and hmac.compare_digest(digest, row["previous_hash"]))
return False
def rotate_token(self, device_id: str, grace_hours: int = None) -> str:
"""Crea el token reemplazo. El anterior queda como 'previous' con
caducidad explícita. Devuelve el nuevo token UNA sola vez."""
grace = TOKEN_GRACE_HOURS if grace_hours is None else int(grace_hours)
if grace <= 0:
raise StoreError("grace_hours debe ser positivo")
token = _gen_token()
now = _now()
with self._transaction():
row = self._get_token_row(device_id)
if row is None:
raise StoreError("device_id no registrado")
self._conn.execute(
"""UPDATE tokens SET
current_hash = ?, current_version = current_version + 1,
previous_hash = current_hash,
previous_expires_at = ?, rotated_at = ?
WHERE device_id = ?""",
(_hash_token(token), _iso(now + timedelta(hours=grace)), _iso(now), device_id),
)
return token
def get_token_version(self, device_id: str):
with self._lock:
row = self._get_token_row(device_id)
if row is None:
return None
return row["current_version"]
def retire_previous(self, device_id: str):
"""Revoca el token anterior (tras verificar la nueva conexión)."""
with self._transaction():
cur = self._conn.execute(
"UPDATE tokens SET previous_hash = NULL, previous_expires_at = NULL WHERE device_id = ?",
(device_id,),
)
if cur.rowcount == 0:
raise StoreError("device_id no registrado")
def revoke_tokens(self, device_id: str):
"""Revocación urgente: elimina la validez de ambos tokens."""
with self._transaction():
cur = self._conn.execute(
"UPDATE tokens SET current_hash = NULL, previous_hash = NULL, "
"previous_expires_at = NULL WHERE device_id = ?",
(device_id,),
)
if cur.rowcount == 0:
raise StoreError("device_id no registrado")
# ---------------------------- settings -----------------------------
def get_settings(self, device_id: str):
with self._lock:
row = self._conn.execute(
"SELECT pcb_version, pyrano, az_dev, ota_auto, config_revision "
"FROM device_settings WHERE device_id = ?", (device_id,)
).fetchone()
if row is None:
return None
return {
"pcb_version": row["pcb_version"],
"pyrano": row["pyrano"],
"az_dev": row["az_dev"],
"ota_auto": bool(row["ota_auto"]),
"config_revision": row["config_revision"],
}
def save_settings(self, device_id: str, pcb_version: str = None,
pyrano: float = None, az_dev: float = None,
ota_auto: bool = None):
"""Persiste en transacción. Lanza StoreError si el valor es inválido;
el llamador debe conservar el estado previo en ese caso."""
if pcb_version is not None and (not isinstance(pcb_version, str) or pcb_version not in PCB_VERSIONS):
raise StoreError("pcb_version inválida")
if pyrano is not None:
p = float(pyrano)
if not math.isfinite(p) or p <= 0:
raise StoreError("pyrano debe ser finita y positiva")
if az_dev is not None:
a = float(az_dev)
if not math.isfinite(a):
raise StoreError("az_dev debe ser finita")
with self._transaction():
dev = self._conn.execute(
"SELECT 1 FROM devices WHERE device_id = ?", (device_id,)
).fetchone()
if dev is None:
raise StoreError("device_id no registrado")
existing = self._conn.execute(
"SELECT pcb_version, pyrano, az_dev, ota_auto FROM device_settings "
"WHERE device_id = ?", (device_id,)
).fetchone()
now = _iso(_now())
if existing is None:
self._conn.execute(
"INSERT INTO device_settings (device_id, pcb_version, pyrano, az_dev, ota_auto, config_revision, updated_at) "
"VALUES (?, ?, ?, ?, ?, 0, ?)",
(device_id,
pcb_version if pcb_version is not None else DEFAULT_PCB_VERSION,
float(pyrano) if pyrano is not None else PYRANO_MV_PER_WM2,
float(az_dev) if az_dev is not None else AZ_DEV_DEFAULT,
1 if ota_auto else 0, now),
)
else:
self._conn.execute(
"""UPDATE device_settings SET
pcb_version = COALESCE(?, pcb_version),
pyrano = COALESCE(?, pyrano),
az_dev = COALESCE(?, az_dev),
ota_auto = COALESCE(?, ota_auto),
config_revision = config_revision + 1,
updated_at = ?
WHERE device_id = ?""",
(pcb_version,
float(pyrano) if pyrano is not None else None,
float(az_dev) if az_dev is not None else None,
(1 if ota_auto else 0) if ota_auto is not None else None,
now, device_id),
)
return self.get_settings(device_id)
# ---------------------------- migration ----------------------------
def get_migration(self, key: str):
with self._lock:
row = self._conn.execute(
"SELECT value FROM migration_state WHERE key = ?", (key,)
).fetchone()
if row is None:
return None
try:
return json.loads(row["value"])
except Exception:
return None
def set_migration(self, key: str, value):
with self._transaction():
self._conn.execute(
"INSERT INTO migration_state (key, value, updated_at) VALUES (?, ?, ?) "
"ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at",
(key, json.dumps(value, ensure_ascii=False), _iso(_now())),
)
def migrate_legacy(self, primary_id: str, pcb_version: str,
pyrano: float, az_dev: float):
"""Importación idempotente del estado legacy a UNA identidad primaria
explícita. Devuelve (importado: bool, token_nuevo: str|None).
Si la primaria no existe se crea (con su token de provisionamiento)."""
marker_key = "legacy_migrated"
if not is_valid_device_id(primary_id):
raise StoreError("device_id inválido")
if pcb_version not in PCB_VERSIONS:
raise StoreError("pcb_version inválida")
if not math.isfinite(pyrano) or pyrano <= 0 or not math.isfinite(az_dev):
raise StoreError("calibración inválida")
token = None
with self._transaction():
if self.get_migration(marker_key) is not None:
return False, None
exists = self._conn.execute(
"SELECT 1 FROM devices WHERE device_id = ?", (primary_id,)
).fetchone()
now = _iso(_now())
if exists:
self._conn.execute(
"UPDATE device_settings SET pcb_version = ?, pyrano = ?, az_dev = ?, "
"config_revision = config_revision + 1, updated_at = ? WHERE device_id = ?",
(pcb_version, float(pyrano), float(az_dev), now, primary_id),
)
else:
count = self._conn.execute("SELECT COUNT(*) FROM devices").fetchone()[0]
if count >= MAX_DEVICES:
raise StoreError("máximo de tarjetas alcanzado")
token = _gen_token()
self._conn.execute(
"INSERT INTO devices (device_id, alias, enabled, created_at, updated_at) "
"VALUES (?, 'Primario', 1, ?, ?)", (primary_id, now, now)
)
self._conn.execute(
"INSERT INTO tokens (device_id, current_hash, current_version) VALUES (?, ?, 1)",
(primary_id, _hash_token(token)),
)
self._conn.execute(
"INSERT INTO device_settings (device_id, pcb_version, pyrano, az_dev, ota_auto, config_revision, updated_at) "
"VALUES (?, ?, ?, ?, 0, 0, ?)",
(primary_id, pcb_version, float(pyrano), float(az_dev), now),
)
self._conn.execute(
"INSERT INTO migration_state (key, value, updated_at) VALUES (?, ?, ?) "
"ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at",
(marker_key, json.dumps({
"primary_id": primary_id, "pcb_version": pcb_version,
"pyrano": float(pyrano), "az_dev": float(az_dev),
}, ensure_ascii=False), now),
)
return True, token
+249
View File
@@ -0,0 +1,249 @@
"""CLI de provisionamiento local de tarjetas (proceso separado del hub).
Uso:
python -m app.devices add --device-id esp32-001122aabbcc --alias "Cuarto sur"
python -m app.devices list
python -m app.devices rename --device-id ... --alias ...
python -m app.devices enable --device-id ...
python -m app.devices disable --device-id ...
python -m app.devices rotate --device-id ... [--grace-hours N]
python -m app.devices revoke --device-id ...
python -m app.devices retire-previous --device-id ...
python -m app.devices migrate-primary --device-id ...
El token generado se entrega UNA sola vez, solo a un archivo local protegido
(0600) bajo STATE_DIR/provision/. Nunca se imprime en stdout ni se guarda en
claro (la BD guarda solo SHA-256).
"""
import argparse
import json
import os
import shutil
import stat
import sys
import time
from app.config import (
DATABASE_PATH, PROVISION_DIR, FIRMWARE_DIR, PCB_STATE_FILE,
DEFAULT_PCB_VERSION, PYRANO_MV_PER_WM2, AZ_DEV_DEFAULT, is_valid_device_id,
)
from app.device_store import DeviceStore, StoreError
def _store() -> DeviceStore:
return DeviceStore(DATABASE_PATH)
def _require_device_id(args) -> str:
device_id = getattr(args, "device_id", None)
if not device_id or not is_valid_device_id(device_id):
print("ERROR: --device-id inválido (formato esperado: esp32-<12 hex minúsculos>)")
sys.exit(2)
return device_id
def _write_token_file(device_id: str, token: str, label: str):
"""Canal local seguro: archivo nuevo (nunca sobreescribe) con permisos
0600. Solo imprime la ruta, NUNCA el token."""
os.makedirs(PROVISION_DIR, exist_ok=True)
path = os.path.join(PROVISION_DIR, f"{device_id}.{label}.token")
if os.path.exists(path):
print(f"ERROR: ya existe {path}; se usa un canal nuevo por operación (no se sobreescribe).")
sys.exit(1)
try:
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
except FileExistsError:
print(f"ERROR: ya existe {path}.")
sys.exit(1)
with os.fdopen(fd, "w", encoding="utf-8") as f:
f.write(token + "\n")
try:
os.chmod(path, stat.S_IRUSR | stat.S_IWUSR)
except Exception:
pass
print(f"Token guardado (una sola vez) en: {path}")
print("ADVERTENCIA: verifica los permisos del archivo (0600 / ACL en Windows).")
print("El token NO se puede recuperar: cópialo a tu ESP32 ahora y borra este archivo tras installarlo.")
def cmd_add(args):
device_id = _require_device_id(args)
store = _store()
try:
token = store.add_device(device_id, args.alias)
except StoreError as e:
print(f"ERROR: {e}")
sys.exit(1)
_write_token_file(device_id, token, "v1")
print(f"Alta completada: {device_id} (alias: {args.alias!r})")
def cmd_list(args):
store = _store()
rows = store.list_devices()
if not rows:
print("Sin tarjetas registradas.")
return
print(f"{'DEVICE_ID':<22} {'ENABLED':<8} {'TOKEN_V':<8} {'ALIAS'}")
for r in rows:
print(f"{r['device_id']:<22} {'si' if r['enabled'] else 'no':<8} "
f"{r['token_version']:<8} {r['alias']}")
def cmd_rename(args):
device_id = _require_device_id(args)
store = _store()
try:
store.set_alias(device_id, args.alias)
except StoreError as e:
print(f"ERROR: {e}")
sys.exit(1)
print(f"Alias actualizado: {device_id} -> {args.alias!r}")
def cmd_enable(args):
_set_enabled(args, True)
def cmd_disable(args):
_set_enabled(args, False)
def _set_enabled(args, enabled: bool):
device_id = _require_device_id(args)
store = _store()
try:
store.set_enabled(device_id, enabled)
except StoreError as e:
print(f"ERROR: {e}")
sys.exit(1)
print(f"{'Habilitada' if enabled else 'Deshabilitada'}: {device_id} "
"(la sesión activa se cierra en el próximo sondeo del hub)")
def cmd_rotate(args):
device_id = _require_device_id(args)
store = _store()
try:
token = store.rotate_token(device_id, args.grace_hours)
v = store.get_token_version(device_id) or 2
except StoreError as e:
print(f"ERROR: {e}")
sys.exit(1)
_write_token_file(device_id, token, f"v{v}")
print("Rotación completada: el token anterior sigue válido hasta su caducidad "
"(o hasta 'retire-previous' / 'revoke').")
def cmd_revoke(args):
device_id = _require_device_id(args)
store = _store()
try:
store.revoke_tokens(device_id)
except StoreError as e:
print(f"ERROR: {e}")
sys.exit(1)
print(f"Revocación urgente: {device_id}. La sesión activa se cierra en el "
"próximo sondeo del hub y ambos tokens quedan inválidos.")
def cmd_retire_previous(args):
device_id = _require_device_id(args)
store = _store()
try:
store.retire_previous(device_id)
except StoreError as e:
print(f"ERROR: {e}")
sys.exit(1)
print(f"Token anterior revocado: {device_id} (solo el token actual queda válido).")
def cmd_migrate_primary(args):
device_id = _require_device_id(args)
store = _store()
pcb = DEFAULT_PCB_VERSION
if os.path.exists(PCB_STATE_FILE):
try:
with open(PCB_STATE_FILE, "r", encoding="utf-8") as f:
v = json.load(f).get("version")
if v not in ("V1", "V2"):
raise ValueError("PCB legacy inválida")
pcb = v
except Exception:
print("ERROR: PCB legacy inválida; migración cancelada sin cambiar ajustes.")
sys.exit(1)
# Respaldo ANTES de la importación (idempotente por timestamp).
try:
bak = f"{PCB_STATE_FILE}.bak-{time.strftime('%Y%m%d-%H%M%S')}"
shutil.copy2(PCB_STATE_FILE, bak)
print(f"Respaldo legacy: {bak}")
except Exception as e:
print(f"ERROR: no se pudo respaldar el PCB legacy ({type(e).__name__}); migración cancelada.")
sys.exit(1)
# La calibración legacy vive en memoria del proceso antiguo; aquí se
# importan los defaults y el operador aplica explícitamente si difieren.
try:
imported, token = store.migrate_legacy(device_id, pcb, PYRANO_MV_PER_WM2, AZ_DEV_DEFAULT)
except StoreError as e:
print(f"ERROR: {e}")
sys.exit(1)
if not imported:
print("Migración ya realizada (idempotente): no se repite. "
"La primaria ya tiene su estado importado.")
return
print(f"Migración legacy -> {device_id} completada (PCB={pcb}, calibración=defaults).")
if token is not None:
_write_token_file(device_id, token, "v1")
print("La primaria no existía: se creó con su token de provisionamiento.")
else:
print("La primaria ya estaba dada de alta: se aplicó solo su estado.")
def main(argv=None):
p = argparse.ArgumentParser(prog="app.devices", description="Provisionamiento local de tarjetas")
sub = p.add_subparsers(dest="cmd", required=True)
sp = sub.add_parser("add", help="Alta de tarjeta (genera token, se entrega una vez)")
sp.add_argument("--device-id", required=True)
sp.add_argument("--alias", default="")
sp.set_defaults(fn=cmd_add)
sp = sub.add_parser("list", help="Lista tarjetas (sin secretos)")
sp.set_defaults(fn=cmd_list)
sp = sub.add_parser("rename", help="Renombra alias")
sp.add_argument("--device-id", required=True)
sp.add_argument("--alias", required=True)
sp.set_defaults(fn=cmd_rename)
sp = sub.add_parser("enable", help="Habilita tarjeta")
sp.add_argument("--device-id", required=True)
sp.set_defaults(fn=cmd_enable)
sp = sub.add_parser("disable", help="Deshabilita tarjeta")
sp.add_argument("--device-id", required=True)
sp.set_defaults(fn=cmd_disable)
sp = sub.add_parser("rotate", help="Rota token (el anterior caduca en grace-hours)")
sp.add_argument("--device-id", required=True)
sp.add_argument("--grace-hours", type=int, default=None)
sp.set_defaults(fn=cmd_rotate)
sp = sub.add_parser("revoke", help="Revocación urgente de ambos tokens")
sp.add_argument("--device-id", required=True)
sp.set_defaults(fn=cmd_revoke)
sp = sub.add_parser("retire-previous", help="Revoca solo el token anterior")
sp.add_argument("--device-id", required=True)
sp.set_defaults(fn=cmd_retire_previous)
sp = sub.add_parser("migrate-primary", help="Importa estado legacy a una primaria explícita")
sp.add_argument("--device-id", required=True)
sp.set_defaults(fn=cmd_migrate_primary)
args = p.parse_args(argv)
args.fn(args)
if __name__ == "__main__":
main()
+205
View File
@@ -0,0 +1,205 @@
"""Coordinación de varias tarjetas: registro de sesiones por identidad,
autenticación, límites por tarjeta y retención de logs.
El estado por tarjeta (buffers, sockets, calibración) vive en HubManager;
aquí solo se coordina. Nunca se reservan buffers para identidades
desconocidas: solo se instancian tarjetas registradas.
"""
import logging
import hashlib
import os
import time
from typing import Dict, Optional
from app.config import (
LOGS_DIR, MAX_DEVICES, WS_RATE_PER_SEC, WS_RATE_BURST,
is_valid_device_id,
)
from app.hub import HubManager
log = logging.getLogger("tracker_hub.fleet")
class _TokenBucket:
__slots__ = ("rate", "burst", "tokens", "ts")
def __init__(self, rate: float, burst: int):
self.rate = rate
self.burst = float(burst)
self.tokens = float(burst)
self.ts = time.monotonic()
def allow(self) -> bool:
now = time.monotonic()
self.tokens = min(self.burst, self.tokens + (now - self.ts) * self.rate)
self.ts = now
if self.tokens >= 1.0:
self.tokens -= 1.0
return True
return False
class FleetManager:
def __init__(self, store, rate: float = WS_RATE_PER_SEC, burst: int = WS_RATE_BURST):
self.store = store
self._rate = rate
self._burst = burst
self._hubs: Dict[str, HubManager] = {}
self._buckets: Dict[str, _TokenBucket] = {}
self._credentials = {}
@property
def hubs(self) -> Dict[str, HubManager]:
return self._hubs
# ---------------------------- identidad ----------------------------
def is_registered(self, device_id: str) -> bool:
if not is_valid_device_id(device_id):
return False
return self.store.get_device(device_id) is not None
def get(self, device_id: str) -> Optional[HubManager]:
"""Devuelve el hub de una tarjeta REGISTRADA (instanciando bajo
demanda; buffers vacíos hasta recibir telemetría). Nunca crea estado
para IDs desconocidos."""
if not self.is_registered(device_id):
return None
h = self._hubs.get(device_id)
if h is None:
h = HubManager(device_id=device_id, store=self.store)
self._hubs[device_id] = h
else:
h._load_settings()
return h
def bind_session(self, device_id, connection_id, token):
self._credentials[device_id] = (connection_id, hashlib.sha256(token.encode()).hexdigest())
self._hubs[device_id].authorize_session = lambda: self.session_valid(device_id, connection_id)
def forget_session(self, device_id, connection_id):
if self._credentials.get(device_id, (None,))[0] == connection_id:
self._credentials.pop(device_id, None)
def session_valid(self, device_id, connection_id):
record = self._credentials.get(device_id)
if record is None or record[0] != connection_id:
return False
try:
return self.store.credential_active(device_id, record[1])
except Exception:
return False
def get_or_create(self, device_id: str) -> Optional[HubManager]:
return self.get(device_id)
def authenticate(self, device_id: str, token: str) -> bool:
"""Verifica ID + token (solo para ese ID y canal). Rechazo genérico:
no se revela si la identidad existe."""
if not is_valid_device_id(device_id) or not token:
return False
try:
return self.store.verify_token(device_id, token)
except Exception as e:
log.error("auth_check_failed", extra={"event": "auth_check_failed",
"device_id": device_id,
"error_type": type(e).__name__})
return False
# ----------------------------- límites -----------------------------
def check_rate(self, device_id: str) -> bool:
if not self.is_registered(device_id):
return False
b = self._buckets.get(device_id)
if b is None:
b = self._buckets[device_id] = _TokenBucket(self._rate, self._burst)
return b.allow()
# --------------------- revocación (polling) ------------------------
async def poll_credentials(self):
"""Sondea credenciales de las tarjetas conectadas. La revocación o
deshabilitación desde la CLI (proceso separado) cierra la sesión
activa en este intervalo."""
closed = []
for device_id, h in list(self._hubs.items()):
if not h.esp32_connected:
continue
try:
connection_id = h.connection_id
active = self.session_valid(device_id, connection_id)
except Exception as e:
log.error("credential_poll_failed", extra={"event": "credential_poll_failed",
"device_id": device_id,
"error_type": type(e).__name__})
active = False
if not active:
log.warning("credential_revoked_close", extra={"event": "credential_revoked_close",
"device_id": device_id})
try:
await h.force_close(connection_id)
except Exception:
pass
closed.append(device_id)
self.forget_session(device_id, connection_id)
return closed
# --------------------------- retención logs -------------------------
def prune_logs(self, retention_days: int) -> int:
"""Borra logs diarios por tarjeta mayores a retention_days. NUNCA
toca los archivos legacy en la raíz de LOGS_DIR."""
if retention_days <= 0:
return 0
cutoff = time.time() - retention_days * 86400
removed = 0
try:
entries = os.listdir(LOGS_DIR)
except Exception:
return 0
for name in entries:
p = os.path.join(LOGS_DIR, name)
if not os.path.isdir(p) or os.path.islink(p):
continue
if not is_valid_device_id(name):
continue
try:
for fn in os.listdir(p):
fp = os.path.join(p, fn)
if (fn.startswith("log_") and fn.endswith(".txt")
and os.path.isfile(fp) and os.path.getmtime(fp) < cutoff):
try:
os.remove(fp)
removed += 1
except Exception:
pass
except Exception:
pass
return removed
# ------------------------------ listado -----------------------------
def list_devices(self):
out = []
for row in self.store.list_devices():
h = self._hubs.get(row["device_id"])
pcb = h.pcb_version if h is not None else None
if pcb is None:
try:
s = self.store.get_settings(row["device_id"])
pcb = s["pcb_version"] if s else None
except Exception:
pcb = None
out.append({
"device_id": row["device_id"],
"alias": row["alias"],
"enabled": row["enabled"],
"esp32_connected": bool(h.esp32_connected) if h is not None else False,
"last_seen": h.last_seen.isoformat() if (h and h.last_seen) else None,
"last_telemetry_at": h.last_telemetry_at.isoformat() if (h and h.last_telemetry_at) else None,
"firmware": h.esp32_firmware_version if h is not None else None,
"pcb_version": pcb,
"created_at": row["created_at"],
})
return out
+630
View File
@@ -0,0 +1,630 @@
import os
import re
import json
import math
import uuid
import asyncio
import logging
from collections import deque
from datetime import datetime
from typing import List, Optional
from fastapi import WebSocket
from app.config import (
LOGS_DIR, FIRMWARE_DIR, FIRMWARE_BIN, OTA_MANIFEST_FILE, OTA_STATE_FILE,
OTA_PUBLIC_URL, ADC_MAX, VREF_MV, VREF_V, GAIN_SHUNT, FF, VOC_DIVIDER,
PCB_VERSIONS, DEFAULT_PCB_VERSION, PCB_STATE_FILE,
PYRANO_MV_PER_WM2, AZ_DEV_DEFAULT, STEPS_80, DEG_SPAN, MAX_SAMPLES,
HISTORY_LIMIT, LOCAL_TZ, BROADCAST_TIMEOUT, LOG_DAILY_MAX_BYTES, get_local_now
)
log = logging.getLogger("tracker_hub.hub")
RE_SAMPLE = re.compile(
r"^(?:LOG:)?\[(?P<ts>[0-9\-/ :]+)\]\s*X:(?P<x>-?\d+),Y:(?P<y>-?\d+),"
r"C:(?P<c>-?\d+),V:(?P<v>-?\d+),P:(?P<p>-?\d+),S:\d+,er:(?P<er>-?\d+)\s*$"
)
# Telemetría periódica (promedios cada 2 s) emitida por el firmware nuevo:
# LOG:[YYYY-MM-DD HH:MM:SS] TEL: t=<uptime>s, steps=<tot>(+<delta>),
# avgC=<c>, avgV=<v>, avgP=<p>, LPF=<lpf>, X=<x>, Y=<y>
# Desde el firmware 1.1.1 (F6) se agregan dos campos de diagnóstico ENTRE LPF
# y X: prox=<0..1> y CmaxN=<counts>. Son OPCIONALES en el parser para no
# romper el formato previo (builds sin F6 no los emiten). X/Y llegan "crudos"
# (getPosX/getPosY); el resto del hub usa posición invertida, así que on_tel()
# los invierte para que las series se superpongan. Con firmware viejo no hay
# líneas TEL: y el parser simplemente no dispara.
RE_TEL = re.compile(
r"^(?:LOG:)?\[(?P<ts>[0-9\-/ :]+)\]\s*TEL:\s*"
r"t=(?P<t>\d+)s,\s*"
r"steps=(?P<steps>\d+)\((?P<stepsDelta>[+\-]?\d+)\),\s*"
r"avgC=(?P<avgC>-?\d+),\s*"
r"avgV=(?P<avgV>-?\d+),\s*"
r"avgP=(?P<avgP>-?\d+),\s*"
r"LPF=(?P<lpf>-?\d+)"
r"(?:,\s*prox=(?P<prox>-?\d+(?:\.\d+)?))?"
r"(?:,\s*CmaxN=(?P<cmaxN>-?\d+))?"
r",\s*"
r"X=(?P<x>-?\d+),\s*"
r"Y=(?P<y>-?\d+)\s*$"
)
RE_AXIS_ACK = re.compile(r"AXISCFG:(?P<x>\d+):(?P<y>\d+):(?P<base>\d+)")
RE_AXIS_ACK_LOG = re.compile(
r"LOG:\[(?:\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}|sin hora)\] "
r"AXISCFG:(?P<x>\d+):(?P<y>\d+):(?P<base>\d+)"
)
RE_PARAM = re.compile(
r"^(?:LOG:)?\[(?P<ts>[0-9\-/ :]+)\]\s*(?P<name>Az_deviation|ErrMax|"
r"MinErrMax|TiltAngle|delay)\s*=\s*(?P<val>[-\d.]+)\s*$"
)
_TS_FORMATS = ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M", "%d/%m/%Y %H:%M:%S", "%d/%m/%Y %H:%M")
def parse_ts(s: str) -> Optional[datetime]:
s = s.strip()
for fmt in _TS_FORMATS:
try:
return datetime.strptime(s, fmt).replace(tzinfo=LOCAL_TZ)
except Exception:
pass
return None
class HubManager:
"""Estado de UNA tarjeta (parsers, buffers, calibración, sesión ESP32).
Constructible en modo standalone (`HubManager()`): efímero, sin SQLite ni
I/O de estado global. Con `store` se persisten calibración/PCB/OTA-auto.
"""
def __init__(self, device_id: Optional[str] = None, store=None):
self.device_id = device_id
self._store = store
self.esp32_socket: Optional[WebSocket] = None
self.esp32_connected = False
self.client_sockets: List[WebSocket] = []
self.last_pos_x = 0
self.last_pos_y = 0
self.last_seen: Optional[datetime] = None
self.last_telemetry_at: Optional[datetime] = None
self.connected_at: Optional[datetime] = None
self.connection_id: Optional[str] = None
self.pyrano = PYRANO_MV_PER_WM2
self.azdev = AZ_DEV_DEFAULT
self.samples: deque = deque(maxlen=MAX_SAMPLES)
self.tel_samples: deque = deque(maxlen=MAX_SAMPLES)
self.esp32_firmware_version: Optional[str] = None
self.ota_manifest: Optional[dict] = None
self.ota_auto_enabled: bool = False
self.ota_url: str = OTA_PUBLIC_URL + "/ota/firmware"
self.pcb_version: str = DEFAULT_PCB_VERSION
self._send_lock = asyncio.Lock()
self._auto_task: Optional[asyncio.Task] = None
self.config_revision = -1
self.log_error_count = 0
self.log_error = None
self._log_error_day = None
self._version_session_id = None
self.authorize_session = None
if store is not None and device_id is not None:
self._load_settings()
# --------------------------- sesión ESP32 ---------------------------
async def connect_esp32(self, websocket: WebSocket, on_connect=None) -> str:
"""Acepta una sesión autenticada. Si ya había otra del MISMO ID, la
cierra explícitamente; los eventos tardíos de la antigua se ignoran
por connection_id."""
await websocket.accept()
old = self.esp32_socket
conn_id = uuid.uuid4().hex[:12]
self.connection_id = conn_id
self.esp32_socket = websocket
self.esp32_connected = True
self.connected_at = get_local_now()
self.last_seen = self.connected_at
self._version_session_id = None
if on_connect is not None:
on_connect(conn_id)
if self._auto_task is not None:
self._auto_task.cancel()
self._auto_task = None
log.info("esp32_connect", extra={"event": "esp32_connect", "device_id": self.device_id,
"connection_id": conn_id})
if old is not None and old is not websocket:
try:
await old.close(code=1000)
except Exception:
pass
await self.broadcast_status()
await self.broadcast_ota()
return conn_id
def disconnect_esp32(self, conn_id: Optional[str] = None):
"""Cierre de sesión. Un `finally` tardío de una sesión reemplazada NO
desconecta a la nueva: se verifica connection_id."""
if conn_id is not None and conn_id != self.connection_id:
log.info("esp32_late_event_ignored", extra={"event": "esp32_late_event_ignored",
"device_id": self.device_id})
return
if self._auto_task is not None:
self._auto_task.cancel()
self._auto_task = None
was_connected = self.esp32_connected
self.esp32_socket = None
self.esp32_connected = False
self.connection_id = None
if was_connected:
log.info("esp32_disconnect", extra={"event": "esp32_disconnect", "device_id": self.device_id,
"connection_id": conn_id})
async def force_close(self, conn_id=None):
"""Cierre forzado (revocación de credenciales): cierra el socket y
marca desconectado; publica el cambio de estado."""
if conn_id is not None and conn_id != self.connection_id:
return
if self._auto_task is not None:
self._auto_task.cancel()
self._auto_task = None
sock, self.esp32_socket = self.esp32_socket, None
self.esp32_connected = False
self.connection_id = None
if sock is not None:
try:
await sock.close(code=1008)
except Exception:
pass
log.warning("esp32_forced_close", extra={"event": "esp32_forced_close", "device_id": self.device_id})
await self.broadcast_status()
await self.broadcast_ota()
async def handle_esp32_message(self, message: str, conn_id: Optional[str] = None):
if conn_id is not None and (conn_id != self.connection_id or not self.esp32_connected):
log.info("esp32_late_message_ignored", extra={"event": "esp32_late_message_ignored",
"device_id": self.device_id})
return
self.last_seen = get_local_now()
line = message.strip()
self._append_log(line)
if not line:
return
try:
await self._process_line(line)
except (ValueError, TypeError, OverflowError):
log.warning("line_rejected", extra={"event": "line_rejected", "device_id": self.device_id})
async def _process_line(self, line):
if line.startswith("POS:"):
parts = line.split(":")
if len(parts) >= 3:
try:
self.last_pos_x, self.last_pos_y = int(parts[1]), int(parts[2])
except ValueError:
pass
axis_ack = RE_AXIS_ACK.fullmatch(line) or RE_AXIS_ACK_LOG.fullmatch(line)
if axis_ack:
x_factor, y_factor, base = (int(axis_ack.group(k)) for k in ("x", "y", "base"))
if (1 <= x_factor <= 8 and 1 <= y_factor <= 8 and 1 <= base <= 64
and x_factor * base <= 64 and y_factor * base <= 64):
await self._broadcast("AXISCFG:" + json.dumps({"x": x_factor, "y": y_factor, "base": base,
"device_id": self.device_id,
"x_budget": x_factor * base, "y_budget": y_factor * base}))
if line.startswith("VER:"):
v = line[4:].strip()
self._version_session_id = self.connection_id
if v and v != self.esp32_firmware_version:
self.esp32_firmware_version = v
await self._broadcast("FW:" + v)
await self.broadcast_ota()
if v:
self._maybe_auto_update()
m = RE_SAMPLE.match(line)
if m:
pt = self.on_sample(m.groupdict())
self.last_telemetry_at = get_local_now()
if pt:
await self._broadcast("DATA:" + json.dumps(pt))
# Telemetría promediada (firmware nuevo). Con firmware viejo no hay
# líneas TEL: y este bloque no se ejecuta (compatibilidad hacia atrás).
mt = RE_TEL.match(line)
if mt:
tpt = self.on_tel(mt.groupdict())
self.last_telemetry_at = get_local_now()
if tpt:
await self._broadcast("TEL:" + json.dumps(tpt))
pm = RE_PARAM.match(line)
if pm:
name, val = pm.group("name"), float(pm.group("val"))
if name == "Az_deviation" and abs(val - self.azdev) > 1e-6:
# Persistir ANTES de mutar el estado en memoria.
if self._store is not None:
try:
self._store.save_settings(self.device_id, az_dev=val)
except Exception as e:
log.error("azdev_persist_failed", extra={"event": "azdev_persist_failed",
"device_id": self.device_id,
"error_type": type(e).__name__})
else:
self.azdev = val
self._recompute_position()
await self._broadcast("CONFIG:" + self._config_json())
else:
self.azdev = val
self._recompute_position()
await self._broadcast("CONFIG:" + self._config_json())
await self._broadcast("LOG:" + line + "\n")
# ------------------------- conversión de datos ------------------------
def _units(self, x, y, c, v, p):
r_shunt = PCB_VERSIONS[self.pcb_version]
gpoa = p / ADC_MAX * (VREF_MV / self.pyrano)
voc = v / ADC_MAX * VREF_V * VOC_DIVIDER
isc = (c / ADC_MAX * (VREF_MV / GAIN_SHUNT)) / 1000.0 / r_shunt
pcpv = voc * isc * FF
return gpoa, voc, isc, pcpv
def _pos_deg(self, x, y):
x_base = y - self.azdev / 90.0 * x
el = 90.0 - (x_base - STEPS_80) / STEPS_80 * DEG_SPAN
az = 180.0 + (x - STEPS_80) / STEPS_80 * DEG_SPAN
return az, el
def on_sample(self, d):
ts = parse_ts(d["ts"])
t = ts.timestamp() if ts else get_local_now().timestamp()
x, y, c, v, p = int(d["x"]), int(d["y"]), int(d["c"]), int(d["v"]), int(d["p"])
gpoa, voc, isc, pcpv = self._units(x, y, c, v, p)
az, el = self._pos_deg(x, y)
pt = {
"ts": round(t, 3), "x": x, "y": y, "p_raw": p, "c_raw": c, "v_raw": v,
"gpoa": round(gpoa, 1), "voc": round(voc, 3),
"isc_ma": round(isc * 1000, 2), "pcpv_mw": round(pcpv * 1000, 2),
"az": round(az, 2), "el": round(el, 2)
}
if self.device_id:
pt["device_id"] = self.device_id
self.samples.append(pt)
return pt
def on_tel(self, d):
ts = parse_ts(d["ts"])
t_wall = ts.timestamp() if ts else get_local_now().timestamp()
avg_c, avg_v, avg_p = int(d["avgC"]), int(d["avgV"]), int(d["avgP"])
# X/Y crudos del firmware -> se invierten para alinear con el sistema
# de coordenadas del hub (muestras y POS usan valores negados).
x, y = -int(d["x"]), -int(d["y"])
gpoa, voc, isc, pcpv = self._units(x, y, avg_c, avg_v, avg_p)
az, el = self._pos_deg(x, y)
pt = {
"ts": round(t_wall, 3), "t": int(d["t"]),
"steps": int(d["steps"]), "steps_delta": int(d["stepsDelta"]),
"avg_c": avg_c, "avg_v": avg_v, "avg_p": avg_p,
"lpf": int(d["lpf"]), "x": x, "y": y,
# F6 (firmware >= 1.1.1): campos de diagnóstico opcionales.
"prox": float(d["prox"]) if d.get("prox") is not None else None,
"cmaxn": int(d["cmaxN"]) if d.get("cmaxN") is not None else None,
"gpoa": round(gpoa, 1), "voc": round(voc, 3),
"isc_ma": round(isc * 1000, 2), "pcpv_mw": round(pcpv * 1000, 2),
"az": round(az, 2), "el": round(el, 2),
}
if self.device_id:
pt["device_id"] = self.device_id
self.tel_samples.append(pt)
return pt
def _recompute_position(self):
for pt in self.samples:
az, el = self._pos_deg(pt["x"], pt["y"])
pt["az"], pt["el"] = round(az, 2), round(el, 2)
for pt in self.tel_samples:
az, el = self._pos_deg(pt["x"], pt["y"])
pt["az"], pt["el"] = round(az, 2), round(el, 2)
def _config_json(self):
return json.dumps({
"device_id": self.device_id,
"pyrano": self.pyrano, "azdev": self.azdev, "adc_max": ADC_MAX,
"vref_mv": VREF_MV, "vref_v": VREF_V, "gain_shunt": GAIN_SHUNT,
"r_shunt": PCB_VERSIONS[self.pcb_version], "ff": FF, "voc_div": VOC_DIVIDER,
"pcb_version": self.pcb_version,
"steps80": STEPS_80, "deg_span": DEG_SPAN, "pos": [self.last_pos_x, self.last_pos_y]
})
# ---------------------------- suscriptores ----------------------------
async def connect_client(self, websocket: WebSocket):
await websocket.accept()
self.client_sockets.append(websocket)
try:
# CONFIG va como instantánea inicial completa (marcador de
# "listo") ANTES de cualquier historial, para que la UI no vea
# datos con configuración vieja.
await self._send_to(websocket, "STATUS:%s" % ("ONLINE" if self.esp32_connected else "OFFLINE"))
await self._send_to(websocket, "CONFIG:" + self._config_json())
await self._send_to(websocket, "HISTORY:" + json.dumps(list(self.samples)[-HISTORY_LIMIT:]))
await self._send_to(websocket, "TEL_HISTORY:" + json.dumps(list(self.tel_samples)[-HISTORY_LIMIT:]))
await self._send_to(websocket, "POS:%d:%d" % (self.last_pos_x, self.last_pos_y))
if self.esp32_firmware_version:
await self._send_to(websocket, "FW:" + self.esp32_firmware_version)
await self._send_to(websocket, "OTA_STATE:" + json.dumps(self._ota_payload()))
today = f"log_{get_local_now():%Y-%m-%d}.txt"
base = LOGS_DIR if self.device_id is None else os.path.join(LOGS_DIR, self.device_id)
path = os.path.join(base, today)
if os.path.exists(path):
with open(path, "rb") as f:
f.seek(max(0, os.path.getsize(path) - 655360))
tail = "".join(f.read().decode("utf-8", errors="replace").splitlines(keepends=True)[-40:])
if tail:
await self._send_to(websocket, "LOG:" + tail)
except Exception as e:
log.error("connect_client_error", extra={"event": "connect_client_error",
"device_id": self.device_id,
"error_type": type(e).__name__})
def disconnect_client(self, websocket):
if websocket in self.client_sockets:
self.client_sockets.remove(websocket)
async def _send_to(self, client, msg: str):
"""Envío acotado por timeout: un navegador lento solo se corta a él."""
try:
await asyncio.wait_for(client.send_text(msg), timeout=BROADCAST_TIMEOUT)
except Exception:
self.disconnect_client(client)
try:
await asyncio.wait_for(client.close(code=1001), timeout=BROADCAST_TIMEOUT)
except Exception:
pass
async def _broadcast(self, msg):
await asyncio.gather(*(self._send_to(c, msg) for c in list(self.client_sockets)))
async def broadcast_status(self):
await self._broadcast("STATUS:%s" % ("ONLINE" if self.esp32_connected else "OFFLINE"))
async def send_command_to_esp32(self, command: str, expected_connection=None) -> bool:
"""Envía al socket ACTUAL de esta tarjeta; escrituras serializadas.
Sin ACK del firmware solo existe estado 'enviado', no 'ejecutado'."""
if not (self.esp32_socket and self.esp32_connected):
return False
async with self._send_lock:
sock = self.esp32_socket
connection_id = self.connection_id
if (sock is None or not self.esp32_connected
or (expected_connection is not None and expected_connection != connection_id)):
return False
if self._store is not None and (self.authorize_session is None or not self.authorize_session()):
return False
try:
await asyncio.wait_for(sock.send_text(command), timeout=BROADCAST_TIMEOUT)
log.info("command_sent", extra={"event": "command_sent", "device_id": self.device_id,
"connection_id": self.connection_id})
return True
except Exception as e:
log.error("command_failed", extra={"event": "command_failed", "device_id": self.device_id,
"error_type": type(e).__name__})
self.disconnect_esp32(connection_id)
await self.broadcast_status()
await self.broadcast_ota()
return False
# --------------------- calibración / PCB (persiste 1ro) ---------------------
def apply_calibration(self, mV_per_WM2=None, az_dev=None) -> dict:
"""Valida, persiste (si hay store) y luego muta. Si la persistencia
falla, el estado previo se conserva y se propaga el error."""
new_pyrano, new_az = self.pyrano, self.azdev
changed_pyrano = changed_az = False
if mV_per_WM2 is not None:
v = float(mV_per_WM2)
if not math.isfinite(v) or v <= 0:
raise ValueError("mV_per_WM2 debe ser finita y positiva")
new_pyrano, changed_pyrano = v, True
if az_dev is not None:
v = float(az_dev)
if not math.isfinite(v):
raise ValueError("az_dev debe ser finita")
new_az, changed_az = v, True
if not (changed_pyrano or changed_az):
return {"mV_per_WM2": self.pyrano, "az_dev": self.azdev}
if self._store is not None:
self._store.save_settings(self.device_id, pyrano=new_pyrano, az_dev=new_az)
if changed_pyrano:
self.pyrano = new_pyrano
for pt in self.samples:
if pt.get("p_raw") is not None:
pt["gpoa"] = round(pt["p_raw"] / ADC_MAX * (VREF_MV / self.pyrano), 1)
for pt in self.tel_samples:
if pt.get("avg_p") is not None:
pt["gpoa"] = round(pt["avg_p"] / ADC_MAX * (VREF_MV / self.pyrano), 1)
if changed_az:
self.azdev = new_az
self._recompute_position()
log.info("calibration_updated", extra={"event": "calibration_updated", "device_id": self.device_id,
"result": {"pyrano": self.pyrano, "az_dev": self.azdev}})
return {"mV_per_WM2": self.pyrano, "az_dev": self.azdev}
def set_pcb_version(self, version) -> bool:
if not isinstance(version, str) or version not in PCB_VERSIONS:
return False
if self._store is not None:
self._store.save_settings(self.device_id, pcb_version=version)
self.pcb_version = version
r = PCB_VERSIONS[version]
for pt in self.samples:
c = pt.get("c_raw")
if c is None:
continue
isc = (c / ADC_MAX * (VREF_MV / GAIN_SHUNT)) / 1000.0 / r
pt["isc_ma"] = round(isc * 1000, 2)
if pt.get("voc") is not None:
pt["pcpv_mw"] = round(pt["voc"] * isc * FF * 1000, 2)
for pt in self.tel_samples:
c = pt.get("avg_c")
if c is None:
continue
isc = (c / ADC_MAX * (VREF_MV / GAIN_SHUNT)) / 1000.0 / r
pt["isc_ma"] = round(isc * 1000, 2)
if pt.get("voc") is not None:
pt["pcpv_mw"] = round(pt["voc"] * isc * FF * 1000, 2)
log.info("pcb_updated", extra={"event": "pcb_updated", "device_id": self.device_id,
"result": version})
return True
async def rebroadcast_config(self):
await self._broadcast("CONFIG:" + self._config_json())
await self._broadcast("HISTORY:" + json.dumps(list(self.samples)[-HISTORY_LIMIT:]))
await self._broadcast("TEL_HISTORY:" + json.dumps(list(self.tel_samples)[-HISTORY_LIMIT:]))
# ------------------------------- OTA --------------------------------
def _ota_payload(self) -> dict:
return {
"device_id": self.device_id,
"device": self.esp32_firmware_version, "available": self.ota_manifest,
"auto": self.ota_auto_enabled, "url": self.ota_url, "connected": self.esp32_connected,
}
async def broadcast_ota(self):
await self._broadcast("OTA_STATE:" + json.dumps(self._ota_payload()))
async def set_ota_auto(self, enabled: bool):
"""OTA automática POR TARJETA (default False; no se hereda de un
estado global)."""
if self._store is not None:
self._store.save_settings(self.device_id, ota_auto=bool(enabled))
self.ota_auto_enabled = bool(enabled)
if not self.ota_auto_enabled and self._auto_task is not None:
self._auto_task.cancel()
self._auto_task = None
await self.broadcast_ota()
async def push_ota(self, sha256: Optional[str] = None, expected_connection=None) -> bool:
"""Push manual de OTA a ESTA tarjeta. Si se indica sha256, debe
coincidir con el manifiesto actual (previene carreras de archivo)."""
if not (self.esp32_connected and self.esp32_socket and self.ota_manifest and os.path.exists(FIRMWARE_BIN)):
return False
if sha256:
cur = str(self.ota_manifest.get("sha256") or "").lower()
if not cur or not _const_eq(str(sha256).lower(), cur):
return False
manifest = self.ota_manifest.copy()
ok = await self.send_command_to_esp32("OTA:" + manifest["url"], expected_connection)
if ok:
await self._broadcast(
f"LOG:[hub] Empujando firmware '{manifest.get('version', '?')}' a la ESP32 (enviado, no ejecutado)...\n"
)
return ok
def _maybe_auto_update(self):
"""Flujo auto POR tarjeta, ligado a identidad Y sesión; se cancela al
desconectar. La subida de artefacto NUNCA invoca esto."""
try:
if self._auto_task is not None:
self._auto_task.cancel()
self._auto_task = None
if not self.ota_auto_enabled or not self.ota_manifest or not os.path.exists(FIRMWARE_BIN):
return
if not self.esp32_firmware_version or self._version_session_id != self.connection_id:
return
if self.esp32_firmware_version == self.ota_manifest.get("version"):
return
cid = self.connection_id
sha = self.ota_manifest.get("sha256")
version = self.ota_manifest.get("version")
async def _do():
await asyncio.sleep(3)
if (self.ota_auto_enabled and self.esp32_connected and cid is not None
and self.connection_id == cid and self.esp32_firmware_version != version):
await self.push_ota(sha, cid)
self._auto_task = asyncio.create_task(_do())
except Exception as e:
log.error("auto_update_error", extra={"event": "auto_update_error",
"device_id": self.device_id,
"error_type": type(e).__name__})
# --------------------------- persistencia ---------------------------
def _load_settings(self):
s = self._store.get_settings(self.device_id)
if s is None:
raise ValueError("Missing registered device settings")
if s["config_revision"] == self.config_revision:
return
self.pcb_version = s["pcb_version"]
self.pyrano, self.azdev = s["pyrano"], s["az_dev"]
self.ota_auto_enabled = s["ota_auto"]
self.config_revision = s["config_revision"]
for points, keys in ((self.samples, ("c_raw", "v_raw", "p_raw")),
(self.tel_samples, ("avg_c", "avg_v", "avg_p"))):
for point in points:
g, v, i, p = self._units(point["x"], point["y"], *(point[k] for k in keys))
point.update(gpoa=round(g, 1), voc=round(v, 3), isc_ma=round(i * 1000, 2), pcpv_mw=round(p * 1000, 2))
self._recompute_position()
def _append_log(self, line: str):
if not line:
return
fn = f"log_{get_local_now():%Y-%m-%d}.txt"
base = LOGS_DIR if self.device_id is None else os.path.join(LOGS_DIR, self.device_id)
try:
os.makedirs(base, exist_ok=True)
path = os.path.join(base, fn)
content = (line + "\n").encode("utf-8")
if (os.path.getsize(path) if os.path.exists(path) else 0) + len(content) > LOG_DAILY_MAX_BYTES:
raise OSError("daily log quota")
with open(path, "ab") as f:
f.write(content)
self.log_error = None
except Exception as e:
# Modo degradado visible: la ingesta continua, el log lo registra.
self.log_error_count += 1
self.log_error = type(e).__name__
if self._log_error_day == fn:
return
self._log_error_day = fn
log.error("log_write_failed", extra={"event": "log_write_failed",
"device_id": self.device_id,
"error_type": type(e).__name__})
def status_payload(self, alias: Optional[str] = None) -> dict:
return {
"device_id": self.device_id,
"alias": alias,
"esp32_connected": self.esp32_connected,
"connected_at": self.connected_at.isoformat() if self.connected_at else None,
"last_seen": self.last_seen.isoformat() if self.last_seen else None,
"last_telemetry_at": self.last_telemetry_at.isoformat() if self.last_telemetry_at else None,
"connection_id": self.connection_id,
"pos_x": self.last_pos_x, "pos_y": self.last_pos_y,
"pyrano_mV_per_Wm2": self.pyrano, "az_dev_deg": self.azdev,
"pcb_version": self.pcb_version,
"active_clients": len(self.client_sockets),
"samples_in_buffer": len(self.samples),
"tel_samples_in_buffer": len(self.tel_samples),
"firmware_device": self.esp32_firmware_version,
"log_degraded": self.log_error is not None,
"log_error_count": self.log_error_count,
"ota": self._ota_payload(),
}
def _const_eq(a: str, b: str) -> bool:
import hmac
return hmac.compare_digest(a, b)
# Nota: HubManager() sigue siendo constructible standalone (sin argumentos,
# sin SQLite ni I/O de estado global) para pruebas efímeras. No hay
# singleton global: cada proceso/instancia gestiona sus propias tarjetas.
Loaded 100 of 2265 files, more files were not shown because too many files have changed in this diff. Show more