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,23 @@
|
|
|
1
|
+
"""Configurable ontology of latent behavioural states."""
|
|
2
|
+
|
|
3
|
+
from .markov import build_generator, stationary_distribution, transition_matrix
|
|
4
|
+
from .ontology import (
|
|
5
|
+
DEFAULT_DWELL,
|
|
6
|
+
DEFAULT_JUMPS,
|
|
7
|
+
DEFAULT_ROOMS,
|
|
8
|
+
DEFAULT_STATES,
|
|
9
|
+
BehaviouralState,
|
|
10
|
+
StateOntology,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"DEFAULT_DWELL",
|
|
15
|
+
"DEFAULT_JUMPS",
|
|
16
|
+
"DEFAULT_ROOMS",
|
|
17
|
+
"DEFAULT_STATES",
|
|
18
|
+
"BehaviouralState",
|
|
19
|
+
"StateOntology",
|
|
20
|
+
"build_generator",
|
|
21
|
+
"stationary_distribution",
|
|
22
|
+
"transition_matrix",
|
|
23
|
+
]
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Continuous-time Markov chain mechanics shared by the latent-state models.
|
|
2
|
+
|
|
3
|
+
Both the behavioural state ontology and the occupancy context model are
|
|
4
|
+
continuous-time chains over a small discrete state set. The maths is the same
|
|
5
|
+
in each case -- build a generator from dwell times and permitted jumps,
|
|
6
|
+
exponentiate it over an arbitrary interval, solve for the stationary
|
|
7
|
+
distribution -- so it lives here once rather than being written twice.
|
|
8
|
+
|
|
9
|
+
Working in continuous time is what lets both models accept observations that
|
|
10
|
+
arrive whenever they happen to arrive, without resampling anything onto a
|
|
11
|
+
common grid.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from functools import lru_cache
|
|
17
|
+
|
|
18
|
+
import numpy as np
|
|
19
|
+
from scipy.linalg import expm
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def build_generator(rates: np.ndarray, jumps: np.ndarray) -> np.ndarray:
|
|
23
|
+
"""Build a transition rate matrix from exit rates and permitted jumps.
|
|
24
|
+
|
|
25
|
+
Parameters
|
|
26
|
+
----------
|
|
27
|
+
rates
|
|
28
|
+
Total exit rate of each state, in transitions per second. The inverse
|
|
29
|
+
of the state's mean dwell time.
|
|
30
|
+
jumps
|
|
31
|
+
Non-negative ``(n, n)`` weights describing where each state may go.
|
|
32
|
+
Rows are normalised to distribute that state's exit rate; the
|
|
33
|
+
diagonal is ignored. A row of zeros falls back to a uniform jump to
|
|
34
|
+
every other state, so a state can never become a trap by accident.
|
|
35
|
+
|
|
36
|
+
Returns
|
|
37
|
+
-------
|
|
38
|
+
numpy.ndarray
|
|
39
|
+
The generator ``Q``, whose rows sum to zero.
|
|
40
|
+
"""
|
|
41
|
+
exit_rates = np.asarray(rates, dtype=float)
|
|
42
|
+
weights = np.array(jumps, dtype=float, copy=True)
|
|
43
|
+
size = exit_rates.size
|
|
44
|
+
if weights.shape != (size, size):
|
|
45
|
+
raise ValueError("jumps must be a square matrix matching the number of states")
|
|
46
|
+
if exit_rates.min() <= 0.0 or not np.all(np.isfinite(exit_rates)):
|
|
47
|
+
raise ValueError("exit rates must be positive and finite")
|
|
48
|
+
if weights.min() < 0.0 or not np.all(np.isfinite(weights)):
|
|
49
|
+
raise ValueError("jump weights must be non-negative and finite")
|
|
50
|
+
|
|
51
|
+
np.fill_diagonal(weights, 0.0)
|
|
52
|
+
row_totals = weights.sum(axis=1)
|
|
53
|
+
uniform = (np.ones((size, size)) - np.eye(size)) / max(size - 1, 1)
|
|
54
|
+
weights = np.where(row_totals[:, None] > 0.0, weights, uniform)
|
|
55
|
+
row_totals = weights.sum(axis=1)
|
|
56
|
+
|
|
57
|
+
generator: np.ndarray = weights * (exit_rates / row_totals)[:, None]
|
|
58
|
+
np.fill_diagonal(generator, -exit_rates)
|
|
59
|
+
return generator
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def transition_matrix(generator: np.ndarray, seconds: float) -> np.ndarray:
|
|
63
|
+
"""Return ``expm(Q * seconds)`` as a row-stochastic matrix.
|
|
64
|
+
|
|
65
|
+
A non-positive interval yields the identity, so repeated updates at the
|
|
66
|
+
same instant leave a belief untouched.
|
|
67
|
+
"""
|
|
68
|
+
matrix = np.asarray(generator, dtype=float)
|
|
69
|
+
if seconds <= 0.0:
|
|
70
|
+
return np.eye(matrix.shape[0])
|
|
71
|
+
return _cached_transition(matrix.tobytes(), matrix.shape[0], round(seconds, 3))
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def stationary_distribution(generator: np.ndarray) -> np.ndarray:
|
|
75
|
+
"""Return the long-run distribution implied by *generator*.
|
|
76
|
+
|
|
77
|
+
This is the most defensible prior for a filter that has seen no evidence
|
|
78
|
+
yet: it is what the declared dynamics say about the system on average.
|
|
79
|
+
"""
|
|
80
|
+
matrix = np.asarray(generator, dtype=float)
|
|
81
|
+
size = matrix.shape[0]
|
|
82
|
+
system = np.vstack([matrix.T, np.ones(size)])
|
|
83
|
+
target = np.zeros(size + 1)
|
|
84
|
+
target[-1] = 1.0
|
|
85
|
+
solution, *_ = np.linalg.lstsq(system, target, rcond=None)
|
|
86
|
+
distribution = np.clip(solution, 0.0, None)
|
|
87
|
+
total = distribution.sum()
|
|
88
|
+
if total <= 0.0:
|
|
89
|
+
return np.full(size, 1.0 / size)
|
|
90
|
+
normalised: np.ndarray = distribution / total
|
|
91
|
+
return normalised
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@lru_cache(maxsize=512)
|
|
95
|
+
def _cached_transition(generator_bytes: bytes, size: int, seconds: float) -> np.ndarray:
|
|
96
|
+
"""Exponentiate a generator, caching on the rounded interval.
|
|
97
|
+
|
|
98
|
+
Ambient streams produce the same handful of intervals over and over, so
|
|
99
|
+
caching keeps the matrix exponential off the hot path of online updates.
|
|
100
|
+
"""
|
|
101
|
+
generator = np.frombuffer(generator_bytes, dtype=float).reshape(size, size)
|
|
102
|
+
matrix = np.clip(np.asarray(expm(generator * seconds), dtype=float), 0.0, None)
|
|
103
|
+
row_sums = matrix.sum(axis=1, keepdims=True)
|
|
104
|
+
stochastic: np.ndarray = matrix / np.where(row_sums > 0.0, row_sums, 1.0)
|
|
105
|
+
return stochastic
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
"""The configurable ontology of latent behavioural states.
|
|
2
|
+
|
|
3
|
+
The states here are deliberately weaker than the activities of daily living a
|
|
4
|
+
clinician would name. ``KITCHEN_ACTIVITY`` says the resident appears to be
|
|
5
|
+
active in the kitchen; it does not say they ate. Claiming food intake needs
|
|
6
|
+
evidence that a contact sensor cannot supply, so the ontology stops where the
|
|
7
|
+
evidence stops and leaves the stronger claim to be made -- or not -- further
|
|
8
|
+
downstream.
|
|
9
|
+
|
|
10
|
+
Transitions are modelled in continuous time. Ambient observations arrive
|
|
11
|
+
asynchronously and irregularly, so the transition operator has to be defined
|
|
12
|
+
for an arbitrary elapsed interval rather than for a fixed time step. A
|
|
13
|
+
continuous-time Markov chain gives exactly that: the generator encodes how
|
|
14
|
+
long a state typically persists, and the transition matrix over any interval
|
|
15
|
+
follows from its matrix exponential.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from collections.abc import Mapping, Sequence
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
from datetime import timedelta
|
|
23
|
+
from enum import Enum
|
|
24
|
+
|
|
25
|
+
import numpy as np
|
|
26
|
+
|
|
27
|
+
from .markov import build_generator, stationary_distribution, transition_matrix
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class BehaviouralState(str, Enum):
|
|
31
|
+
"""A latent behavioural state of the monitored resident.
|
|
32
|
+
|
|
33
|
+
``UNKNOWN`` is not a latent state the chain can occupy. It is the value an
|
|
34
|
+
estimator returns when it declines to commit, and is excluded from the
|
|
35
|
+
ontology's state vector.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
AWAY = "away"
|
|
39
|
+
"""Not in the home."""
|
|
40
|
+
|
|
41
|
+
HOME_ACTIVE = "home_active"
|
|
42
|
+
"""At home and moving about, without a more specific location."""
|
|
43
|
+
|
|
44
|
+
HOME_INACTIVE = "home_inactive"
|
|
45
|
+
"""At home, awake, and largely stationary."""
|
|
46
|
+
|
|
47
|
+
SLEEPING = "sleeping"
|
|
48
|
+
"""In bed with sustained low movement."""
|
|
49
|
+
|
|
50
|
+
BED_AWAKE = "bed_awake"
|
|
51
|
+
"""In bed but moving; distinguishable from sleep only with bed sensing."""
|
|
52
|
+
|
|
53
|
+
BATHROOM_ACTIVITY = "bathroom_activity"
|
|
54
|
+
"""Active in the bathroom. Not a claim about toileting."""
|
|
55
|
+
|
|
56
|
+
KITCHEN_ACTIVITY = "kitchen_activity"
|
|
57
|
+
"""Active in the kitchen. Not a claim about eating or drinking."""
|
|
58
|
+
|
|
59
|
+
UNKNOWN = "unknown"
|
|
60
|
+
"""Insufficient evidence to commit to any state."""
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
#: The states a resident can actually occupy, in canonical vector order.
|
|
64
|
+
DEFAULT_STATES: tuple[BehaviouralState, ...] = (
|
|
65
|
+
BehaviouralState.AWAY,
|
|
66
|
+
BehaviouralState.HOME_ACTIVE,
|
|
67
|
+
BehaviouralState.HOME_INACTIVE,
|
|
68
|
+
BehaviouralState.SLEEPING,
|
|
69
|
+
BehaviouralState.BED_AWAKE,
|
|
70
|
+
BehaviouralState.BATHROOM_ACTIVITY,
|
|
71
|
+
BehaviouralState.KITCHEN_ACTIVITY,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
#: Typical persistence of each state, used to build the generator matrix.
|
|
75
|
+
DEFAULT_DWELL: dict[BehaviouralState, timedelta] = {
|
|
76
|
+
BehaviouralState.AWAY: timedelta(hours=3),
|
|
77
|
+
BehaviouralState.HOME_ACTIVE: timedelta(minutes=20),
|
|
78
|
+
BehaviouralState.HOME_INACTIVE: timedelta(hours=1),
|
|
79
|
+
BehaviouralState.SLEEPING: timedelta(hours=3),
|
|
80
|
+
BehaviouralState.BED_AWAKE: timedelta(minutes=20),
|
|
81
|
+
BehaviouralState.BATHROOM_ACTIVITY: timedelta(minutes=6),
|
|
82
|
+
BehaviouralState.KITCHEN_ACTIVITY: timedelta(minutes=15),
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
#: Room each state implies, where it implies one. States without a room make
|
|
86
|
+
#: no spatial claim and receive no room-specific evidence.
|
|
87
|
+
DEFAULT_ROOMS: dict[BehaviouralState, str | None] = {
|
|
88
|
+
BehaviouralState.AWAY: None,
|
|
89
|
+
BehaviouralState.HOME_ACTIVE: None,
|
|
90
|
+
BehaviouralState.HOME_INACTIVE: None,
|
|
91
|
+
BehaviouralState.SLEEPING: "bedroom",
|
|
92
|
+
BehaviouralState.BED_AWAKE: "bedroom",
|
|
93
|
+
BehaviouralState.BATHROOM_ACTIVITY: "bathroom",
|
|
94
|
+
BehaviouralState.KITCHEN_ACTIVITY: "kitchen",
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
#: Plausible state-to-state moves. Reaching or leaving the home passes through
|
|
98
|
+
#: a general at-home state rather than teleporting out of bed to the street.
|
|
99
|
+
DEFAULT_JUMPS: dict[BehaviouralState, tuple[BehaviouralState, ...]] = {
|
|
100
|
+
BehaviouralState.AWAY: (BehaviouralState.HOME_ACTIVE,),
|
|
101
|
+
BehaviouralState.HOME_ACTIVE: (
|
|
102
|
+
BehaviouralState.AWAY,
|
|
103
|
+
BehaviouralState.HOME_INACTIVE,
|
|
104
|
+
BehaviouralState.BED_AWAKE,
|
|
105
|
+
BehaviouralState.BATHROOM_ACTIVITY,
|
|
106
|
+
BehaviouralState.KITCHEN_ACTIVITY,
|
|
107
|
+
),
|
|
108
|
+
BehaviouralState.HOME_INACTIVE: (
|
|
109
|
+
BehaviouralState.HOME_ACTIVE,
|
|
110
|
+
BehaviouralState.BED_AWAKE,
|
|
111
|
+
BehaviouralState.BATHROOM_ACTIVITY,
|
|
112
|
+
BehaviouralState.KITCHEN_ACTIVITY,
|
|
113
|
+
),
|
|
114
|
+
BehaviouralState.SLEEPING: (BehaviouralState.BED_AWAKE,),
|
|
115
|
+
BehaviouralState.BED_AWAKE: (
|
|
116
|
+
BehaviouralState.SLEEPING,
|
|
117
|
+
BehaviouralState.HOME_ACTIVE,
|
|
118
|
+
BehaviouralState.BATHROOM_ACTIVITY,
|
|
119
|
+
),
|
|
120
|
+
BehaviouralState.BATHROOM_ACTIVITY: (
|
|
121
|
+
BehaviouralState.HOME_ACTIVE,
|
|
122
|
+
BehaviouralState.HOME_INACTIVE,
|
|
123
|
+
BehaviouralState.BED_AWAKE,
|
|
124
|
+
),
|
|
125
|
+
BehaviouralState.KITCHEN_ACTIVITY: (
|
|
126
|
+
BehaviouralState.HOME_ACTIVE,
|
|
127
|
+
BehaviouralState.HOME_INACTIVE,
|
|
128
|
+
),
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@dataclass(frozen=True)
|
|
133
|
+
class StateOntology:
|
|
134
|
+
"""A configurable set of latent states with continuous-time dynamics.
|
|
135
|
+
|
|
136
|
+
Parameters
|
|
137
|
+
----------
|
|
138
|
+
states
|
|
139
|
+
Latent states in canonical vector order. ``UNKNOWN`` is not allowed.
|
|
140
|
+
dwell
|
|
141
|
+
Mean persistence of each state. Longer dwell means the chain is more
|
|
142
|
+
reluctant to leave, which is what supplies temporal smoothing.
|
|
143
|
+
rooms
|
|
144
|
+
Room each state implies, or ``None`` when it makes no spatial claim.
|
|
145
|
+
jumps
|
|
146
|
+
Permitted destinations from each state. Defaults to every other state.
|
|
147
|
+
"""
|
|
148
|
+
|
|
149
|
+
states: tuple[BehaviouralState, ...] = DEFAULT_STATES
|
|
150
|
+
dwell: Mapping[BehaviouralState, timedelta] = field(
|
|
151
|
+
default_factory=lambda: dict(DEFAULT_DWELL)
|
|
152
|
+
)
|
|
153
|
+
rooms: Mapping[BehaviouralState, str | None] = field(
|
|
154
|
+
default_factory=lambda: dict(DEFAULT_ROOMS)
|
|
155
|
+
)
|
|
156
|
+
jumps: Mapping[BehaviouralState, Sequence[BehaviouralState]] = field(
|
|
157
|
+
default_factory=lambda: dict(DEFAULT_JUMPS)
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
def __post_init__(self) -> None:
|
|
161
|
+
"""Validate the ontology and precompute its generator."""
|
|
162
|
+
if len(self.states) < 2:
|
|
163
|
+
raise ValueError("an ontology needs at least two states")
|
|
164
|
+
if len(set(self.states)) != len(self.states):
|
|
165
|
+
raise ValueError("states must be unique")
|
|
166
|
+
if BehaviouralState.UNKNOWN in self.states:
|
|
167
|
+
raise ValueError(
|
|
168
|
+
"UNKNOWN is an estimator abstention, not an occupiable state"
|
|
169
|
+
)
|
|
170
|
+
for state in self.states:
|
|
171
|
+
duration = self.dwell.get(state)
|
|
172
|
+
if duration is None:
|
|
173
|
+
raise ValueError(f"no mean dwell time declared for {state.value}")
|
|
174
|
+
if duration <= timedelta(0):
|
|
175
|
+
raise ValueError(f"mean dwell time for {state.value} must be positive")
|
|
176
|
+
object.__setattr__(self, "_generator", self._build_generator())
|
|
177
|
+
|
|
178
|
+
# ------------------------------------------------------------------
|
|
179
|
+
@property
|
|
180
|
+
def size(self) -> int:
|
|
181
|
+
"""Number of latent states."""
|
|
182
|
+
return len(self.states)
|
|
183
|
+
|
|
184
|
+
def index(self, state: BehaviouralState) -> int:
|
|
185
|
+
"""Return the vector position of *state*."""
|
|
186
|
+
return self.states.index(state)
|
|
187
|
+
|
|
188
|
+
def room_of(self, state: BehaviouralState) -> str | None:
|
|
189
|
+
"""Return the room *state* implies, if any."""
|
|
190
|
+
return self.rooms.get(state)
|
|
191
|
+
|
|
192
|
+
def states_in_room(self, room: str) -> tuple[BehaviouralState, ...]:
|
|
193
|
+
"""Return the states that imply presence in *room*."""
|
|
194
|
+
return tuple(s for s in self.states if self.rooms.get(s) == room)
|
|
195
|
+
|
|
196
|
+
# ------------------------------------------------------------------
|
|
197
|
+
def _build_generator(self) -> np.ndarray:
|
|
198
|
+
"""Build the continuous-time transition rate matrix.
|
|
199
|
+
|
|
200
|
+
A state with mean dwell ``d`` leaves at total rate ``1/d``, split
|
|
201
|
+
evenly across its permitted destinations.
|
|
202
|
+
"""
|
|
203
|
+
size = self.size
|
|
204
|
+
rates = np.array(
|
|
205
|
+
[1.0 / self.dwell[state].total_seconds() for state in self.states],
|
|
206
|
+
dtype=float,
|
|
207
|
+
)
|
|
208
|
+
jumps = np.zeros((size, size), dtype=float)
|
|
209
|
+
for row, state in enumerate(self.states):
|
|
210
|
+
for target in self.jumps.get(state, self.states):
|
|
211
|
+
if target in self.states and target is not state:
|
|
212
|
+
jumps[row, self.index(target)] = 1.0
|
|
213
|
+
return build_generator(rates, jumps)
|
|
214
|
+
|
|
215
|
+
@property
|
|
216
|
+
def generator(self) -> np.ndarray:
|
|
217
|
+
"""The continuous-time generator matrix, in transitions per second."""
|
|
218
|
+
return np.asarray(object.__getattribute__(self, "_generator"))
|
|
219
|
+
|
|
220
|
+
def transition(self, elapsed: timedelta) -> np.ndarray:
|
|
221
|
+
"""Return ``P(Z_{t+elapsed} | Z_t)`` as a row-stochastic matrix."""
|
|
222
|
+
return transition_matrix(self.generator, elapsed.total_seconds())
|
|
223
|
+
|
|
224
|
+
def stationary(self) -> np.ndarray:
|
|
225
|
+
"""Return the stationary distribution implied by the generator.
|
|
226
|
+
|
|
227
|
+
Used as the default prior: before any evidence arrives, the most
|
|
228
|
+
defensible belief is the long-run behaviour of the declared dynamics.
|
|
229
|
+
"""
|
|
230
|
+
return stationary_distribution(self.generator)
|
|
231
|
+
|
|
232
|
+
def uniform(self) -> np.ndarray:
|
|
233
|
+
"""Return a uniform belief over the latent states."""
|
|
234
|
+
return np.full(self.size, 1.0 / self.size)
|
|
235
|
+
|
|
236
|
+
def labels(self) -> list[str]:
|
|
237
|
+
"""Return the state names in vector order."""
|
|
238
|
+
return [state.value for state in self.states]
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Utility helpers for the sensor modeling package."""
|
|
2
|
+
|
|
3
|
+
from .data_io import (
|
|
4
|
+
SensorDataset,
|
|
5
|
+
export_analysis_results,
|
|
6
|
+
simulate_sensor_data,
|
|
7
|
+
)
|
|
8
|
+
from .logging_config import setup_logging
|
|
9
|
+
from .missing import (
|
|
10
|
+
MissingDataResult,
|
|
11
|
+
forward_fill,
|
|
12
|
+
handle_missing_data,
|
|
13
|
+
interpolate_linear,
|
|
14
|
+
)
|
|
15
|
+
from .plotting import (
|
|
16
|
+
plot_benchmark_results,
|
|
17
|
+
plot_change_points,
|
|
18
|
+
plot_quantile_intervals,
|
|
19
|
+
plot_sensor_activity_patterns,
|
|
20
|
+
)
|
|
21
|
+
from .validation import (
|
|
22
|
+
create_model_comparison_report,
|
|
23
|
+
validate_model_predictions,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"SensorDataset",
|
|
28
|
+
"simulate_sensor_data",
|
|
29
|
+
"export_analysis_results",
|
|
30
|
+
"plot_sensor_activity_patterns",
|
|
31
|
+
"plot_quantile_intervals",
|
|
32
|
+
"plot_change_points",
|
|
33
|
+
"plot_benchmark_results",
|
|
34
|
+
"validate_model_predictions",
|
|
35
|
+
"create_model_comparison_report",
|
|
36
|
+
"setup_logging",
|
|
37
|
+
"MissingDataResult",
|
|
38
|
+
"forward_fill",
|
|
39
|
+
"handle_missing_data",
|
|
40
|
+
"interpolate_linear",
|
|
41
|
+
]
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""Data loading and simulation utilities for sensor modeling."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
from collections.abc import Mapping
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from os import PathLike
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
import numpy as np
|
|
13
|
+
import pandas as pd
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
FilePath = str | PathLike[str]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def read_sensor_csv(
|
|
21
|
+
path: FilePath, timestamp_col: str = "timestamp", **kwargs
|
|
22
|
+
) -> pd.DataFrame:
|
|
23
|
+
"""Read a sensor CSV using the public loader contract.
|
|
24
|
+
|
|
25
|
+
Supported layouts are:
|
|
26
|
+
|
|
27
|
+
- a named timestamp column, by default ``timestamp``
|
|
28
|
+
- an unnamed first column created by ``DataFrame.to_csv(index=True)``
|
|
29
|
+
- a plain tabular sensor matrix with no timestamp index
|
|
30
|
+
"""
|
|
31
|
+
df = pd.read_csv(path, **kwargs)
|
|
32
|
+
if timestamp_col in df.columns:
|
|
33
|
+
df[timestamp_col] = pd.to_datetime(df[timestamp_col])
|
|
34
|
+
return df.set_index(timestamp_col).sort_index()
|
|
35
|
+
|
|
36
|
+
first_col = df.columns[0] if len(df.columns) else None
|
|
37
|
+
if isinstance(first_col, str) and first_col.startswith("Unnamed:"):
|
|
38
|
+
parsed_index = pd.to_datetime(df[first_col], errors="coerce")
|
|
39
|
+
if parsed_index.notna().all():
|
|
40
|
+
df = df.drop(columns=[first_col])
|
|
41
|
+
df.index = parsed_index
|
|
42
|
+
return df.sort_index()
|
|
43
|
+
|
|
44
|
+
return df
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class SensorDataset:
|
|
49
|
+
"""Unified in-memory representation of sensor time series data.
|
|
50
|
+
|
|
51
|
+
Parameters
|
|
52
|
+
----------
|
|
53
|
+
data : pd.DataFrame
|
|
54
|
+
DataFrame indexed by timestamps with one column per sensor.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
data: pd.DataFrame
|
|
58
|
+
|
|
59
|
+
@classmethod
|
|
60
|
+
def from_csv(
|
|
61
|
+
cls, path: FilePath, timestamp_col: str = "timestamp", **kwargs
|
|
62
|
+
) -> SensorDataset:
|
|
63
|
+
"""Load sensor data from a CSV file."""
|
|
64
|
+
df = read_sensor_csv(path, timestamp_col=timestamp_col, **kwargs)
|
|
65
|
+
logger.info("Loaded %d rows from %s", len(df), path)
|
|
66
|
+
return cls(df)
|
|
67
|
+
|
|
68
|
+
def to_dataframe(self) -> pd.DataFrame:
|
|
69
|
+
"""Return the underlying DataFrame."""
|
|
70
|
+
return self.data
|
|
71
|
+
|
|
72
|
+
def to_event_sequences(self, sensor: str) -> list[np.ndarray]:
|
|
73
|
+
"""Convert binary activations for *sensor* into per-day event times."""
|
|
74
|
+
if sensor not in self.data.columns:
|
|
75
|
+
raise KeyError(f"Sensor '{sensor}' not found in dataset")
|
|
76
|
+
df = self.data[self.data[sensor] > 0]
|
|
77
|
+
grouped = df.groupby(df.index.date)
|
|
78
|
+
events: list[np.ndarray] = []
|
|
79
|
+
for _, day_df in grouped:
|
|
80
|
+
times = day_df.index
|
|
81
|
+
events.append(
|
|
82
|
+
np.array([t.hour + t.minute / 60.0 for t in times], dtype=float)
|
|
83
|
+
)
|
|
84
|
+
return events
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def simulate_sensor_data(
|
|
88
|
+
n_days: int = 60,
|
|
89
|
+
n_sensors: int = 4,
|
|
90
|
+
seed: int = 42,
|
|
91
|
+
interaction_strength: float = 0.3,
|
|
92
|
+
) -> SensorDataset:
|
|
93
|
+
"""Simulate realistic binary sensor activations."""
|
|
94
|
+
if n_days < 1:
|
|
95
|
+
raise ValueError("n_days must be at least 1")
|
|
96
|
+
if n_sensors < 1:
|
|
97
|
+
raise ValueError("n_sensors must be at least 1")
|
|
98
|
+
if interaction_strength < 0:
|
|
99
|
+
raise ValueError("interaction_strength must be non-negative")
|
|
100
|
+
|
|
101
|
+
rng = np.random.default_rng(seed)
|
|
102
|
+
n_intervals = n_days * 96
|
|
103
|
+
time_index = pd.date_range("2024-01-01", periods=n_intervals, freq="15min")
|
|
104
|
+
sensor_names = [f"sensor_{i}" for i in range(n_sensors)]
|
|
105
|
+
|
|
106
|
+
data = pd.DataFrame(index=time_index, columns=sensor_names)
|
|
107
|
+
sensor_data = np.zeros((n_intervals, n_sensors))
|
|
108
|
+
|
|
109
|
+
activity_patterns = []
|
|
110
|
+
for i in range(n_sensors):
|
|
111
|
+
morning_peak = 24 + i * 4
|
|
112
|
+
evening_peak = 72 + i * 2
|
|
113
|
+
activity_patterns.append(
|
|
114
|
+
{
|
|
115
|
+
"morning_start": morning_peak,
|
|
116
|
+
"morning_end": morning_peak + 12,
|
|
117
|
+
"morning_prob": 0.4 - i * 0.05,
|
|
118
|
+
"evening_start": evening_peak,
|
|
119
|
+
"evening_end": evening_peak + 16,
|
|
120
|
+
"evening_prob": 0.5 - i * 0.06,
|
|
121
|
+
"baseline_prob": 0.02 + i * 0.01,
|
|
122
|
+
}
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
for day in range(n_days):
|
|
126
|
+
day_start = day * 96
|
|
127
|
+
for sensor_idx, pattern in enumerate(activity_patterns):
|
|
128
|
+
day_probs = np.full(96, pattern["baseline_prob"])
|
|
129
|
+
day_probs[pattern["morning_start"] : pattern["morning_end"]] = pattern[
|
|
130
|
+
"morning_prob"
|
|
131
|
+
]
|
|
132
|
+
evening_end = min(pattern["evening_start"] + 16, 96)
|
|
133
|
+
day_probs[pattern["evening_start"] : evening_end] = pattern["evening_prob"]
|
|
134
|
+
for t in range(96):
|
|
135
|
+
if day_start + t < n_intervals:
|
|
136
|
+
base_activation = rng.binomial(1, day_probs[t])
|
|
137
|
+
sensor_data[day_start + t, sensor_idx] = base_activation
|
|
138
|
+
|
|
139
|
+
if interaction_strength > 0:
|
|
140
|
+
for day in range(n_days):
|
|
141
|
+
day_start = day * 96
|
|
142
|
+
for t in range(1, 96):
|
|
143
|
+
global_t = day_start + t
|
|
144
|
+
if global_t >= n_intervals:
|
|
145
|
+
break
|
|
146
|
+
for target_sensor in range(n_sensors):
|
|
147
|
+
if sensor_data[global_t, target_sensor] == 0:
|
|
148
|
+
influence_prob = 0.0
|
|
149
|
+
for source_sensor in range(n_sensors):
|
|
150
|
+
if source_sensor == target_sensor:
|
|
151
|
+
continue
|
|
152
|
+
for lag in range(1, 4):
|
|
153
|
+
if (
|
|
154
|
+
global_t - lag >= 0
|
|
155
|
+
and sensor_data[global_t - lag, source_sensor] == 1
|
|
156
|
+
):
|
|
157
|
+
if abs(source_sensor - target_sensor) == 1:
|
|
158
|
+
influence_prob += (
|
|
159
|
+
interaction_strength
|
|
160
|
+
* 0.3
|
|
161
|
+
* (0.8 ** (lag - 1))
|
|
162
|
+
)
|
|
163
|
+
else:
|
|
164
|
+
influence_prob += (
|
|
165
|
+
interaction_strength
|
|
166
|
+
* 0.1
|
|
167
|
+
* (0.8 ** (lag - 1))
|
|
168
|
+
)
|
|
169
|
+
if influence_prob > rng.random():
|
|
170
|
+
sensor_data[global_t, target_sensor] = 1
|
|
171
|
+
|
|
172
|
+
for i, sensor in enumerate(sensor_names):
|
|
173
|
+
data[sensor] = sensor_data[:, i]
|
|
174
|
+
|
|
175
|
+
return SensorDataset(data.astype(int))
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def export_analysis_results(
|
|
179
|
+
results: Mapping[str, object], filename: FilePath = "sensor_analysis_results"
|
|
180
|
+
) -> dict[str, Path]:
|
|
181
|
+
"""Export analysis results to JSON/CSV files and return written paths."""
|
|
182
|
+
json_path = Path(f"{filename}.json")
|
|
183
|
+
json_path.parent.mkdir(parents=True, exist_ok=True)
|
|
184
|
+
json_path.write_text(
|
|
185
|
+
json.dumps(results, indent=2, default=str),
|
|
186
|
+
encoding="utf-8",
|
|
187
|
+
)
|
|
188
|
+
logger.info("Results exported to %s", json_path)
|
|
189
|
+
|
|
190
|
+
output_paths = {"json": json_path}
|
|
191
|
+
causality_results = results.get("causality_results")
|
|
192
|
+
if isinstance(causality_results, pd.DataFrame):
|
|
193
|
+
csv_path = Path(f"{filename}_causality.csv")
|
|
194
|
+
csv_path.parent.mkdir(parents=True, exist_ok=True)
|
|
195
|
+
causality_results.to_csv(csv_path, index=False)
|
|
196
|
+
output_paths["causality_csv"] = csv_path
|
|
197
|
+
logger.info("Causality results exported to %s", csv_path)
|
|
198
|
+
|
|
199
|
+
return output_paths
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Logging configuration helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def setup_logging(level: int = logging.INFO) -> None:
|
|
9
|
+
"""Configure basic logging for the package."""
|
|
10
|
+
logging.basicConfig(level=level, format="[%(levelname)s] %(name)s: %(message)s")
|