Compare commits

..

1 Commits

Author SHA1 Message Date
Cizz22 aa106a5a85 WIP: tm using relibility 9 months ago

21
.env

@ -1,5 +1,5 @@
ENV=development ENV=development
LOG_LEVEL=INFO LOG_LEVEL=ERROR
PORT=3021 PORT=3021
HOST=0.0.0.0 HOST=0.0.0.0
@ -9,22 +9,15 @@ 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.86 # COLLECTOR_HOSTNAME=192.168.1.82
COLLECTOR_PORT=5432
COLLECTOR_CREDENTIAL_USER=postgres
COLLECTOR_CREDENTIAL_PASSWORD=postgres
COLLECTOR_NAME=digital_twin
TEMPORAL_URL=http://192.168.1.86:7233
# COLLECTOR_PORT=1111 # COLLECTOR_PORT=1111
# COLLECTOR_CREDENTIAL_USER=digital_twin # COLLECTOR_CREDENTIAL_USER=digital_twin
# COLLECTOR_CREDENTIAL_PASSWORD=Pr0jec7@D!g!tTwiN # COLLECTOR_CREDENTIAL_PASSWORD=Pr0jec7@D!g!tTwiN
# COLLECTOR_NAME=digital_twin # COLLECTOR_NAME=digital_twin
# COLLECTOR_HOSTNAME=192.168.1.86 COLLECTOR_HOSTNAME=192.168.1.86
# COLLECTOR_PORT=5432 COLLECTOR_PORT=5432
# COLLECTOR_CREDENTIAL_USER=postgres COLLECTOR_CREDENTIAL_USER=postgres
# COLLECTOR_CREDENTIAL_PASSWORD=postgres COLLECTOR_CREDENTIAL_PASSWORD=postgres
# COLLECTOR_NAME=digital_twin COLLECTOR_NAME=digital_twin

@ -1,208 +0,0 @@
# 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 |

@ -26,6 +26,8 @@ ENV POETRY_VIRTUALENVS_IN_PROJECT=1 \
COPY --from=builder /app/.venv /app/.venv COPY --from=builder /app/.venv /app/.venv
# Copy application files # Copy application files
COPY . /app/ COPY . /app/
# Delete Tests for production
RUN rm -rf /app/tests/
# Add custom configuration to root's .bashrc including password protection # Add custom configuration to root's .bashrc including password protection
RUN echo "# Custom configurations added by Dockerfile" >> /root/.bashrc && \ RUN echo "# Custom configurations added by Dockerfile" >> /root/.bashrc && \

115
Jenkinsfile vendored

