perfact-api-pd-fastapi 0.2__py2.py3-none-any.whl

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,17 @@
1
+ from fastapi import FastAPI
2
+ from perfact.api.main.dbsession import APIRouter
3
+
4
+ from .kpi import mount as kpi_mount
5
+ from .pdunit import mount as pdunitmount
6
+ from .performancedata import mount as performancemount
7
+
8
+ routes = APIRouter()
9
+ routes_pdord = APIRouter()
10
+
11
+
12
+ def mount(app: FastAPI):
13
+ kpi_mount(routes)
14
+ pdunitmount(routes)
15
+ performancemount(routes_pdord)
16
+ app.include_router(routes, prefix="/pd")
17
+ app.include_router(routes_pdord, prefix="/pd/pdord")
@@ -0,0 +1,226 @@
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 PdOrdLch, PdOrdPrd
7
+ from perfact.api.pp.model import PpRsrc, PpShft
8
+ from pydantic import BeforeValidator
9
+ from sqlalchemy import func, select
10
+
11
+ routes = APIRouter()
12
+
13
+
14
+ def parse_datetime_with_UTC_offset(value: str) -> datetime:
15
+ try:
16
+ return datetime.fromisoformat(value)
17
+ except ValueError:
18
+ raise ValueError(
19
+ f"Invalid datetime format. \
20
+ Expected: yyyy-mm-ddThh:mm:ss.sssss+HH:00, got: {value}"
21
+ )
22
+
23
+
24
+ UTCOffsetDatetime = Annotated[datetime, BeforeValidator(parse_datetime_with_UTC_offset)]
25
+
26
+
27
+ def availability_computation(
28
+ session: DBSession, pdunit_id: int, starttime: datetime, stoptime: datetime, /
29
+ ) -> float:
30
+ """OEE function for computation of availability"""
31
+ search_range = func.tstzrange(starttime, stoptime)
32
+ n_intersection = PdOrdLch.lchtimerange.op("*")(search_range)
33
+ in_seconds = func.extract(
34
+ "epoch", func.upper(n_intersection) - func.lower(n_intersection)
35
+ )
36
+
37
+ current_available_time_nq = session.scalar(
38
+ select(
39
+ PdOrdLch.availability_missing_active_operations_nominator(
40
+ pdunit_id, starttime, stoptime
41
+ )
42
+ )
43
+ )
44
+ current_available_time_n = float(current_available_time_nq or 0.0)
45
+
46
+ current_available_time_dq = session.scalar(
47
+ select(
48
+ PdOrdLch.availability_missing_active_operations_denominator(
49
+ pdunit_id, starttime, stoptime
50
+ )
51
+ )
52
+ )
53
+ current_available_time_d = float(current_available_time_dq or 0.0)
54
+
55
+ # nominator calculation
56
+ nominator_q = session.execute(
57
+ select(func.coalesce(func.sum(in_seconds), 0.0))
58
+ .where(PdOrdLch.availability_nominator_eligible(pdunit_id).is_(True))
59
+ .where(PdOrdLch.lchtimerange.op("&&")(search_range))
60
+ ).scalar()
61
+ nominator = float(nominator_q or 0.0) + current_available_time_n
62
+
63
+ # denominator calculation
64
+ d_intersection = n_intersection.op("*")(PpShft.shiftrange)
65
+ in_seconds = func.extract(
66
+ "epoch", func.upper(d_intersection) - func.lower(d_intersection)
67
+ )
68
+ denominator_q = session.execute(
69
+ select(func.coalesce(func.sum(in_seconds), 1.0))
70
+ .select_from(PdOrdLch)
71
+ .join(PpRsrc, PpRsrc.pdunit_id == pdunit_id)
72
+ .join(PpShft, PpShft.pdunit_id == PpRsrc.id)
73
+ .where(PdOrdLch.availability_denominator_eligible(pdunit_id).is_(True))
74
+ .where(PdOrdLch.lchtimerange.op("&&")(search_range))
75
+ .where(n_intersection.op("&&")(PpShft.shiftrange))
76
+ ).scalar()
77
+ denominator = nominator + float(denominator_q or 0.0) + current_available_time_d
78
+
79
+ availability = nominator / denominator
80
+
81
+ return float(availability)
82
+
83
+
84
+ @routes.get("/kpi/oee/availability", tags=["pd"], summary="Route for OEE availability")
85
+ @require_roles("PdKpi")
86
+ def oee_availability(
87
+ session: DBSession,
88
+ pdunit_id: int,
89
+ # ex w timezone "2025-05-20T13:54:15.555103+03:00"
90
+ # ex wo timezone "2025-05-20T13:54:15.555103"
91
+ starttime: datetime,
92
+ # ex w timezone "2025-05-22T11:53:13.561411+03:00"
93
+ # ex wo timezone "2025-05-22T11:53:13.561411"
94
+ stoptime: datetime,
95
+ ) -> float:
96
+ """
97
+ Route for OEE availability
98
+ Uses the availability_computation function
99
+ Returns single floating point value
100
+ """
101
+
102
+ result = availability_computation(session, pdunit_id, starttime, stoptime)
103
+
104
+ return result
105
+
106
+
107
+ def performance_computation(
108
+ session: DBSession, pdunit_id: int, starttime: datetime, stoptime: datetime, /
109
+ ) -> float:
110
+ """OEE function for computation of performance"""
111
+ performance = session.execute(
112
+ select(
113
+ func.coalesce(
114
+ func.sum(PdOrdPrd.ideal_time_over_time_range(starttime, stoptime)), 0.0
115
+ )
116
+ / func.nullif(
117
+ func.coalesce(
118
+ func.sum(PdOrdPrd.actual_time_over_time_range(starttime, stoptime)),
119
+ 0.0,
120
+ ),
121
+ 0.0,
122
+ )
123
+ ).where(PdOrdPrd.performance_eligible(pdunit_id).is_(True))
124
+ ).scalar()
125
+
126
+ if not performance:
127
+ performance = 0
128
+
129
+ return float(performance)
130
+
131
+
132
+ @routes.get("/kpi/oee/performance", tags=["pd"], summary="Route for OEE performance")
133
+ @require_roles("PdKpi")
134
+ def oee_performance(
135
+ session: DBSession,
136
+ pdunit_id: int,
137
+ # ex w timezone "2025-05-20T13:54:15.555103+03:00"
138
+ # ex wo timezone "2025-05-20T13:54:15.555103"
139
+ starttime: datetime,
140
+ # ex w timezone "2025-05-22T11:53:13.561411+03:00"
141
+ # ex wo timezone "2025-05-22T11:53:13.561411"
142
+ stoptime: datetime,
143
+ ) -> float:
144
+ """
145
+ Route for OEE availability
146
+ Uses the performance_computation function
147
+ Returns single floating point value
148
+ """
149
+ result = performance_computation(session, pdunit_id, starttime, stoptime)
150
+
151
+ return result
152
+
153
+
154
+ def quality_computation(
155
+ session: DBSession, pdunit_id: int, starttime: datetime, stoptime: datetime, /
156
+ ) -> float:
157
+ """OEE function computation for quality"""
158
+ quality = session.execute(
159
+ select(
160
+ func.sum(PdOrdPrd.good_quantity_over_range(starttime, stoptime))
161
+ / func.sum(PdOrdPrd.total_quantity_over_range(starttime, stoptime))
162
+ ).where(PdOrdPrd.quality_eligible(pdunit_id).is_(True))
163
+ ).scalar()
164
+
165
+ if not quality:
166
+ quality = 0
167
+
168
+ return float(quality)
169
+
170
+
171
+ @routes.get("/kpi/oee/quality", tags=["pd"], summary="Route for OEE quality")
172
+ # response_model=Sequence[PdordlchGet])
173
+ @require_roles("PdKpi")
174
+ def oee_quality(
175
+ session: DBSession,
176
+ pdunit_id: int,
177
+ # ex w timezone "2025-05-20T13:54:15.555103+03:00"
178
+ # ex wo timezone "2025-05-20T13:54:15.555103"
179
+ starttime: datetime,
180
+ # ex w timezone "2025-05-22T11:53:13.561411+03:00"
181
+ # ex wo timezone "2025-05-22T11:53:13.561411"
182
+ stoptime: datetime,
183
+ ) -> float:
184
+ """
185
+ Route for OEE quality
186
+ Uses the quality_computation function
187
+ Returns single floating point value
188
+ """
189
+ result = quality_computation(session, pdunit_id, starttime, stoptime)
190
+
191
+ return result
192
+
193
+
194
+ @routes.get("/kpi/oee", tags=["pd"], summary="OEE calculation")
195
+ # response_model=Sequence[PdordlchGet])
196
+ @require_roles("PdKpi")
197
+ def oee_calculation(
198
+ session: DBSession,
199
+ pdunit_id: int,
200
+ # ex w timezone "2025-05-20T13:54:15.555103+03:00"
201
+ # ex wo timezone "2025-05-20T13:54:15.555103"
202
+ starttime: datetime,
203
+ # ex w timezone "2025-05-22T11:53:13.561411+03:00"
204
+ # ex wo timezone "2025-05-22T11:53:13.561411"
205
+ stoptime: datetime,
206
+ ) -> dict:
207
+ """
208
+ OEE route for complete calculations: availability, performance, quality
209
+ Returns dict/json type response containing
210
+ all results under their specific category
211
+ """
212
+ availability = availability_computation(session, pdunit_id, starttime, stoptime)
213
+ performance = performance_computation(session, pdunit_id, starttime, stoptime)
214
+ quality = quality_computation(session, pdunit_id, starttime, stoptime)
215
+ oee = availability * performance * quality
216
+
217
+ return {
218
+ "availability": availability,
219
+ "performance": performance,
220
+ "quality": quality,
221
+ "oee": oee,
222
+ }
223
+
224
+
225
+ def mount(app: APIRouter):
226
+ app.include_router(routes)
@@ -0,0 +1,72 @@
1
+ """
2
+ Locust load test for all three /get_time_performance_data endpoints.
3
+ - Each task POSTs a JSON array of pdunit_ids to one endpoint.
4
+ - Payloads are generated once per user, reused for all endpoints.
5
+ - Tracks comparative performance and error rates.
6
+ """
7
+
8
+ import json
9
+ import os
10
+ from itertools import cycle
11
+
12
+ from locust import HttpUser, between, task
13
+
14
+ API_BASE = os.getenv("API_BASE_PATH", "/api/pd/pdord")
15
+ ALL_ENDPOINTS = [
16
+ "/get_time_performance_data_v1", # Pure SQL
17
+ "/get_time_performance_data_v2", # ORM
18
+ "/get_time_performance_data_v3", # Pure Python SQL
19
+ "/get_time_performance_data_v4", # Pure Python ORM
20
+ ]
21
+ PDUNIT_ID_POOL = list(range(2000, 3001))
22
+ PDUNITS_PER_REQUEST = 10
23
+
24
+ API_KEY = os.getenv("API_KEY")
25
+ API_KEY_HEADER = os.getenv("API_KEY_HEADER", "Authorization")
26
+ API_KEY_PREFIX = os.getenv("API_KEY_PREFIX", "Apikey")
27
+
28
+
29
+ class PdUnitPerformanceUser(HttpUser):
30
+ wait_time = between(0.5, 2.0)
31
+
32
+ def on_start(self):
33
+ self.client.trust_env = False
34
+ pool_len = len(PDUNIT_ID_POOL)
35
+ start = abs(hash((os.getpid(), id(self)))) % pool_len
36
+ self.pdunit_ids = [
37
+ PDUNIT_ID_POOL[(start + i) % pool_len] for i in range(PDUNITS_PER_REQUEST)
38
+ ]
39
+ self.headers = {"Content-Type": "application/json"}
40
+ if API_KEY:
41
+ self.headers[API_KEY_HEADER] = f"{API_KEY_PREFIX} {API_KEY}"
42
+ self.endpoint_cycle = cycle(ALL_ENDPOINTS)
43
+
44
+ @task
45
+ def post_next_endpoint(self):
46
+ self._post_to_endpoint(next(self.endpoint_cycle))
47
+
48
+ def _post_to_endpoint(self, endpoint):
49
+ url = API_BASE + endpoint
50
+ payload = json.dumps(self.pdunit_ids)
51
+ with self.client.post(
52
+ url,
53
+ data=payload,
54
+ headers=self.headers,
55
+ catch_response=True,
56
+ name=endpoint,
57
+ ) as resp:
58
+ if resp.status_code != 200:
59
+ resp.failure(f"{endpoint} failed: {resp.status_code}")
60
+ else:
61
+ try:
62
+ data = resp.json()
63
+ if not isinstance(data, dict):
64
+ resp.failure(f"{endpoint} returned non-dict response")
65
+ except Exception as e:
66
+ resp.failure(f"{endpoint} invalid JSON: {e}")
67
+
68
+
69
+ # How to use currently:
70
+ # locust -f locustfile.py --host http://127.0.0.1:8000
71
+ # --web-host 127.0.0.1 --web-port 8010
72
+ # add your key export API_KEY='your_key'
@@ -0,0 +1,42 @@
1
+ from typing import Optional, Sequence
2
+
3
+ from fastapi import HTTPException
4
+ from perfact.api.base.model import PydanticBase
5
+ from perfact.api.main import Auth, DBSession, VisibilityRegistryDep, require_roles
6
+ from perfact.api.main.dbsession import APIRouter
7
+ from perfact.api.pd.model import PdUnit
8
+ from sqlalchemy import select
9
+
10
+ routes = APIRouter()
11
+
12
+
13
+ class PdUnitGet(PydanticBase):
14
+ name: Optional[str]
15
+ num: str
16
+ overprodmay: bool
17
+ overprodfactor: float
18
+ seqnum: Optional[int]
19
+
20
+
21
+ @routes.get(
22
+ "/pdunit",
23
+ tags=["pd"],
24
+ summary="Get all visible PdUnit",
25
+ response_model=Sequence[PdUnitGet],
26
+ )
27
+ @require_roles("PdRead")
28
+ def pdunit_get_all(
29
+ session: DBSession, auth: Auth, visibility: VisibilityRegistryDep
30
+ ) -> Sequence[PdUnit]:
31
+ """
32
+ returns all PdUnit which the current user is allowed to see.
33
+ """
34
+ policy = visibility.get(PdUnit)
35
+ if auth is None:
36
+ raise HTTPException(status_code=401, detail="Not authenticated")
37
+
38
+ return session.execute(select(PdUnit).filter(policy.filter(auth))).scalars().all()
39
+
40
+
41
+ def mount(app: APIRouter):
42
+ app.include_router(routes)
@@ -0,0 +1,701 @@
1
+ from datetime import datetime, timedelta, timezone
2
+ from typing import Any, Dict, Mapping, Optional, Sequence
3
+
4
+ from perfact.api.main.auth import Auth, require_roles
5
+ from perfact.api.main.dbsession import APIRouter, DBSession
6
+ from perfact.api.pd.model.pdord import PdOrd
7
+ from perfact.api.pd.model.pdord import PdOrd_TimeStat as PdOrdTimeStat
8
+ from perfact.api.pd.model.pdordlc import PdOrdLc
9
+ from perfact.api.pd.model.pdordlch import PdOrdLch
10
+ from perfact.api.pd.model.pdordlct import PdOrdLct
11
+ from perfact.api.pd.model.pdordprd import PdOrdPrd
12
+ from pydantic import BaseModel
13
+ from sqlalchemy import bindparam, func, select, text
14
+ from sqlalchemy.engine import RowMapping
15
+ from sqlalchemy.orm import aliased
16
+
17
+ routes = APIRouter()
18
+
19
+
20
+ class PdUnitPerformanceGetTime(BaseModel):
21
+ estimated_start: Optional[datetime]
22
+ estimated_end: Optional[datetime]
23
+ delta_start_seconds: Optional[float]
24
+ delta_end_seconds: Optional[float]
25
+ delta_planned_duration_seconds: Optional[float]
26
+ estimated_duration_seconds: Optional[float]
27
+ delta_duration_seconds: Optional[float]
28
+
29
+
30
+ class PdUnitPerformanceGetStatus(BaseModel):
31
+ running_time_seconds: Optional[float]
32
+ remaining_time_seconds: Optional[float]
33
+ downtime_seconds: Optional[float]
34
+
35
+
36
+ class PdUnitPerformanceGetPerformanceTime(BaseModel):
37
+ planned_seconds: Optional[float]
38
+ current_seconds: Optional[float]
39
+ delta_seconds: Optional[float]
40
+
41
+
42
+ class PdUnitPerformanceGetPerformanceTimeSetup(BaseModel):
43
+ current_seconds: Optional[float]
44
+ delta_seconds: Optional[float]
45
+
46
+
47
+ class PdUnitPerformanceGetPerformance(BaseModel):
48
+ cycle_time: Optional[PdUnitPerformanceGetPerformanceTime]
49
+ setup_time: Optional[PdUnitPerformanceGetPerformanceTimeSetup]
50
+
51
+
52
+ class PdUnitPerformanceGet(BaseModel):
53
+ time_management: Optional[PdUnitPerformanceGetTime]
54
+ operation_status: Optional[PdUnitPerformanceGetStatus]
55
+ performance: Optional[PdUnitPerformanceGetPerformance]
56
+
57
+
58
+ def parse_performance_row(data_row):
59
+ return {
60
+ "time_management": {
61
+ "estimated_start": data_row.estimated_actual_start_raw,
62
+ "estimated_end": data_row.estimated_end_raw,
63
+ "delta_start_seconds": data_row.delta_start_seconds,
64
+ "delta_end_seconds": data_row.delta_end_seconds,
65
+ "delta_planned_duration_seconds": data_row.delta_planned_duration_seconds,
66
+ "estimated_duration_seconds": data_row.estimated_duration_seconds,
67
+ "delta_duration_seconds": data_row.delta_duration_seconds,
68
+ },
69
+ "operation_status": {
70
+ "running_time_seconds": data_row.running_time_seconds,
71
+ "remaining_time_seconds": data_row.remaining_time_seconds,
72
+ "downtime_seconds": data_row.downtime_seconds,
73
+ },
74
+ # Performance Metrics
75
+ "performance": {
76
+ "cycle_time": {
77
+ "planned_seconds": data_row.planned_cycle_time_seconds,
78
+ "current_seconds": data_row.current_cycle_time_seconds,
79
+ "delta_seconds": data_row.delta_cycle_time_seconds,
80
+ },
81
+ "setup_time": {
82
+ "current_seconds": data_row.current_setup_time_seconds,
83
+ "delta_seconds": data_row.delta_setup_time_seconds,
84
+ },
85
+ },
86
+ }
87
+
88
+
89
+ def _safe_delta(end: Any, start: Any):
90
+ if end and start:
91
+ return (end - start).total_seconds()
92
+ return None
93
+
94
+
95
+ def _td_to_sec(td: Any):
96
+ return td.total_seconds() if td is not None else None
97
+
98
+
99
+ @routes.post(
100
+ "/get_time_performance_data_v1",
101
+ tags=["pd"],
102
+ summary="get_time_performance_data - variant 1 (Pure SQL)",
103
+ response_model=Dict[str, PdUnitPerformanceGet],
104
+ )
105
+ @require_roles("PdRead")
106
+ def get_time_performance_data_1(
107
+ session: DBSession, auth: Auth, pdord_ids: Sequence[int]
108
+ ):
109
+ if not pdord_ids:
110
+ return {}
111
+
112
+ stm = text(
113
+ """
114
+ -- Get time management and performance overview data for a specific production order
115
+ with base_data as (
116
+ select
117
+ pdord.*,
118
+ pdordlc.*,
119
+ pdordtimestat_activetime,
120
+ pdordtimestat_exectime,
121
+ pdordtimestat_setuptime,
122
+ pdordtimestat_spentindowntime,
123
+ pdordtimestat_cycle_time,
124
+ -- Pre-calculate commonly used values
125
+ coalesce(pdord_planstarttime, pdord_starttime) as planned_start,
126
+ coalesce(pdord_planstoptime, pdord_stoptime) as planned_end,
127
+ (select sum(pdordprd_quantity) from pdordprd where pdordprd_pdord_id = pdord_id) as produced_quantity
128
+
129
+ from pdord
130
+
131
+ join pdordlc
132
+ on pdordlc_id = pdord_pdordlc_id
133
+
134
+ left
135
+ join pdord_timestat
136
+ on pdordtimestat_pdord_id = pdord_id
137
+
138
+ where pdord_id = any(:pdord_id_array)
139
+ ),
140
+ actual_start_calc as (
141
+ select
142
+ *,
143
+ -- Calculate actual start time - when operation transitioned from non-active to active state
144
+ (select min(pdordlch_createtime)
145
+ from pdordlch
146
+ join pdordlct
147
+ on pdordlch_pdordlct_id = pdordlct_id
148
+ join pdordlc from_lc
149
+ on from_lc.pdordlc_id = pdordlct_from_pdordlc_id
150
+ join pdordlc to_lc
151
+ on to_lc.pdordlc_id = pdordlct_to_pdordlc_id
152
+ where pdordlch_pdord_id = pdord_id
153
+ and from_lc.pdordlc_isactive = false
154
+ and to_lc.pdordlc_isactive = true) as actual_start_time,
155
+
156
+ -- Calculate actual end time - when operation transitioned to final state
157
+ (select max(pdordlch_createtime)
158
+ from pdordlch
159
+ join pdordlct
160
+ on pdordlch_pdordlct_id = pdordlct_id
161
+ join pdordlc to_lc
162
+ on to_lc.pdordlc_id = pdordlct_to_pdordlc_id
163
+ where pdordlch_pdord_id = pdord_id
164
+ and to_lc.pdordlc_isfinal = true) as actual_end_time
165
+ from base_data
166
+ ),
167
+ time_calculations as (
168
+ select
169
+ *,
170
+ -- Estimated start calculation based on execution state
171
+ case
172
+ -- If operation has transitioned to active state, use actual start
173
+ when actual_start_time is not null then
174
+ actual_start_time
175
+ -- If operation has not started but is overdue, use current time
176
+ when planned_start is not null and planned_start < now() then
177
+ now()
178
+ -- If operation has not started and not overdue, use planned start
179
+ else planned_start
180
+ end as estimated_start
181
+ from actual_start_calc
182
+ ),
183
+ final_calculations as (
184
+ select
185
+ *,
186
+ -- Estimated end calculation based on execution state
187
+ case
188
+ -- If operation is completed (final state), use actual end time
189
+ when actual_end_time is not null then
190
+ actual_end_time
191
+ -- If operation is in a state that is considered active and has corresponding time data
192
+ when actual_start_time is not null and pdordtimestat_activetime is not null and pdord_qty > 0 and produced_quantity > 0 then
193
+ -- Actual start + estimated total running time
194
+ actual_start_time + (pdordtimestat_activetime * (pdord_qty::float / produced_quantity::float))
195
+ -- If operation has been in execution but no execution time available, use time difference from planned
196
+ when actual_start_time is not null and planned_end is not null and planned_start is not null then
197
+ actual_start_time + (planned_end - planned_start)
198
+ -- If operation has not started but is overdue, use current time + planned duration
199
+ when actual_start_time is null and planned_start is not null and planned_end is not null and planned_start < now() then
200
+ now() + (planned_end - planned_start)
201
+ -- Default: use planned end
202
+ else planned_end
203
+ end as estimated_end,
204
+ -- Cycle time calculations
205
+ extract(epoch from pdord_plancycletime)::float as planned_cycle_time_seconds,
206
+
207
+ -- Calculate current cycle time if operation has been in an active state and has produced parts
208
+ extract(epoch from pdordtimestat_cycle_time)::float
209
+ as current_cycle_time_seconds
210
+ from time_calculations
211
+ )
212
+ select
213
+ pdord_id,
214
+ -- Return calculated timestamps
215
+ estimated_start as estimated_actual_start_raw,
216
+ estimated_end as estimated_end_raw,
217
+
218
+ -- Delta calculations
219
+ extract(epoch from (estimated_start - planned_start))::float as delta_start_seconds,
220
+
221
+ extract(epoch from (estimated_end - planned_end))::float as delta_end_seconds,
222
+
223
+ extract(epoch from (planned_end - planned_start))::float as delta_planned_duration_seconds,
224
+
225
+ -- Duration calculations
226
+ extract(epoch from (estimated_end - estimated_start))::float as estimated_duration_seconds,
227
+
228
+ extract(epoch from ((estimated_end - estimated_start) - (planned_end - planned_start)))::float as delta_duration_seconds,
229
+
230
+ -- Time statistics based on active operation state
231
+ extract(epoch from pdordtimestat_activetime)::float as running_time_seconds,
232
+
233
+ case
234
+ -- Calculate remaining time only if operation has been in an active state and has produced parts
235
+ when actual_start_time is not null and pdordtimestat_activetime is not null and pdord_qty > 0 and produced_quantity > 0 then
236
+ greatest(0, extract(epoch from (
237
+ pdordtimestat_activetime * (pdord_qty::float / produced_quantity::float) - pdordtimestat_activetime
238
+ ))::float)
239
+ else null
240
+ end as remaining_time_seconds,
241
+
242
+ planned_cycle_time_seconds,
243
+ current_cycle_time_seconds,
244
+ current_cycle_time_seconds - planned_cycle_time_seconds as delta_cycle_time_seconds,
245
+
246
+ -- Setup time calculations - total accumulated setup time
247
+ extract(epoch from pdordtimestat_setuptime)::float as current_setup_time_seconds,
248
+
249
+ extract(epoch from (pdordtimestat_setuptime - pdord_plansetuptime))::float as delta_setup_time_seconds,
250
+
251
+ -- Downtime - total accumulated downtime
252
+ extract(epoch from pdordtimestat_spentindowntime)::float as downtime_seconds
253
+
254
+ from final_calculations
255
+ """ # noqa: E501
256
+ )
257
+ params = {"pdord_id_array": pdord_ids}
258
+ resultset = session.execute(stm, params).fetchall()
259
+ return {str(res.pdord_id): parse_performance_row(res) for res in resultset}
260
+
261
+
262
+ @routes.post(
263
+ "/get_time_performance_data_v2",
264
+ tags=["pd"],
265
+ summary="get_time_performance_data_ variant 2 (ORM)",
266
+ response_model=Dict[str, PdUnitPerformanceGet],
267
+ )
268
+ @require_roles("PdRead")
269
+ def get_time_performance_data_2(
270
+ session: DBSession, auth: Auth, pdord_ids: Sequence[int]
271
+ ):
272
+ if not pdord_ids:
273
+ return {}
274
+
275
+ from_lc = aliased(PdOrdLc)
276
+ to_lc = aliased(PdOrdLc)
277
+
278
+ prd_sq = (
279
+ select(func.sum(PdOrdPrd.quantity))
280
+ .where(PdOrdPrd.pdord_id == PdOrd.id)
281
+ .scalar_subquery()
282
+ .label("produced_quantity")
283
+ )
284
+
285
+ start_sq = (
286
+ select(func.min(PdOrdLch.createtime))
287
+ .select_from(PdOrdLch)
288
+ .join(PdOrdLct, PdOrdLch.pdordlct_id == PdOrdLct.id)
289
+ .join(from_lc, from_lc.id == PdOrdLct.from_pdordlc_id)
290
+ .join(to_lc, to_lc.id == PdOrdLct.to_pdordlc_id)
291
+ .where(
292
+ PdOrdLch.pdord_id == PdOrd.id,
293
+ from_lc.isactive.is_(False),
294
+ to_lc.isactive,
295
+ )
296
+ .scalar_subquery()
297
+ .label("actual_start_time")
298
+ )
299
+
300
+ end_sq = (
301
+ select(func.max(PdOrdLch.createtime))
302
+ .select_from(PdOrdLch)
303
+ .join(PdOrdLct, PdOrdLch.pdordlct_id == PdOrdLct.id)
304
+ .join(to_lc, to_lc.id == PdOrdLct.to_pdordlc_id)
305
+ .where(PdOrdLch.pdord_id == PdOrd.id, to_lc.isfinal)
306
+ .scalar_subquery()
307
+ .label("actual_end_time")
308
+ )
309
+
310
+ stm = (
311
+ select(
312
+ PdOrd.id.label("pdord_id"),
313
+ PdOrd.planstarttime.label("pdord_planstarttime"),
314
+ PdOrd.starttime.label("pdord_starttime"),
315
+ PdOrd.planstoptime.label("pdord_planstoptime"),
316
+ PdOrd.stoptime.label("pdord_stoptime"),
317
+ PdOrd.qty.label("pdord_qty"),
318
+ PdOrd.plancycletime.label("pdord_plancycletime"),
319
+ PdOrd.plansetuptime.label("pdord_plansetuptime"),
320
+ PdOrdTimeStat.activetime.label("pdordtimestat_activetime"),
321
+ PdOrdTimeStat.cycle_time.label("pdordtimestat_cycle_time"),
322
+ PdOrdTimeStat.setuptime.label("pdordtimestat_setuptime"),
323
+ PdOrdTimeStat.spentindowntime.label("pdordtimestat_spentindowntime"),
324
+ PdOrdLc.isactive.label("is_active_state"),
325
+ PdOrdLc.isfinal.label("is_final_state"),
326
+ prd_sq,
327
+ start_sq,
328
+ end_sq,
329
+ )
330
+ .join(PdOrdLc, PdOrdLc.id == PdOrd.pdordlc_id)
331
+ .outerjoin(PdOrdTimeStat, PdOrdTimeStat.id == PdOrd.id)
332
+ .where(PdOrd.id.in_(pdord_ids))
333
+ )
334
+
335
+ resultset = session.execute(stm).mappings().fetchall()
336
+
337
+ return {
338
+ str(row["pdord_id"]): process_performance_in_pure_python(row)
339
+ for row in resultset
340
+ }
341
+
342
+
343
+ @routes.post(
344
+ "/get_time_performance_data_v3",
345
+ tags=["pd"],
346
+ summary="get_time_performance_data_ variant 3 (Pure Python SQL)",
347
+ response_model=Dict[str, PdUnitPerformanceGet],
348
+ )
349
+ @require_roles("PdRead")
350
+ def get_time_performance_data_3(
351
+ session: DBSession, auth: Auth, pdord_ids: Sequence[int]
352
+ ):
353
+ if not pdord_ids:
354
+ return {}
355
+
356
+ raw_fetch_sql = text(
357
+ """
358
+ select
359
+ pdord_id,
360
+ pdord_planstarttime,
361
+ pdord_starttime,
362
+ pdord_planstoptime,
363
+ pdord_stoptime,
364
+ pdord_qty,
365
+ pdord_plancycletime,
366
+ pdord_plansetuptime,
367
+
368
+ pdordtimestat_activetime,
369
+ pdordtimestat_cycle_time,
370
+ pdordtimestat_setuptime,
371
+ pdordtimestat_spentindowntime,
372
+
373
+ -- Grab current state flags directly from the lifecycle table
374
+ pdordlc_isactive as is_active_state,
375
+ pdordlc_isfinal as is_final_state,
376
+
377
+ (select sum(pdordprd_quantity)
378
+ from pdordprd where pdordprd_pdord_id = pdord_id) as produced_quantity,
379
+
380
+ (select min(pdordlch_createtime)
381
+ from pdordlch
382
+ join pdordlct on pdordlch_pdordlct_id = pdordlct_id
383
+ join pdordlc from_lc on from_lc.pdordlc_id = pdordlct_from_pdordlc_id
384
+ join pdordlc to_lc on to_lc.pdordlc_id = pdordlct_to_pdordlc_id
385
+ where pdordlch_pdord_id = pdord_id
386
+ and from_lc.pdordlc_isactive = false
387
+ and to_lc.pdordlc_isactive = true) as actual_start_time,
388
+ (select max(pdordlch_createtime)
389
+ from pdordlch
390
+ join pdordlct on pdordlch_pdordlct_id = pdordlct_id
391
+ join pdordlc to_lc on to_lc.pdordlc_id = pdordlct_to_pdordlc_id
392
+ where pdordlch_pdord_id = pdord_id
393
+ and to_lc.pdordlc_isfinal = true) as actual_end_time
394
+ from pdord
395
+ join pdordlc on pdordlc_id = pdord_pdordlc_id
396
+ left join pdord_timestat on pdordtimestat_pdord_id = pdord_id
397
+ where pdord_id in :pdord_id_array
398
+ """
399
+ )
400
+
401
+ params = {"pdord_id_array": pdord_ids}
402
+ stm = raw_fetch_sql.bindparams(bindparam("pdord_id_array", expanding=True))
403
+ resultset = session.execute(stm, params).mappings().fetchall()
404
+
405
+ return {
406
+ str(row["pdord_id"]): process_performance_in_pure_python(row)
407
+ for row in resultset
408
+ }
409
+
410
+
411
+ @routes.post(
412
+ "/get_time_performance_data_v4",
413
+ tags=["pd"],
414
+ summary="get_time_performance_data_ variant 4 (Pure Python ORM)",
415
+ response_model=Dict[str, PdUnitPerformanceGet],
416
+ )
417
+ @require_roles("PdRead")
418
+ def get_time_performance_data_4(
419
+ session: DBSession, auth: Auth, pdord_ids: Sequence[int]
420
+ ):
421
+ if not pdord_ids:
422
+ return {}
423
+
424
+ pdords = (
425
+ session.execute(
426
+ select(
427
+ PdOrd.id,
428
+ PdOrd.pdordlc_id,
429
+ PdOrd.planstarttime,
430
+ PdOrd.starttime,
431
+ PdOrd.planstoptime,
432
+ PdOrd.stoptime,
433
+ PdOrd.qty,
434
+ PdOrd.plancycletime,
435
+ PdOrd.plansetuptime,
436
+ ).where(PdOrd.id.in_(pdord_ids))
437
+ )
438
+ .mappings()
439
+ .all()
440
+ )
441
+ if not pdords:
442
+ return {}
443
+
444
+ pdord_ids = [pdord["id"] for pdord in pdords]
445
+ pdord_lc_ids = {pdord["pdordlc_id"] for pdord in pdords}
446
+
447
+ timestats = (
448
+ session.execute(
449
+ select(
450
+ PdOrdTimeStat.id,
451
+ PdOrdTimeStat.activetime,
452
+ PdOrdTimeStat.cycle_time,
453
+ PdOrdTimeStat.setuptime,
454
+ PdOrdTimeStat.spentindowntime,
455
+ ).where(PdOrdTimeStat.id.in_(pdord_ids))
456
+ )
457
+ .mappings()
458
+ .all()
459
+ )
460
+ timestat_by_pdord_id = {row["id"]: row for row in timestats}
461
+
462
+ prd_rows = session.execute(
463
+ select(PdOrdPrd.pdord_id, PdOrdPrd.quantity).where(
464
+ PdOrdPrd.pdord_id.in_(pdord_ids)
465
+ )
466
+ ).all()
467
+ produced_by_pdord_id: dict[int, float] = {}
468
+ for prd_pdord_id, prd_quantity in prd_rows:
469
+ produced_by_pdord_id[prd_pdord_id] = produced_by_pdord_id.get(
470
+ prd_pdord_id, 0.0
471
+ ) + float(prd_quantity or 0.0)
472
+
473
+ lch_rows = (
474
+ session.execute(
475
+ select(PdOrdLch.pdord_id, PdOrdLch.pdordlct_id, PdOrdLch.createtime).where(
476
+ PdOrdLch.pdord_id.in_(pdord_ids)
477
+ )
478
+ )
479
+ .mappings()
480
+ .all()
481
+ )
482
+
483
+ lct_ids = {row["pdordlct_id"] for row in lch_rows}
484
+ lct_rows = (
485
+ session.execute(
486
+ select(PdOrdLct.id, PdOrdLct.from_pdordlc_id, PdOrdLct.to_pdordlc_id).where(
487
+ PdOrdLct.id.in_(lct_ids)
488
+ )
489
+ )
490
+ .mappings()
491
+ .all()
492
+ if lct_ids
493
+ else []
494
+ )
495
+ lct_by_id = {row["id"]: row for row in lct_rows}
496
+
497
+ transition_lc_ids = {
498
+ lc_id
499
+ for row in lct_rows
500
+ for lc_id in (row["from_pdordlc_id"], row["to_pdordlc_id"])
501
+ }
502
+ all_lc_ids = pdord_lc_ids | transition_lc_ids
503
+ lc_rows = (
504
+ session.execute(
505
+ select(PdOrdLc.id, PdOrdLc.isactive, PdOrdLc.isfinal).where(
506
+ PdOrdLc.id.in_(all_lc_ids)
507
+ )
508
+ )
509
+ .mappings()
510
+ .all()
511
+ if all_lc_ids
512
+ else []
513
+ )
514
+ lc_by_id = {row["id"]: row for row in lc_rows}
515
+
516
+ history_by_pdord_id: dict[int, list[RowMapping]] = {}
517
+ for row in lch_rows:
518
+ history_by_pdord_id.setdefault(row["pdord_id"], []).append(row)
519
+
520
+ result: Dict[str, PdUnitPerformanceGet] = {}
521
+ for pdord in pdords:
522
+ actual_start_time = None
523
+ actual_end_time = None
524
+
525
+ for history in history_by_pdord_id.get(pdord["id"], []):
526
+ transition = lct_by_id.get(history["pdordlct_id"])
527
+ if transition is None:
528
+ continue
529
+
530
+ from_lc = lc_by_id.get(transition["from_pdordlc_id"])
531
+ to_lc = lc_by_id.get(transition["to_pdordlc_id"])
532
+
533
+ if (
534
+ from_lc is not None
535
+ and to_lc is not None
536
+ and (from_lc["isactive"] is False)
537
+ and bool(to_lc["isactive"])
538
+ ):
539
+ if (
540
+ actual_start_time is None
541
+ or history["createtime"] < actual_start_time
542
+ ):
543
+ actual_start_time = history["createtime"]
544
+
545
+ if to_lc is not None and bool(to_lc["isfinal"]):
546
+ if actual_end_time is None or history["createtime"] > actual_end_time:
547
+ actual_end_time = history["createtime"]
548
+
549
+ timestat = timestat_by_pdord_id.get(pdord["id"])
550
+ order_lc = lc_by_id.get(pdord["pdordlc_id"])
551
+ raw_data = {
552
+ "pdord_id": pdord["id"],
553
+ "pdord_planstarttime": pdord["planstarttime"],
554
+ "pdord_starttime": pdord["starttime"],
555
+ "pdord_planstoptime": pdord["planstoptime"],
556
+ "pdord_stoptime": pdord["stoptime"],
557
+ "pdord_qty": pdord["qty"],
558
+ "pdord_plancycletime": pdord["plancycletime"],
559
+ "pdord_plansetuptime": pdord["plansetuptime"],
560
+ "pdordtimestat_activetime": (
561
+ None if timestat is None else timestat["activetime"]
562
+ ),
563
+ "pdordtimestat_cycle_time": (
564
+ None if timestat is None else timestat["cycle_time"]
565
+ ),
566
+ "pdordtimestat_setuptime": (
567
+ None if timestat is None else timestat["setuptime"]
568
+ ),
569
+ "pdordtimestat_spentindowntime": (
570
+ None if timestat is None else timestat["spentindowntime"]
571
+ ),
572
+ "is_active_state": None if order_lc is None else order_lc["isactive"],
573
+ "is_final_state": None if order_lc is None else order_lc["isfinal"],
574
+ "produced_quantity": produced_by_pdord_id.get(pdord["id"], 0.0),
575
+ "actual_start_time": actual_start_time,
576
+ "actual_end_time": actual_end_time,
577
+ }
578
+ result[str(pdord["id"])] = process_performance_in_pure_python(raw_data)
579
+
580
+ return result
581
+
582
+
583
+ def process_performance_in_pure_python(raw_data: Mapping[Any, Any]):
584
+ """
585
+ Takes a dictionary of raw database columns and calculates
586
+ all time management and performance metrics in pure python
587
+ """
588
+ now = datetime.now(timezone.utc)
589
+ planned_start = raw_data.get("pdord_planstarttime") or raw_data.get(
590
+ "pdord_starttime"
591
+ )
592
+ planned_end = raw_data.get("pdord_planstoptime") or raw_data.get("pdord_stoptime")
593
+ actual_start_time = raw_data.get("actual_start_time")
594
+ actual_end_time = raw_data.get("actual_end_time")
595
+
596
+ if actual_start_time is not None:
597
+ estimated_start = actual_start_time
598
+ elif planned_start is not None and planned_start < now:
599
+ estimated_start = now
600
+ else:
601
+ estimated_start = planned_start
602
+
603
+ pdord_qty = float(raw_data.get("pdord_qty") or 0)
604
+ produced_qty = float(raw_data.get("produced_quantity") or 0)
605
+ active_time = raw_data.get("pdordtimestat_activetime")
606
+
607
+ if actual_end_time is not None:
608
+ estimated_end = actual_end_time
609
+ elif (
610
+ actual_start_time is not None
611
+ and active_time is not None
612
+ and pdord_qty > 0
613
+ and produced_qty > 0
614
+ ):
615
+ extrapolated_seconds = active_time.total_seconds() * (pdord_qty / produced_qty)
616
+ estimated_end = actual_start_time + timedelta(seconds=extrapolated_seconds)
617
+ elif (
618
+ actual_start_time is not None
619
+ and planned_end is not None
620
+ and planned_start is not None
621
+ ):
622
+ planned_duration = planned_end - planned_start
623
+ estimated_end = actual_start_time + planned_duration
624
+ elif (
625
+ actual_start_time is None
626
+ and planned_start is not None
627
+ and planned_end is not None
628
+ and planned_start < now
629
+ ):
630
+ planned_duration = planned_end - planned_start
631
+ estimated_end = now + planned_duration
632
+ else:
633
+ estimated_end = planned_end
634
+
635
+ remaining_time_seconds = None
636
+ if (
637
+ actual_start_time is not None
638
+ and active_time is not None
639
+ and pdord_qty > 0
640
+ and produced_qty > 0
641
+ ):
642
+ calc_remaining = (
643
+ active_time.total_seconds() * (pdord_qty / produced_qty)
644
+ - active_time.total_seconds()
645
+ )
646
+ remaining_time_seconds = max(0.0, calc_remaining)
647
+
648
+ delta_start_seconds = _safe_delta(estimated_start, planned_start)
649
+ delta_end_seconds = _safe_delta(estimated_end, planned_end)
650
+ delta_planned_duration = _safe_delta(planned_end, planned_start)
651
+
652
+ dur_est = _safe_delta(estimated_end, estimated_start)
653
+ dur_plan = _safe_delta(planned_end, planned_start)
654
+ estimated_duration = _safe_delta(estimated_end, estimated_start)
655
+ delta_duration_seconds = (
656
+ (dur_est - dur_plan) if (dur_est is not None and dur_plan is not None) else None
657
+ )
658
+
659
+ planned_cycle = _td_to_sec(raw_data.get("pdord_plancycletime"))
660
+ current_cycle = _td_to_sec(raw_data.get("pdordtimestat_cycle_time"))
661
+
662
+ planned_setup = _td_to_sec(raw_data.get("pdord_plansetuptime"))
663
+ current_setup = _td_to_sec(raw_data.get("pdordtimestat_setuptime"))
664
+
665
+ return {
666
+ "time_management": {
667
+ "estimated_start": estimated_start,
668
+ "estimated_end": estimated_end,
669
+ "delta_start_seconds": delta_start_seconds,
670
+ "delta_end_seconds": delta_end_seconds,
671
+ "delta_planned_duration_seconds": delta_planned_duration,
672
+ "estimated_duration_seconds": estimated_duration,
673
+ "delta_duration_seconds": delta_duration_seconds,
674
+ },
675
+ "operation_status": {
676
+ "running_time_seconds": _td_to_sec(active_time),
677
+ "remaining_time_seconds": remaining_time_seconds,
678
+ "downtime_seconds": _td_to_sec(
679
+ raw_data.get("pdordtimestat_spentindowntime")
680
+ ),
681
+ },
682
+ "performance": {
683
+ "cycle_time": {
684
+ "planned_seconds": planned_cycle,
685
+ "current_seconds": current_cycle,
686
+ "delta_seconds": (current_cycle - planned_cycle)
687
+ if (current_cycle is not None and planned_cycle is not None)
688
+ else None,
689
+ },
690
+ "setup_time": {
691
+ "current_seconds": current_setup,
692
+ "delta_seconds": (current_setup - planned_setup)
693
+ if (current_setup is not None and planned_setup is not None)
694
+ else None,
695
+ },
696
+ },
697
+ }
698
+
699
+
700
+ def mount(app: APIRouter):
701
+ app.include_router(routes)
File without changes
@@ -0,0 +1,33 @@
1
+ Metadata-Version: 2.4
2
+ Name: perfact-api-pd-fastapi
3
+ Version: 0.2
4
+ Summary: PerFact API - SQLAlchemy+FastAPI pd apis package
5
+ Author-email: Viktor Dick <viktor.dick@perfact.de>
6
+ License-Expression: GPL-2.0-or-later
7
+ Classifier: Programming Language :: Python :: 3
8
+ Classifier: Programming Language :: SQL
9
+ Classifier: Operating System :: POSIX :: Linux
10
+ Requires-Python: >=3.10
11
+ Description-Content-Type: text/markdown
12
+ Requires-Dist: fastapi[standard-no-fastapi-cloud-cli]
13
+ Requires-Dist: perfact-api-pp
14
+ Requires-Dist: perfact-api-pd
15
+ Requires-Dist: perfact-api-main
16
+
17
+ # PerFact API - pd - APIs
18
+
19
+ This package contains all APIs related to the Pd-module ('Production Execution') of the PerFact software.
20
+ All available APIs are added to the main OpenAPI specification automatically.
21
+
22
+ ## getting started
23
+ This module is discovered and hosted by the `PythonPackages/perfact-api-base`.
24
+ As this package does not contain an own main entrypoint, you have to install it into the base and run that one.
25
+ Please refer to the documentation of the *API Base* to get more information about how the discovery works and how to include this module to your app bundle.
26
+ If everything is working, you should see this module be added to your bundle instance in the log:
27
+ ```
28
+ 2026-[...] - auth.app - INFO - start plugin discovery
29
+ [...]
30
+ 2026-[...] - auth.app - INFO - try to include plugin: perfact.api.pd_fastapi:mount
31
+ [...]
32
+ 2026-[...] - auth.app - INFO - finished discovery and include: X plugins
33
+ ```
@@ -0,0 +1,11 @@
1
+ perfact/api/pd_fastapi/__init__.py,sha256=_VdCnj9Tz8pE2kf0YBi3UW8RnrsEw4NY35S0jeD5Bjs,469
2
+ perfact/api/pd_fastapi/kpi.py,sha256=CS270_GT2EoFjA24JIrQM4d2hdIOISKmWWV-IbXIaoI,7342
3
+ perfact/api/pd_fastapi/locustfile.py,sha256=rFm-nNfEfR6P_Hu0Ya8kGiIjwDDwg5YUxor15HsOCKI,2413
4
+ perfact/api/pd_fastapi/pdunit.py,sha256=5GFnXY6DGo_0XVavjJ1r3f43sLl1dqUYPGgZjHdL6iM,1113
5
+ perfact/api/pd_fastapi/performancedata.py,sha256=EI5TF3zrZm43DfrpUbkrQefT1tA-u157-fccajqna4U,24997
6
+ perfact/api/pd_fastapi/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
+ perfact_api_pd_fastapi-0.2.dist-info/METADATA,sha256=YPp2LQWAJftNv8DT7joggVtRxEfbzz9h92R_q-d8Sfo,1464
8
+ perfact_api_pd_fastapi-0.2.dist-info/WHEEL,sha256=TdQ5LtNwLuxTCjgxN51AgdU5w-KkB9ttmLbzjTH02pg,109
9
+ perfact_api_pd_fastapi-0.2.dist-info/entry_points.txt,sha256=lpsMh9vjZWuc0C2jAPNCVFS5Et8qZ0g2hhVN0haqCys,48
10
+ perfact_api_pd_fastapi-0.2.dist-info/top_level.txt,sha256=1odO3B1JiDF2Lqgnop8k7K4Xs1y_LdwehM53l1NDOnc,8
11
+ perfact_api_pd_fastapi-0.2.dist-info/RECORD,,
@@ -0,0 +1,6 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py2-none-any
5
+ Tag: py3-none-any
6
+
@@ -0,0 +1,2 @@
1
+ [perfact.api]
2
+ pd = perfact.api.pd_fastapi:mount
@@ -0,0 +1 @@
1
+ perfact