OverviewFilesCommitsIssuesDocsReleasesPeers

electrical-review / scripts / 06_schematics.py

324 lines · python ref:
"""06 — Schematic renderings (schemdraw -> PNG + SVG into artifacts/).

Three drawings, every rating pulled from the COMPUTED result JSONs (01/03/04) so
the gauge/fuse on the page equals what the calc produced:
  sld.(png|svg)             system single-line
  dc-strings.(png|svg)      module-level string grouping across the 2 MPPTs
  battery-wiring.(png|svg)  6000XP <-> Docan S3 with Class-T fuse + BMS comms

Run (after 01/03/04):  ~/.venvs/prsm-electrical/bin/python scripts/06_schematics.py
"""
import argparse
import pathlib
import schemdraw
from schemdraw import elements as elm
from common import load_site, banner, load_result, require

schemdraw.use("matplotlib")
ART = pathlib.Path(__file__).resolve().parent.parent / "artifacts"
# Owner selected PATH A (2x 6000XP) on 2026-06-03 -> it is the only path.
ART_PA = ART / "phase2"                # Path A = the selected build (primary deliverable)
CAP = "PRSM OFF-GRID PV · NEK Vermont · NEC 2023 · ratings trace to scripts 01/03/04"
CAP_PA = "PRSM OFF-GRID PV — SELECTED BUILD · PATH A (2× EG4 6000XP) · NEK Vermont · NEC 2023 · ratings trace to scripts 01/11"


def save(d, name, art=ART):
    art.mkdir(parents=True, exist_ok=True)
    d.save(str(art / f"{name}.svg"))
    d.save(str(art / f"{name}.png"), dpi=200)
    print(f"  rendered {art.name}/{name}.svg + .png")


def title(d, text, xy, size=13):
    d += elm.Label().label(text, fontsize=size).at(xy)


def sld(cfg, s01, s03, s04):
    pv, bat, ac = s03["pv_string"], s03["battery"], s03["ac_output"]
    ukwh = require(cfg, "battery.usable_kwh")
    d = schemdraw.Drawing()
    d.config(fontsize=10, unit=2.6)

    # --- main spine ---
    d += elm.Solar().right().label(
        f"PV ARRAY\n14×575W · 2×{s01['n_series']}S\n{s01['array_dc_W']}W DC\n"
        f"cold Voc {s01['string_cold_voc_V']}V", loc="bottom")
    d += elm.Line().right().length(2.4).label(
        f"{pv['final_gauge_AWG']}AWG·{pv['length_ft']}ft\n{pv['vdrop_pct']}%", loc="top")
    d += elm.SwitchDpst().right().label("DC\ndisc", loc="bottom")
    d += elm.Line().right().length(1.0)
    inv = d.add(elm.Ic(pins=[
        elm.IcPin(name="PV", side="left"),
        elm.IcPin(name="AC", side="right"),
        elm.IcPin(name="BATP", side="bottom", slot="1/4"),
        elm.IcPin(name="GND", side="bottom", slot="4/4"),
    ], w=5.2, h=4.4).label(
        "EG4 6000XP\n6kW 120/240V\ndual MPPT\nRSD·AFCI·GFDI", loc="center", fontsize=10
    ).anchor("PV"))

    # --- AC branch ---
    d += elm.Line().right().length(1.6).at(inv.AC)
    d += elm.SwitchDpst().right().label(f"{ac['ocpd_A']}A\n2-pole", loc="top")
    d += elm.Line().right().length(0.6)
    d += elm.RBox(w=2.8, h=2.2).label(
        f"MAIN PANEL\nsubfed\n{ac['final_gauge_AWG']}AWG·{ac['length_ft']}ft", fontsize=9)

    # --- battery branch (down from BATP, far-left bottom pin) ---
    d += elm.Line().down().length(1.4).at(inv.BATP)
    d += elm.Fuse().down().label(f"{s04['classT_fuse_A']}A Class-T", loc="left")
    d += elm.Line().down().length(0.5)
    d += elm.SwitchDpst().down().label("batt disc", loc="left")
    d += elm.Line().down().length(0.5)
    d += elm.Battery().down().label(
        f"Docan S3\n51.2V {ukwh}kWh\n{bat['final_gauge_AWG']}Cu·{bat['length_ft']}ft·{bat['vdrop_pct']}%",
        loc="left")

    # --- ground branch (down from GND, far-right bottom pin) ---
    d += elm.Line().down().length(1.2).at(inv.GND)
    d += elm.Ground().label("GEC\n6AWG\n690.47", loc="right", fontsize=8)

    cx, cy = inv.center
    title(d, "SINGLE-LINE DIAGRAM — PRSM OFF-GRID PV", (cx, cy + 4.2))
    d += elm.Label().label(CAP, fontsize=8).at((cx, cy + 3.3))
    save(d, "sld")


