checks / unit (pull_request) Successful in 7m55s
Four review findings, none of them in executable code. The control law, the tests and the instruments' arithmetic are untouched; every number below was re-measured against the committed 16-day capture before it was written down. R1 - app/control.py carried a justification the decision record and MUTATION.md habit 22 had already retracted, and it was false about the code it sits in. It said an unconditional placement "would also pull the integrator toward a command the SATURATION freeze is pinning", priced at 118 of 256 250 ticks. The 118 was measured against a different pair; on the pair the sentence names the two forms are bit-identical - 0 of 256 246 ticks, re-measured. And the narrow form that shipped DOES move a saturation-frozen integrator: it runs before the freeze block and is not gated on `frozen`. Of 79 160 frozen ticks the cap changes `i_w` on 1095, and on 679 of those it moves it further out on the side it was already on - putting back magnitude the freeze had just let fall, which the freeze's own rule forbids. Worst case 2424 W, at prev_w -3400 W, grid +4697 W, actual -4144 W, sat_count 2 -> 3: the freeze leaves the integrator at -176 W and the cap drags it to -2600 W. It never overrides a same-cycle refusal (the freeze clipped on 75 512 ticks, the slew bound on none of them), and that is stated too. It is also where SAFETY-13's settle values went: gating the cap on `not frozen` puts test_control.py part 5 back at -1190 W exactly, while part 4(b) does not come back - that one moved for the winding reason. R3 - the record read its own sensitivity table backwards. `fit_plant()` picks lag 7.5 s on battery-power RMS, which separates it from 2.5 s by 0.3 %, while the tool's own printed fidelity measure (export against the recorded 29.43 kWh) prefers 2.5 s by the whole 35 % gap. Swept properly: at every lag <= 5.0 s release/1.0's worst quarter is ALREADY 3434 W and this card changes it by exactly zero. The 3739 -> 3434 W headline exists at one plant. New section 3a carries the table; the export reduction now leads every summary and the peak is presented as lag-dependent. The form-A rejection is strengthened rather than weakened: form A is worse on the billed quarter at every lag where release/1.0 produces a peak at all, and at every top-N cut of the quarters. R2 - DOCS.md stated modelled watts to the owner as measurement. It now leads with the export reduction, says the figures are simulated, and says plainly that no capacity-tariff improvement is promised. R4 - tools/slew_sim.py --slew narrated "the OVER-ABSORPTION does not move, and at the slowest slews it is slightly WORSE" while printing a monotonically falling column. That sentence is TRUE on release/1.0 (reproduced: 958/1107/1133/1058/987/1029/1150/1143/1081) and false under this branch's law (777/734/663/563/553/507/435/358/321), so SAFETY-39 is not retracted: its record gets a forward reference, and the script now reads SLEW_LEAD_STEPS out of the law it imported and narrates from the column it just printed. Both branches of that narration were driven. R5 - three counts in the L table were decoration and are re-driven in the CI image on a `cp -a`, purged, `:ro`, one file at a time: L5 is 5+5 with the function names exchanged and 5+4 with the branch bodies exchanged (two mutations wearing one name, now listed separately), L6 is 6+3. The separable- state count is grid-dependent and three sweeps disagree, so all three are quoted with their grids and the load-bearing claim is WHERE they separate. R7 - "a large import answered too weakly" is not what the aggregate shows. Form A cuts import by 5.45 kWh and export by 5.18 kWh, and paired per tick delivers 8.56 kWh less offtake energy on the ticks release/1.0 was importing. The harm is a tail redistribution inside quarters that already net to offtake. The conclusion stands; the sentence is narrowed to it. Verified: the full suite in the CI image, purged tree, `:ro` mount, one file at a time, exactly the command in .gitea/workflows/checks.yml - 30/30, exit 0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
380 lines
20 KiB
Python
380 lines
20 KiB
Python
#!/usr/bin/env python3
|
|
"""SAFETY-39's evidence, on admin/goodwe's OWN plant model.
|
|
|
|
python3 tools/slew_sim.py --repro # ⭐ the 2026-09-05 excursion, replayed
|
|
python3 tools/slew_sim.py --slew # the derivation: slew against the rail
|
|
python3 tools/slew_sim.py --gain # what the slew does NOT own
|
|
python3 tools/slew_sim.py --roster # N=1 -> N=2, and the rail that moved
|
|
python3 tools/slew_sim.py --all
|
|
|
|
WHY THIS IS A SCRIPT AND NOT A `test_*.py`
|
|
==========================================
|
|
Byte for byte tools/alloc_sim.py's reason, and it has not changed: this needs
|
|
admin/goodwe checked out beside this repository for `plantsim.Plant`, and the
|
|
add-on's CI job has no credentials for that repository - it is private and the
|
|
job's token is minted for this one. A `test_*.py` here is globbed and RUN by
|
|
.gitea/workflows/checks.yml, so a file that needs the sibling repo would either
|
|
fail the gate or - worse - pass SILENTLY having checked nothing.
|
|
|
|
So the rig evidence lives here and is quoted in the pull request and in
|
|
DECISIONS/SAFETY-39-slew-against-the-rail.md, and test_control.py's SAFETY-39
|
|
section grades the same properties in CI against a plant it models itself and
|
|
says it models. The two plants are INDEPENDENT - a first-order lag written in
|
|
test_control.py versus `plantsim.Plant`'s ramp-plus-lag with an efficiency and
|
|
a SoC integrator - and they agree on every sign and rank below, which is worth
|
|
more than either alone.
|
|
|
|
WHAT IS REAL IN HERE
|
|
====================
|
|
* the control law - `compute()` and `Tuning`, imported out of THIS checkout,
|
|
never transcribed.
|
|
* the wire - every command goes out through `frame_check.setpoint_atomic()`,
|
|
the firmware's own encoder, and back in through `plantsim.parse`/`decode`,
|
|
because `Inverter.command()` is what drives the modelled unit.
|
|
* the plant - `plantsim.Plant`, the model the rig uses.
|
|
|
|
MODELLED, and therefore not evidence about the thing modelled:
|
|
* the meter. `grid = load - pv - sum(battery)` with no poll delay and no
|
|
noise, sampled once per tick. The real P1 is a poll with its own age.
|
|
* the tick. 2.0 s, which is what the live install was MEASURED running on
|
|
2026-09-05; it is a parameter here (`--tick`) and not a constant, because
|
|
the whole of this card is that the derivation depends on the regime.
|
|
|
|
⚠️ WHAT THIS CANNOT SHOW, SAID BEFORE THE RESULTS RATHER THAN AFTER THEM
|
|
=======================================================================
|
|
THE -5154 W BATTERY EXCURSION IS NOT REPRODUCIBLE HERE, AND NOT BECAUSE THE
|
|
TUNING IS WRONG. `plantsim.Plant.limit()` clamps the plant at `p_max`, so the
|
|
modelled battery cannot exceed its rating by one watt at any tuning, on any
|
|
step, ever. Neither can test_control.py's model. An acceptance criterion of
|
|
"battery power no longer exceeds max_w in simulation" would therefore be
|
|
UNFALSIFIABLE - it passes on the broken configuration too - so no criterion in
|
|
this card is written on it, and the ticket's own AC to that effect is the one
|
|
that had to be rewritten rather than met.
|
|
|
|
What IS reproducible, and is what everything below measures:
|
|
* the setpoint stepping a full `slew_w` per tick, over and over (the live
|
|
symptom that escalated 2 -> 1 -> 13 -> 43 large steps per 30 min);
|
|
* the loop OVER-ABSORBING - taking more than the site needed - and driving
|
|
the meter from EXPORT into IMPORT, i.e. manufacturing the error it chases.
|
|
|
|
⚠️ INVERTER B DOES NOT EXIST. `plantsim.INVERTER_B` is inverter A's numbers
|
|
COPIED, `measured=False`. Every N=2 row below is therefore a result about TWO
|
|
COPIES OF A on a 10000 W rail, NOT about the ~9600 W site that B's datasheet
|
|
and serial imply (goodwe/NOTES.md §1.1b; B is a GW5048D-ES rated 4600 W, and
|
|
that figure may itself be DC). Each such row prints the caveat with it.
|
|
"""
|
|
import argparse
|
|
import math
|
|
import os
|
|
import pathlib
|
|
import sys
|
|
|
|
HERE = pathlib.Path(__file__).resolve().parent
|
|
ADDON = HERE.parent.parent # the goodwe-addon checkout root
|
|
|
|
|
|
def find_sim():
|
|
"""admin/goodwe's `sim/` directory, or None - and the caller says which.
|
|
|
|
$GOODWE_SIM first, the same override test_freeze.py and alloc_sim.py take,
|
|
then the sibling checkouts that actually occur. A missing sibling is not a
|
|
finding about the control law and must never be reported as one.
|
|
"""
|
|
cands = []
|
|
if os.environ.get("GOODWE_SIM"):
|
|
cands.append(pathlib.Path(os.environ["GOODWE_SIM"]))
|
|
for up in (ADDON.parent, ADDON.parent.parent):
|
|
cands += [up / "goodwe" / "sim", up / "goodwe-session" / "goodwe" / "sim"]
|
|
for c in cands:
|
|
if (c / "plantsim.py").exists():
|
|
return c
|
|
return None
|
|
|
|
|
|
def load(sim):
|
|
sys.path[:0] = [str(sim), str(sim.parent / "master")]
|
|
os.environ.setdefault("GOODWE_ADDON_REPO", str(ADDON))
|
|
sys.path[:0] = [str(ADDON / "goodwe_controller")]
|
|
import plantsim # noqa: E402
|
|
from app import control # noqa: E402
|
|
return plantsim, control
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# one experiment
|
|
|
|
|
|
def step_run(plantsim, control, *, gain, slew_w, rail_w, n=1, tick_s=2.0,
|
|
export_w=3385.0, load_w=350.0, phase=5, cycles=300, dt=0.05,
|
|
lag_s=None):
|
|
"""One export step onto a converged loop, through the real wire.
|
|
|
|
`rail_w` is the SITE rail - what `main.py` hands `compute()` as `max_w`
|
|
(`Controller.site_max_w`, the roster sum). Each of the `n` modelled units
|
|
is rated `rail_w / n` and is commanded its even share, which is the
|
|
arrangement ARCH-01's split-invariance says any valid split is equivalent
|
|
to at the meter.
|
|
"""
|
|
over = {} if lag_s is None else dict(lag_s=lag_s)
|
|
spec = dict(plantsim.INVERTER_A, rating_w=rail_w / n, **over)
|
|
invs = [plantsim.Inverter.from_spec(name, spec) for name in "AB"[:n]]
|
|
site = plantsim.Site(invs, load=load_w, pv=0.0)
|
|
tuning = control.Tuning(gain=gain, max_w=rail_w, slew_w=slew_w,
|
|
target_grid_w=-10.0)
|
|
prev, i_w, sat = 0.0, None, 0
|
|
worst_import, peak, biggest, last = -1e9, 0.0, 0.0, None
|
|
for c in range(cycles):
|
|
if c == phase:
|
|
site.pv = export_w + load_w
|
|
site.grid = site.load - site.pv - site.battery
|
|
# ⚠️ ONE UNIT'S POWER, NOT THE SITE'S SUM, at n > 1. The shipped add-on
|
|
# binds a single `inverter_power` sensor, so a two-inverter install has
|
|
# a loop that sees unit A only. sitesim.run_site() makes the same choice
|
|
# and says at length why feeding the sum would be a KINDER rig than the
|
|
# deployment.
|
|
actual = invs[0].plant.actual
|
|
d = control.compute(prev, site.grid, actual, tuning, sat, i_w)
|
|
if c >= phase and last is not None:
|
|
biggest = max(biggest, abs(d.target_w - last))
|
|
last = d.target_w
|
|
prev, i_w, sat = d.target_w, d.i_w, d.sat_count
|
|
for inv in invs:
|
|
inv.command(d.target_w / n)
|
|
site.settle(tick_s, dt=dt)
|
|
if c >= phase:
|
|
worst_import = max(worst_import, site.grid)
|
|
peak = max(peak, abs(site.battery))
|
|
return dict(imp=worst_import, over=peak - export_w, step=biggest)
|
|
|
|
|
|
def family(plantsim, control, *, exports=None, phases=(5, 6, 7), **kw):
|
|
"""The worst case over a family of step sizes and phases.
|
|
|
|
⚠️ A FAMILY, NOT ONE STEP, AND THAT IS ITSELF A FINDING. A single run of
|
|
this loop is not evidence: the 15 W deadband, the 10 W quantisation and the
|
|
phase of the step against the tick move the answer by hundreds of watts,
|
|
NON-MONOTONICALLY, so two adjacent slews rank either way depending on which
|
|
step you happen to run. The first version of this derivation was a single
|
|
step and reported gain 0.3 as WORSE than gain 0.6, which the family shows is
|
|
backwards. Everything printed below is a worst case over the family, which
|
|
is how admin/goodwe grades the same kind of quantity (config.yaml's "1890
|
|
closed-loop runs", "924 runs").
|
|
"""
|
|
exports = exports or range(1000, 4300, 200)
|
|
worst = dict(imp=-1e9, over=-1e9, step=0.0, big=0, runs=0)
|
|
for export_w in exports:
|
|
for phase in phases:
|
|
r = step_run(plantsim, control, export_w=float(export_w),
|
|
phase=phase, **kw)
|
|
worst["runs"] += 1
|
|
worst["imp"] = max(worst["imp"], r["imp"])
|
|
worst["over"] = max(worst["over"], r["over"])
|
|
worst["step"] = max(worst["step"], r["step"])
|
|
worst["big"] += r["step"] >= 900.0
|
|
return worst
|
|
|
|
|
|
def row(label, w):
|
|
print(f" {label:<44} step {w['step']:6.0f} W "
|
|
f"{w['big']:3}/{w['runs']} runs >= 900 W "
|
|
f"over-absorbed {w['over']:6.0f} W meter to {w['imp']:+7.0f} W")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def repro(ps, ct, tick_s):
|
|
print("⭐ THE 2026-09-05 EXCURSION, REPLAYED - gain 0.6, max_w 5000, "
|
|
f"slew_w 1000, {tick_s:.0f} s tick")
|
|
print(" Live, 11:43:55-11:45:55: grid -3385 W, setpoint 380 -> 5000 in five")
|
|
print(" ticks, battery -5154 W, grid +2831 W. One 3385 W export step here:\n")
|
|
print(f" {'t':>5} {'grid in':>9} {'setpoint':>9} {'battery':>9} {'grid out':>9} reason")
|
|
spec = dict(ps.INVERTER_A)
|
|
inv = ps.Inverter.from_spec("A", spec)
|
|
site = ps.Site([inv], load=350.0, pv=0.0)
|
|
t = ct.Tuning(gain=0.6, max_w=5000.0, slew_w=1000.0, target_grid_w=-10.0)
|
|
prev, i_w, sat = 0.0, None, 0
|
|
for c in range(34):
|
|
if c == 5:
|
|
site.pv = 3385.0 + 350.0
|
|
site.grid = site.load - site.pv - site.battery
|
|
g = site.grid
|
|
d = ct.compute(prev, g, inv.plant.actual, t, sat, i_w)
|
|
prev, i_w, sat = d.target_w, d.i_w, d.sat_count
|
|
inv.command(d.target_w)
|
|
site.settle(tick_s, dt=0.05)
|
|
print(f" {c * tick_s:5.0f} {g:9.0f} {d.target_w:9.0f} "
|
|
f"{site.battery:9.0f} {site.grid:9.0f} {d.reason}")
|
|
print("\n ⚠️ THE BATTERY NEVER PASSES 5000 W HERE AND CANNOT: plantsim.Plant")
|
|
print(" clamps at its rating. The -5154 W figure is NOT reproducible in")
|
|
print(" this model at any tuning, so nothing in this card is graded on it.")
|
|
print(" What IS reproduced is the setpoint stepping 1000 W a tick to the")
|
|
print(" rail, and the meter crossing from export into import.")
|
|
|
|
|
|
def slew_table(ps, ct, tick_s, rail):
|
|
frac = ct.SLEW_RAIL_FRAC
|
|
print(f"THE DERIVATION - gain 0.6, site rail {rail:.0f} W, {tick_s:.0f} s tick, "
|
|
f"51 export steps 1.0-4.2 kW\n")
|
|
print(f" the constraint: the loop must not cross its whole output range "
|
|
f"faster than the")
|
|
print(f" plant settles. {rail:.0f} W / ({ct.PLANT_SETTLE_S:.0f} s / "
|
|
f"{tick_s:.0f} s per tick) = {frac * rail:.0f} W per tick.\n")
|
|
overs = []
|
|
for slew in (1000, 800, 700, 600, int(frac * rail), 500, 400, 300, 250):
|
|
w = family(ps, ct, gain=0.6, slew_w=float(slew), rail_w=rail,
|
|
tick_s=tick_s)
|
|
tag = " <- the derived value" if slew == int(frac * rail) else (
|
|
" <- the 2026-09-05 live pair" if slew == 1000 else (
|
|
" <- the live interim" if slew == 400 else ""))
|
|
row(f"slew_w {slew}{tag}", w)
|
|
overs.append(w["over"])
|
|
print("\n READ IT THIS WAY. The >= 900 W step - the live symptom, which")
|
|
print(" escalated 2 -> 1 -> 13 -> 43 per 30 min that morning - is gone from")
|
|
print(" every run below 900 W of slew, and that is what the slew owns.")
|
|
|
|
# ⚠️ THE NARRATION BELOW IS DERIVED FROM THE COLUMN JUST PRINTED, NOT
|
|
# WRITTEN ABOUT IT. SAFETY-39 wrote "the over-absorption does not move, and
|
|
# at the slowest slews it is slightly WORSE" - true of release/1.0's law,
|
|
# which is what it was measured against, and FALSE under SAFETY-48's lead
|
|
# cap, where the same script on the same plant prints a monotonically
|
|
# FALLING column. A transcript whose prose contradicts its own table is a
|
|
# failing check (MUTATION.md habit 8), and a sentence that is right under
|
|
# one law and wrong under another has to be computed rather than typed. So:
|
|
# which law is imported is read off `control.py` itself, and whether the
|
|
# column falls is read off the column.
|
|
cap = getattr(ct, "SLEW_LEAD_STEPS", None)
|
|
falling = all(b < a for a, b in zip(overs, overs[1:]))
|
|
law = ("release/1.0's law - the integrator's lead over the command is "
|
|
"UNBOUNDED" if cap is None else
|
|
f"SAFETY-48's law - the integrator may lead the command by "
|
|
f"{cap} slew step(s)")
|
|
print(f"\n THE LAW THIS RAN AGAINST: {law}.")
|
|
if cap is None and not falling:
|
|
print(" The OVER-ABSORPTION does not move with the slew, and at the")
|
|
print(" slowest slews it is slightly WORSE - `slew_w` bounds the")
|
|
print(" OUTPUT while the integrator goes on winding, so a slower slew")
|
|
print(" leaves it winding for longer. SAFETY-48 bounds that lead and")
|
|
print(" INVERTS this column; see DECISIONS/SAFETY-48 §6.")
|
|
elif cap is not None and falling:
|
|
print(" The OVER-ABSORPTION FALLS MONOTONICALLY with the slew, which")
|
|
print(" is the OPPOSITE of what SAFETY-39 measured on release/1.0")
|
|
print(" (958/1107/1133/1058/987/1029/1150/1143/1081 W, flat and")
|
|
print(" non-monotone). Bounding the integrator's lead is what changed")
|
|
print(" it: the slew now owns a share of the over-absorption it did")
|
|
print(" not own before. DECISIONS/SAFETY-48 §6 has both tables and an")
|
|
print(" independent confirmation on test_control.py's own plant.")
|
|
else:
|
|
print(" ⚠️ NEITHER NARRATION FITS. The law above and the shape of the")
|
|
print(f" column ({'falling' if falling else 'not falling'}) are the")
|
|
print(" combination this script has no sentence for - which means the")
|
|
print(" measurement has moved and DECISIONS/SAFETY-39 §3 and")
|
|
print(" DECISIONS/SAFETY-48 §6 both need re-reading before this table")
|
|
print(" is quoted anywhere.")
|
|
print("\n EITHER WAY THE RIG DOES NOT PICK THE NUMBER, and it is not asked")
|
|
print(" to: the CONSTRAINT picks it, and this table is what says what the")
|
|
print(" constraint does and does not buy.")
|
|
|
|
|
|
def gain_table(ps, ct, tick_s, rail):
|
|
frac = ct.SLEW_RAIL_FRAC
|
|
print(f"WHAT THE SLEW DOES NOT OWN - slew_w at the derived "
|
|
f"{frac * rail:.0f} W, gain swept\n")
|
|
for gain in (0.6, 0.5, 0.4, 0.3, 0.2, 0.1):
|
|
w = family(ps, ct, gain=gain, slew_w=frac * rail, rail_w=rail,
|
|
tick_s=tick_s)
|
|
row(f"gain {gain}" + (" <- shipped" if gain == 0.6 else ""), w)
|
|
print("\n `slew_w` bounds the OUTPUT. The integrator accumulates")
|
|
print(" `gain * error` per cycle and is bounded only by `integrator_max_w`,")
|
|
print(" so at gain 0.6 into a 3.4 kW error it moves ~2000 W per tick and")
|
|
print(f" reaches the {rail:.0f} W rail in under three ticks WHATEVER the slew is.")
|
|
print(" The over-absorption is therefore a GAIN term. `gain: 0.6` is the")
|
|
print(" next constant to re-derive; SAFETY-39 measured it and changed")
|
|
print(" nothing, because two control-law constants moving in one release on")
|
|
print(" a running house makes the next measurement uninterpretable.")
|
|
|
|
|
|
def roster_table(ps, ct, tick_s):
|
|
frac = ct.SLEW_RAIL_FRAC
|
|
print("N=1 -> N=2: THE RAIL IS WHAT MOVED\n")
|
|
print(" ⚠️ INVERTER B DOES NOT EXIST (SPIKE-04, ~2026-09-12) AND")
|
|
print(" plantsim.INVERTER_B IS INVERTER A COPIED, measured=False. The")
|
|
print(" N=2 rows are therefore about TWO COPIES OF A on a 10000 W rail.")
|
|
print(" The site B actually implies is ~9600 W (5000 + 4600), and 4600 is")
|
|
print(" B's DATASHEET and SERIAL, not a measurement, and may be DC -")
|
|
print(" goodwe/NOTES.md §1.1b. Read the rows as architecture, not as a")
|
|
print(" prediction about this house.\n")
|
|
for n, rail in ((1, 5000.0), (2, 10000.0)):
|
|
for slew in (1000.0, frac * rail):
|
|
w = family(ps, ct, gain=0.6, slew_w=slew, rail_w=rail, n=n,
|
|
tick_s=tick_s)
|
|
lab = (f"n={n} rail {rail:.0f} slew {slew:.0f}"
|
|
+ (" [B = A COPIED]" if n == 2 else ""))
|
|
row(lab, w)
|
|
print(f"\n The derived value at the REAL 9600 W site is "
|
|
f"{frac * 9600:.0f} W per tick, and")
|
|
print(" validate.py reads it off `sum(unit_caps(opts))` - the same number")
|
|
print(" `Controller.site_max_w` builds - so adding inverter B moves the")
|
|
print(" bound with no operator action and no second derivation.")
|
|
print("\n ⚠️ AND THE DIRECTION IS NOT THE ONE THE TICKET ASSUMED. A rail that")
|
|
print(" GROWS loosens this bound: slew_w 1000 crosses a 9600 W rail in")
|
|
print(" 19.2 s and PASSES, while at 5000 W the same 1000 is the")
|
|
print(" configuration that was measured failing. That is what the")
|
|
print(" constraint says and it is correct - the bound is on how long the")
|
|
print(" crossing TAKES, and a bigger rail at the same step size takes")
|
|
print(" longer to cross.")
|
|
print("\n ⚠️ AND THE >= 900 W STEP COUNT STAYS AT 48/51 AT THE DERIVED VALUE")
|
|
print(" ON THE N=2 ROW, WHICH IS NOT A FAILURE - IT IS THE THRESHOLD GOING")
|
|
print(" STALE, on the same day and in the same way as the constant. 900 W")
|
|
print(" was a meaningful step against a 5000 W rail; against 10000 W the")
|
|
print(" derived step IS 1111 W, and a check written on the absolute 900")
|
|
print(" would report the correct tuning as broken. Anything grading this")
|
|
print(" symptom at N>=2 has to grade it as a FRACTION of the rail too.")
|
|
print("\n WHAT DOES NOT GET WORSE HERE, STATED BECAUSE IT WAS EXPECTED TO.")
|
|
print(" Over the SAME family of 1.0-4.2 kW export steps, doubling the rail")
|
|
print(" does not worsen the over-absorption (958 -> 810 W): the extra rail")
|
|
print(" is headroom these disturbances never ask for. That is a statement")
|
|
print(" about this family, not about the site - a bigger site sees bigger")
|
|
print(" steps, and the over-absorption is a `gain` term either way, which")
|
|
print(" the enforced ratio does not bound and this card did not change.")
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("--repro", action="store_true")
|
|
ap.add_argument("--slew", action="store_true")
|
|
ap.add_argument("--gain", action="store_true")
|
|
ap.add_argument("--roster", action="store_true")
|
|
ap.add_argument("--all", action="store_true")
|
|
ap.add_argument("--tick", type=float, default=None,
|
|
help="seconds per control cycle (default: control.LOOP_TICK_S)")
|
|
ap.add_argument("--rail", type=float, default=5000.0)
|
|
a = ap.parse_args()
|
|
if not any((a.repro, a.slew, a.gain, a.roster, a.all)):
|
|
a.all = True
|
|
sim = find_sim()
|
|
if sim is None:
|
|
sys.exit("slew_sim: admin/goodwe's sim/ is not beside this checkout, so "
|
|
"plantsim.Plant is unavailable. Clone it as a sibling or set "
|
|
"$GOODWE_SIM. This is a missing repository, NOT a result about "
|
|
"the control law - do not report it as one.")
|
|
ps, ct = load(sim)
|
|
tick = a.tick if a.tick is not None else ct.LOOP_TICK_S
|
|
print(f"slew_sim: plant from {sim}")
|
|
print(f" control law from {ADDON / 'goodwe_controller' / 'app'}")
|
|
print(f" {ps.Inverter.from_spec('A', ps.INVERTER_A).describe()}")
|
|
print(f" tick {tick:.1f} s, SLEW_RAIL_FRAC {ct.SLEW_RAIL_FRAC:.4f} "
|
|
f"= {ct.LOOP_TICK_S:.0f} s / {ct.PLANT_SETTLE_S:.0f} s\n")
|
|
if a.repro or a.all:
|
|
repro(ps, ct, tick); print()
|
|
if a.slew or a.all:
|
|
slew_table(ps, ct, tick, a.rail); print()
|
|
if a.gain or a.all:
|
|
gain_table(ps, ct, tick, a.rail); print()
|
|
if a.roster or a.all:
|
|
roster_table(ps, ct, tick)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|