checks / unit (pull_request) Successful in 8m0s
Two files, and no third. config.yaml 0.10.1 -> 0.11.0; CHANGELOG.md gains `## 0.11.0` and three corrections to sections below it. A MINOR BUMP, NOT A PATCH. TEL-11 adds an option - `pv_entity`, 75 keys -> 76 in `options:` and `schema:` together - which is the same test 0.8.0, 0.9.0 and 0.10.0 were cut on. Nothing here can refuse a stored blob: the key is `str?` with an `options:` default of "", validate.py does not mention it, no default moved and no enum widened, so a blob that starts the add-on on 0.10.1 starts it on 0.11.0. THE TABLE HAS TWO ROWS AND THE WINDOW HAD FOUR MERGES. CI-9's follow-up (9f6b9f1, `.gitea/workflows/checks.yml` only) and SEC-2 (239e120, `docs/RELEASE.md` + README.md) are excluded under PKG-15's rule - AUDIENCE, NOT IMPORTANCE. Neither file is under `goodwe_controller/`, neither is in the built image, neither is in predeploy_check.py's RUNTIME tuple, and there is no version of this add-on on which an operator can observe either. SEC-2 is the closer call and the section argues it in full rather than asserting it: it is the more consequential ticket of the two - it is why this release is a pull request rather than a push to master - and it is still a procedure for whoever cuts a release, not something the person deciding whether to press Update can act on. Both are named in the section with pointers to PR #102 / checks.yml and PR #108 / docs/RELEASE.md. WHAT THE ENTRIES LET AN OPERATOR DECIDE. SAFETY-56: behaviour byte-identical (6030175ed2ab..., reproduced in the CI image over 2390 capture reads, a 66-cell grid stepped 120 times and six driven worlds), three surfaces named with the old and new wording side by side - the STALE_READING line quoted onto the action log and status page, the SAFETY-37 ERROR log line, and the readiness row that asserted a false fact about a named entity. TEL-11: unset is byte-identical in BOTH the control and telemetry documents, set is byte-identical in the control document only; the three TEL-11 rows of that table were re-measured in the CI image on this tree (control 846a10a5... twice, telemetry a443a6ce... / 4e6d1cb2...). It is stated plainly that pv_entity is not a control input and does not change what the loop does, and that absence publishes `unknown` rather than 0. THREE CORRECTIONS BELOW THE NEW SECTION, WITHOUT REWRITING HISTORY. The 0.10.1 preamble said SAFETY-56 was open; the SAFETY-57 entry said its accusatory diagnosis half was "deliberately untouched"; the 0.10.0 preamble left SAFETY-56 with no pointer while SAFETY-57 had one. All three quotations are what the 0.10.1 image actually prints and are left standing - a reader on 0.10.1 has to recognise them - each now scoped to 0.10.1 and pointed at `## 0.11.0`. VERIFIED. test_version_gate.py part 11 all four: 11a one contents table and the slicer closed (302 lines under '0.11.0'), 11b 0.11.0 == config.yaml, 11c all three RETIRED_PROVIDERS ids named - scoped to a pre-0.10.0 crossing and pointing at `## 0.10.0`, which is where the evidence is - 11d still exactly one `## Unreleased` below, PKG-08's, untouched. 31/31 in the CI image, checks.yml's exact command, purged tree, `:ro`. Not deployed and not installed. Shipping is a separate pull request from release/1.0 into master, per docs/RELEASE.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
1167 lines
64 KiB
YAML
1167 lines
64 KiB
YAML
name: GoodWe RS485 Controller
|
|
version: "0.11.0"
|
|
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:
|
|
# ==========================================================================
|
|
# HARDWARE - the inverter, its actuator and its own sensors
|
|
# ==========================================================================
|
|
setpoint_entity: ""
|
|
soc_entity: ""
|
|
batt_entity: ""
|
|
batt_invert: false
|
|
|
|
# ⚠️ EMPTY IS NOT "UNCONFIGURED", IT IS THE SINGLE-INVERTER CONFIGURATION, and
|
|
# it is what every install that upgrades into this version lands on. An empty
|
|
# list means THE SIX FLAT KEYS - `setpoint_entity`, `soc_entity`,
|
|
# `batt_entity`, `batt_invert`, `max_w` and `bms_soc_floor` - DESCRIBE THE
|
|
# ONE INVERTER: same entities, same rating, same behaviour, no operator
|
|
# action, which is the only migration story this key was allowed to have.
|
|
# `build_roster()` reads this and nothing else decides the shape.
|
|
#
|
|
# Declared, it REPLACES those six keys rather than extending them. Each entry
|
|
# names its own actuator, its own rating and its own sensors:
|
|
#
|
|
# inverters:
|
|
# - name: inverter-a
|
|
# setpoint_entity: number.goodwe_a_setpoint_w
|
|
# soc_entity: sensor.goodwe_a_soc
|
|
# batt_entity: sensor.goodwe_a_battery_power
|
|
# max_w: 5000
|
|
# bms_soc_floor: 10
|
|
# - name: inverter-b
|
|
# setpoint_entity: number.goodwe_b_setpoint_w
|
|
# soc_entity: sensor.goodwe_b_soc
|
|
# batt_entity: sensor.goodwe_b_battery_power
|
|
# max_w: 4600
|
|
# bms_soc_floor: 10
|
|
#
|
|
# ⚠️ THE 4600 IS INVERTER B'S DATASHEET FIGURE, NOT A MEASUREMENT AND NOT A
|
|
# PLACEHOLDER. SPIKE-04a (2026-09-04) identified B as a GoodWe GW5048D-ES,
|
|
# serial `55048ESU22AW0992`, whose datasheet gives Max. Charging / Discharging
|
|
# Power as 4600 W; nothing will be connected to its MPPTs, so the battery path
|
|
# is the ceiling and the 5000 VA AC nameplate is not. The 3000 W this example
|
|
# used to carry was a guess made before the unit was known. ⚠️ It is STILL not
|
|
# measured: the datasheet prints 4600 W under BATTERY INPUT DATA, so it may be
|
|
# DC, and `max_w` is graded against 0x0518 - the inverter's own AC power. If it
|
|
# is DC the AC ceiling is nearer 4324 W. SAFETY-20's `min(max_w, dev_max)` is
|
|
# what settles that once the device range is read; see goodwe/NOTES.md §1.1b,
|
|
# which records both readings and picks neither.
|
|
#
|
|
# ⚠️ AND THE `bms_soc_floor: 10` BESIDE IT IS FROM THE SAME PLACE - THE
|
|
# DATASHEET AND THE SERIAL, NOT A PACK ANYONE HAS READ. No second inverter
|
|
# exists on this site (SPIKE-04, ≈ 2026-09-12 for B); the cut-out must be read
|
|
# off the pack that is actually installed (register 0x0560 on the reference
|
|
# unit) before that pack is commanded. It reads 10 here because pack A reads
|
|
# 10, which makes this example DEGENERATE ON THE FLOOR AXIS: a site whose two
|
|
# packs floor at the SAME percent cannot separate `min`, `max`, first or last
|
|
# over the roster, and every SAFETY-32 fixture that DIFFERS the floors differs
|
|
# it for exactly that reason. Do not "simplify" those fixtures to match this
|
|
# example.
|
|
#
|
|
# ⚠️ NOTHING IS INHERITED FROM THE FLAT KEYS BY AN ENTRY THAT OMITS IT, and
|
|
# that is the whole of DEV-02 restated as a config rule. `soc`, `batt` and the
|
|
# rating became per-instance precisely so that no inverter is ever graded
|
|
# against a sibling's numbers; an entry silently inheriting `soc_entity` would
|
|
# rebuild that defect in YAML. `name`, `setpoint_entity`, `max_w` and
|
|
# `bms_soc_floor` are therefore REQUIRED of every entry (the schema below enforces it, and
|
|
# validate.py rule 9 enforces it again for the callers the Supervisor never
|
|
# sees). An omitted `soc_entity`/`batt_entity` means THIS UNIT HAS NO SUCH
|
|
# SENSOR - `inputs_ok` already stops the site for that - and never means
|
|
# "use the other one's".
|
|
#
|
|
# ⚠️ SAFETY-32: `bms_soc_floor` IS PER ENTRY AND IS REQUIRED, NOT DEFAULTED.
|
|
# It is the percent THAT pack's own BMS cuts out at, and two batteries of
|
|
# different models do not share one. A default would be this add-on inventing
|
|
# a cut-out for a battery it has not been told about, which is the same defect
|
|
# as inheriting one: the shallower pack gets protected by a floor that belongs
|
|
# to the deeper pack, and validate.py rule 8 - which refuses a peak_soc_floor
|
|
# the BMS would win against - would be grading the wrong number. Rule 8 grades
|
|
# EVERY entry and names the one it refuses. The flat key below is unchanged and
|
|
# is the single-inverter shape's own cut-out.
|
|
#
|
|
# ⚠️ OPT-09: `capacity_kwh` IS PER ENTRY AND IS OPTIONAL, WHICH IS THE
|
|
# OPPOSITE CHOICE TO `bms_soc_floor` ABOVE AND IS MADE FOR THE SAME REASON.
|
|
# It is THAT pack's usable capacity in kWh, and it is read for exactly one
|
|
# purpose: weighting the SPLIT of the site target between packs by ENERGY
|
|
# headroom rather than by power rating. The two packs here are asymmetric in
|
|
# OPPOSITE directions - 5000 W / 15.36 kWh against 4600 W / 20.48 kWh, from
|
|
# the datasheet and the serials - so a split by rating and a split by energy
|
|
# hand the work to DIFFERENT packs, and the energy one is the question ("which
|
|
# pack can sustain its share for the hour"). A missing cut-out would have to be
|
|
# DEFAULTED, and one default for two batteries protects the shallower pack with
|
|
# the deeper pack's number; a missing capacity has an honest answer instead -
|
|
# this add-on then has NO OPINION about the split and every unit weighs 1.0,
|
|
# which is exactly what shipped before this card. ⚠️ IT IS ALL OR NOTHING
|
|
# ACROSS THE ROSTER: state it on every entry or on none, because a pack
|
|
# weighted in kWh against a sibling weighing a dimensionless 1.0 is not a
|
|
# comparison. There is deliberately NO flat `capacity_kwh` - at one inverter
|
|
# there is no split to weight, so the key would be one an operator can set and
|
|
# nothing reads.
|
|
#
|
|
# ⚠️ AND THE FLAT `setpoint_entity` MUST THEN BE BLANK. Leaving it set while a
|
|
# roster is declared is two config sites aimed at actuators, one of which
|
|
# nothing writes; if it names an entity an entry also names, SAFETY-28's
|
|
# writer census refuses it as two writers on one actuator, and if it names a
|
|
# different one the add-on would run a site quietly smaller than the config
|
|
# reads. Rule 9 refuses both rather than picking one.
|
|
#
|
|
# ⚠️ THE SHAPE WAS MEASURED, NOT ASSUMED. A list of objects is not used
|
|
# anywhere else in this file, and whether Supervisor's options validator
|
|
# accepts one was an open question - `static_profile` carries its structure as
|
|
# a JSON string in a `str?` for exactly that doubt. It was settled by running
|
|
# Supervisor 2026.08.0's OWN `AppOptions` validator (the version this site
|
|
# runs) against both candidates: tools/supervisor_shape_probe.py is the
|
|
# script, DECISIONS/CFG-04-inverter-roster.md is what it printed and why the
|
|
# list won. The default `[]` is load-bearing in that record: WITHOUT it the
|
|
# validator answers `Missing option 'inverters' in root` for every existing
|
|
# options blob, and the upgrade leaves an add-on that cannot start.
|
|
inverters: []
|
|
estop_fitted: false
|
|
|
|
# TEL-11 - THE SITE'S PV PRODUCTION, IN WATTS, AND IT IS READ BY NOTHING THAT
|
|
# DECIDES ANYTHING. Optional, empty on every install that does not set it, and
|
|
# when it is empty the add-on reads no entity, announces no entity, publishes
|
|
# no topic and shows no status row.
|
|
#
|
|
# ⚠️ AN HA ENTITY AND NOT A REGISTER, BECAUSE MOST SOLAR INVERTERS ARE NOT ON
|
|
# THIS BUS. The RS485 bus this add-on speaks carries the BATTERY inverter it
|
|
# drives; a household's PV inverter is normally a different box from a
|
|
# different vendor, reporting into Home Assistant through its own integration.
|
|
# So this takes whatever `sensor.*` the site's PV integration provides, any
|
|
# brand Home Assistant can read.
|
|
#
|
|
# ⚠️ POSITIVE IS PRODUCTION, and there is deliberately no `pv_invert` beside
|
|
# `meter_invert` and `batt_invert`. Those two quantities are bipolar AND are
|
|
# read by the control path, so a reversed sensor there is a plausible number
|
|
# the site acts on; production is unipolar and is read by nothing, so a
|
|
# reversed sensor announces itself as -4 kW at noon in the history this key
|
|
# exists to create. See DECISIONS/TEL-11-pv-production.md.
|
|
#
|
|
# ⚠️ THE ENTITY'S OWN `unit_of_measurement` IS WHAT IS BELIEVED - W, kW, MW
|
|
# and the rest of Home Assistant's power units. A sensor with no unit, or with
|
|
# a unit that is not power, is REFUSED and the status page says which: there
|
|
# is no magnitude heuristic that can tell 30 W of dusk from 30 kW of noon, and
|
|
# a factor of 1000 in the one channel that exists to be believed a week later
|
|
# is worse than a refusal an operator can read.
|
|
pv_entity: ""
|
|
|
|
# ==========================================================================
|
|
# METER - where the grid reading comes from
|
|
# ==========================================================================
|
|
# `off` keeps the original single `meter_entity` path, 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_entity: sensor.p1_meter_active_power
|
|
meter_invert: false
|
|
meter_phases: 1
|
|
|
|
# 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
|
|
meter_max_age_s: 30
|
|
meter_mqtt_topic: ""
|
|
|
|
# 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: ""
|
|
|
|
# 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. 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: []
|
|
|
|
# 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: []
|
|
|
|
# ==========================================================================
|
|
# RATINGS AND LIMITS - what this site is allowed to do
|
|
# ==========================================================================
|
|
max_w: 2000
|
|
|
|
# The pack's OWN cut-out percent - what the BMS enforces whatever
|
|
# `peak_soc_floor` is set to. 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.
|
|
#
|
|
# ⚠️ SAFETY-32 - THIS KEY DESCRIBES ONE PACK, so it is the SINGLE-INVERTER
|
|
# shape's number and nothing more. Declare an `inverters:` roster and each
|
|
# entry states its own `bms_soc_floor`; this one is not read at all and is
|
|
# not inherited by any entry. Rule 8 then grades every unit separately, which
|
|
# is the whole of that card: one cut-out for two packs of different models
|
|
# protects the shallower pack with the deeper pack's number.
|
|
bms_soc_floor: 10
|
|
peak_cap_w: 3500
|
|
peak_forecast_entity: ""
|
|
|
|
# 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
|
|
|
|
# ==========================================================================
|
|
# TARIFF - what energy costs, and whether to trade on it
|
|
# ==========================================================================
|
|
# `off` is the shipped default and means the §11.4 no-price-data row applies:
|
|
# static profile, plan provisional, NO ARBITRAGE.
|
|
#
|
|
# ⚠️ SELECTING A PROVIDER IS WHAT STARTS THE FETCH, AND IT IS NOT
|
|
# `arbitrage_enabled` (TARIFF-10). With a provider set and trading off - the
|
|
# shipped default - the add-on fetches the feed every 30 minutes, validates
|
|
# it, records the day in /data/price_history.json and reports what it holds
|
|
# on the *Price history* status row. It changes NO energy decision: the cheap
|
|
# window the maintenance cycle reads still comes from `static_profile`, and
|
|
# §10.1's arbitrage is gated on `arbitrage_enabled` alone. Until TARIFF-10
|
|
# the fetch was gated on `arbitrage_enabled` too, so this option did nothing
|
|
# whatsoever on a shipped install - the store was never even created.
|
|
#
|
|
# ⚠️ 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. manual_csv carries an all-in figure, so setting
|
|
# either under it is ignored - and logged as ignored, never applied twice.
|
|
# `custom_formula` (TARIFF-15) carries energy AND markup - your contract's
|
|
# formula IS the markup - so it takes price_taxes_eur_kwh only, and your
|
|
# price_markup_eur_kwh is ignored under it, with a line in the log saying so.
|
|
price_provider: "off"
|
|
price_markup_eur_kwh: 0.03
|
|
|
|
# TARIFF-18. The all-in Flemish non-energy figure, EUR/kWh, incl. 6 % VAT.
|
|
# Its components are named in app/price.py's FLEMISH_TAXES_COMPONENTS_EUR_KWH
|
|
# and test_price.py section 1b re-adds them:
|
|
#
|
|
# Fluvius distribution (Antwerpen) 5,35 c EUR/kWh
|
|
# Bijzondere accijns, "Alle verbruik" 4,876
|
|
# Bijdrage groene stroom en WKK Vlaanderen 1,51
|
|
# Energiebijdrage (0 EUR/MWh since 2026-08-01) 0
|
|
# ------
|
|
# 11,736 c EUR/kWh = 0.11736
|
|
#
|
|
# ⚠️ THIS IS PER-DSO AND YOURS IS PROBABLY DIFFERENT. Only the distribution
|
|
# row moves: across the eight Fluvius DSOs it runs 5,28 c EUR/kWh
|
|
# (Midden-Vlaanderen) to 6,70 (West), so the Flemish total runs about 0.117
|
|
# to 0.131. Antwerpen is near the LOW end, which is the direction this
|
|
# default should err in - it is inert on which hour is cheapest, but §17's
|
|
# negative-price guard reads the ABSOLUTE all-in price, and a figure set too
|
|
# high would carry a genuinely negative hour above zero.
|
|
#
|
|
# ⚠️ WALLONIA AND BRUSSELS DO NOT BELONG IN THIS SCALAR AT ALL. Their
|
|
# distribution tariff has a day and a night band; use price_taxes_profile.
|
|
price_taxes_eur_kwh: 0.11736
|
|
|
|
# §9.3: injection is priced SEPARATELY, and in Belgium it is often near zero -
|
|
# which by itself strongly favours self-consumption over export. This is the
|
|
# FLAT injection price, for a site paid the same for every exported kWh.
|
|
#
|
|
# ⚠️ THE REASON THIS COMMENT USED TO GIVE - "no shipped supplier publishes a
|
|
# dynamic injection curve" - WAS FALSE, and TARIFF-07 struck the same
|
|
# sentence from app/price.py and DOCS.md without reaching this file. Eneco
|
|
# publishes `0,1 x BELPEX-H - 1,188` c EUR/kWh on the same card as its
|
|
# consumption formula, ENGIE publishes `(B x eSpot_15) - A`, and Aspiravi
|
|
# `0,07 x Belpex - 2`. A site on one of those sets price_export_formula
|
|
# below; this scalar stays the fixed-product default and is what every site
|
|
# uses today.
|
|
export_price_eur_kwh: 0.04
|
|
|
|
# TARIFF-16. The INJECTION leg as a formula, in price_formula's own grammar
|
|
# and with its OWN coefficients: Eneco uses 0,102 on consumption and 0,100 on
|
|
# injection, and injection is VAT-EXEMPT where consumption is at 6%, so
|
|
# `vat_pct` here is normally 0. A negative `offset` is expected - it is what
|
|
# the published lines carry - and the resulting price legitimately goes below
|
|
# zero in the midday hours a PV household exports in.
|
|
#
|
|
# ⚠️ NO STRATEGY IN THIS ADD-ON READS IT. arbitrage.py prices the sell side
|
|
# as displaced IMPORT and compares two all-in CONSUMPTION prices; it does not
|
|
# read the injection price at all. This option is what the operator is shown
|
|
# and the shape a future export-aware strategy would rest on. Empty by
|
|
# default, which leaves every existing site on the scalar above.
|
|
price_export_formula: ""
|
|
|
|
# §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: ""
|
|
|
|
# TARIFF-17. THE TAXES COMPONENT AS A DAY/NIGHT TABLE, for the sites whose
|
|
# network tariff actually has one. "" means the flat price_taxes_eur_kwh
|
|
# above decides, which is what every install has had until now and is CORRECT
|
|
# IN FLANDERS: Fluvius distribution is a single rate, and its row on the
|
|
# tariff card offers only `Normaal | Uitsl. nacht` - exclusive night being a
|
|
# separate physical circuit, not the night register of a dual-rate meter.
|
|
#
|
|
# The Walloon and Brussels DSOs are on the same card with FOUR columns -
|
|
# `Enkelvoudig | Dag | Nacht | Uitsl. nacht` - and the gap is real money
|
|
# (c EUR/kWh): ORES dag 13,27 / nacht 7,39; AIESH 15,17 / 8,21; Tecteo RESA
|
|
# 12,20 / 7,01; Sibelga 9,39 / 7,10; ORES IMPACT 2026 16,578 / 5,090.
|
|
#
|
|
# ⚠️ WHY THIS IS NOT COSMETIC. The optimiser reads the SPREAD (max - min) and
|
|
# which hour is cheapest. A flat number is additive and identical every hour,
|
|
# so it cancels out of both - it cannot distort anything, and it also cannot
|
|
# express this. These bands are ANTI-CORRELATED with what the battery wants
|
|
# to do (cheap network hours are cheap spot hours), so flattening them
|
|
# UNDERSTATES the true spread and declines cycles that would have paid. See
|
|
# DOCS.md, "The three buckets".
|
|
#
|
|
# Same JSON grammar as static_profile above - the same tiling rule, the same
|
|
# day selectors, the same DST handling, because it is literally the same
|
|
# parser. ⚠️ THE VALUE IS THE TAXES COMPONENT ALONE, NOT AN ALL-IN PRICE:
|
|
#
|
|
# {"periods": [
|
|
# {"name":"dag", "from":"07:00","to":"22:00","days":"mon-fri","eur_per_kwh":0.1327},
|
|
# {"name":"nacht","from":"22:00","to":"07:00","days":"mon-fri","eur_per_kwh":0.0739},
|
|
# {"name":"nacht","from":"00:00","to":"24:00","days":"sat,sun", "eur_per_kwh":0.0739}]}
|
|
#
|
|
# ⚠️ SET THIS *OR* price_taxes_eur_kwh, NOT BOTH. Two writers of one quantity,
|
|
# one of which silently wins, is refused at startup: leave the scalar at its
|
|
# shipped default when you fill this in. A scalar still sitting on the
|
|
# SUPERSEDED 0.06 is accepted too (TARIFF-18 moved that default, and your
|
|
# stored options carry the old number whether you typed it or not) - the
|
|
# refusal is about a figure you chose, not about one this add-on wrote.
|
|
price_taxes_profile: ""
|
|
|
|
# TARIFF-15. YOUR OWN TARIFF CARD, as two fixed coefficients. Used only when
|
|
# `price_provider: custom_formula`; "" is unset.
|
|
#
|
|
# A Belgian dynamic contract prices energy as `B x index + A`, and the two
|
|
# coefficients exist in exactly ONE place - the tariff card in your
|
|
# klantenzone. There is no machine-readable source for them anywhere (CREG's
|
|
# tariff-card pages 404, CREG Scan is a JS wizard, VREG was unreachable), so
|
|
# no plugin and no scraper can ever know YOUR numbers. You are the only party
|
|
# who can see them.
|
|
#
|
|
# A JSON object, on one line. You type what your card prints, IN THE CARD'S
|
|
# OWN UNITS; every unit conversion and the VAT multiplication are the
|
|
# add-on's job, because those are exactly the two steps that silently cost a
|
|
# factor of 10, 100 or 1.06.
|
|
#
|
|
# {"index":"epex_be","resolution":"hourly","factor":0.102,"offset":1.00,
|
|
# "units":"c_eur_per_kwh","vat_pct":6,
|
|
# "card":{"month":"2026-09","index_eur_per_mwh":133.9905,
|
|
# "expect_incl_vat":15.55}}
|
|
#
|
|
# price = (factor x index_eur_per_mwh + offset) x (1 + vat_pct/100), in
|
|
# `units`, converted to EUR/kWh by the add-on.
|
|
#
|
|
# ⚠️ THE `card` BLOCK IS REQUIRED AND IT IS THE SAFETY MECHANISM. Your card
|
|
# prints the formula, the reference index it assumed AND the resulting price.
|
|
# At startup the add-on recomputes that printed price from the numbers you
|
|
# typed and REFUSES TO START unless the two agree to within 0.01 - naming the
|
|
# discrepancy. That catches VAT left out or applied twice, a transposed
|
|
# digit, a unit MISMATCH between the coefficients and the printed price
|
|
# (10x/100x/1000x), and the expensive one: NETWORK CHARGES FOLDED INTO
|
|
# `offset`. A unit error applied to BOTH sides at once cancels out of that
|
|
# comparison; the add-on refuses that separately, by checking the card's own
|
|
# point against the band a Belgian retail tariff can occupy. Your card's formula line is the ENERGY component
|
|
# alone; the add-on adds price_taxes_eur_kwh on top, so folding ~12 c/kWh of
|
|
# network charge into `offset` would bill you for it twice - and it cannot
|
|
# pass, because it moves the computed 15.55 to about 27.
|
|
#
|
|
# ⚠️ AND THE LIMIT OF THAT CHECK, IN THE SAME BREATH: it grades your
|
|
# COEFFICIENTS AGAINST YOUR CARD, not your card against reality. Two numbers
|
|
# copied off the wrong product agree with each other perfectly and pass.
|
|
#
|
|
# ⚠️ FIXED COEFFICIENTS ONLY - the add-on evaluates NO expression of any kind
|
|
# and there is no field that takes one. An unknown key is refused rather than
|
|
# ignored. See DOCS.md for why (it is not only security: two coefficients can
|
|
# be graded against your card and an arbitrary expression cannot be graded
|
|
# against anything).
|
|
price_formula: ""
|
|
price_csv_path: "/share/goodwe_prices.csv"
|
|
# ⚠️ AN OVERRIDE IS THE EXACT URL (TARIFF-09). The plugin sends it verbatim
|
|
# and appends nothing - including the `start`/`end` date range it derives from
|
|
# the horizon for its own feed - so an override gets whatever range that URL
|
|
# answers, which for a bare day-ahead endpoint is ONE Brussels day. Every hour
|
|
# past it is then carried from yesterday and flagged provisional, and §8.3
|
|
# keeps provisional hours out of the trade set. The plugin logs a WARNING
|
|
# naming that consequence on every fetch that uses an override; clearing this
|
|
# option is what restores the full horizon.
|
|
price_feed_url: ""
|
|
price_horizon_h: 48
|
|
price_timeout_s: 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`. 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: ""
|
|
|
|
# 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 IS NOT WHAT TURNS THE PRICE FEED ON (TARIFF-10). `price_provider`
|
|
# above decides whether prices are FETCHED; this decides whether they move
|
|
# energy. Leaving this off on a site with a provider configured is a
|
|
# supported, documented state: prices are fetched, validated and recorded,
|
|
# and nothing trades.
|
|
#
|
|
# ⚠️ 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
|
|
|
|
# ==========================================================================
|
|
# MAINTENANCE - the monthly battery recalibration cycle
|
|
# ==========================================================================
|
|
# ⚠️ 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
|
|
|
|
# ==========================================================================
|
|
# SAFETY - DERIVED CONSTANTS. Every number below was measured. Do not change
|
|
# one without re-deriving it
|
|
# ==========================================================================
|
|
# 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
|
|
|
|
# ⚠️ 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
|
|
|
|
# ⚠️ 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
|
|
|
|
# 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
|
|
|
|
# ⚠️ 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
|
|
|
|
# ⚠️ CFG-03. THIS LINE IS THE SOURCE OF TRUTH FOR THIS DEFAULT, and
|
|
# `app/delivery.py`'s `DELIVERY_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_delivery.py pins the copy by READING this line, so a
|
|
# drift goes red in CI rather than shipping. Two places - options:, schema: -
|
|
# plus that one dict, and no fourth.
|
|
#
|
|
# ⚠️ THERE IS NO `delivery_w` TO GO WITH IT, AND THAT IS DELIBERATE. The watts
|
|
# half of this check is `saturation_w`, the option two blocks down, read
|
|
# directly: it is already the gap at which the control law itself concludes
|
|
# the inverter is not following, and a second key would be a fourth place a
|
|
# threshold has to be kept in step and a way for the two to disagree. So
|
|
# RAISING `saturation_w` raises this check with it, and test_delivery.py part
|
|
# 4b holds that promise by running the same fault at two different
|
|
# `saturation_w` values and requiring two different answers - a check that
|
|
# only ever ran at 500 could not tell this wiring from a hard-coded number.
|
|
#
|
|
# How long ONE inverter may stand at a discharge share it is not producing -
|
|
# short by more than `saturation_w`, OR short by more than a fixed FRACTION
|
|
# of its own share (SAFETY-43; the fraction is derived and is not an option,
|
|
# see app/delivery.py) - while at least one sibling is NOT in that state,
|
|
# before the add-on names it. It REPORTS: nothing is commanded
|
|
# differently, the loop is untouched, and the site goes on running short by
|
|
# that unit's share until somebody looks. app/delivery.py carries why, with
|
|
# the rig measurements on both sides of the decision.
|
|
#
|
|
# Ignored at one inverter, where the check is inert by construction: with no
|
|
# sibling, "this box cannot deliver" and "this plant cannot deliver" are the
|
|
# same observation.
|
|
# ⚠️ MEASURED ON THE TEST-19 TWO-MASTER RIG, NOT CHOSEN. 2640 closed-loop runs
|
|
# at a roster of two in which nothing was faulty, over 6 gains x 5 slews - the
|
|
# whole range this schema accepts - across load reversals, a flapping load and
|
|
# ASYMMETRIC SPLITS, plus a roster-of-ONE arm as the control. At the shipped
|
|
# threshold the worst CONTINUOUS legitimate episode is 35.0 s (the weighted
|
|
# family; 30.0 s on reversals, 0.0 s on flapping, and 0.0 s at every threshold
|
|
# in every one-inverter run). 185 s is 5.29x that.
|
|
#
|
|
# ⚠️ AND THE CAMPAIGN IS RE-RUNNABLE NOW, WHICH IT WAS NOT WHEN THIS NUMBER
|
|
# SHIPPED. TEST-21 (2026-09-07) committed the harness to the OTHER repository,
|
|
# `admin/goodwe:sim/delivery_window.py` - it imports plantsim and frame_check,
|
|
# which that repo owns. From clean checkouts of both, side by side:
|
|
#
|
|
# cd goodwe/sim && GOODWE_ADDON_REPO=/path/to/goodwe-addon \
|
|
# python3 -B delivery_window.py --selftest --campaign --blag
|
|
#
|
|
# ⚠️ THE ORIGINAL RECORD SAID 30.0 s AND IT DID NOT REPRODUCE - the re-run
|
|
# says 35.0 s. 185 s still clears all four bounds and is unchanged; what
|
|
# changed is the claim. DECISIONS/SAFETY-42-unit-delivery.md §3.3 carries both
|
|
# tables and what did and did not reproduce.
|
|
#
|
|
# ⚠️ AND THE MARGIN IS A SENSITIVITY STUDY ON A HYPOTHESIS, NOT A MEASUREMENT,
|
|
# BECAUSE INVERTER B DOES NOT PHYSICALLY EXIST (~2026-09-12). The rig's B is A
|
|
# COPIED. Sweeping a DIFFERENT second unit through that campaign: the worst
|
|
# legitimate episode barely moves with B's ramp (35.0 s at half A's ramp) and
|
|
# scales with its LAG - 45.0 s at twice A's measured 10 s lag, 75.0 s at four
|
|
# times it. 185 s is 2.47x even the four-times-slower case. NO NUMBER HERE IS
|
|
# A MEASUREMENT OF INVERTER B.
|
|
#
|
|
# DELIBERATELY NOT 180 - that is SAFETY-02's site hold and this is not it -
|
|
# and not 150 or 200. 37 control cycles.
|
|
delivery_hold_s: 185
|
|
|
|
# SAFETY-10. Run the whole decision path and command NOTHING - strategies,
|
|
# arbiter, clamps and guards all run, the decision and its reason are
|
|
# recorded, and every inverter is held at 0 W. `app/shadow.py` carries the
|
|
# argument; the three things to know before changing this line are:
|
|
#
|
|
# auto shadow WHILE CONTROL IS STOPPED, live once it is started. This is
|
|
# the shipped value and it is what "default-on" means here.
|
|
# on always shadow, whatever the Start / stop button says. This is the
|
|
# one an operator picks to watch a day of decisions on an armed site
|
|
# before letting it trade.
|
|
# off ordinary control.
|
|
#
|
|
# ⚠️ IT IS NOT A BOOLEAN DEFAULTING TO `true`, AND THAT IS THE WHOLE CARD.
|
|
# The Supervisor composes an add-on's options as `{**these defaults, **the
|
|
# stored user options}`, so a NEW key with a new default reaches every
|
|
# EXISTING install on the next update - including one regulating a real
|
|
# battery. `shadow_mode: true` would silently stop it, and the only symptom
|
|
# would be a battery that has stopped doing anything. There is no mechanism
|
|
# in this options system that lets one key default differently for a new
|
|
# install and an existing one, so "default-on for new" has to be a value the
|
|
# add-on RESOLVES. `auto` is that value: a fresh install ships
|
|
# `auto_start: false` and therefore comes up in shadow mode; an install that
|
|
# is controlling is started, so `auto` resolves to live and NOTHING about it
|
|
# changes.
|
|
#
|
|
# ⚠️ AND UNDER `auto` THE START BUTTON LEAVES SHADOW MODE. Pressing *Start /
|
|
# stop control* is the operator saying "control the battery". Somebody who
|
|
# wants the decisions recorded WHILE the loop runs sets `on` explicitly -
|
|
# which is the configuration this option exists for, and the one that cannot
|
|
# be reached by accident.
|
|
shadow_mode: "auto"
|
|
|
|
# ==========================================================================
|
|
# ADVANCED - the control law and diagnostics
|
|
# ==========================================================================
|
|
auto_start: false
|
|
gain: 0.6
|
|
|
|
# ⚠️ SAFETY-39. 0 IS NOT "UNSET" AND IT IS NOT "NO LIMIT" - IT IS "FOLLOW THE
|
|
# RAIL", and it is the shipped value. `control.slew_limit_w()` resolves it to
|
|
# `SLEW_RAIL_FRAC * max_w`, which is 222 W against the 2000 W default here,
|
|
# 556 W against the live install's 5000 W, and 1067 W against the ~9600 W a
|
|
# second inverter makes of the site. `integrator_max_w: 0` two lines down has
|
|
# meant exactly this since it was added; this key now means it too.
|
|
#
|
|
# ⚠️ WHY IT IS A RATIO AND NOT A NUMBER, WHICH IS THE WHOLE OF THIS CARD. It
|
|
# WAS a number: 1000, derived 2026-08-22 against a clamp of 1500 W
|
|
# (goodwe/NOTES.md, "Simulated 2 kW load step (gain 0.6, clamp 1500)") on the
|
|
# grounds that it caps "how far the loop can jump ahead of the inverter". The
|
|
# rail then went to 5000 on the live install and the constant did not move, so
|
|
# 1000 W per 2 s tick crossed the whole output range in 10 s against a plant
|
|
# NOTES.md §5.10 measured at ~18 s up / ~23 s down. Measured on the owner's
|
|
# system 2026-09-05 11:43-11:46: a 3.4 kW export drove the setpoint 380 -> 5000
|
|
# in five ticks, the battery to -5154 W past a 5000 VA rating, and the grid
|
|
# from 3.4 kW of EXPORT to 2.8 kW of IMPORT. Large (>= 900 W) steps per 30 min
|
|
# went 2 -> 1 -> 13 -> 43 that morning. Nothing was broken; the rail moved.
|
|
#
|
|
# An explicit number is still accepted and is not clamped - the live install
|
|
# has run 400 since 2026-09-05 12:00, which is slower than the rail implies
|
|
# and is fine. What validate.py rule 10 refuses is a number FASTER than the
|
|
# rail implies, because at that setting the stated limit is not the limit and
|
|
# the layer that overrules it is the plant, which has no status row.
|
|
#
|
|
# ⚠️ AND THE COST IS REAL AND IS NOT HIDDEN: following the rail is slower to
|
|
# converge than 1000 was. Measured over 51 export steps on the in-repo model
|
|
# (test_control.py) the largest tick step falls from 1000 W to 556 W and the
|
|
# >= 900 W step disappears from every run - but the OVER-ABSORPTION does not
|
|
# move, because that is a `gain` term and not a slew term. See
|
|
# DECISIONS/SAFETY-39-slew-against-the-rail.md, which says so at length rather
|
|
# than letting this card be read as more than it is.
|
|
slew_w: 0
|
|
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
|
|
log_level: info
|
|
|
|
schema:
|
|
# ==========================================================================
|
|
# HARDWARE - the inverter, its actuator and its own sensors
|
|
# ==========================================================================
|
|
setpoint_entity: str
|
|
soc_entity: str
|
|
batt_entity: str
|
|
batt_invert: bool
|
|
|
|
# CFG-04. THE ONE LIST-OF-OBJECTS IN THIS FILE, and see the options block for
|
|
# the measurement that says Supervisor 2026.08.0 accepts it: nested types are
|
|
# enforced per entry, a missing required key names the key, a scalar where the
|
|
# list belongs is refused, and the UI renders it as a repeatable section.
|
|
#
|
|
# ⚠️ AND OPT-09's `capacity_kwh` IS DECLARED OPTIONAL (`?`), UNLIKE
|
|
# `bms_soc_floor`, WHICH IS THE ARGUMENT IN THE OPTIONS BLOCK ABOVE: an
|
|
# optional cut-out is a defaulted one and a default is one number for two
|
|
# batteries, whereas an absent capacity has an honest answer - no opinion
|
|
# about the split, every unit weighs 1.0, which is what shipped.
|
|
#
|
|
# ⚠️ THREE KEYS ARE REQUIRED PER ENTRY AND THAT IS DELIBERATE. An entry with
|
|
# no `name` has no Store namespace and would share another unit's SAFETY-02
|
|
# latch; one with no `setpoint_entity` is a unit nothing can command; one with
|
|
# no `max_w` would have to borrow a rating, which is the sibling-grading
|
|
# defect DEV-02 removed. The optional pair is the sensors, whose absence
|
|
# honestly means "this unit reports none".
|
|
#
|
|
# ⚠️ THIS BLOCK IS DECLARED BEFORE `max_w:` ON PURPOSE. `max_w` now appears
|
|
# TWICE under `schema:` - once nested here, once flat below - and a reader
|
|
# that matches a stripped line takes the nested one FIRST. That is the
|
|
# first-match trap this card had to fix in three checks; leaving the nesting
|
|
# first keeps the trap baited, so the fix stays graded rather than assumed.
|
|
# test_inverter.py part 12 asserts the ordering is still hostile.
|
|
inverters:
|
|
- name: str
|
|
setpoint_entity: str
|
|
max_w: int(100,5000)
|
|
bms_soc_floor: int(0,50)
|
|
soc_entity: str?
|
|
batt_entity: str?
|
|
batt_invert: bool?
|
|
capacity_kwh: float(0.1,200)?
|
|
|
|
estop_fitted: bool
|
|
# TEL-11. Optional, and OPTIONAL WITHOUT A COMPANION RULE - unlike
|
|
# `price_now_entity`/`price_avg_entity`, which are legal only while
|
|
# `price_provider` is `off` and make the add-on REFUSE TO START otherwise.
|
|
# Nothing in validate.py mentions this key: it cannot conflict with anything,
|
|
# because nothing else reads it.
|
|
pv_entity: str?
|
|
|
|
# ==========================================================================
|
|
# METER - where the grid reading comes from
|
|
# ==========================================================================
|
|
meter_source: list(off|ha_dsmr|mqtt_p1|ha_signed|homewizard_local)
|
|
meter_entity: str
|
|
meter_invert: bool
|
|
|
|
# ⚠️ 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)
|
|
p1_host: str?
|
|
meter_poll_s: int(1,60)
|
|
meter_max_age_s: int(5,300)
|
|
meter_mqtt_topic: str?
|
|
p1_import_entity: str?
|
|
p1_export_entity: str?
|
|
p1_net_entity: str?
|
|
p1_phase_import_entities:
|
|
- str
|
|
|
|
p1_phase_export_entities:
|
|
- str
|
|
|
|
p1_phase_net_entities:
|
|
- str
|
|
|
|
# ==========================================================================
|
|
# RATINGS AND LIMITS - what this site is allowed to do
|
|
# ==========================================================================
|
|
max_w: int(100,5000)
|
|
bms_soc_floor: int(0,50)
|
|
peak_cap_w: int(500,15000)
|
|
peak_forecast_entity: str?
|
|
|
|
# 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)
|
|
|
|
# ==========================================================================
|
|
# TARIFF - what energy costs, and whether to trade on it
|
|
# ==========================================================================
|
|
# ⚠️ 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 plugin means adding it here and to PROVIDERS - and nowhere else,
|
|
# which is AC2.
|
|
#
|
|
# ⚠️ THREE OF THESE FIVE IDS NO LONGER RESOLVE TO A PLUGIN, AND LEAVING THEM
|
|
# HERE IS A DECISION (TARIFF-07). engie_dynamic, totalenergies_dynamic and
|
|
# eneco_eddy were withdrawn - their endpoints do not exist and never did - and
|
|
# the obvious edit is to delete them from this line too. DO NOT. The Supervisor
|
|
# validates the options file against this schema BEFORE the add-on starts, so
|
|
# an id that is not listed here makes an EXISTING config schema-invalid: the
|
|
# operator gets an opaque "expected one of ..." from the Supervisor, the add-on
|
|
# never runs, and validate.py's retired-id refusal - the message that tells
|
|
# them their prices never worked and what to do instead - IS NEVER REACHED, on
|
|
# exactly the sites that need it. Schema tightness would cost the only thing
|
|
# this withdrawal is FOR.
|
|
#
|
|
# The cost of keeping them is that the dropdown still offers three dead ids to
|
|
# a NEW operator. That is the cheaper failure: choosing one is refused at the
|
|
# next startup by name, with the endpoint, the observation and the alternative
|
|
# - which is strictly more than a typo-shaped schema rejection would say. Both
|
|
# translations label them withdrawn in the option description as well.
|
|
#
|
|
# app/price.py's RETIRED_PROVIDERS is the list, test_price.py section 1 asserts
|
|
# this line offers exactly PROVIDERS + RETIRED_PROVIDERS + 'off' - derived, not
|
|
# typed - and register() refuses to re-register a retired id, so the two
|
|
# cannot both claim one.
|
|
price_provider: list(off|generic_dayahead|custom_formula|engie_dynamic|totalenergies_dynamic|eneco_eddy|manual_csv)
|
|
price_markup_eur_kwh: float(0,1)
|
|
price_taxes_eur_kwh: float(0,1)
|
|
|
|
# ⚠️ 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)
|
|
|
|
# TARIFF-16. A JSON string in `price_formula`'s grammar, parsed by
|
|
# `custom_formula.parse_export_formula()` - the SAME parser with its option
|
|
# name as an argument, so a second unit table and a second card rule do not
|
|
# exist to diverge. The `card` block is OPTIONAL on this leg and required on
|
|
# the other: Eneco prints its injection formula with no price beside it, so
|
|
# on that card there is nothing to grade against, and the add-on says at
|
|
# startup that the leg is ungraded rather than inventing an estimate.
|
|
price_export_formula: str?
|
|
|
|
# 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?
|
|
# TARIFF-17. A JSON string for `static_profile`'s reason, and parsed by
|
|
# `static_tariff.parse_taxes_profile()` - the SAME period grammar, so a
|
|
# second tiling rule and a second set of DST bugs do not exist to diverge.
|
|
price_taxes_profile: str?
|
|
# TARIFF-15. A JSON string for the same reason `static_profile` is one: it is
|
|
# ONE option key, it survives config.yaml's flat options block, and
|
|
# parse_price_formula() accepts an already-parsed dict too, so a future
|
|
# nested schema is a config.yaml change and no code change at all.
|
|
price_formula: str?
|
|
price_csv_path: str?
|
|
price_feed_url: str?
|
|
price_horizon_h: int(1,72)
|
|
price_timeout_s: int(2,60)
|
|
price_now_entity: str?
|
|
price_avg_entity: str?
|
|
|
|
# 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)
|
|
|
|
# ==========================================================================
|
|
# MAINTENANCE - the monthly battery recalibration cycle
|
|
# ==========================================================================
|
|
# ⚠️ 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)
|
|
|
|
# ==========================================================================
|
|
# SAFETY - DERIVED CONSTANTS. Every number below was measured. Do not change
|
|
# one without re-deriving it
|
|
# ==========================================================================
|
|
# 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)
|
|
|
|
# SAFETY-42. The floor of 30 is `inverter.LINK_STALE_S`, for
|
|
# `circulation_hold_s`'s reason and the same one SAFETY-30's per-unit window
|
|
# takes it from: below the firmware's own veto window a unit not executing its
|
|
# share is a LINK fault the add-on already answers by dropping it from the
|
|
# split and saying so in the action log, and reporting inside that window
|
|
# would be a second, louder alarm about a state already being handled. The
|
|
# ceiling of 900 is the quarter that bills the capacity tariff - a site
|
|
# running short of one unit's share must be reported well inside one.
|
|
delivery_hold_s: int(30,900)
|
|
|
|
# SAFETY-10. ⚠️ A THREE-VALUED LIST AND NOT A `bool`, for the reason written
|
|
# against the default in the options block above: a boolean has one default
|
|
# and the Supervisor gives it to every install, new and existing alike.
|
|
# `app/shadow.MODES` is this tuple and `validate.py` rule 11 refuses anything
|
|
# else BY NAME, because `shadow.mode()` resolves an unreadable value to
|
|
# SHADOW - safe, but a silent reinterpretation that commands nothing for as
|
|
# long as nobody notices.
|
|
shadow_mode: list(auto|on|off)
|
|
|
|
# ==========================================================================
|
|
# ADVANCED - the control law and diagnostics
|
|
# ==========================================================================
|
|
auto_start: bool
|
|
gain: float(0.05,1.0)
|
|
|
|
# ⚠️ THE FLOOR IS 0, NOT 50, AND 0 IS THE SHIPPED VALUE - see the options block
|
|
# above. It means "follow max_w", so the range starts at the setting that
|
|
# cannot go stale rather than at the smallest number somebody could type.
|
|
# ⚠️ THE CEILING IS STILL 5000 AND IS STILL NOT THE BOUND THAT MATTERS. The
|
|
# binding one is `SLEW_RAIL_FRAC * max_w`, a relation BETWEEN keys, and this
|
|
# grammar cannot express a relation - which is the same reason
|
|
# `maintenance_charge_w`'s ceiling is not its real bound either (see the note
|
|
# above it). validate.py rule 10 is where it is written.
|
|
slew_w: int(0,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)
|
|
log_level: list(trace|debug|info|warning|error)
|