def dc_strings(cfg, s01, s03):
    pv = s03["pv_string"]
    voc_cold = s01["string_cold_voc_V"]
    vmp = round(s01["n_series"] * require(cfg, "panel.vmp_v"))
    n = s01["n_series"]
    d = schemdraw.Drawing()
    d.config(fontsize=10, unit=1.15)
    for row, (yy, mppt) in enumerate([(0.0, "MPPT1"), (-3.0, "MPPT2")]):
        d += elm.Label().label(f"String {row+1}\n(+)", fontsize=9).at((-1.5, yy))
        d += elm.Solar().right().at((0, yy))
        for _ in range(n - 1):
            d += elm.Solar().right()
        d += elm.Line().right().length(0.9).label(f"{pv['final_gauge_AWG']}AWG", loc="top", fontsize=8)
        d += elm.RBox(w=1.6, h=1.5).label(f"{mppt}\nEG4", fontsize=9)
    title(d, f"DC STRING WIRING — 2 strings × {n}S across MPPT1 / MPPT2", (4.5, 1.9))
    d += elm.Label().label(
        f"Each string: {n}×Waaree 575W series · Vmp≈{vmp}V · cold Voc {voc_cold}V "
        f"(≤480V design / 500V abs)\nNO source-circuit fuse — one string per MPPT (690.9(A) Exc.) "
        f"·  MAX 7 modules/string  (8S = 491V, unsafe)", fontsize=8).at((4.5, -5.2))
    save(d, "dc-strings")


def battery_wiring(cfg, s03, s04):
    bat = s03["battery"]
    ukwh = require(cfg, "battery.usable_kwh")
    d = schemdraw.Drawing()
    d.config(fontsize=10, unit=2.4)
    inv = d.add(elm.Ic(pins=[
        elm.IcPin(name="BATP", side="right", slot="3/3"),
        elm.IcPin(name="BATN", side="right", slot="1/3"),
        elm.IcPin(name="COM", side="bottom"),
    ], w=3.0, h=3.2).label("EG4 6000XP\nBAT 46.4–60V\nmax 140A dis", loc="center", fontsize=9).at((0, 0)))

    # positive path: BATP -> Class-T fuse -> disconnect -> battery top
    d += elm.Line().right().length(1.3).at(inv.BATP).label("+", loc="top")
    d += elm.Fuse().right().label(f"{s04['classT_fuse_A']}A\nClass-T", loc="top")
    d += elm.SwitchDpst().right().label("disc", loc="bottom")
    top = d.add(elm.Line().right().length(1.7))
    batt = d.add(elm.Battery().down().at(top.end).label(
        f"Docan S3\n51.2V·{ukwh}kWh\n16S1P · JK 200A BMS", loc="right", fontsize=9))
    # negative return
    d += elm.Line().left().at(batt.end).tox(inv.BATN[0]).label(
        f"−  {bat['final_gauge_AWG']}Cu×{bat['length_ft']}ft·{bat['vdrop_pct']}%", loc="bottom", fontsize=9)
    d += elm.Line().up().toy(inv.BATN[1])
    # BMS comms (dashed)
    c = d.add(elm.Line(ls="--").down().length(1.6).at(inv.COM))
    d += elm.Line(ls="--").right().length(5.2).at(c.end).label(
        "JK BMS → Pylontech CAN → 6000XP 'Pylontech 2' (V19 fw)", loc="bottom", fontsize=8)

    cx, cy = inv.center
    title(d, "BATTERY WIRING — EG4 6000XP ↔ Docan S3", (cx + 2.5, cy + 2.6))
    d += elm.Label().label(
        f"Discharge @6kW = {s04['discharge_A_6kW']}A (binding {s04['binding_limit']['name']} "
        f"{s04['binding_limit']['A']}A, {s04['binding_limit']['headroom_A']}A headroom) · "
        f"{s04['discharge_C']}C · recommend {bat.get('recommended_upsize_AWG','4/0')}Cu upsize",
        fontsize=8).at((cx + 2.0, cy - 4.2))
    save(d, "battery-wiring")


