Files
goodwe-addon/goodwe_controller/config.yaml
T
adminandClaude Opus 5 f000547518
checks / unit (pull_request) Successful in 4m38s
SAFETY-30: grade each inverter against its own power, and the site's total against the meter
THE DEFECT, in one line. `inv.sign_wd.evaluate(stood, self.grid, ...)` compared
the command THIS controller put on ONE actuator against what the WHOLE SITE is
exchanging with the network. At a roster of one those are two views of the same
thing - specs.md §6.2's identity - and that is what makes the check sound and
what earned it the best record in the safety table (NOTES.md: 93 % agreement
while healthy against 4 % during runaway, four observed episodes, zero false
positives). At two inverters they are not, and it fails in BOTH directions:

  FALSE TRIP  inverter A commanded -1500 W while the meter reads +1500 W is
              evidence of nothing when inverter B is discharging 3000 W exactly
              as told. Nothing is faulty and the highest-value check fires.
  MISSED      the converse, and it is worse. A unit commanded +1500 W and
              MEASURED at -1500 W, beside a sibling at +1500 W, leaves P1
              exactly where a healthy site leaves it. The summation destroys the
              evidence and no threshold recovers it.

DEV-02 had already shipped the per-INSTANCE half - one SignWatchdog per
inverter, namespaced latch, a trip on B no longer latching A. What was left was
the COMPARISON, and it is two questions rather than one:

  PER UNIT  this unit's standing command against ITS OWN measured power
            (`Inverter.batt`, holding register 0x0518, per-instance since
            DEV-02). Conclusion: "the actuator I am driving is not doing what I
            told it" - a fact about one device, so the remedy stays the per-unit
            `failsafe` SET that arbiter.CLAIM_SCOPE has classified SCOPE_UNIT
            from the day it was written. That entry read "the evidence is
            site-wide and the remedy is not"; the first half is now simply true.
  SITE      the SUM of the standing commands against the meter, as a new
            `sign_site` SET at P_SAFETY on the site actuator, latched under flat
            `site_sign_tripped` / `site_sign_reason`. This is the 2026-08-20
            fault day and NO per-unit check can see it, because every inverter
            may be faithfully executing its share of a number that is itself
            wrong. It stops every inverter and the banner names the site.

WHAT IT IS NOT, said here because two guards that overlap without saying so is
how a false-alarm generator gets built. It is not SAFETY-31: circulation.py
compares the units AGAINST EACH OTHER and catches them pushing power THROUGH
each other, which nets to zero at the meter and about which no single command is
evidence. This compares each unit against ITS OWN COMMAND. They overlap in
exactly one scenario - a unit reversing while its sibling pulls the other way -
and test_watchdog.py asserts on that fixture that both fire and what each
concludes. It is also not the saturation freeze, which is a MAGNITUDE test
(|command - measured| > 500 W) that shapes the loop and fires on a merely
saturated unit; this is a DIRECTION test that latches and parks one.

THE FOUR NUMBERS, RE-DERIVED AT TWO INVERTERS - one carried with a justification
and one refused. 1890 closed-loop runs on admin/goodwe's TEST-19 two-master rig
in which NOTHING is faulty, swept over the whole range config.yaml accepts for
`gain` and `slew_w`, across load reversals with write staggers and flapping
loads, and RUN AT BOTH ROSTERS so the N=1 arm is the control:

  unit_sign_disagree_w  250   `saturation_w / 2`. The check gates on both sides,
                              opposed, so the smallest gap it can see is 500 W -
                              exactly where compute()'s own anti-windup test
                              already says the inverter is not following. It
                              cannot fire on a divergence the control law calls
                              ordinary tracking. Worst legitimate opposition at
                              250 W: 10.0 s; the curve is nearly flat, because a
                              unit's own power chases its own command.
  unit_sign_disagree_s   85   8.5x that 10.0 s, 2.8x the firmware's own 30 s
                              link veto (inverter.LINK_STALE_S), under half the
                              180 s site hold.
  site_sign_disagree_w  300   CARRIED. Its argument is the meter's noise floor
                              and the 15 W deadband; the roster touches neither,
                              and the campaign agrees - the gate opens in 994 of
                              1050 runs at two inverters and 955 of 1050 at one.
  site_sign_disagree_s  325   NOT CARRIED, and this is the number the card
                              exists to not assume. The worst legitimate
                              crossing at 300 W is 110 s with one inverter and
                              215 s with TWO, because the command must travel a
                              rail that is the roster sum at a `slew_w` that is
                              not - reachable at gain 0.05, slew_w 50 and a
                              2.2 kW load reversal, all schema-legal. 180 s
                              would have shipped a false trip. 325 s is 1.51x
                              the measured worst, inside the 900 s billing
                              quarter and inside every episode of the fault day.

