checks / unit (pull_request) Successful in 4m39s
CFG-04 (#45) merged first and moved the base. Two conflicts, both resolved as the union of the two sides: - app/main.py: an import-block collision. CFG-04 added validate_unit_ranges to the .validate import; SAFETY-30 added AGAINST_METER, AGAINST_OWN_POWER, SIGN_OPTION_DEFAULTS and SignWatchdog to the .watchdog import. Neither side dropped anything, so the resolution keeps both lists whole. - CHANGELOG.md: two adjacent new entries under ## Unreleased. Both kept. Both sides also independently rewrote large parts of test_peakguard.py (60 and 47 insertions against the merge base) and git merged them textually, so the full suite was run on the MERGE RESULT rather than inferred from the two parents: 19 passed, 0 failed, twice. One caveat recorded rather than hidden: test_peakguard.py failed once during the first sequential full-suite run and then passed 10/10 in isolation and in two further full runs on this same tree. It passes on both parents too, so this is the pre-existing flakiness CFG-04 already disclosed, not a merge defect - it is timing- or load-sensitive. It sits on the required CI context and can therefore block an unrelated merge at random, which wants a card of its own. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
669 lines
34 KiB
YAML
669 lines
34 KiB
YAML
name: GoodWe RS485 Controller
|
|
version: "0.6.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:
|
|
# --- sources (required) ---------------------------------------------------
|
|
meter_entity: sensor.p1_meter_active_power
|
|
meter_invert: false
|
|
soc_entity: ""
|
|
batt_entity: ""
|
|
batt_invert: false
|
|
setpoint_entity: ""
|
|
|
|
# --- ARCH-01 / CFG-04: the inverter roster ---------------------------------
|
|
# ⚠️ 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 FIVE FLAT KEYS ABOVE 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 five 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
|
|
# - 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: 3000
|
|
#
|
|
# ⚠️ 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` and `max_w` 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".
|
|
#
|
|
# ⚠️ 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: []
|
|
|
|
# --- 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
|
|
|
|
# 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.
|
|
#
|
|
# ⚠️ 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)
|
|
soc_entity: str?
|
|
batt_entity: str?
|
|
batt_invert: bool?
|
|
|
|
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)
|