def sld_pathA(cfg, pa):
    """Path-A single-line (clean single-spine layout; the explicit two-box detail is in the
    Path-A dc-strings + battery-wiring diagrams). 28-panel array → 2× 6000XP (parallel) → bank."""
    pv = pa["pv_string"]; acu = pa["ac_per_unit"]; acc = pa["ac_combined"]
    pk = pa["battery_per_pack"]; iv = pa["battery_per_inverter"]; m = pa["meta"]
    npack, n_classT = 2, pa["limits"]["classT_count"]
    bank_dod = pa["limits"]["bank_usable_kwh_80dod"]
    d = schemdraw.Drawing()
    d.config(fontsize=10, unit=2.7)

    d += elm.Solar().right().label(
        f"PV ARRAY\n28×575W · {m['strings']}×7S\n{m['array_dc_W']}W DC\ncoldVoc {m['string_cold_voc_V']}V (≤480)",
        loc="bottom")
    d += elm.Line().right().length(2.6).label(
        f"{pv['final_gauge_AWG']}AWG×{pv['length_ft']}ft\n{pv['vdrop_pct']}% · 4 homeruns", loc="top")
    d += elm.SwitchDpst().right().label("DC\ndisc", loc="bottom")
    d += elm.Line().right().length(1.2)
    inv = d.add(elm.Ic(pins=[
        elm.IcPin(name="PV", side="left"), elm.IcPin(name="ACIN", side="top"),
        elm.IcPin(name="AC", side="right"),
        elm.IcPin(name="BATP", side="bottom", slot="1/4"), elm.IcPin(name="GND", side="bottom", slot="4/4"),
    ], w=6.8, h=6.4).anchor("PV"))
    icx, icy = inv.center
    d += elm.Label().label("2× EG4 6000XP (parallel) · 12kW\n4 MPPT · 16kW PV · RSD·AFCI·GFDI ×2",
                           fontsize=9).at((icx, icy + 1.55))

    # generator AC-IN
    d += elm.Line().up().length(1.4).at(inv.ACIN)
    d += elm.SwitchDpst().up().label("gen AC-IN", loc="right", fontsize=9)
    d += elm.Line().up().length(0.6)
    d += elm.RBox(w=2.8, h=1.3).label("GENERATOR\n200A bypass · 90A gen", fontsize=8)

    # AC: 2× 35A unit outputs combine to 70A → main panel
    d += elm.Line().right().length(1.8).at(inv.AC)
    d += elm.SwitchDpst().right().label(f"{acc['ocpd_A']}A\n2-pole", loc="top")
    d += elm.Line().right().length(1.3)
    d += elm.RBox(w=3.0, h=2.3).label(
        f"MAIN PANEL\nsubfed (main off)\n{acc['final_gauge_AWG']}AWG (2×{acu['ocpd_A']}A→{acc['ocpd_A']}A)", fontsize=9)

    # battery branch → busbar → bank
    d += elm.Line().down().length(0.9).at(inv.BATP)
    d += elm.SwitchDpst().down().label(f"bank disc {pa['bank']['busbar_A']}A", loc="right", fontsize=8)
    d += elm.Line().down().length(1.5).label(
        f"{iv['final_gauge_AWG']}Cu/inverter · {pa['bank']['i_inv_A']:.0f}A ea\n(2 inverters tap the bus)",
        loc="left", fontsize=8)
    bus = d.add(elm.RBox(w=3.6, h=1.0).label(f"DC BUSBAR ({pa['bank']['busbar_A']}A)", fontsize=9))
    d += elm.Line().down().length(0.8)
    d += elm.Fuse().down().label(f"{n_classT}× {pa['limits']['classT_fuse_A']}A Class-T\n(2 packs + 2 inverters)", loc="right", fontsize=8)
    d += elm.Line().down().length(0.8)
    d += elm.Battery().down().label(
        f"{npack}× Docan S3 · 51.2V\n{bank_dod}kWh usable (80% DoD)\n{pk['final_gauge_AWG']}Cu/pack · "
        f"{pk['vdrop_pct']}% · 1 master BMS→CAN + RS485 daisy", loc="left", fontsize=8)

    # TRANSITION option (DC-coupled, islanded): shore/grid -> Chargeverter -> fused busbar tap.
    try:
        cv = load_result("12_chargeverter")
    except SystemExit:
        cv = None
    if cv:
        bx, by = bus.center
        d += elm.Line().left().length(0.5).at((bx - 1.8, by))
        d += elm.Fuse().left().label(f"{cv['dc_output']['ocpd_a']}A\nfuse", loc="top", fontsize=7)
        d += elm.RBox(w=3.0, h=1.2).label("EG4 Chargeverter GC\n48V · 0–100A DC\n(≤50A w/ full PV)", fontsize=7)
        d += elm.Line().left().length(0.5)
        d += elm.SwitchDpst().left().label(f"{cv['ac_input']['ocpd_a']}A\n2-pole", loc="top", fontsize=7)
        d += elm.Line().left().length(0.4)
        d += elm.RBox(w=2.4, h=1.1).label("SHORE / GRID\n(transition only —\nDC-coupled, islanded)", fontsize=7)

    d += elm.Line().down().length(1.2).at(inv.GND)
    d += elm.Ground().label("GEC 6AWG\n690.47", loc="right", fontsize=8)

    cx, cy = inv.center
    title(d, "PATH-A SINGLE-LINE — 2× EG4 6000XP (parallel)", (cx, cy + 7.4))
    d += elm.Label().label(CAP_PA, fontsize=8).at((cx, cy + 6.7))
    save(d, "sld", ART_PA)


