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,188 @@
1
+ """Utilities for handling missing sensor data."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Any
7
+
8
+ import pandas as pd
9
+
10
+
11
+ @dataclass
12
+ class MissingDataResult:
13
+ """Container for imputed data and uncertainty metadata.
14
+
15
+ Attributes
16
+ ----------
17
+ data
18
+ Data after applying the selected missing-data strategy.
19
+ original_missing_mask
20
+ Boolean mask identifying cells that were missing in the input.
21
+ imputed_mask
22
+ Boolean mask identifying cells that were missing originally and were
23
+ filled by the chosen strategy.
24
+ remaining_missing_mask
25
+ Boolean mask identifying cells still missing after processing.
26
+ long_gap_mask
27
+ Boolean mask marking gaps longer than ``max_gap`` when provided.
28
+ summary
29
+ Per-column metadata about missingness, imputation, and longest gaps.
30
+ """
31
+
32
+ data: pd.DataFrame
33
+ original_missing_mask: pd.DataFrame
34
+ imputed_mask: pd.DataFrame
35
+ remaining_missing_mask: pd.DataFrame
36
+ long_gap_mask: pd.DataFrame
37
+ summary: dict[str, dict[str, Any]]
38
+
39
+
40
+ def _gap_lengths(mask: pd.Series) -> pd.Series:
41
+ """Return the length of each missing run for every position."""
42
+ lengths = pd.Series(0, index=mask.index, dtype=int)
43
+ run_length = 0
44
+ run_index: list[Any] = []
45
+ for idx, is_missing in mask.items():
46
+ if bool(is_missing):
47
+ run_length += 1
48
+ run_index.append(idx)
49
+ continue
50
+ if run_index:
51
+ lengths.loc[run_index] = run_length
52
+ run_length = 0
53
+ run_index = []
54
+ if run_index:
55
+ lengths.loc[run_index] = run_length
56
+ return lengths
57
+
58
+
59
+ def _long_gap_mask(df: pd.DataFrame, max_gap: int | None) -> pd.DataFrame:
60
+ """Return a boolean mask for missing runs exceeding ``max_gap``."""
61
+ if max_gap is None:
62
+ return pd.DataFrame(False, index=df.index, columns=df.columns)
63
+ mask = df.isna()
64
+ long_gap = pd.DataFrame(False, index=df.index, columns=df.columns)
65
+ for col in df.columns:
66
+ lengths = _gap_lengths(mask[col])
67
+ long_gap[col] = lengths > max_gap
68
+ return long_gap
69
+
70
+
71
+ def _apply_numeric_interpolation(df: pd.DataFrame, max_gap: int | None) -> pd.DataFrame:
72
+ """Interpolate numeric columns while leaving unsupported dtypes untouched."""
73
+ result = df.copy()
74
+ numeric_cols = result.select_dtypes(include="number").columns
75
+ if len(numeric_cols) > 0:
76
+ result.loc[:, numeric_cols] = result.loc[:, numeric_cols].interpolate(
77
+ method="linear",
78
+ limit=max_gap,
79
+ limit_area="inside",
80
+ )
81
+ return result
82
+
83
+
84
+ def _build_summary(
85
+ original_missing: pd.DataFrame,
86
+ imputed_mask: pd.DataFrame,
87
+ remaining_missing: pd.DataFrame,
88
+ ) -> dict[str, dict[str, Any]]:
89
+ """Summarize missingness and imputation per column."""
90
+ summary: dict[str, dict[str, Any]] = {}
91
+ for col in original_missing.columns:
92
+ gap_lengths = _gap_lengths(original_missing[col])
93
+ summary[col] = {
94
+ "original_missing": int(original_missing[col].sum()),
95
+ "imputed": int(imputed_mask[col].sum()),
96
+ "remaining_missing": int(remaining_missing[col].sum()),
97
+ "longest_gap": int(gap_lengths.max()) if len(gap_lengths) else 0,
98
+ "missing_ratio": float(original_missing[col].mean()),
99
+ }
100
+ return summary
101
+
102
+
103
+ def handle_missing_data(
104
+ df: pd.DataFrame,
105
+ strategy: str = "forward_fill",
106
+ *,
107
+ max_gap: int | None = None,
108
+ add_indicators: bool = False,
109
+ ) -> MissingDataResult:
110
+ """Apply a missing-data strategy and return imputation metadata.
111
+
112
+ Parameters
113
+ ----------
114
+ df
115
+ Sensor data indexed by time.
116
+ strategy
117
+ One of ``"forward_fill"``, ``"interpolate"``, ``"gap_aware"``,
118
+ ``"drop"``, or ``"flag"``.
119
+ max_gap
120
+ Maximum consecutive missing samples to fill. Longer gaps remain missing
121
+ for ``"forward_fill"``, ``"interpolate"``, and ``"gap_aware"``.
122
+ add_indicators
123
+ When ``True``, append ``"<column>_was_missing"`` boolean columns.
124
+ """
125
+
126
+ if max_gap is not None and max_gap < 1:
127
+ raise ValueError("max_gap must be at least 1 when provided")
128
+
129
+ original = df.copy()
130
+ original_missing = original.isna()
131
+ long_gap = _long_gap_mask(original, max_gap)
132
+ data = original.copy()
133
+
134
+ if strategy in {"forward_fill", "ffill"}:
135
+ data = data.ffill(limit=max_gap)
136
+ elif strategy == "interpolate":
137
+ data = _apply_numeric_interpolation(data, max_gap)
138
+ non_numeric = data.columns.difference(
139
+ data.select_dtypes(include="number").columns
140
+ )
141
+ if len(non_numeric) > 0:
142
+ data.loc[:, non_numeric] = data.loc[:, non_numeric].ffill(limit=max_gap)
143
+ elif strategy == "gap_aware":
144
+ data = _apply_numeric_interpolation(data, max_gap)
145
+ data = data.ffill(limit=max_gap).bfill(limit=max_gap)
146
+ data = data.mask(long_gap)
147
+ elif strategy == "drop":
148
+ data = data.dropna()
149
+ elif strategy == "flag":
150
+ pass
151
+ else:
152
+ raise ValueError(f"Unsupported missing-data strategy: {strategy}")
153
+
154
+ if add_indicators:
155
+ for col in original.columns:
156
+ data[f"{col}_was_missing"] = original_missing[col]
157
+
158
+ if strategy == "drop":
159
+ kept_missing = original_missing.loc[data.index]
160
+ imputed_mask = pd.DataFrame(False, index=data.index, columns=original.columns)
161
+ remaining_missing = data[original.columns].isna()
162
+ long_gap = long_gap.loc[data.index]
163
+ summary = _build_summary(kept_missing, imputed_mask, remaining_missing)
164
+ else:
165
+ imputed_mask = original_missing & ~data[original.columns].isna()
166
+ remaining_missing = data[original.columns].isna()
167
+ summary = _build_summary(original_missing, imputed_mask, remaining_missing)
168
+
169
+ return MissingDataResult(
170
+ data=data,
171
+ original_missing_mask=original_missing
172
+ if strategy != "drop"
173
+ else original_missing.loc[data.index],
174
+ imputed_mask=imputed_mask,
175
+ remaining_missing_mask=remaining_missing,
176
+ long_gap_mask=long_gap,
177
+ summary=summary,
178
+ )
179
+
180
+
181
+ def forward_fill(df: pd.DataFrame) -> pd.DataFrame:
182
+ """Forward-fill missing values in a DataFrame."""
183
+ return handle_missing_data(df, strategy="forward_fill").data
184
+
185
+
186
+ def interpolate_linear(df: pd.DataFrame) -> pd.DataFrame:
187
+ """Linearly interpolate interior missing values in numeric columns."""
188
+ return handle_missing_data(df, strategy="interpolate").data
@@ -0,0 +1,98 @@
1
+ """Plotting helpers for sensor modeling."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+
7
+ import matplotlib.pyplot as plt
8
+ import numpy as np
9
+ import pandas as pd
10
+ from matplotlib.figure import Figure
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ def plot_quantile_intervals(
16
+ actual_counts: np.ndarray,
17
+ quantile_info: dict,
18
+ title: str = "Model Validation",
19
+ sensor_name: str = "",
20
+ show: bool = True,
21
+ ) -> Figure:
22
+ """Plot observed counts against predicted quantile intervals."""
23
+ time_of_day = np.arange(len(actual_counts)) * 15 / 60.0
24
+
25
+ fig, ax = plt.subplots(figsize=(12, 6))
26
+ ax.fill_between(
27
+ time_of_day,
28
+ quantile_info["lower_quantiles"],
29
+ quantile_info["upper_quantiles"],
30
+ alpha=0.3,
31
+ color="blue",
32
+ label="95% Prediction Interval",
33
+ )
34
+ ax.plot(time_of_day, actual_counts, "r-", linewidth=2, label="Observed Events")
35
+ ax.plot(time_of_day, quantile_info["means"], "k--", label="Predicted Mean")
36
+ ax.set_xlabel("Hour of Day")
37
+ ax.set_ylabel("Event Count")
38
+ ax.set_title(title or f"Model Validation for {sensor_name}")
39
+ ax.legend()
40
+ ax.grid(alpha=0.3)
41
+ fig.tight_layout()
42
+ logger.info("Displayed validation plot for sensor %s", sensor_name)
43
+ if show:
44
+ plt.show()
45
+ return fig
46
+
47
+
48
+ def plot_sensor_activity_patterns(data: pd.DataFrame, show: bool = True) -> Figure:
49
+ """Plot average sensor activation patterns across the day."""
50
+ df = data.copy()
51
+ df["hour"] = df.index.hour
52
+ means = df.groupby("hour")[data.columns].mean()
53
+ ax = means.plot(figsize=(10, 6))
54
+ fig = ax.figure
55
+ ax.set_xlabel("Hour of Day")
56
+ ax.set_ylabel("Activation Probability")
57
+ ax.set_title("Sensor Daily Activity Patterns")
58
+ ax.grid(alpha=0.3)
59
+ fig.tight_layout()
60
+ logger.info("Plotted sensor activity patterns")
61
+ if show:
62
+ plt.show()
63
+ return fig
64
+
65
+
66
+ def plot_change_points(
67
+ series: np.ndarray,
68
+ change_points: np.ndarray,
69
+ title: str = "Change Points",
70
+ show: bool = True,
71
+ ) -> Figure:
72
+ """Plot time series with vertical lines at detected change points."""
73
+ fig, ax = plt.subplots(figsize=(10, 4))
74
+ ax.plot(series, label="series")
75
+ for cp in change_points:
76
+ ax.axvline(cp, color="red", linestyle="--", alpha=0.7)
77
+ ax.set_title(title)
78
+ ax.legend()
79
+ fig.tight_layout()
80
+ logger.info("Plotted %d change points", len(change_points))
81
+ if show:
82
+ plt.show()
83
+ return fig
84
+
85
+
86
+ def plot_benchmark_results(results: dict[str, float], show: bool = True) -> Figure:
87
+ """Bar chart of benchmark times for different algorithms."""
88
+ names = list(results.keys())
89
+ times = list(results.values())
90
+ fig, ax = plt.subplots(figsize=(8, 4))
91
+ ax.bar(names, times, color="skyblue")
92
+ ax.set_ylabel("Seconds")
93
+ ax.set_title("CPD Benchmark Runtime")
94
+ fig.tight_layout()
95
+ logger.info("Plotted benchmark results for %s", names)
96
+ if show:
97
+ plt.show()
98
+ return fig
@@ -0,0 +1,117 @@
1
+ """Validation utilities for sensor models."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from collections.abc import Mapping
7
+ from typing import Protocol
8
+
9
+ import numpy as np
10
+ import pandas as pd
11
+
12
+ from .data_io import SensorDataset
13
+ from .plotting import plot_quantile_intervals
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ class SupportsPredictionIntervals(Protocol):
19
+ """Model interface required by prediction validation."""
20
+
21
+ def predict_probabilities(self, data: pd.DataFrame) -> np.ndarray:
22
+ """Return predicted event probabilities."""
23
+ ...
24
+
25
+ def compute_quantile_intervals(
26
+ self, probabilities: np.ndarray, confidence: float
27
+ ) -> Mapping[str, np.ndarray]:
28
+ """Return lower, upper, and mean prediction intervals."""
29
+ ...
30
+
31
+
32
+ def validate_model_predictions(
33
+ model: SupportsPredictionIntervals,
34
+ test_data: pd.DataFrame | SensorDataset,
35
+ sensor_name: str,
36
+ confidence: float = 0.95,
37
+ *,
38
+ plot: bool = True,
39
+ ) -> dict[str, object]:
40
+ """Validate model predictions using quantile coverage metrics."""
41
+ if not 0 < confidence < 1:
42
+ raise ValueError("confidence must be between 0 and 1")
43
+
44
+ df = test_data.to_dataframe() if isinstance(test_data, SensorDataset) else test_data
45
+ if sensor_name not in df.columns:
46
+ raise KeyError(f"Sensor '{sensor_name}' not found in test data")
47
+
48
+ probabilities = model.predict_probabilities(df)
49
+ quantile_info = model.compute_quantile_intervals(probabilities, confidence)
50
+ if not quantile_info:
51
+ return {
52
+ "validation_successful": False,
53
+ "reason": "Insufficient data for validation",
54
+ }
55
+
56
+ test_array = df[sensor_name].values
57
+ n_days = len(test_array) // 96
58
+ if n_days == 0:
59
+ return {"validation_successful": False, "reason": "Insufficient test data"}
60
+ test_reshaped = test_array[: n_days * 96].reshape(n_days, 96)
61
+ actual_counts = np.sum(test_reshaped, axis=0)
62
+
63
+ outside_count = np.sum(
64
+ (actual_counts < quantile_info["lower_quantiles"])
65
+ | (actual_counts > quantile_info["upper_quantiles"])
66
+ )
67
+ outside_percentage = (outside_count / 96) * 100
68
+ expected_outside = (1 - confidence) * 100
69
+ mse = np.mean((actual_counts - quantile_info["means"]) ** 2)
70
+ coverage_accuracy = 100 - abs(outside_percentage - expected_outside)
71
+
72
+ if plot:
73
+ plot_quantile_intervals(actual_counts, quantile_info, sensor_name=sensor_name)
74
+
75
+ return {
76
+ "validation_successful": True,
77
+ "outside_interval_count": int(outside_count),
78
+ "outside_interval_percentage": outside_percentage,
79
+ "expected_outside_percentage": expected_outside,
80
+ "coverage_accuracy": coverage_accuracy,
81
+ "mean_squared_error": mse,
82
+ "actual_counts": actual_counts,
83
+ "quantile_info": quantile_info,
84
+ "is_well_calibrated": bool(abs(outside_percentage - expected_outside) < 2.5),
85
+ }
86
+
87
+
88
+ def create_model_comparison_report(results: Mapping[str, object]) -> str:
89
+ """Create a formatted comparison report for multiple modeling approaches."""
90
+ report = ["MODEL COMPARISON REPORT", "=" * 40, "\nBIC COMPARISON:"]
91
+ bic_scores: dict[str, float] = {}
92
+ for approach, data in results.items():
93
+ if isinstance(data, Mapping) and isinstance(data.get("total_bic"), int | float):
94
+ total_bic = float(data["total_bic"])
95
+ report.append(f"{approach.replace('_', ' ').title()}: {total_bic:.2f}")
96
+ bic_scores[approach] = total_bic
97
+
98
+ improvements = results.get("improvements")
99
+ if isinstance(improvements, Mapping):
100
+ report.append("\nIMPROVEMENTS (Lower BIC is better):")
101
+ for comparison, improvement in improvements.items():
102
+ if not isinstance(improvement, int | float):
103
+ continue
104
+ improvement_value = float(improvement)
105
+ direction = "better" if improvement_value > 0 else "worse"
106
+ report.append(
107
+ f"{comparison.replace('_', ' ').title()}: {improvement_value:.2f} ({direction})"
108
+ )
109
+
110
+ if bic_scores:
111
+ best_approach = min(bic_scores, key=bic_scores.get)
112
+ report.append(
113
+ f"\nBest performing approach: {best_approach.replace('_', ' ').title()}"
114
+ )
115
+ report.append(f"BIC score: {bic_scores[best_approach]:.2f}")
116
+
117
+ return "\n".join(report)
@@ -0,0 +1,3 @@
1
+ """Visualization utilities for sensor modeling."""
2
+
3
+ __all__ = ["interactive", "clinical", "research", "web_app"]
@@ -0,0 +1,67 @@
1
+ """Healthcare focused visualizations and summaries."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Collection
6
+ from typing import Dict
7
+
8
+ import pandas as pd
9
+ import plotly.express as px
10
+
11
+
12
+ def _require_columns(
13
+ data: pd.DataFrame, required: Collection[str], context: str
14
+ ) -> None:
15
+ """Raise a clear error when required visualization columns are missing."""
16
+ missing = sorted(set(required) - set(data.columns))
17
+ if missing:
18
+ names = ", ".join(missing)
19
+ raise ValueError(f"{context} requires columns: {names}")
20
+
21
+
22
+ def activity_summary(data: pd.DataFrame) -> px.bar:
23
+ """Return a patient-friendly bar chart summarizing activity levels."""
24
+ _require_columns(data, {"activity"}, "activity_summary")
25
+ summary = data.groupby("activity").size().reset_index(name="count")
26
+ return px.bar(summary, x="activity", y="count")
27
+
28
+
29
+ def clinical_alerts(
30
+ data: pd.DataFrame, thresholds: Dict[str, float]
31
+ ) -> Dict[str, bool]:
32
+ """Flag sensors that exceed clinical thresholds."""
33
+ _require_columns(data, {"sensor", "value"}, "clinical_alerts")
34
+ alerts = {}
35
+ for sensor, thresh in thresholds.items():
36
+ alerts[sensor] = bool((data[data["sensor"] == sensor]["value"] > thresh).any())
37
+ return alerts
38
+
39
+
40
+ def trend_monitor(data: pd.DataFrame, window: int = 7) -> px.line:
41
+ """Plot rolling averages over weeks or months."""
42
+ _require_columns(data, {"sensor", "timestamp", "value"}, "trend_monitor")
43
+ if window < 1:
44
+ raise ValueError("window must be at least 1")
45
+
46
+ rolled = (
47
+ data.set_index("timestamp")
48
+ .groupby("sensor")["value"]
49
+ .rolling(window)
50
+ .mean()
51
+ .reset_index()
52
+ )
53
+ return px.line(rolled, x="timestamp", y="value", color="sensor")
54
+
55
+
56
+ def compare_norms(data: pd.DataFrame, normative: pd.DataFrame) -> px.line:
57
+ """Compare patient data against normative statistics."""
58
+ required = {"sensor", "timestamp", "value"}
59
+ _require_columns(data, required, "compare_norms patient data")
60
+ _require_columns(normative, required, "compare_norms normative data")
61
+ merged = pd.merge(
62
+ data, normative, on=["timestamp", "sensor"], suffixes=("_patient", "_norm")
63
+ )
64
+ fig = px.line(
65
+ merged, x="timestamp", y=["value_patient", "value_norm"], color="sensor"
66
+ )
67
+ return fig
@@ -0,0 +1,208 @@
1
+ """Interactive dashboards using Plotly and Bokeh.
2
+
3
+ This module exposes helpers that make it easy to explore sensor data in
4
+ real time, tune model parameters, drill into detected changes, and export
5
+ figures for presentations. The functions are intentionally lightweight so
6
+ that they can run in environments without a full web server.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from collections.abc import Collection, Iterable, Sequence
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ import numpy as np
16
+ import pandas as pd
17
+ import plotly.express as px
18
+ from bokeh.embed import file_html
19
+ from bokeh.layouts import column
20
+ from bokeh.models import ColumnDataSource, CustomJS, Div, HoverTool, Slider
21
+ from bokeh.plotting import figure
22
+ from bokeh.resources import CDN
23
+
24
+
25
+ def _require_columns(
26
+ data: pd.DataFrame, required: Collection[str], context: str
27
+ ) -> None:
28
+ """Raise a clear error when required visualization columns are missing."""
29
+ missing = sorted(set(required) - set(data.columns))
30
+ if missing:
31
+ names = ", ".join(missing)
32
+ raise ValueError(f"{context} requires columns: {names}")
33
+
34
+
35
+ def real_time_display(data: pd.DataFrame) -> px.line:
36
+ """Create a Plotly line figure for streaming sensor data.
37
+
38
+ Parameters
39
+ ----------
40
+ data:
41
+ DataFrame with at least ``timestamp``, ``sensor`` and ``value`` columns.
42
+ """
43
+ _require_columns(data, {"sensor", "timestamp", "value"}, "real_time_display")
44
+ return px.line(data, x="timestamp", y="value", color="sensor")
45
+
46
+
47
+ def _score_parameter(model: Any, param: str, value: float) -> float:
48
+ """Return a diagnostic score for ``param=value`` using model hooks."""
49
+ if hasattr(model, "score_parameter"):
50
+ scorer = getattr(model, "score_parameter")
51
+ try:
52
+ return float(scorer(param, value))
53
+ except TypeError:
54
+ return float(scorer(value))
55
+ if hasattr(model, "evaluate_parameter"):
56
+ evaluator = getattr(model, "evaluate_parameter")
57
+ try:
58
+ return float(evaluator(param, value))
59
+ except TypeError:
60
+ return float(evaluator(value))
61
+ raise ValueError(
62
+ "parameter_tuning requires model.score_parameter(...) or "
63
+ "model.evaluate_parameter(...) to compute diagnostics"
64
+ )
65
+
66
+
67
+ def _build_parameter_sweep(
68
+ model: Any, param: str, values: Sequence[float]
69
+ ) -> tuple[list[float], list[float]]:
70
+ """Evaluate the model over candidate parameter values."""
71
+ ordered_values = sorted({float(v) for v in values})
72
+ if not ordered_values:
73
+ raise ValueError("values must contain at least one candidate")
74
+ if not np.isfinite(ordered_values).all():
75
+ raise ValueError("values must contain only finite candidates")
76
+
77
+ scores = [_score_parameter(model, param, value) for value in ordered_values]
78
+ if not np.isfinite(scores).all():
79
+ raise ValueError("parameter scores must be finite")
80
+ return ordered_values, scores
81
+
82
+
83
+ def parameter_tuning(model: Any, param: str, values: Iterable[float]):
84
+ """Return a Bokeh layout for parameter diagnostics.
85
+
86
+ The model must expose either ``score_parameter(param, value)`` or
87
+ ``evaluate_parameter(param, value)``. The returned view shows a score curve
88
+ across candidate values and updates the highlighted selection plus summary
89
+ text when the slider moves. In Python-backed Bokeh sessions, the callback
90
+ also writes the chosen value back to ``model.<param>``.
91
+ """
92
+
93
+ param_values, scores = _build_parameter_sweep(model, param, list(values))
94
+ current_value = float(getattr(model, param, param_values[0]))
95
+ if current_value not in param_values:
96
+ current_value = param_values[0]
97
+ current_idx = param_values.index(current_value)
98
+ best_idx = max(range(len(scores)), key=lambda idx: scores[idx])
99
+
100
+ curve_source = ColumnDataSource(
101
+ {
102
+ "x": param_values,
103
+ "y": scores,
104
+ "label": [f"{value:.4g}" for value in param_values],
105
+ }
106
+ )
107
+ selected_source = ColumnDataSource(
108
+ {"x": [param_values[current_idx]], "y": [scores[current_idx]]}
109
+ )
110
+
111
+ plot = figure(
112
+ height=320,
113
+ sizing_mode="stretch_width",
114
+ title=f"{param} diagnostic sweep",
115
+ x_axis_label=param,
116
+ y_axis_label="score",
117
+ tools="pan,wheel_zoom,box_zoom,reset,save",
118
+ )
119
+ plot.line("x", "y", source=curve_source, line_width=2, color="#1f77b4")
120
+ plot.scatter("x", "y", source=curve_source, size=8, color="#1f77b4", alpha=0.85)
121
+ plot.scatter(
122
+ "x",
123
+ "y",
124
+ source=selected_source,
125
+ size=14,
126
+ color="#d62728",
127
+ line_color="white",
128
+ line_width=2,
129
+ )
130
+ plot.add_tools(
131
+ HoverTool(
132
+ tooltips=[(param, "@label"), ("score", "@y{0.000}")],
133
+ renderers=plot.renderers[:2],
134
+ )
135
+ )
136
+
137
+ summary = Div(
138
+ text=(
139
+ f"<b>{param}</b>: {param_values[current_idx]:.4g} | "
140
+ f"<b>score</b>: {scores[current_idx]:.4f} | "
141
+ f"<b>best</b>: {param_values[best_idx]:.4g} "
142
+ f"({scores[best_idx]:.4f})"
143
+ ),
144
+ sizing_mode="stretch_width",
145
+ )
146
+
147
+ slider = Slider(
148
+ start=0,
149
+ end=len(param_values) - 1,
150
+ step=1,
151
+ value=current_idx,
152
+ title=f"{param}: {param_values[current_idx]:.4g}",
153
+ )
154
+
155
+ def _update(attr: str, old: int, new: int) -> None: # pragma: no cover
156
+ value = param_values[int(new)]
157
+ setattr(model, param, value)
158
+
159
+ slider.on_change("value", _update)
160
+ slider.js_on_change(
161
+ "value",
162
+ CustomJS(
163
+ args=dict(
164
+ slider=slider,
165
+ selected=selected_source,
166
+ summary=summary,
167
+ values=param_values,
168
+ scores=scores,
169
+ param=param,
170
+ best_value=param_values[best_idx],
171
+ best_score=scores[best_idx],
172
+ ),
173
+ code="""
174
+ const idx = slider.value;
175
+ const value = values[idx];
176
+ const score = scores[idx];
177
+ selected.data = {x: [value], y: [score]};
178
+ selected.change.emit();
179
+ slider.title = `${param}: ${value.toPrecision(4)}`;
180
+ summary.text =
181
+ `<b>${param}</b>: ${value.toPrecision(4)} | <b>score</b>: ${score.toFixed(4)} | ` +
182
+ `<b>best</b>: ${best_value.toPrecision(4)} (${best_score.toFixed(4)})`;
183
+ """,
184
+ ),
185
+ )
186
+ return column(slider, summary, plot, sizing_mode="stretch_width")
187
+
188
+
189
+ def drill_down(changes: pd.DataFrame) -> px.scatter:
190
+ """Plot detected changes with interactive hover information."""
191
+ _require_columns(changes, {"score", "time"}, "drill_down")
192
+ return px.scatter(changes, x="time", y="score")
193
+
194
+
195
+ def export(fig: Any, path: str) -> None:
196
+ """Export a Plotly or Bokeh figure to an HTML file."""
197
+ output_path = Path(path)
198
+ output_path.parent.mkdir(parents=True, exist_ok=True)
199
+ if hasattr(fig, "write_html"):
200
+ fig.write_html(output_path)
201
+ elif fig.__class__.__module__.startswith("bokeh"):
202
+ html = file_html(fig, CDN, "export")
203
+ with open(
204
+ output_path, "w", encoding="utf-8"
205
+ ) as fh: # pragma: no cover - simple file write
206
+ fh.write(html)
207
+ else: # pragma: no cover - defensive fallback
208
+ raise TypeError("Unsupported figure type")