Initial import of Microtracking project
This commit is contained in:
commit
60720c388c
2265 files changed
+1464696
No files matched your search
+35
@@ -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
File diff suppressed because it is too large.
Load diff
+9811
File diff suppressed because it is too large.
Load diff
@@ -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
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
+25618
File diff suppressed because it is too large.
Load diff
+11651
File diff suppressed because it is too large.
Load diff
+17402
File diff suppressed because it is too large.
Load diff
Whitespace-only changes.
Binary file not shown.
BIN
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
@@ -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.
@@ -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]))
|
||||
|
||||
@@ -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)))
|
||||
@@ -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()
|
||||
@@ -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))
|
||||
@@ -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 ##########'
|
||||
@@ -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.
@@ -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}
|
||||
@@ -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.
@@ -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.
@@ -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}
|
||||
Binary file not shown.
@@ -0,0 +1,3 @@
|
||||
\documentclass[final,1p,times]{elsarticle}
|
||||
\usepackage{amssymb}
|
||||
\begin{document}Bisect 0\end{document}
|
||||
Binary file not shown.
@@ -0,0 +1,4 @@
|
||||
\documentclass[final,1p,times]{elsarticle}
|
||||
\usepackage{amssymb}
|
||||
\usepackage{amsmath}
|
||||
\begin{document}Bisect 1\end{document}
|
||||
Binary file not shown.
@@ -0,0 +1,5 @@
|
||||
\documentclass[final,1p,times]{elsarticle}
|
||||
\usepackage{amssymb}
|
||||
\usepackage{amsmath}
|
||||
\usepackage{booktabs}
|
||||
\begin{document}Bisect 2\end{document}
|
||||
Binary file not shown.
@@ -0,0 +1,6 @@
|
||||
\documentclass[final,1p,times]{elsarticle}
|
||||
\usepackage{amssymb}
|
||||
\usepackage{amsmath}
|
||||
\usepackage{booktabs}
|
||||
\usepackage{tikz}
|
||||
\begin{document}Bisect 3\end{document}
|
||||
Binary file not shown.
@@ -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}
|
||||
@@ -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}
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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'"
|
||||
}
|
||||
]
|
||||
@@ -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.
@@ -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.
|
||||
@@ -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.
@@ -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}
|
||||
@@ -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)"
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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}
|
||||
@@ -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}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
Tectonic 0.17.0
|
||||
+317
@@ -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)
|
||||
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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; });
|
||||
@@ -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)
|
||||
@@ -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.")
|
||||
@@ -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)
|
||||
@@ -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))
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
Reference in new issue
Block a user