def dc_strings_pathA(cfg, pa):
    """Path-A DC strings: 4× 7S, two strings per 6000XP (2 MPPTs each)."""
    pv = pa["pv_string"]; m = pa["meta"]
    vmp = round(7 * require(cfg, "panel.vmp_v"))
    cold = m["string_cold_voc_V"]
    d = schemdraw.Drawing()
    d.config(fontsize=10, unit=1.3)
    y = 0.0
    for i in range(4):
        unit = 1 if i < 2 else 2
        mppt = (i % 2) + 1
        d += elm.Label().label(f"String {i+1}\n7S (+)", fontsize=9).at((-1.7, y))
        d += elm.Solar().right().at((0, y)).label("7× Waaree 575W", loc="top", fontsize=8)
        d += elm.Line().right().length(1.0).label(f"{pv['final_gauge_AWG']}AWG", loc="top", fontsize=8)
        d += elm.Line().right().length(0.3).label(f"Vmp≈{vmp}V\ncoldVoc {cold}V", loc="bottom", fontsize=8)
        d += elm.RBox(w=2.2, h=1.0).label(f"6000XP #{unit}\nMPPT{mppt}", fontsize=8)
        y -= 1.8
    title(d, "PATH-A DC STRINGS — 4× 7S, two strings per 6000XP", (2.8, 1.6))
    d += elm.Label().label(
        f"#1: MPPT1+MPPT2 (7S+7S) · #2: MPPT1+MPPT2 (7S+7S) · 28 modules · {m['array_dc_W']}W DC\n"
        f"7S cold Voc {cold}V ≤ 480V design / 500V abs · 1 string/MPPT → NO source fuse · 3 panels held as spares",
        fontsize=8).at((2.8, y - 0.4))
    save(d, "dc-strings", ART_PA)


