OverviewFilesCommitsIssuesDocsReleasesPeers

electrical-review / scripts / 15_tilt_sweep.py

226 lines · python ref:
"""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()