SAFETY-79: peak guard keeps a margin below the limit
New option peak_margin_w (default 50 W, 0-500 W). The peak guard, the
forecast checks and the maintenance charge sizing aim at
peak_cap_w - peak_margin_w; the month peak, the bill, headroom, warnings
and every notice keep the real cap. Owner decision 2026-09-28.
peak_margin_w 0 is byte-identical to ed739d4 (tools/safety79_identity.py,
test_peak_margin.py part 1). validate.py rule 12 refuses a value outside
0-500, not whole, or not below the peak limit.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UBtqPrG1mDLXzTEBnhEkkV
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
ed739d4cdb
commit
7c4c8f5c66
@@ -368,7 +368,7 @@ check("...and it grows monotonically through the quarter, so the guard is at "
|
||||
BATT_NOW = 400.0
|
||||
a_edge = averager_at(CEIL, 450.0)
|
||||
p_edge = PeakTracker.project(a_edge, B + timedelta(seconds=450))
|
||||
d_edge = decide(p_edge, batt_w=BATT_NOW, cap_w=CAP_W, reading=None,
|
||||
d_edge = decide(p_edge, batt_w=BATT_NOW, cap_w=CAP_W, margin_w=0.0, reading=None,
|
||||
achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S, soc=SOC_OK, soc_floor=SOC_FLOOR)
|
||||
e_edge = excess_w(p_edge, CEIL)
|
||||
print(f" held exactly AT the ceiling for 450 s: projected "
|
||||
@@ -383,7 +383,7 @@ check("at the engagement boundary the excess is zero, so the floor is exactly "
|
||||
# than on the excess. Re-derived here rather than described: a guard that
|
||||
# released as soon as `excess <= 0` hands the actuator back at the exact instant
|
||||
# it is working, and the load springs straight back.
|
||||
released = decide(p_edge, batt_w=BATT_NOW, cap_w=CAP_W, reading=None,
|
||||
released = decide(p_edge, batt_w=BATT_NOW, cap_w=CAP_W, margin_w=0.0, reading=None,
|
||||
achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S, soc=SOC_OK, soc_floor=SOC_FLOOR)
|
||||
would_release_on_excess = excess_w(p_edge, CEIL) <= 0.0
|
||||
# ⚠️ `floor_w` IS FORMATTED DEFENSIVELY, AND MUTATION.md's OWN LEDGER IS WHY.
|
||||
@@ -404,7 +404,7 @@ check("...and the claim is held there, not released. `excess` falls to zero as "
|
||||
"arithmetic read as a level", would_release_on_excess and released.engaged)
|
||||
d_gone = decide(PeakTracker.project(averager_at(500.0, 450.0),
|
||||
B + timedelta(seconds=450)),
|
||||
batt_w=BATT_NOW, cap_w=CAP_W, reading=None,
|
||||
batt_w=BATT_NOW, cap_w=CAP_W, margin_w=0.0, reading=None,
|
||||
achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S, soc=SOC_OK, soc_floor=SOC_FLOOR)
|
||||
check("...and it DOES release once the load falls under what the quarter can "
|
||||
"afford, so 'threshold the floor' is not 'never let go'",
|
||||
@@ -413,7 +413,7 @@ check("...and it DOES release once the load falls under what the quarter can "
|
||||
# A hair over, and the floor is the battery plus the excess. Not a constant.
|
||||
a_over = averager_at(CEIL + 200.0, 450.0)
|
||||
p_over = PeakTracker.project(a_over, B + timedelta(seconds=450))
|
||||
d_over = decide(p_over, batt_w=BATT_NOW, cap_w=CAP_W, reading=None,
|
||||
d_over = decide(p_over, batt_w=BATT_NOW, cap_w=CAP_W, margin_w=0.0, reading=None,
|
||||
achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S, soc=SOC_OK, soc_floor=SOC_FLOOR)
|
||||
print(f" 200 W over for 450 s: {d_over.explain()}")
|
||||
check("a quarter projected over the ceiling engages, and the floor is the "
|
||||
@@ -422,7 +422,7 @@ check("a quarter projected over the ceiling engages, and the floor is the "
|
||||
and near(d_over.floor_w, BATT_NOW + excess_w(p_over, CEIL), 1e-6))
|
||||
check("...and the floor moves with the battery measurement, so it is a claim "
|
||||
"about delivered power rather than a fixed setpoint",
|
||||
not near(decide(p_over, batt_w=BATT_NOW + 250.0, cap_w=CAP_W, reading=None,
|
||||
not near(decide(p_over, batt_w=BATT_NOW + 250.0, cap_w=CAP_W, margin_w=0.0, reading=None,
|
||||
achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S, soc=SOC_OK, soc_floor=SOC_FLOOR).floor_w,
|
||||
d_over.floor_w, 1.0))
|
||||
|
||||
@@ -433,7 +433,7 @@ intent = 0.0
|
||||
delivered_floor = []
|
||||
intent_floor = []
|
||||
for _ in range(5):
|
||||
d_del = decide(p_over, batt_w=stuck_batt, cap_w=CAP_W, reading=None,
|
||||
d_del = decide(p_over, batt_w=stuck_batt, cap_w=CAP_W, margin_w=0.0, reading=None,
|
||||
achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S, soc=SOC_OK, soc_floor=SOC_FLOOR)
|
||||
delivered_floor.append(d_del.floor_w)
|
||||
intent = intent + excess_w(p_over, CEIL) # what the intent form would do
|
||||
@@ -516,7 +516,7 @@ check(f"a loop already discharging harder ({BIG:.0f} W) than the floor is not "
|
||||
absurd = averager_at(LIVE_MAX_W * 4, 870.0)
|
||||
p_absurd = PeakTracker.project(absurd, B + timedelta(seconds=870))
|
||||
DEV_MAX = LIVE_MAX_W - 500.0 # a device rating tighter than max_w
|
||||
d_absurd = decide(p_absurd, batt_w=1000.0, cap_w=CAP_W, reading=None,
|
||||
d_absurd = decide(p_absurd, batt_w=1000.0, cap_w=CAP_W, margin_w=0.0, reading=None,
|
||||
achievable_w=min(LIVE_MAX_W, DEV_MAX), max_hold_s=MAX_HOLD_S, soc=SOC_OK, soc_floor=SOC_FLOOR)
|
||||
r6 = resolve([Claim.limit("device", P_SAFETY, -DEV_MAX, DEV_MAX, "rating"),
|
||||
Claim.limit("supervised", P_SAFETY, -LIVE_MAX_W, LIVE_MAX_W, "max_w"),
|
||||
@@ -599,7 +599,7 @@ print(f" a real PeakStore on a missing volume: state={rd_broken.state}, "
|
||||
f"headroom_w()={tr_broken.headroom_w(B)}")
|
||||
check("the real store answers NOT_OBSERVED rather than a peak of 0 W",
|
||||
rd_broken.state == NOT_OBSERVED and rd_broken.peak_w is None)
|
||||
d_broken = decide(p_over, batt_w=BATT_NOW, cap_w=CAP_W, reading=rd_broken,
|
||||
d_broken = decide(p_over, batt_w=BATT_NOW, cap_w=CAP_W, margin_w=0.0, reading=rd_broken,
|
||||
achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S, soc=SOC_OK, soc_floor=SOC_FLOOR)
|
||||
print(f" {d_broken.explain()}")
|
||||
check("AND THE GUARD STILL ENGAGES ON IT. An unreadable peak must not disarm "
|
||||
@@ -627,7 +627,7 @@ print("4. blindness: three ways a projection is not evidence")
|
||||
# (a) NO OPEN BLOCK. A fresh averager projects 0 W - under every ceiling.
|
||||
fresh = QuarterAverager(phases=1, max_hold_s=MAX_HOLD_S)
|
||||
p_fresh = PeakTracker.project(fresh, B)
|
||||
d_fresh = decide(p_fresh, batt_w=0.0, cap_w=CAP_W, reading=None,
|
||||
d_fresh = decide(p_fresh, batt_w=0.0, cap_w=CAP_W, margin_w=0.0, reading=None,
|
||||
achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S, soc=SOC_OK, soc_floor=SOC_FLOOR)
|
||||
print(f" fresh averager: projected {p_fresh.projected_w:.0f} W, "
|
||||
f"block_start={p_fresh.block_start}; {d_fresh.explain()}")
|
||||
@@ -655,7 +655,7 @@ p_frozen = PeakTracker.project(frozen_av, B + timedelta(seconds=600))
|
||||
RAILED = LIVE_MAX_W # the loop has wound to the rail
|
||||
is_frozen = looks_frozen(FREEZE_S + 1.0, -RAILED, LIVE_MAX_W,
|
||||
FreezeWatchdogConfig(hold_s=FREEZE_S))
|
||||
d_frozen = decide(p_frozen, batt_w=-RAILED, cap_w=CAP_W, reading=None,
|
||||
d_frozen = decide(p_frozen, batt_w=-RAILED, cap_w=CAP_W, margin_w=0.0, reading=None,
|
||||
achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S, soc=SOC_OK, soc_floor=SOC_FLOOR,
|
||||
frozen=is_frozen)
|
||||
print(f" meter stuck at -4000 W with the command at {-RAILED:+.0f} W: "
|
||||
@@ -680,7 +680,7 @@ check("...and the same command NOT frozen is left alone, so the blind branch is
|
||||
"not simply always on",
|
||||
near(resolve(SAFE + [Claim.set("loop", P_LOOP, -RAILED, "grid"),
|
||||
peakguard.claim(decide(
|
||||
p_frozen, batt_w=-RAILED, cap_w=CAP_W,
|
||||
p_frozen, batt_w=-RAILED, cap_w=CAP_W, margin_w=0.0,
|
||||
reading=None, achievable_w=LIVE_MAX_W,
|
||||
max_hold_s=MAX_HOLD_S, soc=SOC_OK, soc_floor=SOC_FLOOR, frozen=False))
|
||||
or Claim.limit("noop", P_PEAK)]).target_w,
|
||||
@@ -692,7 +692,7 @@ gapped = averager_at(4200.0, 600.0, gap_at=200.0, gap_s=GAP)
|
||||
p_gap = PeakTracker.project(gapped, B + timedelta(seconds=600))
|
||||
clean = averager_at(4200.0, 600.0)
|
||||
p_clean = PeakTracker.project(clean, B + timedelta(seconds=600))
|
||||
d_gap = decide(p_gap, batt_w=0.0, cap_w=CAP_W, reading=None,
|
||||
d_gap = decide(p_gap, batt_w=0.0, cap_w=CAP_W, margin_w=0.0, reading=None,
|
||||
achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S, soc=SOC_OK, soc_floor=SOC_FLOOR)
|
||||
print(f" a {GAP:.0f} s hole at 4200 W: uncovered {uncovered_s(p_gap):.0f} s, "
|
||||
f"projected {p_gap.projected_w:.0f} W vs {p_clean.projected_w:.0f} W with "
|
||||
@@ -756,7 +756,7 @@ check("a renamed held value now raises AttributeError on the first cycle. The "
|
||||
OLD = (p_held.partial_ws if hasattr(p_held, "partial_ws")
|
||||
else HELD * 300.0) # acc, which the old form would still have had
|
||||
old_projected = (HELD * 300.0 + 0.0 * (QUARTER_S - 300.0)) / QUARTER_S
|
||||
d_true = decide(p_held, batt_w=0.0, cap_w=CAP_W, reading=None,
|
||||
d_true = decide(p_held, batt_w=0.0, cap_w=CAP_W, margin_w=0.0, reading=None,
|
||||
achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S, soc=SOC_OK, soc_floor=SOC_FLOOR)
|
||||
print(f" same fixture: shipped projection {p_held.projected_w:.0f} W "
|
||||
f"(engaged={d_true.engaged}), the defaulted form's "
|
||||
@@ -832,7 +832,7 @@ def replay(path, *, guarded: bool, cap_w=CAP_W, max_w=LIVE_MAX_W, pack=None,
|
||||
next_cycle = t + timedelta(seconds=CYCLE_S)
|
||||
cycles += 1
|
||||
d = decide(PeakTracker.project(ing.averager, t),
|
||||
batt_w=batt, cap_w=cap_w, reading=tracker.peak(t),
|
||||
batt_w=batt, cap_w=cap_w, margin_w=0.0, reading=tracker.peak(t),
|
||||
achievable_w=max_w, max_hold_s=MAX_HOLD_S, soc=soc,
|
||||
soc_floor=floor_pct, frozen=False)
|
||||
if d.floor_w is not None and d.floor_w < 0.0:
|
||||
@@ -1417,7 +1417,8 @@ check("B3: main.py sizes the floor from the MEASURED battery power, not from "
|
||||
# `datetime.now()`. Every number below therefore comes from ONE instant, and the
|
||||
# tolerances that used to absorb the wall clock are gone.
|
||||
_proj3 = _seen["projection"]
|
||||
_kw = dict(cap_w=CAP_W, reading=b3.peak.peak(), achievable_w=LIVE_MAX_W,
|
||||
_kw = dict(cap_w=CAP_W, margin_w=b3.peak_margin_w, reading=b3.peak.peak(),
|
||||
achievable_w=LIVE_MAX_W,
|
||||
max_hold_s=b3.p1.averager.max_hold_s, soc=SOC_OK,
|
||||
soc_floor=SOC_FLOOR)
|
||||
_intent = _shipped_decide(_proj3, batt_w=STANDING, **_kw)
|
||||
@@ -1554,7 +1555,8 @@ check("SAFETY-24 adds exactly the two keys it names and no more: §14's kW/W "
|
||||
"trap `capacity_peak_limit_kw` stays declined, and no speculative "
|
||||
"`peak_guard`/`peak_shave` block appeared beside it",
|
||||
_peak_keys == []
|
||||
and sorted(PEAK_OPTION_DEFAULTS) == ["bms_soc_floor", "peak_soc_floor"])
|
||||
and sorted(PEAK_OPTION_DEFAULTS) == ["bms_soc_floor", "peak_margin_w",
|
||||
"peak_soc_floor"]) # + SAFETY-79
|
||||
# Graded on the parsed call syntax, not on the text: the module docstring names
|
||||
# `peak_cap_w` while arguing that it does not read it, so a grep would be
|
||||
# satisfied by deleting the explanation and would fail on writing one.
|
||||
@@ -1634,13 +1636,13 @@ check("...and the defaulting form that was ACTUALLY shipped once in this "
|
||||
discharge_permitted(float(None or 100), SOC_FLOOR) is None)
|
||||
|
||||
# --- the decision -----------------------------------------------------------
|
||||
d_ok = decide(p_hungry, batt_w=BATT_NOW, cap_w=CAP_W, reading=None,
|
||||
d_ok = decide(p_hungry, batt_w=BATT_NOW, cap_w=CAP_W, margin_w=0.0, reading=None,
|
||||
achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S, soc=SOC_OK,
|
||||
soc_floor=SOC_FLOOR)
|
||||
d_lim = decide(p_hungry, batt_w=BATT_NOW, cap_w=CAP_W, reading=None,
|
||||
d_lim = decide(p_hungry, batt_w=BATT_NOW, cap_w=CAP_W, margin_w=0.0, reading=None,
|
||||
achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S,
|
||||
soc=SOC_FLOOR, soc_floor=SOC_FLOOR)
|
||||
d_unread = decide(p_hungry, batt_w=BATT_NOW, cap_w=CAP_W, reading=None,
|
||||
d_unread = decide(p_hungry, batt_w=BATT_NOW, cap_w=CAP_W, margin_w=0.0, reading=None,
|
||||
achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S, soc=None,
|
||||
soc_floor=SOC_FLOOR)
|
||||
print(f" with battery: {d_ok.explain()}")
|
||||
@@ -1682,7 +1684,7 @@ check("floor-limited and saturated are MUTUALLY EXCLUSIVE, by construction: the
|
||||
# notification that matters.
|
||||
d_quiet = decide(PeakTracker.project(averager_at(500.0, 450.0),
|
||||
B + timedelta(seconds=450)),
|
||||
batt_w=BATT_NOW, cap_w=CAP_W, reading=None,
|
||||
batt_w=BATT_NOW, cap_w=CAP_W, margin_w=0.0, reading=None,
|
||||
achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S,
|
||||
soc=SOC_FLOOR - 5.0, soc_floor=SOC_FLOOR)
|
||||
print(f" an EMPTY pack on a quiet quarter: {d_quiet.explain()}")
|
||||
@@ -1694,7 +1696,7 @@ check("an empty pack on a quarter that does not need shaving is IDLE, not "
|
||||
and d_quiet.floor_w is None)
|
||||
# And the BLIND branch is untouched: it never forces a discharge, so there is
|
||||
# nothing for an energy floor to bound there.
|
||||
d_blind_low = decide(p_frozen, batt_w=-RAILED, cap_w=CAP_W, reading=None,
|
||||
d_blind_low = decide(p_frozen, batt_w=-RAILED, cap_w=CAP_W, margin_w=0.0, reading=None,
|
||||
achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S,
|
||||
soc=SOC_FLOOR - 5.0, soc_floor=SOC_FLOOR, frozen=True)
|
||||
check("...and an empty pack does not disturb the BLIND branch either: SAFETY-"
|
||||
@@ -1751,7 +1753,7 @@ for load in (0.0, CEIL - 500.0, CEIL, CEIL + 300.0, CEIL + 3000.0,
|
||||
for bw in (-2000.0, 0.0, 400.0, 4000.0):
|
||||
for sc in (None, 0.0, BMS_FLOOR, SOC_FLOOR, SOC_FLOOR + 0.5, 100.0):
|
||||
for fl in (SOC_FLOOR, BMS_FLOOR + 1.0, 55.0):
|
||||
_d = decide(_pr, batt_w=bw, cap_w=CAP_W, reading=None,
|
||||
_d = decide(_pr, batt_w=bw, cap_w=CAP_W, margin_w=0.0, reading=None,
|
||||
achievable_w=LIVE_MAX_W,
|
||||
max_hold_s=MAX_HOLD_S, soc=sc, soc_floor=fl)
|
||||
SWEEP_N += 1
|
||||
@@ -1798,8 +1800,8 @@ check("`soc` and `soc_floor` are REQUIRED keyword-only arguments of decide() "
|
||||
"with no default. A default on either is TEST-14's exact shape and would "
|
||||
"be the quiet fallback this card exists to remove - and a default on "
|
||||
"`soc_floor` would additionally be CFG-03's fourth defaults site",
|
||||
_required_kw == {"batt_w", "cap_w", "reading", "achievable_w", "soc",
|
||||
"soc_floor"})
|
||||
_required_kw == {"batt_w", "cap_w", "margin_w", "reading",
|
||||
"achievable_w", "soc", "soc_floor"})
|
||||
_req_fields = tuple(f.name for f in dataclasses.fields(PeakDecision)
|
||||
if f.default is dataclasses.MISSING
|
||||
and f.default_factory is dataclasses.MISSING)
|
||||
@@ -1948,7 +1950,7 @@ check("...and it reaches the action log too", any("FLOOR_LIMITED" in e
|
||||
for e in c24.events))
|
||||
# A missing SoC sensor, through the same real cycle. `inputs_ok` stops the loop
|
||||
# at 0 W in that state, so the guard's own answer is what is graded here.
|
||||
d_nosoc = decide(p_hungry, batt_w=0.0, cap_w=CAP_W, reading=None,
|
||||
d_nosoc = decide(p_hungry, batt_w=0.0, cap_w=CAP_W, margin_w=0.0, reading=None,
|
||||
achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S,
|
||||
soc=None, soc_floor=SOC_FLOOR)
|
||||
check("decide() handed soc=None answers floor-limited rather than a full "
|
||||
@@ -2226,7 +2228,7 @@ def replay_loop(window, *, start_soc, soc_floor, usable_wh=REF_PACK_WH):
|
||||
next_cycle = t + timedelta(seconds=CYCLE_S)
|
||||
cycles += 1
|
||||
d = decide(PeakTracker.project(ing.averager, t), batt_w=batt,
|
||||
cap_w=CAP_W, reading=tracker.peak(t),
|
||||
cap_w=CAP_W, margin_w=0.0, reading=tracker.peak(t),
|
||||
achievable_w=LOOP_MAX_W, max_hold_s=MAX_HOLD_S, soc=soc,
|
||||
soc_floor=soc_floor, frozen=False)
|
||||
dec = compute(prev_w, w - batt, batt, LOOP_TUNING, sat_count=sat,
|
||||
@@ -5462,7 +5464,7 @@ MUT.append(("blindness() returns None - the degenerate 0 W projection is graded"
|
||||
|
||||
# 2. the frozen term dropped from the guard's inputs.
|
||||
MUT.append(("frozen=False - SAFETY-18's term dropped",
|
||||
not decide(p_frozen, batt_w=-RAILED, cap_w=CAP_W, reading=None,
|
||||
not decide(p_frozen, batt_w=-RAILED, cap_w=CAP_W, margin_w=0.0, reading=None,
|
||||
achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S, soc=SOC_OK, soc_floor=SOC_FLOOR,
|
||||
frozen=False).engaged and d_frozen.engaged,
|
||||
"part 4: 'THE BACKSTOP: SAFETY-18's runaway ... cut to 0 W'"))
|
||||
@@ -5533,7 +5535,7 @@ _early = sum(1 for _t in (500.0, 1000.0, 2000.0)
|
||||
if discharge_permitted(SOC_FLOOR - 1.0, SOC_FLOOR) is not None
|
||||
and not decide(PeakTracker.project(
|
||||
averager_at(_t, 450.0), B + timedelta(seconds=450)),
|
||||
batt_w=BATT_NOW, cap_w=CAP_W, reading=None,
|
||||
batt_w=BATT_NOW, cap_w=CAP_W, margin_w=0.0, reading=None,
|
||||
achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S,
|
||||
soc=SOC_FLOOR - 1.0, soc_floor=SOC_FLOOR).engaged)
|
||||
MUT.append((f"the SoC gate moved above the `wanted > 0` test ({_early} quiet "
|
||||
@@ -5963,7 +5965,7 @@ print("13. SAFETY-38 round 4: the ORDERED PAIR inside PeakDecision.explain()")
|
||||
_S38_PROJ = PeakTracker.project(averager_at(CAP_W - 1200.0, 450.0),
|
||||
B + timedelta(seconds=450))
|
||||
_S38_CEIL_W, _S38_CEIL_FROM = quarter_ceiling_w(CAP_W, obs_hi)
|
||||
_S38_IDLE = decide(_S38_PROJ, batt_w=0.0, cap_w=CAP_W, reading=obs_hi,
|
||||
_S38_IDLE = decide(_S38_PROJ, batt_w=0.0, cap_w=CAP_W, margin_w=0.0, reading=obs_hi,
|
||||
achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S,
|
||||
soc=SOC_OK, soc_floor=SOC_FLOOR)
|
||||
# UX-08 2d: the row is in plain words now, and the pair is still ordered.
|
||||
@@ -6042,7 +6044,7 @@ def _proj_at(net_w, elapsed):
|
||||
|
||||
def _decide_on(projection, *, batt_w, soc=SOC_OK, cap_w=CAP_W,
|
||||
achievable_w=LIVE_MAX_W, reading=None, frozen=False):
|
||||
return decide(projection, batt_w=batt_w, cap_w=cap_w, reading=reading,
|
||||
return decide(projection, batt_w=batt_w, cap_w=cap_w, margin_w=0.0, reading=reading,
|
||||
achievable_w=achievable_w, soc=soc, soc_floor=SOC_FLOOR,
|
||||
max_hold_s=MAX_HOLD_S, frozen=frozen)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user