def battery_wiring_pathA(cfg, pa):
    """Path-A battery: busbar fed by 2 packs, feeding 2× 6000XP inputs; per-pack + per-inverter Class-T."""
    pk = pa["battery_per_pack"]; iv = pa["battery_per_inverter"]; L = pa["limits"]; bk = pa["bank"]
    npack = 2; fuse = L["classT_fuse_A"]; ukwh = require(cfg, "battery.usable_kwh")
    d = schemdraw.Drawing()
    d.config(fontsize=9, unit=1.1)
    # central vertical busbar
    d += elm.Line().up().length(2.6).at((0, 0))
    d += elm.Line().down().length(2.6).at((0, 0))
    d += elm.Label().label(f"DC BUSBAR\n{bk['busbar_A']}A", fontsize=9).at((0, 0.0))

    # left: 2 packs in
    for k in range(npack):
        yy = 1.6 - k * 3.2
        d += elm.Line().left().length(1.0).at((0, yy))
        d += elm.Fuse().left().label(f"{fuse}A\nClass-T", loc="top", fontsize=7)
        d += elm.SwitchDpst().left().label("disc", loc="bottom", fontsize=7)
        d += elm.Battery().left().label(f"Docan S3 #{k+1}\n51.2V·{ukwh}kWh\n{pk['final_gauge_AWG']}Cu", loc="top", fontsize=7)
    # right: 2 inverters out
    for k in range(npack):
        yy = 1.6 - k * 3.2
        d += elm.Line().right().length(1.0).at((0, yy))
        d += elm.Fuse().right().label(f"{fuse}A\nClass-T", loc="top", fontsize=7)
        d += elm.SwitchDpst().right().label("disc", loc="bottom", fontsize=7)
        d += elm.RBox(w=2.0, h=1.2).label(f"6000XP #{k+1}\nBAT max140A\n{iv['final_gauge_AWG']}Cu", fontsize=7)

    title(d, "PATH-A BATTERY WIRING — busbar: 2 packs → 2× 6000XP", (0, 5.0))
    d += elm.Label().label(
        f"Bank {bk['i_bank_A']:.0f}A @12kW → {bk['i_pack_A']:.0f}A/pack (17% under 150A BMS) + "
        f"{bk['i_inv_A']:.0f}A/inverter (14A under 140A) · {L['c_dis']}C · {npack+npack}× {fuse}A Class-T · "
        f"busbar/disc {bk['busbar_A']}A (125%×{bk['i_bank_A']:.0f}A) · 1 master BMS→CAN + RS485 daisy + EG4 parallel kit",
        fontsize=7).at((0, -5.2))
    save(d, "battery-wiring", ART_PA)


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--pathA", action="store_true",
                    help="render the SELECTED Path-A (2× 6000XP) diagrams to artifacts/phase2/")
    args = ap.parse_args()
    if args.pathA:
        cfg = load_site()
        banner("06  SCHEMATICS  [SELECTED PATH A — 2× 6000XP -> artifacts/phase2/]")
        pa = load_result("11_pathA")
        ok = []
        for name, fn in [("sld", lambda: sld_pathA(cfg, pa)),
                         ("dc-strings", lambda: dc_strings_pathA(cfg, pa)),
                         ("battery-wiring", lambda: battery_wiring_pathA(cfg, pa))]:
            try:
                fn(); ok.append(name)
            except Exception as e:  # noqa
                import traceback
                print(f"  [FAIL] {name}: {type(e).__name__}: {e}")
                traceback.print_exc()
        print(f"\n  rendered OK: {ok}")
        return
    cfg = load_site()
    banner("06  SCHEMATICS  (schemdraw -> PNG + SVG)")
    s01 = load_result("01_string")
    s03 = load_result("03_conductors")
    s04 = load_result("04_battery")
    ok = []
    for name, fn in [("sld", lambda: sld(cfg, s01, s03, s04)),
                     ("dc-strings", lambda: dc_strings(cfg, s01, s03)),
                     ("battery-wiring", lambda: battery_wiring(cfg, s03, s04))]:
        try:
            fn()
            ok.append(name)
        except Exception as e:  # noqa
            import traceback
            print(f"  [FAIL] {name}: {type(e).__name__}: {e}")
            traceback.print_exc()
    print(f"\n  rendered OK: {ok}")


if __name__ == "__main__":
    main()