diagnostics-framework 0.1.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.
- diagnostics_framework/__init__.py +8 -0
- diagnostics_framework/app.py +185 -0
- diagnostics_framework/models.py +71 -0
- diagnostics_framework/registry.py +79 -0
- diagnostics_framework/runner.py +57 -0
- diagnostics_framework/systems/__init__.py +9 -0
- diagnostics_framework/systems/generic_example.py +201 -0
- diagnostics_framework/systems/sensor_monitoring.py +321 -0
- diagnostics_framework-0.1.0.dist-info/METADATA +162 -0
- diagnostics_framework-0.1.0.dist-info/RECORD +14 -0
- diagnostics_framework-0.1.0.dist-info/WHEEL +5 -0
- diagnostics_framework-0.1.0.dist-info/entry_points.txt +2 -0
- diagnostics_framework-0.1.0.dist-info/licenses/LICENSE +21 -0
- diagnostics_framework-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
2
|
+
|
|
3
|
+
from diagnostics_framework.registry import register_system, register_test, register_plot, register_report, registry
|
|
4
|
+
from diagnostics_framework.models import DiagnosticResult, DiagnosticStatus, DiagnosticSummary
|
|
5
|
+
from diagnostics_framework.runner import run_diagnostics, generate_plot, generate_report
|
|
6
|
+
|
|
7
|
+
# Auto-discover and register all system plugins
|
|
8
|
+
import diagnostics_framework.systems # noqa: F401
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import io
|
|
2
|
+
import json
|
|
3
|
+
|
|
4
|
+
import pandas as pd
|
|
5
|
+
import streamlit as st
|
|
6
|
+
|
|
7
|
+
# Import systems to trigger decorator registration
|
|
8
|
+
import diagnostics_framework.systems # noqa: F401
|
|
9
|
+
|
|
10
|
+
from diagnostics_framework.models import DiagnosticStatus
|
|
11
|
+
from diagnostics_framework.registry import registry
|
|
12
|
+
from diagnostics_framework.runner import run_diagnostics, generate_plot, generate_report
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
STATUS_COLORS = {
|
|
16
|
+
DiagnosticStatus.PASS: "#28a745",
|
|
17
|
+
DiagnosticStatus.FAIL: "#dc3545",
|
|
18
|
+
DiagnosticStatus.WARNING: "#ffc107",
|
|
19
|
+
DiagnosticStatus.ERROR: "#6c757d",
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
STATUS_ICONS = {
|
|
23
|
+
DiagnosticStatus.PASS: "PASS",
|
|
24
|
+
DiagnosticStatus.FAIL: "FAIL",
|
|
25
|
+
DiagnosticStatus.WARNING: "WARN",
|
|
26
|
+
DiagnosticStatus.ERROR: "ERR",
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def load_data(uploaded_file) -> pd.DataFrame | dict | None:
|
|
31
|
+
"""Attempt to load an uploaded file as a DataFrame or dict."""
|
|
32
|
+
if uploaded_file is None:
|
|
33
|
+
return None
|
|
34
|
+
|
|
35
|
+
name = uploaded_file.name.lower()
|
|
36
|
+
try:
|
|
37
|
+
if name.endswith(".csv"):
|
|
38
|
+
return pd.read_csv(uploaded_file)
|
|
39
|
+
elif name.endswith(".json"):
|
|
40
|
+
content = json.load(uploaded_file)
|
|
41
|
+
if isinstance(content, list) and all(isinstance(r, dict) for r in content):
|
|
42
|
+
return pd.DataFrame(content)
|
|
43
|
+
return content
|
|
44
|
+
elif name.endswith((".xls", ".xlsx")):
|
|
45
|
+
return pd.read_excel(uploaded_file)
|
|
46
|
+
elif name.endswith(".parquet"):
|
|
47
|
+
return pd.read_parquet(io.BytesIO(uploaded_file.read()))
|
|
48
|
+
else:
|
|
49
|
+
return uploaded_file.read().decode("utf-8", errors="replace")
|
|
50
|
+
except Exception as e:
|
|
51
|
+
st.error(f"Failed to load file: {e}")
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def render_results(summary):
|
|
56
|
+
"""Render diagnostic results as a styled table."""
|
|
57
|
+
st.subheader("Diagnostic Results")
|
|
58
|
+
|
|
59
|
+
cols = st.columns(4)
|
|
60
|
+
cols[0].metric("Total", len(summary.results))
|
|
61
|
+
cols[1].metric("Pass", summary.pass_count)
|
|
62
|
+
cols[2].metric("Fail", summary.fail_count)
|
|
63
|
+
cols[3].metric("Warn / Error", summary.warning_count + summary.error_count)
|
|
64
|
+
|
|
65
|
+
for result in summary.results:
|
|
66
|
+
color = STATUS_COLORS[result.status]
|
|
67
|
+
icon = STATUS_ICONS[result.status]
|
|
68
|
+
with st.container():
|
|
69
|
+
st.markdown(
|
|
70
|
+
f"<div style='border-left: 4px solid {color}; padding: 8px 12px; margin: 4px 0;'>"
|
|
71
|
+
f"<strong>[{icon}]</strong> <strong>{result.test_name}</strong><br/>"
|
|
72
|
+
f"{result.message}"
|
|
73
|
+
f"</div>",
|
|
74
|
+
unsafe_allow_html=True,
|
|
75
|
+
)
|
|
76
|
+
if result.details:
|
|
77
|
+
with st.expander("Details"):
|
|
78
|
+
st.json(result.details)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def render_plots(system_name, data):
|
|
82
|
+
"""Render plot buttons and display generated plots."""
|
|
83
|
+
st.subheader("Plots")
|
|
84
|
+
plots = registry.get_plots(system_name)
|
|
85
|
+
if not plots:
|
|
86
|
+
st.info("No plots registered for this system.")
|
|
87
|
+
return
|
|
88
|
+
|
|
89
|
+
for plot_info in plots:
|
|
90
|
+
with st.expander(f"{plot_info.name} — {plot_info.description}"):
|
|
91
|
+
try:
|
|
92
|
+
fig = generate_plot(system_name, plot_info.name, data)
|
|
93
|
+
st.pyplot(fig)
|
|
94
|
+
except Exception as e:
|
|
95
|
+
st.error(f"Error generating plot: {e}")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def render_reports(system_name, data):
|
|
99
|
+
"""Render report buttons and display generated reports."""
|
|
100
|
+
st.subheader("Reports")
|
|
101
|
+
reports = registry.get_reports(system_name)
|
|
102
|
+
if not reports:
|
|
103
|
+
st.info("No reports registered for this system.")
|
|
104
|
+
return
|
|
105
|
+
|
|
106
|
+
for report_info in reports:
|
|
107
|
+
with st.expander(f"{report_info.name} — {report_info.description}"):
|
|
108
|
+
try:
|
|
109
|
+
report_text = generate_report(system_name, report_info.name, data)
|
|
110
|
+
st.markdown(report_text)
|
|
111
|
+
except Exception as e:
|
|
112
|
+
st.error(f"Error generating report: {e}")
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def main():
|
|
116
|
+
st.set_page_config(page_title="Diagnostics Dashboard", layout="wide")
|
|
117
|
+
st.title("Diagnostics Dashboard")
|
|
118
|
+
|
|
119
|
+
# --- Sidebar ---
|
|
120
|
+
with st.sidebar:
|
|
121
|
+
st.header("Configuration")
|
|
122
|
+
|
|
123
|
+
systems = registry.get_systems()
|
|
124
|
+
if not systems:
|
|
125
|
+
st.warning("No systems registered. Add a system module to diagnostics_framework/systems/.")
|
|
126
|
+
return
|
|
127
|
+
|
|
128
|
+
system_names = list(systems.keys())
|
|
129
|
+
selected = st.selectbox(
|
|
130
|
+
"Select System",
|
|
131
|
+
system_names,
|
|
132
|
+
format_func=lambda s: f"{s} — {systems[s].description}" if systems[s].description else s,
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
st.markdown("---")
|
|
136
|
+
st.subheader("Upload Data")
|
|
137
|
+
uploaded_file = st.file_uploader(
|
|
138
|
+
"Choose a file",
|
|
139
|
+
type=["csv", "json", "xlsx", "xls", "parquet", "txt"],
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
run_button = st.button("Run Diagnostics", type="primary", use_container_width=True)
|
|
143
|
+
|
|
144
|
+
# --- Main area ---
|
|
145
|
+
if uploaded_file is not None:
|
|
146
|
+
data = load_data(uploaded_file)
|
|
147
|
+
if data is None:
|
|
148
|
+
return
|
|
149
|
+
|
|
150
|
+
if isinstance(data, pd.DataFrame):
|
|
151
|
+
with st.expander("Preview uploaded data"):
|
|
152
|
+
st.dataframe(data.head(50))
|
|
153
|
+
|
|
154
|
+
if run_button:
|
|
155
|
+
with st.spinner("Running diagnostics..."):
|
|
156
|
+
summary = run_diagnostics(selected, data)
|
|
157
|
+
st.session_state["last_summary"] = summary
|
|
158
|
+
st.session_state["last_data"] = data
|
|
159
|
+
st.session_state["last_system"] = selected
|
|
160
|
+
|
|
161
|
+
if "last_summary" in st.session_state and st.session_state.get("last_system") == selected:
|
|
162
|
+
tab_results, tab_plots, tab_reports = st.tabs(["Results", "Plots", "Reports"])
|
|
163
|
+
with tab_results:
|
|
164
|
+
render_results(st.session_state["last_summary"])
|
|
165
|
+
with tab_plots:
|
|
166
|
+
render_plots(selected, st.session_state.get("last_data", data))
|
|
167
|
+
with tab_reports:
|
|
168
|
+
render_reports(selected, st.session_state.get("last_data", data))
|
|
169
|
+
else:
|
|
170
|
+
st.info("Upload a data file in the sidebar and click **Run Diagnostics** to begin.")
|
|
171
|
+
|
|
172
|
+
# Show registered info for the selected system
|
|
173
|
+
if "selected" not in dir():
|
|
174
|
+
return
|
|
175
|
+
st.markdown(f"### System: {selected}")
|
|
176
|
+
tests = registry.get_tests(selected)
|
|
177
|
+
plots = registry.get_plots(selected)
|
|
178
|
+
reports = registry.get_reports(selected)
|
|
179
|
+
st.markdown(f"- **{len(tests)}** diagnostic test(s) registered")
|
|
180
|
+
st.markdown(f"- **{len(plots)}** plot(s) registered")
|
|
181
|
+
st.markdown(f"- **{len(reports)}** report(s) registered")
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
if __name__ == "__main__":
|
|
185
|
+
main()
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
from enum import Enum
|
|
4
|
+
from typing import Any, Callable
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class DiagnosticStatus(Enum):
|
|
8
|
+
PASS = "pass"
|
|
9
|
+
FAIL = "fail"
|
|
10
|
+
WARNING = "warning"
|
|
11
|
+
ERROR = "error"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class DiagnosticResult:
|
|
16
|
+
test_name: str
|
|
17
|
+
status: DiagnosticStatus
|
|
18
|
+
message: str
|
|
19
|
+
details: dict[str, Any] = field(default_factory=dict)
|
|
20
|
+
timestamp: datetime = field(default_factory=datetime.now)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class SystemInfo:
|
|
25
|
+
name: str
|
|
26
|
+
description: str = ""
|
|
27
|
+
version: str = "0.1.0"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class TestInfo:
|
|
32
|
+
name: str
|
|
33
|
+
description: str
|
|
34
|
+
fn: Callable
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class PlotInfo:
|
|
39
|
+
name: str
|
|
40
|
+
description: str
|
|
41
|
+
fn: Callable
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class ReportInfo:
|
|
46
|
+
name: str
|
|
47
|
+
description: str
|
|
48
|
+
fn: Callable
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass
|
|
52
|
+
class DiagnosticSummary:
|
|
53
|
+
system_name: str
|
|
54
|
+
results: list[DiagnosticResult]
|
|
55
|
+
timestamp: datetime = field(default_factory=datetime.now)
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def pass_count(self) -> int:
|
|
59
|
+
return sum(1 for r in self.results if r.status == DiagnosticStatus.PASS)
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def fail_count(self) -> int:
|
|
63
|
+
return sum(1 for r in self.results if r.status == DiagnosticStatus.FAIL)
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def warning_count(self) -> int:
|
|
67
|
+
return sum(1 for r in self.results if r.status == DiagnosticStatus.WARNING)
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def error_count(self) -> int:
|
|
71
|
+
return sum(1 for r in self.results if r.status == DiagnosticStatus.ERROR)
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
from diagnostics_framework.models import SystemInfo, TestInfo, PlotInfo, ReportInfo
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class DiagnosticsRegistry:
|
|
5
|
+
"""Singleton registry for systems, tests, plots, and reports."""
|
|
6
|
+
|
|
7
|
+
_instance = None
|
|
8
|
+
|
|
9
|
+
def __new__(cls):
|
|
10
|
+
if cls._instance is None:
|
|
11
|
+
cls._instance = super().__new__(cls)
|
|
12
|
+
cls._instance._systems = {}
|
|
13
|
+
cls._instance._tests = {}
|
|
14
|
+
cls._instance._plots = {}
|
|
15
|
+
cls._instance._reports = {}
|
|
16
|
+
return cls._instance
|
|
17
|
+
|
|
18
|
+
def add_system(self, name: str, description: str = "", version: str = "0.1.0"):
|
|
19
|
+
self._systems[name] = SystemInfo(name=name, description=description, version=version)
|
|
20
|
+
self._tests.setdefault(name, [])
|
|
21
|
+
self._plots.setdefault(name, [])
|
|
22
|
+
self._reports.setdefault(name, [])
|
|
23
|
+
|
|
24
|
+
def add_test(self, system: str, test_info: TestInfo):
|
|
25
|
+
self._tests.setdefault(system, []).append(test_info)
|
|
26
|
+
|
|
27
|
+
def add_plot(self, system: str, plot_info: PlotInfo):
|
|
28
|
+
self._plots.setdefault(system, []).append(plot_info)
|
|
29
|
+
|
|
30
|
+
def add_report(self, system: str, report_info: ReportInfo):
|
|
31
|
+
self._reports.setdefault(system, []).append(report_info)
|
|
32
|
+
|
|
33
|
+
def get_systems(self) -> dict[str, SystemInfo]:
|
|
34
|
+
return dict(self._systems)
|
|
35
|
+
|
|
36
|
+
def get_tests(self, system: str) -> list[TestInfo]:
|
|
37
|
+
return list(self._tests.get(system, []))
|
|
38
|
+
|
|
39
|
+
def get_plots(self, system: str) -> list[PlotInfo]:
|
|
40
|
+
return list(self._plots.get(system, []))
|
|
41
|
+
|
|
42
|
+
def get_reports(self, system: str) -> list[ReportInfo]:
|
|
43
|
+
return list(self._reports.get(system, []))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# Module-level singleton
|
|
47
|
+
registry = DiagnosticsRegistry()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def register_system(name: str, description: str = "", version: str = "0.1.0"):
|
|
51
|
+
"""Decorator that registers a system. Apply to any function or class (it's returned unchanged)."""
|
|
52
|
+
def decorator(obj):
|
|
53
|
+
registry.add_system(name, description, version)
|
|
54
|
+
return obj
|
|
55
|
+
return decorator
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def register_test(system: str, name: str, description: str = ""):
|
|
59
|
+
"""Decorator that registers a diagnostic test function for a system."""
|
|
60
|
+
def decorator(fn):
|
|
61
|
+
registry.add_test(system, TestInfo(name=name, description=description, fn=fn))
|
|
62
|
+
return fn
|
|
63
|
+
return decorator
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def register_plot(system: str, name: str, description: str = ""):
|
|
67
|
+
"""Decorator that registers a plot function for a system."""
|
|
68
|
+
def decorator(fn):
|
|
69
|
+
registry.add_plot(system, PlotInfo(name=name, description=description, fn=fn))
|
|
70
|
+
return fn
|
|
71
|
+
return decorator
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def register_report(system: str, name: str, description: str = ""):
|
|
75
|
+
"""Decorator that registers a report function for a system."""
|
|
76
|
+
def decorator(fn):
|
|
77
|
+
registry.add_report(system, ReportInfo(name=name, description=description, fn=fn))
|
|
78
|
+
return fn
|
|
79
|
+
return decorator
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import traceback
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
import matplotlib.figure
|
|
6
|
+
|
|
7
|
+
from diagnostics_framework.models import DiagnosticResult, DiagnosticStatus, DiagnosticSummary
|
|
8
|
+
from diagnostics_framework.registry import registry
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def run_diagnostics(system_name: str, data: Any) -> DiagnosticSummary:
|
|
12
|
+
"""Run all registered diagnostic tests for a system against the provided data."""
|
|
13
|
+
tests = registry.get_tests(system_name)
|
|
14
|
+
results = []
|
|
15
|
+
|
|
16
|
+
for test_info in tests:
|
|
17
|
+
try:
|
|
18
|
+
result = test_info.fn(data)
|
|
19
|
+
if isinstance(result, DiagnosticResult):
|
|
20
|
+
results.append(result)
|
|
21
|
+
else:
|
|
22
|
+
results.append(DiagnosticResult(
|
|
23
|
+
test_name=test_info.name,
|
|
24
|
+
status=DiagnosticStatus.ERROR,
|
|
25
|
+
message=f"Test '{test_info.name}' did not return a DiagnosticResult.",
|
|
26
|
+
))
|
|
27
|
+
except Exception as e:
|
|
28
|
+
results.append(DiagnosticResult(
|
|
29
|
+
test_name=test_info.name,
|
|
30
|
+
status=DiagnosticStatus.ERROR,
|
|
31
|
+
message=f"Test raised an exception: {e}",
|
|
32
|
+
details={"traceback": traceback.format_exc()},
|
|
33
|
+
))
|
|
34
|
+
|
|
35
|
+
return DiagnosticSummary(
|
|
36
|
+
system_name=system_name,
|
|
37
|
+
results=results,
|
|
38
|
+
timestamp=datetime.now(),
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def generate_plot(system_name: str, plot_name: str, data: Any) -> matplotlib.figure.Figure:
|
|
43
|
+
"""Generate a plot by name for a system. Returns a matplotlib Figure."""
|
|
44
|
+
plots = registry.get_plots(system_name)
|
|
45
|
+
for plot_info in plots:
|
|
46
|
+
if plot_info.name == plot_name:
|
|
47
|
+
return plot_info.fn(data)
|
|
48
|
+
raise ValueError(f"Plot '{plot_name}' not found for system '{system_name}'.")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def generate_report(system_name: str, report_name: str, data: Any) -> str:
|
|
52
|
+
"""Generate a report by name for a system. Returns a string (plain text or markdown)."""
|
|
53
|
+
reports = registry.get_reports(system_name)
|
|
54
|
+
for report_info in reports:
|
|
55
|
+
if report_info.name == report_name:
|
|
56
|
+
return report_info.fn(data)
|
|
57
|
+
raise ValueError(f"Report '{report_name}' not found for system '{system_name}'.")
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""Auto-discover and import all system modules so their decorators register on import."""
|
|
2
|
+
import importlib
|
|
3
|
+
import pkgutil
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
_package_dir = Path(__file__).parent
|
|
7
|
+
|
|
8
|
+
for _finder, _name, _ispkg in pkgutil.iter_modules([str(_package_dir)]):
|
|
9
|
+
importlib.import_module(f"{__name__}.{_name}")
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Generic Example System
|
|
3
|
+
======================
|
|
4
|
+
A template system demonstrating the diagnostics framework plugin pattern.
|
|
5
|
+
Copy this file and modify it to create diagnostics for your own system.
|
|
6
|
+
"""
|
|
7
|
+
import matplotlib.pyplot as plt
|
|
8
|
+
import numpy as np
|
|
9
|
+
import pandas as pd
|
|
10
|
+
import seaborn as sns
|
|
11
|
+
|
|
12
|
+
from diagnostics_framework.models import DiagnosticResult, DiagnosticStatus
|
|
13
|
+
from diagnostics_framework.registry import register_system, register_test, register_plot, register_report
|
|
14
|
+
|
|
15
|
+
SYSTEM_NAME = "generic_example"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@register_system(SYSTEM_NAME, description="Generic example system for tabular data", version="0.1.0")
|
|
19
|
+
class GenericExampleSystem:
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# ---------------------------------------------------------------------------
|
|
24
|
+
# Diagnostic Tests
|
|
25
|
+
# ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
@register_test(SYSTEM_NAME, name="check_not_empty", description="Verify the data is not empty")
|
|
28
|
+
def check_not_empty(data) -> DiagnosticResult:
|
|
29
|
+
if isinstance(data, pd.DataFrame):
|
|
30
|
+
is_empty = data.empty
|
|
31
|
+
size = len(data)
|
|
32
|
+
elif isinstance(data, (list, dict)):
|
|
33
|
+
is_empty = len(data) == 0
|
|
34
|
+
size = len(data)
|
|
35
|
+
else:
|
|
36
|
+
is_empty = data is None
|
|
37
|
+
size = 0 if data is None else 1
|
|
38
|
+
|
|
39
|
+
if is_empty:
|
|
40
|
+
return DiagnosticResult(
|
|
41
|
+
test_name="check_not_empty",
|
|
42
|
+
status=DiagnosticStatus.FAIL,
|
|
43
|
+
message="Data is empty.",
|
|
44
|
+
)
|
|
45
|
+
return DiagnosticResult(
|
|
46
|
+
test_name="check_not_empty",
|
|
47
|
+
status=DiagnosticStatus.PASS,
|
|
48
|
+
message=f"Data has {size} records.",
|
|
49
|
+
details={"record_count": size},
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@register_test(SYSTEM_NAME, name="check_no_nulls", description="Check for null or missing values")
|
|
54
|
+
def check_no_nulls(data) -> DiagnosticResult:
|
|
55
|
+
if not isinstance(data, pd.DataFrame):
|
|
56
|
+
return DiagnosticResult(
|
|
57
|
+
test_name="check_no_nulls",
|
|
58
|
+
status=DiagnosticStatus.WARNING,
|
|
59
|
+
message="Null check only supported for DataFrame input. Skipped.",
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
null_counts = data.isnull().sum()
|
|
63
|
+
total_nulls = int(null_counts.sum())
|
|
64
|
+
cols_with_nulls = {col: int(count) for col, count in null_counts.items() if count > 0}
|
|
65
|
+
|
|
66
|
+
if total_nulls == 0:
|
|
67
|
+
return DiagnosticResult(
|
|
68
|
+
test_name="check_no_nulls",
|
|
69
|
+
status=DiagnosticStatus.PASS,
|
|
70
|
+
message="No null values found.",
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
return DiagnosticResult(
|
|
74
|
+
test_name="check_no_nulls",
|
|
75
|
+
status=DiagnosticStatus.WARNING if total_nulls < len(data) else DiagnosticStatus.FAIL,
|
|
76
|
+
message=f"Found {total_nulls} null value(s) across {len(cols_with_nulls)} column(s).",
|
|
77
|
+
details={"columns_with_nulls": cols_with_nulls},
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@register_test(SYSTEM_NAME, name="check_numeric_ranges", description="Validate numeric columns have finite values")
|
|
82
|
+
def check_numeric_ranges(data) -> DiagnosticResult:
|
|
83
|
+
if not isinstance(data, pd.DataFrame):
|
|
84
|
+
return DiagnosticResult(
|
|
85
|
+
test_name="check_numeric_ranges",
|
|
86
|
+
status=DiagnosticStatus.WARNING,
|
|
87
|
+
message="Range check only supported for DataFrame input. Skipped.",
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
numeric_cols = data.select_dtypes(include="number").columns.tolist()
|
|
91
|
+
if not numeric_cols:
|
|
92
|
+
return DiagnosticResult(
|
|
93
|
+
test_name="check_numeric_ranges",
|
|
94
|
+
status=DiagnosticStatus.WARNING,
|
|
95
|
+
message="No numeric columns found to check.",
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
issues = {}
|
|
99
|
+
for col in numeric_cols:
|
|
100
|
+
inf_count = int(np.isinf(data[col].dropna()).sum())
|
|
101
|
+
if inf_count > 0:
|
|
102
|
+
issues[col] = {"infinite_values": inf_count}
|
|
103
|
+
|
|
104
|
+
if issues:
|
|
105
|
+
return DiagnosticResult(
|
|
106
|
+
test_name="check_numeric_ranges",
|
|
107
|
+
status=DiagnosticStatus.FAIL,
|
|
108
|
+
message=f"Found infinite values in {len(issues)} column(s).",
|
|
109
|
+
details={"columns_with_issues": issues},
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
return DiagnosticResult(
|
|
113
|
+
test_name="check_numeric_ranges",
|
|
114
|
+
status=DiagnosticStatus.PASS,
|
|
115
|
+
message=f"All {len(numeric_cols)} numeric column(s) have finite values.",
|
|
116
|
+
details={"numeric_columns": numeric_cols},
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
# ---------------------------------------------------------------------------
|
|
121
|
+
# Plots
|
|
122
|
+
# ---------------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
@register_plot(SYSTEM_NAME, name="data_overview", description="Overview histogram of numeric columns")
|
|
125
|
+
def data_overview_plot(data) -> plt.Figure:
|
|
126
|
+
if not isinstance(data, pd.DataFrame):
|
|
127
|
+
fig, ax = plt.subplots()
|
|
128
|
+
ax.text(0.5, 0.5, "Plot requires DataFrame input", ha="center", va="center")
|
|
129
|
+
return fig
|
|
130
|
+
|
|
131
|
+
numeric_cols = data.select_dtypes(include="number").columns.tolist()
|
|
132
|
+
if not numeric_cols:
|
|
133
|
+
fig, ax = plt.subplots()
|
|
134
|
+
ax.text(0.5, 0.5, "No numeric columns to plot", ha="center", va="center")
|
|
135
|
+
return fig
|
|
136
|
+
|
|
137
|
+
n_cols = len(numeric_cols)
|
|
138
|
+
fig, axes = plt.subplots(1, n_cols, figsize=(4 * n_cols, 4), squeeze=False)
|
|
139
|
+
for i, col in enumerate(numeric_cols):
|
|
140
|
+
axes[0][i].hist(data[col].dropna(), bins=20, edgecolor="black", alpha=0.7)
|
|
141
|
+
axes[0][i].set_title(col)
|
|
142
|
+
axes[0][i].set_xlabel("Value")
|
|
143
|
+
axes[0][i].set_ylabel("Count")
|
|
144
|
+
fig.suptitle("Numeric Column Distributions", fontsize=14)
|
|
145
|
+
fig.tight_layout()
|
|
146
|
+
return fig
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@register_plot(SYSTEM_NAME, name="null_heatmap", description="Heatmap showing location of null values")
|
|
150
|
+
def null_heatmap(data) -> plt.Figure:
|
|
151
|
+
if not isinstance(data, pd.DataFrame):
|
|
152
|
+
fig, ax = plt.subplots()
|
|
153
|
+
ax.text(0.5, 0.5, "Plot requires DataFrame input", ha="center", va="center")
|
|
154
|
+
return fig
|
|
155
|
+
|
|
156
|
+
fig, ax = plt.subplots(figsize=(max(6, len(data.columns)), max(4, len(data) * 0.05)))
|
|
157
|
+
sns.heatmap(data.isnull().astype(int), cbar=False, cmap="YlOrRd", ax=ax, yticklabels=False)
|
|
158
|
+
ax.set_title("Null Values (yellow = present, red = null)")
|
|
159
|
+
fig.tight_layout()
|
|
160
|
+
return fig
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
# ---------------------------------------------------------------------------
|
|
164
|
+
# Reports
|
|
165
|
+
# ---------------------------------------------------------------------------
|
|
166
|
+
|
|
167
|
+
@register_report(SYSTEM_NAME, name="summary_report", description="Text summary of the dataset")
|
|
168
|
+
def summary_report(data) -> str:
|
|
169
|
+
if not isinstance(data, pd.DataFrame):
|
|
170
|
+
return f"Data type: {type(data).__name__}\nCannot generate detailed summary for non-DataFrame data."
|
|
171
|
+
|
|
172
|
+
lines = [
|
|
173
|
+
"# Data Summary Report",
|
|
174
|
+
"",
|
|
175
|
+
f"**Rows:** {len(data)}",
|
|
176
|
+
f"**Columns:** {len(data.columns)}",
|
|
177
|
+
"",
|
|
178
|
+
"## Column Types",
|
|
179
|
+
]
|
|
180
|
+
for col in data.columns:
|
|
181
|
+
lines.append(f"- **{col}**: {data[col].dtype}")
|
|
182
|
+
|
|
183
|
+
lines.append("")
|
|
184
|
+
lines.append("## Null Counts")
|
|
185
|
+
null_counts = data.isnull().sum()
|
|
186
|
+
for col, count in null_counts.items():
|
|
187
|
+
if count > 0:
|
|
188
|
+
lines.append(f"- **{col}**: {count} nulls ({count / len(data) * 100:.1f}%)")
|
|
189
|
+
if null_counts.sum() == 0:
|
|
190
|
+
lines.append("- No null values found.")
|
|
191
|
+
|
|
192
|
+
numeric_cols = data.select_dtypes(include="number")
|
|
193
|
+
if not numeric_cols.empty:
|
|
194
|
+
lines.append("")
|
|
195
|
+
lines.append("## Numeric Summary")
|
|
196
|
+
for col in numeric_cols.columns:
|
|
197
|
+
series = numeric_cols[col].dropna()
|
|
198
|
+
lines.append(f"- **{col}**: min={series.min():.4g}, max={series.max():.4g}, "
|
|
199
|
+
f"mean={series.mean():.4g}, std={series.std():.4g}")
|
|
200
|
+
|
|
201
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Sensor Monitoring System
|
|
3
|
+
========================
|
|
4
|
+
Diagnostics for IoT/environmental sensor data with time series,
|
|
5
|
+
battery health, and anomaly detection checks.
|
|
6
|
+
"""
|
|
7
|
+
import matplotlib.pyplot as plt
|
|
8
|
+
import numpy as np
|
|
9
|
+
import pandas as pd
|
|
10
|
+
import seaborn as sns
|
|
11
|
+
|
|
12
|
+
from diagnostics_framework.models import DiagnosticResult, DiagnosticStatus
|
|
13
|
+
from diagnostics_framework.registry import register_system, register_test, register_plot, register_report
|
|
14
|
+
|
|
15
|
+
SYSTEM_NAME = "sensor_monitoring"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@register_system(SYSTEM_NAME, description="IoT sensor monitoring diagnostics", version="0.1.0")
|
|
19
|
+
class SensorMonitoringSystem:
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# ---------------------------------------------------------------------------
|
|
24
|
+
# Diagnostic Tests
|
|
25
|
+
# ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
@register_test(SYSTEM_NAME, name="check_not_empty", description="Verify sensor data is not empty")
|
|
28
|
+
def check_not_empty(data) -> DiagnosticResult:
|
|
29
|
+
if not isinstance(data, pd.DataFrame) or data.empty:
|
|
30
|
+
return DiagnosticResult(
|
|
31
|
+
test_name="check_not_empty",
|
|
32
|
+
status=DiagnosticStatus.FAIL,
|
|
33
|
+
message="No data found.",
|
|
34
|
+
)
|
|
35
|
+
return DiagnosticResult(
|
|
36
|
+
test_name="check_not_empty",
|
|
37
|
+
status=DiagnosticStatus.PASS,
|
|
38
|
+
message=f"Dataset has {len(data)} rows and {len(data.columns)} columns.",
|
|
39
|
+
details={"rows": len(data), "columns": list(data.columns)},
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@register_test(SYSTEM_NAME, name="check_missing_readings", description="Check for missing sensor readings")
|
|
44
|
+
def check_missing_readings(data) -> DiagnosticResult:
|
|
45
|
+
if not isinstance(data, pd.DataFrame):
|
|
46
|
+
return DiagnosticResult(test_name="check_missing_readings", status=DiagnosticStatus.WARNING, message="Skipped: not a DataFrame.")
|
|
47
|
+
|
|
48
|
+
null_counts = data.isnull().sum()
|
|
49
|
+
total_nulls = int(null_counts.sum())
|
|
50
|
+
total_cells = int(data.size)
|
|
51
|
+
pct = total_nulls / total_cells * 100 if total_cells > 0 else 0
|
|
52
|
+
cols_with_nulls = {col: int(c) for col, c in null_counts.items() if c > 0}
|
|
53
|
+
|
|
54
|
+
if total_nulls == 0:
|
|
55
|
+
return DiagnosticResult(test_name="check_missing_readings", status=DiagnosticStatus.PASS, message="No missing readings.")
|
|
56
|
+
|
|
57
|
+
status = DiagnosticStatus.WARNING if pct < 5 else DiagnosticStatus.FAIL
|
|
58
|
+
return DiagnosticResult(
|
|
59
|
+
test_name="check_missing_readings",
|
|
60
|
+
status=status,
|
|
61
|
+
message=f"{total_nulls} missing values ({pct:.1f}% of all cells) across {len(cols_with_nulls)} column(s).",
|
|
62
|
+
details={"missing_by_column": cols_with_nulls, "total_missing": total_nulls, "percent_missing": round(pct, 2)},
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@register_test(SYSTEM_NAME, name="check_battery_health", description="Flag sensors with low battery levels")
|
|
67
|
+
def check_battery_health(data) -> DiagnosticResult:
|
|
68
|
+
if not isinstance(data, pd.DataFrame) or "battery_level" not in data.columns:
|
|
69
|
+
return DiagnosticResult(test_name="check_battery_health", status=DiagnosticStatus.WARNING, message="No battery_level column found.")
|
|
70
|
+
|
|
71
|
+
low_threshold = 20.0
|
|
72
|
+
critical_threshold = 10.0
|
|
73
|
+
battery = data[["sensor_id", "battery_level"]].dropna() if "sensor_id" in data.columns else data[["battery_level"]].dropna()
|
|
74
|
+
|
|
75
|
+
if "sensor_id" in data.columns:
|
|
76
|
+
latest = battery.groupby("sensor_id")["battery_level"].last()
|
|
77
|
+
critical = latest[latest < critical_threshold].to_dict()
|
|
78
|
+
low = latest[(latest >= critical_threshold) & (latest < low_threshold)].to_dict()
|
|
79
|
+
else:
|
|
80
|
+
last_val = float(battery["battery_level"].iloc[-1])
|
|
81
|
+
critical = {"unknown": last_val} if last_val < critical_threshold else {}
|
|
82
|
+
low = {"unknown": last_val} if critical_threshold <= last_val < low_threshold else {}
|
|
83
|
+
|
|
84
|
+
if critical:
|
|
85
|
+
return DiagnosticResult(
|
|
86
|
+
test_name="check_battery_health",
|
|
87
|
+
status=DiagnosticStatus.FAIL,
|
|
88
|
+
message=f"{len(critical)} sensor(s) at CRITICAL battery level (<{critical_threshold}%).",
|
|
89
|
+
details={"critical_sensors": critical, "low_sensors": low},
|
|
90
|
+
)
|
|
91
|
+
if low:
|
|
92
|
+
return DiagnosticResult(
|
|
93
|
+
test_name="check_battery_health",
|
|
94
|
+
status=DiagnosticStatus.WARNING,
|
|
95
|
+
message=f"{len(low)} sensor(s) with low battery (<{low_threshold}%).",
|
|
96
|
+
details={"low_sensors": low},
|
|
97
|
+
)
|
|
98
|
+
return DiagnosticResult(
|
|
99
|
+
test_name="check_battery_health",
|
|
100
|
+
status=DiagnosticStatus.PASS,
|
|
101
|
+
message="All sensors have healthy battery levels.",
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@register_test(SYSTEM_NAME, name="check_temperature_range", description="Validate temperature readings are within expected range")
|
|
106
|
+
def check_temperature_range(data) -> DiagnosticResult:
|
|
107
|
+
if not isinstance(data, pd.DataFrame) or "temperature" not in data.columns:
|
|
108
|
+
return DiagnosticResult(test_name="check_temperature_range", status=DiagnosticStatus.WARNING, message="No temperature column found.")
|
|
109
|
+
|
|
110
|
+
temp = data["temperature"].dropna()
|
|
111
|
+
min_expected, max_expected = -10.0, 50.0
|
|
112
|
+
out_of_range = temp[(temp < min_expected) | (temp > max_expected)]
|
|
113
|
+
|
|
114
|
+
if len(out_of_range) > 0:
|
|
115
|
+
return DiagnosticResult(
|
|
116
|
+
test_name="check_temperature_range",
|
|
117
|
+
status=DiagnosticStatus.FAIL,
|
|
118
|
+
message=f"{len(out_of_range)} readings outside expected range [{min_expected}, {max_expected}].",
|
|
119
|
+
details={"out_of_range_count": len(out_of_range), "min_observed": float(temp.min()), "max_observed": float(temp.max())},
|
|
120
|
+
)
|
|
121
|
+
return DiagnosticResult(
|
|
122
|
+
test_name="check_temperature_range",
|
|
123
|
+
status=DiagnosticStatus.PASS,
|
|
124
|
+
message=f"All {len(temp)} temperature readings within [{min_expected}, {max_expected}]. Range: {temp.min():.1f} to {temp.max():.1f}.",
|
|
125
|
+
details={"min": float(temp.min()), "max": float(temp.max()), "mean": float(temp.mean())},
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@register_test(SYSTEM_NAME, name="check_sensor_status", description="Check for sensors in warning or critical status")
|
|
130
|
+
def check_sensor_status(data) -> DiagnosticResult:
|
|
131
|
+
if not isinstance(data, pd.DataFrame) or "status" not in data.columns:
|
|
132
|
+
return DiagnosticResult(test_name="check_sensor_status", status=DiagnosticStatus.WARNING, message="No status column found.")
|
|
133
|
+
|
|
134
|
+
status_counts = data["status"].value_counts().to_dict()
|
|
135
|
+
critical_count = status_counts.get("critical", 0)
|
|
136
|
+
warning_count = status_counts.get("warning", 0)
|
|
137
|
+
|
|
138
|
+
if critical_count > 0:
|
|
139
|
+
return DiagnosticResult(
|
|
140
|
+
test_name="check_sensor_status",
|
|
141
|
+
status=DiagnosticStatus.FAIL,
|
|
142
|
+
message=f"{critical_count} readings in 'critical' status, {warning_count} in 'warning'.",
|
|
143
|
+
details={"status_breakdown": status_counts},
|
|
144
|
+
)
|
|
145
|
+
if warning_count > 0:
|
|
146
|
+
return DiagnosticResult(
|
|
147
|
+
test_name="check_sensor_status",
|
|
148
|
+
status=DiagnosticStatus.WARNING,
|
|
149
|
+
message=f"{warning_count} readings in 'warning' status.",
|
|
150
|
+
details={"status_breakdown": status_counts},
|
|
151
|
+
)
|
|
152
|
+
return DiagnosticResult(
|
|
153
|
+
test_name="check_sensor_status",
|
|
154
|
+
status=DiagnosticStatus.PASS,
|
|
155
|
+
message="All sensors reporting normal status.",
|
|
156
|
+
details={"status_breakdown": status_counts},
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
# ---------------------------------------------------------------------------
|
|
161
|
+
# Plots
|
|
162
|
+
# ---------------------------------------------------------------------------
|
|
163
|
+
|
|
164
|
+
@register_plot(SYSTEM_NAME, name="temperature_timeseries", description="Temperature over time per sensor")
|
|
165
|
+
def temperature_timeseries(data) -> plt.Figure:
|
|
166
|
+
if not isinstance(data, pd.DataFrame) or "temperature" not in data.columns:
|
|
167
|
+
fig, ax = plt.subplots()
|
|
168
|
+
ax.text(0.5, 0.5, "Requires 'temperature' column", ha="center", va="center")
|
|
169
|
+
return fig
|
|
170
|
+
|
|
171
|
+
fig, ax = plt.subplots(figsize=(12, 5))
|
|
172
|
+
if "sensor_id" in data.columns and "timestamp" in data.columns:
|
|
173
|
+
for sensor_id, group in data.groupby("sensor_id"):
|
|
174
|
+
ts = pd.to_datetime(group["timestamp"], errors="coerce")
|
|
175
|
+
ax.plot(ts, group["temperature"], marker="o", markersize=3, label=sensor_id)
|
|
176
|
+
ax.legend(title="Sensor")
|
|
177
|
+
ax.set_xlabel("Time")
|
|
178
|
+
else:
|
|
179
|
+
ax.plot(data.index, data["temperature"], marker="o", markersize=3)
|
|
180
|
+
ax.set_xlabel("Index")
|
|
181
|
+
ax.set_ylabel("Temperature")
|
|
182
|
+
ax.set_title("Temperature Over Time")
|
|
183
|
+
ax.grid(True, alpha=0.3)
|
|
184
|
+
fig.autofmt_xdate()
|
|
185
|
+
fig.tight_layout()
|
|
186
|
+
return fig
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
@register_plot(SYSTEM_NAME, name="battery_levels", description="Battery level per sensor over time")
|
|
190
|
+
def battery_levels(data) -> plt.Figure:
|
|
191
|
+
if not isinstance(data, pd.DataFrame) or "battery_level" not in data.columns:
|
|
192
|
+
fig, ax = plt.subplots()
|
|
193
|
+
ax.text(0.5, 0.5, "Requires 'battery_level' column", ha="center", va="center")
|
|
194
|
+
return fig
|
|
195
|
+
|
|
196
|
+
fig, ax = plt.subplots(figsize=(12, 5))
|
|
197
|
+
if "sensor_id" in data.columns and "timestamp" in data.columns:
|
|
198
|
+
for sensor_id, group in data.groupby("sensor_id"):
|
|
199
|
+
ts = pd.to_datetime(group["timestamp"], errors="coerce")
|
|
200
|
+
ax.plot(ts, group["battery_level"], marker="o", markersize=3, label=sensor_id)
|
|
201
|
+
ax.legend(title="Sensor")
|
|
202
|
+
ax.set_xlabel("Time")
|
|
203
|
+
else:
|
|
204
|
+
ax.plot(data.index, data["battery_level"], marker="o", markersize=3)
|
|
205
|
+
ax.set_xlabel("Index")
|
|
206
|
+
|
|
207
|
+
ax.axhline(y=20, color="orange", linestyle="--", alpha=0.7, label="Low threshold (20%)")
|
|
208
|
+
ax.axhline(y=10, color="red", linestyle="--", alpha=0.7, label="Critical threshold (10%)")
|
|
209
|
+
ax.set_ylabel("Battery Level (%)")
|
|
210
|
+
ax.set_title("Battery Level Over Time")
|
|
211
|
+
ax.legend(title="Sensor")
|
|
212
|
+
ax.grid(True, alpha=0.3)
|
|
213
|
+
fig.autofmt_xdate()
|
|
214
|
+
fig.tight_layout()
|
|
215
|
+
return fig
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
@register_plot(SYSTEM_NAME, name="correlation_heatmap", description="Correlation matrix of numeric columns")
|
|
219
|
+
def correlation_heatmap(data) -> plt.Figure:
|
|
220
|
+
if not isinstance(data, pd.DataFrame):
|
|
221
|
+
fig, ax = plt.subplots()
|
|
222
|
+
ax.text(0.5, 0.5, "Requires DataFrame input", ha="center", va="center")
|
|
223
|
+
return fig
|
|
224
|
+
|
|
225
|
+
numeric = data.select_dtypes(include="number")
|
|
226
|
+
if numeric.empty:
|
|
227
|
+
fig, ax = plt.subplots()
|
|
228
|
+
ax.text(0.5, 0.5, "No numeric columns", ha="center", va="center")
|
|
229
|
+
return fig
|
|
230
|
+
|
|
231
|
+
fig, ax = plt.subplots(figsize=(8, 6))
|
|
232
|
+
corr = numeric.corr()
|
|
233
|
+
sns.heatmap(corr, annot=True, fmt=".2f", cmap="RdBu_r", center=0, ax=ax, square=True)
|
|
234
|
+
ax.set_title("Correlation Matrix")
|
|
235
|
+
fig.tight_layout()
|
|
236
|
+
return fig
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
@register_plot(SYSTEM_NAME, name="sensor_status_breakdown", description="Pie chart of sensor status counts")
|
|
240
|
+
def sensor_status_breakdown(data) -> plt.Figure:
|
|
241
|
+
if not isinstance(data, pd.DataFrame) or "status" not in data.columns:
|
|
242
|
+
fig, ax = plt.subplots()
|
|
243
|
+
ax.text(0.5, 0.5, "Requires 'status' column", ha="center", va="center")
|
|
244
|
+
return fig
|
|
245
|
+
|
|
246
|
+
status_counts = data["status"].value_counts()
|
|
247
|
+
colors = {"active": "#28a745", "warning": "#ffc107", "critical": "#dc3545", "inactive": "#6c757d"}
|
|
248
|
+
pie_colors = [colors.get(s, "#999999") for s in status_counts.index]
|
|
249
|
+
|
|
250
|
+
fig, ax = plt.subplots(figsize=(6, 6))
|
|
251
|
+
ax.pie(status_counts.values, labels=status_counts.index, autopct="%1.0f%%", colors=pie_colors, startangle=90)
|
|
252
|
+
ax.set_title("Sensor Status Breakdown")
|
|
253
|
+
fig.tight_layout()
|
|
254
|
+
return fig
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
# ---------------------------------------------------------------------------
|
|
258
|
+
# Reports
|
|
259
|
+
# ---------------------------------------------------------------------------
|
|
260
|
+
|
|
261
|
+
@register_report(SYSTEM_NAME, name="sensor_health_report", description="Full health report across all sensors")
|
|
262
|
+
def sensor_health_report(data) -> str:
|
|
263
|
+
if not isinstance(data, pd.DataFrame):
|
|
264
|
+
return "Report requires DataFrame input."
|
|
265
|
+
|
|
266
|
+
lines = ["# Sensor Health Report", ""]
|
|
267
|
+
|
|
268
|
+
lines.append(f"**Total readings:** {len(data)}")
|
|
269
|
+
if "sensor_id" in data.columns:
|
|
270
|
+
sensors = data["sensor_id"].nunique()
|
|
271
|
+
lines.append(f"**Unique sensors:** {sensors}")
|
|
272
|
+
lines.append(f"**Sensors:** {', '.join(sorted(data['sensor_id'].unique()))}")
|
|
273
|
+
lines.append("")
|
|
274
|
+
|
|
275
|
+
# Missing data
|
|
276
|
+
null_counts = data.isnull().sum()
|
|
277
|
+
total_nulls = int(null_counts.sum())
|
|
278
|
+
lines.append("## Data Completeness")
|
|
279
|
+
if total_nulls == 0:
|
|
280
|
+
lines.append("All readings complete — no missing values.")
|
|
281
|
+
else:
|
|
282
|
+
lines.append(f"**{total_nulls} missing values** detected:")
|
|
283
|
+
for col, count in null_counts.items():
|
|
284
|
+
if count > 0:
|
|
285
|
+
lines.append(f"- {col}: {count} missing ({count / len(data) * 100:.1f}%)")
|
|
286
|
+
lines.append("")
|
|
287
|
+
|
|
288
|
+
# Battery
|
|
289
|
+
if "battery_level" in data.columns and "sensor_id" in data.columns:
|
|
290
|
+
lines.append("## Battery Status")
|
|
291
|
+
latest_battery = data.groupby("sensor_id")["battery_level"].last()
|
|
292
|
+
for sensor_id, level in latest_battery.items():
|
|
293
|
+
if pd.isna(level):
|
|
294
|
+
emoji = "unknown"
|
|
295
|
+
elif level < 10:
|
|
296
|
+
emoji = "CRITICAL"
|
|
297
|
+
elif level < 20:
|
|
298
|
+
emoji = "LOW"
|
|
299
|
+
else:
|
|
300
|
+
emoji = "OK"
|
|
301
|
+
display_level = f"{level:.1f}%" if not pd.isna(level) else "N/A"
|
|
302
|
+
lines.append(f"- **{sensor_id}**: {display_level} [{emoji}]")
|
|
303
|
+
lines.append("")
|
|
304
|
+
|
|
305
|
+
# Temperature
|
|
306
|
+
if "temperature" in data.columns:
|
|
307
|
+
temp = data["temperature"].dropna()
|
|
308
|
+
lines.append("## Temperature Summary")
|
|
309
|
+
lines.append(f"- Min: {temp.min():.1f}")
|
|
310
|
+
lines.append(f"- Max: {temp.max():.1f}")
|
|
311
|
+
lines.append(f"- Mean: {temp.mean():.1f}")
|
|
312
|
+
lines.append(f"- Std Dev: {temp.std():.1f}")
|
|
313
|
+
lines.append("")
|
|
314
|
+
|
|
315
|
+
# Status
|
|
316
|
+
if "status" in data.columns:
|
|
317
|
+
lines.append("## Status Summary")
|
|
318
|
+
for status, count in data["status"].value_counts().items():
|
|
319
|
+
lines.append(f"- **{status}**: {count} readings ({count / len(data) * 100:.0f}%)")
|
|
320
|
+
|
|
321
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: diagnostics-framework
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A pluggable diagnostics framework for running system-specific tests, plots, and reports.
|
|
5
|
+
Author: Pam Painter
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/pampainter/diagnostics-framework
|
|
8
|
+
Project-URL: Repository, https://github.com/pampainter/diagnostics-framework
|
|
9
|
+
Project-URL: Issues, https://github.com/pampainter/diagnostics-framework/issues
|
|
10
|
+
Keywords: diagnostics,testing,dashboard,streamlit,data-quality,monitoring
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Intended Audience :: Science/Research
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Topic :: Scientific/Engineering
|
|
20
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
21
|
+
Classifier: Topic :: Software Development :: Testing
|
|
22
|
+
Requires-Python: >=3.10
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
License-File: LICENSE
|
|
25
|
+
Requires-Dist: streamlit>=1.30
|
|
26
|
+
Requires-Dist: pandas>=2.0
|
|
27
|
+
Requires-Dist: matplotlib>=3.7
|
|
28
|
+
Requires-Dist: seaborn>=0.13
|
|
29
|
+
Dynamic: license-file
|
|
30
|
+
|
|
31
|
+
# Diagnostics Framework
|
|
32
|
+
|
|
33
|
+
A pluggable Python framework for running diagnostic tests, generating plots, and viewing reports for any system — all from a Streamlit dashboard.
|
|
34
|
+
|
|
35
|
+
## Overview
|
|
36
|
+
|
|
37
|
+
The framework uses a **plugin architecture**: each "system" is a self-contained module that registers its own diagnostic tests, plots, and reports using simple decorators. Adding a new system is as easy as dropping a new Python file into the `systems/` folder.
|
|
38
|
+
|
|
39
|
+
**Built-in example systems:**
|
|
40
|
+
|
|
41
|
+
| System | Description |
|
|
42
|
+
|--------|-------------|
|
|
43
|
+
| `generic_example` | Basic tabular data checks (nulls, ranges, emptiness) |
|
|
44
|
+
| `sensor_monitoring` | IoT sensor diagnostics with battery health, temperature validation, and status tracking |
|
|
45
|
+
|
|
46
|
+
## Quick Start
|
|
47
|
+
|
|
48
|
+
### Install
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
# Create a virtual environment (Python 3.10+ required)
|
|
52
|
+
python -m venv .venv
|
|
53
|
+
source .venv/bin/activate
|
|
54
|
+
|
|
55
|
+
# Install in editable mode
|
|
56
|
+
pip install -e .
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
### Run the Dashboard
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
streamlit run diagnostics_framework/app.py
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Open http://localhost:8501, select a system, upload a data file, and click **Run Diagnostics**.
|
|
66
|
+
|
|
67
|
+
A sample dataset is included at `sample_data.csv` for testing the `sensor_monitoring` system.
|
|
68
|
+
|
|
69
|
+
## Project Structure
|
|
70
|
+
|
|
71
|
+
```
|
|
72
|
+
diagnostics_framework/
|
|
73
|
+
├── models.py # Dataclasses: DiagnosticResult, DiagnosticStatus, etc.
|
|
74
|
+
├── registry.py # Singleton registry + decorator API
|
|
75
|
+
├── runner.py # Test execution engine with error isolation
|
|
76
|
+
├── app.py # Streamlit dashboard
|
|
77
|
+
└── systems/
|
|
78
|
+
├── __init__.py # Auto-discovers all system modules
|
|
79
|
+
├── generic_example.py # Example: generic tabular data checks
|
|
80
|
+
└── sensor_monitoring.py # Example: IoT sensor diagnostics
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## Adding a New System
|
|
84
|
+
|
|
85
|
+
Create a new file in `diagnostics_framework/systems/` — it will be auto-discovered on import.
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
# diagnostics_framework/systems/my_system.py
|
|
89
|
+
from diagnostics_framework import (
|
|
90
|
+
register_system, register_test, register_plot, register_report,
|
|
91
|
+
DiagnosticResult, DiagnosticStatus,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
SYSTEM_NAME = "my_system"
|
|
95
|
+
|
|
96
|
+
@register_system(SYSTEM_NAME, description="My custom system")
|
|
97
|
+
class MySystem:
|
|
98
|
+
pass
|
|
99
|
+
|
|
100
|
+
@register_test(SYSTEM_NAME, name="my_check", description="Validates something important")
|
|
101
|
+
def my_check(data):
|
|
102
|
+
# Your test logic here
|
|
103
|
+
return DiagnosticResult(
|
|
104
|
+
test_name="my_check",
|
|
105
|
+
status=DiagnosticStatus.PASS,
|
|
106
|
+
message="Everything looks good.",
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
@register_plot(SYSTEM_NAME, name="my_plot", description="Visualizes the data")
|
|
110
|
+
def my_plot(data):
|
|
111
|
+
import matplotlib.pyplot as plt
|
|
112
|
+
fig, ax = plt.subplots()
|
|
113
|
+
# Your plotting logic here
|
|
114
|
+
return fig
|
|
115
|
+
|
|
116
|
+
@register_report(SYSTEM_NAME, name="my_report", description="Summary of findings")
|
|
117
|
+
def my_report(data):
|
|
118
|
+
return "# My Report\n\nEverything is fine."
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
That's it — the new system will appear in the dashboard dropdown automatically.
|
|
122
|
+
|
|
123
|
+
## Decorator API
|
|
124
|
+
|
|
125
|
+
| Decorator | Purpose |
|
|
126
|
+
|-----------|---------|
|
|
127
|
+
| `@register_system(name, description, version)` | Register a new system |
|
|
128
|
+
| `@register_test(system, name, description)` | Add a diagnostic test |
|
|
129
|
+
| `@register_plot(system, name, description)` | Add a plot generator |
|
|
130
|
+
| `@register_report(system, name, description)` | Add a report generator |
|
|
131
|
+
|
|
132
|
+
## Diagnostic Result Statuses
|
|
133
|
+
|
|
134
|
+
| Status | Meaning |
|
|
135
|
+
|--------|---------|
|
|
136
|
+
| `PASS` | Test passed successfully |
|
|
137
|
+
| `FAIL` | Test found a problem |
|
|
138
|
+
| `WARNING` | Test found something worth noting |
|
|
139
|
+
| `ERROR` | Test itself crashed (caught automatically by the runner) |
|
|
140
|
+
|
|
141
|
+
## Programmatic Usage
|
|
142
|
+
|
|
143
|
+
You can also use the framework without the dashboard:
|
|
144
|
+
|
|
145
|
+
```python
|
|
146
|
+
import pandas as pd
|
|
147
|
+
from diagnostics_framework import run_diagnostics
|
|
148
|
+
|
|
149
|
+
data = pd.read_csv("sample_data.csv")
|
|
150
|
+
summary = run_diagnostics("sensor_monitoring", data)
|
|
151
|
+
|
|
152
|
+
for result in summary.results:
|
|
153
|
+
print(f"[{result.status.value}] {result.test_name}: {result.message}")
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
## Dependencies
|
|
157
|
+
|
|
158
|
+
- Python >= 3.10
|
|
159
|
+
- streamlit
|
|
160
|
+
- pandas
|
|
161
|
+
- matplotlib
|
|
162
|
+
- seaborn
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
diagnostics_framework/__init__.py,sha256=un66MjUaL-8GUU9ZaXAykQpiRhdH16Bn3dqyBEy4qMc,423
|
|
2
|
+
diagnostics_framework/app.py,sha256=L6rF7plmMKBruA1lcCZAFI-pQZG-WVPtVz_gEyYk3hk,6486
|
|
3
|
+
diagnostics_framework/models.py,sha256=2Q1Fwc-UXloiCp2MR3Nyhfx66N-yWMB7DzDFk50KCRc,1484
|
|
4
|
+
diagnostics_framework/registry.py,sha256=Mefbvr6IeTzkiLl8oC22a5T7TrGQdpgeNqrDhcyPNAQ,2810
|
|
5
|
+
diagnostics_framework/runner.py,sha256=4D1l50Q2MY3BN9HkuUc2Ux6JprcOXpgqutru3sisUqY,2165
|
|
6
|
+
diagnostics_framework/systems/__init__.py,sha256=zly8KsotoHLrSMC-FOiX8sUtETP2JhxXvp-PS08MoUY,310
|
|
7
|
+
diagnostics_framework/systems/generic_example.py,sha256=QQGdJ-UvsAm4S1Ru0ip6aJWQQuMRGjmyhVOQ1eCXolg,7415
|
|
8
|
+
diagnostics_framework/systems/sensor_monitoring.py,sha256=7pXFnnpWoEMjuev-jICxUMMHVOLlq45CQpii38OV-_c,13819
|
|
9
|
+
diagnostics_framework-0.1.0.dist-info/licenses/LICENSE,sha256=L2eX4LNu6Jo0kOKdN-x_a5IZ_AXtAgrKE-jmVXvAj3A,1068
|
|
10
|
+
diagnostics_framework-0.1.0.dist-info/METADATA,sha256=f9Z7AXAqg_g-9Y9NWlqtH5aRw2lTdkBGLfnh4xgrx9Q,5286
|
|
11
|
+
diagnostics_framework-0.1.0.dist-info/WHEEL,sha256=YCfwYGOYMi5Jhw2fU4yNgwErybb2IX5PEwBKV4ZbdBo,91
|
|
12
|
+
diagnostics_framework-0.1.0.dist-info/entry_points.txt,sha256=OeeBCFxEsxcuQCPWlAKehfyBEXWwtwIpgxBH6ofyk_A,63
|
|
13
|
+
diagnostics_framework-0.1.0.dist-info/top_level.txt,sha256=kymk6vfk_5DwR815dhrFqL6V3PjBvcHQJEbsCxh9Vfg,22
|
|
14
|
+
diagnostics_framework-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Pam Painter
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
diagnostics_framework
|