production-anomaly 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.
- production_anomaly-0.1.0/.gitignore +30 -0
- production_anomaly-0.1.0/LICENSE +21 -0
- production_anomaly-0.1.0/PKG-INFO +211 -0
- production_anomaly-0.1.0/README.md +184 -0
- production_anomaly-0.1.0/pyproject.toml +56 -0
- production_anomaly-0.1.0/src/production_anomaly/__init__.py +31 -0
- production_anomaly-0.1.0/src/production_anomaly/_io.py +81 -0
- production_anomaly-0.1.0/src/production_anomaly/_prepare.py +778 -0
- production_anomaly-0.1.0/src/production_anomaly/analyzer.py +1342 -0
- production_anomaly-0.1.0/src/production_anomaly/cli.py +119 -0
- production_anomaly-0.1.0/src/production_anomaly/report.py +364 -0
- production_anomaly-0.1.0/src/production_anomaly/shifts.py +257 -0
- production_anomaly-0.1.0/tests/conftest.py +38 -0
- production_anomaly-0.1.0/tests/test_cli.py +101 -0
- production_anomaly-0.1.0/tests/test_detection.py +240 -0
- production_anomaly-0.1.0/tests/test_edge_cases.py +382 -0
- production_anomaly-0.1.0/tests/test_quickstart.py +64 -0
- production_anomaly-0.1.0/tests/test_regressions.py +248 -0
- production_anomaly-0.1.0/tests/test_report.py +156 -0
- production_anomaly-0.1.0/tests/test_shifts.py +82 -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,211 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: production-anomaly
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Catch abnormal production rates, downtime and process drift on a manufacturing line
|
|
5
|
+
Project-URL: Homepage, https://pypi.org/project/production-anomaly/
|
|
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,availability,downtime,manufacturing,micro-stops,oee,pandas,process-drift,production,shop-floor
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Intended Audience :: Manufacturing
|
|
14
|
+
Classifier: Intended Audience :: Science/Research
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
18
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
19
|
+
Requires-Python: >=3.9
|
|
20
|
+
Requires-Dist: numpy>=1.23
|
|
21
|
+
Requires-Dist: pandas>=1.5
|
|
22
|
+
Provides-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
|
+
# production-anomaly
|
|
29
|
+
|
|
30
|
+
Find out where a manufacturing line lost output - stoppages, slow running, a speed that sags
|
|
31
|
+
shift after shift, micro-stops that add up, and counter glitches - from nothing more than a
|
|
32
|
+
table of units produced over time.
|
|
33
|
+
|
|
34
|
+
## Install
|
|
35
|
+
|
|
36
|
+
```
|
|
37
|
+
pip install production-anomaly
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Quickstart
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
import numpy as np, pandas as pd, production_anomaly as pa
|
|
44
|
+
|
|
45
|
+
t = pd.date_range("2026-03-02", periods=3 * 288, freq="5min") # three days of 5-minute counts
|
|
46
|
+
units = np.where((t.hour >= 6) & (t.hour < 22), 300.0, 0.0) # the line runs 06:00-22:00
|
|
47
|
+
units[130:142] = 0; units[400:440] *= 0.7; units[700] = 600 # a stop, a slow spell, a double count
|
|
48
|
+
report = pa.analyze(pd.DataFrame({"time": t, "units": units}), shift_hours=(6, 22))
|
|
49
|
+
print(report.summary())
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
```
|
|
53
|
+
production report for 'units' (per-interval counts, 5 min intervals)
|
|
54
|
+
period: 2026-03-02 00:00 to 2026-03-05 00:00
|
|
55
|
+
schedule: 1 shift: 06:00-22:00
|
|
56
|
+
scheduled 48.0 h; 24.0 h outside the shifts not counted
|
|
57
|
+
availability 97.9% (60 min downtime in 1 stoppage)
|
|
58
|
+
performance 97.9% (vs the line's typical rate: 300 per interval = 3,600/h)
|
|
59
|
+
oee_partial 95.8% (availability x performance; quality not measured - it needs scrap data)
|
|
60
|
+
units made 165,600; estimated lost 7,200 (downtime 3,600, slow running 3,600)
|
|
61
|
+
findings:
|
|
62
|
+
- Downtime: stopped for 60 min of 48.0 h scheduled (2.1%) in 1 stoppage; the longest ran 60 min from 2026-03-02 10:50. About 3,600 units lost.
|
|
63
|
+
- Slow running: 1 period, 3.3 h in total, below 80% of the line's typical rate (240 per interval); the longest ran from 2026-03-03 09:20 to 2026-03-03 12:40. About 3,600 units lost.
|
|
64
|
+
- Spikes: 1 interval far above normal; the largest, 600 units at 2026-03-04 10:20, is 2.0x the typical rate and looks like a double count (the same units counted twice). Spikes are counted at the typical rate so they do not inflate the totals.
|
|
65
|
+
stoppages (1, 60 min in total):
|
|
66
|
+
2026-03-02 10:50 to 2026-03-02 11:50 60 min downtime
|
|
67
|
+
events: 1 downtime, 1 slow_running, 1 spike (see report.events)
|
|
68
|
+
notes:
|
|
69
|
+
- Quality is not measured: true OEE is availability x performance x quality, and quality needs scrap or reject counts that this data does not have. oee_partial is availability x performance, so it is an upper bound on the line's real OEE.
|
|
70
|
+
- no target_rate given: performance is measured against the line's own typical rate (300 per 5 min interval, 3,600/h: the average of its normal running intervals), so it shows speed lost against its usual pace, not against rated speed.
|
|
71
|
+
- shift_hours given (1 shift: 06:00-22:00); time outside the shifts is unscheduled and is not counted as downtime.
|
|
72
|
+
- output not given; used 'units', the only numeric column.
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
The line is idle every night in that data. Because `shift_hours=(6, 22)` says it is only
|
|
76
|
+
scheduled 06:00-22:00, those hours are unscheduled, not downtime. Leave `shift_hours` out and
|
|
77
|
+
every hour counts: the nights then show up as downtime, and the report adds a finding that
|
|
78
|
+
suggests the `shift_hours` value to pass.
|
|
79
|
+
|
|
80
|
+
## What it checks
|
|
81
|
+
|
|
82
|
+
The input is a table with a time column and a units column, in either of two shapes, which
|
|
83
|
+
are told apart automatically:
|
|
84
|
+
|
|
85
|
+
- **units per interval** - each row is what the line made in the interval that starts at its
|
|
86
|
+
timestamp (what `DataFrame.resample(...).sum()` gives you);
|
|
87
|
+
- **a running counter** - a total that only goes up. Units per interval are the differences
|
|
88
|
+
between readings.
|
|
89
|
+
|
|
90
|
+
It then looks for:
|
|
91
|
+
|
|
92
|
+
- **Downtime** - runs of zero or near-zero output (at most 5% of the line's high rate), merged
|
|
93
|
+
into stoppages with a start, an end and a duration. A stop of 5 minutes or more is
|
|
94
|
+
`downtime` and costs availability; a shorter one is a `micro_stop` and costs performance,
|
|
95
|
+
the way OEE treats minor stops.
|
|
96
|
+
- **Slow running** - at least 15 minutes below 80% of the reference rate: the lower of the
|
|
97
|
+
line's own typical rate and `target_rate`. It has to persist (with intervals of 15 minutes
|
|
98
|
+
or longer, at least two in a row) and sit further below the typical rate than the line's
|
|
99
|
+
ordinary interval-to-interval scatter explains, so a noisy line is not called slow.
|
|
100
|
+
- **Rate drift** - a sustained decline in running speed across shifts: a robust (Theil-Sen)
|
|
101
|
+
trend over at least 3 shifts that falls by 10% or more, with most shift-to-shift
|
|
102
|
+
comparisons pointing down. One bad hour does not make a drift.
|
|
103
|
+
- **Micro-stops** - frequent short drops, each too small to notice, reported together with
|
|
104
|
+
how many units they cost in total and how many minutes of full-speed running that equals.
|
|
105
|
+
- **Spikes** - an interval far above normal (over 1.5x the typical rate and well outside its
|
|
106
|
+
usual spread). About 2x usually means a double count; 8x or more usually means a counter
|
|
107
|
+
reset or rollover read as production. Spikes are counted at the typical rate, so they do
|
|
108
|
+
not inflate the totals.
|
|
109
|
+
- **Counter resets** - a counter that drops back to zero mid-shift, or a per-interval count
|
|
110
|
+
that goes negative because it was computed from one. It is reported as a reset, never as
|
|
111
|
+
a huge negative rate or as a stoppage, and that interval's output is treated as unknown.
|
|
112
|
+
|
|
113
|
+
Everything else the data forces on the analysis is written into `report.notes`:
|
|
114
|
+
|
|
115
|
+
- **Schedule.** With `shift_hours` given, hours outside the shifts are unscheduled and are
|
|
116
|
+
never downtime. Without it every hour counts as scheduled, and `by_shift` uses 8-hour
|
|
117
|
+
blocks from 06:00. `shift_hours` has no days of the week: whole scheduled days on which
|
|
118
|
+
the line made nothing (weekends, say) are downtime, and a finding lists them and says
|
|
119
|
+
whether they are every Saturday and Sunday in the data. If those are days off, leave their
|
|
120
|
+
rows out of `df`: a day without data counts neither as downtime nor as running, and is
|
|
121
|
+
reported as a day without data rather than as a gap.
|
|
122
|
+
- **Timestamps.** Irregular timestamps are resampled onto a regular grid at the round
|
|
123
|
+
interval (30 s, 1 min, 5 min...) nearest their typical spacing, each reading's units spread
|
|
124
|
+
over the time until the next reading, and a note says so. Only a gap far beyond the data's
|
|
125
|
+
own spacing is treated as missing data. Timezone-aware timestamps keep their timezone in
|
|
126
|
+
every result, and shifts are read in that local clock. Missing readings are unknown time,
|
|
127
|
+
counted neither as downtime nor as running.
|
|
128
|
+
- **Low counts.** When each interval holds only a few whole parts, one part more or less
|
|
129
|
+
would look like a stop or a double count: a 45-second cycle reads 1, 1, 2, 1, 1, 2 per
|
|
130
|
+
minute. Intervals are then merged into the shortest round length that typically holds at
|
|
131
|
+
least 10 parts and fits the shift boundaries (10 minutes for that line), and a note says
|
|
132
|
+
so. Stops are then timed to that length.
|
|
133
|
+
- **Quiet choices.** Any automatic choice (which column is time, which is output, counter or
|
|
134
|
+
counts) is recorded. Rows that share a timestamp are always added up (for a counter, the
|
|
135
|
+
highest reading is kept), even when they repeat the same value; a note points out such
|
|
136
|
+
repeats, since a reading logged twice then shows up as a double-count spike. The caller's
|
|
137
|
+
DataFrame is never modified.
|
|
138
|
+
|
|
139
|
+
**About OEE.** OEE is availability x performance x quality, and quality needs scrap or
|
|
140
|
+
reject counts. This package does not have them, so it does not report OEE. It reports
|
|
141
|
+
`oee_partial` = availability x performance, which is an upper bound on the real figure: a
|
|
142
|
+
line with 90% `oee_partial` and 5% scrap has an OEE near 85.5%. Without `target_rate`,
|
|
143
|
+
performance is measured against the line's own typical rate, so it shows speed lost against
|
|
144
|
+
its usual pace, not against its rated speed. Pass `target_rate` (units per hour) for a
|
|
145
|
+
performance figure you can compare between lines.
|
|
146
|
+
|
|
147
|
+
## API
|
|
148
|
+
|
|
149
|
+
```python
|
|
150
|
+
report = production_anomaly.analyze(df, *, time=None, output=None, target_rate=None, shift_hours=None)
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
- `df` - a DataFrame, or a path to a `.csv`, `.tsv` or `.parquet` file.
|
|
154
|
+
- `time` - the timestamp column. Found automatically when left out (a datetime column, a
|
|
155
|
+
DatetimeIndex, or a column whose text reads as timestamps).
|
|
156
|
+
- `output` - the units-produced column. Found automatically when left out, and the choice is
|
|
157
|
+
noted.
|
|
158
|
+
- `target_rate` - rated speed in **units per hour**. Without it the line is measured
|
|
159
|
+
against its own typical rate.
|
|
160
|
+
- `shift_hours` - when the line is scheduled, in any of these forms: `(6, 22)`;
|
|
161
|
+
`[(6, 14), (14, 22)]`; `{"A": (6, 14), "B": (14, 22), "C": (22, 6)}`;
|
|
162
|
+
`"06:00-14:00,14:00-22:00"`; `"A=6-14,B=14-22"`; or `8` for 8-hour shifts round the clock
|
|
163
|
+
from 06:00. Times are hours or `"HH:MM"`. A shift may run past midnight.
|
|
164
|
+
|
|
165
|
+
`ProductionReport`:
|
|
166
|
+
|
|
167
|
+
| attribute | meaning |
|
|
168
|
+
| --- | --- |
|
|
169
|
+
| `availability` | run time / scheduled time with a usable reading (`None` if nothing was measured) |
|
|
170
|
+
| `performance` | units made / what the reference rate would make in the run time, capped at 1 |
|
|
171
|
+
| `oee_partial` | `availability * performance`; quality is not included (`quality` is always `None`, `quality_note` says why) |
|
|
172
|
+
| `stoppages` | `list[Stoppage(start, end, minutes, kind)]`, `kind` is `"downtime"` or `"micro_stop"` |
|
|
173
|
+
| `events` | `list[Event(kind, start, end, minutes, severity, detail, units_lost, value)]` for `downtime`, `micro_stop`, `slow_running`, `rate_drift`, `spike`, `counter_reset` and `data_gap` |
|
|
174
|
+
| `by_shift` | DataFrame, one row per shift worked: date, shift, start, end, scheduled/measured/downtime/run minutes, units, rate per hour, availability, performance, oee_partial, stoppage and micro-stop counts |
|
|
175
|
+
| `lost_units` | estimated units lost against the reference rate in scheduled time |
|
|
176
|
+
| `lost_by_cause` | that estimate split into `downtime`, `micro_stops`, `slow_running` and `speed_loss` |
|
|
177
|
+
| `findings` | plain-language sentences, biggest loss first |
|
|
178
|
+
| `notes` | how it was measured, and anything about the data worth knowing |
|
|
179
|
+
| `intervals` | the regular per-interval table behind it all, with a `state` per interval |
|
|
180
|
+
| `summary()` | the text report above |
|
|
181
|
+
| `to_dict()` | everything except `intervals`, JSON-safe |
|
|
182
|
+
|
|
183
|
+
`units` is the total made in scheduled time. `typical_rate` and `reference_rate` are units per
|
|
184
|
+
interval, and `interval_minutes` gives the interval length (longer than the data's own
|
|
185
|
+
spacing when counts are low, see above). `lost_units` counts only
|
|
186
|
+
shortfalls, so running fast at one time
|
|
187
|
+
does not cancel a stoppage at another. The losses attached to single events can overlap (a
|
|
188
|
+
drift and a slow spell in the same hours), so use `lost_units` for the total.
|
|
189
|
+
|
|
190
|
+
`ProductionAnalyzer(...)` takes the same arguments plus the thresholds: `interval`
|
|
191
|
+
(for example `"1min"`), `counter` (`"auto"`, `True`, `False`), `near_zero=0.05`,
|
|
192
|
+
`slow_fraction=0.8`, `min_stop_minutes=5`, `min_slow_minutes=15`, `spike_factor=1.5`,
|
|
193
|
+
`drift_threshold=0.10`. Its `.analyze(df)` returns the same report.
|
|
194
|
+
`parse_shift_hours(spec)` shows how a `shift_hours` value is read.
|
|
195
|
+
|
|
196
|
+
## CLI
|
|
197
|
+
|
|
198
|
+
```
|
|
199
|
+
production-anomaly line.csv # print the summary
|
|
200
|
+
production-anomaly line.csv --shift-hours 06:00-14:00,14:00-22:00 --target-rate 3600
|
|
201
|
+
production-anomaly line.csv --time ts --column units --json # the full report as JSON
|
|
202
|
+
production-anomaly line.csv --output report.json --by-shift shifts.csv
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
`--column` is the units column (`output=` in Python). `--output` writes the JSON report and
|
|
206
|
+
`--by-shift` writes the per-shift table. The exit status is 0 on success and 2 when the input
|
|
207
|
+
cannot be read.
|
|
208
|
+
|
|
209
|
+
## License
|
|
210
|
+
|
|
211
|
+
MIT
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
# production-anomaly
|
|
2
|
+
|
|
3
|
+
Find out where a manufacturing line lost output - stoppages, slow running, a speed that sags
|
|
4
|
+
shift after shift, micro-stops that add up, and counter glitches - from nothing more than a
|
|
5
|
+
table of units produced over time.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
pip install production-anomaly
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Quickstart
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
import numpy as np, pandas as pd, production_anomaly as pa
|
|
17
|
+
|
|
18
|
+
t = pd.date_range("2026-03-02", periods=3 * 288, freq="5min") # three days of 5-minute counts
|
|
19
|
+
units = np.where((t.hour >= 6) & (t.hour < 22), 300.0, 0.0) # the line runs 06:00-22:00
|
|
20
|
+
units[130:142] = 0; units[400:440] *= 0.7; units[700] = 600 # a stop, a slow spell, a double count
|
|
21
|
+
report = pa.analyze(pd.DataFrame({"time": t, "units": units}), shift_hours=(6, 22))
|
|
22
|
+
print(report.summary())
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
```
|
|
26
|
+
production report for 'units' (per-interval counts, 5 min intervals)
|
|
27
|
+
period: 2026-03-02 00:00 to 2026-03-05 00:00
|
|
28
|
+
schedule: 1 shift: 06:00-22:00
|
|
29
|
+
scheduled 48.0 h; 24.0 h outside the shifts not counted
|
|
30
|
+
availability 97.9% (60 min downtime in 1 stoppage)
|
|
31
|
+
performance 97.9% (vs the line's typical rate: 300 per interval = 3,600/h)
|
|
32
|
+
oee_partial 95.8% (availability x performance; quality not measured - it needs scrap data)
|
|
33
|
+
units made 165,600; estimated lost 7,200 (downtime 3,600, slow running 3,600)
|
|
34
|
+
findings:
|
|
35
|
+
- Downtime: stopped for 60 min of 48.0 h scheduled (2.1%) in 1 stoppage; the longest ran 60 min from 2026-03-02 10:50. About 3,600 units lost.
|
|
36
|
+
- Slow running: 1 period, 3.3 h in total, below 80% of the line's typical rate (240 per interval); the longest ran from 2026-03-03 09:20 to 2026-03-03 12:40. About 3,600 units lost.
|
|
37
|
+
- Spikes: 1 interval far above normal; the largest, 600 units at 2026-03-04 10:20, is 2.0x the typical rate and looks like a double count (the same units counted twice). Spikes are counted at the typical rate so they do not inflate the totals.
|
|
38
|
+
stoppages (1, 60 min in total):
|
|
39
|
+
2026-03-02 10:50 to 2026-03-02 11:50 60 min downtime
|
|
40
|
+
events: 1 downtime, 1 slow_running, 1 spike (see report.events)
|
|
41
|
+
notes:
|
|
42
|
+
- Quality is not measured: true OEE is availability x performance x quality, and quality needs scrap or reject counts that this data does not have. oee_partial is availability x performance, so it is an upper bound on the line's real OEE.
|
|
43
|
+
- no target_rate given: performance is measured against the line's own typical rate (300 per 5 min interval, 3,600/h: the average of its normal running intervals), so it shows speed lost against its usual pace, not against rated speed.
|
|
44
|
+
- shift_hours given (1 shift: 06:00-22:00); time outside the shifts is unscheduled and is not counted as downtime.
|
|
45
|
+
- output not given; used 'units', the only numeric column.
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
The line is idle every night in that data. Because `shift_hours=(6, 22)` says it is only
|
|
49
|
+
scheduled 06:00-22:00, those hours are unscheduled, not downtime. Leave `shift_hours` out and
|
|
50
|
+
every hour counts: the nights then show up as downtime, and the report adds a finding that
|
|
51
|
+
suggests the `shift_hours` value to pass.
|
|
52
|
+
|
|
53
|
+
## What it checks
|
|
54
|
+
|
|
55
|
+
The input is a table with a time column and a units column, in either of two shapes, which
|
|
56
|
+
are told apart automatically:
|
|
57
|
+
|
|
58
|
+
- **units per interval** - each row is what the line made in the interval that starts at its
|
|
59
|
+
timestamp (what `DataFrame.resample(...).sum()` gives you);
|
|
60
|
+
- **a running counter** - a total that only goes up. Units per interval are the differences
|
|
61
|
+
between readings.
|
|
62
|
+
|
|
63
|
+
It then looks for:
|
|
64
|
+
|
|
65
|
+
- **Downtime** - runs of zero or near-zero output (at most 5% of the line's high rate), merged
|
|
66
|
+
into stoppages with a start, an end and a duration. A stop of 5 minutes or more is
|
|
67
|
+
`downtime` and costs availability; a shorter one is a `micro_stop` and costs performance,
|
|
68
|
+
the way OEE treats minor stops.
|
|
69
|
+
- **Slow running** - at least 15 minutes below 80% of the reference rate: the lower of the
|
|
70
|
+
line's own typical rate and `target_rate`. It has to persist (with intervals of 15 minutes
|
|
71
|
+
or longer, at least two in a row) and sit further below the typical rate than the line's
|
|
72
|
+
ordinary interval-to-interval scatter explains, so a noisy line is not called slow.
|
|
73
|
+
- **Rate drift** - a sustained decline in running speed across shifts: a robust (Theil-Sen)
|
|
74
|
+
trend over at least 3 shifts that falls by 10% or more, with most shift-to-shift
|
|
75
|
+
comparisons pointing down. One bad hour does not make a drift.
|
|
76
|
+
- **Micro-stops** - frequent short drops, each too small to notice, reported together with
|
|
77
|
+
how many units they cost in total and how many minutes of full-speed running that equals.
|
|
78
|
+
- **Spikes** - an interval far above normal (over 1.5x the typical rate and well outside its
|
|
79
|
+
usual spread). About 2x usually means a double count; 8x or more usually means a counter
|
|
80
|
+
reset or rollover read as production. Spikes are counted at the typical rate, so they do
|
|
81
|
+
not inflate the totals.
|
|
82
|
+
- **Counter resets** - a counter that drops back to zero mid-shift, or a per-interval count
|
|
83
|
+
that goes negative because it was computed from one. It is reported as a reset, never as
|
|
84
|
+
a huge negative rate or as a stoppage, and that interval's output is treated as unknown.
|
|
85
|
+
|
|
86
|
+
Everything else the data forces on the analysis is written into `report.notes`:
|
|
87
|
+
|
|
88
|
+
- **Schedule.** With `shift_hours` given, hours outside the shifts are unscheduled and are
|
|
89
|
+
never downtime. Without it every hour counts as scheduled, and `by_shift` uses 8-hour
|
|
90
|
+
blocks from 06:00. `shift_hours` has no days of the week: whole scheduled days on which
|
|
91
|
+
the line made nothing (weekends, say) are downtime, and a finding lists them and says
|
|
92
|
+
whether they are every Saturday and Sunday in the data. If those are days off, leave their
|
|
93
|
+
rows out of `df`: a day without data counts neither as downtime nor as running, and is
|
|
94
|
+
reported as a day without data rather than as a gap.
|
|
95
|
+
- **Timestamps.** Irregular timestamps are resampled onto a regular grid at the round
|
|
96
|
+
interval (30 s, 1 min, 5 min...) nearest their typical spacing, each reading's units spread
|
|
97
|
+
over the time until the next reading, and a note says so. Only a gap far beyond the data's
|
|
98
|
+
own spacing is treated as missing data. Timezone-aware timestamps keep their timezone in
|
|
99
|
+
every result, and shifts are read in that local clock. Missing readings are unknown time,
|
|
100
|
+
counted neither as downtime nor as running.
|
|
101
|
+
- **Low counts.** When each interval holds only a few whole parts, one part more or less
|
|
102
|
+
would look like a stop or a double count: a 45-second cycle reads 1, 1, 2, 1, 1, 2 per
|
|
103
|
+
minute. Intervals are then merged into the shortest round length that typically holds at
|
|
104
|
+
least 10 parts and fits the shift boundaries (10 minutes for that line), and a note says
|
|
105
|
+
so. Stops are then timed to that length.
|
|
106
|
+
- **Quiet choices.** Any automatic choice (which column is time, which is output, counter or
|
|
107
|
+
counts) is recorded. Rows that share a timestamp are always added up (for a counter, the
|
|
108
|
+
highest reading is kept), even when they repeat the same value; a note points out such
|
|
109
|
+
repeats, since a reading logged twice then shows up as a double-count spike. The caller's
|
|
110
|
+
DataFrame is never modified.
|
|
111
|
+
|
|
112
|
+
**About OEE.** OEE is availability x performance x quality, and quality needs scrap or
|
|
113
|
+
reject counts. This package does not have them, so it does not report OEE. It reports
|
|
114
|
+
`oee_partial` = availability x performance, which is an upper bound on the real figure: a
|
|
115
|
+
line with 90% `oee_partial` and 5% scrap has an OEE near 85.5%. Without `target_rate`,
|
|
116
|
+
performance is measured against the line's own typical rate, so it shows speed lost against
|
|
117
|
+
its usual pace, not against its rated speed. Pass `target_rate` (units per hour) for a
|
|
118
|
+
performance figure you can compare between lines.
|
|
119
|
+
|
|
120
|
+
## API
|
|
121
|
+
|
|
122
|
+
```python
|
|
123
|
+
report = production_anomaly.analyze(df, *, time=None, output=None, target_rate=None, shift_hours=None)
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
- `df` - a DataFrame, or a path to a `.csv`, `.tsv` or `.parquet` file.
|
|
127
|
+
- `time` - the timestamp column. Found automatically when left out (a datetime column, a
|
|
128
|
+
DatetimeIndex, or a column whose text reads as timestamps).
|
|
129
|
+
- `output` - the units-produced column. Found automatically when left out, and the choice is
|
|
130
|
+
noted.
|
|
131
|
+
- `target_rate` - rated speed in **units per hour**. Without it the line is measured
|
|
132
|
+
against its own typical rate.
|
|
133
|
+
- `shift_hours` - when the line is scheduled, in any of these forms: `(6, 22)`;
|
|
134
|
+
`[(6, 14), (14, 22)]`; `{"A": (6, 14), "B": (14, 22), "C": (22, 6)}`;
|
|
135
|
+
`"06:00-14:00,14:00-22:00"`; `"A=6-14,B=14-22"`; or `8` for 8-hour shifts round the clock
|
|
136
|
+
from 06:00. Times are hours or `"HH:MM"`. A shift may run past midnight.
|
|
137
|
+
|
|
138
|
+
`ProductionReport`:
|
|
139
|
+
|
|
140
|
+
| attribute | meaning |
|
|
141
|
+
| --- | --- |
|
|
142
|
+
| `availability` | run time / scheduled time with a usable reading (`None` if nothing was measured) |
|
|
143
|
+
| `performance` | units made / what the reference rate would make in the run time, capped at 1 |
|
|
144
|
+
| `oee_partial` | `availability * performance`; quality is not included (`quality` is always `None`, `quality_note` says why) |
|
|
145
|
+
| `stoppages` | `list[Stoppage(start, end, minutes, kind)]`, `kind` is `"downtime"` or `"micro_stop"` |
|
|
146
|
+
| `events` | `list[Event(kind, start, end, minutes, severity, detail, units_lost, value)]` for `downtime`, `micro_stop`, `slow_running`, `rate_drift`, `spike`, `counter_reset` and `data_gap` |
|
|
147
|
+
| `by_shift` | DataFrame, one row per shift worked: date, shift, start, end, scheduled/measured/downtime/run minutes, units, rate per hour, availability, performance, oee_partial, stoppage and micro-stop counts |
|
|
148
|
+
| `lost_units` | estimated units lost against the reference rate in scheduled time |
|
|
149
|
+
| `lost_by_cause` | that estimate split into `downtime`, `micro_stops`, `slow_running` and `speed_loss` |
|
|
150
|
+
| `findings` | plain-language sentences, biggest loss first |
|
|
151
|
+
| `notes` | how it was measured, and anything about the data worth knowing |
|
|
152
|
+
| `intervals` | the regular per-interval table behind it all, with a `state` per interval |
|
|
153
|
+
| `summary()` | the text report above |
|
|
154
|
+
| `to_dict()` | everything except `intervals`, JSON-safe |
|
|
155
|
+
|
|
156
|
+
`units` is the total made in scheduled time. `typical_rate` and `reference_rate` are units per
|
|
157
|
+
interval, and `interval_minutes` gives the interval length (longer than the data's own
|
|
158
|
+
spacing when counts are low, see above). `lost_units` counts only
|
|
159
|
+
shortfalls, so running fast at one time
|
|
160
|
+
does not cancel a stoppage at another. The losses attached to single events can overlap (a
|
|
161
|
+
drift and a slow spell in the same hours), so use `lost_units` for the total.
|
|
162
|
+
|
|
163
|
+
`ProductionAnalyzer(...)` takes the same arguments plus the thresholds: `interval`
|
|
164
|
+
(for example `"1min"`), `counter` (`"auto"`, `True`, `False`), `near_zero=0.05`,
|
|
165
|
+
`slow_fraction=0.8`, `min_stop_minutes=5`, `min_slow_minutes=15`, `spike_factor=1.5`,
|
|
166
|
+
`drift_threshold=0.10`. Its `.analyze(df)` returns the same report.
|
|
167
|
+
`parse_shift_hours(spec)` shows how a `shift_hours` value is read.
|
|
168
|
+
|
|
169
|
+
## CLI
|
|
170
|
+
|
|
171
|
+
```
|
|
172
|
+
production-anomaly line.csv # print the summary
|
|
173
|
+
production-anomaly line.csv --shift-hours 06:00-14:00,14:00-22:00 --target-rate 3600
|
|
174
|
+
production-anomaly line.csv --time ts --column units --json # the full report as JSON
|
|
175
|
+
production-anomaly line.csv --output report.json --by-shift shifts.csv
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
`--column` is the units column (`output=` in Python). `--output` writes the JSON report and
|
|
179
|
+
`--by-shift` writes the per-shift table. The exit status is 0 on success and 2 when the input
|
|
180
|
+
cannot be read.
|
|
181
|
+
|
|
182
|
+
## License
|
|
183
|
+
|
|
184
|
+
MIT
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.27"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "production-anomaly"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Catch abnormal production rates, downtime and process drift on a manufacturing line"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [{ name = "Pranay Mahendrakar" }]
|
|
14
|
+
keywords = [
|
|
15
|
+
"manufacturing",
|
|
16
|
+
"production",
|
|
17
|
+
"oee",
|
|
18
|
+
"downtime",
|
|
19
|
+
"availability",
|
|
20
|
+
"micro-stops",
|
|
21
|
+
"anomaly-detection",
|
|
22
|
+
"process-drift",
|
|
23
|
+
"shop-floor",
|
|
24
|
+
"pandas",
|
|
25
|
+
]
|
|
26
|
+
classifiers = [
|
|
27
|
+
"Development Status :: 4 - Beta",
|
|
28
|
+
"Intended Audience :: Developers",
|
|
29
|
+
"Intended Audience :: Science/Research",
|
|
30
|
+
"Intended Audience :: Manufacturing",
|
|
31
|
+
"Programming Language :: Python :: 3",
|
|
32
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
33
|
+
"Operating System :: OS Independent",
|
|
34
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
35
|
+
]
|
|
36
|
+
dependencies = [
|
|
37
|
+
"pandas>=1.5",
|
|
38
|
+
"numpy>=1.23",
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
[project.optional-dependencies]
|
|
42
|
+
parquet = ["pyarrow>=12"]
|
|
43
|
+
dev = ["pytest>=7"]
|
|
44
|
+
|
|
45
|
+
[project.scripts]
|
|
46
|
+
production-anomaly = "production_anomaly.cli:main"
|
|
47
|
+
|
|
48
|
+
[project.urls]
|
|
49
|
+
Homepage = "https://pypi.org/project/production-anomaly/"
|
|
50
|
+
Author = "https://pypi.org/user/pranaymahendrakar/"
|
|
51
|
+
|
|
52
|
+
[tool.hatch.build.targets.wheel]
|
|
53
|
+
packages = ["src/production_anomaly"]
|
|
54
|
+
|
|
55
|
+
[tool.pytest.ini_options]
|
|
56
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""production-anomaly: catch abnormal production rates, downtime and process drift.
|
|
2
|
+
|
|
3
|
+
import production_anomaly as pa
|
|
4
|
+
report = pa.analyze(df, shift_hours=(6, 22), target_rate=3600)
|
|
5
|
+
print(report.summary())
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
|
|
12
|
+
from .analyzer import ProductionAnalyzer, analyze
|
|
13
|
+
from .report import QUALITY_NOTE, Event, ProductionReport, Stoppage
|
|
14
|
+
from .shifts import Schedule, Shift, parse_shift_hours
|
|
15
|
+
|
|
16
|
+
__version__ = "0.1.0"
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"analyze",
|
|
20
|
+
"ProductionAnalyzer",
|
|
21
|
+
"ProductionReport",
|
|
22
|
+
"Stoppage",
|
|
23
|
+
"Event",
|
|
24
|
+
"Schedule",
|
|
25
|
+
"Shift",
|
|
26
|
+
"parse_shift_hours",
|
|
27
|
+
"QUALITY_NOTE",
|
|
28
|
+
"__version__",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
logging.getLogger(__name__).addHandler(logging.NullHandler())
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Input loading and column checks: a DataFrame, 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, 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
|
+
A DataFrame is returned as-is; callers copy before they change anything, so the
|
|
18
|
+
caller's frame is never modified. ``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 "production-anomaly[parquet]"'
|
|
36
|
+
) from exc
|
|
37
|
+
if ".csv" in suffixes:
|
|
38
|
+
return _read_separated(path, label, ",", "CSV")
|
|
39
|
+
if ".tsv" in suffixes or ".tab" 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 or a path to a .csv/.tsv/.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 with errors that name the argument and the file."""
|
|
52
|
+
try:
|
|
53
|
+
return pd.read_csv(path, sep=sep, encoding="utf-8-sig")
|
|
54
|
+
except pd.errors.EmptyDataError:
|
|
55
|
+
raise ValueError(f"{label}: {path.name} has no columns to read; the file is empty") from None
|
|
56
|
+
except pd.errors.ParserError as exc:
|
|
57
|
+
raise ValueError(f"{label}: {path.name} is not readable as {kind}: {exc}") from None
|
|
58
|
+
except UnicodeDecodeError:
|
|
59
|
+
raise ValueError(
|
|
60
|
+
f"{label}: {path.name} is not UTF-8 text; save it as UTF-8 or pass a DataFrame"
|
|
61
|
+
) from None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def check_columns(df: pd.DataFrame, label: str = "df") -> List[str]:
|
|
65
|
+
"""Reject duplicate column names up front and return the names as text.
|
|
66
|
+
|
|
67
|
+
Without this a duplicated name makes ``df[name]`` return a DataFrame and the
|
|
68
|
+
failure surfaces much later as an AttributeError from inside pandas.
|
|
69
|
+
"""
|
|
70
|
+
names = [str(c) for c in df.columns]
|
|
71
|
+
seen, dupes = set(), []
|
|
72
|
+
for name in names:
|
|
73
|
+
if name in seen and name not in dupes:
|
|
74
|
+
dupes.append(name)
|
|
75
|
+
seen.add(name)
|
|
76
|
+
if dupes:
|
|
77
|
+
raise ValueError(
|
|
78
|
+
f"{label} has duplicate column names: {', '.join(repr(d) for d in dupes)}; "
|
|
79
|
+
"rename them so the time and output columns are unambiguous"
|
|
80
|
+
)
|
|
81
|
+
return names
|