machine-health 0.1.0__tar.gz
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.
- machine_health-0.1.0/.gitignore +30 -0
- machine_health-0.1.0/LICENSE +21 -0
- machine_health-0.1.0/PKG-INFO +205 -0
- machine_health-0.1.0/README.md +178 -0
- machine_health-0.1.0/pyproject.toml +54 -0
- machine_health-0.1.0/src/machine_health/__init__.py +34 -0
- machine_health-0.1.0/src/machine_health/_io.py +84 -0
- machine_health-0.1.0/src/machine_health/_util.py +238 -0
- machine_health-0.1.0/src/machine_health/cli.py +200 -0
- machine_health-0.1.0/src/machine_health/monitor.py +311 -0
- machine_health-0.1.0/src/machine_health/result.py +181 -0
- machine_health-0.1.0/src/machine_health/rules.py +324 -0
- machine_health-0.1.0/src/machine_health/scoring.py +708 -0
- machine_health-0.1.0/tests/conftest.py +51 -0
- machine_health-0.1.0/tests/test_cli.py +187 -0
- machine_health-0.1.0/tests/test_edge.py +211 -0
- machine_health-0.1.0/tests/test_invariants.py +123 -0
- machine_health-0.1.0/tests/test_monitor.py +208 -0
- machine_health-0.1.0/tests/test_quickstart.py +46 -0
- machine_health-0.1.0/tests/test_regressions.py +227 -0
- machine_health-0.1.0/tests/test_rules.py +165 -0
- machine_health-0.1.0/tests/test_score.py +193 -0
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# Virtual environments and scratch space used while building. Never committed:
|
|
2
|
+
# they are large, machine-specific, and rebuilt from pyproject.toml anyway.
|
|
3
|
+
_venvs/
|
|
4
|
+
_proof/
|
|
5
|
+
.venv*/
|
|
6
|
+
|
|
7
|
+
# Build output. Wheels are built by the release workflow from this source, so a
|
|
8
|
+
# wheel in git could silently differ from the code beside it.
|
|
9
|
+
dist/
|
|
10
|
+
build/
|
|
11
|
+
*.egg-info/
|
|
12
|
+
src/*.egg-info/
|
|
13
|
+
|
|
14
|
+
# Python noise
|
|
15
|
+
__pycache__/
|
|
16
|
+
*.py[cod]
|
|
17
|
+
.pytest_cache/
|
|
18
|
+
.mypy_cache/
|
|
19
|
+
.ruff_cache/
|
|
20
|
+
|
|
21
|
+
# Local verification state, not source
|
|
22
|
+
verify_results.json
|
|
23
|
+
publish-log.txt
|
|
24
|
+
published.json
|
|
25
|
+
|
|
26
|
+
# Credentials. None of these belong here, and this line is the backstop.
|
|
27
|
+
.pypirc
|
|
28
|
+
.env
|
|
29
|
+
*.pem
|
|
30
|
+
*.key
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Pranay Mahendrakar
|
|
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,205 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: machine-health
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A single continuously updated 0-100 health score per machine, combining many sensors and rules
|
|
5
|
+
Project-URL: Homepage, https://pypi.org/project/machine-health/
|
|
6
|
+
Project-URL: Author, https://pypi.org/user/pranaymahendrakar/
|
|
7
|
+
Author: Pranay Mahendrakar
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: anomaly-detection,condition-monitoring,iot,machine-health,pandas,predictive-maintenance,sensors,telemetry,time-series
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Intended Audience :: Science/Research
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
17
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
18
|
+
Requires-Python: >=3.9
|
|
19
|
+
Requires-Dist: numpy>=1.23
|
|
20
|
+
Requires-Dist: pandas>=1.5
|
|
21
|
+
Provides-Extra: dev
|
|
22
|
+
Requires-Dist: pyarrow>=12; extra == 'dev'
|
|
23
|
+
Requires-Dist: pytest>=7; extra == 'dev'
|
|
24
|
+
Provides-Extra: parquet
|
|
25
|
+
Requires-Dist: pyarrow>=12; extra == 'parquet'
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
# machine-health
|
|
29
|
+
|
|
30
|
+
One continuously updated 0-100 health score per machine, built from every sensor you have
|
|
31
|
+
and the limits you already know, with the reasoning attached so you can act on it.
|
|
32
|
+
|
|
33
|
+
## Install
|
|
34
|
+
|
|
35
|
+
```
|
|
36
|
+
pip install machine-health
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Quickstart
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
import numpy as np, pandas as pd
|
|
43
|
+
from machine_health import score
|
|
44
|
+
|
|
45
|
+
rng = np.random.default_rng(0)
|
|
46
|
+
temp = np.r_[rng.normal(60, 1, 800), rng.normal(63, 1.5, 200)] # the last fifth runs hot
|
|
47
|
+
vib = np.r_[rng.normal(0.2, 0.02, 800), rng.normal(0.24, 0.025, 200)]
|
|
48
|
+
result = score(pd.DataFrame({"temp": temp, "vibration": vib}), rules={"temp": {"max": 70, "warn_max": 65}})
|
|
49
|
+
print(result.summary())
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
```
|
|
53
|
+
machine health: 78.2 / 100 (grade C, trend degrading)
|
|
54
|
+
window: 800 row(s), baseline: 200 row(s)
|
|
55
|
+
channels (2): temp, vibration
|
|
56
|
+
components (score, weight, points lost):
|
|
57
|
+
stability 68.3 weight 0.30 -9.50
|
|
58
|
+
compliance 90.0 weight 0.30 -3.00
|
|
59
|
+
anomaly 53.8 weight 0.20 -9.25
|
|
60
|
+
availability 100.0 weight 0.20 0.00
|
|
61
|
+
points lost by channel:
|
|
62
|
+
temp -16.19
|
|
63
|
+
vibration -5.57
|
|
64
|
+
violations (1):
|
|
65
|
+
[warning] temp above warn_max=65 on 10 of 800 rows (1.2%), worst 66.73
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
`result.value` is the number to put on a dashboard; everything else on the object says where
|
|
69
|
+
it came from. The points lost are split two ways - `result.component_penalties` (stability,
|
|
70
|
+
compliance, anomaly, availability) and `result.contributors` (temp, vibration) - and each
|
|
71
|
+
split adds up to exactly `result.penalty`, which is `100 - result.value`. The printed figures
|
|
72
|
+
above are those two splits rounded to two decimals.
|
|
73
|
+
|
|
74
|
+
## What it scores
|
|
75
|
+
|
|
76
|
+
Four components, each reported separately on its own 0-100 scale, each measured against a
|
|
77
|
+
**baseline period** of rows you call healthy (by default the first 20% of the data):
|
|
78
|
+
|
|
79
|
+
- **stability** - how far each channel has moved from the way it behaved in the baseline, in
|
|
80
|
+
either of the two ways it can move, whichever costs more. It **swings more**: a channel
|
|
81
|
+
swinging `4x` more than it used to scores 0, and varying less than baseline is never
|
|
82
|
+
penalised. Or it has **settled at another level**: a move of up to one baseline spread is
|
|
83
|
+
free, 1000 spreads scores 0, logarithmic in between. The second half matters because a
|
|
84
|
+
channel that steps to a steady wrong value swings exactly as much as it always did.
|
|
85
|
+
- **compliance** - whether your own limits hold. Hard limits (`min` / `max`) are critical, soft
|
|
86
|
+
limits (`warn_min` / `warn_max`) are warnings, and a single breach always costs something,
|
|
87
|
+
so one bad reading in a million rows does not vanish into the average.
|
|
88
|
+
- **anomaly** - the share of readings that are outliers against the baseline (robust z above
|
|
89
|
+
3.5, median and MAD based). A channel with 10% or more outlying readings scores 0.
|
|
90
|
+
- **availability** - missing readings, plus a penalty for a channel that used to move and is
|
|
91
|
+
now flatlined, which is what a dead sensor looks like.
|
|
92
|
+
|
|
93
|
+
The components are combined with weights (`stability 0.30, compliance 0.30, anomaly 0.20,
|
|
94
|
+
availability 0.20` by default) into `value = 100 - sum(weight * points lost)`, so the score is
|
|
95
|
+
always between 0 and 100.
|
|
96
|
+
|
|
97
|
+
**Grades are fixed bands**, not a curve: `A >= 90`, `B >= 80`, `C >= 70`, `D >= 60`, `F` below 60.
|
|
98
|
+
`result.ok` is `True` when the grade is C or better *and* no hard limit was broken.
|
|
99
|
+
|
|
100
|
+
**A component that cannot be measured is not guessed.** With no rules there is no compliance
|
|
101
|
+
score; with a one-row baseline there is no stability score. The weight of a component that
|
|
102
|
+
could not be measured is shared out over the ones that could, `result.unmeasured` names it,
|
|
103
|
+
`result.to_dict()["components"]` reports it as `null` rather than a free 100, and a note in
|
|
104
|
+
`result.notes` says so. That is why an all-NaN table scores 0 on availability alone instead
|
|
105
|
+
of inheriting three free 100s.
|
|
106
|
+
|
|
107
|
+
**Without rules the score is statistical, not absolute.** It knows only how the machine
|
|
108
|
+
behaves now compared with its own healthy period - nothing about what its readings *mean*.
|
|
109
|
+
That is enough to catch extra noise, outliers, a level shift and dead sensors, but a channel
|
|
110
|
+
whose baseline was never healthy in the first place will look fine. Pass your own limits
|
|
111
|
+
(`rules={"temp": {"max": 80}}`) whenever you have them; they are the only part of the score
|
|
112
|
+
that knows what the numbers are supposed to be.
|
|
113
|
+
|
|
114
|
+
## API
|
|
115
|
+
|
|
116
|
+
```python
|
|
117
|
+
score(df, *, time=None, channels=None, rules=None, weights=None, baseline=None) -> MachineScore
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
- `df` - a DataFrame or a path to a `.csv` / `.tsv` / `.parquet` file; one row per reading,
|
|
121
|
+
one column per channel.
|
|
122
|
+
- `time` - name of the timestamp column. Rows are sorted by it, violations report when they
|
|
123
|
+
first happened, and `result.when` carries the newest timestamp.
|
|
124
|
+
- `channels` - which columns to score; defaults to every numeric column. A channel a rule
|
|
125
|
+
names is always included.
|
|
126
|
+
- `rules` - `{"temp": {"max": 80}}`, a `Rule`, or a list of either. A rule naming a channel
|
|
127
|
+
that is not in the data raises `ValueError` listing the channels that are.
|
|
128
|
+
- `weights` - `{"compliance": 0.5}`; missing names keep their default. Weights that do not sum
|
|
129
|
+
to 1 are normalized and a note is recorded.
|
|
130
|
+
- `baseline` - the healthy period: a DataFrame or file path, a fraction (`0.3`), a row count
|
|
131
|
+
(`500`), or a boolean mask over the rows. Default: the first 20% of the rows.
|
|
132
|
+
|
|
133
|
+
`HealthScorer(time=..., channels=..., rules=..., weights=..., baseline=...)` is the same thing
|
|
134
|
+
as an object, when you want to keep the settings and call `.score(df)` repeatedly.
|
|
135
|
+
|
|
136
|
+
### `MachineScore`
|
|
137
|
+
|
|
138
|
+
- `.value` - 0-100; `.grade` - `"A"` to `"F"`; `.penalty` - `100 - value`
|
|
139
|
+
- `.components` - `{"stability": 68.3, "compliance": 90.0, ...}`, each 0-100
|
|
140
|
+
- `.weights` - the weight each component actually got; `.component_penalties` - points each cost
|
|
141
|
+
- `.contributors` - `{channel: penalty points}`, summing to `.penalty`; `.top_contributors(n)`
|
|
142
|
+
- `.violations` - list of `Violation(channel, limit, value, severity, count, n_rows, fraction,
|
|
143
|
+
worst, first_time)`, critical first; `.critical_violations`
|
|
144
|
+
- `.trend` - `"improving"`, `"stable"` or `"degrading"`: the two halves of the window compared
|
|
145
|
+
by how far each sits from the baseline (level shift, extra spread, missing readings and
|
|
146
|
+
breached rules, in baseline spreads). It compares the halves with each other, not with the
|
|
147
|
+
score, so a half that straddles a step never outranks the steady bad level that follows it
|
|
148
|
+
- `.unmeasured` - components that could not be scored at all; `.notes` - everything the scorer
|
|
149
|
+
decided for you; `.channels`, `.n_rows`, `.n_baseline_rows`, `.when`, `.ok`
|
|
150
|
+
- `.summary()` - the text above; `.to_dict()` - JSON-safe, including the grade bands
|
|
151
|
+
|
|
152
|
+
### `Rule`
|
|
153
|
+
|
|
154
|
+
```python
|
|
155
|
+
from machine_health import Rule, score
|
|
156
|
+
score(df, rules=[Rule("temp", max=80, warn_max=75), Rule("vibration", max=2.5, weight=2.0)])
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
`Rule(channel, min=None, max=None, warn_min=None, warn_max=None, weight=1.0)`. `weight` sets
|
|
160
|
+
how much the rule counts against the other rules inside the compliance component.
|
|
161
|
+
|
|
162
|
+
### `HealthMonitor`
|
|
163
|
+
|
|
164
|
+
```python
|
|
165
|
+
from machine_health import HealthMonitor
|
|
166
|
+
|
|
167
|
+
monitor = HealthMonitor(healthy_df, rules={"temp": {"max": 70}}, time="ts")
|
|
168
|
+
for batch in incoming_batches:
|
|
169
|
+
result = monitor.update(batch) # same baseline every time, so scores are comparable
|
|
170
|
+
print(monitor.history) # one row per update
|
|
171
|
+
print([a.summary() for a in monitor.alerts])
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
`HealthMonitor(baseline_df, rules=None, weights=None, *, time=None, channels=None)` keeps the
|
|
175
|
+
baseline fixed and scores each batch exactly the way `score()` does.
|
|
176
|
+
|
|
177
|
+
- `.update(batch)` - a `MachineScore`. An empty batch returns the previous score unchanged
|
|
178
|
+
with a note instead of raising. From the second update on, `.trend` compares the batch with
|
|
179
|
+
the mean of the previous three.
|
|
180
|
+
- `.history` - DataFrame: `update, when, value, grade, trend, stability, compliance, anomaly,
|
|
181
|
+
availability, rows, violations`. A component that was not measured is `NaN` in its column
|
|
182
|
+
(with no rules, the whole `compliance` column is), never a free 100
|
|
183
|
+
- `.alerts` - `Alert(when, severity, message)`, raised when the grade drops a level (critical
|
|
184
|
+
for two levels or a drop to F) or a rule is violated
|
|
185
|
+
- `.baseline_score` - the baseline scored against itself, the starting point for comparisons
|
|
186
|
+
- `.last_score`, `.summary()`, `.to_dict()`
|
|
187
|
+
|
|
188
|
+
## CLI
|
|
189
|
+
|
|
190
|
+
```
|
|
191
|
+
machine-health telemetry.csv
|
|
192
|
+
machine-health telemetry.csv --time ts --rule "temp:max=80,warn_max=75" --rule "vibration:max=2.5"
|
|
193
|
+
machine-health telemetry.csv --json --output health.json
|
|
194
|
+
machine-health telemetry.csv --baseline 0.3 --weight compliance=0.5 --fail-under 70
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
`--rule` is repeatable and takes `channel:key=number` pairs (`min`, `max`, `warn_min`,
|
|
198
|
+
`warn_max`, `weight`); `--rules FILE` reads the same thing from JSON. `--baseline` takes a
|
|
199
|
+
fraction, a row count or a path to a table of healthy rows. The exit status is 0 unless
|
|
200
|
+
`--fail-under SCORE` is given and the score is below it (2 on a bad argument or unreadable
|
|
201
|
+
file), so it drops straight into a shell check.
|
|
202
|
+
|
|
203
|
+
## License
|
|
204
|
+
|
|
205
|
+
MIT
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
# machine-health
|
|
2
|
+
|
|
3
|
+
One continuously updated 0-100 health score per machine, built from every sensor you have
|
|
4
|
+
and the limits you already know, with the reasoning attached so you can act on it.
|
|
5
|
+
|
|
6
|
+
## Install
|
|
7
|
+
|
|
8
|
+
```
|
|
9
|
+
pip install machine-health
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## Quickstart
|
|
13
|
+
|
|
14
|
+
```python
|
|
15
|
+
import numpy as np, pandas as pd
|
|
16
|
+
from machine_health import score
|
|
17
|
+
|
|
18
|
+
rng = np.random.default_rng(0)
|
|
19
|
+
temp = np.r_[rng.normal(60, 1, 800), rng.normal(63, 1.5, 200)] # the last fifth runs hot
|
|
20
|
+
vib = np.r_[rng.normal(0.2, 0.02, 800), rng.normal(0.24, 0.025, 200)]
|
|
21
|
+
result = score(pd.DataFrame({"temp": temp, "vibration": vib}), rules={"temp": {"max": 70, "warn_max": 65}})
|
|
22
|
+
print(result.summary())
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
```
|
|
26
|
+
machine health: 78.2 / 100 (grade C, trend degrading)
|
|
27
|
+
window: 800 row(s), baseline: 200 row(s)
|
|
28
|
+
channels (2): temp, vibration
|
|
29
|
+
components (score, weight, points lost):
|
|
30
|
+
stability 68.3 weight 0.30 -9.50
|
|
31
|
+
compliance 90.0 weight 0.30 -3.00
|
|
32
|
+
anomaly 53.8 weight 0.20 -9.25
|
|
33
|
+
availability 100.0 weight 0.20 0.00
|
|
34
|
+
points lost by channel:
|
|
35
|
+
temp -16.19
|
|
36
|
+
vibration -5.57
|
|
37
|
+
violations (1):
|
|
38
|
+
[warning] temp above warn_max=65 on 10 of 800 rows (1.2%), worst 66.73
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
`result.value` is the number to put on a dashboard; everything else on the object says where
|
|
42
|
+
it came from. The points lost are split two ways - `result.component_penalties` (stability,
|
|
43
|
+
compliance, anomaly, availability) and `result.contributors` (temp, vibration) - and each
|
|
44
|
+
split adds up to exactly `result.penalty`, which is `100 - result.value`. The printed figures
|
|
45
|
+
above are those two splits rounded to two decimals.
|
|
46
|
+
|
|
47
|
+
## What it scores
|
|
48
|
+
|
|
49
|
+
Four components, each reported separately on its own 0-100 scale, each measured against a
|
|
50
|
+
**baseline period** of rows you call healthy (by default the first 20% of the data):
|
|
51
|
+
|
|
52
|
+
- **stability** - how far each channel has moved from the way it behaved in the baseline, in
|
|
53
|
+
either of the two ways it can move, whichever costs more. It **swings more**: a channel
|
|
54
|
+
swinging `4x` more than it used to scores 0, and varying less than baseline is never
|
|
55
|
+
penalised. Or it has **settled at another level**: a move of up to one baseline spread is
|
|
56
|
+
free, 1000 spreads scores 0, logarithmic in between. The second half matters because a
|
|
57
|
+
channel that steps to a steady wrong value swings exactly as much as it always did.
|
|
58
|
+
- **compliance** - whether your own limits hold. Hard limits (`min` / `max`) are critical, soft
|
|
59
|
+
limits (`warn_min` / `warn_max`) are warnings, and a single breach always costs something,
|
|
60
|
+
so one bad reading in a million rows does not vanish into the average.
|
|
61
|
+
- **anomaly** - the share of readings that are outliers against the baseline (robust z above
|
|
62
|
+
3.5, median and MAD based). A channel with 10% or more outlying readings scores 0.
|
|
63
|
+
- **availability** - missing readings, plus a penalty for a channel that used to move and is
|
|
64
|
+
now flatlined, which is what a dead sensor looks like.
|
|
65
|
+
|
|
66
|
+
The components are combined with weights (`stability 0.30, compliance 0.30, anomaly 0.20,
|
|
67
|
+
availability 0.20` by default) into `value = 100 - sum(weight * points lost)`, so the score is
|
|
68
|
+
always between 0 and 100.
|
|
69
|
+
|
|
70
|
+
**Grades are fixed bands**, not a curve: `A >= 90`, `B >= 80`, `C >= 70`, `D >= 60`, `F` below 60.
|
|
71
|
+
`result.ok` is `True` when the grade is C or better *and* no hard limit was broken.
|
|
72
|
+
|
|
73
|
+
**A component that cannot be measured is not guessed.** With no rules there is no compliance
|
|
74
|
+
score; with a one-row baseline there is no stability score. The weight of a component that
|
|
75
|
+
could not be measured is shared out over the ones that could, `result.unmeasured` names it,
|
|
76
|
+
`result.to_dict()["components"]` reports it as `null` rather than a free 100, and a note in
|
|
77
|
+
`result.notes` says so. That is why an all-NaN table scores 0 on availability alone instead
|
|
78
|
+
of inheriting three free 100s.
|
|
79
|
+
|
|
80
|
+
**Without rules the score is statistical, not absolute.** It knows only how the machine
|
|
81
|
+
behaves now compared with its own healthy period - nothing about what its readings *mean*.
|
|
82
|
+
That is enough to catch extra noise, outliers, a level shift and dead sensors, but a channel
|
|
83
|
+
whose baseline was never healthy in the first place will look fine. Pass your own limits
|
|
84
|
+
(`rules={"temp": {"max": 80}}`) whenever you have them; they are the only part of the score
|
|
85
|
+
that knows what the numbers are supposed to be.
|
|
86
|
+
|
|
87
|
+
## API
|
|
88
|
+
|
|
89
|
+
```python
|
|
90
|
+
score(df, *, time=None, channels=None, rules=None, weights=None, baseline=None) -> MachineScore
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
- `df` - a DataFrame or a path to a `.csv` / `.tsv` / `.parquet` file; one row per reading,
|
|
94
|
+
one column per channel.
|
|
95
|
+
- `time` - name of the timestamp column. Rows are sorted by it, violations report when they
|
|
96
|
+
first happened, and `result.when` carries the newest timestamp.
|
|
97
|
+
- `channels` - which columns to score; defaults to every numeric column. A channel a rule
|
|
98
|
+
names is always included.
|
|
99
|
+
- `rules` - `{"temp": {"max": 80}}`, a `Rule`, or a list of either. A rule naming a channel
|
|
100
|
+
that is not in the data raises `ValueError` listing the channels that are.
|
|
101
|
+
- `weights` - `{"compliance": 0.5}`; missing names keep their default. Weights that do not sum
|
|
102
|
+
to 1 are normalized and a note is recorded.
|
|
103
|
+
- `baseline` - the healthy period: a DataFrame or file path, a fraction (`0.3`), a row count
|
|
104
|
+
(`500`), or a boolean mask over the rows. Default: the first 20% of the rows.
|
|
105
|
+
|
|
106
|
+
`HealthScorer(time=..., channels=..., rules=..., weights=..., baseline=...)` is the same thing
|
|
107
|
+
as an object, when you want to keep the settings and call `.score(df)` repeatedly.
|
|
108
|
+
|
|
109
|
+
### `MachineScore`
|
|
110
|
+
|
|
111
|
+
- `.value` - 0-100; `.grade` - `"A"` to `"F"`; `.penalty` - `100 - value`
|
|
112
|
+
- `.components` - `{"stability": 68.3, "compliance": 90.0, ...}`, each 0-100
|
|
113
|
+
- `.weights` - the weight each component actually got; `.component_penalties` - points each cost
|
|
114
|
+
- `.contributors` - `{channel: penalty points}`, summing to `.penalty`; `.top_contributors(n)`
|
|
115
|
+
- `.violations` - list of `Violation(channel, limit, value, severity, count, n_rows, fraction,
|
|
116
|
+
worst, first_time)`, critical first; `.critical_violations`
|
|
117
|
+
- `.trend` - `"improving"`, `"stable"` or `"degrading"`: the two halves of the window compared
|
|
118
|
+
by how far each sits from the baseline (level shift, extra spread, missing readings and
|
|
119
|
+
breached rules, in baseline spreads). It compares the halves with each other, not with the
|
|
120
|
+
score, so a half that straddles a step never outranks the steady bad level that follows it
|
|
121
|
+
- `.unmeasured` - components that could not be scored at all; `.notes` - everything the scorer
|
|
122
|
+
decided for you; `.channels`, `.n_rows`, `.n_baseline_rows`, `.when`, `.ok`
|
|
123
|
+
- `.summary()` - the text above; `.to_dict()` - JSON-safe, including the grade bands
|
|
124
|
+
|
|
125
|
+
### `Rule`
|
|
126
|
+
|
|
127
|
+
```python
|
|
128
|
+
from machine_health import Rule, score
|
|
129
|
+
score(df, rules=[Rule("temp", max=80, warn_max=75), Rule("vibration", max=2.5, weight=2.0)])
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
`Rule(channel, min=None, max=None, warn_min=None, warn_max=None, weight=1.0)`. `weight` sets
|
|
133
|
+
how much the rule counts against the other rules inside the compliance component.
|
|
134
|
+
|
|
135
|
+
### `HealthMonitor`
|
|
136
|
+
|
|
137
|
+
```python
|
|
138
|
+
from machine_health import HealthMonitor
|
|
139
|
+
|
|
140
|
+
monitor = HealthMonitor(healthy_df, rules={"temp": {"max": 70}}, time="ts")
|
|
141
|
+
for batch in incoming_batches:
|
|
142
|
+
result = monitor.update(batch) # same baseline every time, so scores are comparable
|
|
143
|
+
print(monitor.history) # one row per update
|
|
144
|
+
print([a.summary() for a in monitor.alerts])
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
`HealthMonitor(baseline_df, rules=None, weights=None, *, time=None, channels=None)` keeps the
|
|
148
|
+
baseline fixed and scores each batch exactly the way `score()` does.
|
|
149
|
+
|
|
150
|
+
- `.update(batch)` - a `MachineScore`. An empty batch returns the previous score unchanged
|
|
151
|
+
with a note instead of raising. From the second update on, `.trend` compares the batch with
|
|
152
|
+
the mean of the previous three.
|
|
153
|
+
- `.history` - DataFrame: `update, when, value, grade, trend, stability, compliance, anomaly,
|
|
154
|
+
availability, rows, violations`. A component that was not measured is `NaN` in its column
|
|
155
|
+
(with no rules, the whole `compliance` column is), never a free 100
|
|
156
|
+
- `.alerts` - `Alert(when, severity, message)`, raised when the grade drops a level (critical
|
|
157
|
+
for two levels or a drop to F) or a rule is violated
|
|
158
|
+
- `.baseline_score` - the baseline scored against itself, the starting point for comparisons
|
|
159
|
+
- `.last_score`, `.summary()`, `.to_dict()`
|
|
160
|
+
|
|
161
|
+
## CLI
|
|
162
|
+
|
|
163
|
+
```
|
|
164
|
+
machine-health telemetry.csv
|
|
165
|
+
machine-health telemetry.csv --time ts --rule "temp:max=80,warn_max=75" --rule "vibration:max=2.5"
|
|
166
|
+
machine-health telemetry.csv --json --output health.json
|
|
167
|
+
machine-health telemetry.csv --baseline 0.3 --weight compliance=0.5 --fail-under 70
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
`--rule` is repeatable and takes `channel:key=number` pairs (`min`, `max`, `warn_min`,
|
|
171
|
+
`warn_max`, `weight`); `--rules FILE` reads the same thing from JSON. `--baseline` takes a
|
|
172
|
+
fraction, a row count or a path to a table of healthy rows. The exit status is 0 unless
|
|
173
|
+
`--fail-under SCORE` is given and the score is below it (2 on a bad argument or unreadable
|
|
174
|
+
file), so it drops straight into a shell check.
|
|
175
|
+
|
|
176
|
+
## License
|
|
177
|
+
|
|
178
|
+
MIT
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.27"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "machine-health"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "A single continuously updated 0-100 health score per machine, combining many sensors and rules"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [{ name = "Pranay Mahendrakar" }]
|
|
14
|
+
keywords = [
|
|
15
|
+
"machine-health",
|
|
16
|
+
"condition-monitoring",
|
|
17
|
+
"predictive-maintenance",
|
|
18
|
+
"sensors",
|
|
19
|
+
"telemetry",
|
|
20
|
+
"anomaly-detection",
|
|
21
|
+
"iot",
|
|
22
|
+
"time-series",
|
|
23
|
+
"pandas",
|
|
24
|
+
]
|
|
25
|
+
classifiers = [
|
|
26
|
+
"Development Status :: 4 - Beta",
|
|
27
|
+
"Intended Audience :: Developers",
|
|
28
|
+
"Intended Audience :: Science/Research",
|
|
29
|
+
"Programming Language :: Python :: 3",
|
|
30
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
31
|
+
"Operating System :: OS Independent",
|
|
32
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
33
|
+
]
|
|
34
|
+
dependencies = [
|
|
35
|
+
"pandas>=1.5",
|
|
36
|
+
"numpy>=1.23",
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
[project.optional-dependencies]
|
|
40
|
+
parquet = ["pyarrow>=12"]
|
|
41
|
+
dev = ["pytest>=7", "pyarrow>=12"]
|
|
42
|
+
|
|
43
|
+
[project.scripts]
|
|
44
|
+
machine-health = "machine_health.cli:main"
|
|
45
|
+
|
|
46
|
+
[project.urls]
|
|
47
|
+
Homepage = "https://pypi.org/project/machine-health/"
|
|
48
|
+
Author = "https://pypi.org/user/pranaymahendrakar/"
|
|
49
|
+
|
|
50
|
+
[tool.hatch.build.targets.wheel]
|
|
51
|
+
packages = ["src/machine_health"]
|
|
52
|
+
|
|
53
|
+
[tool.pytest.ini_options]
|
|
54
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""machine-health: one continuously updated 0-100 health score per machine.
|
|
2
|
+
|
|
3
|
+
>>> from machine_health import score
|
|
4
|
+
>>> result = score(df, rules={"temp": {"max": 80}})
|
|
5
|
+
>>> print(result.value, result.grade)
|
|
6
|
+
|
|
7
|
+
The score combines four components - stability, compliance, anomaly and
|
|
8
|
+
availability - and reports each of them separately, so the number can always be
|
|
9
|
+
explained: the points lost add up per component and per channel.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from ._io import load_table
|
|
13
|
+
from .monitor import Alert, HealthMonitor
|
|
14
|
+
from .result import COMPONENTS, GRADE_BANDS, MachineScore, grade_for
|
|
15
|
+
from .rules import Rule, Violation
|
|
16
|
+
from .scoring import DEFAULT_WEIGHTS, HealthScorer, score
|
|
17
|
+
|
|
18
|
+
__version__ = "0.1.0"
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"Alert",
|
|
22
|
+
"COMPONENTS",
|
|
23
|
+
"DEFAULT_WEIGHTS",
|
|
24
|
+
"GRADE_BANDS",
|
|
25
|
+
"HealthMonitor",
|
|
26
|
+
"HealthScorer",
|
|
27
|
+
"MachineScore",
|
|
28
|
+
"Rule",
|
|
29
|
+
"Violation",
|
|
30
|
+
"__version__",
|
|
31
|
+
"grade_for",
|
|
32
|
+
"load_table",
|
|
33
|
+
"score",
|
|
34
|
+
]
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Input loading: a pandas DataFrame, a Series, or a path to a .csv / .tsv / .parquet file."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import List, Union
|
|
8
|
+
|
|
9
|
+
import pandas as pd
|
|
10
|
+
|
|
11
|
+
TableLike = Union[pd.DataFrame, pd.Series, str, "os.PathLike[str]"]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def load_table(source: TableLike, label: str = "df") -> pd.DataFrame:
|
|
15
|
+
"""Return ``source`` as a DataFrame, reading .csv/.tsv/.parquet files from disk.
|
|
16
|
+
|
|
17
|
+
DataFrames are returned as-is (never copied or mutated); a Series becomes a
|
|
18
|
+
one-column frame. ``label`` names the argument in error messages.
|
|
19
|
+
"""
|
|
20
|
+
if isinstance(source, pd.DataFrame):
|
|
21
|
+
return source
|
|
22
|
+
if isinstance(source, pd.Series):
|
|
23
|
+
return source.to_frame()
|
|
24
|
+
if isinstance(source, (str, os.PathLike)):
|
|
25
|
+
path = Path(source)
|
|
26
|
+
if not path.is_file():
|
|
27
|
+
raise FileNotFoundError(f"{label}: no such file: {path}")
|
|
28
|
+
suffixes = [s.lower() for s in path.suffixes]
|
|
29
|
+
if ".parquet" in suffixes or ".pq" in suffixes:
|
|
30
|
+
try:
|
|
31
|
+
return pd.read_parquet(path)
|
|
32
|
+
except ImportError as exc:
|
|
33
|
+
raise ImportError(
|
|
34
|
+
"reading .parquet files needs pyarrow: "
|
|
35
|
+
'pip install "machine-health[parquet]"'
|
|
36
|
+
) from exc
|
|
37
|
+
if ".csv" in suffixes:
|
|
38
|
+
return _read_separated(path, label, ",", "CSV")
|
|
39
|
+
if ".tsv" in suffixes:
|
|
40
|
+
return _read_separated(path, label, "\t", "TSV")
|
|
41
|
+
raise ValueError(
|
|
42
|
+
f"{label}: unsupported file type {path.suffix!r}; expected .csv, .tsv or .parquet"
|
|
43
|
+
)
|
|
44
|
+
raise TypeError(
|
|
45
|
+
f"{label} must be a pandas DataFrame, a Series, or a path to a .csv/.parquet file, "
|
|
46
|
+
f"not {type(source).__name__}"
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _read_separated(path: Path, label: str, sep: str, kind: str) -> pd.DataFrame:
|
|
51
|
+
"""Read a delimited text file, turning pandas' own wording into a message that
|
|
52
|
+
names the argument and the file, the way every other error here does."""
|
|
53
|
+
try:
|
|
54
|
+
return pd.read_csv(path, sep=sep)
|
|
55
|
+
except pd.errors.EmptyDataError:
|
|
56
|
+
raise ValueError(
|
|
57
|
+
f"{label}: {path.name} has no columns to read; the file is empty"
|
|
58
|
+
) from None
|
|
59
|
+
except pd.errors.ParserError as exc:
|
|
60
|
+
raise ValueError(f"{label}: {path.name} is not readable as {kind}: {exc}") from None
|
|
61
|
+
except UnicodeDecodeError:
|
|
62
|
+
raise ValueError(
|
|
63
|
+
f"{label}: {path.name} is not UTF-8 text; save it as UTF-8 or pass a DataFrame"
|
|
64
|
+
) from None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def check_columns(df: pd.DataFrame, label: str = "df") -> List[str]:
|
|
68
|
+
"""Reject duplicate column names up front and return the names as text.
|
|
69
|
+
|
|
70
|
+
Without this a duplicated name makes ``df[name]`` return a DataFrame and the
|
|
71
|
+
failure surfaces much later as an AttributeError from inside pandas.
|
|
72
|
+
"""
|
|
73
|
+
names = [str(c) for c in df.columns]
|
|
74
|
+
seen, dupes = set(), []
|
|
75
|
+
for name in names:
|
|
76
|
+
if name in seen and name not in dupes:
|
|
77
|
+
dupes.append(name)
|
|
78
|
+
seen.add(name)
|
|
79
|
+
if dupes:
|
|
80
|
+
raise ValueError(
|
|
81
|
+
f"{label} has duplicate column names: {', '.join(repr(d) for d in dupes)}; "
|
|
82
|
+
"rename them so each channel is addressable"
|
|
83
|
+
)
|
|
84
|
+
return names
|