diff --git a/.env b/.env index 4e1acd8..9be8d6d 100644 --- a/.env +++ b/.env @@ -9,8 +9,13 @@ CERBO_AC_ACTIVEIN_TOPIC=N/48e7da878d35/vebus/276/Ac/ActiveIn CERBO_ENABLE_PV_SLAVE=1 # RTU slave address for the virtual PV meter used by Maxem autoconfig (typically 001). CERBO_PV_TARGET_SLAVE=1 -# Comma-separated PV power topics (W). Values are summed into realtime total PV production. -CERBO_PV_TOPICS=N/48e7da878d35/solarcharger/283/Pv/0/P,N/48e7da878d35/solarcharger/282/Pv/0/P,N/48e7da878d35/solarcharger/282/Pv/1/P +# Comma-separated PV power topics (W). Default uses the Victron system total PV power topic. +CERBO_PV_TOPICS=N/48e7da878d35/system/0/Dc/Pv/Power +# When 1, emit slave 001 PV power as negative watts; when 0, emit positive watts. +CERBO_PV_SIGN_NEGATIVE=0 +# When 1, subtract PV total watts from the home/grid rewrite on slave 100 before encoding. +# Slave 100 power words are encoded unsigned; negative results are clamped to 0. +CERBO_SUBTRACT_PV_FROM_HOME_USAGE=0 # How to fill phase power registers (0x5B16..0x5B1B): # - activein: use Cerbo Ac/ActiveIn L1/L2/L3 watts (can go negative on export). # - acout: derive watts from ABB phase voltage * Cerbo Ac/Out phase current (non-negative, often best for fuse/load behavior). @@ -20,6 +25,10 @@ CERBO_PHASE_POWER_SOURCE=activein # - activein mode: first net exports against imports across phases, then clamp to >=0. # - other modes: clamp each phase to >=0. CERBO_FORCE_NONNEGATIVE_PHASE_POWER=1 +# When 1, allow slave 100 instantaneous active-power words to be signed. +# In this mode the total is written signed and the phase words are split evenly +# from that signed total instead of being clamped to >=0. +CERBO_ALLOW_SIGNED_INSTANTANEOUS_POWER=0 # When 1, publish MQTT snapshot only after a coherent 3-phase frame is seen for both ActiveIn and Ac/Out. CERBO_COHERENT_PHASE_FRAMES=1 # Max allowed L1/L2/L3 timestamp skew (seconds) inside a coherent frame; frame is skipped if exceeded. @@ -31,7 +40,7 @@ SYNTHETIC_HOME_STATE_FILE=/tmp/modbus-softsplit-state.json # 1 = default to dry-run mode (no RTU open, preview logs only); 0 = normal live mode. DRY_RUN_MAXEM_HOME=0 # INFO by default; use DEBUG to print ABB/Cerbo preview lines. -LOG_LEVEL=DEBUG +LOG_LEVEL=INFO # 1 enables verbose paho MQTT protocol wire logs (CONNECT/SUBSCRIBE/PUBLISH). Keep 0 for readable DEBUG logs. CERBO_MQTT_PROTOCOL_DEBUG=0 # DEBUG snapshot spam control: 0 disables per-snapshot debug lines; >0 logs at most once per interval in seconds. diff --git a/README.md b/README.md index 2a87e13..c8ef5ea 100644 --- a/README.md +++ b/README.md @@ -50,8 +50,8 @@ your specific situation. These lines are emitted at `DEBUG` level (set `LOG_LEVEL=DEBUG` when you want them). - The active rewrite story now reads directly from Victron CerboGX MQTT (read-only) and rewrites selected words in ABB `instantaneous_values` while mirroring all other words verbatim. - - `active_power_total` (`0x5B14/0x5B15`) is sourced from `Ac/ActiveIn` total watts. - - `active_power_l1/l2/l3` (`0x5B16..0x5B1B`) follow `CERBO_PHASE_POWER_SOURCE`. + - `active_power_total` (`0x5B14/0x5B15`) is sourced from `Ac/ActiveIn` total watts and encoded as unsigned on slave `100` (negative clamped to `0`). + - `active_power_l1/l2/l3` (`0x5B16..0x5B1B`) follow `CERBO_PHASE_POWER_SOURCE` and are encoded as unsigned on slave `100` (per-phase negative clamped to `0`). - `current_l1/l2/l3/n` (`0x5B0C..0x5B13`) are sourced from `Ac/Out` phase currents. - `Ac/ActiveIn` values may be positive or negative; `Ac/Out` currents are clamped to non-negative values. - `CERBO_PHASE_POWER_SOURCE` can pivot phase-power behavior without code edits: @@ -61,12 +61,19 @@ your specific situation. - `abb` leaves phase-power words unchanged from ABB passthrough. - `CERBO_COHERENT_PHASE_FRAMES=1` publishes snapshots only after complete 3-phase updates for both `Ac/ActiveIn` and `Ac/Out`, reducing mixed-time phase combinations. + - `CERBO_ALLOW_SIGNED_INSTANTANEOUS_POWER=1` enables signed `instantaneous_values` rewrites on slave `100` for the active-power words only. In that mode `active_power_total` is written signed and `active_power_l1/l2/l3` are a signed equal split of the same total. - Optional virtual PV meter emulation for Maxem slave `001` is available via: - `CERBO_ENABLE_PV_SLAVE=1` - `CERBO_PV_TARGET_SLAVE=1` - `CERBO_PV_TOPICS=` - The PV total is summed from those topics and written to the `instantaneous_values` active-power words on slave `001`. - Phase power/current words for slave `001` are synthesized coherently from that total (equal split by phase, amps from ABB phase voltage). + - `CERBO_PV_SIGN_NEGATIVE=1` + - `CERBO_SUBTRACT_PV_FROM_HOME_USAGE=0` + The default topic is `N/48e7da878d35/system/0/Dc/Pv/Power`. + The PV total is summed from configured topic(s) and written to the `instantaneous_values` active-power words on slave `001`. + Slave `001` is currently modeled as single-phase: `active_power_total` is written as PV watts with the sign controlled by `CERBO_PV_SIGN_NEGATIVE`, mirrored to `active_power_l1`, with `active_power_l2/l3=0`. + `CERBO_PV_SIGN_NEGATIVE` controls whether slave `001` emits PV power as negative (`1`) or positive (`0`). + When `CERBO_SUBTRACT_PV_FROM_HOME_USAGE=1`, PV watts are subtracted from slave `100` home/grid rewrite watts before unsigned encoding (floored at `0`). + For this test model, non-instantaneous blocks on slave `001` are not mirrored from slave `100`. This keeps Maxem home/grid power semantics aligned with grid import/export while preserving AC-out current safety inputs used for EV phase protection. - The dry-run logger prints one semantic line before the preview values so it is obvious that the preview is the @@ -100,7 +107,9 @@ your specific situation. - Cerbo MQTT poller is read-only and subscribes to: - `CERBO_AC_OUT_TOPIC` (default `N/48e7da878d35/vebus/276/Ac/Out`) - `CERBO_AC_ACTIVEIN_TOPIC` (default `N/48e7da878d35/vebus/276/Ac/ActiveIn`) - - `CERBO_PV_TOPICS` (default: three configured `solarcharger/.../Pv/.../P` topics; summed for virtual PV meter power) + - `CERBO_PV_TOPICS` (default: `N/48e7da878d35/system/0/Dc/Pv/Power`; summed for virtual PV meter power) + - `CERBO_PV_SIGN_NEGATIVE` (default: `1`; emit slave `001` PV watts as negative when set, positive when `0`) + - `CERBO_SUBTRACT_PV_FROM_HOME_USAGE` (current test path: `0`; set to `1` only when you want PV offset applied to slave `100`) - At `DEBUG` level the runtime logs snapshot updates from MQTT and preview lines (`ABB source` / `Cerbo ... to Maxem`). - To keep DEBUG readable by default: - `CERBO_MQTT_PROTOCOL_DEBUG=0` suppresses raw paho wire logs (`Sending CONNECT`, `Received PUBLISH`, etc). diff --git a/docs/decisions/project-conventions.md b/docs/decisions/project-conventions.md index d398228..48780a6 100644 --- a/docs/decisions/project-conventions.md +++ b/docs/decisions/project-conventions.md @@ -13,12 +13,10 @@ operator-facing assumptions for `modbus-softsplit`. - active-in topic base `N/48e7da878d35/vebus/276/Ac/ActiveIn` - ac-out topic base `N/48e7da878d35/vebus/276/Ac/Out` - pv topic list (`CERBO_PV_TOPICS`) defaults to: - - `N/48e7da878d35/solarcharger/283/Pv/0/P` - - `N/48e7da878d35/solarcharger/282/Pv/0/P` - - `N/48e7da878d35/solarcharger/282/Pv/1/P` + - `N/48e7da878d35/system/0/Dc/Pv/Power` - Power rewrite source: - `Ac/ActiveIn/L1|L2|L3/P` -> active power total + per phase words. - - Values may be positive or negative. + - Source values may be positive or negative, but slave `100` writes are unsigned-clamped. - `CERBO_PHASE_POWER_SOURCE` controls phase-power words (`0x5B16..0x5B1B`): - `activein` -> phase power from Cerbo `Ac/ActiveIn` (signed). - `acout` -> phase power derived from ABB phase voltages and Cerbo `Ac/Out` currents. @@ -41,16 +39,30 @@ operator-facing assumptions for `modbus-softsplit`. - `0x5B14/0x5B15` active power total from Cerbo `Ac/ActiveIn`. - `0x5B16/0x5B17`, `0x5B18/0x5B19`, `0x5B1A/0x5B1B` active power per-phase from `CERBO_PHASE_POWER_SOURCE`. +- On slave `100`, all rewritten current/power fields are written as unsigned values: + - total power negative values are clamped to `0`. + - phase power negative values are clamped to `0`. + - currents are clamped to `>=0`. +- `CERBO_ALLOW_SIGNED_INSTANTANEOUS_POWER=1` is an opt-in diagnostic mode for slave `100` instantaneous active-power words only: + - `0x5B14/0x5B15` is written as signed total power. + - `0x5B16..0x5B1B` are written as a signed equal split of that same total. + - current words remain unsigned and still come from Cerbo `Ac/Out`. - All other words in `instantaneous_values` and all other Maxem register blocks are mirrored unchanged from the ABB source. - Optional PV meter emulation (`CERBO_ENABLE_PV_SLAVE=1`) publishes a virtual Maxem-compatible slave (default address `001` via `CERBO_PV_TARGET_SLAVE`): - - all Maxem register blocks mirror ABB source values by default. + - non-instantaneous blocks are not mirrored from slave `100` (kept independent/zeroed unless explicitly synthesized). - in `instantaneous_values`, slave `001` rewrites: - - `0x5B14/0x5B15` to summed PV watts from `CERBO_PV_TOPICS`. - - `0x5B16..0x5B1B` to an equal 3-phase split of that total. - - `0x5B0C..0x5B13` to derived non-negative phase currents from - rewritten phase watts and ABB phase voltages; neutral current is `0`. + - `0x5B14/0x5B15` to PV watts from `CERBO_PV_TOPICS` with sign controlled by `CERBO_PV_SIGN_NEGATIVE`. + - `0x5B16/0x5B17` to the same sign-controlled single-phase L1 value. + - `0x5B18..0x5B1B` to `0` (L2/L3 power words). + - current words are not rewritten by the PV helper in this model. +- Optional home offset mode (`CERBO_SUBTRACT_PV_FROM_HOME_USAGE=1`) rewrites + slave `100` home/grid instantaneous active power as: + - `home_usage_watts - pv_total_watts` before unsigned encoding. + - write result to `active_power_total` with floor at `0`. + - write phase powers with per-phase floor at `0`. + - The current 100/001 split test path keeps this disabled (`CERBO_SUBTRACT_PV_FROM_HOME_USAGE=0`). - Preview logs should stay short and verifiable: - `ABB source: X W` - `Cerbo Usage to Maxem: Y W` @@ -91,7 +103,8 @@ operator-facing assumptions for `modbus-softsplit`. - Startup should log effective Cerbo MQTT source settings and source precedence (`env` vs `.env` vs defaults). - Startup should log effective PV slave settings: - `CERBO_ENABLE_PV_SLAVE`, `CERBO_PV_TARGET_SLAVE`, and `CERBO_PV_TOPICS`. + `CERBO_ENABLE_PV_SLAVE`, `CERBO_PV_TARGET_SLAVE`, `CERBO_PV_TOPICS`, + `CERBO_PV_SIGN_NEGATIVE`, and `CERBO_SUBTRACT_PV_FROM_HOME_USAGE`. - `CERBO_MQTT_PROTOCOL_DEBUG=0` should remain default so DEBUG logs stay operator-readable; enable only during MQTT wire troubleshooting. - `CERBO_MQTT_SNAPSHOT_DEBUG_INTERVAL_SECONDS=0` should remain default to prevent per-message snapshot log flooding. - The serving loop should remain timing-safe for the RTU client. diff --git a/docs/registers_rewritting_on_ABB_slave.txt b/docs/registers_rewritting_on_ABB_slave.txt new file mode 100644 index 0000000..fb5c14f --- /dev/null +++ b/docs/registers_rewritting_on_ABB_slave.txt @@ -0,0 +1,49 @@ +Current rewrite model + +Slave 100 (ABB/Home meter path) + +Rewritten words in `instantaneous_values`: + +- `0x5B14/0x5B15` (`active_power_total`, 0.01 W): + - Source intent: Cerbo usage (with optional PV subtraction before encoding). + - Encoding mode: unsigned write (negative values are clamped to `0`). + +- `0x5B16/0x5B17` (`active_power_l1`, 0.01 W) +- `0x5B18/0x5B19` (`active_power_l2`, 0.01 W) +- `0x5B1A/0x5B1B` (`active_power_l3`, 0.01 W) + - Source intent: phase usage from configured phase source. + - Encoding mode: unsigned write (each phase clamped to `>= 0`). + +- `0x5B0C/0x5B0D` (`current_l1`, unsigned, 0.01 A) +- `0x5B0E/0x5B0F` (`current_l2`, unsigned, 0.01 A) +- `0x5B10/0x5B11` (`current_l3`, unsigned, 0.01 A) +- `0x5B12/0x5B13` (`current_n`, unsigned, 0.01 A) + - Source: Cerbo `Ac/Out` currents. + - Encoding mode: unsigned write (non-negative clamp). + +Optional diagnostic mode: + +- `CERBO_ALLOW_SIGNED_INSTANTANEOUS_POWER=1` + - `0x5B14/0x5B15` stays signed total power. + - `0x5B16..0x5B1B` become a signed equal split of that same total. + - Current words stay unsigned and are still sourced from Cerbo `Ac/Out`. + +All other words for slave `100` are mirrored from upstream ABB. + +Slave 001 (PV virtual meter path) + +Rewritten words in `instantaneous_values`: + +- `0x5B14/0x5B15` (`active_power_total`, signed, 0.01 W): + - Value: PV total from configured Cerbo PV topic(s), with sign controlled by `CERBO_PV_SIGN_NEGATIVE`. + +- `0x5B16/0x5B17` (`active_power_l1`, signed, 0.01 W): + - Value: same sign-controlled value as `active_power_total` (single-phase mirror). + +- `0x5B18/0x5B19` (`active_power_l2`, signed, 0.01 W): + - Value: `0`. + +- `0x5B1A/0x5B1B` (`active_power_l3`, signed, 0.01 W): + - Value: `0`. + +Current words on slave `001` are not rewritten by the PV helper in this model. diff --git a/docs/runbooks/maxem-live-validation.md b/docs/runbooks/maxem-live-validation.md index 65b7c6a..bdc6e76 100644 --- a/docs/runbooks/maxem-live-validation.md +++ b/docs/runbooks/maxem-live-validation.md @@ -6,7 +6,7 @@ Validate that Maxem sees: - Home/grid power driven by Cerbo `Ac/ActiveIn` rewrite values. - Phase current safety behavior driven by Cerbo `Ac/Out` current signals. -- (Optional) Solar meter slave `001` powered by summed Cerbo `solarcharger/.../Pv/.../P` topics. +- (Optional) Solar meter slave `001` powered by Cerbo `N/48e7da878d35/system/0/Dc/Pv/Power`. ## Preconditions @@ -17,12 +17,16 @@ Validate that Maxem sees: - If phase sign behavior is under investigation, explicitly record: - `CERBO_PHASE_POWER_SOURCE` - `CERBO_FORCE_NONNEGATIVE_PHASE_POWER` + - `CERBO_ALLOW_SIGNED_INSTANTANEOUS_POWER` + - `CERBO_SUBTRACT_PV_FROM_HOME_USAGE` - `CERBO_COHERENT_PHASE_FRAMES` - `CERBO_COHERENT_PHASE_FRAME_MAX_SKEW_SECONDS` - If PV virtual meter is enabled, explicitly record: - `CERBO_ENABLE_PV_SLAVE` - `CERBO_PV_TARGET_SLAVE` - `CERBO_PV_TOPICS` + - `CERBO_PV_SIGN_NEGATIVE` + - whether non-instantaneous slave `001` blocks are intentionally left unsynthesized (current default behavior). ## Step 1: Offline Sanity Capture @@ -45,7 +49,9 @@ Expected: - `0x5B14, 0x5B15` (total power) - `0x5B16..0x5B1B` (phase L1/L2/L3 power) - Voltage fields remain unchanged unless source changed. -- Power rewrite values can be positive or negative (Cerbo `Ac/ActiveIn`). +- On slave `100`, power rewrite writes are unsigned-clamped (`<0` -> `0`). +- If `CERBO_ALLOW_SIGNED_INSTANTANEOUS_POWER=1`, slave `100` active-power words are signed and the phase words are an equal split of the same total. +- For the current 100/001 split test path, keep `CERBO_SUBTRACT_PV_FROM_HOME_USAGE=0`. - Current rewrite values are clamped to non-negative (Cerbo `Ac/Out`). ## Step 2: Live Runtime with Trace @@ -82,7 +88,11 @@ When PV virtual meter is enabled: - Confirm Maxem autoconfig finds kWh meter address `001`. - Confirm logs show `Cerbo PV to Maxem (slave 001): ...`. -- Confirm only slave `001` instantaneous words are rewritten for PV semantics; other blocks remain mirrored. +- Confirm slave `001` PV semantics: + - `active_power_total` follows `CERBO_PV_SIGN_NEGATIVE`. + - `active_power_l1` mirrors that same sign choice. + - `active_power_l2/l3` are `0`. +- Confirm only slave `001` instantaneous words are rewritten for PV semantics; non-instantaneous blocks are not mirrored from slave `100`. ## Step 4: Long-Run Observation @@ -110,3 +120,4 @@ Validation pass is considered successful when: - Rewritten words match design (`0x5B0C..0x5B1B` for current+active-power fields only). - Maxem dashboard behavior aligns with intended Home/Grid semantics across multiple load conditions. - Service remains stable over long-running periods. +- If you are testing the signed instantaneous mode, repeat the live trace once with `CERBO_ALLOW_SIGNED_INSTANTANEOUS_POWER=1` and compare it to the unsigned baseline. diff --git a/lib/maxem_home_usage.py b/lib/maxem_home_usage.py index 50f59fd..4b9259d 100644 --- a/lib/maxem_home_usage.py +++ b/lib/maxem_home_usage.py @@ -620,8 +620,11 @@ def _format_value(value: float | None, unit: str) -> str: def describe_instantaneous_preview_basis() -> str: return ( - "Preview basis: active_power_total (0x5B14/0x5B15) is rewritten from Cerbo Ac/ActiveIn total watts. " - "active_power_l1/l2/l3 (0x5B16..0x5B1B) follow CERBO_PHASE_POWER_SOURCE mode (activein, acout-derived, or abb passthrough). " + "Preview basis: on slave 100, active_power_total (0x5B14/0x5B15) is rewritten from Cerbo Ac/ActiveIn total watts. " + "It is unsigned by default and can be signed when CERBO_ALLOW_SIGNED_INSTANTANEOUS_POWER=1. " + "When signed mode is enabled, active_power_l1/l2/l3 (0x5B16..0x5B1B) are a signed equal split of the total. " + "When signed mode is disabled, active_power_l1/l2/l3 follow CERBO_PHASE_POWER_SOURCE mode and are encoded unsigned per-phase. " + "On slave 001, PV power sign is controlled by CERBO_PV_SIGN_NEGATIVE. " "current_l1/l2/l3/n (0x5B0C..0x5B13) are rewritten from Cerbo Ac/Out phase currents with non-negative clamp. " "All other registers in instantaneous_values are copied verbatim from the ABB source." ) @@ -852,18 +855,23 @@ def rewrite_pv_instantaneous_values( source_values: Sequence[int], *, pv_total_watts: float | None, + pv_negative: bool = True, ) -> tuple[int, ...]: - phase_watts = split_total_watts_evenly(pv_total_watts) - phase_currents = derive_phase_currents_from_watts(source_values, phase_watts) - total_watts = 0.0 if pv_total_watts is None else max(float(pv_total_watts), 0.0) + pv_watts = 0.0 if pv_total_watts is None else max(float(pv_total_watts), 0.0) + total_watts = -pv_watts if pv_negative else pv_watts + # Single-phase PV model for Maxem slave 001: + # publish total PV as signed-negative or positive power and mirror it onto L1 only. + phase_watts = ( + total_watts, + 0.0, + 0.0, + ) return rewrite_instantaneous_values( source_values, usage_watts=total_watts, phase_usage_watts=phase_watts, - phase_current_amps=phase_currents, - current_n_amps=0.0, - allow_negative=False, - allow_negative_phase=False, + allow_negative=pv_negative, + allow_negative_phase=pv_negative, ) diff --git a/main.py b/main.py index 2491c30..195787c 100755 --- a/main.py +++ b/main.py @@ -19,7 +19,6 @@ CerboMqttPoller, INSTANTANEOUS_VALUES_REGISTER_NAME, describe_instantaneous_preview_basis, - split_total_watts_evenly, derive_phase_watts_from_currents, format_instantaneous_diff_lines, format_instantaneous_preview_lines, @@ -104,10 +103,13 @@ def _parse_args(argv=None): CERBO_AC_ACTIVEIN_TOPIC = _get_setting("CERBO_AC_ACTIVEIN_TOPIC", "N/48e7da878d35/vebus/276/Ac/ActiveIn") CERBO_PV_TOPICS = _parse_csv_setting( "CERBO_PV_TOPICS", - "N/48e7da878d35/solarcharger/283/Pv/0/P,N/48e7da878d35/solarcharger/282/Pv/0/P,N/48e7da878d35/solarcharger/282/Pv/1/P", + "N/48e7da878d35/system/0/Dc/Pv/Power", ) CERBO_ENABLE_PV_SLAVE = _parse_bool_setting("CERBO_ENABLE_PV_SLAVE", "1") CERBO_PV_TARGET_SLAVE = max(int(_get_setting("CERBO_PV_TARGET_SLAVE", "1")), 1) +CERBO_PV_SIGN_NEGATIVE = _parse_bool_setting("CERBO_PV_SIGN_NEGATIVE", "1") +CERBO_ALLOW_SIGNED_INSTANTANEOUS_POWER = _parse_bool_setting("CERBO_ALLOW_SIGNED_INSTANTANEOUS_POWER", "0") +CERBO_SUBTRACT_PV_FROM_HOME_USAGE = _parse_bool_setting("CERBO_SUBTRACT_PV_FROM_HOME_USAGE", "1") CERBO_PHASE_POWER_SOURCE = _normalize_phase_power_source(_get_setting("CERBO_PHASE_POWER_SOURCE", "activein")) CERBO_FORCE_NONNEGATIVE_PHASE_POWER = _parse_bool_setting("CERBO_FORCE_NONNEGATIVE_PHASE_POWER", "0") CERBO_COHERENT_PHASE_FRAMES = _parse_bool_setting("CERBO_COHERENT_PHASE_FRAMES", "1") @@ -209,6 +211,7 @@ def _log_source_effective_config() -> None: ( "Cerbo MQTT source: host=%s(%s) port=%s(%s) ac_out_topic=%s(%s) ac_activein_topic=%s(%s) " "pv_topics=%s(%s) pv_slave_enabled=%s(%s) pv_target_slave=%s(%s) " + "pv_sign_negative=%s(%s) signed_instantaneous_power=%s(%s) subtract_pv_from_home_usage=%s(%s) " "phase_power_source=%s(%s) clamp_negative_phase_power=%s(%s) " "coherent_phase_frames=%s(%s) coherent_phase_frame_max_skew_seconds=%.2f(%s) " "protocol_debug=%s(%s) snapshot_debug_interval_seconds=%.2f(%s)" @@ -227,6 +230,12 @@ def _log_source_effective_config() -> None: _get_setting_source("CERBO_ENABLE_PV_SLAVE"), CERBO_PV_TARGET_SLAVE, _get_setting_source("CERBO_PV_TARGET_SLAVE"), + int(CERBO_PV_SIGN_NEGATIVE), + _get_setting_source("CERBO_PV_SIGN_NEGATIVE"), + int(CERBO_ALLOW_SIGNED_INSTANTANEOUS_POWER), + _get_setting_source("CERBO_ALLOW_SIGNED_INSTANTANEOUS_POWER"), + int(CERBO_SUBTRACT_PV_FROM_HOME_USAGE), + _get_setting_source("CERBO_SUBTRACT_PV_FROM_HOME_USAGE"), CERBO_PHASE_POWER_SOURCE, _get_setting_source("CERBO_PHASE_POWER_SOURCE"), int(CERBO_FORCE_NONNEGATIVE_PHASE_POWER), @@ -291,6 +300,14 @@ def _resolve_phase_usage_watts_for_rewrite( def _allow_negative_phase_power_for_rewrite() -> bool: + if CERBO_ALLOW_SIGNED_INSTANTANEOUS_POWER: + # Signed instantaneous mode intentionally allows negative phase words as + # part of the same split used for the total active-power rewrite. + return True + if CERBO_SUBTRACT_PV_FROM_HOME_USAGE: + # Keep only total power signed in home-PV offset mode. + # Phase power words are intentionally non-negative for Maxem stability. + return False if CERBO_FORCE_NONNEGATIVE_PHASE_POWER: return False return CERBO_PHASE_POWER_SOURCE == "activein" @@ -298,18 +315,22 @@ def _allow_negative_phase_power_for_rewrite() -> bool: def _build_preview_snapshot_for_logging( usage_snapshot, + usage_watts, phase_usage_watts, ): if usage_snapshot is None: return None - if phase_usage_watts is None: - return usage_snapshot return CerboMqttSnapshot( sequence=getattr(usage_snapshot, "sequence", 0), - ac_in_phase_watts=tuple(float(value) for value in phase_usage_watts), - ac_in_total_watts=getattr(usage_snapshot, "rewrite_usage_watts", 0.0), + ac_in_phase_watts=( + tuple(float(value) for value in phase_usage_watts) + if phase_usage_watts is not None + else getattr(usage_snapshot, "phase_usage_watts", None) + ), + ac_in_total_watts=float(usage_watts) if usage_watts is not None else getattr(usage_snapshot, "rewrite_usage_watts", 0.0), ac_out_phase_currents=getattr(usage_snapshot, "phase_current_amps", None), ac_out_current_n=getattr(usage_snapshot, "current_n_amps", None), + pv_total_watts=getattr(usage_snapshot, "pv_total_watts", None), ) @@ -322,10 +343,70 @@ def _snapshot_pv_total_watts(usage_snapshot) -> float | None: return max(float(pv_total_watts), 0.0) +def _split_total_watts_evenly_signed(total_watts: float) -> tuple[float, float, float]: + value = float(total_watts) + per_phase = value / 3.0 + return ( + per_phase, + per_phase, + value - (2.0 * per_phase), + ) + + +def _apply_pv_offset_to_home_usage( + *, + usage_watts: float, + phase_usage_watts: tuple[float, float, float] | None, + usage_snapshot, +) -> tuple[float, tuple[float, float, float] | None]: + if not CERBO_SUBTRACT_PV_FROM_HOME_USAGE: + return float(usage_watts), phase_usage_watts + + pv_total_watts = _snapshot_pv_total_watts(usage_snapshot) + if pv_total_watts is None or pv_total_watts <= 0.0: + return float(usage_watts), phase_usage_watts + + # Offset PV generation from home/grid usage rewrite; signed result is intentional. + adjusted_usage_watts = float(usage_watts) - float(pv_total_watts) + + # Keep total/phase power coherent by spreading signed remainder across L1/L2/L3. + adjusted_phase_usage_watts = _split_total_watts_evenly_signed(adjusted_usage_watts) + return adjusted_usage_watts, adjusted_phase_usage_watts + + +def _clamp_unsigned_usage_for_rewrite( + *, + usage_watts: float, + phase_usage_watts: tuple[float, float, float] | None, +) -> tuple[float, tuple[float, float, float] | None]: + clamped_usage_watts = max(float(usage_watts), 0.0) + if phase_usage_watts is None: + return clamped_usage_watts, None + return clamped_usage_watts, tuple(max(float(value), 0.0) for value in phase_usage_watts) + + +def _prepare_home_instantaneous_power_for_rewrite( + *, + usage_watts: float, + phase_usage_watts: tuple[float, float, float] | None, +) -> tuple[float, tuple[float, float, float] | None, bool, bool]: + if CERBO_ALLOW_SIGNED_INSTANTANEOUS_POWER: + signed_usage_watts = float(usage_watts) + signed_phase_usage_watts = _split_total_watts_evenly_signed(signed_usage_watts) + return signed_usage_watts, signed_phase_usage_watts, True, True + + clamped_usage_watts, clamped_phase_usage_watts = _clamp_unsigned_usage_for_rewrite( + usage_watts=usage_watts, + phase_usage_watts=phase_usage_watts, + ) + return clamped_usage_watts, clamped_phase_usage_watts, False, False + + def _pv_preview_signature( capture: RegisterCapture, *, pv_total_watts: float | None, + pv_negative: bool, ) -> tuple[object, ...]: return ( capture.target_slave, @@ -335,6 +416,7 @@ def _pv_preview_signature( capture.address_length, capture.source_values, pv_total_watts, + pv_negative, ) @@ -342,16 +424,18 @@ def _format_pv_preview_lines( *, pv_target_slave: int, pv_total_watts: float | None, + pv_negative: bool, ) -> list[str]: if pv_total_watts is None: return [f"Cerbo PV to Maxem (slave {pv_target_slave:03d}): awaiting baseline"] - phase_watts = split_total_watts_evenly(pv_total_watts) + pv_raw_watts = max(float(pv_total_watts), 0.0) + pv_signed_total_watts = -pv_raw_watts if pv_negative else pv_raw_watts return [ - f"Cerbo PV to Maxem (slave {pv_target_slave:03d}): {pv_total_watts:,.2f} W", + f"Cerbo PV to Maxem (slave {pv_target_slave:03d}): {pv_signed_total_watts:,.2f} W", ( f"Cerbo PV Phase Watts to Maxem (slave {pv_target_slave:03d}): " - f"L1={phase_watts[0]:,.2f} W, L2={phase_watts[1]:,.2f} W, L3={phase_watts[2]:,.2f} W" + f"L1={pv_signed_total_watts:,.2f} W, L2=0.00 W, L3=0.00 W" ), ] @@ -411,6 +495,11 @@ def main(): "PV virtual meter disabled because CERBO_PV_TARGET_SLAVE=%s collides with existing slave addresses.", CERBO_PV_TARGET_SLAVE, ) + elif pv_slave_enabled_runtime: + logger.info( + "PV virtual meter slave %03d enabled: only instantaneous_values are synthesized; non-instantaneous blocks are not mirrored from slave 100.", + CERBO_PV_TARGET_SLAVE, + ) if dry_run_maxem_home: logger.info( @@ -495,10 +584,25 @@ def main(): source_values=acload_values, usage_snapshot=preview_snapshot, ) + usage_watts, phase_usage_watts = _apply_pv_offset_to_home_usage( + usage_watts=float(usage_watts), + phase_usage_watts=phase_usage_watts, + usage_snapshot=preview_snapshot, + ) + ( + usage_watts, + phase_usage_watts, + allow_negative, + allow_negative_phase, + ) = _prepare_home_instantaneous_power_for_rewrite( + usage_watts=usage_watts, + phase_usage_watts=phase_usage_watts, + ) phase_current_amps = preview_snapshot.phase_current_amps if preview_snapshot else None current_n_amps = preview_snapshot.current_n_amps if preview_snapshot else None preview_display_snapshot = _build_preview_snapshot_for_logging( preview_snapshot, + usage_watts, phase_usage_watts, ) rewritten_values = rewrite_instantaneous_values( @@ -507,8 +611,8 @@ def main(): phase_usage_watts=phase_usage_watts, phase_current_amps=phase_current_amps, current_n_amps=current_n_amps, - allow_negative=True, - allow_negative_phase=_allow_negative_phase_power_for_rewrite(), + allow_negative=allow_negative, + allow_negative_phase=allow_negative_phase, ) preview_signature_value = preview_signature( capture, @@ -539,10 +643,12 @@ def main(): pv_rewritten_values = rewrite_pv_instantaneous_values( acload_values, pv_total_watts=pv_total_watts, + pv_negative=CERBO_PV_SIGN_NEGATIVE, ) pv_preview_signature_value = _pv_preview_signature( pv_capture, pv_total_watts=pv_total_watts, + pv_negative=CERBO_PV_SIGN_NEGATIVE, ) pv_preview_signature_key = ( pv_capture.target_slave, @@ -553,6 +659,7 @@ def main(): for preview_line in _format_pv_preview_lines( pv_target_slave=CERBO_PV_TARGET_SLAVE, pv_total_watts=pv_total_watts, + pv_negative=CERBO_PV_SIGN_NEGATIVE, ): logger.debug(preview_line) if trace_instantaneous_payload: @@ -582,10 +689,25 @@ def main(): source_values=acload_values, usage_snapshot=usage_snapshot, ) + usage_watts, phase_usage_watts = _apply_pv_offset_to_home_usage( + usage_watts=float(usage_watts), + phase_usage_watts=phase_usage_watts, + usage_snapshot=usage_snapshot, + ) + ( + usage_watts, + phase_usage_watts, + allow_negative, + allow_negative_phase, + ) = _prepare_home_instantaneous_power_for_rewrite( + usage_watts=usage_watts, + phase_usage_watts=phase_usage_watts, + ) phase_current_amps = usage_snapshot.phase_current_amps if usage_snapshot else None current_n_amps = usage_snapshot.current_n_amps if usage_snapshot else None live_preview_snapshot = _build_preview_snapshot_for_logging( usage_snapshot, + usage_watts, phase_usage_watts, ) rewritten_values = rewrite_instantaneous_values( @@ -594,8 +716,8 @@ def main(): phase_usage_watts=phase_usage_watts, phase_current_amps=phase_current_amps, current_n_amps=current_n_amps, - allow_negative=True, - allow_negative_phase=_allow_negative_phase_power_for_rewrite(), + allow_negative=allow_negative, + allow_negative_phase=allow_negative_phase, ) live_preview_signature = preview_signature( capture, @@ -627,10 +749,12 @@ def main(): pv_rewritten_values = rewrite_pv_instantaneous_values( acload_values, pv_total_watts=pv_total_watts, + pv_negative=CERBO_PV_SIGN_NEGATIVE, ) pv_preview_signature_value = _pv_preview_signature( pv_capture, pv_total_watts=pv_total_watts, + pv_negative=CERBO_PV_SIGN_NEGATIVE, ) pv_preview_signature_key = ( pv_capture.target_slave, @@ -641,6 +765,7 @@ def main(): for preview_line in _format_pv_preview_lines( pv_target_slave=CERBO_PV_TARGET_SLAVE, pv_total_watts=pv_total_watts, + pv_negative=CERBO_PV_SIGN_NEGATIVE, ): logger.debug(preview_line) if trace_instantaneous_payload: @@ -653,8 +778,6 @@ def main(): maxem_pv.set_values(register_name, addr, pv_rewritten_values) else: maxem_100.set_values(register_name, addr, acload_values) - if maxem_pv is not None: - maxem_pv.set_values(register_name, addr, acload_values) if dry_run_maxem_home: continue diff --git a/tests/10_test_maxem_home_usage.py b/tests/10_test_maxem_home_usage.py index 6c9bba7..602dbdd 100644 --- a/tests/10_test_maxem_home_usage.py +++ b/tests/10_test_maxem_home_usage.py @@ -1,6 +1,8 @@ import unittest from unittest.mock import patch +import main as runtime_main + from lib.maxem_home_usage import ( CerboMqttSnapshot, DomoticzUsageCache, @@ -266,7 +268,7 @@ def set_voltage(address: int, value_volts: float) -> None: self.assertAlmostEqual(currents[1], 2.0, places=4) self.assertAlmostEqual(currents[2], 0.5, places=4) - def test_rewrite_pv_instantaneous_values_sets_total_phase_power_and_currents(self) -> None: + def test_rewrite_pv_instantaneous_values_supports_negative_single_phase_power(self) -> None: source_values = [0] * INSTANTANEOUS_VALUES_REGISTER_LENGTH def set_voltage(address: int, value_volts: float) -> None: @@ -283,18 +285,34 @@ def set_voltage(address: int, value_volts: float) -> None: rewritten = rewrite_pv_instantaneous_values( tuple(source_values), pv_total_watts=900.0, + pv_negative=True, ) decoded = decode_instantaneous_fields(rewritten) - self.assertAlmostEqual(decoded["active_power_total"] or 0.0, 900.0, places=2) - self.assertAlmostEqual(decoded["active_power_l1"] or 0.0, 300.0, places=2) - self.assertAlmostEqual(decoded["active_power_l2"] or 0.0, 300.0, places=2) - self.assertAlmostEqual(decoded["active_power_l3"] or 0.0, 300.0, places=2) - self.assertAlmostEqual(decoded["current_l1"] or 0.0, 1.30, places=2) - self.assertAlmostEqual(decoded["current_l2"] or 0.0, 1.30, places=2) - self.assertAlmostEqual(decoded["current_l3"] or 0.0, 1.30, places=2) + self.assertAlmostEqual(decoded["active_power_total"] or 0.0, -900.0, places=2) + self.assertAlmostEqual(decoded["active_power_l1"] or 0.0, -900.0, places=2) + self.assertAlmostEqual(decoded["active_power_l2"] or 0.0, 0.0, places=2) + self.assertAlmostEqual(decoded["active_power_l3"] or 0.0, 0.0, places=2) + self.assertAlmostEqual(decoded["current_l1"] or 0.0, 0.0, places=2) + self.assertAlmostEqual(decoded["current_l2"] or 0.0, 0.0, places=2) + self.assertAlmostEqual(decoded["current_l3"] or 0.0, 0.0, places=2) self.assertAlmostEqual(decoded["current_n"] or 0.0, 0.0, places=2) + def test_rewrite_pv_instantaneous_values_supports_positive_single_phase_power(self) -> None: + source_values = [0] * INSTANTANEOUS_VALUES_REGISTER_LENGTH + + rewritten = rewrite_pv_instantaneous_values( + tuple(source_values), + pv_total_watts=900.0, + pv_negative=False, + ) + decoded = decode_instantaneous_fields(rewritten) + + self.assertAlmostEqual(decoded["active_power_total"] or 0.0, 900.0, places=2) + self.assertAlmostEqual(decoded["active_power_l1"] or 0.0, 900.0, places=2) + self.assertAlmostEqual(decoded["active_power_l2"] or 0.0, 0.0, places=2) + self.assertAlmostEqual(decoded["active_power_l3"] or 0.0, 0.0, places=2) + def test_net_signed_phase_watts_to_nonnegative_import_offsets_exports(self) -> None: netted = net_signed_phase_watts_to_nonnegative_import((-350.0, 350.0, 0.0)) @@ -387,6 +405,8 @@ def test_preview_basis_explains_instantaneous_power_semantics(self) -> None: self.assertIn("active_power_total (0x5B14/0x5B15)", message) self.assertIn("CERBO_PHASE_POWER_SOURCE", message) + self.assertIn("CERBO_ALLOW_SIGNED_INSTANTANEOUS_POWER", message) + self.assertIn("CERBO_PV_SIGN_NEGATIVE", message) self.assertIn("Ac/ActiveIn", message) self.assertIn("Ac/Out", message) self.assertIn("non-negative clamp", message) @@ -560,6 +580,73 @@ def fetch_reading_from_device(candidate): self.assertAlmostEqual(snapshot.phase_usage_watts[1], 200.0, places=2) self.assertAlmostEqual(snapshot.phase_usage_watts[2], 300.0, places=2) + def test_signed_total_split_preserves_negative_sum(self) -> None: + phase_watts = runtime_main._split_total_watts_evenly_signed(-450.0) + + self.assertAlmostEqual(sum(phase_watts), -450.0, places=6) + self.assertAlmostEqual(phase_watts[0], -150.0, places=6) + self.assertAlmostEqual(phase_watts[1], -150.0, places=6) + self.assertAlmostEqual(phase_watts[2], -150.0, places=6) + + def test_apply_pv_offset_to_home_usage_keeps_signed_result_and_phase_consistency(self) -> None: + snapshot = CerboMqttSnapshot( + sequence=1, + ac_in_phase_watts=(100.0, 200.0, 300.0), + ac_in_total_watts=600.0, + pv_total_watts=900.0, + ) + + with patch.object(runtime_main, "CERBO_SUBTRACT_PV_FROM_HOME_USAGE", True): + adjusted_usage, adjusted_phase_usage = runtime_main._apply_pv_offset_to_home_usage( + usage_watts=100.0, + phase_usage_watts=(10.0, 20.0, 70.0), + usage_snapshot=snapshot, + ) + + self.assertAlmostEqual(adjusted_usage, -800.0, places=6) + self.assertIsNotNone(adjusted_phase_usage) + self.assertAlmostEqual(sum(adjusted_phase_usage), -800.0, places=6) + + def test_prepare_home_instantaneous_power_for_rewrite_can_keep_signed_total_and_equal_split(self) -> None: + with patch.object(runtime_main, "CERBO_ALLOW_SIGNED_INSTANTANEOUS_POWER", True): + usage_watts, phase_usage_watts, allow_negative, allow_negative_phase = ( + runtime_main._prepare_home_instantaneous_power_for_rewrite( + usage_watts=-450.0, + phase_usage_watts=(10.0, 20.0, 30.0), + ) + ) + + self.assertAlmostEqual(usage_watts, -450.0, places=6) + self.assertTrue(allow_negative) + self.assertTrue(allow_negative_phase) + self.assertIsNotNone(phase_usage_watts) + self.assertAlmostEqual(sum(phase_usage_watts), -450.0, places=6) + self.assertAlmostEqual(phase_usage_watts[0], -150.0, places=6) + self.assertAlmostEqual(phase_usage_watts[1], -150.0, places=6) + self.assertAlmostEqual(phase_usage_watts[2], -150.0, places=6) + + def test_prepare_home_instantaneous_power_for_rewrite_clamps_when_signed_mode_is_off(self) -> None: + with patch.object(runtime_main, "CERBO_ALLOW_SIGNED_INSTANTANEOUS_POWER", False): + usage_watts, phase_usage_watts, allow_negative, allow_negative_phase = ( + runtime_main._prepare_home_instantaneous_power_for_rewrite( + usage_watts=-450.0, + phase_usage_watts=(-10.0, 20.0, -30.0), + ) + ) + + self.assertAlmostEqual(usage_watts, 0.0, places=6) + self.assertFalse(allow_negative) + self.assertFalse(allow_negative_phase) + self.assertIsNotNone(phase_usage_watts) + self.assertAlmostEqual(phase_usage_watts[0], 0.0, places=6) + self.assertAlmostEqual(phase_usage_watts[1], 20.0, places=6) + self.assertAlmostEqual(phase_usage_watts[2], 0.0, places=6) + + def test_disable_negative_phase_power_when_home_offset_enabled(self) -> None: + with patch.object(runtime_main, "CERBO_SUBTRACT_PV_FROM_HOME_USAGE", True): + with patch.object(runtime_main, "CERBO_FORCE_NONNEGATIVE_PHASE_POWER", True): + self.assertFalse(runtime_main._allow_negative_phase_power_for_rewrite()) + if __name__ == "__main__": unittest.main()