Compare commits

...

11 Commits

15
.env

@ -9,12 +9,19 @@ DATABASE_CREDENTIAL_USER=postgres
DATABASE_CREDENTIAL_PASSWORD=postgres DATABASE_CREDENTIAL_PASSWORD=postgres
DATABASE_NAME=digital_twin DATABASE_NAME=digital_twin
COLLECTOR_HOSTNAME=192.168.1.82 COLLECTOR_HOSTNAME=192.168.1.86
COLLECTOR_PORT=1111 COLLECTOR_PORT=5432
COLLECTOR_CREDENTIAL_USER=digital_twin COLLECTOR_CREDENTIAL_USER=postgres
COLLECTOR_CREDENTIAL_PASSWORD=Pr0jec7@D!g!tTwiN COLLECTOR_CREDENTIAL_PASSWORD=postgres
COLLECTOR_NAME=digital_twin COLLECTOR_NAME=digital_twin
TEMPORAL_URL=http://192.168.1.86:7233
# COLLECTOR_PORT=1111
# COLLECTOR_CREDENTIAL_USER=digital_twin
# COLLECTOR_CREDENTIAL_PASSWORD=Pr0jec7@D!g!tTwiN
# COLLECTOR_NAME=digital_twin
# COLLECTOR_HOSTNAME=192.168.1.86 # COLLECTOR_HOSTNAME=192.168.1.86
# COLLECTOR_PORT=5432 # COLLECTOR_PORT=5432

@ -0,0 +1,208 @@
# DigitalTwin - Optimum Overhaul App (be-optimumoh) API Documentation
This document maps all the API endpoints, route handlers, database models, and external service layers inside the `be-optimumoh` microservice. It is a FastAPI-based backend engine managing Overhaul schedules, standard scopes, spare parts readiness, Temporal workflow executions, and optimization/constraint calculations.
---
## 📊 Summary of Services
| Service / Module | Purpose | Endpoints Count | Primary Database Tables |
| :--- | :--- | :---: | :--- |
| **Overhauls** (`overhauls`) | Core overhaul session status, critical parts, schedules, and systems overview. | 4 | `oh_ms_overhaul` |
| **Standard Scope** (`scope-equipments`) | Fleet scopes configuration, master equipment availability, and historical overhaul logs. | 4 | `oh_ms_standard_scope`, `oh_ms_equipment_oh_history` |
| **Overhaul Activities** (`overhaul-activity`) | Operations/activities scheduling and batch additions to active overhaul sessions. | 4 | `oh_tr_overhaul_activity` |
| **Workscope Groups** (`workscopes`) | Maintenance activity groups, tasks, and overhaul scheduling. | 5 | `oh_ms_workscope_group`, `oh_ms_workscope_task` |
| **Spare Parts** (`spareparts`) | Global inventory levels, procurement planning status, and planning remarks. | 2 | `oh_ms_sparepart`, `oh_ms_sparepart_remark` |
| **Equipment Workscopes** (`equipment-workscopes`) | Equipment-specific maintenance work scope groups mapping and jobs tracking. | 3 | `oh_tr_equipment_workscope_group` |
| **Equipment Spare Parts** (`equipment-spareparts`) | Material part catalog tracking per asset location tag. | 1 | `oh_ms_scope_equipment_part` |
| **Monitoring Gantt** (`overhaul-gantt`) | Google Sheets Gantt performance charts extraction and sync triggers. | 3 | `oh_ms_monitoring_spreadsheet` |
| **Overhaul Schedules** (`overhaul-schedules`) | Detailed calendar session creation, history tracking, updates, and deletes. | 6 | `oh_ms_overhaul` |
| **Time Constraints** (`time-constraint`) | Scheduling simulations under a rigid time window restriction. | 9 | `oh_ms_calculation_param`, `oh_tr_calculation_result` |
| **Optimized Time Constraints** | Simulations for ideal schedules under a variable optimal calendar logic. | 9 | `oh_tr_calculation_data`, `oh_tr_calculation_equipment_result` |
| **Target Reliability** | Background RBD Monte Carlo simulations executed via Temporal workflows. | 2 | None (Leverages Temporal Client SDK) |
| **Budget Constraints** | Financial threshold filtering and consequence calculation matrix. | 1 | `oh_tr_calculation_result` |
---
## 🛡️ App Middlewares & Framework Architecture
### 1. Security Headers Middleware
FastAPI adds rigid HTTP security headers to all incoming connections inside `src/main.py`. In production, it enforces `Strict-Transport-Security`, `X-Frame-Options` (DENY), and full CSP blockades. In development mode, it supports relaxed parameters.
### 2. Request Sanitization & Slowapi Limiting
All JSON and path structures pass through `RequestValidationMiddleware` (`slowapi` integration) to enforce rate limiting thresholds globally, returning standard payload error formatting on breaches.
### 3. Multi-Session Context Scoping
Scoped database sessions are generated per request context dynamically utilizing ContextVars in `src/context.py` and SQLAlchemy's async connection pool engine:
* `db` session: Optimum overhaul local PostgreSQL dataset (`default`).
* `collector_db` session: Asset metadata and equipment configurations.
---
## 🛠️ Complete API Endpoints Catalog
```mermaid
graph TD
UI[Frontend Client Application] -->|JWTBearer token| G[FastAPI Gateway Router]
G -->|Overhaul Master| OH[Overhaul Service]
G -->|Standard Scopes| SS[Standard Scope Service]
G -->|Spare Parts| SP[Spare Parts Service]
G -->|Gantt Tracking| GT[Gantt Chart Service]
G -->|Simulations API| TC[Constraint Calculations Service]
TC -->|Simulation Worker| TMP[Temporalio Cluster Engine]
```
### 1. 📂 Overhauls Service (`overhauls`)
Prefix: `/overhauls` — Exposes master overhaul dashboard summaries, schedules, critical parts status, and system maps.
| Service | Endpoint | Method | Type | DB Tables Touched | Called By | LOC | Protect? (Y/N) | Dependencies |
| :--- | :--- | :---: | :--- | :--- | :--- | :---: | :---: | :--- |
| **Overhauls** | `/overhauls` | `GET` | Algorithm | `oh_ms_overhaul` | Frontend | ~28 | **Y** | JWTBearer |
| **Overhauls** | `/overhauls/schedules` | `GET` | CRUD | `oh_ms_overhaul` | Frontend | ~8 | **Y** | JWTBearer |
| **Overhauls** | `/overhauls/critical-parts` | `GET` | CRUD | `oh_ms_sparepart`, `oh_ms_sparepart_procurement` | Frontend | ~8 | **Y** | JWTBearer |
| **Overhauls** | `/overhauls/system-components` | `GET` | CRUD | None (Static mapping) | Frontend | ~10 | **Y** | JWTBearer |
---
### 2. 📋 Standard Scope Service (`scope-equipments`)
Prefix: `/scope-equipments` — Manages fleet-wide equipment checklists and standard scopes configurations.
| Service | Endpoint | Method | Type | DB Tables Touched | Called By | LOC | Protect? (Y/N) | Dependencies |
| :--- | :--- | :---: | :--- | :--- | :--- | :---: | :---: | :--- |
| **Standard Scope** | `/scope-equipments` | `GET` | CRUD | `oh_ms_standard_scope` | Frontend | ~10 | **Y** | JWTBearer |
| **Standard Scope** | `/scope-equipments/available/{scope_name}` | `GET` | CRUD | `ms_equipment_master`, `oh_ms_standard_scope` | Frontend | ~9 | **Y** | JWTBearer |
| **Standard Scope** | `/scope-equipments` | `POST` | CRUD | `oh_ms_standard_scope` | Frontend | ~8 | **Y** | JWTBearer |
| **Standard Scope** | `/scope-equipments/history/{oh_session_id}`| `GET` | CRUD | `oh_ms_equipment_oh_history`, `ms_equipment_master` | Frontend | ~10 | **Y** | JWTBearer |
---
### 3. 🏃‍♂️ Overhaul Activity Service (`overhaul-activity`)
Prefix: `/overhaul-activity` — Controls tasks assigned to individual overhaul sessions.
| Service | Endpoint | Method | Type | DB Tables Touched | Called By | LOC | Protect? (Y/N) | Dependencies |
| :--- | :--- | :---: | :--- | :--- | :--- | :---: | :---: | :--- |
| **Overhaul Activity** | `/overhaul-activity/{overhaul_session}` | `GET` | CRUD | `oh_tr_overhaul_activity` | Frontend | ~25 | **Y** | JWTBearer |
| **Overhaul Activity** | `/overhaul-activity/{overhaul_session_id}`| `POST` | CRUD | `oh_tr_overhaul_activity` | Frontend | ~15 | **Y** | JWTBearer |
| **Overhaul Activity** | `/overhaul-activity/{overhaul_session}/{assetnum}`| `GET` | CRUD | `oh_tr_overhaul_activity` | Frontend | ~17 | **Y** | JWTBearer |
| **Overhaul Activity** | `/overhaul-activity/delete/{overhaul_session}/{location_tag}`| `POST` | CRUD | `oh_tr_overhaul_activity` | Frontend | ~8 | **Y** | JWTBearer |
---
### 4. 🔨 Workscope Group Service (`workscopes`)
Prefix: `/workscopes` — Defines tasks and maintenance groupings.
| Service | Endpoint | Method | Type | DB Tables Touched | Called By | LOC | Protect? (Y/N) | Dependencies |
| :--- | :--- | :---: | :--- | :--- | :--- | :---: | :---: | :--- |
| **Workscope Group** | `/workscopes` | `GET` | CRUD | `oh_ms_workscope_group` | Frontend | ~12 | **Y** | JWTBearer |
| **Workscope Group** | `/workscopes` | `POST` | CRUD | `oh_ms_workscope_group` | Frontend | ~8 | **Y** | JWTBearer |
| **Workscope Group** | `/workscopes/{scope_equipment_activity_id}`| `GET` | CRUD | `oh_ms_workscope_task` | Frontend | ~12 | **Y** | JWTBearer |
| **Workscope Group** | `/workscopes/update/{scope_equipment_activity_id}`| `POST` | CRUD | `oh_ms_workscope_task` | Frontend | ~17 | **Y** | JWTBearer |
| **Workscope Group** | `/workscopes/delete/{scope_equipment_activity_id}`| `POST` | CRUD | `oh_ms_workscope_task` | Frontend | ~14 | **Y** | JWTBearer |
---
### 5. 📦 Spare Parts Service (`spareparts`)
Prefix: `/spareparts` — Manages part catalogs and remark notations.
| Service | Endpoint | Method | Type | DB Tables Touched | Called By | LOC | Protect? (Y/N) | Dependencies |
| :--- | :--- | :---: | :--- | :--- | :--- | :---: | :---: | :--- |
| **Spare Parts** | `/spareparts` | `GET` | CRUD | `oh_ms_sparepart`, `oh_ms_sparepart_procurement` | Frontend | ~12 | **Y** | JWTBearer |
| **Spare Parts** | `/spareparts` | `POST` | CRUD | `oh_ms_sparepart_remark` | Frontend | ~8 | **Y** | JWTBearer |
---
### 6. 🔌 Equipment Workscopes Service (`equipment-workscopes`)
Prefix: `/equipment-workscopes` — Maps equipment directly to defined work scopes.
| Service | Endpoint | Method | Type | DB Tables Touched | Called By | LOC | Protect? (Y/N) | Dependencies |
| :--- | :--- | :---: | :--- | :--- | :--- | :---: | :---: | :--- |
| **Equipment Workscopes**| `/equipment-workscopes/{location_tag}`| `GET` | CRUD | `oh_tr_equipment_workscope_group` | Frontend | ~12 | **Y** | JWTBearer |
| **Equipment Workscopes**| `/equipment-workscopes/{assetnum}` | `POST` | CRUD | `oh_tr_equipment_workscope_group` | Frontend | ~11 | **Y** | JWTBearer |
| **Equipment Workscopes**| `/equipment-workscopes/delete/{scope_job_id}`| `POST` | CRUD | `oh_tr_equipment_workscope_group` | Frontend | ~9 | **Y** | JWTBearer |
---
### 7. 🔩 Equipment Spare Parts Service (`equipment-spareparts`)
Prefix: `/equipment-spareparts` — Lists active spare parts mapping per equipment tag.
| Service | Endpoint | Method | Type | DB Tables Touched | Called By | LOC | Protect? (Y/N) | Dependencies |
| :--- | :--- | :---: | :--- | :--- | :--- | :---: | :---: | :--- |
| **Equipment Spareparts**| `/equipment-spareparts/{location_tag}`| `GET` | CRUD | `oh_ms_scope_equipment_part` | Frontend | ~10 | **Y** | JWTBearer |
---
### 8. 📊 Overhaul Gantt Chart Service (`overhaul-gantt`)
Prefix: `/overhaul-gantt` — Coordinates Google Sheets sync actions to build overhaul performance monitoring dashboards.
| Service | Endpoint | Method | Type | DB Tables Touched | Called By | LOC | Protect? (Y/N) | Dependencies |
| :--- | :--- | :---: | :--- | :--- | :--- | :---: | :---: | :--- |
| **Overhaul Gantt** | `/overhaul-gantt` | `GET` | Integration | `oh_ms_monitoring_spreadsheet` | Frontend | ~19 | **Y** | Google Sheets APIs, cell parsers |
| **Overhaul Gantt** | `/overhaul-gantt/spreadsheet`| `GET` | CRUD | `oh_ms_monitoring_spreadsheet` | Frontend | ~22 | **Y** | JWTBearer |
| **Overhaul Gantt** | `/overhaul-gantt/spreadsheet`| `POST` | CRUD | `oh_ms_monitoring_spreadsheet` | Frontend | ~36 | **Y** | Google Sheets URL validator |
---
### 9. 📅 Overhaul Session Schedules Service (`overhaul-schedules`)
Prefix: `/overhaul-schedules` — Controls scheduler calendar timelines.
| Service | Endpoint | Method | Type | DB Tables Touched | Called By | LOC | Protect? (Y/N) | Dependencies |
| :--- | :--- | :---: | :--- | :--- | :--- | :---: | :---: | :--- |
| **Schedules** | `/overhaul-schedules` | `GET` | CRUD | `oh_ms_overhaul` | Frontend | ~10 | **Y** | JWTBearer |
| **Schedules** | `/overhaul-schedules/history`| `GET` | CRUD | `oh_ms_overhaul` | Frontend | ~4 | **Y** | JWTBearer |
| **Schedules** | `/overhaul-schedules/{overhaul_session_id}`| `GET` | CRUD | `oh_ms_overhaul` | Frontend | ~10 | **Y** | JWTBearer |
| **Schedules** | `/overhaul-schedules` | `POST` | CRUD | `oh_ms_overhaul` | Frontend | ~5 | **Y** | JWTBearer |
| **Schedules** | `/overhaul-schedules/update/{scope_id}`| `POST` | CRUD | `oh_ms_overhaul` | Frontend | ~12 | **Y** | JWTBearer |
| **Schedules** | `/overhaul-schedules/delete/{scope_id}`| `POST` | CRUD | `oh_ms_overhaul` | Frontend | ~13 | **Y** | JWTBearer |
---
### 10. ⏱️ Time Constraints Calculation Service (`time-constraint`)
Prefix: `/calculation/time-constraint` — Simulates failure counts and risks under strict overhaul downtime constraints.
| Service | Endpoint | Method | Type | DB Tables Touched | Called By | LOC | Protect? (Y/N) | Dependencies |
| :--- | :--- | :---: | :--- | :--- | :--- | :---: | :---: | :--- |
| **Time Constraints** | `/calculation/time-constraint` | `POST` | Algorithm | `oh_ms_calculation_param` | Frontend | ~36 | **Y** | RBD risk simulation logic |
| **Time Constraints** | `/calculation/time-constraint` | `GET` | CRUD | `oh_tr_calculation_data` | Frontend | ~18 | **Y** | JWTBearer |
| **Time Constraints** | `/calculation/time-constraint/parameters`| `GET` | CRUD | `oh_ms_calculation_param` | Frontend | ~13 | **Y** | JWTBearer |
| **Time Constraints** | `/calculation/time-constraint/{calculation_id}`| `GET` | Algorithm | `oh_tr_calculation_result` | Frontend | ~18 | **N** | Internal Auth Key (Unprotected endpoint) |
| **Time Constraints** | `/calculation/time-constraint/{calculation_id}/{assetnum}`| `GET` | CRUD | `oh_tr_calculation_equipment_result` | Frontend | ~9 | **Y** | JWTBearer |
| **Time Constraints** | `/calculation/time-constraint/{calculation_id}/simulation`| `POST` | Algorithm | `oh_tr_calculation_result` | Frontend | ~13 | **Y** | Temporal timelines calculator |
| **Time Constraints** | `/calculation/time-constraint/update/{calculation_id}`| `POST` | CRUD | `oh_tr_calculation_equipment_result` | Frontend | ~18 | **Y** | Bulk parameters updates |
| **Time Constraints** | `/calculation/time-constraint/{calculation_id}/refresh-spareparts`| `POST` | Integration | `oh_ms_sparepart`, `oh_ms_calculation_param` | Frontend | ~15 | **Y** | Inventory sync parameters |
| **Time Constraints** | `/calculation/time-constraint/{calculation_id}/recalculate`| `POST` | Algorithm / Int | `oh_tr_calculation_result` | Frontend | ~17 | **Y** | Temporal workflows trigger |
---
### 11. ⚙️ Optimized Time Constraints Service (`optimum-time-constraint`)
Prefix: `/calculation/optimum-time-constraint` — Solves for optimized downtime intervals.
| Service | Endpoint | Method | Type | DB Tables Touched | Called By | LOC | Protect? (Y/N) | Dependencies |
| :--- | :--- | :---: | :--- | :--- | :--- | :---: | :---: | :--- |
| **Optimum Constraints** | `/calculation/optimum-time-constraint` | `POST` | Algorithm | `oh_ms_calculation_param` | Frontend | ~29 | **Y** | Optimum solver iterations |
| **Optimum Constraints** | `/calculation/optimum-time-constraint` | `GET` | CRUD | `oh_tr_calculation_data` | Frontend | ~15 | **Y** | JWTBearer |
| **Optimum Constraints** | `/calculation/optimum-time-constraint/parameters`| `GET` | CRUD | `oh_ms_calculation_param` | Frontend | ~13 | **Y** | JWTBearer |
| **Optimum Constraints** | `/calculation/optimum-time-constraint/{calculation_id}`| `GET` | Algorithm | `oh_tr_calculation_result` | Frontend | ~19 | **N** | Internal Auth Key (Unprotected endpoint) |
| **Optimum Constraints** | `/calculation/optimum-time-constraint/{calculation_id}/{assetnum}`| `GET` | CRUD | `oh_tr_calculation_equipment_result` | Frontend | ~8 | **Y** | JWTBearer |
| **Optimum Constraints** | `/calculation/optimum-time-constraint/{calculation_id}/simulation`| `POST` | Algorithm | `oh_tr_calculation_result` | Frontend | ~13 | **Y** | Optimization simulation matrix |
| **Optimum Constraints** | `/calculation/optimum-time-constraint/update/{calculation_id}`| `POST` | CRUD | `oh_tr_calculation_equipment_result` | Frontend | ~18 | **Y** | JWTBearer |
| **Optimum Constraints** | `/calculation/optimum-time-constraint/{calculation_id}/refresh-spareparts`| `POST` | Integration | `oh_ms_sparepart` | Frontend | ~15 | **Y** | Inventory sync parameters |
| **Optimum Constraints** | `/calculation/optimum-time-constraint/{calculation_id}/recalculate`| `POST` | Algorithm / Int | `oh_tr_calculation_result` | Frontend | ~17 | **Y** | Temporal workflows trigger |
---
### 12. ⚡ Target Reliability Simulation Service (`target-reliability`)
Prefix: `/calculation/target-reliability` — Leverages Temporal client triggers to dispatch heavy RBD Monte Carlo workflows.
| Service | Endpoint | Method | Type | DB Tables Touched | Called By | LOC | Protect? (Y/N) | Dependencies |
| :--- | :--- | :---: | :--- | :--- | :--- | :---: | :---: | :--- |
| **Target Reliability** | `/calculation/target-reliability/simulate`| `POST` | Integration | None (Triggers Temporal workflow) | Frontend | ~18 | **Y** | Temporal IO client runner |
| **Target Reliability** | `/calculation/target-reliability` | `GET` | Algorithm | `oh_ms_overhaul` | Frontend | ~88 | **Y** | Temporal client status tracking, EAF math |
---
### 13. 💰 Budget Constraints Service (`budget-constraint`)
Prefix: `/calculation/budget-constraint` — Computes maintenance strategies according to cost limit thresholds.
| Service | Endpoint | Method | Type | DB Tables Touched | Called By | LOC | Protect? (Y/N) | Dependencies |
| :--- | :--- | :---: | :--- | :--- | :--- | :---: | :---: | :--- |
| **Budget Constraints** | `/calculation/budget-constraint/{session_id}`| `GET` | Algorithm | `oh_ms_overhaul`, `oh_tr_calculation_result` | Frontend | ~25 | **Y** | Risk-cost matrix calculator |

@ -0,0 +1,41 @@
# Equipment Level Overhaul Optimization
This document explains the mathematical theory and implementation used to determine the optimal overhaul interval for individual assets.
## 1. Theoretical Foundation
The optimization model is based on the **Expected Total Cost per Unit Time (CPUT)** theory, a standard approach in reliability engineering for age-replacement and block-replacement policies.
### Objective Function
The goal is to find an overhaul interval ($T$) that minimizes the total expected cost amortized over time:
$$C(T) = \frac{C_{Preventive} + C_{Corrective} \cdot E[N(T)]}{T}$$
Where:
* **$T$**: The overhaul interval (e.g., month 12, 24, etc.).
* **$C_{Preventive}$**: The cost of a planned overhaul (materials, labor, and procurement).
* **$C_{Corrective}$**: The cost of an unplanned failure (repairs + risk cost of downtime).
* **$E[N(T)]$**: The expected number of failures occurring in the interval $(0, T]$. This is derived from the **NHPP (Non-Homogeneous Poisson Process)** reliability model.
## 2. The Cost Balance (The "U-Curve")
Optimization works by balancing two competing costs:
1. **Overhaul Cost ($C_{PM}/T$)**: As the interval $T$ increases, the amortized cost of the overhaul decreases (economy of waiting).
2. **Failure Risk ($C_{CM} \cdot E[N(T)]/T$)**: As the interval $T$ increases, the probability and expected frequency of failure increase (cost of wear-out).
The point where these two lines intersect typically represents the **Global Minimum** of the total cost curve, known as the **Optimum Overhaul Month**.
## 3. Implementation Logic
In the `service.py` engine, the search is performed as follows:
1. **Failure Projection**: The system fetches reliability prediction data (cumulative failures) for the analysis window.
2. **Sparepart Simulation**: For every potential month $T$, the system simulates the procurement process to calculate the real $C_{Preventive}$ (including any shortage penalties).
3. **Cost Amortization**:
```python
total_cycle_cost = total_expected_failure_cost + total_preventive_cost
cput = total_cycle_cost / month_index
```
4. **Grid Search**: The system iterates through all months in the analysis window and identifies the index with the lowest `cput` value.
## 4. Interpretation in UI
* **Analysis Window Card**: Shows the CPUT value at your currently selected month.
* **Optimum Target Card**: Shows the month $T$ where the curve is at its lowest point.
* **Potential Benefit**: Calculated as $CPUT(current) - CPUT(optimum)$. This represents the real monthly cash-flow saving if the maintenance interval is adjusted.

@ -0,0 +1,47 @@
# Plant Level Overhaul Optimization
This document explains how individual asset optimizations are aggregated to find the best economic strategy for the entire fleet or plant.
## 1. Fleet Aggregation Theory
At the plant level, the objective is to minimize the **Total Fleet Cost per Unit Time**. This assumes a **Uniform Interval Policy** or a **Synchronized Overhaul Strategy** where the plant looks for a common maintenance rhythm.
### Plant Objective Function
The plant-level cost function is the summation of individual equipment cost functions ($C_i$):
$$C_{Plant}(T) = \sum_{i=1}^{N} C_i(T) = \frac{\sum_{i=1}^{N} (C_{p,i} + C_{f,i} \cdot E[N_i(T)])}{T}$$
Where:
* **$N$**: Total number of equipments in the scope.
* **$C_{p,i}$**: Preventive cost for equipment $i$.
* **$C_{f,i}$**: Failure cost for equipment $i$.
* **$E[N_i(T)]$**: Expected failures for equipment $i$ until time $T$.
## 2. Searching for the Fleet Optimum
The "actual theory" used by the engine involves a two-phase search:
### Phase 1: Unconstrained Summation
The system calculates the CPUT curve for every piece of equipment independently. It then sums these curves to create a "Fleet U-Curve." The minimum of this sum represents the **Theoretical Fleet Optimum**.
### Phase 2: Sparepart Interaction & Constraints
Unlike a simple sum, the real plant optimum must account for **shared resources** (e.g., a limited budget or limited spare parts).
1. **Sparepart Conflicts**: If multiple equipments reach their optimal interval at the same time, the `SparepartManager` checks if there are enough parts in the warehouse.
2. **Constraint Penalty**: If parts are missing, a "Procurement Penalty" is added to the $C_{p,i}$ for that specific month, effectively shifting the "U-curve" to the right (delaying) or left (earlier) depending on availability.
3. **Final Selection**: The system chooses the Month $T$ that minimizes the *constrained* total fleet cost.
## 3. Implementation in `service.py`
The code uses `numpy` to perform vector addition of the cost curves:
```python
# Aggregate amortized costs for fleet analysis
total_corrective_costs += np.array(corrective_costs)
total_preventive_costs += np.array(preventive_costs)
total_costs += np.array(total_costs_equipment)
# Find the month T that minimizes the sum
fleet_optimal_index = np.argmin(total_costs)
```
## 4. Key Metrics for Decision Makers
* **Fleet CPUT**: The average monthly budget required to maintain the plant at the chosen interval.
* **Accumulation Control**: By using amortized costs (Rp/Month) instead of total cumulative costs, the plant chart remains stable and allows for direct comparison between intervals regardless of the number of assets.
* **Risk vs. Cost**: The plant chart shows the trade-off between the *Fleet Failure Risk* (increasing line) and *Fixed Overhaul Costs* (decreasing line).

@ -0,0 +1,26 @@
import asyncio
from src.database.core import engine
from sqlalchemy import text
async def alter_table():
async with engine.begin() as conn:
try:
await conn.execute(text("ALTER TABLE oh_tr_calculation_data ADD COLUMN plant_results JSONB"))
print("Added plant_results")
except Exception as e:
print(f"plant_results exists: {e}")
try:
await conn.execute(text("ALTER TABLE oh_tr_calculation_data ADD COLUMN fleet_statistics JSONB"))
print("Added fleet_statistics")
except Exception as e:
print(f"fleet_statistics exists: {e}")
try:
await conn.execute(text("ALTER TABLE oh_tr_calculation_data ADD COLUMN analysis_metadata JSONB"))
print("Added analysis_metadata")
except Exception as e:
print(f"analysis_metadata exists: {e}")
if __name__ == "__main__":
asyncio.run(alter_table())

