"""15 — Seasonal tilt sweep (PVWatts v8) for the Phase-1 14-panel array.
Same production model as 02 (PVWatts v8, array_type=0 fixed open rack, 14% losses,
azimuth 180) so the numbers are comparable to 02_production.json — BUT this script also
models the BOM panel's BIFACIALITY (Waaree BiN-08-575 is bifacial, datasheet 0.80),
which 02 omits. For every tilt it computes BOTH:
- mono : module_type=0, no bifaciality -> the conservative floor (== 02's basis)
- BOM : + bifaciality from site.yaml (panel.bifaciality) + gcr -> the actual panel
All seasonal optima / schedules / deltas are taken on the BOM (bifacial) series, since
that is the panel actually in the BOM. The shoulder-priority / 4x-per-year question:
- annual-optimal fixed tilt
- per-season optimal tilt (winter DJF / spring MAM / summer JJA / fall SON) + the
combined shoulder (MAM+SON) optimum
- achievable annual energy under schedules: fixed 55 (racking-handoff angle) ·
fixed annual-optimal · 2-position (summer/winter half) · 4-position (per-season opt)
- deltas of each schedule vs the fixed 55 baseline
GCR NOTE: bifacial rear-gain depends on ground-coverage ratio (row pitch). Phase 1 is a
SINGLE row (2-high x 7-wide) -> minimal rear shading -> rear gain at/above the gcr=0.3
estimate used here. Multi-row Phase 2 will have higher GCR and lower per-panel rear gain.
albedo: PVWatts v8 uses the NSRDB dataset's monthly surface albedo (incl. winter snow);
a scalar albedo override was tested and had no effect, so it is left to the dataset.
Every kWh here is computed by PVWatts, not estimated (CLAUDE.md rule).
Run (needs network + NREL_API_KEY):
~/.venvs/prsm-electrical/bin/python scripts/15_tilt_sweep.py
"""
import os
import requests
from common import load_site, require, get, banner, result, dump_result
HOSTS = ["https://developer.nrel.gov", "https://developer.nlr.gov"]
PATH = "/api/pvwatts/v8.json"
MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun",
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
SEASONS = {
"winter": [11, 0, 1], # Dec Jan Feb
"spring": [2, 3, 4], # Mar Apr May
"summer": [5, 6, 7], # Jun Jul Aug
"fall": [8, 9, 10], # Sep Oct Nov
}
SHOULDER = SEASONS["spring"] + SEASONS["fall"]
SUMMER_HALF = [3, 4, 5, 6, 7, 8] # Apr-Sep
WINTER_HALF = [9, 10, 11, 0, 1, 2] # Oct-Mar
TILT_GRID = list(range(10, 76, 5)) # 10..75 by 5 deg
BASELINE_TILT = 55 # racking-handoff fixed angle
GCR = 0.3 # single-row Phase 1 (2-high x 7-wide); rear gain >= this. FLAG: layout-dependent.
def call_pvwatts(params):
last = None
for host in HOSTS:
try:
r = requests.get(host + PATH, params=params, timeout=30)
if r.status_code == 200 and "outputs" in r.json():
return r.json(), host, None
last = f"{host}: HTTP {r.status_code} {r.text[:120]}"
except Exception as e: # noqa
last = f"{host}: {type(e).__name__}: {e}"
return None, None, last
def season_sum(monthly, idxs):
return sum(monthly[i] for i in idxs)
def main():
cfg = load_site()
banner("15 SEASONAL TILT SWEEP (PVWatts v8, Phase-1 14-panel, mono + BOM-bifacial)")
modules = int(require(cfg, "array.module_count"))
pmp = require(cfg, "panel.pmp_w")
kw = round(modules * pmp / 1000.0, 2)
az = require(cfg, "array.azimuth_deg")
lat = require(cfg, "site.latitude")
lon = require(cfg, "site.longitude")
bifaciality = float(get(cfg, "panel.bifaciality", 0.0)) # BOM panel = 0.80
losses = 14
dc_ac = round(kw / 6.0, 2)
key = os.environ.get("NREL_API_KEY", "DEMO_KEY")
result("system_capacity", kw, "kW DC", f"{modules} x {pmp}W (Phase-1 BOM Waaree BiN-08-575)")
result("bifaciality (BOM)", bifaciality, "", f"site.yaml panel.bifaciality; gcr={GCR} (single-row)")
result("azimuth / losses", f"{az} / {losses}", "deg / %")
result("tilt grid", f"{TILT_GRID[0]}..{TILT_GRID[-1]} step 5", "deg")
def pull(tilt, bif):
p = {"api_key": key, "system_capacity": kw, "module_type": 0,
"array_type": 0, "tilt": tilt, "azimuth": az, "losses": losses,
"lat": lat, "lon": lon, "dc_ac_ratio": dc_ac}
if bif:
p.update(bifaciality=bifaciality, gcr=GCR)
data, host, err = call_pvwatts(p)
if data is None:
raise SystemExit(f"PVWatts failed at tilt {tilt} (bif={bif}): {err}")
return data["outputs"], host
by_tilt = []
host_used = None
for t in TILT_GRID:
o_m, host_used = pull(t, False)
o_b, host_used = pull(t, True)
mono = round(o_m["ac_annual"])
bm = [round(x, 1) for x in o_b["ac_monthly"]]
rec = {"tilt": t, "ac_annual_mono": mono, "ac_annual": round(o_b["ac_annual"]),
"ac_monthly": bm, # bifacial monthly (BOM)
"winter": round(season_sum(bm, SEASONS["winter"])),
"spring": round(season_sum(bm, SEASONS["spring"])),
"summer": round(season_sum(bm, SEASONS["summer"])),
"fall": round(season_sum(bm, SEASONS["fall"])),
"shoulder": round(season_sum(bm, SHOULDER))}
rec["bifacial_gain_pct"] = round(100 * (rec["ac_annual"] - mono) / mono, 1)
by_tilt.append(rec)
result("VERIFIED host", host_used, "", "PVWatts v8")
def best(key_):
return max(by_tilt, key=lambda r: r[key_])
ann_opt = best("ac_annual")
season_opt = {s: {"tilt": best(s)["tilt"], "kwh": best(s)[s]} for s in SEASONS}
shoulder_opt = {"tilt": best("shoulder")["tilt"], "kwh": best("shoulder")["shoulder"]}
base = next(r for r in by_tilt if r["tilt"] == BASELINE_TILT)
b55 = base["ac_annual"]
sched = {}
sched["fixed_55"] = b55
sched["fixed_annual_optimal"] = {"tilt": ann_opt["tilt"], "annual_kwh": ann_opt["ac_annual"]}
def best_half(idxs):
b = max(by_tilt, key=lambda r: season_sum(r["ac_monthly"], idxs))
return b["tilt"], round(season_sum(b["ac_monthly"], idxs))
sh_t, sh_kwh = best_half(SUMMER_HALF)
wh_t, wh_kwh = best_half(WINTER_HALF)
sched["two_position"] = {"summer_half_tilt": sh_t, "winter_half_tilt": wh_t,
"annual_kwh": sh_kwh + wh_kwh}
four, four_total = {}, 0
for s, idxs in SEASONS.items():
b = max(by_tilt, key=lambda r: season_sum(r["ac_monthly"], idxs))
kwh = round(season_sum(b["ac_monthly"], idxs))
four[s] = {"tilt": b["tilt"], "kwh": kwh}
four_total += kwh
sched["four_position"] = {"per_season": four, "annual_kwh": four_total}
# recommended snow/off-grid-aware schedule (steep winter, moderate summer)
rec_sched = {"winter": 55, "spring": 35, "summer": 30, "fall": 45}
rec_total = sum(next(r for r in by_tilt if r["tilt"] == tt)[s]
for s, tt in rec_sched.items())
sched["four_position_recommended"] = {"per_season_tilt": rec_sched, "annual_kwh": rec_total}
def d(v):
return {"kwh": v - b55, "pct": round(100 * (v - b55) / b55, 2)}
deltas = {
"fixed_annual_optimal": d(ann_opt["ac_annual"]),
"two_position": d(sched["two_position"]["annual_kwh"]),
"four_position": d(four_total),
"four_position_recommended": d(rec_total),
}
sh_base = base["shoulder"]
deltas["shoulder_only_at_shoulder_opt"] = {
"tilt": shoulder_opt["tilt"], "kwh": shoulder_opt["kwh"] - sh_base,
"pct": round(100 * (shoulder_opt["kwh"] - sh_base) / sh_base, 2),
"note": "spring+fall energy only (BOM bifacial), shoulder-optimal tilt vs fixed 55"}
print("\n TILT MONO BOM-bif +bif% winter spring summer fall shoulder")
for r in by_tilt:
star = " <-55" if r["tilt"] == BASELINE_TILT else ""
opt = " *opt" if r["tilt"] == ann_opt["tilt"] else ""
print(f" {r['tilt']:>3} {r['ac_annual_mono']:>6} {r['ac_annual']:>6} "
f"{r['bifacial_gain_pct']:>4}% {r['winter']:>5} {r['spring']:>5} "
f"{r['summer']:>6} {r['fall']:>5} {r['shoulder']:>6}{star}{opt}")
print()
result("annual-optimal tilt (BOM)", f"{ann_opt['tilt']} deg",
f"{ann_opt['ac_annual']} kWh/yr",
f"vs fixed 55 = {b55} (+{deltas['fixed_annual_optimal']['kwh']} kWh, "
f"+{deltas['fixed_annual_optimal']['pct']}%)")
for s in SEASONS:
result(f"{s} optimal tilt", f"{season_opt[s]['tilt']} deg",
f"{season_opt[s]['kwh']} kWh", "")
result("SHOULDER (spring+fall) optimal", f"{shoulder_opt['tilt']} deg",
f"{shoulder_opt['kwh']} kWh",
f"+{deltas['shoulder_only_at_shoulder_opt']['kwh']} kWh "
f"(+{deltas['shoulder_only_at_shoulder_opt']['pct']}%) vs fixed 55 shoulders")
print()
result("2-position annual", f"{sched['two_position']['annual_kwh']} kWh/yr",
"", f"summer-half {sh_t} / winter-half {wh_t} deg; "
f"+{deltas['two_position']['kwh']} (+{deltas['two_position']['pct']}%) vs 55")
result("4-position annual (max)", f"{sched['four_position']['annual_kwh']} kWh/yr",
"", "w{w}/sp{sp}/su{su}/f{f} deg; +{dk} (+{dp}%) vs 55".format(
w=four["winter"]["tilt"], sp=four["spring"]["tilt"],
su=four["summer"]["tilt"], f=four["fall"]["tilt"],
dk=deltas["four_position"]["kwh"], dp=deltas["four_position"]["pct"]))
result("4-position annual (recommended)", f"{rec_total} kWh/yr",
"", f"55/45/35/30; +{deltas['four_position_recommended']['kwh']} "
f"(+{deltas['four_position_recommended']['pct']}%) vs 55")
dump_result("15_tilt_sweep", {
"model": "PVWatts v8, array_type=0, module_type=0, losses=14%, az=180 (matches 02)",
"panel": "Waaree BiN-08-575 bifacial (Grade B) — BOM",
"bifaciality": bifaciality, "gcr": GCR,
"gcr_note": "Phase-1 single row -> rear gain >= this estimate; multi-row Phase 2 higher GCR/lower rear gain",
"albedo_note": "PVWatts dataset monthly albedo (incl. snow) used; scalar override had no effect",
"host": host_used, "system_kw": kw, "lat": lat, "lon": lon,
"tilt_grid": TILT_GRID, "baseline_tilt": BASELINE_TILT,
"primary_series": "BOM bifacial (ac_annual / ac_monthly); ac_annual_mono = conservative floor",
"by_tilt": by_tilt,
"annual_optimal": {"tilt": ann_opt["tilt"], "ac_annual": ann_opt["ac_annual"]},
"season_optimal": season_opt, "shoulder_optimal": shoulder_opt,
"fixed_55": {"ac_annual": b55, "ac_annual_mono": base["ac_annual_mono"],
"bifacial_gain_pct": base["bifacial_gain_pct"],
"ac_monthly": base["ac_monthly"], "winter": base["winter"],
"spring": base["spring"], "summer": base["summer"],
"fall": base["fall"], "shoulder": base["shoulder"]},
"schedules": sched, "deltas_vs_fixed55": deltas,
})
if __name__ == "__main__":
main()