Compare commits

..
30 Commits
Author SHA1 Message Date
glenn schrooyenandClaude 97686319f3 TARIFF-24 round 3: a run planned with a forecast keeps its gap to its end
checks / unit (pull_request) Successful in 51m57s
infra-pin / fresh (pull_request) Successful in 1s
Losing the solar forecast mid-run used to widen the gap at once and stop a
grid charge (and a sensor that went unavailable for a minute stopped and
restarted it). A run under way when the forecast is lost now keeps the gap it
was planned with until it ends (Controller.weather_held(), recorded by
_wx_track() each cycle a forecast is read); the wider gap applies from the next
run. The plan key holds the hold state so the plan rebuilds when it ends. Home's
mirror (homeview.trade_verdict) asks the same weather_held(now).

Tests: test_tariff24_weather.py part 4 (going: the charge continues to the run's
end, the next run is not_worth; flap of one and two reads: no stop, no restart;
Home agrees) and a test_modes_p4.py scenario that runs a forecast day at a gap
between 1x and 1.5x the cycle cost. DECISIONS wording follows.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-28 17:17:56 +01:00
glenn schrooyenandClaude d424b862d8 TARIFF-24 round 2: test_modes_p4 scenarios keep the 0.30 gate they were written for
checks / unit (pull_request) Successful in 49m18s
infra-pin / fresh (pull_request) Successful in 1s
With a forecast read the gap needed is the cycle cost (0.20), not 1.5 x (0.30),
so a midday 0.21 gap now plans a grid top-up that four scenarios (sunny, Cheap
hours partly, forecast too high, refusal) are not about. They ask for cost 0.30
with the forecast; the no-forecast leg keeps 0.20 (= 0.30 widened).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-28 15:42:50 +01:00
glenn schrooyenandClaude 382b0ea411 TARIFF-24 round 2: a usable solar forecast replaces the "no weather" x1.5 (AUDIT-02 F1)
With battery modes on, the price gap a mode needs was always widened x1.5 by
Uncertainty.NO_WEATHER, even with a solar forecast read, so a 0.08 day/night
gap never traded at the shipped cycle cost. The doubt is now added only when
no usable forecast is read (solar_fc is None, the day plan's own test), in the
run plan (_price_runs), the charge gate (_mode_roles) and Home's mirror
(homeview.trade_verdict). The rule is one helper, arbitrage.weather_uncertainty();
the modes-off trade path (main.py cycle) keeps its literal NO_WEATHER.

DOCS "Battery modes" sentence, CHANGELOG 0.19.1 line and the DECISIONS note
follow. Tests: test_tariff24_weather.py.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-28 15:08:21 +01:00
glenn schrooyenandClaude 230ba0b57a TARIFF-24: test file follows the repo's suite rules (MUTATION.md pointer, named absence)
checks / unit (pull_request) Successful in 49m23s
infra-pin / fresh (pull_request) Successful in 2s
Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-28 01:09:39 +01:00
glenn schrooyenandClaude d667f3f3a8 TARIFF-24: battery modes start the tariff task (hotfix 0.19.1)
With battery modes on, a day/night or fixed static_profile, no price feed and
arbitrage off, tariff_refresh_wanted() was false: no tariff task, no
tariff_plan, mode_runs None, so Leftover solar / Cheap hours never charged from
the grid and the Batteries screen said "No prices". packmode.enabled(opts) is
now a third term of the gate. With price_provider off the task is pure
arithmetic on static_profile, so no HTTPS call is added.

Also: version 0.19.1, CHANGELOG entry, DECISIONS note, test_tariff24.py, and a
shorter tariff_refresh_wanted() docstring (history stays in DECISIONS).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-28 01:02:32 +01:00
admin 793a67623c Merge pull request 'Deploy 0.19.0' (#208) from deploy-0.19.0 into master
Reviewed-on: #208
2026-10-26 08:47:42 +01:00
admin 514256923b Merge pull request 'Release 0.19.0' (#207) from release-0.18.3 into release/1.0
infra-pin / fresh (push) Successful in 1s
checks / unit (pull_request) Successful in 48m38s
infra-pin / fresh (pull_request) Successful in 1s
2026-10-26 03:10:27 +01:00
glenn schrooyenandClaude Opus 5.5 b7108e552d Release 0.19.0: review text fixes to the CHANGELOG section
checks / unit (pull_request) Successful in 48m2s
infra-pin / fresh (pull_request) Successful in 1s
Battery modes on trade less, not longer; the wizard's Dutch checks need
UX-51; the SAFETY-121 limit as measured; the headline names every change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
2026-10-26 02:19:44 +01:00
glenn schrooyenandClaude 31d09ebae2 Release 0.19.0: complete headline and the reason for the minor bump
checks / unit (pull_request) Successful in 47m48s
infra-pin / fresh (pull_request) Successful in 1s
Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-26 01:15:16 +01:00
glenn schrooyenandClaude ae9268835f Release 0.19.0: minor bump for changed price trading behaviour
checks / unit (pull_request) Successful in 48m3s
infra-pin / fresh (pull_request) Successful in 1s
Owner chose 0.19.0 (2026-10-26) because PRICE-02 changes trading behaviour.
Version and CHANGELOG heading/first line only; no option changes.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-26 00:15:43 +01:00
glenn schrooyenandClaude f8b3e23a46 Release 0.18.3
checks / unit (pull_request) Successful in 48m2s
infra-pin / fresh (pull_request) Successful in 1s
Bump version to 0.18.3 and add the 0.18.3 CHANGELOG section: DOC-8, UX-45,
SAFETY-119, SAFETY-121, UX-46, PRICE-02. A patch bump: no option added,
removed or renamed, no default changed.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-25 16:53:30 +01:00
admin aa228062c5 Merge PR #206: PRICE-02 the price verdict skips ended periods
infra-pin / fresh (push) Successful in 1s
2026-10-22 18:57:37 +02:00
glenn schrooyenandClaude Opus 5.5 6b2d0091d6 PRICE-02 review: keep an open buy over midnight in the real-year replay; say which variant the kWh figure is
checks / unit (pull_request) Successful in 47m40s
infra-pin / fresh (pull_request) Successful in 1s
The production-shaped plan sees tomorrow from 13:00, so most buys are sold
the next day or later. The ledger forgot the open buy at each midnight and
booked 46 of 51 sales as holdings: 5 trips, +1.21 EUR. Carried over, the
same decisions give 51 trips, +13.81 EUR, all clearing the threshold.
Decisions are unchanged and identical on the old and new verdict. No
assertion changed.

DECISIONS: 27.5 -> 42.6 kWh is the refresh-:00, tomorrow-known variant; give
the range of the other three.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
2026-10-22 16:48:43 +02:00
glenn schrooyenandClaude 3dd4dd89e0 PRICE-02 round 3: say that hourly and static plans change too, test plans built off the hour, exact period end and the Home page path, production-shaped real-year replay
checks / unit (pull_request) Successful in 47m47s
infra-pin / fresh (pull_request) Successful in 1s
Docs and tests only; no change under goodwe_controller/app.

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-22 14:59:13 +02:00
glenn schrooyenandClaude 891ed7bdd7 PRICE-02 round 2: require now, undo the test loosening, analyse ties
checks / unit (pull_request) Successful in 47m49s
infra-pin / fresh (pull_request) Successful in 2s
- TariffPlan.arbitrage(now=...) is required (keyword-only, no default); so are
  homeview.trade_verdict/refusal_key's now. Existing test calls pass one.
- test_arbitrage.py: no module-level decide wrapper. Fixtures that probed a
  one-day plan after its extremes now hold two days (as a site does); the
  flap day gets a unique cheapest and dearest hour; the replay breaks its
  ties without direction. The flap bound is <= 4 and the replay check is
  len(_trades) < 365 again. 228 checks pass on the old and the new code.
- test_price02.py part 6: ties (equal quarters, a real 15-minute day,
  alternating hourly prices) and the required now.
- Identity pins: the masked digest is only for the older-tree comparison;
  this tree's own digests are pinned with nothing hidden.
- DECISIONS: the tie analysis with numbers.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-22 10:37:43 +02:00
glenn schrooyenandClaude 2ff9be710c PRICE-02: correct the DECISIONS note (which sentences changed, how identity is pinned)
checks / unit (pull_request) Successful in 47m48s
infra-pin / fresh (pull_request) Successful in 1s
Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-22 08:37:39 +02:00
glenn schrooyenandClaude ecd9822861 PRICE-02: pin the modes-off identity with a digest that hides only the changed words
checks / unit (pull_request) Successful in 48m44s
infra-pin / fresh (pull_request) Successful in 2s
test_price_run_fixes.py part 5 and test_modes_p3.py part 1 compare the modes-off
replay with digests printed on older trees. The trade sentence now says
"price period", and a sale quotes another "above the cheapest" figure once the
first cheap period is over. ident.trade_digest() hides exactly those two
things; re-measured on e3e4ba6, 7a00f12, 90bc901 and this tree, the digests
are the same, so every command is unchanged.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-22 08:20:50 +02:00
glenn schrooyenandClaude 4297f7f3f1 PRICE-02: the trading verdict skips price periods that have ended
checks / unit (pull_request) Failing after 48m38s
infra-pin / fresh (pull_request) Successful in 1s
TariffPlan.arbitrage(now=...) drops the points whose period has ended
(end <= now), so the cheapest and dearest point can still be traded.
decide() (modes off), the modes-on allowed() gate and the Home verdict
all pass their own now. Hourly feeds and the static tariff are unchanged.
The off-peak sentence and the charge/discharge sentences say "price
period". Tests: test_price02.py; test_arbitrage.py rolls its whole-day
plans to start at now; test_modes_p3.py part 1 digests leave the trade
sentence out (commands unchanged on 7a00f12, 90bc901 and this tree).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-22 07:19:28 +02:00
admin 90bc9017b2 Merge pull request 'UX-46: Dutch status rows, banners, notes and wizard lines on NL pages; peak forecast label without (W)' (#205) from UX-46 into release/1.0
infra-pin / fresh (push) Successful in 2s
2026-10-22 06:10:36 +02:00
admin ad374ae208 Deploy 0.18.2 to master (#200) 2026-10-17 01:59:54 +02:00
admin ec2b0c3de4 Deploy 0.18.1 to master (#197) 2026-10-16 15:05:06 +02:00
admin 3b7d6b7f05 Deploy 0.18.0 to master (#193) 2026-10-16 00:42:54 +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
23 changed files with 2001 additions and 170 deletions
+99
View File
@@ -1,5 +1,104 @@
# Changelog
## 0.19.1
**A patch bump: two fixes for battery modes, 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.19.0 also
start 0.19.1. The changes below apply with one inverter or two.
**Battery modes now see a day/night or fixed tariff.** With battery modes on, a day/night or fixed tariff and no price service, the batteries never got the prices: "Cheap hours" never charged from the grid at the cheap rate, and the Batteries screen said "No prices". They get them now, with no price service and no internet call added.
**A solar forecast now counts for the price gap.** With battery modes on and a solar forecast read, a price mode needs a gap of at least *Battery cycle cost*, not 1.5 times that. Without a forecast it is still 1.5 times. A day/night gap of 0.08 now works at a cycle cost of 0.07 or less.
Contents of this release, in merge order since 0.19.0 (`793a676`):
| | |
|---|---|
| `TARIFF-24` | With battery modes on, the add-on now also resolves the tariff when no price service and no price trading are set, so a day/night or fixed tariff gives the batteries their price runs ([[TARIFF-24]]). With battery modes on and a solar forecast read, the price gap a mode needs is *Battery cycle cost* (widened only for a real-time feed), not 1.5 times that; Home and the Batteries screen use the same rule ([[AUDIT-02]] F1). |
### Known issues
The list under `## 0.19.0` below still applies.
### 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.19.0
**A minor bump: price trading behaves differently, plus a fix for a false warning, the Home price chart in 15-minute bars, more Dutch text and EMS-port wording, and no option changes.**
No option was added, removed or renamed, and no default changed (86 options). It is a minor bump because price trading changes when the battery charges and gives power; every saved setting still starts.
**Existing installs need no option change:** options that start 0.18.2 also
start 0.19.0. The changes below apply with one inverter or two.
**Price trading skips periods that are over.** When the add-on trades on price, it now picks only from price periods that are not over yet. With battery modes off and 15-minute prices, the battery therefore trades for longer (about 1.5 to 2.5 times the minutes in a simulation of 16 real days), so it cycles more; with battery modes on, it no longer charges from the grid because of a cheap period that is over. Hourly and day/night sites change too: they no longer pause at the start of each hour.
Contents of this release, in merge order since 0.18.2 (`c67d0cd`):
| | |
|---|---|
| `243a756` | The docs, the add-on description and the field guide now say the add-on is wired to the inverter's EMS port, not a "meter bus". In Home Assistant the device's manufacturer text becomes "GoodWe (via RS485 EMS port)", and the device and its entities stay the same ([[DOC-8]], #201) |
| `75f0804` | The price chart on the Home page now shows one bar for each 15-minute price, the price the add-on decides on, where it showed hour averages. Its cheap and dear colours now show where the add-on will charge or give power, instead of thirds of the price range ([[UX-45]], #202) |
| `bee7431` | First half of the "not keeping up" fix, shipped with the next row and described there ([[SAFETY-119]], #203) |
| `e077ae7` | The false "Battery is not keeping up" warning, and its Home Assistant notification, no longer appear when the peak guard steps in and then gives control back near the start of a 15-minute period; battery commands do not change. A real overload still warns at the start of each 15-minute period, up to about 25 s later with the default 5 s meter, but with an uneven meter of about 15 to 20 s and a small overload, it can stay quiet for up to an hour ([[SAFETY-121]], #204, with [[SAFETY-119]]) |
| `90bc901` | On Dutch pages, the status rows, banners and settings notes now show Dutch text where they showed English, and so do the setup wizard's checks once [[UX-51]] is fixed. The settings label of the 15-minute demand forecast sensor no longer ends in "(W)", and English pages change only by that label ([[UX-46]], #205) |
| `aa22806` | Price trading, and the check for it with battery modes on, now ignore price periods that have already ended, so the add-on no longer aims at a 15-minute period that is over and then does nothing. Hourly and day/night sites also change: there is no pause at the start of each hour, as the owner accepted ([[PRICE-02]], #206) |
### 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]]).
- A steady overload on a 1 s meter can warn, and send the Home Assistant notification, 2 or 3 times at the start of each 15-minute period instead of once. The peak limit is still kept ([[SAFETY-122]]).
- A fixed tariff is priced once per hour, so a price window that starts or ends at :15, :30 or :45 gets the wrong price for part of that hour. Windows that start and end on the hour are right ([[TARIFF-23]]).
- Step 6 of the setup wizard stays empty because of an error in the page, in English and in Dutch ([[UX-51]]).
- On Dutch pages, refusal texts, the last-event line under the Home buttons, the installer details and the trading status sentences are still English, and a few Dutch texts read badly, such as "1 metingen geweigerd" ([[UX-52]]).
- Home can show a grid charge that the add-on will not take: the rest of a cheap run that the trade check refuses (battery modes on), or a charge after the last dear price period (battery modes off). The battery stays idle then ([[UX-53]]).
- Some price texts say "hour" where the add-on acts on a 15-minute period, such as "cheapest hour" ([[UX-47]]).
- After the add-on restarts, the Home price chart shows no price for the earlier part of today, and the setup check can say "12 of 24 hours", though the prices are known ([[UX-48]]).
- With battery modes on and only batteries on "Leftover solar", the Home price chart can mark a cheap period where no battery charges ([[UX-49]]).
- On a phone, the bars of the Home price chart are too thin to tap one 15-minute period. The arrow keys work ([[UX-50]]).
### 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.2
**A patch bump: one fix to how the 15-minute demand forecast sensor is read, and no option changes.**
@@ -0,0 +1,89 @@
# PRICE-02 - the trading verdict skips price periods that have ended
**Status: for review.** Tests: `test_price02.py`. Found by AUDIT-01 (F1).
The refresh floors the plan to the top of the hour, so on the 15-minute feed
it holds the periods of this hour that are already over. The verdict took the
cheapest and dearest over every point. With modes off it could aim at a period
that had passed and then do nothing, for up to 45 min plus the refresh lag.
With modes on the `allowed()` gate could be open only because of such a period.
Decision: `TariffPlan.arbitrage(now=...)` drops the points whose period has
ended (`end <= now`, the span `price._cover_table()` gives, as
`priceruns.firm_slots()` does). Its three callers pass their own `now`:
`arbitrage.decide()`, `Controller._mode_roles()` and `homeview.trade_verdict()`.
- Taken in the verdict, not when the plan is built: a plan built at 03:00 is
still used at 03:20, and the Home chart keeps the day's earlier bars.
- An hourly point that is still current stays in. But an hourly or static plan
is rebuilt every 30 min counted from add-on start, so it is usually off the
hour and, until the next refresh, holds the hour that has just ended. That
hour is dropped too (round 3, owner-accepted 2026-10-22). On a day/night
tariff all day hours tie and the old verdict took the first, the ended one,
so it paused the trade every hour until the refresh: 91 on/off changes over
2 days with the refresh at :10, 4 now (4 before as well, with the refresh on
the hour). With the refresh on the hour nothing changes.
- `now` is required: keyword-only, no default, like the other three arguments. A
caller that could leave it out would get the elapsed periods back and the
defect with them. A test that wants the whole series passes an instant before it.
- The sentences of this verdict in `arbitrage.py` (status row and action log,
English only) say "price period" where they said "hour": the idle one now
reads "now is not the cheapest or the dearest price period". The UI texts
that say "cheapest hour" (AUDIT-01 T1-T5) are not touched here.
- Not changed here: F2 (static profile sampled at HH:00) and F3 (carried value).
Measured: with price off, every command over the modes-off replay is
byte-identical. On a static tariff rebuilt on the hour it is too; rebuilt 20
min off the hour the commands differ (digest 79f06d6381ff3358 on release/1.0,
18de19d9fe919fe5 here), for the reason above. On the dynamic replay every command is
the same too; only a sale's sentence quotes a smaller "above the cheapest"
figure once the first cheap period is over. The identity pins in
`test_modes_p3.py` and `test_price_run_fixes.py` hide just that figure and the
word change (`opt11p3_identity.trade_digest()`).
## Ties (round 2)
The verdict takes `min`/`max` over the points still ahead and the first one wins
a tie, so with equal prices it is the first period still ahead, which is the
current one. Before, it was the first of the whole plan, often one that had
ended. Two effects follow, and both are the card's intent, not a side effect:
- Equal quarters (hour-constant data, or a feed that prints the same price in
several quarters) now act in every quarter of the cheap (or dear) block,
not in the first only. Test: `test_price02.py` part 6.
- After the cheapest period has passed, the next cheapest still ahead is the
target, if its spread still clears the cost. A hourly plan did this at every
refresh already (it started at the current hour); a 15-minute plan did it
only from the next hour on, because it kept the elapsed quarters of this one.
Measured with `scratch/PRICE-02/dev/tiesim.py` (refresh every 30 min from the
floored hour, verdict at 7 min into each period, threshold 0.05, old = the
verdict over the whole plan, new = this card):
| data | days | acting periods old -> new | action changes old -> new |
|---|---|---|---|
| real 15-minute day, energy-charts 2026-09-09 | 1 | 4 -> 5 | 6 -> 6 |
| real 15-minute days 2026-09-09 and -10 | 2 | 14 -> 26 | 24 -> 24 |
| EPEX 2025-26 hourly prices as four equal quarters | 12 | 18 -> 72 | 36 -> 20 |
| the same with random +-0.002 noise per quarter (no ties) | 12 | 18 -> 33 | 36 -> 48 |
So a real 15-minute day does not switch more often (24 -> 24), and a day of
equal quarters switches less. A synthetic day of noisy, tie-free quarters does
(3.0 -> 4.0 changes a day): each period that passes hands the target to the
next cheapest one ahead. Every change is still at a period edge, never within
one. The replay in `test_arbitrage.py` (synthetic year, hourly) gives the same
ledger on the old and the new code once its ties are broken (280 round trips,
85 held, both): it printed one maximum twice per day, and the old fixture
tested the first of them only because its plan began at midnight.
## Battery throughput (round 3)
On real 15-minute days the trade runs longer: about 1.5 to 2.5 times the
minutes spent charging or discharging, with fewer on/off switches (for example
78 -> 68, 92 -> 72), over 16 days (2026-09-02 to 09-17, a decision every
minute, refresh at :00 or :10, tomorrow's prices known always or from 13:00;
reviewer's sim). Through a modelled 10 kWh battery that is 27.5 -> 42.6 kWh
(refresh at :00, tomorrow always known); 19.6-31.9 -> 43.4-48.7 kWh in the
other three variants. Charge to
discharge reversals are unchanged (10 -> 10, 13 -> 13). More cycling is the
price of not pausing.
@@ -0,0 +1,66 @@
# TARIFF-24 - battery modes need the tariff task
**Status: FIXED in 0.19.1 (hotfix from 0.19.0, owner go 2026-10-28).**
Follows TARIFF-10 (fetching is not trading). Tests: `test_tariff24.py`.
**The defect.** The control cycle gets prices only from
`Controller.tariff_plan`, which only `run_tariff_refresh()` stores. `amain()`
starts that task when `tariff_refresh_wanted(opts)`, which was
`provider_configured(opts) or arbitrage.enabled(opts)`. Battery modes read the
plan but were not a term. With battery modes on, a day/night or fixed
`static_profile`, `price_provider: off` and `arbitrage_enabled: false` (the
site trade switch is not read with modes on) no task ran. Then
`fresh_tariff_plan()` was None, `_price_runs()` was None and `mode_runs` was
None, so Leftover solar and Cheap hours never charged from the grid. The
Batteries screen said "No prices, so it follows the house" while Home priced
the day from `static_plan()` itself (`homeview.build_plan()`).
**The fix** (`main.py`, `tariff_refresh_wanted()`). `packmode.enabled(opts)`
is a third term. The gate stays a widening: nothing that started the task
before stops. No HTTPS call is added: with `price_provider: off`,
`resolve_tariff()` is the pure `static_plan()`.
**Not changed.** Modes off, arbitrage off, no feed still starts no task. The
control law, the price runs and every message are untouched.
**One rule, not two.** Gating on "modes on and a static profile" was
considered. It saves one arithmetic pass every 30 min on a site with modes on
and no prices at all, and it adds a second condition that has to track what
`resolve_tariff()` accepts. With no profile and no feed the plan is `no_data`,
exactly what the cycle sees without the task.
**UX-55.** Home said "no prices have arrived" (`mode_runs` None) with every
battery on Solar first. The same cause, and this fix clears it for a site with
a static profile (`test_tariff24.py` part 5). A site with no profile and no
feed still has no prices, and Home still says so.
## Round 2 (AUDIT-02 F1): the forecast replaces the "no weather" doubt
**Decided 2026-10-28 (owner), in the same hotfix.** The price gap a mode needs
was `cycle_cost_threshold_eur` x 1.5 (`Uncertainty.NO_WEATHER`) whether or not
a solar forecast was read. At the shipped cost 0.10 that is 0.15, so a
day/night gap (0.08) and many dynamic days never traded, and the screens said
only "Prices differ too little".
**Why the doubt goes with a forecast.** The widening stands in for not knowing
the weather. The forecast is that knowledge, and the day plan already trusts it
for how much to charge. Trusting it for the plan but doubting it for the gap
was one input judged twice.
**The rule** (`arbitrage.weather_uncertainty()`). With battery modes on and a
usable forecast (`solar_fc` not None, the day plan's own test) the doubt is
not added: the gap needed is the cycle cost, widened by the price product only.
No forecast, or a stale or unusable one: 1.5 x, as before. It is read in three
places that must agree: the run plan (`_price_runs()`), the charge gate
(`_mode_roles()`) and Home's mirror (`homeview.trade_verdict()`). The forecast
is read at most every 60 s and the plan key already holds "forecast or not", so
the gap follows in the cycle the read changes, both ways, with one exception:
a run planned with a forecast that is under way keeps that gap to its end
(`Controller.weather_held()`), so a sensor that goes unavailable does not stop
or restart a charge. The wider gap applies from the next run.
**Not changed.** Modes off (the site trade) keeps the doubt always. The
shipped cost 0.10 still needs a gap of 0.10 even with a forecast: a 0.08
day/night site must set *Battery cycle cost* to 0.08 or less. Showing the two
numbers on the screens (AUDIT-02 F1 option b) is a separate card.
+1 -1
View File
@@ -538,7 +538,7 @@ With battery modes on, these choices replace the *Charging and selling on price*
**Cheap and expensive hours.** They come from the published day-ahead prices, as runs of consecutive 15-minute periods, not a single period.
- The cheap run is the cheapest stretch long enough to fill the batteries that may charge, at their power.
- An expensive hour costs at least *Battery cycle cost* (`cycle_cost_threshold_eur`, widened as for trading) more than the cheap run. The batteries hold their power until then.
- An expensive hour costs more than the cheap run by at least *Battery cycle cost* (`cycle_cost_threshold_eur`) when a solar forecast is read, and by 1.5 times that when none is (a real-time price feed widens it further). The batteries hold their power until then.
- The sale uses the most expensive stretch inside those hours, and never a period below 0.
- A carried (not yet published) price is never used. A run that has started runs to its end.
- With no prices, or too small a gap, every battery follows the house.
+26 -11
View File
@@ -147,7 +147,7 @@ from dataclasses import dataclass
from datetime import datetime
from .arbiter import ACTUATOR_SITE, P_ARBITRAGE, Claim
from .price import Absence, price_option
from .price import Absence, Uncertainty, price_option
_LOG = logging.getLogger("goodwe.arbitrage")
@@ -172,7 +172,7 @@ IDLE_NOT_COVERED = (
IDLE_NO_SOC = (
"state of charge is not being read, and a battery whose fill nobody knows "
"cannot be told to empty or to fill")
IDLE_OFF_PEAK_HOUR = "this hour is neither the buy hour nor the sell hour"
IDLE_OFF_PEAK_HOUR = "now is not the cheapest or the dearest price period"
# OPT-09. A TRADE NOTHING CAN TAKE IS NOT A TRADE, AND THE ALTERNATIVE IS WORSE
# THAN A MISSED HOUR. `charge_w`/`discharge_w` used to be the site's whole rating
# and are now SIZED to the packs whose state of charge permits that direction, so
@@ -196,7 +196,7 @@ IDLE_HEADROOM_UNMEASURED = (
"creates the billed quantity (§9.1) and nothing is measuring the quarter "
"this one would land in")
NEGATIVE_PRICE = (
"§17: the sell hour is priced BELOW ZERO, so discharging into it would give "
"§17: the sell period is priced BELOW ZERO, so discharging into it would give "
"energy away and pay for the privilege. Arbitrage must not export at a "
"negative price")
@@ -267,6 +267,21 @@ def threshold_eur_kwh(opts: dict) -> float:
return v
def weather_uncertainty(modes_on: bool, solar_fc, held: bool = False) -> frozenset:
"""The "no weather" doubt (x1.5) a trade must clear. TARIFF-24.
Battery modes read a solar forecast; with one read (`solar_fc` not None) the
doubt is gone, and `held` (a run planned with a forecast is under way, see
`Controller.weather_held()`) keeps it gone until that run ends. Everything
else, and the modes without a forecast, keep it. One rule for the run plan,
the charge gate and Home's mirror. See
DECISIONS/TARIFF-24-modes-need-the-tariff-task.md.
"""
if modes_on and (solar_fc is not None or held):
return frozenset()
return frozenset({Uncertainty.NO_WEATHER})
def soc_bounds(opts: dict) -> tuple:
"""(floor, ceiling) in percent. The reserve arbitrage may not spend."""
return (float(price_option(opts, "arbitrage_soc_floor")),
@@ -347,7 +362,7 @@ def decide(plan, now: datetime, *, peak_guard_active, charge_headroom_w, soc,
verdict = plan.arbitrage(peak_guard_active=peak_guard_active,
min_spread_eur_kwh=min_spread_eur_kwh,
uncertainty=uncertainty)
uncertainty=uncertainty, now=now)
return verdict.match(
refused=lambda v: _idle(v.reason),
allowed=lambda v: _act(plan, now, v, soc, charge_w, discharge_w,
@@ -362,7 +377,7 @@ def _act(plan, now, v, soc, charge_w, discharge_w, floor_pct, ceiling_pct,
if here is None:
return _idle(
f"the series carries a tradeable spread but no price for this "
f"instant ({now.isoformat()}), which is §11.4 for this hour rather "
f"instant ({now.isoformat()}), which is §11.4 for this period rather "
f"than a reason to reach for a neighbouring one",
spread=v.spread_eur_kwh, required=v.required_spread_eur_kwh)
@@ -381,7 +396,7 @@ def _act(plan, now, v, soc, charge_w, discharge_w, floor_pct, ceiling_pct,
# irreversible action may rest on it.
if here.provisional:
return _idle(
f"§8.3: the price for this hour ({here.start.isoformat()}) is "
f"§8.3: the price for this period ({here.start.isoformat()}) is "
f"CARRIED FORWARD, not published, and no irreversible action may be "
f"taken on a provisional price",
spread=v.spread_eur_kwh, required=v.required_spread_eur_kwh,
@@ -399,18 +414,18 @@ def _act(plan, now, v, soc, charge_w, discharge_w, floor_pct, ceiling_pct,
# negative price reports the reason that actually forbids the trade
# rather than one that happens to fire first on some other site.
if price < 0:
return _idle(f"{NEGATIVE_PRICE} - this hour is "
return _idle(f"{NEGATIVE_PRICE} - this period is "
f"{price:+.4f} EUR/kWh", **common)
if soc <= floor_pct:
return _idle(
f"the sell hour is here at {price:.4f} EUR/kWh but the pack is "
f"the sell period is here at {price:.4f} EUR/kWh but the pack is "
f"at {soc:g} %, at or under the {floor_pct:g} % reserve floor "
f"arbitrage may not spend (§10.1 priority 4)", **common)
if not abs(float(discharge_w)) > 0.0:
return _idle(IDLE_NO_SIZE, **common)
return ArbitrageDecision(
action=DISCHARGE, power_w=abs(float(discharge_w)),
reason=(f"{DISCHARGE}: this is the dearest firm hour at "
reason=(f"{DISCHARGE}: this is the dearest firm price period at "
f"{price:.4f} EUR/kWh, {v.spread_eur_kwh:.4f} above the "
f"cheapest and clear of the {v.required_spread_eur_kwh:.4f} "
f"this cycle had to cover; discharging "
@@ -421,7 +436,7 @@ def _act(plan, now, v, soc, charge_w, discharge_w, floor_pct, ceiling_pct,
if soc >= ceiling_pct:
return _idle(
f"the buy hour is here at {price:.4f} EUR/kWh but the pack is at "
f"the buy period is here at {price:.4f} EUR/kWh but the pack is at "
f"{soc:g} %, at or over the {ceiling_pct:g} % ceiling - there is "
f"nowhere to put the cheap energy", **common)
if not abs(float(charge_w)) > 0.0:
@@ -456,7 +471,7 @@ def _act(plan, now, v, soc, charge_w, discharge_w, floor_pct, ceiling_pct,
capped = size_w < asked_w
return ArbitrageDecision(
action=CHARGE, power_w=-size_w,
reason=(f"{CHARGE}: this is the cheapest firm hour at "
reason=(f"{CHARGE}: this is the cheapest firm price period at "
f"{price:.4f} EUR/kWh, {v.spread_eur_kwh:.4f} below the dearest "
f"and clear of the {v.required_spread_eur_kwh:.4f} this cycle "
f"had to cover; charging {size_w:.0f} W from {soc:g} %"
+17 -8
View File
@@ -22,7 +22,7 @@ from . import arbitrage, messages, peakguard, uitext
from .maintenance import CHARGE as M_CHARGE, DRAIN as M_DRAIN, HOLD as M_HOLD
from .maintenance import IDLE as M_IDLE, next_slot_start
from .packmode import CHEAP_HOURS, GRID_CHARGE_MODES, LEFTOVER_SOLAR, SOLAR_FIRST
from .price import Absence, Uncertainty, brussels, _cover_table
from .price import Absence, brussels, _cover_table
from .static_tariff import static_plan
from .tariff import BILLING_TZ
from .uimodel import REGION_PRESETS, options_to_ui
@@ -262,11 +262,12 @@ def _run(i, kind, start, end, day0, day1, what, why, amount, *, kwh=None,
"eur_kwh": None if price is None else round(price, 4)}
def trade_verdict(c, plan):
def trade_verdict(c, plan, now):
"""The arbitrage verdict the control cycle would reach on this plan, or a
reason there is none. The SAME call and inputs as `Controller.cycle()`:
`plan.arbitrage()` with the peak guard's state, the cycle cost and the
no-weather uncertainty. Returns `(verdict_or_None, why_key)`."""
no-weather uncertainty, and (PRICE-02) the same `now`. Returns
`(verdict_or_None, why_key)`."""
if not arbitrage.enabled(c.o):
return None, "off"
guard = arbitrage.guard_state(c.peak_guard)
@@ -276,20 +277,28 @@ def trade_verdict(c, plan):
return None, "no_prices"
verdict = plan.arbitrage(peak_guard_active=guard,
min_spread_eur_kwh=arbitrage.threshold_eur_kwh(c.o),
uncertainty=frozenset({Uncertainty.NO_WEATHER}))
uncertainty=arbitrage.weather_uncertainty(
bool(getattr(c, "modes_on", False)),
getattr(c, "solar_fc", None),
bool(getattr(c, "weather_held",
lambda _n: False)(now))),
now=now)
return verdict.match(
allowed=lambda v: (v, "allowed"),
refused=lambda _v: (None, refusal_key(plan, guard)))
refused=lambda _v: (None, refusal_key(plan, guard, now)))
def refusal_key(plan, guard):
def refusal_key(plan, guard, now):
"""Which of `TariffPlan.arbitrage()`'s refusals fired, in its own order:
the peak guard, no prices, under two published points, then the gap."""
if guard:
return "peak"
if not plan.has_prices:
return "no_prices"
if sum(1 for p in _points(plan) if not p.provisional) < 2:
pts = _points(plan)
ahead = {id(p) for _s, end, p in _cover_table(pts) if end > now}
pts = [p for p in pts if id(p) in ahead]
if sum(1 for p in pts if not p.provisional) < 2:
return "not_firm"
return "not_worth"
@@ -602,7 +611,7 @@ def build_plan(c, now, shadow, running, held=False, settled=True):
limit_w = c.peak.cap_w # the limit the controller parsed at start
runs, lines = [], []
# The control cycle trades on the fresh plan only, so this does too.
verdict, trade_why = trade_verdict(c, fresh)
verdict, trade_why = trade_verdict(c, fresh, now)
bars = price_bars(seen, day0, day1, price_windows(c, fresh, verdict),
now, days.kinds)
modes_on = bool(getattr(c, "modes_on", False))
+46 -58
View File
@@ -174,57 +174,17 @@ TARIFF_MAX_AGE_S = 3 * TARIFF_REFRESH_S
def tariff_refresh_wanted(opts: dict) -> bool:
"""TARIFF-10. Does this install need the background tariff refresh at all?
"""Does this install need the background tariff refresh? (TARIFF-10, TARIFF-24)
THE DEFECT THIS PREVENTS, and it shipped: the gate below used to be
`arbitrage.enabled(opts)` alone. That option defaults to `false`, and
`run_tariff_refresh` is the only caller of `resolve_tariff()`, which is the
only caller of `price_history.carry()` - so on a shipped install with a
provider configured NOTHING was ever fetched, no plan was ever resolved,
`/data/price_history.json` was never created, the *Price history* row read
`empty` forever, and TARIFF-09's short-horizon warning could not fire
because there was no fetch to warn about. Measured over a 40 h soak of two
instances side by side: zero feed requests and `present: false` across all
802 samples on the shipped default, against a resolved plan from the same
feed on the instance with arbitrage armed. ⚠️ AND `DOCS.md` HAS
DESCRIBED THE BEHAVIOUR BELOW SINCE THE COMMIT THAT WROTE THE GATE:
OPT-04 (bdd7f18) added the refresh task, the `arbitrage.enabled()` guard on
it, and the sentence saying a configured provider still fetches, all in one
change. config.yaml has said it since TARIFF-02 (852fc7c).
Two terms, and they answer two different questions:
provider_configured(opts) -> "the operator asked for a price feed", so
fetch it, validate it and record the day
(§8.3). This is the term TARIFF-10 ADDS.
arbitrage.enabled(opts) -> "the operator armed trading". This term is
what the gate USED to be, kept verbatim.
⚠️ IT IS AN `or`, SO THE GATE IS A STRICT WIDENING. Every configuration
that started this task before still starts it; none that did not start it
stops anything. That matters because the second term is not redundant:
`price_provider: off` + a `static_profile` + `arbitrage_enabled: true` is a
configuration validate.py explicitly ACCEPTS, and it trades - measured, at
`cycle_cost_threshold_eur: 0.05` on a 0.20/0.284 day/night profile,
`ARBITRAGE_CHARGE -5000 W`. Dropping that term to make the gate read
`provider_configured(opts)` alone would leave `self.tariff_plan` at None on
that site forever and `decide()` would answer "no tariff plan has been
resolved yet" on every cycle - silently disarming a strategy the UI says is
running, which is the exact failure validate.py's OPT-04 rules exist to
refuse.
⚠️ AND NO CONFIGURATION ACQUIRES A PERIODIC HTTPS CALL FROM THIS TICKET.
With `price_provider` off, `resolve_tariff()` returns `static_plan()` - pure
arithmetic, no socket - so the second term starts a task that fetches
nothing. The true shipped default (no provider, no arbitrage) starts no
task at all. What is fetched is decided by the FIRST term only.
⚠️ THIS IS NOT A TRADING SWITCH. It decides whether prices are LOOKED UP.
Whether they move energy is `arbitrage.enabled()` at the one call site in
`cycle()`, and TARIFF-10 did not touch it: `arbitrage_enabled: false` still
means no trade, ever, on any configuration.
Yes with a price feed, with trading on price armed, or with battery modes
on: the modes read `self.tariff_plan` for their price runs. With
`price_provider` off the refresh is pure arithmetic on `static_profile`, so
no term here adds an HTTPS call. It decides whether prices are looked up,
never whether energy moves. See DECISIONS/TARIFF-10-fetching-is-not-trading.md
and DECISIONS/TARIFF-24-modes-need-the-tariff-task.md.
"""
return provider_configured(opts) or arbitrage.enabled(opts)
return (provider_configured(opts) or arbitrage.enabled(opts)
or packmode.enabled(opts))
def load_options() -> dict:
@@ -1000,6 +960,9 @@ class Controller:
# and whether last tick filed a mode trade (a run edge reseeds).
self.mode_runs = None
self._runs_key = None
# TARIFF-24: the end of the run that was under way while a forecast
# was read; until then losing the forecast does not widen the gap.
self._wx_free_to = None
self.mode_tick = None
self._run_done = {}
self._mode_trade_was = False
@@ -2266,6 +2229,28 @@ class Controller:
probing=probing))
return out
def _wx_track(self, now) -> None:
"""TARIFF-24, once per cycle, before the plan: with a forecast read,
remember when the run now under way (cheap, give or sell) ends."""
if self.solar_fc is None:
return
rp = self.mode_runs
ends = []
if rp is not None and rp.on:
ends = [r.end for r in (rp.cheap, rp.sell)
if r is not None and r.holds(now)]
g = rp.give_at(now)
if g is not None:
ends.append(g.end)
self._wx_free_to = max(ends) if ends else None
def weather_held(self, now) -> bool:
"""TARIFF-24: no forecast now, but a run planned with one is under
way: it keeps its gap to its end. The wider gap applies from the next
run. Pure; Home asks the same (`homeview.trade_verdict()`)."""
return (self.solar_fc is None and self._wx_free_to is not None
and now < self._wx_free_to)
def _price_runs(self, now, packs, floor_pct, ceiling_pct):
"""The kept run plan, rebuilt at most once per price slot (15 min) or
when the prices or a battery's choice change; a run under way is
@@ -2279,9 +2264,11 @@ class Controller:
slot = int(now.timestamp() // 900)
key = (id(plan), slot, tuple((p.name, p.use, p.mode, p.sell)
for p in packs))
held = self.weather_held(now)
if self.fc_entity is not None:
# P4: a forecast arriving or going rebuilds the plan at once.
key += (self.solar_fc is None,)
# P4: a forecast arriving or going rebuilds the plan at once (and
# so does the end of a run that held the gap, TARIFF-24).
key += (self.solar_fc is None, held)
if key != self._runs_key:
done_c = self._run_done.get("charge", (None, set()))[1]
done_s = self._run_done.get("sell", (None, set()))[1]
@@ -2290,7 +2277,7 @@ class Controller:
required = price_required_spread(
arbitrage.threshold_eur_kwh(self.o),
price_uncertainty_of(plan.read.product)
| {Uncertainty.NO_WEATHER})
| arbitrage.weather_uncertainty(True, self.solar_fc, held))
ref = self._ref_cheap
if ref is not None and now - ref.end > REF_CHEAP_KEEP:
ref = self._ref_cheap = None
@@ -2359,6 +2346,7 @@ class Controller:
quarter), exactly the gate `arbitrage.decide()` has."""
floor_pct, ceiling_pct = arbitrage.soc_bounds(self.o)
packs = self._mode_packs(maint_results)
self._wx_track(now)
runs = self._price_runs(now, packs, floor_pct, ceiling_pct)
guard = arbitrage.guard_state(self.peak_guard)
plan = self.fresh_tariff_plan(now)
@@ -2369,7 +2357,9 @@ class Controller:
return plan.arbitrage(
peak_guard_active=active,
min_spread_eur_kwh=arbitrage.threshold_eur_kwh(self.o),
uncertainty=frozenset({Uncertainty.NO_WEATHER})).match(
uncertainty=arbitrage.weather_uncertainty(
True, self.solar_fc, self.weather_held(now)),
now=now).match(
allowed=lambda _v: True, refused=lambda _v: False)
if guard is not None and plan is not None and runs is not None:
# A charge asks the guard (section 9.1 interlock); a sell only
@@ -8050,11 +8040,9 @@ async def amain() -> None:
# ESP32 heartbeat. A fetch on that path is a supplier's TLS timeout able
# to push this add-on towards layer 1's 30 s veto. See TARIFF_REFRESH_S.
#
# ⚠️ STARTED WHEN A PROVIDER IS CONFIGURED, *OR* WHEN TRADING IS ARMED
# - TARIFF-10, and it used to be the second term alone. `arbitrage_
# enabled` ships `false`, so a site that had selected a price feed
# fetched nothing at all; the whole argument, the measurement and what
# the `or` is protecting are in `tariff_refresh_wanted()` above.
# ⚠️ STARTED FOR A PROVIDER, FOR ARMED TRADING OR FOR BATTERY MODES:
# see `tariff_refresh_wanted()` above and DECISIONS/TARIFF-10 and
# TARIFF-24 for why each term is there.
#
# ⚠️ IT DOES NOT ARM ANYTHING. Fetching a price and trading on one are
# two decisions; this is the first, and `cycle()`'s
+29 -3
View File
@@ -207,7 +207,7 @@ from datetime import datetime, timedelta, timezone
from . import messages, price_chain
from .price import (
MAX_PLAUSIBLE_EUR_KWH, Absence, PricePoint, PriceProduct, PriceRead,
Uncertainty, _Outcome, brussels, load_prices, price_option,
Uncertainty, _Outcome, _cover_table, brussels, load_prices, price_option,
provider_configured, required_spread, uncertainty_of, widen_factor,
)
from .price_chain import uses_chain
@@ -986,12 +986,25 @@ class TariffPlan:
return here.eur_per_kwh < (sum(p.eur_per_kwh for p in firm) / len(firm))
def arbitrage(self, *, peak_guard_active: bool, min_spread_eur_kwh,
uncertainty) -> Arbitrage:
uncertainty, now: datetime) -> Arbitrage:
"""May OPT-04 trade, and on what numbers. See the module docstring.
⚠️ ALL THREE ARGUMENTS ARE REQUIRED AND KEYWORD-ONLY, for one reason
PRICE-02. `now` (tz-aware) drops the points whose price period has
ended (`end <= now`, the span `price._cover_table()` gives it), so the
cheapest and dearest point are always ones that can still be traded.
It is taken here, per call, and not when the plan is built: a plan
built at 03:00 is still used at 03:20. An hourly point that is still
current stays in.
⚠️ ALL FOUR ARGUMENTS ARE REQUIRED AND KEYWORD-ONLY, for one reason
each, and none of them has a default:
`now` PRICE-02. A caller that could leave it out
would get the whole series back, elapsed
periods included, and the verdict could aim
at a period that is over. A test that wants
the whole series passes an instant before it.
`peak_guard_active` §9.1/§10.1 put peak shaving above arbitrage,
and this is how that ordering survives a
strategy that has not been written yet: the
@@ -1029,6 +1042,12 @@ class TariffPlan:
f"{sorted(map(repr, caller_sources))} - a string widens by "
f"nothing while reporting that it widened.")
if not isinstance(now, datetime) or now.tzinfo is None \
or now.utcoffset() is None:
raise TypeError(f"now must be a tz-aware datetime, not {now!r}; "
f"a naive instant is ambiguous for one hour "
f"every October")
if peak_guard_active:
return Arbitrage.refuse(
"the capacity-tariff peak guard is holding, and §10.1 puts peak "
@@ -1056,6 +1075,8 @@ class TariffPlan:
# day with no fetch at all) has no firm pair left and is refused, which
# is the previous behaviour arrived at by a truer route.
pts = self.read.points
ahead = {id(p) for _s, end, p in _cover_table(pts) if end > now}
pts = tuple(p for p in pts if id(p) in ahead)
firm = tuple(p for p in pts if not p.provisional)
if len(firm) < 2:
# ⚠️ POINTS, AND THE HOUR FIGURE BESIDE IT RATHER THAN INSTEAD OF
@@ -1334,6 +1355,11 @@ async def resolve_tariff(opts: dict, since: datetime, until: datetime,
that, so the spread is over hours that are still ahead. Both directions of
the difference are real: the window gains the unpublished hours it was
missing and loses the elapsed ones it should never have had.
⚠️ PRICE-02: THAT HOLDS FOR AN HOURLY FEED ONLY. The window is floored to
the hour, so a 15-minute feed keeps the elapsed periods of the current
hour, and the plan ages between refreshes. `TariffPlan.arbitrage(now=...)`
drops the periods that have ended, at every call.
"""
stamp = now or datetime.now(timezone.utc)
if not provider_configured(opts):
+1 -1
View File
@@ -1,5 +1,5 @@
name: GoodWe RS485 Controller
version: "0.18.2"
version: "0.19.1"
slug: goodwe_controller
description: >-
Drives a GoodWe ES/BP battery inverter over its RS485 EMS port by sending it a
+63 -20
View File
@@ -109,6 +109,11 @@ from app.validate import validate_startup_config
UTC = timezone.utc
HERE = os.path.dirname(os.path.abspath(__file__))
# PRICE-02: `TariffPlan.arbitrage(now=...)` is required. This instant is before every
# point these fixtures build, so no period has ended and the whole series counts.
from datetime import datetime as _DT, timezone as _TZ # noqa: E402
LONG_AGO = _DT(2000, 1, 1, tzinfo=_TZ.utc)
fails = []
@@ -194,13 +199,22 @@ NO_WEATHER = frozenset({Uncertainty.NO_WEATHER})
def plan_of(prices, *, product=PriceProduct.DAY_AHEAD, start=D0,
provisional=False, provider="generic_dayahead"):
provisional=False, provider="generic_dayahead", days=2):
"""A TariffPlan over consecutive hourly prices starting at `start`.
`provisional` may be a single bool or a per-hour sequence.
PRICE-02: the verdict skips price periods that have ended, so a plan
probed at 18:30 no longer sees the cheap hour at 03:00 of the same day. A
running site holds 24-48 h, so `days=2` repeats the day (tomorrow's
prices are today's): probes in day one keep their extremes, and the
first cheapest/dearest hour still ahead is today's. Pass `days=1` for a
plan that must be exactly the given hours.
"""
prices = list(prices) * days
flags = ([bool(provisional)] * len(prices)
if isinstance(provisional, bool) else list(provisional))
if isinstance(provisional, bool)
else list(provisional) * days)
pts = [PricePoint(start=brussels(start + timedelta(hours=i)),
eur_per_kwh=round(float(p), 6), provisional=f)
for i, (p, f) in enumerate(zip(prices, flags))]
@@ -344,7 +358,7 @@ check(f"a realistic Belgian day/night profile has a spread of "
f"{_st_spread!r} EUR/kWh - the number the product decision is about",
near(_st_spread, 0.08))
_shipped = PRICE_OPTION_DEFAULTS["cycle_cost_threshold_eur"]
_st_verdict = derived(lambda: _st_plan.arbitrage(
_st_verdict = derived(lambda: _st_plan.arbitrage(now=LONG_AGO,
peak_guard_active=False, min_spread_eur_kwh=_shipped,
uncertainty=NO_WEATHER))
check(f"...and at the SHIPPED threshold of {_shipped} EUR/kWh that site does "
@@ -361,7 +375,7 @@ check(f"...and it is the SPREAD rung that refuses it, not a flat-tariff or a "
check("...while the SAME site trades once the threshold is set below its "
"spread, so the refusal above is about the number and not about static "
"tariffs being unsupported",
derived(lambda: _st_plan.arbitrage(
derived(lambda: _st_plan.arbitrage(now=LONG_AGO,
peak_guard_active=False,
min_spread_eur_kwh=_st_spread / widen_factor(NO_WEATHER) - 1e-4,
uncertainty=NO_WEATHER).kind) == "allowed")
@@ -1644,7 +1658,7 @@ check("a FULLY carried series trades nothing at all, whatever its spread - "
# standing if a future caller ever hands `_act` a plan the verdict did not come
# from.
_probe = _arb._point_at(PLAN, hour(CHEAP_H))
_verdict = PLAN.arbitrage(peak_guard_active=False, min_spread_eur_kwh=0.0,
_verdict = PLAN.arbitrage(now=LONG_AGO, peak_guard_active=False, min_spread_eur_kwh=0.0,
uncertainty=frozenset())
check("the point matched for the buy hour IS the verdict's own cheapest point, "
"by identity - which is why `_act`'s provisional guard is unreachable "
@@ -1665,6 +1679,14 @@ print("4b. §10.2 hysteresis - a price on the boundary cannot flap at 30 s")
# threshold repeatedly, hour to hour, which is the shape that makes a
# per-tick price-vs-threshold comparison flap.
FLAP = [0.20 + (0.101 if i % 2 else 0.0) for i in range(24)]
# PRICE-02: the verdict skips periods that have ended, and this day had ties
# (0.20 at every even hour, 0.301 at every odd one). With ties the first hour
# still ahead is the extreme, so the verdict moves on every hour - once per
# hour, never per tick, which test_price02.py part 6 grades. This check is
# about a verdict that stays put while the prices keep crossing the threshold,
# so the day gets one cheapest and one dearest hour at its end (22: 0.60,
# 23: 0.05), unique and ahead of every probe until they come.
FLAP[22], FLAP[23] = 0.60, 0.05
_flap_plan = plan_of(FLAP)
check("the flapping fixture really does straddle the threshold hour by hour - "
"every neighbouring pair differs by more than it",
@@ -1846,7 +1868,15 @@ def replay(threshold, unc, *, soc0=50.0, floor=20.0, ceiling=95.0):
neg_sells = 0
for n in range(365):
day0 = REPLAY_START + timedelta(days=n)
prices = synth_day(n)
# PRICE-02: no two hours share a price. The synthetic day is
# symmetric, so it prints the same maximum twice; the verdict now takes
# the first one still AHEAD, where it used to take the first of the
# day (a tie, graded on its own in test_price02.py part 6). So the
# ties are broken by a jitter of at most 5e-5 EUR/kWh that has no
# direction: not later-is-dearer, which would send every day to a
# buy-then-sell and hide the selectivity this replay checks.
prices = [round(p + 1e-6 * ((37 * h + 101 * n) % 50), 6)
for h, p in enumerate(synth_day(n))]
plan = plan_of(prices, start=day0)
open_buy = None
for h in range(24):
@@ -2056,18 +2086,26 @@ check(f"BUT NOT ONE OF THE 365 DAYS HAS A NEGATIVE DEAREST HOUR "
def replay_real(days, threshold, unc, *, soc0=50.0, floor=20.0, ceiling=95.0):
"""The shipped `decide()` over the real year. Returns the ledger, nothing else.
One `TariffPlan` per Brussels day, so "the cheapest hour" means the cheapest
hour of that day - the same shape as section 7's replay, and the same shape
a site with a day-ahead feed actually sees.
The plan is built the way production builds it: one per hour, from that
hour (the refresh floors to the hour) to the end of the Brussels day,
plus the next day from 13:00 Brussels, when the day-ahead prices are
published. So a site sees 11 to 35 hours ahead, never a whole day behind
it and never a day on its own. (An earlier version built one plan per
calendar day with no tomorrow: after the cheap hour no cheap hour was left
ahead, and the ledger shrank to a few trades that no real site would see.)
An open buy is kept over midnight: with tomorrow in view most buys are
sold the next day or later (46 of 51 at the shipped threshold).
"""
soc = soc0
trades, holdings, bad, neg_sells = [], [], [], 0
for day in days:
plan = TariffPlan(
PriceRead.series(day, PriceProduct.DAY_AHEAD, "generic_dayahead",
day[0].start), None, "provider")
open_buy = None
for p in day:
open_buy = None
for di, day in enumerate(days):
nxt = days[di + 1] if di + 1 < len(days) else []
for i, p in enumerate(day):
ahead = day[i:] + (nxt if brussels(p.start).hour >= 13 else [])
plan = TariffPlan(
PriceRead.series(ahead, PriceProduct.DAY_AHEAD,
"generic_dayahead", p.start), None, "provider")
d = decide(plan, p.start + timedelta(minutes=30),
peak_guard_active=False, soc=soc,
min_spread_eur_kwh=threshold, uncertainty=unc,
@@ -2444,7 +2482,7 @@ check("...and every one of its points is provisional, so firm_points() is "
"a fresh observation",
isinstance(_t12_fb, TariffPlan) and _t12_fb.provisional is True
and _t12_fb.firm_points() == ()
and _t12_fb.arbitrage(peak_guard_active=False, min_spread_eur_kwh=0.0,
and _t12_fb.arbitrage(now=LONG_AGO, peak_guard_active=False, min_spread_eur_kwh=0.0,
uncertainty=NO_WEATHER).kind == "refused")
_t12_f.tariff_plan, _t12_f.tariff_at = _t12_good, datetime.now(UTC) - \
timedelta(seconds=1800)
@@ -2789,7 +2827,7 @@ check(f"the shipped rule and this one give DIFFERENT points at both probes - "
print()
print("10b. the buy/sell identity test matches the actual extremum interval")
_qv = QPLAN.arbitrage(peak_guard_active=False, min_spread_eur_kwh=_shipped,
_qv = QPLAN.arbitrage(now=LONG_AGO, peak_guard_active=False, min_spread_eur_kwh=_shipped,
uncertainty=NO_WEATHER)
check("the verdict's cheapest and dearest are the two QUARTERS, not the hours "
"containing them - `static_tariff` reduces over every firm point and "
@@ -2921,7 +2959,7 @@ check(f"...and its firm half's extremes are quarters {_mix_cheap_i} and "
f"{_mix_dear_i}, neither on a whole hour, so the identity test below is "
f"about the quarter and not about the hour",
(_mix_cheap_i - MIX_N_H) % 4 != 0 and (_mix_dear_i - MIX_N_H) % 4 != 0)
_mv = MIX_PLAN.arbitrage(peak_guard_active=False, min_spread_eur_kwh=_shipped,
_mv = MIX_PLAN.arbitrage(now=LONG_AGO, peak_guard_active=False, min_spread_eur_kwh=_shipped,
uncertainty=NO_WEATHER)
_mix_buy_t = qprobe(_mix_cheap_i, gaps=MIX_GAPS)
_mix_sell_t = qprobe(_mix_dear_i, gaps=MIX_GAPS)
@@ -2933,14 +2971,19 @@ check("the verdict over a MIXED series still names firm quarters, and "
is MIX_PTS[_mix_cheap_i]
and derived(lambda: _arb._point_at(MIX_PLAN, _mix_sell_t))
is MIX_PTS[_mix_dear_i])
# PRICE-02: the same day twice, as a running site holds 24-48 h (see plan_of).
# The probes stay in day one and keep their extremes: once the cheap quarter is
# over, the verdict at the dear quarter needs a cheap one still ahead.
MIX2_PLAN = qplan_of(MIX_PRICES * 2, provisional=MIX_FLAGS * 2,
gaps=MIX_GAPS + [900] + MIX_GAPS)
check("...so the mixed day CHARGES and DISCHARGES in its firm quarters, at "
"their own prices",
decide(MIX_PLAN, _mix_buy_t, peak_guard_active=False, soc=50.0,
decide(MIX2_PLAN, _mix_buy_t, peak_guard_active=False, soc=50.0,
min_spread_eur_kwh=_shipped, uncertainty=NO_WEATHER,
charge_headroom_w=HEADROOM_CLEAR,
charge_w=MAX_W, discharge_w=MAX_W, soc_floor_pct=20.0,
soc_ceiling_pct=95.0).action == CHARGE
and decide(MIX_PLAN, _mix_sell_t, peak_guard_active=False, soc=50.0,
and decide(MIX2_PLAN, _mix_sell_t, peak_guard_active=False, soc=50.0,
min_spread_eur_kwh=_shipped, uncertainty=NO_WEATHER,
charge_headroom_w=HEADROOM_CLEAR,
charge_w=MAX_W, discharge_w=MAX_W, soc_floor_pct=20.0,
+2 -2
View File
@@ -264,7 +264,7 @@ check("trading on price: a charge block at the cheapest hour (13:00) and a "
check("...placed where the control cycle's own verdict puts them: the same "
"plan.arbitrage() call on the same plan",
c_arb.fresh_tariff_plan(NOW).arbitrage(
peak_guard_active=False, min_spread_eur_kwh=0.05,
now=NOW, peak_guard_active=False, min_spread_eur_kwh=0.05,
uncertainty=frozenset({__import__("app.price", fromlist=["x"])
.Uncertainty.NO_WEATHER}))
.match(allowed=lambda v: (v.cheapest.start, v.dearest.start),
@@ -717,7 +717,7 @@ def _dn(arb):
_c_off, _p_off = _dn(False)
_c_on, _p_on = _dn(True)
_v_on, _why_on = homeview.trade_verdict(_c_on, _c_on.fresh_tariff_plan(NOW))
_v_on, _why_on = homeview.trade_verdict(_c_on, _c_on.fresh_tariff_plan(NOW), NOW)
check("UX-45: day/night tariff with the stored plan and price trading off: "
"24 bars, no cheap or dear mark",
len(_p_off) == 24 and {b["kind"] for b in _p_off} == {"mid"})
+42 -10
View File
@@ -66,22 +66,54 @@ A, B = "inverter-a", "inverter-b"
# ============================================================================ #
print("1. modes off: byte-identical to 7a00f12 on the trade path")
# Printed by tools/opt11p3_identity.py on release/1.0 7a00f12 (2026-09-29),
# goodwe-addon-checks:1. 1080 cycles each; the trade CHARGEs and DISCHARGEs.
AT_7A00F12 = {
# Printed on release/1.0 7a00f12 (2026-09-29), goodwe-addon-checks:1, 1080
# cycles each; the trade CHARGEs and DISCHARGEs.
#
# PRICE-02 (2026-10-22) changed two things in the trade sentence and nothing
# else: the plan now drops price periods that have ended, so a sale's "N above
# the cheapest" is another number once the first cheap period is over, and the
# sentences say "price period" for "hour". So there are three pins:
# AT_7A00F12_OLD the digests printed on 7a00f12, sentence included. They no
# longer match, and the check below says so: that is what
# makes the mask necessary.
# AT_7A00F12 the same rows through `ident.trade_digest()`, which hides
# exactly those two things (and only for this comparison with
# an older tree). It equals what 7a00f12 gives through it.
# AT_NOW this tree's own digests, sentence included, with nothing
# hidden: any later change to a command, a sentence or a
# figure moves it. The mask cannot hide a future drift.
AT_7A00F12_OLD = {
"n1": "71245b227ca40bada4f71f57ea6154475f4b0bd60239c9ad4f80bffb0094514c",
"n2": "56cf28010b06765c29e5479abb815d7503d570b39240d82dfc818efa90c93722",
"n1_off": "71245b227ca40bada4f71f57ea6154475f4b0bd60239c9ad4f80bffb0094514c",
"n2_off": "56cf28010b06765c29e5479abb815d7503d570b39240d82dfc818efa90c93722",
}
AT_7A00F12 = {
"n1": "33886ef03f364852883f0370230e6e04823fbb46a4704f88ca75bf9082d98c01",
"n2": "666a31f41491c22750720524853d95944d5fba395d1d14040c460d71414b275f",
"n1_off": "33886ef03f364852883f0370230e6e04823fbb46a4704f88ca75bf9082d98c01",
"n2_off": "666a31f41491c22750720524853d95944d5fba395d1d14040c460d71414b275f",
}
AT_NOW = {
"n1": "939b3d47bbd64fa50e3d70538f092fb98e5c4bd5ead85a7fc696a1dbb514039e",
"n2": "c51008213dd739e1e5142c236d9a950aa585992393e4af120086f1ba52ff3e0d",
"n1_off": "939b3d47bbd64fa50e3d70538f092fb98e5c4bd5ead85a7fc696a1dbb514039e",
"n2_off": "c51008213dd739e1e5142c236d9a950aa585992393e4af120086f1ba52ff3e0d",
}
_cmd_digest = ident.trade_digest
_rows = {k: ident.run(**ident.SCENARIOS[k])[0] for k in AT_7A00F12}
_got = {k: ident.digest(v) for k, v in _rows.items()}
_got = {k: _cmd_digest(v) for k, v in _rows.items()}
for k in AT_7A00F12:
print(f" {k} {'same' if _got[k] == AT_7A00F12[k] else 'DIFFERS'} "
f"{_got[k][:16]}")
_full = {k: ident.digest(v) for k, v in _rows.items()}
check("with battery modes off (option absent or false), every command, "
"integrator, allocation and trade sentence equals 7a00f12's, at one "
"inverter and at two", _got == AT_7A00F12)
"integrator, allocation and trade action equals 7a00f12's, at one "
"inverter and at two (the sentence masked as above)",
_got == AT_7A00F12)
check("...the mask is needed and nothing more: unmasked, n1 and n2 differ "
"from 7a00f12's printed digests, and unmasked they equal this tree's "
"own pins, so a later change to any sentence or figure fails here",
all(_full[k] != AT_7A00F12_OLD[k] for k in AT_7A00F12_OLD)
and _full == AT_NOW)
_acts = {r[-1][0] for r in _rows["n2"] if r[-1]}
check("...and the runs reach the trade P3 now skips with modes on: a charge "
"and a sale", {arbitrage.CHARGE, arbitrage.DISCHARGE} <= _acts)
@@ -96,7 +128,7 @@ def _moved(scn, patch):
real(c, t)
ident._feed = feed
try:
return ident.digest(ident.run(**ident.SCENARIOS[scn])[0]) \
return _cmd_digest(ident.run(**ident.SCENARIOS[scn])[0]) \
!= AT_7A00F12[scn]
finally:
ident._feed = real
@@ -112,7 +144,7 @@ def _moved_headroom(scn):
real = peakguard.charge_headroom_w
peakguard.charge_headroom_w = lambda g: None
try:
return ident.digest(ident.run(**ident.SCENARIOS[scn])[0]) \
return _cmd_digest(ident.run(**ident.SCENARIOS[scn])[0]) \
!= AT_7A00F12[scn]
finally:
peakguard.charge_headroom_w = real
+42 -4
View File
@@ -540,6 +540,12 @@ rp.Hass.state = _sensor
rp_start = [UTC0]
FC_OPTS = {"pv_forecast_entity": "sensor.energy_production_today_remaining",
"cycle_cost_threshold_eur": 0.20}
# TARIFF-24 round 2: with a forecast read, a price mode needs the cycle cost
# itself (0.20 here), no longer 1.5 times it (0.30). These scenarios were
# written for a 0.30 gap, and a midday 0.21 gap now plans a grid top-up they
# are not about, so they ask for 0.30 with the forecast; the no-forecast leg
# (`rows_nf`) keeps 0.20, which is 0.30 widened: the same gate on both sides.
GATE_030 = {"cycle_cost_threshold_eur": 0.30}
def seed_profile(c):
@@ -597,7 +603,8 @@ def invariants(tag, rows, c):
# Sunny: 40 kWh forecast. B (Leftover solar) at 30 % needs nothing.
rows, st_, c = run_day(SF_LS, fc=40.0, soc0={"a": 60.0, "b": 30.0})
rows, st_, c = run_day(SF_LS, fc=40.0, soc0={"a": 60.0, "b": 30.0},
extra=GATE_030)
charge = invariants("sunny", rows, c)
_last = nth(rows, -1, "a cycle")
print(f" sunny: verdict {present(_last) and _last['plan'] and _last['plan'].verdict}, "
@@ -607,6 +614,35 @@ check("sunny: a day plan is made, and says sunny",
for r in rows))
check("sunny: no grid charge at all", not charge)
# TARIFF-24 round 2: the same day at the cycle cost 0.20 itself. With the
# forecast read the gap needed is 0.20 (not 1.5 x = 0.30), so the midday
# quarter 0.339 against the evening 0.551 (gap 0.21, between 1x and 1.5x) is
# a planned top-up for B (Leftover solar) up to its planned level.
rows_g, st_, c_g = run_day(SF_LS, fc=40.0, soc0={"a": 60.0, "b": 30.0})
charge_g = invariants("gap between 1x and 1.5x", rows_g, c_g)
_first_g = nth(charge_g, 0, "a charge cycle")
_gaps = [min(g.mean_eur_kwh for g in r["runs"].give)
- r["runs"].cheap.mean_eur_kwh for r in charge_g
if r["runs"] is not None and r["runs"].give]
print(f" gap 0.20 <= gap < 0.30: {len(charge_g)} charge cycles, run gap "
f"{min(_gaps, default=0):.3f}, B to "
f"{max((r['soc']['b'] for r in rows_g), default=0):.1f} %")
check("a forecast day whose price gap lies between 1x and 1.5x the cycle "
"cost (0.20 < 0.21 < 0.30) plans a cheap run: B charges from the grid "
"inside it, only B, and only up to its planned level",
present(_first_g) and len(_gaps) == len(charge_g)
and all(0.20 <= g < 0.30 for g in _gaps)
and all(r["runs"].cheap.holds(r["now"]) for r in charge_g)
and all(r["roles"][B] == modetrade.CHARGE for r in charge_g)
and all(r["roles"][A] != modetrade.CHARGE for r in charge_g)
and max(r["soc"]["b"] for r in rows_g) <= 45.64 + 2.0)
check("...and the same day with no forecast read does not (it needs 0.30): "
"the old gate, kept for a day without a forecast",
not [r for r in run_day(SF_LS, fc=None,
soc0={"a": 60.0, "b": 30.0})[0]
if r["action"] == arbitrage.CHARGE
and r["now"].astimezone(L).hour == 12])
# Dark: 1 kWh. B buys the evening's shortfall only, in the cheap run.
rows, st_, c = run_day(SF_LS, fc=1.0, soc0={"a": 60.0, "b": 30.0})
charge = invariants("dark", rows, c)
@@ -623,7 +659,8 @@ check("dark: A rests while B charges", all(r["roles"][A] == modetrade.REST
for r in charge))
# Cheap hours on a partly sunny day: ignores the plan, fills as in P3.
rows, st_, c = run_day(SF_CH, fc=14.0, soc0={"a": 60.0, "b": 30.0})
rows, st_, c = run_day(SF_CH, fc=14.0, soc0={"a": 60.0, "b": 30.0},
extra=GATE_030)
charge = invariants("cheap hours, partly", rows, c)
top = max(r["soc"]["b"] for r in rows)
print(f" cheap hours partly: B reached {top:.1f} %")
@@ -648,7 +685,8 @@ check("forecast lost mid-run: Leftover solar was charging, and stops within "
# The forecast is far too high (40 kWh, and there is no sun at all): no grid
# charge, so B covers less of the evening - a money risk, never a safety one.
rows, st_, c = run_day(SF_LS, fc=40.0, soc0={"a": 30.0, "b": 30.0})
rows, st_, c = run_day(SF_LS, fc=40.0, soc0={"a": 30.0, "b": 30.0},
extra=GATE_030)
invariants("forecast too high", rows, c)
check("forecast too high: no grid charge, and B still keeps its power for "
"the dear run", not [r for r in rows if r["action"]
@@ -658,7 +696,7 @@ check("forecast too high: no grid charge, and B still keeps its power for "
# A refusal wins over the plan.
rows, st_, c = run_day(SF_LS, fc=1.0, soc0={"a": 60.0, "b": 30.0},
refuse=("b",))
refuse=("b",), extra=GATE_030)
invariants("refusal", rows, c)
check("refusal: B refuses to charge, so the plan's charge ends and B is "
"never a charger after it", rows and not [
+8 -3
View File
@@ -99,6 +99,11 @@ from app.static_tariff import (
TariffPlan, parse_taxes_profile, static_status)
from app.validate import validate_startup_config
# PRICE-02: `TariffPlan.arbitrage(now=...)` is required. This instant is before every
# point these fixtures build, so no period has ended and the whole series counts.
from datetime import datetime as _DT, timezone as _TZ # noqa: E402
LONG_AGO = _DT(2000, 1, 1, tzinfo=_TZ.utc)
fails = []
@@ -4518,8 +4523,8 @@ _T11_FLAT = TariffPlan(
[PricePoint(_T11_NOW + timedelta(minutes=15 * i), 0.10, False)
for i in range(96)],
PriceProduct.DAY_AHEAD, "generic_dayahead", _T11_NOW), None, "provider")
_r83 = derived(lambda: _T11_ONEFIRM.arbitrage(**_T11_A9).reason)
_rflat = derived(lambda: _T11_FLAT.arbitrage(**_T11_A9).reason)
_r83 = derived(lambda: _T11_ONEFIRM.arbitrage(now=LONG_AGO, **_T11_A9).reason)
_rflat = derived(lambda: _T11_FLAT.arbitrage(now=LONG_AGO, **_T11_A9).reason)
print(f" §8.3 refusal : {_r83[:118]}")
print(f" flat refusal : {_rflat[:118]}")
# 96 quarter-hourly points span 24 hours; ONE firm point spans one of them.
@@ -4828,7 +4833,7 @@ def _t17_verdict(points):
read = PriceRead.series(points, PriceProduct.DAY_AHEAD, "t17",
datetime.now(UTC))
plan = TariffPlan(read, None, "provider")
return plan.arbitrage(peak_guard_active=False,
return plan.arbitrage(now=LONG_AGO, peak_guard_active=False,
min_spread_eur_kwh=PRICE_OPTION_DEFAULTS[
"cycle_cost_threshold_eur"],
uncertainty=frozenset())
+538
View File
@@ -0,0 +1,538 @@
"""Runnable check for PRICE-02. `python3 test_price02.py`
The trading verdict is taken over the price periods that have not ended.
`TariffPlan.arbitrage(now=...)` drops the points with `end <= now` (the span
`price._cover_table()` gives each one) and every caller passes its `now`:
`arbitrage.decide()` (modes off), the `allowed()` gate in
`Controller._mode_roles()` (modes on) and the Home page's `trade_verdict()`.
1. A 15-minute day, probed at :05, :20, :35 and :50 of the cheap hour: the
buy leg and the sell leg move to the current period and never name one that
has ended, and `decide()` trades in it. Cheapest in the past, the current
and a future period are all covered.
2. A plan built at 03:00 and used at 03:20 (the 30-minute refresh lag), and the
shipped refresh (`fill_from_yesterday`, which keeps the whole current hour).
3. An hourly feed and the static day/night tariff: an hourly point that is
still current stays in. A plan built off the hour (the refresh runs every
30 min from add-on start, so :20 and :50 are normal) no longer aims at the
hour that has just ended, so a trade runs through (owner, 2026-10-22), and
a period that ends exactly at `now` is over.
4. Modes on: a spread that exists only because of an ended period no longer
opens the gate (`Controller._mode_roles`), nor the Home page verdict.
5. The reason text says "price period"; `now` is required (no default).
6. Ties. The verdict takes the first extreme still ahead, where it used to
take the first of the whole plan. On hour-constant 15-minute data (four
equal quarters) that is four acting periods in the cheap hour, not one;
the number of times the action changes does not grow. The same on a real
15-minute day, and hourly alternating prices change at most once an hour.
Mutations: MUTATION.md says how to run them (purged copies); the ones of
PRICE-02 are listed in the pull request.
"""
import json
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.join(HERE, "tools"))
logging.disable(logging.WARNING)
from app import arbitrage, homeview # noqa: E402
from app.arbitrage import CHARGE, DISCHARGE, decide # noqa: E402
from app.price import (PricePoint, PriceProduct, PriceRead, # noqa: E402
Uncertainty, brussels)
from app.price_forecast import fill_from_yesterday # noqa: E402
from app.static_tariff import TariffPlan, static_plan # noqa: E402
fails = []
def check(name, cond):
print(f" {'ok ' if cond else 'FAIL'} {name}")
if not cond:
fails.append(name)
UTC = timezone.utc
LONG_AGO = datetime(2000, 1, 1, tzinfo=UTC) # before every point: nothing has ended, the old reach
NO_WEATHER = frozenset({Uncertainty.NO_WEATHER})
D0 = datetime(2026, 10, 20, 0, 0, tzinfo=UTC) # 02:00 Brussels
H3 = D0 + timedelta(hours=1) # 03:00 Brussels
ARGS = dict(peak_guard_active=False, min_spread_eur_kwh=0.10,
uncertainty=NO_WEATHER)
def qplan(prices, start=D0, minutes=15, product=PriceProduct.DAY_AHEAD):
pts = [PricePoint(brussels(start + timedelta(minutes=minutes * i)), p,
False) for i, p in enumerate(prices)]
return TariffPlan(PriceRead.series(pts, product, "generic_dayahead",
start), None, "provider")
ARGS_NO_NOW = dict(ARGS)
ARGS5 = dict(ARGS, min_spread_eur_kwh=0.05, uncertainty=frozenset())
def dec(plan, now, **kw):
base = dict(peak_guard_active=False, charge_headroom_w=5000.0, soc=50.0,
min_spread_eur_kwh=0.10, uncertainty=frozenset(),
charge_w=2500.0, discharge_w=2500.0, soc_floor_pct=10.0,
soc_ceiling_pct=90.0)
base.update(kw)
return decide(plan, now, **base)
def hhmm(t):
return f"{brussels(t):%H:%M}"
def ahead_extreme(prices, now, pick, start=D0, minutes=15):
"""The expected leg, by plain arithmetic: the extreme price over the
periods whose end is after `now`, as the start instant (first on a tie)."""
best = None
for i, p in enumerate(prices):
s = start + timedelta(minutes=minutes * i)
if s + timedelta(minutes=minutes) <= now:
continue
if best is None or pick(p, best[1]):
best = (s, p)
return best[0]
# --------------------------------------------------------------------------- #
print("1. a 15-minute day: the verdict is taken from the current period on")
BUY = [0.30] * 192 # 48 h of quarters from 02:00
for q, p in ((4, 0.05), (5, 0.06), (6, 0.07), (7, 0.08), (68, 0.50)):
BUY[q] = p # 03:00 .05 03:15 .06 03:30 .07 03:45 .08 19:00 .50
BUY_PLAN = qplan(BUY)
check("the fixture: the day's cheapest period is 03:00, then three dearer "
"ones, and the dearest is 19:00 - so every probe has a past, a current "
"and a future period to choose between",
BUY.index(min(BUY)) == 4 and BUY.index(max(BUY)) == 68
and BUY[4] < BUY[5] < BUY[6] < BUY[7] < min(BUY[8:68]))
want_q = {5: 4, 20: 5, 35: 6, 50: 7}
for mins, q in want_q.items():
now = H3 + timedelta(minutes=mins)
v = BUY_PLAN.arbitrage(now=now, **ARGS)
d = dec(BUY_PLAN, now)
got = v.cheapest.start
exp = ahead_extreme(BUY, now, lambda a, b: a < b)
check(f"03:{mins:02d}: the buy leg is {hhmm(exp)} (the first period not "
f"over), the verdict says {hhmm(got)}, and decide() is {d.action}",
got == exp == D0 + timedelta(minutes=15 * q)
and d.action == CHARGE
and abs(d.price_now_eur_kwh - BUY[q]) < 1e-9)
check("before the change the same plan at 03:20 aimed at 03:00 (the "
"verdict without `now`) and decide() did nothing - the defect, shown",
BUY_PLAN.arbitrage(now=LONG_AGO, **ARGS).cheapest.start == H3
and arbitrage._act(BUY_PLAN, H3 + timedelta(minutes=20),
BUY_PLAN.arbitrage(now=LONG_AGO, **ARGS), 50.0, 2500.0, 2500.0,
10.0, 90.0, 5000.0).action is None)
# A cheapest period still AHEAD stays the target from every probe.
FUT = [0.30] * 192
FUT[8], FUT[68] = 0.04, 0.50 # cheapest 04:00, dearest 19:00
FUT_PLAN = qplan(FUT)
check("the cheapest in the FUTURE (04:00) is the buy leg at :05, :20, :35 and "
":50 of the hour before it - nothing ahead is dropped",
all(FUT_PLAN.arbitrage(now=H3 + timedelta(minutes=m), **ARGS)
.cheapest.start == H3 + timedelta(hours=1) for m in (5, 20, 35, 50)))
check("...and at 04:20 (that period is now the current one) it is still the "
"current period at :00-:15 only: 04:20 aims elsewhere",
FUT_PLAN.arbitrage(now=H3 + timedelta(hours=1, minutes=20), **ARGS)
.cheapest.start != H3 + timedelta(hours=1))
print("\n1b. the sell leg")
SELL = [0.30] * 192
for q, p in ((4, 0.70), (5, 0.65), (6, 0.60), (7, 0.55), (68, 0.10)):
SELL[q] = p # dearest 03:00 .70 .. 03:45 .55 ; cheapest 19:00
SELL_PLAN = qplan(SELL)
for mins, q in want_q.items():
now = H3 + timedelta(minutes=mins)
v = SELL_PLAN.arbitrage(now=now, **ARGS)
d = dec(SELL_PLAN, now)
exp = ahead_extreme(SELL, now, lambda a, b: a > b)
check(f"03:{mins:02d}: the sell leg is {hhmm(exp)}, the verdict says "
f"{hhmm(v.dearest.start)}, and decide() is {d.action}",
v.dearest.start == exp == D0 + timedelta(minutes=15 * q)
and d.action == DISCHARGE
and abs(d.price_now_eur_kwh - SELL[q]) < 1e-9)
# --------------------------------------------------------------------------- #
print("\n2. the plan is older than the verdict")
# Built at 03:00 and used at 03:20, 03:35 (the refresh runs every 30 min).
built = H3
feed = qplan(BUY).read
plan_0300 = TariffPlan(fill_from_yesterday(
feed, yesterday=PriceRead.no_data("none"), since=built,
until=built + timedelta(hours=48), stamp=built), None, "provider")
check("the plan built at 03:00 starts at 03:00 (the refresh floors to the hour)",
min(p.start for p in plan_0300.read.points) == H3)
for mins, q in ((20, 5), (35, 6)):
now = H3 + timedelta(minutes=mins)
d = dec(plan_0300, now)
check(f"used at 03:{mins:02d} the verdict is "
f"{hhmm(D0 + timedelta(minutes=15 * q))} and decide() charges there",
plan_0300.arbitrage(now=now, **ARGS).cheapest.start
== D0 + timedelta(minutes=15 * q) and d.action == CHARGE)
# The shipped refresh at 03:20 keeps the whole hour: the verdict still skips it.
now = H3 + timedelta(minutes=20)
plan_refresh = TariffPlan(fill_from_yesterday(
feed, yesterday=PriceRead.no_data("none"), since=now,
until=now + timedelta(hours=48), stamp=now), None, "provider")
check("the shipped refresh at 03:20 still holds the 03:00 period, as before, "
"and the verdict does not aim at it",
min(p.start for p in plan_refresh.read.points) == H3
and plan_refresh.arbitrage(now=now, **ARGS).cheapest.start
== H3 + timedelta(minutes=15))
# --------------------------------------------------------------------------- #
print("\n3. hourly feeds and the static tariff: a current hour stays in")
HR = [0.30] * 48 # 48 hourly points from 02:00
HR[1], HR[17] = 0.05, 0.50 # cheapest 03:00, dearest 19:00
HR_PLAN = qplan(HR, minutes=60)
same = True
for m in (0, 5, 20, 35, 59):
now = H3 + timedelta(minutes=m)
a = HR_PLAN.arbitrage(now=now, **ARGS)
b = HR_PLAN.arbitrage(now=LONG_AGO, **ARGS)
same = same and a.cheapest is b.cheapest and a.dearest is b.dearest \
and a.spread_eur_kwh == b.spread_eur_kwh
check("an hourly point that is still current stays in: at 03:00, :05, :20, "
":35 and :59 the verdict is the one without `now`", same)
check("...and decide() still charges in the current hour at 03:40",
dec(HR_PLAN, H3 + timedelta(minutes=40)).action == CHARGE)
check("...and once the hour has ended its point is gone: at 04:05 the buy leg "
"is no longer 03:00",
HR_PLAN.arbitrage(now=H3 + timedelta(hours=1, minutes=5), **ARGS)
.cheapest.start != H3)
TWO = {"periods": [
{"name": "day", "from": "07:00", "to": "22:00", "days": "mon-sun",
"eur_per_kwh": 0.40},
{"name": "night", "from": "22:00", "to": "07:00", "days": "mon-sun",
"eur_per_kwh": 0.18}]}
OPTS = {"price_provider": "off", "static_profile": json.dumps(TWO)}
ok, n_checked = True, 0
for h in range(0, 24):
for m in (0, 20, 50):
now = D0 + timedelta(hours=h - 2, minutes=m) # from 22:00 UTC the day before
plan = static_plan(OPTS, now, now + timedelta(hours=48), now=now)
a = plan.arbitrage(now=now, **ARGS)
b = plan.arbitrage(now=LONG_AGO, **ARGS)
n_checked += 1
ok = ok and a.kind == b.kind == "allowed" and a.cheapest is b.cheapest \
and a.dearest is b.dearest and a.spread_eur_kwh == b.spread_eur_kwh
check(f"the static day/night plan, built at the instant it is used (so it "
f"holds no ended hour), gives the same verdict with and without `now` "
f"at {n_checked} instants", ok and n_checked == 72)
# 3b. The refresh is NOT on the hour. main.py rebuilds the plan every 30 min
# counted from add-on start (TARIFF_REFRESH_S = 1800), so a plan built at :20
# or :50 starts at the floored hour and, until the next refresh, still holds
# the hour that has just ended. Owner decision 2026-10-22: the verdict skips
# that hour too, so a trade runs through instead of pausing until the refresh.
print("\n3b. hourly and static plans built off the hour: the ended hour is not the target")
HR2 = [0.30] * 48 # hourly from 02:00 Brussels
HR2[1], HR2[2], HR2[17] = 0.05, 0.05, 0.50 # cheap 03:00 and 04:00 (a tie), dear 19:00
built = H3 + timedelta(minutes=50) # the refresh at :50 floors to 03:00
plan_h = TariffPlan(fill_from_yesterday(
qplan(HR2, minutes=60).read, yesterday=PriceRead.no_data("none"),
since=built, until=built + timedelta(hours=48), stamp=built),
None, "provider")
now = H3 + timedelta(hours=1, minutes=5) # 04:05, before the next refresh
check("the hourly plan built at 03:50 starts at 03:00 and is used at 04:05, so "
"it still holds the 03:00 hour, which is over",
min(p.start for p in plan_h.read.points) == H3)
old_v = plan_h.arbitrage(now=LONG_AGO, **ARGS)
new_v = plan_h.arbitrage(now=now, **ARGS)
check("without `now` (the old verdict) the buy leg is the ended 03:00 hour "
"(first of the tie) and decide() does nothing at 04:05 - the pause",
old_v.cheapest.start == H3
and arbitrage._act(plan_h, now, old_v, 50.0, 2500.0, 2500.0, 10.0,
90.0, 5000.0).action is None)
check("with `now` the buy leg is 04:00, the hour in progress, and decide() "
"charges at 04:05: the trade runs through",
new_v.cheapest.start == H3 + timedelta(hours=1)
and dec(plan_h, now).action == CHARGE)
# The day/night tariff, refresh at :20 of the hour: all 15 day hours tie.
b_local = D0 + timedelta(hours=10, minutes=20) # 12:20 Brussels
plan_s = static_plan(OPTS, b_local, b_local + timedelta(hours=48), now=b_local)
now = D0 + timedelta(hours=11, minutes=5) # 13:05 Brussels
old_v = plan_s.arbitrage(now=LONG_AGO, **ARGS)
new_v = plan_s.arbitrage(now=now, **ARGS)
check("the day/night plan built at 12:20 starts at 12:00; used at 13:05 its "
"sell leg without `now` is the ended 12:00 hour (first of the tied day "
"hours) and decide() does nothing - the old hourly pause",
min(p.start for p in plan_s.read.points) == D0 + timedelta(hours=10)
and old_v.dearest.start == D0 + timedelta(hours=10)
and arbitrage._act(plan_s, now, old_v, 50.0, 2500.0, 2500.0, 10.0,
90.0, 5000.0).action is None)
check("with `now` the sell leg is 13:00, the hour in progress, and decide() "
"discharges at 13:05: the trade runs through",
new_v.dearest.start == D0 + timedelta(hours=11)
and dec(plan_s, now).action == DISCHARGE)
ok = True
for m in (0, 5, 20, 35, 50, 59): # every minute class of the hour
t = D0 + timedelta(hours=12, minutes=m) # 14:mm Brussels
v = plan_s.arbitrage(now=t, **ARGS)
ok = ok and v.dearest.start == D0 + timedelta(hours=12) \
and dec(plan_s, t).action == DISCHARGE
check("...and through the whole of 14:00-14:59 the target is the 14:00 hour "
"and the trade stays on (no pause at the top of the hour)", ok)
# The exact boundary: a period ends AT its `end`, so at that instant it is over.
# (`end >= now` would keep it for one more instant.)
print("\n3c. the exact end of a period")
at = H3 + timedelta(minutes=15) # 03:15:00 exactly
v = BUY_PLAN.arbitrage(now=at, **ARGS)
check("at 03:15:00 exactly the 03:00 quarter is over: the buy leg is 03:15 "
"and decide() charges", v.cheapest.start == at and dec(BUY_PLAN, at)
.action == CHARGE)
check("one microsecond earlier the 03:00 quarter is still in view",
BUY_PLAN.arbitrage(now=at - timedelta(microseconds=1), **ARGS)
.cheapest.start == H3)
at_h = H3 + timedelta(hours=1) # 04:00:00 exactly
check("at 04:00:00 exactly the 03:00 hour is over (hourly feed), one "
"microsecond earlier it is not",
plan_h.arbitrage(now=at_h, **ARGS).cheapest.start == at_h
and plan_h.arbitrage(now=at_h - timedelta(microseconds=1), **ARGS)
.cheapest.start == H3)
two = qplan([0.10, 0.20]) # two firm quarters from 02:00
check("the Home page's refusal key at the exact end of the first of two "
"quarters: one is left, so 'not_firm' (with `end >= now` it would say "
"'not_worth'); a microsecond earlier both count",
homeview.refusal_key(two, False, D0 + timedelta(minutes=15)) == "not_firm"
and homeview.refusal_key(two, False, D0 + timedelta(minutes=15)
- timedelta(microseconds=1)) == "not_worth")
# --------------------------------------------------------------------------- #
print("\n4. modes on: an ended period no longer opens the gate")
GATE = [0.30] * 192
GATE[4] = 0.10 # the ONLY spread is the 03:00 period
GATE_PLAN = qplan(GATE)
now = H3 + timedelta(minutes=20)
check("the fixture: with the ended 03:00 period in view the verdict is "
"allowed; from 03:20 on the rest of the series is flat",
GATE_PLAN.arbitrage(now=LONG_AGO, **ARGS).kind == "allowed"
and GATE_PLAN.arbitrage(now=now, **ARGS).kind == "refused"
and "flat" in GATE_PLAN.arbitrage(now=now, **ARGS).reason)
import opt11p3_replay as rp # noqa: E402
WPTS = [(D0 + timedelta(minutes=15 * i), 900, 0.10) for i in range(192)]
def controller_at(at, plan):
"""The real Controller after a few cycles of the modes-on replay at `at`
(two batteries, one on Cheap hours), holding `plan` as its fresh plan."""
_st, c = rp.run("p3_ch_sell", [(D0.timestamp() - 10, 800.0)], WPTS, at,
20.0, soc0={"a": 60.0, "b": 30.0},
extra={"cycle_cost_threshold_eur": 0.05})
c.tariff_plan, c.tariff_at = plan, at
return c
from app import modetrade # noqa: E402
GATE_SEEN = {}
_real_decide = modetrade.decide
def _spy(runs, now_, packs, **kw):
GATE_SEEN.update(charge_ok=kw["charge_ok"], sell_ok=kw["sell_ok"],
trade_why=kw["trade_why"])
return _real_decide(runs, now_, packs, **kw)
modetrade.decide = _spy
c = controller_at(now, GATE_PLAN)
GATE_SEEN.clear()
c._mode_roles(now, None)
late = dict(GATE_SEEN)
c2 = controller_at(H3 + timedelta(minutes=5), GATE_PLAN)
GATE_SEEN.clear()
c2._mode_roles(H3 + timedelta(minutes=5), None)
early = dict(GATE_SEEN)
modetrade.decide = _real_decide
check(f"Controller._mode_roles() (modes on, the `allowed()` gate) at 03:05, "
f"03:00 still current: charge and sell are allowed ({early})",
early.get("charge_ok") is True and early.get("sell_ok") is True)
check(f"...and at 03:20, with only flat periods ahead: neither is allowed, "
f"and the reason is the spread ({late})",
late.get("charge_ok") is False and late.get("sell_ok") is False
and late.get("trade_why") == "not_worth")
c.o["arbitrage_enabled"] = True
v, why = homeview.trade_verdict(c, GATE_PLAN, now)
check(f"the Home page verdict takes the same `now`: no verdict at 03:20 "
f"({why!r}), and a verdict at 03:05",
v is None and why == "not_worth"
and homeview.trade_verdict(c, GATE_PLAN, H3 + timedelta(minutes=5))[0]
is not None)
check("...and the refusal key counts only periods not over: two firm periods "
"left, so 'not_worth', and with one left 'not_firm'",
homeview.refusal_key(GATE_PLAN, False, now) == "not_worth"
and homeview.refusal_key(qplan([0.1, 0.2]), False,
D0 + timedelta(minutes=20)) == "not_firm")
# --------------------------------------------------------------------------- #
print("\n5. the text")
check("the reason for an off-peak period says 'price period', not 'hour'",
arbitrage.IDLE_OFF_PEAK_HOUR
== "now is not the cheapest or the dearest price period")
_b = dec(BUY_PLAN, H3 + timedelta(minutes=20))
_s = dec(SELL_PLAN, H3 + timedelta(minutes=20))
check("...and the charge and the discharge sentences say 'price period' too "
"(the unit is 15 minutes), with no 'firm hour' left",
"cheapest firm price period" in _b.reason
and "dearest firm price period" in _s.reason
and "firm hour" not in _b.reason + _s.reason)
_hi = dec(BUY_PLAN, H3 + timedelta(minutes=20), soc=95.0)
_lo = dec(SELL_PLAN, H3 + timedelta(minutes=20), soc=10.0)
check("...and so do the full-pack and empty-pack sentences ('buy period', "
"'sell period')",
"the buy period is here" in _hi.reason
and "the sell period is here" in _lo.reason)
import inspect # noqa: E402
_par = inspect.signature(TariffPlan.arbitrage).parameters
check("`now` is required: keyword-only with no default, like the three "
"arguments beside it, so a caller that forgets it fails loudly instead "
"of getting the elapsed periods back",
_par["now"].default is inspect.Parameter.empty
and _par["now"].kind is inspect.Parameter.KEYWORD_ONLY
and all(p.default is inspect.Parameter.empty
for n, p in _par.items() if n != "self"))
try:
BUY_PLAN.arbitrage(**ARGS_NO_NOW)
_forgot = False
except TypeError:
_forgot = True
check("...and calling without it raises TypeError", _forgot)
try:
BUY_PLAN.arbitrage(now=datetime(2026, 10, 20, 3, 20), **ARGS)
_naive = False
except TypeError as e:
_naive = "tz-aware" in str(e)
check("a naive `now` is refused: it is ambiguous for one hour every October",
_naive)
# --------------------------------------------------------------------------- #
print("\n6. ties: the first extreme still ahead")
def acts(plan, instants, ago=False, args=ARGS):
out = []
for t in instants:
v = plan.arbitrage(now=LONG_AGO if ago else t, **args)
d = arbitrage._act(plan, t, v, 50.0, 2500.0, 2500.0, 10.0, 90.0,
5000.0) if v.kind == "allowed" else None
out.append(None if d is None else d.action)
return out
# Hour-constant 15-minute data: the cheap hour is four equal quarters.
HC = [round(0.40 - 0.0001 * i, 4) for i in range(192)] # falling, no ties
for q in range(4, 8):
HC[q] = 0.05 # 03:00-04:00 four equal quarters
HC[68:72] = [0.50] * 4 # 19:00-20:00 four equal quarters
HC_PLAN = qplan(HC)
probe = [H3 + timedelta(minutes=m) for m in (5, 20, 35, 50, 65)]
old = acts(HC_PLAN, probe, ago=True)
new = acts(HC_PLAN, probe)
check(f"before: four equal cheap quarters, the verdict is the first of them, "
f"so the trade acts in one quarter ({old})",
old == [CHARGE, None, None, None, None])
check(f"after: the first one still ahead, so it acts in all four and stops "
f"with the hour ({new}) - four acting periods for the same hour, one "
f"block either way",
new == [CHARGE, CHARGE, CHARGE, CHARGE, None])
changes = lambda a: sum(1 for x, y in zip(a, a[1:]) if x != y) # noqa: E731
check("...the action changes the same number of times (one start, one stop)",
changes(old) == changes(new) == 1)
# A real 15-minute day, as production runs it: refresh every 30 min from the
# floored hour, the verdict at 7 min into every period.
REAL = os.path.join(HERE, "scenarios",
"energy-charts-be-price-2026-09-09_2026-09-10-twoday.json")
_d = json.load(open(REAL))
_pts = [PricePoint(brussels(datetime.fromtimestamp(t, UTC)), p / 1000.0, False)
for t, p in zip(_d["unix_seconds"], _d["price"])]
_feed = PriceRead.series(_pts, PriceProduct.DAY_AHEAD, "generic_dayahead",
_pts[0].start)
_day0 = _pts[0].start.astimezone(UTC)
def real_day(ago):
out = []
for q in range(96):
t = _day0 + timedelta(minutes=15 * q + 7)
ref = t.replace(minute=0 if t.minute < 30 else 30, second=0,
microsecond=0)
plan = TariffPlan(fill_from_yesterday(
_feed, yesterday=PriceRead.no_data("none"), since=ref,
until=ref + timedelta(hours=36), stamp=ref), None, "provider")
v = plan.arbitrage(now=LONG_AGO if ago else t, **ARGS5)
out.append(arbitrage._act(plan, t, v, 50.0, 2500.0, 2500.0, 10.0,
90.0, 5000.0).action
if v.kind == "allowed" else None)
return out
r_old, r_new = real_day(True), real_day(False)
n_old = sum(1 for a in r_old if a)
n_new = sum(1 for a in r_new if a)
print(f" real 15-minute day 2026-09-09: acting periods {n_old} -> {n_new}, "
f"action changes {changes(r_old)} -> {changes(r_new)}")
check("a real 15-minute day (energy-charts, 2026-09-09) does not switch more "
"often: the action changes no more often than before",
changes(r_new) <= changes(r_old))
check("...and every period the old verdict acted in is still acted in, with "
"the quarters it had lost to the elapsed-period tie added",
all(n is not None for o, n in zip(r_old, r_new) if o) and n_new >= n_old)
# Alternating hourly prices (the flapping day): ties everywhere, so the verdict
# moves on every hour. At most once an hour, never once a tick.
FL = [0.20 + (0.101 if i % 2 else 0.0) for i in range(24)] * 2
FL_PLAN = qplan(FL, minutes=60)
ticks = [acts(FL_PLAN, [D0 + timedelta(seconds=30 * k)], args=ARGS5)[0]
for k in range(24 * 120)]
check(f"alternating hourly prices: {changes(ticks)} action changes over "
f"2880 ticks - at most one an hour, not one a tick",
changes(ticks) <= 24 and CHARGE in ticks and DISCHARGE in ticks)
# The Home page path: `homeview.build_plan()` hands its own `now` to
# `trade_verdict`. With a `now` floored to the hour it would still aim at the
# 03:00 period at 03:20 and draw a trade that the control cycle does not take.
print("\n4b. the Home page plan (build_plan) uses the instant it is rendered at")
c.o["arbitrage_enabled"] = True
c.modes_on = False
def home_at(t):
c.tariff_plan, c.tariff_at = GATE_PLAN, t
return homeview.build_plan(c, t, False, True)
h_early = home_at(H3 + timedelta(minutes=5))
h_late = home_at(H3 + timedelta(minutes=20))
trade_kinds = ("grid", "give")
check("build_plan at 03:05 (03:00 still current): the spread is real, so the "
"page draws a trade (verdict_key 'trade')",
h_early["verdict_key"] == "trade"
and any(r["kind"] in trade_kinds for r in h_early["runs"]))
check("build_plan at 03:20 (only flat periods ahead): no trade on the page, "
"the same refusal as trade_verdict gives (verdict_key 'house')",
h_late["verdict_key"] == "house"
and not any(r["kind"] in trade_kinds for r in h_late["runs"])
and h_late["lines"] != h_early["lines"])
print()
if fails:
print(f"{len(fails)} FAILED: " + "; ".join(fails))
sys.exit(1)
print("all checks passed")
+14 -9
View File
@@ -87,6 +87,11 @@ from app.static_tariff import Arbitrage, TariffPlan
UTC = timezone.utc
HERE = os.path.dirname(os.path.abspath(__file__))
# PRICE-02: `TariffPlan.arbitrage(now=...)` is required. This instant is before every
# point these fixtures build, so no period has ended and the whole series counts.
from datetime import datetime as _DT, timezone as _TZ # noqa: E402
LONG_AGO = _DT(2000, 1, 1, tzinfo=_TZ.utc)
fails = []
@@ -905,7 +910,7 @@ def min_accepted_spread(product, sources, base, provider="generic_dayahead"):
lo, hi = 0.0, 4.0
for _ in range(60):
mid = (lo + hi) / 2
v = plan_with_spread(mid, product, provider).arbitrage(
v = plan_with_spread(mid, product, provider).arbitrage(now=LONG_AGO,
peak_guard_active=False, min_spread_eur_kwh=base,
uncertainty=sources)
if v.kind == "allowed":
@@ -1013,7 +1018,7 @@ check(f"the fixture is non-degenerate: the whole-series spread "
f"{_all_spread:.6f} and the firm-only spread {_firm_spread:.6f} are "
f"different numbers, so a verdict can be traced to one of them",
not near(_all_spread, _firm_spread, 1e-6))
_v = derived(lambda: _mixed.arbitrage(peak_guard_active=False,
_v = derived(lambda: _mixed.arbitrage(now=LONG_AGO, peak_guard_active=False,
min_spread_eur_kwh=0.0,
uncertainty=frozenset()))
check("a PART-published series still allows arbitrage - §8.3 forbids acting on "
@@ -1032,7 +1037,7 @@ check("...and it reports the threshold it cleared and why, rather than leaving "
_all_prov = TariffPlan(PriceRead.series(_cheap_stale, PriceProduct.DAY_AHEAD,
"generic_dayahead", D0), None,
"fallback")
_vp = derived(lambda: _all_prov.arbitrage(peak_guard_active=False,
_vp = derived(lambda: _all_prov.arbitrage(now=LONG_AGO, peak_guard_active=False,
min_spread_eur_kwh=0.0,
uncertainty=frozenset()))
check("a WHOLLY provisional series - the §11.4 fallback, or a day before any "
@@ -1046,23 +1051,23 @@ _one_firm = TariffPlan(
"generic_dayahead", D0), None, "provider")
check("one firm hour is not enough either: a buy hour and a sell hour must "
"BOTH be published before a cycle can be committed",
_one_firm.arbitrage(peak_guard_active=False, min_spread_eur_kwh=0.0,
_one_firm.arbitrage(now=LONG_AGO, peak_guard_active=False, min_spread_eur_kwh=0.0,
uncertainty=frozenset()).kind == "refused")
check("§10.1 still holds above all of it: the peak guard refuses the same "
"series that is otherwise allowed",
_mixed.arbitrage(peak_guard_active=True, min_spread_eur_kwh=0.0,
_mixed.arbitrage(now=LONG_AGO, peak_guard_active=True, min_spread_eur_kwh=0.0,
uncertainty=frozenset()).kind == "refused")
check("the three questions cannot be skipped: all of peak_guard_active, "
"min_spread_eur_kwh and uncertainty are required and keyword-only",
raises(lambda: _mixed.arbitrage(peak_guard_active=False), TypeError)
and raises(lambda: _mixed.arbitrage(peak_guard_active=False,
raises(lambda: _mixed.arbitrage(now=LONG_AGO, peak_guard_active=False), TypeError)
and raises(lambda: _mixed.arbitrage(now=LONG_AGO, peak_guard_active=False,
min_spread_eur_kwh=0.0), TypeError)
and raises(lambda: _mixed.arbitrage(False, 0.0, frozenset()), TypeError))
check("...nor answered with a truthy 'unknown' or a negative threshold",
raises(lambda: _mixed.arbitrage(peak_guard_active=None,
raises(lambda: _mixed.arbitrage(now=LONG_AGO, peak_guard_active=None,
min_spread_eur_kwh=0.0,
uncertainty=frozenset()), TypeError)
and raises(lambda: _mixed.arbitrage(peak_guard_active=False,
and raises(lambda: _mixed.arbitrage(now=LONG_AGO, peak_guard_active=False,
min_spread_eur_kwh=-0.1,
uncertainty=frozenset()), TypeError))
+10 -5
View File
@@ -118,6 +118,11 @@ from app.static_tariff import resolve_tariff
UTC = timezone.utc
HERE = os.path.dirname(os.path.abspath(__file__))
# PRICE-02: `TariffPlan.arbitrage(now=...)` is required. This instant is before every
# point these fixtures build, so no period has ended and the whole series counts.
from datetime import datetime as _DT, timezone as _TZ # noqa: E402
LONG_AGO = _DT(2000, 1, 1, tzinfo=_TZ.utc)
fails = []
_tmpdirs = []
@@ -699,8 +704,8 @@ check(f"the short answer stays a SERIES of what exists - {_pre.read.kind}, "
# --- the trade set, which is where the whole thing lands ------------------- #
_A9 = dict(peak_guard_active=False, min_spread_eur_kwh=0.0,
uncertainty=frozenset())
_v2 = _pub.arbitrage(**_A9)
_v1 = _pre.arbitrage(**_A9)
_v2 = _pub.arbitrage(now=LONG_AGO, **_A9)
_v1 = _pre.arbitrage(now=LONG_AGO, **_A9)
check("both plans still ALLOW a trade, so the two verdicts below are compared "
"against each other and not against a refusal",
_v2.kind == "allowed" and _v1.kind == "allowed")
@@ -744,8 +749,8 @@ print("2. AC2 - the spread stopped being taken over hours that have gone")
_A = dict(peak_guard_active=False, min_spread_eur_kwh=0.0,
uncertainty=frozenset())
_v_before = _before.arbitrage(**_A)
_v_after = _after.arbitrage(**_A)
_v_before = _before.arbitrage(now=LONG_AGO, **_A)
_v_after = _after.arbitrage(now=LONG_AGO, **_A)
check("both plans still ALLOW a trade, so this section compares two verdicts "
"rather than a verdict and a refusal",
_v_before.kind == "allowed" and _v_after.kind == "allowed")
@@ -843,7 +848,7 @@ check("...and no two of them share a whole hour by accident: the fixture puts "
_q_spread_want = round(max(p.eur_per_kwh for p in _q_win)
- min(p.eur_per_kwh for p in _q_win), 6)
_qv = _q_after.arbitrage(**_A)
_qv = _q_after.arbitrage(now=LONG_AGO, **_A)
_q_spread_got = _qv.match(allowed=lambda v: v.spread_eur_kwh,
refused=lambda _v: None)
check(f"...and the spread is the fixture's own max - min over that window "
+39 -10
View File
@@ -596,30 +596,59 @@ check("selling is off: no sale on any cycle",
# ============================================================================ #
print("\n5. modes off: byte-identical to release/1.0 e3e4ba6")
# Printed by tools/opt11p3_identity.py on release/1.0 e3e4ba6 (2026-10-13),
# goodwe-addon-checks:1, with this branch's harness (same API). n1 and n2 are
# also 5a4cdc2's; the lag ones moved with SAFETY-90/91 (n2_lag now equals n2:
# the next-quarter grading removed the edge tick's blindness at N=2).
AT_BASE = {
# Printed on release/1.0 e3e4ba6 (2026-10-13), goodwe-addon-checks:1, with this
# branch's harness (same API). n1 and n2 are also 5a4cdc2's; the lag ones moved
# with SAFETY-90/91 (n2_lag now equals n2: the next-quarter grading removed the
# edge tick's blindness at N=2).
#
# PRICE-02 (2026-10-22) changed two things in the trade sentence and nothing
# else (see test_modes_p3.py part 1): the "N above the cheapest" figure of a
# sale and "hour" -> "price period". Three pins, as there:
# AT_BASE_OLD the digests printed on e3e4ba6, sentence included (no longer
# matched: the mask is needed);
# AT_BASE the same rows through `ident.trade_digest()`, equal to what
# e3e4ba6 gives through it;
# AT_NOW this tree's own digests with nothing hidden.
AT_BASE_OLD = {
"n1": "71245b227ca40bada4f71f57ea6154475f4b0bd60239c9ad4f80bffb0094514c",
"n2": "56cf28010b06765c29e5479abb815d7503d570b39240d82dfc818efa90c93722",
"n1_lag": "86ef0f5ad8da1498ff7dcf59262a177bef847a0b201ce804e947c8eab5f07190",
"n2_lag": "56cf28010b06765c29e5479abb815d7503d570b39240d82dfc818efa90c93722",
}
_got = {k: ident.digest(ident.run(**ident.SCENARIOS[k])[0])
for k in AT_BASE}
AT_BASE = {
"n1": "33886ef03f364852883f0370230e6e04823fbb46a4704f88ca75bf9082d98c01",
"n2": "666a31f41491c22750720524853d95944d5fba395d1d14040c460d71414b275f",
"n1_lag": "f6c67b351bd732fb69e62cafbfdb2bcff6032f5d8845836659d096b8c677d3c0",
"n2_lag": "666a31f41491c22750720524853d95944d5fba395d1d14040c460d71414b275f",
}
AT_NOW = {
"n1": "939b3d47bbd64fa50e3d70538f092fb98e5c4bd5ead85a7fc696a1dbb514039e",
"n2": "c51008213dd739e1e5142c236d9a950aa585992393e4af120086f1ba52ff3e0d",
"n1_lag": "78aac892975be81a7ceac0c000ed441a731bd4f42ff9f729996cbee53d6b7f02",
"n2_lag": "c51008213dd739e1e5142c236d9a950aa585992393e4af120086f1ba52ff3e0d",
}
_cmd_digest = ident.trade_digest
_rows5 = {k: ident.run(**ident.SCENARIOS[k])[0] for k in AT_BASE}
_got = {k: _cmd_digest(v) for k, v in _rows5.items()}
_full5 = {k: ident.digest(v) for k, v in _rows5.items()}
for _k in AT_BASE:
print(f" {_k} {'same' if _got[_k] == AT_BASE[_k] else 'DIFFERS'} "
f"{_got[_k][:16]}")
check("with battery modes off every command, integrator, allocation and "
"trade sentence equals e3e4ba6's, also when every quarter boundary's "
"cycle still sees the old P1 block", _got == AT_BASE)
"trade action equals e3e4ba6's (the sentence masked as above), also when "
"every quarter boundary's cycle still sees the old P1 block",
_got == AT_BASE)
check("...the mask is needed and nothing more: unmasked, the digests differ "
"from e3e4ba6's printed ones, and equal this tree's own pins, so a "
"later change to any sentence or figure fails here",
all(_full5[k] != AT_BASE_OLD[k] for k in AT_BASE_OLD)
and _full5 == AT_NOW)
_real_project = tariff.PeakTracker.project
tariff.PeakTracker.project = staticmethod(
lambda a, now, roll=False: _real_project(a, now, roll=True))
try:
_moved = ident.digest(ident.run(**ident.SCENARIOS["n1_lag"])[0]) \
_moved = _cmd_digest(ident.run(**ident.SCENARIOS["n1_lag"])[0]) \
!= AT_BASE["n1_lag"]
finally:
tariff.PeakTracker.project = _real_project
+1 -1
View File
@@ -1178,7 +1178,7 @@ async def _modes_plan():
c = W.ctl
plan = c.fresh_tariff_plan(t)
verdict = plan.arbitrage(
peak_guard_active=False, min_spread_eur_kwh=0.05,
now=t, peak_guard_active=False, min_spread_eur_kwh=0.05,
uncertainty=frozenset())
return verdict.match(allowed=lambda v: ("allowed", v.spread_eur_kwh),
refused=lambda v: ("refused", v.reason))
+29 -24
View File
@@ -82,6 +82,11 @@ from app.validate import validate_startup_config
HERE = Path(__file__).resolve().parent
UTC = timezone.utc
# PRICE-02: `TariffPlan.arbitrage(now=...)` is required. This instant is before every
# point these fixtures build, so no period has ended and the whole series counts.
from datetime import datetime as _DT, timezone as _TZ # noqa: E402
LONG_AGO = _DT(2000, 1, 1, tzinfo=_TZ.utc)
fails = []
@@ -333,8 +338,8 @@ check(f"...while the two-period spread is the day/night difference "
f"{_spread_two}, re-derived here as {DAY_P - NIGHT_P:.4f}",
near(_spread_two, DAY_P - NIGHT_P, 1e-9))
_v_fixed = p_fixed.arbitrage(peak_guard_active=False, min_spread_eur_kwh=0.0, uncertainty=frozenset())
_v_two = p_two.arbitrage(peak_guard_active=False, min_spread_eur_kwh=0.0, uncertainty=frozenset())
_v_fixed = p_fixed.arbitrage(now=LONG_AGO, peak_guard_active=False, min_spread_eur_kwh=0.0, uncertainty=frozenset())
_v_two = p_two.arbitrage(now=LONG_AGO, peak_guard_active=False, min_spread_eur_kwh=0.0, uncertainty=frozenset())
check("a fully-fixed tariff REFUSES arbitrage", _v_fixed.kind == "refused")
check("...for the true reason - the series is flat, so there is nothing to buy "
"low and sell high, not because a FIXED enum was matched on",
@@ -354,7 +359,7 @@ _flat_dayahead = TariffPlan(
None, "provider")
check("a DAY_AHEAD series that happens to be flat is refused too, so the rule "
"is arithmetic and not a product label",
_flat_dayahead.arbitrage(peak_guard_active=False, min_spread_eur_kwh=0.0, uncertainty=frozenset()).kind == "refused")
_flat_dayahead.arbitrage(now=LONG_AGO, peak_guard_active=False, min_spread_eur_kwh=0.0, uncertainty=frozenset()).kind == "refused")
# The numbers an optimiser needs exist ONLY on the permitting variant.
check("the allowed verdict carries the spread, the cheapest hour and the "
@@ -379,16 +384,16 @@ check("...and there is no falsy variant, so `if verdict:` cannot look like a "
# §10.1 priority 2 over priority 3, expressed so OPT-04 cannot skip it.
check("peak shaving outranks arbitrage: the SAME allowed plan is refused when "
"the peak guard is holding",
p_two.arbitrage(peak_guard_active=True, min_spread_eur_kwh=0.0, uncertainty=frozenset()).kind == "refused")
p_two.arbitrage(now=LONG_AGO, peak_guard_active=True, min_spread_eur_kwh=0.0, uncertainty=frozenset()).kind == "refused")
check("...and the reason names the ordering rather than the tariff",
"priority 2" in p_two.arbitrage(peak_guard_active=True, min_spread_eur_kwh=0.0, uncertainty=frozenset()).reason)
"priority 2" in p_two.arbitrage(now=LONG_AGO, peak_guard_active=True, min_spread_eur_kwh=0.0, uncertainty=frozenset()).reason)
check("...and the question cannot be skipped: peak_guard_active is required "
"and keyword-only, so a spread is not obtainable without answering it",
raises(lambda: p_two.arbitrage(), TypeError)
and raises(lambda: p_two.arbitrage(False), TypeError))
check("...nor answered with a truthy 'unknown'",
raises(lambda: p_two.arbitrage(peak_guard_active=None), TypeError)
and raises(lambda: p_two.arbitrage(peak_guard_active="no"), TypeError))
raises(lambda: p_two.arbitrage(now=LONG_AGO, peak_guard_active=None), TypeError)
and raises(lambda: p_two.arbitrage(now=LONG_AGO, peak_guard_active="no"), TypeError))
# --------------------------------------------------------------------------- #
@@ -524,7 +529,7 @@ check("...and its underlying read has no .points at all, so `min(read.points)` "
"cannot run against it",
raises(lambda: _none.read.points, Absence))
check("...and arbitrage is refused, per §11.4",
_none.arbitrage(peak_guard_active=False, min_spread_eur_kwh=0.0, uncertainty=frozenset()).kind == "refused"
_none.arbitrage(now=LONG_AGO, peak_guard_active=False, min_spread_eur_kwh=0.0, uncertainty=frozenset()).kind == "refused"
and arbitrage_allowed(_none.read) is False)
check("...and static_status() says so in words rather than reporting a price",
static_status(opts(None), THU) == (False, "No fixed tariff set"))
@@ -776,12 +781,12 @@ check("...marked PROVISIONAL, which is the one flag that differs",
_fb.provisional and plan_is_provisional(_fb.read))
check("...and arbitrage is refused on it, per §11.4, even though a full series "
"exists and has a real spread",
_fb.arbitrage(peak_guard_active=False, min_spread_eur_kwh=0.0, uncertainty=frozenset()).kind == "refused"
_fb.arbitrage(now=LONG_AGO, peak_guard_active=False, min_spread_eur_kwh=0.0, uncertainty=frozenset()).kind == "refused"
and near(_fb.spread_eur_kwh(), DAY_P - NIGHT_P))
check("...while the SAME profile as the configured product allows it - so the "
"refusal is about provisionality and not about the profile",
static_plan(opts(TWO_PERIOD), THU, THU + timedelta(days=1)
).arbitrage(peak_guard_active=False, min_spread_eur_kwh=0.0, uncertainty=frozenset()).kind == "allowed")
).arbitrage(now=LONG_AGO, peak_guard_active=False, min_spread_eur_kwh=0.0, uncertainty=frozenset()).kind == "allowed")
check("...and the provisional plan has no cheap window either: §8.3 forbids an "
"irreversible action on a provisional price, and a grid top-up is one",
not any(_fb.cheap_now(at_local(THU, h)) for h in range(24)))
@@ -821,8 +826,8 @@ check("a live feed is used in preference to the static profile",
and _live_plan.product is PriceProduct.DAY_AHEAD)
check("...and arbitrage is allowed on it, with the spread derived from the "
"feed rather than from the profile",
_live_plan.arbitrage(peak_guard_active=False, min_spread_eur_kwh=0.0, uncertainty=frozenset()).kind == "allowed"
and near(_live_plan.arbitrage(peak_guard_active=False, min_spread_eur_kwh=0.0, uncertainty=frozenset()).spread_eur_kwh,
_live_plan.arbitrage(now=LONG_AGO, peak_guard_active=False, min_spread_eur_kwh=0.0, uncertainty=frozenset()).kind == "allowed"
and near(_live_plan.arbitrage(now=LONG_AGO, peak_guard_active=False, min_spread_eur_kwh=0.0, uncertainty=frozenset()).spread_eur_kwh,
0.023, 1e-9))
@@ -1036,7 +1041,7 @@ check(f"each tiny-spread probe really carries the spread it is named for, and "
all(near(p.spread_eur_kwh(), d, 1e-12)
and len([q for q in p.read.points if not q.provisional]) == 2
for d, p in _tiny9))
_tinyv9 = [(d, rung9(p.arbitrage(**_A9))) for d, p in _tiny9]
_tinyv9 = [(d, rung9(p.arbitrage(now=LONG_AGO, **_A9))) for d, p in _tiny9]
check(f"TEST-16 AC1: a spread that is SMALL BUT REAL is ALLOWED at "
f"min_spread_eur_kwh=0.0 - down to 0.000001 EUR/kWh, so the flat rung is "
f"`spread == 0.0` and not `spread <= epsilon` for any epsilon "
@@ -1045,7 +1050,7 @@ check(f"TEST-16 AC1: a spread that is SMALL BUT REAL is ALLOWED at "
check("...and the exactly flat series on the OTHER side of that boundary is "
"still refused, and refused BY THE FLAT RUNG - without this the sweep "
"above would also pass for a rung that had been deleted outright",
rung9(plan9([0.20000, 0.20000]).arbitrage(**_A9)) == "flat")
rung9(plan9([0.20000, 0.20000]).arbitrage(now=LONG_AGO, **_A9)) == "flat")
_q9 = plan9([0.20000, 0.2000005])
check(f"...and a difference below the rung's own 6-decimal rounding is flat BY "
f"CONSTRUCTION and says so: the raw difference is "
@@ -1056,10 +1061,10 @@ check(f"...and a difference below the rung's own 6-decimal rounding is flat BY "
f"to bisect further",
not near(_q9.spread_eur_kwh(), 0.0, 1e-9)
and near(round(_q9.spread_eur_kwh(), 6), 0.0, 1e-12)
and rung9(_q9.arbitrage(**_A9)) == "flat")
and rung9(_q9.arbitrage(now=LONG_AGO, **_A9)) == "flat")
# --- and what D's survival actually costs: the sentence, not the verdict -----
_lie9 = plan9([0.20000, 0.20050]).arbitrage(**_A9)
_lie9 = plan9([0.20000, 0.20050]).arbitrage(now=LONG_AGO, **_A9)
check(f"TEST-16 AC4: the 0.0005 verdict carries the real spread rather than a "
f"flatness claim about a tariff that is not flat - the false sentence is "
f"what a loosened rung ships to the operator while the outcome at a "
@@ -1085,19 +1090,19 @@ check(f"the one-firm probe is well formed: exactly ONE firm hour out of three, "
check("TEST-16 AC2: one firm hour is refused BY §8.3's FIRM-PAIR RUNG, named "
"in the reason - not by the flat rung that would also have refused it. "
"This is what takes `len(firm) < 2` -> `< 1` red on its own",
rung9(_one9.arbitrage(**_A9)) == "firm-pair")
rung9(_one9.arbitrage(now=LONG_AGO, **_A9)) == "firm-pair")
check("...and the sentence is §8.3's in full, so the operator is told the "
"series is half-published rather than told it is fixed-price",
"§8.3" in _one9.arbitrage(**_A9).reason
and "only 1 of 3" in _one9.arbitrage(**_A9).reason
and "flat" not in _one9.arbitrage(**_A9).reason)
"§8.3" in _one9.arbitrage(now=LONG_AGO, **_A9).reason
and "only 1 of 3" in _one9.arbitrage(now=LONG_AGO, **_A9).reason
and "flat" not in _one9.arbitrage(now=LONG_AGO, **_A9).reason)
# The rung pinned from ABOVE as well. Without this, `len(firm) < 3` - the same
# +0 B edit in the other direction - would pass everything above.
_two9 = plan9([0.20, 0.40, 0.10], [False, False, True])
check("...and TWO firm hours are ALLOWED on the identical series shape, so the "
"rung is pinned from above too and `< 3` is red as well as `< 1`",
rung9(_two9.arbitrage(**_A9)) == "allowed"
and near(_two9.arbitrage(**_A9).spread_eur_kwh, 0.20))
rung9(_two9.arbitrage(now=LONG_AGO, **_A9)) == "allowed"
and near(_two9.arbitrage(now=LONG_AGO, **_A9).spread_eur_kwh, 0.20))
# ...and from below, where the failure is not a wrong sentence but a crash:
# `max()` over an empty `firm` raises ValueError, so `< 0` does not degrade the
# reason, it takes the verdict away entirely.
@@ -1105,7 +1110,7 @@ _zero9 = plan9([0.20, 0.40, 0.10], [True, True, True])
check("...and a FULLY provisional series is refused by the same rung and does "
"not raise - `max()` over an empty firm set is a ValueError, so this row "
"is what makes `len(firm) < 0` red",
rung9(derived(lambda: _zero9.arbitrage(**_A9))) == "firm-pair")
rung9(derived(lambda: _zero9.arbitrage(now=LONG_AGO, **_A9))) == "firm-pair")
# --- AC3: the two rungs do not mask each other ------------------------------
# ⚠️ THE POINT OF THE TICKET, AND IT IS A PROPERTY OF THE TABLE, NOT OF A ROW.
@@ -1130,7 +1135,7 @@ _TABLE9 = (
("two firm hours of three", _two9, "allowed"),
("no firm hours at all", _zero9, "firm-pair"),
)
_got9 = [(name, want, rung9(derived(lambda p=pl: p.arbitrage(**_A9))))
_got9 = [(name, want, rung9(derived(lambda p=pl: p.arbitrage(now=LONG_AGO, **_A9))))
for name, pl, want in _TABLE9]
check(f"TEST-16 AC3: each stimulus is answered by the rung that OWNS it, and "
f"by no other - the flat rung is asked a question the firm-pair rung "
+374
View File
@@ -0,0 +1,374 @@
"""Runnable check for TARIFF-24. `python3 test_tariff24.py`
Battery modes on, a day/night `static_profile`, no price feed and the site trade
switch off used to start no tariff task: `Controller.tariff_plan` stayed None,
`mode_runs` stayed None, Leftover solar and Cheap hours never charged from the
grid, and the Batteries screen said "No prices" while Home priced the day from
the profile itself. DECISIONS/TARIFF-24-modes-need-the-tariff-task.md.
1. The startup gate: `tariff_refresh_wanted()` by configuration, and the guard
`amain()` really puts on the task (read from its source, not retyped).
2. End to end, on the real Controller and the real `run_tariff_refresh()` task,
started only if `amain()`'s own guard says so: night period, one battery
Leftover solar and one Cheap hours.
3. The same with a gap below the cycle cost.
4. Home and the Batteries screen agree.
5. UX-55: with every battery Solar first Home no longer says "no prices have
arrived" on a static profile, and still does with no profile and no feed.
6. Nothing else moved: modes off starts no task; the gate rows in part 1 keep
the arbitrage and feed cases as before.
Mutations (MUTATION.md, purged copies, graded over this file): the gate
reverted to `provider or arbitrage` fails 18 checks in parts 1 to 5; `True`
fails the modes-off rows; `bool(opts.get(...))` fails the exact-true row;
dropping the arbitrage term fails the arbitrage rows.
"""
import ast
import asyncio
import logging
import os
import sys
import tempfile
import time
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.join(HERE, "tools"))
logging.disable(logging.WARNING)
import opt11p3_replay as rp # noqa: E402
from named_absence import first, nth, present # noqa: E402
import app.main as m # noqa: E402
from app import homeview, modetrade, packmode # noqa: E402
from app import price as _price, static_tariff as _st # noqa: E402
from app.main import Controller, tariff_refresh_wanted # noqa: E402
from app.p1 import make_sample, split_signed # noqa: E402
from app.store import unit_key # noqa: E402
fails = []
def check(name, cond):
print(f" {'ok ' if cond else 'FAIL'} {name}")
if not cond:
fails.append(name)
A, B = "inverter-a", "inverter-b"
# A Belgian-style day/night tariff, example values: dear 07:00-22:00, cheap
# 22:00-07:00, every day. The gap is 0.08 EUR/kWh.
PROFILE = ('{"periods": ['
'{"name": "night", "days": "all", "from": "22:00", "to": "07:00",'
' "eur_per_kwh": 0.22},'
'{"name": "day", "days": "all", "from": "07:00", "to": "22:00",'
' "eur_per_kwh": 0.30}]}')
# A Friday in winter time (+01:00), 23:00 local: inside the night period.
L = timezone(timedelta(hours=1))
NIGHT = datetime(2026, 10, 30, 23, 0, tzinfo=L).astimezone(timezone.utc)
def opts(*, modes=True, arb=False, profile=PROFILE, provider=None,
cost=0.05):
o = dict(rp.options(modes, arb, 2))
o["cycle_cost_threshold_eur"] = cost
if profile is not None:
o["static_profile"] = profile
if provider is not None:
o["price_provider"] = provider
return o
# --------------------------------------------------------------------------- #
# The guard amain() puts on the tariff task, read from amain()'s source.
# --------------------------------------------------------------------------- #
def _guards_of(node, guards=()):
"""[(guards, coroutine name)] for every create_task(...) under `node`."""
out = []
for child in ast.iter_child_nodes(node):
if isinstance(child, ast.If):
test = ast.unparse(child.test)
for stmt in child.body:
out += _guards_of(stmt, guards + (test,))
for stmt in child.orelse:
out += _guards_of(stmt, guards + (f"not ({test})",))
continue
if isinstance(child, ast.Call) and \
ast.unparse(child.func).endswith("create_task") and child.args:
arg = child.args[0]
name = (arg.func.attr if isinstance(arg, ast.Call)
and isinstance(arg.func, ast.Attribute)
else ast.unparse(arg))
out.append((guards, name))
continue
out += _guards_of(child, guards)
return out
_TREE = ast.parse(open(os.path.join(HERE, "app", "main.py"),
encoding="utf-8").read())
_AMAIN = first((n for n in ast.walk(_TREE)
if isinstance(n, ast.AsyncFunctionDef) and n.name == "amain"),
"amain() in main.py")
_TASK_GUARDS = [g for g, n in _guards_of(_AMAIN) if n == "run_tariff_refresh"]
def task_started(o) -> bool:
"""Would amain() start the tariff refresh task for options `o`?"""
if len(_TASK_GUARDS) != 1:
return False
return all(bool(eval(g, vars(m), {"opts": o})) for g in _TASK_GUARDS[0])
# --------------------------------------------------------------------------- #
print("1. the startup gate")
check("amain() starts the tariff refresh from exactly one guard, and that "
"guard can be decided from the options alone",
len(_TASK_GUARDS) == 1 and len(_TASK_GUARDS[0]) >= 1
and all(isinstance(eval(g, vars(m), {"opts": opts()}), bool)
for g in _TASK_GUARDS[0]))
GATE = [
# label, options, task wanted
("modes on + static day/night only", opts(), True),
("modes on + a fixed price only",
opts(profile='{"periods": [{"name": "all", "days": "all", "from": '
'"00:00", "to": "24:00", "eur_per_kwh": 0.27}]}'), True),
("modes on, nothing else", opts(profile=None), True),
("modes off + arbitrage off + no feed (static profile)",
opts(modes=False), False),
("modes off + arbitrage off + no feed, no profile",
opts(modes=False, profile=None), False),
("modes off + arbitrage on", opts(modes=False, arb=True), True),
("modes off + a feed", opts(modes=False, provider="generic_dayahead"),
True),
("modes on + a feed", opts(provider="generic_dayahead"), True),
("modes on + arbitrage on", opts(arb=True), True),
]
for label, o, want in GATE:
got = tariff_refresh_wanted(o)
print(f" wanted={got!s:5} started={task_started(o)!s:5} {label}")
check(f"{label}: tariff_refresh_wanted() is {want}", got is want)
check(f"{label}: amain() starts the task exactly as that says",
task_started(o) is want)
check("battery_modes_enabled has to be exactly true (packmode.enabled()): "
"'true' as text or 1 does not start the task",
not tariff_refresh_wanted(dict(opts(), battery_modes_enabled="true"))
and not tariff_refresh_wanted(dict(opts(), battery_modes_enabled=1)))
# --------------------------------------------------------------------------- #
# The real Controller over a few minutes of a night: the task starts only if
# amain()'s own guard says so; then read_inputs() + cycle() as the control
# loop runs them.
# --------------------------------------------------------------------------- #
CHOICE = {"a": (True, "leftover_solar", False),
"b": (True, "cheap_hours", False)}
FETCHES = []
async def _no_fetch(*a, **k):
FETCHES.append(a)
raise AssertionError("the tariff task reached for a price feed")
def drive(o, *, start=NIGHT, minutes=20, soc0=None, choice=CHOICE,
house_w=300.0, tick=5.0):
"""-> (controller, rows, task_was_started)."""
store = rp._Store()
for u, (use, mode, sell) in choice.items():
store.data[unit_key(f"inverter-{u}", "pack_use")] = use
store.data[unit_key(f"inverter-{u}", "pack_mode")] = mode
store.data[unit_key(f"inverter-{u}", "pack_sell")] = sell
rows = []
async def go():
hass = rp.Hass(("a", "b"), lambda t: house_w,
soc0 or {"a": 40.0, "b": 40.0})
tmp = tempfile.mkdtemp(prefix="t24-")
c = Controller(dict(o), hass, store, rp._Pub(),
stop=asyncio.Event(),
peak_db_path=os.path.join(tmp, "tariff.db"),
price_history_path=os.path.join(tmp, "ph.json"))
for i in c.inverters:
i.dev_min, i.dev_max = -5000.0, 5000.0
c.auto = True
real_dt, real_hv = m.datetime, homeview.datetime
clock = [start]
class _DT(real_dt):
@classmethod
def now(cls, tz=None):
return clock[0] if tz is None else clock[0].astimezone(tz)
m.datetime = homeview.datetime = _DT
_real_load = _price.load_prices, _st.load_prices
_price.load_prices = _st.load_prices = _no_fetch
started = task_started(o)
task = (asyncio.create_task(c.run_tariff_refresh())
if started else None)
try:
t = 0.0
while t < minutes * 60:
now = start + timedelta(seconds=t)
hass.t = t
clock[0] = now
if t == 0.0 and started:
for _ in range(500): # let the task do its first pass
if c.tariff_plan is not None:
break
await asyncio.sleep(0)
imp, exp = split_signed(hass.grid())
c.p1.submit(make_sample("ha_signed", imp, exp, phases=1,
ingest_ts=now,
ingest_mono=time.monotonic()))
await c.read_inputs(now_mono=t)
await c.cycle()
hass.advance(tick, 10.0)
rows.append(dict(
now=now, runs=c.mode_runs, tick=c.mode_tick,
plan=c.fresh_tariff_plan(now),
action=getattr(c.arb, "action", None),
p={u: p.p for u, p in hass.plants.items()},
soc={u: p.soc for u, p in hass.plants.items()}))
t += tick
# What the two screens say at the end, while the clock is still
# the simulated one.
c.home_plan = homeview.build_plan(c, clock[0], False, True)
c.battery_cards = homeview.battery_rows(c, False, True)
finally:
if task is not None:
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
m.datetime, homeview.datetime = real_dt, real_hv
_price.load_prices, _st.load_prices = _real_load
return c, started
c, started = asyncio.run(go())
return c, rows, started
def charging_b(rows):
"""Cycles in which the Cheap hours battery was charging (grid: no sun and
a 300 W house in this scenario)."""
return [r for r in rows if r["p"]["b"] < -200.0]
def why_of(c, name):
card = first((b for b in c.battery_cards if b["name"] == name),
f"a card for {name}")
return card["why_key"]
# --------------------------------------------------------------------------- #
print("\n2. end to end, night, modes on, static day/night, no feed, no arbitrage")
c1, r1, started1 = drive(opts())
_last = nth(r1, -1, "the last cycle")
_first1 = nth(r1, 0, "the first cycle")
print(f" tariff task started by amain's guard: {started1}")
print(f" tariff_plan: {None if c1.tariff_plan is None else c1.tariff_plan.words}")
print(f" mode_runs: {_last['runs']}")
print(f" mode_tick reason / roles: "
f"{None if _last['tick'] is None else (_last['tick'].why, dict(_last['tick'].roles))}")
print(f" Batteries screen why_key: A {why_of(c1, A)}, B {why_of(c1, B)}")
print(f" cycles with B charging: {len(charging_b(r1))} of {len(r1)}, "
f"B soc {_first1['soc']['b']:.1f} -> {_last['soc']['b']:.1f}")
check("the tariff refresh task is started", started1)
check("...and it adds no network call: with price_provider off the feed "
"loader is never reached (it is replaced by one that fails)",
not FETCHES and not _price.provider_configured(opts()))
check("a fresh tariff plan with prices is held", present(_last)
and _last["plan"] is not None and _last["plan"].has_prices)
check("the price runs exist (mode_runs is not None) and carry a cheap run "
"that holds in the night period",
present(_last) and _last["runs"] is not None and _last["runs"].on
and _last["runs"].cheap is not None
and _last["runs"].cheap.holds(_last["now"]))
check("the Cheap hours battery charges from the grid in the night period",
len(charging_b(r1)) > len(r1) // 2
and present(_last, _first1)
and _last["soc"]["b"] > _first1["soc"]["b"] + 0.3)
check("...and the Leftover solar battery does not (no sun, no forecast)",
not any(r["p"]["a"] < -200.0 for r in r1))
check("...the reason is not 'no prices': neither battery reads no_prices",
why_of(c1, A) != "no_prices" and why_of(c1, B) != "no_prices")
check("...only the Cheap hours battery got the grid charge role",
present(_last) and _last["tick"] is not None
and _last["tick"].roles.get(B) == modetrade.CHARGE
and _last["tick"].roles.get(A) != modetrade.CHARGE)
# --------------------------------------------------------------------------- #
print("\n3. the same with a gap below the cycle cost")
c3, r3, started3 = drive(opts(cost=0.20))
_l3 = nth(r3, -1, "the last cycle")
print(f" started {started3}; mode_runs {_l3['runs']}; "
f"why A {why_of(c3, A)}, B {why_of(c3, B)}")
check("the task is started and the plan is there, so mode_runs is not None",
started3 and present(_l3) and _l3["plan"] is not None
and _l3["runs"] is not None)
check("the reason is the gap: no_spread ('Prices differ too little'), "
"never no_prices, on both batteries",
why_of(c3, A) == "no_spread" and why_of(c3, B) == "no_spread"
and present(_l3) and _l3["runs"] is not None
and not _l3["runs"].on)
check("...and no battery charges from the grid",
not charging_b(r3) and not any(r["p"]["a"] < -200.0 for r in r3))
# --------------------------------------------------------------------------- #
print("\n4. Home and the Batteries screen agree")
hp = c1.home_plan
_bars = [b for b in hp["prices"] if b["eur_kwh"] is not None]
_words = {b["why_key"]: b["why"] for b in c1.battery_cards}
print(f" Home: has_prices {hp['has_prices']}, {len(_bars)} priced bars, "
f"verdict {hp['verdict_key']}, lines {hp['lines']}")
print(f" Batteries: {[(b['name'], b['why_key'], b['why']) for b in c1.battery_cards]}")
check("Home prices the day, and the Batteries screen does not say it has no "
"prices", hp["has_prices"] and _bars
and all(b["why_key"] != "no_prices" for b in c1.battery_cards))
check("Home's plan has the grid charge block for the Cheap hours battery "
"(the 'trade' verdict) and no 'no prices have arrived' line",
hp["verdict_key"] == "trade"
and any(r["kind"] == "grid" for r in hp["runs"])
and not any("no prices have arrived" in ln for ln in hp["lines"]))
hp3 = c3.home_plan
check("with the gap too small both say so: Home 'price gap is too small', "
"the batteries 'differ too little'; neither says no prices",
any("price gap is too small" in ln for ln in hp3["lines"])
and all(b["why_key"] == "no_spread" for b in c3.battery_cards))
# --------------------------------------------------------------------------- #
print("\n5. UX-55: every battery Solar first, static day/night, no feed")
SF = {"a": (True, "solar_first", False), "b": (True, "solar_first", False)}
c5, r5, s5 = drive(opts(), choice=SF, minutes=5)
print(f" Home lines: {c5.home_plan['lines']}")
check("Home no longer says 'no prices have arrived' with every battery Solar "
"first on a static profile",
s5 and not any("no prices have arrived" in ln
for ln in c5.home_plan["lines"]))
c5b, r5b, s5b = drive(opts(profile=None), choice=SF, minutes=5)
print(f" no profile, no feed: started {s5b}, Home lines "
f"{c5b.home_plan['lines']}")
check("...and with no profile and no feed there are truly no prices, and "
"Home still says so",
any("no prices have arrived" in ln for ln in c5b.home_plan["lines"]))
# --------------------------------------------------------------------------- #
print("\n6. nothing else moved")
c6, r6, s6 = drive(opts(modes=False), choice={}, minutes=5)
check("modes off, arbitrage off, no feed: no task, no plan, no price runs "
"(as before)", not s6 and c6.tariff_plan is None
and all(r["runs"] is None for r in r6))
print()
if fails:
print(f"{len(fails)} FAILED:")
for f in fails:
print(f" - {f}")
sys.exit(1)
print("all checks passed")
+439
View File
@@ -0,0 +1,439 @@
"""Runnable check for TARIFF-24 round 2 (AUDIT-02 F1). `python3 test_tariff24_weather.py`
With battery modes on, the price gap a mode needs was always widened x1.5
(`Uncertainty.NO_WEATHER`) even with a solar forecast read. Now the doubt is
added only when no usable forecast is read (`arbitrage.weather_uncertainty()`),
in the run plan (`_price_runs()`), the charge gate (`_mode_roles()`) and Home's
mirror (`homeview.trade_verdict()`). Modes off is untouched.
DECISIONS/TARIFF-24-modes-need-the-tariff-task.md.
1. The rule itself, and that modes off keeps the doubt.
2. Day/night 0.34/0.26 (gap 0.08), modes on, real Controller, night period:
with a forecast at cycle cost 0.07 the cheap run is planned and the Cheap
hours battery charges; without one, or with a stale one, the same cost needs
0.105 and the reason is `not_worth` / "Prices differ too little".
3. A dynamic day with a 0.12 gap at cost 0.10: on with a forecast, not worth
without.
4. The forecast coming (the gate follows in the same cycle) and going mid-run:
a run under way keeps its gap to its end, the next run needs the wider one;
a forecast unavailable for one or two reads mid-run does not stop or
restart the charge.
5. Home agrees with control in each case (Batteries Why, Home line, and
`trade_verdict()`, Home's mirror of the gate).
Mutations (MUTATION.md, purged copies, graded over this file): the doubt always
added (the old code) fails parts 2 to 5; never added fails the without-forecast
rows; Home's mirror left on the old rule fails part 5.
"""
import asyncio
import logging
import math
import os
import sys
import tempfile
import time
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.join(HERE, "tools"))
logging.disable(logging.WARNING)
import opt11p3_replay as rp # noqa: E402
from named_absence import first, nth, present # noqa: E402
import app.main as m # noqa: E402
from app import arbitrage, homeview, modetrade # noqa: E402
from app.main import Controller # noqa: E402
from app.p1 import make_sample, split_signed # noqa: E402
from app.price import (PricePoint, PriceProduct, PriceRead, # noqa: E402
Uncertainty, brussels)
from app.static_tariff import TariffPlan # noqa: E402
from app.store import unit_key # noqa: E402
fails = []
def check(name, cond):
print(f" {'ok ' if cond else 'FAIL'} {name}")
if not cond:
fails.append(name)
A, B = "inverter-a", "inverter-b"
PROFILE = ('{"periods": ['
'{"name": "night", "days": "all", "from": "22:00", "to": "07:00",'
' "eur_per_kwh": 0.26},'
'{"name": "day", "days": "all", "from": "07:00", "to": "22:00",'
' "eur_per_kwh": 0.34}]}')
L = timezone(timedelta(hours=1))
NIGHT = datetime(2026, 10, 30, 23, 0, tzinfo=L).astimezone(timezone.utc)
CHOICE = {"a": (True, "leftover_solar", False),
"b": (True, "cheap_hours", False)}
FC_TODAY, FC_TOMORROW = "sensor.fc_today", "sensor.fc_tomorrow"
TARIFF_REFRESH_S = m.TARIFF_REFRESH_S
def opts(cost, *, arb=False, sensors=True):
o = dict(rp.options(True, arb, 2))
o.update(cycle_cost_threshold_eur=cost, static_profile=PROFILE)
if sensors:
o.update(pv_forecast_entity=FC_TODAY,
pv_forecast_tomorrow_entity=FC_TOMORROW)
return o
def dynamic_points(start):
"""A dynamic day-ahead shape, 0.16 .. 0.28 (gap 0.12), 15-minute points."""
out, t = [], start.replace(minute=0, second=0, microsecond=0)
for _ in range(48 * 4):
h = brussels(t).hour + brussels(t).minute / 60
p = 0.22 + 0.06 * math.cos((h - 19) / 24 * 2 * math.pi)
out.append(PricePoint(start=brussels(t), eur_per_kwh=round(p, 4),
provisional=False))
t += timedelta(minutes=15)
return out
def sensor(kwh, at):
return {"state": f"{kwh}", "attributes": {"unit_of_measurement": "kWh"},
"last_updated": at.isoformat(), "last_reported": at.isoformat()}
def forecast_mode(mode, t, now):
"""What the two forecast sensors read: 'fresh', 'stale', 'gone', or a
function of the simulated seconds that returns one of those."""
if callable(mode):
mode = mode(t)
if mode == "gone":
return {"state": "unavailable", "attributes": {}}
at = now if mode == "fresh" else now - timedelta(hours=5)
return at
def take(c, now):
"""What Home and the Batteries screen say now, and Home's mirror of the
gate (it reads the trade switch, so ask it as a site that has it on next
to the modes)."""
keep = c.o
c.o = dict(keep, arbitrage_enabled=True)
try:
mirror, why = homeview.trade_verdict(c, c.fresh_tariff_plan(now), now)
finally:
c.o = keep
return dict(home=homeview.build_plan(c, now, False, True),
cards=homeview.battery_rows(c, False, True),
mirror=mirror, mirror_why=why)
def drive(o, *, fc="fresh", points=None, minutes=20, tick=5.0,
house_w=300.0, soc0=None, snap_at=()):
"""-> (controller, rows). `points`: a feed-shaped plan injected every
cycle (no task); else the static profile via the real tariff task."""
store = rp._Store()
for u, (use, mode, sell) in CHOICE.items():
for k, val in (("pack_use", use), ("pack_mode", mode),
("pack_sell", sell)):
store.data[unit_key(f"inverter-{u}", k)] = val
rows = []
snaps = {}
class H(rp.Hass):
async def state(self, e):
now = NIGHT + timedelta(seconds=self.t)
if fc is None:
return None
at = forecast_mode(fc, self.t, now)
if isinstance(at, dict):
return at
return sensor(0.0 if e == FC_TODAY else 12.0, at)
async def go():
hass = H(("a", "b"), lambda t: house_w, soc0 or {"a": 40.0, "b": 40.0})
tmp = tempfile.mkdtemp(prefix="t24w-")
c = Controller(dict(o), hass, store, rp._Pub(), stop=asyncio.Event(),
peak_db_path=os.path.join(tmp, "tariff.db"),
price_history_path=os.path.join(tmp, "ph.json"))
for i in c.inverters:
i.dev_min, i.dev_max = -5000.0, 5000.0
c.auto = True
real_dt, real_hv = m.datetime, homeview.datetime
clock = [NIGHT]
class _DT(real_dt):
@classmethod
def now(cls, tz=None):
return clock[0] if tz is None else clock[0].astimezone(tz)
m.datetime = homeview.datetime = _DT
pts = None if points is None else points
task = None
if pts is None:
task = asyncio.create_task(c.run_tariff_refresh())
try:
t = 0.0
while t < minutes * 60:
now = NIGHT + timedelta(seconds=t)
hass.t = t
clock[0] = now
if pts is not None:
c.tariff_plan = TariffPlan(
PriceRead.series(pts, PriceProduct.DAY_AHEAD,
"generic_dayahead", now), None,
"provider")
c.tariff_at = now
elif t == 0.0 or t % TARIFF_REFRESH_S == 0.0:
# The task's own 30 min wait is wall clock, so the
# simulated clock restarts it: one refresh per period.
if task is not None and t > 0.0:
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
task = asyncio.create_task(c.run_tariff_refresh())
for _ in range(500):
if c.tariff_plan is not None and (
c.tariff_at is not None and c.tariff_at >= now):
break
await asyncio.sleep(0)
imp, exp = split_signed(hass.grid())
c.p1.submit(make_sample("ha_signed", imp, exp, phases=1,
ingest_ts=now,
ingest_mono=time.monotonic()))
await c.read_inputs(now_mono=t)
await c.cycle()
hass.advance(tick, 10.0)
rp_ = c.mode_runs
rows.append(dict(
t=t, now=now, fc_none=c.solar_fc is None,
runs_on=None if rp_ is None else rp_.on,
reason=None if rp_ is None else rp_.reason,
roles=dict(c.mode_tick.roles) if c.mode_tick else {},
why=None if c.mode_tick is None else c.mode_tick.why,
charging=hass.plants["b"].p < -200.0,
soc_b=hass.plants["b"].soc,
cheap_end=None if rp_ is None or rp_.cheap is None
else rp_.cheap.end))
if t in snap_at:
snaps[t] = take(c, now)
t += tick
last = take(c, clock[0])
c.home_plan, c.cards = last["home"], last["cards"]
c.mirror, c.mirror_why = last["mirror"], last["mirror_why"]
c.snaps = snaps
finally:
if task is not None:
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
m.datetime, homeview.datetime = real_dt, real_hv
return c
return asyncio.run(go()), rows
def why_of(c, name):
card = first((b for b in c.cards if b["name"] == name), f"card {name}")
return card["why_key"]
# --------------------------------------------------------------------------- #
print("1. the rule")
NW = frozenset({Uncertainty.NO_WEATHER})
check("modes on with a forecast read: no doubt", arbitrage.weather_uncertainty(
True, object()) == frozenset())
check("modes on with no forecast (None): the doubt", arbitrage.weather_uncertainty(
True, None) == NW)
check("modes off, forecast or not: the doubt, as before",
arbitrage.weather_uncertainty(False, object()) == NW
and arbitrage.weather_uncertainty(False, None) == NW)
# --------------------------------------------------------------------------- #
print("\n2. day/night 0.34 / 0.26 (gap 0.08), night period")
c1, r1 = drive(opts(0.07), fc="fresh")
l1 = nth(r1, -1, "last cycle")
print(f" forecast read, cost 0.07: mode_runs on={l1['runs_on']} "
f"reason={l1['reason']}; B charging {sum(r['charging'] for r in r1)} of "
f"{len(r1)}; Why A {why_of(c1, A)} B {why_of(c1, B)}")
check("with a forecast at cost 0.07 the cheap run is planned (needed 0.07 < "
"0.08) and the Cheap hours battery charges in the night period",
present(l1) and not l1["fc_none"] and l1["runs_on"] is True
and sum(r["charging"] for r in r1) > len(r1) // 2
and l1["roles"].get(B) == modetrade.CHARGE)
for label, mode in (("no forecast sensor reads", "gone"),
("a stale forecast", "stale")):
c, rr = drive(opts(0.07), fc=mode)
ll = nth(rr, -1, "last cycle")
print(f" {label}, cost 0.07: on={ll['runs_on']} reason={ll['reason']}; "
f"B charging {sum(r['charging'] for r in rr)}; Why A "
f"{why_of(c, A)} B {why_of(c, B)}")
check(f"{label}: the same cost needs 0.105 > 0.08, so not_worth and "
f"'Prices differ too little', no grid charge",
present(ll) and ll["fc_none"] and ll["runs_on"] is False
and ll["reason"] == "not_worth"
and why_of(c, A) == "no_spread" and why_of(c, B) == "no_spread"
and not any(r["charging"] for r in rr))
c0, r0 = drive(opts(0.07, sensors=False), fc=None)
check("no forecast sensor configured at all: not_worth too (as 0.19.0)",
present(nth(r0, -1)) and r0[-1]["runs_on"] is False)
c2, r2 = drive(opts(0.10), fc="fresh")
check("at the shipped cost 0.10 even a forecast does not make 0.08 enough "
"(needs 0.10): still not_worth, honestly",
present(nth(r2, -1)) and r2[-1]["runs_on"] is False
and r2[-1]["reason"] == "not_worth")
# --------------------------------------------------------------------------- #
print("\n3. a dynamic day, gap 0.12, cost 0.10")
pts = dynamic_points(NIGHT - timedelta(hours=1))
cd, rd = drive(opts(0.10), fc="fresh", points=pts)
cn, rn = drive(opts(0.10), fc="gone", points=pts)
_ld, _ln = nth(rd, -1, "last cycle"), nth(rn, -1, "last cycle")
print(f" with forecast: on={_ld['runs_on']} reason={_ld['reason']}; "
f"without: on={_ln['runs_on']} reason={_ln['reason']}")
check("with a forecast the run plan is on (needs 0.10 < 0.12)",
present(nth(rd, -1)) and rd[-1]["runs_on"] is True
and why_of(cd, A) != "no_spread")
check("without one it is not_worth (needs 0.15 > 0.12) and says so",
present(nth(rn, -1)) and rn[-1]["runs_on"] is False
and rn[-1]["reason"] == "not_worth"
and why_of(cn, B) == "no_spread")
# --------------------------------------------------------------------------- #
print("\n4. the forecast going and coming mid-run (a run under way keeps its gap)")
CUT = 600.0 # 10 min in
SOC = {"a": 40.0, "b": 60.0} # B's cheap run is then about 2 h
def going(t):
return "fresh" if t < CUT else "gone"
def coming(t):
return "gone" if t < CUT else "fresh"
def flap(n_reads):
"""Unavailable for `n_reads` forecast reads (60 s apart) from CUT."""
return lambda t: "gone" if CUT <= t < CUT + 60.0 * n_reads else "fresh"
MID, AFTER = 3000.0, 7800.0
cg, rg = drive(opts(0.07), fc=going, minutes=135, soc0=SOC,
snap_at=(MID, AFTER))
cc, rc = drive(opts(0.07), fc=coming, minutes=25)
_end = nth([r["cheap_end"] for r in rg if r["cheap_end"] is not None], 0,
"the cheap run's end")
_end_t = (_end - NIGHT).total_seconds() if present(_end) else 1e9
lost = [r for r in rg if r["fc_none"]]
print(f" going: cheap run ends at t={_end_t:.0f} s; forecast lost from "
f"t={lost[0]['t'] if lost else None}")
check("going: the run planned with the forecast ends before the snapshot "
"taken after it (the scenario is long enough)",
present(_end) and CUT + 300.0 < _end_t < AFTER - 300.0 and bool(lost)
and lost[0]["t"] - CUT <= 60.0)
check("going: the forecast read is lost mid-run and the run under way KEEPS "
"its gap: the plan stays on and B keeps charging, cycle after cycle, "
"until B is full or the run ends",
all(r["runs_on"] is True and (r["roles"].get(B) == modetrade.CHARGE
or r["soc_b"] >= 94.0)
for r in rg if r["fc_none"] and r["t"] < _end_t - 60.0)
and all(r["charging"] or r["soc_b"] >= 94.0 for r in rg
if 60.0 <= r["t"] < _end_t - 60.0)
and max(r["soc_b"] for r in rg if r["fc_none"]) >= 90.0)
check("going: from the run's end the wider gap is back: the next run is "
"not_worth (needs 0.105 at 0.07), no new run, and no grid charge",
all(r["runs_on"] is False and r["reason"] == "not_worth"
for r in rg if r["t"] >= _end_t + 30.0)
and not any(r["charging"] for r in rg if r["t"] >= _end_t + 60.0))
_sm, _sa = cg.snaps.get(MID), cg.snaps.get(AFTER)
check("going, Home agrees mid-run: its mirror still allows, the Batteries "
"screen says it charges from the grid, and Home does not say the gap "
"is too small",
_sm is not None and _sm["mirror"] is not None
and first((b for b in _sm["cards"] if b["name"] == B),
"a card")["why_key"] == "grid_charge"
and not any("price gap is too small" in ln
for ln in _sm["home"]["lines"]))
check("going, Home agrees after the run: its mirror refuses, the Batteries "
"screen says 'differ too little' and Home says the gap is too small",
_sa is not None and _sa["mirror"] is None
and first((b for b in _sa["cards"] if b["name"] == B),
"a card")["why_key"] == "no_spread"
and any("price gap is too small" in ln for ln in _sa["home"]["lines"]))
for n in (1, 2):
cf, rf = drive(opts(0.07), fc=flap(n), minutes=30, soc0=SOC,
snap_at=(CUT + 30.0,))
gone = [r for r in rf if r["fc_none"]]
print(f" flap, {n} read(s) lost: {len(gone)} cycles without a "
f"forecast; B charging in {sum(r['charging'] for r in rf)} of "
f"{len(rf)}")
check(f"flap: the forecast is unavailable for {n} read(s) mid-run: the "
f"plan stays on and B charges without a break (no stop, no "
f"restart)",
bool(gone) and all(r["runs_on"] is True for r in rf)
and all(r["charging"] and r["roles"].get(B) == modetrade.CHARGE
for r in rf if r["t"] >= 60.0))
sf = cf.snaps.get(CUT + 30.0)
check(f"flap, {n} read(s): Home agrees while the read is lost (mirror "
f"allows, Batteries 'charges from the grid')",
sf is not None and sf["mirror"] is not None
and first((b for b in sf["cards"] if b["name"] == B),
"a card")["why_key"] == "grid_charge")
def flips(rows):
"""Cycles where the forecast read changed, and whether the plan and the
charge gate changed in that very cycle."""
out = []
for i in range(1, len(rows)):
if rows[i]["fc_none"] != rows[i - 1]["fc_none"]:
out.append((i, rows[i]["t"], rows[i]["runs_on"],
rows[i]["charging"], rows[i - 1]["charging"]))
return out
fcm = flips(rc)
print(f" coming: forecast read arrives at cycle/t {fcm}")
check("coming (no run under way before): not_worth until the read is back, "
"then on in the same cycle, and the battery charges",
len(fcm) == 1 and fcm[0][1] - CUT <= 60.0 and fcm[0][2] is True
and all(r["runs_on"] is False for r in rc if r["fc_none"])
and all(r["runs_on"] is True for r in rc if not r["fc_none"])
and any(r["charging"] for r in rc if r["t"] >= fcm[0][1] + 40.0))
# --------------------------------------------------------------------------- #
print("\n5. Home agrees with control")
CASES = (("forecast, cost 0.07", c1, r1, True),
("no forecast, cost 0.07", *drive(opts(0.07), fc="gone"), False),
("dynamic, forecast", cd, rd, True),
("dynamic, no forecast", cn, rn, False))
for label, cx, rx, on in CASES:
last = nth(rx, -1, "last cycle")
hp = cx.home_plan
mirror_ok = cx.mirror is not None
print(f" {label}: control on={last['runs_on']}; Home mirror "
f"{'allows' if mirror_ok else 'refuses'} ({cx.mirror_why}); "
f"lines {hp['lines'][:1]}; Why B {why_of(cx, B)}")
check(f"{label}: Home's mirror of the gate allows exactly when control "
f"plans", present(last) and mirror_ok is on
and last["runs_on"] is on)
check(f"{label}: the Batteries screen and the Home line say the gap is "
f"too small exactly when control does",
(why_of(cx, B) == "no_spread") is (not on)
and any("price gap is too small" in ln for ln in hp["lines"])
is (not on))
if label.startswith("dynamic"):
continue # its cheap run is tomorrow morning: not on today's chart
check(f"{label}: Home draws a grid charge block exactly when control "
f"charges", any(r["kind"] == "grid" for r in hp["runs"]) is on)
print()
if fails:
print(f"{len(fails)} FAILED:")
for f in fails:
print(f" - {f}")
sys.exit(1)
print("all checks passed")
@@ -17,6 +17,7 @@ test_modes_p3.py part 1 holds this tree to the digests printed on 7a00f12.
import functools
import hashlib
import os
import re
import sys
import tempfile
import time
@@ -117,6 +118,31 @@ def digest(rows) -> str:
return hashlib.sha256(repr(rows).encode()).hexdigest()
def _trade_words(text):
"""PRICE-02: the trade sentence as release/1.0 before it printed it. The
plan now drops price periods that have ended, so a sale's "above the
cheapest" figure is a different number once the first cheap period is
over, and the sentences say "price period" where they said "hour". Both
maps are applied to both sides, so only those two things are hidden.
ONLY FOR COMPARING WITH A TREE OLDER THAN PRICE-02. A pin of this tree
itself uses `digest()`, with nothing hidden."""
text = text.replace("this hour is neither the buy hour nor the sell hour",
"now is not the cheapest or the dearest price period")
text = re.sub(r"[0-9]+\.[0-9]+ above the cheapest",
"N above the cheapest", text)
return text.replace("firm hour at", "firm price period at")
def trade_digest(rows) -> str:
"""`digest()` with the trade sentence passed through `_trade_words()`:
every command, integrator and allocation, and the action, watts and the
rest of the sentence, byte for byte."""
return digest([r[:-1] + (None if r[-1] is None else
(r[-1][0], r[-1][1], _trade_words(r[-1][2])),)
for r in rows])
def digests() -> dict:
return {k: digest(run(**v)[0]) for k, v in SCENARIOS.items()}