Skip to content

Instantly share code, notes, and snippets.

@mharizanov
Created July 28, 2026 14:02
Show Gist options
  • Select an option

  • Save mharizanov/5d6f03ecb3f0d4db6a3a793a19008e51 to your computer and use it in GitHub Desktop.

Select an option

Save mharizanov/5d6f03ecb3f0d4db6a3a793a19008e51 to your computer and use it in GitHub Desktop.
Fit a clear-sky 'ideal produce' PV curve from Home Assistant recorder history and emit a template sensor (companion to the harizanov.com clear-sky line post)
#!/usr/bin/env python3
"""Fit a clear-sky ("ideal produce") PV curve to your own Home Assistant history.
The idea: clouds only ever subtract. Bin a few years of hourly PV output by sun
position and the top ~2-4% of every bin is what your array does under a clear sky.
Fit a small physical model to that envelope and you get an "ideal produce" curve
you can overlay on actual production - no panel datasheet or roof measuring needed.
At the end this script prints a ready-to-paste Home Assistant template sensor.
Companion post: https://harizanov.com/ (search "clear-sky line")
Usage
-----
1. Find your PV power sensor in the recorder DB (default HAOS path shown):
sqlite3 -readonly /homeassistant/home-assistant_v2.db \
"SELECT id, statistic_id FROM statistics_meta WHERE statistic_id LIKE '%photovolta%';"
2. Export hourly long-term statistics for it (and optionally an OUTDOOR
temperature sensor - genuinely outdoor, check it reads winter-cold in January):
sqlite3 -readonly -csv /homeassistant/home-assistant_v2.db \
"SELECT start_ts, mean FROM statistics WHERE metadata_id=<PV_ID> ORDER BY start_ts;" > pv_hourly.csv
sqlite3 -readonly -csv /homeassistant/home-assistant_v2.db \
"SELECT start_ts, mean FROM statistics WHERE metadata_id=<TEMP_ID> ORDER BY start_ts;" > temp_hourly.csv
3. Fill in SITE CONFIG below, `pip install numpy scipy`, run the script.
Notes
-----
- The more full years of data the better; 2+ years covers all seasons.
- Export/feed-in limits poison the envelope: if your inverter curtails at a flat
ceiling (mine did at 5.0 kW for two summers), those hours read as "clear sky"
lower than reality. Pool years where the limit was off, or expect the fit to
underestimate midday summer. Check with a histogram of near-noon values.
- Timestamps are unix UTC throughout; sun position is computed in UTC, so DST
never enters the picture.
- Written for a single-orientation array in the northern hemisphere; widen the
panel-azimuth bounds in FIT if yours faces far from south.
"""
import numpy as np
from scipy.optimize import least_squares
# ---------------- SITE CONFIG ----------------
LAT = None # decimal degrees, e.g. 42.75 (keep your exact coords private!)
LON = None # decimal degrees east
ALT_M = None # site altitude in meters, e.g. 720
PV_CSV = 'pv_hourly.csv'
TEMP_CSV = 'temp_hourly.csv' # set to None if you have no outdoor temp history
TEMP_ENTITY = 'sensor.your_outdoor_temperature' # used in the emitted HA template
# ----------------------------------------------
assert None not in (LAT, LON, ALT_M), "fill in LAT / LON / ALT_M first"
PRESS = np.exp(-ALT_M / 8435.0) # broadband pressure correction for altitude
def load_csv(path, ncols):
rows = []
with open(path) as f:
for line in f:
parts = line.strip().split(',')
if len(parts) < ncols:
continue
try:
rows.append([float(p) if p != '' else np.nan for p in parts[:ncols]])
except ValueError:
pass
return np.array(rows)
def solar_position(ts):
"""NOAA solar position; ts = unix seconds (array). Returns apparent elevation
and azimuth (degrees, azimuth 0=N clockwise), incl. refraction correction."""
ts = np.asarray(ts, dtype=float)
jd = ts / 86400.0 + 2440587.5
T = (jd - 2451545.0) / 36525.0
L0 = np.mod(280.46646 + T * (36000.76983 + T * 0.0003032), 360)
M = 357.52911 + T * (35999.05029 - 0.0001537 * T)
Mr = np.radians(M)
ecc = 0.016708634 - T * (0.000042037 + 0.0000001267 * T)
C = (np.sin(Mr) * (1.914602 - T * (0.004817 + 0.000014 * T))
+ np.sin(2 * Mr) * (0.019993 - 0.000101 * T) + np.sin(3 * Mr) * 0.000289)
omega = 125.04 - 1934.136 * T
lam = L0 + C - 0.00569 - 0.00478 * np.sin(np.radians(omega))
eps = (23 + (26 + (21.448 - T * (46.8150 + T * (0.00059 - T * 0.001813))) / 60) / 60
+ 0.00256 * np.cos(np.radians(omega)))
epsr, lamr = np.radians(eps), np.radians(lam)
decl = np.arcsin(np.sin(epsr) * np.sin(lamr))
y = np.tan(epsr / 2) ** 2
L0r = np.radians(L0)
eqtime = 4 * np.degrees(y * np.sin(2 * L0r) - 2 * ecc * np.sin(Mr)
+ 4 * ecc * y * np.sin(Mr) * np.cos(2 * L0r)
- 0.5 * y * y * np.sin(4 * L0r) - 1.25 * ecc * ecc * np.sin(2 * Mr))
tst = np.mod(ts, 86400.0) / 60.0 + eqtime + 4 * LON
ha = np.mod(tst / 4.0 - 180.0 + 180, 360) - 180
har, latr = np.radians(ha), np.radians(LAT)
cos_zen = np.clip(np.sin(latr) * np.sin(decl) + np.cos(latr) * np.cos(decl) * np.cos(har), -1, 1)
el = 90 - np.degrees(np.arccos(cos_zen))
elr = np.radians(np.clip(el, -1, 89))
refr = np.where(el > 85, 0,
np.where(el > 5, 58.1 / np.tan(elr) - 0.07 / np.tan(elr) ** 3 + 0.000086 / np.tan(elr) ** 5,
np.where(el > -0.575,
1735 + el * (-518.2 + el * (103.4 + el * (-12.79 + el * 0.711))),
-20.772 / np.tan(elr)))) / 3600.0
sin_zen = np.sin(np.arccos(cos_zen)) + 1e-12
sin_az = -np.cos(decl) * np.sin(har) / sin_zen
cos_az = (np.sin(decl) - np.sin(latr) * cos_zen) / (np.cos(latr) * sin_zen)
return el + refr, np.mod(np.degrees(np.arctan2(sin_az, cos_az)), 360)
def doy_frac(ts):
dt64 = np.asarray(ts, dtype=float).astype('datetime64[s]')
return (dt64 - dt64.astype('datetime64[Y]')).astype('timedelta64[s]').astype(float) / 86400.0 + 1
# ---------------- load & scrub ----------------
pv = load_csv(PV_CSV, 2)
ts, p = pv[:, 0], pv[:, 1]
ok = ~np.isnan(p)
ts, p = ts[ok], p[ok]
# stuck-sensor flatlines (same value >=3h while producing) and nonzero night rows
stuck = np.zeros(len(p), bool)
for i in range(2, len(p)):
if p[i] > 100 and p[i] == p[i - 1] == p[i - 2]:
stuck[i - 2:i + 1] = True
el0, _ = solar_position(ts + 1800)
drop = stuck | ((el0 < -3) & (p > 50))
print(f"scrubbed {drop.sum()} suspect rows")
ts, p = ts[~drop], p[~drop]
# ambient temperature: hourly history if provided, else seasonal sinusoid fit
if TEMP_CSV:
tmp = load_csv(TEMP_CSV, 2)
tok = ~np.isnan(tmp[:, 1])
tts, tvs = tmp[tok, 0], tmp[tok, 1]
tdoy, thr = doy_frac(tts), np.mod(tts, 86400) / 3600.0
A = np.column_stack([np.ones_like(tdoy),
np.cos(2 * np.pi * tdoy / 365.25), np.sin(2 * np.pi * tdoy / 365.25),
np.cos(2 * np.pi * thr / 24), np.sin(2 * np.pi * thr / 24)])
tc, *_ = np.linalg.lstsq(A, tvs, rcond=None)
temp_map = dict(zip(tmp[:, 0], tmp[:, 1]))
def get_temp(tsx):
d, h = doy_frac(tsx), np.mod(tsx, 86400) / 3600.0
out = (tc[0] + tc[1] * np.cos(2 * np.pi * d / 365.25) + tc[2] * np.sin(2 * np.pi * d / 365.25)
+ tc[3] * np.cos(2 * np.pi * h / 24) + tc[4] * np.sin(2 * np.pi * h / 24))
for i, t in enumerate(tsx):
v = temp_map.get(t - np.mod(t, 3600), np.nan)
if not np.isnan(v):
out[i] = v
return out
else:
def get_temp(tsx):
return np.full(len(tsx), 15.0) # crude; thermal derate folds into kwp
# ---------------- model ----------------
# params: tilt, panel_az, kwp_eff, tau0, tau_cos, tau_sin, am_exp, kdiff, horiz_e, horiz_w, clip_w
def model_power(params, el, az, doy, tamb):
tilt, azp, kwp, tau0, tauc, taus, bexp, kd, hde, hdw, pclip = params
elr = np.radians(np.maximum(el, 0.0))
sinel = np.sin(elr)
up = el > 0.5
E0 = 1361.0 * (1 + 0.033 * np.cos(2 * np.pi * (doy - 3) / 365.25))
am = PRESS / (sinel + 0.50572 * (np.maximum(el, 0) + 6.07995) ** -1.6364)
ang = 2 * np.pi * doy / 365.25
tau = tau0 * (1 + tauc * np.cos(ang) + taus * np.sin(ang)) # seasonal turbidity
dni = E0 * np.exp(-tau * am ** bexp) * up
hor = np.where(az < 180, hde, hdw) # east/west horizon obstruction
dni = dni / (1 + np.exp(-(el - hor)))
dhi = kd * E0 * np.maximum(sinel, 0) ** 0.6 * up
tr = np.radians(tilt)
cosaoi = sinel * np.cos(tr) + np.cos(elr) * np.sin(tr) * np.cos(np.radians(az - azp))
poa = (dni * np.maximum(cosaoi, 0) + dhi * (1 + np.cos(tr)) / 2
+ 0.2 * (dni * sinel + dhi) * (1 - np.cos(tr)) / 2)
pdc = kwp * poa * (1 - 0.0038 * (tamb + 0.032 * poa - 25.0))
return np.minimum(np.maximum(pdc, 0), pclip)
# hourly stats are means, so average the model over 6 sub-samples per hour
SUB = np.arange(300, 3600, 600.0)
tsub = (ts[:, None] + SUB[None, :]).ravel()
el_sub, az_sub = solar_position(tsub)
el_sub, az_sub = el_sub.reshape(len(ts), 6), az_sub.reshape(len(ts), 6)
el_mid, az_mid = el_sub[:, 2:4].mean(1), az_sub[:, 2:4].mean(1)
tamb = get_temp(ts)
doy = doy_frac(ts)
def model_hourly(params):
out = np.zeros(len(ts))
for j in range(6):
out += model_power(params, el_sub[:, j], az_sub[:, j], doy, tamb)
return out / 6.0
# ---------------- envelope selection ----------------
day = el_mid > 1
binid = np.clip((el_mid / 2).astype(int), 0, 40) * 100 + np.clip(((az_mid - 40) / 5).astype(int), 0, 60)
env_idx = []
for b in np.unique(binid[day]):
idx = np.where(day & (binid == b))[0]
if len(idx) < 8:
continue
lo, hi = np.percentile(p[idx], [95.5, 99.8]) # top slice, extreme outliers off
env_idx.extend(idx[(p[idx] >= lo) & (p[idx] <= hi)])
env_idx = np.array(sorted(env_idx))
print(f"envelope points: {len(env_idx)} of {day.sum()} day hours")
# ---------------- FIT ----------------
pmax = np.nanmax(p)
x0 = np.array([25.0, 180.0, pmax / 1100, 0.28, -0.1, 0.0, 0.65, 0.10, 2.0, 2.0, pmax])
lb = np.array([5.0, 120.0, 0.3 * x0[2], 0.08, -0.6, -0.6, 0.3, 0.02, -2.0, -2.0, 0.85 * pmax])
ub = np.array([60.0, 240.0, 2.0 * x0[2], 0.80, 0.6, 0.6, 2.5, 0.30, 12.0, 12.0, 1.25 * pmax])
def resid(params):
r = model_hourly(params)[env_idx] - p[env_idx]
return r * np.where(r < 0, 2.0, 1.0) / 100.0 # asymmetric: model should cap the envelope
fit = least_squares(resid, x0, bounds=(lb, ub))
pars = fit.x
names = ['tilt', 'panel_az', 'kwp_eff', 'tau0', 'tau_cos', 'tau_sin', 'am_exp', 'kdiff', 'horiz_e', 'horiz_w', 'clip_w']
print("\nfitted parameters:")
for n, v in zip(names, pars):
print(f" {n:9s} = {v:.4f}")
# ---------------- validation ----------------
m = model_hourly(pars)
r = p[env_idx] - m[env_idx]
print(f"\nenvelope fit: rmse={np.sqrt(np.mean(r ** 2)):.0f} W bias={np.mean(r):.0f} W")
exc = day & (p > m)
print(f"hours actual>model: {100 * exc.sum() / day.sum():.1f}% (expect ~3-4% for an envelope)")
ldays = ((ts + 7200) // 86400).astype(int)
ratios = []
for d in np.unique(ldays):
msk = (ldays == d) & day
if msk.sum() >= 4 and m[msk].sum() > 1000:
ratios.append(p[msk].sum() / m[msk].sum())
ratios = np.array(ratios)
print(f"daily actual/ideal ratio: p50={np.percentile(ratios, 50):.2f} p98={np.percentile(ratios, 98):.2f} "
f"(p98 should sit just under 1.0; clear days ~0.95+)")
# ---------------- emit HA template sensor ----------------
tilt, azp, kwp, tau0, tauc, taus, bexp, kd, hde, hdw, pclip = pars
print(f"""
# ---- paste into a package / configuration.yaml ----
template:
- sensor:
- name: "Solar ideal produce"
unique_id: solar_ideal_produce_clearsky
unit_of_measurement: "W"
device_class: power
state_class: measurement
state: >-
{{% set el = state_attr('sun.sun','elevation') | float(-90) %}}
{{% if el <= 0.5 %}}
0
{{% else %}}
{{% set az = state_attr('sun.sun','azimuth') | float(180) %}}
{{% set doy = now().timetuple().tm_yday %}}
{{% set tamb = states('{TEMP_ENTITY}') | float(15) %}}
{{% set w = 2 * pi * doy / 365.25 %}}
{{% set rad = pi / 180 %}}
{{% set elr = el * rad %}}
{{% set sinel = sin(elr) %}}
{{% set e0 = 1361 * (1 + 0.033 * cos(2 * pi * (doy - 3) / 365.25)) %}}
{{% set am = {PRESS:.4f} / (sinel + 0.50572 * (el + 6.07995) ** (-1.6364)) %}}
{{% set tau = {tau0:.4f} * (1 + {tauc:.4f} * cos(w) + {taus:.4f} * sin(w)) %}}
{{% set dni = e0 * e ** (-tau * am ** {bexp:.4f}) %}}
{{% set hor = {hde:.4f} if az < 180 else {hdw:.4f} %}}
{{% set dni = dni / (1 + e ** (-(el - hor))) %}}
{{% set dhi = {kd:.4f} * e0 * sinel ** 0.6 %}}
{{% set tr = {tilt:.4f} * rad %}}
{{% set cosaoi = [sinel * cos(tr) + cos(elr) * sin(tr) * cos((az - {azp:.4f}) * rad), 0] | max %}}
{{% set poa = dni * cosaoi + dhi * (1 + cos(tr)) / 2 + 0.2 * (dni * sinel + dhi) * (1 - cos(tr)) / 2 %}}
{{% set pdc = {kwp:.4f} * poa * (1 - 0.0038 * (tamb + 0.032 * poa - 25)) %}}
{{{{ [[pdc, 0] | max, {pclip:.1f}] | min | round(0) }}}}
{{% endif %}}
""")
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment