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