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.
Files changed (114) hide show
  1. sensor_modeling/__init__.py +45 -0
  2. sensor_modeling/alerts/__init__.py +26 -0
  3. sensor_modeling/alerts/alert.py +532 -0
  4. sensor_modeling/analysis/__init__.py +43 -0
  5. sensor_modeling/analysis/_frame.py +19 -0
  6. sensor_modeling/analysis/behavioral_analysis.py +57 -0
  7. sensor_modeling/analysis/behavioral_metrics.py +66 -0
  8. sensor_modeling/analysis/comparison.py +164 -0
  9. sensor_modeling/analysis/dependency_network.py +408 -0
  10. sensor_modeling/analysis/granger_causality.py +314 -0
  11. sensor_modeling/analysis/pipeline.py +168 -0
  12. sensor_modeling/analysis/reporting.py +109 -0
  13. sensor_modeling/baseline/__init__.py +30 -0
  14. sensor_modeling/baseline/adaptive.py +520 -0
  15. sensor_modeling/baseline/features.py +224 -0
  16. sensor_modeling/change_point/__init__.py +13 -0
  17. sensor_modeling/change_point/_validation.py +31 -0
  18. sensor_modeling/change_point/adaptive_normalization.py +55 -0
  19. sensor_modeling/change_point/embedding_cpd.py +60 -0
  20. sensor_modeling/change_point/energy_efficient.py +57 -0
  21. sensor_modeling/change_point/genetic_optimization.py +65 -0
  22. sensor_modeling/cli.py +416 -0
  23. sensor_modeling/context/__init__.py +33 -0
  24. sensor_modeling/context/occupancy.py +529 -0
  25. sensor_modeling/data/__init__.py +5 -0
  26. sensor_modeling/data/loaders.py +146 -0
  27. sensor_modeling/data/preprocessing.py +83 -0
  28. sensor_modeling/data/synthetic.py +121 -0
  29. sensor_modeling/data/validation.py +81 -0
  30. sensor_modeling/evaluation/__init__.py +92 -0
  31. sensor_modeling/evaluation/ablation.py +303 -0
  32. sensor_modeling/evaluation/attribution.py +474 -0
  33. sensor_modeling/evaluation/detection.py +297 -0
  34. sensor_modeling/evaluation/metrics.py +541 -0
  35. sensor_modeling/evaluation/provenance.py +309 -0
  36. sensor_modeling/examples/__init__.py +1 -0
  37. sensor_modeling/examples/demos/__init__.py +1 -0
  38. sensor_modeling/examples/demos/ambient_pipeline_demo.py +418 -0
  39. sensor_modeling/examples/demos/bernoulli_ar_demo.py +356 -0
  40. sensor_modeling/examples/demos/cpd_ar_demo.py +25 -0
  41. sensor_modeling/examples/demos/cpd_benchmark.py +42 -0
  42. sensor_modeling/examples/demos/hmm_granger_demo.py +30 -0
  43. sensor_modeling/examples/demos/nhpp_pelt_demo.py +80 -0
  44. sensor_modeling/examples/tutorials/__init__.py +1 -0
  45. sensor_modeling/fusion/__init__.py +46 -0
  46. sensor_modeling/fusion/defaults.py +296 -0
  47. sensor_modeling/fusion/emissions.py +339 -0
  48. sensor_modeling/fusion/estimate.py +375 -0
  49. sensor_modeling/fusion/filter.py +323 -0
  50. sensor_modeling/health/__init__.py +31 -0
  51. sensor_modeling/health/monitor.py +590 -0
  52. sensor_modeling/health/status.py +74 -0
  53. sensor_modeling/hmm/__init__.py +15 -0
  54. sensor_modeling/hmm/adaptive_hmm.py +22 -0
  55. sensor_modeling/hmm/base.py +134 -0
  56. sensor_modeling/hmm/circadian_hmm.py +22 -0
  57. sensor_modeling/hmm/heterogeneous_hmm.py +22 -0
  58. sensor_modeling/hmm/hierarchical_hmm.py +35 -0
  59. sensor_modeling/hmm/scaled_dirichlet_hmm.py +23 -0
  60. sensor_modeling/interop/__init__.py +57 -0
  61. sensor_modeling/interop/fhir.py +418 -0
  62. sensor_modeling/interop/privacy.py +308 -0
  63. sensor_modeling/models/__init__.py +12 -0
  64. sensor_modeling/models/bernoulli_ar/__init__.py +6 -0
  65. sensor_modeling/models/bernoulli_ar/base_model.py +569 -0
  66. sensor_modeling/models/bernoulli_ar/multivariate_model.py +411 -0
  67. sensor_modeling/models/change_point_detection/__init__.py +10 -0
  68. sensor_modeling/models/change_point_detection/deep.py +65 -0
  69. sensor_modeling/models/change_point_detection/pelt.py +159 -0
  70. sensor_modeling/models/nhpp_pelt/__init__.py +5 -0
  71. sensor_modeling/models/nhpp_pelt/bspline.py +96 -0
  72. sensor_modeling/models/nhpp_pelt/cli.py +243 -0
  73. sensor_modeling/models/nhpp_pelt/diagnostics.py +234 -0
  74. sensor_modeling/models/nhpp_pelt/io.py +58 -0
  75. sensor_modeling/models/nhpp_pelt/model.py +408 -0
  76. sensor_modeling/models/nhpp_pelt/optimizer.py +142 -0
  77. sensor_modeling/models/nhpp_pelt/plotting.py +218 -0
  78. sensor_modeling/models/nhpp_pelt/quad.py +72 -0
  79. sensor_modeling/models/nhpp_pelt/regularization.py +121 -0
  80. sensor_modeling/models/nhpp_pelt/utils.py +174 -0
  81. sensor_modeling/observations/__init__.py +59 -0
  82. sensor_modeling/observations/adapters.py +195 -0
  83. sensor_modeling/observations/ingest.py +269 -0
  84. sensor_modeling/observations/observation.py +270 -0
  85. sensor_modeling/observations/registry.py +262 -0
  86. sensor_modeling/observations/stream.py +342 -0
  87. sensor_modeling/observations/types.py +107 -0
  88. sensor_modeling/observations/units.py +117 -0
  89. sensor_modeling/online/__init__.py +36 -0
  90. sensor_modeling/online/benchmarks.py +242 -0
  91. sensor_modeling/online/pipeline.py +485 -0
  92. sensor_modeling/simulation/__init__.py +54 -0
  93. sensor_modeling/simulation/faults.py +191 -0
  94. sensor_modeling/simulation/household.py +862 -0
  95. sensor_modeling/states/__init__.py +23 -0
  96. sensor_modeling/states/markov.py +105 -0
  97. sensor_modeling/states/ontology.py +238 -0
  98. sensor_modeling/utils/__init__.py +41 -0
  99. sensor_modeling/utils/data_io.py +199 -0
  100. sensor_modeling/utils/logging_config.py +10 -0
  101. sensor_modeling/utils/missing.py +188 -0
  102. sensor_modeling/utils/plotting.py +98 -0
  103. sensor_modeling/utils/validation.py +117 -0
  104. sensor_modeling/visualization/__init__.py +3 -0
  105. sensor_modeling/visualization/clinical.py +67 -0
  106. sensor_modeling/visualization/interactive.py +208 -0
  107. sensor_modeling/visualization/research.py +60 -0
  108. sensor_modeling/visualization/web_app.py +137 -0
  109. sensor_modeling-0.2.0.dist-info/METADATA +683 -0
  110. sensor_modeling-0.2.0.dist-info/RECORD +114 -0
  111. sensor_modeling-0.2.0.dist-info/WHEEL +5 -0
  112. sensor_modeling-0.2.0.dist-info/entry_points.txt +18 -0
  113. sensor_modeling-0.2.0.dist-info/licenses/LICENSE +21 -0
  114. 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")