AT ONE INVERTER NOTHING CHANGES, and "nothing" is asserted rather than assumed.
The two questions are the same sentence there, so only the detector that shipped
is armed: same comparison against the meter, same 300 W / 180 s, same reason
string byte for byte, same namespaced latch and flat rollback mirror, same
`failsafe` claim, same banner, same single status row - and the site detector is
NEVER CALLED, counted on the object over twelve cycles.

VERIFIED BY BREAKING IT. Thirteen mutations on fresh per-mutation trees with no
__pycache__, mounted :ro, with the battery refusing to report at all unless the
baseline is green. Twelve red on the first pass. M4 - the site half armed at N=1
as well - SURVIVED, because the check read `disagree_since is None` and the unit
detector trips first, parks the inverter and leaves the site standing at 0 W:
the assertion ran, the sentence was true, and the data could not reach the
failure it named. Replaced with a call counter on the object; M4 is red now.
Recorded as a survivor in DECISIONS/SAFETY-30-per-unit-sign.md §5, with the
first battery's own RED BASELINE - a shared mutation directory read mid-copy -
discarded rather than reported.

RECORDED, NOT FIXED: the site comparison is defeated by a square-wave load whose
half period is about one control cycle, which holds it opposed for ~1000 s. The
N=1 control arm is what makes that interpretable - it is identical at the roster
this add-on ships, so it is a property of comparing a lagged command against a
meter and has nothing to do with the second inverter. It wants an alternation
test rather than a number, and a card of its own.

SAFETY-24's lo-only asymmetry proof re-run unchanged: 1296 combinations, 0
negative floors, `hi` +inf on every claim, every claim on the SITE actuator.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-04 09:33:20 +02:00

590 lines
30 KiB
YAML

