sensor-modeling 0.2.0__py3-none-any.whl
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.
- sensor_modeling/__init__.py +45 -0
- sensor_modeling/alerts/__init__.py +26 -0
- sensor_modeling/alerts/alert.py +532 -0
- sensor_modeling/analysis/__init__.py +43 -0
- sensor_modeling/analysis/_frame.py +19 -0
- sensor_modeling/analysis/behavioral_analysis.py +57 -0
- sensor_modeling/analysis/behavioral_metrics.py +66 -0
- sensor_modeling/analysis/comparison.py +164 -0
- sensor_modeling/analysis/dependency_network.py +408 -0
- sensor_modeling/analysis/granger_causality.py +314 -0
- sensor_modeling/analysis/pipeline.py +168 -0
- sensor_modeling/analysis/reporting.py +109 -0
- sensor_modeling/baseline/__init__.py +30 -0
- sensor_modeling/baseline/adaptive.py +520 -0
- sensor_modeling/baseline/features.py +224 -0
- sensor_modeling/change_point/__init__.py +13 -0
- sensor_modeling/change_point/_validation.py +31 -0
- sensor_modeling/change_point/adaptive_normalization.py +55 -0
- sensor_modeling/change_point/embedding_cpd.py +60 -0
- sensor_modeling/change_point/energy_efficient.py +57 -0
- sensor_modeling/change_point/genetic_optimization.py +65 -0
- sensor_modeling/cli.py +416 -0
- sensor_modeling/context/__init__.py +33 -0
- sensor_modeling/context/occupancy.py +529 -0
- sensor_modeling/data/__init__.py +5 -0
- sensor_modeling/data/loaders.py +146 -0
- sensor_modeling/data/preprocessing.py +83 -0
- sensor_modeling/data/synthetic.py +121 -0
- sensor_modeling/data/validation.py +81 -0
- sensor_modeling/evaluation/__init__.py +92 -0
- sensor_modeling/evaluation/ablation.py +303 -0
- sensor_modeling/evaluation/attribution.py +474 -0
- sensor_modeling/evaluation/detection.py +297 -0
- sensor_modeling/evaluation/metrics.py +541 -0
- sensor_modeling/evaluation/provenance.py +309 -0
- sensor_modeling/examples/__init__.py +1 -0
- sensor_modeling/examples/demos/__init__.py +1 -0
- sensor_modeling/examples/demos/ambient_pipeline_demo.py +418 -0
- sensor_modeling/examples/demos/bernoulli_ar_demo.py +356 -0
- sensor_modeling/examples/demos/cpd_ar_demo.py +25 -0
- sensor_modeling/examples/demos/cpd_benchmark.py +42 -0
- sensor_modeling/examples/demos/hmm_granger_demo.py +30 -0
- sensor_modeling/examples/demos/nhpp_pelt_demo.py +80 -0
- sensor_modeling/examples/tutorials/__init__.py +1 -0
- sensor_modeling/fusion/__init__.py +46 -0
- sensor_modeling/fusion/defaults.py +296 -0
- sensor_modeling/fusion/emissions.py +339 -0
- sensor_modeling/fusion/estimate.py +375 -0
- sensor_modeling/fusion/filter.py +323 -0
- sensor_modeling/health/__init__.py +31 -0
- sensor_modeling/health/monitor.py +590 -0
- sensor_modeling/health/status.py +74 -0
- sensor_modeling/hmm/__init__.py +15 -0
- sensor_modeling/hmm/adaptive_hmm.py +22 -0
- sensor_modeling/hmm/base.py +134 -0
- sensor_modeling/hmm/circadian_hmm.py +22 -0
- sensor_modeling/hmm/heterogeneous_hmm.py +22 -0
- sensor_modeling/hmm/hierarchical_hmm.py +35 -0
- sensor_modeling/hmm/scaled_dirichlet_hmm.py +23 -0
- sensor_modeling/interop/__init__.py +57 -0
- sensor_modeling/interop/fhir.py +418 -0
- sensor_modeling/interop/privacy.py +308 -0
- sensor_modeling/models/__init__.py +12 -0
- sensor_modeling/models/bernoulli_ar/__init__.py +6 -0
- sensor_modeling/models/bernoulli_ar/base_model.py +569 -0
- sensor_modeling/models/bernoulli_ar/multivariate_model.py +411 -0
- sensor_modeling/models/change_point_detection/__init__.py +10 -0
- sensor_modeling/models/change_point_detection/deep.py +65 -0
- sensor_modeling/models/change_point_detection/pelt.py +159 -0
- sensor_modeling/models/nhpp_pelt/__init__.py +5 -0
- sensor_modeling/models/nhpp_pelt/bspline.py +96 -0
- sensor_modeling/models/nhpp_pelt/cli.py +243 -0
- sensor_modeling/models/nhpp_pelt/diagnostics.py +234 -0
- sensor_modeling/models/nhpp_pelt/io.py +58 -0
- sensor_modeling/models/nhpp_pelt/model.py +408 -0
- sensor_modeling/models/nhpp_pelt/optimizer.py +142 -0
- sensor_modeling/models/nhpp_pelt/plotting.py +218 -0
- sensor_modeling/models/nhpp_pelt/quad.py +72 -0
- sensor_modeling/models/nhpp_pelt/regularization.py +121 -0
- sensor_modeling/models/nhpp_pelt/utils.py +174 -0
- sensor_modeling/observations/__init__.py +59 -0
- sensor_modeling/observations/adapters.py +195 -0
- sensor_modeling/observations/ingest.py +269 -0
- sensor_modeling/observations/observation.py +270 -0
- sensor_modeling/observations/registry.py +262 -0
- sensor_modeling/observations/stream.py +342 -0
- sensor_modeling/observations/types.py +107 -0
- sensor_modeling/observations/units.py +117 -0
- sensor_modeling/online/__init__.py +36 -0
- sensor_modeling/online/benchmarks.py +242 -0
- sensor_modeling/online/pipeline.py +485 -0
- sensor_modeling/simulation/__init__.py +54 -0
- sensor_modeling/simulation/faults.py +191 -0
- sensor_modeling/simulation/household.py +862 -0
- sensor_modeling/states/__init__.py +23 -0
- sensor_modeling/states/markov.py +105 -0
- sensor_modeling/states/ontology.py +238 -0
- sensor_modeling/utils/__init__.py +41 -0
- sensor_modeling/utils/data_io.py +199 -0
- sensor_modeling/utils/logging_config.py +10 -0
- sensor_modeling/utils/missing.py +188 -0
- sensor_modeling/utils/plotting.py +98 -0
- sensor_modeling/utils/validation.py +117 -0
- sensor_modeling/visualization/__init__.py +3 -0
- sensor_modeling/visualization/clinical.py +67 -0
- sensor_modeling/visualization/interactive.py +208 -0
- sensor_modeling/visualization/research.py +60 -0
- sensor_modeling/visualization/web_app.py +137 -0
- sensor_modeling-0.2.0.dist-info/METADATA +683 -0
- sensor_modeling-0.2.0.dist-info/RECORD +114 -0
- sensor_modeling-0.2.0.dist-info/WHEEL +5 -0
- sensor_modeling-0.2.0.dist-info/entry_points.txt +18 -0
- sensor_modeling-0.2.0.dist-info/licenses/LICENSE +21 -0
- sensor_modeling-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,862 @@
|
|
|
1
|
+
"""A synthetic household with controlled ground truth.
|
|
2
|
+
|
|
3
|
+
The simulator exists so that inference can be evaluated against something
|
|
4
|
+
known. That only works if the generative process is genuinely different from
|
|
5
|
+
the inference model, so this module deliberately does *not* sample from the
|
|
6
|
+
continuous-time chain the filter uses. It generates a **schedule**: a person
|
|
7
|
+
who wakes at roughly the same time each morning, makes breakfast, goes out,
|
|
8
|
+
comes back, and goes to bed, with stochastic timings and durations.
|
|
9
|
+
|
|
10
|
+
Inference then has to recover that schedule through a Markov model that knows
|
|
11
|
+
nothing about schedules. If the two shared a generator, good results would
|
|
12
|
+
prove only that the code can invert its own assumptions.
|
|
13
|
+
|
|
14
|
+
What the ground truth records:
|
|
15
|
+
|
|
16
|
+
.. code-block:: text
|
|
17
|
+
|
|
18
|
+
resident location and behavioural state, minute by minute
|
|
19
|
+
sleep periods and night-time bathroom trips
|
|
20
|
+
visitor arrivals, departures, and their own movements
|
|
21
|
+
room transitions and door crossings
|
|
22
|
+
which sensor activations came from whom
|
|
23
|
+
|
|
24
|
+
Sensor records are then generated *from* that truth, with per-sensor rates,
|
|
25
|
+
false activations, and misses. Degrading the stream further -- dropouts,
|
|
26
|
+
stuck sensors, a wearable left off -- is the separate concern of
|
|
27
|
+
:mod:`sensor_modeling.simulation.faults`, so that robustness studies can vary
|
|
28
|
+
faults without regenerating behaviour.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import logging
|
|
34
|
+
from collections.abc import Sequence
|
|
35
|
+
from dataclasses import dataclass, field
|
|
36
|
+
from datetime import date, datetime, time, timedelta, timezone, tzinfo
|
|
37
|
+
from zoneinfo import ZoneInfo
|
|
38
|
+
|
|
39
|
+
import numpy as np
|
|
40
|
+
|
|
41
|
+
from ..context.occupancy import OccupancyContext
|
|
42
|
+
from ..observations.observation import Observation
|
|
43
|
+
from ..observations.registry import SensorRegistry, SensorSpec
|
|
44
|
+
from ..observations.types import Modality, ObservationKind
|
|
45
|
+
from ..observations.units import Unit
|
|
46
|
+
from ..states.ontology import BehaviouralState
|
|
47
|
+
|
|
48
|
+
logger = logging.getLogger(__name__)
|
|
49
|
+
|
|
50
|
+
S = BehaviouralState
|
|
51
|
+
LISBON = ZoneInfo("Europe/Lisbon")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True)
|
|
55
|
+
class Episode:
|
|
56
|
+
"""One period of known resident behaviour.
|
|
57
|
+
|
|
58
|
+
Attributes
|
|
59
|
+
----------
|
|
60
|
+
start, end
|
|
61
|
+
Absolute bounds of the episode.
|
|
62
|
+
state
|
|
63
|
+
The resident's true behavioural state.
|
|
64
|
+
room
|
|
65
|
+
The room they are in, or ``None`` when out of the home.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
start: datetime
|
|
69
|
+
end: datetime
|
|
70
|
+
state: BehaviouralState
|
|
71
|
+
room: str | None
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def duration(self) -> timedelta:
|
|
75
|
+
"""Length of the episode."""
|
|
76
|
+
return self.end - self.start
|
|
77
|
+
|
|
78
|
+
def contains(self, moment: datetime) -> bool:
|
|
79
|
+
"""Whether *moment* falls inside the episode."""
|
|
80
|
+
return self.start <= moment < self.end
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass(frozen=True)
|
|
84
|
+
class VisitorPeriod:
|
|
85
|
+
"""A period during which someone other than the resident is present."""
|
|
86
|
+
|
|
87
|
+
start: datetime
|
|
88
|
+
end: datetime
|
|
89
|
+
room_sequence: tuple[str, ...]
|
|
90
|
+
|
|
91
|
+
def room_at(self, moment: datetime) -> str | None:
|
|
92
|
+
"""Return which room the visitor occupies at *moment*."""
|
|
93
|
+
if not self.start <= moment < self.end:
|
|
94
|
+
return None
|
|
95
|
+
span = (self.end - self.start).total_seconds()
|
|
96
|
+
if span <= 0:
|
|
97
|
+
return self.room_sequence[0]
|
|
98
|
+
progress = (moment - self.start).total_seconds() / span
|
|
99
|
+
index = min(
|
|
100
|
+
int(progress * len(self.room_sequence)), len(self.room_sequence) - 1
|
|
101
|
+
)
|
|
102
|
+
return self.room_sequence[index]
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@dataclass(frozen=True)
|
|
106
|
+
class GroundTruth:
|
|
107
|
+
"""Everything the simulator knows and the inference layer must not see."""
|
|
108
|
+
|
|
109
|
+
episodes: tuple[Episode, ...]
|
|
110
|
+
visitors: tuple[VisitorPeriod, ...]
|
|
111
|
+
tz: tzinfo
|
|
112
|
+
|
|
113
|
+
def state_at(self, moment: datetime) -> BehaviouralState | None:
|
|
114
|
+
"""Return the resident's true state at *moment*, if simulated."""
|
|
115
|
+
for episode in self.episodes:
|
|
116
|
+
if episode.contains(moment):
|
|
117
|
+
return episode.state
|
|
118
|
+
return None
|
|
119
|
+
|
|
120
|
+
def room_at(self, moment: datetime) -> str | None:
|
|
121
|
+
"""Return the resident's true room at *moment*."""
|
|
122
|
+
for episode in self.episodes:
|
|
123
|
+
if episode.contains(moment):
|
|
124
|
+
return episode.room
|
|
125
|
+
return None
|
|
126
|
+
|
|
127
|
+
def visitor_at(self, moment: datetime) -> bool:
|
|
128
|
+
"""Whether anyone other than the resident is present at *moment*."""
|
|
129
|
+
return any(period.start <= moment < period.end for period in self.visitors)
|
|
130
|
+
|
|
131
|
+
def context_at(self, moment: datetime) -> OccupancyContext:
|
|
132
|
+
"""Return the true occupancy context at *moment*."""
|
|
133
|
+
state = self.state_at(moment)
|
|
134
|
+
resident_home = state is not None and state is not S.AWAY
|
|
135
|
+
visitor = self.visitor_at(moment)
|
|
136
|
+
if resident_home and visitor:
|
|
137
|
+
return OccupancyContext.RESIDENT_WITH_VISITOR
|
|
138
|
+
if resident_home:
|
|
139
|
+
return OccupancyContext.RESIDENT_ALONE
|
|
140
|
+
return OccupancyContext.VISITOR_ONLY if visitor else OccupancyContext.EMPTY
|
|
141
|
+
|
|
142
|
+
def states_at(self, moments: Sequence[datetime]) -> list[BehaviouralState | None]:
|
|
143
|
+
"""Return the true state at each of *moments*, efficiently.
|
|
144
|
+
|
|
145
|
+
Uses a single ordered sweep rather than scanning every episode for
|
|
146
|
+
every moment, which matters when evaluating months of minute-level
|
|
147
|
+
estimates.
|
|
148
|
+
"""
|
|
149
|
+
results: list[BehaviouralState | None] = []
|
|
150
|
+
index = 0
|
|
151
|
+
episodes = self.episodes
|
|
152
|
+
for moment in moments:
|
|
153
|
+
while index < len(episodes) and episodes[index].end <= moment:
|
|
154
|
+
index += 1
|
|
155
|
+
if index < len(episodes) and episodes[index].contains(moment):
|
|
156
|
+
results.append(episodes[index].state)
|
|
157
|
+
else:
|
|
158
|
+
results.append(None)
|
|
159
|
+
return results
|
|
160
|
+
|
|
161
|
+
def daily_hours(self, state: BehaviouralState) -> dict[date, float]:
|
|
162
|
+
"""Return true hours spent in *state* per local calendar day."""
|
|
163
|
+
totals: dict[date, float] = {}
|
|
164
|
+
for episode in self.episodes:
|
|
165
|
+
if episode.state is not state:
|
|
166
|
+
continue
|
|
167
|
+
cursor = episode.start
|
|
168
|
+
while cursor < episode.end:
|
|
169
|
+
local = cursor.astimezone(self.tz)
|
|
170
|
+
next_midnight = datetime.combine(
|
|
171
|
+
local.date() + timedelta(days=1),
|
|
172
|
+
time.min,
|
|
173
|
+
tzinfo=local.tzinfo,
|
|
174
|
+
)
|
|
175
|
+
boundary = min(next_midnight.astimezone(timezone.utc), episode.end)
|
|
176
|
+
hours = (boundary - cursor).total_seconds() / 3600.0
|
|
177
|
+
totals[local.date()] = totals.get(local.date(), 0.0) + hours
|
|
178
|
+
cursor = boundary
|
|
179
|
+
return totals
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
@dataclass
|
|
183
|
+
class BehaviourShift:
|
|
184
|
+
"""A persistent change in the resident's routine, injected at a known day.
|
|
185
|
+
|
|
186
|
+
Parameters
|
|
187
|
+
----------
|
|
188
|
+
start_day
|
|
189
|
+
Days after the simulation begins at which the change takes effect.
|
|
190
|
+
sleep_delta_hours
|
|
191
|
+
Change in nightly sleep duration, negative for less sleep.
|
|
192
|
+
night_bathroom_extra
|
|
193
|
+
Additional expected night-time bathroom trips per night.
|
|
194
|
+
outing_probability_delta
|
|
195
|
+
Change in the probability of going out on a given day.
|
|
196
|
+
ramp_days
|
|
197
|
+
Days over which the change phases in linearly. Zero produces a step
|
|
198
|
+
on ``start_day``; a positive value produces a gradual decline, which
|
|
199
|
+
is a different detection problem: a step is visible in a single day's
|
|
200
|
+
deviation, whereas a slow trend never is and can only be found by
|
|
201
|
+
looking across a window.
|
|
202
|
+
"""
|
|
203
|
+
|
|
204
|
+
start_day: int
|
|
205
|
+
sleep_delta_hours: float = 0.0
|
|
206
|
+
night_bathroom_extra: float = 0.0
|
|
207
|
+
outing_probability_delta: float = 0.0
|
|
208
|
+
ramp_days: int = 0
|
|
209
|
+
|
|
210
|
+
def __post_init__(self) -> None:
|
|
211
|
+
"""Validate the injected shift."""
|
|
212
|
+
if self.start_day < 0:
|
|
213
|
+
raise ValueError("start_day must be non-negative")
|
|
214
|
+
if self.ramp_days < 0:
|
|
215
|
+
raise ValueError("ramp_days must be non-negative")
|
|
216
|
+
|
|
217
|
+
def active_on(self, day_index: int) -> bool:
|
|
218
|
+
"""Whether the shift applies on the given simulation day."""
|
|
219
|
+
return day_index >= self.start_day
|
|
220
|
+
|
|
221
|
+
def strength_on(self, day_index: int) -> float:
|
|
222
|
+
"""Return how far the change has taken effect, in ``[0, 1]``."""
|
|
223
|
+
if day_index < self.start_day:
|
|
224
|
+
return 0.0
|
|
225
|
+
if self.ramp_days <= 0:
|
|
226
|
+
return 1.0
|
|
227
|
+
elapsed = day_index - self.start_day
|
|
228
|
+
return min(elapsed / self.ramp_days, 1.0)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
@dataclass
|
|
232
|
+
class HouseholdConfig:
|
|
233
|
+
"""Configuration of the synthetic household.
|
|
234
|
+
|
|
235
|
+
Parameters
|
|
236
|
+
----------
|
|
237
|
+
days
|
|
238
|
+
Number of days to simulate.
|
|
239
|
+
start
|
|
240
|
+
Local date the simulation begins.
|
|
241
|
+
tz
|
|
242
|
+
Timezone of the household, so routines follow the local clock.
|
|
243
|
+
seed
|
|
244
|
+
Random seed. Every run with the same configuration is identical.
|
|
245
|
+
wake_hour, sleep_hour
|
|
246
|
+
Mean local wake and bedtime, in hours.
|
|
247
|
+
timing_jitter_minutes
|
|
248
|
+
Standard deviation of daily timing variation.
|
|
249
|
+
night_bathroom_rate
|
|
250
|
+
Expected night-time bathroom trips per night.
|
|
251
|
+
outing_probability
|
|
252
|
+
Probability of leaving the home on a given day.
|
|
253
|
+
visitor_probability
|
|
254
|
+
Probability of a visitor on a given day.
|
|
255
|
+
carer_weekday_visits
|
|
256
|
+
Whether a regular carer visits on weekday mornings while the resident
|
|
257
|
+
may be out, which is what makes attribution non-trivial.
|
|
258
|
+
shift
|
|
259
|
+
An optional persistent behavioural change to inject.
|
|
260
|
+
"""
|
|
261
|
+
|
|
262
|
+
days: int = 60
|
|
263
|
+
start: date = date(2024, 3, 4)
|
|
264
|
+
tz: tzinfo = LISBON
|
|
265
|
+
seed: int = 20240304
|
|
266
|
+
wake_hour: float = 7.0
|
|
267
|
+
sleep_hour: float = 23.0
|
|
268
|
+
timing_jitter_minutes: float = 35.0
|
|
269
|
+
night_bathroom_rate: float = 0.8
|
|
270
|
+
outing_probability: float = 0.55
|
|
271
|
+
visitor_probability: float = 0.2
|
|
272
|
+
carer_weekday_visits: bool = True
|
|
273
|
+
shift: BehaviourShift | None = None
|
|
274
|
+
|
|
275
|
+
def __post_init__(self) -> None:
|
|
276
|
+
"""Validate the household configuration."""
|
|
277
|
+
if self.days < 1:
|
|
278
|
+
raise ValueError("days must be at least 1")
|
|
279
|
+
if not 0.0 <= self.wake_hour < 24.0 or not 0.0 < self.sleep_hour < 24.0:
|
|
280
|
+
raise ValueError("wake_hour and sleep_hour must be valid hours of day")
|
|
281
|
+
if self.wake_hour >= self.sleep_hour:
|
|
282
|
+
raise ValueError("wake_hour must precede sleep_hour within the same day")
|
|
283
|
+
if self.timing_jitter_minutes < 0:
|
|
284
|
+
raise ValueError("timing_jitter_minutes must be non-negative")
|
|
285
|
+
if self.night_bathroom_rate < 0:
|
|
286
|
+
raise ValueError("night_bathroom_rate must be non-negative")
|
|
287
|
+
for name in ("outing_probability", "visitor_probability"):
|
|
288
|
+
value = float(getattr(self, name))
|
|
289
|
+
if not 0.0 <= value <= 1.0:
|
|
290
|
+
raise ValueError(f"{name} must lie in [0, 1]")
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def build_registry() -> SensorRegistry:
|
|
294
|
+
"""Return the sensor deployment of the synthetic household.
|
|
295
|
+
|
|
296
|
+
Deliberately multimodal, so that ablation studies have something to
|
|
297
|
+
ablate: object contacts, room motion, a door, bed pressure, a radar
|
|
298
|
+
presence feature, a wearable, and a personal beacon.
|
|
299
|
+
"""
|
|
300
|
+
return SensorRegistry.from_specs(
|
|
301
|
+
[
|
|
302
|
+
SensorSpec(
|
|
303
|
+
"front_door",
|
|
304
|
+
Modality.DOOR,
|
|
305
|
+
room="hall",
|
|
306
|
+
description="Contact on the entrance door; fires on any crossing.",
|
|
307
|
+
),
|
|
308
|
+
SensorSpec(
|
|
309
|
+
"bedroom_motion",
|
|
310
|
+
Modality.MOTION,
|
|
311
|
+
room="bedroom",
|
|
312
|
+
description="PIR covering the bedroom.",
|
|
313
|
+
),
|
|
314
|
+
SensorSpec(
|
|
315
|
+
"bathroom_motion",
|
|
316
|
+
Modality.MOTION,
|
|
317
|
+
room="bathroom",
|
|
318
|
+
description="PIR covering the bathroom.",
|
|
319
|
+
),
|
|
320
|
+
SensorSpec(
|
|
321
|
+
"kitchen_motion",
|
|
322
|
+
Modality.MOTION,
|
|
323
|
+
room="kitchen",
|
|
324
|
+
description="PIR covering the kitchen.",
|
|
325
|
+
),
|
|
326
|
+
SensorSpec(
|
|
327
|
+
"living_motion",
|
|
328
|
+
Modality.MOTION,
|
|
329
|
+
room="living",
|
|
330
|
+
description="PIR covering the living room.",
|
|
331
|
+
),
|
|
332
|
+
SensorSpec(
|
|
333
|
+
"fridge_contact",
|
|
334
|
+
Modality.CONTACT,
|
|
335
|
+
room="kitchen",
|
|
336
|
+
description="Contact on the fridge door. Records the door "
|
|
337
|
+
"opening, which is not a record of eating.",
|
|
338
|
+
),
|
|
339
|
+
SensorSpec(
|
|
340
|
+
"bed_pressure",
|
|
341
|
+
Modality.BED_PRESSURE,
|
|
342
|
+
kind=ObservationKind.STATE,
|
|
343
|
+
room="bedroom",
|
|
344
|
+
expected_interval=timedelta(minutes=5),
|
|
345
|
+
value_range=(0.0, 1.0),
|
|
346
|
+
description="Load cell reporting bed occupancy as a level.",
|
|
347
|
+
),
|
|
348
|
+
SensorSpec(
|
|
349
|
+
"living_radar",
|
|
350
|
+
Modality.RADAR,
|
|
351
|
+
kind=ObservationKind.SAMPLE,
|
|
352
|
+
unit=Unit.COUNT,
|
|
353
|
+
room="living",
|
|
354
|
+
expected_interval=timedelta(minutes=1),
|
|
355
|
+
value_range=(0.0, 6.0),
|
|
356
|
+
description="Derived mmWave feature: number of tracked people "
|
|
357
|
+
"in the home. Not raw radar; no identity is inferred.",
|
|
358
|
+
),
|
|
359
|
+
SensorSpec(
|
|
360
|
+
"wearable_motion",
|
|
361
|
+
Modality.WEARABLE_MOTION,
|
|
362
|
+
kind=ObservationKind.SAMPLE,
|
|
363
|
+
room=None,
|
|
364
|
+
expected_interval=timedelta(minutes=1),
|
|
365
|
+
value_range=(0.0, 10.0),
|
|
366
|
+
attributable=True,
|
|
367
|
+
description="Accelerometer activity magnitude from a worn "
|
|
368
|
+
"device. Attributable to the resident when worn.",
|
|
369
|
+
),
|
|
370
|
+
SensorSpec(
|
|
371
|
+
"resident_beacon",
|
|
372
|
+
Modality.PROXIMITY,
|
|
373
|
+
kind=ObservationKind.STATE,
|
|
374
|
+
expected_interval=timedelta(minutes=1),
|
|
375
|
+
value_range=(0.0, 1.0),
|
|
376
|
+
attributable=True,
|
|
377
|
+
description="Whether the resident's personal tag is in range "
|
|
378
|
+
"of the home hub.",
|
|
379
|
+
),
|
|
380
|
+
]
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
#: Activation rate per hour of each motion-style sensor when the resident is
|
|
385
|
+
#: in that room and active, and when they are not. The non-zero idle rate is
|
|
386
|
+
#: what produces the false activations any real deployment has.
|
|
387
|
+
ACTIVE_RATE = 45.0
|
|
388
|
+
IDLE_RATE = 0.12
|
|
389
|
+
|
|
390
|
+
#: Expected wearable activity magnitude in each true state.
|
|
391
|
+
WEARABLE_LEVEL: dict[BehaviouralState, float] = {
|
|
392
|
+
S.SLEEPING: 0.03,
|
|
393
|
+
S.BED_AWAKE: 0.25,
|
|
394
|
+
S.HOME_INACTIVE: 0.35,
|
|
395
|
+
S.HOME_ACTIVE: 1.4,
|
|
396
|
+
S.KITCHEN_ACTIVITY: 1.1,
|
|
397
|
+
S.BATHROOM_ACTIVITY: 0.9,
|
|
398
|
+
S.AWAY: 1.9,
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def _localise(day: date, hour: float, zone: tzinfo) -> datetime:
|
|
403
|
+
"""Return the absolute instant of a local wall-clock hour on *day*.
|
|
404
|
+
|
|
405
|
+
Wall-clock arithmetic is done once, here, and everything downstream works
|
|
406
|
+
in absolute time. A local hour that does not exist because of a
|
|
407
|
+
spring-forward is nudged past the gap rather than silently resolving to
|
|
408
|
+
the wrong instant.
|
|
409
|
+
"""
|
|
410
|
+
clamped = min(max(hour, 0.0), 23.999)
|
|
411
|
+
naive = datetime.combine(day, time.min) + timedelta(hours=clamped)
|
|
412
|
+
local = naive.replace(tzinfo=zone)
|
|
413
|
+
if local.astimezone(timezone.utc).astimezone(zone).hour != local.hour:
|
|
414
|
+
local = (naive + timedelta(hours=1)).replace(tzinfo=zone)
|
|
415
|
+
return local.astimezone(timezone.utc)
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def _plan_day(
|
|
419
|
+
day_index: int,
|
|
420
|
+
day: date,
|
|
421
|
+
config: HouseholdConfig,
|
|
422
|
+
rng: np.random.Generator,
|
|
423
|
+
) -> tuple[list[Episode], list[VisitorPeriod]]:
|
|
424
|
+
"""Generate one day of true behaviour and visitor activity."""
|
|
425
|
+
zone = config.tz
|
|
426
|
+
jitter = config.timing_jitter_minutes / 60.0
|
|
427
|
+
shift = config.shift
|
|
428
|
+
# How far the injected change has taken effect today. A step shift is
|
|
429
|
+
# fully on from its start day; a ramped one phases in.
|
|
430
|
+
strength = shift.strength_on(day_index) if shift is not None else 0.0
|
|
431
|
+
|
|
432
|
+
weekend = day.weekday() >= 5
|
|
433
|
+
wake_hour = config.wake_hour + (1.2 if weekend else 0.0)
|
|
434
|
+
wake_hour += rng.normal(0.0, jitter)
|
|
435
|
+
sleep_hour = config.sleep_hour + rng.normal(0.0, jitter)
|
|
436
|
+
if strength > 0.0 and shift is not None:
|
|
437
|
+
# Less sleep is taken from the front of the night: the resident wakes
|
|
438
|
+
# earlier rather than going to bed later, which is the pattern most
|
|
439
|
+
# often reported in the ambient-monitoring literature.
|
|
440
|
+
wake_hour -= shift.sleep_delta_hours * strength
|
|
441
|
+
|
|
442
|
+
wake = _localise(day, wake_hour, zone)
|
|
443
|
+
bedtime = _localise(day, sleep_hour, zone)
|
|
444
|
+
midnight = _localise(day, 0.0, zone)
|
|
445
|
+
|
|
446
|
+
episodes: list[Episode] = []
|
|
447
|
+
|
|
448
|
+
# --- night: sleeping, interrupted by bathroom trips ---------------
|
|
449
|
+
night_rate = config.night_bathroom_rate + (
|
|
450
|
+
shift.night_bathroom_extra * strength if shift is not None else 0.0
|
|
451
|
+
)
|
|
452
|
+
trips = int(rng.poisson(max(night_rate, 0.0)))
|
|
453
|
+
trip_starts = sorted(
|
|
454
|
+
midnight + timedelta(hours=float(h))
|
|
455
|
+
for h in rng.uniform(1.0, max(wake_hour - 0.5, 1.5), size=trips)
|
|
456
|
+
)
|
|
457
|
+
cursor = midnight
|
|
458
|
+
for trip_start in trip_starts:
|
|
459
|
+
if trip_start <= cursor or trip_start >= wake:
|
|
460
|
+
continue
|
|
461
|
+
episodes.append(Episode(cursor, trip_start, S.SLEEPING, "bedroom"))
|
|
462
|
+
trip_end = min(trip_start + timedelta(minutes=float(rng.uniform(3, 9))), wake)
|
|
463
|
+
episodes.append(Episode(trip_start, trip_end, S.BATHROOM_ACTIVITY, "bathroom"))
|
|
464
|
+
cursor = trip_end
|
|
465
|
+
if cursor < wake:
|
|
466
|
+
episodes.append(Episode(cursor, wake, S.SLEEPING, "bedroom"))
|
|
467
|
+
|
|
468
|
+
# --- morning routine ----------------------------------------------
|
|
469
|
+
cursor = wake
|
|
470
|
+
morning_bathroom = cursor + timedelta(minutes=float(rng.uniform(8, 20)))
|
|
471
|
+
episodes.append(Episode(cursor, morning_bathroom, S.BED_AWAKE, "bedroom"))
|
|
472
|
+
cursor = morning_bathroom
|
|
473
|
+
after_bathroom = cursor + timedelta(minutes=float(rng.uniform(8, 18)))
|
|
474
|
+
episodes.append(Episode(cursor, after_bathroom, S.BATHROOM_ACTIVITY, "bathroom"))
|
|
475
|
+
cursor = after_bathroom
|
|
476
|
+
breakfast_end = cursor + timedelta(minutes=float(rng.uniform(20, 45)))
|
|
477
|
+
episodes.append(Episode(cursor, breakfast_end, S.KITCHEN_ACTIVITY, "kitchen"))
|
|
478
|
+
cursor = breakfast_end
|
|
479
|
+
|
|
480
|
+
daytime, cursor = _plan_daytime(day, cursor, bedtime, config, rng, zone, strength)
|
|
481
|
+
episodes.extend(daytime)
|
|
482
|
+
|
|
483
|
+
next_midnight = _localise(day + timedelta(days=1), 0.0, zone)
|
|
484
|
+
if next_midnight > cursor:
|
|
485
|
+
episodes.append(Episode(cursor, next_midnight, S.SLEEPING, "bedroom"))
|
|
486
|
+
|
|
487
|
+
return episodes, _plan_visitors(day, config, rng, zone)
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def _plan_daytime(
|
|
491
|
+
day: date,
|
|
492
|
+
start: datetime,
|
|
493
|
+
bedtime: datetime,
|
|
494
|
+
config: HouseholdConfig,
|
|
495
|
+
rng: np.random.Generator,
|
|
496
|
+
zone: tzinfo,
|
|
497
|
+
shift_strength: float,
|
|
498
|
+
) -> tuple[list[Episode], datetime]:
|
|
499
|
+
"""Generate the waking day from after breakfast until bedtime.
|
|
500
|
+
|
|
501
|
+
Returns the episodes and the moment the day's routine finishes, which is
|
|
502
|
+
normally bedtime but can be earlier if the day was compressed.
|
|
503
|
+
"""
|
|
504
|
+
episodes: list[Episode] = []
|
|
505
|
+
cursor = start
|
|
506
|
+
shift = config.shift
|
|
507
|
+
|
|
508
|
+
outing_probability = config.outing_probability + (
|
|
509
|
+
shift.outing_probability_delta * shift_strength if shift is not None else 0.0
|
|
510
|
+
)
|
|
511
|
+
if rng.random() < min(max(outing_probability, 0.0), 1.0):
|
|
512
|
+
out_start = cursor + timedelta(minutes=float(rng.uniform(30, 150)))
|
|
513
|
+
episodes.append(Episode(cursor, out_start, S.HOME_INACTIVE, "living"))
|
|
514
|
+
out_end = min(
|
|
515
|
+
out_start + timedelta(hours=float(rng.uniform(1.0, 4.0))),
|
|
516
|
+
bedtime - timedelta(hours=1),
|
|
517
|
+
)
|
|
518
|
+
if out_end > out_start:
|
|
519
|
+
episodes.append(Episode(out_start, out_end, S.AWAY, None))
|
|
520
|
+
cursor = max(out_end, out_start)
|
|
521
|
+
|
|
522
|
+
lunch_start = max(cursor, _localise(day, 12.5 + rng.normal(0, 0.4), zone))
|
|
523
|
+
if lunch_start > cursor:
|
|
524
|
+
episodes.append(Episode(cursor, lunch_start, S.HOME_INACTIVE, "living"))
|
|
525
|
+
lunch_end = lunch_start + timedelta(minutes=float(rng.uniform(25, 55)))
|
|
526
|
+
episodes.append(Episode(lunch_start, lunch_end, S.KITCHEN_ACTIVITY, "kitchen"))
|
|
527
|
+
cursor = lunch_end
|
|
528
|
+
|
|
529
|
+
afternoon_end = min(
|
|
530
|
+
cursor + timedelta(hours=float(rng.uniform(2.0, 4.5))),
|
|
531
|
+
bedtime - timedelta(hours=1.5),
|
|
532
|
+
)
|
|
533
|
+
if afternoon_end > cursor:
|
|
534
|
+
episodes.extend(_alternate(cursor, afternoon_end, rng))
|
|
535
|
+
cursor = afternoon_end
|
|
536
|
+
|
|
537
|
+
dinner_end = min(cursor + timedelta(minutes=float(rng.uniform(30, 60))), bedtime)
|
|
538
|
+
if dinner_end > cursor:
|
|
539
|
+
episodes.append(Episode(cursor, dinner_end, S.KITCHEN_ACTIVITY, "kitchen"))
|
|
540
|
+
cursor = dinner_end
|
|
541
|
+
|
|
542
|
+
if bedtime > cursor:
|
|
543
|
+
episodes.extend(_alternate(cursor, bedtime, rng))
|
|
544
|
+
cursor = bedtime
|
|
545
|
+
|
|
546
|
+
return episodes, cursor
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
def _alternate(
|
|
550
|
+
start: datetime, end: datetime, rng: np.random.Generator
|
|
551
|
+
) -> list[Episode]:
|
|
552
|
+
"""Fill a span with alternating active and inactive living-room blocks."""
|
|
553
|
+
episodes: list[Episode] = []
|
|
554
|
+
cursor = start
|
|
555
|
+
active = False
|
|
556
|
+
while cursor < end:
|
|
557
|
+
span = timedelta(minutes=float(rng.uniform(15, 60)))
|
|
558
|
+
block_end = min(cursor + span, end)
|
|
559
|
+
episodes.append(
|
|
560
|
+
Episode(
|
|
561
|
+
cursor,
|
|
562
|
+
block_end,
|
|
563
|
+
S.HOME_ACTIVE if active else S.HOME_INACTIVE,
|
|
564
|
+
"living",
|
|
565
|
+
)
|
|
566
|
+
)
|
|
567
|
+
cursor = block_end
|
|
568
|
+
active = not active
|
|
569
|
+
return episodes
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
def _plan_visitors(
|
|
573
|
+
day: date, config: HouseholdConfig, rng: np.random.Generator, zone: tzinfo
|
|
574
|
+
) -> list[VisitorPeriod]:
|
|
575
|
+
"""Generate visitor periods for one day."""
|
|
576
|
+
periods: list[VisitorPeriod] = []
|
|
577
|
+
if config.carer_weekday_visits and day.weekday() < 5:
|
|
578
|
+
start = _localise(day, 9.0 + rng.normal(0, 0.3), zone)
|
|
579
|
+
periods.append(
|
|
580
|
+
VisitorPeriod(
|
|
581
|
+
start,
|
|
582
|
+
start + timedelta(minutes=float(rng.uniform(30, 60))),
|
|
583
|
+
("hall", "kitchen", "living", "hall"),
|
|
584
|
+
)
|
|
585
|
+
)
|
|
586
|
+
if rng.random() < config.visitor_probability:
|
|
587
|
+
start = _localise(day, 15.0 + rng.normal(0, 1.0), zone)
|
|
588
|
+
periods.append(
|
|
589
|
+
VisitorPeriod(
|
|
590
|
+
start,
|
|
591
|
+
start + timedelta(hours=float(rng.uniform(1.0, 3.0))),
|
|
592
|
+
("hall", "living", "kitchen", "living", "hall"),
|
|
593
|
+
)
|
|
594
|
+
)
|
|
595
|
+
return periods
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
@dataclass(frozen=True)
|
|
599
|
+
class SimulationResult:
|
|
600
|
+
"""A simulated household: what happened, and what the sensors recorded."""
|
|
601
|
+
|
|
602
|
+
registry: SensorRegistry
|
|
603
|
+
observations: tuple[Observation, ...]
|
|
604
|
+
truth: GroundTruth
|
|
605
|
+
config: HouseholdConfig = field(repr=False)
|
|
606
|
+
|
|
607
|
+
@property
|
|
608
|
+
def start(self) -> datetime:
|
|
609
|
+
"""First instant of simulated time."""
|
|
610
|
+
return self.truth.episodes[0].start
|
|
611
|
+
|
|
612
|
+
@property
|
|
613
|
+
def end(self) -> datetime:
|
|
614
|
+
"""Last instant of simulated time."""
|
|
615
|
+
return self.truth.episodes[-1].end
|
|
616
|
+
|
|
617
|
+
def observations_for(self, sensor_ids: Sequence[str]) -> tuple[Observation, ...]:
|
|
618
|
+
"""Return only the records from *sensor_ids*, for ablation studies."""
|
|
619
|
+
wanted = set(sensor_ids)
|
|
620
|
+
return tuple(obs for obs in self.observations if obs.sensor_id in wanted)
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
def _emit_events(
|
|
624
|
+
sensor_id: str,
|
|
625
|
+
modality: Modality,
|
|
626
|
+
times: np.ndarray,
|
|
627
|
+
origin: datetime,
|
|
628
|
+
generated_by: str,
|
|
629
|
+
) -> list[Observation]:
|
|
630
|
+
"""Turn Poisson arrival offsets into activation observations."""
|
|
631
|
+
return [
|
|
632
|
+
Observation(
|
|
633
|
+
timestamp=origin + timedelta(seconds=float(offset)),
|
|
634
|
+
sensor_id=sensor_id,
|
|
635
|
+
modality=modality,
|
|
636
|
+
kind=ObservationKind.EVENT,
|
|
637
|
+
value=1.0,
|
|
638
|
+
source="sim-hub",
|
|
639
|
+
context={"generated_by": generated_by},
|
|
640
|
+
)
|
|
641
|
+
for offset in times
|
|
642
|
+
]
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
def _poisson_times(
|
|
646
|
+
rate_per_hour: float, span: timedelta, rng: np.random.Generator
|
|
647
|
+
) -> np.ndarray:
|
|
648
|
+
"""Sample event offsets in seconds from a homogeneous Poisson process."""
|
|
649
|
+
seconds = span.total_seconds()
|
|
650
|
+
if seconds <= 0 or rate_per_hour <= 0:
|
|
651
|
+
return np.empty(0)
|
|
652
|
+
expected = rate_per_hour * seconds / 3600.0
|
|
653
|
+
count = int(rng.poisson(expected))
|
|
654
|
+
return np.sort(rng.uniform(0.0, seconds, size=count))
|
|
655
|
+
|
|
656
|
+
|
|
657
|
+
_ROOM_SENSORS = {
|
|
658
|
+
"bedroom": ("bedroom_motion", Modality.MOTION),
|
|
659
|
+
"bathroom": ("bathroom_motion", Modality.MOTION),
|
|
660
|
+
"kitchen": ("kitchen_motion", Modality.MOTION),
|
|
661
|
+
"living": ("living_motion", Modality.MOTION),
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
def _room_observations(
|
|
666
|
+
truth: GroundTruth, rng: np.random.Generator
|
|
667
|
+
) -> list[Observation]:
|
|
668
|
+
"""Generate motion and contact activations from resident and visitors."""
|
|
669
|
+
records: list[Observation] = []
|
|
670
|
+
|
|
671
|
+
for episode in truth.episodes:
|
|
672
|
+
for room, (sensor_id, modality) in _ROOM_SENSORS.items():
|
|
673
|
+
occupied = episode.room == room and episode.state is not S.AWAY
|
|
674
|
+
rate = ACTIVE_RATE if occupied else IDLE_RATE
|
|
675
|
+
if occupied and episode.state in (S.SLEEPING, S.HOME_INACTIVE):
|
|
676
|
+
rate = 6.0 if episode.state is S.HOME_INACTIVE else 0.6
|
|
677
|
+
offsets = _poisson_times(rate, episode.duration, rng)
|
|
678
|
+
records.extend(
|
|
679
|
+
_emit_events(sensor_id, modality, offsets, episode.start, "resident")
|
|
680
|
+
)
|
|
681
|
+
|
|
682
|
+
if episode.state is S.KITCHEN_ACTIVITY:
|
|
683
|
+
offsets = _poisson_times(9.0, episode.duration, rng)
|
|
684
|
+
records.extend(
|
|
685
|
+
_emit_events(
|
|
686
|
+
"fridge_contact",
|
|
687
|
+
Modality.CONTACT,
|
|
688
|
+
offsets,
|
|
689
|
+
episode.start,
|
|
690
|
+
"resident",
|
|
691
|
+
)
|
|
692
|
+
)
|
|
693
|
+
|
|
694
|
+
# Visitors trip the same ambient sensors. This is the contamination that
|
|
695
|
+
# makes attribution necessary rather than decorative.
|
|
696
|
+
for period in truth.visitors:
|
|
697
|
+
span = period.end - period.start
|
|
698
|
+
steps = max(len(period.room_sequence), 1)
|
|
699
|
+
slice_span = span / steps
|
|
700
|
+
for index, room in enumerate(period.room_sequence):
|
|
701
|
+
entry = period.start + slice_span * index
|
|
702
|
+
sensor = _ROOM_SENSORS.get(room)
|
|
703
|
+
if sensor is None:
|
|
704
|
+
continue
|
|
705
|
+
sensor_id, modality = sensor
|
|
706
|
+
offsets = _poisson_times(ACTIVE_RATE * 0.7, slice_span, rng)
|
|
707
|
+
records.extend(_emit_events(sensor_id, modality, offsets, entry, "visitor"))
|
|
708
|
+
if room == "kitchen":
|
|
709
|
+
offsets = _poisson_times(6.0, slice_span, rng)
|
|
710
|
+
records.extend(
|
|
711
|
+
_emit_events(
|
|
712
|
+
"fridge_contact",
|
|
713
|
+
Modality.CONTACT,
|
|
714
|
+
offsets,
|
|
715
|
+
entry,
|
|
716
|
+
"visitor",
|
|
717
|
+
)
|
|
718
|
+
)
|
|
719
|
+
return records
|
|
720
|
+
|
|
721
|
+
|
|
722
|
+
def _door_observations(truth: GroundTruth) -> list[Observation]:
|
|
723
|
+
"""Generate door crossings at every genuine entry and exit."""
|
|
724
|
+
crossings: list[tuple[datetime, str]] = []
|
|
725
|
+
for previous, current in zip(truth.episodes, truth.episodes[1:]):
|
|
726
|
+
if (previous.state is S.AWAY) != (current.state is S.AWAY):
|
|
727
|
+
crossings.append((current.start, "resident"))
|
|
728
|
+
for period in truth.visitors:
|
|
729
|
+
crossings.append((period.start, "visitor"))
|
|
730
|
+
crossings.append((period.end, "visitor"))
|
|
731
|
+
|
|
732
|
+
return [
|
|
733
|
+
Observation(
|
|
734
|
+
timestamp=moment,
|
|
735
|
+
sensor_id="front_door",
|
|
736
|
+
modality=Modality.DOOR,
|
|
737
|
+
kind=ObservationKind.EVENT,
|
|
738
|
+
value=1.0,
|
|
739
|
+
source="sim-hub",
|
|
740
|
+
context={"generated_by": who},
|
|
741
|
+
)
|
|
742
|
+
for moment, who in sorted(crossings)
|
|
743
|
+
]
|
|
744
|
+
|
|
745
|
+
|
|
746
|
+
def _sampled_observations(
|
|
747
|
+
truth: GroundTruth, rng: np.random.Generator, interval: timedelta
|
|
748
|
+
) -> list[Observation]:
|
|
749
|
+
"""Generate the periodic sampled and state-reporting sensors."""
|
|
750
|
+
records: list[Observation] = []
|
|
751
|
+
cursor = truth.episodes[0].start
|
|
752
|
+
end = truth.episodes[-1].end
|
|
753
|
+
|
|
754
|
+
moments: list[datetime] = []
|
|
755
|
+
while cursor < end:
|
|
756
|
+
moments.append(cursor)
|
|
757
|
+
cursor += interval
|
|
758
|
+
states = truth.states_at(moments)
|
|
759
|
+
|
|
760
|
+
for moment, state in zip(moments, states):
|
|
761
|
+
if state is None:
|
|
762
|
+
continue
|
|
763
|
+
in_bed = state in (S.SLEEPING, S.BED_AWAKE)
|
|
764
|
+
records.append(
|
|
765
|
+
Observation(
|
|
766
|
+
timestamp=moment,
|
|
767
|
+
sensor_id="bed_pressure",
|
|
768
|
+
modality=Modality.BED_PRESSURE,
|
|
769
|
+
kind=ObservationKind.STATE,
|
|
770
|
+
value=1.0 if in_bed else 0.0,
|
|
771
|
+
source="sim-hub",
|
|
772
|
+
context={"generated_by": "resident"},
|
|
773
|
+
)
|
|
774
|
+
)
|
|
775
|
+
|
|
776
|
+
# The radar reports how many people it can track. It is a derived
|
|
777
|
+
# feature with real uncertainty: it occasionally loses a still person
|
|
778
|
+
# and occasionally splits one into two.
|
|
779
|
+
people = (0 if state is S.AWAY else 1) + (1 if truth.visitor_at(moment) else 0)
|
|
780
|
+
if state in (S.SLEEPING, S.HOME_INACTIVE) and rng.random() < 0.12:
|
|
781
|
+
people = max(people - 1, 0)
|
|
782
|
+
elif rng.random() < 0.04:
|
|
783
|
+
people += 1
|
|
784
|
+
records.append(
|
|
785
|
+
Observation(
|
|
786
|
+
timestamp=moment,
|
|
787
|
+
sensor_id="living_radar",
|
|
788
|
+
modality=Modality.RADAR,
|
|
789
|
+
kind=ObservationKind.SAMPLE,
|
|
790
|
+
value=float(people),
|
|
791
|
+
unit=Unit.COUNT,
|
|
792
|
+
confidence=0.75,
|
|
793
|
+
source="sim-radar",
|
|
794
|
+
context={"generated_by": "environment"},
|
|
795
|
+
)
|
|
796
|
+
)
|
|
797
|
+
|
|
798
|
+
level = WEARABLE_LEVEL.get(state, 0.5)
|
|
799
|
+
records.append(
|
|
800
|
+
Observation(
|
|
801
|
+
timestamp=moment,
|
|
802
|
+
sensor_id="wearable_motion",
|
|
803
|
+
modality=Modality.WEARABLE_MOTION,
|
|
804
|
+
kind=ObservationKind.SAMPLE,
|
|
805
|
+
value=float(max(rng.normal(level, 0.22), 0.0)),
|
|
806
|
+
source="sim-wearable",
|
|
807
|
+
context={"generated_by": "resident"},
|
|
808
|
+
)
|
|
809
|
+
)
|
|
810
|
+
records.append(
|
|
811
|
+
Observation(
|
|
812
|
+
timestamp=moment,
|
|
813
|
+
sensor_id="resident_beacon",
|
|
814
|
+
modality=Modality.PROXIMITY,
|
|
815
|
+
kind=ObservationKind.STATE,
|
|
816
|
+
value=0.0 if state is S.AWAY else 1.0,
|
|
817
|
+
source="sim-wearable",
|
|
818
|
+
context={"generated_by": "resident"},
|
|
819
|
+
)
|
|
820
|
+
)
|
|
821
|
+
return records
|
|
822
|
+
|
|
823
|
+
|
|
824
|
+
def simulate(config: HouseholdConfig | None = None) -> SimulationResult:
|
|
825
|
+
"""Simulate a synthetic household and its sensor record.
|
|
826
|
+
|
|
827
|
+
Every run with the same configuration produces byte-identical output, so
|
|
828
|
+
experiments built on it are reproducible from the seed alone.
|
|
829
|
+
"""
|
|
830
|
+
settings = config or HouseholdConfig()
|
|
831
|
+
rng = np.random.default_rng(settings.seed)
|
|
832
|
+
|
|
833
|
+
episodes: list[Episode] = []
|
|
834
|
+
visitors: list[VisitorPeriod] = []
|
|
835
|
+
for day_index in range(settings.days):
|
|
836
|
+
day = settings.start + timedelta(days=day_index)
|
|
837
|
+
day_episodes, day_visitors = _plan_day(day_index, day, settings, rng)
|
|
838
|
+
episodes.extend(day_episodes)
|
|
839
|
+
visitors.extend(day_visitors)
|
|
840
|
+
|
|
841
|
+
episodes = [episode for episode in episodes if episode.duration > timedelta(0)]
|
|
842
|
+
episodes.sort(key=lambda episode: episode.start)
|
|
843
|
+
truth = GroundTruth(tuple(episodes), tuple(visitors), settings.tz)
|
|
844
|
+
|
|
845
|
+
records = _room_observations(truth, rng)
|
|
846
|
+
records.extend(_door_observations(truth))
|
|
847
|
+
records.extend(_sampled_observations(truth, rng, timedelta(minutes=1)))
|
|
848
|
+
records.sort(key=lambda obs: obs.timestamp)
|
|
849
|
+
|
|
850
|
+
logger.info(
|
|
851
|
+
"Simulated %d days: %d episodes, %d visitor periods, %d observations",
|
|
852
|
+
settings.days,
|
|
853
|
+
len(episodes),
|
|
854
|
+
len(visitors),
|
|
855
|
+
len(records),
|
|
856
|
+
)
|
|
857
|
+
return SimulationResult(
|
|
858
|
+
registry=build_registry(),
|
|
859
|
+
observations=tuple(records),
|
|
860
|
+
truth=truth,
|
|
861
|
+
config=settings,
|
|
862
|
+
)
|