perfact-api-pd-fastapi 0.5__tar.gz → 0.6__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.
Files changed (27) hide show
  1. {perfact_api_pd_fastapi-0.5 → perfact_api_pd_fastapi-0.6}/PKG-INFO +1 -1
  2. perfact_api_pd_fastapi-0.6/src/perfact/api/pd_fastapi/kpi.py +221 -0
  3. {perfact_api_pd_fastapi-0.5 → perfact_api_pd_fastapi-0.6}/src/perfact_api_pd_fastapi.egg-info/PKG-INFO +1 -1
  4. {perfact_api_pd_fastapi-0.5 → perfact_api_pd_fastapi-0.6}/src/perfact_api_pd_fastapi.egg-info/SOURCES.txt +3 -0
  5. perfact_api_pd_fastapi-0.6/tests/__init__.py +0 -0
  6. perfact_api_pd_fastapi-0.6/tests/conftest.py +0 -0
  7. perfact_api_pd_fastapi-0.6/tests/helpers.py +17 -0
  8. {perfact_api_pd_fastapi-0.5 → perfact_api_pd_fastapi-0.6}/tests/test_init.py +3 -1
  9. perfact_api_pd_fastapi-0.6/tests/test_kpi.py +333 -0
  10. {perfact_api_pd_fastapi-0.5 → perfact_api_pd_fastapi-0.6}/tests/test_pdunit.py +4 -2
  11. {perfact_api_pd_fastapi-0.5 → perfact_api_pd_fastapi-0.6}/tests/test_performance.py +4 -2
  12. perfact_api_pd_fastapi-0.5/src/perfact/api/pd_fastapi/kpi.py +0 -133
  13. perfact_api_pd_fastapi-0.5/tests/test_kpi.py +0 -105
  14. {perfact_api_pd_fastapi-0.5 → perfact_api_pd_fastapi-0.6}/README.md +0 -0
  15. {perfact_api_pd_fastapi-0.5 → perfact_api_pd_fastapi-0.6}/pyproject.toml +0 -0
  16. {perfact_api_pd_fastapi-0.5 → perfact_api_pd_fastapi-0.6}/setup.cfg +0 -0
  17. {perfact_api_pd_fastapi-0.5 → perfact_api_pd_fastapi-0.6}/src/perfact/api/pd_fastapi/__init__.py +0 -0
  18. {perfact_api_pd_fastapi-0.5 → perfact_api_pd_fastapi-0.6}/src/perfact/api/pd_fastapi/pdunit.py +0 -0
  19. {perfact_api_pd_fastapi-0.5 → perfact_api_pd_fastapi-0.6}/src/perfact/api/pd_fastapi/pdunitlch.py +0 -0
  20. {perfact_api_pd_fastapi-0.5 → perfact_api_pd_fastapi-0.6}/src/perfact/api/pd_fastapi/performancedata.py +0 -0
  21. {perfact_api_pd_fastapi-0.5 → perfact_api_pd_fastapi-0.6}/src/perfact/api/pd_fastapi/py.typed +0 -0
  22. {perfact_api_pd_fastapi-0.5 → perfact_api_pd_fastapi-0.6}/src/perfact_api_pd_fastapi.egg-info/dependency_links.txt +0 -0
  23. {perfact_api_pd_fastapi-0.5 → perfact_api_pd_fastapi-0.6}/src/perfact_api_pd_fastapi.egg-info/entry_points.txt +0 -0
  24. {perfact_api_pd_fastapi-0.5 → perfact_api_pd_fastapi-0.6}/src/perfact_api_pd_fastapi.egg-info/requires.txt +0 -0
  25. {perfact_api_pd_fastapi-0.5 → perfact_api_pd_fastapi-0.6}/src/perfact_api_pd_fastapi.egg-info/top_level.txt +0 -0
  26. {perfact_api_pd_fastapi-0.5 → perfact_api_pd_fastapi-0.6}/tests/locustfile.py +0 -0
  27. {perfact_api_pd_fastapi-0.5 → perfact_api_pd_fastapi-0.6}/tox.ini +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: perfact-api-pd-fastapi
3
- Version: 0.5
3
+ Version: 0.6
4
4
  Summary: PerFact API - SQLAlchemy+FastAPI pd apis package
5
5
  Author-email: Viktor Dick <viktor.dick@perfact.de>
6
6
  License-Expression: GPL-2.0-or-later
