Quick overview:
goodwe-manager.mp4
- Create venv
python3 -m venv venvand activate it. venv/bin/activate - Install dependencies
pip install -r requirements.txt - Copy
.env.exampleto.envand fill in the values - Replace the rest of
poor-man's configinmain.py - Run with
python main.py(TODO: instructions for gunicorn) - Run with
--dry-runparameter to disable communication with the inverter (used for testing)
Pulling a checkout that's behind may require a one-off migration step - see
CHANGELOG.md for what changed and why. Most notably: if
you have old data-*.csv files from before the switch to SQLite, run
python _migrate_csv_to_sqlite.py once to import them into data.db
(safe to re-run; already-migrated files are skipped).
_calculate_income.py and the MQTT bridge (see PR #35) can price
imported energy using a real time-of-use tariff schedule
instead of a flat rate. Copy an example from tariff_examples/ (e.g.
g12w_pge.yaml) to tariff_import.yaml (already gitignored), fill in
your actual rates, and set TARIFF_IMPORT_CONFIG=tariff_import.yaml in
.env. Without this set, both keep using the flat IMPORT_PRICE_KWH
constant, unchanged from today's behavior. See TARIFF_SCHEMA.md for
the full YAML schema.
Set MQTT_HOST (and MQTT_USERNAME/MQTT_PASSWORD if your broker
requires auth) in .env to publish live telemetry, daily energy
counters, RCE export prices, tariff-based import prices (if
TARIFF_IMPORT_CONFIG is also set), and the combined PV forecast to your
MQTT broker. Unset MQTT_HOST (the default) disables this entirely - no
behavior change from before this feature existed. See MQTT_TOPICS.md
for the full topic/payload list and Home Assistant/Predbat setup.
The daily-reset energy counters (e_day_exp, e_day_imp, e_load_day,
e_bat_charge_day, e_bat_discharge_day) are published live but
deliberately not written to data.db - they reset every midnight,
so a lifetime of DB rows for them adds nothing that the lifetime
counters (e_total, etc., which are persisted) don't already give
historical analysis. See sensors.py's DB_SENSORS.
RCE export prices publish at 15-minute granularity by default (the
market's own settlement period). Set RCE_EXPORT_GRANULARITY=hourly to
average each hour's four quarters first instead, matching how PGE
settles prosument export credit on an hourly basis.
A negative RCE price publishes as 0.0 by default (RCE_EXPORT_NEGATIVE_PRICES=zero),
matching net-billing paying nothing for it. Set RCE_EXPORT_NEGATIVE_PRICES=raw
to publish the true negative value instead - note the 23% VAT bonus is
never applied to a negative price either way, since the law doesn't
define a bonus on a loss.
Everything this bridge publishes stays in zł, unconverted - Predbat's own cost-optimization math (comparing import cost vs. export revenue to plan actions) works identically regardless of currency scale, as long as import and export use the same unit. The one place scale genuinely matters is Predbat's absolute-currency threshold settings, tuned against its own pence-scale examples:
metric_min_improvement(default0.0) - minimum cost improvement worth lowering the battery SoC for.metric_min_improvement_export(default0.1) - minimum cost improvement worth a forced export for.metric_min_improvement_export_freeze(default0.1) - minimum cost improvement worth freezing an export for.metric_min_improvement_plan(default2.0) - minimum cost improvement worth changing the whole plan for.
Left at their pence-tuned defaults against zł-scale prices (roughly
0.3-1.3 zł/kWh here), thresholds like 0.1 stop meaning "negligible"
and start meaning "10-30% of a typical price difference," which can make
Predbat silently skip real, worthwhile charge/discharge/export
decisions. Set these explicitly to a zł-appropriate value (e.g. 0.001
in place of 0.1) in Predbat's apps.yaml rather than relying on the
shipped defaults - this is a Predbat-side config note, not something
goodwe_manager can fix on its end.
_estimate_battery_efficiency.py is a one-off, manually-run offline
analysis (not part of the live app - see "Documentation conventions"
below on the _-prefix convention) that estimates Predbat's
battery_loss/battery_loss_discharge/inverter_loss config values
from this household's actual inverter_history, instead of guessing at
generic li-ion defaults. Run it against data.db (or a bounded extract
of it - see --db-path) and read the printed report; nothing writes to
Predbat's config automatically. See GOODWE_SENSOR_NOTES.md for the
methodology and the sensor-level quirks (BMS self-consumption offset,
grid phase sign disagreement, cross-register poll timing skew) it
depends on and works around.
With the MQTT bridge enabled, CONTROL_MODE lets an optimizer drive the
battery through MQTT commands (control/set, see MQTT_TOPICS.md for the
payloads, the Home Assistant script and the Predbat apps.yaml example).
off (default) changes nothing; shadow computes and publishes what it
would do in control/state but never writes; on writes to the inverter.
CONTROL_CHARGE_CURRENT_A, CONTROL_DISCHARGE_CURRENT_A and
CONTROL_MIN_SOC are your normal battery current limits and on-grid
minimum SoC - required when not off, and restored whenever the battery
isn't frozen.
| Mode | What the inverter does (measured on a GW8KN-ET) |
|---|---|
auto |
normal self-use (EMS AUTO) |
charge P |
battery charges at exactly P W; shortfall from the grid |
export P |
battery discharges at exactly P W; PV not curtailed, the rest is exported |
freeze_charge |
no discharge (on-grid minimum SoC raised to the current SoC); surplus PV still charges |
freeze_export |
no charging (charge current 0); surplus exported, battery covers the deficit |
Fail-safe rules: every command expires (at most 60 min ahead) and falls
back to auto; a restart falls back to auto; a grid outage switches to
auto with the normal limits until 60 s after the grid is back. There is
no inverter-side watchdog on this firmware, so if the Pi dies the inverter
keeps the last mode.
Before on: disable the eco mode slots (they still act in auto; enabled
ones show up as warnings), and while control is on, change the on-grid
minimum SoC through CONTROL_MIN_SOC, not SolarGo - the executor rewrites
it. To change other settings in SolarGo, set a dashboard override to auto
first, otherwise an active command is re-applied within ~10 s.
OFF_GRID_SHADOW_SCAN_GUARD=on turns the inverter's shadow scan (PV global
MPPT scan) off as soon as the inverter goes off-grid - it misbehaves there -
and puts the previous value back once the grid has stayed up for
OFF_GRID_SHADOW_SCAN_COOLDOWN_MIN (default 15): outages come in bursts and
the grid often drops again minutes after returning. If shadow scan was
already off, nothing is written. Independent of CONTROL_MODE. The pending
restore is kept in shadow_scan_guard.json (git-ignored), so a restart
mid-outage still puts it back. A manual change on the /config page during
an outage or the cooldown is replaced by the saved value when it's restored.
Data is pulled every few seconds from the inverter in the local network using the Goodwe API
and streaming data to the frontend using Server Sent Events (SSE) for real-time updates.
Data is written into a SQLite database (data.db, inverter_history table) and can be processed later
by some scripts calculating the cost savings. Older data-xxxx-xx-xx_xx-xx-xx.csv files are legacy/historical
only - the app no longer writes to them (see "Upgrading" above for importing them).
See GOODWE_SENSOR_NOTES.md before writing anything that computes energy/power
balances across PV, battery, grid, and load fields - several of the raw sensor
names are easy to misread (which field is actually the grid meter, sign
conventions the goodwe library leaves undocumented, real hardware noise/quirks).
Design docs for a feature under development live in docs/superpowers/{specs,plans,notes}/
(dated filenames, e.g. docs/superpowers/notes/2026-09-11-diagram-mockup-checklist.md).
Once the feature is implemented and merged, its spec/plan get deleted in a follow-up
commit - the code and tests are the source of truth for current behavior, and the
design rationale/trade-offs stay recoverable via that PR's commit history rather than
bloating main's tree with docs that will otherwise silently go stale. notes/ (recorded
investigations/decisions, e.g. bug root-causes, calibration data) are longer-lived than
specs/plans and are kept unless truly obsolete.
Caveat: if you delete a spec/plan, grep the repo for links to it first (e.g. this file used to link straight to a since-deleted sqlite storage spec) - a dangling reference is easy to miss and won't fail any build.
Scripts starting from underscore _ are not used by the main application, they are some drafts, experiments or utils.
Dates for script input can be specified in formats: YYYY-MM-DD or DD.MM.YYYY.
There are a lot of poor-man's solutions, quick hacks and bad conventions in the code that should be fixed. Some of them include:
- mixing async (Goodwe API) and sync (Flask) code - async code runs in a separate thread, this might cause unexpected problems, although a special care has been taken to ensure proper synchronization between them and finalizing on exit - a solution would be to use an alternative to Flask that supports async (and also supports Server Sent Events)
- PV forecast assumes that there are two PV strings with the same peak power and tilt but with a different orientation angle (basically east-west configuration) and that logic is quite hard-coded even though it looks like it's configurable
- too much code in main.py - needs to be extracted for readability and maintainability
- Jinja templates use a lot of code repetition - needs to extract meta-templates and macros
TODO: instructions for running as a linux service