name: GoodWe RS485 Controller
version: "0.5.2"
slug: goodwe_controller
description: >-
Drives a GoodWe ES/BP battery inverter over RS485 by emulating its smart
meter, holding net grid exchange at zero and running a monthly battery
maintenance cycle.
url: https://gittea.kammenstraatha.duckdns.org/admin/goodwe-addon
arch:
- aarch64
- amd64
- armv7
init: false
startup: application
boot: auto
# Needed to read the meter and to write the ESPHome setpoint number.
homeassistant_api: true
hassio_api: true
# Own diagnostics/operations UI inside HA. This is what a field tech and a
# remote supporter both look at, so it is not optional.
ingress: true
ingress_port: 8099
panel_icon: mdi:battery-sync
panel_title: GoodWe
# Optional: publish status entities by MQTT discovery. `want` rather than
# `need` - the add-on runs fine with no broker, it just publishes nothing.
services:
- mqtt:want
options:
# --- sources (required) ---------------------------------------------------
meter_entity: sensor.p1_meter_active_power
meter_invert: false
soc_entity: ""
batt_entity: ""
batt_invert: false
setpoint_entity: ""
# --- P1 meter ingestion (specs §5.2 / §14 `meter:`) -------------------------
# `off` keeps the original single meter_entity path above, so an existing
# install is untouched until it opts in. ha_dsmr subscribes to the DSMR
# integration's entities over the HA WebSocket; mqtt_p1 reads the topic below;
# ha_signed reads ONE signed HA entity (+ import / - export), which is what a
# HomeWizard P1 publishes and what ha_dsmr cannot consume.
# homewizard_local skips Home Assistant entirely and polls the meter's own
# local API. ⚠️ It is the ONLY mode whose sensor.p1_sample_age_s measures when
# the meter REPORTED rather than when the value last CHANGED - HA emits
# nothing at all for a repeated reading - so it is the only mode a firmware
# watchdog may threshold. See DOCS.md.
meter_source: "off"
meter_phases: 1
meter_max_age_s: 30
meter_mqtt_topic: ""
# homewizard_local only. The meter's own address, `host` or `host:port`.
p1_host: ""
# homewizard_local only. Seconds between polls. Must be well under
# meter_max_age_s - the age is never fresher than this interval - and there is
# nothing to gain below the meter's own ~5.0 s update rate (NOTES.md:640).
meter_poll_s: 5
# The two UNSIGNED Belgian registers. The EMS derives net power from them
# (import - export); do NOT point these at a signed template sensor.
p1_import_entity: ""
p1_export_entity: ""
# Optional, in L1..L3 order. Required for the capacity-tariff peak on a
# three-phase connection; the list length must equal meter_phases.
p1_phase_import_entities: []
p1_phase_export_entities: []
# ha_signed only. ONE signed net-power sensor: positive = import from the
# grid, negative = export to it. Do NOT split it into two template sensors -
# the split is done in the add-on (p1.split_signed) precisely so the sign
# convention is tested rather than living in unreviewed YAML.
p1_net_entity: ""
# Optional, in L1..L3 order, each one signed the same way. Same role as
# p1_phase_import_entities: the capacity-tariff peak on a three-phase
# connection. The list length must equal meter_phases.
p1_phase_net_entities: []
# --- control ---------------------------------------------------------------
max_w: 2000
gain: 0.6
slew_w: 1000
deadband_w: 15
target_grid_w: -10
step_w: 10
saturation_w: 500
saturation_cycles: 3
integrator_max_w: 0
heartbeat_s: 10
stale_input_s: 15
cycle_floor_s: 15
auto_start: false
# --- SAFETY-02 sign-disagreement watchdog ----------------------------------
# Trips when the emitted setpoint and the P1 net figure point OPPOSITE ways,
# both over sign_disagree_w, continuously for sign_disagree_s. Action: 0 W and
# a failsafe that latches until an operator starts control again.
#
# Both defaults are measured, not chosen. On the captured healthy day
# (2026-08-23) the longest legitimate opposed run - a load step the loop has
# not caught up with - is 30 s, and sign agreement over the gated samples is
# 93.4 %. On the captured fault day (2026-08-20) the clusters run 0.0-10.5 %
# agreement with opposed runs up to 1752 s. 300 W / 180 s sits in the middle of
# a very wide gap: at 60 s the healthy day still trips nothing, at 300 s the
# fault day still trips 13 times. There is no reason to move either number
# without a capture that says so. They are options because the AC says
# thresholds are defaults rather than constants - not because they are knobs
# anyone is expected to turn.
#
# ⚠️ sign_disagree_w must stay BELOW max_w or the watchdog can never fire (the
# arbiter bounds the command to +-max_w). validate.py refuses that config.
sign_disagree_w: 300
sign_disagree_s: 180
# --- SAFETY-30 per-inverter sign-disagreement watchdog ---------------------
# ⚠️ A DIFFERENT COMPARISON, NOT A SECOND COPY OF THE ONE ABOVE, AND IT IS
# ARMED ONLY WHEN THIS SITE HAS MORE THAN ONE INVERTER. The pair above grades
# the SITE's total command against P1 - one meter, one grid connection. This
# pair grades EACH inverter's own share against THAT inverter's own measured
# power (holding register 0x0518), and it catches the fault the meter cannot
# answer for once there are two units: a unit doing the opposite of what it
# was told, while its sibling covers for it and P1 reads exactly what a
# healthy site reads.
#
# At ONE inverter these two keys do nothing at all. §6.2's identity makes the
# site's total command that inverter's command, so the pair above is already
# asking this question of the same actuator, and arming a second detector
# would be two latches and two banners for one fault.
#
# Both defaults are measured on admin/goodwe's two-master rig, over 1890
# closed-loop runs in which nothing is faulty, swept across the whole range
# this file accepts for `gain` and `slew_w`. 250 W is `saturation_w / 2`, so
# the smallest divergence this can see is exactly the gap at which the control
# law itself already says the inverter is not following; 85 s is 8.5x the
# worst legitimate opposition those runs produce (10.0 s), 2.8x the firmware's
# own 30 s link veto, and under half the 180 s the site check takes. See
# app/watchdog.py's SIGN_OPTION_DEFAULTS and
# DECISIONS/SAFETY-30-per-unit-sign.md.
#
# ⚠️ unit_sign_disagree_w must stay BELOW max_w, for sign_disagree_w's reason:
# a share is bounded to +-max_w, so a threshold at or above it can never fire.
# validate.py refuses that config.
unit_sign_disagree_w: 250
unit_sign_disagree_s: 85
# --- SAFETY-30 SITE sign-disagreement watchdog -----------------------------
# ⚠️ THE SAME COMPARISON `sign_disagree_*` MAKES, AT THE ROSTER WHERE IT STOPS
# BEING A STATEMENT ABOUT ONE INVERTER. At two or more units the SUM of the
# standing commands is graded against P1, and the trip stops the whole site -
# what it catches is the site being asked for the wrong thing, which every
# per-inverter check is blind to because each unit is faithfully executing its
# share. At ONE inverter these two keys do nothing: `sign_disagree_*` above is
# already asking this of the same actuator.
#
# The threshold is the shipped 300 W, CARRIED, and the reason is that the
# argument behind it does not depend on the roster: near zero the sign is
# noise, the deadband is 15 W, and there is one P1 whatever is behind it.
#
# ⚠️ THE WINDOW IS NOT CARRIED, AND THAT IS A MEASUREMENT. The transient 180 s
# exists to reject is the loop crossing zero after a load reversal, and the
# command has to travel the whole SITE rail at `slew_w` per cycle - a rail
# that is the roster sum while `slew_w` is not. On 1890 two-inverter runs in
# which nothing is faulty, the worst continuous legitimate opposition at 300 W
# is 110 s at one inverter and 215 s at two: carrying 180 s across would have
# shipped a false trip reachable at `gain: 0.05`, `slew_w: 50` and an ordinary
# 2.2 kW load reversal. 325 s is 1.51x that, inside the 900 s billing quarter,
# and still inside every episode of the 2026-08-20 fault day.
site_sign_disagree_w: 300
site_sign_disagree_s: 325
# --- SAFETY-18 frozen-but-fresh meter detector -----------------------------
# Seconds the meter may keep answering with an UNCHANGED measurement while the
# command stands at the rail before the add-on raises an alarm. Both halves
# are required: neither one alone is safe, and app/freeze.py carries why.
#
# ALARM ONLY. It does not command 0 W and does not latch. A false trip parks
# the battery; a false notification costs an alert.
#
# ⚠️ Only `meter_source: homewizard_local` can see this at all - it is the
# only transport that reads the meter's own document. On every other source
# the status page says NOT COVERED rather than "armed".
#
# 300 s is measured, not chosen. Over 2399 s of the live house at the
# converged operating point (sim/scenarios/p1-age-2026-08-26.jsonl.gz, mean
# grid -14.2 W, 1194 polls, 735 of them repeating the previous reading) the
# LONGEST run without a distinct reading is 15.4 s (the largest unchanged_s any
# sample observes is 8.0 s). 300 s is 19.5x the pessimistic figure, and
# still inside the 900 s quarter that bills the capacity tariff.
meter_freeze_s: 300
# --- SAFETY-31: circulating power ------------------------------------------
# ⚠️ CFG-03. THESE TWO LINES ARE THE SOURCE OF TRUTH FOR THESE DEFAULTS, and
# `app/circulation.py`'s `CIRC_OPTION_DEFAULTS` is the ONE in-code copy - the
# Dockerfile does not put this file in the image, so the running add-on cannot
# read it. test_watchdog.py pins the copy by READING these lines, so a drift
# goes red in CI rather than shipping. Add a third option in three places -
# options:, schema:, CIRC_OPTION_DEFAULTS - and in no fourth.
#
# Circulating power is sum(|Bi|) - |sum(Bi)| over MEASURED per-inverter power:
# two inverters pushing against each other, which NETS TO ZERO AT THE METER
# and is therefore invisible to every other guard in this add-on. Both numbers
# below were measured on the TEST-19 two-master rig over 924 closed-loop runs
# in which nothing was faulty; circulation.py carries the curve they were read
# off and the argument for each. They are NOT sensitivity preferences.
#
# Both are ignored at one inverter, where the quantity is identically zero.
circulation_w: 700
circulation_hold_s: 75
# --- maintenance -----------------------------------------------------------
# ⚠️ CFG-03. THESE EIGHT LINES ARE THE SOURCE OF TRUTH FOR THE MAINTENANCE
# DEFAULTS. The Supervisor reads this file and the operator edits this file,
# so nothing else in the add-on gets an opinion about what a missing
# `maintenance_*` key means. There is exactly ONE in-code copy -
# `app/maintenance.py`'s `MAINT_OPTION_DEFAULTS` - because the Dockerfile does
# not put this file in the image and the running add-on therefore cannot read
# it; test_maintenance.py pins that copy by READING these lines, so a drift
# goes red in CI rather than shipping. main.py and validate.py both read the
# mirror and type no number of their own.
#
# Before this card the same defaults were written down three times and had
# already disagreed: this block said 2000 while main.py and MaintConfig said
# 2500, so an options dict with the key ABSENT passed validation at 2000 and
# then ran at 2500 against a 2000 W ceiling - a silent 0 W arbiter
# contradiction. Changing one of these numbers now moves every consumer.
maintenance_enabled: false
maintenance_interval_days: 28
maintenance_start_hour: 10
# ⚠️ BOTH MUST STAY AT OR BELOW max_w, and that is why they are 2000 rather
# than the 2500 they shipped as. The cheap-window charge shaping asks the
# arbiter for `hi = -maintenance_charge_w` against the supervised `lo =
# -max_w`: above the ceiling that is `lo > hi`, a contradiction resolved to
# 0 W, so maintenance would SILENTLY NEVER CHARGE in a cheap window on a
# default install. validate.py refuses that config (rule 6), so these
# defaults had to become self-consistent with the max_w above rather than
# ship a configuration the validator rejects.
#
# The discharge side is clamped rather than contradicted - it drains at max_w
# and the arbiter says so - so it is only warned about; it is 2000 here for
# symmetry and to keep the shipped config warning-free.
#
# Raising max_w (the live reference install runs 5000) is what unlocks a
# faster maintenance cycle. Raise max_w FIRST, then these.
maintenance_discharge_w: 2000
maintenance_charge_w: 2000
maintenance_soc_floor: 11
maintenance_soc_target: 99
maintenance_hold_min: 120
# --- tariff / capacity tariff (all optional) ------------------------------
peak_forecast_entity: ""
peak_cap_w: 3500
# --- SAFETY-24: the energy backstop under SAFETY-07's forced discharge -----
# The peak guard can COMMAND a discharge to hold a billed quarter under
# peak_cap_w. Until this card it was bounded from below by nothing but the
# pack's own BMS cut-out - a real floor, but the PACK's protection rather than
# the OPERATOR's. On the 2026-08-20 replay the guard asks for 878 Wh in a day.
#
# peak_soc_floor is the percent below which that discharge is not sized. When
# it binds the guard reports FLOOR-LIMITED, keeps charging blocked (a 0 W
# floor), and the add-on raises ONE notification saying the quarter will be
# exceeded - failing to shave is never silent, because the euro figure is the
# whole point of the guard.
#
# ⚠️ THIS IS NOT maintenance_soc_floor AND MUST NOT BE MERGED WITH IT.
# maintenance_soc_floor is the DRAIN TARGET of the monthly recalibration: the
# depth the cycle deliberately goes to, once a month, on purpose. This is the
# depth a money-saving discharge may never reach on any day. The right values
# differ, and sharing one key would couple two features that want to move
# independently.
#
# ⚠️ 20 IS DERIVED, NOT PICKED. The reference pack is 13.8 kWh usable and the
# worst modelled guard day is 878 Wh, i.e. 6.4 % - so 10 points above the
# pack's own cut-out leaves the guard ~1.6 of its worst observed days of room
# and still never reaches the BMS.
#
# ⚠️⚠️ SAFETY-25 - WHAT THIS DOES NOT BOUND, AND IT IS THE HALF THE NAME HIDES.
# peak_soc_floor BOUNDS THE PEAK GUARD ONLY. IT IS NOT A PACK-WIDE RESERVE.
# Ordinary self-consumption - the grid-following loop that holds the meter at
# zero - has no SoC floor of any kind, so on a sustained-load evening it will
# discharge STRAIGHT PAST this number and go on until the pack's own BMS
# cut-out (bms_soc_floor) stops it. Replayed on the committed 2026-08-20
# capture, 19:00-22:00 UTC: a 13.8 kWh pack starting at 21 % ends the evening
# at 10.0 %, and 343 of 587 control cycles deliver discharge with the pack
# already below this floor. Setting this key higher does not change that by
# one tenth of a percent - the same replay at every value the schema accepts
# (5..60) ends at the same 10.0 %.
#
# The reason is structural, not an oversight: the peak guard files a `lo`
# limit (a LOWER bound on the setpoint), which is what makes it provably
# unable to cap a discharge or contradict the arbiter - and therefore also
# unable to stop one the loop itself wants. See peakguard.py's module note and
# DECISIONS/SAFETY-25-loop-soc-floor.md, which records the options for a
# loop-side reserve and is waiting on the product owner. Until that is
# answered, the BMS is the only floor under self-consumption.
peak_soc_floor: 20
# The pack's OWN cut-out percent - what the BMS enforces whatever is set
# above. Register 0x0560 reads 10 on the reference GW5000S-BP, consistent with
# DoD 90. It is here so validate.py can REFUSE a peak_soc_floor at or below
# it: such a floor can never bind, the BMS wins, and the status page would
# report an energy backstop that does not exist.
bms_soc_floor: 10
# ⚠️ THE OLD PRICE PATH, AND IT IS NOT DEAD YET. These two entities are the
# add-on delegating the whole price question to Home Assistant: main.py reads
# both and derives ONE BOOLEAN, `cheap = now <= avg`, which is consumed by
# nothing except the maintenance cycle's cheap-window charge floor. That is
# all they have ever done. They are kept, because retiring them would take
# the maintenance cycle's only price signal away on a live install before
# anything replaces it - TARIFF-03 (static profile) and OPT-04 (arbitrage)
# are the cards that replace it, and neither has landed.
#
# ⚠️ They may NOT be combined with price_provider below. Two price sources on
# one site, one of which silently wins, is exactly the "double up" the PM
# named; validate.py rule 7 refuses that configuration at startup rather than
# letting an operator believe the provider they configured is driving
# something. See app/validate.py.
price_now_entity: ""
price_avg_entity: ""
# --- price providers (TARIFF-02, specs §9.3 / §14 `tariffs:`) --------------
# `off` is the shipped default and means the §11.4 no-price-data row applies:
# static profile, plan provisional, NO ARBITRAGE. Nothing in 0.5.x consumes
# the series yet - OPT-04 is the consumer - so turning this on today fetches,
# validates and publishes prices and changes no energy decision.
#
# ⚠️ THESE DEFAULTS ARE MIRRORED ONCE, IN app/price.py's
# PRICE_OPTION_DEFAULTS, and test_price.py reads THIS FILE and asserts the two
# agree key for key. Do not add a third copy in main.py - CFG-03 is open
# because that has already happened three times.
#
# markup/taxes are the §14 `tariffs.energy_price.*` figures and are added by
# the FRAMEWORK, only for the components the chosen plugin declares its feed
# does not already contain. engie_dynamic, totalenergies_dynamic, eneco_eddy
# and manual_csv publish a figure that already carries the markup, so setting
# it under those is ignored - and logged as ignored, never applied twice.
price_provider: "off"
price_markup_eur_kwh: 0.03
price_taxes_eur_kwh: 0.06
price_csv_path: "/share/goodwe_prices.csv"
price_feed_url: ""
price_horizon_h: 48
price_timeout_s: 10
# --- static / fully-fixed tariffs (TARIFF-03, specs §9.2 / §11.4) ----------
# §14's `tariffs.static_profile`, and "" is its `null`. §9.2: "a large share
# of the installed base is on [a static profile]" - this is what makes those
# sites first-class rather than "price_provider: off, good luck".
#
# A JSON object, on one line, whose periods must tile every day type exactly
# once - no gap and no overlap. A gap is REFUSED at startup rather than
# defaulted, because an invented price is one an optimiser would trade on.
# A fully-fixed tariff is ONE period covering 00:00-24:00, all days; the
# add-on derives "fixed" from there being one distinct price, so there is no
# box to tick and none to tick wrongly.
#
# {"periods": [
# {"name":"day", "from":"07:00","to":"22:00","days":"mon-fri","eur_per_kwh":0.34},
# {"name":"night","from":"22:00","to":"07:00","days":"mon-fri","eur_per_kwh":0.26},
# {"name":"night","from":"00:00","to":"24:00","days":"sat,sun", "eur_per_kwh":0.26}]}
#
# ⚠️ A window written "22:00-07:00 mon-fri" does NOT cover Saturday morning -
# a period applies on the day the CLOCK reads, so the weekend row above is
# required, not decorative. See DOCS.md.
#
# eur_per_kwh is ALL-IN (energy + markup + taxes, §9.3), the same basis the
# dynamic providers are assembled onto. price_markup_eur_kwh and
# price_taxes_eur_kwh are NOT added to it.
#
# ⚠️ RECOMMENDED EVEN ON A DYNAMIC SITE. §11.4's degradation row is "no price
# data -> fall back to the static profile"; with this empty there is nothing
# to fall back to and a dead feed leaves the site with no tariff at all.
# validate.py warns about exactly that.
static_profile: ""
# §9.3: injection is priced SEPARATELY, and in Belgium it is often near zero -
# which by itself strongly favours self-consumption over export. A scalar, not
# a second series: no shipped supplier publishes a dynamic injection curve.
export_price_eur_kwh: 0.04
# --- price arbitrage (OPT-04, specs §10.1 priority 3 / §17) ---------------
# Charge in the cheapest published hour, discharge in the dearest, and ONLY
# when the spread covers what the round trip costs. Off in the shipped
# configuration: this is the first strategy in the add-on that moves energy
# against the meter on purpose, so it is armed deliberately or not at all.
#
# ⚠️ IT NEEDS A PRICE SOURCE AND A P1 METER, and validate.py refuses the
# combinations that cannot work rather than letting them look armed. With
# `price_provider: off` and no `static_profile` there is no series to trade
# on; with `meter_source: off` the capacity-tariff peak guard never runs, and
# §9.1 puts peak shaving above arbitrage - a charge nothing measures is the
# exact failure that guard exists to prevent.
#
# ⚠️ AT THE SHIPPED THRESHOLD A STATIC DAY/NIGHT SITE DOES NOT TRADE. The
# realistic Belgian day/night spread is about 0.08 EUR/kWh and the threshold
# below is 0.10, so the verdict is "the firm spread does not cover this
# cycle's cost" and nothing happens - correctly. That is a product decision
# (this number, and app/price.py's widening factors) and it is open with the
# owner; the code is correct at any value and no number was picked to make a
# trade appear.
arbitrage_enabled: false
# §10.1's `cycle_cost_threshold_eur`: what one full round trip costs per kWh
# - the wear on the pack plus the conversion losses. A spread below this is
# a cycle that loses money and pack life, so it is not taken.
cycle_cost_threshold_eur: 0.10
# The reserve arbitrage may not spend, and the headroom it may not overfill.
# Deliberately NOT the maintenance_soc_* keys: those describe a monthly
# balancing cycle, and pointing a trading strategy at them would couple two
# unrelated policies through one number.
arbitrage_soc_floor: 20
arbitrage_soc_ceiling: 95
# --- site ------------------------------------------------------------------
estop_fitted: false
log_level: info
schema:
meter_entity: str
meter_invert: bool
soc_entity: str
batt_entity: str
batt_invert: bool
setpoint_entity: str
meter_source: list(off|ha_dsmr|mqtt_p1|ha_signed|homewizard_local)
# ⚠️ 2 is accepted by this range but is not a real Belgian connection. A
# telegram whose phase count disagrees is rejected at ingest and logged, so a
# mis-set 2 shows up immediately as "0 telegrams accepted" rather than as a
# quietly wrong number.
meter_phases: int(1,3)
meter_max_age_s: int(5,300)
meter_poll_s: int(1,60)
meter_mqtt_topic: str?
p1_host: str?
p1_import_entity: str?
p1_export_entity: str?
p1_phase_import_entities:
- str
p1_phase_export_entities:
- str
p1_net_entity: str?
p1_phase_net_entities:
- str
max_w: int(100,5000)
gain: float(0.05,1.0)
slew_w: int(50,5000)
deadband_w: int(0,500)
target_grid_w: float(-200,200)
step_w: int(1,100)
saturation_w: int(100,2000)
saturation_cycles: int(1,10)
integrator_max_w: int(0,15000)
heartbeat_s: int(2,25)
stale_input_s: int(5,120)
cycle_floor_s: int(5,60)
auto_start: bool
# No "off" value, deliberately: this is a P0 safety check and the schema is
# not the place to make disarming it a supported configuration.
sign_disagree_w: int(50,5000)
sign_disagree_s: int(30,1800)
# SAFETY-30. No "off" value, for SAFETY-02's reason.
#
# ⚠️ THE THRESHOLD'S RANGE IS THE SAME AS THE SITE CHECK'S ON PURPOSE, AND THE
# WINDOW'S FLOOR IS NOT. On the two-inverter rig the per-unit comparison's
# worst continuous legitimate opposition is nearly FLAT in the threshold -
# 25.0 s at 50 W, 15.0 s at 150 W, 10.0 s at 250 W, 5.0 s at 500 W - because a
# unit's own power chases its own command rather than the load. So no value in
# this range puts the guard outside the regime it can be answered for, and the
# floor does not have to be a knee the way `circulation_w`'s does.
unit_sign_disagree_w: int(50,5000)
# The floor of 30 is `inverter.LINK_STALE_S`, the firmware's own veto window:
# below it, a unit not executing its share is a LINK fault the add-on already
# answers by dropping the unit from the split, and firing inside that window
# would be a second, louder alarm about a state that is already being handled.
unit_sign_disagree_s: int(30,1800)
# SAFETY-30's SITE pair. Same ranges as `sign_disagree_*`, because it is the
# same comparison asked of the site total - and no "off" value, for the same
# reason. ⚠️ THERE IS DELIBERATELY NO validate.py RULE PAIRING
# site_sign_disagree_w WITH max_w, unlike the other two thresholds: the bound
# on the SITE command is `Controller.site_max_w`, the ROSTER SUM, and
# `validate_startup_config()` is handed options rather than a roster. A rule
# written against max_w would refuse a perfectly reachable two-inverter
# configuration the day CFG-04 lands. The schema range is the bound here.
site_sign_disagree_w: int(50,5000)
site_sign_disagree_s: int(30,1800)
# No "off" value, for SAFETY-02's reason: disarming a safety alarm is not a
# supported configuration. ⚠️ THE FLOOR OF 60 IS NOT A RECOMMENDATION. At 300 s
# the slowest a HEALTHY meter's 1 Wh register can tick and still look unchanged
# is 12 W, which is inside the 15 W deadband, so the two terms of the
# conjunction are mutually exclusive rather than merely far apart. At 60 s that
# figure is 60 W and the argument is gone - only the margin is left. See
# app/freeze.py.
meter_freeze_s: int(60,3600)
# SAFETY-31. No "off" value, for SAFETY-02's and meter_freeze_s's reason:
# disarming a safety alarm is not a supported configuration.
#
# ⚠️ THE FLOOR OF 400 IS NOT A RECOMMENDATION, IT IS THE MEASURED EDGE OF THE
# REGIME IN WHICH THIS GUARD CAN BE ANSWERED FOR. On the TEST-19 rig, the
# worst CONTINUOUS opposition produced by a site where nothing is faulty - a
# flapping load reversing every control cycle, swept over the whole range this
# schema accepts for `gain` and `slew_w` - is 5.0 s at a 400 W threshold and
# above, 10.0 s at 300 W, 40.0 s at 250 W and 505.0 s at 100 W. Below the
# knee, legitimate operation holds the sensor over the threshold for MINUTES
# and no window shorter than the 900 s billing quarter could tell that from a
# fault. A configurable value under 400 W would be a false-alarm generator
# that this project's own doctrine (SAFETY-15, SAFETY-19) forbids shipping.
circulation_w: int(400,5000)
# The floor of 30 is `inverter.LINK_STALE_S`: below the firmware's own veto
# window a unit still executing a stale share is a LINK fault the add-on
# already handles by dropping it from the split, and firing inside that window
# would raise a second, louder alarm about a state already being managed. The
# ceiling of 900 is the quarter that bills the capacity tariff - a circulating
# loop must be caught and acted on well inside one.
circulation_hold_s: int(30,900)
# ⚠️ CFG-03 - THIS IS WHERE A RANGE LIVES, AND THE ONLY PLACE IT LIVES. The
# `options:` block above holds the DEFAULT; this block holds the BOUNDS, and
# `MAINT_OPTION_DEFAULTS` deliberately mirrors the former and not the latter -
# the Supervisor is what enforces a range and the Supervisor reads only this
# file, so a second copy of `int(500,5000)` in Python would be a place to
# disagree and nothing else. What is NOT expressible here is a relation
# BETWEEN keys: `maintenance_charge_w` must also stay at or below `max_w`, the
# schema grammar has no way to say so, and that is why validate.py's rule 6 is
# written in code instead. Add a ninth maintenance option in three places -
# options:, schema:, MAINT_OPTION_DEFAULTS - and in no fourth.
maintenance_enabled: bool
maintenance_interval_days: int(1,90)
maintenance_start_hour: int(0,23)
maintenance_discharge_w: int(500,5000)
maintenance_charge_w: int(500,5000)
maintenance_soc_floor: int(5,30)
maintenance_soc_target: int(50,100)
maintenance_hold_min: int(5,480)
peak_forecast_entity: str?
peak_cap_w: int(500,15000)
# SAFETY-24. No "off" value: a guard that can command a discharge does not get
# a supported way to have no energy bound at all. The ranges OVERLAP on
# purpose - the schema cannot express "above bms_soc_floor", so validate.py
# rule 8 is what refuses the pair, with a message naming both numbers.
peak_soc_floor: int(5,60)
bms_soc_floor: int(0,50)
price_now_entity: str?
price_avg_entity: str?
# ⚠️ The provider list is written out rather than left as `str`, so the
# Supervisor rejects a typo in the UI. validate.py ALSO refuses an unknown id
# against app/price.py's PROVIDERS registry, because sim/inject.py and the
# checks reach validate_startup_config() with a dict the Supervisor never saw.
# Adding a sixth plugin means adding it here and to PROVIDERS - and nowhere
# else, which is AC2.
price_provider: list(off|generic_dayahead|engie_dynamic|totalenergies_dynamic|eneco_eddy|manual_csv)
price_markup_eur_kwh: float(0,1)
price_taxes_eur_kwh: float(0,1)
price_csv_path: str?
price_feed_url: str?
price_horizon_h: int(1,72)
price_timeout_s: int(2,60)
# A JSON string rather than a nested schema, deliberately: it is ONE option
# key (§14 has one), it survives config.yaml's flat options block, and
# parse_static_profile() accepts an already-parsed dict too, so a future
# nested schema is a config.yaml change and no code change at all.
static_profile: str?
# ⚠️ The lower bound is NEGATIVE on purpose. Belgian injection tariffs have
# gone below zero (you pay to export at midday in summer), and a schema that
# bottoms out at 0 would make that site inexpressible.
export_price_eur_kwh: float(-1,1)
# OPT-04. ⚠️ The threshold's lower bound is 0 and not negative: it is a COST,
# and a negative one is a threshold every spread clears - arbitrage with the
# brake removed. app/arbitrage.py refuses it in code too, because
# validate_startup_config() is also sim/inject.py's entry point and can be
# handed any dict at all.
arbitrage_enabled: bool
cycle_cost_threshold_eur: float(0,1)
arbitrage_soc_floor: int(5,95)
arbitrage_soc_ceiling: int(10,100)
estop_fitted: bool
log_level: list(trace|debug|info|warning|error)