Skip to content

Commit 130a84d

Browse files
committed
Initial cut of VirtualHousehold support.
1 parent 7d065c3 commit 130a84d

2 files changed

Lines changed: 356 additions & 9 deletions

File tree

src/powersensor_local/__init__.py

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,35 @@
11
"""Direct (non-cloud) interface to Powersensor devices
22
3-
This package contains two abstraction layers:
3+
This package contains various abstractions for interacting with Powersensor
4+
devices on the local network.
45
5-
• PowersensorDevices is the main API layer
6-
• PowersensorListener provides a lower-level abstraction
6+
The recommended approach is to use mDNS to discover plugs via their service
7+
"_powersensor._tcp.local", and then instantiate a PlugApi to obtain the event
8+
stream from each plug.
79
8-
These are both available within this namespace, or specifically as
9-
devices.PowersensorDevices and listener.PowersensorListener
10+
A legacy abstraction is also provided via PowersensorDevices, which uses
11+
an older way of discovering plugs.
1012
11-
The 'events' and 'rawfirehose' modules are helper utilities provided as
12-
debug aids, which get installed under the names ps-events and ps-rawfirehose
13-
respectively.
13+
Lower-level interfaces are available in the PlugListener and
14+
PowersensorListener classed, though they are not recommended for general use.
15+
16+
Additionally a convience abstraction for translating some of the events into
17+
a household view is available in VirtualHousehold.
18+
19+
Quick overview:
20+
• PlugApi is the recommended API layer
21+
• PlugListener is the lower-level abstraction used by PlugApi
22+
• PowersensorDevices is the legacy main API layer
23+
• PowersensorListener provides a (legacy) lower-level abstraction
24+
• VirtualHousehold can be used to translate events into a household view
25+
26+
The 'plugevents' and 'rawplug' modules are helper utilities provided as
27+
debug aids, which get installed under the names ps-plugevents and ps-rawplug
28+
respectively. There are also the legacy 'events' and 'rawfirehose' debug aids
29+
which get installed under the names ps-events and ps-rawfirehose respectively.
1430
"""
15-
__all__ = [ 'devices', 'listener' ]
31+
__all__ = [ 'devices', 'listener', 'plug_api', 'plug_listener' ]
1632
from .devices import PowersensorDevices
1733
from .listener import PowersensorListener
34+
from .plug_api import PlugApi
35+
from .plug_listener import PlugListener
Lines changed: 329 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,329 @@
1+
"""Abstraction for producing a household view."""
2+
3+
from async_event_emitter import AsyncEventEmitter
4+
from dataclasses import dataclass
5+
from typing import Optional
6+
7+
KEY_DUR_S = 'duration_s'
8+
KEY_RESET = 'summation_resettime_utc'
9+
KEY_START = 'starttime_utc'
10+
KEY_SUM_J = 'summation_joules'
11+
KEY_WATTS = 'watts'
12+
13+
@dataclass
14+
class InstantaneousValues:
15+
starttime_utc: int
16+
solar_watts: float
17+
housenet_watts: float
18+
duration_s: int
19+
20+
@dataclass
21+
class SummationValues:
22+
starttime_utc: int
23+
solar_summation: float
24+
solar_resettime: int
25+
housenet_summation: float
26+
housenet_resettime: int
27+
28+
@dataclass
29+
class SummationDeltas:
30+
solar_generation: float
31+
to_grid: float
32+
from_grid: float
33+
home_use: float
34+
35+
def same_duration(ev1: dict, ev2: dict):
36+
"""Close-enough matching of duration_s in events."""
37+
dur = KEY_DUR_S
38+
if not dur in ev1 or not dur in ev2:
39+
return False
40+
# We don't care about sub-second differences
41+
d1 = round(ev1[dur], 0)
42+
d2 = round(ev2[dur], 0)
43+
return d1 == d2
44+
45+
def matching_instants(starttime_utc: int, solar_events: list, housenet_events: list) -> Optional[InstantaneousValues]:
46+
"""Attempts to match and merge solar+housenet average_power events."""
47+
solar = solar_events.find_by_key(KEY_START, starttime_utc)
48+
housenet = housenet_events.find_by_key(KEY_START, starttime_utc)
49+
if solar is not None and housenet is not None and same_duration(solar, housenet):
50+
return InstantaneousValues(
51+
starttime_utc = starttime_utc,
52+
solar_watts = solar[KEY_WATTS],
53+
housenet_watts = housenet[KEY_WATTS],
54+
duration_s = round(solar[KEY_DUR_S], 0),
55+
)
56+
else:
57+
return None
58+
59+
def make_instant_housenet(ev: dict) -> Optional[InstantaneousValues]:
60+
"""Helper for case where no solar merge is expected."""
61+
if ev is None:
62+
return None
63+
return InstantaneousValues(
64+
starttime_utc = ev[KEY_START],
65+
solar_watts = 0,
66+
housenet_watts = ev[KEY_WATTS],
67+
duration_s = round(ev[KEY_DUR_S], 0)
68+
)
69+
70+
def matching_summations(starttime_utc: int, solar_events: list, housenet_events: list) -> Optional[SummationValues]:
71+
"""Attempts to match and merge solar+housenet summation events."""
72+
solar = solar_events.find_by_key(KEY_START, starttime_utc)
73+
housenet = housenet_events.find_by_key(KEY_START, starttime_utc)
74+
if solar is not None and housenet is not None:
75+
return SummationValues(
76+
starttime_utc = starttime_utc,
77+
solar_summation =solar[KEY_SUM_J],
78+
solar_resettime = solar[KEY_RESET],
79+
housenet_summation = housenet[KEY_SUM_J],
80+
housenet_resettime = housenet[KEY_RESET],
81+
)
82+
else:
83+
return None
84+
85+
def make_summation_housenet(ev: dict) -> Optional[SummationValues]:
86+
"""Helper for case where no solar merge is expected."""
87+
if ev is None:
88+
return None
89+
return SummationValues(
90+
starttime_utc = ev[KEY_START],
91+
solar_summation = 0,
92+
solar_resettime = 0,
93+
housenet_summation = ev[KEY_SUM_J],
94+
housenet_resettime = ev[KEY_RESET]
95+
)
96+
97+
98+
99+
class VirtualHousehold(AsyncEventEmitter):
100+
"""
101+
Class for processing average_power and summation_energy events into
102+
to/from grid, solar generation, and home usage events.
103+
104+
To use, simply feed the appropriate PlugApi events to the
105+
process_average_power_event and process_summation_event member functions.
106+
107+
Point-in-time power flow events include:
108+
109+
* home_usage
110+
* from_grid
111+
* to_grid (only for solar kits)
112+
* solar_generation (only for solar kits)
113+
114+
These all have an event payload in the form:
115+
116+
{ timestamp_utc: , watts: }
117+
118+
Energy summation events include:
119+
120+
* home_usage_summation
121+
* from_grid_summation
122+
* to_grid_summation (only for solar kits)
123+
* solar_generation_summation (only for solar kits)
124+
125+
These all have an event payload in the form:
126+
127+
{ timestamp_utc: , summation_resettime_utc: , summation_joules: }
128+
129+
Summations may reset at any time. Track the summation_resettime_utc
130+
field to take note of summation resets.
131+
"""
132+
133+
def __init__(self, with_solar: bool):
134+
"""Constructor.
135+
with_solar True if it's already known that solar exists. Will be
136+
automatically enabled upon encountering a solar event during
137+
processing, but until such a time may generate incorrect values
138+
for home usage. Similarly, if this is set to True but no solar
139+
exists, no events may be generated.
140+
"""
141+
super().__init__()
142+
self._expect_solar = with_solar
143+
self._summation = self.SummationInfo(0, 0, 0, 0)
144+
self._counters = self.Counters(0, 0, 0, 0, 0)
145+
self._solar_instants = self.EventBuffer(31)
146+
self._housenet_instants = self.EventBuffer(31)
147+
self._solar_summations = self.EventBuffer(5)
148+
self._housenet_summations = self.EventBuffer(5)
149+
150+
async def process_average_power_event(self, ev: dict):
151+
"""Ingests an event of type 'average_power'."""
152+
if not KEY_START in ev:
153+
return
154+
starttime_utc = int(ev[KEY_START])
155+
if 'role' in ev:
156+
role = ev['role']
157+
if role == 'house-net':
158+
self._housenet_instants.append(ev)
159+
await self._process_instants(starttime_utc)
160+
elif role == 'solar':
161+
if not self._expect_solar:
162+
self._expect_solar = True
163+
self._solar_instants.append(ev)
164+
await self._process_instants(starttime_utc)
165+
166+
async def process_summation_event(self, ev: dict):
167+
"""Ingests an event of type 'summation_energy'."""
168+
if not KEY_START in ev:
169+
return
170+
starttime_utc = int(ev[KEY_START])
171+
if 'role' in ev:
172+
role = ev['role']
173+
if role == 'house-net':
174+
self._housenet_summations.append(ev)
175+
await self._process_summations(starttime_utc)
176+
elif role == 'solar':
177+
if not self._expect_solar:
178+
self._expect_solar = True
179+
self._solar_summations.append(ev)
180+
await self._process_summations(starttime_utc)
181+
182+
async def _process_instants(self, starttime_utc: int):
183+
v = None
184+
if self._expect_solar:
185+
v = matching_instants(starttime_utc, self._solar_instants, self._housenet_instants)
186+
else:
187+
v = make_instant_housenet(self._housenet_instants.find_by_key(KEY_START, starttime_utc))
188+
if v is None:
189+
return
190+
191+
self._solar_instants.evict_older(KEY_START, starttime_utc)
192+
self._housenet_instants.evict_older(KEY_START, starttime_utc)
193+
194+
await self.emit('from_grid', {
195+
'timestamp_utc': v.starttime_utc,
196+
'watts': v.housenet_watts if v.housenet_watts > 0 else 0,
197+
})
198+
await self.emit('home_usage', {
199+
'timestamp_utc': v.starttime_utc,
200+
'watts': max(v.housenet_watts - v.solar_watts, 0),
201+
})
202+
if self._expect_solar:
203+
await self.emit('solar_generation', {
204+
'timestamp_utc': v.starttime_utc,
205+
'watts': max(-v.solar_watts, 0),
206+
})
207+
await self.emit('to_grid', {
208+
'timestamp_utc': v.starttime_utc,
209+
'watts': -v.housenet_watts if v.housenet_watts < 0 else 0,
210+
})
211+
212+
async def _process_summations(self, starttime_utc: int):
213+
v = None
214+
if self._expect_solar:
215+
v = matching_summations(starttime_utc, self._solar_summations, self._housenet_summations)
216+
else:
217+
v = make_summation_housenet(self._housenet_summations.find_by_key(KEY_START, starttime_utc))
218+
if v is None:
219+
return
220+
221+
self._solar_summations.evict_older(KEY_START, starttime_utc)
222+
self._housenet_summations.evict_older(KEY_START, starttime_utc)
223+
224+
if not self._resettime_validation(v, starttime_utc):
225+
return
226+
227+
deltas = self._calculate_summation_deltas(v)
228+
self._increment_counters(deltas)
229+
230+
await self.emit('from_grid_summation', {
231+
'timestamp_utc': starttime_utc,
232+
'summation_resettime_utc': self._counters.resettime_utc,
233+
'summation_joules': self._counters.from_grid,
234+
})
235+
await self.emit('home_usage_summation', {
236+
'timestamp_utc': starttime_utc,
237+
'summation_resettime_utc': self._counters.resettime_utc,
238+
'summation_joules': self._counters.home_use,
239+
})
240+
if self._expect_solar:
241+
await self.emit('solar_generation_summation', {
242+
'timestamp_utc': starttime_utc,
243+
'summation_resettime_utc': self._counters.resettime_utc,
244+
'summation_joules': self._counters.solar_generation,
245+
})
246+
await self.emit('to_grid_summation', {
247+
'timestamp_utc': starttime_utc,
248+
'summation_resettime_utc': self._counters.resettime_utc,
249+
'summation_joules': self._counters.to_grid,
250+
})
251+
252+
def _resettime_validation(self, v: SummationValues, starttime_utc: int) -> bool:
253+
res = True
254+
summ = self._summation
255+
if v.solar_resettime != summ.solar_resettime:
256+
summ.solar_resettime = v.solar_resettime
257+
summ.solar_last = v.solar_summation
258+
res = False
259+
if v.housenet_resettime != summ.housenet_resettime:
260+
summ.housenet_resettime = v.housenet_resettime
261+
summ.housenet_last = v.housenet_summation
262+
res = False
263+
if not res:
264+
self._clear_counters(starttime_utc)
265+
return res
266+
267+
def _clear_counters(self, resettime_utc: int):
268+
self._counters = self.Counters(resettime_utc, 0, 0, 0, 0)
269+
270+
def _calculate_summation_deltas(self, v: SummationValues) -> SummationDeltas:
271+
summ = self._summation
272+
273+
solar_delta = v.solar_summation - summ.solar_last
274+
summ.solar_last = v.solar_summation
275+
276+
housenet_delta = v.housenet_summation - summ.housenet_last
277+
summ.housenet_last = v.housenet_summation
278+
279+
return SummationDeltas(
280+
solar_generation = max(-solar_delta, 0),
281+
to_grid = -housenet_delta if housenet_delta < 0 else 0,
282+
from_grid = housenet_delta if housenet_delta > 0 else 0,
283+
home_use = max(housenet_delta - solar_delta, 0)
284+
)
285+
286+
def _increment_counters(self, d: SummationDeltas):
287+
self._counters.solar_generation += d.solar_generation
288+
self._counters.to_grid += d.to_grid
289+
self._counters.from_grid += d.from_grid
290+
self._counters.home_use += d.home_use
291+
292+
class EventBuffer:
293+
def __init__(self, keep: int):
294+
self._keep = keep
295+
self._evs = []
296+
297+
def find_by_key(self, key: str, value: any):
298+
for ev in self._evs:
299+
if key in ev and ev[key] == value:
300+
return ev
301+
return None
302+
303+
def append(self, ev: dict):
304+
self._evs.append(ev)
305+
if len(self._evs) > self._keep:
306+
del self._evs[0]
307+
308+
def evict_older(self, key: str, value: float):
309+
while len(self._evs) > 0:
310+
ev = self._evs[0]
311+
if key in ev and ev[key] <= value:
312+
del self._evs[0]
313+
else:
314+
return
315+
316+
@dataclass
317+
class SummationInfo:
318+
solar_resettime: int
319+
solar_last: float
320+
housenet_resettime: int
321+
housenet_last: float
322+
323+
@dataclass
324+
class Counters:
325+
resettime_utc: int
326+
solar_generation: float
327+
to_grid: float
328+
from_grid: float
329+
home_use: float

0 commit comments

Comments
 (0)