@ -2,95 +2,106 @@ pipeline {
agent any agent any
environment { environment {
// Replace with your Docker Hub username/organization
DOCKER_HUB_USERNAME = 'aimodocker' DOCKER_HUB_USERNAME = 'aimodocker'
// This creates DOCKER_AUTH_USR and DOCKER_AUTH_PSW // Use credentials for Docker Hub
DOCKER_AUTH = credentials('aimodocker') DOCKER_CREDENTIALS = credentials('aimodocker')
// Replace with your image name
IMAGE_NAME = 'oh-service' IMAGE_NAME = 'oh-service'
SERVICE_NAME = 'ahm-app' // Replace with your docker compose service name
SERVICE_NAME = 'oh-app'
// Variable for Git commit hash
GIT_COMMIT_HASH = ''
SECURITY_PREFIX = 'security' // Replace with the SSH credentials for development server
// SSH_CREDENTIALS = credentials('backend-server-digitaltwin')
// Initialize variables to be updated in script blocks // SSH_CREDENTIALS_USR = 'aimo'
GIT_COMMIT_HASH = "" // SSH_SERVER_IP = '192.168.1.82'
IMAGE_TAG = ""
SECONDARY_TAG = ""
} }
stages { stages {
stage('Checkout & Setup') { stage('Checkout') {
steps { steps {
script { script {
// Checkout and get git commit hash
checkout scm checkout scm
GIT_COMMIT_HASH = sh(script: 'git rev-parse --short HEAD', returnStdout: true).trim() def commitHash = sh(script: 'git rev-parse --short HEAD', returnStdout: true).trim()
GIT_COMMIT_HASH = commitHash
// Use env.BRANCH_NAME or logic to handle detached HEAD if necessary echo "Git commit hash: ${GIT_COMMIT_HASH}"
def branch = env.BRANCH_NAME ?: 'unknown'
echo "Current Branch: ${branch}"
if (branch == 'main') {
IMAGE_TAG = GIT_COMMIT_HASH
SECONDARY_TAG = 'latest'
} else if (branch == 'oh_security') {
IMAGE_TAG = "${SECURITY_PREFIX}-${GIT_COMMIT_HASH}"
SECONDARY_TAG = "${SECURITY_PREFIX}-latest"
} else {
IMAGE_TAG = "temp-${GIT_COMMIT_HASH}"
SECONDARY_TAG = "" // Ensure it's empty for other branches
}
echo "Primary Tag: ${IMAGE_TAG}"
} }
} }
} }
stage('Docker Login') { stage('Docker Login') {
steps { steps {
// Fixed variable names based on the 'DOCKER_AUTH' environment key sh '''
sh "echo ${DOCKER_AUTH_PSW} | docker login -u ${DOCKER_AUTH_USR} --password-stdin" echo ${DOCKER_CREDENTIALS_PSW} | docker login -u ${DOCKER_CREDENTIALS_USR} --password-stdin
'''
} }
} }
stage('Build & Tag') { stage('Build Docker Image') {
steps { steps {
script { script {
def fullImageName = "${DOCKER_HUB_USERNAME}/${IMAGE_NAME}" // Build with commit hash tag
sh "docker build -t ${fullImageName}:${IMAGE_TAG} ." sh """
docker build -t ${DOCKER_HUB_USERNAME}/${IMAGE_NAME}:latest .
if (SECONDARY_TAG) { docker tag ${DOCKER_HUB_USERNAME}/${IMAGE_NAME}:latest ${DOCKER_HUB_USERNAME}/${IMAGE_NAME}:${GIT_COMMIT_HASH}
sh "docker tag ${fullImageName}:${IMAGE_TAG} ${fullImageName}:${SECONDARY_TAG}" """
}
} }
} }
} }
stage('Push to Docker Hub') { stage('Push to Docker Hub') {
steps { steps {
script { sh """
def fullImageName = "${DOCKER_HUB_USERNAME}/${IMAGE_NAME}" # Push both tags
sh "docker push ${fullImageName}:${IMAGE_TAG}" docker push ${DOCKER_HUB_USERNAME}/${IMAGE_NAME}:${GIT_COMMIT_HASH}
docker push ${DOCKER_HUB_USERNAME}/${IMAGE_NAME}:latest
if (SECONDARY_TAG) { """
sh "docker push ${fullImageName}:${SECONDARY_TAG}"
}
}
} }
} }
// stage('Deploy') {
// steps {
// script {
// sshagent(credentials: ['backend-server-digitaltwin']) {
// sh """
// ssh -o StrictHostKeyChecking=no -p 12558 aimo@0.tcp.ap.ngrok.io '
// cd ~/digital-twin/Docker
// sudo docker compose pull ${SERVICE_NAME}
// sudo docker compose up -d ${SERVICE_NAME}
// '
// """
// }
// }
// }
// }
} }
post { post {
always { always {
script { // Clean up
sh 'docker logout' sh 'docker logout'
def fullImageName = "${DOCKER_HUB_USERNAME}/${IMAGE_NAME}"
// Clean up images to save agent disk space // Clean up local images
sh "docker rmi ${fullImageName}:${IMAGE_TAG} || true" script {
if (SECONDARY_TAG) { try {
sh "docker rmi ${fullImageName}:${SECONDARY_TAG} || true" sh """
# Push both tags
docker rmi ${DOCKER_HUB_USERNAME}/${IMAGE_NAME}:${GIT_COMMIT_HASH}
docker rmi ${DOCKER_HUB_USERNAME}/${IMAGE_NAME}:latest
"""
} catch (err) {
echo "Failed to clean up images: ${err}"
} }
} }
} }
success { success {
echo "Successfully processed ${env.BRANCH_NAME}." echo "Successfully built, pushed, and deployed Docker image with tags: latest and ${GIT_COMMIT_HASH}"
}
failure {
echo 'Failed to build/push/deploy Docker image!'
} }
} }
} }

@ -1,44 +0,0 @@
# Unit Testing Guide - be-optimumoh
This document provides instructions on how to set up and run unit tests for the **be-optimumoh** project.
## 1. Preparation
### Install Dependencies
Ensure you have all dependencies installed. This project uses `poetry`.
```bash
# Install dependencies
poetry install
```
## 2. Configuration
### Pytest Configuration
Ensure the `pytest.ini` file in the root directory points to the `unit` test folder:
```ini
[pytest]
testpaths = tests/unit
python_files = test_*.py
asyncio_mode = auto
```
## 3. Running Tests
### Run Unit Tests
To run all unit tests in the project:
```bash
poetry run pytest tests/unit
```
### Run Specific Unit Test File
```bash
poetry run pytest tests/unit/test_specific_feature.py
```
## 4. Best Practices
- **Isolation**: Unit tests should be isolated from external services. Use mocking for APIs and databases.
- **Async Testing**: Use `@pytest.mark.asyncio` for asynchronous test functions.

@ -1,41 +0,0 @@
# 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.

@ -1,47 +0,0 @@
# 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).

@ -1,26 +0,0 @@
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())

@ -1,155 +0,0 @@
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)

@ -1,263 +0,0 @@
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.

Before

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

720
poetry.lock generated

@ -1,17 +1,5 @@
# This file is automatically @generated by Poetry 2.1.4 and should not be changed by hand. # This file is automatically @generated by Poetry 2.1.4 and should not be changed by hand.
[[package]]
name = "absl-py"
version = "2.3.1"
description = "Abseil Python Common Libraries, see https://github.com/abseil/abseil-py."
optional = false
python-versions = ">=3.8"
groups = ["main"]
files = [
{file = "absl_py-2.3.1-py3-none-any.whl", hash = "sha256:eeecf07f0c2a93ace0772c92e596ace6d3d3996c042b2128459aaae2a76de11d"},
{file = "absl_py-2.3.1.tar.gz", hash = "sha256:a97820526f7fbfd2ec1bce83f3f25e3a14840dac0d8e02a0b71cd75db3f77fc9"},
]
[[package]] [[package]]
name = "aiohappyeyeballs" name = "aiohappyeyeballs"
version = "2.6.1" version = "2.6.1"
@ -148,25 +136,6 @@ files = [
frozenlist = ">=1.1.0" frozenlist = ">=1.1.0"
typing-extensions = {version = ">=4.2", markers = "python_version < \"3.13\""} typing-extensions = {version = ">=4.2", markers = "python_version < \"3.13\""}
[[package]]
name = "aiosqlite"
version = "0.20.0"
description = "asyncio bridge to the standard sqlite3 module"
optional = false
python-versions = ">=3.8"
groups = ["main"]
files = [
{file = "aiosqlite-0.20.0-py3-none-any.whl", hash = "sha256:36a1deaca0cac40ebe32aac9977a6e2bbc7f5189f23f4a54d5908986729e5bd6"},
{file = "aiosqlite-0.20.0.tar.gz", hash = "sha256:6d35c8c256637f4672f843c31021464090805bf925385ac39473fb16eaaca3d7"},
]
[package.dependencies]
typing_extensions = ">=4.0"
[package.extras]
dev = ["attribution (==1.7.0)", "black (==24.2.0)", "coverage[toml] (==7.4.1)", "flake8 (==7.0.0)", "flake8-bugbear (==24.2.6)", "flit (==3.9.0)", "mypy (==1.8.0)", "ufmt (==2.3.0)", "usort (==1.0.8.post1)"]
docs = ["sphinx (==7.2.6)", "sphinx-mdinclude (==0.5.3)"]
[[package]] [[package]]
name = "annotated-types" name = "annotated-types"
version = "0.7.0" version = "0.7.0"
@ -451,114 +420,6 @@ 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"
@ -695,79 +556,6 @@ 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"
@ -1242,18 +1030,6 @@ files = [
[package.extras] [package.extras]
all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"]
[[package]]
name = "immutabledict"
version = "4.2.1"
description = "Immutable wrapper around dictionaries (a fork of frozendict)"
optional = false
python-versions = ">=3.8"
groups = ["main"]
files = [
{file = "immutabledict-4.2.1-py3-none-any.whl", hash = "sha256:c56a26ced38c236f79e74af3ccce53772827cef5c3bce7cab33ff2060f756373"},
{file = "immutabledict-4.2.1.tar.gz", hash = "sha256:d91017248981c72eb66c8ff9834e99c2f53562346f23e7f51e7a5ebcf66a3bcc"},
]
[[package]] [[package]]
name = "importlib-resources" name = "importlib-resources"
version = "6.4.5" version = "6.4.5"
@ -1304,133 +1080,6 @@ 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"
@ -1557,85 +1206,6 @@ 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"
@ -1850,63 +1420,6 @@ rsa = ["cryptography (>=3.0.0)"]
signals = ["blinker (>=1.4.0)"] signals = ["blinker (>=1.4.0)"]
signedtoken = ["cryptography (>=3.0.0)", "pyjwt (>=2.0.0,<3)"] signedtoken = ["cryptography (>=3.0.0)", "pyjwt (>=2.0.0,<3)"]
[[package]]
name = "ortools"
version = "9.14.6206"
description = "Google OR-Tools python libraries and modules"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "ortools-9.14.6206-cp310-cp310-macosx_10_15_x86_64.whl", hash = "sha256:6e2364edd1577cd094e7c7121ec5fb0aa462a69a78ce29cdc40fa45943ff0091"},
{file = "ortools-9.14.6206-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:164b726b4d358ae68a018a52ff1999c0646d6f861b33676c2c83e2ddb60cfa13"},
{file = "ortools-9.14.6206-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ebb0e210969cc3246fe78dadf9038936a3a18edc8156e23a394e2bbcec962431"},
{file = "ortools-9.14.6206-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:174de2f04c106c7dcc5989560f2c0e065e78fba0ad0d1fd029897582f4823c3a"},
{file = "ortools-9.14.6206-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:e6d994ebcf9cbdda1e20a75662967124e7e6ffd707c7f60b2db1a11f2104d384"},
{file = "ortools-9.14.6206-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5763472f8b05072c96c36c4eafadd9f6ffcdab38a81d8f0142fc408ad52a4342"},
{file = "ortools-9.14.6206-cp310-cp310-win_amd64.whl", hash = "sha256:6711516f837f06836ff9fda66fe4337b88c214f2ba6a921b84d3b05876f1fa8c"},
{file = "ortools-9.14.6206-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:8bcd8481846090585a4fac82800683555841685c49fa24578ad1e48a37918568"},
{file = "ortools-9.14.6206-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5af2bbf2fff7d922ba036e27d7ff378abecb24749380c86a77fa6208d5ba35cd"},
{file = "ortools-9.14.6206-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a6ab43490583c4bbf0fff4e51bb1c15675d5651c2e8e12ba974fd08e8c05a48f"},
{file = "ortools-9.14.6206-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9aa2c0c50a765c6a060960dcb0207bd6aeb6341f5adacb3d33e613b7e7409428"},
{file = "ortools-9.14.6206-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:64ec63fd92125499e9ca6b72700406dda161eefdfef92f04c35c5150391f89a4"},
{file = "ortools-9.14.6206-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:8651008f05257471f45a919ade5027afa12ab6f7a4fdf0a8bcc18c92032f8571"},
{file = "ortools-9.14.6206-cp311-cp311-win_amd64.whl", hash = "sha256:ca60877830a631545234e83e7f6bd55830334a4d0c2b51f1669b1f2698d58b84"},
{file = "ortools-9.14.6206-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:e38c8c4a184820cbfdb812a8d484f6506cf16993ce2a95c88bc1c9d23b17c63e"},
{file = "ortools-9.14.6206-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:db685073cbed9f8bfaa744f5e883f3dea57c93179b0abe1788276fd3b074fa61"},
{file = "ortools-9.14.6206-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d4bfb8bffb29991834cf4bde7048ca8ee8caed73e8dd21e5ec7de99a33bbfea0"},
{file = "ortools-9.14.6206-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eb464a698837e7f90ca5f9b3d748b6ddf553198a70032bc77824d1cd88695d2b"},
{file = "ortools-9.14.6206-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8f33deaeb7c3dda8ca1d29c5b9aa9c3a4f2ca9ecf34f12a1f809bb2995f41274"},
{file = "ortools-9.14.6206-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:086e7c2dc4f23efffb20a5e20f618c7d6adb99b2d94f684cab482387da3bc434"},
{file = "ortools-9.14.6206-cp312-cp312-win_amd64.whl", hash = "sha256:17c13b0bfde17ac57789ad35243edf1318ecd5db23cf949b75ab62480599f188"},
{file = "ortools-9.14.6206-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:8d0df7eef8ba53ad235e29018389259bad2e667d9594b9c2a412ed6a5756bd4e"},
{file = "ortools-9.14.6206-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:57dfe10844ce8331634d4723040fe249263fd490407346efc314c0bc656849b5"},
{file = "ortools-9.14.6206-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5c0c2c00a6e5d5c462e76fdda7dbd40d0f9139f1df4211d34b36906696248020"},
{file = "ortools-9.14.6206-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:38044cf39952d93cbcc02f6acdbe0a9bd3628fbf17f0d7eb0374060fa028c22e"},
{file = "ortools-9.14.6206-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:98564de773d709e1e49cb3c32f6917589c314f047786d88bd5f324c0eb7be96e"},
{file = "ortools-9.14.6206-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:80528b0ac72dc3de00cbeef2ce028517a476450b5877b1cda1b8ecb9fa98505e"},
{file = "ortools-9.14.6206-cp313-cp313-win_amd64.whl", hash = "sha256:47b1b15dcb085d32c61621b790259193aefa9e4577abadf233d47fbe7d0b81ef"},
{file = "ortools-9.14.6206-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d26a0f9ed97ef9d3384a9069923585f5f974c3fde555a41f4d6381fbe7840bc4"},
{file = "ortools-9.14.6206-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d40d8141667d47405f296a9f687058c566d7816586e9a672b59e9fcec8493133"},
{file = "ortools-9.14.6206-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:aefea81ed81aa937873efc520381785ed65380e52917f492ab566f46bbb5660d"},
{file = "ortools-9.14.6206-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f044bb277db3ab6a1b958728fe1cf14ca87c3800d67d7b321d876b48269340f6"},
{file = "ortools-9.14.6206-cp39-cp39-macosx_10_15_x86_64.whl", hash = "sha256:070dc7cebfa0df066acb6b9a6d02339351be8f91b2352b782ee7f40412207e20"},
{file = "ortools-9.14.6206-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:5eb558a03b4ada501ecdea7b89f0d3bdf2cc6752e1728759ccf27923f592a8c2"},
{file = "ortools-9.14.6206-cp39-cp39-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:646329fa74a5c48c591b7fabfd26743f6d2de4e632b3b96ec596c47bfe19177a"},
{file = "ortools-9.14.6206-cp39-cp39-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aa5161924f35b8244295acd0fab2a8171bb08ef8d5cfaf1913a21274475704cc"},
{file = "ortools-9.14.6206-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:e253526a026ae194aed544a0d065163f52a0c9cb606a1061c62df546877d5452"},
{file = "ortools-9.14.6206-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:dcb496ef633d884036770783f43bf8a47ff253ecdd8a8f5b95f00276ec241bfd"},
{file = "ortools-9.14.6206-cp39-cp39-win_amd64.whl", hash = "sha256:2733f635675de631fdc7b1611878ec9ee2f48a26434b7b3c07d0a0f535b92e03"},
]
[package.dependencies]
absl-py = ">=2.0.0"
immutabledict = ">=3.0.0"
numpy = ">=1.13.3"
pandas = ">=2.0.0"
protobuf = ">=6.31.1,<6.32"
typing-extensions = ">=4.12"
[[package]] [[package]]
name = "packaging" name = "packaging"
version = "24.2" version = "24.2"
@ -2005,115 +1518,6 @@ 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"
@ -2258,21 +1662,23 @@ testing = ["google-api-core (>=1.31.5)"]
[[package]] [[package]]
name = "protobuf" name = "protobuf"
version = "6.31.1" version = "5.29.0"
description = "" description = ""
optional = false optional = false
python-versions = ">=3.9" python-versions = ">=3.8"
groups = ["main"] groups = ["main"]
files = [ files = [
{file = "protobuf-6.31.1-cp310-abi3-win32.whl", hash = "sha256:7fa17d5a29c2e04b7d90e5e32388b8bfd0e7107cd8e616feef7ed3fa6bdab5c9"}, {file = "protobuf-5.29.0-cp310-abi3-win32.whl", hash = "sha256:ea7fb379b257911c8c020688d455e8f74efd2f734b72dc1ea4b4d7e9fd1326f2"},
{file = "protobuf-6.31.1-cp310-abi3-win_amd64.whl", hash = "sha256:426f59d2964864a1a366254fa703b8632dcec0790d8862d30034d8245e1cd447"}, {file = "protobuf-5.29.0-cp310-abi3-win_amd64.whl", hash = "sha256:34a90cf30c908f47f40ebea7811f743d360e202b6f10d40c02529ebd84afc069"},
{file = "protobuf-6.31.1-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:6f1227473dc43d44ed644425268eb7c2e488ae245d51c6866d19fe158e207402"}, {file = "protobuf-5.29.0-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:c931c61d0cc143a2e756b1e7f8197a508de5365efd40f83c907a9febf36e6b43"},
{file = "protobuf-6.31.1-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:a40fc12b84c154884d7d4c4ebd675d5b3b5283e155f324049ae396b95ddebc39"}, {file = "protobuf-5.29.0-cp38-abi3-manylinux2014_aarch64.whl", hash = "sha256:85286a47caf63b34fa92fdc1fd98b649a8895db595cfa746c5286eeae890a0b1"},
{file = "protobuf-6.31.1-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:4ee898bf66f7a8b0bd21bce523814e6fbd8c6add948045ce958b73af7e8878c6"}, {file = "protobuf-5.29.0-cp38-abi3-manylinux2014_x86_64.whl", hash = "sha256:0d10091d6d03537c3f902279fcf11e95372bdd36a79556311da0487455791b20"},
{file = "protobuf-6.31.1-cp39-cp39-win32.whl", hash = "sha256:0414e3aa5a5f3ff423828e1e6a6e907d6c65c1d5b7e6e975793d5590bdeecc16"}, {file = "protobuf-5.29.0-cp38-cp38-win32.whl", hash = "sha256:0cd67a1e5c2d88930aa767f702773b2d054e29957432d7c6a18f8be02a07719a"},
{file = "protobuf-6.31.1-cp39-cp39-win_amd64.whl", hash = "sha256:8764cf4587791e7564051b35524b72844f845ad0bb011704c3736cce762d8fe9"}, {file = "protobuf-5.29.0-cp38-cp38-win_amd64.whl", hash = "sha256:e467f81fdd12ded9655cea3e9b83dc319d93b394ce810b556fb0f421d8613e86"},
{file = "protobuf-6.31.1-py3-none-any.whl", hash = "sha256:720a6c7e6b77288b85063569baae8536671b39f15cc22037ec7045658d80489e"}, {file = "protobuf-5.29.0-cp39-cp39-win32.whl", hash = "sha256:17d128eebbd5d8aee80300aed7a43a48a25170af3337f6f1333d1fac2c6839ac"},
{file = "protobuf-6.31.1.tar.gz", hash = "sha256:d8cac4c982f0b957a4dc73a80e2ea24fab08e679c0de9deb835f4a12d69aca9a"}, {file = "protobuf-5.29.0-cp39-cp39-win_amd64.whl", hash = "sha256:6c3009e22717c6cc9e6594bb11ef9f15f669b19957ad4087214d69e08a213368"},
{file = "protobuf-5.29.0-py3-none-any.whl", hash = "sha256:88c4af76a73183e21061881360240c0cdd3c39d263b4e8fb570aaf83348d608f"},
{file = "protobuf-5.29.0.tar.gz", hash = "sha256:445a0c02483869ed8513a585d80020d012c6dc60075f96fa0563a724987b1001"},
] ]
[[package]] [[package]]
@ -2565,25 +1971,6 @@ pluggy = ">=1.5,<2"
[package.extras] [package.extras]
dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"]
[[package]]
name = "pytest-asyncio"
version = "0.24.0"
description = "Pytest support for asyncio"
optional = false
python-versions = ">=3.8"
groups = ["main"]
files = [
{file = "pytest_asyncio-0.24.0-py3-none-any.whl", hash = "sha256:a811296ed596b69bf0b6f3dc40f83bcaf341b155a269052d82efa2b25ac7037b"},
{file = "pytest_asyncio-0.24.0.tar.gz", hash = "sha256:d081d828e576d85f875399194281e92bf8a68d60d72d1a2faf2feddb6c46b276"},
]
[package.dependencies]
pytest = ">=8.2,<9"
[package.extras]
docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1.0)"]
testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)"]
[[package]] [[package]]
name = "python-dateutil" name = "python-dateutil"
version = "2.9.0.post0" version = "2.9.0.post0"
@ -2776,6 +2163,85 @@ files = [
[package.dependencies] [package.dependencies]
pyasn1 = ">=0.1.3" pyasn1 = ">=0.1.3"
[[package]]
name = "scipy"
version = "1.16.2"
description = "Fundamental algorithms for scientific computing in Python"
optional = false
python-versions = ">=3.11"
groups = ["main"]
files = [
{file = "scipy-1.16.2-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:6ab88ea43a57da1af33292ebd04b417e8e2eaf9d5aa05700be8d6e1b6501cd92"},
{file = "scipy-1.16.2-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:c95e96c7305c96ede73a7389f46ccd6c659c4da5ef1b2789466baeaed3622b6e"},
{file = "scipy-1.16.2-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:87eb178db04ece7c698220d523c170125dbffebb7af0345e66c3554f6f60c173"},
{file = "scipy-1.16.2-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:4e409eac067dcee96a57fbcf424c13f428037827ec7ee3cb671ff525ca4fc34d"},
{file = "scipy-1.16.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e574be127bb760f0dad24ff6e217c80213d153058372362ccb9555a10fc5e8d2"},
{file = "scipy-1.16.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f5db5ba6188d698ba7abab982ad6973265b74bb40a1efe1821b58c87f73892b9"},
{file = "scipy-1.16.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ec6e74c4e884104ae006d34110677bfe0098203a3fec2f3faf349f4cb05165e3"},
{file = "scipy-1.16.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:912f46667d2d3834bc3d57361f854226475f695eb08c08a904aadb1c936b6a88"},
{file = "scipy-1.16.2-cp311-cp311-win_amd64.whl", hash = "sha256:91e9e8a37befa5a69e9cacbe0bcb79ae5afb4a0b130fd6db6ee6cc0d491695fa"},
{file = "scipy-1.16.2-cp311-cp311-win_arm64.whl", hash = "sha256:f3bf75a6dcecab62afde4d1f973f1692be013110cad5338007927db8da73249c"},
{file = "scipy-1.16.2-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:89d6c100fa5c48472047632e06f0876b3c4931aac1f4291afc81a3644316bb0d"},
{file = "scipy-1.16.2-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:ca748936cd579d3f01928b30a17dc474550b01272d8046e3e1ee593f23620371"},
{file = "scipy-1.16.2-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:fac4f8ce2ddb40e2e3d0f7ec36d2a1e7f92559a2471e59aec37bd8d9de01fec0"},
{file = "scipy-1.16.2-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:033570f1dcefd79547a88e18bccacff025c8c647a330381064f561d43b821232"},
{file = "scipy-1.16.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ea3421209bf00c8a5ef2227de496601087d8f638a2363ee09af059bd70976dc1"},
{file = "scipy-1.16.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f66bd07ba6f84cd4a380b41d1bf3c59ea488b590a2ff96744845163309ee8e2f"},
{file = "scipy-1.16.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e9feab931bd2aea4a23388c962df6468af3d808ddf2d40f94a81c5dc38f32ef"},
{file = "scipy-1.16.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:03dfc75e52f72cf23ec2ced468645321407faad8f0fe7b1f5b49264adbc29cb1"},
{file = "scipy-1.16.2-cp312-cp312-win_amd64.whl", hash = "sha256:0ce54e07bbb394b417457409a64fd015be623f36e330ac49306433ffe04bc97e"},
{file = "scipy-1.16.2-cp312-cp312-win_arm64.whl", hash = "sha256:2a8ffaa4ac0df81a0b94577b18ee079f13fecdb924df3328fc44a7dc5ac46851"},
{file = "scipy-1.16.2-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:84f7bf944b43e20b8a894f5fe593976926744f6c185bacfcbdfbb62736b5cc70"},
{file = "scipy-1.16.2-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:5c39026d12edc826a1ef2ad35ad1e6d7f087f934bb868fc43fa3049c8b8508f9"},
{file = "scipy-1.16.2-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:e52729ffd45b68777c5319560014d6fd251294200625d9d70fd8626516fc49f5"},
{file = "scipy-1.16.2-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:024dd4a118cccec09ca3209b7e8e614931a6ffb804b2a601839499cb88bdf925"},
{file = "scipy-1.16.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7a5dc7ee9c33019973a470556081b0fd3c9f4c44019191039f9769183141a4d9"},
{file = "scipy-1.16.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c2275ff105e508942f99d4e3bc56b6ef5e4b3c0af970386ca56b777608ce95b7"},
{file = "scipy-1.16.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:af80196eaa84f033e48444d2e0786ec47d328ba00c71e4299b602235ffef9acb"},
{file = "scipy-1.16.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9fb1eb735fe3d6ed1f89918224e3385fbf6f9e23757cacc35f9c78d3b712dd6e"},
{file = "scipy-1.16.2-cp313-cp313-win_amd64.whl", hash = "sha256:fda714cf45ba43c9d3bae8f2585c777f64e3f89a2e073b668b32ede412d8f52c"},
{file = "scipy-1.16.2-cp313-cp313-win_arm64.whl", hash = "sha256:2f5350da923ccfd0b00e07c3e5cfb316c1c0d6c1d864c07a72d092e9f20db104"},
{file = "scipy-1.16.2-cp313-cp313t-macosx_10_14_x86_64.whl", hash = "sha256:53d8d2ee29b925344c13bda64ab51785f016b1b9617849dac10897f0701b20c1"},
{file = "scipy-1.16.2-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:9e05e33657efb4c6a9d23bd8300101536abd99c85cca82da0bffff8d8764d08a"},
{file = "scipy-1.16.2-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:7fe65b36036357003b3ef9d37547abeefaa353b237e989c21027b8ed62b12d4f"},
{file = "scipy-1.16.2-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:6406d2ac6d40b861cccf57f49592f9779071655e9f75cd4f977fa0bdd09cb2e4"},
{file = "scipy-1.16.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ff4dc42bd321991fbf611c23fc35912d690f731c9914bf3af8f417e64aca0f21"},
{file = "scipy-1.16.2-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:654324826654d4d9133e10675325708fb954bc84dae6e9ad0a52e75c6b1a01d7"},
{file = "scipy-1.16.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:63870a84cd15c44e65220eaed2dac0e8f8b26bbb991456a033c1d9abfe8a94f8"},
{file = "scipy-1.16.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:fa01f0f6a3050fa6a9771a95d5faccc8e2f5a92b4a2e5440a0fa7264a2398472"},
{file = "scipy-1.16.2-cp313-cp313t-win_amd64.whl", hash = "sha256:116296e89fba96f76353a8579820c2512f6e55835d3fad7780fece04367de351"},
{file = "scipy-1.16.2-cp313-cp313t-win_arm64.whl", hash = "sha256:98e22834650be81d42982360382b43b17f7ba95e0e6993e2a4f5b9ad9283a94d"},
{file = "scipy-1.16.2-cp314-cp314-macosx_10_14_x86_64.whl", hash = "sha256:567e77755019bb7461513c87f02bb73fb65b11f049aaaa8ca17cfaa5a5c45d77"},
{file = "scipy-1.16.2-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:17d9bb346194e8967296621208fcdfd39b55498ef7d2f376884d5ac47cec1a70"},
{file = "scipy-1.16.2-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:0a17541827a9b78b777d33b623a6dcfe2ef4a25806204d08ead0768f4e529a88"},
{file = "scipy-1.16.2-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:d7d4c6ba016ffc0f9568d012f5f1eb77ddd99412aea121e6fa8b4c3b7cbad91f"},
{file = "scipy-1.16.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:9702c4c023227785c779cba2e1d6f7635dbb5b2e0936cdd3a4ecb98d78fd41eb"},
{file = "scipy-1.16.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d1cdf0ac28948d225decdefcc45ad7dd91716c29ab56ef32f8e0d50657dffcc7"},
{file = "scipy-1.16.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:70327d6aa572a17c2941cdfb20673f82e536e91850a2e4cb0c5b858b690e1548"},
{file = "scipy-1.16.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5221c0b2a4b58aa7c4ed0387d360fd90ee9086d383bb34d9f2789fafddc8a936"},
{file = "scipy-1.16.2-cp314-cp314-win_amd64.whl", hash = "sha256:f5a85d7b2b708025af08f060a496dd261055b617d776fc05a1a1cc69e09fe9ff"},
{file = "scipy-1.16.2-cp314-cp314-win_arm64.whl", hash = "sha256:2cc73a33305b4b24556957d5857d6253ce1e2dcd67fa0ff46d87d1670b3e1e1d"},
{file = "scipy-1.16.2-cp314-cp314t-macosx_10_14_x86_64.whl", hash = "sha256:9ea2a3fed83065d77367775d689401a703d0f697420719ee10c0780bcab594d8"},
{file = "scipy-1.16.2-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:7280d926f11ca945c3ef92ba960fa924e1465f8d07ce3a9923080363390624c4"},
{file = "scipy-1.16.2-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:8afae1756f6a1fe04636407ef7dbece33d826a5d462b74f3d0eb82deabefd831"},
{file = "scipy-1.16.2-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:5c66511f29aa8d233388e7416a3f20d5cae7a2744d5cee2ecd38c081f4e861b3"},
{file = "scipy-1.16.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:efe6305aeaa0e96b0ccca5ff647a43737d9a092064a3894e46c414db84bc54ac"},
{file = "scipy-1.16.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7f3a337d9ae06a1e8d655ee9d8ecb835ea5ddcdcbd8d23012afa055ab014f374"},
{file = "scipy-1.16.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bab3605795d269067d8ce78a910220262711b753de8913d3deeaedb5dded3bb6"},
{file = "scipy-1.16.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b0348d8ddb55be2a844c518cd8cc8deeeb8aeba707cf834db5758fc89b476a2c"},
{file = "scipy-1.16.2-cp314-cp314t-win_amd64.whl", hash = "sha256:26284797e38b8a75e14ea6631d29bda11e76ceaa6ddb6fdebbfe4c4d90faf2f9"},
{file = "scipy-1.16.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d2a4472c231328d4de38d5f1f68fdd6d28a615138f842580a8a321b5845cf779"},
{file = "scipy-1.16.2.tar.gz", hash = "sha256:af029b153d243a80afb6eabe40b0a07f8e35c9adc269c019f364ad747f826a6b"},
]
[package.dependencies]
numpy = ">=1.25.2,<2.6"
[package.extras]
dev = ["cython-lint (>=0.12.2)", "doit (>=0.36.0)", "mypy (==1.10.0)", "pycodestyle", "pydevtool", "rich-click", "ruff (>=0.0.292)", "types-psutil", "typing_extensions"]
doc = ["intersphinx_registry", "jupyterlite-pyodide-kernel", "jupyterlite-sphinx (>=0.19.1)", "jupytext", "linkify-it-py", "matplotlib (>=3.5)", "myst-nb (>=1.2.0)", "numpydoc", "pooch", "pydata-sphinx-theme (>=0.15.2)", "sphinx (>=5.0.0,<8.2.0)", "sphinx-copybutton", "sphinx-design (>=0.4.0)"]
test = ["Cython", "array-api-strict (>=2.3.1)", "asv", "gmpy2", "hypothesis (>=6.30)", "meson", "mpmath", "ninja ; sys_platform != \"emscripten\"", "pooch", "pytest (>=8.0.0)", "pytest-cov", "pytest-timeout", "pytest-xdist", "scikit-umfpack", "threadpoolctl"]
[[package]] [[package]]
name = "shellingham" name = "shellingham"
version = "1.5.4" version = "1.5.4"
@ -3542,4 +3008,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 = "e0e48b89f5ad77fa11d84cd759aee3e849ab6ed94f8756384c39290204083bb8" content-hash = "91dbae2db3aade422091b46bec01e32cabc4457814e736775fc0d020d785fae5"

@ -12,8 +12,6 @@ fastapi = { extras = ["standard"], version = "^0.115.4" }
sqlalchemy = "^2.0.36" sqlalchemy = "^2.0.36"
httpx = "^0.27.2" httpx = "^0.27.2"
pytest = "^8.3.3" pytest = "^8.3.3"
pytest-asyncio = "^0.24.0"
aiosqlite = "^0.20.0"
faker = "^30.8.2" faker = "^30.8.2"
factory-boy = "^3.3.1" factory-boy = "^3.3.1"
sqlalchemy-utils = "^0.41.2" sqlalchemy-utils = "^0.41.2"
@ -32,8 +30,7 @@ google-api-python-client = "^2.169.0"
google-auth-httplib2 = "^0.2.0" 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" scipy = "^1.16.2"
matplotlib = "^3.10.9"
[build-system] [build-system]

@ -1,6 +0,0 @@
[pytest]
asyncio_mode = auto
testpaths = tests/unit
python_files = test_*.py
filterwarnings =
ignore::pydantic.PydanticDeprecatedSince20

@ -1,35 +0,0 @@
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())

@ -10,9 +10,7 @@ from src.calculation_budget_constrains.router import \
from src.calculation_target_reliability.router import \ 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
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
@ -34,7 +32,7 @@ from src.equipment_sparepart.router import router as equipment_sparepart_router
# from src.overhaul.router import router as overhaul_router # from src.overhaul.router import router as overhaul_router
# from src.overhaul_history.router import router as overhaul_history_router # from src.overhaul_history.router import router as overhaul_history_router
# from src.overhaul_activity.router import router as scope_equipment_activity_router # from src.overhaul_activity.router import router as scope_equipment_activity_router
from src.overhaul_scope.router import router as ovehaul_schedule_router # # from src.overhaul_schedule.router import router as ovehaul_schedule_router
# from src.scope_equipment_part.router import router as scope_equipment_part_router # from src.scope_equipment_part.router import router as scope_equipment_part_router
# from src.calculation_target_reliability.router import router as calculation_target_reliability # from src.calculation_target_reliability.router import router as calculation_target_reliability
# #
@ -74,7 +72,6 @@ authenticated_api_router.include_router(
overhaul_router, prefix="/overhauls", tags=["overhaul"] overhaul_router, prefix="/overhauls", tags=["overhaul"]
) )
# authenticated_api_router.include_router(job_router, prefix="/jobs", tags=["job"]) # authenticated_api_router.include_router(job_router, prefix="/jobs", tags=["job"])
# # # Overhaul session data # # # Overhaul session data
@ -145,9 +142,9 @@ authenticated_api_router.include_router(
# scope_equipment_part_router, prefix="/equipment-parts", tags=["scope_equipment_parts"] # scope_equipment_part_router, prefix="/equipment-parts", tags=["scope_equipment_parts"]
# ) # )
authenticated_api_router.include_router( # authenticated_api_router.include_router(
ovehaul_schedule_router, prefix="/overhaul-schedules", tags=["overhaul_schedules"] # ovehaul_schedule_router, prefix="/overhaul-schedules", tags=["overhaul_schedules"]
) # )
# calculation # calculation
calculation_router = APIRouter(prefix="/calculation", tags=["calculations"]) calculation_router = APIRouter(prefix="/calculation", tags=["calculations"])
@ -159,13 +156,6 @@ 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,
@ -182,16 +172,4 @@ calculation_router.include_router(
authenticated_api_router.include_router(calculation_router) authenticated_api_router.include_router(calculation_router)
api_router.include_router(
get_calculation,
prefix="/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,5 @@
# app/auth/auth_bearer.py # app/auth/auth_bearer.py
import json
from typing import Annotated, Optional from typing import Annotated, Optional
import requests import requests
@ -44,22 +43,12 @@ class JWTBearer(HTTPBearer):
) )
request.state.user = message request.state.user = message
from src.context import set_user_id, set_username, set_role
if hasattr(message, "user_id"):
set_user_id(str(message.user_id))
if hasattr(message, "username"):
set_username(message.username)
elif hasattr(message, "name"):
set_username(message.name)
if hasattr(message, "role"):
set_role(message.role)
return message return message
else: else:
raise HTTPException(status_code=403, detail="Invalid authorization code.") raise HTTPException(status_code=403, detail="Invalid authorization code.")
def verify_jwt(self, jwtoken: str, method: str, endpoint: str): def verify_jwt(self, jwtoken: str, method: str, endpoint: str):
try: try:
response = requests.get( response = requests.get(
f"{config.AUTH_SERVICE_API}/verify-token", f"{config.AUTH_SERVICE_API}/verify-token",
@ -81,155 +70,15 @@ class JWTBearer(HTTPBearer):
async def get_current_user(request: Request) -> UserBase: async def get_current_user(request: Request) -> UserBase:
return request.state.user return request.state.user
async def get_token(request: Request):
token = request.headers.get("Authorization")
if token:
return token.replace("Bearer ", "") # Menghapus prefix "Bearer "
else:
return request.cookies.get("access_token") # Fallback ke cookie
return "" # Mengembalikan token atau None jika tidak ada
async def internal_key(request: Request): async def get_token(request: Request):
token = request.headers.get("Authorization") token = request.headers.get("Authorization")
if not token: if token:
api_key = request.headers.get("X-Internal-Key")
if api_key != config.API_KEY:
raise HTTPException(
status_code=403, detail="Invalid Key."
)
try:
headers = {
'Content-Type': 'application/json'
}
response = requests.post(
f"{config.AUTH_SERVICE_API}/sign-in",
headers=headers,
data=json.dumps({
"username": "ohuser",
"password": "123456789"
})
)
if not response.ok:
print(str(response.json()))
raise Exception("error auth")
user_data = response.json()
return user_data['data']['access_token']
except Exception as e:
raise Exception(str(e))
else:
try:
response = requests.get(
f"{config.AUTH_SERVICE_API}/verify-token",
headers={"Authorization": f"{token}"},
)
if not response.ok:
raise HTTPException(
status_code=403, detail="Invalid token."
)
return token.split(" ")[1] return token.split(" ")[1]
except Exception as e: return ""
print(f"Token verification error: {str(e)}")
return False, str(e)
import httpx
import logging
from typing import Dict, Any
import src.config as config
log = logging.getLogger(__name__)
AUTH_NOTIFY_ENDPOINT = f"{config.AUTH_SERVICE_API}/admin/notify-limit"
async def notify_admin_on_rate_limit(
endpoint_name: str,
ip_address: str,
method: str = "POST",
cooldown: int = 900,
timeout: int = 5
) -> Dict[str, Any]:
"""
Kirim notifikasi ke admin via be-auth service ketika rate limit terlampaui.
Async version - gunakan di async context.
"""
payload = {
"endpoint_name": endpoint_name,
"ip_address": ip_address,
"method": method,
"cooldown": cooldown,
}
try:
async with httpx.AsyncClient(timeout=timeout) as client:
response = await client.post(AUTH_NOTIFY_ENDPOINT, json=payload)
response.raise_for_status()
result = response.json()
log.info(f"Notifikasi admin sent | Endpoint: {endpoint_name}")
return result
except Exception as e:
log.error(f"Error notifying admin: {str(e)}")
return {"status": False, "message": str(e), "data": payload}
def notify_admin_on_rate_limit_sync(
endpoint_name: str,
ip_address: str,
method: str = "POST",
cooldown: int = 900,
timeout: int = 5
) -> Dict[str, Any]:
"""
Kirim notifikasi ke admin via be-auth service.
Sync version - gunakan di exception handler atau sync context.
RECOMMENDED untuk use case ini.
"""
payload = {
"endpoint_name": endpoint_name,
"ip_address": ip_address,
"method": method,
"cooldown": cooldown,
}
try:
response = httpx.post(AUTH_NOTIFY_ENDPOINT, json=payload, timeout=timeout)
response.raise_for_status()
result = response.json()
log.info(f"Notifikasi admin sent | Endpoint: {endpoint_name}")
return result
except Exception as e:
log.error(f"Error notifying admin: {str(e)}")
return {"status": False, "message": str(e), "data": payload}
CurrentUser = Annotated[UserBase, Depends(get_current_user)] CurrentUser = Annotated[UserBase, Depends(get_current_user)]
Token = Annotated[str, Depends(get_token)] Token = Annotated[str, Depends(get_token)]
InternalKey = Annotated[str, Depends(internal_key)]

@ -1,12 +1,10 @@
from typing import Annotated, Dict, List, Optional from typing import Dict, List, Optional
from fastapi import APIRouter, HTTPException, status from fastapi import APIRouter, HTTPException, status
from fastapi.params import Query from fastapi.params import Query
from src.auth.service import Token from src.auth.service import Token
from src.calculation_budget_constrains.schema import BudgetContraintQuery
from src.calculation_target_reliability.service import get_simulation_results from src.calculation_target_reliability.service import get_simulation_results
from src.config import TC_RBD_ID
from src.database.core import CollectorDbSession, DbSession from src.database.core import CollectorDbSession, DbSession
from src.models import StandardResponse from src.models import StandardResponse
@ -21,12 +19,12 @@ async def get_target_reliability(
token: Token, token: Token,
session_id: str, session_id: str,
collector_db: CollectorDbSession, collector_db: CollectorDbSession,
params: Annotated[BudgetContraintQuery, Query()], cost_threshold: float = Query(100),
): ):
"""Get all scope pagination.""" """Get all scope pagination."""
cost_threshold = params.cost_threshold
results = await get_simulation_results( results = await get_simulation_results(
simulation_id = TC_RBD_ID, simulation_id = "default",
token=token token=token
) )

@ -32,9 +32,6 @@ class OverhaulRead(OverhaulBase):
systemComponents: Dict[str, Any] systemComponents: Dict[str, Any]
class BudgetContraintQuery(DefultBase):
cost_threshold: float = 100
# { # {
# "overview": { # "overview": {
# "totalEquipment": 30, # "totalEquipment": 30,

@ -1,7 +1,6 @@
import math
from collections import defaultdict from collections import defaultdict
import random import random
from typing import Optional, List, Dict, Tuple from typing import Optional
from uuid import UUID from uuid import UUID
from sqlalchemy import Delete, Select from sqlalchemy import Delete, Select
@ -9,12 +8,23 @@ 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(
*, *,
@ -74,7 +84,7 @@ async def get_all_budget_constrains(
for item in result: for item in result:
cost = item["total_cost"] or 1.0 cost = item["total_cost"] or 1.0
efficiency = item["contribution_norm"] / cost efficiency = item["contribution_norm"] / cost
item["priority_score"] = item["contribution_norm"] item["priority_score"] = 0.7 * item["contribution_norm"] + 0.3 * efficiency
# Choose method # Choose method
if use_optimal: if use_optimal:
@ -94,98 +104,65 @@ def calculate_asset_eaf_contributions(plant_result, eq_results):
for asset in eq_results: for asset in eq_results:
node_name = asset.get("aeros_node", {}).get("node_name") node_name = asset.get("aeros_node", {}).get("node_name")
if node_name: if node_name:
results[node_name] = asset.get("contribution_factor", 0.0) results[node_name] = asset.get("contribution", 0.0)
return results return 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 selection: select items by score until budget is used.""" """Greedy fallback: 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)
current_cost_total = 0.0 total_cost = 0
selected, excluded = [], [] selected, excluded = [], []
for eq in equipments_sorted: for eq in equipments_sorted:
cost = eq.get("total_cost", 0.0) if total_cost + eq["cost"] <= budget:
if current_cost_total + cost <= budget:
selected.append(eq) selected.append(eq)
current_cost_total += cost total_cost += eq["cost"]
else: else:
excluded.append(eq) excluded.append(eq)
return selected, excluded return selected, excluded
def knapsack_selection(equipments: List[dict], budget: float, scale: Optional[float] = None) -> Tuple[List[dict], List[dict]]: def knapsack_selection(equipments: List[dict], budget: float, scale: int = 10_000_000) -> 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 dynamic scaling + 1D DP optimization to ensure accuracy for very large numbers. Uses scaling + 1D DP optimization to avoid MemoryError.
Falls back to greedy if W is too large.
""" """
if not equipments: n = len(equipments)
return [], []
# Pre-filter: strictly impossible items
eligible_items = []
strictly_excluded = []
for eq in equipments:
if eq["total_cost"] > budget:
strictly_excluded.append(eq)
else:
eligible_items.append(eq)
if not eligible_items: # Scale costs + budget
return [], strictly_excluded costs = [int(eq["total_cost"] // scale) for eq in equipments]
values = [eq["priority_score"] for eq in equipments]
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)
costs = [int(math.ceil(eq["total_cost"] / scale)) for eq in eligible_items]
values = [eq["priority_score"] for eq in eligible_items]
W = int(budget // scale) W = int(budget // scale)
# Fallback if W is still too large
if W > 1_000_000:
print("too big")
return greedy_selection(equipments, budget)
# 1D DP array # 1D DP array
dp = [0.0] * (W + 1) dp = [0.0] * (W + 1)
# 2D table for backtracking keep = [[False] * (W + 1) for _ in range(n)] # track selection choices
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]: 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 # Backtrack to find selected items
selected = [] selected, excluded = [], []
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(eligible_items[i]) selected.append(equipments[i])
w -= costs[i] w -= costs[i]
else: else:
backtrack_excluded.append(eligible_items[i]) excluded.append(equipments[i])
excluded = backtrack_excluded + strictly_excluded
# 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:
# Sort by priority score DESC then cost ASC
excluded.sort(key=lambda x: (-x["priority_score"], x["total_cost"]))
for eq in excluded[:]:
if eq["total_cost"] <= remaining_budget:
selected.append(eq)
excluded.remove(eq)
remaining_budget -= eq["total_cost"]
return selected, excluded return selected, excluded

@ -1,46 +1,16 @@
import asyncio
from typing import Dict, List, Optional from typing import Dict, List, Optional
from typing_extensions import Annotated
from temporalio.client import Client
from fastapi import APIRouter, HTTPException, status from fastapi import APIRouter, HTTPException, status
from fastapi.params import Query from fastapi.params import Query
from src.calculation_target_reliability.utils import wait_for_workflow
from src.config import TEMPORAL_URL, TR_RBD_ID
from src.database.core import DbSession, CollectorDbSession 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
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(
@ -64,21 +34,12 @@ async def get_target_reliability(
db_session: DbSession, db_session: DbSession,
token: Token, token: Token,
collector_db: CollectorDbSession, collector_db: CollectorDbSession,
params: Annotated[TargetReliabiltiyQuery, Query()], oh_session_id: Optional[str] = Query(None),
# oh_session_id: Optional[str] = Query(None), eaf_input: float = Query(99.8),
# eaf_input: float = Query(99.8), duration: int = Query(8760),
# duration: int = Query(17520), simulation_id: Optional[str] = Query(None)
# simulation_id: Optional[str] = Query(None),
# cut_hours = Query(0)
): ):
"""Get all scope pagination.""" """Get all scope pagination."""
oh_session_id = params.oh_session_id
eaf_input = params.eaf_input
duration = params.duration
simulation_id = params.simulation_id
cut_hours = params.cut_hours
oh_duration = params.oh_duration
if not oh_session_id: if not oh_session_id:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
@ -92,39 +53,13 @@ async def get_target_reliability(
# oh_duration=duration # oh_duration=duration
# ) # )
if simulation_id: # simulation_id = await run_rbd_simulation(
try: # sim_hours=duration,
temporal_client = await Client.connect(TEMPORAL_URL) # token=token
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"]:
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(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Simulation not found or error checking status: {str(e)}",
)
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
if not simulation_id:
simulation_id = "default"
results = await get_simulation_results( results = await get_simulation_results(
simulation_id=simulation_id, simulation_id=simulation_id,
@ -137,10 +72,7 @@ async def get_target_reliability(
db_session=db_session, db_session=db_session,
oh_session_id=oh_session_id, oh_session_id=oh_session_id,
collector_db=collector_db, collector_db=collector_db,
simulation_id=simulation_id, simulation_id=simulation_id
duration=duration,
po_duration=oh_duration,
cut_hours=float(cut_hours)
) )

@ -39,8 +39,6 @@ class AssetWeight(OverhaulBase):
num_of_failures: int num_of_failures: int
down_time: float down_time: float
efficiency: float efficiency: float
improvement_impact:float
birbaum: float
class MaintenanceScenario(OverhaulBase): class MaintenanceScenario(OverhaulBase):
location_tag: str location_tag: str
@ -54,24 +52,12 @@ class MaintenanceScenario(OverhaulBase):
class OptimizationResult(OverhaulBase): class OptimizationResult(OverhaulBase):
current_plant_eaf: float current_plant_eaf: float
target_plant_eaf: float target_plant_eaf: float
possible_plant_eaf:float
eaf_gap: float eaf_gap: float
eaf_improvement_text:str
recommended_reduced_outage:Optional[float] = 0
warning_message:Optional[str]
asset_contributions: List[dict] asset_contributions: List[dict]
optimization_success: bool = False optimization_success: bool = False
simulation_id: Optional[str] = None simulation_id: Optional[str] = None
class TargetReliabiltiyQuery(DefultBase):
oh_session_id: Optional[str] = Field(None)
eaf_input: float = Field(99.8)
duration: int = Field(17520)
simulation_id: Optional[str] = Field(None)
cut_hours:int = Field(0)
oh_duration: int = Field(1200)
# { # {
# "overview": { # "overview": {
# "totalEquipment": 30, # "totalEquipment": 30,

@ -1,10 +1,8 @@
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, select from sqlalchemy import Delete, 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.contribution_util import calculate_contribution, calculate_contribution_accurate from src.contribution_util import calculate_contribution, calculate_contribution_accurate
from src.database.core import DbSession, CollectorDbSession from src.database.core import DbSession, CollectorDbSession
from datetime import datetime, timedelta from datetime import datetime, timedelta
@ -18,35 +16,18 @@ from .schema import AssetWeight,MaintenanceScenario,OptimizationResult
from src.overhaul_activity.service import get_standard_scope_by_session_id from src.overhaul_activity.service import get_standard_scope_by_session_id
client = httpx.AsyncClient(timeout=300.0) RBD_SERVICE_API = "http://192.168.1.82:8000/rbd"
from src.calculation_time_constrains.model import CalculationData client = httpx.AsyncClient(timeout=300.0)
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"}
async def run_rbd_simulation(*, sim_hours: int, token):
sim_data = { sim_data = {
"SimulationName": f"Simulasi TR OH {sim_hours}_{oh_duration}", "SimulationName": "Simulation OH Reliability Target",
"SchematicName": "- TJB - Unit 3 -", "SchematicName": "- TJB - Unit 3 -",
"SimSeed": 1, "SimSeed": 1,
"SimDuration": sim_hours, "SimDuration": sim_hours,
"OverhaulInterval": max(sim_hours - oh_duration - 1, 1),
"DurationUnit": "UHour", "DurationUnit": "UHour",
"SimNumRun": 1,
"IsDefault": False,
"OverhaulDuration": oh_duration
} }
headers = { headers = {
@ -59,23 +40,7 @@ async def run_rbd_simulation(*, sim_hours: int, oh_duration: int = 1200, oh_sess
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()
resp_json = response.json() return 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 = {
@ -109,111 +74,77 @@ async def get_simulation_results(*, simulation_id: str, token: str):
"plant_result": plant_data "plant_result": plant_data
} }
def calculate_asset_eaf_contributions(plant_result, eq_results, standard_scope, eaf_gap, scheduled_outage): def calculate_asset_eaf_contributions(plant_result, eq_results, standard_scope, eaf_gap):
""" """
Calculate each asset's contribution to plant EAF with realistic, fair improvement allocation. Calculate each asset's contribution to plant EAF with realistic improvement potential.
The total EAF gap is distributed among assets proportionally to their contribution potential. Ranking:
Automatically skips equipment with no unplanned downtime (only scheduled outages). 1. Highest contribution (Birnbaum Importance)
2. Tie-breaker: Contribution per downtime (efficiency)
""" """
eaf_gap_fraction = eaf_gap / 100.0 if eaf_gap > 1.0 else eaf_gap eaf_gap_fraction = eaf_gap / 100.0 if eaf_gap > 1.0 else eaf_gap
total_hours = plant_result.get("total_uptime") + plant_result.get("total_downtime") MIN_BIRNBAUM_IMPORTANCE = 0.0005
plant_operating_fraction = (total_hours - scheduled_outage) / total_hours REALISTIC_MAX_AVAILABILITY = 0.995 # 99.5%
MIN_IMPROVEMENT_PERCENT = 0.005 # 0.5%
REALISTIC_MAX_TECHNICAL = 0.995
REALISTIC_MAX_AVAILABILITY = REALISTIC_MAX_TECHNICAL * plant_operating_fraction
MIN_IMPROVEMENT_PERCENT = 0.0001
min_improvement_fraction = MIN_IMPROVEMENT_PERCENT / 100.0 min_improvement_fraction = MIN_IMPROVEMENT_PERCENT / 100.0
EPSILON = 0.001 # 1 ms or a fraction of an hour for comparison tolerance
results = [] results = []
weighted_assets = []
# Step 1: Collect eligible assets and their weights
for asset in eq_results: for asset in eq_results:
node = asset.get("aeros_node") asset_name = asset.get("aeros_node").get("node_name")
if not node:
continue
asset_name = node.get("node_name")
num_of_events = asset.get("num_events", 0)
if asset_name not in standard_scope: if asset_name not in standard_scope:
continue continue
contribution_factor = asset.get("contribution_factor", 0.0) birnbaum = asset.get("contribution", 0.0)
birbaum = asset.get("contribution", 0.0)
current_availability = asset.get("availability", 0.0) current_availability = asset.get("availability", 0.0)
downtime = asset.get("total_downtime", 0.0) downtime = asset.get("total_downtime", 0.0)
# --- NEW: Skip equipment with no failures and near-maximum availability --- # Filter 1: Importance too low
if ( if birnbaum < MIN_BIRNBAUM_IMPORTANCE:
num_of_events < 2 # no unplanned events
or contribution_factor <= 0
):
# This equipment has nothing to improve realistically
continue continue
# Max possible availability improvement
# --- Compute realistic possible improvement ---
if REALISTIC_MAX_AVAILABILITY > current_availability:
max_possible_improvement = REALISTIC_MAX_AVAILABILITY - current_availability max_possible_improvement = REALISTIC_MAX_AVAILABILITY - current_availability
else: if max_possible_improvement <= 0:
max_possible_improvement = 0.0 # No improvement possible continue
# Compute weighted importance (Birnbaum × FV)
raw_weight = birbaum
weight = math.sqrt(max(raw_weight, 0.0))
weighted_assets.append((asset, weight, 0))
# Step 2: Compute total weight
total_weight = sum(w for _, w, _ in weighted_assets) or 1.0
# Step 3: Distribute improvement proportionally to weight # Required improvement (limited by plant gap and availability ceiling)
for asset, weight, max_possible_improvement in weighted_assets: required_impr = min(eaf_gap_fraction, max_possible_improvement) * birnbaum
node = asset.get("aeros_node")
contribution_factor = asset.get("contribution_factor", 0.0)
birbaum = asset.get("contribution", 0.0)
current_availability = asset.get("availability", 0.0)
downtime = asset.get("total_downtime", 0.0)
required_improvement = eaf_gap_fraction * (weight/total_weight) # Filter 2: Improvement too small
required_improvement = min(required_improvement, max_possible_improvement) if required_impr < min_improvement_fraction:
required_improvement = max(required_improvement, min_improvement_fraction) continue
improvement_impact = required_improvement * contribution_factor # Contribution efficiency (secondary metric)
efficiency = birbaum / downtime if downtime > 0 else birbaum efficiency = birnbaum / downtime if downtime > 0 else birnbaum
contribution = AssetWeight( contribution = AssetWeight(
node=node, node=asset.get("aeros_node"),
availability=current_availability, availability=current_availability,
contribution=contribution_factor, contribution=birnbaum,
required_improvement=required_improvement, required_improvement=required_impr,
improvement_impact=improvement_impact,
num_of_failures=asset.get("num_events", 0), num_of_failures=asset.get("num_events", 0),
down_time=downtime, down_time=downtime,
efficiency=efficiency, efficiency= efficiency
birbaum=birbaum,
) )
results.append(contribution) results.append(contribution)
# Step 4: Sort by Birnbaum importance # Sort: 1) contribution (desc), 2) efficiency (desc)
results.sort(key=lambda x: x.birbaum, reverse=True) results.sort(key=lambda x: (x.contribution, x.efficiency), reverse=True)
return results return results
def project_eaf_improvement(asset: AssetWeight, improvement_factor: float = 0.3) -> float: def project_eaf_improvement(asset: AssetWeight, improvement_factor: float = 0.3) -> float:
""" """
Project EAF improvement after maintenance Project EAF improvement after maintenance
This is a simplified model - you should replace with your actual prediction logic This is a simplified model - you should replace with your actual prediction logic
""" """
current_downtime_pct = 100 - asset.eaf current_downtime_pct = 100 - asset.eaf
# Assume maintenance reduces downtime by improvement_factor
improved_downtime_pct = current_downtime_pct * (1 - improvement_factor) improved_downtime_pct = current_downtime_pct * (1 - improvement_factor)
projected_eaf = 100 - improved_downtime_pct projected_eaf = 100 - improved_downtime_pct
return min(projected_eaf, 99.9) # Cap at 99.9% return min(projected_eaf, 99.9) # Cap at 99.9%
@ -226,69 +157,24 @@ async def identify_worst_eaf_contributors(
oh_session_id: str, oh_session_id: str,
collector_db: CollectorDbSession, collector_db: CollectorDbSession,
simulation_id: str, simulation_id: str,
duration: int,
po_duration: int,
cut_hours: float = 0, # new optional parameter: how many hours of planned outage user wants to cut
): ):
""" """
Identify equipment that contributes most to plant EAF reduction, Identify equipment that contributes most to plant EAF reduction
evaluate if target EAF is physically achievable, and optionally in order to reach a target EAF.
calculate the additional improvement if user cuts scheduled outage.
""" """
# Extract results
calc_result = simulation_result["calc_result"] calc_result = simulation_result["calc_result"]
plant_result = simulation_result["plant_result"] plant_result = simulation_result["plant_result"]
# Ensure list of equipment
eq_results = calc_result if isinstance(calc_result, list) else [calc_result] eq_results = calc_result if isinstance(calc_result, list) else [calc_result]
# Base parameters # Current plant EAF and gap
current_plant_eaf = plant_result.get("eaf", 0) current_plant_eaf = plant_result.get("eaf", 0)
total_hours = duration
scheduled_outage = int(po_duration)
reduced_outage = max(scheduled_outage - cut_hours, 0)
max_eaf_possible = (total_hours - reduced_outage) / total_hours * 100
# Improvement purely from outage reduction (global)
scheduled_eaf_gain = (cut_hours / total_hours) * 100 if cut_hours > 0 else 0.0
# Target feasibility check
warning_message = None
if target_eaf > max_eaf_possible:
impossible_gap = target_eaf - max_eaf_possible
required_scheduled_hours = total_hours * (1 - target_eaf / 100)
required_reduction = reduced_outage - required_scheduled_hours
# Build dynamic phrase for clarity
if cut_hours > 0:
reduction_phrase = f" even after reducing planned outage by {cut_hours}h"
else:
reduction_phrase = ""
warning_message = (
f"⚠️ Target EAF {target_eaf:.2f}% exceeds theoretical maximum {max_eaf_possible:.2f}%"
f"{reduction_phrase}.\n"
f"To achieve it, planned outage must be further reduced by approximately "
f"{required_reduction:.1f} hours (from {reduced_outage:.0f}h → {required_scheduled_hours:.0f}h)."
)
# Cap target EAF to max achievable for calculation
target_eaf = max_eaf_possible
eaf_gap = (target_eaf - current_plant_eaf) / 100.0 eaf_gap = (target_eaf - current_plant_eaf) / 100.0
if eaf_gap <= 0:
return OptimizationResult(
current_plant_eaf=current_plant_eaf,
target_plant_eaf=target_eaf,
possible_plant_eaf=current_plant_eaf,
eaf_gap=0,
warning_message=warning_message or "Target already achieved or exceeded.",
asset_contributions=[],
optimization_success=True,
simulation_id=simulation_id,
eaf_improvement_text=""
)
# Get standard scope (equipment in OH) # Get standard scope (equipment allowed for overhaul/optimization)
standard_scope = await get_standard_scope_by_session_id( standard_scope = await get_standard_scope_by_session_id(
db_session=db_session, db_session=db_session,
overhaul_session_id=oh_session_id, overhaul_session_id=oh_session_id,
@ -296,92 +182,43 @@ async def identify_worst_eaf_contributors(
) )
standard_scope_location_tags = [tag.location_tag for tag in standard_scope] standard_scope_location_tags = [tag.location_tag for tag in standard_scope]
# Compute contributions for reliability improvements # Compute contributions
asset_contributions = calculate_asset_eaf_contributions( asset_contributions = calculate_asset_eaf_contributions(
plant_result, eq_results, standard_scope_location_tags, eaf_gap, reduced_outage plant_result, eq_results, standard_scope_location_tags, eaf_gap=eaf_gap
) )
# Greedy improvement allocation project_eaf_improvement = 0.0
project_eaf_improvement_total = 0.0
selected_eq = [] selected_eq = []
# Greedy select until gap is closed
for asset in asset_contributions: for asset in asset_contributions:
if project_eaf_improvement_total >= eaf_gap: if project_eaf_improvement >= eaf_gap:
break break
if (project_eaf_improvement_total + asset.improvement_impact) <= eaf_gap: if (project_eaf_improvement + asset.required_improvement) <= eaf_gap:
selected_eq.append(asset) selected_eq.append(asset)
project_eaf_improvement_total += asset.improvement_impact project_eaf_improvement += asset.required_improvement
else: else:
# allow overshoot tolerance by skipping large ones, continue with smaller ones
continue continue
# Total EAF after improvements + optional outage cut # Build output with efficiency included
possible_eaf_plant = current_plant_eaf + project_eaf_improvement_total * 100 + scheduled_eaf_gain
possible_eaf_plant = min(possible_eaf_plant, max_eaf_possible)
selected_eq.sort(key=lambda x: x.birbaum, reverse=True)
required_cut_hours = 0
# --- 2. Optimization feasible but cannot reach target (underperformance case) ---
if possible_eaf_plant < target_eaf:
# Calculate shortfall
performance_gap = target_eaf - possible_eaf_plant
# Estimate how many scheduled outage hours must be reduced to close the remaining gap
# Each hour reduced adds (1 / total_hours) * 100 % to plant EAF
required_cut_hours = (performance_gap / 100) * total_hours
reliability_limit_msg = (
f"⚠️ Optimization was unable to reach target EAF {target_eaf:.2f}%.\n"
f"The best achievable EAF based on current reliability is "
f"{possible_eaf_plant:.2f}% (short by {performance_gap:.2f}%)."
)
# Add actionable recommendation
recommendation_msg = (
f"To achieve the target EAF, consider reducing planned outage by approximately "
f"{required_cut_hours:.1f} hours or {int(required_cut_hours/24)} days (from {reduced_outage:.0f}h → {reduced_outage - required_cut_hours:.0f}h)."
)
if warning_message:
warning_message = warning_message + "\n\n" + reliability_limit_msg + "\n" + recommendation_msg
else:
warning_message = reliability_limit_msg + "\n" + recommendation_msg
# --- EAF improvement reporting ---
eaf_improvement_points = (possible_eaf_plant - current_plant_eaf)
# Express as text for user readability
if eaf_improvement_points > 0:
improvement_text = f"{eaf_improvement_points:.6f} percentage points increase"
else:
improvement_text = "No measurable improvement achieved"
# Build result
return OptimizationResult( return OptimizationResult(
current_plant_eaf=current_plant_eaf, current_plant_eaf=current_plant_eaf,
target_plant_eaf=target_eaf, target_plant_eaf=target_eaf,
possible_plant_eaf=possible_eaf_plant,
eaf_gap=eaf_gap, eaf_gap=eaf_gap,
warning_message=warning_message, # numeric
eaf_improvement_text=improvement_text,
recommended_reduced_outage=required_cut_hours,
asset_contributions=[ asset_contributions=[
{ {
"node": asset.node, "node": asset.node,
"availability": asset.availability, "availability": asset.availability,
"contribution": asset.contribution, "contribution": asset.contribution,
"sensitivy": asset.birbaum,
"required_improvement": asset.required_improvement, "required_improvement": asset.required_improvement,
"system_impact": asset.improvement_impact,
"num_of_failures": asset.num_of_failures, "num_of_failures": asset.num_of_failures,
"down_time": asset.down_time, "down_time": asset.down_time,
"efficiency": asset.efficiency, "efficiency": asset.efficiency,
} }
for asset in selected_eq for asset in selected_eq
], ],
outage_reduction_hours=cut_hours, optimization_success=(current_plant_eaf + project_eaf_improvement) >= target_eaf,
optimization_success=(current_plant_eaf + project_eaf_improvement_total * 100 + scheduled_eaf_gain)
>= target_eaf,
simulation_id=simulation_id, simulation_id=simulation_id,
) )

@ -1,10 +1,6 @@
import asyncio
from datetime import datetime, timedelta from datetime import datetime, timedelta
import random import random
from typing import List, Optional from typing import List, Optional
from temporalio.client import Client
from src.config import TEMPORAL_URL, TR_RBD_ID
def generate_down_periods(start_date: datetime, end_date: datetime, def generate_down_periods(start_date: datetime, end_date: datetime,
num_periods: Optional[int] = None, min_duration: int = 3, num_periods: Optional[int] = None, min_duration: int = 3,
@ -56,36 +52,3 @@ def generate_down_periods(start_date: datetime, end_date: datetime,
down_periods.append((period_start, period_end)) down_periods.append((period_start, period_end))
return sorted(down_periods) return sorted(down_periods)
async def wait_for_workflow(simulation_id, max_retries=3):
workflow_id = f"simulation-{simulation_id}" # use returned ID
retries = 0
temporal_client = await Client.connect(TEMPORAL_URL)
while True:
try:
handle = temporal_client.get_workflow_handle(workflow_id=workflow_id)
desc = await handle.describe()
status = desc.status.name
if status not in ["RUNNING", "CONTINUED_AS_NEW"]:
print(f"✅ Workflow {workflow_id} finished with status: {status}")
break
print(f"⏳ Workflow {workflow_id} still {status}, checking again in 10s...")
except Exception as e:
retries += 1
if retries > max_retries:
print(f"⚠️ Workflow {workflow_id} not found after {max_retries} retries, treating as done. Error: {e}")
break
else:
print(f"⚠️ Workflow {workflow_id} not found (retry {retries}/{max_retries}), waiting 10s before retry...")
await asyncio.sleep(10)
continue
retries = 0 # reset retries if describe() worked
await asyncio.sleep(30)
return simulation_id

@ -7,7 +7,6 @@ from sqlalchemy import Select, func, select
from sqlalchemy.orm import joinedload from sqlalchemy.orm import joinedload
from src.auth.service import Token from src.auth.service import Token
from src.config import TC_RBD_ID
from src.database.core import DbSession from src.database.core import DbSession
from src.overhaul_scope.service import get_all from src.overhaul_scope.service import get_all
from src.standard_scope.model import StandardScope from src.standard_scope.model import StandardScope
@ -21,7 +20,8 @@ from .service import (create_calculation_result_service, create_param_and_data,
get_avg_cost_by_asset, get_avg_cost_by_asset,
get_calculation_by_reference_and_parameter, get_calculation_by_reference_and_parameter,
get_calculation_data_by_id, get_calculation_result, get_calculation_data_by_id, get_calculation_result,
run_simulation_with_spareparts) get_corrective_cost_time_chart,
get_overhaul_cost_by_time_chart, run_simulation, run_simulation_with_spareparts)
from src.database.core import CollectorDbSession from src.database.core import CollectorDbSession
@ -86,106 +86,22 @@ async def create_calculation(
db_session: DbSession, db_session: DbSession,
collector_db_session: CollectorDbSession, collector_db_session: CollectorDbSession,
calculation_time_constrains_in: CalculationTimeConstrainsParametersCreate, calculation_time_constrains_in: CalculationTimeConstrainsParametersCreate,
created_by: str, created_by: str
simulation_id
): ):
from temporalio.client import Client
from src.config import TEMPORAL_URL
from temporal.temporal_workflows import OptimumOHCalculationWorkflow
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( calculation_data = await create_param_and_data(
db_session=db_session, db_session=db_session,
calculation_param_in=calculation_time_constrains_in, calculation_param_in=calculation_time_constrains_in,
created_by=created_by 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
)
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
# 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( # results = await create_calculation_result_service(
db_session=db_session, # db_session=db_session, calculation=calculation_data, token=token
calculation=calculation_data, # )
token=token, results = await run_simulation_with_spareparts(
collector_db_session=collector_db_session, db_session=db_session, calculation=calculation_data, token=token, collector_db_session=collector_db_session
simulation_id=rbd_simulation_id
) )
from .service import get_calculation_result return results["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( async def get_or_create_scope_equipment_calculation(

@ -70,13 +70,6 @@ class CalculationData(Base, DefaultMixin, IdentityMixin):
max_interval = Column(Integer, nullable=True) max_interval = Column(Integer, nullable=True)
rbd_simulation_id = Column(UUID(as_uuid=True), 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")
parameter = relationship("CalculationParam", back_populates="calculation_data") parameter = relationship("CalculationParam", back_populates="calculation_data")
@ -85,10 +78,8 @@ class CalculationData(Base, DefaultMixin, IdentityMixin):
"CalculationEquipmentResult", lazy="raise", viewonly=True "CalculationEquipmentResult", lazy="raise", viewonly=True
) )
results = relationship("CalculationResult", lazy="raise", viewonly=True) results = relationship("CalculationResult", lazy="raise", viewonly=True)
@classmethod @classmethod
async def create_with_param( async def create_with_param(
cls, cls,
@ -151,7 +142,6 @@ 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)
@ -162,7 +152,6 @@ class CalculationEquipmentResult(Base, DefaultMixin):
optimum_day = Column(Integer, default=1) optimum_day = Column(Integer, default=1)
is_included = Column(Boolean, default=True) is_included = Column(Boolean, default=True)
procurement_details = Column(JSON, nullable=True) procurement_details = Column(JSON, nullable=True)
is_initial = Column(Boolean, default=True)
master_equipment = relationship( master_equipment = relationship(
"MasterEquipment", "MasterEquipment",

@ -1,12 +1,9 @@
from typing import Annotated, List, Optional, Union from typing import List, Optional, Union
from fastapi import APIRouter from fastapi import APIRouter
from fastapi.params import Query from fastapi.params import Query
import requests
from src import config from src.auth.service import CurrentUser, Token
from src.auth.service import CurrentUser, InternalKey, Token
from src.config import DEFAULT_TC_ID
from src.database.core import DbSession from src.database.core import DbSession
from src.models import StandardResponse from src.models import StandardResponse
@ -18,17 +15,16 @@ from .schema import (CalculationResultsRead,
CalculationTimeConstrainsParametersCreate, CalculationTimeConstrainsParametersCreate,
CalculationTimeConstrainsParametersRead, CalculationTimeConstrainsParametersRead,
CalculationTimeConstrainsParametersRetrive, CalculationTimeConstrainsParametersRetrive,
CalculationTimeConstrainsRead, CreateCalculationQuery, EquipmentResult, CalculationTimeConstrainsReadNoResult) CalculationTimeConstrainsRead, EquipmentResult)
from .service import (bulk_update_equipment, get_calculation_result, from .service import (bulk_update_equipment, get_calculation_result,
get_calculation_result_by_day, get_calculation_by_assetnum, get_all_calculations) get_calculation_result_by_day, get_calculation_by_assetnum)
from src.database.core import CollectorDbSession from src.database.core import CollectorDbSession
router = APIRouter() router = APIRouter()
get_calculation = APIRouter()
@router.post( @router.post(
"", response_model=StandardResponse[Union[dict, CalculationTimeConstrainsRead]] "", response_model=StandardResponse[Union[str, CalculationTimeConstrainsRead]]
) )
async def create_calculation_time_constrains( async def create_calculation_time_constrains(
token: Token, token: Token,
@ -36,16 +32,10 @@ async def create_calculation_time_constrains(
collector_db_session: CollectorDbSession, collector_db_session: CollectorDbSession,
current_user: CurrentUser, current_user: CurrentUser,
calculation_time_constrains_in: CalculationTimeConstrainsParametersCreate, calculation_time_constrains_in: CalculationTimeConstrainsParametersCreate,
params: Annotated[CreateCalculationQuery, Query()], scope_calculation_id: Optional[str] = Query(None),
# scope_calculation_id: Optional[str] = Query(None), with_results: Optional[int] = Query(0),
# with_results: Optional[int] = Query(0),
# simulation_id = Query(None)
): ):
"""Save calculation time constrains Here""" """Save calculation time constrains Here"""
scope_calculation_id = params.scope_calculation_id
with_results = params.with_results
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(
@ -60,34 +50,9 @@ async def create_calculation_time_constrains(
collector_db_session=collector_db_session, collector_db_session=collector_db_session,
calculation_time_constrains_in=calculation_time_constrains_in, calculation_time_constrains_in=calculation_time_constrains_in,
created_by=current_user.name, 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",
) )
return StandardResponse(data=str(results), message="Data created successfully")
@router.get( @router.get(
@ -114,21 +79,14 @@ async def get_calculation_parameters(
) )
@get_calculation.get( @router.get(
"/{calculation_id}", response_model=StandardResponse[CalculationTimeConstrainsRead] "/{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")): async def get_calculation_results(db_session: DbSession, calculation_id):
if calculation_id == 'default':
calculation_id = DEFAULT_TC_ID
results = await get_calculation_result( results = await get_calculation_result(
db_session=db_session, calculation_id=calculation_id, token=token, include_risk_cost=include_risk_cost db_session=db_session, calculation_id=calculation_id
) )
# requests.post(f"{config.AUTH_SERVICE_API}/sign-out", headers={
# "Authorization": f"Bearer {token}"
# })
return StandardResponse( return StandardResponse(
data=results, data=results,
message="Data retrieved successfully", message="Data retrieved successfully",
@ -170,15 +128,12 @@ async def get_simulation_result(
) )
@router.post("/update/{calculation_id}", response_model=StandardResponse[List[str]]) @router.put("/{calculation_id}", response_model=StandardResponse[List[str]])
async def update_selected_equipment( async def update_selected_equipment(
db_session: DbSession, db_session: DbSession,
calculation_id, calculation_id,
calculation_time_constrains_in: List[CalculationSelectedEquipmentUpdate], calculation_time_constrains_in: List[CalculationSelectedEquipmentUpdate],
): ):
if calculation_id == 'default':
calculation_id = "3b9a73a2-bde6-418c-9e2f-19046f501a05"
results = await bulk_update_equipment( results = await bulk_update_equipment(
db=db_session, db=db_session,
selected_equipments=calculation_time_constrains_in, selected_equipments=calculation_time_constrains_in,
@ -189,42 +144,3 @@ 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")

@ -1,4 +1,3 @@
from src.overhaul_scope.schema import ScopeRead
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime from datetime import datetime
from typing import Any, Dict, List, Optional, Union from typing import Any, Dict, List, Optional, Union
@ -37,7 +36,6 @@ 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):
@ -59,8 +57,6 @@ 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
@ -74,7 +70,6 @@ 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
@ -108,19 +103,6 @@ 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):
id: UUID
created_at: datetime
optimum_oh_day: Optional[int]
max_interval: Optional[int]
optimum_analysis: Optional[dict]
session: ScopeRead
# optimum_oh_day: int
# max_interval: int
# optimal_analysis: dict
# analysis_metadata: dict
class CalculationTimeConstrainsRead(CalculationTimeConstrainsBase): class CalculationTimeConstrainsRead(CalculationTimeConstrainsBase):
id: UUID id: UUID
@ -146,9 +128,3 @@ class CalculationTimeConstrainsSimulationRead(CalculationTimeConstrainsBase):
class CalculationSelectedEquipmentUpdate(CalculationTimeConstrainsBase): class CalculationSelectedEquipmentUpdate(CalculationTimeConstrainsBase):
is_included: bool is_included: bool
location_tag: str location_tag: str
name:str
class CreateCalculationQuery(DefultBase):
scope_calculation_id: Optional[str] = Field(None)
with_results: Optional[int] = Field(0)
simulation_id: Optional[UUID] = Field(None)

File diff suppressed because it is too large Load Diff

@ -1,10 +1,11 @@
import datetime import datetime
import json import json
import numpy as np
import pandas as pd import pandas as pd
import requests import requests
from src.config import RBD_SERVICE_API from src.config import REALIBILITY_SERVICE_API
def get_months_between(start_date: datetime.datetime, end_date: datetime.datetime) -> int: def get_months_between(start_date: datetime.datetime, end_date: datetime.datetime) -> int:
""" """
@ -15,42 +16,39 @@ def get_months_between(start_date: datetime.datetime, end_date: datetime.datetim
return months return months
def create_time_series_data(chart_data, max_hours=None): def create_time_series_data(chart_data, max_hours=24096):
# Filter out ON_OH # Filter out data points with currentEvent = "ON_OH"
filtered_data = [d for d in chart_data if d["currentEvent"] != "ON_OH"] filtered_data = [data for data in chart_data if data['currentEvent'] != 'ON_OH']
sorted_data = sorted(filtered_data, key=lambda x: x["cumulativeTime"])
# Sort filtered data by cumulative time
sorted_data = sorted(filtered_data, key=lambda x: x['cumulativeTime'])
if not sorted_data: if not sorted_data:
return [] return []
hourly_data = [] hourly_data = []
current_state_index = 0 current_state_index = 0
current_flow_rate = sorted_data[0]["flowRate"] current_flow_rate = sorted_data[0]['flowRate']
current_eq_status = sorted_data[0]["currentEQStatus"] current_eq_status = sorted_data[0]['currentEQStatus']
# Determine maximum bound (either given or from data)
last_time = int(sorted_data[-1]["cumulativeTime"])
if max_hours is None:
max_hours = last_time
for hour in range(0, max_hours + 1): # start from 0 for hour in range(1, max_hours + 1):
# Advance state if needed # Check if we need to advance to the next state
while (current_state_index < len(sorted_data) - 1 and while (current_state_index < len(sorted_data) - 1 and
hour >= sorted_data[current_state_index + 1]["cumulativeTime"]): hour >= int(sorted_data[current_state_index + 1]['cumulativeTime'])):
current_state_index += 1 current_state_index += 1
current_flow_rate = sorted_data[current_state_index]["flowRate"] current_flow_rate = sorted_data[current_state_index]['flowRate']
current_eq_status = sorted_data[current_state_index]["currentEQStatus"] current_eq_status = sorted_data[current_state_index]['currentEQStatus']
# Add hourly data point
hourly_data.append({ hourly_data.append({
"cumulativeTime": hour, 'cumulativeTime': hour,
"flowRate": current_flow_rate, 'flowRate': current_flow_rate,
"currentEQStatus": current_eq_status 'currentEQStatus': current_eq_status
}) })
return hourly_data return hourly_data
def calculate_failures_per_month(hourly_data): def calculate_failures_per_month(hourly_data):
""" """
Calculate the cumulative number of failures up to each month from hourly data. Calculate the cumulative number of failures up to each month from hourly data.
@ -68,7 +66,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['cumulativeTime'] hour = data_point['hour']
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,7 +76,6 @@ 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
@ -100,152 +97,96 @@ def calculate_failures_per_month(hourly_data):
return result return result
def calculate_oos_per_month(hourly_data): def analyze_monthly_metrics(timestamp_outs):
""" """
Calculate the discrete OOS hours for each month from hourly data. Analyze time series data to calculate monthly metrics:
A failure is defined as when currentEQStatus = "OoS". 1. Failure count per month
2. Cumulative failure count each month
Args: 3. Total out-of-service time per month
hourly_data: List of dicts with 'cumulativeTime', 'flowRate', and 'currentEQStatus' keys 4. Average flow rate per month
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 datetime
import datetime
import pandas as pd
async def plant_simulation_metrics(simulation_id: str, location_tag: str, max_interval, token, last_oh_date, use_location_tag: int = 1):
"""Get failure predictions for equipment from simulation service"""
calc_result_url = f"{RBD_SERVICE_API}/aeros/simulation/result/calc/{simulation_id}/{location_tag}"
try:
response = requests.get(
calc_result_url,
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {token}",
},
timeout=30
)
response.raise_for_status()
prediction_data = response.json()['data']
except (requests.RequestException, ValueError) as e:
raise Exception(str(e))
# Check if timestamp_outs is None or empty
if timestamp_outs is None or not timestamp_outs:
# Return empty results with zero values
return {}
return prediction_data # Convert to DataFrame for easier manipulation
df = pd.DataFrame(timestamp_outs)
def analyze_monthly_metrics(timestamp_outs, start_date, max_flow_rate: float = 550): # Check if DataFrame is empty after creation
if not timestamp_outs: if df.empty:
return {} return {}
df = pd.DataFrame(timestamp_outs) # Check if required columns exist
required_columns = ['cumulativeTime', 'currentEQStatus', 'flowRate'] required_columns = ['cumulativeTime', 'currentEQStatus', 'flowRate']
if not all(col in df.columns for col in required_columns): missing_columns = [col for col in required_columns if col not in df.columns]
if missing_columns:
return {} return {}
start_oh = datetime.datetime(start_date.year, start_date.month, start_date.day) # Assuming the simulation starts from a reference date (you can modify this)
# For this example, I'll use January 1, 2024 as the start date
# Actual datetime from cumulative hours start_date = datetime.datetime(2025, 10, 22)
df['datetime'] = df['cumulativeTime'].apply(lambda x: start_oh + datetime.timedelta(hours=x))
df['month_year'] = df['datetime'].dt.to_period('M')
# Duration until next timestamp
df['duration_hours'] = df['cumulativeTime'].shift(-1) - df['cumulativeTime']
df['duration_hours'] = df['duration_hours'].fillna(0)
# Failure detection # Convert cumulative hours to actual datetime
df['status_change'] = df['currentEQStatus'].shift() != df['currentEQStatus'] df['datetime'] = df['cumulativeTime'].apply(
df['failure'] = (df['currentEQStatus'] == 'OoS') & df['status_change'] lambda x: start_date + datetime.timedelta(hours=x)
)
# Cumulative tracking
df['cumulative_failures'] = df['failure'].cumsum()
df['cumulative_oos'] = (df['duration_hours'] * (df['currentEQStatus'] == 'OoS')).cumsum()
# Derating calculation # Extract month-year for grouping
# Derating = capacity reduction below max but not outage df['month_year'] = df['datetime'].dt.to_period('M')
df['derating'] = (max_flow_rate - df['flowRate']).clip(lower=0)
df['is_derated'] = (df['currentEQStatus'] == 'Svc') & (df['derating'] > 0)
# Equivalent Derated Hours (EFDH) → sum of derating * hours, then normalized by max capacity # Calculate time duration for each record (difference between consecutive cumulative times)
df['derated_mwh'] = df['derating'] * df['duration_hours'] df['duration_hours'] = df['cumulativeTime'].diff().fillna(df['cumulativeTime'].iloc[0])
df['derated_hours_equivalent'] = df['derated_mwh'] / max_flow_rate
# Initialize results dictionary
monthly_results = {} monthly_results = {}
for month_period, group in df.groupby('month_year', sort=True): # Track cumulative failures across all months
cumulative_failures = 0
cummulative_oos = 0
# Group by month-year and ensure chronological order
for month_period, group in df.groupby('month_year'):
month_str = str(month_period) month_str = str(month_period)
monthly_results[month_str] = {} monthly_results[month_str] = {}
# Failures # 1. Count failures per month
monthly_results[month_str]['failures_count'] = int(group['failure'].sum()) # A failure is when currentEQStatus changes from "Svc" to "OoS"
monthly_results[month_str]['cumulative_failures'] = int(group['cumulative_failures'].max()) status_changes = group['currentEQStatus'].shift() != group['currentEQStatus']
failures = ((group['currentEQStatus'] == 'OoS') & status_changes).sum()
monthly_results[month_str]['failures_count'] = int(failures)
# 2. Add failures to cumulative count
cumulative_failures += failures
monthly_results[month_str]['cumulative_failures'] = int(cumulative_failures)
# OOS hours # 3. Total out-of-service time per month (in hours)
oos_time = group.loc[group['currentEQStatus'] == 'OoS', 'duration_hours'].sum() oos_time = group[group['currentEQStatus'] == 'OoS']['duration_hours'].sum()
monthly_results[month_str]['total_oos_hours'] = float(oos_time) monthly_results[month_str]['total_oos_hours'] = float(oos_time)
monthly_results[month_str]['cummulative_oos'] = float(group['cumulative_oos'].max())
# Flow rate (weighted average) cummulative_oos += oos_time
monthly_results[month_str]['cummulative_oos'] = float(cummulative_oos)
# 4. Average flow rate per month (weighted by duration)
# Calculate weighted average flow rate
total_flow_time = (group['flowRate'] * group['duration_hours']).sum() total_flow_time = (group['flowRate'] * group['duration_hours']).sum()
total_time = group['duration_hours'].sum() total_time = group['duration_hours'].sum()
avg_flow_rate = total_flow_time / total_time if total_time > 0 else 0 avg_flow_rate = total_flow_time / total_time if total_time > 0 else 0
monthly_results[month_str]['avg_flow_rate'] = float(avg_flow_rate) monthly_results[month_str]['avg_flow_rate'] = float(avg_flow_rate)
# Extra metrics # Additional useful metrics
monthly_results[month_str]['total_hours'] = float(total_time) monthly_results[month_str]['total_hours'] = float(total_time)
service_hours = group.loc[group['currentEQStatus'] == 'Svc', 'duration_hours'].sum() monthly_results[month_str]['service_hours'] = float(
monthly_results[month_str]['service_hours'] = float(service_hours) group[group['currentEQStatus'] == 'Svc']['duration_hours'].sum()
)
monthly_results[month_str]['availability_percentage'] = float( monthly_results[month_str]['availability_percentage'] = float(
(service_hours / total_time * 100) if total_time > 0 else 0 (monthly_results[month_str]['service_hours'] / total_time * 100) if total_time > 0 else 0
) )
# Derating metrics
derating_hours = group.loc[group['is_derated'], 'duration_hours'].sum()
derated_mwh = group['derated_mwh'].sum()
equivalent_derated_hours = group['derated_hours_equivalent'].sum()
monthly_results[month_str]['derating_hours'] = float(derating_hours)
monthly_results[month_str]['derated_mwh'] = float(derated_mwh)
monthly_results[month_str]['equivalent_derated_hours'] = float(equivalent_derated_hours)
return monthly_results return monthly_results
def calculate_risk_cost_per_failure(monthly_results, birnbaum_importance, energy_price): def calculate_risk_cost_per_failure(monthly_results, birnbaum_importance, energy_price):
""" """
Calculate risk cost per failure for each month based on: Calculate risk cost per failure for each month based on:
@ -336,10 +277,272 @@ def get_monthly_risk_analysis(timestamp_outs, birnbaum_importance, energy_price)
'risk_cost_array': risk_analysis['risk_cost_per_failure_array'] 'risk_cost_array': risk_analysis['risk_cost_per_failure_array']
} }
# Usage example:
# birnbaum_importance = 0.85 # Example value def fetch_reliability(location_tags):
# energy_price = 100 # Example: $100 per unit url = f"{REALIBILITY_SERVICE_API}/asset/batch"
# resp = requests.get(url, json={"location_tags": location_tags})
# results = get_monthly_risk_analysis(timestamp_outs, birnbaum_importance, energy_price) resp.raise_for_status()
# risk_cost_array = results['risk_cost_array'] return resp.json().get("data", [])
# print("Risk cost per failure each month:", risk_cost_array)
import math
from scipy.stats import lognorm, norm
def get_reliability(distribution: str, params: dict, t: float) -> float:
d = (distribution or "").lower()
if d in ["weibull_2p", "weibull_3p"]:
eta = params.get("eta"); beta = params.get("beta"); gamma_ = params.get("gamma", 0)
if eta is None or beta is None: return 1.0
if t <= gamma_: return 1.0
return math.exp(-((t - gamma_) / eta) ** beta)
elif d in ["exponential", "exponential_2p"]:
lam = params.get("lambda") or params.get("Lambda")
if lam is None: return 1.0
return math.exp(-lam * t)
elif "lognormal" in d:
mu = params.get("mu"); sigma = params.get("sigma"); gamma_ = params.get("gamma", 0)
if mu is None or sigma is None: return 1.0
return 1 - lognorm.cdf(max(t-gamma_,0), s=sigma, scale=math.exp(mu))
elif "normal" in d:
mu = params.get("mu"); sigma = params.get("sigma")
if mu is None or sigma is None: return 1.0
return 1 - norm.cdf(t, loc=mu, scale=sigma)
elif "nhpp" in d:
eta = params.get("eta")
beta = params.get("beta")
lam = params.get("lambda", 1)
if eta is None or beta is None:
return 1.0
if t <= 0:
return 1.0 # at time 0, survival = 1
return math.exp(-(t / eta) ** beta)
else:
return 1.0
import numpy as np
def failures_per_month(distribution, params, mttr, design_flow_rate=100,
population=1, months=24, hours_per_month=720,
mode="expected", runs=1):
"""
Calculate monthly failures, cumulative failures, downtime, and avg flowrate.
- mode="expected": returns smooth fractional expected values.
- mode="simulate": returns integer values per run (stochastic).
- runs: number of Monte Carlo runs (only used if simulate).
- If simulate with runs>1, returns P50 (median) summary across runs.
"""
all_runs = []
for r in range(runs):
results = []
cumulative = 0
total_oos_hours = 0
for m in range(1, months+1):
t_start = (m-1) * hours_per_month
t_end = m * hours_per_month
R_start = get_reliability(distribution, params, t_start)
R_end = get_reliability(distribution, params, t_end)
# Probability of failure in this month
prob_failure = max(0.0, R_start - R_end)
if mode == "expected":
failures = population * prob_failure # fractional
else: # simulate
failures = np.random.binomial(population, prob_failure)
cumulative += failures
# Downtime (failures × MTTR)
oos_hours = failures * mttr
total_oos_hours += oos_hours
service_hours = hours_per_month - oos_hours
if service_hours < 0:
service_hours = 0
# Availability = service / total
availability = service_hours / hours_per_month
# Avg flowrate scaled
avg_flowrate = design_flow_rate * availability
results.append({
"month": m,
"failures": failures,
"cumulative_failures": cumulative,
"oos_hours": oos_hours,
"total_oos_hours": total_oos_hours,
"service_hours": service_hours,
"availability": availability,
"avg_flowrate": avg_flowrate
})
all_runs.append(results)
# === OUTPUTS ===
if mode == "expected" or runs == 1:
return all_runs[0] # smooth or single trajectory
# === Summarize multiple runs (return only P50 for each field) ===
summary = []
keys = ["failures", "cumulative_failures", "oos_hours",
"total_oos_hours", "service_hours", "availability", "avg_flowrate"]
total_oos_hours = 0
cumulative = 0
for m in range(months):
row = {"month": m+1}
for key in keys:
values = [r[m][key] for r in all_runs]
if key == 'failures':
failures = float(np.percentile(values, 90)) # P50 median
oos_hours = failures * mttr
total_oos_hours += oos_hours
service_hours = hours_per_month - oos_hours
availability = service_hours / hours_per_month
avg_flowrate = design_flow_rate * availability
cumulative += failures
summary.append({
"month": m,
"failures": failures,
"cumulative_failures": cumulative,
"oos_hours": oos_hours,
"total_oos_hours": total_oos_hours,
"service_hours": service_hours,
"availability": availability,
"avg_flowrate": avg_flowrate
})
return summary
import pandas as pd
def get_reliability_data(location_tags, months=24):
# 1. Fetch parameters
data = fetch_reliability(location_tags)
all_results = []
for asset in data:
distribution = asset.get("distribution")
params = asset.get("parameters", {})
mttr = 3
tag = asset.get("location_tag")
# 2. Predict monthly
results = failures_per_month(distribution, params, mttr, design_flow_rate, months=months)
# 3. Store with location_tag
for row in results:
row["location_tag"] = tag
all_results.append(row)
return all_results
import numpy as np
import math
def sample_failure_time(distribution, params):
"""Draw one failure time from the reliability distribution."""
d = (distribution or "").lower()
u = np.random.rand()
if d in ["weibull_2p", "weibull_3p"]:
eta = params.get("eta"); beta = params.get("beta"); gamma_ = params.get("gamma", 0)
if eta is None or beta is None: return np.inf
return gamma_ + eta * (-math.log(1-u))**(1/beta)
elif "exponential" in d or "exponential_2p" in d:
lam = params.get("lambda") or params.get("Lambda")
if lam is None: return np.inf
return -math.log(1-u) / lam
elif "lognormal" in d:
mu = params.get("mu"); sigma = params.get("sigma"); gamma_ = params.get("gamma", 0)
if mu is None or sigma is None: return np.inf
return gamma_ + np.random.lognormal(mean=mu, sigma=sigma)
elif "normal" in d:
mu = params.get("mu"); sigma = params.get("sigma")
if mu is None or sigma is None: return np.inf
return max(0, np.random.normal(mu, sigma))
else:
return np.inf
def simulate_failures(distribution, params, mttr, design_flow_rate=100,
population=1, months=24, hours_per_month=720,
runs=1000):
"""
Simulate failures over a given horizon using renewal process.
Always in stochastic mode, results aggregated to P50 across runs.
"""
horizon = months * hours_per_month
all_runs = []
for r in range(runs):
results = []
failures_by_month = [0] * months
for _ in range(population):
# First failure
t = sample_failure_time(distribution, params)
while t < horizon:
month_idx = int(t // hours_per_month)
if month_idx < months:
failures_by_month[month_idx] += 1
# Renewal: after repair (mttr), draw new TTF
t += mttr + sample_failure_time(distribution, params)
# Build results for this run
cumulative = 0
total_oos_hours = 0
for m in range(months):
failures = failures_by_month[m]
cumulative += failures
oos_hours = failures * mttr
total_oos_hours += oos_hours
service_hours = max(0, hours_per_month - oos_hours)
availability = service_hours / hours_per_month
avg_flowrate = design_flow_rate * availability
results.append({
"month": m+1,
"failures": failures,
"cumulative_failures": cumulative,
"oos_hours": oos_hours,
"total_oos_hours": total_oos_hours,
"service_hours": service_hours,
"availability": availability,
"avg_flowrate": avg_flowrate
})
all_runs.append(results)
# === Aggregate to P50 ===
summary = []
for m in range(months):
row = {"month": m+1}
for key in ["failures", "cumulative_failures", "oos_hours",
"total_oos_hours", "service_hours", "availability", "avg_flowrate"]:
values = [r[m][key] for r in all_runs]
row[key] = float(np.percentile(values, 50)) # median
summary.append(row)
return summary

@ -45,9 +45,8 @@ def get_config():
config = get_config() config = get_config()
LOG_LEVEL = config("LOG_LEVEL", default="INFO") LOG_LEVEL = config("LOG_LEVEL", default=logging.WARNING)
ENV = (os.getenv("ENV") or os.getenv("ENVIRONMENT") or os.getenv("ENVIRONEMENT") or "production").strip() ENV = config("ENV", default="local")
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")
@ -84,14 +83,3 @@ MAXIMO_API_KEY = config("MAXIMO_API_KEY", default="keys")
AUTH_SERVICE_API = config("AUTH_SERVICE_API", default="http://192.168.1.82:8000/auth") AUTH_SERVICE_API = config("AUTH_SERVICE_API", default="http://192.168.1.82:8000/auth")
REALIBILITY_SERVICE_API = config("REALIBILITY_SERVICE_API", default="http://192.168.1.82:8000/reliability") REALIBILITY_SERVICE_API = config("REALIBILITY_SERVICE_API", default="http://192.168.1.82:8000/reliability")
RBD_SERVICE_API = config("RBD_SERVICE_API", default="http://192.168.1.82:8000/rbd")
TEMPORAL_URL = config("TEMPORAL_URL", default="http://192.168.1.86:7233")
API_KEY = config("API_KEY", default="0KFvcB7zWENyKVjoma9FKZNofVSViEshYr59zEQNGaYjyUP34gCJKDuqHuk9VfvE")
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")
DEFAULT_TC_ID = config("DEFAULT_TC_ID", default="44f483f3-bfe4-4094-a59f-b97a10f2fea6")
TEMPORAL_URL

@ -1,42 +0,0 @@
from contextvars import ContextVar
from typing import Optional, Final
REQUEST_ID_CTX_KEY: Final[str] = "request_id"
USER_ID_CTX_KEY: Final[str] = "user_id"
USERNAME_CTX_KEY: Final[str] = "username"
ROLE_CTX_KEY: Final[str] = "role"
_request_id_ctx_var: ContextVar[Optional[str]] = ContextVar(REQUEST_ID_CTX_KEY, default=None)
_user_id_ctx_var: ContextVar[Optional[str]] = ContextVar(USER_ID_CTX_KEY, default=None)
_username_ctx_var: ContextVar[Optional[str]] = ContextVar(USERNAME_CTX_KEY, default=None)
_role_ctx_var: ContextVar[Optional[str]] = ContextVar(ROLE_CTX_KEY, default=None)
def get_request_id() -> Optional[str]:
return _request_id_ctx_var.get()
def set_request_id(request_id: str):
return _request_id_ctx_var.set(request_id)
def reset_request_id(token):
_request_id_ctx_var.reset(token)
def get_user_id() -> Optional[str]:
return _user_id_ctx_var.get()
def set_user_id(user_id: str):
return _user_id_ctx_var.set(user_id)
def get_username() -> Optional[str]:
return _username_ctx_var.get()
def set_username(username: str):
return _username_ctx_var.set(username)
def get_role() -> Optional[str]:
return _role_ctx_var.get()
def set_role(role: str):
return _role_ctx_var.set(role)

@ -256,12 +256,12 @@ def calculate_contribution_accurate(availabilities: Dict[str, float], structure_
key=lambda x: x[1]['birnbaum_importance'], key=lambda x: x[1]['birnbaum_importance'],
reverse=True) reverse=True)
# print("\n=== COMPONENT IMPORTANCE ANALYSIS ===") print("\n=== COMPONENT IMPORTANCE ANALYSIS ===")
# print(f"System Availability: {system_info['system_availability']:.6f} ({system_info['system_availability']*100:.4f}%)") print(f"System Availability: {system_info['system_availability']:.6f} ({system_info['system_availability']*100:.4f}%)")
# print(f"System Unavailability: {system_info['system_unavailability']:.6f}") print(f"System Unavailability: {system_info['system_unavailability']:.6f}")
# print("\nComponent Rankings (by Birnbaum Importance):") print("\nComponent Rankings (by Birnbaum Importance):")
# print(f"{'Component':<20} {'Availability':<12} {'Birnbaum':<12} {'Criticality':<12} {'F-V':<12} {'Contribution%':<12}") print(f"{'Component':<20} {'Availability':<12} {'Birnbaum':<12} {'Criticality':<12} {'F-V':<12} {'Contribution%':<12}")
# print("-" * 92) print("-" * 92)
for component, measures in sorted_components: for component, measures in sorted_components:
print(f"{component:<20} {measures['component_availability']:<12.6f} " print(f"{component:<20} {measures['component_availability']:<12.6f} "

@ -1,6 +1,5 @@
# src/database.py # src/database.py
import re import re
import operator
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from typing import Annotated, Any from typing import Annotated, Any
@ -51,7 +50,7 @@ class Base(DeclarativeBase):
def dict(self): def dict(self):
"""Returns a dict representation of a model.""" """Returns a dict representation of a model."""
if hasattr(self, '__table__'): if hasattr(self, '__table__'):
return {c.name: operator.attrgetter(c.name)(self) for c in self.__table__.columns} return {c.name: getattr(self, c.name) for c in self.__table__.columns}
return {} return {}
class CollectorBase(DeclarativeBase): class CollectorBase(DeclarativeBase):
@ -62,7 +61,7 @@ class CollectorBase(DeclarativeBase):
def dict(self): def dict(self):
"""Returns a dict representation of a model.""" """Returns a dict representation of a model."""
if hasattr(self, '__table__'): if hasattr(self, '__table__'):
return {c.name: operator.attrgetter(c.name)(self) for c in self.__table__.columns} return {c.name: getattr(self, c.name) for c in self.__table__.columns}
return {} return {}
@asynccontextmanager @asynccontextmanager

@ -1,22 +0,0 @@
from typing import List, Optional
from pydantic import Field
from src.models import DefultBase
class CommonParams(DefultBase):
# This ensures no extra query params are allowed
current_user: Optional[str] = Field(None, alias="currentUser")
page: int = Field(1, gt=0, lt=2147483647)
items_per_page: int = Field(5, gt=0, le=50, multiple_of=5, alias="itemsPerPage")
query_str: Optional[str] = Field(None, alias="q")
filter_spec: Optional[str] = Field(None, alias="filter")
sort_by: List[str] = Field(default_factory=list, alias="sortBy[]")
descending: List[bool] = Field(default_factory=list, alias="descending[]")
exclude: List[str] = Field(default_factory=list, alias="exclude[]")
all_params: int = Field(0, alias="all")
# Property to mirror your original return dict's bool conversion
@property
def is_all(self) -> bool:
return bool(self.all_params)

@ -1,5 +1,5 @@
import logging import logging
from typing import Annotated, List, Type, TypeVar from typing import Annotated, List
from fastapi import Depends, Query from fastapi import Depends, Query
from pydantic.types import Json, constr from pydantic.types import Json, constr
@ -7,8 +7,6 @@ from sqlalchemy import Select, desc, func, or_
from sqlalchemy.exc import ProgrammingError from sqlalchemy.exc import ProgrammingError
from sqlalchemy_filters import apply_pagination from sqlalchemy_filters import apply_pagination
from src.database.schema import CommonParams
from .core import DbSession from .core import DbSession
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@ -49,21 +47,6 @@ CommonParameters = Annotated[
Depends(common_parameters), Depends(common_parameters),
] ]
T = TypeVar("T", bound=CommonParams)
def get_params_factory(model_type: Type[T]):
async def wrapper(
db_session: DbSession,
params: Annotated[model_type, Query()] # type: ignore
):
res = params.model_dump()
return {
"db_session": db_session,
"all": params.is_all,
**res
}
return wrapper
def search(*, query_str: str, query: Query, model, sort=False): def search(*, query_str: str, query: Query, model, sort=False):
"""Perform a search based on the query.""" """Perform a search based on the query."""

@ -5,7 +5,7 @@ from sqlalchemy import Delete, Select, and_, text
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from src.auth.service import CurrentUser from src.auth.service import CurrentUser
from src.database.core import CollectorDbSession, DbSession from src.database.core import DbSession
from src.database.service import CommonParameters, search_filter_sort_paginate from src.database.service import CommonParameters, search_filter_sort_paginate
from .model import ScopeEquipmentPart from .model import ScopeEquipmentPart
@ -16,203 +16,139 @@ from .schema import ScopeEquipmentActivityCreate, ScopeEquipmentActivityUpdate
# result = await db_session.get(ScopeEquipmentActivity, scope_equipment_activity_id) # result = await db_session.get(ScopeEquipmentActivity, scope_equipment_activity_id)
# return result # return result
from typing import Optional, List, Dict, Any
from sqlalchemy.ext.asyncio import AsyncSession as DbSession
from sqlalchemy.sql import text
import logging
logger = logging.getLogger(__name__)
# async def get_all(
# db_session: CollectorDbSession,
# location_tag: Optional[str] = None,
# start_year: int = 2023,
# end_year: Optional[int] = None,
# parent_wonum: Optional[str] = None
# ) -> List[Dict[str, Any]]:
# """
# Retrieve overhaul spare parts consumption data.
# Handles missing data, null parent WO, and query safety.
# Args:
# db_session: Async SQLAlchemy session
# location_tag: Optional location filter
# start_year: Year to start analysis (default 2023)
# end_year: Optional year to end analysis (default start_year + 1)
# parent_wonum: Parent work order number (required for context)
# Returns:
# List of dictionaries with spare part usage per overhaul WO.
# """
# # --- 1. Basic validation ---
# if not parent_wonum:
# logger.warning("Parent WO number not provided. Returning empty result.")
# return []
# if start_year < 1900 or (end_year and end_year < start_year):
# raise ValueError("Invalid year range provided.")
# if end_year is None:
# end_year = start_year + 1
# # --- 2. Build SQL safely ---
# base_query = """
# WITH filtered_wo AS (
# SELECT wonum, location_tag
# FROM public.wo_max
# WHERE worktype = 'OH'
# AND xx_parent = :parent_wonum
# """
# params = {
# "parent_wonum": parent_wonum,
# }
# if location_tag:
# base_query += " AND location_tag = :location_tag"
# params["location_tag"] = location_tag
# base_query += """
# ),
# filtered_materials AS (
# SELECT wonum, itemnum, itemqty, inv_curbaltotal, inv_avgcost
# FROM public.wo_max_material
# WHERE wonum IN (SELECT wonum FROM filtered_wo)
# )
# SELECT
# fwo.location_tag AS location_tag,
# fm.itemnum,
# spl.description AS sparepart_name,
# COALESCE(SUM(fm.itemqty), 0) AS parts_consumed_in_oh,
# COALESCE(AVG(fm.inv_avgcost), 0) AS avgcost,
# COALESCE(AVG(fm.inv_curbaltotal), 0) AS inv_curbaltotal
# FROM filtered_wo fwo
# INNER JOIN filtered_materials fm ON fwo.wonum = fm.wonum
# LEFT JOIN public.maximo_sparepart_pr_po_line spl ON fm.itemnum = spl.item_num
# GROUP BY fwo.location_tag, fm.itemnum, spl.description
# ORDER BY fwo.location_tag, fm.itemnum;
# """
# # --- 3. Execute query ---
# try:
# result = await db_session.execute(text(base_query), params)
# rows = result.fetchall()
# # Handle "no data found"
# if not rows:
# logger.info(f"No spare part data found for parent WO {parent_wonum}.")
# return []
# # --- 4. Map results cleanly ---
# equipment_parts = []
# for row in rows:
# try:
# equipment_parts.append({
# "location_tag": row.location_tag,
# "itemnum": row.itemnum,
# "sparepart_name": row.sparepart_name or "-",
# "parts_consumed_in_oh": float(row.parts_consumed_in_oh or 0),
# "avgcost": float(row.avgcost or 0),
# "inv_curbaltotal": float(row.inv_curbaltotal or 0)
# })
# except Exception as parse_err:
# logger.error(f"Failed to parse row {row}: {parse_err}")
# continue # Skip malformed rows
# return equipment_parts
# except Exception as e:
# logger.exception(f"Database query failed: {e}")
# raise RuntimeError("Failed to fetch overhaul spare parts data.") from e
from typing import List, Dict, Any, Optional
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.sql import text
def create_dummy_parts(assetnum: str, count: int = 5):
"""
Create a list of dummy ScopeEquipmentPart objects with random stock values.
Args:
assetnum (str): The base asset number to generate dummy parts for.
count (int): The number of parts to create. Default is 5.
Returns:
List[ScopeEquipmentPart]: A list of dummy ScopeEquipmentPart objects.
"""
parts = []
for i in range(1, count + 1):
# Generate a unique part asset number
part_assetnum = f"{assetnum}_PART_{i}"
stock = random.randint(1, 100) # Random stock value between 1 and 100
parts.append({"assetnum": part_assetnum, "stock": stock})
return parts
from sqlalchemy import text
from typing import Optional, List, Dict, Any
from datetime import datetime
async def get_all( async def get_all(
db_session: AsyncSession, db_session: DbSession,
location_tag: Optional[str] = None, location_tag: Optional[str] = None,
start_year: int = 2023, start_year: int = 2023,
end_year: Optional[int] = None end_year: Optional[int] = None
) -> List[Dict[str, Any]]: ) -> List[Dict[str, Any]]:
""" """
Get overhaul spare parts consumption data with optimized query. Get overhaul spare parts consumption data with optimized query
Args: Args:
db_session: SQLAlchemy async database session db_session: Database session
location_tag: Optional filter for location (asset_location) location_tag: Optional location filter
start_year: Starting year (default: 2023) start_year: Year to start analysis (default: 2023)
end_year: Ending year (default: start_year + 1) end_year: Year to end analysis (default: start_year + 1)
Returns: Returns:
List of dictionaries with spare parts consumption data List of dictionaries containing spare parts consumption data
""" """
# Set default end year
# Set end year if not provided
if end_year is None: if end_year is None:
end_year = start_year + 1 end_year = start_year + 1
# Build query dynamically # Build dynamic query
query_str = """ base_query = """
WITH filtered_wo AS ( WITH filtered_wo AS (
SELECT DISTINCT wonum, asset_location, asset_unit SELECT wonum, asset_location
FROM public.wo_maximo ma FROM public.wo_staging_maximo_2
WHERE ma.xx_parent IN ('155026', '155027', '155029', '155030') WHERE worktype = 'OH'
""" """
params = {} params = {}
# Optional filter for location # Add location filter to CTE if provided
if location_tag: if location_tag:
query_str += " AND asset_location = :location_tag" base_query += " AND asset_location = :location_tag"
params["location_tag"] = location_tag params["location_tag"] = location_tag
query_str += """ base_query += """
), ),
filtered_materials AS ( filtered_transactions AS (
SELECT SELECT wonum, itemnum, curbal
mat.wonum, FROM public.maximo_material_use_transactions
mat.itemnum, WHERE issuetype = 'ISSUE'
mat.itemqty, AND wonum IN (SELECT wonum FROM filtered_wo)
mat.inv_curbaltotal AS inv_curbaltotal,
mat.inv_avgcost AS inv_avgcost
FROM public.wo_maximo_material AS mat
WHERE mat.wonum IN (SELECT wonum FROM filtered_wo)
) )
SELECT SELECT
fwo.asset_location AS location_tag, fwo.asset_location AS location_tag,
ft.itemnum, ft.itemnum,
COALESCE(spl.description, 'Unknown') AS sparepart_name, spl.description AS sparepart_name,
AVG(ft.itemqty) AS total_parts_used, COUNT(*) AS parts_consumed_in_oh,
COALESCE(AVG(ft.inv_avgcost), 0) AS avg_cost, MIN(ft.curbal) AS min_remaining_balance,
COALESCE(AVG(ft.inv_curbaltotal), 0) AS avg_inventory_balance MAX(mi.curbaltotal) AS inv_curbaltotal
FROM filtered_wo AS fwo FROM filtered_wo fwo
INNER JOIN filtered_materials AS ft INNER JOIN filtered_transactions ft ON fwo.wonum = ft.wonum
ON fwo.wonum = ft.wonum INNER JOIN public.maximo_inventory mi ON ft.itemnum = mi.itemnum
LEFT JOIN public.maximo_sparepart_pr_po_line AS spl LEFT JOIN public.maximo_sparepart_pr_po_line spl ON ft.itemnum = spl.item_num
ON ft.itemnum = spl.item_num
GROUP BY fwo.asset_location, ft.itemnum, spl.description GROUP BY fwo.asset_location, ft.itemnum, spl.description
ORDER BY fwo.asset_location, ft.itemnum; ORDER BY fwo.asset_location, ft.itemnum
""" """
query = text(base_query)
try: try:
result = await db_session.execute(text(query_str), params) results = await db_session.execute(query, params)
rows = result.fetchall()
equipment_parts = [] equipment_parts = []
for row in rows: for row in results:
equipment_parts.append({ equipment_parts.append({
"location_tag": row.location_tag, "location_tag": row.location_tag,
"itemnum": row.itemnum, "itemnum": row.itemnum,
"sparepart_name": row.sparepart_name, "sparepart_name": row.sparepart_name,
"parts_consumed_in_oh": float(row.total_parts_used or 0), "parts_consumed_in_oh": row.parts_consumed_in_oh,
"avg_cost": float(row.avg_cost or 0), "min_remaining_balance": float(row.min_remaining_balance),
"inv_curbaltotal": float(row.avg_inventory_balance or 0), "inv_curbaltotal": float(row.inv_curbaltotal)
}) })
return equipment_parts return equipment_parts
except Exception as e: except Exception as e:
print(f"[get_all] Database query error: {e}") # Log the error appropriately in your application
print(f"Database query error: {e}")
raise raise
# async def create(*, db_session: DbSession, scope_equipment_activty_in: ScopeEquipmentActivityCreate):
# activity = ScopeEquipmentActivity(
# **scope_equipment_activty_in.model_dump())
# db_session.add(activity)
# await db_session.commit()
# return activity
# async def update(*, db_session: DbSession, activity: ScopeEquipmentActivity, scope_equipment_activty_in: ScopeEquipmentActivityUpdate):
# """Updates a document."""
# data = scope_equipment_activty_in.model_dump()
# update_data = scope_equipment_activty_in.model_dump(exclude_defaults=True)
# for field in data:
# if field in update_data:
# setattr(activity, field, update_data[field])
# await db_session.commit()
# return activity
# async def delete(*, db_session: DbSession, scope_equipment_activity_id: str):
# """Deletes a document."""
# activity = await db_session.get(ScopeEquipmentActivity, scope_equipment_activity_id)
# await db_session.delete(activity)
# await db_session.commit()

@ -40,7 +40,7 @@ async def create_scope_equipment_jobs(
) )
@router.post("/delete/{scope_job_id}", response_model=StandardResponse[None]) @router.delete("/{scope_job_id}", response_model=StandardResponse[None])
async def delete_scope_equipment_job(db_session: DbSession, scope_job_id): async def delete_scope_equipment_job(db_session: DbSession, scope_job_id):
await delete(db_session=db_session, scope_job_id=scope_job_id) await delete(db_session=db_session, scope_job_id=scope_job_id)

@ -14,11 +14,7 @@ from sqlalchemy.exc import (DataError, DBAPIError, IntegrityError,
SQLAlchemyError) SQLAlchemyError)
from src.enums import ResponseStatus from src.enums import ResponseStatus
from src.auth.service import notify_admin_on_rate_limit_sync
from starlette.exceptions import HTTPException as StarletteHTTPException
log = logging.getLogger(__name__)
class ErrorDetail(BaseModel): class ErrorDetail(BaseModel):
field: Optional[str] = None field: Optional[str] = None
@ -61,7 +57,7 @@ def get_request_context(request: Request):
return { return {
"endpoint": request.url.path, "endpoint": request.url.path,
"url": str(request.url), "url": request.url,
"method": request.method, "method": request.method,
"remote_addr": get_client_ip(), "remote_addr": get_client_ip(),
} }
@ -71,34 +67,32 @@ def handle_sqlalchemy_error(error: SQLAlchemyError):
""" """
Handle SQLAlchemy errors and return user-friendly error messages. Handle SQLAlchemy errors and return user-friendly error messages.
""" """
try: original_error = getattr(error, "orig", None)
original_error = error.orig print(original_error)
except AttributeError:
original_error = None
if isinstance(error, IntegrityError): if isinstance(error, IntegrityError):
if "unique constraint" in str(error).lower(): if "unique constraint" in str(error).lower():
return "This record already exists.", 422 return "This record already exists.", 409
elif "foreign key constraint" in str(error).lower(): elif "foreign key constraint" in str(error).lower():
return "Related record not found.", 422 return "Related record not found.", 400
else: else:
return "Data integrity error.", 422 return "Data integrity error.", 400
elif isinstance(error, DataError) or isinstance(original_error, AsyncPGDataError): elif isinstance(error, DataError) or isinstance(original_error, AsyncPGDataError):
return "Invalid data provided.", 422 return "Invalid data provided.", 400
elif isinstance(error, DBAPIError): elif isinstance(error, DBAPIError):
if "unique constraint" in str(error).lower(): if "unique constraint" in str(error).lower():
return "This record already exists.", 422 return "This record already exists.", 409
elif "foreign key constraint" in str(error).lower(): elif "foreign key constraint" in str(error).lower():
return "Related record not found.", 422 return "Related record not found.", 400
elif "null value in column" in str(error).lower(): elif "null value in column" in str(error).lower():
return "Required data missing.", 422 return "Required data missing.", 400
elif "invalid input for query argument" in str(error).lower(): elif "invalid input for query argument" in str(error).lower():
return "Invalid data provided.", 422 return "Invalid data provided.", 400
else: else:
return "Database error.", 500 return "Database error.", 500
else: else:
# Log the full error for debugging purposes # Log the full error for debugging purposes
log.error(f"Unexpected database error: {str(error)}") logging.error(f"Unexpected database error: {str(error)}")
return "An unexpected database error occurred.", 500 return "An unexpected database error occurred.", 500
@ -106,100 +100,31 @@ def handle_exception(request: Request, exc: Exception):
""" """
Global exception handler for Fastapi application. Global exception handler for Fastapi application.
""" """
import uuid
error_id = str(uuid.uuid1())
request_info = get_request_context(request) request_info = get_request_context(request)
# Store error_id in request.state for middleware/logging
request.state.error_id = error_id
if isinstance(exc, RateLimitExceeded): if isinstance(exc, RateLimitExceeded):
# Kirim notifikasi ke admin _rate_limit_exceeded_handler(request, exc)
notify_admin_on_rate_limit_sync( if isinstance(exc, HTTPException):
endpoint_name=request_info["endpoint"], logging.error(
ip_address=request_info["remote_addr"], f"HTTP exception | Code: {exc.status_code} | Error: {exc.detail} | Request: {request_info}",
method=request_info["method"], extra={"error_category": "http"},
)
log.warning(
f"Rate limit exceeded: {str(exc.description) if hasattr(exc, 'description') else str(exc)} | Error ID: {error_id}",
extra={
"error_id": error_id,
"error_category": "rate_limit",
"detail": str(exc.description) if hasattr(exc, "description") else str(exc),
"request": request_info,
},
)
return JSONResponse(
status_code=429,
content={
"data": None,
"message": "Rate limit exceeded",
"status": ResponseStatus.ERROR,
"error_id": error_id
}
)
if isinstance(exc, RequestValidationError):
log.warning(
f"Validation error: {exc.errors()} | Error ID: {error_id}",
extra={
"error_id": error_id,
"error_category": "validation",
"errors": exc.errors(),
"request": request_info,
},
)
return JSONResponse(
status_code=422,
content={
"data": exc.errors(),
"message": "Validation Error",
"status": ResponseStatus.ERROR,
"error_id": error_id
},
)
if isinstance(exc, (HTTPException, StarletteHTTPException)):
# Log as warning for 4xx, error for 5xx
status_code = exc.status_code if hasattr(exc, "status_code") else 500
detail = exc.detail if hasattr(exc, "detail") else str(exc)
log_level = logging.WARNING if 400 <= status_code < 500 else logging.ERROR
log.log(
log_level,
f"HTTP {status_code}: {detail} | Error ID: {error_id}",
extra={
"error_id": error_id,
"error_category": "http",
"status_code": status_code,
"request": request_info,
},
) )
return JSONResponse( return JSONResponse(
status_code=status_code, status_code=exc.status_code,
content={ content={
"data": None, "data": None,
"message": str(detail), "message": str(exc.detail),
"status": ResponseStatus.ERROR, "status": ResponseStatus.ERROR,
"error_id": error_id "errors": [ErrorDetail(message=str(exc.detail)).model_dump()],
}, },
) )
if isinstance(exc, SQLAlchemyError): if isinstance(exc, SQLAlchemyError):
error_message, status_code = handle_sqlalchemy_error(exc) error_message, status_code = handle_sqlalchemy_error(exc)
# Log integrity errors as warning, others as error logging.error(
log_level = logging.WARNING if 400 <= status_code < 500 else logging.ERROR f"Database Error | Error: {str(error_message)} | Request: {request_info}",
log.log( extra={"error_category": "database"},
log_level,
f"Database error: {error_message} | Error ID: {error_id}",
extra={
"error_id": error_id,
"error_category": "database",
"violation": str(exc).split('\n')[0],
"request": request_info,
},
) )
return JSONResponse( return JSONResponse(
@ -208,27 +133,24 @@ def handle_exception(request: Request, exc: Exception):
"data": None, "data": None,
"message": error_message, "message": error_message,
"status": ResponseStatus.ERROR, "status": ResponseStatus.ERROR,
"error_id": error_id "errors": [ErrorDetail(message=error_message).model_dump()],
}, },
) )
# Log unexpected errors # Log unexpected errors
log.error( logging.error(
f"Unexpected error: {str(exc)} | Error ID: {error_id}", f"Unexpected Error | Error: {str(exc)} | Request: {request_info}",
extra={ extra={"error_category": "unexpected"},
"error_id": error_id,
"error_category": "unexpected",
"request": request_info,
},
) )
return JSONResponse( return JSONResponse(
status_code=500, status_code=500,
content={ content={
"data": None, "data": None,
"message": "An unexpected error occurred", "message": str(exc),
"status": ResponseStatus.ERROR, "status": ResponseStatus.ERROR,
"error_id": error_id "errors": [
ErrorDetail(message="An unexpected error occurred.").model_dump()
],
}, },
) )

@ -1,27 +1,11 @@
import logging import logging
import json
import datetime
import os
import sys import sys
from typing import Optional
from src.config import LOG_LEVEL from src.config import LOG_LEVEL
from src.enums import OptimumOHEnum from src.enums import OptimumOHEnum
LOG_FORMAT_DEBUG = "%(levelname)s:%(message)s:%(pathname)s:%(funcName)s:%(lineno)d" LOG_FORMAT_DEBUG = "%(levelname)s:%(message)s:%(pathname)s:%(funcName)s:%(lineno)d"
# ANSI Color Codes
RESET = "\033[0m"
COLORS = {
"DEBUG": "\033[36m", # Cyan
"INFO": "\033[32m", # Green
"WARNING": "\033[33m", # Yellow
"WARN": "\033[33m", # Yellow
"ERROR": "\033[31m", # Red
"CRITICAL": "\033[1;31m", # Bold Red
}
class LogLevels(OptimumOHEnum): class LogLevels(OptimumOHEnum):
info = "INFO" info = "INFO"
@ -30,104 +14,32 @@ class LogLevels(OptimumOHEnum):
debug = "DEBUG" debug = "DEBUG"
class JSONFormatter(logging.Formatter):
"""
Custom formatter to output logs in JSON format.
"""
def format(self, record):
from src.context import get_request_id, get_user_id, get_username, get_role
request_id = None
user_id = None
username = None
role = None
try:
request_id = get_request_id()
user_id = get_user_id()
username = get_username()
role = get_role()
except Exception:
pass
# Standard fields from requirements
log_record = {
"timestamp": datetime.datetime.fromtimestamp(record.created).strftime("%Y-%m-%d %H:%M:%S"),
"level": record.levelname,
"message": record.getMessage(),
}
# Add Context information if available
if request_id:
log_record["request_id"] = request_id
# Add Error context if available
if hasattr(record, "error_id"):
log_record["error_id"] = record.error_id
elif "error_id" in record.__dict__:
log_record["error_id"] = record.error_id
if user_id:
log_record["user_id"] = user_id
# Add any extra attributes passed to the log call
standard_attrs = {
"args", "asctime", "created", "exc_info", "exc_text", "filename",
"funcName", "levelname", "levelno", "lineno", "module", "msecs",
"message", "msg", "name", "pathname", "process", "processName",
"relativeCreated", "stack_info", "thread", "threadName", "error_id"
}
for key, value in record.__dict__.items():
if key not in standard_attrs and not key.startswith("_"):
log_record[key] = value
log_json = json.dumps(log_record)
# Apply color if the output is a terminal
if sys.stdout.isatty():
level_color = COLORS.get(record.levelname, "")
return f"{level_color}{log_json}{RESET}"
return log_json
def configure_logging(): def configure_logging():
log_level = str(LOG_LEVEL).upper() # cast to string log_level = str(LOG_LEVEL).upper() # cast to string
log_levels = list(LogLevels) log_levels = list(LogLevels)
if log_level not in log_levels: if log_level not in log_levels:
log_level = LogLevels.error # we use error as the default log level
logging.basicConfig(level=LogLevels.error)
# Get the root logger return
root_logger = logging.getLogger()
root_logger.setLevel(log_level)
# Clear existing handlers to avoid duplicate logs
if root_logger.hasHandlers():
root_logger.handlers.clear()
# Create a stream handler that outputs to stdout if log_level == LogLevels.debug:
handler = logging.StreamHandler(sys.stdout) logging.basicConfig(level=log_level, format=LOG_FORMAT_DEBUG)
return
# Use JSONFormatter for all environments, or could be conditional logging.basicConfig(level=log_level)
# For now, let's assume the user wants JSON everywhere as requested
formatter = JSONFormatter()
# If debug mode is specifically requested and we want the old format for debug: # sometimes the slack client can be too verbose
# if log_level == LogLevels.debug: logging.getLogger("slack_sdk.web.base_client").setLevel(logging.CRITICAL)
# formatter = logging.Formatter(LOG_FORMAT_DEBUG)
handler.setFormatter(formatter)
root_logger.addHandler(handler)
# Reconfigure uvicorn loggers to use our JSON formatter
for logger_name in ["uvicorn", "uvicorn.access", "uvicorn.error", "fastapi"]:
logger = logging.getLogger(logger_name)
logger.handlers = []
logger.propagate = True
# Silence the chatty uvicorn access logs as we have custom middleware logging def setup_logging(logger):
logging.getLogger("uvicorn.access").setLevel(logging.WARNING) # Your logging configuration here
logger.setLevel(logging.DEBUG)
# Create formatter
formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")
# sometimes the slack client can be too verbose # Create console handler
logging.getLogger("slack_sdk.web.base_client").setLevel(logging.CRITICAL) stream_handler = logging.StreamHandler(sys.stdout)
stream_handler.setFormatter(formatter)
logger.addHandler(stream_handler)

@ -1,6 +1,5 @@
import logging import logging
import time import time
from src.context import set_request_id, reset_request_id, get_request_id
from contextvars import ContextVar from contextvars import ContextVar
from os import path from os import path
from typing import Final, Optional from typing import Final, Optional
@ -12,7 +11,6 @@ from fastapi.responses import JSONResponse
from pydantic import ValidationError from pydantic import ValidationError
from slowapi import _rate_limit_exceeded_handler from slowapi import _rate_limit_exceeded_handler
from slowapi.errors import RateLimitExceeded from slowapi.errors import RateLimitExceeded
from slowapi.middleware import SlowAPIMiddleware
from sqlalchemy import inspect from sqlalchemy import inspect
from sqlalchemy.ext.asyncio import async_scoped_session from sqlalchemy.ext.asyncio import async_scoped_session
from sqlalchemy.orm import scoped_session from sqlalchemy.orm import scoped_session
@ -29,42 +27,37 @@ from src.database.core import async_session, engine, async_collector_session
from src.enums import ResponseStatus from src.enums import ResponseStatus
from src.exceptions import handle_exception from src.exceptions import handle_exception
from src.logging import configure_logging from src.logging import configure_logging
from src.middleware import RequestValidationMiddleware
from src.rate_limiter import limiter from src.rate_limiter import limiter
from fastapi.exceptions import RequestValidationError
from starlette.exceptions import HTTPException as StarletteHTTPException
log = logging.getLogger(__name__) 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 define the exception handlers
exception_handlers = {Exception: handle_exception}
# we create the ASGI for the app # we create the ASGI for the app
app = FastAPI( app = FastAPI(
exception_handlers=exception_handlers,
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
app.add_exception_handler(Exception, handle_exception)
app.add_exception_handler(HTTPException, handle_exception)
app.add_exception_handler(StarletteHTTPException, handle_exception)
app.add_exception_handler(RequestValidationError, handle_exception)
app.add_exception_handler(RateLimitExceeded, handle_exception)
app.state.limiter = limiter app.state.limiter = limiter
app.add_middleware(GZipMiddleware, minimum_size=1000) app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
app.add_middleware(SlowAPIMiddleware) app.add_middleware(GZipMiddleware, minimum_size=2000)
# credentials: "include", # credentials: "include",
REQUEST_ID_CTX_KEY: Final[str] = "request_id"
_request_id_ctx_var: ContextVar[Optional[str]] = ContextVar(
REQUEST_ID_CTX_KEY, default=None
)
def get_request_id() -> Optional[str]:
return _request_id_ctx_var.get()
def security_headers_middleware(app: FastAPI): def security_headers_middleware(app: FastAPI):
is_production = False is_production = False
@ -127,19 +120,13 @@ def security_headers_middleware(app: FastAPI):
security_headers_middleware(app) security_headers_middleware(app)
app.add_middleware(RequestValidationMiddleware)
@app.middleware("http") @app.middleware("http")
async def db_session_middleware(request: Request, call_next): async def db_session_middleware(request: Request, call_next):
request_id = str(uuid1()) request_id = str(uuid1())
# we create a per-request id such that we can ensure that our session is scoped for a particular request. # we create a per-request id such that we can ensure that our session is scoped for a particular request.
# see: https://github.com/tiangolo/fastapi/issues/726 # see: https://github.com/tiangolo/fastapi/issues/726
ctx_token = set_request_id(request_id) ctx_token = _request_id_ctx_var.set(request_id)
try: try:
session = async_scoped_session(async_session, scopefunc=get_request_id) session = async_scoped_session(async_session, scopefunc=get_request_id)
@ -148,68 +135,14 @@ async def db_session_middleware(request: Request, call_next):
collector_session = async_scoped_session(async_collector_session, scopefunc=get_request_id) collector_session = async_scoped_session(async_collector_session, scopefunc=get_request_id)
request.state.collector_db = collector_session() request.state.collector_db = collector_session()
start_time = time.time()
response = await call_next(request) response = await call_next(request)
process_time = (time.time() - start_time) * 1000 except Exception as e:
raise e from None
# Skip logging in middleware if it's an error (already logged in handle_exception)
if response.status_code >= 400:
return response
from src.context import get_username, get_role, get_user_id, set_user_id, set_username, set_role
# Pull from context or fallback to request.state.user
username = get_username()
role = get_role()
user_id = get_user_id()
user_obj = getattr(request.state, "user", None)
if user_obj:
# message is UserBase dict/obj in this project
if isinstance(user_obj, dict):
u_id = user_obj.get("user_id")
u_name = user_obj.get("name") or user_obj.get("username")
u_role = user_obj.get("role")
else:
u_id = getattr(user_obj, "user_id", None)
u_name = getattr(user_obj, "name", None) or getattr(user_obj, "username", None)
u_role = getattr(user_obj, "role", None)
if not user_id and u_id:
user_id = str(u_id)
set_user_id(user_id)
if not username and u_name:
username = u_name
set_username(username)
if not role and u_role:
role = u_role
set_role(role)
user_info_str = ""
if user_id:
user_info_str = f" | User ID: {user_id}"
error_id = getattr(request.state, "error_id", None)
log_msg = f"HTTP {request.method} {request.url.path} completed in {round(process_time, 2)}ms{user_info_str}"
if error_id:
log_msg += f" | Error ID: {error_id}"
log.info(
log_msg,
extra={
"method": request.method,
"path": request.url.path,
"status_code": response.status_code,
"duration_ms": round(process_time, 2),
"user_id": user_id,
"error_id": error_id,
},
)
finally: finally:
await request.state.db.close() await request.state.db.close()
await request.state.collector_db.close() await request.state.collector_db.close()
reset_request_id(ctx_token) _request_id_ctx_var.reset(ctx_token)
return response return response

@ -1,332 +1,79 @@
from datetime import datetime from datetime import datetime
from typing import Optional, Union from sqlalchemy import select, func, cast, Numeric
from sqlalchemy import select, func, cast, Numeric, text
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy import and_ from sqlalchemy import and_
from sqlalchemy.sql import not_ from sqlalchemy.sql import not_
from src.maximo.model import WorkOrderData # Assuming this is where your model is from src.maximo.model import WorkOrderData # Assuming this is where your model is
from src.database.core import CollectorDbSession, DbSession from src.database.core import CollectorDbSession
from src.overhaul_scope.model import OverhaulScope
async def get_cm_cost_summary(collector_db: CollectorDbSession, last_oh_date:datetime, upcoming_oh_date:datetime): async def get_cm_cost_summary(collector_db: CollectorDbSession, last_oh_date:datetime, upcoming_oh_date:datetime):
query = text("""WITH part_costs AS ( query = select(
SELECT WorkOrderData.location,
mu.wonum, (func.sum(WorkOrderData.total_cost_max).cast(Numeric) / func.count(WorkOrderData.wonum)).label('avg_cost')
SUM(mu.itemqty * COALESCE(inv.avgcost, po.unit_cost, 0)) AS parts_total_cost ).where(
FROM maximo_workorder_materials mu and_(
LEFT JOIN maximo_inventory inv # WorkOrderData.wo_start >= last_oh_date,
ON mu.itemnum = inv.itemnum # WorkOrderData.wo_start <= upcoming_oh_date,
LEFT JOIN ( WorkOrderData.worktype.in_(['CM', 'EM', 'PROACTIVE']),
SELECT item_num, AVG(unit_cost) AS unit_cost WorkOrderData.system_tag.in_(['HPB', 'AH', 'APC', 'SCR', 'CL', 'DM', 'CRH', 'ASH', 'BAD', 'DS', 'WTP',
FROM maximo_sparepart_pr_po_line
GROUP BY item_num
) po
ON mu.itemnum = po.item_num
GROUP BY mu.wonum
),
wo_costs AS (
SELECT
w.wonum,
w.asset_location,
(COALESCE(w.mat_cost_max, 0) + COALESCE(pc.parts_total_cost, 0)) AS total_wo_cost
FROM wo_staging_maximo_2 w
LEFT JOIN part_costs pc
ON w.wonum = pc.wonum
WHERE
w.worktype IN ('CM', 'EM', 'PROACTIVE')
AND w.asset_system IN (
'HPB','AH','APC','SCR','CL','DM','CRH','ASH','BAD','DS','WTP',
'MT', 'SUP', 'DCS', 'FF', 'EG', 'AI', 'SPS', 'EVM', 'SCW', 'KLH', 'CH', 'MT', 'SUP', 'DCS', 'FF', 'EG', 'AI', 'SPS', 'EVM', 'SCW', 'KLH', 'CH',
'TUR', 'LOT', 'HRH', 'ESP', 'CAE', 'GMC', 'BFT', 'LSH', 'CHB', 'BSS', 'TUR', 'LOT', 'HRH', 'ESP', 'CAE', 'GMC', 'BFT', 'LSH', 'CHB', 'BSS',
'LOS', 'LPB', 'SAC', 'CP', 'EHS', 'RO', 'GG', 'MS', 'CW', 'SO', 'ATT', 'LOS', 'LPB', 'SAC', 'CP', 'EHS', 'RO', 'GG', 'MS', 'CW', 'SO', 'ATT',
'AFG', 'EHB', 'RP', 'FO', 'PC', 'APE', 'AF', 'DMW', 'BRS', 'GEN', 'ABS', 'AFG', 'EHB', 'RP', 'FO', 'PC', 'APE', 'AF', 'DMW', 'BRS', 'GEN', 'ABS',
'CHA', 'TR', 'H2', 'BDW', 'LOM', 'ACR', 'AL', 'FW', 'COND', 'CCCW', 'IA', 'CHA', 'TR', 'H2', 'BDW', 'LOM', 'ACR', 'AL', 'FW', 'COND', 'CCCW', 'IA',
'GSS','BOL','SSB','CO','OA','CTH-UPD','AS','DP' 'GSS', 'BOL', 'SSB', 'CO', 'OA', 'CTH-UPD', 'AS', 'DP']),
WorkOrderData.reportdate.is_not(None),
WorkOrderData.actstart.is_not(None),
WorkOrderData.actfinish.is_not(None),
WorkOrderData.unit.in_([3, 0]),
WorkOrderData.reportdate >= datetime.strptime('2015-01-01', '%Y-%m-%d'),
not_(WorkOrderData.wonum.like('T%'))
) )
AND w.reportdate IS NOT NULL ).group_by(
AND w.actstart IS NOT NULL WorkOrderData.location
AND w.actfinish IS NOT NULL ).order_by(
AND w.asset_unit IN ('3','00') func.count(WorkOrderData.wonum).desc()
AND w.reportdate >= '2015-01-01'
AND w.wonum NOT LIKE 'T%'
),
-- find max cost per location
location_max AS (
SELECT asset_location, MAX(total_wo_cost) AS max_cost
FROM wo_costs
WHERE total_wo_cost > 0
GROUP BY asset_location
),
-- filter WO costs to only reasonable range (e.g. >0 and >=10% of max)
filtered_wo AS (
SELECT w.*
FROM wo_costs w
JOIN location_max lm ON w.asset_location = lm.asset_location
WHERE w.total_wo_cost > 0
) )
SELECT result = await collector_db.execute(query)
asset_location, data = result.all()
SUM(total_wo_cost)::numeric / COUNT(wonum) AS avg_cost
FROM filtered_wo
GROUP BY asset_location
ORDER BY avg_cost DESC;
""")
results = await collector_db.execute(query)
data = []
for row in results:
data.append({
"location_tag": row.asset_location,
"avg_cost": row.avg_cost
})
return { return {
item["location_tag"]: item["avg_cost"] for item in data data.location: data.avg_cost for data in data
} }
# async def get_oh_cost_summary(collector_db: CollectorDbSession, last_oh_date:datetime, upcoming_oh_date:datetime):
# query = text("""
# WITH target_wo AS (
# -- Get work orders under a specific parent(s)
# SELECT
# wonum,
# xx_parent,
# assetnum,
# location_tag AS asset_location,
# actmatcost,
# actservcost,
# reportdate
# FROM public.wo_maxim
# WHERE xx_parent = ANY(:parent_nums)
# ),
# part_costs AS (
# -- Calculate parts cost per WO if actmatcost = 0
# SELECT
# wm.wonum,
# SUM(
# wm.itemqty *
# COALESCE(wm.inv_avgcost, po.unit_cost, 0)
# ) AS parts_total_cost
# FROM public.wo_maxim_material wm
# LEFT JOIN (
# SELECT item_num, AVG(unit_cost) AS unit_cost
# FROM public.maximo_sparepart_pr_po_line
# GROUP BY item_num
# ) po ON wm.itemnum = po.item_num
# WHERE wm.itemnum IS NOT NULL
# GROUP BY wm.wonum
# ),
# wo_costs AS (
# SELECT
# w.wonum,
# w.asset_location,
# CASE
# WHEN COALESCE(w.actmatcost, 0) > 0 THEN COALESCE(w.actmatcost, 0)
# ELSE COALESCE(pc.parts_total_cost, 0)
# END AS material_cost,
# COALESCE(w.actservcost, 0) AS service_cost
# FROM target_wo w
# LEFT JOIN part_costs pc ON w.wonum = pc.wonum
# )
# SELECT
# asset_location,
# ROUND(SUM(material_cost + service_cost)::numeric / COUNT(wonum), 2) AS avg_cost,
# COUNT(wonum) AS total_wo_count
# FROM wo_costs
# GROUP BY asset_location
# ORDER BY total_wo_count DESC;
# """)
# parent_nums = []
# result = await collector_db.execute(query, {"parent_nums": parent_nums})
# data = []
# for row in result:
# data.append({
# "location_tag": row.asset_location,
# "avg_cost": float(row.avg_cost or 0.0),
# "total_wo_count": row.total_wo_count,
# })
# return {item["location_tag"]: item["avg_cost"] for item in data}
async def get_oh_cost_summary(collector_db: CollectorDbSession, last_oh_date:datetime, upcoming_oh_date:datetime): async def get_oh_cost_summary(collector_db: CollectorDbSession, last_oh_date:datetime, upcoming_oh_date:datetime):
# query = text(""" query = select(
# WITH part_costs AS ( WorkOrderData.location,
# SELECT (func.sum(WorkOrderData.total_cost_max).cast(Numeric) / func.count(WorkOrderData.wonum)).label('avg_cost')
# wm.wonum, ).where(
# SUM(wm.itemqty * COALESCE(wm.inv_avgcost, po.unit_cost, 0)) AS parts_total_cost and_(
# FROM public.wo_maxim_material wm # WorkOrderData.wo_start >= last_oh_date,
# LEFT JOIN ( # WorkOrderData.wo_start <= upcoming_oh_date,
# SELECT item_num, AVG(unit_cost) AS unit_cost WorkOrderData.worktype.in_(['OH']),
# FROM public.maximo_sparepart_pr_po_line WorkOrderData.system_tag.in_(['HPB', 'AH', 'APC', 'SCR', 'CL', 'DM', 'CRH', 'ASH', 'BAD', 'DS', 'WTP',
# GROUP BY item_num 'MT', 'SUP', 'DCS', 'FF', 'EG', 'AI', 'SPS', 'EVM', 'SCW', 'KLH', 'CH',
# ) po ON wm.itemnum = po.item_num 'TUR', 'LOT', 'HRH', 'ESP', 'CAE', 'GMC', 'BFT', 'LSH', 'CHB', 'BSS',
# WHERE wm.itemnum IS NOT NULL 'LOS', 'LPB', 'SAC', 'CP', 'EHS', 'RO', 'GG', 'MS', 'CW', 'SO', 'ATT',
# GROUP BY wm.wonum 'AFG', 'EHB', 'RP', 'FO', 'PC', 'APE', 'AF', 'DMW', 'BRS', 'GEN', 'ABS',
# ), 'CHA', 'TR', 'H2', 'BDW', 'LOM', 'ACR', 'AL', 'FW', 'COND', 'CCCW', 'IA',
# wo_costs AS ( 'GSS', 'BOL', 'SSB', 'CO', 'OA', 'CTH-UPD', 'AS', 'DP']),
# SELECT WorkOrderData.reportdate.is_not(None),
# w.wonum, WorkOrderData.actstart.is_not(None),
# w.asset_location, WorkOrderData.actfinish.is_not(None),
# -- Use mat_cost_max if parts_total_cost = 0 WorkOrderData.unit.in_([3, 0]),
# CASE WorkOrderData.reportdate >= datetime.strptime('2015-01-01', '%Y-%m-%d'),
# WHEN COALESCE(pc.parts_total_cost, 0) = 0 THEN COALESCE(w.mat_cost_max , 0) not_(WorkOrderData.wonum.like('T%'))
# ELSE COALESCE(pc.parts_total_cost, 0) )
# END AS total_wo_cost ).group_by(
# FROM wo_staging_maximo_2 w WorkOrderData.location
# LEFT JOIN part_costs pc ).order_by(
# ON w.wonum = pc.wonum func.count(WorkOrderData.wonum).desc()
# WHERE
# w.worktype = 'OH'
# AND w.reportdate IS NOT NULL
# AND w.actstart IS NOT NULL
# AND w.actfinish IS NOT NULL
# AND w.asset_unit IN ('3', '00')
# AND w.wonum NOT LIKE 'T%'
# )
# SELECT
# asset_location,
# AVG(total_wo_cost) AS avg_cost
# FROM wo_costs
# GROUP BY asset_location
# ORDER BY COUNT(wonum) DESC;
# """)
query = text("""
WITH part_costs AS (
SELECT
wm.wonum,
SUM(wm.itemqty * COALESCE(inv.avgcost, po.unit_cost, 0)) AS parts_total_cost
FROM public.maximo_workorder_materials wm
JOIN public.maximo_inventory AS inv on inv.itemnum = wm.itemnum
LEFT JOIN (
SELECT item_num, AVG(unit_cost) AS unit_cost
FROM public.maximo_sparepart_pr_po_line
GROUP BY item_num
) po ON wm.itemnum = po.item_num
WHERE wm.itemnum IS NOT NULL
GROUP BY wm.wonum
),
wo_costs AS (
SELECT
w.wonum,
w.asset_location,
-- Use mat_cost_max if parts_total_cost = 0
CASE
WHEN COALESCE(pc.parts_total_cost, 0) = 0 THEN COALESCE(w.mat_cost_max , 0)
ELSE COALESCE(pc.parts_total_cost, 0)
END AS total_wo_cost
FROM wo_staging_maximo_2 w
LEFT JOIN part_costs pc
ON w.wonum = pc.wonum
WHERE
w.worktype = 'OH'
AND w.reportdate IS NOT NULL
AND w.actstart IS NOT NULL
AND w.actfinish IS NOT NULL
AND w.asset_unit IN ('3', '00')
AND w.wonum NOT LIKE 'T%'
) )
SELECT
asset_location,
AVG(total_wo_cost) AS avg_cost
FROM wo_costs
GROUP BY asset_location
ORDER BY COUNT(wonum) DESC;
""")
result = await collector_db.execute(query) result = await collector_db.execute(query)
data = [] data = result.all()
for row in result:
data.append({
"location_tag": row.asset_location,
"avg_cost": row.avg_cost
})
return { return {
item["location_tag"]: item["avg_cost"] for item in data data.location: data.avg_cost for data in data
}
from uuid import UUID
async def get_history_oh_wo(*, db_session: DbSession, collector_db_session: CollectorDbSession, oh_session_id: UUID, parent_wo_num: Optional[Union[str, list]] = None):
## Get Parent wo num from oh session table
if not parent_wo_num:
query = select(OverhaulScope.wo_parent).where(OverhaulScope.id == oh_session_id)
result = await db_session.execute(query)
parent_wo_num = result.scalar()
if not parent_wo_num:
return []
# Ensure parent_wo_num is a list and removed duplicates if any
if isinstance(parent_wo_num, str):
parent_wo_num = [parent_wo_num]
else:
parent_wo_num = list(set(parent_wo_num))
sql_query = text("""
WITH target_wos AS (
SELECT
w.wonum,
w.assetnum,
COALESCE(w.actmatcost, 0) as actmatcost,
COALESCE(w.actservcost, 0) as actservcost
FROM public.wo_maximo w
WHERE w.xx_parent = ANY(:parent_wo_num)
),
wo_tasks AS (
SELECT
t.xx_parent AS parent_wonum,
JSON_AGG(t.description) AS task_list
FROM public.wo_maximo t
JOIN target_wos tw ON t.xx_parent = tw.wonum
GROUP BY t.xx_parent
)
SELECT
w.assetnum,
e.name AS equipment_name,
e.location_tag,
JSON_OBJECT_AGG(w.wonum, COALESCE(wt.task_list, '[]'::json)) AS wonum_list,
COUNT(w.wonum) AS total_wo_count,
COALESCE(SUM(w.actmatcost), 0) AS total_material_cost,
COALESCE(SUM(w.actservcost), 0) AS total_service_cost,
COALESCE(SUM(w.actmatcost + w.actservcost), 0) AS total_actual_cost
FROM target_wos w
INNER JOIN public.ms_equipment_master e
ON w.assetnum = e.assetnum
LEFT JOIN wo_tasks wt
ON w.wonum = wt.parent_wonum
GROUP BY
w.assetnum,
e.name,
e.location_tag
ORDER BY total_actual_cost DESC;
""")
results = await collector_db_session.execute(sql_query, {"parent_wo_num": parent_wo_num})
return [
{
"assetnum": row.assetnum,
"equipment_name": row.equipment_name,
"location_tag": row.location_tag,
"wonum_list": row.wonum_list,
"total_wo_count": row.total_wo_count,
"total_material_cost": float(row.total_material_cost),
"total_service_cost": float(row.total_service_cost),
"total_actual_cost": float(row.total_actual_cost)
} }
for row in results
]

@ -1,426 +0,0 @@
import json
import re
import logging
from collections import Counter
from fastapi import Request, HTTPException
from starlette.middleware.base import BaseHTTPMiddleware
# =========================
# Configuration
# =========================
ALLOWED_MULTI_PARAMS = {
"sortBy[]",
"descending[]",
"exclude[]",
"assetnums",
"plant_ids",
"job_ids",
}
ALLOWED_DATA_PARAMS = {
"actual_shutdown", "all_params", "analysis_metadata", "asset_contributions",
"assetnum", "assetnums", "assigned_date", "availability", "availableScopes",
"avg_cost", "birbaum", "calculation_type", "capacity_weight", "code",
"contribution", "corrective_cost", "corrective_costs", "cost", "costPerFailure",
"cost_savings_vs_planned", "cost_threshold", "cost_trend", "created_at",
"crew_number", "criticalParts", "critical_procurement_items", "current_eaf",
"current_plant_eaf", "current_stock", "current_user", "cut_hours",
"daily_failures", "data", "datetime", "day", "days", "descending",
"description", "down_time", "duration", "duration_oh", "eaf_gap",
"eaf_improvement_text", "eaf_input", "efficiency", "end_date",
"equipment_name", "equipment_results", "equipment_with_sparepart_constraints",
"exclude", "excluded_equipment", "expected_delivery_date", "filter_spec",
"finish", "fleet_statistics", "id", "improvement_impact", "included_equipment",
"included_in_optimization", "intervalDays", "is_included", "itemnum",
"items", "itemsPerPage", "items_per_page", "job", "job_ids",
"last_overhaul_date", "lead_time", "location", "location_tag", "location_tags",
"maintenance_type", "master_equipment", "material_cost", "max_interval",
"max_interval_months", "message", "month", "months_from_planned", "name",
"next_planned_overhaul", "node", "num_failures", "num_of_failures",
"ohSessionId", "oh_scope", "oh_session_id", "oh_type", "oh_types",
"optimal_analysis", "optimal_breakdown", "optimal_month", "optimal_total_cost",
"optimization_success", "optimum_analysis", "optimum_day", "optimum_oh",
"optimum_oh_day", "optimum_oh_month", "order_date", "overhaulCost",
"overhaul_activity", "overhaul_cost", "overhaul_costs",
"overhaul_reference_type", "overhaul_scope", "overhaul_scope_id", "overview",
"page", "parent", "parent_id", "plan_duration", "planned_month",
"planned_outage", "plant_level_benefit", "po_pr_id", "po_vendor_delivery_date",
"possible_plant_eaf", "priority_score", "procurement_cost", "procurement_costs",
"procurement_details", "projected_eaf_improvement", "quantity",
"quantity_required", "query_str", "recommendedScope", "recommended_reduced_outage",
"reference", "reference_id", "remark", "removal_date", "required_improvement",
"results", "schedules", "scope", "scope_calculation_id", "scope_equipment_job",
"scope_name", "scope_overhaul", "service_cost", "session", "simulation",
"simulation_id", "sort_by", "sortBy[]", "descending[]", "exclude[]",
"sparepart_id", "sparepart_impact", "sparepart_name", "sparepart_summary",
"spreadsheet_link", "start", "start_date", "status", "subsystem", "system",
"systemComponents", "target_plant_eaf", "tasks", "timing_recommendation",
"total", "totalPages", "total_cost", "total_equipment", "total_equipment_analyzed",
"total_procurement_items", "type", "unit_cost", "warning_message", "with_results",
"workscope", "workscope_group", "year", "_", "t", "timestamp",
"q", "filter", "currentUser", "risk_cost", "all", "with_results",
"eaf_threshold", "simulation_id", "scope_calculation_id", "calculation_id", "simulationRBDId"
}
ALLOWED_HEADERS = {
"host",
"user-agent",
"accept",
"accept-language",
"accept-encoding",
"connection",
"upgrade-insecure-requests",
"if-modified-since",
"if-none-match",
"cache-control",
"authorization",
"content-type",
"content-length",
"origin",
"referer",
"sec-fetch-dest",
"sec-fetch-mode",
"sec-fetch-site",
"sec-fetch-user",
"sec-ch-ua",
"sec-ch-ua-mobile",
"sec-ch-ua-platform",
"pragma",
"dnt",
"priority",
"x-forwarded-for",
"x-forwarded-proto",
"x-forwarded-host",
"x-forwarded-port",
"x-real-ip",
"x-request-id",
"x-correlation-id",
"x-requested-with",
"x-csrf-token",
"x-xsrf-token",
"postman-token",
"x-forwarded-path",
"x-forwarded-prefix",
"cookie",
"x-kong-request-id"
}
MAX_QUERY_PARAMS = 50
MAX_QUERY_LENGTH = 2000
MAX_JSON_BODY_SIZE = 1024 * 500 # 500 KB
XSS_PATTERN = re.compile(
r"("
r"<(script|iframe|embed|object|svg|img|video|audio|base|link|meta|form|button|details|animate)\b|"
r"javascript\s*:|vbscript\s*:|data\s*:[^,]*base64[^,]*|data\s*:text/html|"
r"\bon[a-z]+\s*=|" # Catch-all for any 'on' event (onerror, onclick, etc.)
r"style\s*=.*expression\s*\(|" # Old IE specific
r"\b(eval|setTimeout|setInterval|Function)\s*\("
r")",
re.IGNORECASE,
)
SQLI_PATTERN = re.compile(
r"("
# 1. Keywords followed by whitespace and common SQL characters
r"\b(UNION|SELECT|INSERT|UPDATE|DELETE|DROP|ALTER|CREATE|TRUNCATE|EXEC(UTE)?|DECLARE)\b\s+[\w\*\(\']|"
# 2. Time-based attacks (more specific than just 'SLEEP')
r"\b(WAITFOR\b\s+DELAY|PG_SLEEP|SLEEP\s*\()|"
# 3. System tables/functions
r"\b(INFORMATION_SCHEMA|SYS\.|SYSOBJECTS|XP_CMDSHELL|LOAD_FILE|INTO\s+OUTFILE)\b|"
# 4. Logical Tautologies (OR 1=1) - Optimized for boundaries
r"\b(OR|AND)\b\s+['\"]?\d+['\"]?\s*=\s*['\"]?\d+|"
# 5. Comments
# Match '--' if at start or preceded by whitespace
r"(?<!\S)--|"
# Match block comments, ensuring they aren't part of mime patterns like */*
r"(?<!\*)/\*|(?<!\*)\*/(?!\*)|"
# Match '#' if at start or preceded by whitespace
r"(?<!\S)#|"
# 6. Hex / Stacked Queries
r";\s*\b(SELECT|DROP|DELETE|UPDATE|INSERT)\b"
r")",
re.IGNORECASE
)
RCE_PATTERN = re.compile(
r"("
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)|"
# Only flag naked commands if they are clearly standalone or system paths
r"(/etc/passwd|/etc/shadow|/etc/group|/etc/issue|/proc/self/|/windows/system32/|C:\\Windows\\)\b"
r")",
re.IGNORECASE,
)
TRAVERSAL_PATTERN = re.compile(
r"(\.\.[/\\]|%2e%2e%2f|%2e%2e/|\.\.%2f|%2e%2e%5c|%252e%252e%252f|\\00)",
re.IGNORECASE,
)
FORBIDDEN_JSON_KEYS = {"__proto__", "constructor", "prototype"}
DYNAMIC_KEYS = {
"data",
"results",
"analysis_metadata",
"asset_contributions",
"equipment_results",
"optimal_analysis",
"optimum_analysis",
"schedules",
"tasks",
"all_params",
"parameters",
"program_data"
}
log = logging.getLogger("security_logger")
def has_control_chars(value: str) -> bool:
return any(ord(c) < 32 and c not in ("\n", "\r", "\t") for c in value)
def inspect_value(value: str, source: str):
if not isinstance(value, str) or value == "*/*":
return
if XSS_PATTERN.search(value):
log.warning(f"Security violation: Potential XSS payload detected in {source}, value: {value}")
raise HTTPException(
status_code=422,
detail="Invalid request parameters",
)
if SQLI_PATTERN.search(value):
log.warning(f"Security violation: Potential SQL injection payload detected in {source}, value: {value}")
raise HTTPException(
status_code=422,
detail="Invalid request parameters",
)
if RCE_PATTERN.search(value):
log.warning(f"Security violation: Potential RCE payload detected in {source}, value: {value}")
raise HTTPException(
status_code=422,
detail="Invalid request parameters",
)
if TRAVERSAL_PATTERN.search(value):
log.warning(f"Security violation: Potential Path Traversal payload detected in {source}, value: {value}")
raise HTTPException(
status_code=422,
detail="Invalid request parameters",
)
if has_control_chars(value):
log.warning(f"Security violation: Invalid control characters detected in {source}")
raise HTTPException(
status_code=422,
detail="Invalid request parameters",
)
def inspect_json(obj, path="body", check_whitelist=True):
if isinstance(obj, dict):
for key, value in obj.items():
if key in FORBIDDEN_JSON_KEYS:
log.warning(f"Security violation: Forbidden JSON key detected: {path}.{key}")
raise HTTPException(
status_code=422,
detail="Invalid request parameters",
)
# if check_whitelist and key not in ALLOWED_DATA_PARAMS:
# log.warning(f"Security violation: Unknown JSON key detected: {path}.{key}")
# raise HTTPException(
# status_code=422,
# detail="Invalid request parameters",
# )
# 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)
inspect_json(value, f"{path}.{key}", check_whitelist=should_check_subkeys)
elif isinstance(obj, list):
for i, item in enumerate(obj):
inspect_json(item, f"{path}[{i}]", check_whitelist=check_whitelist)
elif isinstance(obj, str):
inspect_value(obj, path)
# =========================
# Middleware
# =========================
class RequestValidationMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
# -------------------------
# 0. Header validation
# -------------------------
header_keys = [key.lower() for key, _ in request.headers.items()]
# Check for duplicate headers
header_counter = Counter(header_keys)
duplicate_headers = [key for key, count in header_counter.items() if count > 1]
ALLOW_DUPLICATE_HEADERS = {'accept', 'accept-encoding', 'accept-language', 'accept-charset', 'cookie'}
real_duplicates = [h for h in duplicate_headers if h not in ALLOW_DUPLICATE_HEADERS]
if real_duplicates:
log.warning(f"Security violation: Duplicate headers detected: {real_duplicates}")
raise HTTPException(
status_code=422,
detail="Invalid request parameters",
)
# Whitelist headers
unknown_headers = [key for key in header_keys if key not in ALLOWED_HEADERS]
if unknown_headers:
filtered_unknown = [h for h in unknown_headers if not h.startswith('sec-')]
if filtered_unknown:
log.warning(f"Security violation: Unknown headers detected: {filtered_unknown}")
raise HTTPException(
status_code=422,
detail="Invalid request parameters",
)
# Inspect header values
for key, value in request.headers.items():
if value:
inspect_value(value, f"header '{key}'")
# -------------------------
# 1. Query string limits
# -------------------------
if len(request.url.query) > MAX_QUERY_LENGTH:
log.warning(f"Security violation: Query string too long")
raise HTTPException(
status_code=422,
detail="Invalid request parameters",
)
params = request.query_params.multi_items()
if len(params) > MAX_QUERY_PARAMS:
log.warning(f"Security violation: Too many query parameters")
raise HTTPException(
status_code=422,
detail="Invalid request parameters",
)
# Check for unknown query parameters
# unknown_params = [key for key, _ in params if key not in ALLOWED_DATA_PARAMS]
# if unknown_params:
# log.warning(f"Security violation: Unknown query parameters detected: {unknown_params}")
# raise HTTPException(
# status_code=422,
# detail="Invalid request parameters",
# )
# -------------------------
# 2. Duplicate parameters
# -------------------------
counter = Counter(key for key, _ in params)
duplicates = [
key for key, count in counter.items()
if count > 1 and key not in ALLOWED_MULTI_PARAMS
]
if duplicates:
log.warning(f"Security violation: Duplicate query parameters detected: {duplicates}")
raise HTTPException(
status_code=422,
detail="Invalid request parameters",
)
# -------------------------
# 3. Query param inspection & Pagination
# -------------------------
pagination_size_keys = {"size", "itemsPerPage", "per_page", "limit", "items_per_page"}
for key, value in params:
if value:
inspect_value(value, f"query param '{key}'")
if key in pagination_size_keys and value:
try:
size_val = int(value)
if size_val > 100:
log.warning(f"Security violation: Pagination size too large ({size_val})")
raise HTTPException(
status_code=422,
detail="Invalid request parameters",
)
if size_val % 5 != 0:
log.warning(f"Security violation: Pagination size not multiple of 5 ({size_val})")
raise HTTPException(
status_code=422,
detail="Invalid request parameters",
)
except ValueError:
log.warning(f"Security violation: Pagination size invalid value")
raise HTTPException(
status_code=422,
detail="Invalid request parameters",
)
# -------------------------
# 4. Content-Type sanity
# -------------------------
content_type = request.headers.get("content-type", "")
if content_type and not any(
content_type.startswith(t)
for t in ("application/json", "multipart/form-data", "application/x-www-form-urlencoded")
):
log.warning(f"Security violation: Unsupported Content-Type: {content_type}")
raise HTTPException(status_code=422, detail="Invalid request parameters")
# -------------------------
# 5. Single source check (Query vs JSON Body)
# -------------------------
has_query = len(params) > 0
has_body = False
if content_type.startswith("application/json"):
# We can't easily check body existence without consuming it,
# so we check if Content-Length > 0
content_length = request.headers.get("content-length")
if content_length and int(content_length) > 0:
has_body = True
if has_query and has_body:
log.warning(f"Security violation: Mixed parameters (query + JSON body)")
raise HTTPException(
status_code=422,
detail="Invalid request parameters",
)
# -------------------------
# 6. JSON body inspection
# -------------------------
if content_type.startswith("application/json"):
body = await request.body()
# if len(body) > MAX_JSON_BODY_SIZE:
# raise HTTPException(status_code=422, detail="JSON body too large")
if body:
try:
payload = json.loads(body)
except json.JSONDecodeError:
log.warning(f"Security violation: Invalid JSON body")
raise HTTPException(status_code=422, detail="Invalid request parameters")
inspect_json(payload)
# Re-inject body for downstream handlers
async def receive():
return {"type": "http.request", "body": body}
request._receive = receive
return await call_next(request)

@ -69,8 +69,6 @@ class DefultBase(BaseModel):
validate_assignment = True validate_assignment = True
arbitrary_types_allowed = True arbitrary_types_allowed = True
str_strip_whitespace = True str_strip_whitespace = True
populate_by_name = True
extra="forbid"
json_encoders = { json_encoders = {
# custom output conversion for datetime # custom output conversion for datetime

@ -1,172 +0,0 @@
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

@ -1,917 +0,0 @@
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}

@ -1,233 +0,0 @@
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")

@ -1,252 +0,0 @@
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()

@ -1,140 +0,0 @@
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

@ -3,7 +3,7 @@ from typing import List
from fastapi import APIRouter, HTTPException, status from fastapi import APIRouter, HTTPException, status
from src.auth.service import Token from src.auth.service import Token
from src.database.core import CollectorDbSession, DbSession from src.database.core import DbSession
from src.models import StandardResponse from src.models import StandardResponse
from src.overhaul.service import (get_overhaul_critical_parts, from src.overhaul.service import (get_overhaul_critical_parts,
get_overhaul_overview, get_overhaul_overview,
@ -18,11 +18,11 @@ router = APIRouter()
@router.get("", response_model=StandardResponse[OverhaulRead]) @router.get("", response_model=StandardResponse[OverhaulRead])
async def get_overhaul(db_session: DbSession, token:Token, collector_db_session:CollectorDbSession): async def get_overhaul(db_session: DbSession, token:Token):
"""Get all scope pagination.""" """Get all scope pagination."""
overview = await get_overhaul_overview(db_session=db_session) overview = await get_overhaul_overview(db_session=db_session)
schedules = await get_overhaul_schedules(db_session=db_session) schedules = await get_overhaul_schedules(db_session=db_session)
criticalParts = await get_overhaul_critical_parts(db_session=db_session, session_id=overview["overhaul"]["id"], token=token, collector_db_session=collector_db_session) criticalParts = await get_overhaul_critical_parts(db_session=db_session, session_id=overview["overhaul"]["id"], token=token)
systemComponents = get_overhaul_system_components() systemComponents = get_overhaul_system_components()
return StandardResponse( return StandardResponse(

@ -6,7 +6,6 @@ from sqlalchemy import Delete, Select
from src.auth.service import CurrentUser from src.auth.service import CurrentUser
from src.calculation_target_reliability.service import RBD_SERVICE_API from src.calculation_target_reliability.service import RBD_SERVICE_API
from src.config import TC_RBD_ID
from src.database.core import DbSession from src.database.core import DbSession
from src.contribution_util import calculate_contribution from src.contribution_util import calculate_contribution
from src.overhaul_activity.service import get_standard_scope_by_session_id from src.overhaul_activity.service import get_standard_scope_by_session_id
@ -29,9 +28,9 @@ async def get_simulation_results(*, simulation_id: str, token: str):
"Content-Type": "application/json" "Content-Type": "application/json"
} }
calc_result_url = f"{RBD_SERVICE_API}/aeros/simulation/result/calc/{simulation_id}?nodetype=RegularNode" calc_result_url = f"{RBD_SERVICE_API}/aeros/simulation/result/calc/default?nodetype=RegularNode"
# plot_result_url = f"{RBD_SERVICE_API}/aeros/simulation/result/plot/{simulation_id}?nodetype=RegularNode" # plot_result_url = f"{RBD_SERVICE_API}/aeros/simulation/result/plot/{simulation_id}?nodetype=RegularNode"
calc_plant_result = f"{RBD_SERVICE_API}/aeros/simulation/result/calc/{simulation_id}/plant" calc_plant_result = f"{RBD_SERVICE_API}/aeros/simulation/result/calc/default/plant"
async with httpx.AsyncClient(timeout=300.0) as client: async with httpx.AsyncClient(timeout=300.0) as client:
calc_task = client.get(calc_result_url, headers=headers) calc_task = client.get(calc_result_url, headers=headers)
@ -55,17 +54,16 @@ async def get_simulation_results(*, simulation_id: str, token: str):
"plant_result": plant_data "plant_result": plant_data
} }
async def get_overhaul_critical_parts(db_session, session_id, token, collector_db_session): async def get_overhaul_critical_parts(db_session, session_id, token):
"""Get all overhaul critical parts.""" """Get all overhaul critical parts."""
equipments = await get_standard_scope_by_session_id( equipments, _ = await get_by_oh_session_id(
db_session=db_session, db_session=db_session,
overhaul_session_id=session_id, oh_session_id=session_id,
collector_db=collector_db_session
) )
criticality_simulation = await get_simulation_results( criticality_simulation = await get_simulation_results(
simulation_id = TC_RBD_ID, simulation_id="default",
token=token token=token
) )
@ -81,7 +79,7 @@ async def get_overhaul_critical_parts(db_session, session_id, token, collector_d
{ {
"id": equipment.id, "id": equipment.id,
"location_tag": equipment.location_tag, "location_tag": equipment.location_tag,
"name": equipment.equipment_name, "name": equipment.master_equipment.name,
"matrix": rbd_simulation.get(equipment.location_tag) "matrix": rbd_simulation.get(equipment.location_tag)
} for equipment in equipments } for equipment in equipments

@ -106,8 +106,8 @@ async def get_overhaul_equipment(
# ) # )
@router.post( @router.delete(
"/delete/{overhaul_session}/{location_tag}", "/{overhaul_session}/{location_tag}",
response_model=StandardResponse[None], response_model=StandardResponse[None],
) )
async def delete_scope(db_session: DbSession, location_tag: str, overhaul_session:UUID): async def delete_scope(db_session: DbSession, location_tag: str, overhaul_session:UUID):

@ -12,7 +12,6 @@ from src.auth.service import CurrentUser
from src.database.core import DbSession from src.database.core import DbSession
from src.database.service import CommonParameters, search_filter_sort_paginate from src.database.service import CommonParameters, search_filter_sort_paginate
from src.overhaul_activity.utils import get_material_cost, get_service_cost from src.overhaul_activity.utils import get_material_cost, get_service_cost
from src.utils import update_model
from src.overhaul_scope.model import OverhaulScope from src.overhaul_scope.model import OverhaulScope
from src.overhaul_scope.service import get as get_session, get_prev_oh from src.overhaul_scope.service import get as get_session, get_prev_oh
from src.standard_scope.model import MasterEquipment, StandardScope from src.standard_scope.model import MasterEquipment, StandardScope
@ -106,21 +105,10 @@ async def get_all(
).distinct() ).distinct()
) )
if location_tag: equipments = (await common['db_session'].execute(query)).scalars().all()
query = query.filter(StandardScope.location_tag == location_tag)
# Use search_filter_sort_paginate for server-side pagination
# Prioritize the 'all' parameter passed to the function
common_params = {**common, "all": all or common.get("all", False)}
paginated_results = await search_filter_sort_paginate(
model=query,
**common_params
)
equipments = paginated_results["items"]
material_cost = await get_cm_cost_summary(collector_db=collector_db, last_oh_date=prev_oh_scope.end_date, upcoming_oh_date=overhaul.start_date) material_cost = await get_cm_cost_summary(collector_db=collector_db, last_oh_date=prev_oh_scope.end_date, upcoming_oh_date=overhaul.start_date)
service_cost = get_service_cost(scope=overhaul.maintenance_type.name, total_equipment=len(equipments))
overhaul_cost = await get_oh_cost_summary(collector_db=collector_db, last_oh_date=prev_oh_scope.end_date, upcoming_oh_date=overhaul.start_date) overhaul_cost = await get_oh_cost_summary(collector_db=collector_db, last_oh_date=prev_oh_scope.end_date, upcoming_oh_date=overhaul.start_date)
results = [] results = []
@ -144,12 +132,21 @@ async def get_all(
results.append(res) results.append(res)
# Return paginated structure with transformed items # # Pagination parameters
return { # page = common.get("page", 1)
**paginated_results, # items_per_page = common.get("items_per_page", 10)
"items": results
# Sort by overhaul_cost descending
results.sort(key=lambda x: x.overhaul_cost, reverse=True)
# Build response data
data = {
"items": results,
"total": len(results),
} }
return data
async def get_standard_scope_by_session_id(*, db_session: DbSession, overhaul_session_id: UUID, collector_db: CollectorDbSession): async def get_standard_scope_by_session_id(*, db_session: DbSession, overhaul_session_id: UUID, collector_db: CollectorDbSession):
overhaul = await get_session(db_session=db_session, overhaul_session_id=overhaul_session_id) overhaul = await get_session(db_session=db_session, overhaul_session_id=overhaul_session_id)
@ -445,7 +442,9 @@ async def update(
update_data = overhaul_activity_in.model_dump(exclude_defaults=True) update_data = overhaul_activity_in.model_dump(exclude_defaults=True)
update_model(activity, update_data) for field in data:
if field in update_data:
setattr(activity, field, update_data[field])
await db_session.commit() await db_session.commit()

@ -1,17 +0,0 @@
from sqlalchemy import Column, String
from src.database.core import Base
from src.models import DefaultMixin
class OverhaulGantt(Base, DefaultMixin):
__tablename__ = "oh_ms_monitoring_spreadsheet"
spreadsheet_id = Column(String, nullable=True)
spreadsheet_link = Column(String, nullable=True)

@ -1,15 +1,11 @@
import re
from typing import List, Optional from typing import List, Optional
from fastapi import APIRouter, HTTPException, status from fastapi import APIRouter, HTTPException, status
from sqlalchemy import select
from src.auth.service import CurrentUser from src.auth.service import CurrentUser
from src.database.core import DbSession from src.database.core import DbSession
from src.database.service import CommonParameters from src.database.service import CommonParameters
from src.models import StandardResponse from src.models import StandardResponse
from src.overhaul_gantt.model import OverhaulGantt
from src.overhaul_gantt.schema import OverhaulGanttIn
# from .schema import (OverhaulScheduleCreate, OverhaulSchedulePagination, OverhaulScheduleUpdate) # from .schema import (OverhaulScheduleCreate, OverhaulSchedulePagination, OverhaulScheduleUpdate)
from .service import get_gantt_performance_chart from .service import get_gantt_performance_chart
@ -18,94 +14,19 @@ router = APIRouter()
@router.get( @router.get(
"", response_model=StandardResponse[dict] "", response_model=StandardResponse[list]
) )
async def get_gantt_performance(db_session: DbSession): async def get_gantt_performance():
"""Get all scope pagination.""" """Get all scope pagination."""
# return # return
query = select(OverhaulGantt).limit(1) results = await get_gantt_performance_chart()
data = (await db_session.execute(query)).scalar_one_or_none()
results, gantt_data = await get_gantt_performance_chart(spreadsheet_id=data.spreadsheet_id)
return StandardResponse( return StandardResponse(
data={ data=results,
"chart_data": results,
"gantt_data": gantt_data
},
message="Data retrieved successfully", message="Data retrieved successfully",
) )
@router.get(
"/spreadsheet", response_model=StandardResponse[dict]
)
async def get_gantt_spreadsheet(db_session: DbSession):
"""Get all scope pagination."""
# return
query = select(OverhaulGantt).limit(1)
data = (await db_session.execute(query)).scalar_one_or_none()
result = {
"spreadsheet_id": None,
"spreadsheet_link": None
}
if data:
result = {
"spreadsheet_id": data.spreadsheet_id,
"spreadsheet_link": data.spreadsheet_link
}
return StandardResponse(
data=result,
message="Data retrieved successfully",
)
@router.post(
"/spreadsheet", response_model=StandardResponse[dict]
)
async def update_gantt_spreadsheet(db_session: DbSession, spreadsheet_in: OverhaulGanttIn):
"""Get all scope pagination."""
# return
match = re.search(r"/d/([a-zA-Z0-9-_]+)", spreadsheet_in.spreadsheet_link)
if not match:
raise ValueError("Invalid Google Sheets URL")
spreadsheet_id = match.group(1)
query = select(OverhaulGantt).limit(1)
data = (await db_session.execute(query)).scalar_one_or_none()
if data:
data.spreadsheet_link = spreadsheet_in.spreadsheet_link
data.spreadsheet_id = spreadsheet_id
else:
spreadsheet = OverhaulGantt(
spreadsheet_id=spreadsheet_id,
spreadsheet_link=spreadsheet_in.spreadsheet_link
)
db_session.add(spreadsheet)
await db_session.commit()
if data:
result = {
"spreadsheet_id": spreadsheet_id
}
return StandardResponse(
data=result,
message="Data retrieved successfully",
)
# @router.post("", response_model=StandardResponse[None]) # @router.post("", response_model=StandardResponse[None])
# async def create_overhaul_equipment_jobs( # async def create_overhaul_equipment_jobs(

@ -9,12 +9,8 @@
# from src.scope_equipment_job.schema import ScopeEquipmentJobRead # from src.scope_equipment_job.schema import ScopeEquipmentJobRead
# from src.job.schema import ActivityMasterRead # from src.job.schema import ActivityMasterRead
from pydantic import Field # class OverhaulScheduleBase(DefultBase):
from src.models import DefultBase # pass
class OverhaulGanttIn(DefultBase):
spreadsheet_link: str = Field(...)
# class OverhaulScheduleCreate(OverhaulScheduleBase): # class OverhaulScheduleCreate(OverhaulScheduleBase):

@ -6,7 +6,7 @@ from sqlalchemy.orm import selectinload
# from .model import OverhaulSchedule # from .model import OverhaulSchedule
# from .schema import OverhaulScheduleCreate, OverhaulScheduleUpdate # from .schema import OverhaulScheduleCreate, OverhaulScheduleUpdate
from .utils import fetch_all_sections, get_google_creds, get_spreatsheed_service, process_spreadsheet_data from .utils import get_google_creds, get_spreatsheed_service, process_spreadsheet_data
# async def get_all(*, common): # async def get_all(*, common):
# """Returns all documents.""" # """Returns all documents."""
@ -53,60 +53,20 @@ from .utils import fetch_all_sections, get_google_creds, get_spreatsheed_service
async def get_gantt_performance_chart(*, spreadsheet_id = "1gZXuwA97zU1v4QBv56wKeiqadc6skHUucGKYG8qVFRk"): async def get_gantt_performance_chart(*, spreadsheet_id = "1gZXuwA97zU1v4QBv56wKeiqadc6skHUucGKYG8qVFRk"):
creds = get_google_creds() creds = get_google_creds()
RANGE_NAME = "'SUMMARY'!K34:AZ38" # Or just "2024 schedule" RANGE_NAME = "'2024 kurva s'!N79:BJ83" # Or just "2024 schedule"
GANTT_DATA_NAME = "ACTUAL PROGRESS"
try: try:
service = get_spreatsheed_service(creds) service = get_spreatsheed_service(creds)
sheet = service.spreadsheets() sheet = service.spreadsheets()
response = sheet.values().get(spreadsheetId=spreadsheet_id, range=RANGE_NAME).execute()
response = sheet.values().get(
spreadsheetId=spreadsheet_id,
range=RANGE_NAME
).execute()
values = response.get("values", []) values = response.get("values", [])
keys = ['day', 'time', 'plan', 'actual', 'gap']
if len(values) < 4: transposed = list(zip(*values))
raise Exception("Spreadsheet format invalid: need 4 rows (DAY, DATE, PLAN, ACTUAL).") results = [dict(zip(keys, result)) for result in transposed]
# Extract rows
day_row = values[0][1:]
date_row = values[1][1:]
plan_row = values[3][1:]
actual_row = values[4][1:]
total_days = len(day_row)
# PAD rows so lengths match day count
date_row += [""] * (total_days - len(date_row))
plan_row += [""] * (total_days - len(plan_row))
actual_row += [""] * (total_days - len(actual_row))
results = []
for i in range(total_days):
day = day_row[i]
date = date_row[i]
plan = plan_row[i]
actual = actual_row[i] if actual_row[i] else "0%" # <-- FIX HERE
results.append({
"day": day,
"date": date,
"plan": plan,
"actual": actual
})
except Exception as e: except Exception as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=e)
processed_data = process_spreadsheet_data(results) processed_data = process_spreadsheet_data(results)
gantt_data = fetch_all_sections(service=service, spreadsheet_id=spreadsheet_id, sheet_name=GANTT_DATA_NAME)
return processed_data, gantt_data return processed_data

@ -22,176 +22,44 @@ def process_spreadsheet_data(rows):
processed_data = [] processed_data = []
for row in rows: for row in rows:
processed_row = convert_spreadsheet_data(row) processed_row = convert_spreadsheet_data(row)
processed_data.append(processed_row) if processed_row else None processed_data.append(processed_row)
return processed_data return processed_data
from datetime import datetime def convert_spreadsheet_data(data):
from datetime import datetime
def convert_spreadsheet_data(data, default_year=None):
"""
Convert spreadsheet row into structured data.
Expected keys: day, date, plan, actual
"""
# Skip header or invalid rows
if not data.get("day") or not data["day"].isdigit():
return None
result = {} result = {}
# Convert day # Convert day to integer
result["day"] = int(data["day"]) result['day'] = int(data['day'])
# Determine default year
if default_year is None:
default_year = datetime.now().year
date_str = data.get("date", "").strip()
# ---------- DATE HANDLING ----------
# Accept formats like: "Nov 20", "Dec 3", "Jan 1"
parsed_date = None
if date_str:
try:
parsed_date = datetime.strptime(f"{date_str} {default_year}", "%b %d %Y")
except ValueError:
try:
parsed_date = datetime.strptime(f"{date_str} {default_year}", "%B %d %Y")
except:
parsed_date = None
# YEAR ROLLOVER (Dec → Jan next year)
if parsed_date and parsed_date.month == 1 and "Dec" in data.get("date", ""):
parsed_date = parsed_date.replace(year=default_year + 1)
result["date"] = parsed_date
# ---------- PERCENT HANDLING ----------
def parse_percent(value):
if not value:
return 0.0
v = value.strip().replace(",", ".").replace("%", "")
try:
return float(v) / 100.0
except:
return 0.0
result["plan"] = parse_percent(data.get("plan", "0")) # Convert time to a datetime object
result["actual"] = parse_percent(data.get("actual", "0")) from datetime import datetime
# Assuming Indonesian format with month names
# Gap calculation # Replace Indonesian month names with English if needed
result["gap"] = result["actual"] - result["plan"] month_mapping = {
'Januari': 'January', 'Februari': 'February', 'Maret': 'March',
return result 'April': 'April', 'Mei': 'May', 'Juni': 'June',
'Juli': 'July', 'Agustus': 'August', 'September': 'September',
'Oktober': 'October', 'November': 'November', 'Desember': 'December'
def fetch_all_sections(service, spreadsheet_id, sheet_name):
# Fetch a wide range including columns AL
result = service.spreadsheets().values().get(
spreadsheetId=spreadsheet_id,
range=f"{sheet_name}!A5:M5000"
).execute()
values = result.get("values", [])
if not values:
raise ValueError("No data found in sheet")
data = []
current_section = None
current_subsystem = None
for row in values:
# Pad missing columns to avoid index errors
row += [""] * (13 - len(row))
colA, colB, colC, colD, colE, colF, colG, colH, colI, colJ, colK, colL, colM = row
# Detect a SECTION — bold blue rows in Column C
if colC and not colD and not colE:
current_section = colC.strip()
current_subsystem = None
continue
# Detect a SUBSYSTEM — indented header in Column D
if colD and not colE:
current_subsystem = colD.strip()
continue
# Detect a TASK — Column E populated
if colE:
task = colE.strip()
pic = colF.strip()
start_date = indo_formatted_date(colG.strip())
finish_date = indo_formatted_date(colH.strip())
duration = colI.strip()
plan = colK.strip()
actual = colL.strip()
gap = colM.strip()
data.append({
"system": current_section,
"subsystem": current_subsystem,
"task": task,
"PIC": pic,
"start_date": start_date,
"end_date": finish_date,
"duration": int(duration),
"plan": plan,
"actual": actual,
"gap": gap
})
return data
def indo_formatted_date(date_str, base_year=2025):
"""
Convert short date like 'Nov 20', '30-Dec', 'Jan 1'
into: 'Rabu, November 20, 2025'
If month is January, year becomes 2026.
"""
# Month mappings
eng_to_indo_month = {
"Jan": "Januari", "Feb": "Februari", "Mar": "Maret", "Apr": "April",
"May": "Mei", "Jun": "Juni", "Jul": "Juli", "Aug": "Agustus",
"Sep": "September", "Oct": "Oktober", "Nov": "November", "Dec": "Desember"
}
indo_days = {
0: "Senin",
1: "Selasa",
2: "Rabu",
3: "Kamis",
4: "Jumat",
5: "Sabtu",
6: "Minggu"
} }
# Normalize formats ("30-Dec" → "Dec 30") time_str = data['time']
if "-" in date_str: for indo, eng in month_mapping.items():
d, m = date_str.split("-") time_str = time_str.replace(indo, eng)
date_str = f"{m} {d}"
# Format: "Sabtu, Juli 13, 2024" -> "Saturday, July 13, 2024"
# Parse using English abbreviation # Removing the day of week to simplify parsing
try: time_str = time_str.split(', ', 1)[1] # Remove "Sabtu, "
dt = datetime.strptime(f"{date_str} {base_year}", "%b %d %Y") result['time'] = datetime.strptime(time_str, '%B %d, %Y')
except:
return None # Convert percentage strings to floats
# Handling format like "0,12%" -> 0.12
for key in ['plan', 'actual', 'gap']:
# Replace comma with dot (European to US decimal notation)
value = data[key].replace(',', '.')
# Remove percentage sign
value = value.rstrip('%')
# Convert to float
result[key] = float(value) / 100 # Divide by 100 to get the actual decimal value
# Handle year rollover (Jan -> next year) return result
if dt.month == 1:
dt = dt.replace(year=base_year + 1)
# Convert to Indonesian components
day_name = indo_days[dt.weekday()]
month_name = eng_to_indo_month[dt.strftime("%b")]
return f"{day_name}, {month_name} {dt.day}, {dt.year}"

@ -46,7 +46,7 @@ async def create_overhaul_equipment_jobs(
) )
@router.post("/delete/{overhaul_job_id}", response_model=StandardResponse[None]) @router.delete("/{overhaul_job_id}", response_model=StandardResponse[None])
async def delete_overhaul_equipment_job(db_session: DbSession, overhaul_job_id): async def delete_overhaul_equipment_job(db_session: DbSession, overhaul_job_id):
await delete(db_session=db_session, overhaul_job_id=overhaul_job_id) await delete(db_session=db_session, overhaul_job_id=overhaul_job_id)

@ -42,7 +42,7 @@ async def create_overhaul_equipment_jobs(
message="Data created successfully", message="Data created successfully",
) )
@router.post("/update/{overhaul_job_id}", response_model=StandardResponse[None]) @router.put("/{overhaul_job_id}", response_model=StandardResponse[None])
async def update_overhaul_schedule( async def update_overhaul_schedule(
db_session: DbSession, overhaul_job_id: str, overhaul_job_in: OverhaulScheduleUpdate db_session: DbSession, overhaul_job_id: str, overhaul_job_in: OverhaulScheduleUpdate
): ):
@ -53,7 +53,7 @@ async def update_overhaul_schedule(
message="Data updated successfully", message="Data updated successfully",
) )
@router.post("/delete/{overhaul_job_id}", response_model=StandardResponse[None]) @router.delete("/{overhaul_job_id}", response_model=StandardResponse[None])
async def delete_overhaul_equipment_job(db_session: DbSession, overhaul_job_id): async def delete_overhaul_equipment_job(db_session: DbSession, overhaul_job_id):
await delete(db_session=db_session, overhaul_schedule_id=overhaul_job_id) await delete(db_session=db_session, overhaul_schedule_id=overhaul_job_id)

@ -7,7 +7,6 @@ from sqlalchemy.orm import selectinload
from src.auth.service import CurrentUser from src.auth.service import CurrentUser
from src.database.core import DbSession from src.database.core import DbSession
from src.database.service import search_filter_sort_paginate from src.database.service import search_filter_sort_paginate
from src.utils import update_model
from src.scope_equipment_job.model import ScopeEquipmentJob from src.scope_equipment_job.model import ScopeEquipmentJob
from src.overhaul_activity.model import OverhaulActivity from src.overhaul_activity.model import OverhaulActivity
@ -42,7 +41,9 @@ async def update(*, db_session: DbSession, overhaul_schedule_id: str, overhaul_j
update_data = overhaul_job_in.model_dump(exclude_defaults=True) update_data = overhaul_job_in.model_dump(exclude_defaults=True)
update_model(overhaul_schedule, update_data) for field in data:
if field in update_data:
setattr(overhaul_schedule, field, update_data[field])
await db_session.commit() await db_session.commit()

@ -1,5 +1,4 @@
from sqlalchemy import JSON from sqlalchemy import Column, DateTime, Float, Integer, String, ForeignKey, UUID
from sqlalchemy import Column, DateTime , Integer, String, ForeignKey, UUID
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from src.database.core import Base from src.database.core import Base
@ -15,7 +14,6 @@ class OverhaulScope(Base, DefaultMixin):
status = Column(String, nullable=False, default="Upcoming") status = Column(String, nullable=False, default="Upcoming")
maintenance_type_id = Column( maintenance_type_id = Column(
UUID(as_uuid=True), ForeignKey("oh_ms_maintenance_type.id"), nullable=False) UUID(as_uuid=True), ForeignKey("oh_ms_maintenance_type.id"), nullable=False)
wo_parent = Column(JSON, nullable=True)
maintenance_type = relationship("MaintenanceType", lazy="selectin", backref="overhaul_scopes") maintenance_type = relationship("MaintenanceType", lazy="selectin", backref="overhaul_scopes")
# activity_equipments = relationship("OverhaulActivity", lazy="selectin") # activity_equipments = relationship("OverhaulActivity", lazy="selectin")

@ -1,4 +1,3 @@
from typing import List
from typing import Optional from typing import Optional
from fastapi import APIRouter, HTTPException, status from fastapi import APIRouter, HTTPException, status
@ -10,7 +9,7 @@ from src.models import StandardResponse
from .model import OverhaulScope from .model import OverhaulScope
from .schema import ScopeCreate, ScopePagination, ScopeRead, ScopeUpdate from .schema import ScopeCreate, ScopePagination, ScopeRead, ScopeUpdate
from .service import create, delete, get, get_all, update,get_all_oh_with_history_service from .service import create, delete, get, get_all, update
router = APIRouter() router = APIRouter()
@ -26,9 +25,6 @@ async def get_scopes(common: CommonParameters, scope_name: Optional[str] = None)
message="Data retrieved successfully", message="Data retrieved successfully",
) )
@router.get("/history", response_model=StandardResponse[List[ScopeRead]])
async def get_history(db_session: DbSession):
return StandardResponse(data=await get_all_oh_with_history_service(db_session=db_session), message="Data retrieved successfully")
@router.get("/{overhaul_session_id}", response_model=StandardResponse[ScopeRead]) @router.get("/{overhaul_session_id}", response_model=StandardResponse[ScopeRead])
async def get_scope(db_session: DbSession, overhaul_session_id: str): async def get_scope(db_session: DbSession, overhaul_session_id: str):
@ -49,14 +45,14 @@ async def create_scope(db_session: DbSession, scope_in: ScopeCreate):
return StandardResponse(data=scope, message="Data created successfully") return StandardResponse(data=scope, message="Data created successfully")
@router.post("/update/{scope_id}", response_model=StandardResponse[ScopeRead]) @router.put("/{scope_id}", response_model=StandardResponse[ScopeRead])
async def update_scope( async def update_scope(
db_session: DbSession, db_session: DbSession,
scope_id: str, scope_id: str,
scope_in: ScopeUpdate, scope_in: ScopeUpdate,
current_user: CurrentUser, current_user: CurrentUser,
): ):
scope = await get(db_session=db_session, overhaul_session_id=scope_id, for_update=True) scope = await get(db_session=db_session, scope_id=scope_id)
if not scope: if not scope:
raise HTTPException( raise HTTPException(
@ -70,9 +66,9 @@ async def update_scope(
) )
@router.post("/delete/{scope_id}", response_model=StandardResponse[ScopeRead]) @router.delete("/{scope_id}", response_model=StandardResponse[ScopeRead])
async def delete_scope(db_session: DbSession, scope_id: str): async def delete_scope(db_session: DbSession, scope_id: str):
scope = await get(db_session=db_session, overhaul_session_id=scope_id, for_update=True) scope = await get(db_session=db_session, scope_id=scope_id)
if not scope: if not scope:
raise HTTPException( raise HTTPException(
@ -83,5 +79,3 @@ async def delete_scope(db_session: DbSession, scope_id: str):
await delete(db_session=db_session, scope_id=scope_id) await delete(db_session=db_session, scope_id=scope_id)
return StandardResponse(message="Data deleted successfully", data=scope) return StandardResponse(message="Data deleted successfully", data=scope)

@ -7,7 +7,7 @@ from src.auth.service import CurrentUser
from src.database.core import DbSession from src.database.core import DbSession
from src.database.service import search_filter_sort_paginate from src.database.service import search_filter_sort_paginate
from src.overhaul_activity.model import OverhaulActivity from src.overhaul_activity.model import OverhaulActivity
from src.utils import time_now, update_model from src.utils import time_now
from src.standard_scope.model import MasterEquipment, StandardScope, EquipmentOHHistory from src.standard_scope.model import MasterEquipment, StandardScope, EquipmentOHHistory
from src.workscope_group.model import MasterActivity from src.workscope_group.model import MasterActivity
from src.workscope_group_maintenance_type.model import WorkscopeOHType from src.workscope_group_maintenance_type.model import WorkscopeOHType
@ -20,12 +20,10 @@ from datetime import datetime
from uuid import UUID from uuid import UUID
async def get( async def get(
*, db_session: DbSession, overhaul_session_id: UUID, for_update: bool = False *, db_session: DbSession, overhaul_session_id: UUID
) -> Optional[OverhaulScope]: ) -> Optional[OverhaulScope]:
"""Returns a document based on the given document id.""" """Returns a document based on the given document id."""
query = Select(OverhaulScope).filter(OverhaulScope.id == overhaul_session_id).options(selectinload(OverhaulScope.maintenance_type)) query = Select(OverhaulScope).filter(OverhaulScope.id == overhaul_session_id).options(selectinload(OverhaulScope.maintenance_type))
if for_update:
query = query.with_for_update()
result = await db_session.execute(query) result = await db_session.execute(query)
return result.scalars().one_or_none() return result.scalars().one_or_none()
@ -73,22 +71,14 @@ async def create(*, db_session: DbSession, scope_in: ScopeCreate):
if start_date and end_date: if start_date and end_date:
duration_days = (end_date - start_date).days duration_days = (end_date - start_date).days
# Prevent TOCTOU for maintenance type creation maintenance_type = Select(MaintenanceType).where(MaintenanceType.name == scope_in.type)
maintenance_type = Select(MaintenanceType).where(MaintenanceType.name == scope_in.type).with_for_update() maintenance_type = await db_session.execute(maintenance_type)
maintenance_type_exec = await db_session.execute(maintenance_type) maintenance_type = maintenance_type.scalars().one_or_none()
maintenance_type = maintenance_type_exec.scalars().one_or_none()
if maintenance_type is None: if maintenance_type is None:
try:
# Need to use nested transaction for safe concurrent creation
async with db_session.begin_nested():
maintenance_type = MaintenanceType(name=scope_in.type) maintenance_type = MaintenanceType(name=scope_in.type)
db_session.add(maintenance_type) db_session.add(maintenance_type)
await db_session.flush() await db_session.flush()
except BaseException:
# Another concurrent request already created it
maintenance_type_exec = await db_session.execute(Select(MaintenanceType).where(MaintenanceType.name == scope_in.type).with_for_update())
maintenance_type = maintenance_type_exec.scalars().one_or_none()
# Create the OverhaulScope object with all hardcoded values # Create the OverhaulScope object with all hardcoded values
overhaul_session = OverhaulScope( overhaul_session = OverhaulScope(
@ -142,7 +132,9 @@ async def update(*, db_session: DbSession, scope: OverhaulScope, scope_in: Scope
update_data = scope_in.model_dump(exclude_defaults=True) update_data = scope_in.model_dump(exclude_defaults=True)
update_model(scope, update_data) for field in data:
if field in update_data:
setattr(scope, field, update_data[field])
await db_session.commit() await db_session.commit()
@ -169,7 +161,7 @@ async def get_overview_overhaul(*, db_session: DbSession):
) )
) )
ongoing_result = await db_session.execute(ongoing_query.options(selectinload(OverhaulScope.maintenance_type))) ongoing_result = await db_session.execute(ongoing_query.options(selectinload(OverhaulScope.maintenance_type)))
ongoing_overhaul = ongoing_result.scalar_one_or_none() ongoing_overhaul = ongoing_result.first()
# 2. If no ongoing overhaul, get the closest scheduled overhaul # 2. If no ongoing overhaul, get the closest scheduled overhaul
if ongoing_overhaul is None: if ongoing_overhaul is None:
@ -211,9 +203,6 @@ async def get_overview_overhaul(*, db_session: DbSession):
results = await db_session.execute(equipments) results = await db_session.execute(equipments)
#Remaining days based on status
remaining_days = (selected_overhaul.start_date - current_date).days if selected_overhaul.status == "Upcoming" else (selected_overhaul.end_date - current_date).days
return { return {
"status": selected_overhaul.status, "status": selected_overhaul.status,
"overhaul": { "overhaul": {
@ -223,12 +212,58 @@ async def get_overview_overhaul(*, db_session: DbSession):
"end_date": selected_overhaul.end_date, "end_date": selected_overhaul.end_date,
"duration_oh": selected_overhaul.duration_oh, "duration_oh": selected_overhaul.duration_oh,
"crew_number": selected_overhaul.crew_number, "crew_number": selected_overhaul.crew_number,
"remaining_days": remaining_days, "remaining_days": (selected_overhaul.start_date - current_date).days,
"equipment_count": len(results.scalars().all()), "equipment_count": len(results.scalars().all()),
}, },
} }
async def get_all_oh_with_history_service(*, db_session: DbSession):
query = Select(OverhaulScope).options(selectinload(OverhaulScope.maintenance_type)).where(OverhaulScope.wo_parent.isnot(None)) # if ongoing_result:
results = await db_session.execute(query) # ongoing_overhaul, equipment_count = ongoing_result # Unpack the result tuple
return results.scalars().all() # return {
# "status": "Ongoing",
# "overhaul": {
# "id": ongoing_overhaul.id,
# "type": ongoing_overhaul.maintenance_type.name,
# "start_date": ongoing_overhaul.start_date,
# "end_date": ongoing_overhaul.end_date,
# "duration_oh": ongoing_overhaul.duration_oh,
# "crew_number": ongoing_overhaul.crew_number,
# "remaining_days": (ongoing_overhaul.end_date - current_date).days,
# "equipment_count": equipment_count,
# },
# }
# # For upcoming overhaul with count
# upcoming_query = (
# Select(OverhaulScope, func.count(OverhaulActivity.id).label("equipment_count"))
# .outerjoin(OverhaulScope.activity_equipments)
# .where(
# OverhaulScope.start_date > current_date,
# )
# .group_by(OverhaulScope.id)
# .order_by(OverhaulScope.start_date)
# )
# upcoming_result = await db_session.execute(upcoming_query)
# upcoming_result = upcoming_result.first()
# if upcoming_result:
# upcoming_overhaul, equipment_count = upcoming_result # Unpack the result tuple
# days_until = (upcoming_overhaul.start_date - current_date).days
# return {
# "status": "Upcoming",
# "overhaul": {
# "id": upcoming_overhaul.id,
# "type": upcoming_overhaul.type,
# "start_date": upcoming_overhaul.start_date,
# "end_date": upcoming_overhaul.end_date,
# "duration_oh": upcoming_overhaul.duration_oh,
# "crew_number": upcoming_overhaul.crew_number,
# "remaining_days": days_until,
# "equipment_count": equipment_count,
# },
# }
# return {"status": "no_overhaul", "overhaul": None}

@ -1,7 +1,4 @@
from slowapi import Limiter from slowapi import Limiter
from slowapi.util import get_remote_address from slowapi.util import get_remote_address
limiter = Limiter( limiter = Limiter(key_func=get_remote_address)
key_func=get_remote_address,
default_limits=["20000 per hour", "100000 per day"]
)

@ -33,11 +33,3 @@ class MasterSparepartProcurement(Base, DefaultMixin):
eta_requisition = Column(Date, nullable=False) eta_requisition = Column(Date, nullable=False)
eta_ordered = Column(Date, nullable=True) eta_ordered = Column(Date, nullable=True)
eta_received = Column(Date, nullable=True) eta_received = Column(Date, nullable=True)
class SparepartRemark(Base, DefaultMixin):
__tablename__ = "oh_ms_sparepart_remark"
itemnum = Column(String, nullable=False)
remark = Column(String, nullable=False)

@ -4,18 +4,17 @@ from src.database.core import CollectorDbSession
from src.database.service import (CommonParameters, DbSession, from src.database.service import (CommonParameters, DbSession,
search_filter_sort_paginate) search_filter_sort_paginate)
from src.models import StandardResponse from src.models import StandardResponse
from src.sparepart.schema import SparepartRemark
from .service import create_remark, get_spareparts_paginated from .service import get_all
router = APIRouter() router = APIRouter()
@router.get("", response_model=StandardResponse[list]) @router.get("", response_model=StandardResponse[list])
async def get_sparepart(collector_db_session:CollectorDbSession, db_session: DbSession): async def get_sparepart(collector_db_session:CollectorDbSession):
"""Get all scope activity pagination.""" """Get all scope activity pagination."""
# return # return
data = await get_spareparts_paginated(db_session=db_session, collector_db_session=collector_db_session) data = await get_all(collector_db_session)
@ -25,17 +24,6 @@ async def get_sparepart(collector_db_session:CollectorDbSession, db_session: DbS
) )
@router.post("", response_model=StandardResponse[SparepartRemark])
async def create_remark_route(collector_db_session:CollectorDbSession, db_session: DbSession, remark_in:SparepartRemark):
sparepart_remark = await create_remark(db_session=db_session, collector_db_session=collector_db_session, remark_in=remark_in)
return StandardResponse(
data=sparepart_remark,
message="Remark Created successfully"
)
# @router.post("", response_model=StandardResponse[ActivityMasterCreate]) # @router.post("", response_model=StandardResponse[ActivityMasterCreate])
# async def create_activity(db_session: DbSession, activity_in: ActivityMasterCreate): # async def create_activity(db_session: DbSession, activity_in: ActivityMasterCreate):

@ -1,6 +1,4 @@
from dataclasses import dataclass from datetime import datetime
from datetime import date, datetime
from enum import Enum
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
from uuid import UUID from uuid import UUID
@ -37,48 +35,41 @@ class ActivityMasterRead(ActivityMaster):
class ActivityMasterPagination(Pagination): class ActivityMasterPagination(Pagination):
items: List[ActivityMasterRead] = [] items: List[ActivityMasterRead] = []
class ProcurementStatus(Enum):
PLANNED = "planned" # {
ORDERED = "ordered" # "overview": {
RECEIVED = "received" # "totalEquipment": 30,
CANCELLED = "cancelled" # "nextSchedule": {
# "date": "2025-01-12",
@dataclass # "Overhaul": "B",
class SparepartRequirement: # "equipmentCount": 30
"""Sparepart requirement for equipment overhaul""" # }
sparepart_id: str # },
quantity_required: int # "criticalParts": [
lead_time: int # "Boiler feed pump",
sparepart_name: str # "Boiler reheater system",
unit_cost: float # "Drum Level (Right) Root Valve A",
avg_cost: float # "BCP A Discharge Valve",
remark:str # "BFPT A EXH Press HI Root VLV"
# ],
@dataclass # "schedules": [
class SparepartStock: # {
"""Current sparepart stock information""" # "date": "2025-01-12",
sparepart_id: str # "Overhaul": "B",
sparepart_name: str # "status": "upcoming"
current_stock: int # }
unit_cost: float # // ... other scheduled overhauls
location: str # ],
remark:str # "systemComponents": {
# "boiler": {
@dataclass # "status": "operational",
class ProcurementRecord: # "lastOverhaul": "2024-06-15"
"""Purchase Order/Purchase Request record""" # },
po_pr_id: str # "turbine": {
sparepart_id: str # "hpt": { "status": "operational" },
sparepart_name: str # "ipt": { "status": "operational" },
quantity: int # "lpt": { "status": "operational" }
unit_cost: float # }
total_cost: float # // ... other major components
order_date: date # }
expected_delivery_date: date # }
status: ProcurementStatus
po_vendor_delivery_date: date
class SparepartRemark(DefultBase):
itemnum: str
remark:str

File diff suppressed because it is too large Load Diff

@ -4,15 +4,15 @@ from fastapi import APIRouter, HTTPException, status
from fastapi.params import Query from fastapi.params import Query
from src.auth.service import CurrentUser from src.auth.service import CurrentUser
from src.database.core import DbSession, CollectorDbSession from src.database.core import DbSession
from src.database.service import CommonParameters, search_filter_sort_paginate from src.database.service import CommonParameters, search_filter_sort_paginate
from src.models import StandardResponse from src.models import StandardResponse
from .schema import (MasterEquipmentPagination, ScopeEquipmentCreate, from .schema import (MasterEquipmentPagination, ScopeEquipmentCreate,
ScopeEquipmentPagination, ScopeEquipmentRead, ScopeEquipmentPagination, ScopeEquipmentRead,
ScopeEquipmentUpdate) ScopeEquipmentUpdate)
from .service import (create, delete, get_all, get_all_master_equipment, update, get_history_standard_scope_wo_service) from .service import (create, delete, get_all, get_all_master_equipment, update)
from uuid import UUID
router = APIRouter() router = APIRouter()
@ -47,13 +47,6 @@ async def create_scope_equipment(
return StandardResponse(data=scope, message="Data created successfully") return StandardResponse(data=scope, message="Data created successfully")
@router.get("/history/{oh_session_id}", response_model=StandardResponse[List[dict]])
async def get_history_standard_scope_wo(
db_session: DbSession, collector_db_session:CollectorDbSession, oh_session_id:UUID):
results = await get_history_standard_scope_wo_service(db_session=db_session, collector_db_session=collector_db_session, oh_session_id=oh_session_id)
return StandardResponse(data=results, message="Data retrieved successfully")
# @router.put("/{assetnum}", response_model=StandardResponse[ScopeEquipmentRead]) # @router.put("/{assetnum}", response_model=StandardResponse[ScopeEquipmentRead])
# async def update_scope_equipment( # async def update_scope_equipment(
# db_session: DbSession, assetnum: str, scope__equipment_in: ScopeEquipmentUpdate # db_session: DbSession, assetnum: str, scope__equipment_in: ScopeEquipmentUpdate

@ -37,9 +37,8 @@ class ScopeEquipmentRead(ScopeEquipmentBase):
master_equipment: Optional[MasterEquipmentBase] = Field(None) master_equipment: Optional[MasterEquipmentBase] = Field(None)
class ScopeEquipmentPagination(DefultBase): class ScopeEquipmentPagination(Pagination):
items: List[ScopeEquipmentRead] = [] items: List[ScopeEquipmentRead] = []
total: int
class MasterEquipmentRead(DefultBase): class MasterEquipmentRead(DefultBase):
assetnum: Optional[str] = Field(None, title="Asset Number") assetnum: Optional[str] = Field(None, title="Asset Number")

@ -7,8 +7,7 @@ from sqlalchemy.dialects.postgresql import insert
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from src.auth.service import CurrentUser from src.auth.service import CurrentUser
from src.database.core import DbSession, CollectorDbSession from src.database.core import DbSession
from src.utils import update_model
from src.database.service import CommonParameters, search_filter_sort_paginate from src.database.service import CommonParameters, search_filter_sort_paginate
from src.overhaul_scope.model import OverhaulScope from src.overhaul_scope.model import OverhaulScope
from src.standard_scope.enum import ScopeEquipmentType from src.standard_scope.enum import ScopeEquipmentType
@ -19,7 +18,6 @@ from src.workscope_group.model import MasterActivity
from src.workscope_group_maintenance_type.model import WorkscopeOHType from src.workscope_group_maintenance_type.model import WorkscopeOHType
from src.overhaul_scope.model import MaintenanceType from src.overhaul_scope.model import MaintenanceType
from src.overhaul_scope.service import get as get_overhaul from src.overhaul_scope.service import get as get_overhaul
from src.maximo.service import get_history_oh_wo
from .model import MasterEquipment, MasterEquipmentTree, StandardScope from .model import MasterEquipment, MasterEquipmentTree, StandardScope
from .schema import ScopeEquipmentCreate, ScopeEquipmentUpdate from .schema import ScopeEquipmentCreate, ScopeEquipmentUpdate
from uuid import UUID from uuid import UUID
@ -62,15 +60,8 @@ async def get_all(*, common, oh_scope: Optional[str] = None):
# ).distinct() # ).distinct()
) )
results = await common['db_session'].execute(query) results = await search_filter_sort_paginate(model=query, **common)
return results
items = results.scalars().all()
return {
"items": items,
"total": len(items)
}
async def get_by_oh_session_id(*, db_session: DbSession, oh_session_id: UUID): async def get_by_oh_session_id(*, db_session: DbSession, oh_session_id: UUID):
@ -155,7 +146,9 @@ async def update(
update_data = scope_equipment_in.model_dump(exclude_defaults=True) update_data = scope_equipment_in.model_dump(exclude_defaults=True)
update_model(scope_equipment, update_data) for field in data:
if field in update_data:
setattr(scope_equipment, field, update_data[field])
await db_session.commit() await db_session.commit()
@ -220,25 +213,3 @@ async def get_equipment_level_by_no(*, db_session: DbSession, level: int):
result = await db_session.execute(query) result = await db_session.execute(query)
return result.scalars().all() return result.scalars().all()
async def get_history_standard_scope_wo_service(*, db_session: DbSession, collector_db_session:CollectorDbSession, oh_session_id:UUID):
planning_oh_data = await get_by_oh_session_id(db_session=db_session, oh_session_id=oh_session_id)
planning_scopes = planning_oh_data[0]
overhaul = planning_oh_data[1]
results = await get_history_oh_wo(
db_session=db_session,
collector_db_session=collector_db_session,
oh_session_id=oh_session_id,
parent_wo_num=overhaul.wo_parent
)
scope_cost_map = {scope.location_tag: scope.service_cost for scope in planning_scopes}
for result in results:
result["planning_service_cost"] = scope_cost_map.get(result["location_tag"], 0)
return results

@ -22,8 +22,7 @@ def parse_relative_expression(date_str: str) -> Optional[datetime]:
unit, offset = match.groups() unit, offset = match.groups()
offset = int(offset) if offset else 0 offset = int(offset) if offset else 0
# Use UTC timezone for consistency # Use UTC timezone for consistency
jakarta_tz = pytz.timezone("Asia/Jakarta") today = datetime.now(timezone.tzname("Asia/Jakarta"))
today = datetime.now(jakarta_tz)
if unit == "H": if unit == "H":
# For hours, keep minutes and seconds # For hours, keep minutes and seconds
result_time = today + timedelta(hours=offset) result_time = today + timedelta(hours=offset)
@ -65,7 +64,7 @@ def parse_date_string(date_str: str) -> Optional[datetime]:
minute=0, minute=0,
second=0, second=0,
microsecond=0, microsecond=0,
tzinfo=pytz.timezone("Asia/Jakarta"), tzinfo=timezone.tzname("Asia/Jakarta"),
) )
return dt return dt
except ValueError: except ValueError:
@ -140,13 +139,3 @@ def save_to_pastebin(data, title="Result Log", expire_date="1H"):
return response.text # This will be the paste URL return response.text # This will be the paste URL
else: else:
return f"Error: {response.status_code} - {response.text}" return f"Error: {response.status_code} - {response.text}"
def update_model(model, update_data: dict):
"""
Update a SQLAlchemy model with data from a dictionary.
"""
for key, value in update_data.items():
if hasattr(model, key):
setattr(model, key, value)

@ -47,8 +47,8 @@ async def get_activity(db_session: DbSession, activity_id: str):
return StandardResponse(data=activity, message="Data retrieved successfully") return StandardResponse(data=activity, message="Data retrieved successfully")
@router.post( @router.put(
"/update/{scope_equipment_activity_id}", response_model=StandardResponse[ActivityMaster] "/{scope_equipment_activity_id}", response_model=StandardResponse[ActivityMaster]
) )
async def update_scope( async def update_scope(
db_session: DbSession, activity_in: ActivityMasterCreate, activity_id db_session: DbSession, activity_in: ActivityMasterCreate, activity_id
@ -69,8 +69,8 @@ async def update_scope(
) )
@router.post( @router.delete(
"/delete/{scope_equipment_activity_id}", response_model=StandardResponse[ActivityMaster] "/{scope_equipment_activity_id}", response_model=StandardResponse[ActivityMaster]
) )
async def delete_scope(db_session: DbSession, activity_id: str): async def delete_scope(db_session: DbSession, activity_id: str):
activity = await get(db_session=db_session, activity_id=activity_id) activity = await get(db_session=db_session, activity_id=activity_id)

@ -6,7 +6,6 @@ from sqlalchemy.orm import joinedload, selectinload
from src.auth.service import CurrentUser from src.auth.service import CurrentUser
from src.database.core import DbSession from src.database.core import DbSession
from src.database.service import CommonParameters, search_filter_sort_paginate from src.database.service import CommonParameters, search_filter_sort_paginate
from src.utils import update_model
from .model import MasterActivity from .model import MasterActivity
from .schema import ActivityMaster, ActivityMasterCreate from .schema import ActivityMaster, ActivityMasterCreate
@ -44,7 +43,9 @@ async def update(
update_data = activity_in.model_dump(exclude_defaults=True) update_data = activity_in.model_dump(exclude_defaults=True)
update_model(activity, update_data) for field in data:
if field in update_data:
setattr(activity, field, update_data[field])
await db_session.commit() await db_session.commit()

@ -6,7 +6,6 @@ from sqlalchemy.orm import joinedload, selectinload
from src.auth.service import CurrentUser from src.auth.service import CurrentUser
from src.database.core import DbSession from src.database.core import DbSession
from src.database.service import CommonParameters, search_filter_sort_paginate from src.database.service import CommonParameters, search_filter_sort_paginate
from src.utils import update_model
from .model import MasterActivity from .model import MasterActivity
from .schema import ActivityMaster, ActivityMasterCreate from .schema import ActivityMaster, ActivityMasterCreate
@ -44,7 +43,9 @@ async def update(
update_data = activity_in.model_dump(exclude_defaults=True) update_data = activity_in.model_dump(exclude_defaults=True)
update_model(activity, update_data) for field in data:
if field in update_data:
setattr(activity, field, update_data[field])
await db_session.commit() await db_session.commit()

@ -1,159 +0,0 @@
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

@ -1,21 +0,0 @@
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)

@ -1,6 +0,0 @@
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.

@ -1,68 +1,68 @@
# import asyncio import asyncio
# from typing import AsyncGenerator, Generator from typing import AsyncGenerator, Generator
# import pytest import pytest
# from httpx import AsyncClient from httpx import AsyncClient
# from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
# from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import sessionmaker
# from sqlalchemy.pool import StaticPool from sqlalchemy.pool import StaticPool
# from sqlalchemy_utils import database_exists, drop_database from sqlalchemy_utils import database_exists, drop_database
# from starlette.config import environ from starlette.config import environ
# from starlette.testclient import TestClient from starlette.testclient import TestClient
# # from src.database import Base, get_db # from src.database import Base, get_db
# # from src.main import app # from src.main import app
# # Test database URL # Test database URL
# TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:" TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:"
# engine = create_async_engine( engine = create_async_engine(
# TEST_DATABASE_URL, TEST_DATABASE_URL,
# connect_args={"check_same_thread": False}, connect_args={"check_same_thread": False},
# poolclass=StaticPool, poolclass=StaticPool,
# ) )
# async_session = sessionmaker( async_session = sessionmaker(
# engine, engine,
# class_=AsyncSession, class_=AsyncSession,
# expire_on_commit=False, expire_on_commit=False,
# autocommit=False, autocommit=False,
# autoflush=False, autoflush=False,
# ) )
# async def override_get_db() -> AsyncGenerator[AsyncSession, None]: async def override_get_db() -> AsyncGenerator[AsyncSession, None]:
# async with async_session() as session: async with async_session() as session:
# try: try:
# yield session yield session
# await session.commit() await session.commit()
# except Exception: except Exception:
# await session.rollback() await session.rollback()
# raise raise
# finally: finally:
# await session.close() await session.close()
# app.dependency_overrides[get_db] = override_get_db app.dependency_overrides[get_db] = override_get_db
# @pytest.fixture(scope="session") @pytest.fixture(scope="session")
# def event_loop() -> Generator: def event_loop() -> Generator:
# loop = asyncio.get_event_loop_policy().new_event_loop() loop = asyncio.get_event_loop_policy().new_event_loop()
# yield loop yield loop
# loop.close() loop.close()
# @pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
# async def setup_db() -> AsyncGenerator[None, None]: async def setup_db() -> AsyncGenerator[None, None]:
# async with engine.begin() as conn: async with engine.begin() as conn:
# await conn.run_sync(Base.metadata.create_all) await conn.run_sync(Base.metadata.create_all)
# yield yield
# async with engine.begin() as conn: async with engine.begin() as conn:
# await conn.run_sync(Base.metadata.drop_all) await conn.run_sync(Base.metadata.drop_all)
# @pytest.fixture @pytest.fixture
# async def client() -> AsyncGenerator[AsyncClient, None]: async def client() -> AsyncGenerator[AsyncClient, None]:
# async with AsyncClient(app=app, base_url="http://test") as client: async with AsyncClient(app=app, base_url="http://test") as client:
# yield client yield client

@ -0,0 +1,3 @@
from sqlalchemy.orm import scoped_session, sessionmaker
Session = scoped_session(sessionmaker())

@ -0,0 +1,28 @@
import uuid
from datetime import datetime
from factory import (LazyAttribute, LazyFunction, SelfAttribute, Sequence,
SubFactory, post_generation)
from factory.alchemy import SQLAlchemyModelFactory
from factory.fuzzy import FuzzyChoice, FuzzyDateTime, FuzzyInteger, FuzzyText
from faker import Faker
from faker.providers import misc
from .database import Session
# from pytz import UTC
fake = Faker()
fake.add_provider(misc)
class BaseFactory(SQLAlchemyModelFactory):
"""Base Factory."""
class Meta:
"""Factory configuration."""
abstract = True
sqlalchemy_session = Session
sqlalchemy_session_persistence = "commit"

@ -1,44 +0,0 @@
import pytest
from src.calculation_budget_constrains.service import greedy_selection, knapsack_selection
def test_greedy_selection():
equipments = [
{"id": 1, "total_cost": 100, "priority_score": 10, "cost": 100},
{"id": 2, "total_cost": 50, "priority_score": 20, "cost": 50},
{"id": 3, "total_cost": 60, "priority_score": 15, "cost": 60},
]
budget = 120
# Items sorted by priority_score: id 2 (20), id 3 (15), id 1 (10)
# 2 (50) + 3 (60) = 110. Item 1 (100) won't fit.
selected, excluded = greedy_selection(equipments, budget)
selected_ids = [e["id"] for e in selected]
assert 2 in selected_ids
assert 3 in selected_ids
assert len(selected) == 2
assert excluded[0]["id"] == 1
def test_knapsack_selection_basic():
# Similar items but where greedy might fail if cost/value ratio is tricky
# item 1: value 10, cost 60
# item 2: value 7, cost 35
# item 3: value 7, cost 35
# budget: 70
# Greedy would take item 1 (value 10, remaining budget 10, can't take more)
# Optimal would take item 2 and 3 (value 14, remaining budget 0)
scale = 1 # No scaling for simplicity in this test
equipments = [
{"id": 1, "total_cost": 60, "priority_score": 10},
{"id": 2, "total_cost": 35, "priority_score": 7},
{"id": 3, "total_cost": 35, "priority_score": 7},
]
budget = 70
selected, excluded = knapsack_selection(equipments, budget, scale=1)
selected_ids = [e["id"] for e in selected]
assert 2 in selected_ids
assert 3 in selected_ids
assert len(selected) == 2
assert 1 not in selected_ids

@ -1,14 +0,0 @@
from src.context import set_request_id, get_request_id, set_user_id, get_user_id
def test_request_id_context():
test_id = "test-request-id-123"
set_request_id(test_id)
assert get_request_id() == test_id
def test_user_id_context():
test_uid = "user-456"
set_user_id(test_uid)
assert get_user_id() == test_uid
def test_context_default_none():
assert get_request_id() is None or get_request_id() != ""

@ -1,53 +0,0 @@
import pytest
from decimal import Decimal
from src.contribution_util import prod, system_availability, get_all_components, birnbaum_importance
def test_prod():
assert prod([1, 2, 3]) == 6.0
assert prod([0.5, 0.5]) == 0.25
assert prod([]) == 1.0
def test_system_availability_series():
structure = {"series": ["A", "B"]}
availabilities = {"A": 0.9, "B": 0.8}
# 0.9 * 0.8 = 0.72
assert system_availability(structure, availabilities) == pytest.approx(0.72)
def test_system_availability_parallel():
structure = {"parallel": ["A", "B"]}
availabilities = {"A": 0.9, "B": 0.8}
# 1 - (1-0.9)*(1-0.8) = 1 - 0.1*0.2 = 1 - 0.02 = 0.98
assert system_availability(structure, availabilities) == pytest.approx(0.98)
def test_system_availability_nested():
# (A in series with (B in parallel with C))
structure = {
"series": [
"A",
{"parallel": ["B", "C"]}
]
}
availabilities = {"A": 0.9, "B": 0.8, "C": 0.7}
# B||C = 1 - (1-0.8)*(1-0.7) = 1 - 0.2*0.3 = 0.94
# A && (B||C) = 0.9 * 0.94 = 0.846
assert system_availability(structure, availabilities) == pytest.approx(0.846)
def test_get_all_components():
structure = {
"series": [
"A",
{"parallel": ["B", "C"]}
]
}
assert get_all_components(structure) == {"A", "B", "C"}
def test_birnbaum_importance():
structure = {"series": ["A", "B"]}
availabilities = {"A": 0.9, "B": 0.8}
# I_B(A) = A_sys(A=1) - A_sys(A=0)
# A_sys(A=1, B=0.8) = 1 * 0.8 = 0.8
# A_sys(A=0, B=0.8) = 0 * 0.8 = 0
# I_B(A) = 0.8
assert birnbaum_importance(structure, availabilities, "A") == pytest.approx(0.8)
# I_B(B) = A_sys(B=1, A=0.9) - A_sys(B=0, A=0.9) = 0.9 - 0 = 0.9
assert birnbaum_importance(structure, availabilities, "B") == pytest.approx(0.9)

@ -1,31 +0,0 @@
import pytest
from sqlalchemy.exc import IntegrityError, DataError, DBAPIError
from src.exceptions import handle_sqlalchemy_error
def test_handle_sqlalchemy_error_unique_constraint():
err = IntegrityError("Unique constraint", params=None, orig=Exception("unique constraint violation"))
msg, status = handle_sqlalchemy_error(err)
assert status == 409
assert "already exists" in msg
def test_handle_sqlalchemy_error_foreign_key():
err = IntegrityError("Foreign key constraint", params=None, orig=Exception("foreign key constraint violation"))
msg, status = handle_sqlalchemy_error(err)
assert status == 400
assert "Related record not found" in msg
def test_handle_sqlalchemy_error_data_error():
err = DataError("Invalid data", params=None, orig=None)
msg, status = handle_sqlalchemy_error(err)
assert status == 400
assert "Invalid data" in msg
def test_handle_sqlalchemy_error_generic_dbapi():
class MockError:
def __str__(self):
return "Some generic database error"
err = DBAPIError("Generic error", params=None, orig=MockError())
msg, status = handle_sqlalchemy_error(err)
assert status == 500
assert "Database error" in msg

@ -1,58 +0,0 @@
import pytest
from unittest.mock import AsyncMock, MagicMock
from fastapi import HTTPException
from src.middleware import RequestValidationMiddleware
@pytest.mark.asyncio
async def test_request_validation_middleware_query_length():
middleware = RequestValidationMiddleware(app=MagicMock())
request = MagicMock()
request.url.query = "a" * 2001
with pytest.raises(HTTPException) as excinfo:
await middleware.dispatch(request, AsyncMock())
assert excinfo.value.status_code == 422
assert "Invalid request parameters" in excinfo.value.detail
@pytest.mark.asyncio
async def test_request_validation_middleware_too_many_params():
middleware = RequestValidationMiddleware(app=MagicMock())
request = MagicMock()
request.url.query = "a=1"
request.query_params.multi_items.return_value = [("param", "val")] * 51
with pytest.raises(HTTPException) as excinfo:
await middleware.dispatch(request, AsyncMock())
assert excinfo.value.status_code == 422
assert "Invalid request parameters" in excinfo.value.detail
@pytest.mark.asyncio
async def test_request_validation_middleware_xss_detection():
middleware = RequestValidationMiddleware(app=MagicMock())
request = MagicMock()
request.url.query = "q=<script>"
request.query_params.multi_items.return_value = [("q", "<script>")]
with pytest.raises(HTTPException) as excinfo:
await middleware.dispatch(request, AsyncMock())
assert excinfo.value.status_code == 422
assert "Invalid request parameters" in excinfo.value.detail
@pytest.mark.asyncio
async def test_request_validation_middleware_pagination_logic():
middleware = RequestValidationMiddleware(app=MagicMock())
request = MagicMock()
request.url.query = "size=105"
request.query_params.multi_items.return_value = [("size", "105")]
request.headers = {}
with pytest.raises(HTTPException) as excinfo:
await middleware.dispatch(request, AsyncMock())
assert excinfo.value.status_code == 422
assert "Invalid request parameters" in excinfo.value.detail
request.query_params.multi_items.return_value = [("size", "7")]
with pytest.raises(HTTPException) as excinfo:
await middleware.dispatch(request, AsyncMock())
assert excinfo.value.status_code == 422
assert "Invalid request parameters" in excinfo.value.detail

@ -1,64 +0,0 @@
import pytest
import math
from src.calculation_target_reliability.service import calculate_asset_eaf_contributions
def test_calculate_asset_eaf_contributions_basic():
# Mock plant result
plant_result = {
"total_uptime": 7000,
"total_downtime": 1000,
"eaf": 85.0
}
# total_hours = 8000
# Mock equipment results
eq_results = [
{
"aeros_node": {"node_name": "Asset1"},
"num_events": 5,
"contribution_factor": 0.5,
"contribution": 0.1, # Birnbaum
"availability": 0.9,
"total_downtime": 100
},
{
"aeros_node": {"node_name": "Asset2"},
"num_events": 2,
"contribution_factor": 0.3,
"contribution": 0.05,
"availability": 0.95,
"total_downtime": 50
}
]
standard_scope = ["Asset1", "Asset2"]
eaf_gap = 2.0 # 2% gap
scheduled_outage = 500
results = calculate_asset_eaf_contributions(
plant_result, eq_results, standard_scope, eaf_gap, scheduled_outage
)
assert len(results) == 2
# Check sorting (highest birnbaum first)
assert results[0].node["node_name"] == "Asset1"
assert results[0].birbaum > results[1].birbaum
# Check that required_improvement is positive
assert results[0].required_improvement > 0
assert results[0].improvement_impact > 0
def test_calculate_asset_eaf_contributions_skipping():
plant_result = {"total_uptime": 1000, "total_downtime": 0, "eaf": 100}
eq_results = [{
"aeros_node": {"node_name": "Asset1"},
"num_events": 0,
"contribution_factor": 0.5,
"contribution": 0.1,
"availability": 1.0,
"total_downtime": 0
}]
results = calculate_asset_eaf_contributions(
plant_result, eq_results, ["Asset1"], 1.0, 0
)
assert len(results) == 0

@ -1,49 +0,0 @@
import pytest
from pydantic import ValidationError
from src.database.schema import CommonParams
from src.overhaul.schema import OverhaulCriticalParts
def test_common_params_valid():
params = CommonParams(
page=1,
itemsPerPage=10,
q="search test",
all=1
)
assert params.page == 1
assert params.items_per_page == 10
assert params.query_str == "search test"
assert params.is_all is True
def test_common_params_page_constraints():
# Test page must be > 0
with pytest.raises(ValidationError):
CommonParams(page=0)
with pytest.raises(ValidationError):
CommonParams(page=-1)
def test_common_params_items_per_page_constraints():
# Test items_per_page must be multiple of 5
with pytest.raises(ValidationError):
CommonParams(itemsPerPage=7)
# Test items_per_page maximum
with pytest.raises(ValidationError):
CommonParams(itemsPerPage=55)
# Valid multiples of 5
assert CommonParams(itemsPerPage=50).items_per_page == 50
assert CommonParams(itemsPerPage=5).items_per_page == 5
def test_overhaul_critical_parts_valid():
parts = OverhaulCriticalParts(criticalParts=["Part A", "Part B"])
assert parts.criticalParts == ["Part A", "Part B"]
def test_overhaul_critical_parts_invalid():
# criticalParts is required and must be a list
with pytest.raises(ValidationError):
OverhaulCriticalParts()
with pytest.raises(ValidationError):
OverhaulCriticalParts(criticalParts="Not a list")

@ -1,55 +0,0 @@
import pytest
from fastapi import HTTPException
from src.middleware import (
inspect_value,
inspect_json,
has_control_chars,
XSS_PATTERN,
SQLI_PATTERN
)
def test_xss_patterns():
# Test common XSS payloads in be-optimumoh
payloads = [
"<script>alert(1)</script>",
"javascript:alert(1)",
"onerror=alert(1)",
"onload=alert(1)",
"<svg",
"<img"
]
for payload in payloads:
assert XSS_PATTERN.search(payload) is not None
def test_sqli_patterns():
# Test common SQLi payloads in be-optimumoh
payloads = [
"UNION SELECT * FROM users",
"OR 1=1",
"WAITFOR DELAY '0:0:5'",
"INFORMATION_SCHEMA.TABLES",
]
for payload in payloads:
assert SQLI_PATTERN.search(payload) is not None
def test_inspect_value_raises():
with pytest.raises(HTTPException) as excinfo:
inspect_value("<script>", "source")
assert excinfo.value.status_code == 422
assert "Invalid request parameters" in excinfo.value.detail
with pytest.raises(HTTPException) as excinfo:
inspect_value("UNION SELECT * FROM users", "source")
assert excinfo.value.status_code == 422
assert "Invalid request parameters" in excinfo.value.detail
def test_inspect_json_raises():
with pytest.raises(HTTPException) as excinfo:
inspect_json({"__proto__": "polluted"})
assert excinfo.value.status_code == 422
assert "Invalid request parameters" in excinfo.value.detail
def test_has_control_chars():
assert has_control_chars("normal string") is False
assert has_control_chars("string with \x00 null") is True
assert has_control_chars("string with \n newline") is False

@ -1,33 +0,0 @@
import pytest
from datetime import datetime, timedelta
from src.calculation_target_reliability.utils import generate_down_periods
def test_generate_down_periods_count():
start = datetime(2025, 1, 1)
end = datetime(2025, 1, 31)
# Test fixed number of periods
periods = generate_down_periods(start, end, num_periods=5)
# It attempts to generate 5, but might be fewer due to overlaps
assert len(periods) <= 5
# Check they are within range
for p_start, p_end in periods:
assert p_start >= start
assert p_end <= end
assert p_start < p_end
def test_generate_down_periods_no_overlap():
start = datetime(2025, 1, 1)
end = datetime(2025, 1, 31)
periods = generate_down_periods(start, end, num_periods=10)
# Sort and check gaps
for i in range(len(periods) - 1):
assert periods[i][1] <= periods[i+1][0]
def test_generate_down_periods_too_small_range():
start = datetime(2025, 1, 1)
end = datetime(2025, 1, 2)
# Requesting 5 days duration in 1 day range
periods = generate_down_periods(start, end, num_periods=1, min_duration=5)
assert len(periods) == 0

@ -1,36 +0,0 @@
import pytest
from datetime import datetime, timedelta
from src.utils import parse_relative_expression, parse_date_string
def test_parse_relative_expression_days():
# Test T, T+n, T-n
result = parse_relative_expression("T")
assert result is not None
assert isinstance(result, datetime)
result_plus = parse_relative_expression("T+5")
assert result_plus is not None
result_minus = parse_relative_expression("T-3")
assert result_minus is not None
def test_parse_relative_expression_invalid():
assert parse_relative_expression("abc") is None
assert parse_relative_expression("123") is None
assert parse_relative_expression("T++1") is None
def test_parse_date_string_formats():
# Test various ISO and common formats
dt = parse_date_string("2024-11-08")
assert dt.year == 2024
assert dt.month == 11
assert dt.day == 8
dt = parse_date_string("08-11-2024")
assert dt.year == 2024
assert dt.month == 11
assert dt.day == 8
def test_parse_date_string_invalid():
with pytest.raises(ValueError):
parse_date_string("invalid-date")
Loading…
Cancel
Save