Skip to content

Commit b263be8

Browse files
committed
collector: fix battery_current zero-clamp; add Solar Assistant field-mapping reference
- battery_current (reg 98) is signed (negative = discharge). _clamp_values was forcing any negative value to 0.0, zeroing out battery_current during discharge. Add _SIGNED_FIELDS to preserve signed fields. - Add docs/reference/solar-assistant-field-mapping.md documenting SA's InfluxDB schema, register mapping, and lux-mon parity gaps (captured from a live SA v2.0.0 install).
1 parent 2b37edb commit b263be8

2 files changed

Lines changed: 129 additions & 1 deletion

File tree

‎collector/collector.py‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -727,12 +727,19 @@ def _writer_loop(self) -> None:
727727
"soh": 100,
728728
}
729729

730+
# Fields that are legitimately signed (negative values are meaningful,
731+
# e.g. battery_current is negative while discharging). These must NOT be
732+
# clamped to 0 by _clamp_values.
733+
_SIGNED_FIELDS = {
734+
"battery_current",
735+
}
736+
730737
def _clamp_values(self, decoded: dict) -> None:
731738
"""Clamp decoded values to physical sanity limits in-place."""
732739
for key, limit in self._SANITY_LIMITS.items():
733740
if key in decoded:
734741
val = decoded[key]["value"]
735-
if val < 0:
742+
if val < 0 and key not in self._SIGNED_FIELDS:
736743
decoded[key]["value"] = 0.0
737744
elif val > limit:
738745
logger.warning(
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
# Solar Assistant Field Mapping (reference for lux-mon parity)
2+
3+
Captured 2026-08-21 from a live Solar Assistant install (Raspberry Pi,
4+
`192.168.12.12`, SA v2.0.0) and cross-referenced against lux-mon's register
5+
map and the EG4/LuxPower Modbus protocol doc.
6+
7+
## Solar Assistant architecture
8+
9+
Solar Assistant runs the **same stack as lux-mon**:
10+
11+
| Port | Service | Notes |
12+
|------|---------|-------|
13+
| 80 | `beam.smp` (Elixir/Phoenix) | Main SA web app + API |
14+
| 8899 | `beam.smp` | Second SA listener (raw TCP, not HTTP) |
15+
| 1883 | `mosquitto` | MQTT broker (anonymous access allowed) |
16+
| 3000 | `grafana-server` | Grafana (localhost only) |
17+
| 8086 | `influxd` | InfluxDB v1.8 (localhost only) |
18+
| 8088 | `influxd` | InfluxDB RPC (localhost only) |
19+
| 5353 | `avahi-daemon` | mDNS, publishes `_solar-assistant._tcp` |
20+
21+
The Elixir app self-extracts to `/dev/shm/grafana-sync/<hash>/` and its
22+
persistent Grafana dashboards live in `/etc/grafana/solar-assistant/dashboards/`.
23+
24+
## InfluxDB schema (SA measurement → field)
25+
26+
SA uses **one measurement per metric**, with a single field:
27+
- `inverter_0` for single-inverter values
28+
- `combined` for site-wide/aggregate values
29+
30+
Full measurement list (35 total):
31+
32+
```
33+
AC output voltage Battery current Battery power
34+
Battery power hourly Battery power in hourly Battery power out hourly
35+
Battery state of charge Battery temperature Battery voltage
36+
CPU temperature Cloud cover Free storage
37+
Grid frequency Grid power Grid power hourly
38+
Grid power in hourly Grid power out hourly Grid voltage
39+
Inverter temperature Load power Load power essential
40+
Load power hourly Load power non-essential Outside temperature
41+
PV current 1 PV current 2 PV power
42+
PV power 1 PV power 2 PV power hourly
43+
PV power predicted PV power predicted hourly PV voltage 1
44+
PV voltage 2
45+
```
46+
47+
## Field → register mapping (SA vs lux-mon)
48+
49+
| SA measurement | SA field | lux-mon register | lux-mon name | Match |
50+
|---|---|---|---|---|
51+
| Battery voltage | inverter_0 | 4 | battery_voltage | ✅ |
52+
| Battery state of charge | combined | 5 | soc | ✅ |
53+
| Battery power | combined | 10/11 | charge/discharge_power | ✅ (sign) |
54+
| Battery current | inverter_0 | 98 | battery_current | ✅ (signed) |
55+
| Battery temperature | combined | 67 | temp_battery | ✅ |
56+
| PV power 1 | inverter_0 | 7 | pv1_power | ✅ |
57+
| PV power 2 | inverter_0 | 8 | pv2_power | ✅ |
58+
| PV voltage 1 | inverter_0 | 1 | pv1_voltage | ✅ |
59+
| PV voltage 2 | inverter_0 | 2 | pv2_voltage | ✅ |
60+
| PV current 1 | inverter_0 | — | (derived) | ⚠️ see below |
61+
| PV current 2 | inverter_0 | — | (derived) | ⚠️ see below |
62+
| Grid voltage | inverter_0 | 12 | grid_voltage_r | ✅ |
63+
| Grid frequency | inverter_0 | 15 | grid_frequency | ✅ |
64+
| Grid power | combined | 26/27 | grid_export/import_power | ✅ |
65+
| Load power | combined | 24 | eps_power | ✅ |
66+
| AC output voltage | inverter_0 | 20 | eps_voltage_r | ✅ |
67+
| Inverter temperature | inverter_0 | **66** | temp_radiator_2 | ⚠️ see below |
68+
| Outside temperature | combined | — | (not from inverter) | — |
69+
| Cloud cover | combined | — | (not from inverter) | — |
70+
| PV power predicted | combined | — | (not from inverter) | — |
71+
| CPU temperature | combined | — | (Pi CPU, not inverter) | — |
72+
73+
## Key findings
74+
75+
### 1. "Inverter temperature" is a labeling difference, NOT a bug
76+
77+
SA's "Inverter temperature" reads **register 66 (Radiator 2 / heat-sink 2)**,
78+
not register 64 (internal temperature).
79+
80+
- SA "Inverter temperature" = 102.2°F = 39°C = lux-mon `temp_radiator_2` (reg 66)
81+
- lux-mon `temp_inverter` = 91.4°F = 33°C = reg 64 (internal temp)
82+
83+
Per the EG4 protocol doc, reg 64 = "Internal Temp", reg 65/66 = "Radiator 1/2
84+
Temp" (heat sinks). **lux-mon is correct**; SA is mislabeling the heat-sink
85+
temperature as "Inverter temperature". lux-mon exposes both `temp_inverter`
86+
(reg 64) and `temp_radiator_2` (reg 66) separately, which is strictly more
87+
correct.
88+
89+
### 2. Battery current is signed (negative = discharge)
90+
91+
Register 98 (`battery_current`) is signed. When discharging, the raw value is
92+
two's-complement negative (e.g. raw 65432 → -10.4 A).
93+
94+
**Bug fixed 2026-08-21:** `PassiveCollector._clamp_values` was clamping *any*
95+
negative value to 0.0, which zeroed out `battery_current` during discharge.
96+
Added `_SIGNED_FIELDS = {"battery_current"}` so signed fields are not
97+
zero-clamped.
98+
99+
### 3. Fields SA derives that lux-mon does not (yet) have
100+
101+
These are not raw inverter registers — SA computes or sources them elsewhere:
102+
103+
- **PV current 1/2** — SA reports these; lux-mon has `pv1_voltage`/`pv2_voltage`
104+
but no `pv1_current`/`pv2_current` register. Current can be derived as
105+
`power / voltage` per MPPT string.
106+
- **Outside temperature** — sourced from a weather API, not the inverter.
107+
- **Cloud cover** — sourced from a weather API.
108+
- **PV power predicted** — sourced from a weather/solar forecast API.
109+
- **CPU temperature** — the Pi's own CPU temp, not the inverter.
110+
- **Free storage** — the Pi's disk free space.
111+
- **Hourly rollups** (`* hourly`, `* in hourly`, `* out hourly`) — InfluxDB
112+
continuous queries / downsampling, not raw registers.
113+
114+
## lux-mon parity gaps (to close)
115+
116+
1. Derive `pv1_current` / `pv2_current` from `pv1_power / pv1_voltage` (and
117+
same for string 2) to match SA's "PV current 1/2".
118+
2. Add weather-sourced `outside_temperature`, `cloud_cover`, and
119+
`pv_power_predicted` (optional; requires a weather API).
120+
3. Add hourly downsampling measurements to match SA's `* hourly` series.
121+
4. (Optional) Add `CPU temperature` and `Free storage` for host health parity.

0 commit comments

Comments
 (0)