Compare commits

...
26 Commits
Author SHA1 Message Date
admin ec2b0c3de4 Deploy 0.18.1 to master (#197) 2026-10-16 15:05:06 +02:00
admin 780c1f67bb Merge pull request 'Release 0.18.1' (#196) from release-0.18.1 into release/1.0
infra-pin / fresh (push) Successful in 1s
checks / unit (pull_request) Successful in 40m21s
infra-pin / fresh (pull_request) Successful in 1s
Release 0.18.1
2026-10-16 13:46:24 +02:00
glenn schrooyenandClaude Opus 5.5 0ee3c3468b Release 0.18.1 review: CHANGELOG text fixes
checks / unit (pull_request) Successful in 40m18s
infra-pin / fresh (pull_request) Successful in 1s
SAFETY-118 line: the add-on already turns a kW forecast into W
(main.py read_inputs, abs(raw) < 50), so only the settings-page warning is
wrong. Rounding note and rows: the new settings page; UX-44 also changed
one DOCS line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 13:03:37 +02:00
glenn schrooyenandClaude dec8e7b69f Release 0.18.1
checks / unit (pull_request) Successful in 40m39s
infra-pin / fresh (pull_request) Successful in 2s
Version 0.18.0 -> 0.18.1 (patch, no option changes) and a closed 0.18.1 CHANGELOG section.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-16 12:08:58 +02:00
admin 8e02a0928b Merge pull request 'UX-44: contract label says every 15 minutes, not hourly' (#195) from UX-44 into release/1.0
infra-pin / fresh (push) Successful in 2s
2026-10-16 06:59:19 +02:00
glenn schrooyenandClaude 1bd2ba1397 UX-44: contract label says every 15 minutes, not hourly
checks / unit (pull_request) Successful in 40m37s
infra-pin / fresh (pull_request) Successful in 2s
The settings page contract choice read "Dynamic, hourly" / "Dynamisch,
per uur". Belgian day-ahead prices are per 15 minutes since 2025-10-01.
Now "Dynamic, every 15 minutes" / "Dynamisch, per kwartier". One DOCS
line that called dynamic pricing "hourly pricing" now says dynamic.
test_ui_text.py part 7 fails if either label says hourly again.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-16 05:26:32 +02:00
admin b703aaa6d5 UX-43: tariff prices take five decimals (#194)
infra-pin / fresh (push) Successful in 2s
2026-10-16 04:30:09 +02:00
UX-43 review 92a3d3400a UX-43 review: plain wording for the DOCS line on five-decimal prices
checks / unit (pull_request) Successful in 40m23s
infra-pin / fresh (pull_request) Successful in 1s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 03:47:28 +02:00
glenn schrooyenandClaude 8c954acb36 UX-43: keep each field description to one sentence (STYLE)
checks / unit (pull_request) Successful in 40m31s
infra-pin / fresh (pull_request) Successful in 1s
Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
2026-10-16 02:29:31 +02:00
glenn schrooyenandClaude 8f72ab00d7 UX-43: tariff price fields take five decimals
ui.fixed_price, ui.day_price, ui.night_price and ui.periods step 0.00001
EUR/kWh (the period editor's price input took a hard-coded 0.01). A short
c-EUR/kWh hint is added to the four field descriptions, EN and NL.

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
2026-10-16 01:40:40 +02:00
admin 3b7d6b7f05 Deploy 0.18.0 to master (#193) 2026-10-16 00:42:54 +02:00
admin a0e091cca3 Release 0.18.0 (#192)
infra-pin / fresh (push) Successful in 1s
checks / unit (pull_request) Successful in 40m22s
infra-pin / fresh (pull_request) Successful in 1s
2026-10-15 23:17:10 +02:00
glenn schrooyenandClaude Opus 5.5 44cd1fde2a Release 0.18.0: review fixes to the CHANGELOG section
checks / unit (pull_request) Successful in 40m17s
infra-pin / fresh (pull_request) Successful in 2s
OPT-15 and OPT-20 rows: the retry is two inverters only, and OPT-20 no
longer claims no other battery moves. SAFETY-111 added to known issues.
MAINT-19 marked as from code reading. Wording fixes in the SAFETY-112 and
MAINT-11 rows, UX-38 and the price service note.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 22:35:47 +02:00
Claude e7c83d3a57 Release 0.18.0: version bump and CHANGELOG section
checks / unit (pull_request) Successful in 40m26s
infra-pin / fresh (pull_request) Successful in 1s
config.yaml version 0.17.0 -> 0.18.0 and a closed ## 0.18.0 CHANGELOG
section for OPT-17, SAFETY-112, OPT-15, MAINT-11, PRICE-01 and OPT-20.
No code change.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-15 21:37:17 +02:00
adminandClaude Opus 5.5 004f859ec7 Merge PR #191: OPT-20 a refusal probe asks only PROBE_W, not the whole trade
infra-pin / fresh (push) Successful in 1s
Reviewed at ccdb9dc: full suite 78/78, CI run 1289 green, identity equal to bf9019e. Review: PR #191 comment 2185.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 14:56:17 +02:00
glenn schrooyenandClaude ccdb9dc17a OPT-20: test_opt20 points at MUTATION.md (test_bytecode part 5)
checks / unit (pull_request) Successful in 40m25s
infra-pin / fresh (pull_request) Successful in 1s
Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-15 13:12:22 +02:00
glenn schrooyenandClaude 06339a2446 OPT-20: a refusal probe asks only PROBE_W, not the whole trade
checks / unit (pull_request) Failing after 40m47s
infra-pin / fresh (pull_request) Successful in 1s
The probe's trade was sized at the probing battery's rating. Its SAFETY-73
pin held 200 W of that, and the allocator put the rest on a battery with no
mode range (one in a maintenance phase): TEST-23 OPT-15 g7, -2300 W on A in
its maintenance charge for 34 s, grid 3100 W.

modetrade.decide() now asks PROBE_W for the probing battery plus what the
other chargers would have had without the probe; size() adds the probe on top
of their share, or files PROBE_W exactly, or nothing. _mode_trade() carries
the pin only on the ticks it is filed, so the window's first and last cycle
do not move another battery's share either. Modes off and modes on with no
probe are unchanged (bf9019e digests). Tests: test_opt20.py.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-15 13:08:59 +02:00
admin bf9019e70d Merge PR #190: PRICE-01 price_service provider, fallback chain and gate
infra-pin / fresh (push) Successful in 1s
2026-10-14 22:47:43 +02:00
admin 1b6f16dba2 Deploy 0.17.0 to master (#186) 2026-10-11 01:37:01 +02:00
admin df154a26d1 Deploy 0.16.0 to master (#179) 2026-10-08 01:43:37 +02:00
admin 21599938ea Merge pull request 'Deploy 0.15.0 to master' (#174) from deploy-0.15.0 into master
Deploy 0.15.0 to master (#174)
2026-10-05 10:17:38 +02:00
admin 3a54b253cb Merge pull request 'Deploy 0.14.0 to master' (#170) from deploy-0.14.0 into master
Deploy 0.14.0 to master (#170)
2026-10-03 12:32:16 +02:00
admin 8b9680fc35 Merge pull request 'Deploy 0.13.0 to master' (#166) from deploy-0.13.0 into master
Reviewed-on: #166
2026-10-02 15:26:46 +02:00
admin 3ce18708b6 PKG-18: ship 0.12.0 to master, so TEL-12's pv_entities can reach an installed 0.11.0 (#118) 2026-09-24 23:22:34 +02:00
adminandClaude Opus 5 78acb558ff PKG-17: ship 0.11.0 to master, so SAFETY-56's wording fix and TEL-11's pv_entity can reach an installed 0.10.1 (#115)
master is what the Home Assistant Supervisor clones, and the Supervisor
compares config.yaml version strings - so 0.11.0 could not be offered to an
installed 0.10.1 without this bump reaching master.

Carries SAFETY-56 (#112), TEL-11 (#113), the CI-9 follow-up (#102) and SEC-2's
docs/RELEASE.md (#108), by way of the 0.11.0 cut (#114).

NOT A FAST-FORWARD. master was not an ancestor of release/1.0: 0f14c2a, the
pre-SEC-2 direct push, exists only on master. Its tree is the same object as
the merge base b0b24e5 (c1fef26), so it carries no content delta, and
`git merge-tree --write-tree` over the two branches returns a tree bit-identical
to cccb2fa's own (6670502). This merge commit therefore contributes exactly
release/1.0's tree - master synthesises nothing.

Reviewed as the release decision, not a re-review of content. Verified from the
remote: slug goodwe_controller and every identity key unchanged (cross-checked
against the live box's Supervisor export), version 0.10.1 -> 0.11.0, and the
green is this PR's own - run 828 / job 1953, path checks.yml@refs/pull/115/head,
PRNUM 115, GRADED refs/pull/115/head merged into master, run_attempt 1, 31 test
files all passing in 8m01s of real work.

predeploy_check.py re-run against the owner's current options (exported
2026-09-24 20:02, 75 keys, pv_entity unset): CLEARED, exit 0. That retires the
stale-2026-09-07-export warning in the pull request body. Option surface
75 -> 76 with only pv_entity added, str? defaulting to "", no key removed, no
default moved, no rule added to validate.py - no SystemExit(78) on this hop.

Merging is not installing. No tag is cut here and `ha store reload` /
`ha addons update` have not been run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-24 20:28:35 +02:00
glenn schrooyenandClaude Opus 5 0f14c2a5de PKG-16: ship 0.10.1 to master, so the Supervisor can offer SAFETY-57's fix
master is what Home Assistant clones. SAFETY-57 fixed a deadlock that parked
the reference install's battery for ~13 minutes while the site exported
2.4 kW, and the Supervisor compares config.yaml version strings - so the fix
could not reach an installed 0.10.0 without this bump reaching master.

Brings SAFETY-57 (#104) and the 0.10.1 cut (#105). master was a strict
ancestor; no conflict was possible.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-22 15:54:29 +02:00
14 changed files with 721 additions and 23 deletions
+142
View File
@@ -1,5 +1,147 @@
# Changelog
## 0.18.1
**A patch bump: two small fixes to the settings page, and no option changes.**
No option was added, removed or renamed, and no default changed (86 options).
**Existing installs need no option change:** options that start 0.18.0 also
start 0.18.1. The changes below apply with one inverter or two.
**Price rounding on 0.18.0.** On 0.18.0, the new settings page saved the fixed,
day and night prices rounded to 2 decimals (0.34512 became 0.35); prices in time
windows kept their decimals. If you typed more than 2 decimals in those
fields on 0.18.0, enter them again.
Contents of this release, in merge order since 0.18.0 (`a0e091c`):
| | |
|---|---|
| `b703aaa` | The four tariff price fields on the new settings page (fixed, day, night and time windows) take 5 decimals, for example 0.34512 €/kWh, and are saved as typed. A short hint says to divide the c€/kWh on your bill by 100 ([[UX-43]], #194) |
| `8e02a09` | The dynamic contract choice on the new settings page now says "Dynamic, every 15 minutes" ("Dynamisch, per kwartier") instead of "hourly". The choice works as before, and one line in the docs no longer calls dynamic prices hourly ([[UX-44]], #195) |
### Known issues
- A false "Battery is not keeping up" can still show when the peak guard pauses a maintenance charge ([[SAFETY-92]]).
- A battery capped at a low limit with a perfectly flat reading can still trip the stale check ([[SAFETY-74]]).
- A battery whose reading at rest is beyond about ±175 W is still not caught when it refuses ([[SAFETY-97]]).
- The peak guard can still back off near the end of a 15-minute period when a meter reading is late, by less than 30 s ([[SAFETY-99]]).
- Two inverters: the maintenance charge can switch on and off while the house is above the peak aim ([[SAFETY-88]]).
- Two inverters: the "circulating" figure counts a battery's reading at rest ([[SAFETY-98]]).
- Two inverters: a drain that starts during a big step in house use can send the other battery to full power, and about 1 kW goes to the grid for about 10 s ([[SAFETY-104]]).
- Two inverters: when a battery comes back during a maintenance charge, the grid can step by several kW at once ([[SAFETY-101]]).
- Two inverters: when a battery drops out during a solar surplus, the others can swing by up to 1.5 kW in a simulation ([[SAFETY-105]]).
- Two inverters: after a drain ends, a change in house use can go unanswered for about 9 s, because some readings are old ([[SAFETY-106]]).
- Two inverters: when one battery's level is missing, both batteries are held at 0 W, and for about 15 s the last command stays on ([[SAFETY-108]]).
- Two inverters: if one battery's level stays missing for 12 h, the other battery's cycle can be marked failed, with a 7-day wait and a notice, though it is healthy. This comes from reading the code; it has not been seen on a site ([[MAINT-17]]).
- When the 15-minute demand forecast sensor counts the battery's own charge, the maintenance charge runs at about half the power the peak limit leaves, so it takes about twice as long. Seen on a site: about 1.2 kW instead of 2.5 kW, and the peak limit was kept ([[SAFETY-111]]).
- One inverter: after a link drop, the battery's own safety stop to 0 W can be taken as a refusal, and the site stays at 0 W for up to 15 min ([[SAFETY-102]]).
- After a Home Assistant restart, control still waits until Home Assistant has finished starting, which can take several minutes, though the readings are already there ([[SAFETY-115]]).
- While Home Assistant is not answering, the page can list inverters as refusing commands and sensors as missing, and send you to Developer Tools, though they are fine ([[UX-37]]).
- If Home Assistant hangs or stops, the page keeps saying "Waiting for Home Assistant" and never turns into an alarm ([[SAFETY-113]]).
- Right after Home Assistant has started, the page can say for about 4 s that an inverter cannot be controlled, and the first command can come late ([[SAFETY-114]]).
- After a Home Assistant restart, the controller status sensor can still say "running" while the add-on cannot control. It was seen once, and the cause is not known ([[SAFETY-116]]).
- Battery modes on: after a planned top-up from the grid, the Batteries screen can say "Enough sun is forecast" ([[UX-32]]).
- Battery modes on: the forecast line "we count on" can show more sun than the plan counts, because the plan takes house use off first ([[UX-33]]).
- Battery modes on: before a planned top-up, the Today list shows a smaller "gives" amount than the plan will use; it grows as the charge happens ([[UX-34]]).
- Two inverters, battery modes on: while the other battery is in its maintenance charge, a battery's card can say it charges from the grid while it stays at 0 W ([[OPT-18]]).
- Two inverters, battery modes on: while a battery refuses to charge in a cheap run, the action log on the page gets about 8 lines an hour for its retries, and the page hint says "waiting for a better price" ([[UX-38]]).
- A battery in a safety stop, or one the add-on cannot reach, can still be started for a cycle, and its drain then waits about 12 h and ends in a failed-cycle notice. This comes from reading the code; it has not been seen on a site ([[MAINT-19]]).
- On the Settings page, the rows of the solar forecast lists are labelled "Phase 1", "Phase 2" instead of "Sensor 1", "Sensor 2" ([[UX-27]]).
- In the solar forecast list, a blank or repeated row makes "source 2 of 2" count differently from the rows on the Settings page ([[UX-28]]).
- The solar forecast message says "has no reading" also when the sensor shows a number that is too old or in the wrong unit ([[UX-29]]).
- If you use only the forecast lists, two Settings notes sit on the single-sensor field, and one of them tells you to set that field ([[UX-30]]).
- After a restart during a price outage, the "No prices" message can say "yet" when prices did come in earlier ([[UX-39]]).
- On the new settings page, you cannot see whether a price service token is set. Check the Configuration tab ([[UX-41]]).
- On the classic settings page, the first click on Save after editing a field can do nothing. Click again ([[UX-42]]).
- If the 15-minute demand forecast sensor reads in kW, the settings page warns that its number is taken as watts and asks for a sensor in W. The add-on does turn kW into W, so a kW sensor works ([[SAFETY-118]]).
- The price chart on the Home page shows one bar per hour, the average of four 15-minute prices, while the add-on decides on the 15-minute prices ([[UX-45]]).
### If you are crossing from a version older than 0.10.0
`## 0.10.0` below can refuse an old config: `slew_w` above its limit, and the
withdrawn `price_provider` ids `engie_dynamic`, `totalenergies_dynamic` and
`eneco_eddy`. Read it before you update. Run `tools/predeploy_check.py` with the
bare options (`jq '.data.options'`) first.
## 0.18.0
**A minor bump: new behaviour, three new options and one new value for an
existing option.** No option was removed or renamed, and no default of an
existing option changed (83 options -> 86). **Existing installs need no option
change:** options that start 0.17.0 also start 0.18.0, and the new options are
off or empty until you use them. The changes below apply with one inverter or
two, unless a line says "Two inverters".
New options, and what they do if you leave them alone:
| option | default | left alone |
|---|---|---|
| `pv_forecast_entities`, `pv_forecast_tomorrow_entities` | `[]` | the single forecast sensors work as before ([[OPT-17]]) |
| `price_service_token` | empty | not used; it is read only with `price_provider: price_service` ([[PRICE-01]]) |
`price_provider` also accepts a new value, `price_service`. Its default is
unchanged, so the price source stays as it is.
**Price service.** The new price source `price_service` reads prices from the
add-on's price server, with the token you were given. If it does not answer,
prices come from energy-charts.info, then from the prices the add-on saved.
Sites on the Home Assistant price sensors or a fixed tariff need no change. To
move a site over, see `DOCS.md`, "Moving a site from the Home Assistant price
sensors to `price_service`".
Contents of this release, in merge order since 0.17.0 (`e36565f`):
| | |
|---|---|
| `3050fa5` | The solar forecast can come from several sensors, added up, for example one per inverter. If one of them has no reading, that day's forecast is not used, and the status says which one. The single sensor still works ([[OPT-17]]) |
| `e3e4ba6` | While Home Assistant is still starting, the page now says "Waiting for Home Assistant" and the log says the add-on waits, instead of looking like a fault. Control itself is unchanged: it starts when Home Assistant answers ([[SAFETY-112]]) |
| `79dd077` | Battery modes on only: a charge in a cheap run no longer stops at the start of a new 15-minute period, and with two inverters a battery that refused to charge is tried again every 15 min. In one test, the export at the end of a charge was 48 W where the code before this fix gave 746 W ([[OPT-15]]) |
| `0ce7d35` | Two inverters: each battery card has Start cycle and Stop cycle buttons, so you can start or stop the cycle on the battery you choose. Only one battery can cycle at a time, and a second start is refused with a short message ([[MAINT-11]]) |
| `bf9019e` | New price source `price_service`. If it fails, prices come from energy-charts.info, then from saved prices. With no price at all and the monthly cycle on, a notice shows and the cycle waits ([[PRICE-01]]) |
| `004f859` | Two inverters, battery modes on: the 15-minute retry of a battery that refused to charge now asks that battery for 200 W, and no longer puts the rest of the charge on another battery. In the test, without this fix, a battery in its maintenance charge took about 2,300 W for about 34 s at each retry ([[OPT-20]]) |
### Known issues
- A false "Battery is not keeping up" can still show when the peak guard pauses a maintenance charge ([[SAFETY-92]]).
- A battery capped at a low limit with a perfectly flat reading can still trip the stale check ([[SAFETY-74]]).
- A battery whose reading at rest is beyond about ±175 W is still not caught when it refuses ([[SAFETY-97]]).
- The peak guard can still back off near the end of a 15-minute period when a meter reading is late, by less than 30 s ([[SAFETY-99]]).
- Two inverters: the maintenance charge can switch on and off while the house is above the peak aim ([[SAFETY-88]]).
- Two inverters: the "circulating" figure counts a battery's reading at rest ([[SAFETY-98]]).
- Two inverters: a drain that starts during a big step in house use can send the other battery to full power, and about 1 kW goes to the grid for about 10 s ([[SAFETY-104]]).
- Two inverters: when a battery comes back during a maintenance charge, the grid can step by several kW at once ([[SAFETY-101]]).
- Two inverters: when a battery drops out during a solar surplus, the others can swing by up to 1.5 kW in a simulation ([[SAFETY-105]]).
- Two inverters: after a drain ends, a change in house use can go unanswered for about 9 s, because some readings are old ([[SAFETY-106]]).
- Two inverters: when one battery's level is missing, both batteries are held at 0 W, and for about 15 s the last command stays on ([[SAFETY-108]]).
- Two inverters: if one battery's level stays missing for 12 h, the other battery's cycle can be marked failed, with a 7-day wait and a notice, though it is healthy. This comes from reading the code; it has not been seen on a site ([[MAINT-17]]).
- When the 15-minute demand forecast sensor counts the battery's own charge, the maintenance charge runs at about half the power the peak limit leaves, so it takes about twice as long. Seen on a site: about 1.2 kW instead of 2.5 kW, and the peak limit was kept ([[SAFETY-111]]).
- One inverter: after a link drop, the battery's own safety stop to 0 W can be taken as a refusal, and the site stays at 0 W for up to 15 min ([[SAFETY-102]]).
- After a Home Assistant restart, control still waits until Home Assistant has finished starting, which can take several minutes, though the readings are already there ([[SAFETY-115]]).
- While Home Assistant is not answering, the page can list inverters as refusing commands and sensors as missing, and send you to Developer Tools, though they are fine ([[UX-37]]).
- If Home Assistant hangs or stops, the page keeps saying "Waiting for Home Assistant" and never turns into an alarm ([[SAFETY-113]]).
- Right after Home Assistant has started, the page can say for about 4 s that an inverter cannot be controlled, and the first command can come late ([[SAFETY-114]]).
- After a Home Assistant restart, the controller status sensor can still say "running" while the add-on cannot control. It was seen once, and the cause is not known ([[SAFETY-116]]).
- Battery modes on: after a planned top-up from the grid, the Batteries screen can say "Enough sun is forecast" ([[UX-32]]).
- Battery modes on: the forecast line "we count on" can show more sun than the plan counts, because the plan takes house use off first ([[UX-33]]).
- Battery modes on: before a planned top-up, the Today list shows a smaller "gives" amount than the plan will use; it grows as the charge happens ([[UX-34]]).
- Two inverters, battery modes on: while the other battery is in its maintenance charge, a battery's card can say it charges from the grid while it stays at 0 W ([[OPT-18]]).
- Two inverters, battery modes on: while a battery refuses to charge in a cheap run, the action log on the page gets about 8 lines an hour for its retries, and the page hint says "waiting for a better price" ([[UX-38]]).
- A battery in a safety stop, or one the add-on cannot reach, can still be started for a cycle, and its drain then waits about 12 h and ends in a failed-cycle notice. This comes from reading the code; it has not been seen on a site ([[MAINT-19]]).
- On the Settings page, the rows of the solar forecast lists are labelled "Phase 1", "Phase 2" instead of "Sensor 1", "Sensor 2" ([[UX-27]]).
- In the solar forecast list, a blank or repeated row makes "source 2 of 2" count differently from the rows on the Settings page ([[UX-28]]).
- The solar forecast message says "has no reading" also when the sensor shows a number that is too old or in the wrong unit ([[UX-29]]).
- If you use only the forecast lists, two Settings notes sit on the single-sensor field, and one of them tells you to set that field ([[UX-30]]).
- After a restart during a price outage, the "No prices" message can say "yet" when prices did come in earlier ([[UX-39]]).
- On the new settings page, you cannot see whether a price service token is set. Check the Configuration tab ([[UX-41]]).
- On the classic settings page, the first click on Save after editing a field can do nothing. Click again ([[UX-42]]).
### If you are crossing from a version older than 0.10.0
`## 0.10.0` below can refuse an old config: `slew_w` above its limit, and the
withdrawn `price_provider` ids `engie_dynamic`, `totalenergies_dynamic` and
`eneco_eddy`. Read it before you update. Run `tools/predeploy_check.py` with the
bare options (`jq '.data.options'`) first.
## 0.17.0
**A minor bump: changed behaviour, and no option changes.** No option was
@@ -132,6 +132,23 @@ P1 is its section 6 P1, and the pull request lists what was measured.
own, and a rise past that is the peak floor's, which ends the hold.
A maintenance drain, hold or charge that starts during the hold ends it
on that tick (replayed at edge + 3 s).
- OPT-20 (TEST-23 OPT-15 g7, `test_opt20.py`): a probe asks only PROBE_W.
The trade was sized at the probing battery's rating (2500 W), B's pin held
200 W of it, and the allocator put the other 2300 W on A, in its
maintenance charge phase (no mode range there, so nothing kept it out):
grid 3100 W for 34 s. Now the ask is PROBE_W for the probing battery plus
what the other chargers would have had without the probe (none: 200 W
alone), and it carries the pin only on the ticks the pin is filed (the
window's first and last cycle moved another battery's share by 200 W).
- Chosen: the probe sits on top of a headroom-sized charge, so beside a
normal charger its command is the same and the grid rises 200 W (about
7 W on the quarter) rather than that charger giving 200 W up. A probe
that the headroom or the bound cannot hold at 200 W is not filed.
- Unchanged, same on bf9019e: with a charge ask the other batteries cannot
give (one sign), so an idle-looking A that gives the house stops for the
window and the grid rises by what it gave. The trade line now says 200 W
for a probe alone; its words are UX-38's. The base P4 F4 path (a normal
trade on a battery in maintenance charge) is not touched.
## P4: the solar forecast and the day plan (2026-09-29)
- Input: a Home Assistant forecast sensor (`pv_forecast_entity`, kWh left
+4 -3
View File
@@ -3113,15 +3113,16 @@ second set of 23- and 25-hour-day bugs.
### Static day/night and fully-fixed tariffs (`static_profile`)
Most Belgian households are **not** on a dynamic contract, and an EMS that only
works under hourly pricing is an EMS for a minority. `static_profile` is how a
works under dynamic pricing is an EMS for a minority. `static_profile` is how a
two-period day/night tariff or a single fixed price becomes a first-class
configuration rather than "turn the price feature off and hope".
On the add-on page, choose **Other time windows** under Settings, Tariff and
prices, or in Setup step 4. Add one row per time window, with its start, end,
days and price. The page names any overlap, gap or empty price before you can
save. You only need the JSON below for days the page cannot choose, such as
Monday and Wednesday only.
save. Prices take five decimals: divide the c€/kWh on your bill by 100, so
34.512 c€/kWh is 0.34512 €/kWh. You only need the JSON below for days the page
cannot choose, such as Monday and Wednesday only.
It is one option, holding a JSON object, on one line:
+13
View File
@@ -2441,6 +2441,19 @@ class Controller:
tick = self.mode_tick
if tick is None:
return False
if tick.action == modetrade.CHARGE:
# OPT-20: the ask carries PROBE_W for exactly the probes whose pin
# is filed this tick. `decide()` ran before the pin was (or the
# window closed since), so the two can differ by one cycle; left
# alone, the allocator takes the pin's watts out of another
# battery's share, or hands them to one it can reach.
pins = _ctl.PROBE_W * sum(
1 for c in claims if c.source == "refused"
and c.kind == "set" and c.value < 0)
if pins != tick.probe_w:
tick = self.mode_tick = dataclasses.replace(
tick, ask_w=tick.ask_w + tick.probe_w - pins,
probe_w=pins)
size = modetrade.size(tick, self.loop_bound(claims)
if tick.action else None, self.arb_headroom_w)
runs = self.mode_runs
+26 -4
View File
@@ -47,12 +47,17 @@ Rules that keep this safe (tests: test_modes_p3.py, test_price_run_fixes.py):
is its SAFETY-73 probe inside a cheap run (OPT-15): it is a charger then,
pinned at PROBE_W by its own claim, so the others rest (one sign) and the
card's "tries again every 15 minutes" holds in the run too;
* OPT-20: a probe asks PROBE_W for the probing battery and nothing more.
The rest of the ask is what the other chargers would have had without
the probe (none: PROBE_W alone), so a probe never places charge on a
battery in a maintenance phase or on one that rests;
* no grid charge while another battery drains for its monthly cycle
(`draining`, SAFETY-78): it would charge against the drain.
"""
from dataclasses import dataclass
from .control import PROBE_W
from .packmode import (CHEAP_HOURS, GRID_CHARGE_MODES, LEFTOVER_SOLAR,
PLAN_CHARGE_MODES, SOLAR_FIRST)
@@ -88,6 +93,7 @@ class Tick:
action: str | None # "charge" | "sell" | None
ask_w: float # signed site ask before sizing (0 if none)
why: str | None # why no trade when a run is on, else None
probe_w: float = 0.0 # OPT-20: the part of `ask_w` that is a probe
def _room_kwh(p: Pack, pct: float) -> float:
@@ -232,8 +238,12 @@ def decide(runs, now, packs, *, charge_ok, sell_ok, trade_why, price_now,
ranges[p.name] = (lo, hi)
if charging:
ask = -sum(abs(p.rating_w) for p in ch)
return Tick(ranges, roles, CHARGE, ask, None)
# OPT-20: a probing refuser is asked PROBE_W (its own pin holds it
# there); only the batteries that charge without a probe are asked
# for their rating.
probe_w = PROBE_W * sum(1 for p in ch if p.refused < 0)
ask = -sum(abs(p.rating_w) for p in ch if p.refused >= 0) - probe_w
return Tick(ranges, roles, CHARGE, ask, None, probe_w)
if selling:
ask = sum(abs(p.rating_w) for p in se) + max(0.0, float(house_w))
return Tick(ranges, roles, SELL, ask, None)
@@ -250,8 +260,20 @@ def size(tick: Tick, bound, headroom_w) -> float:
return 0.0
lo, hi = bound
if tick.action == CHARGE:
cap = min(-float(tick.ask_w), -float(lo),
float(headroom_w) if headroom_w is not None else 0.0)
room = float(headroom_w) if headroom_w is not None else 0.0
pw = float(tick.probe_w)
if pw > 0.0:
# OPT-20: the others' share is sized as without the probe; the
# probe's PROBE_W is added on top (the grid rises by PROBE_W at
# most), and alone it is sized by the bound and the headroom.
rest = max(0.0, min(-float(tick.ask_w) - pw, -float(lo) - pw,
room))
if rest > 0.0:
return -(rest + pw)
# PROBE_W exactly or no trade: less would void the pin and hand
# the remainder to whichever battery the allocator can reach.
return -pw if min(-float(lo), room) >= pw else 0.0
cap = min(-float(tick.ask_w), -float(lo), room)
return -max(0.0, cap)
return max(0.0, min(float(tick.ask_w), float(hi)))
+1 -1
View File
@@ -311,7 +311,7 @@
const days = h("select", { class: "sel", "aria-label": say(P.days) });
for (const [v, label] of f.days || []) days.append(h("option", { value: v, selected: v === row.days }, label));
days.addEventListener("change", () => set("days", days.value));
const price = h("input", { class: "num", type: "number", inputmode: "decimal", step: "0.01",
const price = h("input", { class: "num", type: "number", inputmode: "decimal", step: String(f.step || 0.01),
value: row.price == null ? "" : row.price, "aria-label": say(P.price) });
price.addEventListener("input", () => set("price", price.value === "" || isNaN(+price.value) ? null : +price.value));
const cell = (label, ...kids) => h("label", { class: "pf" }, h("span", { class: "tiny muted" }, label), ...kids);
+5 -5
View File
@@ -23,13 +23,13 @@
"contract": {"name": "Your contract", "description": "How your supplier prices each kWh you take from the grid."},
"region": {"name": "Region", "description": "Fills in grid costs, levies and VAT for the year shown, but not yet for Wallonia or Brussels, so check your bill."},
"fallback": {"name": "Price when the feed is down", "description": "Used when the price source does not answer."},
"fixed_price": {"name": "Price per kWh, all in", "description": "Energy, supplier costs, grid costs, levies and VAT together."},
"day_price": {"name": "Day price, weekdays 07:00 to 22:00", "description": "All in, as on your bill."},
"night_price": {"name": "Night and weekend price", "description": "All in, as on your bill."},
"periods": {"name": "Time windows and prices", "description": "One price per kWh for each time window, all in, as on your bill."}
"fixed_price": {"name": "Price per kWh, all in", "description": "Energy, supplier costs, grid costs, levies and VAT together: divide c€/kWh from the bill by 100, so 34.512 c€/kWh is 0.34512."},
"day_price": {"name": "Day price, weekdays 07:00 to 22:00", "description": "All in, as on your bill: divide c€/kWh by 100, so 34.512 c€/kWh is 0.34512."},
"night_price": {"name": "Night and weekend price", "description": "All in, as on your bill: divide c€/kWh by 100, so 34.512 c€/kWh is 0.34512."},
"periods": {"name": "Time windows and prices", "description": "One price per kWh for each time window, all in, as on your bill: divide c€/kWh by 100, so 34.512 c€/kWh is 0.34512."}
},
"choices": {
"contract": {"none": "No prices", "fixed": "Fixed price", "daynight": "Day and night", "dynamic": "Dynamic, hourly", "custom": "Other time windows"},
"contract": {"none": "No prices", "fixed": "Fixed price", "daynight": "Day and night", "dynamic": "Dynamic, every 15 minutes", "custom": "Other time windows"},
"fallback": {"none": "None", "fixed": "One price", "daynight": "Day and night", "custom": "Other time windows"},
"region": {"flanders": "Flanders, Fluvius Antwerpen", "own": "I enter my own figure"},
"meter_source": {"off": "One grid power entity", "ha_dsmr": "Import and export entities", "mqtt_p1": "Meter over MQTT", "ha_signed": "One net power entity", "homewizard_local": "HomeWizard meter on the network"},
+5 -5
View File
@@ -23,13 +23,13 @@
"contract": {"name": "Uw contract", "description": "Hoe uw leverancier elke kWh prijst die u van het net afneemt."},
"region": {"name": "Regio", "description": "Vult nettarieven, heffingen en btw in voor het getoonde jaar, nog niet voor Wallonië of Brussel, dus kijk uw factuur na."},
"fallback": {"name": "Prijs als de feed wegvalt", "description": "Gebruikt als de prijsbron niet antwoordt."},
"fixed_price": {"name": "Prijs per kWh, alles inbegrepen", "description": "Energie, kosten van de leverancier, nettarieven, heffingen en btw samen."},
"day_price": {"name": "Dagprijs, weekdagen 07:00 tot 22:00", "description": "Alles inbegrepen, zoals op uw factuur."},
"night_price": {"name": "Nacht- en weekendprijs", "description": "Alles inbegrepen, zoals op uw factuur."},
"periods": {"name": "Tijdvakken en prijzen", "description": "Eén prijs per kWh voor elk tijdvak, alles inbegrepen, zoals op uw factuur."}
"fixed_price": {"name": "Prijs per kWh, alles inbegrepen", "description": "Energie, kosten van de leverancier, nettarieven, heffingen en btw samen: deel c€/kWh van de factuur door 100, dus 34,512 c€/kWh is 0,34512."},
"day_price": {"name": "Dagprijs, weekdagen 07:00 tot 22:00", "description": "Alles inbegrepen, zoals op uw factuur: deel c€/kWh door 100, dus 34,512 c€/kWh is 0,34512."},
"night_price": {"name": "Nacht- en weekendprijs", "description": "Alles inbegrepen, zoals op uw factuur: deel c€/kWh door 100, dus 34,512 c€/kWh is 0,34512."},
"periods": {"name": "Tijdvakken en prijzen", "description": "Eén prijs per kWh voor elk tijdvak, alles inbegrepen, zoals op uw factuur: deel c€/kWh door 100, dus 34,512 c€/kWh is 0,34512."}
},
"choices": {
"contract": {"none": "Geen prijzen", "fixed": "Vaste prijs", "daynight": "Dag en nacht", "dynamic": "Dynamisch, per uur", "custom": "Andere tijdvakken"},
"contract": {"none": "Geen prijzen", "fixed": "Vaste prijs", "daynight": "Dag en nacht", "dynamic": "Dynamisch, per kwartier", "custom": "Andere tijdvakken"},
"fallback": {"none": "Geen", "fixed": "Eén prijs", "daynight": "Dag en nacht", "custom": "Andere tijdvakken"},
"region": {"flanders": "Vlaanderen, Fluvius Antwerpen", "own": "Ik vul zelf een bedrag in"},
"meter_source": {"off": "Eén entiteit voor netvermogen", "ha_dsmr": "Entiteiten voor afname en injectie", "mqtt_p1": "Meter via MQTT", "ha_signed": "Eén entiteit voor nettovermogen", "homewizard_local": "HomeWizard-meter op het netwerk"},
+4 -4
View File
@@ -164,13 +164,13 @@ GROUPS = (
wizard=4),
Field("price_export_formula", TEXT),
Field("ui.fallback", SEGMENTED, show=_DYNAMIC, wizard=4),
Field("ui.fixed_price", NUMBER, "€/kWh", step=0.01,
Field("ui.fixed_price", NUMBER, "€/kWh", step=0.00001,
show=(("ui.fallback", "==", "fixed"),), wizard=4),
Field("ui.day_price", NUMBER, "€/kWh", step=0.01,
Field("ui.day_price", NUMBER, "€/kWh", step=0.00001,
show=(("ui.fallback", "==", "daynight"),), wizard=4),
Field("ui.night_price", NUMBER, "€/kWh", step=0.01,
Field("ui.night_price", NUMBER, "€/kWh", step=0.00001,
show=(("ui.fallback", "==", "daynight"),), wizard=4),
Field("ui.periods", PERIOD_LIST, "€/kWh", step=0.01,
Field("ui.periods", PERIOD_LIST, "€/kWh", step=0.00001,
show=(("ui.fallback", "==", "custom"),), wizard=4),
)),
Part("selling", (
+1 -1
View File
@@ -1,5 +1,5 @@
name: GoodWe RS485 Controller
version: "0.17.0"
version: "0.18.1"
slug: goodwe_controller
description: >-
Drives a GoodWe ES/BP battery inverter over RS485 by emulating its smart
+267
View File
@@ -0,0 +1,267 @@
"""Runnable check for OPT-20. `python3 test_opt20.py`
A refusal probe (OPT-15 F3, SAFETY-73) asks the probing battery PROBE_W and
nothing else. On TEST-23 (OPT-15 g7) the probe made the trade ask 2500 W: B
was pinned at 200 W and the allocator put the other 2300 W on A, which sat in
its maintenance charge phase at 0 W, with the grid at 3100 W for 34 s.
1. modetrade: the ask carries PROBE_W for the probing battery, the other
chargers' ratings as without the probe, and `size()` adds the probe on top
of their share (or asks PROBE_W exactly, or nothing).
2. main: the ask carries PROBE_W for exactly the pins filed this tick, so the
first and the last cycle of a window do not move another battery's share.
3. The real Controller in the lagged replay, with B refusing and probed while
A is (a) in its maintenance charge phase (also with the charge shaped to
0 W), (b) idle, (c) a normal charger in the same run, and the OPT-15 rig's
g7 sequence: A's command is the same through the window as just before it,
B gets PROBE_W, and the commanded charge rises by PROBE_W at most.
4. Battery modes on with no probe: byte-identical to release/1.0 bf9019e.
Out of scope, and unchanged: a normal (non-probing) trade placed on a battery
in maintenance charge (OPT-11 P4 F4) and the action-log wording (UX-38).
Mutations: MUTATION.md says how to run them (purged copies); the 12 of
OPT-20 are listed in the pull request.
"""
import dataclasses
import logging
import os
import sys
from types import SimpleNamespace
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
sys.path.insert(0, os.path.join(HERE, "tools"))
logging.disable(logging.WARNING)
import opt20_scen as sc # noqa: E402
from app import arbitrage, modetrade, packmode # noqa: E402
from app import control as ctl # noqa: E402
from app import main # noqa: E402
from app.arbiter import Claim, P_SAFETY # noqa: E402
fails = []
def check(name, cond):
print(f" {'ok ' if cond else 'FAIL'} {name}")
if not cond:
fails.append(name)
PW = ctl.PROBE_W
A, B = sc.A, sc.B
# ============================================================================ #
print("1. modetrade: the ask and the size")
_runs = SimpleNamespace(on=True, cheap=SimpleNamespace(holds=lambda n: True),
charge_useful=lambda: True, give_at=lambda n: None,
sell=None)
def _dec(packs, **kw):
args = dict(charge_ok=True, sell_ok=True, trade_why=None, price_now=0.1,
headroom_w=3000.0, floor_pct=20.0, ceiling_pct=95.0,
house_w=800.0)
args.update(kw)
return modetrade.decide(_runs, sc.UTC0, packs, **args)
_pb = modetrade.Pack(B, True, packmode.CHEAP_HOURS, False, 2500.0, 30.0, 10.0,
refused=-1, probing=True)
_pa = modetrade.Pack(A, True, packmode.CHEAP_HOURS, False, 2500.0, 50.0, 10.0)
_pm = dataclasses.replace(_pa, mode=packmode.SOLAR_FIRST, idle=False)
_pi = dataclasses.replace(_pa, mode=packmode.SOLAR_FIRST)
_t_alone = _dec([_pb, _pm])
check("a probe beside a battery in maintenance asks PROBE_W and no more",
_t_alone.action == modetrade.CHARGE and _t_alone.ask_w == -PW
and _t_alone.probe_w == PW)
check("...beside an idle battery that cannot charge from the grid too",
_dec([_pb, _pi]).ask_w == -PW and _dec([_pb, _pi]).probe_w == PW)
_t_two = _dec([_pb, _pa])
check("...beside a normal charger the ask is that charger's rating plus "
"PROBE_W", _t_two.ask_w == -2500.0 - PW and _t_two.probe_w == PW)
_t_np = _dec([dataclasses.replace(_pb, probing=False), _pa])
check("no probe: the ask is the chargers' ratings, probe_w is 0 (as before)",
_t_np.ask_w == -2500.0 and _t_np.probe_w == 0.0
and _dec([_pa, _pm]).ask_w == -2500.0
and modetrade.Tick({}, {}, None, 0.0, None).probe_w == 0.0)
def sz(ask, probe_w, lo, room, action=modetrade.CHARGE):
t = modetrade.Tick({}, {}, action, ask, None, probe_w)
return modetrade.size(t, (lo, 2500.0), room)
check("size, a probe alone: PROBE_W, whatever the headroom above it",
sz(-PW, PW, -(PW), 3000.0) == -PW and sz(-PW, PW, -2700.0, 9000.0) == -PW)
check("...exactly PROBE_W or nothing: a headroom or a bound under it files "
"no trade (a smaller ask voids the pin and the allocator would place "
"it elsewhere)",
sz(-PW, PW, -2700.0, PW - 1) == 0.0 and sz(-PW, PW, -(PW - 1), 3000.0)
== 0.0 and sz(-PW, PW, -2700.0, 0.0) == 0.0
and sz(-PW, PW, -2700.0, PW) == -PW)
check("size with another charger: its share is sized as without the probe, "
"the probe's PROBE_W is added on top",
sz(-2700.0, PW, -2700.0, 3000.0) == -2700.0
and sz(-2700.0, PW, -2700.0, 1000.0) == -1200.0
and sz(-2700.0, PW, -1700.0, 3000.0) == -1700.0
and sz(-2700.0, PW, -2700.0, 0.0) == 0.0)
check("size with no probe is what it was (ask, bound, headroom)",
sz(-2500.0, 0.0, -2700.0, 3000.0) == -2500.0
and sz(-2500.0, 0.0, -2700.0, 1000.0) == -1000.0
and sz(-2500.0, 0.0, -1800.0, 3000.0) == -1800.0
and sz(-2500.0, 0.0, -2700.0, None) == 0.0
and modetrade.size(modetrade.Tick({}, {}, None, 0.0, None),
(-5.0, 5.0), 3000.0) == 0.0)
# ============================================================================ #
print("\n2. main: the ask carries the pins filed this tick")
def trade(tick, pins, bound=(-2700.0, 2500.0), room=3000.0):
"""The trade `_mode_trade()` files: (claims, size or None)."""
claims = [Claim.set("refused", P_SAFETY, -PW * 1.0, ctl.PROBING,
actuator="x") for _ in range(pins)]
me = SimpleNamespace(
mode_tick=tick, loop_bound=lambda cl: bound, arb_headroom_w=room,
mode_runs=SimpleNamespace(
cheap=SimpleNamespace(start=sc.UTC0, end=sc.UTC0,
mean_eur_kwh=0.1),
sell=None, level_eur_kwh=0.1),
arb=None, _arb_action=None, log_event=lambda *a, **k: None)
filed = main.Controller._mode_trade(me, claims, sc.UTC0)
t = [c for c in claims if c.source == arbitrage.SOURCE]
return filed, (t[0].value if t else None), me.mode_tick
_t0 = modetrade.Tick({}, {A: modetrade.CHARGE}, modetrade.CHARGE, -2500.0,
None, 0.0)
check("the pin is filed but decide() ran before it (the window's first "
"cycle): the charger keeps its whole ask and the pin is added",
trade(_t0, 1)[1] == -2700.0 and trade(_t0, 1)[2].probe_w == PW)
check("the window closed after decide() (its last cycle): the probe's PROBE_W "
"leaves the ask, so no battery is handed it",
trade(_t_two, 0)[1] == -2500.0
and trade(_t_alone, 0) == (False, None, trade(_t_alone, 0)[2])
and trade(_t_alone, 0)[2].action is None)
check("pin and ask agree: nothing changes",
trade(_t_two, 1)[1] == -2700.0 and trade(_t_alone, 1)[1] == -PW
and trade(_t0, 0)[1] == -2500.0)
# ============================================================================ #
print("\n3. the real Controller: B refuses and is probed")
def charge(r):
return -sum(min(0.0, v) for v in r["shares"].values())
def window_checks(rows, label):
"""Per probe window: A as just before, B at PROBE_W (or 0 when nothing is
traded), the charge up by at most PROBE_W."""
win = sc.windows(rows)
ok_a = ok_b = ok_sum = ok_grid = True
for i, j in win:
pre = rows[i - 1]
lo, hi = max(0, i - 3), min(len(rows) - 1, j + 3)
ext = rows[lo:hi + 1]
base_sum = sum(min(0.0, v) for v in pre["shares"].values())
ok_a &= all(abs(r["shares"].get(A, 0.0) - pre["shares"].get(A, 0.0))
< 1e-6 for r in ext)
bs = [r["shares"].get(B, 0.0) for r in ext]
if any(r["action"] for r in rows[i:j + 1]):
ok_b &= set(bs) <= {0.0, -PW} and bs.count(-PW) >= 20
else: # a drain comes first: the window trades nothing
ok_b &= set(bs) == {0.0}
ok_sum &= all(charge(r) <= charge(pre) + PW + 1e-6 for r in ext)
ok_grid &= max(r["grid"] for r in ext) <= pre["grid"] + PW + 50.0
n_ch = sum(1 for r in rows if r["probing"] and r["action"])
print(f" {label}: {len(win)} window(s), {n_ch} probing cycles with a "
f"trade, highest grid "
f"{max(r['grid'] for r in rows):.0f} W")
return win, ok_a, ok_b, ok_sum, ok_grid
SCEN = {
"(a) A in its maintenance charge phase": (
sc.day(sc.SF_CH, a_maint=sc.MAINT_CHARGE), "same"),
"(a') ...with the charge shaped to 0 W": (
sc.day(sc.SF_CH, a_maint=sc.MAINT_CHARGE, shaped=True), "same"),
"(b) A idle at 0 W (Solar first, the sun covers the house)": (
sc.day(sc.SF_CH, house_w=-10.0), "same"),
"(c) A a normal charger in the same run": (sc.day(sc.CH_CH), "same"),
"(g7) the OPT-15 rig: drain, then maintenance charge at 0 W": (
sc.g7(), "same"),
# Not the fix's: A gives the house, and a charge ask means no other
# battery gives (OPT-11 owner answer 3), so A rests, as on bf9019e.
"(b2) A gives 800 W to the house": (sc.day(sc.SF_CH), "rests"),
}
for _name, (_rows, _kind) in SCEN.items():
win, ok_a, ok_b, ok_sum, ok_grid = window_checks(_rows, _name)
check(f"{_name}: a probe happens (non-vacuous)", len(win) >= 1)
if _kind == "same":
check(f"{_name}: A's command is the same through each window, first "
"and last cycle included", ok_a)
check(f"{_name}: the grid rises by PROBE_W at most (plus 50 W of "
"noise)", ok_grid)
else:
check(f"{_name}: A stops giving for the window (the one-sign rule, "
"as on bf9019e), charges nothing and gives back after it",
all(r["shares"].get(A, 0.0) == 0.0 for i, j in win
for r in _rows[i + 1:j]) and all(
_rows[j + 4]["shares"].get(A, 0.0) > 0.0 for i, j in win))
check(f"{_name}: B gets PROBE_W and nothing else", ok_b)
check(f"{_name}: the commanded charge rises by PROBE_W at most", ok_sum)
# The g7 sequence in detail: the probe after the drain.
_g7 = SCEN["(g7) the OPT-15 rig: drain, then maintenance charge at 0 W"][0]
_w7 = sc.windows(_g7)
_after = [w for w in _w7 if _g7[w[0]]["now"] > sc.at(18.3, sc.GUTC0)]
_peak7 = max(r["grid"] for r in _g7 if r["now"] > sc.at(18.3, sc.GUTC0))
print(f" g7: probes after the drain at "
f"{[_g7[w[0]]['now'].astimezone(sc.L).strftime('%H:%M:%S') for w in _after]}"
f", grid after the drain at most {_peak7:.0f} W (TEST-23: 3100 W)")
check("g7: the probe after A's drain asks B for 200 W with A at 0 W "
"(TEST-23: -2300 W on A, the grid at 3100 W)",
_after and all(
abs(r["shares"].get(A, 0.0)) < 1e-6 for i, j in _after
for r in _g7[i:j + 4]) and _peak7 <= 800.0 + PW + 50.0)
check("g7: during the drain no probe trades (a drain comes first)",
all(r["action"] is None for r in _g7
if sc.at(18.0, sc.GUTC0) <= r["now"] < sc.at(18.28, sc.GUTC0)))
_acts = [r["reason"] for i, j in _after for r in _g7[i:j + 1]
if r["action"]]
check("g7: the action log's trade line names the probe's watts, 200 W",
_acts and all(f"{PW:.0f} W on {B}" in a for a in _acts))
check("no opposite-direction split on any of these cycles",
not [r for _r, _ in SCEN.values() for r in _r
if any(v > 0 for k, v in r["shares"].items() if r["action"])
and any(v < 0 for v in r["shares"].values())])
# ============================================================================ #
print("\n4. battery modes on, no probe: byte-identical to release/1.0 bf9019e")
# Printed by tools/opt20_scen.py on release/1.0 bf9019e (2026-10-15),
# goodwe-addon-checks:1.
AT_BASE = {
"sf_ch": "422d6578a7decf1ae3749edd1898ff3cd0541d904c95f636cdd9b1de8c1a07c9",
"ch_ch": "15fc25dd34186e56d34b752d37c9e8613295ba737938c92454acca08c83080fb",
"ch_ch_maint":
"e46e3c666dd63ab33b56e83c0a560c07cec7774a9a70560f55c62a6ae6f55920",
}
_got = sc.no_probe_digests()
for _k in AT_BASE:
print(f" {_k} {'same' if _got[_k] == AT_BASE[_k] else 'DIFFERS'} "
f"{_got[_k][:16]}")
check("with no refusal every share, grid, trade sentence, role and plant "
"power of a scripted cheap run equals bf9019e's (A Solar first, A "
"charging, A in maintenance charge)", _got == AT_BASE)
print()
if fails:
print(f"{len(fails)} FAILED:")
for f in fails:
print(f" - {f}")
sys.exit(1)
print("all checks passed")
+16
View File
@@ -286,6 +286,22 @@ check("app/uitext.py falls back to English per key and reads nl-BE as nl",
and uitext._merge({"a": {"b": 1, "c": 2}}, {"a": {"b": 3}})
== {"a": {"b": 3, "c": 2}})
# ============================================================================ #
print("\n7. the dynamic contract choice says 15 minutes, never hourly (UX-44)")
# Belgian day-ahead prices are per 15 minutes since 2025-10-01.
HOURLY = re.compile(r"hourly|per hour|\bhour\b|per uur|\buur\b|uurprijs",
re.I)
_dyn = {"en": EN["choices"]["contract"]["dynamic"],
"nl": NL["choices"]["contract"]["dynamic"]}
check(f"the label reads 'Dynamic, every 15 minutes' / 'Dynamisch, per "
f"kwartier' ({_dyn})",
_dyn == {"en": "Dynamic, every 15 minutes",
"nl": "Dynamisch, per kwartier"})
check("neither language calls the dynamic contract hourly",
not any(HOURLY.search(t) for t in _dyn.values()))
check("mutation: the old labels are caught",
all(HOURLY.search(t) for t in ("Dynamic, hourly", "Dynamisch, per uur")))
print()
if fails:
print(f"{len(fails)} FAILED")
+60
View File
@@ -327,6 +327,66 @@ check("days the editor has no choice for stay in the Configuration tab, and "
["ui.periods"] is None and edit(dict(SITE, static_profile=_raw), gain=0.5)
["static_profile"] == _raw)
print("\n8. UX-43: tariff prices take five decimals")
from app import uilayout as _L # noqa: E402
_PX = 0.34512
_TARIFF = ("ui.fixed_price", "ui.day_price", "ui.night_price", "ui.periods")
_steps = {k: _L.field(k).step for k in _TARIFF}
check("the four tariff price fields step 0.00001 EUR/kWh",
sorted(_steps) == sorted(_TARIFF)
and all(v == 0.00001 for v in _steps.values()))
_f5 = edit(DEFAULTS, ui__contract="fixed", ui__fixed_price=_PX)
check("fixed price 0.34512 is saved exactly and read back exactly",
'"eur_per_kwh": 0.34512}' in _f5["static_profile"]
and U.options_to_ui(_f5)["ui.fixed_price"] == _PX
and trip(_f5) == _f5)
_d5 = edit(SITE, ui__contract="daynight", ui__day_price=_PX,
ui__night_price=0.26789)
check("day price 0.34512 and night price 0.26789 are saved and read back exactly",
'"eur_per_kwh": 0.34512' in _d5["static_profile"]
and '"eur_per_kwh": 0.26789' in _d5["static_profile"]
and U.options_to_ui(_d5)["ui.day_price"] == _PX
and U.options_to_ui(_d5)["ui.night_price"] == 0.26789
and not validate_startup_config(_d5))
_n5 = edit(SITE, ui__contract="daynight", ui__day_price=0.3, ui__night_price=_PX)
check("night price 0.34512 alone is saved and read back exactly",
U.options_to_ui(_n5)["ui.night_price"] == _PX)
_r3 = rows(("00:00", "07:00", "mon-sun", 0.12345), ("07:00", "22:00", "mon-sun", _PX),
("22:00", "24:00", "mon-sun", 0.05001))
_p3 = periods(_r3)
check("three windows with five-decimal prices save and read back exactly",
[p.eur_per_kwh for p in parse_static_profile(_p3["static_profile"]).periods]
== [0.12345, _PX, 0.05001]
and [r["price"] for r in U.options_to_ui(_p3)["ui.periods"]]
== [0.12345, _PX, 0.05001] and trip(_p3) == _p3
and "e-" not in _p3["static_profile"])
check("a two-decimal price still works", U.options_to_ui(
edit(DEFAULTS, ui__contract="fixed", ui__fixed_price=0.31))
["ui.fixed_price"] == 0.31)
_old = dict(SITE, price_provider="off", static_profile=json.dumps({"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}]}))
check("a stored profile with two-decimal prices is unchanged by load and save",
trip(_old) == _old)
_js = open(os.path.join(HERE, "app", "static", "settings.js"),
encoding="utf-8").read()
check("the window editor's price input takes its step from the field, not a "
"hard-coded 0.01", 'step: "0.01"' not in _js
and "step: String(f.step || 0.01)" in _js)
for _lang in ("en", "nl"):
with open(os.path.join(HERE, "app", "ui_text", f"{_lang}.json"),
encoding="utf-8") as _fh:
_flds = json.load(_fh)["fields"]
_hint = "34.512" if _lang == "en" else "34,512"
_eq = "0.34512" if _lang == "en" else "0,34512"
check(f"[{_lang}] the c€/kWh hint sits on all four tariff price fields",
all(_hint in _flds[k]["description"] and _eq in _flds[k]["description"]
for k in ("fixed_price", "day_price", "night_price", "periods")))
print()
if fails:
print(f"{len(fails)} FAILED")
+160
View File
@@ -0,0 +1,160 @@
"""OPT-20: the refusal-probe scenarios, on the REAL Controller in the lagged
replay (tools/opt11p3_replay.py). Uses only the API bf9019e has, so the same
file runs on both trees (test_opt20.py pins the digests printed on bf9019e).
Battery B is Cheap hours and refuses to charge; it is probed every 15 minutes
inside the cheap run (OPT-15). A is, per scenario, in its maintenance charge
phase, idle (Solar first), or a normal charger in the same run.
"""
import hashlib
import logging
import os
import sys
from datetime import datetime, timedelta, timezone
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
sys.path.insert(0, os.path.dirname(HERE))
logging.disable(logging.WARNING)
import opt11p3_replay as rp # noqa: E402
from app import packmode # noqa: E402
from app.maintenance import CHARGE, DRAIN, MaintResult # noqa: E402
L = timezone(timedelta(hours=2))
A, B = "inverter-a", "inverter-b"
LAG = rp.Lag(tau_s=0.05, ramp_w_s=1e9)
SF_CH = {"a": (True, packmode.SOLAR_FIRST, False),
"b": (True, packmode.CHEAP_HOURS, False)}
CH_CH = {"a": (True, packmode.CHEAP_HOURS, False),
"b": (True, packmode.CHEAP_HOURS, False)}
# A day of the owner's kind: cheap 02:00-04:00 local, flat otherwise.
D0 = datetime(2026, 9, 9, 0, 0, tzinfo=L)
UTC0 = D0.astimezone(timezone.utc)
_PR = [0.30] * 192
for _i in range(8, 16):
_PR[_i] = 0.10
WPTS = [(UTC0 + timedelta(minutes=15 * i), 900, p) for i, p in enumerate(_PR)]
# OPT-15 rig g7: prices 0 from 17:45 to 18:45 local, flat 0.06 otherwise.
G0 = datetime(2026, 10, 13, 0, 0, tzinfo=L)
GUTC0 = G0.astimezone(timezone.utc)
_GP = [0.06] * 192
for _i in range(71, 75):
_GP[_i] = 0.0
for _i in range(76, 78):
_GP[_i] = 0.5
G7PTS = [(GUTC0 + timedelta(minutes=15 * i), 900, p)
for i, p in enumerate(_GP)]
def at(h, base=UTC0):
return base + timedelta(hours=h)
def _rows(rows):
def cb(c, h, now, pr):
sh = dict(c.allocation.shares) if c.allocation is not None \
and c.allocation.shares else {}
rows.append(dict(
now=now, grid=h.grid(), shares=sh,
action=getattr(c.arb, "action", None),
reason=getattr(c.arb, "reason", None) or "",
probing=c.inverters[1].probe_from is not None,
roles=dict(c.mode_tick.roles) if c.mode_tick else {},
p={u: p.p for u, p in h.plants.items()}))
return cb
def day(choice, *, a_maint=None, shaped=False, refuse=("b",), hours=0.5,
house_w=800.0):
"""02:00..: a cheap run, `house_w` house. `a_maint` is A's forced maintenance
result from 5 minutes in (B's refusal stands by then); `shaped` closes the
maintenance charge's own watts (charge-only shaping, hi = 0)."""
rows = []
def patch(c):
if shaped:
c._charge_headroom = lambda floor, claims: 0.0
if a_maint is None:
return
m, real = c.maints[0], c.maints[0].tick
def tick(now, soc, blocked_by=None, gate_open=True):
if now >= at(1.9 + 5 / 60):
return a_maint
return real(now, soc, blocked_by=blocked_by, gate_open=gate_open)
m.tick = tick
rp.run("p3_ch_sell", [(UTC0.timestamp() - 10, house_w)], WPTS, at(1.9),
3600 * hours, check=_rows(rows), lag=LAG, n=2,
soc0={"a": 50.0, "b": 30.0}, choice=choice, refuse=refuse,
patch=patch, extra={"cycle_cost_threshold_eur": 0.10})
return rows
def g7():
"""The OPT-15 rig's g7: B refuses from the start of the cheap run, A
drains 18:00 to 18:17:04 and then sits in its maintenance charge phase
(charge-only) at 0 W. The probe after the drain used to put the trade's
2500 W on A."""
rows = []
t_end = at(18 + 17 / 60 + 4 / 3600, GUTC0)
def patch(c):
m, real = c.maints[0], c.maints[0].tick
def tick(now, soc, blocked_by=None, gate_open=True):
if at(18.0, GUTC0) <= now < t_end:
return MaintResult(DRAIN, True, 2000.0, False, [])
if now >= t_end:
return MaintResult(CHARGE, False, None, True, [])
return real(now, soc, blocked_by=blocked_by, gate_open=gate_open)
m.tick = tick
rp.run("p3_ch_sell", [(GUTC0.timestamp() - 10, 800.0)], G7PTS,
at(17.6, GUTC0), 3600 * 1.1, check=_rows(rows), lag=LAG, n=2,
soc0={"a": 17.0, "b": 73.0}, choice=SF_CH, refuse=("b",),
patch=patch, extra={"cycle_cost_threshold_eur": 0.10})
return rows
MAINT_CHARGE = MaintResult(CHARGE, False, None, True, [])
def windows(rows):
"""[(first, last)] row indexes of each probe window."""
out, i = [], 0
while i < len(rows):
if rows[i]["probing"]:
j = i
while j + 1 < len(rows) and rows[j + 1]["probing"]:
j += 1
out.append((i, j))
i = j + 1
else:
i += 1
return out
def digest(rows) -> str:
keep = [(r["now"].isoformat(), sorted(r["shares"].items()),
round(r["grid"], 3), r["action"], r["reason"],
sorted(r["roles"].items()), sorted(r["p"].items()))
for r in rows]
return hashlib.sha256(repr(keep).encode()).hexdigest()
def no_probe_digests() -> dict:
"""Battery modes on, no refusal, so no probe: a scripted cheap run."""
return {
"sf_ch": digest(day(SF_CH, refuse=(), hours=1.2)),
"ch_ch": digest(day(CH_CH, refuse=(), hours=1.2)),
"ch_ch_maint": digest(day(CH_CH, a_maint=MAINT_CHARGE, refuse=(),
hours=1.2)),
}
if __name__ == "__main__":
for k, v in no_probe_digests().items():
print(k, v)