@ -0,0 +1,155 @@
import sys
with open('/home/atra/Development/be-optimumoh/src/calculation_time_constrains/service.py', 'r') as f:
lines = f.readlines()
# Find the point where it broke
# Line 159 is ' \'month_index\': i + 1,\n'
# We want to keep up to line 159 (index 158)
new_lines = lines[:159]
new_lines.append(" 'source': source\n")
new_lines.append(" }\n")
new_lines.append(" \n")
new_lines.append(" return monthly_data\n")
new_lines.append("\n")
new_lines.append(" async def get_simulation_results(self, simulation_id: str = \"default\"):\n")
new_lines.append(" \"\"\"Get simulation results for Birnbaum importance calculations\"\"\"\n")
new_lines.append(" headers = {\n")
new_lines.append(" \"Authorization\": f\"Bearer {self.token}\",\n")
new_lines.append(" \"Content-Type\": \"application/json\"\n")
new_lines.append(" }\n")
new_lines.append("\n")
new_lines.append(" calc_result_url = f\"{self.api_base_url}/aeros/simulation/result/calc/{simulation_id}?nodetype=RegularNode\"\n")
new_lines.append(" plant_result_url = f\"{self.api_base_url}/aeros/simulation/result/calc/{simulation_id}/plant\"\n")
new_lines.append("\n")
new_lines.append(" async with httpx.AsyncClient(timeout=300.0) as client:\n")
new_lines.append(" calc_task = client.get(calc_result_url, headers=headers)\n")
new_lines.append(" plant_task = client.get(plant_result_url, headers=headers)\n")
new_lines.append("\n")
new_lines.append(" calc_response, plant_response = await asyncio.gather(calc_task, plant_task)\n")
new_lines.append("\n")
new_lines.append(" calc_response.raise_for_status()\n")
new_lines.append(" plant_response.raise_for_status()\n")
new_lines.append("\n")
new_lines.append(" calc_data = calc_response.json()[\"data\"]\n")
new_lines.append(" plant_data = plant_response.json()[\"data\"]\n")
new_lines.append("\n")
new_lines.append(" return {\n")
new_lines.append(" \"calc_result\": calc_data,\n")
new_lines.append(" \"plant_result\": plant_data\n")
new_lines.append(" }\n")
new_lines.append("\n")
new_lines.append(" def _calculate_equipment_costs_with_spareparts(self, failures_prediction: Dict, birnbaum_importance: float,\n")
new_lines.append(" preventive_cost: float, failure_replacement_cost: float, ecs,\n")
new_lines.append(" location_tag: str, planned_overhauls: List = None, loss_production_permonth=0) -> List[Dict]:\n")
new_lines.append(" \"\"\"Calculate costs for each month including sparepart costs and availability\"\"\"\n")
new_lines.append(" \n")
new_lines.append(" if not failures_prediction:\n")
new_lines.append(" self.logger.warning(f\"No failure prediction data for {location_tag}\")\n")
new_lines.append(" return []\n")
new_lines.append("\n")
new_lines.append(" results = []\n")
new_lines.append(" months = list(failures_prediction.keys())\n")
new_lines.append(" num_months = len(months)\n")
new_lines.append(" failure_counts = []\n")
new_lines.append(" \n")
new_lines.append(" monthly_risk_cost_per_failure = 0\n")
new_lines.append(" \n")
new_lines.append(" if ecs:\n")
new_lines.append(" is_trip = 1 if ecs.get(\"Diskripsi Operasional Akibat Equip. Failure\") == \"Trip\" else 0\n")
new_lines.append(" is_series = 0 if not birnbaum_importance else math.floor(birnbaum_importance)\n")
new_lines.append(" if is_trip:\n")
new_lines.append(" downtime = ecs.get(\"Estimasi Waktu Maint. / Downtime / Gangguan (Jam)\")\n")
new_lines.append(" monthly_risk_cost_per_failure = 660 * 1000000 * is_trip * downtime * is_series\n")
new_lines.append(" \n")
new_lines.append(" for month_key in months:\n")
new_lines.append(" data = failures_prediction[month_key]\n")
new_lines.append(" failure_counts.append(data['cumulative_failures'])\n")
new_lines.append(" \n")
new_lines.append(" for i in range(num_months):\n")
new_lines.append(" month_index = i + 1\n")
new_lines.append(" \n")
new_lines.append(" # Use only months within the analysis window\n")
new_lines.append(" if month_index > self.time_window_months:\n")
new_lines.append(" continue\n")
new_lines.append("\n")
new_lines.append(" # Check sparepart availability for this month\n")
new_lines.append(" sparepart_analysis = self._analyze_sparepart_availability(\n")
new_lines.append(" location_tag, month_index - 1, planned_overhauls or []\n")
new_lines.append(" )\n")
new_lines.append(" \n")
new_lines.append(" # THEORY: Total Expected Cost per Unit Time (CPUT)\n")
new_lines.append(" # Reference: Maintenance Optimization Models (Age/Block Replacement)\n")
new_lines.append(" # C(T) = [Total Preventive Cost + Total Expected Corrective Cost(T)] / T\n")
new_lines.append(" \n")
new_lines.append(" # 1. Total Expected Corrective Cost until month_index (Expected number of failures * cost per failure)\n")
new_lines.append(" # In NHPP model, Expected Failures E[N(T)] = Cumulative Failures\n")
new_lines.append(" total_expected_failure_cost = failure_counts[i] * (failure_replacement_cost + monthly_risk_cost_per_failure)\n")
new_lines.append(" \n")
new_lines.append(" # 2. Total Preventive Cost (One-time cost at month_index)\n")
new_lines.append(" # Includes labor, materials, and procurement delays\n")
new_lines.append(" procurement_cost = sparepart_analysis['total_procurement_cost']\n")
new_lines.append(" total_preventive_cost = preventive_cost + procurement_cost\n")
new_lines.append(" \n")
new_lines.append(" # 3. Expected Total Cycle Cost\n")
new_lines.append(" total_cycle_cost = total_expected_failure_cost + total_preventive_cost\n")
new_lines.append(" \n")
new_lines.append(" # 4. Expected Cost Per Unit Time (Optimization Target)\n")
new_lines.append(" # This value forms the U-shaped curve\n")
new_lines.append(" cput = total_cycle_cost / month_index\n")
new_lines.append(" \n")
new_lines.append(" # Store both absolute and amortized components for visualization\n")
new_lines.append(" results.append({\n")
new_lines.append(" 'month': month_index,\n")
new_lines.append(" 'number_of_failures': failure_counts[i],\n")
new_lines.append(" 'is_actual': failures_prediction[months[i]].get('source') == 'actual',\n")
new_lines.append(" \n")
new_lines.append(" # Amortized components (for the \"U\" chart)\n")
new_lines.append(" 'failure_cost': total_expected_failure_cost / month_index,\n")
new_lines.append(" 'preventive_cost': preventive_cost / month_index,\n")
new_lines.append(" 'procurement_cost': procurement_cost / month_index,\n")
new_lines.append(" 'total_cost': cput,\n")
new_lines.append(" \n")
new_lines.append(" # Absolute values (for breakdown analysis)\n")
new_lines.append(" 'absolute_failure_cost': total_expected_failure_cost,\n")
new_lines.append(" 'absolute_overhaul_cost': preventive_cost,\n")
new_lines.append(" 'absolute_procurement_cost': procurement_cost,\n")
new_lines.append(" 'total_cycle_cost': total_cycle_cost,\n")
new_lines.append("\n")
new_lines.append(" 'is_after_planned_oh': month_index > self.planned_oh_months,\n")
new_lines.append(" 'delay_months': max(0, month_index - self.planned_oh_months),\n")
new_lines.append(" 'procurement_details': sparepart_analysis,\n")
new_lines.append(" 'sparepart_available': sparepart_analysis['available'],\n")
new_lines.append(" 'sparepart_status': sparepart_analysis['message'],\n")
new_lines.append(" 'can_proceed': sparepart_analysis['can_proceed_with_delays']\n")
new_lines.append(" })\n")
new_lines.append(" \n")
new_lines.append(" return results\n")
new_lines.append("\n")
new_lines.append(" def _analyze_sparepart_availability(self, equipment_tag: str, target_month: int, \n")
new_lines.append(" planned_overhauls: List) -> Dict:\n")
new_lines.append(" \"\"\"Analyze sparepart availability for equipment at target month\"\"\"\n")
new_lines.append(" if not self.sparepart_manager:\n")
new_lines.append(" return {\n")
new_lines.append(" 'available': True,\n")
new_lines.append(" 'message': 'Sparepart manager not initialized',\n")
new_lines.append(" 'total_procurement_cost': 0,\n")
new_lines.append(" 'procurement_needed': [],\n")
new_lines.append(" 'can_proceed_with_delays': True\n")
new_lines.append(" }\n")
new_lines.append(" \n")
new_lines.append(" # Convert planned overhauls to format expected by sparepart manager\n")
new_lines.append(" other_overhauls = [(eq_tag, month) for eq_tag, month in planned_overhauls\n")
new_lines.append(" if eq_tag != equipment_tag and month <= target_month]\n")
new_lines.append("\n")
new_lines.append(" return self.sparepart_manager.check_sparepart_availability(\n")
new_lines.append(" equipment_tag, target_month, other_overhauls\n")
new_lines.append(" )\n")
new_lines.append("\n")
new_lines.extend(lines[159:])
with open('/home/atra/Development/be-optimumoh/src/calculation_time_constrains/service.py', 'w') as f:
f.writelines(new_lines)

@ -0,0 +1,263 @@
import re
with open('src/calculation_time_constrains/service.py', 'r') as f:
content = f.read()
# Replacement 1: run_simulation_with_spareparts
replacement_1 = """ finally:
await optimum_oh_model._close_session()
# Re-fetch calculation_data with equipment_results to ensure they are loaded
from sqlalchemy import select
from sqlalchemy.orm import selectinload
calculation_query = await db_session.execute(
select(CalculationData)
.options(selectinload(CalculationData.equipment_results), selectinload(CalculationData.parameter))
.where(CalculationData.id == calculation.id)
)
scope_calculation = calculation_query.scalar_one_or_none()
data_num = scope_calculation.max_interval
all_equipment = scope_calculation.equipment_results
included_equipment = [eq for eq in all_equipment if eq.is_included]
calculation_results = []
fleet_statistics = {
'total_equipment': len(all_equipment),
'included_equipment': len(included_equipment),
'excluded_equipment': len(all_equipment) - len(included_equipment),
'equipment_with_sparepart_constraints': 0,
'total_procurement_items': 0,
'critical_procurement_items': 0,
'total_spareparts': 745
}
avg_failure_cost = sum((eq.material_cost or 0) + (3 * 111000 * 3) for eq in all_equipment) / len(all_equipment) if all_equipment else 0
rbd_marginal_fails = [0] * data_num
try:
if scope_calculation.rbd_simulation_id:
from src.config import RBD_SERVICE_API
import httpx
plant_result_url = f"{RBD_SERVICE_API}/aeros/simulation/result/calc/{scope_calculation.rbd_simulation_id}/plant"
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.get(
plant_result_url,
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
)
if response.status_code == 200:
plant_data = response.json().get("data", {})
timestamp_outs = plant_data.get("timestamp_outs", [])
if timestamp_outs:
from src.calculation_time_constrains.utils import create_time_series_data, calculate_failures_per_month
hourly_data = create_time_series_data(timestamp_outs, max_hours=data_num * 720)
cumulative_rbd_fails = calculate_failures_per_month(hourly_data)
rbd_fails_map = {m['month']: m['failures'] for m in cumulative_rbd_fails}
prev_fail = 0
for m in range(1, data_num + 1):
curr_fail = rbd_fails_map.get(m, prev_fail)
rbd_marginal_fails[m-1] = curr_fail - prev_fail
prev_fail = curr_fail
except Exception as e:
import logging
logger = logging.getLogger(__name__)
logger.warning(f"Failed to fetch plant simulation: {e}")
cumulative_plant_failures = 0
import numpy as np
from .schema import CalculationResultsRead
for month_index in range(data_num):
historical_marginal_fail = 0
for eq in all_equipment:
if eq.is_actual and month_index < len(eq.is_actual) and eq.is_actual[month_index]:
curr_fail = eq.daily_failures[month_index] if month_index < len(eq.daily_failures) else 0
prev_fail = eq.daily_failures[month_index-1] if month_index > 0 and (month_index - 1) < len(eq.daily_failures) else 0
historical_marginal_fail += max(0, curr_fail - prev_fail)
marginal_fail = rbd_marginal_fails[month_index] + historical_marginal_fail
cumulative_plant_failures += marginal_fail
month_result = {
"overhaul_cost": 0.0,
"corrective_cost": 0.0,
"procurement_cost": 0.0,
"num_failures": cumulative_plant_failures,
"day": month_index + 1,
"month": month_index + 1,
"procurement_details": {},
"sparepart_summary": {
"total_procurement_cost": 0.0,
"equipment_requiring_procurement": 0,
"critical_shortages": 0,
"existing_orders_value": 0.0,
"new_orders_required": 0,
"urgent_procurements": 0
}
}
equipment_requiring_procurement = 0
total_existing_orders_value = 0.0
total_new_orders_value = 0.0
critical_shortages = 0
urgent_procurements = 0
for eq in all_equipment:
if month_index >= len(eq.procurement_details):
continue
procurement_detail = eq.procurement_details[month_index]
if (procurement_detail and isinstance(procurement_detail, dict) and procurement_detail.get("procurement_needed")):
equipment_requiring_procurement += 1
pr_po_summary = procurement_detail.get("pr_po_summary", {})
existing_orders_value = pr_po_summary.get("total_existing_value", 0)
total_existing_orders_value += existing_orders_value
new_orders_value = pr_po_summary.get("total_new_orders_value", 0)
total_new_orders_value += new_orders_value
critical_missing = procurement_detail.get("critical_missing_parts", 0)
if critical_missing > 0:
critical_shortages += 1
recommendations = procurement_detail.get("recommendations", [])
urgent_items = [r for r in recommendations if r.get("priority") == "CRITICAL"]
if urgent_items:
urgent_procurements += 1
is_included_eq = False if eq.is_initial else eq.is_included
month_result["procurement_details"][eq.location_tag] = {
"is_included": is_included_eq,
"location_tag": eq.location_tag,
"details": procurement_detail.get("procurement_needed", []),
"detailed_message": procurement_detail.get("detailed_message", ""),
"pr_po_summary": pr_po_summary,
"recommendations": recommendations,
"sparepart_available": procurement_detail.get("sparepart_available", True),
"can_proceed": procurement_detail.get("can_proceed_with_delays", True),
"critical_missing_parts": critical_missing,
"existing_orders_value": existing_orders_value,
"new_orders_value": new_orders_value
}
if eq.is_included:
if (month_index < len(eq.overhaul_costs) and month_index < len(eq.procurement_costs)):
month_result["overhaul_cost"] += float(eq.overhaul_costs[month_index])
month_result["procurement_cost"] += float(eq.procurement_costs[month_index])
month_result["corrective_cost"] = (cumulative_plant_failures * avg_failure_cost) / (month_index + 1)
month_result["sparepart_summary"].update({
"total_procurement_cost": month_result["procurement_cost"],
"equipment_requiring_procurement": equipment_requiring_procurement,
"critical_shortages": critical_shortages,
"existing_orders_value": total_existing_orders_value,
"new_orders_required": len([eq for eq in all_equipment if month_index < len(eq.procurement_details) and eq.procurement_details[month_index] and eq.procurement_details[month_index].get("procurement_needed")]),
"urgent_procurements": urgent_procurements
})
month_result["total_cost"] = month_result["corrective_cost"] + month_result["overhaul_cost"] + month_result["procurement_cost"]
calculation_results.append(month_result)
optimum_day = np.argmin([month["total_cost"] for month in calculation_results])
scope_calculation.optimum_oh_day = int(optimum_day)
fleet_statistics['equipment_with_sparepart_constraints'] = len([
eq for eq in all_equipment if any(detail and detail.get("procurement_needed") for detail in eq.procurement_details if detail)
])
fleet_statistics['total_procurement_items'] = sum([
len(detail.get("procurement_needed", [])) for eq in all_equipment for detail in eq.procurement_details if detail and isinstance(detail, dict)
])
analysis_metadata = {
"planned_month": (scope.start_date.year - prev_oh_scope.end_date.year) * 12 + (scope.start_date.month - prev_oh_scope.end_date.month) if prev_oh_scope and scope else 0,
"total_fleet_procurement_cost": sum([eq.procurement_costs[int(scope_calculation.optimum_oh_day)] for eq in all_equipment if eq.procurement_costs]),
"last_oh_date": prev_oh_scope.end_date.isoformat() if prev_oh_scope else None,
"next_oh_date": scope.start_date.isoformat() if scope else None,
"optimal_stat": None
}
calc_results_read = [CalculationResultsRead(**r) for r in calculation_results]
optimal_analysis = _analyze_optimal_timing(
calc_results_read, scope_calculation.optimum_oh_day, prev_oh_scope, scope
)
scope_calculation.plant_results = calculation_results
scope_calculation.fleet_statistics = fleet_statistics
scope_calculation.analysis_metadata = analysis_metadata
scope_calculation.optimum_analysis = optimal_analysis
await db_session.commit()
return {
"id": calculation.id,
"optimum": optimal_analysis
}"""
pattern_1 = re.compile(r" finally:\n await optimum_oh_model\._close_session\(\).*?return \{\n \"id\": calculation_data\.id,\n \"optimum\": stats\n \}", re.DOTALL)
if pattern_1.search(content):
content = pattern_1.sub(replacement_1, content)
else:
print("Could not find Replacement 1 target")
# Replacement 2: get_calculation_result
replacement_2 = """async def get_calculation_result(db_session: DbSession, calculation_id: str, token, include_risk_cost):
\"\"\"
Get calculation results from DB, returning pre-calculated plant and equipment results.
\"\"\"
try:
# Get calculation data with equipment results
calculation_query = await db_session.execute(
select(CalculationData)
.options(selectinload(CalculationData.equipment_results))
.where(CalculationData.id == calculation_id)
)
scope_calculation = calculation_query.scalar_one_or_none()
if not scope_calculation:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Calculation with id {calculation_id} does not exist.",
)
scope_overhaul = await get_scope(
db_session=db_session,
overhaul_session_id=scope_calculation.overhaul_session_id
)
if not scope_overhaul:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Overhaul scope for session {scope_calculation.overhaul_session_id} does not exist.",
)
# Parse pre-calculated plant results
plant_results_raw = scope_calculation.plant_results or []
calculation_results = [CalculationResultsRead(**r) for r in plant_results_raw]
# Return comprehensive result
return CalculationTimeConstrainsRead(
id=scope_calculation.id,
reference=scope_calculation.overhaul_session_id,
scope=scope_overhaul.maintenance_type.name,
results=calculation_results,
optimum_oh=scope_calculation.optimum_oh_day,
optimum_oh_month=scope_calculation.optimum_oh_day + 1 if scope_calculation.optimum_oh_day is not None else None,
equipment_results=scope_calculation.equipment_results,
fleet_statistics=scope_calculation.fleet_statistics or {},
optimal_analysis=scope_calculation.optimum_analysis or {},
analysis_metadata=scope_calculation.analysis_metadata or {}
)
except HTTPException:
raise
except Exception as e:
import logging
logger = logging.getLogger(__name__)
logger.error(f"Error in get_calculation_result for calculation_id {calculation_id}: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Internal error processing calculation results: {str(e)}",
)"""
pattern_2 = re.compile(r"async def get_calculation_result\(db_session: DbSession, calculation_id: str, token, include_risk_cost\):.*?raise HTTPException\(\n status_code=status\.HTTP_500_INTERNAL_SERVER_ERROR,\n detail=f\"Internal error processing calculation results: \{str\(e\)\}\",\n \)", re.DOTALL)
if pattern_2.search(content):
content = pattern_2.sub(replacement_2, content)
else:
print("Could not find Replacement 2 target")
with open('src/calculation_time_constrains/service.py', 'w') as f:
f.write(content)
print("Patch applied.")

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

498
poetry.lock generated

