Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e9fa11e60d | ||
|
|
d2f63fe86a | ||
|
|
cdafa2c189 | ||
|
|
6263a57b6b |
@@ -1,13 +1,117 @@
|
||||
# Changelog
|
||||
|
||||
## 0.20.0
|
||||
|
||||
**A minor bump: a Forecast tab, a night grid charge sized to what the sun will not give, a kept evening forecast, and no option changes.**
|
||||
No option was added, removed or renamed, and no default changed (86 options). It is a minor bump because Leftover solar now buys different amounts from the grid and the night charge keeps its level when the forecast goes quiet; every saved setting still starts.
|
||||
**Existing installs need no option change:** options that start 0.19.2 also
|
||||
start 0.20.0. The changes below apply with one inverter or two.
|
||||
|
||||
**A Forecast tab.** A new tab next to Home and Batteries shows today and tomorrow hour by hour: the solar forecast, the sun measured so far, the house's expected use, and each battery's level with its planned charge and give blocks. It shows what the add-on plans and changes no command.
|
||||
|
||||
**Leftover solar buys what the sun will not give.** On a day/night tariff the night grid charge now covers the expensive hours' use less the sun counted for them, where it used to buy for the whole day. A forecast with only a daily total, such as Forecast.Solar, is no longer counted several times over by day, so Leftover solar no longer charges too little because of it.
|
||||
|
||||
**The evening forecast is kept for the night.** Forecast.Solar-type sensors stop updating in the evening. The last fresh reading is now kept until 06:00, so the night cheap run is judged with the forecast's price gap and Leftover solar still plans its charge. A newer reading replaces it at once, and a charge that is running keeps its planned level if the figure ends.
|
||||
|
||||
**No false peak alarm during the night grid charge.** "Battery stopped limiting the peak", and its notification, no longer appear while the 15-minute period stays within 10 W of the aim.
|
||||
|
||||
Contents of this release, in merge order since 0.19.2 (`f1c9733`):
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `a1d0703` | Home now draws a grid charge only where the add-on will take it, with battery modes on or off; before, it could show the rest of a refused cheap run, or a charge after the last dear period. The price texts say "lowest price left" and "highest price left", and no command changes ([[UX-53]], #209) |
|
||||
| `f5afb42` | The day planner's hour loop now always ends on the night the clocks go forward. Without time-zone data it could go round forever and freeze the add-on; the add-on's own image has that data ([[SAFETY-123]], #211) |
|
||||
| `4a1f6cc` | A new Forecast tab shows today and tomorrow hour by hour: the sun, the house and each battery's level, with the charge and give blocks Home would draw. It shows the plan and changes no command; notes say when a part is not known yet ([[UX-54]], #210) |
|
||||
| `6be7e41` | The add-on's test files now run six at a time in the checks, so a check takes about 15 to 25 minutes, where one by one it ran past an hour. No change in behaviour ([[CI-15]], #215) |
|
||||
| `b6be312` | The day plan counts the sun right: a forecast with only a daily total is no longer counted several times over. On a day/night tariff the night charge for Leftover solar covers the expensive hours' use less the sun counted for them, shared between batteries by capacity, and the screens say when another battery or the grid covers the rest ([[OPT-23]], [[OPT-28]], #218) |
|
||||
| `cc9bbeb` | When a solar forecast sensor stops updating in the evening, the night plan uses its last fresh reading, for the day it is for only. One missed reading no longer drops the forecast at once, and the pages say when a figure is kept ([[OPT-27]], #219) |
|
||||
| `deee98d` | The night grid charge under the peak limit no longer raises a false "Battery stopped limiting the peak" alarm when the 15-minute period stays within 10 W of the aim. A real overrun still alarms; a steadily rising load alarms a little later, and with a peak margin under 10 W a period can end up to 10 W over the limit without it ([[SAFETY-126]], #221) |
|
||||
| `8afadbd`, `cdafa2c` | The kept evening figure now counts until 06:00, not until 03:00, and a charge that is running keeps its planned level when the figure ends. A newer reading replaces the evening figure at once and for good, so it does not come back later in the night ([[OPT-34]], #222, with its fix #225) |
|
||||
| `d4c01da` | Comments and docstrings in the peak guard and the price files were cut to a line and a pointer to the decision notes. No change in behaviour: a check shows the code itself is the same ([[MAINT-20]] part 1, #223) |
|
||||
|
||||
The merge that brought 0.19.1 and 0.19.2 into this branch (`f1b7244`, #220) adds nothing else; the Forecast tab uses the same price gap as control since it.
|
||||
|
||||
### 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. This is about the guard's discharge only; a grid charge is cut back since 0.19.2 ([[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]]).
|
||||
- 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]]).
|
||||
- Control acts on Home Assistant readings that arrive late. With readings 10 s behind, the give run can swing and send up to 1.9 kW to the grid for about 50 s in a rig test; with readings 30 s behind, a battery can charge from the grid in an expensive period. Also in 0.19.2 ([[SAFETY-128]]).
|
||||
- In the last second of a 15-minute period, being a few watts over the aim can make the peak guard ask a battery for full power for one second. When a charge ends on a period edge, the last-second give can fade out over about 10 s, and a rig test exported up to 7.4 kW for about 8 s ([[SAFETY-124]]).
|
||||
- During the night grid charge, a 15-minute period can end 6 to 14 W over the peak aim, because the battery charges harder than asked. This was seen in simulation; a rig test saw 2 to 8 W ([[OPT-35]]).
|
||||
- The test rig could not bring back the false peak alarm that [[SAFETY-126]] fixes, so that fix is shown in simulation only. Watch the notifications for a few nights ([[TEST-35]]).
|
||||
- The night cheap run is sized for the batteries' full charging power, not for what the peak limit leaves. Under a tight limit the run can end before the batteries reach their level, and Home can show a level they do not reach (79.9 % against a shown 95 % in a rig test) ([[OPT-25]]).
|
||||
- By day, a Leftover solar plan that has only a daily forecast figure counts the sun from now, not from when the figure was read. It counts about 22 % too much for a figure one hour old; the night plan is not affected ([[OPT-30]]).
|
||||
- The night charge can buy too much in a house that uses more at night than by day, such as one with a car charger or heat pump, and on a sunny day, because spare sun in one expensive hour does not count for the later ones ([[OPT-31]]).
|
||||
- A cheap run that has already started can overlap a new give run after a re-plan, so a battery can charge from the grid at the dearest price for part of an hour. This was seen in a test case, not on a site. Also in 0.19.2 ([[OPT-32]]).
|
||||
- If the solar forecast sensors stop updating before 17:00, as in winter or further north, there is no forecast from three hours after their last update until 20:00 ([[OPT-33]]).
|
||||
- With a forecast that gives only a daily total, the sun is spread an hour off on the two clock-change days after the change. The day's total is right ([[OPT-24]]).
|
||||
- The add-on learns the house's use only once a solar forecast is set, so a site that adds a forecast later waits one more day for its first plan ([[OPT-26]]).
|
||||
- Home says "No trading on price yet: no prices have arrived" when every battery is on Solar first, though the prices are shown ([[UX-55]]).
|
||||
- The Home plan header can say "Charges in the cheapest hour, gives power in the dearest" when Home draws no charge, and it says "hour" for 15-minute prices ([[UX-56]]).
|
||||
- While the peak guard holds back charging, Home hides the rest of the cheap run and gives no reason. The charge starts again when the guard lets go ([[UX-57]]).
|
||||
- With battery modes on and selling on, Home can draw a "Sells up to" block when the add-on judges the trade not worth it, and then it will not sell ([[UX-58]]).
|
||||
- Home, the plan and the Forecast tab stop drawing a battery's give at the trading floor, though control goes on giving down to the battery's cut-out ([[UX-59]]).
|
||||
- During a price outage, the Batteries screen says "Prices differ too little" and Home says "The price gap is too small" ([[UX-60]]).
|
||||
- With battery modes on and no meter, or a meter that is a Home Assistant entity, a grid charge is not possible and nothing says so ([[UX-61]]).
|
||||
- On the Batteries screen, a charge the add-on refused in a cheap run reads as "Saves its power for ...", for up to 15 minutes ([[UX-62]]).
|
||||
- A Leftover solar battery with no capacity set never charges from the grid when a solar forecast is set, and nothing warns about it ([[UX-63]]).
|
||||
- The "How the modes work" box on the Batteries screen does not describe Cheap hours only ([[UX-64]]).
|
||||
- With battery modes on, Settings still shows "Trade on the price spread", which the modes ignore. A site with modes on and no price source starts, and the Batteries screen says "No prices, so it follows the house" without pointing to Settings ([[UX-65]]).
|
||||
- With battery modes off and several equal prices in a row, Home and the Forecast tab draw one price quarter, while the add-on trades every quarter of the run ([[UX-67]]).
|
||||
- Late in the day, the Forecast tab's "now" line and last bars sit over the axis labels at the right edge ([[UX-68]]).
|
||||
- The Forecast tab's level curve charges at each battery's full rating. Under a peak limit the levels it draws are too high and a charge block can end early (54.6 % shown against 39.7 % in a rig test) ([[UX-70]]).
|
||||
- The peak forecast check says maintenance charging is not kept under the peak limit when no peak forecast is set, but since 0.19.2 it is ([[UX-73]]).
|
||||
- The peak guard row can show the same figure twice, such as "at this rate 3 500 W, over 3 500 W". Also in 0.19.2 ([[UX-74]]).
|
||||
- If the inverter's setpoint entity appears in Home Assistant after the add-on has started, the page keeps saying "Inverter control: not found in Home Assistant" and the batteries say "An alarm stopped control" until a restart, though control runs ([[UX-75]]).
|
||||
|
||||
### 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.2
|
||||
|
||||
### Not in a release yet
|
||||
|
||||
- The night grid charge no longer raises "Battery stopped limiting the peak" when the 15-minute period stays on the aim. A steadily rising load now alarms a little later, and with a peak margin of 0 a period can end up to 10 W over the limit without this alarm ([[SAFETY-126]]).
|
||||
- A night grid charge for *Leftover solar* no longer stops at 03:00 when the solar forecast sensors went quiet in the evening. The evening's figure is used until 06:00, and a charge that has started goes on to its level ([[OPT-34]]).
|
||||
|
||||
**A patch bump: two fixes, one for charging from the grid under the peak limit and one for the night charge of Leftover solar, and no option changes.**
|
||||
**A patch bump: one fix for charging from the grid under the peak limit, 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.1 also
|
||||
start 0.19.2. The change below applies with one inverter or two.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,51 +0,0 @@
|
||||
# MAIN - why app/main.py is built the way it is
|
||||
|
||||
**Status: SHIPPED.** Written down by MAINT-20 part 2 from the long comments and
|
||||
docstrings of `app/main.py`, so the module can keep one line and a pointer. The
|
||||
comments that were shortened but not rewritten here are kept word for word in
|
||||
[[MAIN-orchestration-archive]], by their line in `app/main.py` at release/1.0
|
||||
`d4c01da`. Related: [[SAFETY-07-peak-guard]], [[FCST-04-the-day-that-is-carried]],
|
||||
[[SAFETY-30-per-unit-sign]], [[SAFETY-73-refusal-per-inverter]],
|
||||
[[SAFETY-121-bound-every-quarter]], [[TARIFF-10-fetching-is-not-trading]],
|
||||
[[TARIFF-24-modes-need-the-tariff-task]], [[OPT-34-the-night-charge-keeps-its-level]].
|
||||
## Controller construction
|
||||
|
||||
- **Per-write budget (DEV-02).** Each write is dispatched concurrently and bounded, so one hung `hass.call()` cannot eat a sibling's layer-1 budget (the ESP32 zeroes after about 30 s, and a fault on A could zero a healthy B). The number is derived: `heartbeat_s` max 25 s, layer-1 veto `validate.FIRMWARE_INGEST_VETO_S` 30 s, so 25 + 4 < 30. test_inverter.py grades the inequality, so raising the heartbeat ceiling goes red instead of taking an inverter to zero. Below hass.py's 10 s HTTP timeout on purpose: abandon the write and re-issue it next tick, since `last_write` moves only on an accepted write.
|
||||
- **Tariff refresh (OPT-04).** `resolve_tariff()` may do an HTTPS fetch. The tick feeds the ESP32 heartbeat, so a supplier's TLS timeout on the tick would push the add-on into layer 1. The tick never awaits a fetch. The 30 min cadence is not about price change (day-ahead publishes once at 14-16 h CET and 8.3's carried hours go firm then); it picks the switch up within the hour at 48 fetches a day. The age bound answers another question: a failed refresh still stores a plan (the 11.4 static fallback), the bound covers the refresh task dying while a plan from hours ago stands as current. Three missed refreshes, for `p1.max_age_s`'s reason.
|
||||
- **Status rows (TARIFF-11).** The Tariff row used to be `static_status()`'s sentence alone and never saw the provider plan (read 'no static profile' at all 802 samples of a 40 h soak with a live feed). `tick_words` and `plan` are two clocks and the row says so; the plan is stamped with its own age from `tariff_at` (TARIFF-12 does not move it on a kept plan), and `age_s` is the caller's measurement. The Arbitrage row used to be `"off (arbitrage_enabled)" if self.arb is None`, under a comment saying `arb is None` iff trading was off; false, since `cycle()` returns at the input hold before `self.arb = None`: `arbitrage_enabled: true` plus a sensor answering None rendered 'off (arbitrage_enabled)', naming an option that is on, and past the first cycle the previous decision stood as current. Three states: off, armed with no decision this cycle, a decision with its age (always printed: a row that reports staleness only once stale cannot be told from one that never does).
|
||||
- **`stop` is required (review catch).** `stop=None` made a Controller that cannot stop itself the default: on the late-refusal path in `run_control()` it logged the banner, returned and left the process alive forever with no loop and no output.
|
||||
- **Action log first.** The store migration can log to it (15: how an operator finds a latch was re-armed); an AttributeError on the way to saying so would stop the add-on booting.
|
||||
- **Roster and migrations (DEV-02, SAFETY-44, MAINT-05).** Every per-device value lives on the roster objects (app/inverter.py); a `Tuning` and an integrator are deliberately not per device (6.2: one integrator on P1 above the split; a second regulating the same meter is the two-writers failure). The SAFETY-02 key migration runs before the first `Inverter` reads its latch, else a tripped install comes up armed with a green banner. SAFETY-44: it is handed `roster_names(opts)[0]`, never `UNIT_A` (on a declared roster the flat latch landed under `unit/inverter-a/`, read back under the name it was handed, stamping the marker); `roster_names()` returns `[UNIT_A]` on the flat shape so today's path is byte-identical. `misdirected` is unreachable from that call site (`unit` is a roster member by construction; dropping the `roster` argument survives 28/28), so the claim that the call site buys the refusal is gone; the argument stays for new callers and is graded in test_maintenance.py. The maintenance migration targets `self.inverters[0].name`, not `UNIT_A`: with `UNIT_A` the history would sit under a name on no roster (a nameless entry falls back to a positional `inverter-1`). Every two-pack fixture once named its first entry `inverter-a`, so swapping them left 19 files green; `migrate_maint_keys()` now grades the namespace it wrote against the names the machines read, and test_maintenance.py sections 9 and 11 use `["garage", "shed"]`.
|
||||
- **CFG-04 disclosure.** A declared `inverters:` roster ignores `soc_entity`, `batt_entity`, `batt_invert` and `max_w`, which the UI still shows. Rule 9(e) refuses the dangerous one (a flat `setpoint_entity`); the rest cannot be refused (the schema has no blank value) so one action-log line at every start names the roster and the ignored keys.
|
||||
- **Site bound.** Summed from the roster, not `opts["max_w"]`: it is the integrator bound and rail of the site loop, then split. Identical at N=1; a per-unit `max_w` binds each share through its `supervised` claim.
|
||||
- **SAFETY-39 slew fallback** is 0 ("follow the rail"); it was 1000, a stale third copy. `control.slew_limit_w()` is the one reader.
|
||||
- **CFG-03 in main.py.** Eight `opts.get(key, <literal>)` calls used to live in the maintenance setup and two disagreed with config.yaml (2500 against 2000). `MaintConfig.from_options()` reads them. A ninth key goes in config.yaml `options:`/`schema:` and `maintenance.MAINT_OPTION_DEFAULTS` only.
|
||||
- **Watchdog thresholds (SAFETY-30).** The per-unit pair is re-derived at two inverters, not carried (it grades a command against one unit's own power). The site pair is used only at N>=2: the 300 W threshold carries (meter noise, deadband), the window does not (110 s at one inverter, 215 s at two, against a 180 s hold).
|
||||
- **Latches (SAFETY-02, -31, -34, -42, -18).** SAFETY-02: one per actuator, restored from namespaced keys; graded on every start (a latch that failed to move namespace, or was taken by an older add-on in a rollback, is a disarmed guard, so a disagreement re-arms by hand and shouts). SAFETY-31: site-level, flat keys (circulating power is defined over the roster), restored because it acts. SAFETY-42's delivery latch does not act, so it is restored only so the ALARM survives (the 2026-09-02 outage went unseen 6 h 25 m with the detector correct). SAFETY-18 is not restored (does not act). SAFETY-30's site check is armed only at N>=2.
|
||||
- **PV (TEL-11).** Instrumentation only; wiring it in would be an unrequested behaviour change and would destroy the measurement.
|
||||
- **Peak guard setup (SAFETY-07, TARIFF-01, FCST-04).** See SAFETY-07-peak-guard.md and FCST-04. The windowing caveat for FCST-04: hours already elapsed leave the spread, so a cheap night that has gone no longer sets the day's cheapest hour.
|
||||
|
||||
## Cycle state and latch repair
|
||||
|
||||
- **Allocation record (SAFETY-28).** The tick that mints a record increments once per resolution, not per loop iteration. A cycle with no resolution (the "inputs missing, not yet stale" hold) must not bump it: that hold returns before the allocator so one missed poll does not zero the inverter, and the heartbeat re-affirms `self.target` through the write path; bumping would make that heartbeat unauthorised and drop the site to 0 W. The record is superseded, never expired.
|
||||
- **`self.arb` (TARIFF-11).** See "Status rows" above; `arb_at < cycle_at` is the derived "the latest cycle did not reach the arbitrage step".
|
||||
- **Shadow (SAFETY-10, SAFETY-47).** `shadow_would_w` is what a live controller would have put on the wire; `self.target` is what is standing on the wire (held at `shadow.WIRE_W`, re-seeded each shadow cycle). Folding them shows an operator a number nothing is doing. The "sized from the billed-quarter meter" flag is recorded where the decision was taken (the page runs on its own task); False, not None, before the first shadow cycle.
|
||||
- **ARB-03.** `units` is a property derived from the roster so a site cannot claim two actuators while holding one writer. At N=1 (6.2) the grid connection and the only inverter are one actuator; a second entry makes per-inverter claims stop being site claims (ALLOC-01).
|
||||
- **DEV-02 latch guard (`_check_sign_latch...`).** Runs on every start: gating on `rep.ran` swallowed store.py's rule-2 round trip (upgrade, downgrade with a trip in the window, upgrade; the controller came up untripped while state.json said `sign_tripped: true`; a firmware rollback path was performed on this site on 2026-09-02). The gate could go because `handle_action()` clears the flat keys with the namespaced ones and the trip mirrors them. SAFETY-44 second half: the question is whether a namespaced latch THAT A MACHINE READS is set. An earlier version accepted any `unit/*/sign_tripped`, which blinded the guard on exactly the installs the broken migration had put the latch under `unit/inverter-a/` (a key nothing reads). The first blunt fix (every off-roster latch is litter) re-armed a healthy A off B's mirrored flat key (test_inverter.py DEV-02 restart case). `UNIT_A` is the discriminator: the only name the broken migration wrote. A single flag written for two intents was defeated by one removed unit's key (roster ['garage','shed'] with `unit/old-shed/sign_tripped` drove on a state file reading `sign_tripped: true`; `tools/rehearse_roster.py` exit 1); the fixtures now hold both arrangements. Residual: a first unit really named `inverter-a`, latched and removed from the roster is re-armed on `inverters[0]` (conservative). The printed reason is the flat mirror's (whatever tripped last), so on a multi-unit site one inverter's evidence may appear under another's name; test_maintenance.py's check that the reason is not the removed unit's depends on that fixture's flat string, not on this code.
|
||||
- **Maintenance views (MAINT-05).** `maints` is one FSM per roster entry; `maint`, `maint_unit` and the button target are views of the one that holds the slot (`maintenance.arbitrate()` allows at most one out of IDLE). validate.py rule 6 grades `maintenance_charge_w` against the weakest cap on the roster.
|
||||
- **N=1 views.** Properties, not fields, and they refuse at N>=2 (6.2's identity is false there). Nothing inside main.py may read them (test_inverter.py AST scan).
|
||||
- **`_arm_sign_watchdogs` (SAFETY-30).** Called every cycle because fixtures append an `Inverter` after construction; a detector frozen at construction would be graded against the meter at a roster of two. `arm()` touches no timer or latch.
|
||||
- **Per-inverter refusal (SAFETY-73).** The probe ask is PROBE_W, not `saturation_w`: a unit's share is often small (a 400 W night load split two ways) and SAFETY-43 would name the empty battery as not delivering. The site target is restarted from the watts on the wire when a unit reopens (phase A: -1059 W).
|
||||
|
||||
## Write path and cycle
|
||||
|
||||
- **The write gate (SAFETY-28).** specs 1.5 makes two writers on one actuator a non-goal. `len(validate.ACTUATOR_WRITE_KEYS) == 1` is a fact about the options schema, not the writes, and goes false when CFG-04 adds per-inverter config. So the enforcement is on the write: a number reaches the wire only if the single allocator placed it, on this tick, for this unit (`Allocation.authorises()`); a second writer must file a Claim. 0 W is always authorised and the test for zero comes first and reads nothing: the failsafe branches write 0 W while the allocator has refused (`Allocation.ok` False, no shares), and a gate asking its permission would deadlock the failsafe behind the component it answers; at S = 0 broadcast and allocation coincide, so there is no over-command (the hazard is N-times loop gain from broadcast, TEST-19's `N=2 gain 1.0` limit cycle). The predicate is `value == 0.0`, not a `failsafe=True` flag, which a second writer would pass; -0.0 == 0.0 and a nan never gets here. DEV-02: the unit is an argument, not the N=1 view; the default refuses at N>=2. The refusal sits above the clamp and below the non-finite coercion.
|
||||
- **Clamp (SAFETY-03)** is on the only path to the wire, so no optimizer, maintenance or manual path routes around it. A non-finite value is loss of input (11.1), coerced to 0 W. Every engagement goes to the action log undeduplicated; the notification is deduplicated.
|
||||
- **Shadow (SAFETY-10)** suppresses in the same method, below the gate and the clamp, as a substitution not a return. It is not a second writer.
|
||||
- **Reads (DEV-02, TEL-12).** Each inverter's readings are gathered concurrently across units and sequentially within one; PV entities ride in the same gather (a slow sensor must not push a healthy battery toward layer 1's veto). P1 has no inverter-side fallback: the inverter's AC power correlates 0.998 with battery power and 0.09 with the real meter.
|
||||
- **Stale reading (SAFETY-37/56/57).** The power reading goes through the freshness gate, the SoC does not (the power feeds the sign and delivery checks, which a frozen register fools). SAFETY-57 rewrote the escape clause: "it clears itself when the register reports a different value" was false for the state announced, because the 0 W it announces is what stops the value changing. The release is reported, never silent (it withdraws a SITE STOP).
|
||||
- **TARIFF-03.** The one shiftable load is the maintenance cycle's cheap-window top-up; the static window is synchronous and I/O-free, and validate.py refuses `static_profile` together with the price entities.
|
||||
- **Cycle ordering (SAFETY-02/31/42).** The watchdog evaluations run every cycle, before the input hold, so a run of held cycles cannot leave their timers resuming from a start that predates the gap. Reason first, flag second (each `set` writes the file): a crash between persists a reason nothing reads, not a latch with no explanation. The per-unit trip mirrors the legacy flat keys (store.py rule 2 covered only the latch present at migration time). The site check is flat-keyed, armed only at N>=2. SAFETY-31's input is MEASURED power (`inv.batt`), never the allocation record, which has sign-coherence by construction.
|
||||
- **Maintenance FSMs (MAINT-05).** Each pack is ticked with its own SoC (a minimum would abort a cycle on a sibling's state, an average would declare a pack full that is not); `arbitrate()` runs first. A deferral is carried to the status page because the edge-logged line scrolls out of the 40-row log.
|
||||
- **Aggregates.** SUM for the site bound, achievable rating and the battery power the site loop regulates against (`compute()`'s saturation test compares against the site target; one unit's power trips the freeze permanently, up to 510 W of split-dependence at gain 0.2, test_alloc.py part 8). MINIMUM for the discharge permission (the emptiest pack). MEASURED power for circulating power. Unknown bounds stay unknown when any unit is silent.
|
||||
- **Trade sizing (OPT-09).** `trade_sizing` uses arbitrage's own SoC bounds, not the BMS cut-out (SAFETY-25): sizing is the trade's business, the pack envelope is the pack's. Weights are a preference, so a pack between its arbitrage floor and its cut-out can still take part of a sell; fixing it needs a per-pack envelope depending on which claim won the tick.
|
||||
@@ -34,6 +34,13 @@ known, so the add-on does not depend on it.
|
||||
midnight, so a 09:05 reading, unusable since 21:05, does not come back for five
|
||||
minutes after midnight. Same-day figures and the daytime rule keep 12 h and 3 h.
|
||||
4. A fresh figure replaces the kept one at once, as before.
|
||||
*Rig F-1 fix:* once every today sensor has given a fresh reading after
|
||||
midnight, the evening figure is no stand-in for that day any more
|
||||
(`Hold.replaced()`). When that reading goes stale (3 h), it is kept or not
|
||||
as any today reading (OPT-27), so it is "no forecast" at night and the latch
|
||||
keeps the level it set; the older 17:05 figure never comes back. Rig: A
|
||||
stopped at 69.5 % after a brighter 02:00 figure, then charged back to 94.4 %
|
||||
from 05:15 when the 17:05 figure returned. Test: `test_opt34.py` F-1.
|
||||
|
||||
**Step 1 alone is enough for the owner's shape, steps 2-3 are still needed.**
|
||||
With the window put back to midnight + 3 h the owner's night still ends as a
|
||||
@@ -59,4 +66,4 @@ A fresh figure that LOWERS the level during a charge still lowers it, as before.
|
||||
**Mutants** (all red in `test_opt34.py`): no latch; window back to midnight + 3 h;
|
||||
15 h without a limit; no "usable at midnight" rule; the latch without the narrow
|
||||
gap, without its save, or not kept through the next run; the window 6 h after
|
||||
midnight instead of 06:00 local (DST); 15 h cut to 12 h; the new screen line off or shown without a cheap run; no expiry check; no pruning of a changed mode; no night bound; a left-the-plan battery keeping its level.
|
||||
midnight instead of 06:00 local (DST); 15 h cut to 12 h; the new screen line off or shown without a cheap run; no expiry check; no pruning of a changed mode; no night bound; a left-the-plan battery keeping its level; no `replaced()` check (F-1); `replaced()` with one of several today sensors (any, not all).
|
||||
|
||||
+3753
-1200
File diff suppressed because it is too large
Load Diff
@@ -38,7 +38,8 @@ midnight, if it is at most `KEPT_MAX_AGE_S` old when used.
|
||||
Never beyond that, and never for another day: a today figure is only today's,
|
||||
a tomorrow figure is tomorrow's until midnight and then, until the bell starts
|
||||
(`BELL_FROM_H`, 06:00 local), today's (OPT-34: at most `KEPT_MORNING_MAX_AGE_S`
|
||||
old, and it must have been usable at midnight).
|
||||
old, it must have been usable at midnight, and no fresh today reading may
|
||||
have replaced it since).
|
||||
DECISIONS/OPT-27-last-fresh-forecast-for-the-night.md,
|
||||
DECISIONS/OPT-34-the-night-charge-keeps-its-level.md.
|
||||
|
||||
@@ -351,6 +352,13 @@ class Hold:
|
||||
evening = _local_hour(now) >= BELL_TO_H and now < _bounds(day0)[1]
|
||||
return (day, at, True) if evening else None
|
||||
|
||||
def replaced(self, entities, day0) -> bool:
|
||||
"""OPT-34 F-1: every today sensor has given a fresh reading for
|
||||
`day0` (so after midnight, newer than any evening figure). The
|
||||
evening's tomorrow figure is then no stand-in for today any more,
|
||||
also once those readings are stale."""
|
||||
return all(("today", e, day0) in self._r for e in entities)
|
||||
|
||||
def dump(self) -> dict:
|
||||
return {"v": 1, "r": [
|
||||
[kind, ent, d.day0.isoformat(), d.kwh, at.isoformat(),
|
||||
@@ -492,7 +500,8 @@ def read_sum(today, tomorrow, now: datetime, hold: Hold | None = None):
|
||||
where `Hold.stand_in()` allows it (the forecast then has `kept` set when
|
||||
that took a reading older than `FRESH_S`). After midnight, if the today
|
||||
side is still short, the tomorrow figures read before midnight are
|
||||
today's, until the bell starts (`stand_in()`). Without a `hold` this is
|
||||
today's, until the bell starts (`stand_in()`), unless fresh today
|
||||
readings have replaced them (`Hold.replaced()`). Without a `hold` this is
|
||||
exactly as before."""
|
||||
if not today:
|
||||
return None, NOT_SET, ()
|
||||
@@ -537,7 +546,8 @@ def read_sum(today, tomorrow, now: datetime, hold: Hold | None = None):
|
||||
# day; all of them are needed, as for any sum.
|
||||
subs = [hold.stand_in("tomorrow", ent, day0, now, as_today=True)
|
||||
for ent, _st in tomorrow]
|
||||
if all(r is not None for r in subs):
|
||||
if all(r is not None for r in subs) and not hold.replaced(
|
||||
[ent for ent, _st in today], day0):
|
||||
days = [r[0] for r in subs]
|
||||
ats = [r[1] for r in subs]
|
||||
gaps = [g for g in gaps if g[0] != "today"]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
name: GoodWe RS485 Controller
|
||||
version: "0.19.2"
|
||||
version: "0.20.0"
|
||||
slug: goodwe_controller
|
||||
description: >-
|
||||
Drives a GoodWe ES/BP battery inverter over its RS485 EMS port by sending it a
|
||||
|
||||
@@ -36,6 +36,8 @@ DECISIONS note and the dev report.
|
||||
and before a charge has started, up and down;
|
||||
e. the last hour, after the kept figure has ended at 06:00: the charge under
|
||||
way goes on to its level, also over a restart; the screens say so.
|
||||
f. F-1 (rig): one fresh reading at 02:00, then silent again: when it is
|
||||
stale (05:00) the 17:05 figure it replaced does not come back.
|
||||
3. The text, EN and NL.
|
||||
4. `_latch_charge()` alone: a battery that leaves the plan leaves the latch.
|
||||
"""
|
||||
@@ -193,6 +195,47 @@ for label, now in (("01:00", at(4, 1, 0)), ("04:00", at(4, 4, 0))):
|
||||
"17:05 figure again", fr is not None and fr.kept
|
||||
and fr.today.kwh == 12.0 and fr.at == at(3, 17, 5))
|
||||
|
||||
# F-1 (rig): a fresh today reading at 02:00 replaces the 17:05 figure; once
|
||||
# that reading is stale (05:00) the 17:05 figure must not come back.
|
||||
for label, kwh in (("brighter", 40.0), ("darker", 1.0)):
|
||||
hn = hold_of(at(3, 17, 5))
|
||||
f2 = rs(st(kwh, at(4, 2, 0)), st(kwh, at(4, 2, 0)), at(4, 2, 1), hn)[0]
|
||||
f4 = rs(GONE, GONE, at(4, 4, 59), hn)[0]
|
||||
snap = json.loads(json.dumps(hn.dump()))
|
||||
f5 = [rs(GONE, GONE, t, hn)[0] for t in (at(4, 5, 1), at(4, 5, 59))]
|
||||
f5s = [rs(st(kwh, at(4, 2, 0)), st(12.0, at(3, 17, 5)), t, hn)[0]
|
||||
for t in (at(4, 5, 1), at(4, 5, 30))]
|
||||
check(f"F-1 {label} 02:00 reading: used fresh at 02:01 and kept for "
|
||||
"a missing sensor to 04:59 (3 h)",
|
||||
f2 is not None and not f2.kept and f2.today.kwh == kwh
|
||||
and f4 is not None and f4.today.kwh == kwh and not f4.kept)
|
||||
check("... and from 05:01 (stale) to 05:59: no forecast, never the "
|
||||
"17:05 figure (sensors gone, or showing their old readings)",
|
||||
all(x is None for x in f5 + f5s))
|
||||
back = solarfc.Hold(snap)
|
||||
check("... also after a restart at 03:00 (the saved hold keeps the "
|
||||
"02:00 reading)", rs(GONE, GONE, at(4, 3, 0), back)[0] is not None
|
||||
and rs(GONE, GONE, at(4, 5, 1), back)[0] is None)
|
||||
# Two today sensors, only one read fresh: the sum never had a fresh figure,
|
||||
# so the evening one still stands in (as before).
|
||||
h2 = solarfc.Hold()
|
||||
td2 = [("sensor.t1", st(0.2, at(3, 17, 5))),
|
||||
("sensor.t2", st(0.2, at(3, 17, 5)))]
|
||||
tm2 = [("sensor.m1", st(6.0, at(3, 17, 5))),
|
||||
("sensor.m2", st(6.0, at(3, 17, 5)))]
|
||||
gone2 = [(e, GONE) for e, _s in td2], [(e, GONE) for e, _s in tm2]
|
||||
solarfc.read_sum(td2, tm2, at(3, 17, 10), h2)
|
||||
solarfc.read_sum([("sensor.t1", st(9.0, at(4, 2, 0))), td2[1]], tm2,
|
||||
at(4, 2, 1), h2)
|
||||
g5 = solarfc.read_sum(*gone2, at(4, 5, 1), h2)[0]
|
||||
check("two today sensors, only one read fresh at 02:00: the evening sum "
|
||||
"(12.0) still stands in at 05:01", g5 is not None and g5.kept
|
||||
and g5.today.kwh == 12.0)
|
||||
solarfc.read_sum([td2[0], ("sensor.t2", st(9.0, at(4, 2, 10)))], tm2,
|
||||
at(4, 2, 11), h2)
|
||||
check("... and once both have read fresh, it does not",
|
||||
solarfc.read_sum(*gone2, at(4, 5, 20), h2)[0] is None)
|
||||
|
||||
# DST: the 25 h autumn day and the 23 h spring day. The window ends at 06:00
|
||||
# LOCAL, not 6 h after midnight.
|
||||
for (y, mo, d), label in (((2026, 10, 25), "25 h day 2026-10-25"),
|
||||
@@ -431,6 +474,19 @@ SCEN = {
|
||||
soc={"a": 65.0, "b": 95.0},
|
||||
marks=[at(4, 5, 50), at(4, 6, 10), at(4, 6, 45)]),
|
||||
}
|
||||
# F-1 (rig 4v): silent from 17:05, one fresh reading at 02:00, silent again.
|
||||
# It is stale at 05:00, before the 06:00 bell: the 17:05 figure it replaced
|
||||
# must not come back. Brighter (A's level drops), with a restart at 03:00,
|
||||
# and darker (the level rises).
|
||||
F1_MARKS = [at(4, 1, 55)] + [at(4, 2, 15) + timedelta(minutes=15 * i)
|
||||
for i in range(19)]
|
||||
for k, kwh, rst, hw in (("f1_bright", 30.0, (), 700.0),
|
||||
("f1_bright_rs", 30.0, (at(4, 3, 0),), 700.0),
|
||||
("f1_dark", 1.0, (), 500.0)):
|
||||
SCEN[k] = dict(start=at(4, 1, 30), hours=5.5, house_w=hw,
|
||||
soc={"a": 50.0, "b": 50.0}, fresh_from=at(4, 2, 0),
|
||||
fresh_until=at(4, 2, 0), fresh=(kwh, kwh),
|
||||
restarts=list(rst), marks=F1_MARKS)
|
||||
t0 = time.time()
|
||||
with ProcessPoolExecutor(max_workers=min(6, os.cpu_count() or 2),
|
||||
mp_context=get_context("fork")) as pool:
|
||||
@@ -563,6 +619,45 @@ check("(control: the same latch, still valid until tomorrow 07:15, does "
|
||||
f"charge both: A {sl_['soc'][0]:.1f}, B {sl_['soc'][1]:.1f} %)",
|
||||
sl_["soc"][0] > 11.0 and sl_["soc"][1] > 11.0
|
||||
and sl_["marks"]["22:20"]["state"] == "latched")
|
||||
for k in ("f1_bright", "f1_bright_rs"):
|
||||
fb = R[k]
|
||||
mk = fb["marks"]
|
||||
lo_t = mk["02:15"]["target_a"]
|
||||
after = [mk[h] for h in sorted(mk) if h >= "02:15"]
|
||||
print(f" {k}: A level 01:55 {mk['01:55']['target_a']} -> 02:15 "
|
||||
f"{lo_t}; A {fb['soc_at']['02:15'][0]:.1f} % at 02:15, "
|
||||
f"{fb['soc_at']['06:55'][0]:.1f} % at 06:55; lines "
|
||||
+ " | ".join(sorted({x['line'] for x in after})))
|
||||
check(f"F-1 {k}: a brighter fresh figure at 02:00 lowers A's level "
|
||||
"below its SoC and A stops",
|
||||
lo_t is not None and mk["01:55"]["target_a"] is not None
|
||||
and lo_t < mk["01:55"]["target_a"] - 10.0
|
||||
and lo_t < mk["02:15"]["soc_a"])
|
||||
check(f"... after 02:00 the screen never says 'read 17:05' and from "
|
||||
"05:15 there is no forecast, with the LOWERED level",
|
||||
all("17:05" not in x["line"] and not (x["kept"] and
|
||||
not x["fc_none"])
|
||||
for x in after)
|
||||
and all(mk[h]["fc_none"] and near(mk[h]["target_a"], lo_t, 0.5)
|
||||
for h in mk if "05:15" <= h < "07:00"))
|
||||
check(f"... so A stays at its lowered level to 07:00 "
|
||||
f"({fb['soc_at']['02:30'][0]:.1f} -> "
|
||||
f"{fb['soc_at']['06:55'][0]:.1f} %)",
|
||||
fb["soc_at"]["06:55"][0] < fb["soc_at"]["02:30"][0] + 1.0)
|
||||
fd = R["f1_dark"]
|
||||
md = fd["marks"]
|
||||
print(f" f1_dark: A level 01:55 {md['01:55']['target_a']} -> 02:15 "
|
||||
f"{md['02:15']['target_a']}; A {fd['soc_at']['06:55'][0]:.1f} % "
|
||||
"at 06:55")
|
||||
check("F-1 darker fresh figure at 02:00: the level rises, is held after "
|
||||
"05:00 with no forecast (never 'read 17:05'), and A charges to it",
|
||||
md["02:15"]["target_a"] is not None
|
||||
and md["01:55"]["target_a"] is not None
|
||||
and md["02:15"]["target_a"] > md["01:55"]["target_a"] + 3.0
|
||||
and all("17:05" not in md[h]["line"] for h in md if h >= "02:15")
|
||||
and all(md[h]["fc_none"] for h in md if "05:15" <= h < "07:00")
|
||||
and near(fd["soc_at"]["06:55"][0], md["02:15"]["target_a"], 1.5))
|
||||
|
||||
tb = R["tail_bfull"]
|
||||
check("B full: the run ends at 06:33 before A is at its level, so the next "
|
||||
"slice is only A's, planned after the figure has ended with A's "
|
||||
|
||||
@@ -5,19 +5,12 @@
|
||||
|
||||
For each FILE (default: every goodwe_controller/app/*.py that differs from
|
||||
BASE_REF, default release/1.0) the ast.dump() of the tree with docstrings
|
||||
removed must be identical at BASE_REF and in the working tree, and so must
|
||||
the `python -OO` compiled code (a wholly removed docstring leaves a no-op
|
||||
there). An added or removed app/*.py file is a code difference. The diff
|
||||
against BASE_REF may touch only goodwe_controller/app/*.py,
|
||||
goodwe_controller/DECISIONS/*.md and this script. Also prints code lines vs
|
||||
comment-only + docstring lines (whole lines, via tokenize) before and after,
|
||||
and the docstrings removed entirely or added. Run from the repository root.
|
||||
Exit 1 on any difference.
|
||||
removed must be identical at BASE_REF and in the working tree. Also prints
|
||||
code lines vs comment-only + docstring lines (whole lines, via tokenize) before
|
||||
and after. Run from the repository root. Exit 1 on any code difference.
|
||||
"""
|
||||
import ast
|
||||
import io
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tokenize
|
||||
@@ -60,87 +53,29 @@ def counts(src):
|
||||
return len(code), len(doc) + len(com - code - doc)
|
||||
|
||||
|
||||
def qualnames(src):
|
||||
out = {}
|
||||
|
||||
def walk(node, prefix):
|
||||
for n in ast.iter_child_nodes(node):
|
||||
if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef,
|
||||
ast.ClassDef)):
|
||||
q = prefix + n.name
|
||||
out[q] = docstring(n) is not None
|
||||
walk(n, q + ".")
|
||||
out["<module>"] = docstring(ast.parse(src)) is not None
|
||||
walk(ast.parse(src), "")
|
||||
return out
|
||||
|
||||
|
||||
def code_sig(co):
|
||||
consts = tuple(code_sig(c) if hasattr(c, "co_code") else c
|
||||
for c in co.co_consts)
|
||||
return (co.co_name, co.co_code, consts, co.co_names, co.co_varnames,
|
||||
co.co_freevars, co.co_cellvars, co.co_flags, co.co_argcount,
|
||||
co.co_stacksize, co.co_exceptiontable)
|
||||
|
||||
|
||||
def oo_same(old, new):
|
||||
return (code_sig(compile(old, "f", "exec", optimize=2))
|
||||
== code_sig(compile(new, "f", "exec", optimize=2)))
|
||||
|
||||
|
||||
def git(*a):
|
||||
return subprocess.run(["git", *a], check=True, capture_output=True,
|
||||
text=True).stdout
|
||||
|
||||
|
||||
def exists(base, f):
|
||||
return subprocess.run(["git", "cat-file", "-e", f"{base}:{f}"],
|
||||
capture_output=True).returncode == 0
|
||||
|
||||
|
||||
ALLOWED = re.compile(r"^goodwe_controller/(app/[^/]+\.py|DECISIONS/[^/]+\.md|"
|
||||
r"tools/maint20_same_code\.py)$")
|
||||
|
||||
|
||||
def ratio(m, c):
|
||||
return f"{m / c:.2f}" if c else "n/a"
|
||||
|
||||
|
||||
def main(argv):
|
||||
base = argv[0] if argv else "release/1.0"
|
||||
files = argv[1:] or [
|
||||
f for f in git("diff", "--name-only", base, "--",
|
||||
"goodwe_controller/app").split()
|
||||
if f.endswith(".py")]
|
||||
bad = 0
|
||||
print(f"base {base} = {git('rev-parse', base).strip()}")
|
||||
changed = git("diff", "--name-only", base).split()
|
||||
for f in changed:
|
||||
if not ALLOWED.match(f):
|
||||
print(f"FAIL: diff touches {f}, outside app/*.py, DECISIONS/*.md "
|
||||
"and this script")
|
||||
bad += 1
|
||||
files = argv[1:] or [f for f in changed if re.match(
|
||||
r"^goodwe_controller/app/[^/]+\.py$", f)]
|
||||
for f in files:
|
||||
in_base, in_head = exists(base, f), os.path.exists(f)
|
||||
if in_base != in_head:
|
||||
print(f"CODE DIFFERS {f} "
|
||||
f"{'removed' if in_base else 'added'} file")
|
||||
bad += 1
|
||||
continue
|
||||
old = git("show", f"{base}:{f}")
|
||||
new = open(f, encoding="utf-8").read()
|
||||
same = dump(old) == dump(new)
|
||||
oo = oo_same(old, new)
|
||||
(c0, m0), (c1, m1) = counts(old), counts(new)
|
||||
print(f"{'SAME CODE' if same else 'CODE DIFFERS'} {f} "
|
||||
f"code {c0}->{c1} comment+doc {m0}->{m1} "
|
||||
f"ratio {ratio(m0, c0)}->{ratio(m1, c1)} "
|
||||
f"-OO {'same' if oo else 'DIFFERS'}")
|
||||
bad += (not same) + (not oo)
|
||||
qo, qn = qualnames(old), qualnames(new)
|
||||
for q in sorted(q for q in qo if qo[q] and not qn.get(q, True)):
|
||||
print(f" docstring REMOVED: {f}:{q}")
|
||||
for q in sorted(q for q in qn if qn[q] and not qo.get(q, True)):
|
||||
print(f" docstring ADDED: {f}:{q}")
|
||||
print("OK: no code change" if not bad else f"FAIL: {bad} problem(s)")
|
||||
f"ratio {m0 / c0:.2f}->{m1 / c1:.2f}")
|
||||
bad += not same
|
||||
print("OK: no code change" if not bad else f"FAIL: {bad} file(s) differ")
|
||||
return 1 if bad else 0
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user