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,218 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Sequence
4
+ from dataclasses import dataclass
5
+ from typing import Any, List, Optional, Tuple, Union
6
+
7
+ import matplotlib.pyplot as plt
8
+ import numpy as np
9
+
10
+ from .utils import type_check
11
+
12
+ Array1D = np.ndarray
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class PlotConfig:
17
+ """Styling for the raster (top) and λ̂ curves (middle)."""
18
+
19
+ figsize: Tuple[float, float] = (12.0, 8.5)
20
+ dpi: int = 110
21
+ raster_markersize: float = 4.0
22
+ raster_alpha: float = 0.9
23
+ band_alpha: float = 0.08
24
+ grid_points: int = 400
25
+ title_top: str = "Daily event raster with segment bands"
26
+ title_bottom: str = "Estimated segment intensities λ̂(t)"
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class HistConfig:
31
+ """Config for per-segment average histograms (bottom row)."""
32
+
33
+ bins: Union[int, str] = "fd" # 'fd' | 'sqrt' | 'sturges' | int
34
+ density: bool = True # True → average rate (events/day/unit-time)
35
+ alpha: float = 0.5
36
+ title: str = "Per-segment average histograms of event times"
37
+
38
+
39
+ def _common_bin_edges(
40
+ all_events: Array1D, delta: float, bins: Union[int, str]
41
+ ) -> Array1D:
42
+ type_check(delta > 0, "delta must be positive.")
43
+ all_events = np.asarray(all_events, float)
44
+ all_events = all_events[(all_events >= 0.0) & (all_events <= delta)]
45
+
46
+ if isinstance(bins, int):
47
+ type_check(bins >= 1, "bins must be >= 1.")
48
+ return np.linspace(0.0, delta, bins + 1)
49
+
50
+ if all_events.size == 0:
51
+ return np.linspace(0.0, delta, 21)
52
+
53
+ if bins == "fd":
54
+ q25, q75 = np.quantile(all_events, [0.25, 0.75])
55
+ iqr = max(q75 - q25, 1e-12)
56
+ h = 2.0 * iqr / np.cbrt(all_events.size)
57
+ k = 20 if (not np.isfinite(h) or h <= 0) else max(1, int(np.ceil(delta / h)))
58
+ return np.linspace(0.0, delta, k + 1)
59
+ if bins == "sqrt":
60
+ k = max(1, int(np.ceil(np.sqrt(all_events.size))))
61
+ return np.linspace(0.0, delta, k + 1)
62
+ if bins == "sturges":
63
+ k = max(1, int(np.ceil(np.log2(all_events.size) + 1.0)))
64
+ return np.linspace(0.0, delta, k + 1)
65
+ raise ValueError("bins must be int or one of {'fd','sqrt','sturges'}.")
66
+
67
+
68
+ def _avg_hist_per_segment(
69
+ days: Sequence[Array1D],
70
+ segments: Sequence[Tuple[int, int]],
71
+ edges: Array1D,
72
+ density: bool,
73
+ ) -> List[Array1D]:
74
+ """
75
+ Average per-day histogram for each segment, using common bin edges.
76
+ If `density` is True, convert counts/bin/day to rate by dividing by bin width.
77
+ """
78
+ edges = np.asarray(edges, float)
79
+ type_check(np.all(np.diff(edges) > 0), "edges must be strictly increasing.")
80
+
81
+ widths = np.diff(edges)
82
+ avg_counts: List[Array1D] = []
83
+
84
+ for i_start, i_end in segments:
85
+ type_check(1 <= i_start <= i_end <= len(days), "segment indices out of range.")
86
+ L = i_end - i_start + 1
87
+
88
+ acc = np.zeros(edges.size - 1, dtype=float)
89
+ for d in range(i_start - 1, i_end):
90
+ ev = np.asarray(days[d], float)
91
+ if ev.size == 0:
92
+ continue
93
+ ev = ev[(ev >= edges[0]) & (ev <= edges[-1])]
94
+ counts, _ = np.histogram(ev, bins=edges)
95
+ acc += counts.astype(float)
96
+
97
+ mean_per_day = acc / max(L, 1)
98
+ if density:
99
+ mean_per_day = mean_per_day / widths
100
+ avg_counts.append(mean_per_day)
101
+
102
+ return avg_counts
103
+
104
+
105
+ def plot_segments_and_intensities_with_histograms(
106
+ days: Sequence[Array1D],
107
+ model: Any,
108
+ config: PlotConfig = PlotConfig(),
109
+ hist: HistConfig = HistConfig(),
110
+ *,
111
+ save_path: Optional[str] = None,
112
+ ) -> plt.Figure:
113
+ """
114
+ Three-row figure:
115
+ (1) event raster with segment bands,
116
+ (2) segment λ̂(t) curves (optionally add CI bands outside),
117
+ (3) per-segment average histograms (small multiples).
118
+ """
119
+ type_check(len(days) >= 1, "days must contain at least one array.")
120
+ need = ["segments_", "knots_", "degree_", "delta_", "P_", "weights_"]
121
+ missing = [a for a in need if not hasattr(model, a)]
122
+ type_check(len(missing) == 0, f"Model is missing attributes: {missing}.")
123
+ type_check(
124
+ hasattr(model, "intensity_on_grid"), "Model must implement intensity_on_grid()."
125
+ )
126
+
127
+ n_days = len(days)
128
+ segments: List[Tuple[int, int]] = list(model.segments_)
129
+ delta: float = float(model.delta_)
130
+ n_segments = len(segments)
131
+ type_check(n_segments >= 1, "Model has no segments.")
132
+
133
+ all_events = (
134
+ np.concatenate([np.asarray(d, float) for d in days])
135
+ if n_days
136
+ else np.array([], float)
137
+ )
138
+ edges = _common_bin_edges(all_events, delta, hist.bins)
139
+ widths = np.diff(edges)
140
+ centers = edges[:-1] + 0.5 * widths
141
+ avg_counts = _avg_hist_per_segment(days, segments, edges, density=hist.density)
142
+
143
+ fig = plt.figure(figsize=config.figsize, dpi=config.dpi)
144
+ gs = fig.add_gridspec(nrows=3, ncols=1, height_ratios=[1.4, 1.0, 1.0], hspace=0.35)
145
+ ax_top = fig.add_subplot(gs[0, 0])
146
+ ax_mid = fig.add_subplot(gs[1, 0])
147
+
148
+ # -------- Top: raster with segment bands --------
149
+ for k, (i_start, i_end) in enumerate(segments):
150
+ y0, y1 = i_start - 0.5, i_end + 0.5
151
+ if k % 2 == 0:
152
+ ax_top.axhspan(y0, y1, alpha=config.band_alpha)
153
+ ax_top.text(
154
+ delta * 1.002,
155
+ (y0 + y1) / 2.0,
156
+ f"Seg {k+1}\n({i_start}–{i_end})",
157
+ va="center",
158
+ ha="left",
159
+ fontsize=9,
160
+ )
161
+
162
+ for i, ev in enumerate(days, start=1):
163
+ ev = np.asarray(ev, float)
164
+ if ev.size:
165
+ ax_top.plot(
166
+ ev,
167
+ np.full_like(ev, i, float),
168
+ linestyle="None",
169
+ marker="|",
170
+ markersize=config.raster_markersize,
171
+ alpha=config.raster_alpha,
172
+ )
173
+
174
+ ax_top.set_xlim(0.0, delta)
175
+ ax_top.set_ylim(0.5, n_days + 0.5)
176
+ ax_top.set_xlabel("Time within day")
177
+ ax_top.set_ylabel("Day index")
178
+ ax_top.set_title(config.title_top)
179
+ ax_top.grid(True, linestyle="--", linewidth=0.5, alpha=0.5)
180
+
181
+ # -------- Middle: intensity curves (no explicit colors) --------
182
+ grid = np.linspace(0.0, delta, config.grid_points)
183
+ for k, (i_start, i_end) in enumerate(segments):
184
+ lam = np.asarray(model.intensity_on_grid(k, grid), float)
185
+ type_check(lam.shape == grid.shape, "intensity_on_grid must match grid shape.")
186
+ label = f"Seg {k+1} (days {i_start}–{i_end})"
187
+ ax_mid.plot(grid, lam, label=label)
188
+
189
+ ax_mid.set_xlim(0.0, delta)
190
+ ax_mid.set_xlabel("Time within day")
191
+ ax_mid.set_ylabel("Intensity λ(t)")
192
+ ax_mid.set_title(config.title_bottom)
193
+ ax_mid.grid(True, linestyle="--", linewidth=0.5, alpha=0.5)
194
+ ax_mid.legend(loc="best", fontsize=9, frameon=False)
195
+
196
+ # -------- Bottom: per-segment average histograms --------
197
+ subgs = gs[2, 0].subgridspec(1, n_segments, wspace=0.15)
198
+ for k in range(n_segments):
199
+ ax = fig.add_subplot(subgs[0, k])
200
+ ax.bar(centers, avg_counts[k], width=widths, align="center", alpha=hist.alpha)
201
+ ax.set_xlim(0.0, delta)
202
+ if k == 0:
203
+ ax.set_ylabel(
204
+ "Avg rate\n(events/day/unit-time)"
205
+ if hist.density
206
+ else "Avg count/day/bin"
207
+ )
208
+ ax.set_xlabel("Time within day")
209
+ ax.set_title(f"Seg {k+1}: days {segments[k][0]}–{segments[k][1]}", fontsize=10)
210
+ ax.grid(True, linestyle="--", linewidth=0.5, alpha=0.5)
211
+
212
+ fig.text(0.5, 0.04 + 0.03, hist.title, ha="center", va="center", fontsize=11)
213
+
214
+ fig.tight_layout()
215
+ if save_path:
216
+ fig.savefig(save_path, dpi=config.dpi, bbox_inches="tight")
217
+
218
+ return fig
@@ -0,0 +1,72 @@
1
+ # nhpp/quad.py
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass
5
+ from typing import Tuple
6
+
7
+ import numpy as np
8
+
9
+ from .utils import type_check
10
+
11
+ Array1D = np.ndarray
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class QuadratureConfig:
16
+ """
17
+ Settings for Gauss–Legendre quadrature used to integrate terms like
18
+ ∫ exp(wᵀψ(t)) {1, ψ, ψψᵀ} dt.
19
+
20
+ Attributes
21
+ ----------
22
+ n_points : int
23
+ Order of the Gauss–Legendre rule (≥ 2). Larger → more accurate, slower.
24
+ ridge : float
25
+ Small diagonal ridge added to the Hessian to ensure positive definiteness.
26
+ """
27
+
28
+ n_points: int = 256
29
+ ridge: float = 1e-8
30
+
31
+
32
+ def leggauss_on_interval(a: float, b: float, n: int) -> Tuple[Array1D, Array1D]:
33
+ """
34
+ Compute Gauss–Legendre nodes and weights on a closed interval [a, b].
35
+
36
+ Parameters
37
+ ----------
38
+ a : float
39
+ Interval start (can equal b for a degenerate interval; not useful here).
40
+ b : float
41
+ Interval end; must satisfy b > a in practice.
42
+ n : int
43
+ Number of quadrature points (order). Must be ≥ 2.
44
+
45
+ Returns
46
+ -------
47
+ nodes : np.ndarray, shape (n,)
48
+ Quadrature abscissae in [a, b].
49
+ weights : np.ndarray, shape (n,)
50
+ Corresponding quadrature weights that integrate polynomials of degree 2n-1 exactly.
51
+
52
+ Notes
53
+ -----
54
+ We obtain nodes/weights on [-1, 1] via `numpy.polynomial.legendre.leggauss`
55
+ and map them affinely to [a, b].
56
+ """
57
+ type_check(n >= 2, "Quadrature order n must be >= 2.")
58
+ type_check(np.isfinite(a) and np.isfinite(b), "Interval bounds must be finite.")
59
+ type_check(b >= a, "Require b >= a.")
60
+
61
+ # Nodes/weights on [-1, 1]
62
+ x, w = np.polynomial.legendre.leggauss(n)
63
+
64
+ # Affine map to [a, b]
65
+ xm = 0.5 * (b + a)
66
+ xr = 0.5 * (b - a)
67
+ nodes = xm + xr * x
68
+ weights = xr * w
69
+ return nodes.astype(float, copy=False), weights.astype(float, copy=False)
70
+
71
+
72
+ __all__ = ["QuadratureConfig", "leggauss_on_interval"]
@@ -0,0 +1,121 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Iterable, Sequence
4
+ from typing import TYPE_CHECKING, List, Tuple
5
+
6
+ import numpy as np
7
+
8
+ if TYPE_CHECKING:
9
+ from .model import NHPPConfig
10
+
11
+ Array1D = np.ndarray
12
+ Array2D = np.ndarray
13
+
14
+
15
+ def difference_matrix(p: int, order: int = 2) -> Array2D:
16
+ """
17
+ Finite-difference operator D^(order) for P-splines.
18
+
19
+ For p parameters and order=2, returns a (p-2)×p matrix whose rows each
20
+ realize [1, -2, 1] at consecutive positions. More generally:
21
+ D^(k) = diff(I_p, n=k, axis=0).
22
+
23
+ Returns
24
+ -------
25
+ D : np.ndarray, shape (p - order, p)
26
+ """
27
+ if p <= order:
28
+ raise ValueError("p must be > order.")
29
+ D = np.eye(p, dtype=float)
30
+ for _ in range(order):
31
+ D = np.diff(D, n=1, axis=0)
32
+ return D
33
+
34
+
35
+ def p_spline_RtR(p: int, order: int = 2, gamma: float = 0.0) -> Array2D:
36
+ """
37
+ Build γ * (D^T D), where D is the 'order'-th difference operator.
38
+
39
+ Parameters
40
+ ----------
41
+ p : int
42
+ Number of spline coefficients (P).
43
+ order : int
44
+ Difference order (2 for the classic curvature penalty).
45
+ gamma : float
46
+ Penalty strength γ ≥ 0.
47
+
48
+ Returns
49
+ -------
50
+ RtR : np.ndarray, shape (p, p)
51
+ γ * (Dᵀ D). If gamma==0 returns a zero matrix for convenience.
52
+ """
53
+ if gamma <= 0.0:
54
+ return np.zeros((p, p), dtype=float)
55
+ D = difference_matrix(p, order=order)
56
+ return float(gamma) * (D.T @ D)
57
+
58
+
59
+ def sweep_gamma(
60
+ days: Sequence[np.ndarray],
61
+ base_cfg: NHPPConfig,
62
+ gammas: Iterable[float],
63
+ *,
64
+ order: int = 2,
65
+ ) -> List[Tuple[float, int, float]]:
66
+ """
67
+ Fit across a grid of γ values (P-spline penalty strength) and return:
68
+ (gamma, n_changepoints, total_penalized_cost)
69
+
70
+ Notes
71
+ -----
72
+ - Uses the model's existing penalty_beta (SIC if None).
73
+ - The reported total cost matches the objective: sum segment costs (incl. γ term)
74
+ + β per segment.
75
+ """
76
+ # local imports to avoid circular deps
77
+ from .bspline import bspline_design_matrix
78
+ from .model import NHPPPELT, NHPPConfig
79
+ from .optimizer import SegmentOptimizer
80
+ from .quad import QuadratureConfig
81
+
82
+ out: List[Tuple[float, int, float]] = []
83
+
84
+ for g in gammas:
85
+ cfg = NHPPConfig(
86
+ **{
87
+ **base_cfg.__dict__,
88
+ "pspline_gamma": float(g),
89
+ "pspline_order": int(order),
90
+ }
91
+ )
92
+ model = NHPPPELT(cfg).fit(days)
93
+
94
+ # re-accumulate the exact objective with the fitted weights
95
+ total = 0.0
96
+ quad = QuadratureConfig(n_points=cfg.quad.n_points, ridge=cfg.hessian_ridge)
97
+ opt = SegmentOptimizer(
98
+ delta=model.delta_,
99
+ degree=model.degree_,
100
+ knots=model.knots_,
101
+ quad=quad,
102
+ newton=cfg.newton,
103
+ pspline_gamma=cfg.pspline_gamma,
104
+ pspline_order=cfg.pspline_order,
105
+ )
106
+
107
+ for (i, j), w in zip(model.segments_, model.weights_):
108
+ # sufficient stats s for [i..j]
109
+ s = np.zeros(cfg.n_basis, dtype=float)
110
+ for d in range(i - 1, j):
111
+ ev = np.asarray(days[d], float)
112
+ if ev.size:
113
+ s += bspline_design_matrix(ev, cfg.degree, model.knots_).sum(axis=0)
114
+ # minimize with warm-start = fitted w to recover cost term
115
+ _, c = opt.minimize(L=j - i + 1, s=s, w0=w)
116
+ total += float(c)
117
+
118
+ total += len(model.segments_) * float(model.beta_)
119
+ out.append((float(g), len(model.changepoints_), total))
120
+
121
+ return out
@@ -0,0 +1,174 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from collections.abc import Iterable, Sequence
5
+ from typing import TYPE_CHECKING, List, Tuple
6
+
7
+ import numpy as np
8
+
9
+ if TYPE_CHECKING:
10
+ from .model import NHPPPELT, NHPPConfig
11
+
12
+ # ------------------------------- Types -------------------------------
13
+
14
+ Array1D = np.ndarray
15
+ Array2D = np.ndarray
16
+
17
+
18
+ # ---------------------------- Validators ----------------------------
19
+
20
+
21
+ def type_check(condition: bool, msg: str) -> None:
22
+ """Runtime guard with a concise error message."""
23
+ if not condition:
24
+ raise ValueError(msg)
25
+
26
+
27
+ # -------------------------- Normal PPF (Φ⁻¹) -------------------------
28
+
29
+
30
+ def normal_ppf(p: float) -> float:
31
+ """
32
+ Approximate the standard-normal inverse CDF Φ⁻¹(p).
33
+ Accuracy ~ 4e-4 absolute in typical ranges. SciPy-free.
34
+
35
+ Parameters
36
+ ----------
37
+ p : float
38
+ Probability in (0, 1).
39
+
40
+ Returns
41
+ -------
42
+ float
43
+ Approximate quantile.
44
+
45
+ Notes
46
+ -----
47
+ Combines a Beasley–Springer/Wichura-style central approximation
48
+ with a simple tail refinement. Good enough for CI bands.
49
+ """
50
+ type_check(0.0 < p < 1.0, "p must be in (0,1).")
51
+ # exploit symmetry
52
+ if p > 0.5:
53
+ return -normal_ppf(1.0 - p)
54
+
55
+ # coefficients (central region)
56
+ a = [2.50662823884, -18.61500062529, 41.39119773534, -25.44106049637]
57
+ b = [-8.47351093090, 23.08336743743, -21.06224101826, 3.13082909833]
58
+
59
+ # auxiliary for mild tails
60
+ c = [
61
+ 0.3374754822726147,
62
+ 0.9761690190917186,
63
+ 0.1607979714918209,
64
+ 0.0276438810333863,
65
+ 0.0038405729373609,
66
+ 0.0003951896511919,
67
+ 0.0000321767881768,
68
+ 0.0000002888167364,
69
+ 0.0000003960315187,
70
+ ]
71
+
72
+ # central transform
73
+ t = np.sqrt(-2.0 * np.log(p))
74
+ x = t - (((a[3] * t + a[2]) * t + a[1]) * t + a[0]) / (
75
+ (((b[3] * t + b[2]) * t + b[1]) * t + b[0]) * t + 1.0
76
+ )
77
+
78
+ # refine for not-too-small p
79
+ if p >= 0.02425:
80
+ q = p - 0.5
81
+ r = q * q
82
+ poly = c[8]
83
+ for k in range(7, -1, -1):
84
+ poly = poly * r + c[k]
85
+ return float(q * poly)
86
+ return float(x)
87
+
88
+
89
+ # --------------------------- JSON exporters --------------------------
90
+
91
+
92
+ def to_jsonable(model: NHPPPELT) -> dict:
93
+ """
94
+ Convert a fitted model into a JSON-serializable dictionary.
95
+ """
96
+ return {
97
+ "delta": float(model.delta_),
98
+ "degree": int(model.degree_),
99
+ "n_basis": int(model.P_),
100
+ "beta": float(model.beta_),
101
+ "knots": model.knots_.tolist(),
102
+ "changepoints": [int(c) for c in model.changepoints_],
103
+ "segments": [(int(i), int(j)) for (i, j) in model.segments_],
104
+ "weights": [w.astype(float).tolist() for w in model.weights_],
105
+ }
106
+
107
+
108
+ def save_results_json(model: NHPPPELT, path: str) -> None:
109
+ """
110
+ Save fitted model parameters/results to a JSON file.
111
+
112
+ Parameters
113
+ ----------
114
+ model : NHPPPELT
115
+ Fitted model.
116
+ path : str
117
+ Destination path.
118
+ """
119
+ with open(path, "w", encoding="utf-8") as f:
120
+ json.dump(to_jsonable(model), f, ensure_ascii=False, indent=2)
121
+
122
+
123
+ # ------------------------------ Sweep β ------------------------------
124
+
125
+
126
+ def sweep_beta(
127
+ days: Sequence[Array1D],
128
+ base_cfg: NHPPConfig,
129
+ betas: Iterable[float],
130
+ ) -> List[Tuple[float, int, float]]:
131
+ """
132
+ Fit across candidate penalties and return tuples of:
133
+ (beta, n_changepoints, total_penalized_cost)
134
+
135
+ Useful to visualize an elbow/stability curve before fixing β.
136
+
137
+ Notes
138
+ -----
139
+ - Reuses each solution’s segment weights as warm starts to
140
+ recompute exact segment costs for the reported total.
141
+ """
142
+ from .bspline import bspline_design_matrix
143
+ from .model import NHPPPELT, NHPPConfig
144
+ from .optimizer import QuadratureConfig, SegmentOptimizer
145
+
146
+ results: List[Tuple[float, int, float]] = []
147
+
148
+ for b in betas:
149
+ cfg = NHPPConfig(**{**base_cfg.__dict__, "penalty_beta": float(b)})
150
+ model = NHPPPELT(cfg).fit(days)
151
+
152
+ total_cost = 0.0
153
+ for (i, j), w in zip(model.segments_, model.weights_):
154
+ quad = QuadratureConfig(n_points=cfg.quad.n_points, ridge=cfg.hessian_ridge)
155
+ opt = SegmentOptimizer(
156
+ delta=model.delta_,
157
+ degree=model.degree_,
158
+ knots=model.knots_,
159
+ quad=quad,
160
+ newton=cfg.newton,
161
+ )
162
+ # build sufficient statistics s for days i..j
163
+ s = np.zeros(cfg.n_basis, dtype=float)
164
+ for d in range(i - 1, j):
165
+ ev = np.asarray(days[d], dtype=float)
166
+ if ev.size:
167
+ s += bspline_design_matrix(ev, cfg.degree, model.knots_).sum(axis=0)
168
+ _, c = opt.minimize(L=j - i + 1, s=s, w0=w) # warm start
169
+ total_cost += float(c)
170
+
171
+ total_cost += len(model.segments_) * float(b)
172
+ results.append((float(b), len(model.changepoints_), total_cost))
173
+
174
+ return results
@@ -0,0 +1,59 @@
1
+ """Canonical, hardware-neutral representation of sensor observations.
2
+
3
+ This package defines the single data model every other stage of the pipeline
4
+ consumes. The central claim it encodes is that a sensor record is *evidence*,
5
+ not behaviour: an :class:`Observation` carries the value, the unit it was
6
+ measured in, the reliability attached to it, and the provenance of any repair
7
+ applied during ingestion, so that later stages can weight it honestly.
8
+ """
9
+
10
+ from .adapters import (
11
+ LegacyConversionError,
12
+ naive_utc,
13
+ observations_from_dataset,
14
+ observations_from_frame,
15
+ observations_from_records,
16
+ )
17
+ from .ingest import (
18
+ ClockOffsetEstimator,
19
+ IngestionReport,
20
+ ObservationIngestor,
21
+ RejectedObservation,
22
+ )
23
+ from .observation import Observation, default_kind, require_aware
24
+ from .registry import (
25
+ SensorContractError,
26
+ SensorRegistry,
27
+ SensorSpec,
28
+ UnknownSensorError,
29
+ )
30
+ from .stream import Gap, ObservationStream
31
+ from .types import Modality, ObservationFlag, ObservationKind
32
+ from .units import Unit, convert, to_canonical
33
+
34
+ __all__ = [
35
+ "ClockOffsetEstimator",
36
+ "Gap",
37
+ "IngestionReport",
38
+ "LegacyConversionError",
39
+ "Modality",
40
+ "Observation",
41
+ "ObservationFlag",
42
+ "ObservationIngestor",
43
+ "ObservationKind",
44
+ "ObservationStream",
45
+ "RejectedObservation",
46
+ "SensorContractError",
47
+ "SensorRegistry",
48
+ "SensorSpec",
49
+ "Unit",
50
+ "UnknownSensorError",
51
+ "convert",
52
+ "default_kind",
53
+ "naive_utc",
54
+ "observations_from_dataset",
55
+ "observations_from_frame",
56
+ "observations_from_records",
57
+ "require_aware",
58
+ "to_canonical",
59
+ ]