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.
@@ -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