@@ -0,0 +1,221 @@
1
+ from datetime import datetime
2
+ from typing import Annotated, Optional
3
+
4
+ from fastapi import HTTPException
5
+ from perfact.api.main import Auth, VisibilityRegistryDep
6
+ from perfact.api.main.auth import require_roles
7
+ from perfact.api.main.dbsession import APIRouter, DBSession
8
+ from perfact.api.pd.model.pdunit import PdUnit
9
+ from perfact.api.pd.services.oee.availability import compute_availability
10
+ from perfact.api.pd.services.oee.breakdown import oee_breakdown_computation
11
+ from perfact.api.pd.services.oee.prod_metrics import (
12
+ compute_performance,
13
+ compute_quality,
14
+ )
15
+ from perfact.api.pd.services.oee.prod_records import get_prod_records
16
+ from perfact.api.pd.services.oee.state_spans import get_state_spans
17
+ from perfact.api.pd.services.oee.summary import oee_workcenter_time_computation
18
+ from pydantic import BaseModel, BeforeValidator
19
+ from sqlalchemy import select
20
+
21
+ routes = APIRouter()
22
+
23
+
24
+ def _require_visible_unit(
25
+ session: DBSession,
26
+ auth: Auth,
27
+ visibility: VisibilityRegistryDep,
28
+ pdunit_id: int,
29
+ ) -> PdUnit:
30
+ """Fetch PdUnit with visibility policy applied. Raise 404 if not found or hidden."""
31
+ policy = visibility.get(PdUnit)
32
+ unit = session.scalar(
33
+ select(PdUnit).where(PdUnit.id == pdunit_id).where(policy.filter(auth))
34
+ )
35
+ if unit is None:
36
+ raise HTTPException(status_code=404)
37
+ return unit
38
+
39
+
40
+ def parse_datetime_with_UTC_offset(value: str) -> datetime:
41
+ try:
42
+ return datetime.fromisoformat(value)
43
+ except ValueError:
44
+ raise ValueError(
45
+ f"Invalid datetime format. \
46
+ Expected: yyyy-mm-ddThh:mm:ss.sssss+HH:00, got: {value}"
47
+ )
48
+
49
+
50
+ UTCOffsetDatetime = Annotated[datetime, BeforeValidator(parse_datetime_with_UTC_offset)]
51
+
52
+
53
+ @routes.get("/kpi/oee/availability", tags=["pd"], summary="Route for OEE availability")
54
+ @require_roles("PdKpi")
55
+ def oee_availability(
56
+ session: DBSession,
57
+ auth: Auth,
58
+ visibility: VisibilityRegistryDep,
59
+ pdunit_id: int,
60
+ # ex w timezone "2025-05-20T13:54:15.555103+03:00"
61
+ # ex wo timezone "2025-05-20T13:54:15.555103"
62
+ starttime: datetime,
63
+ # ex w timezone "2025-05-22T11:53:13.561411+03:00"
64
+ # ex wo timezone "2025-05-22T11:53:13.561411"
65
+ stoptime: datetime,
66
+ ) -> float:
67
+ """
68
+ Route for OEE availability
69
+ Uses the availability_computation function
70
+ Returns single floating point value
71
+ """
72
+
73
+ unit = _require_visible_unit(session, auth, visibility, pdunit_id)
74
+ spans = get_state_spans(session, pdunit_id, starttime, stoptime)
75
+ shifts = list(session.scalars(unit.shifts_in_range_stmt(starttime, stoptime)).all())
76
+ return compute_availability(spans, shifts, starttime, stoptime)
77
+
78
+
79
+ @routes.get("/kpi/oee/performance", tags=["pd"], summary="Route for OEE performance")
80
+ @require_roles("PdKpi")
81
+ def oee_performance(
82
+ session: DBSession,
83
+ auth: Auth,
84
+ visibility: VisibilityRegistryDep,
85
+ pdunit_id: int,
86
+ # ex w timezone "2025-05-20T13:54:15.555103+03:00"
87
+ # ex wo timezone "2025-05-20T13:54:15.555103"
88
+ starttime: datetime,
89
+ # ex w timezone "2025-05-22T11:53:13.561411+03:00"
90
+ # ex wo timezone "2025-05-22T11:53:13.561411"
91
+ stoptime: datetime,
92
+ ) -> float:
93
+ """
94
+ Route for OEE availability
95
+ Uses the performance_computation function
96
+ Returns single floating point value
97
+ """
98
+ _require_visible_unit(session, auth, visibility, pdunit_id)
99
+ records = get_prod_records(session, pdunit_id, starttime, stoptime)
100
+ spans = get_state_spans(session, pdunit_id, starttime, stoptime)
101
+ isexecution_spans = [s for s in spans if s.isexecution]
102
+ return compute_performance(
103
+ records, starttime, stoptime, isexecution_spans=isexecution_spans
104
+ )
105
+
106
+
107
+ @routes.get("/kpi/oee/quality", tags=["pd"], summary="Route for OEE quality")
108
+ # response_model=Sequence[PdordlchGet])
109
+ @require_roles("PdKpi")
110
+ def oee_quality(
111
+ session: DBSession,
112
+ auth: Auth,
113
+ visibility: VisibilityRegistryDep,
114
+ pdunit_id: int,
115
+ # ex w timezone "2025-05-20T13:54:15.555103+03:00"
116
+ # ex wo timezone "2025-05-20T13:54:15.555103"
117
+ starttime: datetime,
118
+ # ex w timezone "2025-05-22T11:53:13.561411+03:00"
119
+ # ex wo timezone "2025-05-22T11:53:13.561411"
120
+ stoptime: datetime,
121
+ ) -> float | None:
122
+ """
123
+ Route for OEE quality
124
+ Uses the quality_computation function
125
+ Returns single floating point value
126
+ """
127
+ _require_visible_unit(session, auth, visibility, pdunit_id)
128
+ records = get_prod_records(session, pdunit_id, starttime, stoptime)
129
+ return compute_quality(records, starttime, stoptime)
130
+
131
+
132
+ class OEECalculationResponse(BaseModel):
133
+ """Response model for GET /kpi/oee.
134
+
135
+ Includes A/P/Q/OEE values plus workcenter-level time breakdown and
136
+ quantity aggregates across all operations in the requested window.
137
+ See ADR 0003.
138
+ """
139
+
140
+ availability: float
141
+ performance: float
142
+ quality: float | None
143
+ oee: float | None
144
+ planned_machine_time_seconds: float
145
+ unplanned_downtime_seconds: float
146
+ setup_time_deviation_seconds: float
147
+ productive_time_seconds: float
148
+ pdordprd_quantitytotal: float
149
+ pdordprd_quantity: float
150
+
151
+
152
+ @routes.get("/kpi/oee", tags=["pd"], summary="OEE calculation")
153
+ @require_roles("PdKpi")
154
+ def oee_calculation(
155
+ session: DBSession,
156
+ auth: Auth,
157
+ visibility: VisibilityRegistryDep,
158
+ pdunit_id: int,
159
+ starttime: datetime,
160
+ stoptime: datetime,
161
+ ) -> OEECalculationResponse:
162
+ """OEE with time breakdown and quantity aggregates for one work centre."""
163
+ unit = _require_visible_unit(session, auth, visibility, pdunit_id)
164
+ spans = get_state_spans(session, pdunit_id, starttime, stoptime)
165
+ shifts = list(session.scalars(unit.shifts_in_range_stmt(starttime, stoptime)).all())
166
+ availability = compute_availability(spans, shifts, starttime, stoptime)
167
+ prod_records = get_prod_records(session, pdunit_id, starttime, stoptime)
168
+ isexecution_spans = [s for s in spans if s.isexecution]
169
+ performance = compute_performance(
170
+ prod_records, starttime, stoptime, isexecution_spans=isexecution_spans
171
+ )
172
+ quality = compute_quality(prod_records, starttime, stoptime)
173
+ oee = availability * performance * quality if quality is not None else None
174
+ summary = oee_workcenter_time_computation(session, pdunit_id, starttime, stoptime)
175
+ return OEECalculationResponse(
176
+ availability=availability,
177
+ performance=performance,
178
+ quality=quality,
179
+ oee=oee,
180
+ **summary.__dict__,
181
+ )
182
+
183
+
184
+ class OEEBreakdownResponse(BaseModel):
185
+ pdord_id: int
186
+ pdord_num: str
187
+ planned_machine_time_seconds: float
188
+ unplanned_downtime_seconds: float
189
+ setup_time_deviation_seconds: float
190
+ productive_time_seconds: float
191
+ availability: float
192
+ performance: float
193
+ quality: float | None
194
+ oee: float | None
195
+ pdordprd_quantitytotal: float
196
+ pdordprd_quantity: float
197
+ equipment: list[str]
198
+ mtart_num: Optional[str]
199
+ mtart_name: Optional[str]
200
+ operation_start: Optional[datetime]
201
+ operation_end: Optional[datetime]
202
+
203
+
204
+ @routes.get("/kpi/oee/breakdown", tags=["pd"], summary="Per-operation OEE breakdown")
205
+ @require_roles("PdKpi")
206
+ def oee_breakdown(
207
+ session: DBSession,
208
+ auth: Auth,
209
+ visibility: VisibilityRegistryDep,
210
+ pdunit_id: int,
211
+ starttime: datetime,
212
+ stoptime: datetime,
213
+ ) -> list[OEEBreakdownResponse]:
214
+ """Return one OEE row per operation that had active time on the given unit."""
215
+ _require_visible_unit(session, auth, visibility, pdunit_id)
216
+ rows = oee_breakdown_computation(session, pdunit_id, starttime, stoptime)
217
+ return [OEEBreakdownResponse(**row.__dict__) for row in rows]
218
+
219
+
220
+ def mount(app: APIRouter):
221
+ app.include_router(routes)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: perfact-api-pd-fastapi
3
- Version: 0.5
3
+ Version: 0.6
4
4
  Summary: PerFact API - SQLAlchemy+FastAPI pd apis package
