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,96 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Optional
4
+
5
+ import numpy as np
6
+
7
+ from .utils import type_check
8
+
9
+ Array1D = np.ndarray
10
+ Array2D = np.ndarray
11
+
12
+
13
+ def open_uniform_knots(
14
+ delta: float, degree: int, n_basis: int, internal_knots: Optional[Array1D]
15
+ ) -> Array1D:
16
+ """
17
+ Construct an open (clamped) knot vector on [0, delta].
18
+
19
+ If internal_knots is provided, then:
20
+ n_basis == len(internal_knots) + degree + 1
21
+ and we place degree+1 repeats at boundaries.
22
+
23
+ Returns
24
+ -------
25
+ knots : np.ndarray, shape (n_basis + degree + 1,)
26
+ """
27
+ type_check(delta > 0, "delta must be positive.")
28
+ type_check(degree >= 0, "degree must be non-negative.")
29
+
30
+ if internal_knots is not None:
31
+ internal_knots = np.asarray(internal_knots, dtype=float)
32
+ type_check(
33
+ np.all((internal_knots > 0) & (internal_knots < delta)),
34
+ "internal_knots must be in (0, delta).",
35
+ )
36
+ expected = internal_knots.size + degree + 1
37
+ type_check(
38
+ n_basis == expected,
39
+ f"n_basis must equal len(internal_knots)+degree+1 = {expected}.",
40
+ )
41
+ breaks = np.r_[0.0, internal_knots, delta]
42
+ else:
43
+ n_internal = n_basis - degree - 1
44
+ type_check(n_internal >= 0, "n_basis must be at least degree+1.")
45
+ if n_internal > 0:
46
+ internal = np.linspace(0.0, delta, n_internal + 2)[1:-1]
47
+ breaks = np.r_[0.0, internal, delta]
48
+ else:
49
+ breaks = np.array([0.0, delta], dtype=float)
50
+
51
+ t0 = np.repeat(breaks[0], degree + 1)
52
+ t1 = np.repeat(breaks[-1], degree + 1)
53
+ return np.r_[t0, breaks[1:-1], t1]
54
+
55
+
56
+ def _basis_at_scalar(t: float, degree: int, knots: Array1D) -> Array1D:
57
+ """Cox–de Boor recursion for all basis functions at a scalar t."""
58
+ M = knots.size
59
+ n_basis = M - degree - 1
60
+ N = np.zeros((degree + 1, n_basis), dtype=float)
61
+
62
+ # degree 0
63
+ for i in range(n_basis):
64
+ if knots[i] <= t < knots[i + 1] or (
65
+ t == knots[-1] and knots[i] <= t <= knots[i + 1]
66
+ ):
67
+ N[0, i] = 1.0
68
+
69
+ # recursion
70
+ for k in range(1, degree + 1):
71
+ for i in range(n_basis):
72
+ left_den = knots[i + k] - knots[i]
73
+ right_den = knots[i + k + 1] - knots[i + 1]
74
+ left = 0.0 if left_den <= 0 else (t - knots[i]) / left_den * N[k - 1, i]
75
+ right = (
76
+ 0.0
77
+ if right_den <= 0
78
+ else (knots[i + k + 1] - t) / right_den * N[k - 1, i + 1]
79
+ if i + 1 < n_basis
80
+ else 0.0
81
+ )
82
+ N[k, i] = left + right
83
+
84
+ return N[degree, :]
85
+
86
+
87
+ def bspline_design_matrix(ts: Array1D, degree: int, knots: Array1D) -> Array2D:
88
+ """
89
+ Evaluate B-spline basis at vector ts (shape (m,)),
90
+ returning matrix Psi with shape (m, n_basis).
91
+ """
92
+ ts = np.asarray(ts, dtype=float)
93
+ out = np.empty((ts.size, knots.size - degree - 1), dtype=float)
94
+ for r, t in enumerate(ts):
95
+ out[r, :] = _basis_at_scalar(float(t), degree, knots)
96
+ return out
@@ -0,0 +1,243 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import os
6
+ from typing import List
7
+
8
+ import matplotlib.pyplot as plt
9
+ import numpy as np
10
+
11
+ from .diagnostics import DiagConfig, plot_time_rescaling_diagnostics
12
+ from .model import NHPPPELT, NHPPConfig
13
+ from .plotting import (
14
+ HistConfig,
15
+ PlotConfig,
16
+ plot_segments_and_intensities_with_histograms,
17
+ )
18
+ from .utils import save_results_json
19
+
20
+ Array1D = np.ndarray
21
+
22
+
23
+ # --------------------------- I/O helpers ---------------------------
24
+
25
+
26
+ def _parse_grid(expr: str) -> List[float]:
27
+ """
28
+ Parse a numeric grid:
29
+ - comma list: 0,0.001,0.01
30
+ - range: start:stop:step (inclusive end within tolerance)
31
+ """
32
+ if ":" in expr:
33
+ a, b, c = expr.split(":")
34
+ start, stop, step = float(a), float(b), float(c)
35
+ if step <= 0:
36
+ raise ValueError("step must be > 0")
37
+ vals, x = [], start
38
+ while x <= stop + 1e-12:
39
+ vals.append(x)
40
+ x += step
41
+ return vals
42
+ return [float(x) for x in expr.split(",") if x.strip()]
43
+
44
+
45
+ def _load_npz_days(path: str) -> List[Array1D]:
46
+ """
47
+ Load days from a .npz created as:
48
+ np.savez(path, days=np.array(list_of_arrays, dtype=object))
49
+ """
50
+ with np.load(path, allow_pickle=True) as data:
51
+ if "days" not in data:
52
+ raise ValueError("NPZ must contain array 'days' (dtype=object).")
53
+ arr = data["days"]
54
+ if arr.dtype != object:
55
+ raise ValueError("NPZ 'days' must be an object array of 1-D float arrays.")
56
+ return [np.asarray(x, float) for x in arr.tolist()]
57
+
58
+
59
+ def _parse_betas(expr: str) -> List[float]:
60
+ """
61
+ Parse betas:
62
+ - Comma list: 10,20,40
63
+ - Range: start:stop:step (inclusive-ish, step > 0)
64
+ """
65
+ if ":" in expr:
66
+ a, b, c = expr.split(":")
67
+ start, stop, step = float(a), float(b), float(c)
68
+ if step <= 0:
69
+ raise ValueError("step must be > 0")
70
+ vals = []
71
+ x = start
72
+ # include stop within tolerance
73
+ while x <= stop + 1e-12:
74
+ vals.append(x)
75
+ x += step
76
+ return vals
77
+ return [float(x) for x in expr.split(",") if x.strip()]
78
+
79
+
80
+ # --------------------------- Subcommands ---------------------------
81
+
82
+
83
+ def cmd_fit(args: argparse.Namespace) -> None:
84
+ days = _load_npz_days(args.input)
85
+ cfg = NHPPConfig(
86
+ delta=args.delta,
87
+ degree=args.degree,
88
+ n_basis=args.n_basis,
89
+ knot_strategy=args.knot_strategy,
90
+ min_seg_len=args.min_seg_len,
91
+ penalty_beta=(None if args.beta is None else float(args.beta)),
92
+ )
93
+ model = NHPPPELT(cfg).fit(days)
94
+ print("Changepoints:", model.changepoints_)
95
+ print("Segments:", model.segments_)
96
+
97
+ if args.json_out:
98
+ os.makedirs(os.path.dirname(args.json_out) or ".", exist_ok=True)
99
+ save_results_json(model, args.json_out)
100
+ print(f"Saved JSON to {args.json_out}")
101
+
102
+ if args.plot_out:
103
+ fig = plot_segments_and_intensities_with_histograms(
104
+ days,
105
+ model,
106
+ config=PlotConfig(grid_points=args.grid_points),
107
+ hist=HistConfig(bins="fd", density=True, alpha=0.4),
108
+ save_path=args.plot_out,
109
+ )
110
+ plt.close(fig)
111
+ print(f"Saved plot to {args.plot_out}")
112
+
113
+ if args.diag_out:
114
+ fig = plot_time_rescaling_diagnostics(
115
+ days,
116
+ model,
117
+ config=DiagConfig(grid_points=args.grid_points),
118
+ save_path=args.diag_out,
119
+ )
120
+ plt.close(fig)
121
+ print(f"Saved diagnostics to {args.diag_out}")
122
+
123
+
124
+ def cmd_sweep_beta(args: argparse.Namespace) -> None:
125
+ from .utils import sweep_beta
126
+
127
+ days = _load_npz_days(args.input)
128
+ base_cfg = NHPPConfig(
129
+ delta=args.delta,
130
+ degree=args.degree,
131
+ n_basis=args.n_basis,
132
+ knot_strategy=args.knot_strategy,
133
+ min_seg_len=args.min_seg_len,
134
+ penalty_beta=None,
135
+ )
136
+ betas = _parse_betas(args.betas)
137
+ results = sweep_beta(days, base_cfg, betas)
138
+ # Print as JSON lines for easy piping
139
+ for beta, n_cp, total in results:
140
+ print(json.dumps({"beta": beta, "n_changepoints": n_cp, "total_cost": total}))
141
+
142
+
143
+ def cmd_sweep_gamma(args: argparse.Namespace) -> None:
144
+ from .model import NHPPConfig
145
+ from .regularization import sweep_gamma
146
+
147
+ days = _load_npz_days(args.input)
148
+ base_cfg = NHPPConfig(
149
+ delta=args.delta,
150
+ degree=args.degree,
151
+ n_basis=args.n_basis,
152
+ knot_strategy=args.knot_strategy,
153
+ min_seg_len=args.min_seg_len,
154
+ penalty_beta=(None if args.beta is None else float(args.beta)),
155
+ )
156
+ gammas = _parse_grid(args.gammas)
157
+ results = sweep_gamma(days, base_cfg, gammas, order=args.order)
158
+ for gamma, n_cp, total in results:
159
+ print(json.dumps({"gamma": gamma, "n_changepoints": n_cp, "total_cost": total}))
160
+
161
+
162
+ # --------------------------- CLI wiring ---------------------------
163
+
164
+
165
+ def build_parser() -> argparse.ArgumentParser:
166
+ p = argparse.ArgumentParser(
167
+ prog="nhpp",
168
+ description="NHPP segmentation with B-splines & PELT (fit/plot/diagnostics).",
169
+ )
170
+ sub = p.add_subparsers(dest="cmd", required=True)
171
+
172
+ # fit
173
+ pf = sub.add_parser("fit", help="Fit model to days and optionally save plots/JSON.")
174
+ pf.add_argument(
175
+ "--input", required=True, help="Path to .npz with object array 'days'."
176
+ )
177
+ pf.add_argument("--delta", type=float, default=24.0)
178
+ pf.add_argument("--degree", type=int, default=3)
179
+ pf.add_argument("--n-basis", type=int, default=5, dest="n_basis")
180
+ pf.add_argument(
181
+ "--knot-strategy", choices=["quantile", "equispaced"], default="quantile"
182
+ )
183
+ pf.add_argument("--min-seg-len", type=int, default=2, dest="min_seg_len")
184
+ pf.add_argument("--beta", type=float, default=None, help="If omitted, uses SIC.")
185
+ pf.add_argument("--grid-points", type=int, default=500)
186
+ pf.add_argument("--json-out", default=None, help="Save fitted state JSON here.")
187
+ pf.add_argument("--plot-out", default=None, help="Save segmentation plot PNG here.")
188
+ pf.add_argument(
189
+ "--diag-out", default=None, help="Save time-rescaling diagnostics PNG here."
190
+ )
191
+ pf.set_defaults(func=cmd_fit)
192
+
193
+ # sweep-beta
194
+ ps = sub.add_parser("sweep-beta", help="Evaluate different penalties (β).")
195
+ ps.add_argument(
196
+ "--input", required=True, help="Path to .npz with object array 'days'."
197
+ )
198
+ ps.add_argument("--delta", type=float, default=24.0)
199
+ ps.add_argument("--degree", type=int, default=3)
200
+ ps.add_argument("--n-basis", type=int, default=5, dest="n_basis")
201
+ ps.add_argument(
202
+ "--knot-strategy", choices=["quantile", "equispaced"], default="quantile"
203
+ )
204
+ ps.add_argument("--min-seg-len", type=int, default=2, dest="min_seg_len")
205
+ ps.add_argument("--betas", required=True, help="Comma list or start:stop:step.")
206
+ ps.set_defaults(func=cmd_sweep_beta)
207
+
208
+ # sweep-gamma
209
+ pg = sub.add_parser(
210
+ "sweep-gamma", help="Evaluate different P-spline strengths (γ)."
211
+ )
212
+ pg.add_argument(
213
+ "--input", required=True, help="Path to .npz with object array 'days'."
214
+ )
215
+ pg.add_argument("--delta", type=float, default=24.0)
216
+ pg.add_argument("--degree", type=int, default=3)
217
+ pg.add_argument("--n-basis", type=int, default=5, dest="n_basis")
218
+ pg.add_argument(
219
+ "--knot-strategy", choices=["quantile", "equispaced"], default="quantile"
220
+ )
221
+ pg.add_argument("--min-seg-len", type=int, default=2, dest="min_seg_len")
222
+ pg.add_argument("--beta", type=float, default=None, help="If omitted, uses SIC.")
223
+ pg.add_argument(
224
+ "--gammas",
225
+ required=True,
226
+ help="Comma list or start:stop:step, e.g. 0:0.1:0.005",
227
+ )
228
+ pg.add_argument(
229
+ "--order", type=int, default=2, help="Difference order (usually 2)."
230
+ )
231
+ pg.set_defaults(func=cmd_sweep_gamma)
232
+
233
+ return p
234
+
235
+
236
+ def main() -> None:
237
+ parser = build_parser()
238
+ args = parser.parse_args()
239
+ args.func(args)
240
+
241
+
242
+ if __name__ == "__main__":
243
+ main()
@@ -0,0 +1,234 @@
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
6
+
7
+ import matplotlib.pyplot as plt
8
+ import numpy as np
9
+
10
+ Array1D = np.ndarray
11
+
12
+
13
+ def _check(cond: bool, msg: str) -> None:
14
+ if not cond:
15
+ raise ValueError(msg)
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class DiagConfig:
20
+ """
21
+ Configuration for time-rescaling diagnostics.
22
+
23
+ Attributes
24
+ ----------
25
+ grid_points : int
26
+ Uniform grid resolution for λ(t) and Λ(t) approximation.
27
+ figsize : tuple[float, float]
28
+ Matplotlib figure size.
29
+ dpi : int
30
+ Figure DPI.
31
+ hist_bins : int
32
+ Number of bins for U-histogram (Uniform(0,1) check).
33
+ alpha : float
34
+ Transparency for histograms and fills.
35
+ """
36
+
37
+ grid_points: int = 2000
38
+ figsize: Tuple[float, float] = (12.0, 7.0)
39
+ dpi: int = 110
40
+ hist_bins: int = 20
41
+ alpha: float = 0.5
42
+
43
+
44
+ def _lambda_grid(
45
+ model: Any, seg_index: int, grid_points: int
46
+ ) -> Tuple[Array1D, Array1D]:
47
+ """Compute λ̂_k(t) on a uniform grid for segment k."""
48
+ delta: float = float(model.delta_)
49
+ grid = np.linspace(0.0, delta, grid_points)
50
+ lam = np.asarray(model.intensity_on_grid(seg_index, grid), float)
51
+ _check(lam.shape == grid.shape, "intensity_on_grid must match grid shape.")
52
+ return grid, lam
53
+
54
+
55
+ def _cumulative_int(grid: Array1D, lam: Array1D) -> Array1D:
56
+ """
57
+ Cumulative integral Λ(t) via the trapezoidal rule on a uniform grid.
58
+ Returns array of same length as grid with Λ(0)=0.
59
+ """
60
+ # Guard
61
+ _check(
62
+ grid.ndim == 1 and lam.ndim == 1 and grid.size == lam.size,
63
+ "grid/lam shape mismatch.",
64
+ )
65
+ d = np.diff(grid)
66
+ # trapezoid increments: ∫ lam ~ 0.5*(lam_i + lam_{i+1})*Δ
67
+ incr = 0.5 * (lam[:-1] + lam[1:]) * d
68
+ return np.r_[0.0, np.cumsum(incr)]
69
+
70
+
71
+ def _interp_cumint(t: Array1D, grid: Array1D, Lambda: Array1D) -> Array1D:
72
+ """
73
+ Interpolate Λ(t) for arbitrary event times using linear interpolation
74
+ over the precomputed (grid, Λ(grid)).
75
+ """
76
+ t = np.asarray(t, float)
77
+ t = np.clip(t, grid[0], grid[-1])
78
+ return np.interp(t, grid, Lambda)
79
+
80
+
81
+ def time_rescale_events_for_segment(
82
+ model: Any,
83
+ seg_index: int,
84
+ events_per_day: Sequence[Array1D],
85
+ grid_points: int = 2000,
86
+ ) -> Tuple[List[Array1D], List[Array1D]]:
87
+ """
88
+ Apply the time-rescaling theorem *within a fitted segment*:
89
+ ΔΛ_j should be i.i.d. Exp(1);
90
+ U_j = 1 - exp(-ΔΛ_j) should be i.i.d. Uniform(0,1).
91
+
92
+ Parameters
93
+ ----------
94
+ model : fitted NHPPPELT-like object
95
+ seg_index : int
96
+ Segment index in 0..K-1.
97
+ events_per_day : list of 1-D arrays
98
+ Only the days that belong to this segment.
99
+ grid_points : int
100
+ Resolution for Λ approximation.
101
+
102
+ Returns
103
+ -------
104
+ delta_L_list : list[np.ndarray]
105
+ For each day, the Exp(1)-should-be inter-arrival ΔΛ sequence.
106
+ U_list : list[np.ndarray]
107
+ Uniform(0,1)-should-be values 1 - exp(-ΔΛ).
108
+ """
109
+ grid, lam = _lambda_grid(model, seg_index, grid_points)
110
+ Lambda = _cumulative_int(grid, lam)
111
+
112
+ delta_L_list: List[Array1D] = []
113
+ U_list: List[Array1D] = []
114
+
115
+ for ev in events_per_day:
116
+ ev = np.asarray(ev, float)
117
+ if ev.size == 0:
118
+ delta_L_list.append(np.array([], float))
119
+ U_list.append(np.array([], float))
120
+ continue
121
+ # Rescaled event times Λ(t_j)
122
+ L_times = _interp_cumint(ev, grid, Lambda)
123
+ # Inter-arrival increments; include leading gap from 0 to first event
124
+ L_with0 = np.r_[0.0, L_times]
125
+ dL = np.diff(L_with0)
126
+ U = 1.0 - np.exp(-dL)
127
+ delta_L_list.append(dL)
128
+ U_list.append(U)
129
+
130
+ return delta_L_list, U_list
131
+
132
+
133
+ def ks_stat_uniform(u: Array1D) -> float:
134
+ """
135
+ Kolmogorov-Smirnov statistic for Uniform(0,1).
136
+ (Asymptotic p-value can be added if needed; we return only D.)
137
+ """
138
+ u = np.sort(np.asarray(u, float))
139
+ n = u.size
140
+ if n == 0:
141
+ return np.nan
142
+ # D+ = max_i i/n - u_i ; D- = max_i u_i - (i-1)/n
143
+ i = np.arange(1, n + 1, dtype=float)
144
+ d_plus = np.max(i / n - u)
145
+ d_minus = np.max(u - (i - 1.0) / n)
146
+ return float(max(d_plus, d_minus))
147
+
148
+
149
+ def plot_time_rescaling_diagnostics(
150
+ days: Sequence[Array1D],
151
+ model: Any,
152
+ config: DiagConfig = DiagConfig(),
153
+ *,
154
+ save_path: Optional[str] = None,
155
+ ) -> plt.Figure:
156
+ """
157
+ Build a 2-row diagnostics figure per segment:
158
+ Row 1 (left-to-right over segments): histogram of U with Uniform(0,1) reference line.
159
+ Row 2: exponential QQ plot for ΔΛ vs Exp(1) quantiles.
160
+
161
+ Notes
162
+ -----
163
+ - No external stats deps; uniform line and exponential quantiles are computed analytically.
164
+ - Uses Matplotlib defaults for colors.
165
+ """
166
+ _check(
167
+ hasattr(model, "segments_") and hasattr(model, "delta_"),
168
+ "Model missing required attributes.",
169
+ )
170
+ segments: List[Tuple[int, int]] = list(model.segments_)
171
+ n_segments = len(segments)
172
+ _check(n_segments >= 1, "No segments found.")
173
+
174
+ # Group events per segment
175
+ seg_days: List[List[Array1D]] = []
176
+ for i_start, i_end in segments:
177
+ seg_days.append([np.asarray(d, float) for d in days[i_start - 1 : i_end]])
178
+
179
+ # Precompute transformed values
180
+ all_U: List[Array1D] = []
181
+ all_dL: List[Array1D] = []
182
+ for k, evs in enumerate(seg_days):
183
+ dL_list, U_list = time_rescale_events_for_segment(
184
+ model, k, evs, grid_points=config.grid_points
185
+ )
186
+ all_U.append(np.concatenate(U_list) if U_list else np.array([], float))
187
+ all_dL.append(np.concatenate(dL_list) if dL_list else np.array([], float))
188
+
189
+ # Figure layout: 2 rows × K columns
190
+ fig = plt.figure(figsize=config.figsize, dpi=config.dpi)
191
+ gs = fig.add_gridspec(nrows=2, ncols=n_segments, hspace=0.35, wspace=0.2)
192
+
193
+ # Row 1: U histograms
194
+ for k in range(n_segments):
195
+ ax = fig.add_subplot(gs[0, k])
196
+ u = all_U[k]
197
+ # Histogram (counts normalized to density); no explicit color
198
+ ax.hist(
199
+ u, bins=config.hist_bins, range=(0.0, 1.0), density=True, alpha=config.alpha
200
+ )
201
+ # Uniform(0,1) reference density = 1 on [0,1]
202
+ ax.plot([0.0, 1.0], [1.0, 1.0]) # default color/style
203
+ D = ks_stat_uniform(u)
204
+ ax.set_title(f"Seg {k+1} — U histogram (KS D={D:.3f})", fontsize=10)
205
+ ax.set_xlim(0.0, 1.0)
206
+ ax.set_xlabel("U = 1 - exp(-ΔΛ)")
207
+ if k == 0:
208
+ ax.set_ylabel("Density")
209
+ ax.grid(True, linestyle="--", linewidth=0.5, alpha=0.5)
210
+
211
+ # Row 2: Exponential QQ (ΔΛ vs Exp(1))
212
+ for k in range(n_segments):
213
+ ax = fig.add_subplot(gs[1, k])
214
+ dL = np.sort(all_dL[k])
215
+ n = dL.size
216
+ if n >= 2:
217
+ # Theoretical Exp(1) quantiles for p_i = (i-0.5)/n
218
+ p = (np.arange(1, n + 1) - 0.5) / n
219
+ q = -np.log(1.0 - p)
220
+ ax.plot(q, dL, linestyle="None", marker="o", markersize=3, alpha=0.8)
221
+ # y=x reference
222
+ lim = float(max(q[-1], dL[-1])) if n > 0 else 1.0
223
+ ax.plot([0.0, lim], [0.0, lim])
224
+ ax.set_title(f"Seg {k+1} — QQ: ΔΛ vs Exp(1)", fontsize=10)
225
+ ax.set_xlabel("Theoretical Exp(1) quantiles")
226
+ if k == 0:
227
+ ax.set_ylabel("Observed ΔΛ")
228
+ ax.grid(True, linestyle="--", linewidth=0.5, alpha=0.5)
229
+
230
+ fig.suptitle("Time-rescaling diagnostics per segment", y=0.99, fontsize=12)
231
+ fig.tight_layout()
232
+ if save_path:
233
+ fig.savefig(save_path, dpi=config.dpi, bbox_inches="tight")
234
+ return fig
@@ -0,0 +1,58 @@
1
+ from __future__ import annotations
2
+
3
+ import csv
4
+ import glob
5
+ import os
6
+ from collections.abc import Sequence
7
+ from typing import List
8
+
9
+ import numpy as np
10
+
11
+ Array1D = np.ndarray
12
+
13
+
14
+ def load_days_from_npy_folder(folder: str) -> List[Array1D]:
15
+ """
16
+ Load days from a folder of .npy files.
17
+ Each .npy must contain a 1-D float array of event times in [0, delta].
18
+ Files are sorted by filename to define day order.
19
+ """
20
+ paths = sorted(glob.glob(os.path.join(folder, "*.npy")))
21
+ days: List[Array1D] = []
22
+ for p in paths:
23
+ arr = np.load(p)
24
+ if arr.ndim != 1:
25
+ raise ValueError(f"{p}: expected 1-D array.")
26
+ days.append(arr.astype(float, copy=False))
27
+ return days
28
+
29
+
30
+ def load_days_from_csv_folder(folder: str, column: str = "time") -> List[Array1D]:
31
+ """
32
+ Load days from a folder of .csv files (one file per day).
33
+ The CSV must have a header row including `column` with event times (floats).
34
+ Files are sorted by filename to define day order.
35
+ """
36
+ paths = sorted(glob.glob(os.path.join(folder, "*.csv")))
37
+ days: List[Array1D] = []
38
+ for p in paths:
39
+ vals: List[float] = []
40
+ with open(p, newline="") as f:
41
+ reader = csv.DictReader(f)
42
+ if reader.fieldnames is None or column not in reader.fieldnames:
43
+ raise ValueError(f"{p}: missing '{column}' column.")
44
+ for row in reader:
45
+ try:
46
+ vals.append(float(row[column]))
47
+ except (TypeError, ValueError):
48
+ continue # skip malformed rows
49
+ days.append(np.asarray(vals, float))
50
+ return days
51
+
52
+
53
+ def save_days_npz(days: Sequence[Array1D], path: str) -> None:
54
+ """
55
+ Save list of days to an .npz that works with nhpp.cli (--input).
56
+ """
57
+ obj = np.array([np.asarray(d, float) for d in days], dtype=object)
58
+ np.savez(path, days=obj)