@ -451,6 +451,114 @@ files = [
{file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
] ]
[[package]]
name = "contourpy"
version = "1.3.3"
description = "Python library for calculating contours of 2D quadrilateral grids"
optional = false
python-versions = ">=3.11"
groups = ["main"]
files = [
{file = "contourpy-1.3.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:709a48ef9a690e1343202916450bc48b9e51c049b089c7f79a267b46cffcdaa1"},
{file = "contourpy-1.3.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:23416f38bfd74d5d28ab8429cc4d63fa67d5068bd711a85edb1c3fb0c3e2f381"},
{file = "contourpy-1.3.3-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:929ddf8c4c7f348e4c0a5a3a714b5c8542ffaa8c22954862a46ca1813b667ee7"},
{file = "contourpy-1.3.3-cp311-cp311-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9e999574eddae35f1312c2b4b717b7885d4edd6cb46700e04f7f02db454e67c1"},
{file = "contourpy-1.3.3-cp311-cp311-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0bf67e0e3f482cb69779dd3061b534eb35ac9b17f163d851e2a547d56dba0a3a"},
{file = "contourpy-1.3.3-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:51e79c1f7470158e838808d4a996fa9bac72c498e93d8ebe5119bc1e6becb0db"},
{file = "contourpy-1.3.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:598c3aaece21c503615fd59c92a3598b428b2f01bfb4b8ca9c4edeecc2438620"},
{file = "contourpy-1.3.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:322ab1c99b008dad206d406bb61d014cf0174df491ae9d9d0fac6a6fda4f977f"},
{file = "contourpy-1.3.3-cp311-cp311-win32.whl", hash = "sha256:fd907ae12cd483cd83e414b12941c632a969171bf90fc937d0c9f268a31cafff"},
{file = "contourpy-1.3.3-cp311-cp311-win_amd64.whl", hash = "sha256:3519428f6be58431c56581f1694ba8e50626f2dd550af225f82fb5f5814d2a42"},
{file = "contourpy-1.3.3-cp311-cp311-win_arm64.whl", hash = "sha256:15ff10bfada4bf92ec8b31c62bf7c1834c244019b4a33095a68000d7075df470"},
{file = "contourpy-1.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b08a32ea2f8e42cf1d4be3169a98dd4be32bafe4f22b6c4cb4ba810fa9e5d2cb"},
{file = "contourpy-1.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:556dba8fb6f5d8742f2923fe9457dbdd51e1049c4a43fd3986a0b14a1d815fc6"},
{file = "contourpy-1.3.3-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92d9abc807cf7d0e047b95ca5d957cf4792fcd04e920ca70d48add15c1a90ea7"},
{file = "contourpy-1.3.3-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b2e8faa0ed68cb29af51edd8e24798bb661eac3bd9f65420c1887b6ca89987c8"},
{file = "contourpy-1.3.3-cp312-cp312-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:626d60935cf668e70a5ce6ff184fd713e9683fb458898e4249b63be9e28286ea"},
{file = "contourpy-1.3.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4d00e655fcef08aba35ec9610536bfe90267d7ab5ba944f7032549c55a146da1"},
{file = "contourpy-1.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:451e71b5a7d597379ef572de31eeb909a87246974d960049a9848c3bc6c41bf7"},
{file = "contourpy-1.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:459c1f020cd59fcfe6650180678a9993932d80d44ccde1fa1868977438f0b411"},
{file = "contourpy-1.3.3-cp312-cp312-win32.whl", hash = "sha256:023b44101dfe49d7d53932be418477dba359649246075c996866106da069af69"},
{file = "contourpy-1.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:8153b8bfc11e1e4d75bcb0bff1db232f9e10b274e0929de9d608027e0d34ff8b"},
{file = "contourpy-1.3.3-cp312-cp312-win_arm64.whl", hash = "sha256:07ce5ed73ecdc4a03ffe3e1b3e3c1166db35ae7584be76f65dbbe28a7791b0cc"},
{file = "contourpy-1.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:177fb367556747a686509d6fef71d221a4b198a3905fe824430e5ea0fda54eb5"},
{file = "contourpy-1.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d002b6f00d73d69333dac9d0b8d5e84d9724ff9ef044fd63c5986e62b7c9e1b1"},
{file = "contourpy-1.3.3-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:348ac1f5d4f1d66d3322420f01d42e43122f43616e0f194fc1c9f5d830c5b286"},
{file = "contourpy-1.3.3-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:655456777ff65c2c548b7c454af9c6f33f16c8884f11083244b5819cc214f1b5"},
{file = "contourpy-1.3.3-cp313-cp313-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:644a6853d15b2512d67881586bd03f462c7ab755db95f16f14d7e238f2852c67"},
{file = "contourpy-1.3.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4debd64f124ca62069f313a9cb86656ff087786016d76927ae2cf37846b006c9"},
{file = "contourpy-1.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a15459b0f4615b00bbd1e91f1b9e19b7e63aea7483d03d804186f278c0af2659"},
{file = "contourpy-1.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ca0fdcd73925568ca027e0b17ab07aad764be4706d0a925b89227e447d9737b7"},
{file = "contourpy-1.3.3-cp313-cp313-win32.whl", hash = "sha256:b20c7c9a3bf701366556e1b1984ed2d0cedf999903c51311417cf5f591d8c78d"},
{file = "contourpy-1.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:1cadd8b8969f060ba45ed7c1b714fe69185812ab43bd6b86a9123fe8f99c3263"},
{file = "contourpy-1.3.3-cp313-cp313-win_arm64.whl", hash = "sha256:fd914713266421b7536de2bfa8181aa8c699432b6763a0ea64195ebe28bff6a9"},
{file = "contourpy-1.3.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:88df9880d507169449d434c293467418b9f6cbe82edd19284aa0409e7fdb933d"},
{file = "contourpy-1.3.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:d06bb1f751ba5d417047db62bca3c8fde202b8c11fb50742ab3ab962c81e8216"},
{file = "contourpy-1.3.3-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e4e6b05a45525357e382909a4c1600444e2a45b4795163d3b22669285591c1ae"},
{file = "contourpy-1.3.3-cp313-cp313t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ab3074b48c4e2cf1a960e6bbeb7f04566bf36b1861d5c9d4d8ac04b82e38ba20"},
{file = "contourpy-1.3.3-cp313-cp313t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c3d53c796f8647d6deb1abe867daeb66dcc8a97e8455efa729516b997b8ed99"},
{file = "contourpy-1.3.3-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50ed930df7289ff2a8d7afeb9603f8289e5704755c7e5c3bbd929c90c817164b"},
{file = "contourpy-1.3.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4feffb6537d64b84877da813a5c30f1422ea5739566abf0bd18065ac040e120a"},
{file = "contourpy-1.3.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2b7e9480ffe2b0cd2e787e4df64270e3a0440d9db8dc823312e2c940c167df7e"},
{file = "contourpy-1.3.3-cp313-cp313t-win32.whl", hash = "sha256:283edd842a01e3dcd435b1c5116798d661378d83d36d337b8dde1d16a5fc9ba3"},
{file = "contourpy-1.3.3-cp313-cp313t-win_amd64.whl", hash = "sha256:87acf5963fc2b34825e5b6b048f40e3635dd547f590b04d2ab317c2619ef7ae8"},
{file = "contourpy-1.3.3-cp313-cp313t-win_arm64.whl", hash = "sha256:3c30273eb2a55024ff31ba7d052dde990d7d8e5450f4bbb6e913558b3d6c2301"},
{file = "contourpy-1.3.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fde6c716d51c04b1c25d0b90364d0be954624a0ee9d60e23e850e8d48353d07a"},
{file = "contourpy-1.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:cbedb772ed74ff5be440fa8eee9bd49f64f6e3fc09436d9c7d8f1c287b121d77"},
{file = "contourpy-1.3.3-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:22e9b1bd7a9b1d652cd77388465dc358dafcd2e217d35552424aa4f996f524f5"},
{file = "contourpy-1.3.3-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a22738912262aa3e254e4f3cb079a95a67132fc5a063890e224393596902f5a4"},
{file = "contourpy-1.3.3-cp314-cp314-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:afe5a512f31ee6bd7d0dda52ec9864c984ca3d66664444f2d72e0dc4eb832e36"},
{file = "contourpy-1.3.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f64836de09927cba6f79dcd00fdd7d5329f3fccc633468507079c829ca4db4e3"},
{file = "contourpy-1.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1fd43c3be4c8e5fd6e4f2baeae35ae18176cf2e5cced681cca908addf1cdd53b"},
{file = "contourpy-1.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6afc576f7b33cf00996e5c1102dc2a8f7cc89e39c0b55df93a0b78c1bd992b36"},
{file = "contourpy-1.3.3-cp314-cp314-win32.whl", hash = "sha256:66c8a43a4f7b8df8b71ee1840e4211a3c8d93b214b213f590e18a1beca458f7d"},
{file = "contourpy-1.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:cf9022ef053f2694e31d630feaacb21ea24224be1c3ad0520b13d844274614fd"},
{file = "contourpy-1.3.3-cp314-cp314-win_arm64.whl", hash = "sha256:95b181891b4c71de4bb404c6621e7e2390745f887f2a026b2d99e92c17892339"},
{file = "contourpy-1.3.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:33c82d0138c0a062380332c861387650c82e4cf1747aaa6938b9b6516762e772"},
{file = "contourpy-1.3.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ea37e7b45949df430fe649e5de8351c423430046a2af20b1c1961cae3afcda77"},
{file = "contourpy-1.3.3-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d304906ecc71672e9c89e87c4675dc5c2645e1f4269a5063b99b0bb29f232d13"},
{file = "contourpy-1.3.3-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ca658cd1a680a5c9ea96dc61cdbae1e85c8f25849843aa799dfd3cb370ad4fbe"},
{file = "contourpy-1.3.3-cp314-cp314t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ab2fd90904c503739a75b7c8c5c01160130ba67944a7b77bbf36ef8054576e7f"},
{file = "contourpy-1.3.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b7301b89040075c30e5768810bc96a8e8d78085b47d8be6e4c3f5a0b4ed478a0"},
{file = "contourpy-1.3.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2a2a8b627d5cc6b7c41a4beff6c5ad5eb848c88255fda4a8745f7e901b32d8e4"},
{file = "contourpy-1.3.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fd6ec6be509c787f1caf6b247f0b1ca598bef13f4ddeaa126b7658215529ba0f"},
{file = "contourpy-1.3.3-cp314-cp314t-win32.whl", hash = "sha256:e74a9a0f5e3fff48fb5a7f2fd2b9b70a3fe014a67522f79b7cca4c0c7e43c9ae"},
{file = "contourpy-1.3.3-cp314-cp314t-win_amd64.whl", hash = "sha256:13b68d6a62db8eafaebb8039218921399baf6e47bf85006fd8529f2a08ef33fc"},
{file = "contourpy-1.3.3-cp314-cp314t-win_arm64.whl", hash = "sha256:b7448cb5a725bb1e35ce88771b86fba35ef418952474492cf7c764059933ff8b"},
{file = "contourpy-1.3.3-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:cd5dfcaeb10f7b7f9dc8941717c6c2ade08f587be2226222c12b25f0483ed497"},
{file = "contourpy-1.3.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:0c1fc238306b35f246d61a1d416a627348b5cf0648648a031e14bb8705fcdfe8"},
{file = "contourpy-1.3.3-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:70f9aad7de812d6541d29d2bbf8feb22ff7e1c299523db288004e3157ff4674e"},
{file = "contourpy-1.3.3-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5ed3657edf08512fc3fe81b510e35c2012fbd3081d2e26160f27ca28affec989"},
{file = "contourpy-1.3.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:3d1a3799d62d45c18bafd41c5fa05120b96a28079f2393af559b843d1a966a77"},
{file = "contourpy-1.3.3.tar.gz", hash = "sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880"},
]
[package.dependencies]
numpy = ">=1.25"
[package.extras]
bokeh = ["bokeh", "selenium"]
docs = ["furo", "sphinx (>=7.2)", "sphinx-copybutton"]
mypy = ["bokeh", "contourpy[bokeh,docs]", "docutils-stubs", "mypy (==1.17.0)", "types-Pillow"]
test = ["Pillow", "contourpy[test-no-images]", "matplotlib"]
test-no-images = ["pytest", "pytest-cov", "pytest-rerunfailures", "pytest-xdist", "wurlitzer"]
[[package]]
name = "cycler"
version = "0.12.1"
description = "Composable style cycles"
optional = false
python-versions = ">=3.8"
groups = ["main"]
files = [
{file = "cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30"},
{file = "cycler-0.12.1.tar.gz", hash = "sha256:88bb128f02ba341da8ef447245a9e138fae777f6a23943da4540077d3601eb1c"},
]
[package.extras]
docs = ["ipython", "matplotlib", "numpydoc", "sphinx"]
tests = ["pytest", "pytest-cov", "pytest-xdist"]
[[package]] [[package]]
name = "deprecated" name = "deprecated"
version = "1.2.15" version = "1.2.15"
@ -587,6 +695,79 @@ uvicorn = {version = ">=0.15.0", extras = ["standard"]}
[package.extras] [package.extras]
standard = ["uvicorn[standard] (>=0.15.0)"] standard = ["uvicorn[standard] (>=0.15.0)"]
[[package]]
name = "fonttools"
version = "4.62.1"
description = "Tools to manipulate font files"
optional = false
python-versions = ">=3.10"
groups = ["main"]
files = [
{file = "fonttools-4.62.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ad5cca75776cd453b1b035b530e943334957ae152a36a88a320e779d61fc980c"},
{file = "fonttools-4.62.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0b3ae47e8636156a9accff64c02c0924cbebad62854c4a6dbdc110cd5b4b341a"},
{file = "fonttools-4.62.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c9b9e288b4da2f64fd6180644221749de651703e8d0c16bd4b719533a3a7d6e3"},
{file = "fonttools-4.62.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7bca7a1c1faf235ffe25d4f2e555246b4750220b38de8261d94ebc5ce8a23c23"},
{file = "fonttools-4.62.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b4e0fcf265ad26e487c56cb12a42dffe7162de708762db951e1b3f755319507d"},
{file = "fonttools-4.62.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:2d850f66830a27b0d498ee05adb13a3781637b1826982cd7e2b3789ef0cc71ae"},
{file = "fonttools-4.62.1-cp310-cp310-win32.whl", hash = "sha256:486f32c8047ccd05652aba17e4a8819a3a9d78570eb8a0e3b4503142947880ed"},
{file = "fonttools-4.62.1-cp310-cp310-win_amd64.whl", hash = "sha256:5a648bde915fba9da05ae98856987ca91ba832949a9e2888b48c47ef8b96c5a9"},
{file = "fonttools-4.62.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:40975849bac44fb0b9253d77420c6d8b523ac4dcdcefeff6e4d706838a5b80f7"},
{file = "fonttools-4.62.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9dde91633f77fa576879a0c76b1d89de373cae751a98ddf0109d54e173b40f14"},
{file = "fonttools-4.62.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6acb4109f8bee00fec985c8c7afb02299e35e9c94b57287f3ea542f28bd0b0a7"},
{file = "fonttools-4.62.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1c5c25671ce8805e0d080e2ffdeca7f1e86778c5cbfbeae86d7f866d8830517b"},
{file = "fonttools-4.62.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a5d8825e1140f04e6c99bb7d37a9e31c172f3bc208afbe02175339e699c710e1"},
{file = "fonttools-4.62.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:268abb1cb221e66c014acc234e872b7870d8b5d4657a83a8f4205094c32d2416"},
{file = "fonttools-4.62.1-cp311-cp311-win32.whl", hash = "sha256:942b03094d7edbb99bdf1ae7e9090898cad7bf9030b3d21f33d7072dbcb51a53"},
{file = "fonttools-4.62.1-cp311-cp311-win_amd64.whl", hash = "sha256:e8514f4924375f77084e81467e63238b095abda5107620f49421c368a6017ed2"},
{file = "fonttools-4.62.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:90365821debbd7db678809c7491ca4acd1e0779b9624cdc6ddaf1f31992bf974"},
{file = "fonttools-4.62.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:12859ff0b47dd20f110804c3e0d0970f7b832f561630cd879969011541a464a9"},
{file = "fonttools-4.62.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c125ffa00c3d9003cdaaf7f2c79e6e535628093e14b5de1dccb08859b680936"},
{file = "fonttools-4.62.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:149f7d84afca659d1a97e39a4778794a2f83bf344c5ee5134e09995086cc2392"},
{file = "fonttools-4.62.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0aa72c43a601cfa9273bb1ae0518f1acadc01ee181a6fc60cd758d7fdadffc04"},
{file = "fonttools-4.62.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:19177c8d96c7c36359266e571c5173bcee9157b59cfc8cb0153c5673dc5a3a7d"},
{file = "fonttools-4.62.1-cp312-cp312-win32.whl", hash = "sha256:a24decd24d60744ee8b4679d38e88b8303d86772053afc29b19d23bb8207803c"},
{file = "fonttools-4.62.1-cp312-cp312-win_amd64.whl", hash = "sha256:9e7863e10b3de72376280b515d35b14f5eeed639d1aa7824f4cf06779ec65e42"},
{file = "fonttools-4.62.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:c22b1014017111c401469e3acc5433e6acf6ebcc6aa9efb538a533c800971c79"},
{file = "fonttools-4.62.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:68959f5fc58ed4599b44aad161c2837477d7f35f5f79402d97439974faebfebe"},
{file = "fonttools-4.62.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef46db46c9447103b8f3ff91e8ba009d5fe181b1920a83757a5762551e32bb68"},
{file = "fonttools-4.62.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6706d1cb1d5e6251a97ad3c1b9347505c5615c112e66047abbef0f8545fa30d1"},
{file = "fonttools-4.62.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2e7abd2b1e11736f58c1de27819e1955a53267c21732e78243fa2fa2e5c1e069"},
{file = "fonttools-4.62.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:403d28ce06ebfc547fbcb0cb8b7f7cc2f7a2d3e1a67ba9a34b14632df9e080f9"},
{file = "fonttools-4.62.1-cp313-cp313-win32.whl", hash = "sha256:93c316e0f5301b2adbe6a5f658634307c096fd5aae60a5b3412e4f3e1728ab24"},
{file = "fonttools-4.62.1-cp313-cp313-win_amd64.whl", hash = "sha256:7aa21ff53e28a9c2157acbc44e5b401149d3c9178107130e82d74ceb500e5056"},
{file = "fonttools-4.62.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fa1d16210b6b10a826d71bed68dd9ec24a9e218d5a5e2797f37c573e7ec215ca"},
{file = "fonttools-4.62.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:aa69d10ed420d8121118e628ad47d86e4caa79ba37f968597b958f6cceab7eca"},
{file = "fonttools-4.62.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd13b7999d59c5eb1c2b442eb2d0c427cb517a0b7a1f5798fc5c9e003f5ff782"},
{file = "fonttools-4.62.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8d337fdd49a79b0d51c4da87bc38169d21c3abbf0c1aa9367eff5c6656fb6dae"},
{file = "fonttools-4.62.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d241cdc4a67b5431c6d7f115fdf63335222414995e3a1df1a41e1182acd4bcc7"},
{file = "fonttools-4.62.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c05557a78f8fa514da0f869556eeda40887a8abc77c76ee3f74cf241778afd5a"},
{file = "fonttools-4.62.1-cp314-cp314-win32.whl", hash = "sha256:49a445d2f544ce4a69338694cad575ba97b9a75fff02720da0882d1a73f12800"},
{file = "fonttools-4.62.1-cp314-cp314-win_amd64.whl", hash = "sha256:1eecc128c86c552fb963fe846ca4e011b1be053728f798185a1687502f6d398e"},
{file = "fonttools-4.62.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:1596aeaddf7f78e21e68293c011316a25267b3effdaccaf4d59bc9159d681b82"},
{file = "fonttools-4.62.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:8f8fca95d3bb3208f59626a4b0ea6e526ee51f5a8ad5d91821c165903e8d9260"},
{file = "fonttools-4.62.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee91628c08e76f77b533d65feb3fbe6d9dad699f95be51cf0d022db94089cdc4"},
{file = "fonttools-4.62.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5f37df1cac61d906e7b836abe356bc2f34c99d4477467755c216b72aa3dc748b"},
{file = "fonttools-4.62.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:92bb00a947e666169c99b43753c4305fc95a890a60ef3aeb2a6963e07902cc87"},
{file = "fonttools-4.62.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:bdfe592802ef939a0e33106ea4a318eeb17822c7ee168c290273cbd5fabd746c"},
{file = "fonttools-4.62.1-cp314-cp314t-win32.whl", hash = "sha256:b820fcb92d4655513d8402d5b219f94481c4443d825b4372c75a2072aa4b357a"},
{file = "fonttools-4.62.1-cp314-cp314t-win_amd64.whl", hash = "sha256:59b372b4f0e113d3746b88985f1c796e7bf830dd54b28374cd85c2b8acd7583e"},
{file = "fonttools-4.62.1-py3-none-any.whl", hash = "sha256:7487782e2113861f4ddcc07c3436450659e3caa5e470b27dc2177cade2d8e7fd"},
{file = "fonttools-4.62.1.tar.gz", hash = "sha256:e54c75fd6041f1122476776880f7c3c3295ffa31962dc6ebe2543c00dca58b5d"},
]
[package.extras]
all = ["brotli (>=1.0.1) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=0.8.0) ; platform_python_implementation != \"CPython\"", "lxml (>=4.0)", "lz4 (>=1.7.4.2)", "matplotlib", "munkres ; platform_python_implementation == \"PyPy\"", "pycairo", "scipy ; platform_python_implementation != \"PyPy\"", "skia-pathops (>=0.5.0)", "sympy", "uharfbuzz (>=0.45.0)", "unicodedata2 (>=17.0.0) ; python_version <= \"3.14\"", "xattr ; sys_platform == \"darwin\"", "zopfli (>=0.1.4)"]
graphite = ["lz4 (>=1.7.4.2)"]
interpolatable = ["munkres ; platform_python_implementation == \"PyPy\"", "pycairo", "scipy ; platform_python_implementation != \"PyPy\""]
lxml = ["lxml (>=4.0)"]
pathops = ["skia-pathops (>=0.5.0)"]
plot = ["matplotlib"]
repacker = ["uharfbuzz (>=0.45.0)"]
symfont = ["sympy"]
type1 = ["xattr ; sys_platform == \"darwin\""]
unicode = ["unicodedata2 (>=17.0.0) ; python_version <= \"3.14\""]
woff = ["brotli (>=1.0.1) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=0.8.0) ; platform_python_implementation != \"CPython\"", "zopfli (>=0.1.4)"]
[[package]] [[package]]
name = "frozenlist" name = "frozenlist"
version = "1.7.0" version = "1.7.0"
@ -1123,6 +1304,133 @@ MarkupSafe = ">=2.0"
[package.extras] [package.extras]
i18n = ["Babel (>=2.7)"] i18n = ["Babel (>=2.7)"]
[[package]]
name = "kiwisolver"
version = "1.5.0"
description = "A fast implementation of the Cassowary constraint solver"
optional = false
python-versions = ">=3.10"
groups = ["main"]
files = [
{file = "kiwisolver-1.5.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:32cc0a5365239a6ea0c6ed461e8838d053b57e397443c0ca894dcc8e388d4374"},
{file = "kiwisolver-1.5.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:cc0b66c1eec9021353a4b4483afb12dfd50e3669ffbb9152d6842eb34c7e29fd"},
{file = "kiwisolver-1.5.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:86e0287879f75621ae85197b0877ed2f8b7aa57b511c7331dce2eb6f4de7d476"},
{file = "kiwisolver-1.5.0-cp310-cp310-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:62f59da443c4f4849f73a51a193b1d9d258dcad0c41bc4d1b8fb2bcc04bfeb22"},
{file = "kiwisolver-1.5.0-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9190426b7aa26c5229501fa297b8d0653cfd3f5a36f7990c264e157cbf886b3b"},
{file = "kiwisolver-1.5.0-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c8277104ded0a51e699c8c3aff63ce2c56d4ed5519a5f73e0fd7057f959a2b9e"},
{file = "kiwisolver-1.5.0-cp310-cp310-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8f9baf6f0a6e7571c45c8863010b45e837c3ee1c2c77fcd6ef423be91b21fedb"},
{file = "kiwisolver-1.5.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cff8e5383db4989311f99e814feeb90c4723eb4edca425b9d5d9c3fefcdd9537"},
{file = "kiwisolver-1.5.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:ebae99ed6764f2b5771c522477b311be313e8841d2e0376db2b10922daebbba4"},
{file = "kiwisolver-1.5.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:d5cd5189fc2b6a538b75ae45433140c4823463918f7b1617c31e68b085c0022c"},
{file = "kiwisolver-1.5.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f42c23db5d1521218a3276bb08666dcb662896a0be7347cba864eca45ff64ede"},
{file = "kiwisolver-1.5.0-cp310-cp310-win_amd64.whl", hash = "sha256:94eff26096eb5395136634622515b234ecb6c9979824c1f5004c6e3c3c85ccd2"},
{file = "kiwisolver-1.5.0-cp310-cp310-win_arm64.whl", hash = "sha256:dd952e03bfbb096cfe2dd35cd9e00f269969b67536cb4370994afc20ff2d0875"},
{file = "kiwisolver-1.5.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9eed0f7edbb274413b6ee781cca50541c8c0facd3d6fd289779e494340a2b85c"},
{file = "kiwisolver-1.5.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3c4923e404d6bcd91b6779c009542e5647fef32e4a5d75e115e3bbac6f2335eb"},
{file = "kiwisolver-1.5.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0df54df7e686afa55e6f21fb86195224a6d9beb71d637e8d7920c95cf0f89aac"},
{file = "kiwisolver-1.5.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2517e24d7315eb51c10664cdb865195df38ab74456c677df67bb47f12d088a27"},
{file = "kiwisolver-1.5.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff710414307fefa903e0d9bdf300972f892c23477829f49504e59834f4195398"},
{file = "kiwisolver-1.5.0-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6176c1811d9d5a04fa391c490cc44f451e240697a16977f11c6f722efb9041db"},
{file = "kiwisolver-1.5.0-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50847dca5d197fcbd389c805aa1a1cf32f25d2e7273dc47ab181a517666b68cc"},
{file = "kiwisolver-1.5.0-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:01808c6d15f4c3e8559595d6d1fe6411c68e4a3822b4b9972b44473b24f4e679"},
{file = "kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:f1f9f4121ec58628c96baa3de1a55a4e3a333c5102c8e94b64e23bf7b2083309"},
{file = "kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:b7d335370ae48a780c6e6a6bbfa97342f563744c39c35562f3f367665f5c1de2"},
{file = "kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:800ee55980c18545af444d93fdd60c56b580db5cc54867d8cbf8a1dc0829938c"},
{file = "kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:c438f6ca858697c9ab67eb28246c92508af972e114cac34e57a6d4ba17a3ac08"},
{file = "kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:8c63c91f95173f9c2a67c7c526b2cea976828a0e7fced9cdcead2802dc10f8a4"},
{file = "kiwisolver-1.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:beb7f344487cdcb9e1efe4b7a29681b74d34c08f0043a327a74da852a6749e7b"},
{file = "kiwisolver-1.5.0-cp311-cp311-win_arm64.whl", hash = "sha256:ad4ae4ffd1ee9cd11357b4c66b612da9888f4f4daf2f36995eda64bd45370cac"},
{file = "kiwisolver-1.5.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:4e9750bc21b886308024f8a54ccb9a2cc38ac9fa813bf4348434e3d54f337ff9"},
{file = "kiwisolver-1.5.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:72ec46b7eba5b395e0a7b63025490d3214c11013f4aacb4f5e8d6c3041829588"},
{file = "kiwisolver-1.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ed3a984b31da7481b103f68776f7128a89ef26ed40f4dc41a2223cda7fb24819"},
{file = "kiwisolver-1.5.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bb5136fb5352d3f422df33f0c879a1b0c204004324150cc3b5e3c4f310c9049f"},
{file = "kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b2af221f268f5af85e776a73d62b0845fc8baf8ef0abfae79d29c77d0e776aaf"},
{file = "kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b0f172dc8ffaccb8522d7c5d899de00133f2f1ca7b0a49b7da98e901de87bf2d"},
{file = "kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6ab8ba9152203feec73758dad83af9a0bbe05001eb4639e547207c40cfb52083"},
{file = "kiwisolver-1.5.0-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:cdee07c4d7f6d72008d3f73b9bf027f4e11550224c7c50d8df1ae4a37c1402a6"},
{file = "kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7c60d3c9b06fb23bd9c6139281ccbdc384297579ae037f08ae90c69f6845c0b1"},
{file = "kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:e315e5ec90d88e140f57696ff85b484ff68bb311e36f2c414aa4286293e6dee0"},
{file = "kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:1465387ac63576c3e125e5337a6892b9e99e0627d52317f3ca79e6930d889d15"},
{file = "kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:530a3fd64c87cffa844d4b6b9768774763d9caa299e9b75d8eca6a4423b31314"},
{file = "kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1d9daea4ea6b9be74fe2f01f7fbade8d6ffab263e781274cffca0dba9be9eec9"},
{file = "kiwisolver-1.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:f18c2d9782259a6dc132fdc7a63c168cbc74b35284b6d75c673958982a378384"},
{file = "kiwisolver-1.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:f7c7553b13f69c1b29a5bde08ddc6d9d0c8bfb84f9ed01c30db25944aeb852a7"},
{file = "kiwisolver-1.5.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:fd40bb9cd0891c4c3cb1ddf83f8bbfa15731a248fdc8162669405451e2724b09"},
{file = "kiwisolver-1.5.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c0e1403fd7c26d77c1f03e096dc58a5c726503fa0db0456678b8668f76f521e3"},
{file = "kiwisolver-1.5.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:dda366d548e89a90d88a86c692377d18d8bd64b39c1fb2b92cb31370e2896bbd"},
{file = "kiwisolver-1.5.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:332b4f0145c30b5f5ad9374881133e5aa64320428a57c2c2b61e9d891a51c2f3"},
{file = "kiwisolver-1.5.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c50b89ffd3e1a911c69a1dd3de7173c0cd10b130f56222e57898683841e4f96"},
{file = "kiwisolver-1.5.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4db576bb8c3ef9365f8b40fe0f671644de6736ae2c27a2c62d7d8a1b4329f099"},
{file = "kiwisolver-1.5.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0b85aad90cea8ac6797a53b5d5f2e967334fa4d1149f031c4537569972596cb8"},
{file = "kiwisolver-1.5.0-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:d36ca54cb4c6c4686f7cbb7b817f66f5911c12ddb519450bbe86707155028f87"},
{file = "kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:38f4a703656f493b0ad185211ccfca7f0386120f022066b018eb5296d8613e23"},
{file = "kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3ac2360e93cb41be81121755c6462cff3beaa9967188c866e5fce5cf13170859"},
{file = "kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c95cab08d1965db3d84a121f1c7ce7479bdd4072c9b3dafd8fecce48a2e6b902"},
{file = "kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:fc20894c3d21194d8041a28b65622d5b86db786da6e3cfe73f0c762951a61167"},
{file = "kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7a32f72973f0f950c1920475d5c5ea3d971b81b6f0ec53b8d0a956cc965f22e0"},
{file = "kiwisolver-1.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:0bf3acf1419fa93064a4c2189ac0b58e3be7872bf6ee6177b0d4c63dc4cea276"},
{file = "kiwisolver-1.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:fa8eb9ecdb7efb0b226acec134e0d709e87a909fa4971a54c0c4f6e88635484c"},
{file = "kiwisolver-1.5.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:db485b3847d182b908b483b2ed133c66d88d49cacf98fd278fadafe11b4478d1"},
{file = "kiwisolver-1.5.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:be12f931839a3bdfe28b584db0e640a65a8bcbc24560ae3fdb025a449b3d754e"},
{file = "kiwisolver-1.5.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:16b85d37c2cbb3253226d26e64663f755d88a03439a9c47df6246b35defbdfb7"},
{file = "kiwisolver-1.5.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4432b835675f0ea7414aab3d37d119f7226d24869b7a829caeab49ebda407b0c"},
{file = "kiwisolver-1.5.0-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b0feb50971481a2cc44d94e88bdb02cdd497618252ae226b8eb1201b957e368"},
{file = "kiwisolver-1.5.0-cp313-cp313t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:56fa888f10d0f367155e76ce849fa1166fc9730d13bd2d65a2aa13b6f5424489"},
{file = "kiwisolver-1.5.0-cp313-cp313t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:940dda65d5e764406b9fb92761cbf462e4e63f712ab60ed98f70552e496f3bf1"},
{file = "kiwisolver-1.5.0-cp313-cp313t-manylinux_2_39_riscv64.whl", hash = "sha256:89fc958c702ee9a745e4700378f5d23fddbc46ff89e8fdbf5395c24d5c1452a3"},
{file = "kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9027d773c4ff81487181a925945743413f6069634d0b122d0b37684ccf4f1e18"},
{file = "kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:5b233ea3e165e43e35dba1d2b8ecc21cf070b45b65ae17dd2747d2713d942021"},
{file = "kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ce9bf03dad3b46408c08649c6fbd6ca28a9fce0eb32fdfffa6775a13103b5310"},
{file = "kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:fc4d3f1fb9ca0ae9f97b095963bc6326f1dbfd3779d6679a1e016b9baaa153d3"},
{file = "kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f443b4825c50a51ee68585522ab4a1d1257fac65896f282b4c6763337ac9f5d2"},
{file = "kiwisolver-1.5.0-cp313-cp313t-win_arm64.whl", hash = "sha256:893ff3a711d1b515ba9da14ee090519bad4610ed1962fbe298a434e8c5f8db53"},
{file = "kiwisolver-1.5.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8df31fe574b8b3993cc61764f40941111b25c2d9fea13d3ce24a49907cd2d615"},
{file = "kiwisolver-1.5.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1d49a49ac4cbfb7c1375301cd1ec90169dfeae55ff84710d782260ce77a75a02"},
{file = "kiwisolver-1.5.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0cbe94b69b819209a62cb27bdfa5dc2a8977d8de2f89dfd97ba4f53ed3af754e"},
{file = "kiwisolver-1.5.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:80aa065ffd378ff784822a6d7c3212f2d5f5e9c3589614b5c228b311fd3063ac"},
{file = "kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e7f886f47ab881692f278ae901039a234e4025a68e6dfab514263a0b1c4ae05"},
{file = "kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5060731cc3ed12ca3a8b57acd4aeca5bbc2f49216dd0bec1650a1acd89486bcd"},
{file = "kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7a4aa69609f40fce3cbc3f87b2061f042eee32f94b8f11db707b66a26461591a"},
{file = "kiwisolver-1.5.0-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:d168fda2dbff7b9b5f38e693182d792a938c31db4dac3a80a4888de603c99554"},
{file = "kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:413b820229730d358efd838ecbab79902fe97094565fdc80ddb6b0a18c18a581"},
{file = "kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5124d1ea754509b09e53738ec185584cc609aae4a3b510aaf4ed6aa047ef9303"},
{file = "kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e4415a8db000bf49a6dd1c478bf70062eaacff0f462b92b0ba68791a905861f9"},
{file = "kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d618fd27420381a4f6044faa71f46d8bfd911bd077c555f7138ed88729bfbe79"},
{file = "kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5092eb5b1172947f57d6ea7d89b2f29650414e4293c47707eb499ec07a0ac796"},
{file = "kiwisolver-1.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:d76e2d8c75051d58177e762164d2e9ab92886534e3a12e795f103524f221dd8e"},
{file = "kiwisolver-1.5.0-cp314-cp314-win_arm64.whl", hash = "sha256:fa6248cd194edff41d7ea9425ced8ca3a6f838bfb295f6f1d6e6bb694a8518df"},
{file = "kiwisolver-1.5.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:d1ffeb80b5676463d7a7d56acbe8e37a20ce725570e09549fe738e02ca6b7e1e"},
{file = "kiwisolver-1.5.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bc4d8e252f532ab46a1de9349e2d27b91fce46736a9eedaa37beaca66f574ed4"},
{file = "kiwisolver-1.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6783e069732715ad0c3ce96dbf21dbc2235ab0593f2baf6338101f70371f4028"},
{file = "kiwisolver-1.5.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e7c4c09a490dc4d4a7f8cbee56c606a320f9dc28cf92a7157a39d1ce7676a657"},
{file = "kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a075bd7bd19c70cf67c8badfa36cf7c5d8de3c9ddb8420c51e10d9c50e94920"},
{file = "kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bdd3e53429ff02aa319ba59dfe4ceeec345bf46cf180ec2cf6fd5b942e7975e9"},
{file = "kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3cdcb35dc9d807259c981a85531048ede628eabcffb3239adf3d17463518992d"},
{file = "kiwisolver-1.5.0-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:70d593af6a6ca332d1df73d519fddb5148edb15cd90d5f0155e3746a6d4fcc65"},
{file = "kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:377815a8616074cabbf3f53354e1d040c35815a134e01d7614b7692e4bf8acfa"},
{file = "kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0255a027391d52944eae1dbb5d4cc5903f57092f3674e8e544cdd2622826b3f0"},
{file = "kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:012b1eb16e28718fa782b5e61dc6f2da1f0792ca73bd05d54de6cb9561665fc9"},
{file = "kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:0e3aafb33aed7479377e5e9a82e9d4bf87063741fc99fc7ae48b0f16e32bdd6f"},
{file = "kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e7a116ae737f0000343218c4edf5bd45893bfeaff0993c0b215d7124c9f77646"},
{file = "kiwisolver-1.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:1dd9b0b119a350976a6d781e7278ec7aca0b201e1a9e2d23d9804afecb6ca681"},
{file = "kiwisolver-1.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:58f812017cd2985c21fbffb4864d59174d4903dd66fa23815e74bbc7a0e2dd57"},
{file = "kiwisolver-1.5.0-graalpy312-graalpy250_312_native-macosx_10_13_x86_64.whl", hash = "sha256:5ae8e62c147495b01a0f4765c878e9bfdf843412446a247e28df59936e99e797"},
{file = "kiwisolver-1.5.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:f6764a4ccab3078db14a632420930f6186058750df066b8ea2a7106df91d3203"},
{file = "kiwisolver-1.5.0-graalpy312-graalpy250_312_native-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c31c13da98624f957b0fb1b5bae5383b2333c2c3f6793d9825dd5ce79b525cb7"},
{file = "kiwisolver-1.5.0-graalpy312-graalpy250_312_native-win_amd64.whl", hash = "sha256:1f1489f769582498610e015a8ef2d36f28f505ab3096d0e16b4858a9ec214f57"},
{file = "kiwisolver-1.5.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:295d9ffe712caa9f8a3081de8d32fc60191b4b51c76f02f951fd8407253528f4"},
{file = "kiwisolver-1.5.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:51e8c4084897de9f05898c2c2a39af6318044ae969d46ff7a34ed3f96274adca"},
{file = "kiwisolver-1.5.0-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b83af57bdddef03c01a9138034c6ff03181a3028d9a1003b301eb1a55e161a3f"},
{file = "kiwisolver-1.5.0-pp310-pypy310_pp73-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf4679a3d71012a7c2bf360e5cd878fbd5e4fcac0896b56393dec239d81529ed"},
{file = "kiwisolver-1.5.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:41024ed50e44ab1a60d3fe0a9d15a4ccc9f5f2b1d814ff283c8d01134d5b81bc"},
{file = "kiwisolver-1.5.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ec4c85dc4b687c7f7f15f553ff26a98bfe8c58f5f7f0ac8905f0ba4c7be60232"},
{file = "kiwisolver-1.5.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:12e91c215a96e39f57989c8912ae761286ac5a9584d04030ceb3368a357f017a"},
{file = "kiwisolver-1.5.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:be4a51a55833dc29ab5d7503e7bcb3b3af3402d266018137127450005cdfe737"},
{file = "kiwisolver-1.5.0-pp311-pypy311_pp73-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:daae526907e262de627d8f70058a0f64acc9e2641c164c99c8f594b34a799a16"},
{file = "kiwisolver-1.5.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:59cd8683f575d96df5bb48f6add94afc055012c29e28124fcae2b63661b9efb1"},
{file = "kiwisolver-1.5.0.tar.gz", hash = "sha256:d4193f3d9dc3f6f79aaed0e5637f45d98850ebf01f7ca20e69457f3e8946b66a"},
]
[[package]] [[package]]
name = "limits" name = "limits"
version = "3.13.0" version = "3.13.0"
@ -1249,6 +1557,85 @@ files = [
{file = "markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0"}, {file = "markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0"},
] ]
[[package]]
name = "matplotlib"
version = "3.10.9"
description = "Python plotting package"
optional = false
python-versions = ">=3.10"
groups = ["main"]
files = [
{file = "matplotlib-3.10.9-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:77210dce9cb8153dffc967efaae990543392563d5a376d4dd8539bebcb0ed217"},
{file = "matplotlib-3.10.9-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1e7698ac9868428e84d2c967424803b2472ff7167d9d6590d4204ed775343c3b"},
{file = "matplotlib-3.10.9-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1aa972116abb4c9d201bf245620b433726cb6856f3bef6a78f776a00f5c92d37"},
{file = "matplotlib-3.10.9-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ae2f11957b27ce53497dd4d7b235c4d4f1faf383dfb39d0c5beb833bff883294"},
{file = "matplotlib-3.10.9-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b049278ddce116aaa1c1377ebf58adea909132dfce0281cf7e3a1ea9fc2e2c65"},
{file = "matplotlib-3.10.9-cp310-cp310-win_amd64.whl", hash = "sha256:82834c3c292d24d3a8aae77cd2d20019de69d692a34a970e4fdb8d33e2ea3dda"},
{file = "matplotlib-3.10.9-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:68cfdcede415f7c8f5577b03303dd94526cdb6d11036cecdc205e08733b2d2bb"},
{file = "matplotlib-3.10.9-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dfca0129678bd56379db26c52b5d77ed7de314c047492fbdc763aa7501710cfb"},
{file = "matplotlib-3.10.9-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8e436d155fa8a3399dc62683f8f5d0e2e50d25d0144a73edd73f82eec8f4abfb"},
{file = "matplotlib-3.10.9-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:56fc0bd271b00025c6edfdc7c2dcd247372c8e1544971d62e1dc7c17367e8bf9"},
{file = "matplotlib-3.10.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a5a6104ed666402ba5106d7f36e0e0cdca4e8d7fa4d39708ca88019e2835a2eb"},
{file = "matplotlib-3.10.9-cp311-cp311-win_amd64.whl", hash = "sha256:d730e984eddf56974c3e72b6129c7ca462ac38dc624338f4b0b23eb23ecba00f"},
{file = "matplotlib-3.10.9-cp311-cp311-win_arm64.whl", hash = "sha256:51bf0ddbdc598e060d46c16b5590708f81a1624cefbaaf62f6a81bf9285b8c80"},
{file = "matplotlib-3.10.9-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f0c3c28d9fbcc1fe7a03be236d73430cf6409c41fb2383a7ac52fe932b072cb1"},
{file = "matplotlib-3.10.9-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:41cb28c2bd769aa3e98322c6ab09854cbcc52ab69d2759d681bba3e327b2b320"},
{file = "matplotlib-3.10.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ae20801130378b82d647ff5047c07316295b68dc054ca6b3c13519d0ea624285"},
{file = "matplotlib-3.10.9-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6c63ebcd8b4b169eb2f5c200552ae6b8be8999a005b6b507ed76fb8d7d674fe2"},
{file = "matplotlib-3.10.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d75d11c949914165976c621b2324f9ef162af7ebf4b057ddf95dd1dba7e5edcf"},
{file = "matplotlib-3.10.9-cp312-cp312-win_amd64.whl", hash = "sha256:d091f9d758b34aaaaa6331d13574bf01891d903b3dec59bfff458ef7551de5d6"},
{file = "matplotlib-3.10.9-cp312-cp312-win_arm64.whl", hash = "sha256:10cc5ce06d10231c36f40e875f3c7e8050362a4ee8f0ee5d29a6b3277d57bb42"},
{file = "matplotlib-3.10.9-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b580440f1ff81a0e34122051a3dfabb7e4b7f9e380629929bde0eff9af72165f"},
{file = "matplotlib-3.10.9-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b1b745c489cd1a77a0dc1120a05dc87af9798faebc913601feb8c73d89bf2d1e"},
{file = "matplotlib-3.10.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8f3bcac1ca5ed000a6f4337d47ba67dfddf37ed6a46c15fd7f014997f7bf865f"},
{file = "matplotlib-3.10.9-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a8d66a55def891c33147ba3ba9bfcabf0b526a43764c818acbb4525e5ed0838"},
{file = "matplotlib-3.10.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d843374407c4017a6403b59c6c81606773d136f3259d5b6da3131bc814542cc2"},
{file = "matplotlib-3.10.9-cp313-cp313-win_amd64.whl", hash = "sha256:f4399f64b3e94cd500195490972ae1ee81170df1636fa15364d157d5bdd7b921"},
{file = "matplotlib-3.10.9-cp313-cp313-win_arm64.whl", hash = "sha256:ba7b3b8ef09eab7df0e86e9ae086faa433efbfbdb46afcb3aa16aabf779469a8"},
{file = "matplotlib-3.10.9-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:09218df8a93712bd6ea133e83a153c755448cf7868316c531cffcc43f69d1cc9"},
{file = "matplotlib-3.10.9-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:82368699727bfb7b0182e1aa13082e3c08e092fa1a25d3e1fd92405bff96f6d4"},
{file = "matplotlib-3.10.9-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3225f4e1edcb8c86c884ddf79ebe20ecd0a67d30188f279897554ccd8fded4dc"},
{file = "matplotlib-3.10.9-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de2445a0c6690d21b7eb6ce071cebad6d40a2e9bdf10d039074a96ba19797b99"},
{file = "matplotlib-3.10.9-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:b2b9516251cb89ff618d757daec0e2ed1bf21248013844a853d87ef85ab3081d"},
{file = "matplotlib-3.10.9-cp313-cp313t-win_amd64.whl", hash = "sha256:e9fae004b941b23ff2edcf1567a857ed77bafc8086ffa258190462328434faf8"},
{file = "matplotlib-3.10.9-cp313-cp313t-win_arm64.whl", hash = "sha256:6b63d9c7c769b88ab81e10dc86e4e0607cf56817b9f9e6cf24b2a5f1693b8e38"},
{file = "matplotlib-3.10.9-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:172db52c9e683f5d12eaf57f0f54834190e12581fe1cc2a19595a8f5acb4e77d"},
{file = "matplotlib-3.10.9-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:97e35e8d39ccc85859095e01a53847432ba9a53ddf7986f7a54a11b73d0e143f"},
{file = "matplotlib-3.10.9-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aba1615dabe83188e19d4f75a253c6a08423e04c1425e64039f800050a69de6b"},
{file = "matplotlib-3.10.9-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34cf8167e023ad956c15f36302911d5406bd99a9862c1a8499ea6f7c0e015dc2"},
{file = "matplotlib-3.10.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:59476c6d29d612b8e9bb6ce8c5b631be6ba8f9e3a2421f22a02b192c7dd28716"},
{file = "matplotlib-3.10.9-cp314-cp314-win_amd64.whl", hash = "sha256:336b9acc64d309063126edcdaca00db9373af3c476bb94388fe9c5a53ad13e6f"},
{file = "matplotlib-3.10.9-cp314-cp314-win_arm64.whl", hash = "sha256:2dc9477819ffd78ad12a20df1d9d6a6bd4fec6aaa9072681465fddca052f1456"},
{file = "matplotlib-3.10.9-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:da4e09638420548f31c354032a6250e473c68e5a4e96899b4844cf39ddea23fe"},
{file = "matplotlib-3.10.9-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:345f6f68ecc8da0ca56fad2ea08fde1a115eda530079eca185d50a7bc3e146c6"},
{file = "matplotlib-3.10.9-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4edcfbd8565339aa62f1cd4012f7180926fdbe71850f7b0d3c379c175cd6b66c"},
{file = "matplotlib-3.10.9-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6be157fe17fc37cb95ac1d7374cf717ce9259616edec911a78d9d26dae8522d4"},
{file = "matplotlib-3.10.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:4e42042d54db34fda4e95a7bd3e5789c2a995d2dad3eb8850232ee534092fbbf"},
{file = "matplotlib-3.10.9-cp314-cp314t-win_amd64.whl", hash = "sha256:c27df8b3848f32a83d1767566595e43cfaa4460380974da06f4279a7ec143c39"},
{file = "matplotlib-3.10.9-cp314-cp314t-win_arm64.whl", hash = "sha256:a49f1eadc84ca85fd72fa4e89e70e61bf86452df6f971af04b12c60761a0772c"},
{file = "matplotlib-3.10.9-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:1872fb212a05b729e649754a72d5da61d03e0554d76e80303b6f83d1d2c0552b"},
{file = "matplotlib-3.10.9-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:985f2238880e2e69093f588f5fe2e46771747febf0649f3cf7f7b7480875317f"},
{file = "matplotlib-3.10.9-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6640f75af2c6148293caa0a2b39dd806a492dd66c8a8b04035813e33d0fd2585"},
{file = "matplotlib-3.10.9-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:42fb814efabe95c06c1994d8ab5a8385f43a249e23badd3ba931d4308e5bca20"},
{file = "matplotlib-3.10.9-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:f76e640a5268850bfda54b5131b1b1941cc685e42c5fa98ed9f2d64038308cba"},
{file = "matplotlib-3.10.9-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3fc0364dfbe1d07f6d15c5ebd0c5bf89e126916e5a8667dd4a7a6e84c36653d4"},
{file = "matplotlib-3.10.9.tar.gz", hash = "sha256:fd66508e8c6877d98e586654b608a0456db8d7e8a546eb1e2600efd957302358"},
]
[package.dependencies]
contourpy = ">=1.0.1"
cycler = ">=0.10"
fonttools = ">=4.22.0"
kiwisolver = ">=1.3.1"
numpy = ">=1.23"
packaging = ">=20.0"
pillow = ">=8"
pyparsing = ">=3"
python-dateutil = ">=2.7"
[package.extras]
dev = ["meson-python (>=0.13.1,<0.17.0)", "pybind11 (>=2.13.2,!=2.13.3)", "setuptools (>=64)", "setuptools_scm (>=7,<10)"]
[[package]] [[package]]
name = "mdurl" name = "mdurl"
version = "0.1.2" version = "0.1.2"
@ -1618,6 +2005,115 @@ sql-other = ["SQLAlchemy (>=2.0.0)", "adbc-driver-postgresql (>=0.8.0)", "adbc-d
test = ["hypothesis (>=6.46.1)", "pytest (>=7.3.2)", "pytest-xdist (>=2.2.0)"] test = ["hypothesis (>=6.46.1)", "pytest (>=7.3.2)", "pytest-xdist (>=2.2.0)"]
xml = ["lxml (>=4.9.2)"] xml = ["lxml (>=4.9.2)"]
[[package]]
name = "pillow"
version = "12.2.0"
description = "Python Imaging Library (fork)"
optional = false
python-versions = ">=3.10"
groups = ["main"]
files = [
{file = "pillow-12.2.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:a4e8f36e677d3336f35089648c8955c51c6d386a13cf6ee9c189c5f5bd713a9f"},
{file = "pillow-12.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2e589959f10d9824d39b350472b92f0ce3b443c0a3442ebf41c40cb8361c5b97"},
{file = "pillow-12.2.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a52edc8bfff4429aaabdf4d9ee0daadbbf8562364f940937b941f87a4290f5ff"},
{file = "pillow-12.2.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:975385f4776fafde056abb318f612ef6285b10a1f12b8570f3647ad0d74b48ec"},
{file = "pillow-12.2.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd9c0c7a0c681a347b3194c500cb1e6ca9cab053ea4d82a5cf45b6b754560136"},
{file = "pillow-12.2.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:88d387ff40b3ff7c274947ed3125dedf5262ec6919d83946753b5f3d7c67ea4c"},
{file = "pillow-12.2.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:51c4167c34b0d8ba05b547a3bb23578d0ba17b80a5593f93bd8ecb123dd336a3"},
{file = "pillow-12.2.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:34c0d99ecccea270c04882cb3b86e7b57296079c9a4aff88cb3b33563d95afaa"},
{file = "pillow-12.2.0-cp310-cp310-win32.whl", hash = "sha256:b85f66ae9eb53e860a873b858b789217ba505e5e405a24b85c0464822fe88032"},
{file = "pillow-12.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:673aa32138f3e7531ccdbca7b3901dba9b70940a19ccecc6a37c77d5fdeb05b5"},
{file = "pillow-12.2.0-cp310-cp310-win_arm64.whl", hash = "sha256:3e080565d8d7c671db5802eedfb438e5565ffa40115216eabb8cd52d0ecce024"},
{file = "pillow-12.2.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:8be29e59487a79f173507c30ddf57e733a357f67881430449bb32614075a40ab"},
{file = "pillow-12.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:71cde9a1e1551df7d34a25462fc60325e8a11a82cc2e2f54578e5e9a1e153d65"},
{file = "pillow-12.2.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f490f9368b6fc026f021db16d7ec2fbf7d89e2edb42e8ec09d2c60505f5729c7"},
{file = "pillow-12.2.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8bd7903a5f2a4545f6fd5935c90058b89d30045568985a71c79f5fd6edf9b91e"},
{file = "pillow-12.2.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3997232e10d2920a68d25191392e3a4487d8183039e1c74c2297f00ed1c50705"},
{file = "pillow-12.2.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e74473c875d78b8e9d5da2a70f7099549f9eb37ded4e2f6a463e60125bccd176"},
{file = "pillow-12.2.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:56a3f9c60a13133a98ecff6197af34d7824de9b7b38c3654861a725c970c197b"},
{file = "pillow-12.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:90e6f81de50ad6b534cab6e5aef77ff6e37722b2f5d908686f4a5c9eba17a909"},
{file = "pillow-12.2.0-cp311-cp311-win32.whl", hash = "sha256:8c984051042858021a54926eb597d6ee3012393ce9c181814115df4c60b9a808"},
{file = "pillow-12.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:6e6b2a0c538fc200b38ff9eb6628228b77908c319a005815f2dde585a0664b60"},
{file = "pillow-12.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:9a8a34cc89c67a65ea7437ce257cea81a9dad65b29805f3ecee8c8fe8ff25ffe"},
{file = "pillow-12.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d192a155bbcec180f8564f693e6fd9bccff5a7af9b32e2e4bf8c9c69dbad6b5"},
{file = "pillow-12.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f3f40b3c5a968281fd507d519e444c35f0ff171237f4fdde090dd60699458421"},
{file = "pillow-12.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:03e7e372d5240cc23e9f07deca4d775c0817bffc641b01e9c3af208dbd300987"},
{file = "pillow-12.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b86024e52a1b269467a802258c25521e6d742349d760728092e1bc2d135b4d76"},
{file = "pillow-12.2.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7371b48c4fa448d20d2714c9a1f775a81155050d383333e0a6c15b1123dda005"},
{file = "pillow-12.2.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62f5409336adb0663b7caa0da5c7d9e7bdbaae9ce761d34669420c2a801b2780"},
{file = "pillow-12.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:01afa7cf67f74f09523699b4e88c73fb55c13346d212a59a2db1f86b0a63e8c5"},
{file = "pillow-12.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc3d34d4a8fbec3e88a79b92e5465e0f9b842b628675850d860b8bd300b159f5"},
{file = "pillow-12.2.0-cp312-cp312-win32.whl", hash = "sha256:58f62cc0f00fd29e64b29f4fd923ffdb3859c9f9e6105bfc37ba1d08994e8940"},
{file = "pillow-12.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:7f84204dee22a783350679a0333981df803dac21a0190d706a50475e361c93f5"},
{file = "pillow-12.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:af73337013e0b3b46f175e79492d96845b16126ddf79c438d7ea7ff27783a414"},
{file = "pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8297651f5b5679c19968abefd6bb84d95fe30ef712eb1b2d9b2d31ca61267f4c"},
{file = "pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:50d8520da2a6ce0af445fa6d648c4273c3eeefbc32d7ce049f22e8b5c3daecc2"},
{file = "pillow-12.2.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:766cef22385fa1091258ad7e6216792b156dc16d8d3fa607e7545b2b72061f1c"},
{file = "pillow-12.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5d2fd0fa6b5d9d1de415060363433f28da8b1526c1c129020435e186794b3795"},
{file = "pillow-12.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56b25336f502b6ed02e889f4ece894a72612fe885889a6e8c4c80239ff6e5f5f"},
{file = "pillow-12.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f1c943e96e85df3d3478f7b691f229887e143f81fedab9b20205349ab04d73ed"},
{file = "pillow-12.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:03f6fab9219220f041c74aeaa2939ff0062bd5c364ba9ce037197f4c6d498cd9"},
{file = "pillow-12.2.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cdfebd752ec52bf5bb4e35d9c64b40826bc5b40a13df7c3cda20a2c03a0f5ed"},
{file = "pillow-12.2.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eedf4b74eda2b5a4b2b2fb4c006d6295df3bf29e459e198c90ea48e130dc75c3"},
{file = "pillow-12.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:00a2865911330191c0b818c59103b58a5e697cae67042366970a6b6f1b20b7f9"},
{file = "pillow-12.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1e1757442ed87f4912397c6d35a0db6a7b52592156014706f17658ff58bbf795"},
{file = "pillow-12.2.0-cp313-cp313-win32.whl", hash = "sha256:144748b3af2d1b358d41286056d0003f47cb339b8c43a9ea42f5fea4d8c66b6e"},
{file = "pillow-12.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:390ede346628ccc626e5730107cde16c42d3836b89662a115a921f28440e6a3b"},
{file = "pillow-12.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:8023abc91fba39036dbce14a7d6535632f99c0b857807cbbbf21ecc9f4717f06"},
{file = "pillow-12.2.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:042db20a421b9bafecc4b84a8b6e444686bd9d836c7fd24542db3e7df7baad9b"},
{file = "pillow-12.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd025009355c926a84a612fecf58bb315a3f6814b17ead51a8e48d3823d9087f"},
{file = "pillow-12.2.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88ddbc66737e277852913bd1e07c150cc7bb124539f94c4e2df5344494e0a612"},
{file = "pillow-12.2.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d362d1878f00c142b7e1a16e6e5e780f02be8195123f164edf7eddd911eefe7c"},
{file = "pillow-12.2.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c727a6d53cb0018aadd8018c2b938376af27914a68a492f59dfcaca650d5eea"},
{file = "pillow-12.2.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:efd8c21c98c5cc60653bcb311bef2ce0401642b7ce9d09e03a7da87c878289d4"},
{file = "pillow-12.2.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9f08483a632889536b8139663db60f6724bfcb443c96f1b18855860d7d5c0fd4"},
{file = "pillow-12.2.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dac8d77255a37e81a2efcbd1fc05f1c15ee82200e6c240d7e127e25e365c39ea"},
{file = "pillow-12.2.0-cp313-cp313t-win32.whl", hash = "sha256:ee3120ae9dff32f121610bb08e4313be87e03efeadfc6c0d18f89127e24d0c24"},
{file = "pillow-12.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:325ca0528c6788d2a6c3d40e3568639398137346c3d6e66bb61db96b96511c98"},
{file = "pillow-12.2.0-cp313-cp313t-win_arm64.whl", hash = "sha256:2e5a76d03a6c6dcef67edabda7a52494afa4035021a79c8558e14af25313d453"},
{file = "pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:3adc9215e8be0448ed6e814966ecf3d9952f0ea40eb14e89a102b87f450660d8"},
{file = "pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:6a9adfc6d24b10f89588096364cc726174118c62130c817c2837c60cf08a392b"},
{file = "pillow-12.2.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:6a6e67ea2e6feda684ed370f9a1c52e7a243631c025ba42149a2cc5934dec295"},
{file = "pillow-12.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2bb4a8d594eacdfc59d9e5ad972aa8afdd48d584ffd5f13a937a664c3e7db0ed"},
{file = "pillow-12.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:80b2da48193b2f33ed0c32c38140f9d3186583ce7d516526d462645fd98660ae"},
{file = "pillow-12.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22db17c68434de69d8ecfc2fe821569195c0c373b25cccb9cbdacf2c6e53c601"},
{file = "pillow-12.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b14cc0106cd9aecda615dd6903840a058b4700fcb817687d0ee4fc8b6e389be"},
{file = "pillow-12.2.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cbeb542b2ebc6fcdacabf8aca8c1a97c9b3ad3927d46b8723f9d4f033288a0f"},
{file = "pillow-12.2.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4bfd07bc812fbd20395212969e41931001fd59eb55a60658b0e5710872e95286"},
{file = "pillow-12.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9aba9a17b623ef750a4d11b742cbafffeb48a869821252b30ee21b5e91392c50"},
{file = "pillow-12.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:deede7c263feb25dba4e82ea23058a235dcc2fe1f6021025dc71f2b618e26104"},
{file = "pillow-12.2.0-cp314-cp314-win32.whl", hash = "sha256:632ff19b2778e43162304d50da0181ce24ac5bb8180122cbe1bf4673428328c7"},
{file = "pillow-12.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:4e6c62e9d237e9b65fac06857d511e90d8461a32adcc1b9065ea0c0fa3a28150"},
{file = "pillow-12.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:b1c1fbd8a5a1af3412a0810d060a78b5136ec0836c8a4ef9aa11807f2a22f4e1"},
{file = "pillow-12.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:57850958fe9c751670e49b2cecf6294acc99e562531f4bd317fa5ddee2068463"},
{file = "pillow-12.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d5d38f1411c0ed9f97bcb49b7bd59b6b7c314e0e27420e34d99d844b9ce3b6f3"},
{file = "pillow-12.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c0a9f29ca8e79f09de89293f82fc9b0270bb4af1d58bc98f540cc4aedf03166"},
{file = "pillow-12.2.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1610dd6c61621ae1cf811bef44d77e149ce3f7b95afe66a4512f8c59f25d9ebe"},
{file = "pillow-12.2.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a34329707af4f73cf1782a36cd2289c0368880654a2c11f027bcee9052d35dd"},
{file = "pillow-12.2.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e9c4f5b3c546fa3458a29ab22646c1c6c787ea8f5ef51300e5a60300736905e"},
{file = "pillow-12.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fb043ee2f06b41473269765c2feae53fc2e2fbf96e5e22ca94fb5ad677856f06"},
{file = "pillow-12.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f278f034eb75b4e8a13a54a876cc4a5ab39173d2cdd93a638e1b467fc545ac43"},
{file = "pillow-12.2.0-cp314-cp314t-win32.whl", hash = "sha256:6bb77b2dcb06b20f9f4b4a8454caa581cd4dd0643a08bacf821216a16d9c8354"},
{file = "pillow-12.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6562ace0d3fb5f20ed7290f1f929cae41b25ae29528f2af1722966a0a02e2aa1"},
{file = "pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb"},
{file = "pillow-12.2.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0538bd5e05efec03ae613fd89c4ce0368ecd2ba239cc25b9f9be7ed426b0af1f"},
{file = "pillow-12.2.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:394167b21da716608eac917c60aa9b969421b5dcbbe02ae7f013e7b85811c69d"},
{file = "pillow-12.2.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5d04bfa02cc2d23b497d1e90a0f927070043f6cbf303e738300532379a4b4e0f"},
{file = "pillow-12.2.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0c838a5125cee37e68edec915651521191cef1e6aa336b855f495766e77a366e"},
{file = "pillow-12.2.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a6c9fa44005fa37a91ebfc95d081e8079757d2e904b27103f4f5fa6f0bf78c0"},
{file = "pillow-12.2.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:25373b66e0dd5905ed63fa3cae13c82fbddf3079f2c8bf15c6fb6a35586324c1"},
{file = "pillow-12.2.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:bfa9c230d2fe991bed5318a5f119bd6780cda2915cca595393649fc118ab895e"},
{file = "pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5"},
]
[package.extras]
docs = ["furo", "olefile", "sphinx (>=8.2)", "sphinx-autobuild", "sphinx-copybutton", "sphinx-inline-tabs", "sphinxext-opengraph"]
fpx = ["olefile"]
mic = ["olefile"]
test-arrow = ["arro3-compute", "arro3-core", "nanoarrow", "pyarrow"]
tests = ["check-manifest", "coverage (>=7.4.2)", "defusedxml", "markdown2", "olefile", "packaging", "pyroma (>=5)", "pytest", "pytest-cov", "pytest-timeout", "pytest-xdist", "trove-classifiers (>=2024.10.12)"]
xmp = ["defusedxml"]
[[package]] [[package]]
name = "pluggy" name = "pluggy"
version = "1.5.0" version = "1.5.0"
@ -3046,4 +3542,4 @@ propcache = ">=0.2.1"
[metadata] [metadata]
lock-version = "2.1" lock-version = "2.1"
python-versions = "^3.11" python-versions = "^3.11"
content-hash = "256c8104c6eeb5b288dd0cdf02fe7cbad4f75aa93fc71f8d44da8b605d72f886" content-hash = "e0e48b89f5ad77fa11d84cd759aee3e849ab6ed94f8756384c39290204083bb8"

@ -33,6 +33,7 @@ google-auth-httplib2 = "^0.2.0"
google-auth-oauthlib = "^1.2.2" google-auth-oauthlib = "^1.2.2"
aiohttp = "^3.12.14" aiohttp = "^3.12.14"
ortools = "^9.14.6206" ortools = "^9.14.6206"
matplotlib = "^3.10.9"
[build-system] [build-system]

@ -0,0 +1,35 @@
import asyncio
import os
from temporalio.client import Client
from temporalio.worker import Worker
from temporal.temporal_workflows import OptimumOHCalculationWorkflow
from temporal.temporal_workflows import create_optimum_oh_calculation, request_rbd_simulation, run_optimum_oh_calculation
TEMPORAL_URL = os.environ.get("TEMPORAL_URL", "http://192.168.1.86:7233")
async def main():
client = await Client.connect(TEMPORAL_URL)
try:
worker = Worker(
client,
task_queue="oh-sim-queue",
workflows=[OptimumOHCalculationWorkflow],
activities=[
create_optimum_oh_calculation,
request_rbd_simulation,
run_optimum_oh_calculation
],
max_concurrent_workflow_tasks=50,
max_concurrent_activities=12
)
await worker.run()
except Exception as e:
print(f"Worker failed: {e}")
import traceback
traceback.print_exc()
if __name__ == "__main__":
asyncio.run(main())

@ -11,6 +11,8 @@ from src.calculation_target_reliability.router import \
router as calculation_target_reliability router as calculation_target_reliability
from src.calculation_time_constrains.router import \ from src.calculation_time_constrains.router import \
router as calculation_time_constrains_router, get_calculation router as calculation_time_constrains_router, get_calculation
from src.optimum_time_constraint.router import router as optimum_time_constraint_router
from src.optimum_time_constraint.router import get_calculation as optimum_get_calculation
# from src.job.router import router as job_router # from src.job.router import router as job_router
from src.overhaul.router import router as overhaul_router from src.overhaul.router import router as overhaul_router
@ -157,6 +159,13 @@ calculation_router.include_router(
tags=["calculation", "time_constraint"], tags=["calculation", "time_constraint"],
) )
# Optimized Time constrains
calculation_router.include_router(
optimum_time_constraint_router,
prefix="/optimum-time-constraint",
tags=["calculation", "optimum_time_constraint"],
)
# Target reliability # Target reliability
calculation_router.include_router( calculation_router.include_router(
calculation_target_reliability, calculation_target_reliability,
@ -179,4 +188,10 @@ api_router.include_router(
tags=["calculation", "time_constraint"], tags=["calculation", "time_constraint"],
) )
api_router.include_router(
optimum_get_calculation,
prefix="/calculation/optimum-time-constraint",
tags=["calculation", "optimum_time_constraint"],
)
api_router.include_router(authenticated_api_router) api_router.include_router(authenticated_api_router)

@ -1,6 +1,7 @@
import math
from collections import defaultdict from collections import defaultdict
import random import random
from typing import Optional from typing import Optional, List, Dict, Tuple
from uuid import UUID from uuid import UUID
from sqlalchemy import Delete, Select from sqlalchemy import Delete, Select
@ -8,23 +9,12 @@ from sqlalchemy import Delete, Select
from src.auth.service import CurrentUser from src.auth.service import CurrentUser
from src.contribution_util import calculate_contribution_accurate from src.contribution_util import calculate_contribution_accurate
from src.database.core import CollectorDbSession, DbSession from src.database.core import CollectorDbSession, DbSession
# from src.scope_equipment.model import ScopeEquipment
# from src.scope_equipment.service import get_by_scope_name
from src.overhaul_activity.service import get_all_by_session_id, get_standard_scope_by_session_id from src.overhaul_activity.service import get_all_by_session_id, get_standard_scope_by_session_id
# async def get_all_budget_constrains( # async def get_all_budget_constrains(
# *, db_session: DbSession, session_id: str, cost_threshold: float = 100000000 # *, db_session: DbSession, session_id: str, cost_threshold: float = 100000000
# ): # ):
# At the module level, add this dictionary to store persistent EAF values
from collections import defaultdict
from uuid import UUID
from typing import List, Dict, Tuple
from src.database.core import CollectorDbSession, DbSession
from src.overhaul_activity.service import get_standard_scope_by_session_id
from src.contribution_util import calculate_contribution_accurate
async def get_all_budget_constrains( async def get_all_budget_constrains(
*, *,
@ -109,64 +99,87 @@ def calculate_asset_eaf_contributions(plant_result, eq_results):
def greedy_selection(equipments: List[dict], budget: float) -> Tuple[List[dict], List[dict]]: def greedy_selection(equipments: List[dict], budget: float) -> Tuple[List[dict], List[dict]]:
"""Greedy fallback: select items by score until budget is used.""" """Greedy selection: select items by score until budget is used."""
# Sort by priority_score descending # Sort by priority_score descending
equipments_sorted = sorted(equipments, key=lambda x: x["priority_score"], reverse=True) equipments_sorted = sorted(equipments, key=lambda x: x["priority_score"], reverse=True)
total_cost = 0 current_cost_total = 0.0
selected, excluded = [], [] selected, excluded = [], []
for eq in equipments_sorted: for eq in equipments_sorted:
if total_cost + eq["cost"] <= budget: cost = eq.get("total_cost", 0.0)
if current_cost_total + cost <= budget:
selected.append(eq) selected.append(eq)
total_cost += eq["cost"] current_cost_total += cost
else: else:
excluded.append(eq) excluded.append(eq)
return selected, excluded return selected, excluded
def knapsack_selection(equipments: List[dict], budget: float, scale: int = 10_000_000) -> Tuple[List[dict], List[dict]]: def knapsack_selection(equipments: List[dict], budget: float, scale: Optional[float] = None) -> Tuple[List[dict], List[dict]]:
""" """
Select equipment optimally within budget using 0/1 knapsack DP. Select equipment optimally within budget using 0/1 knapsack DP.
Uses scaling + 1D DP optimization to avoid MemoryError. Uses dynamic scaling + 1D DP optimization to ensure accuracy for very large numbers.
Falls back to greedy if W is too large.
""" """
n = len(equipments) if not equipments:
return [], []
# Scale costs + budget # Pre-filter: strictly impossible items
costs = [int(eq["total_cost"] // scale) for eq in equipments] eligible_items = []
values = [eq["priority_score"] for eq in equipments] strictly_excluded = []
W = int(budget // scale) for eq in equipments:
if eq["total_cost"] > budget:
strictly_excluded.append(eq)
else:
eligible_items.append(eq)
if not eligible_items:
return [], strictly_excluded
n = len(eligible_items)
# Dynamic scaling for big numbers: target higher W (10,000) for better precision
if scale is None:
target_W = 10000
scale = max(1.0, budget / target_W)
# Fallback if W is still too large costs = [int(math.ceil(eq["total_cost"] / scale)) for eq in eligible_items]
if W > 1_000_000: values = [eq["priority_score"] for eq in eligible_items]
print("too big") W = int(budget // scale)
return greedy_selection(equipments, budget)
# 1D DP array # 1D DP array
dp = [0.0] * (W + 1) dp = [0.0] * (W + 1)
keep = [[False] * (W + 1) for _ in range(n)] # track selection choices # 2D table for backtracking
keep = [[False] * (W + 1) for _ in range(n)]
for i in range(n): for i in range(n):
cost, value = costs[i], values[i] cost, value = costs[i], values[i]
# Skip if cost is zero but actual cost is greater than budget (handled by eligible_items filter)
for w in range(W, cost - 1, -1): for w in range(W, cost - 1, -1):
if dp[w - cost] + value >= dp[w]: # <= FIXED HERE if dp[w - cost] + value >= dp[w]:
dp[w] = dp[w - cost] + value dp[w] = dp[w - cost] + value
keep[i][w] = True keep[i][w] = True
# Backtrack to find selected items # Backtrack
selected, excluded = [], [] selected = []
backtrack_excluded = []
w = W w = W
for i in range(n - 1, -1, -1): for i in range(n - 1, -1, -1):
if keep[i][w]: if keep[i][w]:
selected.append(equipments[i]) selected.append(eligible_items[i])
w -= costs[i] w -= costs[i]
else: else:
excluded.append(equipments[i]) backtrack_excluded.append(eligible_items[i])
# Optional: fill leftover budget with zero-priority items excluded = backtrack_excluded + strictly_excluded
remaining_budget = budget - sum(eq["total_cost"] for eq in selected)
# Precision correction: greedy fill leftover budget with actual values
current_total_spent = sum(eq["total_cost"] for eq in selected)
remaining_budget = budget - current_total_spent
if remaining_budget > 0: if remaining_budget > 0:
# Sort by priority score DESC then cost ASC
excluded.sort(key=lambda x: (-x["priority_score"], x["total_cost"]))
for eq in excluded[:]: for eq in excluded[:]:
if eq["total_cost"] <= remaining_budget: if eq["total_cost"] <= remaining_budget:
selected.append(eq) selected.append(eq)

@ -11,10 +11,36 @@ from src.database.core import DbSession, CollectorDbSession
from src.auth.service import Token from src.auth.service import Token
from src.models import StandardResponse from src.models import StandardResponse
from pydantic import BaseModel, Field
from .service import run_rbd_simulation, get_simulation_results, identify_worst_eaf_contributors from .service import run_rbd_simulation, get_simulation_results, identify_worst_eaf_contributors
from .schema import OptimizationResult, TargetReliabiltiyQuery from .schema import OptimizationResult, TargetReliabiltiyQuery
router = APIRouter() router = APIRouter()
class SimulateRequest(BaseModel):
duration: int = Field(17520, description="Simulation duration in hours")
oh_duration: int = Field(1200, description="Overhaul duration in hours")
oh_session_id: str = Field(..., description="Overhaul session ID")
@router.post("/simulate", response_model=StandardResponse)
async def start_simulation(
token: Token,
db_session: DbSession,
payload: SimulateRequest
):
"""Start RBD simulation for target reliability."""
token_str = token.token if hasattr(token, 'token') else str(token)
result = await run_rbd_simulation(
sim_hours=payload.duration,
oh_duration=payload.oh_duration,
oh_session_id=payload.oh_session_id,
db_session=db_session,
token=token_str
)
return StandardResponse(
data={"simulation_id": result.get("data")},
message=result.get("message", "Simulation started successfully")
)
# @router.get("", response_model=StandardResponse[List[Dict]]) # @router.get("", response_model=StandardResponse[List[Dict]])
# async def get_target_reliability( # async def get_target_reliability(
@ -51,6 +77,7 @@ async def get_target_reliability(
duration = params.duration duration = params.duration
simulation_id = params.simulation_id simulation_id = params.simulation_id
cut_hours = params.cut_hours cut_hours = params.cut_hours
oh_duration = params.oh_duration
if not oh_session_id: if not oh_session_id:
raise HTTPException( raise HTTPException(
@ -65,40 +92,39 @@ async def get_target_reliability(
# oh_duration=duration # oh_duration=duration
# ) # )
if duration != 17520: if simulation_id:
if not simulation_id: try:
raise HTTPException( temporal_client = await Client.connect(TEMPORAL_URL)
status_code=status.HTTP_400_BAD_REQUEST, handle = temporal_client.get_workflow_handle(f"simulation-{simulation_id}")
detail="Simulation ID is required for non-default duration. Please run simulation first.", desc = await handle.describe()
) status_name = desc.status.name
else:
try:
temporal_client = await Client.connect(TEMPORAL_URL)
handle = temporal_client.get_workflow_handle(f"simulation-{simulation_id}")
desc = await handle.describe()
status_name = desc.status.name
if status_name in ["RUNNING", "CONTINUED_AS_NEW"]: if status_name in ["RUNNING", "CONTINUED_AS_NEW"]:
raise HTTPException(
status_code=status.HTTP_425_TOO_EARLY,
detail="Simulation is still running.",
)
elif status_name != "COMPLETED":
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Simulation failed with status: {status_name}",
)
except HTTPException:
raise
except Exception as e:
# Handle connection errors or invalid workflow IDs
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_425_TOO_EARLY,
detail=f"Simulation not found or error checking status: {str(e)}", detail="Simulation is still running.",
)
elif status_name != "COMPLETED":
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Simulation failed with status: {status_name}",
) )
except HTTPException:
raise
except Exception as e:
# Handle connection errors or invalid workflow IDs
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Simulation not found or error checking status: {str(e)}",
)
else: else:
if duration != 17520 or oh_duration != 1200:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Simulation ID is required for non-default duration or OH duration. Please run simulation first.",
)
simulation_id = TR_RBD_ID simulation_id = TR_RBD_ID
results = await get_simulation_results( results = await get_simulation_results(
simulation_id=simulation_id, simulation_id=simulation_id,
@ -113,7 +139,7 @@ async def get_target_reliability(
collector_db=collector_db, collector_db=collector_db,
simulation_id=simulation_id, simulation_id=simulation_id,
duration=duration, duration=duration,
po_duration=1200, po_duration=oh_duration,
cut_hours=float(cut_hours) cut_hours=float(cut_hours)
) )

@ -70,6 +70,7 @@ class TargetReliabiltiyQuery(DefultBase):
duration: int = Field(17520) duration: int = Field(17520)
simulation_id: Optional[str] = Field(None) simulation_id: Optional[str] = Field(None)
cut_hours:int = Field(0) cut_hours:int = Field(0)
oh_duration: int = Field(1200)
# { # {
# "overview": { # "overview": {

@ -1,7 +1,7 @@
import math import math
from typing import Optional, List from typing import Optional, List
from dataclasses import dataclass from dataclasses import dataclass
from sqlalchemy import Delete, Select from sqlalchemy import Delete, Select, select
import httpx import httpx
from src.auth.service import CurrentUser from src.auth.service import CurrentUser
from src.config import RBD_SERVICE_API from src.config import RBD_SERVICE_API
@ -21,17 +21,32 @@ from src.overhaul_activity.service import get_standard_scope_by_session_id
client = httpx.AsyncClient(timeout=300.0) client = httpx.AsyncClient(timeout=300.0)
async def run_rbd_simulation(*, sim_hours: int, token): from src.calculation_time_constrains.model import CalculationData
async def run_rbd_simulation(*, sim_hours: int, oh_duration: int = 1200, oh_session_id: str, db_session: DbSession, token: str):
# Check if a simulation with these parameters already exists
stmt = select(CalculationData).where(
CalculationData.overhaul_session_id == oh_session_id,
CalculationData.analysis_metadata["type"].as_string() == "target_reliability",
CalculationData.analysis_metadata["sim_hours"].as_string() == str(sim_hours),
CalculationData.analysis_metadata["oh_duration"].as_string() == str(oh_duration)
)
result = await db_session.execute(stmt)
existing_calc = result.scalars(yield).first()
if existing_calc and existing_calc.rbd_simulation_id:
return {"data": str(existing_calc.rbd_simulation_id), "status": "success", "message": "Loaded existing simulation"}
sim_data = { sim_data = {
"SimulationName": f"Simulasi TR OH {sim_hours}", "SimulationName": f"Simulasi TR OH {sim_hours}_{oh_duration}",
"SchematicName": "- TJB - Unit 3 -", "SchematicName": "- TJB - Unit 3 -",
"SimSeed": 1, "SimSeed": 1,
"SimDuration": sim_hours, "SimDuration": sim_hours,
"OverhaulInterval": sim_hours - 1201, "OverhaulInterval": max(sim_hours - oh_duration - 1, 1),
"DurationUnit": "UHour", "DurationUnit": "UHour",
"SimNumRun": 1, "SimNumRun": 1,
"IsDefault": False, "IsDefault": False,
"OverhaulDuration": 1200 "OverhaulDuration": oh_duration
} }
headers = { headers = {
@ -44,7 +59,23 @@ async def run_rbd_simulation(*, sim_hours: int, token):
async with httpx.AsyncClient(timeout=300.0) as client: async with httpx.AsyncClient(timeout=300.0) as client:
response = await client.post(rbd_simulation_url, json=sim_data, headers=headers) response = await client.post(rbd_simulation_url, json=sim_data, headers=headers)
response.raise_for_status() response.raise_for_status()
return response.json() resp_json = response.json()
sim_id = resp_json.get("data")
if sim_id:
new_calc = CalculationData(
overhaul_session_id=oh_session_id,
rbd_simulation_id=sim_id,
analysis_metadata={
"type": "target_reliability",
"sim_hours": str(sim_hours),
"oh_duration": str(oh_duration)
}
)
db_session.add(new_calc)
await db_session.commit()
return resp_json
async def get_simulation_results(*, simulation_id: str, token: str): async def get_simulation_results(*, simulation_id: str, token: str):
headers = { headers = {

@ -89,22 +89,103 @@ async def create_calculation(
created_by: str, created_by: str,
simulation_id simulation_id
): ):
calculation_data = await create_param_and_data( from temporalio.client import Client
db_session=db_session, from src.config import TEMPORAL_URL
calculation_param_in=calculation_time_constrains_in, from temporal.temporal_workflows import OptimumOHCalculationWorkflow
created_by=created_by, import uuid
if simulation_id is None:
temporal_client = await Client.connect(TEMPORAL_URL)
workflow_id = f"optimum-oh-calc-{uuid.uuid4()}"
args = {
"token": token.token if hasattr(token, 'token') else str(token),
"calculation_in": calculation_time_constrains_in.model_dump(mode="json"),
"created_by": created_by,
"callback_workflow_id": workflow_id,
}
handle = await temporal_client.start_workflow(
OptimumOHCalculationWorkflow.run,
args,
id=workflow_id,
task_queue="oh-sim-queue" # or whatever task queue they use
)
return {
"data": workflow_id,
"status": "success",
"message": "Calculation workflow started successfully"
}
else:
calculation_data = await create_param_and_data(
db_session=db_session,
calculation_param_in=calculation_time_constrains_in,
created_by=created_by
)
await run_simulation_with_spareparts(
db_session=db_session,
calculation=calculation_data,
token=token,
collector_db_session=collector_db_session,
simulation_id=simulation_id
)
return await get_calculation_result(
db_session=db_session,
calculation_id=str(calculation_data.id),
token=token,
include_risk_cost=0
)
async def recalculate_calculation(
*,
token: str,
db_session: DbSession,
collector_db_session: CollectorDbSession,
calculation_id: str,
simulation_id: Optional[str] = None
):
calculation_data = await get_calculation_data_by_id(
db_session=db_session, calculation_id=calculation_id
) )
rbd_simulation_id = simulation_id or TC_RBD_ID if not calculation_data:
raise HTTPException(
# results = await create_calculation_result_service( status_code=status.HTTP_404_NOT_FOUND,
# db_session=db_session, calculation=calculation_data, token=token detail="Calculation not found",
# ) )
results = await run_simulation_with_spareparts(
db_session=db_session, calculation=calculation_data, token=token, collector_db_session=collector_db_session, simulation_id=rbd_simulation_id rbd_simulation_id = simulation_id or calculation_data.rbd_simulation_id or TC_RBD_ID
# Delete old results to avoid duplicates
from sqlalchemy import delete
from .model import CalculationEquipmentResult, CalculationResult
await db_session.execute(
delete(CalculationResult).where(CalculationResult.calculation_data_id == calculation_data.id)
)
await db_session.execute(
delete(CalculationEquipmentResult).where(CalculationEquipmentResult.calculation_data_id == calculation_data.id)
)
await db_session.commit()
await run_simulation_with_spareparts(
db_session=db_session,
calculation=calculation_data,
token=token,
collector_db_session=collector_db_session,
simulation_id=rbd_simulation_id
) )
return results from .service import get_calculation_result
return await get_calculation_result(
db_session=db_session,
calculation_id=calculation_id,
token=token,
include_risk_cost=1
)
async def get_or_create_scope_equipment_calculation( async def get_or_create_scope_equipment_calculation(

@ -73,6 +73,9 @@ class CalculationData(Base, DefaultMixin, IdentityMixin):
rbd_simulation_id = Column(UUID(as_uuid=True), nullable=True) rbd_simulation_id = Column(UUID(as_uuid=True), nullable=True)
optimum_analysis = Column(JSON, nullable=True) optimum_analysis = Column(JSON, nullable=True)
plant_results = Column(JSON, nullable=True)
fleet_statistics = Column(JSON, nullable=True)
analysis_metadata = Column(JSON, nullable=True)
session = relationship("OverhaulScope", lazy="raise") session = relationship("OverhaulScope", lazy="raise")
@ -148,6 +151,7 @@ class CalculationEquipmentResult(Base, DefaultMixin):
corrective_costs = Column(JSON, nullable=False) corrective_costs = Column(JSON, nullable=False)
overhaul_costs = Column(JSON, nullable=False) overhaul_costs = Column(JSON, nullable=False)
daily_failures = Column(JSON, nullable=False) daily_failures = Column(JSON, nullable=False)
is_actual = Column(JSON, nullable=True) # List of booleans
procurement_costs = Column(JSON, nullable=False) procurement_costs = Column(JSON, nullable=False)
location_tag = Column(String(255), nullable=False) location_tag = Column(String(255), nullable=False)
material_cost = Column(Float, nullable=False) material_cost = Column(Float, nullable=False)

@ -44,7 +44,8 @@ async def create_calculation_time_constrains(
"""Save calculation time constrains Here""" """Save calculation time constrains Here"""
scope_calculation_id = params.scope_calculation_id scope_calculation_id = params.scope_calculation_id
with_results = params.with_results with_results = params.with_results
simulation_id = params.simulation_id simulation_id = calculation_time_constrains_in.simulationRBDId
if scope_calculation_id: if scope_calculation_id:
results = await get_or_create_scope_equipment_calculation( results = await get_or_create_scope_equipment_calculation(
@ -65,6 +66,8 @@ async def create_calculation_time_constrains(
return StandardResponse(data=results, message="Data created successfully") return StandardResponse(data=results, message="Data created successfully")
@router.get( @router.get(
"", response_model=StandardResponse[List[CalculationTimeConstrainsReadNoResult]] "", response_model=StandardResponse[List[CalculationTimeConstrainsReadNoResult]]
) )
@ -186,3 +189,42 @@ async def update_selected_equipment(
data=results, data=results,
message="Data retrieved successfully", message="Data retrieved successfully",
) )
@router.post("/{calculation_id}/refresh-spareparts", response_model=StandardResponse[dict])
async def refresh_spareparts(
db_session: DbSession,
collector_db_session: CollectorDbSession,
calculation_id: str,
current_user: CurrentUser,
):
"""Refresh sparepart availability for an existing calculation"""
from .service import refresh_spareparts_service
await refresh_spareparts_service(
db_session=db_session,
collector_db_session=collector_db_session,
calculation_id=calculation_id
)
return StandardResponse(data={}, message="Spareparts refreshed successfully")
@router.post("/{calculation_id}/recalculate", response_model=StandardResponse[CalculationTimeConstrainsRead])
async def recalculate_calculation_api(
db_session: DbSession,
collector_db_session: CollectorDbSession,
calculation_id: str,
token: Token,
current_user: CurrentUser,
):
"""Recalculate an existing simulation with fresh data"""
from .flows import recalculate_calculation
results = await recalculate_calculation(
token=token,
db_session=db_session,
collector_db_session=collector_db_session,
calculation_id=calculation_id
)
return StandardResponse(data=results, message="Calculation updated with fresh data")

@ -37,6 +37,7 @@ class CalculationTimeConstrainsParametersCreate(CalculationTimeConstrainsBase):
overhaulCost: Optional[float] = Field(0, description="Overhaul cost") overhaulCost: Optional[float] = Field(0, description="Overhaul cost")
ohSessionId: Optional[UUID] = Field(None, description="Scope OH") ohSessionId: Optional[UUID] = Field(None, description="Scope OH")
costPerFailure: Optional[float] = Field(0, description="Cost per failure") costPerFailure: Optional[float] = Field(0, description="Cost per failure")
simulationRBDId: Optional[str] = Field(None, description="Simulation RBD ID")
# class CalculationTimeConstrainsCreate(CalculationTimeConstrainsBase): # class CalculationTimeConstrainsCreate(CalculationTimeConstrainsBase):
@ -58,6 +59,8 @@ class CalculationResultsRead(CalculationTimeConstrainsBase):
sparepart_summary: dict sparepart_summary: dict
class OptimumResult(CalculationTimeConstrainsBase): class OptimumResult(CalculationTimeConstrainsBase):
overhaul_cost: float overhaul_cost: float
corrective_cost: float corrective_cost: float
@ -71,6 +74,7 @@ class EquipmentResult(CalculationTimeConstrainsBase):
overhaul_costs: List[float] overhaul_costs: List[float]
procurement_costs: List[float] procurement_costs: List[float]
daily_failures: List[float] daily_failures: List[float]
is_actual: Optional[List[bool]] = None
location_tag: str location_tag: str
material_cost: float material_cost: float
service_cost: float service_cost: float
@ -104,6 +108,7 @@ class AnalysisMetadata(CalculationTimeConstrainsBase):
calculation_type: str calculation_type: str
total_equipment_analyzed: int total_equipment_analyzed: int
included_in_optimization: int included_in_optimization: int
optimal_stat: Optional[dict]
class CalculationTimeConstrainsReadNoResult(CalculationTimeConstrainsBase): class CalculationTimeConstrainsReadNoResult(CalculationTimeConstrainsBase):
id: UUID id: UUID

File diff suppressed because it is too large Load Diff

@ -68,7 +68,7 @@ def calculate_failures_per_month(hourly_data):
monthly_data = {} monthly_data = {}
for data_point in hourly_data: for data_point in hourly_data:
hour = data_point['hour'] hour = data_point['cumulativeTime']
current_eq_status = data_point['currentEQStatus'] current_eq_status = data_point['currentEQStatus']
# Calculate which month this hour belongs to (1-based) # Calculate which month this hour belongs to (1-based)
@ -78,6 +78,7 @@ def calculate_failures_per_month(hourly_data):
# Check if this is the start of a failure (transition to "OoS") # Check if this is the start of a failure (transition to "OoS")
if current_eq_status == "OoS" and previous_eq_status is not None and previous_eq_status != "OoS": if current_eq_status == "OoS" and previous_eq_status is not None and previous_eq_status != "OoS":
total_failures += 1 total_failures += 1
# Special case: if the very first data point is a failure # Special case: if the very first data point is a failure
elif current_eq_status == "OoS" and previous_eq_status is None: elif current_eq_status == "OoS" and previous_eq_status is None:
total_failures += 1 total_failures += 1
@ -99,6 +100,48 @@ def calculate_failures_per_month(hourly_data):
return result return result
def calculate_oos_per_month(hourly_data):
"""
Calculate the discrete OOS hours for each month from hourly data.
A failure is defined as when currentEQStatus = "OoS".
Args:
hourly_data: List of dicts with 'cumulativeTime', 'flowRate', and 'currentEQStatus' keys
Returns:
List of dicts with 'month' and 'oos_hours' keys (discrete monthly count)
"""
monthly_data = {}
for data_point in hourly_data:
hour = data_point['cumulativeTime']
current_eq_status = data_point['currentEQStatus']
# Calculate which month this hour belongs to (1-based)
# Assuming 30 days per month = 720 hours per month
month = ((hour - 1) // 720) + 1
if month not in monthly_data:
monthly_data[month] = 0
if current_eq_status == "OoS":
monthly_data[month] += 1
# Convert to list format
result = []
if monthly_data:
max_month = max(monthly_data.keys())
for month in range(1, max_month + 1):
result.append({
'month': month,
'oos_hours': monthly_data.get(month, 0)
})
return result
import pandas as pd import pandas as pd
import datetime import datetime

@ -46,7 +46,8 @@ config = get_config()
LOG_LEVEL = config("LOG_LEVEL", default="INFO") LOG_LEVEL = config("LOG_LEVEL", default="INFO")
ENV = config("ENV", default="local") ENV = (os.getenv("ENV") or os.getenv("ENVIRONMENT") or os.getenv("ENVIRONEMENT") or "production").strip()
DEBUG = 1 if (ENV.upper() in ("DEV", "DEVELOPMENT", "LOCAL") and "PROD" not in ENV.upper()) else 0
PORT = config("PORT", cast=int, default=8000) PORT = config("PORT", cast=int, default=8000)
HOST = config("HOST", default="localhost") HOST = config("HOST", default="localhost")
@ -91,4 +92,6 @@ API_KEY = config("API_KEY", default="0KFvcB7zWENyKVjoma9FKZNofVSViEshYr59zEQNGaY
TR_RBD_ID = config("TR_RBD_ID", default="f04f365e-25d8-4036-87c2-ba1bfe1f9229") TR_RBD_ID = config("TR_RBD_ID", default="f04f365e-25d8-4036-87c2-ba1bfe1f9229")
TC_RBD_ID = config("TC_RBD_ID", default="f8523cb0-dc3c-4edb-bcf1-eea7b62582f1") TC_RBD_ID = config("TC_RBD_ID", default="f8523cb0-dc3c-4edb-bcf1-eea7b62582f1")
DEFAULT_TC_ID = config("DEFAULT_TC_ID", default="44f483f3-bfe4-4094-a59f-b97a10f2fea6") DEFAULT_TC_ID = config("DEFAULT_TC_ID", default="44f483f3-bfe4-4094-a59f-b97a10f2fea6")
TEMPORAL_URL

@ -39,12 +39,15 @@ log = logging.getLogger(__name__)
# we configure the logging level and format # we configure the logging level and format
configure_logging() configure_logging()
from src.config import DEBUG
# we create the ASGI for the app # we create the ASGI for the app
app = FastAPI( app = FastAPI(
openapi_url="", openapi_url="",
title="LCCA API", title="LCCA API",
description="Welcome to LCCA's API documentation!", description="Welcome to LCCA's API documentation!",
version="0.1.0", version="0.1.0",
debug=bool(DEBUG),
) )
# we define the exception handlers # we define the exception handlers
@ -63,7 +66,6 @@ app.add_middleware(SlowAPIMiddleware)
def security_headers_middleware(app: FastAPI): def security_headers_middleware(app: FastAPI):
is_production = False is_production = False

@ -60,7 +60,7 @@ ALLOWED_DATA_PARAMS = {
"total_procurement_items", "type", "unit_cost", "warning_message", "with_results", "total_procurement_items", "type", "unit_cost", "warning_message", "with_results",
"workscope", "workscope_group", "year", "_", "t", "timestamp", "workscope", "workscope_group", "year", "_", "t", "timestamp",
"q", "filter", "currentUser", "risk_cost", "all", "with_results", "q", "filter", "currentUser", "risk_cost", "all", "with_results",
"eaf_threshold", "simulation_id", "scope_calculation_id", "calculation_id" "eaf_threshold", "simulation_id", "scope_calculation_id", "calculation_id", "simulationRBDId"
} }
ALLOWED_HEADERS = { ALLOWED_HEADERS = {
@ -154,7 +154,7 @@ RCE_PATTERN = re.compile(
r"\$\(.*\)|`.*`|" # Command substitution $(...) or `...` r"\$\(.*\)|`.*`|" # Command substitution $(...) or `...`
r"[;&|]\s*(cat|ls|id|whoami|pwd|ifconfig|ip|netstat|nc|netcat|nmap|curl|wget|python|php|perl|ruby|bash|sh|cmd|powershell|pwsh|sc\s+|tasklist|taskkill|base64|sudo|crontab|ssh|ftp|tftp)|" r"[;&|]\s*(cat|ls|id|whoami|pwd|ifconfig|ip|netstat|nc|netcat|nmap|curl|wget|python|php|perl|ruby|bash|sh|cmd|powershell|pwsh|sc\s+|tasklist|taskkill|base64|sudo|crontab|ssh|ftp|tftp)|"
# Only flag naked commands if they are clearly standalone or system paths # Only flag naked commands if they are clearly standalone or system paths
r"\b(/etc/passwd|/etc/shadow|/etc/group|/etc/issue|/proc/self/|/windows/system32/|C:\\Windows\\)\b" r"(/etc/passwd|/etc/shadow|/etc/group|/etc/issue|/proc/self/|/windows/system32/|C:\\Windows\\)\b"
r")", r")",
re.IGNORECASE, re.IGNORECASE,
) )
@ -239,12 +239,12 @@ def inspect_json(obj, path="body", check_whitelist=True):
detail="Invalid request parameters", detail="Invalid request parameters",
) )
if check_whitelist and key not in ALLOWED_DATA_PARAMS: # if check_whitelist and key not in ALLOWED_DATA_PARAMS:
log.warning(f"Security violation: Unknown JSON key detected: {path}.{key}") # log.warning(f"Security violation: Unknown JSON key detected: {path}.{key}")
raise HTTPException( # raise HTTPException(
status_code=422, # status_code=422,
detail="Invalid request parameters", # detail="Invalid request parameters",
) # )
# Recurse. If the key is a dynamic container, we stop whitelist checking for children. # Recurse. If the key is a dynamic container, we stop whitelist checking for children.
should_check_subkeys = check_whitelist and (key not in DYNAMIC_KEYS) should_check_subkeys = check_whitelist and (key not in DYNAMIC_KEYS)
@ -316,13 +316,13 @@ class RequestValidationMiddleware(BaseHTTPMiddleware):
) )
# Check for unknown query parameters # Check for unknown query parameters
unknown_params = [key for key, _ in params if key not in ALLOWED_DATA_PARAMS] # unknown_params = [key for key, _ in params if key not in ALLOWED_DATA_PARAMS]
if unknown_params: # if unknown_params:
log.warning(f"Security violation: Unknown query parameters detected: {unknown_params}") # log.warning(f"Security violation: Unknown query parameters detected: {unknown_params}")
raise HTTPException( # raise HTTPException(
status_code=422, # status_code=422,
detail="Invalid request parameters", # detail="Invalid request parameters",
) # )
# ------------------------- # -------------------------
# 2. Duplicate parameters # 2. Duplicate parameters
@ -351,7 +351,7 @@ class RequestValidationMiddleware(BaseHTTPMiddleware):
if key in pagination_size_keys and value: if key in pagination_size_keys and value:
try: try:
size_val = int(value) size_val = int(value)
if size_val > 50: if size_val > 100:
log.warning(f"Security violation: Pagination size too large ({size_val})") log.warning(f"Security violation: Pagination size too large ({size_val})")
raise HTTPException( raise HTTPException(
status_code=422, status_code=422,

@ -0,0 +1,172 @@
from typing import Optional
from uuid import UUID
from fastapi import HTTPException, status
from sqlalchemy import func, select
from src.auth.service import Token
from src.config import TC_RBD_ID
from src.database.core import DbSession, CollectorDbSession
from src.overhaul_scope.service import get_all
from src.standard_scope.model import StandardScope
from src.workorder.model import MasterWorkOrder
from src.calculation_time_constrains.schema import (
CalculationTimeConstrainsParametersCreate,
CalculationTimeConstrainsParametersRead,
CalculationTimeConstrainsParametersRetrive,
)
from src.optimum_time_constraint.service import (
get_calculation_data_by_id,
get_calculation_result,
)
async def get_create_calculation_parameters(
*, db_session: DbSession, calculation_id: Optional[str] = None
):
if calculation_id is not None:
calculation = await get_calculation_data_by_id(
calculation_id=calculation_id, db_session=db_session
)
if not calculation:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="A data with this id does not exist.",
)
return CalculationTimeConstrainsParametersRead(
costPerFailure=calculation.parameter.avg_failure_cost,
overhaulCost=calculation.parameter.overhaul_cost,
reference=calculation,
)
stmt = (
select(
StandardScope,
func.avg(MasterWorkOrder.total_cost_max).label("average_cost"),
)
.outerjoin(MasterWorkOrder, StandardScope.location_tag == MasterWorkOrder.location_tag)
.group_by(StandardScope.id)
)
results = await db_session.execute(stmt)
costFailure = results.all()
scopes = await get_all(db_session=db_session)
avaiableScopes = {scope.id: scope.scope_name for scope in scopes}
costFailurePerScope = {
avaiableScopes.get(costPerFailure[0]): costPerFailure[1]
for costPerFailure in costFailure
}
return CalculationTimeConstrainsParametersRetrive(
costPerFailure=costFailurePerScope,
availableScopes=avaiableScopes.values(),
recommendedScope="A",
)
async def create_calculation(
*,
token: str,
db_session: DbSession,
collector_db_session: CollectorDbSession,
calculation_time_constrains_in: CalculationTimeConstrainsParametersCreate,
created_by: str,
simulation_id
):
from temporalio.client import Client
from src.config import TEMPORAL_URL
from temporal.temporal_workflows import OptimumOHCalculationWorkflow
import uuid
temporal_client = await Client.connect(TEMPORAL_URL)
workflow_id = f"optimum-oh-calc-{uuid.uuid4()}"
args = {
"token": token.token if hasattr(token, 'token') else str(token),
"calculation_in": calculation_time_constrains_in.model_dump(mode="json"),
"created_by": created_by
}
handle = await temporal_client.start_workflow(
OptimumOHCalculationWorkflow.run,
args,
id=workflow_id,
task_queue="oh-task-queue"
)
return {
"data": workflow_id,
"status": "success",
"message": "Calculation workflow started successfully"
}
async def recalculate_calculation(
*,
token: str,
db_session: DbSession,
collector_db_session: CollectorDbSession,
calculation_id: str,
simulation_id: Optional[str] = None
):
calculation_data = await get_calculation_data_by_id(
db_session=db_session, calculation_id=calculation_id
)
if not calculation_data:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Calculation not found",
)
rbd_simulation_id = simulation_id or calculation_data.rbd_simulation_id or TC_RBD_ID
from sqlalchemy import delete
from src.calculation_time_constrains.model import CalculationEquipmentResult, CalculationResult
await db_session.execute(
delete(CalculationResult).where(CalculationResult.calculation_data_id == calculation_data.id)
)
await db_session.execute(
delete(CalculationEquipmentResult).where(CalculationEquipmentResult.calculation_data_id == calculation_data.id)
)
await db_session.commit()
from src.optimum_time_constraint.optimizer import run_simulation_with_spareparts
await run_simulation_with_spareparts(
db_session=db_session,
calculation=calculation_data,
token=token,
collector_db_session=collector_db_session,
simulation_id=rbd_simulation_id
)
return await get_calculation_result(
db_session=db_session,
calculation_id=calculation_id,
token=token,
include_risk_cost=1
)
async def get_or_create_scope_equipment_calculation(
*,
db_session: DbSession,
scope_calculation_id,
calculation_time_constrains_in: Optional[CalculationTimeConstrainsParametersCreate]
):
scope_calculation = await get_calculation_data_by_id(
db_session=db_session, calculation_id=scope_calculation_id
)
if not scope_calculation:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="A data with this id does not exist.",
)
return scope_calculation.id

@ -0,0 +1,917 @@
import asyncio
import calendar
import datetime
import json
import logging
import math
from datetime import date, timedelta
from typing import Dict, List, Optional, Tuple
import aiohttp
import httpx
import numpy as np
import requests
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from src.config import REALIBILITY_SERVICE_API, RBD_SERVICE_API
from src.database.core import CollectorDbSession, DbSession
from src.calculation_time_constrains.model import CalculationData, CalculationEquipmentResult
from src.calculation_time_constrains.schema import CalculationResultsRead
from src.calculation_time_constrains.utils import (
calculate_failures_per_month,
create_time_series_data,
get_months_between,
)
from src.overhaul_scope.service import get as get_scope, get_prev_oh
from src.sparepart.service import load_sparepart_data_from_db
log = logging.getLogger(__name__)
class OptimumCostModelWithSpareparts:
def __init__(
self,
token: str,
last_oh_date: date,
next_oh_date: date,
sparepart_manager,
time_window_months: Optional[int] = None,
base_url: str = "http://192.168.1.82:8000",
):
"""
Initialize the Optimum Cost Model with sparepart management
"""
self.api_base_url = base_url
self.token = token
self.last_oh_date = last_oh_date
self.next_oh_date = next_oh_date
self.session = None
self.sparepart_manager = sparepart_manager
# Calculate planned overhaul interval in months
self.planned_oh_months = get_months_between(last_oh_date, next_oh_date)
# Set analysis time window: next OH + 6 months
self.time_window_months = time_window_months or (self.planned_oh_months + 6)
# Pre-calculate date range for API calls
self.date_range = self._generate_date_range()
self.logger = log
def _generate_date_range(self) -> List[datetime.datetime]:
"""Generate date range for analysis based on time window"""
dates = []
current_date = datetime.datetime.combine(self.last_oh_date, datetime.datetime.min.time())
end_date = current_date + timedelta(days=self.time_window_months * 30)
while current_date <= end_date:
dates.append(current_date)
current_date += timedelta(days=31)
return dates
async def _create_session(self):
"""Create aiohttp session with connection pooling"""
if self.session is None:
timeout = aiohttp.ClientTimeout(total=300)
connector = aiohttp.TCPConnector(
limit=500,
limit_per_host=200,
ttl_dns_cache=300,
use_dns_cache=True,
force_close=False,
enable_cleanup_closed=True,
)
self.session = aiohttp.ClientSession(
timeout=timeout,
connector=connector,
headers={"Authorization": f"Bearer {self.token}"},
)
async def _close_session(self):
"""Close aiohttp session"""
if self.session:
await self.session.close()
self.session = None
async def get_failures_prediction(self, location_tag: str):
"""Get failure predictions for equipment from Reliability Predict service"""
start_date = self.last_oh_date.strftime("%Y-%m-%d")
end_date_val = self.next_oh_date + timedelta(days=6 * 30)
end_date = end_date_val.strftime("%Y-%m-%d")
predict_url = f"{REALIBILITY_SERVICE_API}/main/predict/{location_tag}/{start_date}/{end_date}"
try:
response = requests.get(
predict_url,
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {self.token}",
},
timeout=30,
)
response.raise_for_status()
prediction_data = response.json()
except (requests.RequestException, ValueError) as e:
self.logger.error(f"Failed to fetch prediction data for {location_tag}: {e}")
return None
predictions = (
prediction_data.get("data", {}).get("predictions")
if prediction_data.get("data")
else None
)
if not predictions:
self.logger.warning(f"No prediction data available for {location_tag}")
return None
monthly_data = {}
cumulative = 0.0
for i, pred in enumerate(predictions):
month_key = pred["month"]
monthly_fail = pred["predicted_failures"]
source = pred.get("source", "predicted")
cumulative += monthly_fail
monthly_data[month_key] = {
"cumulative_failures": cumulative,
"monthly_failures": monthly_fail,
"month_index": i + 1,
"source": source,
}
return monthly_data
async def get_simulation_results(self, simulation_id: str = "default"):
"""Get simulation results for Birnbaum importance calculations"""
headers = {
"Authorization": f"Bearer {self.token}",
"Content-Type": "application/json",
}
calc_result_url = f"{self.api_base_url}/aeros/simulation/result/calc/{simulation_id}?nodetype=RegularNode"
plant_result_url = f"{self.api_base_url}/aeros/simulation/result/calc/{simulation_id}/plant"
async with httpx.AsyncClient(timeout=300.0) as client:
calc_task = client.get(calc_result_url, headers=headers)
plant_task = client.get(plant_result_url, headers=headers)
calc_response, plant_response = await asyncio.gather(calc_task, plant_task)
calc_response.raise_for_status()
plant_response.raise_for_status()
calc_data = calc_response.json()["data"]
plant_data = plant_response.json()["data"]
return {"calc_result": calc_data, "plant_result": plant_data}
def _calculate_equipment_costs_with_spareparts(
self,
failures_prediction: Dict,
birnbaum_importance: float,
preventive_cost: float,
failure_replacement_cost: float,
ecs,
location_tag: str,
planned_overhauls: List = None,
loss_production_permonth=0,
) -> List[Dict]:
"""Calculate costs for each month including sparepart costs and availability"""
if not failures_prediction:
self.logger.warning(f"No failure prediction data for {location_tag}")
return []
results = []
months = list(failures_prediction.keys())
num_months = len(months)
failure_counts = []
monthly_risk_cost_per_failure = 0
if ecs:
is_trip = 1 if ecs.get("Diskripsi Operasional Akibat Equip. Failure") == "Trip" else 0
is_series = 0 if not birnbaum_importance else math.floor(birnbaum_importance)
if is_trip:
downtime = ecs.get("Estimasi Waktu Maint. / Downtime / Gangguan (Jam)")
monthly_risk_cost_per_failure = 660 * 1000000 * is_trip * downtime * is_series
for month_key in months:
data = failures_prediction[month_key]
failure_counts.append(data["cumulative_failures"])
for i in range(num_months):
month_index = i + 1
if month_index > self.time_window_months:
continue
sparepart_analysis = self._analyze_sparepart_availability(
location_tag, month_index - 1, planned_overhauls or []
)
total_expected_failure_cost = failure_counts[i] * (
failure_replacement_cost + monthly_risk_cost_per_failure
)
procurement_cost = sparepart_analysis["total_procurement_cost"]
total_preventive_cost = preventive_cost + procurement_cost
total_cycle_cost = total_expected_failure_cost + total_preventive_cost
cput = total_cycle_cost / month_index
results.append(
{
"month": month_index,
"number_of_failures": failure_counts[i],
"is_actual": failures_prediction[months[i]].get("source") == "actual",
"failure_cost": total_expected_failure_cost / month_index,
"preventive_cost": preventive_cost / month_index,
"procurement_cost": procurement_cost / month_index,
"total_cost": cput,
"absolute_failure_cost": total_expected_failure_cost,
"absolute_overhaul_cost": preventive_cost,
"absolute_procurement_cost": procurement_cost,
"total_cycle_cost": total_cycle_cost,
"is_after_planned_oh": month_index > self.planned_oh_months,
"delay_months": max(0, month_index - self.planned_oh_months),
"procurement_details": sparepart_analysis,
"sparepart_available": sparepart_analysis["available"],
"sparepart_status": sparepart_analysis["message"],
"can_proceed": sparepart_analysis["can_proceed_with_delays"],
}
)
return results
def _analyze_sparepart_availability(
self, equipment_tag: str, target_month: int, planned_overhauls: List
) -> Dict:
"""Analyze sparepart availability for equipment at target month"""
if not self.sparepart_manager:
return {
"available": True,
"message": "Sparepart manager not initialized",
"total_procurement_cost": 0,
"procurement_needed": [],
"can_proceed_with_delays": True,
}
other_overhauls = [
(eq_tag, month)
for eq_tag, month in planned_overhauls
if eq_tag != equipment_tag and month <= target_month
]
return self.sparepart_manager.check_sparepart_availability(
equipment_tag, target_month, other_overhauls
)
def _find_optimal_timing_with_spareparts(
self, cost_results: List[Dict], location_tag: str
) -> Optional[Dict]:
"""Find optimal timing considering sparepart constraints"""
if not cost_results:
return None
feasible_results = [r for r in cost_results if r["can_proceed"]]
min_cost = float("inf")
optimal_result = None
for i, result in enumerate(cost_results):
if result in feasible_results and result["total_cost"] < min_cost:
min_cost = result["total_cost"]
optimal_result = result
if optimal_result is None:
return None
return self._create_optimal_result(optimal_result, location_tag, "OPTIMAL")
def _create_optimal_result(
self, optimal_result: Dict, location_tag: str, status: str
) -> Dict:
"""Create standardized optimal result dictionary"""
return {
"location_tag": location_tag,
"optimal_month": optimal_result["month"],
"optimal_index": optimal_result["month"] - 1,
"optimal_cost": optimal_result["total_cost"],
"failure_cost": optimal_result["failure_cost"],
"preventive_cost": optimal_result["preventive_cost"],
"procurement_cost": optimal_result["procurement_cost"],
"number_of_failures": optimal_result["number_of_failures"],
"is_delayed": optimal_result["is_after_planned_oh"],
"delay_months": optimal_result["delay_months"],
"planned_oh_month": self.planned_oh_months,
"planned_cost": None,
"cost_vs_planned": None,
"savings_from_delay": 0,
"cost_of_delay": 0,
"sparepart_available": optimal_result["sparepart_available"],
"sparepart_status": optimal_result["sparepart_status"],
"procurement_details": optimal_result["procurement_details"],
"optimization_status": status,
"all_monthly_costs": [],
}
async def calculate_cost_all_equipment_with_spareparts(
self,
db_session,
collector_db_session,
equipments: List,
calculation,
preventive_cost: float,
simulation_id: str = "default",
):
"""
Calculate optimal overhaul timing for entire fleet considering sparepart constraints
"""
self.logger.info(
f"Starting fleet optimization with reliability prediction for {len(equipments)} equipment"
)
max_interval = self.time_window_months
# Phase 1: Calculate individual optimal timings without considering interactions
individual_results = {}
try:
with open("src/calculation_time_constrains/full_equipment_with_downtime_opdesc.json", "r") as f:
data = json.load(f)
ecs_tags = {eq["Location"]: eq for eq in data}
except FileNotFoundError:
ecs_tags = {}
for equipment in equipments:
location_tag = equipment.location_tag
contribution_factor = 1.0
ecs = ecs_tags.get(location_tag, None)
monthly_data = await self.get_failures_prediction(location_tag)
if not monthly_data:
continue
equipment_preventive_cost = equipment.overhaul_cost + equipment.service_cost
failure_replacement_cost = equipment.material_cost + (3 * 111000 * 3)
cost_results = self._calculate_equipment_costs_with_spareparts(
failures_prediction=monthly_data,
birnbaum_importance=contribution_factor,
preventive_cost=equipment_preventive_cost,
failure_replacement_cost=failure_replacement_cost,
location_tag=location_tag,
planned_overhauls=[],
ecs=ecs,
loss_production_permonth=0,
)
if not cost_results:
continue
optimal_timing = self._find_optimal_timing_with_spareparts(cost_results, location_tag)
if optimal_timing:
optimal_timing["all_monthly_costs"] = cost_results
individual_results[location_tag] = optimal_timing
self.logger.info(
f"Individual optimal for {location_tag}: Month {optimal_timing['optimal_month']}"
)
# Phase 2: Optimize considering sparepart interactions
self.logger.info("Phase 2: Optimizing with sparepart interactions...")
improved_plan = self._optimize_fleet_with_sparepart_constraints(
individual_results, equipments, simulation_id
)
# Phase 3: Generate final results and database objects
fleet_results = []
total_corrective_costs = np.zeros(max_interval)
total_preventive_costs = np.zeros(max_interval)
total_procurement_costs = np.zeros(max_interval)
total_costs = np.zeros(max_interval)
total_fleet_procurement_cost = 0
for equipment in equipments:
location_tag = equipment.location_tag
if location_tag not in individual_results:
continue
equipment_timing = next(
(month for tag, month in improved_plan if tag == location_tag),
individual_results[location_tag]["optimal_month"],
)
cost_data = individual_results[location_tag]["all_monthly_costs"][equipment_timing - 1]
all_costs = individual_results[location_tag]["all_monthly_costs"]
corrective_costs = [r["failure_cost"] for r in all_costs]
preventive_costs = [r["preventive_cost"] for r in all_costs]
procurement_costs = [r["procurement_cost"] for r in all_costs]
failures = [r["number_of_failures"] for r in all_costs]
total_costs_equipment = [r["total_cost"] for r in all_costs]
procurement_details = [r["procurement_details"] for r in all_costs]
def pad_array(arr, target_length):
if len(arr) < target_length:
return arr + [arr[-1]] * (target_length - len(arr))
return arr[:target_length]
corrective_costs = pad_array(corrective_costs, max_interval)
preventive_costs = pad_array(preventive_costs, max_interval)
procurement_costs = pad_array(procurement_costs, max_interval)
failures = pad_array(failures, max_interval)
total_costs_equipment = pad_array(total_costs_equipment, max_interval)
procurement_details = pad_array(procurement_details, max_interval)
is_actual_list = [r.get("is_actual", False) for r in all_costs]
is_actual_list = pad_array(is_actual_list, max_interval)
equipment_result = CalculationEquipmentResult(
corrective_costs=corrective_costs,
overhaul_costs=preventive_costs,
procurement_costs=procurement_costs,
daily_failures=failures,
is_actual=is_actual_list,
location_tag=equipment.location_tag,
material_cost=equipment.material_cost,
service_cost=equipment.service_cost,
optimum_day=equipment_timing - 1,
calculation_data_id=calculation.id,
procurement_details=procurement_details,
)
fleet_results.append(equipment_result)
total_corrective_costs += np.array(corrective_costs)
total_preventive_costs += np.array(preventive_costs)
total_procurement_costs += np.array(procurement_costs)
total_costs += np.array(total_costs_equipment)
total_fleet_procurement_cost += cost_data["procurement_cost"]
fleet_optimal_index = np.argmin(total_costs)
calculation.optimum_oh_day = int(fleet_optimal_index)
calculation.max_interval = max_interval
calculation.rbd_simulation_id = simulation_id
db_session.add_all(fleet_results)
await db_session.commit()
return int(fleet_optimal_index)
def _optimize_fleet_with_sparepart_constraints(
self, individual_results: Dict, equipments: List, simulation_id: str
) -> List[Tuple[str, int]]:
"""
Optimize fleet overhaul timing considering sparepart sharing constraints
"""
current_plan = [(tag, result["optimal_month"]) for tag, result in individual_results.items()]
current_plan.sort(key=lambda x: x[1])
improved_plan = []
processed_equipment = []
for equipment_tag, optimal_month in current_plan:
sparepart_analysis = self.sparepart_manager.check_sparepart_availability(
equipment_tag, optimal_month - 1, processed_equipment
)
if sparepart_analysis["available"] or sparepart_analysis["can_proceed_with_delays"]:
improved_plan.append((equipment_tag, optimal_month))
processed_equipment.append((equipment_tag, optimal_month))
else:
alternative_month = self._find_alternative_timing(
equipment_tag,
optimal_month,
individual_results[equipment_tag]["all_monthly_costs"],
processed_equipment,
)
if alternative_month:
improved_plan.append((equipment_tag, alternative_month))
processed_equipment.append((equipment_tag, alternative_month))
else:
improved_plan.append((equipment_tag, optimal_month))
processed_equipment.append((equipment_tag, optimal_month))
return improved_plan
def _find_alternative_timing(
self,
equipment_tag: str,
preferred_month: int,
cost_results: List[Dict],
processed_equipment: List[Tuple[str, int]],
) -> Optional[int]:
"""
Find alternative timing when preferred month has sparepart constraints
"""
search_range = 6
candidates = []
for offset in range(-search_range // 2, search_range // 2 + 1):
candidate_month = preferred_month + offset
if candidate_month <= 0 or candidate_month > len(cost_results):
continue
if candidate_month == preferred_month:
continue
sparepart_analysis = self.sparepart_manager.check_sparepart_availability(
equipment_tag, candidate_month - 1, processed_equipment
)
if sparepart_analysis["available"] or sparepart_analysis["can_proceed_with_delays"]:
cost_data = cost_results[candidate_month - 1]
candidates.append((candidate_month, cost_data["total_cost"]))
if not candidates:
return None
candidates.sort(key=lambda x: x[1])
return candidates[0][0]
async def __aenter__(self):
await self._create_session()
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
await self._close_session()
def _analyze_optimal_timing(
calculation_results: List, optimum_oh_day: int, prev_oh_scope, scope_overhaul
) -> Dict:
"""Analyze optimal timing and provide recommendations"""
if not calculation_results:
return {}
optimal_result = None
if 0 <= optimum_oh_day < len(calculation_results):
optimal_result = calculation_results[optimum_oh_day]
planned_oh_months = None
if prev_oh_scope and scope_overhaul:
planned_oh_months = get_months_between(prev_oh_scope.end_date, scope_overhaul.start_date)
timing_recommendation = "OPTIMAL"
if planned_oh_months:
if optimum_oh_day + 1 < planned_oh_months:
timing_recommendation = "EARLY"
elif optimum_oh_day + 1 > planned_oh_months:
timing_recommendation = "DELAYED"
else:
timing_recommendation = "ON_SCHEDULE"
cost_trend = "STABLE"
if len(calculation_results) > 1:
early_costs = [r.total_cost for r in calculation_results[: len(calculation_results) // 3]]
late_costs = [r.total_cost for r in calculation_results[-len(calculation_results) // 3 :]]
avg_early = sum(early_costs) / len(early_costs) if early_costs else 0
avg_late = sum(late_costs) / len(late_costs) if late_costs else 0
if avg_late > avg_early * 1.2:
cost_trend = "INCREASING"
elif avg_late < avg_early * 0.8:
cost_trend = "DECREASING"
return {
"optimal_month": optimum_oh_day + 1,
"planned_month": planned_oh_months,
"timing_recommendation": timing_recommendation,
"optimal_total_cost": optimal_result.total_cost if optimal_result else 0,
"optimal_breakdown": {
"corrective_cost": optimal_result.corrective_cost if optimal_result else 0,
"overhaul_cost": optimal_result.overhaul_cost if optimal_result else 0,
"procurement_cost": optimal_result.procurement_cost if optimal_result else 0,
"num_failures": optimal_result.num_failures if optimal_result else 0,
},
"cost_trend": cost_trend,
"months_from_planned": (optimum_oh_day + 1 - planned_oh_months)
if planned_oh_months
else None,
"cost_savings_vs_planned": None,
"sparepart_impact": {
"equipment_with_constraints": optimal_result.sparepart_summary["equipment_requiring_procurement"]
if optimal_result
else 0,
"critical_shortages": optimal_result.sparepart_summary["critical_shortages"]
if optimal_result
else 0,
"procurement_investment": optimal_result.sparepart_summary["total_procurement_cost"]
if optimal_result
else 0,
},
}
async def run_simulation_with_spareparts(
*,
db_session: DbSession,
calculation,
token: str,
collector_db_session: CollectorDbSession,
time_window_months: Optional[int] = None,
simulation_id: str = "default",
) -> Dict:
"""
Run complete overhaul optimization simulation with sparepart management
"""
from src.optimum_time_constraint.service import get_calculation_data_by_id
from src.overhaul_activity.service import get_standard_scope_by_session_id
equipments = await get_standard_scope_by_session_id(
db_session=db_session,
overhaul_session_id=calculation.overhaul_session_id,
collector_db=collector_db_session,
)
scope = await get_scope(
db_session=db_session, overhaul_session_id=calculation.overhaul_session_id
)
prev_oh_scope = await get_prev_oh(db_session=db_session, overhaul_session=scope)
calculation_data = await get_calculation_data_by_id(
db_session=db_session, calculation_id=calculation.id
)
time_window_months = get_months_between(prev_oh_scope.end_date, scope.start_date) + 6
sparepart_manager = await load_sparepart_data_from_db(
scope=scope,
prev_oh_scope=prev_oh_scope,
db_session=collector_db_session,
app_db_session=db_session,
analysis_window_months=time_window_months,
)
optimum_oh_model = OptimumCostModelWithSpareparts(
token=token,
last_oh_date=prev_oh_scope.end_date,
next_oh_date=scope.start_date,
base_url=RBD_SERVICE_API,
sparepart_manager=sparepart_manager,
)
try:
fleet_optimal_index = await optimum_oh_model.calculate_cost_all_equipment_with_spareparts(
db_session=db_session,
collector_db_session=collector_db_session,
equipments=equipments,
calculation=calculation_data,
preventive_cost=calculation_data.parameter.overhaul_cost,
simulation_id=simulation_id,
)
finally:
await optimum_oh_model._close_session()
calculation_query = await db_session.execute(
select(CalculationData)
.options(
selectinload(CalculationData.equipment_results),
selectinload(CalculationData.parameter),
)
.where(CalculationData.id == calculation.id)
)
scope_calculation = calculation_query.scalar_one_or_none()
data_num = scope_calculation.max_interval
all_equipment = scope_calculation.equipment_results
included_equipment = [eq for eq in all_equipment if eq.is_included]
calculation_results = []
fleet_statistics = {
"total_equipment": len(all_equipment),
"included_equipment": len(included_equipment),
"excluded_equipment": len(all_equipment) - len(included_equipment),
"equipment_with_sparepart_constraints": 0,
"total_procurement_items": 0,
"critical_procurement_items": 0,
"total_spareparts": 745,
}
avg_failure_cost = (
sum((eq.material_cost or 0) + (3 * 111000 * 3) for eq in all_equipment) / len(all_equipment)
if all_equipment
else 0
)
rbd_marginal_fails = [0] * data_num
try:
if scope_calculation.rbd_simulation_id:
plant_result_url = f"{RBD_SERVICE_API}/aeros/simulation/result/calc/{scope_calculation.rbd_simulation_id}/plant"
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.get(
plant_result_url,
headers={
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
},
)
if response.status_code == 200:
plant_data = response.json().get("data", {})
timestamp_outs = plant_data.get("timestamp_outs", [])
if timestamp_outs:
hourly_data = create_time_series_data(
timestamp_outs, max_hours=data_num * 720
)
cumulative_rbd_fails = calculate_failures_per_month(hourly_data)
rbd_fails_map = {m["month"]: m["failures"] for m in cumulative_rbd_fails}
prev_fail = 0
for m in range(1, data_num + 1):
curr_fail = rbd_fails_map.get(m, prev_fail)
rbd_marginal_fails[m - 1] = curr_fail - prev_fail
prev_fail = curr_fail
except Exception as e:
logger = logging.getLogger(__name__)
logger.warning(f"Failed to fetch plant simulation: {e}")
cumulative_plant_failures = 0
for month_index in range(data_num):
historical_marginal_fail = 0
for eq in all_equipment:
if eq.is_actual and month_index < len(eq.is_actual) and eq.is_actual[month_index]:
curr_fail = (
eq.daily_failures[month_index]
if month_index < len(eq.daily_failures)
else 0
)
prev_fail = (
eq.daily_failures[month_index - 1]
if month_index > 0 and (month_index - 1) < len(eq.daily_failures)
else 0
)
historical_marginal_fail += max(0, curr_fail - prev_fail)
marginal_fail = rbd_marginal_fails[month_index] + historical_marginal_fail
cumulative_plant_failures += marginal_fail
month_result = {
"overhaul_cost": 0.0,
"corrective_cost": 0.0,
"procurement_cost": 0.0,
"num_failures": cumulative_plant_failures,
"day": month_index + 1,
"month": month_index + 1,
"procurement_details": {},
"sparepart_summary": {
"total_procurement_cost": 0.0,
"equipment_requiring_procurement": 0,
"critical_shortages": 0,
"existing_orders_value": 0.0,
"new_orders_required": 0,
"urgent_procurements": 0,
},
}
equipment_requiring_procurement = 0
total_existing_orders_value = 0.0
total_new_orders_value = 0.0
critical_shortages = 0
urgent_procurements = 0
for eq in all_equipment:
if month_index >= len(eq.procurement_details):
continue
procurement_detail = eq.procurement_details[month_index]
if (
procurement_detail
and isinstance(procurement_detail, dict)
and procurement_detail.get("procurement_needed")
):
equipment_requiring_procurement += 1
pr_po_summary = procurement_detail.get("pr_po_summary", {})
existing_orders_value = pr_po_summary.get("total_existing_value", 0)
total_existing_orders_value += existing_orders_value
new_orders_value = pr_po_summary.get("total_new_orders_value", 0)
total_new_orders_value += new_orders_value
critical_missing = procurement_detail.get("critical_missing_parts", 0)
if critical_missing > 0:
critical_shortages += 1
recommendations = procurement_detail.get("recommendations", [])
urgent_items = [
r for r in recommendations if r.get("priority") == "CRITICAL"
]
if urgent_items:
urgent_procurements += 1
is_included_eq = False if eq.is_initial else eq.is_included
month_result["procurement_details"][eq.location_tag] = {
"is_included": is_included_eq,
"location_tag": eq.location_tag,
"details": procurement_detail.get("procurement_needed", []),
"detailed_message": procurement_detail.get("detailed_message", ""),
"pr_po_summary": pr_po_summary,
"recommendations": recommendations,
"sparepart_available": procurement_detail.get("sparepart_available", True),
"can_proceed": procurement_detail.get("can_proceed_with_delays", True),
"critical_missing_parts": critical_missing,
"existing_orders_value": existing_orders_value,
"new_orders_value": new_orders_value,
}
if eq.is_included:
if month_index < len(eq.overhaul_costs) and month_index < len(
eq.procurement_costs
):
month_result["overhaul_cost"] += float(eq.overhaul_costs[month_index])
month_result["procurement_cost"] += float(eq.procurement_costs[month_index])
month_result["corrective_cost"] = (
cumulative_plant_failures * avg_failure_cost
) / (month_index + 1)
month_result["sparepart_summary"].update(
{
"total_procurement_cost": month_result["procurement_cost"],
"equipment_requiring_procurement": equipment_requiring_procurement,
"critical_shortages": critical_shortages,
"existing_orders_value": total_existing_orders_value,
"new_orders_required": len(
[
eq
for eq in all_equipment
if month_index < len(eq.procurement_details)
and eq.procurement_details[month_index]
and eq.procurement_details[month_index].get("procurement_needed")
]
),
"urgent_procurements": urgent_procurements,
}
)
month_result["total_cost"] = (
month_result["corrective_cost"]
+ month_result["overhaul_cost"]
+ month_result["procurement_cost"]
)
calculation_results.append(month_result)
optimum_day = np.argmin([month["total_cost"] for month in calculation_results])
scope_calculation.optimum_oh_day = int(optimum_day)
fleet_statistics["equipment_with_sparepart_constraints"] = len(
[
eq
for eq in all_equipment
if any(
detail and detail.get("procurement_needed")
for detail in eq.procurement_details
if detail
)
]
)
fleet_statistics["total_procurement_items"] = sum(
[
len(detail.get("procurement_needed", []))
for eq in all_equipment
for detail in eq.procurement_details
if detail and isinstance(detail, dict)
]
)
analysis_metadata = {
"planned_month": (scope.start_date.year - prev_oh_scope.end_date.year) * 12
+ (scope.start_date.month - prev_oh_scope.end_date.month)
if prev_oh_scope and scope
else 0,
"total_fleet_procurement_cost": sum(
[
eq.procurement_costs[int(scope_calculation.optimum_oh_day)]
for eq in all_equipment
if eq.procurement_costs
]
),
"last_oh_date": prev_oh_scope.end_date.isoformat() if prev_oh_scope else None,
"next_oh_date": scope.start_date.isoformat() if scope else None,
"optimal_stat": None,
}
calc_results_read = [CalculationResultsRead(**r) for r in calculation_results]
optimal_analysis = _analyze_optimal_timing(
calc_results_read, scope_calculation.optimum_oh_day, prev_oh_scope, scope
)
scope_calculation.plant_results = calculation_results
scope_calculation.fleet_statistics = fleet_statistics
scope_calculation.analysis_metadata = analysis_metadata
scope_calculation.optimum_analysis = optimal_analysis
await db_session.commit()
return {"id": calculation.id, "optimum": optimal_analysis}

@ -0,0 +1,233 @@
from typing import Annotated, List, Optional, Union
from fastapi import APIRouter
from fastapi.params import Query
from src.auth.service import CurrentUser, InternalKey, Token
from src.config import DEFAULT_TC_ID
from src.database.core import DbSession, CollectorDbSession
from src.models import StandardResponse
from src.optimum_time_constraint.flows import (
create_calculation,
get_create_calculation_parameters,
get_or_create_scope_equipment_calculation,
recalculate_calculation,
)
from src.calculation_time_constrains.schema import (
CalculationResultsRead,
CalculationSelectedEquipmentUpdate,
CalculationTimeConstrainsCreate,
CalculationTimeConstrainsParametersCreate,
CalculationTimeConstrainsParametersRead,
CalculationTimeConstrainsParametersRetrive,
CalculationTimeConstrainsRead,
CreateCalculationQuery,
EquipmentResult,
CalculationTimeConstrainsReadNoResult,
)
from src.optimum_time_constraint.service import (
bulk_update_equipment,
get_calculation_result,
get_calculation_result_by_day,
get_calculation_by_assetnum,
get_all_calculations,
refresh_spareparts_service,
)
router = APIRouter()
get_calculation = APIRouter()
@router.post(
"", response_model=StandardResponse[Union[dict, CalculationTimeConstrainsRead]]
)
async def create_calculation_time_constrains(
token: Token,
db_session: DbSession,
collector_db_session: CollectorDbSession,
current_user: CurrentUser,
calculation_time_constrains_in: CalculationTimeConstrainsParametersCreate,
params: Annotated[CreateCalculationQuery, Query()],
):
"""Save calculation time constrains Here"""
scope_calculation_id = params.scope_calculation_id
simulation_id = params.simulation_id
if scope_calculation_id:
results = await get_or_create_scope_equipment_calculation(
db_session=db_session,
scope_calculation_id=scope_calculation_id,
calculation_time_constrains_in=calculation_time_constrains_in,
)
else:
results = await create_calculation(
token=token,
db_session=db_session,
collector_db_session=collector_db_session,
calculation_time_constrains_in=calculation_time_constrains_in,
created_by=current_user.name,
simulation_id=simulation_id,
)
return StandardResponse(data=results, message="Data created successfully")
@router.get(
"", response_model=StandardResponse[List[CalculationTimeConstrainsReadNoResult]]
)
async def get_all_simulation_calculations(
db_session: DbSession,
token: Token,
current_user: CurrentUser,
):
"""Get all calculation time constrains Here"""
calculations = await get_all_calculations(db_session=db_session)
return StandardResponse(
data=calculations,
message="Data retrieved successfully",
)
@router.get(
"/parameters",
response_model=StandardResponse[
Union[
CalculationTimeConstrainsParametersRetrive,
CalculationTimeConstrainsParametersRead,
]
],
)
async def get_calculation_parameters(
db_session: DbSession, calculation_id: Optional[str] = Query(default=None)
):
"""Get all calculation parameter."""
parameters = await get_create_calculation_parameters(
db_session=db_session, calculation_id=calculation_id
)
return StandardResponse(
data=parameters,
message="Data retrieved successfully",
)
@get_calculation.get(
"/{calculation_id}", response_model=StandardResponse[CalculationTimeConstrainsRead]
)
async def get_calculation_results(
db_session: DbSession,
calculation_id,
token: InternalKey,
include_risk_cost: int = Query(1, alias="risk_cost"),
):
if calculation_id == "default":
calculation_id = DEFAULT_TC_ID
results = await get_calculation_result(
db_session=db_session,
calculation_id=calculation_id,
token=token,
include_risk_cost=include_risk_cost,
)
return StandardResponse(
data=results,
message="Data retrieved successfully",
)
@router.get(
"/{calculation_id}/{assetnum}", response_model=StandardResponse[EquipmentResult]
)
async def get_calculation_per_equipment(db_session: DbSession, calculation_id, assetnum):
results = await get_calculation_by_assetnum(
db_session=db_session, assetnum=assetnum, calculation_id=calculation_id
)
return StandardResponse(
data=results,
message="Data retrieved successfully",
)
@router.post(
"/{calculation_id}/simulation",
response_model=StandardResponse[CalculationResultsRead],
)
async def get_simulation_result(
db_session: DbSession,
calculation_id,
calculation_simuation_in: CalculationTimeConstrainsCreate,
):
simulation_result = await get_calculation_result_by_day(
db_session=db_session,
calculation_id=calculation_id,
simulation_day=calculation_simuation_in.intervalDays,
)
return StandardResponse(
data=simulation_result, message="Data retrieved successfully"
)
@router.post("/update/{calculation_id}", response_model=StandardResponse[List[str]])
async def update_selected_equipment(
db_session: DbSession,
calculation_id,
calculation_time_constrains_in: List[CalculationSelectedEquipmentUpdate],
):
if calculation_id == "default":
calculation_id = "3b9a73a2-bde6-418c-9e2f-19046f501a05"
results = await bulk_update_equipment(
db=db_session,
selected_equipments=calculation_time_constrains_in,
calculation_data_id=calculation_id,
)
return StandardResponse(
data=results,
message="Data retrieved successfully",
)
@router.post("/{calculation_id}/refresh-spareparts", response_model=StandardResponse[dict])
async def refresh_spareparts(
db_session: DbSession,
collector_db_session: CollectorDbSession,
calculation_id: str,
current_user: CurrentUser,
):
"""Refresh sparepart availability for an existing calculation"""
await refresh_spareparts_service(
db_session=db_session,
collector_db_session=collector_db_session,
calculation_id=calculation_id,
)
return StandardResponse(data={}, message="Spareparts refreshed successfully")
@router.post("/{calculation_id}/recalculate", response_model=StandardResponse[CalculationTimeConstrainsRead])
async def recalculate_calculation_api(
db_session: DbSession,
collector_db_session: CollectorDbSession,
calculation_id: str,
token: Token,
current_user: CurrentUser,
):
"""Recalculate an existing simulation with fresh data"""
results = await recalculate_calculation(
token=token,
db_session=db_session,
collector_db_session=collector_db_session,
calculation_id=calculation_id,
)
return StandardResponse(data=results, message="Calculation updated with fresh data")

@ -0,0 +1,252 @@
import datetime
from typing import Coroutine, List, Optional, Tuple, Dict
from uuid import UUID
from fastapi import HTTPException, status
from sqlalchemy import and_, case, func, select, update
from sqlalchemy.orm import joinedload, selectinload
from src.database.core import DbSession, CollectorDbSession
from src.workorder.model import MasterWorkOrder
from src.calculation_time_constrains.model import (CalculationData, CalculationEquipmentResult, CalculationResult)
from src.calculation_time_constrains.schema import (
CalculationResultsRead,
CalculationSelectedEquipmentUpdate,
CalculationTimeConstrainsParametersCreate,
CalculationTimeConstrainsRead
)
from src.overhaul_scope.service import get as get_scope, get_prev_oh
async def create_param_and_data(
*,
db_session: DbSession,
calculation_param_in: CalculationTimeConstrainsParametersCreate,
created_by: str,
parameter_id: Optional[UUID] = None,
):
"""Creates a new document."""
if calculation_param_in.ohSessionId is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="overhaul_session_id is required",
)
calculationData = await CalculationData.create_with_param(
db=db_session,
overhaul_session_id=calculation_param_in.ohSessionId,
avg_failure_cost=calculation_param_in.costPerFailure,
overhaul_cost=calculation_param_in.overhaulCost,
created_by=created_by,
params_id=parameter_id,
)
return calculationData
async def get_calculation_result(db_session: DbSession, calculation_id: str, token, include_risk_cost):
"""
Get calculation results from DB, returning pre-calculated plant and equipment results.
"""
try:
# Get calculation data with equipment results
calculation_query = await db_session.execute(
select(CalculationData)
.options(selectinload(CalculationData.equipment_results))
.where(CalculationData.id == calculation_id)
)
scope_calculation = calculation_query.scalar_one_or_none()
if not scope_calculation:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Calculation with id {calculation_id} does not exist.",
)
scope_overhaul = await get_scope(
db_session=db_session,
overhaul_session_id=scope_calculation.overhaul_session_id
)
if not scope_overhaul:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Overhaul scope for session {scope_calculation.overhaul_session_id} does not exist.",
)
# Parse pre-calculated plant results
plant_results_raw = scope_calculation.plant_results or []
calculation_results = [CalculationResultsRead(**r) for r in plant_results_raw]
# Return comprehensive result
return CalculationTimeConstrainsRead(
id=scope_calculation.id,
reference=scope_calculation.overhaul_session_id,
scope=scope_overhaul.maintenance_type.name,
results=calculation_results,
optimum_oh=scope_calculation.optimum_oh_day,
optimum_oh_month=scope_calculation.optimum_oh_day + 1 if scope_calculation.optimum_oh_day is not None else None,
equipment_results=scope_calculation.equipment_results,
fleet_statistics=scope_calculation.fleet_statistics or {},
optimal_analysis=scope_calculation.optimum_analysis or {},
analysis_metadata=scope_calculation.analysis_metadata or {}
)
except HTTPException:
raise
except Exception as e:
import logging
logger = logging.getLogger(__name__)
logger.error(f"Error in get_calculation_result for calculation_id {calculation_id}: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Internal error processing calculation results: {str(e)}",
)
async def get_calculation_data_by_id(db_session: DbSession, calculation_id) -> CalculationData:
stmt = (
select(CalculationData)
.filter(CalculationData.id == calculation_id)
.options(
joinedload(CalculationData.equipment_results),
joinedload(CalculationData.parameter),
)
)
result = await db_session.execute(stmt)
return result.unique().scalar()
async def get_all_calculations(db_session: DbSession) -> List[CalculationData]:
stmt = (
select(CalculationData)
.options(selectinload(CalculationData.session))
.where(
CalculationData.optimum_oh_day.isnot(None),
CalculationData.max_interval.isnot(None),
CalculationData.optimum_analysis.isnot(None),
)
.order_by(CalculationData.created_at.desc())
)
result = await db_session.execute(stmt)
return result.scalars().all()
async def get_calculation_by_assetnum(*, db_session: DbSession, assetnum: str, calculation_id: str):
stmt = (
select(CalculationEquipmentResult)
.where(CalculationEquipmentResult.assetnum == assetnum)
.where(CalculationEquipmentResult.calculation_data_id == calculation_id)
)
result = await db_session.execute(stmt)
return result.scalar()
async def get_calculation_by_reference_and_parameter(
*, db_session: DbSession, calculation_reference_id, parameter_id
):
stmt = select(CalculationData).filter(
and_(
CalculationData.reference_id == calculation_reference_id,
CalculationData.parameter_id == parameter_id,
)
)
result = await db_session.execute(stmt)
return result.scalar()
async def get_calculation_result_by_day(*, db_session: DbSession, calculation_id, simulation_day):
stmt = select(CalculationData).filter(CalculationData.id == calculation_id)
result = await db_session.execute(stmt)
calculation_data = result.scalar_one_or_none()
if not calculation_data or not calculation_data.plant_results:
return None
for res in calculation_data.plant_results:
if res.get("day") == simulation_day:
return res
return None
async def get_avg_cost_by_asset(*, db_session: DbSession, assetnum: str):
stmt = select(func.avg(MasterWorkOrder.total_cost_max).label("average_cost")).where(
MasterWorkOrder.assetnum == assetnum
)
result = await db_session.execute(stmt)
return result.scalar_one_or_none()
async def bulk_update_equipment(
*,
db: DbSession,
selected_equipments: List[CalculationSelectedEquipmentUpdate],
calculation_data_id: UUID,
):
case_mappings = {asset.location_tag: asset.is_included for asset in selected_equipments}
location_tags = list(case_mappings.keys())
when_clauses = [
(CalculationEquipmentResult.location_tag == location_tag, is_included)
for location_tag, is_included in case_mappings.items()
]
stmt = (
update(CalculationEquipmentResult)
.where(CalculationEquipmentResult.calculation_data_id == calculation_data_id)
.where(CalculationEquipmentResult.location_tag.in_(location_tags))
.values(
{
"is_included": case(*when_clauses),
"is_initial": False
}
)
)
await db.execute(stmt)
await db.commit()
return location_tags
async def refresh_spareparts_service(db_session: DbSession, collector_db_session: CollectorDbSession, calculation_id: str):
stmt = select(CalculationData).where(CalculationData.id == calculation_id).options(
joinedload(CalculationData.equipment_results)
)
result = await db_session.execute(stmt)
calculation = result.scalar_one_or_none()
if not calculation:
raise HTTPException(status_code=404, detail="Calculation not found")
scope = await get_scope(db_session=db_session, overhaul_session_id=calculation.overhaul_session_id)
prev_oh_scope = await get_prev_oh(db_session=db_session, overhaul_session=scope)
last_oh_date = prev_oh_scope.end_date
next_oh_date = scope.start_date
time_window_months = ((next_oh_date.year - last_oh_date.year) * 12 +
(next_oh_date.month - last_oh_date.month) + 6)
from src.sparepart.service import load_sparepart_data_from_db
sparepart_manager = await load_sparepart_data_from_db(
scope=scope,
prev_oh_scope=prev_oh_scope,
db_session=collector_db_session,
app_db_session=db_session,
analysis_window_months=time_window_months
)
for eq in calculation.equipment_results:
procurement_details = []
procurement_costs = []
for month_index in range(time_window_months):
status = sparepart_manager.check_sparepart_availability(eq.location_tag, month_index)
procurement_details.append(status)
if not status['available']:
procurement_costs.append(status['total_procurement_cost'])
else:
procurement_costs.append(0.0)
eq.procurement_details = procurement_details
eq.procurement_costs = procurement_costs
await db_session.commit()

@ -0,0 +1,140 @@
import asyncio
from datetime import timedelta
import httpx
from temporalio import activity, workflow
from src.config import RBD_SERVICE_API
from src.database.core import get_main_session, get_collector_session
from src.optimum_time_constraint.service import (
create_param_and_data,
get_calculation_data_by_id
)
from src.optimum_time_constraint.optimizer import run_simulation_with_spareparts
from src.calculation_time_constrains.schema import CalculationTimeConstrainsParametersCreate
from src.overhaul_scope.service import get as get_scope, get_prev_oh
from src.calculation_time_constrains.utils import get_months_between
from src.calculation_target_reliability.utils import wait_for_workflow
@activity.defn
async def create_optimum_oh_calculation(args: dict) -> str:
token = args["token"]
calc_in_dict = args["calculation_in"]
created_by = args["created_by"]
calc_in = CalculationTimeConstrainsParametersCreate(**calc_in_dict)
async with get_main_session() as db_session:
calc_data = await create_param_and_data(
db_session=db_session,
calculation_param_in=calc_in,
created_by=created_by
)
return str(calc_data.id)
@activity.defn
async def request_rbd_simulation(args: dict) -> str:
calc_id = args["calc_id"]
token = args["token"]
async with get_main_session() as db_session:
calc_data = await get_calculation_data_by_id(db_session=db_session, calculation_id=calc_id)
scope = await get_scope(db_session=db_session, overhaul_session_id=calc_data.overhaul_session_id)
prev_oh_scope = await get_prev_oh(db_session=db_session, overhaul_session=scope)
time_window_months = get_months_between(prev_oh_scope.end_date, scope.start_date) + 6
sim_duration_hours = time_window_months * 720
sim_input = {
"SchematicName": "- TJB - Unit 3 -",
"SimulationName": f"OptimumOH_Calc_{calc_id}",
"SimDuration": sim_duration_hours,
"DurationUnit": "UHour",
"OffSet": 0,
"SimSeed": 99,
"SimNumRun": 1,
"OverhaulInterval": 0,
"MaintenanceOutages": 0,
"IsDefault": False,
"OverhaulDuration": 0,
"AhmJobId": None
}
async with httpx.AsyncClient(timeout=30.0) as client:
resp = await client.post(
f"{RBD_SERVICE_API}/aeros/simulation/run",
json=sim_input,
headers={"Authorization": f"Bearer {token}"}
)
resp.raise_for_status()
sim_id = resp.json()["data"]
calc_data.rbd_simulation_id = sim_id
return str(sim_id)
@activity.defn
async def wait_for_rbd_simulation(sim_id: str) -> str:
await wait_for_workflow(sim_id)
return sim_id
@activity.defn
async def run_optimum_oh_calculation(args: dict) -> dict:
calc_id = args["calc_id"]
token = args["token"]
sim_id = args["sim_id"]
async with get_main_session() as db_session:
async with get_collector_session() as collector_db:
calc_data = await get_calculation_data_by_id(db_session=db_session, calculation_id=calc_id)
results = await run_simulation_with_spareparts(
db_session=db_session,
calculation=calc_data,
token=token,
collector_db_session=collector_db,
simulation_id=sim_id
)
return {"id": str(results["id"]), "optimum": results["optimum"]}
@workflow.defn
class OptimumOHCalculationWorkflow:
def __init__(self):
self.done = False
self.result = None
@workflow.signal
def notify_done(self, result: dict):
self.done = True
self.result = result
@workflow.run
async def run(self, args: dict) -> dict:
calc_id = await workflow.execute_activity(
create_optimum_oh_calculation,
args,
start_to_close_timeout=timedelta(minutes=1)
)
args["calc_id"] = calc_id
sim_id = await workflow.execute_activity(
request_rbd_simulation,
args,
start_to_close_timeout=timedelta(minutes=2)
)
args["sim_id"] = sim_id
# await workflow.execute_activity(
# wait_for_rbd_simulation,
# sim_id,
# start_to_close_timeout=timedelta(hours=2)
# )
await workflow.wait_condition(lambda: self.done)
result = await workflow.execute_activity(
run_optimum_oh_calculation,
args,
start_to_close_timeout=timedelta(minutes=30)
)
return result

@ -437,13 +437,19 @@ class SparepartManager:
for record in self.procurement_records: for record in self.procurement_records:
if record.status in [ProcurementStatus.ORDERED, ProcurementStatus.PLANNED]: if record.status in [ProcurementStatus.ORDERED, ProcurementStatus.PLANNED]:
# Skip records with no expected delivery date (e.g., still PR stage) # Prioritize vendor delivery date, fall back to estimated arrival
if not record.expected_delivery_date: delivery_date = record.po_vendor_delivery_date or record.expected_delivery_date
if not delivery_date:
continue continue
# Ensure we have a date object
if isinstance(delivery_date, datetime):
delivery_date = delivery_date.date()
months_from_start = ( months_from_start = (
(record.expected_delivery_date.year - self.analysis_start_date.year) * 12 + (delivery_date.year - self.analysis_start_date.year) * 12 +
(record.expected_delivery_date.month - self.analysis_start_date.month) (delivery_date.month - self.analysis_start_date.month)
) )
if 0 <= months_from_start < self.analysis_window_months: if 0 <= months_from_start < self.analysis_window_months:
@ -506,6 +512,7 @@ class SparepartManager:
requirements = self.equipment_requirements[equipment_tag] requirements = self.equipment_requirements[equipment_tag]
missing_parts = [] missing_parts = []
all_parts = []
procurement_needed = [] procurement_needed = []
total_procurement_cost = 0 total_procurement_cost = 0
@ -622,6 +629,20 @@ class SparepartManager:
procurement_needed.append(new_order) procurement_needed.append(new_order)
pr_po_summary['required_new_orders'].append(new_order) pr_po_summary['required_new_orders'].append(new_order)
pr_po_summary['total_new_orders_value'] += procurement_cost pr_po_summary['total_new_orders_value'] += procurement_cost
# Track all parts for frontend visibility
all_parts.append({
'sparepart_id': sparepart_id,
'sparepart_name': sparepart_name,
'remark': sparepart_remark,
'required': needed_quantity,
'current_stock': current_stock,
'ordered_quantity': total_ordered_quantity,
'effective_available': effective_stock,
'shortage': shortage if effective_stock < needed_quantity else 0,
'criticality': requirement.criticality or "warning",
'is_available': effective_stock >= needed_quantity
})
# Check for critical parts # Check for critical parts
critical_missing = [p for p in missing_parts if p['criticality'] == 'critical'] critical_missing = [p for p in missing_parts if p['criticality'] == 'critical']
@ -636,6 +657,7 @@ class SparepartManager:
'total_missing_parts': len(missing_parts), 'total_missing_parts': len(missing_parts),
'critical_missing_parts': len(critical_missing), 'critical_missing_parts': len(critical_missing),
'missing_parts': missing_parts, 'missing_parts': missing_parts,
'all_parts': all_parts,
'procurement_needed': procurement_needed, 'procurement_needed': procurement_needed,
'total_procurement_cost': total_procurement_cost, 'total_procurement_cost': total_procurement_cost,
'can_proceed_with_delays': len(critical_missing) == 0, 'can_proceed_with_delays': len(critical_missing) == 0,
@ -914,22 +936,40 @@ class SparepartManager:
# Integration functions for database operations # Integration functions for database operations
async def load_sparepart_data_from_db(scope, prev_oh_scope, db_session, app_db_session, analysis_window_months = None) -> SparepartManager: async def load_sparepart_data_from_db(scope, prev_oh_scope, db_session, app_db_session, analysis_window_months = None) -> SparepartManager:
"""Load sparepart data from database""" """
# You'll need to implement these queries based on your database schema Load sparepart data from database dynamically based on the overhaul scope.
# Get scope dates for analysis window """
# scope = await get_scope(db_session=db_session, overhaul_session_id=overhaul_session_id)
# prev_oh_scope = await get_prev_oh(db_session=db_session, overhaul_session=scope)
analysis_start_date = prev_oh_scope.end_date analysis_start_date = prev_oh_scope.end_date
analysis_window_months = int(((scope.start_date - prev_oh_scope.end_date).days / 30) * 1.2) if not analysis_window_months else analysis_window_months if not analysis_window_months:
# Default to the interval between OHs + 20% buffer
analysis_window_months = int(((scope.start_date - prev_oh_scope.end_date).days / 30) * 1.2)
# Ensure at least 12 months for small intervals
analysis_window_months = max(12, analysis_window_months)
sparepart_manager = SparepartManager(analysis_start_date, analysis_window_months) sparepart_manager = SparepartManager(analysis_start_date, analysis_window_months)
start_date = prev_oh_scope.end_date
end_date = scope.start_date
# Load sparepart stocks # Extract WO parents for the current OH scope to filter materials
# Example query - adjust based on your schema wo_parents = []
query = text("""SELECT if scope and scope.wo_parent:
if isinstance(scope.wo_parent, list):
wo_parents = scope.wo_parent
elif isinstance(scope.wo_parent, str):
wo_parents = [scope.wo_parent]
if not wo_parents:
log.warning(f"No wo_parent IDs found for scope {scope.id}. Sparepart requirements might be empty.")
# Optionally, we could fallback to all OH workorders if needed, but safer to warn.
# 1. Load Sparepart Remarks from App DB
sparepart_remark_results = (await app_db_session.execute(
select(SparepartRemark)
)).scalars().all()
sparepart_remark_dict = {item.itemnum: item.remark for item in sparepart_remark_results}
# 2. Load Current Sparepart Stocks from Maximo
# We query materials used in OHs to get a relevant set of spareparts
stocks_query = text("""
SELECT
wm.inv_itemnum AS itemnum, wm.inv_itemnum AS itemnum,
wm.inv_itemsetid AS itemsetid, wm.inv_itemsetid AS itemsetid,
wm.inv_location AS location, wm.inv_location AS location,
@ -942,32 +982,21 @@ async def load_sparepart_data_from_db(scope, prev_oh_scope, db_session, app_db_s
WHERE wm.inv_itemnum IS NOT NULL WHERE wm.inv_itemnum IS NOT NULL
GROUP BY wm.inv_itemnum, wm.inv_itemsetid, wm.inv_location, mspl.description GROUP BY wm.inv_itemnum, wm.inv_itemsetid, wm.inv_location, mspl.description
""") """)
log.info("Fetch sparepart")
sparepart_stocks_query = await db_session.execute(query)
sparepart_remark = (await app_db_session.execute( log.info(f"Fetching current stocks for scope {scope.id}")
select(SparepartRemark) sparepart_stocks_results = await db_session.execute(stocks_query)
)).scalars().all()
sparepart_remark_dict = {item.itemnum: item.remark for item in sparepart_remark}
for stock_record in sparepart_stocks_query: for row in sparepart_stocks_results:
stock = SparepartStock( stock = SparepartStock(
sparepart_id=stock_record.itemnum, sparepart_id=row.itemnum,
remark=sparepart_remark_dict.get(stock_record.itemnum), sparepart_name=row.description,
sparepart_name=stock_record.description, current_stock=int(row.curbaltotal or 0),
current_stock=stock_record.curbaltotal, unit_cost=float(row.avgcost or 0),
unit_cost=stock_record.avgcost, location=row.location,
location=stock_record.location or "Unknown", remark=sparepart_remark_dict.get(row.itemnum, "")
) )
sparepart_manager.add_sparepart_stock(stock) sparepart_manager.add_sparepart_stock(stock)
# parent_nums = []
# query = text("""
# WITH target_wo AS (
# -- Work orders from the given parent(s)
# SELECT
# wonum, # wonum,
# xx_parent, # xx_parent,
# location_tag AS asset_location # location_tag AS asset_location
@ -1116,22 +1145,15 @@ async def load_sparepart_data_from_db(scope, prev_oh_scope, db_session, app_db_s
# for equipment_tag, requirements in equipment_requirements.items(): # for equipment_tag, requirements in equipment_requirements.items():
# sparepart_manager.add_equipment_requirements(equipment_tag, requirements) # sparepart_manager.add_equipment_requirements(equipment_tag, requirements)
# Load equipment sparepart requirements # 3. Load Equipment Sparepart Requirements
# You'll need to create this table/relationship # We analyze materials planned for the current OH session
query = text("""WITH oh_workorders AS ( req_query = text("""
-- First, get all OH work orders WITH current_oh as (
SELECT DISTINCT
wonum,
asset_location
FROM public.wo_maximo ma
WHERE worktype = 'OH' AND asset_location IS NOT NULL and asset_unit IN ('3', '00') AND EXTRACT(YEAR FROM reportdate) >= 2019
),
current_oh as (
SELECT DISTINCT wonum, asset_location, asset_unit SELECT DISTINCT wonum, asset_location, asset_unit
FROM public.wo_maximo ma FROM public.wo_maximo ma
WHERE ma.xx_parent IN ('155026', '155027', '155029', '155030') WHERE ma.xx_parent = ANY(:wo_parents)
), ),
sparepart_usage AS ( sparepart_usage AS (
SELECT SELECT
oh.asset_location, oh.asset_location,
mwm.itemnum, mwm.itemnum,
@ -1141,146 +1163,95 @@ async def load_sparepart_data_from_db(scope, prev_oh_scope, db_session, app_db_s
FROM current_oh oh FROM current_oh oh
INNER JOIN public.wo_maximo_material mwm INNER JOIN public.wo_maximo_material mwm
ON oh.wonum = mwm.wonum ON oh.wonum = mwm.wonum
), ),
location_sparepart_stats AS ( location_sparepart_stats AS (
-- Calculate average usage per sparepart per location SELECT
SELECT asset_location,
asset_location, itemnum,
itemnum, COUNT(DISTINCT wonum) as total_wo_count,
COUNT(DISTINCT wonum) as total_wo_count, SUM(itemqty) as total_qty_used,
SUM(itemqty) as total_qty_used, AVG(itemqty) as avg_qty_per_wo
AVG(itemqty) as avg_qty_per_wo, FROM sparepart_usage
MIN(itemqty) as min_qty_used, GROUP BY asset_location, itemnum
MAX(itemqty) as max_qty_used ),
FROM sparepart_usage pr_po_combined AS (
GROUP BY asset_location, itemnum SELECT
), mspl.item_num,
pr_po_combined AS ( mspl.num,
-- Combine PR and PO data by num to get issue_date and delivery dates mspl.unit_cost,
SELECT mspl.qty_ordered,
mspl.item_num, MAX(CASE WHEN mspo.type = 'PR' THEN mspo.issue_date END) as issue_date,
mspl.num, MAX(CASE WHEN mspo.type = 'PO' THEN mspo.vendeliverydate END) as vendeliverydate,
mspl.unit_cost, MAX(CASE WHEN mspo.type = 'PO' THEN mspo.estimated_arrival_date END) as estimated_arrival_date
mspl.qty_ordered, FROM public.maximo_sparepart_pr_po_line mspl
MAX(CASE WHEN mspo.type = 'PR' THEN mspo.issue_date END) as issue_date, INNER JOIN public.maximo_sparepart_pr_po mspo
MAX(CASE WHEN mspo.type = 'PO' THEN mspo.vendeliverydate END) as vendeliverydate, ON mspl.num = mspo.num
MAX(CASE WHEN mspo.type = 'PO' THEN mspo.estimated_arrival_date END) as estimated_arrival_date WHERE mspo.type IN ('PR', 'PO')
FROM public.maximo_sparepart_pr_po_line mspl GROUP BY mspl.item_num, mspl.num, mspl.unit_cost, mspl.qty_ordered
INNER JOIN public.maximo_sparepart_pr_po mspo ),
ON mspl.num = mspo.num leadtime_stats AS (
WHERE mspo.type IN ('PR', 'PO') SELECT
GROUP BY mspl.item_num, mspl.num, mspl.unit_cost, mspl.qty_ordered item_num,
), ROUND(CAST(AVG(
leadtime_stats AS ( EXTRACT(EPOCH FROM (
-- Calculate lead time statistics for each item COALESCE(vendeliverydate, estimated_arrival_date) - issue_date
-- Prioritize vendeliverydate over estimated_arrival_date )) / 86400 / 30.44
SELECT ) AS NUMERIC), 1) as avg_leadtime_months
item_num, FROM pr_po_combined
ROUND(CAST(AVG( WHERE issue_date IS NOT NULL
EXTRACT(EPOCH FROM ( AND COALESCE(vendeliverydate, estimated_arrival_date) IS NOT NULL
COALESCE(vendeliverydate, estimated_arrival_date) - issue_date AND COALESCE(vendeliverydate, estimated_arrival_date) > issue_date
)) / 86400 / 30.44 GROUP BY item_num
) AS NUMERIC), 1) as avg_leadtime_months, ),
ROUND(CAST(MIN( cost_stats AS (
EXTRACT(EPOCH FROM ( SELECT
COALESCE(vendeliverydate, estimated_arrival_date) - issue_date item_num,
)) / 86400 / 30.44 ROUND(CAST(AVG(unit_cost) AS NUMERIC), 2) as avg_unit_cost
) AS NUMERIC), 1) as min_leadtime_months, FROM pr_po_combined
ROUND(CAST(MAX( WHERE unit_cost IS NOT NULL AND unit_cost > 0
EXTRACT(EPOCH FROM ( GROUP BY item_num
COALESCE(vendeliverydate, estimated_arrival_date) - issue_date ),
)) / 86400 / 30.44 item_descriptions AS (
) AS NUMERIC), 1) as max_leadtime_months, SELECT DISTINCT
COUNT(*) as leadtime_sample_size, item_num,
-- Additional metrics for transparency FIRST_VALUE(description) OVER (
COUNT(CASE WHEN vendeliverydate IS NOT NULL THEN 1 END) as vendelivery_count, PARTITION BY item_num
COUNT(CASE WHEN vendeliverydate IS NULL AND estimated_arrival_date IS NOT NULL THEN 1 END) as estimated_only_count ORDER BY created_at DESC NULLS LAST
FROM pr_po_combined ) as description
WHERE issue_date IS NOT NULL FROM public.maximo_sparepart_pr_po_line
AND COALESCE(vendeliverydate, estimated_arrival_date) IS NOT NULL WHERE description IS NOT NULL
AND COALESCE(vendeliverydate, estimated_arrival_date) > issue_date )
GROUP BY item_num
),
cost_stats AS (
-- Calculate cost statistics for each item
SELECT SELECT
item_num, lss.asset_location,
ROUND(CAST(AVG(unit_cost) AS NUMERIC), 2) as avg_unit_cost, lss.itemnum,
ROUND(CAST(MIN(unit_cost) AS NUMERIC), 2) as min_unit_cost, COALESCE(id.description, 'No description available') as item_description,
ROUND(CAST(MAX(unit_cost) AS NUMERIC), 2) as max_unit_cost, lss.avg_qty_per_wo,
COUNT(*) as cost_sample_size, iin.inv_avgcost,
-- Total value statistics COALESCE(lt.avg_leadtime_months, 0) as avg_leadtime_months,
ROUND(CAST(AVG(unit_cost * qty_ordered) AS NUMERIC), 2) as avg_order_value, COALESCE(cs.avg_unit_cost, 0) as avg_unit_cost
ROUND(CAST(SUM(unit_cost * qty_ordered) AS NUMERIC), 2) as total_value_ordered FROM location_sparepart_stats lss
FROM pr_po_combined LEFT JOIN item_descriptions id ON lss.itemnum = id.item_num
WHERE unit_cost IS NOT NULL AND unit_cost > 0 LEFT JOIN leadtime_stats lt ON lss.itemnum = lt.item_num
GROUP BY item_num LEFT JOIN cost_stats cs ON lss.itemnum = cs.item_num
), LEFT JOIN (SELECT DISTINCT itemnum, inv_avgcost FROM sparepart_usage) iin ON lss.itemnum = iin.itemnum
item_descriptions AS ( ORDER BY lss.asset_location, lss.itemnum;
-- Get unique descriptions for each item (optimized) """)
SELECT DISTINCT
item_num,
FIRST_VALUE(description) OVER (
PARTITION BY item_num
ORDER BY created_at DESC NULLS LAST
) as description
FROM public.maximo_sparepart_pr_po_line
WHERE description IS NOT NULL
),
item_inventory as (
SELECT
itemnum,
avgcost
FROM public.maximo_inventory
)
SELECT
lss.asset_location,
lss.itemnum,
COALESCE(id.description, 'No description available') as item_description,
lss.total_wo_count,
lss.total_qty_used,
ROUND(CAST(lss.avg_qty_per_wo AS NUMERIC), 2) as avg_qty_per_wo,
lss.min_qty_used,
lss.max_qty_used,
iin.inv_avgcost,
-- Lead time metrics
COALESCE(lt.avg_leadtime_months, 0) as avg_leadtime_months,
COALESCE(lt.min_leadtime_months, 0) as min_leadtime_months,
COALESCE(lt.max_leadtime_months, 0) as max_leadtime_months,
COALESCE(lt.leadtime_sample_size, 0) as leadtime_sample_size,
COALESCE(lt.vendelivery_count, 0) as vendelivery_count,
COALESCE(lt.estimated_only_count, 0) as estimated_only_count,
-- Cost metrics
COALESCE(cs.avg_unit_cost, 0) as avg_unit_cost,
COALESCE(cs.min_unit_cost, 0) as min_unit_cost,
COALESCE(cs.max_unit_cost, 0) as max_unit_cost,
COALESCE(cs.cost_sample_size, 0) as cost_sample_size,
COALESCE(cs.avg_order_value, 0) as avg_order_value,
COALESCE(cs.total_value_ordered, 0) as total_value_ordered,
-- Estimated total cost for average OH
ROUND(CAST(COALESCE(lss.avg_qty_per_wo * cs.avg_unit_cost, 0) AS NUMERIC), 2) as estimated_cost_per_oh
FROM location_sparepart_stats lss
LEFT JOIN item_descriptions id ON lss.itemnum = id.item_num
LEFT JOIN leadtime_stats lt ON lss.itemnum = lt.item_num
LEFT JOIN cost_stats cs ON lss.itemnum = cs.item_num
LEFT JOIN sparepart_usage iin ON lss.itemnum = iin.itemnum
ORDER BY lss.asset_location, lss.itemnum;""")
equipment_requirements_query = await db_session.execute(query) log.info(f"Loading requirements for {len(wo_parents)} WO parents")
req_results = await db_session.execute(req_query, {"wo_parents": wo_parents})
equipment_requirements = defaultdict(list) equipment_requirements = defaultdict(list)
for req_record in equipment_requirements_query: for row in req_results:
requirement = SparepartRequirement( requirement = SparepartRequirement(
sparepart_id=req_record.itemnum, sparepart_id=row.itemnum,
quantity_required=float(req_record.avg_qty_per_wo), quantity_required=float(row.avg_qty_per_wo or 0),
lead_time=float(req_record.avg_leadtime_months), lead_time=int(row.avg_leadtime_months or 0),
sparepart_name=req_record.item_description, sparepart_name=row.item_description,
unit_cost=float(req_record.avg_unit_cost), unit_cost=float(row.avg_unit_cost or 0),
avg_cost=float(req_record.inv_avgcost or 0), avg_cost=float(row.inv_avgcost or 0),
remark=sparepart_remark_dict.get(req_record.itemnum, "") remark=sparepart_remark_dict.get(row.itemnum, "")
) )
equipment_requirements[req_record.asset_location].append(requirement) equipment_requirements[row.asset_location].append(requirement)
for equipment_tag, requirements in equipment_requirements.items(): for equipment_tag, requirements in equipment_requirements.items():
sparepart_manager.add_equipment_requirements(equipment_tag, requirements) sparepart_manager.add_equipment_requirements(equipment_tag, requirements)

@ -0,0 +1,159 @@
import asyncio
from datetime import timedelta
from temporalio import activity, workflow
# with workflow.unsafe.imports_passed_through():
# import httpx
# from src.config import RBD_SERVICE_API
# from src.database.core import get_main_session, get_collector_session
# from src.optimum_time_constraint.service import (
# create_param_and_data,
# get_calculation_data_by_id
# )
# from src.optimum_time_constraint.optimizer import run_simulation_with_spareparts
# from src.overhaul_scope.service import get as get_scope, get_prev_oh
# from src.calculation_time_constrains.utils import get_months_between
# from src.calculation_target_reliability.utils import wait_for_workflow
@activity.defn
async def create_optimum_oh_calculation(args: dict) -> str:
from src.calculation_time_constrains.schema import CalculationTimeConstrainsParametersCreate
from src.calculation_time_constrains.service import create_param_and_data
from src.database.core import get_main_session
token = args["token"]
calc_in_dict = args["calculation_in"]
created_by = args["created_by"]
calc_in = CalculationTimeConstrainsParametersCreate(**calc_in_dict)
async with get_main_session() as db_session:
calc_data = await create_param_and_data(
db_session=db_session,
calculation_param_in=calc_in,
created_by=created_by
)
return str(calc_data.id)
@activity.defn
async def request_rbd_simulation(args: dict) -> dict:
from src.calculation_time_constrains.service import get_calculation_data_by_id
from src.database.core import get_main_session
from src.overhaul_scope.service import get as get_scope, get_prev_oh
from src.calculation_time_constrains.utils import get_months_between
from src.calculation_target_reliability.utils import wait_for_workflow
import httpx
from src.config import RBD_SERVICE_API
calc_id = args["calc_id"]
token = args["token"]
callback_workflow_id = args["callback_workflow_id"]
async with get_main_session() as db_session:
calc_data = await get_calculation_data_by_id(db_session=db_session, calculation_id=calc_id)
scope = await get_scope(db_session=db_session, overhaul_session_id=calc_data.overhaul_session_id)
prev_oh_scope = await get_prev_oh(db_session=db_session, overhaul_session=scope)
# Calculate time window
time_window_months = get_months_between(prev_oh_scope.end_date, scope.start_date) + 6
sim_duration_hours = time_window_months * 720
sim_input = {
"SchematicName": "- TJB - Unit 3 -",
"SimulationName": f"OptimumOH_Calc_{calc_id}",
"SimDuration": sim_duration_hours,
"DurationUnit": "UHour",
"OffSet": 0,
"SimSeed": 99,
"SimNumRun": 1,
"OverhaulInterval": sim_duration_hours + 1,
"MaintenanceOutages": 0,
"IsDefault": False,
"OverhaulDuration": 1200,
"AhmJobId": None,
"CallbackWorkflowId": callback_workflow_id,
}
async with httpx.AsyncClient(timeout=30.0) as client:
resp = await client.post(
f"{RBD_SERVICE_API}/aeros/simulation/run",
json=sim_input,
headers={"Authorization": f"Bearer {token}"}
)
resp.raise_for_status()
sim_id = resp.json()["data"]
calc_data.rbd_simulation_id = sim_id
return sim_id
# @activity.defn
# async def wait_for_rbd_simulation(sim_id: str) -> str:
# await wait_for_workflow(sim_id)
# return sim_id
@activity.defn
async def run_optimum_oh_calculation(args: dict) -> dict:
from src.database.core import get_main_session, get_collector_session
from src.calculation_time_constrains.service import get_calculation_data_by_id, run_simulation_with_spareparts
from src.config import RBD_SERVICE_API
calc_id = args["calc_id"]
token = args["token"]
sim_id = args["sim_id"]
async with get_main_session() as db_session:
async with get_collector_session() as collector_db:
calc_data = await get_calculation_data_by_id(db_session=db_session, calculation_id=calc_id)
results = await run_simulation_with_spareparts(
db_session=db_session,
calculation=calc_data,
token=token,
collector_db_session=collector_db,
simulation_id=sim_id,
rbd_service_api=RBD_SERVICE_API
)
# return simplified result since temporal handles JSON
return {"id": str(results["id"]), "optimum": results["optimum"]}
@workflow.defn
class OptimumOHCalculationWorkflow:
def __init__(self):
self.done = False
@workflow.signal
def notify_done(self):
self.done = True
@workflow.run
async def run(self, args: dict) -> dict:
# 1. Create calculation
calc_id = await workflow.execute_activity(
create_optimum_oh_calculation,
args,
start_to_close_timeout=timedelta(minutes=1)
)
args["calc_id"] = calc_id
# 2. Request RBD simulation
sim_id = await workflow.execute_activity(
request_rbd_simulation,
args,
start_to_close_timeout=timedelta(minutes=2)
)
args["sim_id"] = sim_id
# Wait for RBD simulation to finish using signal
await workflow.wait_condition(lambda: self.done)
# 4. Run Optimum OH calculation
result = await workflow.execute_activity(
run_optimum_oh_calculation,
args,
start_to_close_timeout=timedelta(minutes=30)
)
return result

@ -0,0 +1,21 @@
from sqlalchemy import Column, JSON, String, select
from sqlalchemy.orm import declarative_base
Base = declarative_base()
class Test(Base):
__tablename__ = 'test'
id = Column(String, primary_key=True)
meta = Column(JSON)
try:
s = select(Test).where(Test.meta["type"].as_string() == "target_reliability")
print("as_string works")
except Exception as e:
print("as_string error:", e)
try:
s = select(Test).where(Test.meta["type"].astext == "target_reliability")
print("astext works")
except Exception as e:
print("astext error:", e)

@ -0,0 +1,6 @@
import asyncio
import httpx
from src.config import RBD_SERVICE_API
async def main():
token = "your_token_here" # I don't have a token. I'll need a token.

@ -11,7 +11,8 @@ async def test_request_validation_middleware_query_length():
with pytest.raises(HTTPException) as excinfo: with pytest.raises(HTTPException) as excinfo:
await middleware.dispatch(request, AsyncMock()) await middleware.dispatch(request, AsyncMock())
assert excinfo.value.status_code == 414 assert excinfo.value.status_code == 422
assert "Invalid request parameters" in excinfo.value.detail
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_request_validation_middleware_too_many_params(): async def test_request_validation_middleware_too_many_params():
@ -22,8 +23,8 @@ async def test_request_validation_middleware_too_many_params():
with pytest.raises(HTTPException) as excinfo: with pytest.raises(HTTPException) as excinfo:
await middleware.dispatch(request, AsyncMock()) await middleware.dispatch(request, AsyncMock())
assert excinfo.value.status_code == 400 assert excinfo.value.status_code == 422
assert "Too many query parameters" in excinfo.value.detail assert "Invalid request parameters" in excinfo.value.detail
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_request_validation_middleware_xss_detection(): async def test_request_validation_middleware_xss_detection():
@ -34,23 +35,24 @@ async def test_request_validation_middleware_xss_detection():
with pytest.raises(HTTPException) as excinfo: with pytest.raises(HTTPException) as excinfo:
await middleware.dispatch(request, AsyncMock()) await middleware.dispatch(request, AsyncMock())
assert excinfo.value.status_code == 400 assert excinfo.value.status_code == 422
assert "Potential XSS payload" in excinfo.value.detail assert "Invalid request parameters" in excinfo.value.detail
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_request_validation_middleware_pagination_logic(): async def test_request_validation_middleware_pagination_logic():
middleware = RequestValidationMiddleware(app=MagicMock()) middleware = RequestValidationMiddleware(app=MagicMock())
request = MagicMock() request = MagicMock()
request.url.query = "size=55" request.url.query = "size=105"
request.query_params.multi_items.return_value = [("size", "55")] request.query_params.multi_items.return_value = [("size", "105")]
request.headers = {} request.headers = {}
with pytest.raises(HTTPException) as excinfo: with pytest.raises(HTTPException) as excinfo:
await middleware.dispatch(request, AsyncMock()) await middleware.dispatch(request, AsyncMock())
assert excinfo.value.status_code == 400 assert excinfo.value.status_code == 422
assert "cannot exceed 50" in excinfo.value.detail assert "Invalid request parameters" in excinfo.value.detail
request.query_params.multi_items.return_value = [("size", "7")] request.query_params.multi_items.return_value = [("size", "7")]
with pytest.raises(HTTPException) as excinfo: with pytest.raises(HTTPException) as excinfo:
await middleware.dispatch(request, AsyncMock()) await middleware.dispatch(request, AsyncMock())
assert "must be a multiple of 5" in excinfo.value.detail assert excinfo.value.status_code == 422
assert "Invalid request parameters" in excinfo.value.detail

@ -11,10 +11,10 @@ from src.middleware import (
def test_xss_patterns(): def test_xss_patterns():
# Test common XSS payloads in be-optimumoh # Test common XSS payloads in be-optimumoh
payloads = [ payloads = [
"<script>", "<script>alert(1)</script>",
"javascript:", "javascript:alert(1)",
"onerror=", "onerror=alert(1)",
"onload=", "onload=alert(1)",
"<svg", "<svg",
"<img" "<img"
] ]
@ -24,13 +24,10 @@ def test_xss_patterns():
def test_sqli_patterns(): def test_sqli_patterns():
# Test common SQLi payloads in be-optimumoh # Test common SQLi payloads in be-optimumoh
payloads = [ payloads = [
"UNION", "UNION SELECT * FROM users",
"SELECT", "OR 1=1",
"INSERT", "WAITFOR DELAY '0:0:5'",
"DELETE", "INFORMATION_SCHEMA.TABLES",
"DROP",
"--",
"OR 1=1"
] ]
for payload in payloads: for payload in payloads:
assert SQLI_PATTERN.search(payload) is not None assert SQLI_PATTERN.search(payload) is not None
@ -38,19 +35,19 @@ def test_sqli_patterns():
def test_inspect_value_raises(): def test_inspect_value_raises():
with pytest.raises(HTTPException) as excinfo: with pytest.raises(HTTPException) as excinfo:
inspect_value("<script>", "source") inspect_value("<script>", "source")
assert excinfo.value.status_code == 400 assert excinfo.value.status_code == 422
assert "Potential XSS payload" in excinfo.value.detail assert "Invalid request parameters" in excinfo.value.detail
with pytest.raises(HTTPException) as excinfo: with pytest.raises(HTTPException) as excinfo:
inspect_value("UNION SELECT", "source") inspect_value("UNION SELECT * FROM users", "source")
assert excinfo.value.status_code == 400 assert excinfo.value.status_code == 422
assert "Potential SQL injection" in excinfo.value.detail assert "Invalid request parameters" in excinfo.value.detail
def test_inspect_json_raises(): def test_inspect_json_raises():
with pytest.raises(HTTPException) as excinfo: with pytest.raises(HTTPException) as excinfo:
inspect_json({"__proto__": "polluted"}) inspect_json({"__proto__": "polluted"})
assert excinfo.value.status_code == 400 assert excinfo.value.status_code == 422
assert "Forbidden JSON key" in excinfo.value.detail assert "Invalid request parameters" in excinfo.value.detail
def test_has_control_chars(): def test_has_control_chars():
assert has_control_chars("normal string") is False assert has_control_chars("normal string") is False

Loading…
Cancel
Save