5
5
  Author-email: Viktor Dick <viktor.dick@perfact.de>
6
6
  License-Expression: GPL-2.0-or-later
@@ -13,6 +13,9 @@ src/perfact_api_pd_fastapi.egg-info/dependency_links.txt
13
13
  src/perfact_api_pd_fastapi.egg-info/entry_points.txt
14
14
  src/perfact_api_pd_fastapi.egg-info/requires.txt
15
15
  src/perfact_api_pd_fastapi.egg-info/top_level.txt
16
+ tests/__init__.py
17
+ tests/conftest.py
18
+ tests/helpers.py
16
19
  tests/locustfile.py
17
20
  tests/test_init.py
18
21
  tests/test_kpi.py
File without changes
File without changes
@@ -0,0 +1,17 @@
1
+ from fastapi.routing import APIRoute, _IncludedRouter
2
+
3
+
4
+ def collect_paths(router, prefix: str = "") -> set[str]:
5
+ """Recursively collect route paths from a router, handling _IncludedRouter wrappers.
6
+
7
+ FastAPI ≥ 0.115 lazily wraps included routers as _IncludedRouter instead of
8
+ immediately flattening them into APIRoute instances in the parent routes list.
9
+ """
10
+ paths: set[str] = set()
11
+ for route in router.routes:
12
+ if isinstance(route, APIRoute):
13
+ paths.add(prefix + route.path)
14
+ elif isinstance(route, _IncludedRouter):
15
+ sub_prefix = prefix + (route.include_context.prefix or "")
16
+ paths |= collect_paths(route.original_router, sub_prefix)
17
+ return paths
@@ -1,13 +1,15 @@
1
1
  from fastapi import FastAPI
2
2
  from perfact.api.pd_fastapi import mount
3
3
 
4
+ from .helpers import collect_paths
5
+
4
6
 
5
7
  def test_mount_adds_pd_and_pdord_routes() -> None:
6
8
  app = FastAPI()
7
9
 
8
10
  mount(app)
9
11
 
10
- paths = {route.path for route in app.routes}
12
+ paths = collect_paths(app)
11
13
  assert "/pd/kpi/oee" in paths
