checks / unit (pull_request) Successful in 3m41s
peak_soc_floor reads like a pack-wide reserve and is not one. The
grid-following loop has no SoC guard of any kind, so on a sustained-load
evening it discharges straight past that number and keeps going until the
pack's own BMS cut-out stops it.
The gap has now been written down four times without being filed -
SAFETY-18's finding (freeze.py:18), SAFETY-24's module note, its PR body,
and its honestly-uncaught 13th mutation - and never once measured. It is
measured here, on the committed 2026-08-20 capture, driving the SHIPPED
control.compute(), peakguard.decide(), peakguard.claim() and the real
arbiter.resolve() in main.py's order:
19:00-22:00 UTC, 830 W mean, minimum 701 W, zero export in the window
reference pack (300 Ah x 51.2 V x DoD 90) started at 21 %, one point
above peak_soc_floor
ends at 10.0 % - the bms_soc_floor cut-out
1525 Wh delivered by the LOOP over 587 cycles
549 cycles below peak_soc_floor, 343 of them still discharging
0 cycles on which the peak guard filed any floor at all
AND IT IS A BEHAVIOUR, NOT A CONFIG VALUE. The same evening ends at
9.9715 % at peak_soc_floor = 5, 11, 20 and 60 - the shipped value and both
ends of the schema range - identical to four decimal places. The option an
operator would reach for cannot move the number by any amount at all. On
the same day's high-load block the two states coexist: 49 cycles reporting
PEAK GUARD FLOOR-LIMITED and, on all 49 of them, the arbiter simultaneously
granting the loop up to 2000 W of discharge.
WHY SAFETY-24 COULD NOT CLOSE IT. The peak guard files a `lo` limit - a
LOWER bound on the setpoint. That asymmetry is exactly what makes it
trustworthy: a reviewer proved across 19,800 combinations that it can never
lower a target, cap a discharge or contradict the arbiter. The same property
makes it structurally incapable of stopping a discharge the loop itself
wants. A floor on the guard cannot become a floor on the pack by being
raised, and the sweep above is that sentence measured.
WHAT THIS COMMIT CHANGES IS THE HONESTY, on the four surfaces an operator
actually reads. That half is required whichever way the open question is
answered, and it is the real misstatement on the live system today:
* config.yaml, beside the option itself - it BOUNDS THE PEAK GUARD ONLY
and is NOT A PACK-WIDE RESERVE, with the replayed evening and the sweep;
* DOCS.md, in the options table and in a new section carrying the figures;
* the STATUS-PAGE ROW for a floor-limited guard. This is the one line a
human reads at the moment the guard stops discharging, and read alone it
says "the battery is being held at peak_soc_floor" - which the replay
shows is false in every cycle that row is rendered;
* the PERSISTENT NOTIFICATION, which outlives the page. It now names what
has stopped (the guard buying a lower bill) and what has not (the pack
being spent).
peakguard.py and freeze.py carry the measurement beside the findings they
already recorded, so the next reader does not have to take the caveat on
trust.
THE PRODUCT DECISION IS NOT TAKEN HERE, and must not be. Whether the loop
gets a reserve floor of its own decides whether this house's battery may
discharge to the BMS floor on a heavy evening; that moves real energy and it
is the owner's call. DECISIONS/SAFETY-25-loop-soc-floor.md is the memo AC 1
asks for: the options, what each costs, what each protects, weighed against
MAINT-01 (a pack repeatedly at the BMS floor is the SoC-drift condition the
maintenance cycle exists to correct, and an over-tight reserve fights it from
the other side) and against the fact that this would be the FIFTH SoC key -
OPT-04's arbitrage_soc_floor fired SAFETY-24's "revisit if a third floor
appears" trigger unremarked, so SAFETY-27's naming decision should land
first. It carries a recommendation and says plainly that it is not a
decision.
It also records a trap found while writing it, and now CHECKED through the
real arbiter rather than described: the obvious shape of a loop-side floor
is a `hi` limit at P_LOOP, and filed while the peak guard is asking for a
positive floor that is `lo > hi` - which arbiter.py resolves to a
CONTRADICTION at 0 W, the battery PARKED in the one quarter of the month
that sets the bill. It is the same crossing-limits hazard
shaping_suspended() already exists to avoid. The ordering
`bms_soc_floor < loop_soc_floor <= peak_soc_floor` removes it provably,
because wherever the reserve binds the guard is already floor-limited and
files lo = 0; both halves are run in part 10d.
test_peakguard.py part 10 is a TRIPWIRE, not a guarantee. It records a
defect and goes RED the day somebody builds a loop-side floor, so the
exposure cannot change size under a green suite and closing it has to be
deliberate. Part 11's mutation 13 stays honestly NOT CAUGHT - nothing
prevents the loop discharging past this floor - but it now reports a number
instead of a sentence.
Verified: 16/16 suites green in the CI image. Eight mutations, each against
a tree copied fresh with __pycache__ excluded and mounted :ro, so no .pyc
existed to go stale (MUTATION.md's structural immunity, the same one it
records for CI); byte deltas +61, -8, -465, -430, -1088, -46, file deleted,
and -95; none is +0 B. All eight go red on the named check:
M1 peakguard.claim() also files hi=floor_w -> 7 red, incl. 10c
M2 control.compute() gain forced to 0 -> 6 red, incl. 10a/10b
M3 scope sentence removed from the status row -> 10e red
M4 scope sentence removed from the notification -> 10e red
M5 scope paragraph removed from config.yaml -> 10e red
M6 scope bullet weakened in DOCS.md -> 10e red
M7 the decision memo deleted -> 10e red
M8 arbiter clamps crossing limits instead of
reporting a contradiction -> 5 red, incl. 10d
AC 2 is left explicitly OPEN, pending the owner's answer.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
441 lines
21 KiB
YAML
441 lines
21 KiB
YAML
name: GoodWe RS485 Controller
|
|
version: "0.5.2"
|
|
slug: goodwe_controller
|
|
description: >-
|
|
Drives a GoodWe ES/BP battery inverter over RS485 by emulating its smart
|
|
meter, holding net grid exchange at zero and running a monthly battery
|
|
maintenance cycle.
|
|
url: https://gittea.kammenstraatha.duckdns.org/admin/goodwe-addon
|
|
arch:
|
|
- aarch64
|
|
- amd64
|
|
- armv7
|
|
init: false
|
|
startup: application
|
|
boot: auto
|
|
|
|
# Needed to read the meter and to write the ESPHome setpoint number.
|
|
homeassistant_api: true
|
|
hassio_api: true
|
|
|
|
# Own diagnostics/operations UI inside HA. This is what a field tech and a
|
|
# remote supporter both look at, so it is not optional.
|
|
ingress: true
|
|
ingress_port: 8099
|
|
panel_icon: mdi:battery-sync
|
|
panel_title: GoodWe
|
|
|
|
# Optional: publish status entities by MQTT discovery. `want` rather than
|
|
# `need` - the add-on runs fine with no broker, it just publishes nothing.
|
|
services:
|
|
- mqtt:want
|
|
|
|
options:
|
|
# --- sources (required) ---------------------------------------------------
|
|
meter_entity: sensor.p1_meter_active_power
|
|
meter_invert: false
|
|
soc_entity: ""
|
|
batt_entity: ""
|
|
batt_invert: false
|
|
setpoint_entity: ""
|
|
|
|
# --- P1 meter ingestion (specs §5.2 / §14 `meter:`) -------------------------
|
|
# `off` keeps the original single meter_entity path above, so an existing
|
|
# install is untouched until it opts in. ha_dsmr subscribes to the DSMR
|
|
# integration's entities over the HA WebSocket; mqtt_p1 reads the topic below;
|
|
# ha_signed reads ONE signed HA entity (+ import / - export), which is what a
|
|
# HomeWizard P1 publishes and what ha_dsmr cannot consume.
|
|
# homewizard_local skips Home Assistant entirely and polls the meter's own
|
|
# local API. ⚠️ It is the ONLY mode whose sensor.p1_sample_age_s measures when
|
|
# the meter REPORTED rather than when the value last CHANGED - HA emits
|
|
# nothing at all for a repeated reading - so it is the only mode a firmware
|
|
# watchdog may threshold. See DOCS.md.
|
|
meter_source: "off"
|
|
meter_phases: 1
|
|
meter_max_age_s: 30
|
|
meter_mqtt_topic: ""
|
|
# homewizard_local only. The meter's own address, `host` or `host:port`.
|
|
p1_host: ""
|
|
# homewizard_local only. Seconds between polls. Must be well under
|
|
# meter_max_age_s - the age is never fresher than this interval - and there is
|
|
# nothing to gain below the meter's own ~5.0 s update rate (NOTES.md:640).
|
|
meter_poll_s: 5
|
|
# The two UNSIGNED Belgian registers. The EMS derives net power from them
|
|
# (import - export); do NOT point these at a signed template sensor.
|
|
p1_import_entity: ""
|
|
p1_export_entity: ""
|
|
# Optional, in L1..L3 order. Required for the capacity-tariff peak on a
|
|
# three-phase connection; the list length must equal meter_phases.
|
|
p1_phase_import_entities: []
|
|
p1_phase_export_entities: []
|
|
# ha_signed only. ONE signed net-power sensor: positive = import from the
|
|
# grid, negative = export to it. Do NOT split it into two template sensors -
|
|
# the split is done in the add-on (p1.split_signed) precisely so the sign
|
|
# convention is tested rather than living in unreviewed YAML.
|
|
p1_net_entity: ""
|
|
# Optional, in L1..L3 order, each one signed the same way. Same role as
|
|
# p1_phase_import_entities: the capacity-tariff peak on a three-phase
|
|
# connection. The list length must equal meter_phases.
|
|
p1_phase_net_entities: []
|
|
|
|
# --- control ---------------------------------------------------------------
|
|
max_w: 2000
|
|
gain: 0.6
|
|
slew_w: 1000
|
|
deadband_w: 15
|
|
target_grid_w: -10
|
|
step_w: 10
|
|
saturation_w: 500
|
|
saturation_cycles: 3
|
|
integrator_max_w: 0
|
|
heartbeat_s: 10
|
|
stale_input_s: 15
|
|
cycle_floor_s: 15
|
|
auto_start: false
|
|
|
|
# --- SAFETY-02 sign-disagreement watchdog ----------------------------------
|
|
# Trips when the emitted setpoint and the P1 net figure point OPPOSITE ways,
|
|
# both over sign_disagree_w, continuously for sign_disagree_s. Action: 0 W and
|
|
# a failsafe that latches until an operator starts control again.
|
|
#
|
|
# Both defaults are measured, not chosen. On the captured healthy day
|
|
# (2026-08-23) the longest legitimate opposed run - a load step the loop has
|
|
# not caught up with - is 30 s, and sign agreement over the gated samples is
|
|
# 93.4 %. On the captured fault day (2026-08-20) the clusters run 0.0-10.5 %
|
|
# agreement with opposed runs up to 1752 s. 300 W / 180 s sits in the middle of
|
|
# a very wide gap: at 60 s the healthy day still trips nothing, at 300 s the
|
|
# fault day still trips 13 times. There is no reason to move either number
|
|
# without a capture that says so. They are options because the AC says
|
|
# thresholds are defaults rather than constants - not because they are knobs
|
|
# anyone is expected to turn.
|
|
#
|
|
# ⚠️ sign_disagree_w must stay BELOW max_w or the watchdog can never fire (the
|
|
# arbiter bounds the command to +-max_w). validate.py refuses that config.
|
|
sign_disagree_w: 300
|
|
sign_disagree_s: 180
|
|
|
|
# --- SAFETY-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
|
|
|
|
# --- maintenance -----------------------------------------------------------
|
|
maintenance_enabled: false
|
|
maintenance_interval_days: 28
|
|
maintenance_start_hour: 10
|
|
# ⚠️ BOTH MUST STAY AT OR BELOW max_w, and that is why they are 2000 rather
|
|
# than the 2500 they shipped as. The cheap-window charge shaping asks the
|
|
# arbiter for `hi = -maintenance_charge_w` against the supervised `lo =
|
|
# -max_w`: above the ceiling that is `lo > hi`, a contradiction resolved to
|
|
# 0 W, so maintenance would SILENTLY NEVER CHARGE in a cheap window on a
|
|
# default install. validate.py refuses that config (rule 6), so these
|
|
# defaults had to become self-consistent with the max_w above rather than
|
|
# ship a configuration the validator rejects.
|
|
#
|
|
# The discharge side is clamped rather than contradicted - it drains at max_w
|
|
# and the arbiter says so - so it is only warned about; it is 2000 here for
|
|
# symmetry and to keep the shipped config warning-free.
|
|
#
|
|
# Raising max_w (the live reference install runs 5000) is what unlocks a
|
|
# faster maintenance cycle. Raise max_w FIRST, then these.
|
|
maintenance_discharge_w: 2000
|
|
maintenance_charge_w: 2000
|
|
maintenance_soc_floor: 11
|
|
maintenance_soc_target: 99
|
|
maintenance_hold_min: 120
|
|
|
|
# --- tariff / capacity tariff (all optional) ------------------------------
|
|
peak_forecast_entity: ""
|
|
peak_cap_w: 3500
|
|
|
|
# --- SAFETY-24: the energy backstop under SAFETY-07's forced discharge -----
|
|
# The peak guard can COMMAND a discharge to hold a billed quarter under
|
|
# peak_cap_w. Until this card it was bounded from below by nothing but the
|
|
# pack's own BMS cut-out - a real floor, but the PACK's protection rather than
|
|
# the OPERATOR's. On the 2026-08-20 replay the guard asks for 878 Wh in a day.
|
|
#
|
|
# peak_soc_floor is the percent below which that discharge is not sized. When
|
|
# it binds the guard reports FLOOR-LIMITED, keeps charging blocked (a 0 W
|
|
# floor), and the add-on raises ONE notification saying the quarter will be
|
|
# exceeded - failing to shave is never silent, because the euro figure is the
|
|
# whole point of the guard.
|
|
#
|
|
# ⚠️ THIS IS NOT maintenance_soc_floor AND MUST NOT BE MERGED WITH IT.
|
|
# maintenance_soc_floor is the DRAIN TARGET of the monthly recalibration: the
|
|
# depth the cycle deliberately goes to, once a month, on purpose. This is the
|
|
# depth a money-saving discharge may never reach on any day. The right values
|
|
# differ, and sharing one key would couple two features that want to move
|
|
# independently.
|
|
#
|
|
# ⚠️ 20 IS DERIVED, NOT PICKED. The reference pack is 13.8 kWh usable and the
|
|
# worst modelled guard day is 878 Wh, i.e. 6.4 % - so 10 points above the
|
|
# pack's own cut-out leaves the guard ~1.6 of its worst observed days of room
|
|
# and still never reaches the BMS.
|
|
#
|
|
# ⚠️⚠️ SAFETY-25 - WHAT THIS DOES NOT BOUND, AND IT IS THE HALF THE NAME HIDES.
|
|
# peak_soc_floor BOUNDS THE PEAK GUARD ONLY. IT IS NOT A PACK-WIDE RESERVE.
|
|
# Ordinary self-consumption - the grid-following loop that holds the meter at
|
|
# zero - has no SoC floor of any kind, so on a sustained-load evening it will
|
|
# discharge STRAIGHT PAST this number and go on until the pack's own BMS
|
|
# cut-out (bms_soc_floor) stops it. Replayed on the committed 2026-08-20
|
|
# capture, 19:00-22:00 UTC: a 13.8 kWh pack starting at 21 % ends the evening
|
|
# at 10.0 %, and 343 of 587 control cycles deliver discharge with the pack
|
|
# already below this floor. Setting this key higher does not change that by
|
|
# one tenth of a percent - the same replay at every value the schema accepts
|
|
# (5..60) ends at the same 10.0 %.
|
|
#
|
|
# The reason is structural, not an oversight: the peak guard files a `lo`
|
|
# limit (a LOWER bound on the setpoint), which is what makes it provably
|
|
# unable to cap a discharge or contradict the arbiter - and therefore also
|
|
# unable to stop one the loop itself wants. See peakguard.py's module note and
|
|
# DECISIONS/SAFETY-25-loop-soc-floor.md, which records the options for a
|
|
# loop-side reserve and is waiting on the product owner. Until that is
|
|
# answered, the BMS is the only floor under self-consumption.
|
|
peak_soc_floor: 20
|
|
# The pack's OWN cut-out percent - what the BMS enforces whatever is set
|
|
# above. Register 0x0560 reads 10 on the reference GW5000S-BP, consistent with
|
|
# DoD 90. It is here so validate.py can REFUSE a peak_soc_floor at or below
|
|
# it: such a floor can never bind, the BMS wins, and the status page would
|
|
# report an energy backstop that does not exist.
|
|
bms_soc_floor: 10
|
|
|
|
# ⚠️ THE OLD PRICE PATH, AND IT IS NOT DEAD YET. These two entities are the
|
|
# add-on delegating the whole price question to Home Assistant: main.py reads
|
|
# both and derives ONE BOOLEAN, `cheap = now <= avg`, which is consumed by
|
|
# nothing except the maintenance cycle's cheap-window charge floor. That is
|
|
# all they have ever done. They are kept, because retiring them would take
|
|
# the maintenance cycle's only price signal away on a live install before
|
|
# anything replaces it - TARIFF-03 (static profile) and OPT-04 (arbitrage)
|
|
# are the cards that replace it, and neither has landed.
|
|
#
|
|
# ⚠️ They may NOT be combined with price_provider below. Two price sources on
|
|
# one site, one of which silently wins, is exactly the "double up" the PM
|
|
# named; validate.py rule 7 refuses that configuration at startup rather than
|
|
# letting an operator believe the provider they configured is driving
|
|
# something. See app/validate.py.
|
|
price_now_entity: ""
|
|
price_avg_entity: ""
|
|
|
|
# --- price providers (TARIFF-02, specs §9.3 / §14 `tariffs:`) --------------
|
|
# `off` is the shipped default and means the §11.4 no-price-data row applies:
|
|
# static profile, plan provisional, NO ARBITRAGE. Nothing in 0.5.x consumes
|
|
# the series yet - OPT-04 is the consumer - so turning this on today fetches,
|
|
# validates and publishes prices and changes no energy decision.
|
|
#
|
|
# ⚠️ THESE DEFAULTS ARE MIRRORED ONCE, IN app/price.py's
|
|
# PRICE_OPTION_DEFAULTS, and test_price.py reads THIS FILE and asserts the two
|
|
# agree key for key. Do not add a third copy in main.py - CFG-03 is open
|
|
# because that has already happened three times.
|
|
#
|
|
# markup/taxes are the §14 `tariffs.energy_price.*` figures and are added by
|
|
# the FRAMEWORK, only for the components the chosen plugin declares its feed
|
|
# does not already contain. engie_dynamic, totalenergies_dynamic, eneco_eddy
|
|
# and manual_csv publish a figure that already carries the markup, so setting
|
|
# it under those is ignored - and logged as ignored, never applied twice.
|
|
price_provider: "off"
|
|
price_markup_eur_kwh: 0.03
|
|
price_taxes_eur_kwh: 0.06
|
|
price_csv_path: "/share/goodwe_prices.csv"
|
|
price_feed_url: ""
|
|
price_horizon_h: 48
|
|
price_timeout_s: 10
|
|
|
|
# --- static / fully-fixed tariffs (TARIFF-03, specs §9.2 / §11.4) ----------
|
|
# §14's `tariffs.static_profile`, and "" is its `null`. §9.2: "a large share
|
|
# of the installed base is on [a static profile]" - this is what makes those
|
|
# sites first-class rather than "price_provider: off, good luck".
|
|
#
|
|
# A JSON object, on one line, whose periods must tile every day type exactly
|
|
# once - no gap and no overlap. A gap is REFUSED at startup rather than
|
|
# defaulted, because an invented price is one an optimiser would trade on.
|
|
# A fully-fixed tariff is ONE period covering 00:00-24:00, all days; the
|
|
# add-on derives "fixed" from there being one distinct price, so there is no
|
|
# box to tick and none to tick wrongly.
|
|
#
|
|
# {"periods": [
|
|
# {"name":"day", "from":"07:00","to":"22:00","days":"mon-fri","eur_per_kwh":0.34},
|
|
# {"name":"night","from":"22:00","to":"07:00","days":"mon-fri","eur_per_kwh":0.26},
|
|
# {"name":"night","from":"00:00","to":"24:00","days":"sat,sun", "eur_per_kwh":0.26}]}
|
|
#
|
|
# ⚠️ A window written "22:00-07:00 mon-fri" does NOT cover Saturday morning -
|
|
# a period applies on the day the CLOCK reads, so the weekend row above is
|
|
# required, not decorative. See DOCS.md.
|
|
#
|
|
# eur_per_kwh is ALL-IN (energy + markup + taxes, §9.3), the same basis the
|
|
# dynamic providers are assembled onto. price_markup_eur_kwh and
|
|
# price_taxes_eur_kwh are NOT added to it.
|
|
#
|
|
# ⚠️ RECOMMENDED EVEN ON A DYNAMIC SITE. §11.4's degradation row is "no price
|
|
# data -> fall back to the static profile"; with this empty there is nothing
|
|
# to fall back to and a dead feed leaves the site with no tariff at all.
|
|
# validate.py warns about exactly that.
|
|
static_profile: ""
|
|
|
|
# §9.3: injection is priced SEPARATELY, and in Belgium it is often near zero -
|
|
# which by itself strongly favours self-consumption over export. A scalar, not
|
|
# a second series: no shipped supplier publishes a dynamic injection curve.
|
|
export_price_eur_kwh: 0.04
|
|
|
|
# --- price arbitrage (OPT-04, specs §10.1 priority 3 / §17) ---------------
|
|
# Charge in the cheapest published hour, discharge in the dearest, and ONLY
|
|
# when the spread covers what the round trip costs. Off in the shipped
|
|
# configuration: this is the first strategy in the add-on that moves energy
|
|
# against the meter on purpose, so it is armed deliberately or not at all.
|
|
#
|
|
# ⚠️ IT NEEDS A PRICE SOURCE AND A P1 METER, and validate.py refuses the
|
|
# combinations that cannot work rather than letting them look armed. With
|
|
# `price_provider: off` and no `static_profile` there is no series to trade
|
|
# on; with `meter_source: off` the capacity-tariff peak guard never runs, and
|
|
# §9.1 puts peak shaving above arbitrage - a charge nothing measures is the
|
|
# exact failure that guard exists to prevent.
|
|
#
|
|
# ⚠️ AT THE SHIPPED THRESHOLD A STATIC DAY/NIGHT SITE DOES NOT TRADE. The
|
|
# realistic Belgian day/night spread is about 0.08 EUR/kWh and the threshold
|
|
# below is 0.10, so the verdict is "the firm spread does not cover this
|
|
# cycle's cost" and nothing happens - correctly. That is a product decision
|
|
# (this number, and app/price.py's widening factors) and it is open with the
|
|
# owner; the code is correct at any value and no number was picked to make a
|
|
# trade appear.
|
|
arbitrage_enabled: false
|
|
# §10.1's `cycle_cost_threshold_eur`: what one full round trip costs per kWh
|
|
# - the wear on the pack plus the conversion losses. A spread below this is
|
|
# a cycle that loses money and pack life, so it is not taken.
|
|
cycle_cost_threshold_eur: 0.10
|
|
# The reserve arbitrage may not spend, and the headroom it may not overfill.
|
|
# Deliberately NOT the maintenance_soc_* keys: those describe a monthly
|
|
# balancing cycle, and pointing a trading strategy at them would couple two
|
|
# unrelated policies through one number.
|
|
arbitrage_soc_floor: 20
|
|
arbitrage_soc_ceiling: 95
|
|
|
|
# --- site ------------------------------------------------------------------
|
|
estop_fitted: false
|
|
log_level: info
|
|
|
|
schema:
|
|
meter_entity: str
|
|
meter_invert: bool
|
|
soc_entity: str
|
|
batt_entity: str
|
|
batt_invert: bool
|
|
setpoint_entity: str
|
|
|
|
meter_source: list(off|ha_dsmr|mqtt_p1|ha_signed|homewizard_local)
|
|
# ⚠️ 2 is accepted by this range but is not a real Belgian connection. A
|
|
# telegram whose phase count disagrees is rejected at ingest and logged, so a
|
|
# mis-set 2 shows up immediately as "0 telegrams accepted" rather than as a
|
|
# quietly wrong number.
|
|
meter_phases: int(1,3)
|
|
meter_max_age_s: int(5,300)
|
|
meter_poll_s: int(1,60)
|
|
meter_mqtt_topic: str?
|
|
p1_host: str?
|
|
p1_import_entity: str?
|
|
p1_export_entity: str?
|
|
p1_phase_import_entities:
|
|
- str
|
|
p1_phase_export_entities:
|
|
- str
|
|
p1_net_entity: str?
|
|
p1_phase_net_entities:
|
|
- str
|
|
|
|
max_w: int(100,5000)
|
|
gain: float(0.05,1.0)
|
|
slew_w: int(50,5000)
|
|
deadband_w: int(0,500)
|
|
target_grid_w: float(-200,200)
|
|
step_w: int(1,100)
|
|
saturation_w: int(100,2000)
|
|
saturation_cycles: int(1,10)
|
|
integrator_max_w: int(0,15000)
|
|
heartbeat_s: int(2,25)
|
|
stale_input_s: int(5,120)
|
|
cycle_floor_s: int(5,60)
|
|
auto_start: bool
|
|
|
|
# No "off" value, deliberately: this is a P0 safety check and the schema is
|
|
# not the place to make disarming it a supported configuration.
|
|
sign_disagree_w: int(50,5000)
|
|
sign_disagree_s: int(30,1800)
|
|
|
|
# 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)
|
|
|
|
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)
|