oee 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.
oee-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Atakan Arikan
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.
oee-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,141 @@
1
+ Metadata-Version: 2.4
2
+ Name: oee
3
+ Version: 0.1.0
4
+ Summary: Overall Equipment Effectiveness for Python: availability, performance and quality with the full time waterfall, TEEP, and correct multi-machine roll-up. Computed from the standard definitions and validated against published examples.
5
+ Author: Atakan Arikan
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/arikanatakan/oee
8
+ Project-URL: Repository, https://github.com/arikanatakan/oee
9
+ Project-URL: Issues, https://github.com/arikanatakan/oee/issues
10
+ Keywords: oee,overall-equipment-effectiveness,manufacturing,tpm,teep,availability,performance,quality,six-big-losses,production,lean
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Manufacturing
13
+ Classifier: Intended Audience :: Science/Research
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Topic :: Scientific/Engineering
20
+ Requires-Python: >=3.10
21
+ Description-Content-Type: text/markdown
22
+ License-File: LICENSE
23
+ Provides-Extra: dev
24
+ Requires-Dist: pytest>=7; extra == "dev"
25
+ Requires-Dist: ruff; extra == "dev"
26
+ Requires-Dist: build; extra == "dev"
27
+ Dynamic: license-file
28
+
29
+ # oee
30
+
31
+ [![CI](https://github.com/arikanatakan/oee/actions/workflows/ci.yml/badge.svg)](https://github.com/arikanatakan/oee/actions/workflows/ci.yml)
32
+ [![PyPI](https://img.shields.io/pypi/v/oee)](https://pypi.org/project/oee/)
33
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
34
+
35
+ Overall Equipment Effectiveness for Python.
36
+
37
+ Compute OEE (Availability x Performance x Quality) from machine times and piece
38
+ counts, get the full time waterfall and the three loss categories, TEEP and
39
+ utilization, and roll figures up correctly across machines and shifts. Computed
40
+ from the standard definitions and validated against published worked examples.
41
+
42
+ ## Motivation
43
+
44
+ OEE is the standard manufacturing efficiency metric, but Python has no library
45
+ for it: what exists is monitoring *applications* (Flask/Django dashboards) or
46
+ one-off tutorial scripts. The arithmetic looks trivial (three numbers
47
+ multiplied) and that is exactly why it is usually done wrong:
48
+
49
+ * the time waterfall (planned -> run -> net run -> fully productive) and where
50
+ each loss sits is skipped;
51
+ * TEEP and utilization (which capture schedule loss) are left out;
52
+ * and figures are *averaged* across machines, which is incorrect: a fast machine
53
+ and a slow one do not combine to the mean of their OEEs.
54
+
55
+ `oee` does these properly, from the standard definitions, and returns one result
56
+ with the factors, the waterfall, every loss, and provenance.
57
+
58
+ ```
59
+ pip install oee
60
+ ```
61
+
62
+ No runtime dependencies.
63
+
64
+ ## Usage
65
+
66
+ The canonical worked example (Vorne's *Fast Guide to OEE*):
67
+
68
+ ```python
69
+ import oee
70
+
71
+ r = oee.oee(
72
+ planned_production_time=420, # minutes (480 shift - 60 of breaks)
73
+ downtime=47,
74
+ ideal_rate=60, # pieces per minute
75
+ total_count=19271,
76
+ reject_count=423,
77
+ all_time=480, # optional, for TEEP and utilization
78
+ )
79
+ r.availability # 0.888
80
+ r.performance # 0.861
81
+ r.quality # 0.978
82
+ r.oee # 0.748
83
+ r.teep # 0.654
84
+ print(r.summary())
85
+ ```
86
+
87
+ Roll up across machines (correctly, not by averaging):
88
+
89
+ ```python
90
+ m1 = oee.oee(planned_production_time=100, run_time=90, ideal_cycle_time=1,
91
+ total_count=80, good_count=80) # OEE 0.80
92
+ m2 = oee.oee(planned_production_time=300, run_time=150, ideal_cycle_time=1,
93
+ total_count=150, good_count=135) # OEE 0.45
94
+
95
+ line = oee.aggregate([m1, m2])
96
+ line.oee # 0.5375, not the 0.625 average of the two
97
+ ```
98
+
99
+ When you already have the three factors:
100
+
101
+ ```python
102
+ oee.oee_from_factors(0.90, 0.95, 0.999).world_class # True (OEE >= 85%)
103
+ ```
104
+
105
+ Every result carries the factors, the time waterfall, the losses, `world_class`
106
+ and `meets_target` flags, `summary()`, and a JSON-safe `to_dict()` with
107
+ provenance (version, input hash, timestamp).
108
+
109
+ ## What it computes
110
+
111
+ | Group | Output |
112
+ |-------|--------|
113
+ | Factors | availability, performance, quality, OEE |
114
+ | Extended | TEEP, utilization (when total calendar time is given) |
115
+ | Waterfall | planned -> run -> net run -> fully productive time, with schedule, availability, performance and quality losses |
116
+ | Roll-up | correct aggregation across machines, lines and shifts |
117
+
118
+ All times must be in the same unit; `ideal_cycle_time` is that unit per piece
119
+ (or pass `ideal_rate` in pieces per that unit). Performance above 100% is capped
120
+ and flagged, since it means the ideal rate or counts are off.
121
+
122
+ ## Status
123
+
124
+ Version 0.1.0. Single-machine OEE, the time waterfall, TEEP/utilization, and
125
+ correct roll-up. The `OEEResult` contract is append-only from here.
126
+
127
+ ## Roadmap
128
+
129
+ | Version | Scope |
130
+ |---------|-------|
131
+ | 0.2 | the six big losses (breakdown/setup, minor stops/speed, defects/startup) and a downtime-reason Pareto; computing OEE from an event log |
132
+ | 0.3 | plotting (the OEE waterfall, six-big-losses and trend charts) as an optional extra |
133
+ | 0.4 | an MCP server so an agent can compute and explain OEE |
134
+
135
+ Out of scope: data collection / machine connectivity (that is the job of an
136
+ MES or an IoT dashboard); `oee` is the calculation layer they can build on.
137
+
138
+ ## License
139
+
140
+ MIT. Written and maintained by [Atakan Arikan](https://github.com/arikanatakan),
141
+ MSc Student at Tsinghua University and Politecnico di Milano.
oee-0.1.0/README.md ADDED
@@ -0,0 +1,113 @@
1
+ # oee
2
+
3
+ [![CI](https://github.com/arikanatakan/oee/actions/workflows/ci.yml/badge.svg)](https://github.com/arikanatakan/oee/actions/workflows/ci.yml)
4
+ [![PyPI](https://img.shields.io/pypi/v/oee)](https://pypi.org/project/oee/)
5
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
6
+
7
+ Overall Equipment Effectiveness for Python.
8
+
9
+ Compute OEE (Availability x Performance x Quality) from machine times and piece
10
+ counts, get the full time waterfall and the three loss categories, TEEP and
11
+ utilization, and roll figures up correctly across machines and shifts. Computed
12
+ from the standard definitions and validated against published worked examples.
13
+
14
+ ## Motivation
15
+
16
+ OEE is the standard manufacturing efficiency metric, but Python has no library
17
+ for it: what exists is monitoring *applications* (Flask/Django dashboards) or
18
+ one-off tutorial scripts. The arithmetic looks trivial (three numbers
19
+ multiplied) and that is exactly why it is usually done wrong:
20
+
21
+ * the time waterfall (planned -> run -> net run -> fully productive) and where
22
+ each loss sits is skipped;
23
+ * TEEP and utilization (which capture schedule loss) are left out;
24
+ * and figures are *averaged* across machines, which is incorrect: a fast machine
25
+ and a slow one do not combine to the mean of their OEEs.
26
+
27
+ `oee` does these properly, from the standard definitions, and returns one result
28
+ with the factors, the waterfall, every loss, and provenance.
29
+
30
+ ```
31
+ pip install oee
32
+ ```
33
+
34
+ No runtime dependencies.
35
+
36
+ ## Usage
37
+
38
+ The canonical worked example (Vorne's *Fast Guide to OEE*):
39
+
40
+ ```python
41
+ import oee
42
+
43
+ r = oee.oee(
44
+ planned_production_time=420, # minutes (480 shift - 60 of breaks)
45
+ downtime=47,
46
+ ideal_rate=60, # pieces per minute
47
+ total_count=19271,
48
+ reject_count=423,
49
+ all_time=480, # optional, for TEEP and utilization
50
+ )
51
+ r.availability # 0.888
52
+ r.performance # 0.861
53
+ r.quality # 0.978
54
+ r.oee # 0.748
55
+ r.teep # 0.654
56
+ print(r.summary())
57
+ ```
58
+
59
+ Roll up across machines (correctly, not by averaging):
60
+
61
+ ```python
62
+ m1 = oee.oee(planned_production_time=100, run_time=90, ideal_cycle_time=1,
63
+ total_count=80, good_count=80) # OEE 0.80
64
+ m2 = oee.oee(planned_production_time=300, run_time=150, ideal_cycle_time=1,
65
+ total_count=150, good_count=135) # OEE 0.45
66
+
67
+ line = oee.aggregate([m1, m2])
68
+ line.oee # 0.5375, not the 0.625 average of the two
69
+ ```
70
+
71
+ When you already have the three factors:
72
+
73
+ ```python
74
+ oee.oee_from_factors(0.90, 0.95, 0.999).world_class # True (OEE >= 85%)
75
+ ```
76
+
77
+ Every result carries the factors, the time waterfall, the losses, `world_class`
78
+ and `meets_target` flags, `summary()`, and a JSON-safe `to_dict()` with
79
+ provenance (version, input hash, timestamp).
80
+
81
+ ## What it computes
82
+
83
+ | Group | Output |
84
+ |-------|--------|
85
+ | Factors | availability, performance, quality, OEE |
86
+ | Extended | TEEP, utilization (when total calendar time is given) |
87
+ | Waterfall | planned -> run -> net run -> fully productive time, with schedule, availability, performance and quality losses |
88
+ | Roll-up | correct aggregation across machines, lines and shifts |
89
+
90
+ All times must be in the same unit; `ideal_cycle_time` is that unit per piece
91
+ (or pass `ideal_rate` in pieces per that unit). Performance above 100% is capped
92
+ and flagged, since it means the ideal rate or counts are off.
93
+
94
+ ## Status
95
+
96
+ Version 0.1.0. Single-machine OEE, the time waterfall, TEEP/utilization, and
97
+ correct roll-up. The `OEEResult` contract is append-only from here.
98
+
99
+ ## Roadmap
100
+
101
+ | Version | Scope |
102
+ |---------|-------|
103
+ | 0.2 | the six big losses (breakdown/setup, minor stops/speed, defects/startup) and a downtime-reason Pareto; computing OEE from an event log |
104
+ | 0.3 | plotting (the OEE waterfall, six-big-losses and trend charts) as an optional extra |
105
+ | 0.4 | an MCP server so an agent can compute and explain OEE |
106
+
107
+ Out of scope: data collection / machine connectivity (that is the job of an
108
+ MES or an IoT dashboard); `oee` is the calculation layer they can build on.
109
+
110
+ ## License
111
+
112
+ MIT. Written and maintained by [Atakan Arikan](https://github.com/arikanatakan),
113
+ MSc Student at Tsinghua University and Politecnico di Milano.
@@ -0,0 +1,32 @@
1
+ """oee - Overall Equipment Effectiveness for Python.
2
+
3
+ Compute OEE (Availability x Performance x Quality) from machine times and piece
4
+ counts, get the full time waterfall and the three loss categories, TEEP and
5
+ utilization, and roll figures up correctly across machines and shifts. Every
6
+ result carries provenance and a JSON-safe payload.
7
+
8
+ import oee
9
+
10
+ r = oee.oee(
11
+ planned_production_time=420, # minutes
12
+ downtime=47,
13
+ ideal_cycle_time=1 / 60, # minutes per piece
14
+ total_count=19271,
15
+ reject_count=423,
16
+ )
17
+ r.oee # 0.7479
18
+ r.summary() # the factor and loss breakdown
19
+
20
+ Computed from the standard definitions and validated against published worked
21
+ examples.
22
+ """
23
+
24
+ from ._result import Alert, OEEResult
25
+ from ._version import __version__
26
+ from .aggregate import aggregate
27
+ from .core import oee, oee_from_factors
28
+
29
+ __all__ = [
30
+ "oee", "oee_from_factors", "aggregate",
31
+ "OEEResult", "Alert", "__version__",
32
+ ]
@@ -0,0 +1,146 @@
1
+ """The result contract: one ``OEEResult`` per computation, carrying the three
2
+ factors, the time waterfall, the loss categories, provenance and a JSON-safe
3
+ payload, so a calculation can be reproduced and audited later.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import hashlib
9
+ import json
10
+ from dataclasses import dataclass
11
+ from datetime import datetime, timezone
12
+
13
+ SCHEMA = 1
14
+ WORLD_CLASS_OEE = 0.85
15
+
16
+
17
+ def utcnow() -> str:
18
+ return datetime.now(timezone.utc).isoformat(timespec="seconds")
19
+
20
+
21
+ def data_hash(obj: object) -> str:
22
+ payload = json.dumps(obj, sort_keys=True, default=str).encode("utf-8")
23
+ return "sha256:" + hashlib.sha256(payload).hexdigest()[:16]
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class Alert:
28
+ """A data-quality or benchmark note attached to a result."""
29
+
30
+ indicator: str
31
+ message: str
32
+ severity: str = "warning" # info | warning | error
33
+
34
+ def __str__(self) -> str:
35
+ return f"[{self.severity.upper()}] {self.indicator}: {self.message}"
36
+
37
+
38
+ def _pct(value: float | None) -> float | None:
39
+ return None if value is None else round(100.0 * value, 1)
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class OEEResult:
44
+ """Overall Equipment Effectiveness and the time/loss breakdown behind it."""
45
+
46
+ name: str | None
47
+ availability: float
48
+ performance: float
49
+ quality: float
50
+ oee: float
51
+ performance_raw: float
52
+ utilization: float | None
53
+ teep: float | None
54
+ planned_production_time: float | None
55
+ run_time: float | None
56
+ net_run_time: float | None
57
+ fully_productive_time: float | None
58
+ all_time: float | None
59
+ schedule_loss: float | None
60
+ availability_loss: float | None
61
+ performance_loss: float | None
62
+ quality_loss: float | None
63
+ total_count: float | None
64
+ good_count: float | None
65
+ reject_count: float | None
66
+ target_oee: float
67
+ alerts: tuple[Alert, ...]
68
+ meta: dict
69
+
70
+ @property
71
+ def world_class(self) -> bool:
72
+ """True when OEE meets the widely cited world-class level of 85%."""
73
+ return self.oee >= WORLD_CLASS_OEE
74
+
75
+ @property
76
+ def meets_target(self) -> bool:
77
+ return self.oee >= self.target_oee
78
+
79
+ @property
80
+ def ok(self) -> bool:
81
+ """True when no error-severity (data-quality) alert was raised."""
82
+ return not any(a.severity == "error" for a in self.alerts)
83
+
84
+ def _verdict(self) -> str:
85
+ if self.world_class:
86
+ return "world-class"
87
+ if self.meets_target:
88
+ return "meets target"
89
+ return "below target"
90
+
91
+ def summary(self) -> str:
92
+ head = "oee" + (f" {self.name}" if self.name else "")
93
+ lines = [
94
+ f"{head} - {self.meta.get('computed_at', '')}",
95
+ f" Availability {_pct(self.availability):>5}%",
96
+ f" Performance {_pct(self.performance):>5}%",
97
+ f" Quality {_pct(self.quality):>5}%",
98
+ f" OEE {_pct(self.oee):>5}%",
99
+ ]
100
+ if self.teep is not None:
101
+ lines.append(f" TEEP {_pct(self.teep):>5}%")
102
+ for alert in self.alerts:
103
+ lines.append(" " + str(alert))
104
+ lines.append(f"Verdict: OEE {_pct(self.oee)}% - {self._verdict()}")
105
+ return "\n".join(lines)
106
+
107
+ def to_dict(self) -> dict:
108
+ return {
109
+ "schema": SCHEMA,
110
+ "name": self.name,
111
+ "factors": {
112
+ "availability": self.availability,
113
+ "performance": self.performance,
114
+ "quality": self.quality,
115
+ "oee": self.oee,
116
+ "performance_raw": self.performance_raw,
117
+ "utilization": self.utilization,
118
+ "teep": self.teep,
119
+ },
120
+ "times": {
121
+ "all_time": self.all_time,
122
+ "planned_production_time": self.planned_production_time,
123
+ "run_time": self.run_time,
124
+ "net_run_time": self.net_run_time,
125
+ "fully_productive_time": self.fully_productive_time,
126
+ },
127
+ "losses": {
128
+ "schedule_loss": self.schedule_loss,
129
+ "availability_loss": self.availability_loss,
130
+ "performance_loss": self.performance_loss,
131
+ "quality_loss": self.quality_loss,
132
+ },
133
+ "counts": {
134
+ "total_count": self.total_count,
135
+ "good_count": self.good_count,
136
+ "reject_count": self.reject_count,
137
+ },
138
+ "world_class": self.world_class,
139
+ "meets_target": self.meets_target,
140
+ "target_oee": self.target_oee,
141
+ "alerts": [
142
+ {"indicator": a.indicator, "message": a.message, "severity": a.severity}
143
+ for a in self.alerts
144
+ ],
145
+ "meta": self.meta,
146
+ }
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,75 @@
1
+ """Roll OEE up across machines, lines, shifts or periods.
2
+
3
+ OEE figures must not be averaged: a fast machine and a slow one do not combine
4
+ to the mean of their OEEs. The correct roll-up sums the underlying time and
5
+ count buckets and recomputes the factors from the totals, which this does.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from ._result import Alert, OEEResult, data_hash, utcnow
11
+ from ._version import __version__
12
+
13
+ _REQUIRED = ("planned_production_time", "run_time", "net_run_time",
14
+ "fully_productive_time", "total_count", "good_count")
15
+
16
+
17
+ def aggregate(parts, *, target_oee: float = 0.85,
18
+ name: str | None = None) -> OEEResult:
19
+ """Combine several OEEResults into one by summing their time/count buckets."""
20
+ parts = list(parts)
21
+ if not parts:
22
+ raise ValueError("aggregate needs at least one result")
23
+ for i, part in enumerate(parts):
24
+ if not isinstance(part, OEEResult):
25
+ raise TypeError("aggregate expects OEEResult instances")
26
+ for field in _REQUIRED:
27
+ if getattr(part, field) is None:
28
+ raise ValueError(
29
+ f"part {i} has no {field}; aggregate needs results created "
30
+ "from times and counts (oee(), not oee_from_factors())")
31
+
32
+ s_planned = sum(p.planned_production_time for p in parts)
33
+ s_run = sum(p.run_time for p in parts)
34
+ s_net = sum(p.net_run_time for p in parts)
35
+ s_fp = sum(p.fully_productive_time for p in parts)
36
+ s_total = sum(p.total_count for p in parts)
37
+ s_good = sum(p.good_count for p in parts)
38
+
39
+ availability = s_run / s_planned if s_planned else 0.0
40
+ performance = s_net / s_run if s_run else 0.0
41
+ quality = s_fp / s_net if s_net else 0.0
42
+ oee_value = s_fp / s_planned if s_planned else 0.0
43
+
44
+ if all(p.all_time is not None for p in parts):
45
+ s_all = sum(p.all_time for p in parts)
46
+ utilization = s_planned / s_all if s_all else 0.0
47
+ teep = s_fp / s_all if s_all else 0.0
48
+ schedule_loss = s_all - s_planned
49
+ else:
50
+ s_all = utilization = teep = schedule_loss = None
51
+
52
+ alerts: list[Alert] = []
53
+ if oee_value < target_oee:
54
+ alerts.append(Alert(
55
+ "oee", f"OEE {oee_value * 100:.1f}% is below the target "
56
+ f"{target_oee * 100:.0f}%", "info"))
57
+
58
+ meta = {
59
+ "computed_at": utcnow(),
60
+ "version": __version__,
61
+ "method": "aggregate",
62
+ "parts": len(parts),
63
+ "input_hash": data_hash([p.meta.get("input_hash") for p in parts]),
64
+ }
65
+ return OEEResult(
66
+ name=name, availability=availability, performance=performance,
67
+ quality=quality, oee=oee_value, performance_raw=performance,
68
+ utilization=utilization, teep=teep,
69
+ planned_production_time=s_planned, run_time=s_run, net_run_time=s_net,
70
+ fully_productive_time=s_fp, all_time=s_all, schedule_loss=schedule_loss,
71
+ availability_loss=s_planned - s_run, performance_loss=s_run - s_net,
72
+ quality_loss=s_net - s_fp, total_count=s_total, good_count=s_good,
73
+ reject_count=s_total - s_good, target_oee=target_oee,
74
+ alerts=tuple(alerts), meta=meta,
75
+ )
oee-0.1.0/oee/core.py ADDED
@@ -0,0 +1,165 @@
1
+ """Overall Equipment Effectiveness from the standard definitions.
2
+
3
+ OEE = Availability x Performance x Quality, with
4
+
5
+ Availability = run time / planned production time
6
+ Performance = (ideal cycle time x total count) / run time
7
+ Quality = good count / total count
8
+
9
+ All times must be in the same unit, and ``ideal_cycle_time`` in that unit per
10
+ piece (or pass ``ideal_rate`` in pieces per that unit). The full time waterfall
11
+ (planned -> run -> net run -> fully productive) and each loss are returned, plus
12
+ TEEP and utilization when ``all_time`` is given.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import math
18
+
19
+ from ._result import Alert, OEEResult, data_hash, utcnow
20
+ from ._version import __version__
21
+
22
+
23
+ def _finite(value: float, label: str) -> float:
24
+ if not isinstance(value, (int, float)) or isinstance(value, bool):
25
+ raise TypeError(f"{label} must be a number")
26
+ value = float(value)
27
+ if not math.isfinite(value):
28
+ raise ValueError(f"{label} must be finite")
29
+ return value
30
+
31
+
32
+ def _exactly_one(a, b, name_a: str, name_b: str):
33
+ if (a is None) == (b is None):
34
+ raise ValueError(f"provide exactly one of {name_a} or {name_b}")
35
+ return a, b
36
+
37
+
38
+ def oee(planned_production_time, *, run_time=None, downtime=None,
39
+ ideal_cycle_time=None, ideal_rate=None, total_count,
40
+ good_count=None, reject_count=None, all_time=None,
41
+ target_oee: float = 0.85, name: str | None = None) -> OEEResult:
42
+ """Compute OEE and the time/loss waterfall from times and piece counts."""
43
+ planned = _finite(planned_production_time, "planned_production_time")
44
+ if planned <= 0:
45
+ raise ValueError("planned_production_time must be positive")
46
+
47
+ _exactly_one(run_time, downtime, "run_time", "downtime")
48
+ run = _finite(run_time, "run_time") if run_time is not None \
49
+ else planned - _finite(downtime, "downtime")
50
+ if not 0 <= run <= planned:
51
+ raise ValueError("run_time must be between 0 and planned_production_time")
52
+
53
+ _exactly_one(ideal_cycle_time, ideal_rate, "ideal_cycle_time", "ideal_rate")
54
+ if ideal_cycle_time is not None:
55
+ cycle = _finite(ideal_cycle_time, "ideal_cycle_time")
56
+ else:
57
+ rate = _finite(ideal_rate, "ideal_rate")
58
+ if rate <= 0:
59
+ raise ValueError("ideal_rate must be positive")
60
+ cycle = 1.0 / rate
61
+ if cycle <= 0:
62
+ raise ValueError("ideal_cycle_time must be positive")
63
+
64
+ total = _finite(total_count, "total_count")
65
+ if total <= 0:
66
+ raise ValueError("total_count must be positive")
67
+ _exactly_one(good_count, reject_count, "good_count", "reject_count")
68
+ good = _finite(good_count, "good_count") if good_count is not None \
69
+ else total - _finite(reject_count, "reject_count")
70
+ if not 0 <= good <= total:
71
+ raise ValueError("good_count must be between 0 and total_count")
72
+ reject = total - good
73
+
74
+ alerts: list[Alert] = []
75
+
76
+ availability = run / planned if planned else 0.0
77
+
78
+ ideal_time_total = cycle * total
79
+ performance_raw = ideal_time_total / run if run > 0 else 0.0
80
+ if performance_raw > 1.0 + 1e-9:
81
+ performance = 1.0
82
+ net_run = run
83
+ alerts.append(Alert(
84
+ "performance", "performance exceeds 100%; capped at 100% (check "
85
+ "ideal_cycle_time / ideal_rate and counts)", "warning"))
86
+ else:
87
+ performance = performance_raw
88
+ net_run = ideal_time_total
89
+
90
+ quality = good / total
91
+ fully_productive = quality * net_run
92
+ oee_value = availability * performance * quality
93
+
94
+ schedule_loss = None
95
+ utilization = None
96
+ teep = None
97
+ if all_time is not None:
98
+ at = _finite(all_time, "all_time")
99
+ if at < planned:
100
+ raise ValueError("all_time must be at least planned_production_time")
101
+ schedule_loss = at - planned
102
+ utilization = planned / at if at > 0 else 0.0
103
+ teep = fully_productive / at if at > 0 else 0.0
104
+
105
+ if not 0 < target_oee <= 1:
106
+ raise ValueError("target_oee must be in (0, 1]")
107
+ if oee_value < target_oee:
108
+ alerts.append(Alert(
109
+ "oee", f"OEE {oee_value * 100:.1f}% is below the target "
110
+ f"{target_oee * 100:.0f}%", "info"))
111
+
112
+ meta = {
113
+ "computed_at": utcnow(),
114
+ "version": __version__,
115
+ "method": "time_and_counts",
116
+ "input_hash": data_hash({
117
+ "planned": planned, "run": run, "cycle": cycle,
118
+ "total": total, "good": good, "all_time": all_time,
119
+ }),
120
+ }
121
+ return OEEResult(
122
+ name=name, availability=availability, performance=performance,
123
+ quality=quality, oee=oee_value, performance_raw=performance_raw,
124
+ utilization=utilization, teep=teep,
125
+ planned_production_time=planned, run_time=run, net_run_time=net_run,
126
+ fully_productive_time=fully_productive, all_time=all_time,
127
+ schedule_loss=schedule_loss, availability_loss=planned - run,
128
+ performance_loss=run - net_run, quality_loss=net_run - fully_productive,
129
+ total_count=total, good_count=good, reject_count=reject,
130
+ target_oee=target_oee, alerts=tuple(alerts), meta=meta,
131
+ )
132
+
133
+
134
+ def oee_from_factors(availability, performance, quality, *,
135
+ target_oee: float = 0.85, name: str | None = None) -> OEEResult:
136
+ """Compute OEE from the three factors directly (no times or counts)."""
137
+ a = _finite(availability, "availability")
138
+ p = _finite(performance, "performance")
139
+ q = _finite(quality, "quality")
140
+ alerts: list[Alert] = []
141
+ for label, value in (("availability", a), ("performance", p), ("quality", q)):
142
+ if not 0 <= value <= 1.0 + 1e-9:
143
+ raise ValueError(f"{label} must be between 0 and 1")
144
+ if not 0 < target_oee <= 1:
145
+ raise ValueError("target_oee must be in (0, 1]")
146
+ oee_value = a * p * q
147
+ if oee_value < target_oee:
148
+ alerts.append(Alert(
149
+ "oee", f"OEE {oee_value * 100:.1f}% is below the target "
150
+ f"{target_oee * 100:.0f}%", "info"))
151
+ meta = {
152
+ "computed_at": utcnow(),
153
+ "version": __version__,
154
+ "method": "factors",
155
+ "input_hash": data_hash({"a": a, "p": p, "q": q}),
156
+ }
157
+ return OEEResult(
158
+ name=name, availability=a, performance=p, quality=q, oee=oee_value,
159
+ performance_raw=p, utilization=None, teep=None,
160
+ planned_production_time=None, run_time=None, net_run_time=None,
161
+ fully_productive_time=None, all_time=None, schedule_loss=None,
162
+ availability_loss=None, performance_loss=None, quality_loss=None,
163
+ total_count=None, good_count=None, reject_count=None,
164
+ target_oee=target_oee, alerts=tuple(alerts), meta=meta,
165
+ )
oee-0.1.0/oee/py.typed ADDED
File without changes
@@ -0,0 +1,141 @@
1
+ Metadata-Version: 2.4
2
+ Name: oee
3
+ Version: 0.1.0
4
+ Summary: Overall Equipment Effectiveness for Python: availability, performance and quality with the full time waterfall, TEEP, and correct multi-machine roll-up. Computed from the standard definitions and validated against published examples.
5
+ Author: Atakan Arikan
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/arikanatakan/oee
8
+ Project-URL: Repository, https://github.com/arikanatakan/oee
9
+ Project-URL: Issues, https://github.com/arikanatakan/oee/issues
10
+ Keywords: oee,overall-equipment-effectiveness,manufacturing,tpm,teep,availability,performance,quality,six-big-losses,production,lean
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Manufacturing
13
+ Classifier: Intended Audience :: Science/Research
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Topic :: Scientific/Engineering
20
+ Requires-Python: >=3.10
21
+ Description-Content-Type: text/markdown
22
+ License-File: LICENSE
23
+ Provides-Extra: dev
24
+ Requires-Dist: pytest>=7; extra == "dev"
25
+ Requires-Dist: ruff; extra == "dev"
26
+ Requires-Dist: build; extra == "dev"
27
+ Dynamic: license-file
28
+
29
+ # oee
30
+
31
+ [![CI](https://github.com/arikanatakan/oee/actions/workflows/ci.yml/badge.svg)](https://github.com/arikanatakan/oee/actions/workflows/ci.yml)
32
+ [![PyPI](https://img.shields.io/pypi/v/oee)](https://pypi.org/project/oee/)
33
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
34
+
35
+ Overall Equipment Effectiveness for Python.
36
+
37
+ Compute OEE (Availability x Performance x Quality) from machine times and piece
38
+ counts, get the full time waterfall and the three loss categories, TEEP and
39
+ utilization, and roll figures up correctly across machines and shifts. Computed
40
+ from the standard definitions and validated against published worked examples.
41
+
42
+ ## Motivation
43
+
44
+ OEE is the standard manufacturing efficiency metric, but Python has no library
45
+ for it: what exists is monitoring *applications* (Flask/Django dashboards) or
46
+ one-off tutorial scripts. The arithmetic looks trivial (three numbers
47
+ multiplied) and that is exactly why it is usually done wrong:
48
+
49
+ * the time waterfall (planned -> run -> net run -> fully productive) and where
50
+ each loss sits is skipped;
51
+ * TEEP and utilization (which capture schedule loss) are left out;
52
+ * and figures are *averaged* across machines, which is incorrect: a fast machine
53
+ and a slow one do not combine to the mean of their OEEs.
54
+
55
+ `oee` does these properly, from the standard definitions, and returns one result
56
+ with the factors, the waterfall, every loss, and provenance.
57
+
58
+ ```
59
+ pip install oee
60
+ ```
61
+
62
+ No runtime dependencies.
63
+
64
+ ## Usage
65
+
66
+ The canonical worked example (Vorne's *Fast Guide to OEE*):
67
+
68
+ ```python
69
+ import oee
70
+
71
+ r = oee.oee(
72
+ planned_production_time=420, # minutes (480 shift - 60 of breaks)
73
+ downtime=47,
74
+ ideal_rate=60, # pieces per minute
75
+ total_count=19271,
76
+ reject_count=423,
77
+ all_time=480, # optional, for TEEP and utilization
78
+ )
79
+ r.availability # 0.888
80
+ r.performance # 0.861
81
+ r.quality # 0.978
82
+ r.oee # 0.748
83
+ r.teep # 0.654
84
+ print(r.summary())
85
+ ```
86
+
87
+ Roll up across machines (correctly, not by averaging):
88
+
89
+ ```python
90
+ m1 = oee.oee(planned_production_time=100, run_time=90, ideal_cycle_time=1,
91
+ total_count=80, good_count=80) # OEE 0.80
92
+ m2 = oee.oee(planned_production_time=300, run_time=150, ideal_cycle_time=1,
93
+ total_count=150, good_count=135) # OEE 0.45
94
+
95
+ line = oee.aggregate([m1, m2])
96
+ line.oee # 0.5375, not the 0.625 average of the two
97
+ ```
98
+
99
+ When you already have the three factors:
100
+
101
+ ```python
102
+ oee.oee_from_factors(0.90, 0.95, 0.999).world_class # True (OEE >= 85%)
103
+ ```
104
+
105
+ Every result carries the factors, the time waterfall, the losses, `world_class`
106
+ and `meets_target` flags, `summary()`, and a JSON-safe `to_dict()` with
107
+ provenance (version, input hash, timestamp).
108
+
109
+ ## What it computes
110
+
111
+ | Group | Output |
112
+ |-------|--------|
113
+ | Factors | availability, performance, quality, OEE |
114
+ | Extended | TEEP, utilization (when total calendar time is given) |
115
+ | Waterfall | planned -> run -> net run -> fully productive time, with schedule, availability, performance and quality losses |
116
+ | Roll-up | correct aggregation across machines, lines and shifts |
117
+
118
+ All times must be in the same unit; `ideal_cycle_time` is that unit per piece
119
+ (or pass `ideal_rate` in pieces per that unit). Performance above 100% is capped
120
+ and flagged, since it means the ideal rate or counts are off.
121
+
122
+ ## Status
123
+
124
+ Version 0.1.0. Single-machine OEE, the time waterfall, TEEP/utilization, and
125
+ correct roll-up. The `OEEResult` contract is append-only from here.
126
+
127
+ ## Roadmap
128
+
129
+ | Version | Scope |
130
+ |---------|-------|
131
+ | 0.2 | the six big losses (breakdown/setup, minor stops/speed, defects/startup) and a downtime-reason Pareto; computing OEE from an event log |
132
+ | 0.3 | plotting (the OEE waterfall, six-big-losses and trend charts) as an optional extra |
133
+ | 0.4 | an MCP server so an agent can compute and explain OEE |
134
+
135
+ Out of scope: data collection / machine connectivity (that is the job of an
136
+ MES or an IoT dashboard); `oee` is the calculation layer they can build on.
137
+
138
+ ## License
139
+
140
+ MIT. Written and maintained by [Atakan Arikan](https://github.com/arikanatakan),
141
+ MSc Student at Tsinghua University and Politecnico di Milano.
@@ -0,0 +1,17 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ oee/__init__.py
5
+ oee/_result.py
6
+ oee/_version.py
7
+ oee/aggregate.py
8
+ oee/core.py
9
+ oee/py.typed
10
+ oee.egg-info/PKG-INFO
11
+ oee.egg-info/SOURCES.txt
12
+ oee.egg-info/dependency_links.txt
13
+ oee.egg-info/requires.txt
14
+ oee.egg-info/top_level.txt
15
+ tests/test_aggregate.py
16
+ tests/test_core.py
17
+ tests/test_validation_suite.py
@@ -0,0 +1,5 @@
1
+
2
+ [dev]
3
+ pytest>=7
4
+ ruff
5
+ build
@@ -0,0 +1 @@
1
+ oee
@@ -0,0 +1,49 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "oee"
7
+ version = "0.1.0"
8
+ description = "Overall Equipment Effectiveness for Python: availability, performance and quality with the full time waterfall, TEEP, and correct multi-machine roll-up. Computed from the standard definitions and validated against published examples."
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ authors = [{ name = "Atakan Arikan" }]
12
+ requires-python = ">=3.10"
13
+ dependencies = []
14
+ keywords = [
15
+ "oee", "overall-equipment-effectiveness", "manufacturing", "tpm", "teep",
16
+ "availability", "performance", "quality", "six-big-losses",
17
+ "production", "lean",
18
+ ]
19
+ classifiers = [
20
+ "Development Status :: 4 - Beta",
21
+ "Intended Audience :: Manufacturing",
22
+ "Intended Audience :: Science/Research",
23
+ "License :: OSI Approved :: MIT License",
24
+ "Programming Language :: Python :: 3",
25
+ "Programming Language :: Python :: 3.10",
26
+ "Programming Language :: Python :: 3.11",
27
+ "Programming Language :: Python :: 3.12",
28
+ "Topic :: Scientific/Engineering",
29
+ ]
30
+
31
+ [project.optional-dependencies]
32
+ dev = ["pytest>=7", "ruff", "build"]
33
+
34
+ [project.urls]
35
+ Homepage = "https://github.com/arikanatakan/oee"
36
+ Repository = "https://github.com/arikanatakan/oee"
37
+ Issues = "https://github.com/arikanatakan/oee/issues"
38
+
39
+ [tool.setuptools.packages.find]
40
+ include = ["oee*"]
41
+
42
+ [tool.setuptools.package-data]
43
+ oee = ["py.typed"]
44
+
45
+ [tool.ruff]
46
+ line-length = 88
47
+
48
+ [tool.pytest.ini_options]
49
+ testpaths = ["tests"]
oee-0.1.0/setup.cfg ADDED
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,70 @@
1
+ import pytest
2
+
3
+ import oee
4
+
5
+
6
+ def test_rollup_is_not_the_average_of_oees():
7
+ m1 = oee.oee(planned_production_time=100, run_time=90, ideal_cycle_time=1,
8
+ total_count=80, good_count=80)
9
+ m2 = oee.oee(planned_production_time=300, run_time=150, ideal_cycle_time=1,
10
+ total_count=150, good_count=135)
11
+ agg = oee.aggregate([m1, m2])
12
+ naive = (m1.oee + m2.oee) / 2
13
+ assert agg.oee == pytest.approx(0.5375, abs=1e-4)
14
+ assert agg.oee != pytest.approx(naive, abs=1e-3)
15
+
16
+
17
+ def test_buckets_sum():
18
+ m1 = oee.oee(planned_production_time=100, run_time=90, ideal_cycle_time=1,
19
+ total_count=80, good_count=80)
20
+ m2 = oee.oee(planned_production_time=300, run_time=150, ideal_cycle_time=1,
21
+ total_count=150, good_count=135)
22
+ agg = oee.aggregate([m1, m2])
23
+ assert agg.planned_production_time == 400
24
+ assert agg.run_time == 240
25
+ assert agg.total_count == 230
26
+ assert agg.good_count == 215
27
+ assert agg.oee == pytest.approx(agg.fully_productive_time / agg.planned_production_time)
28
+
29
+
30
+ def test_single_part_matches_itself():
31
+ m = oee.oee(planned_production_time=100, run_time=90, ideal_cycle_time=1,
32
+ total_count=80, good_count=80)
33
+ agg = oee.aggregate([m])
34
+ assert agg.oee == pytest.approx(m.oee)
35
+ assert agg.availability == pytest.approx(m.availability)
36
+
37
+
38
+ def test_teep_when_all_parts_have_all_time():
39
+ m1 = oee.oee(planned_production_time=100, run_time=90, ideal_cycle_time=1,
40
+ total_count=80, good_count=80, all_time=120)
41
+ m2 = oee.oee(planned_production_time=300, run_time=150, ideal_cycle_time=1,
42
+ total_count=150, good_count=135, all_time=360)
43
+ agg = oee.aggregate([m1, m2])
44
+ assert agg.teep is not None
45
+ assert agg.utilization == pytest.approx(400 / 480)
46
+
47
+
48
+ def test_teep_none_when_a_part_lacks_all_time():
49
+ m1 = oee.oee(planned_production_time=100, run_time=90, ideal_cycle_time=1,
50
+ total_count=80, good_count=80, all_time=120)
51
+ m2 = oee.oee(planned_production_time=300, run_time=150, ideal_cycle_time=1,
52
+ total_count=150, good_count=135)
53
+ agg = oee.aggregate([m1, m2])
54
+ assert agg.teep is None
55
+
56
+
57
+ def test_factors_only_part_cannot_be_aggregated():
58
+ f = oee.oee_from_factors(0.9, 0.9, 0.9)
59
+ with pytest.raises(ValueError):
60
+ oee.aggregate([f])
61
+
62
+
63
+ def test_empty_raises():
64
+ with pytest.raises(ValueError):
65
+ oee.aggregate([])
66
+
67
+
68
+ def test_non_result_raises():
69
+ with pytest.raises(TypeError):
70
+ oee.aggregate([{"oee": 0.5}])
@@ -0,0 +1,106 @@
1
+ import json
2
+
3
+ import pytest
4
+
5
+ import oee
6
+
7
+
8
+ def _vorne(**over):
9
+ kw = dict(planned_production_time=420, downtime=47, ideal_rate=60,
10
+ total_count=19271, reject_count=423)
11
+ kw.update(over)
12
+ return oee.oee(**kw)
13
+
14
+
15
+ def test_basic_factors_and_oee():
16
+ r = _vorne()
17
+ assert r.oee == pytest.approx(0.7480, abs=0.001)
18
+ assert r.availability == pytest.approx(0.8881, abs=0.001)
19
+ assert not r.world_class
20
+ assert not r.meets_target
21
+
22
+
23
+ def test_run_time_and_downtime_agree():
24
+ a = oee.oee(planned_production_time=420, downtime=47, ideal_rate=60,
25
+ total_count=19271, reject_count=423)
26
+ b = oee.oee(planned_production_time=420, run_time=373, ideal_rate=60,
27
+ total_count=19271, reject_count=423)
28
+ assert a.oee == pytest.approx(b.oee)
29
+
30
+
31
+ def test_ideal_rate_and_cycle_agree():
32
+ a = oee.oee(planned_production_time=420, downtime=47, ideal_rate=60,
33
+ total_count=19271, reject_count=423)
34
+ b = oee.oee(planned_production_time=420, downtime=47, ideal_cycle_time=1 / 60,
35
+ total_count=19271, reject_count=423)
36
+ assert a.performance == pytest.approx(b.performance)
37
+
38
+
39
+ def test_waterfall_adds_up():
40
+ r = _vorne(all_time=480)
41
+ assert r.schedule_loss + r.planned_production_time == pytest.approx(r.all_time)
42
+ assert r.availability_loss + r.run_time == pytest.approx(r.planned_production_time)
43
+ assert r.performance_loss + r.net_run_time == pytest.approx(r.run_time)
44
+ assert r.quality_loss + r.fully_productive_time == pytest.approx(r.net_run_time)
45
+ assert r.oee == pytest.approx(r.fully_productive_time / r.planned_production_time)
46
+
47
+
48
+ def test_teep_is_oee_times_utilization():
49
+ r = _vorne(all_time=480)
50
+ assert r.teep == pytest.approx(r.oee * r.utilization, abs=1e-9)
51
+
52
+
53
+ def test_performance_over_100_is_capped_and_flagged():
54
+ r = oee.oee(planned_production_time=100, run_time=100, ideal_cycle_time=2,
55
+ total_count=100, good_count=100)
56
+ assert r.performance == 1.0
57
+ assert r.performance_raw == pytest.approx(2.0)
58
+ assert any(a.indicator == "performance" for a in r.alerts)
59
+
60
+
61
+ def test_world_class_flag():
62
+ r = oee.oee_from_factors(0.90, 0.95, 0.999)
63
+ assert r.world_class
64
+ assert r.oee == pytest.approx(0.8541, abs=0.001)
65
+
66
+
67
+ def test_to_dict_json_serializable():
68
+ d = _vorne(all_time=480).to_dict()
69
+ json.dumps(d)
70
+ assert d["schema"] == 1
71
+ assert "input_hash" in d["meta"]
72
+ assert d["factors"]["oee"] == pytest.approx(0.7480, abs=0.001)
73
+
74
+
75
+ def test_summary_plain_text():
76
+ t = _vorne().summary()
77
+ assert "OEE" in t and "Verdict" in t
78
+ assert "—" not in t # no em dash
79
+
80
+
81
+ def test_provenance_changes_with_input():
82
+ a = _vorne().meta["input_hash"]
83
+ b = _vorne(reject_count=500).meta["input_hash"]
84
+ assert a != b
85
+
86
+
87
+ @pytest.mark.parametrize("over", [
88
+ {"planned_production_time": 0},
89
+ {"downtime": 500}, # run time negative
90
+ {"total_count": 0},
91
+ {"good_count": 99999}, # good > total
92
+ ])
93
+ def test_invalid_inputs_raise(over):
94
+ with pytest.raises(ValueError):
95
+ _vorne(**over)
96
+
97
+
98
+ def test_both_run_and_downtime_raises():
99
+ with pytest.raises(ValueError):
100
+ oee.oee(planned_production_time=420, run_time=373, downtime=47,
101
+ ideal_rate=60, total_count=100, good_count=100)
102
+
103
+
104
+ def test_factors_out_of_range_raises():
105
+ with pytest.raises(ValueError):
106
+ oee.oee_from_factors(1.2, 0.9, 0.9)
@@ -0,0 +1,27 @@
1
+ """Check oee against published and hand-derived worked examples."""
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ import pytest
7
+
8
+ import oee
9
+
10
+ CASES = json.loads((Path(__file__).parent / "validation_cases.json").read_text())["cases"]
11
+
12
+
13
+ def _compute(case):
14
+ if case["method"] == "time_and_counts":
15
+ return oee.oee(**case["inputs"])
16
+ if case["method"] == "factors":
17
+ return oee.oee_from_factors(**case["inputs"])
18
+ raise ValueError(case["method"])
19
+
20
+
21
+ @pytest.mark.parametrize("case", CASES, ids=[c["id"] for c in CASES])
22
+ def test_case(case):
23
+ result = _compute(case)
24
+ tol = case["tol"]
25
+ for field, expected in case["expected"].items():
26
+ actual = getattr(result, field)
27
+ assert actual == pytest.approx(expected, abs=tol), f"{case['id']}.{field}"