12
14
  assert "/pd/pdunit" in paths
13
15
  assert "/pd/pdord/get_time_performance_data_v1" in paths
@@ -0,0 +1,333 @@
1
+ import asyncio
2
+ from datetime import datetime, timezone
3
+ from unittest.mock import Mock
4
+
5
+ import pytest
6
+ from perfact.api.main.dbsession import APIRouter
7
+ from perfact.api.pd.services.oee.breakdown import OEEBreakdownRow
8
+ from perfact.api.pd.services.oee.state_spans import StateSpan
9
+ from perfact.api.pd.services.oee.summary import OEEWorkcenterSummary
10
+ from perfact.api.pd_fastapi import kpi
11
+ from psycopg.types.range import Range as PsycopgRange
12
+ from sqlalchemy.orm import Session
13
+
14
+
15
+ def test_parse_datetime_with_utc_offset_accepts_isoformat() -> None:
16
+ value = "2025-05-20T13:54:15.555103+03:00"
17
+
18
+ parsed = kpi.parse_datetime_with_UTC_offset(value)
19
+
20
+ assert parsed == datetime.fromisoformat(value)
21
+
22
+
23
+ def test_parse_datetime_with_utc_offset_rejects_invalid_input() -> None:
24
+ with pytest.raises(ValueError, match="Invalid datetime format"):
25
+ kpi.parse_datetime_with_UTC_offset("not-a-datetime")
26
+
27
+
28
+ def test_oee_performance_route_delegates_to_compute_performance(
29
+ monkeypatch: pytest.MonkeyPatch,
30
+ ) -> None:
31
+ """oee_performance delegates to get_prod_records, get_state_spans,
32
+ and compute_performance."""
33
+ session: Session = Mock()
34
+ auth = Mock()
35
+ visibility = Mock()
36
+ pdunit_id = 7
37
+ starttime = datetime(2025, 1, 1, tzinfo=timezone.utc)
38
+ stoptime = datetime(2025, 1, 2, tzinfo=timezone.utc)
39
+ expected = 0.82
40
+
41
+ monkeypatch.setattr(kpi, "_require_visible_unit", lambda *_: Mock())
42
+ monkeypatch.setattr(kpi, "get_prod_records", lambda *_: [])
43
+ monkeypatch.setattr(kpi, "get_state_spans", lambda *_: [])
44
+ monkeypatch.setattr(
45
+ kpi,
46
+ "compute_performance",
47
+ lambda records, start, stop, isexecution_spans=None: expected,
48
+ )
49
+
50
+ coro = kpi.oee_performance(
51
+ session, auth, visibility, pdunit_id, starttime, stoptime
52
+ )
53
+ result: float = asyncio.run(coro) # type: ignore[arg-type]
54
+
55
+ assert result == expected
56
+
57
+
58
+ def test_oee_quality_route_delegates_to_compute_quality(
59
+ monkeypatch: pytest.MonkeyPatch,
60
+ ) -> None:
61
+ """oee_quality delegates to get_prod_records and compute_quality."""
62
+ session: Session = Mock()
63
+ auth = Mock()
64
+ visibility = Mock()
65
+ pdunit_id = 7
66
+ starttime = datetime(2025, 1, 1, tzinfo=timezone.utc)
67
+ stoptime = datetime(2025, 1, 2, tzinfo=timezone.utc)
68
+ expected = 0.97
69
+
70
+ monkeypatch.setattr(kpi, "_require_visible_unit", lambda *_: Mock())
71
+ monkeypatch.setattr(kpi, "get_prod_records", lambda *_: [])
72
+ monkeypatch.setattr(kpi, "compute_quality", lambda records, start, stop: expected)
73
+
74
+ coro = kpi.oee_quality(session, auth, visibility, pdunit_id, starttime, stoptime)
75
+ result: float | None = asyncio.run(coro) # type: ignore[arg-type]
76
+
77
+ assert result == expected
78
+
79
+
80
+ def test_oee_availability_delegates_to_compute_availability(
81
+ monkeypatch: pytest.MonkeyPatch,
82
+ ) -> None:
83
+ """oee_availability delegates to get_state_spans and compute_availability."""
84
+ session: Session = Mock()
85
+ session.scalars.return_value.all.return_value = [] # type: ignore[attr-defined]
86
+ auth = Mock()
87
+ visibility = Mock()
88
+ pdunit_id = 7
89
+ starttime = datetime(2025, 1, 1, tzinfo=timezone.utc)
90
+ stoptime = datetime(2025, 1, 2, tzinfo=timezone.utc)
91
+ expected = 0.91
92
+ captured: list[tuple] = []
93
+
94
+ def fake_get_state_spans(s, uid, start, stop):
95
+ captured.append(("get_state_spans", uid, start, stop))
96
+ return []
97
+
98
+ def fake_compute_availability(spans, shifts, start, stop):
99
+ captured.append(("compute_availability", start, stop))
100
+ return expected
101
+
102
+ monkeypatch.setattr(kpi, "_require_visible_unit", lambda *_: Mock())
103
+ monkeypatch.setattr(kpi, "get_state_spans", fake_get_state_spans)
104
+ monkeypatch.setattr(kpi, "compute_availability", fake_compute_availability)
105
+
106
+ coro = kpi.oee_availability(
107
+ session, auth, visibility, pdunit_id, starttime, stoptime
108
+ )
109
+ result: float = asyncio.run(coro) # type: ignore[arg-type]
110
+
111
+ assert result == expected
112
+ assert ("get_state_spans", pdunit_id, starttime, stoptime) in captured
113
+ assert ("compute_availability", starttime, stoptime) in captured
114
+
115
+
116
+ def test_oee_calculation_returns_combined_values(
117
+ monkeypatch: pytest.MonkeyPatch,
118
+ ) -> None:
119
+ """Route returns OEECalculationResponse combining A/P/Q and workcenter time
120
+ summary.
121
+ """
122
+ session: Session = Mock()
123
+ pdunit_id = 3
124
+ starttime = datetime(2025, 2, 1, tzinfo=timezone.utc)
125
+ stoptime = datetime(2025, 2, 2, tzinfo=timezone.utc)
126
+ fake_summary = OEEWorkcenterSummary(
127
+ planned_machine_time_seconds=100.0,
128
+ unplanned_downtime_seconds=50.0,
129
+ setup_time_deviation_seconds=10.0,
130
+ productive_time_seconds=200.0,
131
+ pdordprd_quantitytotal=10.0,
132
+ pdordprd_quantity=9.0,
133
+ )
134
+
135
+ auth = Mock()
136
+ visibility = Mock()
137
+ session.scalars.return_value.all.return_value = [] # type: ignore[attr-defined]
138
+ monkeypatch.setattr(kpi, "_require_visible_unit", lambda *_: Mock())
139
+ monkeypatch.setattr(kpi, "get_state_spans", lambda *_: [])
140
+ monkeypatch.setattr(kpi, "compute_availability", lambda *_, **__: 0.5)
141
+ monkeypatch.setattr(kpi, "get_prod_records", lambda *_: [])
142
+ monkeypatch.setattr(kpi, "compute_performance", lambda *_, **__: 0.8)
143
+ monkeypatch.setattr(kpi, "compute_quality", lambda *_: 0.9)
144
+ monkeypatch.setattr(kpi, "oee_workcenter_time_computation", lambda *_: fake_summary)
145
+
146
+ result: kpi.OEECalculationResponse = asyncio.run(
147
+ kpi.oee_calculation(session, auth, visibility, pdunit_id, starttime, stoptime) # type: ignore[arg-type]
148
+ )
149
+
150
+ assert result.availability == 0.5
151
+ assert result.performance == 0.8
152
+ assert result.quality == 0.9
153
+ assert result.oee == pytest.approx(0.36)
154
+ assert result.planned_machine_time_seconds == 100.0
155
+ assert result.unplanned_downtime_seconds == 50.0
156
+ assert result.setup_time_deviation_seconds == 10.0
157
+ assert result.productive_time_seconds == 200.0
158
+ assert result.pdordprd_quantitytotal == 10.0
159
+ assert result.pdordprd_quantity == 9.0
160
+
161
+
162
+ def test_oee_performance_route_passes_only_isexecution_spans(
163
+ monkeypatch: pytest.MonkeyPatch,
164
+ ) -> None:
165
+ """oee_performance passes only isexecution spans to compute_performance.
166
+
167
+ Non-execution spans (e.g. setup) must be excluded so that production records
168
+ recorded during setup are not included in the performance numerator/denominator.
169
+ """
170
+ session: Session = Mock()
171
+ auth = Mock()
172
+ visibility = Mock()
173
+ pdunit_id = 7
174
+ starttime = datetime(2025, 1, 1, tzinfo=timezone.utc)
175
+ stoptime = datetime(2025, 1, 2, tzinfo=timezone.utc)
176
+
177
+ dummy_range: PsycopgRange[datetime] = PsycopgRange(None, None)
178
+ exec_span = StateSpan(
179
+ pdord_id=1, range=dummy_range, isactive=True, isexecution=True
180
+ )
181
+ setup_span = StateSpan(
182
+ pdord_id=1, range=dummy_range, isactive=True, isexecution=False
183
+ )
184
+ captured_isexecution_spans: list[StateSpan] = []
185
+
186
+ monkeypatch.setattr(kpi, "_require_visible_unit", lambda *_: Mock())
187
+ monkeypatch.setattr(kpi, "get_prod_records", lambda *_: [])
188
+ monkeypatch.setattr(kpi, "get_state_spans", lambda *_: [exec_span, setup_span])
189
+
190
+ def fake_compute_performance(records, start, stop, isexecution_spans=None):
191
+ captured_isexecution_spans.extend(isexecution_spans or [])
192
+ return 0.82
193
+
194
+ monkeypatch.setattr(kpi, "compute_performance", fake_compute_performance)
195
+
196
+ coro = kpi.oee_performance(
197
+ session, auth, visibility, pdunit_id, starttime, stoptime
198
+ )
199
+ asyncio.run(coro) # type: ignore[arg-type]
200
+
201
+ assert exec_span in captured_isexecution_spans
202
+ assert setup_span not in captured_isexecution_spans
203
+
204
+
205
+ def test_oee_calculation_performance_uses_only_isexecution_spans(
206
+ monkeypatch: pytest.MonkeyPatch,
207
+ ) -> None:
208
+ """oee_calculation passes only isexecution spans to compute_performance.
209
+
210
+ Non-execution spans must be excluded so the work-centre performance matches
211
+ the per-operation breakdown performance for single-operation scenarios.
212
+ """
213
+ session: Session = Mock()
214
+ session.scalars.return_value.all.return_value = [] # type: ignore[attr-defined]
215
+ auth = Mock()
216
+ visibility = Mock()
217
+ pdunit_id = 3
218
+ starttime = datetime(2025, 2, 1, tzinfo=timezone.utc)
219
+ stoptime = datetime(2025, 2, 2, tzinfo=timezone.utc)
220
+
221
+ dummy_range: PsycopgRange[datetime] = PsycopgRange(None, None)
222
+ exec_span = StateSpan(
223
+ pdord_id=1, range=dummy_range, isactive=True, isexecution=True
224
+ )
225
+ setup_span = StateSpan(
226
+ pdord_id=1, range=dummy_range, isactive=True, isexecution=False
227
+ )
228
+ captured_isexecution_spans: list[StateSpan] = []
229
+
230
+ monkeypatch.setattr(kpi, "_require_visible_unit", lambda *_: Mock())
231
+ monkeypatch.setattr(kpi, "get_state_spans", lambda *_: [exec_span, setup_span])
232
+ monkeypatch.setattr(kpi, "compute_availability", lambda *_, **__: 0.9)
233
+ monkeypatch.setattr(kpi, "get_prod_records", lambda *_: [])
234
+
235
+ def fake_compute_performance(records, start, stop, isexecution_spans=None):
236
+ captured_isexecution_spans.extend(isexecution_spans or [])
237
+ return 0.8
238
+
239
+ monkeypatch.setattr(kpi, "compute_performance", fake_compute_performance)
240
+ monkeypatch.setattr(kpi, "compute_quality", lambda *_: 0.9)
241
+ monkeypatch.setattr(
242
+ kpi,
243
+ "oee_workcenter_time_computation",
244
+ lambda *_: OEEWorkcenterSummary(
245
+ planned_machine_time_seconds=0.0,
246
+ unplanned_downtime_seconds=0.0,
247
+ setup_time_deviation_seconds=0.0,
248
+ productive_time_seconds=0.0,
249
+ pdordprd_quantitytotal=0.0,
250
+ pdordprd_quantity=0.0,
251
+ ),
252
+ )
253
+
254
+ coro = kpi.oee_calculation(
255
+ session, auth, visibility, pdunit_id, starttime, stoptime
256
+ )
257
+ asyncio.run(coro) # type: ignore[arg-type]
258
+
259
+ assert exec_span in captured_isexecution_spans
260
+ assert setup_span not in captured_isexecution_spans
261
+
262
+
263
+ def test_mount_includes_kpi_routes() -> None:
264
+ from .helpers import collect_paths
265
+
266
+ app_router = APIRouter()
267
+
268
+ kpi.mount(app_router)
269
+
270
+ paths = collect_paths(app_router)
271
+ assert "/kpi/oee/availability" in paths
272
+ assert "/kpi/oee/performance" in paths
273
+ assert "/kpi/oee/quality" in paths
274
+ assert "/kpi/oee" in paths
275
+ assert "/kpi/oee/breakdown" in paths
276
+
277
+
278
+ def test_oee_breakdown_route_delegates_to_service(
279
+ monkeypatch: pytest.MonkeyPatch,
280
+ ) -> None:
281
+ """Route delegates to oee_breakdown_computation and returns its result."""
282
+ session: Session = Mock()
283
+ pdunit_id = 5
284
+ starttime = datetime(2025, 3, 1, tzinfo=timezone.utc)
285
+ stoptime = datetime(2025, 3, 2, tzinfo=timezone.utc)
286
+ fake_row = OEEBreakdownRow(
287
+ pdord_id=1,
288
+ pdord_num="OP-001",
289
+ planned_machine_time_seconds=600.0,
290
+ unplanned_downtime_seconds=120.0,
291
+ setup_time_deviation_seconds=60.0,
292
+ productive_time_seconds=3000.0,
293
+ availability=0.9,
294
+ performance=0.85,
295
+ quality=0.95,
296
+ oee=0.9 * 0.85 * 0.95,
297
+ pdordprd_quantitytotal=10.0,
298
+ pdordprd_quantity=9.0,
299
+ equipment=["TOOL-1"],
300
+ mtart_num="MAT-001",
301
+ mtart_name="Material One",
302
+ operation_start=datetime(2025, 3, 1, 6, tzinfo=timezone.utc),
303
+ operation_end=datetime(2025, 3, 1, 18, tzinfo=timezone.utc),
304
+ )
305
+ captured: list[tuple] = []
306
+
307
+ def fake_breakdown(s, u, start, stop):
308
+ captured.append((s, u, start, stop))
309
+ return [fake_row]
310
+
311
+ auth = Mock()
312
+ visibility = Mock()
313
+ monkeypatch.setattr(kpi, "_require_visible_unit", lambda *_: Mock())
314
+ monkeypatch.setattr(kpi, "oee_breakdown_computation", fake_breakdown)
315
+
316
+ result: list[kpi.OEEBreakdownResponse] = asyncio.run(
317
+ kpi.oee_breakdown(session, auth, visibility, pdunit_id, starttime, stoptime) # type: ignore[arg-type]
318
+ )
319
+
320
+ assert captured == [(session, pdunit_id, starttime, stoptime)]
321
+ assert len(result) == 1
322
+ assert result[0].pdord_id == 1
323
+ assert result[0].pdord_num == "OP-001"
324
+ assert result[0].availability == 0.9
325
+ assert result[0].performance == 0.85
326
+ assert result[0].quality == 0.95
327
+ assert result[0].pdordprd_quantitytotal == 10.0
328
+ assert result[0].pdordprd_quantity == 9.0
329
+ assert result[0].equipment == ["TOOL-1"]
330
+ assert result[0].mtart_num == "MAT-001"
331
+ assert result[0].mtart_name == "Material One"
332
+ assert result[0].operation_start == datetime(2025, 3, 1, 6, tzinfo=timezone.utc)
333
+ assert result[0].operation_end == datetime(2025, 3, 1, 18, tzinfo=timezone.utc)
@@ -36,7 +36,7 @@ def test_pdunit_get_all_requires_authentication() -> None:
36
36
 
