span-panel-api-schema-1 1.1.3__tar.gz → 1.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {span_panel_api_schema_1-1.1.3 → span_panel_api_schema_1-1.2.0}/CHANGELOG.md +33 -0
- {span_panel_api_schema_1-1.1.3 → span_panel_api_schema_1-1.2.0}/PKG-INFO +2 -2
- {span_panel_api_schema_1-1.1.3 → span_panel_api_schema_1-1.2.0}/pyproject.toml +6 -2
- {span_panel_api_schema_1-1.1.3 → span_panel_api_schema_1-1.2.0}/src/span_panel_api_schema_1/adapter.py +125 -10
- {span_panel_api_schema_1-1.1.3 → span_panel_api_schema_1-1.2.0}/src/span_panel_api_schema_1/circuits.py +2 -2
- {span_panel_api_schema_1-1.1.3 → span_panel_api_schema_1-1.2.0}/src/span_panel_api_schema_1/devices.py +112 -49
- {span_panel_api_schema_1-1.1.3 → span_panel_api_schema_1-1.2.0}/src/span_panel_api_schema_1/field_metadata.py +8 -7
- span_panel_api_schema_1-1.2.0/src/span_panel_api_schema_1/firmware.py +34 -0
- {span_panel_api_schema_1-1.1.3 → span_panel_api_schema_1-1.2.0}/src/span_panel_api_schema_1/panel.py +14 -12
- {span_panel_api_schema_1-1.1.3 → span_panel_api_schema_1-1.2.0}/src/span_panel_api_schema_1/snapshot.py +70 -13
- {span_panel_api_schema_1-1.1.3 → span_panel_api_schema_1-1.2.0}/.gitignore +0 -0
- {span_panel_api_schema_1-1.1.3 → span_panel_api_schema_1-1.2.0}/README.md +0 -0
- {span_panel_api_schema_1-1.1.3 → span_panel_api_schema_1-1.2.0}/spec/catalogs/breaker.json +0 -0
- {span_panel_api_schema_1-1.1.3 → span_panel_api_schema_1-1.2.0}/spec/catalogs/charge-limit.json +0 -0
- {span_panel_api_schema_1-1.1.3 → span_panel_api_schema_1-1.2.0}/spec/catalogs/connection.json +0 -0
- {span_panel_api_schema_1-1.1.3 → span_panel_api_schema_1-1.2.0}/spec/catalogs/door.json +0 -0
- {span_panel_api_schema_1-1.1.3 → span_panel_api_schema_1-1.2.0}/spec/catalogs/grid-forming.json +0 -0
- {span_panel_api_schema_1-1.1.3 → span_panel_api_schema_1-1.2.0}/spec/catalogs/grid.json +0 -0
- {span_panel_api_schema_1-1.1.3 → span_panel_api_schema_1-1.2.0}/spec/catalogs/info.json +0 -0
- {span_panel_api_schema_1-1.1.3 → span_panel_api_schema_1-1.2.0}/spec/catalogs/load-shed.json +0 -0
- {span_panel_api_schema_1-1.1.3 → span_panel_api_schema_1-1.2.0}/spec/catalogs/meter.json +0 -0
- {span_panel_api_schema_1-1.1.3 → span_panel_api_schema_1-1.2.0}/spec/catalogs/pcs.json +0 -0
- {span_panel_api_schema_1-1.1.3 → span_panel_api_schema_1-1.2.0}/spec/catalogs/power-flows.json +0 -0
- {span_panel_api_schema_1-1.1.3 → span_panel_api_schema_1-1.2.0}/spec/catalogs/shed-forecast.json +0 -0
- {span_panel_api_schema_1-1.1.3 → span_panel_api_schema_1-1.2.0}/spec/catalogs/shed.json +0 -0
- {span_panel_api_schema_1-1.1.3 → span_panel_api_schema_1-1.2.0}/spec/catalogs/soc.json +0 -0
- {span_panel_api_schema_1-1.1.3 → span_panel_api_schema_1-1.2.0}/spec/catalogs/status.json +0 -0
- {span_panel_api_schema_1-1.1.3 → span_panel_api_schema_1-1.2.0}/spec/catalogs/switch.json +0 -0
- {span_panel_api_schema_1-1.1.3 → span_panel_api_schema_1-1.2.0}/spec/registries/device-types.md +0 -0
- {span_panel_api_schema_1-1.1.3 → span_panel_api_schema_1-1.2.0}/src/span_panel_api_schema_1/__init__.py +0 -0
- {span_panel_api_schema_1-1.1.3 → span_panel_api_schema_1-1.2.0}/src/span_panel_api_schema_1/adoption.py +0 -0
- {span_panel_api_schema_1-1.1.3 → span_panel_api_schema_1-1.2.0}/src/span_panel_api_schema_1/catalog.py +0 -0
- {span_panel_api_schema_1-1.1.3 → span_panel_api_schema_1-1.2.0}/src/span_panel_api_schema_1/charge_limit.py +0 -0
- {span_panel_api_schema_1-1.1.3 → span_panel_api_schema_1-1.2.0}/src/span_panel_api_schema_1/const.py +0 -0
- {span_panel_api_schema_1-1.1.3 → span_panel_api_schema_1-1.2.0}/src/span_panel_api_schema_1/description.py +0 -0
- {span_panel_api_schema_1-1.1.3 → span_panel_api_schema_1-1.2.0}/src/span_panel_api_schema_1/extension.py +0 -0
- {span_panel_api_schema_1-1.1.3 → span_panel_api_schema_1-1.2.0}/src/span_panel_api_schema_1/py.typed +0 -0
- {span_panel_api_schema_1-1.1.3 → span_panel_api_schema_1-1.2.0}/src/span_panel_api_schema_1/reference/parent_child_tree.json +0 -0
- {span_panel_api_schema_1-1.1.3 → span_panel_api_schema_1-1.2.0}/src/span_panel_api_schema_1/spec_lock.json +0 -0
- {span_panel_api_schema_1-1.1.3 → span_panel_api_schema_1-1.2.0}/src/span_panel_api_schema_1/transport.py +0 -0
|
@@ -9,6 +9,39 @@ number. A release here means this parser changed, never that the panel did.
|
|
|
9
9
|
|
|
10
10
|
Pre-releases are not listed separately. A beta is a step towards the next public version, so its changes are folded into that version's entry as they land and are described against the last public release, never against the beta before it.
|
|
11
11
|
|
|
12
|
+
## [1.2.0]
|
|
13
|
+
|
|
14
|
+
Every PV inverter a panel commissions is read, and the battery's power keeps its sign on firmware r202639. Requires `span-panel-api` **3.6.0 or newer**.
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
|
|
18
|
+
- **A second inverter's feeding circuit is labeled `device_type="pv"`**, so its power no longer reads as a load with the opposite sign.
|
|
19
|
+
- **A PV inverter wired through two breakers now reports its power and link status consistently**, with both circuits labeled `pv` and its feed and link status read from the one with the lowest circuit id.
|
|
20
|
+
- **`pv` is the inverter on the lowest breaker space when more than one is commissioned**, rather than whichever came first in the device tree.
|
|
21
|
+
- **Each inverter's extension properties are filed under that inverter**, with its `pv_inverters` key as the `instance_key` when more than one is commissioned.
|
|
22
|
+
- **The first snapshot waits briefly for the circuits that feed an inverter**, so an inverter's key and its circuit's label do not change just after connecting.
|
|
23
|
+
- **`battery.power_w` is discharge-positive on firmware r202639 and later, from the first snapshot on**, where a charging battery read positive.
|
|
24
|
+
|
|
25
|
+
### Added
|
|
26
|
+
|
|
27
|
+
- **`span_panel_api_schema_1.firmware.release_build`** reads the six-digit release build number out of a firmware version string, or returns `None` when it has none.
|
|
28
|
+
- **`build_battery` takes keyword-only `firmware_version` and `power_flow_battery`** to pick the BESS meter's wire frame, and behaves as before without them.
|
|
29
|
+
- **Every PV inverter is read, not only the first**, each landing in `pv_inverters` with its serial number when published, its device id and its key.
|
|
30
|
+
- **`TreeRoles.pvs` lists every inverter**, and `snapshot.primary_pv`, `devices.pv_inverter_key` and `circuits.circuit_tabs` choose the one `pv` describes, give an inverter's key, and read a circuit's breaker spaces.
|
|
31
|
+
- **`pv.serial_number` is read from `info/serial-number`**, so that property no longer appears as a discovery row.
|
|
32
|
+
|
|
33
|
+
## [1.1.4]
|
|
34
|
+
|
|
35
|
+
The parser reports the islanding state the panel is acting on, and refuses islanding writes the panel would ignore. Reported and fixed by [@dcj](https://github.com/dcj) in [#179](https://github.com/SpanPanel/span-panel-api/pull/179), from issue
|
|
36
|
+
[#178](https://github.com/SpanPanel/span-panel-api/issues/178).
|
|
37
|
+
|
|
38
|
+
### Fixed
|
|
39
|
+
|
|
40
|
+
- **An islanding assertion in force is now the reported islanding state even while the MID is ready.** `resolve_islanding_state` returned the MID's sensed value whenever the MID was ready and read `shed/asserted-islanding-state` only when it was not, so
|
|
41
|
+
`dsm_state` could disagree with the state the panel was acting on. `ON_GRID` or `OFF_GRID` asserted now wins; otherwise the ready MID's value, then the existing fallback.
|
|
42
|
+
- **`set_dominant_power_source_target` returns None unless the panel declares `shed/asserted-islanding-state` settable**, the same refusal `set_circuit_relay_target` makes.
|
|
43
|
+
- **`dominant_power_source_payload` refuses `NONE` and `UNKNOWN`** instead of publishing `NONE`, which the panel ignores. An assertion clears itself once the panel's link to the battery recovers.
|
|
44
|
+
|
|
12
45
|
## [1.1.3]
|
|
13
46
|
|
|
14
47
|
`spec_lock.json` ships inside this wheel, so its shape changing is a release here even though the parser did not change.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: span-panel-api-schema-1
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.2.0
|
|
4
4
|
Summary: Parent/child schema (data-model-version 1.x) parser for span-panel-api
|
|
5
5
|
Project-URL: Homepage, https://github.com/SpanPanel/span-panel-api
|
|
6
6
|
Project-URL: Issues, https://github.com/SpanPanel/span-panel-api/issues
|
|
@@ -8,7 +8,7 @@ Author: SpanPanel
|
|
|
8
8
|
License-Expression: MIT
|
|
9
9
|
Requires-Python: <4.0,>=3.14
|
|
10
10
|
Requires-Dist: ebus-sdk<0.24,>=0.19.0
|
|
11
|
-
Requires-Dist: span-panel-api<4.0,>=3.
|
|
11
|
+
Requires-Dist: span-panel-api<4.0,>=3.6.0
|
|
12
12
|
Description-Content-Type: text/markdown
|
|
13
13
|
|
|
14
14
|
# span-panel-api-schema-1
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "span-panel-api-schema-1"
|
|
3
|
-
version = "1.
|
|
3
|
+
version = "1.2.0"
|
|
4
4
|
description = "Parent/child schema (data-model-version 1.x) parser for span-panel-api"
|
|
5
5
|
authors = [
|
|
6
6
|
{name = "SpanPanel"}
|
|
@@ -21,7 +21,11 @@ dependencies = [
|
|
|
21
21
|
# bootstrap defining everything this parser imports. 3.1.0 adds `ControlTarget`
|
|
22
22
|
# and replaces the `set_*_topic` members with `set_*_target`, so this wheel is
|
|
23
23
|
# rejected at discovery by any 3.0.x.
|
|
24
|
-
|
|
24
|
+
# Raised to 3.6.0, the first bootstrap whose snapshot carries `pv_inverters`
|
|
25
|
+
# and the inverter's `serial_number`, `device_id` and `node_id`, which this
|
|
26
|
+
# parser fills. Against an earlier one, building a snapshot fails on those
|
|
27
|
+
# arguments.
|
|
28
|
+
"span-panel-api>=3.6.0,<4.0",
|
|
25
29
|
# Only this distribution depends on the eBus SDK. The bootstrap and
|
|
26
30
|
# schema-0 stay clean, so a flat-panel install never pulls it in — which is
|
|
27
31
|
# what bounds the release coupling this dependency introduces to panels on
|
|
@@ -18,7 +18,8 @@ tree, and the SDK repopulates — so there is no resync hook to wire or forget.
|
|
|
18
18
|
from __future__ import annotations
|
|
19
19
|
|
|
20
20
|
import logging
|
|
21
|
-
|
|
21
|
+
import time
|
|
22
|
+
from typing import TYPE_CHECKING, Final
|
|
22
23
|
|
|
23
24
|
from ebus_sdk import Controller
|
|
24
25
|
|
|
@@ -28,11 +29,15 @@ from span_panel_api_schema_1.circuits import priority_is_settable, relay_is_sett
|
|
|
28
29
|
from span_panel_api_schema_1.const import (
|
|
29
30
|
HOMIE_DOMAIN,
|
|
30
31
|
HOMIE_VERSION,
|
|
32
|
+
NODE_CONNECTION,
|
|
31
33
|
NODE_INFO,
|
|
32
34
|
NODE_LOAD_SHED,
|
|
33
35
|
NODE_SHED,
|
|
34
36
|
NODE_SWITCH,
|
|
35
37
|
PROP_ASSERTED_ISLANDING_STATE,
|
|
38
|
+
PROP_FED_BY_DEVICE_ID,
|
|
39
|
+
PROP_FEEDS_DEVICE_ID,
|
|
40
|
+
PROP_FIRMWARE_VERSION,
|
|
36
41
|
PROP_MODEL,
|
|
37
42
|
PROP_NAME,
|
|
38
43
|
PROP_PRIORITY,
|
|
@@ -40,9 +45,10 @@ from span_panel_api_schema_1.const import (
|
|
|
40
45
|
STATE_READY,
|
|
41
46
|
TYPE_CIRCUIT,
|
|
42
47
|
)
|
|
43
|
-
from span_panel_api_schema_1.description import device_type
|
|
48
|
+
from span_panel_api_schema_1.description import declared_settable, device_type, node_properties
|
|
49
|
+
from span_panel_api_schema_1.devices import feed_circuit_ids
|
|
44
50
|
from span_panel_api_schema_1.field_metadata import build_field_metadata
|
|
45
|
-
from span_panel_api_schema_1.panel import integer
|
|
51
|
+
from span_panel_api_schema_1.panel import integer, text
|
|
46
52
|
from span_panel_api_schema_1.snapshot import TreeRoles, build_snapshot, harmonised_evse_keys
|
|
47
53
|
from span_panel_api_schema_1.transport import ControllerRoutes
|
|
48
54
|
|
|
@@ -55,6 +61,33 @@ if TYPE_CHECKING:
|
|
|
55
61
|
|
|
56
62
|
_LOGGER = logging.getLogger(__name__)
|
|
57
63
|
|
|
64
|
+
FEED_GRACE_S: Final = 2.0
|
|
65
|
+
"""How long an inverter's feed may stay unvalued once everything else has arrived.
|
|
66
|
+
|
|
67
|
+
A retained `connection/feeds-device-id` lands in the same burst as its circuit's
|
|
68
|
+
name. One still missing this long after the rest of the tree's labels is taken
|
|
69
|
+
as absent: a DER may be published with no connection record at all.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _unvalued_feeds_for_unplaced_pvs(roles: TreeRoles) -> list[str]:
|
|
74
|
+
"""Circuits whose feed has not arrived, while an inverter has no known source.
|
|
75
|
+
|
|
76
|
+
Empty when every inverter is placed, by a circuit's
|
|
77
|
+
`connection/feeds-device-id` or a lugs device's `connection/fed-by-device-id`:
|
|
78
|
+
no value still to arrive can then change any inverter's key.
|
|
79
|
+
"""
|
|
80
|
+
placed = set(feed_circuit_ids(roles.circuits))
|
|
81
|
+
placed.update(text(lugs, NODE_CONNECTION, PROP_FED_BY_DEVICE_ID) for lugs in roles.lugs)
|
|
82
|
+
if all(pv.device_id in placed for pv in roles.pvs):
|
|
83
|
+
return []
|
|
84
|
+
return [
|
|
85
|
+
circuit.device_id
|
|
86
|
+
for circuit in roles.circuits
|
|
87
|
+
if PROP_FEEDS_DEVICE_ID in circuit.get_node_properties(NODE_CONNECTION)
|
|
88
|
+
and circuit.get_property(NODE_CONNECTION, PROP_FEEDS_DEVICE_ID) is None
|
|
89
|
+
]
|
|
90
|
+
|
|
58
91
|
|
|
59
92
|
class SchemaOneAdapter:
|
|
60
93
|
"""Parser for the parent/child schema (data-model-version 1.x)."""
|
|
@@ -67,9 +100,18 @@ class SchemaOneAdapter:
|
|
|
67
100
|
schema_major = "schema_1"
|
|
68
101
|
SUPPORTS_DATA_MODEL_VERSIONS: tuple[str, str] = (">=1.0", "<2.0")
|
|
69
102
|
|
|
70
|
-
def __init__(
|
|
103
|
+
def __init__(
|
|
104
|
+
self,
|
|
105
|
+
serial_number: str,
|
|
106
|
+
schema: V2HomieSchema,
|
|
107
|
+
*,
|
|
108
|
+
clock: Callable[[], float] = time.monotonic,
|
|
109
|
+
) -> None:
|
|
71
110
|
self._serial_number = serial_number
|
|
72
111
|
self._schema = schema
|
|
112
|
+
self._clock = clock
|
|
113
|
+
self._feed_wait_since: float | None = None
|
|
114
|
+
self._feed_wait_reported = False
|
|
73
115
|
self._routes = ControllerRoutes()
|
|
74
116
|
self._controller = Controller(root_device_id=serial_number, mqttc=self._routes)
|
|
75
117
|
self._property_callbacks: list[Callable[[str, str, str, str | None], None]] = []
|
|
@@ -169,10 +211,30 @@ class SchemaOneAdapter:
|
|
|
169
211
|
way to get a placeholder. Under parent/child every mapped device has
|
|
170
212
|
the same exposure, so a DER missing the model it declared is reported
|
|
171
213
|
alongside a circuit missing its name.
|
|
214
|
+
|
|
215
|
+
An inverter's feeding circuit is waited on too, because it decides the
|
|
216
|
+
inverter's `pv_inverters` key, and a key that moved after the first
|
|
217
|
+
snapshot would move every entity built on it. A circuit feeding nothing
|
|
218
|
+
publishes no `connection/feeds-device-id` value at all, so an unvalued
|
|
219
|
+
one is ambiguous; it is reported only while some inverter is still
|
|
220
|
+
unplaced (no circuit feeds it and no lugs device names it as its
|
|
221
|
+
source), which is the one case its value could still change a key.
|
|
222
|
+
|
|
223
|
+
The panel itself is reported while a declared BESS waits on the
|
|
224
|
+
panel's declared firmware version, which decides the BESS meter's frame.
|
|
225
|
+
|
|
226
|
+
The feed wait is bounded by `FEED_GRACE_S`. A DER may be published with
|
|
227
|
+
no connection record pointing at it, and then no value is coming; a
|
|
228
|
+
retained feed lands in the same burst as its circuit's name, so once
|
|
229
|
+
every name, model and firmware version has arrived, a feed still
|
|
230
|
+
unvalued after the grace is taken as absent. The inverter is then keyed
|
|
231
|
+
by its device id, reported once at INFO rather than the transport
|
|
232
|
+
warning after the full name timeout. The grace restarts whenever
|
|
233
|
+
anything else is missing again.
|
|
172
234
|
"""
|
|
173
235
|
roles = TreeRoles(self._children())
|
|
174
236
|
missing = [circuit.device_id for circuit in roles.circuits if not circuit.get_property(NODE_INFO, PROP_NAME)]
|
|
175
|
-
ders = (roles.bess, roles.
|
|
237
|
+
ders = (roles.bess, *roles.pvs, *roles.evse)
|
|
176
238
|
missing.extend(
|
|
177
239
|
device.device_id
|
|
178
240
|
for device in ders
|
|
@@ -180,8 +242,45 @@ class SchemaOneAdapter:
|
|
|
180
242
|
and PROP_MODEL in device.get_node_properties(NODE_INFO)
|
|
181
243
|
and device.get_property(NODE_INFO, PROP_MODEL) is None
|
|
182
244
|
)
|
|
245
|
+
missing.extend(self._firmware_version_pending(roles))
|
|
246
|
+
feeds = _unvalued_feeds_for_unplaced_pvs(roles)
|
|
247
|
+
if feeds and not missing:
|
|
248
|
+
now = self._clock()
|
|
249
|
+
if self._feed_wait_since is None:
|
|
250
|
+
self._feed_wait_since = now
|
|
251
|
+
if now - self._feed_wait_since >= FEED_GRACE_S:
|
|
252
|
+
if not self._feed_wait_reported:
|
|
253
|
+
_LOGGER.info(
|
|
254
|
+
"No connection record names %d circuit(s) as an inverter's feed; "
|
|
255
|
+
"inverters without one are keyed by device id",
|
|
256
|
+
len(feeds),
|
|
257
|
+
)
|
|
258
|
+
self._feed_wait_reported = True
|
|
259
|
+
feeds = []
|
|
260
|
+
elif missing:
|
|
261
|
+
self._feed_wait_since = None
|
|
262
|
+
missing.extend(feeds)
|
|
183
263
|
return missing
|
|
184
264
|
|
|
265
|
+
def _firmware_version_pending(self, roles: TreeRoles) -> list[str]:
|
|
266
|
+
"""The panel itself, while a declared BESS waits on the panel's firmware version.
|
|
267
|
+
|
|
268
|
+
The release build in the panel's `info/firmware-version` decides which
|
|
269
|
+
frame the BESS meter is read in. A first snapshot built before it lands
|
|
270
|
+
falls back to comparing signs with `power-flows/battery`, and a later one
|
|
271
|
+
may read the other way, so the battery's power would flip once after
|
|
272
|
+
connect. Only a declared property is waited on: a panel that does not
|
|
273
|
+
promise one is never held for it.
|
|
274
|
+
"""
|
|
275
|
+
root = self._controller.get_root(self._serial_number)
|
|
276
|
+
if roles.bess is None or root is None:
|
|
277
|
+
return []
|
|
278
|
+
if PROP_FIRMWARE_VERSION not in root.get_node_properties(NODE_INFO):
|
|
279
|
+
return []
|
|
280
|
+
if root.get_property(NODE_INFO, PROP_FIRMWARE_VERSION) is None:
|
|
281
|
+
return [root.device_id]
|
|
282
|
+
return []
|
|
283
|
+
|
|
185
284
|
def find_node_by_type(self, type_str: str) -> str | None:
|
|
186
285
|
"""Return the id of the first device declaring `type_str`.
|
|
187
286
|
|
|
@@ -275,9 +374,23 @@ class SchemaOneAdapter:
|
|
|
275
374
|
the BESS stops discharging. Returning None here, as this did until the
|
|
276
375
|
successor was decided, left that recovery unavailable during an outage.
|
|
277
376
|
|
|
377
|
+
**The panel decides whether a write takes effect, by its own link to the
|
|
378
|
+
battery.** It accepts `ON_GRID` or `OFF_GRID` only while the battery's
|
|
379
|
+
`status/communication-state` is not `OK`; otherwise it ignores the write
|
|
380
|
+
and the published value does not change. A lost MID alone does not make
|
|
381
|
+
the write eligible. `NONE` is always ignored.
|
|
382
|
+
|
|
383
|
+
**None unless the panel declares the assertion `$settable`**, the same
|
|
384
|
+
refusal `set_circuit_relay_target` makes. Absence of the declaration,
|
|
385
|
+
or of the property, authorizes nothing (see
|
|
386
|
+
`description.declared_settable`).
|
|
387
|
+
|
|
278
388
|
Payload translation is not optional: the flat enum this protocol speaks
|
|
279
389
|
is not the one the panel accepts. See `dominant_power_source_payload`.
|
|
280
390
|
"""
|
|
391
|
+
root = self._controller.get_root(self._serial_number)
|
|
392
|
+
if not declared_settable(node_properties(root, NODE_SHED).get(PROP_ASSERTED_ISLANDING_STATE)):
|
|
393
|
+
return None
|
|
281
394
|
return self._target(self._serial_number, NODE_SHED, PROP_ASSERTED_ISLANDING_STATE)
|
|
282
395
|
|
|
283
396
|
def dominant_power_source_payload(self, value: str) -> str | None:
|
|
@@ -291,17 +404,19 @@ class SchemaOneAdapter:
|
|
|
291
404
|
|
|
292
405
|
The narrowing loses nothing, because the six values were a *source
|
|
293
406
|
class* pressed into service as a manual override and the job only ever
|
|
294
|
-
needed on-grid
|
|
295
|
-
|
|
296
|
-
|
|
407
|
+
needed on-grid or off-grid. Anything not recognized returns None rather
|
|
408
|
+
than guessing, so the transport refuses the command instead of
|
|
409
|
+
asserting something the user did not ask for.
|
|
410
|
+
|
|
411
|
+
**`NONE` and `UNKNOWN` are refused too.** The panel ignores a written
|
|
412
|
+
`NONE`, so publishing one would report a clear that never happened. An
|
|
413
|
+
assertion clears itself once the panel's link to the battery recovers.
|
|
297
414
|
"""
|
|
298
415
|
return {
|
|
299
416
|
"GRID": "ON_GRID",
|
|
300
417
|
"BATTERY": "OFF_GRID",
|
|
301
418
|
"PV": "OFF_GRID",
|
|
302
419
|
"GENERATOR": "OFF_GRID",
|
|
303
|
-
"NONE": "NONE",
|
|
304
|
-
"UNKNOWN": "NONE",
|
|
305
420
|
}.get(value.strip().upper())
|
|
306
421
|
|
|
307
422
|
def set_evse_charge_limit_target(self, node_id: str) -> ControlTarget | None:
|
|
@@ -116,7 +116,7 @@ def _optional_integer(device: DiscoveredDevice, node: str, prop: str) -> int | N
|
|
|
116
116
|
return None if raw is None else int(raw)
|
|
117
117
|
|
|
118
118
|
|
|
119
|
-
def
|
|
119
|
+
def circuit_tabs(device: DiscoveredDevice) -> list[int]:
|
|
120
120
|
"""Breaker spaces from ``info/spaces``.
|
|
121
121
|
|
|
122
122
|
v1.0 publishes the occupied spaces literally (``"36,38"``), where the flat
|
|
@@ -254,7 +254,7 @@ def build_circuit(
|
|
|
254
254
|
# circuit's.
|
|
255
255
|
produced_energy_wh=_number(device, NODE_METER, PROP_IMPORTED_ENERGY),
|
|
256
256
|
consumed_energy_wh=_number(device, NODE_METER, PROP_EXPORTED_ENERGY),
|
|
257
|
-
tabs=
|
|
257
|
+
tabs=circuit_tabs(device),
|
|
258
258
|
priority=priority,
|
|
259
259
|
# `always-on` is `not relay-controllable`, and the flat schema derived
|
|
260
260
|
# user-controllability from `always-on` — so this is the same answer by
|
|
@@ -19,36 +19,33 @@ against conflating them. Both are now carried, in separate fields
|
|
|
19
19
|
(``connected`` and ``communication_state``), because they answer different
|
|
20
20
|
questions: the panel's view of the link, and the publisher's view of its own.
|
|
21
21
|
|
|
22
|
-
**Battery power is discharge-positive, and
|
|
23
|
-
``
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
22
|
+
**Battery power is discharge-positive, and the wire's frame for it changed at r202639.**
|
|
23
|
+
``battery.power_w`` is positive while the battery *discharges*: the frame the eBus
|
|
24
|
+
specification asks for from a device's own meter, and deliberately not the
|
|
25
|
+
into-the-device rule ``build_circuit`` follows.
|
|
26
|
+
|
|
27
|
+
Through r202633 the BESS ``meter/active-power`` is charge-positive, carrying the
|
|
28
|
+
same sign as the enclosure's ``power-flows/battery``, so ``build_battery`` negates
|
|
29
|
+
it. Measured rather than reasoned: driving the producer into self-consumption
|
|
30
|
+
with the grid at zero forces the direction. PV 4181 W plus battery 1917 W meeting
|
|
31
|
+
a 6099 W load leaves nothing ambiguous: the battery is discharging. The wire
|
|
31
32
|
publishes ``-1917.49`` and this module reports ``+1917.49``.
|
|
32
33
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
two therefore tells a consumer which firmware it is on -- equal means today's,
|
|
49
|
-
opposite means a conformant future one -- which is what would let this conversion
|
|
50
|
-
stay correct across that change. Undecidable while the battery is idle and both
|
|
51
|
-
read zero.
|
|
34
|
+
From r202639 the BESS meter is published discharge-positive, the negation of
|
|
35
|
+
``power-flows/battery`` (whose own sign did not change), so it passes through.
|
|
36
|
+
The frame is chosen from the enclosure's release build number
|
|
37
|
+
(``firmware.release_build``), never from the respin suffix after it. Where the
|
|
38
|
+
version does not parse, the two wire properties decide instead: one nonzero sign
|
|
39
|
+
on both is the old frame, opposite signs the new one. Where either is zero or
|
|
40
|
+
absent the old frame is assumed, because that is what every earlier release of
|
|
41
|
+
this package applied and what the reference capture (``ebus-panel-sim`` 0.8.0,
|
|
42
|
+
which publishes no SPAN version string) carries. A battery at rest reads zero in
|
|
43
|
+
both frames, so the assumption only decides a nonzero BESS meter beside a missing
|
|
44
|
+
or zero flow figure.
|
|
45
|
+
|
|
46
|
+
On either firmware ``panel.power_flow_battery`` (passed through untouched, and
|
|
47
|
+
charge-positive) and ``battery.power_w`` end up as each other's mirror, so a
|
|
48
|
+
consumer showing both sees one convention after negating either one.
|
|
52
49
|
"""
|
|
53
50
|
|
|
54
51
|
from __future__ import annotations
|
|
@@ -78,6 +75,7 @@ from span_panel_api_schema_1.const import (
|
|
|
78
75
|
PROP_VENDOR_NAME,
|
|
79
76
|
UNKNOWN,
|
|
80
77
|
)
|
|
78
|
+
from span_panel_api_schema_1.firmware import release_build
|
|
81
79
|
from span_panel_api_schema_1.panel import integer, number, resolve_grid_forming_device_name, text
|
|
82
80
|
|
|
83
81
|
if TYPE_CHECKING:
|
|
@@ -114,12 +112,14 @@ def feed_circuit_ids(circuits: list[DiscoveredDevice]) -> dict[str, str]:
|
|
|
114
112
|
|
|
115
113
|
v1.0 states the relationship on the *circuit* (``connection/feeds-device-id``)
|
|
116
114
|
rather than on the DER, so it is read once here and handed to whichever
|
|
117
|
-
device needs it.
|
|
115
|
+
device needs it. A device fed by more than one circuit (the specification
|
|
116
|
+
allows it) maps to the circuit with the lowest device id, so the answer, and
|
|
117
|
+
every key built on it, does not depend on discovery order.
|
|
118
118
|
"""
|
|
119
119
|
feeds: dict[str, str] = {}
|
|
120
|
-
for circuit in circuits:
|
|
120
|
+
for circuit in sorted(circuits, key=lambda device: device.device_id):
|
|
121
121
|
fed = text(circuit, NODE_CONNECTION, PROP_FEEDS_DEVICE_ID)
|
|
122
|
-
if fed:
|
|
122
|
+
if fed and fed not in feeds:
|
|
123
123
|
feeds[fed] = circuit.device_id
|
|
124
124
|
return feeds
|
|
125
125
|
|
|
@@ -157,12 +157,18 @@ def feed_connection_statuses(circuits: list[DiscoveredDevice]) -> dict[str, str]
|
|
|
157
157
|
Most circuits publish neither. A mixed-load or unsurveyed circuit feeds no
|
|
158
158
|
commissioned DER, so it has no connection record to publish — the spec calls
|
|
159
159
|
that normal, which is why nothing here treats a missing record as an error.
|
|
160
|
+
|
|
161
|
+
A device fed by more than one circuit takes the status of the circuit
|
|
162
|
+
``feed_circuit_ids`` chose for it, so its link health and its feed come from
|
|
163
|
+
one record whatever the discovery order. That circuit's silence is not
|
|
164
|
+
filled from the other feed: it leaves the device absent here, as any
|
|
165
|
+
circuit publishing only one half does.
|
|
160
166
|
"""
|
|
167
|
+
by_id = {circuit.device_id: circuit for circuit in circuits}
|
|
161
168
|
statuses: dict[str, str] = {}
|
|
162
|
-
for
|
|
163
|
-
|
|
164
|
-
status
|
|
165
|
-
if fed and status:
|
|
169
|
+
for fed, circuit_id in feed_circuit_ids(circuits).items():
|
|
170
|
+
status = text(by_id[circuit_id], NODE_CONNECTION, PROP_FEEDS_DEVICE_STATUS)
|
|
171
|
+
if status:
|
|
166
172
|
statuses[fed] = status
|
|
167
173
|
return statuses
|
|
168
174
|
|
|
@@ -185,32 +191,71 @@ def _connected(status: str | None) -> bool | None:
|
|
|
185
191
|
return None if status is None else status == STATUS_OK
|
|
186
192
|
|
|
187
193
|
|
|
188
|
-
|
|
189
|
-
|
|
194
|
+
BESS_METER_DISCHARGE_POSITIVE_FROM_BUILD = 202639
|
|
195
|
+
"""The first release build whose BESS ``meter/active-power`` is discharge-positive."""
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _bess_meter_is_charge_positive(
|
|
199
|
+
firmware_version: str | None,
|
|
200
|
+
raw_power_w: float | None,
|
|
201
|
+
power_flow_battery: float | None,
|
|
202
|
+
) -> bool:
|
|
203
|
+
"""Whether the BESS meter on the wire is in the pre-r202639 frame.
|
|
204
|
+
|
|
205
|
+
The enclosure's release build decides when it parses. Otherwise the BESS
|
|
206
|
+
meter is compared with ``power-flows/battery``, which kept its sign across
|
|
207
|
+
the change: equal signs mean the old frame, opposite signs the new one. With
|
|
208
|
+
either one zero or absent the comparison says nothing, and the old frame is
|
|
209
|
+
assumed (see the module docstring for why).
|
|
210
|
+
"""
|
|
211
|
+
build = release_build(firmware_version)
|
|
212
|
+
if build is not None:
|
|
213
|
+
return build < BESS_METER_DISCHARGE_POSITIVE_FROM_BUILD
|
|
214
|
+
if raw_power_w is None or power_flow_battery is None or raw_power_w == 0.0 or power_flow_battery == 0.0:
|
|
215
|
+
return True
|
|
216
|
+
return (raw_power_w > 0.0) == (power_flow_battery > 0.0)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _discharge_positive(raw_power_w: float | None, *, charge_positive_on_wire: bool) -> float | None:
|
|
220
|
+
"""Bring the BESS meter into the device's own frame, discharge-positive.
|
|
190
221
|
|
|
191
222
|
Positive means power flowing *out of the battery*, which is discharging, and
|
|
192
|
-
which is what the eBus specification asks of a device's own meter.
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
self-consumption, where the grid sits at zero and the direction cannot be
|
|
196
|
-
argued.
|
|
223
|
+
which is what the eBus specification asks of a device's own meter. A wire
|
|
224
|
+
value already in that frame passes through; one in the older charge-positive
|
|
225
|
+
frame is negated.
|
|
197
226
|
|
|
198
227
|
`None` stays `None`: a BESS that publishes no `meter` node has no power
|
|
199
228
|
reading, which is not the same as zero. The `0.0` guard is `build_circuit`'s,
|
|
200
|
-
for the same reason
|
|
201
|
-
`0.0` and formats as `"-0.0"`.
|
|
229
|
+
for the same reason: negating `0.0` yields `-0.0`, which compares equal to
|
|
230
|
+
`0.0` and formats as `"-0.0"`. A wire `-0.0` is normalized on the
|
|
231
|
+
pass-through path too.
|
|
202
232
|
"""
|
|
203
233
|
if raw_power_w is None:
|
|
204
234
|
return None
|
|
205
|
-
|
|
235
|
+
if raw_power_w == 0.0:
|
|
236
|
+
return 0.0
|
|
237
|
+
return -raw_power_w if charge_positive_on_wire else raw_power_w
|
|
206
238
|
|
|
207
239
|
|
|
208
|
-
def build_battery(
|
|
209
|
-
|
|
240
|
+
def build_battery(
|
|
241
|
+
bess: DiscoveredDevice | None,
|
|
242
|
+
owners: list[DiscoveredDevice],
|
|
243
|
+
*,
|
|
244
|
+
firmware_version: str | None = None,
|
|
245
|
+
power_flow_battery: float | None = None,
|
|
246
|
+
) -> SpanBatterySnapshot:
|
|
247
|
+
"""Build the battery snapshot. An uncommissioned panel yields the empty one.
|
|
248
|
+
|
|
249
|
+
`firmware_version` is the enclosure's ``info/firmware-version`` and
|
|
250
|
+
`power_flow_battery` its ``power-flows/battery``; together they pick the
|
|
251
|
+
BESS meter's wire frame. Omitted, they leave the old frame assumed.
|
|
252
|
+
"""
|
|
210
253
|
if bess is None:
|
|
211
254
|
return SpanBatterySnapshot()
|
|
212
255
|
|
|
213
256
|
status = connection_status_for(bess.device_id, owners)
|
|
257
|
+
raw_power_w = number(bess, NODE_METER, PROP_ACTIVE_POWER)
|
|
258
|
+
charge_positive_on_wire = _bess_meter_is_charge_positive(firmware_version, raw_power_w, power_flow_battery)
|
|
214
259
|
|
|
215
260
|
return SpanBatterySnapshot(
|
|
216
261
|
# Historically misnamed in the snapshot and kept that way: `soe_percentage`
|
|
@@ -229,7 +274,7 @@ def build_battery(bess: DiscoveredDevice | None, owners: list[DiscoveredDevice])
|
|
|
229
274
|
nameplate_capacity_kwh=number(bess, NODE_INFO, PROP_NAMEPLATE_CAPACITY),
|
|
230
275
|
# None when unclaimed, so "nobody has said" stays distinct from "not OK".
|
|
231
276
|
connected=_connected(status),
|
|
232
|
-
power_w=_discharge_positive(
|
|
277
|
+
power_w=_discharge_positive(raw_power_w, charge_positive_on_wire=charge_positive_on_wire),
|
|
233
278
|
# The BESS's own link health, kept as the published enum string rather
|
|
234
279
|
# than collapsed to a bool: DEGRADED is neither OK nor LOST, and a bool
|
|
235
280
|
# would have to pick one.
|
|
@@ -237,6 +282,17 @@ def build_battery(bess: DiscoveredDevice | None, owners: list[DiscoveredDevice])
|
|
|
237
282
|
)
|
|
238
283
|
|
|
239
284
|
|
|
285
|
+
def pv_inverter_key(pv: DiscoveredDevice, feeds: Mapping[str, str]) -> str:
|
|
286
|
+
"""The inverter's `pv_inverters` key: its feeding circuit's id, else its device id.
|
|
287
|
+
|
|
288
|
+
The circuit first because its id survives firmware r202639, which renumbers
|
|
289
|
+
every PV device on a panel with more than one inverter. A circuit feeds at
|
|
290
|
+
most one device, so two circuit-fed inverters cannot share a key. An
|
|
291
|
+
inverter no circuit feeds keeps its device id, the only handle it has.
|
|
292
|
+
"""
|
|
293
|
+
return feeds.get(pv.device_id) or pv.device_id
|
|
294
|
+
|
|
295
|
+
|
|
240
296
|
def build_pv(
|
|
241
297
|
pv: DiscoveredDevice | None,
|
|
242
298
|
feeds: dict[str, str],
|
|
@@ -245,7 +301,11 @@ def build_pv(
|
|
|
245
301
|
*,
|
|
246
302
|
feed_statuses: dict[str, str],
|
|
247
303
|
) -> SpanPVSnapshot:
|
|
248
|
-
"""Build
|
|
304
|
+
"""Build one inverter's PV snapshot. An uncommissioned panel yields the empty one.
|
|
305
|
+
|
|
306
|
+
`info/nominal-power` is the array's DC size as recorded at installation,
|
|
307
|
+
carried for display and never a limit on what the inverter produces.
|
|
308
|
+
"""
|
|
249
309
|
if pv is None:
|
|
250
310
|
return SpanPVSnapshot()
|
|
251
311
|
|
|
@@ -253,9 +313,12 @@ def build_pv(
|
|
|
253
313
|
connected=_connected(feed_statuses.get(pv.device_id)),
|
|
254
314
|
vendor_name=_optional(text(pv, NODE_INFO, PROP_VENDOR_NAME)),
|
|
255
315
|
model=_optional(text(pv, NODE_INFO, PROP_MODEL)),
|
|
316
|
+
serial_number=_optional(text(pv, NODE_INFO, PROP_SERIAL_NUMBER)),
|
|
256
317
|
software_version=_optional(text(pv, NODE_INFO, PROP_FIRMWARE_VERSION)),
|
|
257
318
|
nameplate_capacity_w=number(pv, NODE_INFO, PROP_NOMINAL_POWER),
|
|
258
319
|
feed_circuit_id=feeds.get(pv.device_id),
|
|
320
|
+
device_id=pv.device_id,
|
|
321
|
+
node_id=pv_inverter_key(pv, feeds),
|
|
259
322
|
# Retired as a property in v1.0 and derived instead, per the enclosure model's
|
|
260
323
|
# own replacement rule. `None` where no owner references the DER, because the
|
|
261
324
|
# integration gates whether a control entity exists on this value.
|
|
@@ -157,11 +157,11 @@ _PROPERTY_FIELD_MAP: tuple[tuple[str, str, str, str], ...] = (
|
|
|
157
157
|
(TYPE_BESS, NODE_INFO, "serial-number", "battery.serial_number"),
|
|
158
158
|
(TYPE_BESS, NODE_INFO, "firmware-version", "battery.software_version"),
|
|
159
159
|
(TYPE_BESS, NODE_INFO, "nameplate-capacity", "battery.nameplate_capacity_kwh"),
|
|
160
|
-
# The BESS's own meter and its own link health. `battery.power_w`
|
|
161
|
-
# sign flip (`build_battery` reports discharge-positive, the wire
|
|
162
|
-
#
|
|
163
|
-
# describes
|
|
164
|
-
# with it.
|
|
160
|
+
# The BESS's own meter and its own link health. `battery.power_w` may carry a
|
|
161
|
+
# sign flip (`build_battery` reports discharge-positive, and the wire is
|
|
162
|
+
# charge-positive before r202639), which does not affect the unit or the
|
|
163
|
+
# datatype this row describes: a row states what the property *is*, not what
|
|
164
|
+
# the mapper does with it.
|
|
165
165
|
(TYPE_BESS, NODE_METER, "active-power", "battery.power_w"),
|
|
166
166
|
(TYPE_BESS, NODE_STATUS, "communication-state", "battery.communication_state"),
|
|
167
167
|
# --- PV ------------------------------------------------------------------
|
|
@@ -457,6 +457,7 @@ _CONSUMED_WITHOUT_A_ROW: tuple[tuple[str, str, str], ...] = (
|
|
|
457
457
|
(TYPE_EVSE, NODE_INFO, "serial-number"),
|
|
458
458
|
(TYPE_EVSE, NODE_INFO, "vendor-name"),
|
|
459
459
|
(TYPE_PV, NODE_INFO, "firmware-version"),
|
|
460
|
+
(TYPE_PV, NODE_INFO, "serial-number"),
|
|
460
461
|
)
|
|
461
462
|
"""Declarations the mapper reads into the snapshot without a metadata row.
|
|
462
463
|
|
|
@@ -472,8 +473,8 @@ _CONSUMED_OFF_SNAPSHOT: dict[tuple[str, str, str], str] = {
|
|
|
472
473
|
"parses the tree, so it is consumed before any snapshot exists"
|
|
473
474
|
),
|
|
474
475
|
(TYPE_PANEL, NODE_SHED, "asserted-islanding-state"): (
|
|
475
|
-
"tier
|
|
476
|
-
"
|
|
476
|
+
"tier 1 of resolve_islanding_state (panel.py), which returns a non-NONE "
|
|
477
|
+
"assertion first, and the write target of set_dominant_power_source_target"
|
|
477
478
|
),
|
|
478
479
|
(TYPE_LUGS, NODE_CONNECTION, "feeds-device-id"): (
|
|
479
480
|
"the downstream-lugs feedthrough branch of resolve_relative_position "
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Read the release build number out of the enclosure's firmware version string.
|
|
2
|
+
|
|
3
|
+
SPAN firmware publishes ``info/firmware-version`` as slash-separated segments,
|
|
4
|
+
for example ``spanos2/r202639/03``. The ``rNNNNNN`` segment names the release and
|
|
5
|
+
is the only part a behavior gate may compare: the trailing ``/03`` is a respin of
|
|
6
|
+
that release, and two respins of one release publish the same wire conventions.
|
|
7
|
+
|
|
8
|
+
Kept to one function so every gate in this package parses the string the same
|
|
9
|
+
way, and so a string this package does not recognize yields `None` rather than a
|
|
10
|
+
guess. A caller decides what `None` means for its own question.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import re
|
|
16
|
+
|
|
17
|
+
_RELEASE_SEGMENT = re.compile(r"r(\d{6})")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def release_build(firmware_version: str | None) -> int | None:
|
|
21
|
+
"""The six-digit release build number, or `None` when there is none to read.
|
|
22
|
+
|
|
23
|
+
``"spanos2/r202639/03"`` gives ``202639``. Only a whole path segment of the
|
|
24
|
+
form ``r`` plus exactly six digits counts, so ``"r2026390"``, ``"rc1"`` and a
|
|
25
|
+
simulator's ``"example/v0.1.0"`` all give `None`. The first such segment wins;
|
|
26
|
+
the respin suffix after it is never read.
|
|
27
|
+
"""
|
|
28
|
+
if firmware_version is None:
|
|
29
|
+
return None
|
|
30
|
+
for segment in firmware_version.strip().split("/"):
|
|
31
|
+
match = _RELEASE_SEGMENT.fullmatch(segment)
|
|
32
|
+
if match is not None:
|
|
33
|
+
return int(match.group(1))
|
|
34
|
+
return None
|
{span_panel_api_schema_1-1.1.3 → span_panel_api_schema_1-1.2.0}/src/span_panel_api_schema_1/panel.py
RENAMED
|
@@ -600,7 +600,6 @@ _GRID_POWER_EPSILON_W = 1.0
|
|
|
600
600
|
|
|
601
601
|
ISLANDING_ON_GRID = "ON_GRID"
|
|
602
602
|
ISLANDING_OFF_GRID = "OFF_GRID"
|
|
603
|
-
ASSERTION_NONE = "NONE"
|
|
604
603
|
|
|
605
604
|
|
|
606
605
|
def resolve_islanding_state(mid: DiscoveredDevice | None, panel: DiscoveredDevice) -> str | None:
|
|
@@ -608,17 +607,19 @@ def resolve_islanding_state(mid: DiscoveredDevice | None, panel: DiscoveredDevic
|
|
|
608
607
|
|
|
609
608
|
| tier | condition | source |
|
|
610
609
|
| --- | --- | --- |
|
|
611
|
-
| 1 |
|
|
612
|
-
| 2 | MID
|
|
610
|
+
| 1 | `shed/asserted-islanding-state` is `ON_GRID` or `OFF_GRID` | asserted |
|
|
611
|
+
| 2 | MID `$state` is `ready` and `islanding-state` present | sensed |
|
|
613
612
|
| 3 | no MID at all | `power-flows/grid` heuristic |
|
|
614
613
|
| 4 | none of the above | unknown |
|
|
615
614
|
|
|
616
|
-
**
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
615
|
+
**An assertion in force is the effective state, so it outranks the MID.** When the
|
|
616
|
+
panel's link to the battery is not healthy (the battery's `status/communication-state`
|
|
617
|
+
is not `OK`) and the grid returns, the user asserts the grid is up so the BESS stops
|
|
618
|
+
discharging, and the panel acts on that assertion until it clears. The panel ignores
|
|
619
|
+
the write while that link is healthy.
|
|
620
|
+
Reading the MID first would report the sensed value while the panel is acting on a
|
|
621
|
+
different one. The assertion clears itself back to `NONE` once the battery link
|
|
622
|
+
recovers, so the MID's answer returns without anything here having to expire it.
|
|
622
623
|
|
|
623
624
|
**Tier 3 never answers `OFF_GRID`, and never asserts on-grid from a missing MID.** An
|
|
624
625
|
earlier draft reasoned that no MID means no islanding authority means on-grid. That is
|
|
@@ -627,14 +628,15 @@ def resolve_islanding_state(mid: DiscoveredDevice | None, panel: DiscoveredDevic
|
|
|
627
628
|
counterexample. Grid power flowing is positive evidence of being on-grid; its absence
|
|
628
629
|
is not evidence of the opposite.
|
|
629
630
|
"""
|
|
631
|
+
asserted = text(panel, NODE_SHED, PROP_ASSERTED_ISLANDING_STATE)
|
|
632
|
+
if asserted in (ISLANDING_ON_GRID, ISLANDING_OFF_GRID):
|
|
633
|
+
return asserted
|
|
634
|
+
|
|
630
635
|
if mid is not None:
|
|
631
636
|
if mid.state == "ready":
|
|
632
637
|
sensed = text(mid, NODE_GRID, PROP_ISLANDING_STATE)
|
|
633
638
|
if sensed:
|
|
634
639
|
return sensed
|
|
635
|
-
asserted = text(panel, NODE_SHED, PROP_ASSERTED_ISLANDING_STATE)
|
|
636
|
-
if asserted and asserted != ASSERTION_NONE:
|
|
637
|
-
return asserted
|
|
638
640
|
return None
|
|
639
641
|
|
|
640
642
|
grid_power = number(panel, NODE_POWER_FLOWS, "grid")
|
|
@@ -11,11 +11,13 @@ from __future__ import annotations
|
|
|
11
11
|
import time
|
|
12
12
|
from typing import TYPE_CHECKING
|
|
13
13
|
|
|
14
|
-
from span_panel_api.models import ExtensionSubject, SpanPanelSnapshot
|
|
14
|
+
from span_panel_api.models import ExtensionSubject, SpanPanelSnapshot, SpanPVSnapshot
|
|
15
15
|
from span_panel_api_schema_1.adoption import build_adopted_devices
|
|
16
|
-
from span_panel_api_schema_1.circuits import build_circuit
|
|
16
|
+
from span_panel_api_schema_1.circuits import build_circuit, circuit_tabs
|
|
17
17
|
from span_panel_api_schema_1.const import (
|
|
18
|
+
NODE_CONNECTION,
|
|
18
19
|
NODE_INFO,
|
|
20
|
+
PROP_FEEDS_DEVICE_ID,
|
|
19
21
|
PROP_MODEL,
|
|
20
22
|
PROP_SERIAL_NUMBER,
|
|
21
23
|
TYPE_BESS,
|
|
@@ -34,6 +36,7 @@ from span_panel_api_schema_1.devices import (
|
|
|
34
36
|
build_pv,
|
|
35
37
|
feed_circuit_ids,
|
|
36
38
|
feed_connection_statuses,
|
|
39
|
+
pv_inverter_key,
|
|
37
40
|
)
|
|
38
41
|
from span_panel_api_schema_1.extension import build_extension_properties
|
|
39
42
|
from span_panel_api_schema_1.field_metadata import addressed_rows
|
|
@@ -71,7 +74,9 @@ class TreeRoles:
|
|
|
71
74
|
self.lugs: list[DiscoveredDevice] = []
|
|
72
75
|
self.evse: list[DiscoveredDevice] = []
|
|
73
76
|
self.bess: DiscoveredDevice | None = None
|
|
74
|
-
|
|
77
|
+
# Every inverter, in tree order. From r202639 each commissioned inverter
|
|
78
|
+
# is its own device; earlier firmware published one.
|
|
79
|
+
self.pvs: list[DiscoveredDevice] = []
|
|
75
80
|
self.mid: DiscoveredDevice | None = None
|
|
76
81
|
|
|
77
82
|
for device in devices:
|
|
@@ -84,11 +89,39 @@ class TreeRoles:
|
|
|
84
89
|
self.evse.append(device)
|
|
85
90
|
elif declared == TYPE_BESS and self.bess is None:
|
|
86
91
|
self.bess = device
|
|
87
|
-
elif declared == TYPE_PV
|
|
88
|
-
self.
|
|
92
|
+
elif declared == TYPE_PV:
|
|
93
|
+
self.pvs.append(device)
|
|
89
94
|
elif declared == TYPE_MID and self.mid is None:
|
|
90
95
|
self.mid = device
|
|
91
96
|
|
|
97
|
+
self.pv: DiscoveredDevice | None = primary_pv(self.pvs, self.circuits)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def primary_pv(pvs: Sequence[DiscoveredDevice], circuits: Sequence[DiscoveredDevice]) -> DiscoveredDevice | None:
|
|
101
|
+
"""The inverter ``snapshot.pv`` describes, chosen the same way on every build.
|
|
102
|
+
|
|
103
|
+
The order depends only on what the panel publishes, never on the order the
|
|
104
|
+
devices were discovered in:
|
|
105
|
+
|
|
106
|
+
1. an inverter whose feeding circuit publishes its breaker spaces, before
|
|
107
|
+
one with no feeding circuit (or a circuit that publishes none);
|
|
108
|
+
2. among those, the lowest breaker space the feeding circuit occupies;
|
|
109
|
+
3. then the lowest device id.
|
|
110
|
+
|
|
111
|
+
A single inverter is chosen whatever it publishes, so a panel with one
|
|
112
|
+
inverter reads exactly as it did before more than one could be published.
|
|
113
|
+
"""
|
|
114
|
+
if not pvs:
|
|
115
|
+
return None
|
|
116
|
+
feeds = feed_circuit_ids(list(circuits))
|
|
117
|
+
lowest_tab = {circuit.device_id: min(circuit_tabs(circuit), default=None) for circuit in circuits}
|
|
118
|
+
|
|
119
|
+
def rank(device: DiscoveredDevice) -> tuple[int, int, str]:
|
|
120
|
+
tab = lowest_tab.get(feeds.get(device.device_id, ""))
|
|
121
|
+
return (0, tab, device.device_id) if tab is not None else (1, 0, device.device_id)
|
|
122
|
+
|
|
123
|
+
return min(pvs, key=rank)
|
|
124
|
+
|
|
92
125
|
|
|
93
126
|
def build_snapshot(panel: DiscoveredDevice, children: list[DiscoveredDevice], ready_since: float = 0.0) -> SpanPanelSnapshot:
|
|
94
127
|
"""Build a full snapshot from the panel and its descendants."""
|
|
@@ -104,11 +137,16 @@ def build_snapshot(panel: DiscoveredDevice, children: list[DiscoveredDevice], re
|
|
|
104
137
|
feed_statuses = feed_connection_statuses(roles.circuits)
|
|
105
138
|
# A DER's device type decides how its feeding circuit is labelled, so the
|
|
106
139
|
# circuit inherits it — matching the flat adapter, where the same circuit
|
|
107
|
-
# reports device_type "pv" rather than "circuit".
|
|
140
|
+
# reports device_type "pv" rather than "circuit". Every inverter's circuit,
|
|
141
|
+
# not only the one `snapshot.pv` describes: a PV circuit left labeled
|
|
142
|
+
# "circuit" is read as a load, with its power in the opposite sign. Read
|
|
143
|
+
# from the circuit side rather than through `feeds`, which keeps one circuit
|
|
144
|
+
# per DER: every circuit of a DER fed by several carries its power.
|
|
145
|
+
kind_by_der = {device.device_id: "pv" for device in roles.pvs} | {device.device_id: "evse" for device in roles.evse}
|
|
108
146
|
der_type_by_circuit = {
|
|
109
|
-
|
|
110
|
-
for
|
|
111
|
-
if
|
|
147
|
+
circuit.device_id: kind
|
|
148
|
+
for circuit in roles.circuits
|
|
149
|
+
if (kind := kind_by_der.get(text(circuit, NODE_CONNECTION, PROP_FEEDS_DEVICE_ID)))
|
|
112
150
|
}
|
|
113
151
|
|
|
114
152
|
circuits = {}
|
|
@@ -176,12 +214,25 @@ def build_snapshot(panel: DiscoveredDevice, children: list[DiscoveredDevice], re
|
|
|
176
214
|
("panel", panel),
|
|
177
215
|
("battery", roles.bess),
|
|
178
216
|
("mid", roles.mid),
|
|
179
|
-
("pv", roles.pv),
|
|
217
|
+
*((("pv", roles.pv),) if len(roles.pvs) == 1 else ()),
|
|
180
218
|
)
|
|
181
219
|
if device is not None
|
|
182
220
|
]
|
|
221
|
+
# One inverter keeps the singleton `pv` subject it always had. With more
|
|
222
|
+
# than one, each inverter is its own subject keyed like `pv_inverters`, so
|
|
223
|
+
# no inverter's vendor properties are filed under another's.
|
|
224
|
+
pv_subjects = (
|
|
225
|
+
[(device, ExtensionSubject(kind="pv", instance_key=pv_inverter_key(device, feeds))) for device in roles.pvs]
|
|
226
|
+
if len(roles.pvs) > 1
|
|
227
|
+
else []
|
|
228
|
+
)
|
|
229
|
+
pv_inverters = {
|
|
230
|
+
pv_inverter_key(device, feeds): build_pv(device, feeds, upstream, downstream, feed_statuses=feed_statuses)
|
|
231
|
+
for device in roles.pvs
|
|
232
|
+
}
|
|
233
|
+
|
|
183
234
|
extension_properties = build_extension_properties(
|
|
184
|
-
[*singleton_subjects, *lugs_subjects, *evse_subjects, *circuit_subjects],
|
|
235
|
+
[*singleton_subjects, *pv_subjects, *lugs_subjects, *evse_subjects, *circuit_subjects],
|
|
185
236
|
addressed_rows(children),
|
|
186
237
|
)
|
|
187
238
|
|
|
@@ -240,8 +291,14 @@ def build_snapshot(panel: DiscoveredDevice, children: list[DiscoveredDevice], re
|
|
|
240
291
|
downstream_l1_current_a=fields.downstream_l1_current_a,
|
|
241
292
|
downstream_l2_current_a=fields.downstream_l2_current_a,
|
|
242
293
|
circuits=circuits,
|
|
243
|
-
battery=build_battery(
|
|
244
|
-
|
|
294
|
+
battery=build_battery(
|
|
295
|
+
roles.bess,
|
|
296
|
+
owners,
|
|
297
|
+
firmware_version=fields.firmware_version,
|
|
298
|
+
power_flow_battery=fields.power_flow_battery,
|
|
299
|
+
),
|
|
300
|
+
pv=pv_inverters[pv_inverter_key(roles.pv, feeds)] if roles.pv is not None else SpanPVSnapshot(),
|
|
301
|
+
pv_inverters=pv_inverters,
|
|
245
302
|
mid=build_mid(roles.mid, device_names),
|
|
246
303
|
# Gated on the node being declared, not on any value: every limit this
|
|
247
304
|
# capability publishes is legally `0.0`, so there is no reading that can
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{span_panel_api_schema_1-1.1.3 → span_panel_api_schema_1-1.2.0}/spec/catalogs/charge-limit.json
RENAMED
|
File without changes
|
{span_panel_api_schema_1-1.1.3 → span_panel_api_schema_1-1.2.0}/spec/catalogs/connection.json
RENAMED
|
File without changes
|
|
File without changes
|
{span_panel_api_schema_1-1.1.3 → span_panel_api_schema_1-1.2.0}/spec/catalogs/grid-forming.json
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{span_panel_api_schema_1-1.1.3 → span_panel_api_schema_1-1.2.0}/spec/catalogs/load-shed.json
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{span_panel_api_schema_1-1.1.3 → span_panel_api_schema_1-1.2.0}/spec/catalogs/power-flows.json
RENAMED
|
File without changes
|
{span_panel_api_schema_1-1.1.3 → span_panel_api_schema_1-1.2.0}/spec/catalogs/shed-forecast.json
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{span_panel_api_schema_1-1.1.3 → span_panel_api_schema_1-1.2.0}/spec/registries/device-types.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{span_panel_api_schema_1-1.1.3 → span_panel_api_schema_1-1.2.0}/src/span_panel_api_schema_1/const.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{span_panel_api_schema_1-1.1.3 → span_panel_api_schema_1-1.2.0}/src/span_panel_api_schema_1/py.typed
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|