37
37
  with pytest.raises(HTTPException, match="Not authenticated") as exc_info:
38
38
  asyncio.run(
39
- pdunit.pdunit_get_all(session=session, auth=None, visibility=visibility)
39
+ pdunit.pdunit_get_all(session=session, auth=None, visibility=visibility) # type: ignore[arg-type]
40
40
  )
41
41
 
42
42
  assert exc_info.value.status_code == 401
@@ -45,9 +45,11 @@ def test_pdunit_get_all_requires_authentication() -> None:
45
45
 
46
46
 
47
47
  def test_mount_includes_pdunit_route() -> None:
48
+ from .helpers import collect_paths
49
+
48
50
  app_router = APIRouter()
49
51
 
50
52
  pdunit.mount(app_router)
51
53
 
52
- paths = {route.path for route in app_router.routes}
54
+ paths = collect_paths(app_router)
53
55
  assert "/pdunit" in paths
@@ -1,13 +1,15 @@
1
- from fastapi import APIRouter
1
+ from perfact.api.main.dbsession import APIRouter
2
2
  from perfact.api.pd_fastapi import performancedata as perf
3
3
 
4
+ from .helpers import collect_paths
5
+
4
6
 
5
7
  def test_mount_includes_performance_routes() -> None:
6
8
  app_router = APIRouter()
7
9
 
8
10
  perf.mount(app_router)
9
11
 
10
- paths = {route.path for route in app_router.routes}
12
+ paths = collect_paths(app_router)
11
13
  assert "/get_time_performance_data_v1" in paths
12
14
  assert "/get_time_performance_data_v2" in paths
13
15
  assert "/get_time_performance_data_v3" in paths
@@ -1,133 +0,0 @@
1
- from datetime import datetime
2
- from typing import Annotated
3
-
4
- from perfact.api.main.auth import require_roles
5
- from perfact.api.main.dbsession import APIRouter, DBSession
6
- from perfact.api.pd.model import PdOrdPrd
7
- from pydantic import BeforeValidator
8
-
9
- routes = APIRouter()
10
-
11
-
12
- def parse_datetime_with_UTC_offset(value: str) -> datetime:
13
- try:
14
- return datetime.fromisoformat(value)
15
- except ValueError:
16
- raise ValueError(
17
- f"Invalid datetime format. \
18
- Expected: yyyy-mm-ddThh:mm:ss.sssss+HH:00, got: {value}"
19
- )
20
-
21
-
22
- UTCOffsetDatetime = Annotated[datetime, BeforeValidator(parse_datetime_with_UTC_offset)]
23
-
24
-
25
- @routes.get("/kpi/oee/availability", tags=["pd"], summary="Route for OEE availability")
26
- @require_roles("PdKpi")
27
- def oee_availability(
28
- session: DBSession,
29
- pdunit_id: int,
30
- # ex w timezone "2025-05-20T13:54:15.555103+03:00"
31
- # ex wo timezone "2025-05-20T13:54:15.555103"
32
- starttime: datetime,
33
- # ex w timezone "2025-05-22T11:53:13.561411+03:00"
34
- # ex wo timezone "2025-05-22T11:53:13.561411"
35
- stoptime: datetime,
36
- ) -> float:
37
- """
38
- Route for OEE availability
39
- Uses the availability_computation function
40
- Returns single floating point value
41
- """
42
-
43
- result = PdOrdPrd.oee_availability_computation(
44
- session, pdunit_id, starttime, stoptime
45
- )
46
-
47
- return result
48
-
49
-
50
- @routes.get("/kpi/oee/performance", tags=["pd"], summary="Route for OEE performance")
51
- @require_roles("PdKpi")
52
- def oee_performance(
53
- session: DBSession,
54
- pdunit_id: int,
55
- # ex w timezone "2025-05-20T13:54:15.555103+03:00"
56
- # ex wo timezone "2025-05-20T13:54:15.555103"
57
- starttime: datetime,
58
- # ex w timezone "2025-05-22T11:53:13.561411+03:00"
59
- # ex wo timezone "2025-05-22T11:53:13.561411"
60
- stoptime: datetime,
61
- ) -> float:
62
- """
63
- Route for OEE availability
64
- Uses the performance_computation function
65
- Returns single floating point value
66
- """
67
- result = PdOrdPrd.oee_performance_computation(
68
- session, pdunit_id, starttime, stoptime
69
- )
70
-
71
- return result
72
-
73
-
74
- @routes.get("/kpi/oee/quality", tags=["pd"], summary="Route for OEE quality")
75
- # response_model=Sequence[PdordlchGet])
76
- @require_roles("PdKpi")
77
- def oee_quality(
78
- session: DBSession,
79
- pdunit_id: int,
80
- # ex w timezone "2025-05-20T13:54:15.555103+03:00"
81
- # ex wo timezone "2025-05-20T13:54:15.555103"
82
- starttime: datetime,
83
- # ex w timezone "2025-05-22T11:53:13.561411+03:00"
84
- # ex wo timezone "2025-05-22T11:53:13.561411"
85
- stoptime: datetime,
86
- ) -> float | None:
87
- """
88
- Route for OEE quality
89
- Uses the quality_computation function
90
- Returns single floating point value
91
- """
92
- result = PdOrdPrd.oee_quality_computation(session, pdunit_id, starttime, stoptime)
93
-
94
- return result
95
-
96
-
97
- @routes.get("/kpi/oee", tags=["pd"], summary="OEE calculation")
98
- # response_model=Sequence[PdordlchGet])
99
- @require_roles("PdKpi")
100
- def oee_calculation(
101
- session: DBSession,
102
- pdunit_id: int,
103
- # ex w timezone "2025-05-20T13:54:15.555103+03:00"
104
- # ex wo timezone "2025-05-20T13:54:15.555103"
105
- starttime: datetime,
106
- # ex w timezone "2025-05-22T11:53:13.561411+03:00"
107
- # ex wo timezone "2025-05-22T11:53:13.561411"
108
- stoptime: datetime,
109
- ) -> dict:
110
- """
111
- OEE route for complete calculations: availability, performance, quality
112
- Returns dict/json type response containing
113
- all results under their specific category
114
- """
115
- availability = PdOrdPrd.oee_availability_computation(
116
- session, pdunit_id, starttime, stoptime
117
- )
118
- performance = PdOrdPrd.oee_performance_computation(
119
- session, pdunit_id, starttime, stoptime
120
- )
121
- quality = PdOrdPrd.oee_quality_computation(session, pdunit_id, starttime, stoptime)
122
- oee = availability * performance * quality if quality is not None else None
123
-
124
- return {
125
- "availability": availability,
126
- "performance": performance,
127
- "quality": quality,
128
- "oee": oee,
129
- }
130
-
131
-
132
- def mount(app: APIRouter):
133
- app.include_router(routes)
@@ -1,105 +0,0 @@
1
- import asyncio
2
- from datetime import datetime, timezone
3
-
4
- import pytest
5
- from perfact.api.main.dbsession import APIRouter
6
- from perfact.api.pd_fastapi import kpi
7
-
8
-
9
- def test_parse_datetime_with_utc_offset_accepts_isoformat() -> None:
10
- value = "2025-05-20T13:54:15.555103+03:00"
11
-
12
- parsed = kpi.parse_datetime_with_UTC_offset(value)
13
-
14
- assert parsed == datetime.fromisoformat(value)
15
-
16
-
17
- def test_parse_datetime_with_utc_offset_rejects_invalid_input() -> None:
18
- with pytest.raises(ValueError, match="Invalid datetime format"):
19
- kpi.parse_datetime_with_UTC_offset("not-a-datetime")
20
-
21
-
22
- @pytest.mark.parametrize(
23
- ("route_func", "computation_name", "expected"),
24
- [
25
- (kpi.oee_availability, "oee_availability_computation", 0.91),
26
- (kpi.oee_performance, "oee_performance_computation", 0.82),
27
- (kpi.oee_quality, "oee_quality_computation", 0.97),
28
- ],
29
- )
30
- def test_single_kpi_routes_delegate_to_model(
31
- monkeypatch: pytest.MonkeyPatch,
32
- route_func,
33
- computation_name: str,
34
- expected: float,
35
- ) -> None:
36
- session = object()
37
- pdunit_id = 7
38
- starttime = datetime(2025, 1, 1, tzinfo=timezone.utc)
39
- stoptime = datetime(2025, 1, 2, tzinfo=timezone.utc)
40
- captured: list[tuple[object, int, datetime, datetime]] = []
41
-
42
- def fake_computation(
43
- local_session, local_pdunit_id, local_starttime, local_stoptime
44
- ):
45
- captured.append(
46
- (local_session, local_pdunit_id, local_starttime, local_stoptime)
47
- )
48
- return expected
49
-
50
- monkeypatch.setattr(
51
- kpi.PdOrdPrd,
52
- computation_name,
53
- staticmethod(fake_computation),
54
- )
55
-
56
- result = asyncio.run(route_func(session, pdunit_id, starttime, stoptime))
57
-
58
- assert result == expected
59
- assert captured == [(session, pdunit_id, starttime, stoptime)]
60
-
61
-
62
- def test_oee_calculation_returns_combined_values(
63
- monkeypatch: pytest.MonkeyPatch,
64
- ) -> None:
65
- session = object()
66
- pdunit_id = 3
67
- starttime = datetime(2025, 2, 1, tzinfo=timezone.utc)
68
- stoptime = datetime(2025, 2, 2, tzinfo=timezone.utc)
69
-
70
- monkeypatch.setattr(
71
- kpi.PdOrdPrd,
72
- "oee_availability_computation",
73
- staticmethod(lambda *_: 0.5),
74
- )
75
- monkeypatch.setattr(
76
- kpi.PdOrdPrd,
77
- "oee_performance_computation",
78
- staticmethod(lambda *_: 0.8),
79
- )
80
- monkeypatch.setattr(
81
- kpi.PdOrdPrd,
82
- "oee_quality_computation",
83
- staticmethod(lambda *_: 0.9),
84
- )
85
-
86
- result = asyncio.run(kpi.oee_calculation(session, pdunit_id, starttime, stoptime))
87
-
88
- assert result == {
89
- "availability": 0.5,
90
- "performance": 0.8,
91
- "quality": 0.9,
92
- "oee": 0.36000000000000004,
93
- }
94
-
95
-
96
- def test_mount_includes_kpi_routes() -> None:
97
- app_router = APIRouter()
98
-
99
- kpi.mount(app_router)
100
-
101
- paths = {route.path for route in app_router.routes}
102
- assert "/kpi/oee/availability" in paths
103
- assert "/kpi/oee/performance" in paths
104
- assert "/kpi/oee/quality" in paths
105
- assert "/kpi/oee" in paths