Compare commits

..

11 Commits

@ -1,2 +0,0 @@
tests/
.venv/

15
.env

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

1
.gitignore vendored

@ -1,4 +1,3 @@
env/ env/
__pycache__/ __pycache__/
venv/ venv/
tests/

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

236
Jenkinsfile vendored

@ -6,22 +6,16 @@ pipeline {
// This creates DOCKER_AUTH_USR and DOCKER_AUTH_PSW // This creates DOCKER_AUTH_USR and DOCKER_AUTH_PSW
DOCKER_AUTH = credentials('aimodocker') DOCKER_AUTH = credentials('aimodocker')
IMAGE_NAME = 'oh-service' IMAGE_NAME = 'oh-service'
SERVICE_NAME = 'be-optimumoh' SERVICE_NAME = 'ahm-app'
SECURITY_PREFIX = 'security' SECURITY_PREFIX = 'security'
VAULT_ADDR = 'http://127.0.0.1:8200'
VAULT_KV_PATH = 'secret/data/Ep=X1qdWqzh+H&x&dRmV'
VAULT_ENV_FILE = 'vault.env'
TEMP_DOCKERFILE = 'Dockerfile.ci'
// Initialize variables to be updated in script blocks // Initialize variables to be updated in script blocks
GIT_COMMIT_HASH = "" GIT_COMMIT_HASH = ""
IMAGE_TAG = "" IMAGE_TAG = ""
SECONDARY_TAG = "" SECONDARY_TAG = ""
} }
stages { stages {
stage('Checkout & Setup') { stage('Checkout & Setup') {
steps { steps {
@ -49,7 +43,6 @@ pipeline {
} }
} }
stage('Docker Login') { stage('Docker Login') {
steps { steps {
// Fixed variable names based on the 'DOCKER_AUTH' environment key // Fixed variable names based on the 'DOCKER_AUTH' environment key
@ -57,191 +50,12 @@ pipeline {
} }
} }
// stage('Security: Verify Dependencies') {
// steps {
// script {
// sh """#!/bin/bash
// set -e
// WORKSPACE_DIR="\$(pwd)"
// echo "Working directory: \${WORKSPACE_DIR}"
// echo "1. Checking Python dependencies..."
// if [ -f "\${WORKSPACE_DIR}/requirements.txt" ]; then
// echo " - Found requirements.txt"
// echo ""
// echo "2. Running security checks inside Python container..."
// # Buat container, copy file, jalankan check
// CONTAINER_ID=\$(docker create python:3.11-slim bash -c '
// set -e
// echo "Installing security tools..."
// pip install --upgrade pip setuptools wheel > /dev/null 2>&1
// pip install pip-audit safety > /dev/null 2>&1
// echo ""
// echo "Installing dependencies from requirements.txt..."
// pip install -r /tmp/requirements.txt
// echo ""
// echo "Running pip-audit for known vulnerabilities..."
// pip-audit --desc || {
// echo "Security vulnerabilities detected"
// exit 1
// }
// echo ""
// echo "Running safety check..."
// safety check || {
// echo "Safety check found issues"
// exit 1
// }
// echo ""
// echo "Checking for outdated packages..."
// pip list --outdated || echo "All packages are up to date"
// echo ""
// echo "Verifying package integrity..."
// pip check || {
// echo "Dependency conflicts detected"
// exit 1
// }
// echo "All Python dependencies verified successfully"
// ')
// echo " - Container ID: \${CONTAINER_ID}"
// # Copy requirements.txt langsung ke container (tanpa volume mount)
// docker cp "\${WORKSPACE_DIR}/requirements.txt" "\${CONTAINER_ID}:/tmp/requirements.txt"
// # Copy Pipfile jika ada
// if [ -f "\${WORKSPACE_DIR}/Pipfile.lock" ]; then
// docker cp "\${WORKSPACE_DIR}/Pipfile.lock" "\${CONTAINER_ID}:/tmp/Pipfile.lock"
// fi
// # Jalankan container dan pastikan cleanup
// docker start --attach "\${CONTAINER_ID}" || {
// EXIT_CODE=\$?
// docker rm "\${CONTAINER_ID}" > /dev/null 2>&1 || true
// exit \${EXIT_CODE}
// }
// docker rm "\${CONTAINER_ID}" > /dev/null 2>&1 || true
// echo "✓ All Python dependencies are verified and up to date"
// else
// echo " - requirements.txt not found, skipping dependency check"
// echo " Directory contents:"
// ls -la "\${WORKSPACE_DIR}/"
// fi
// """
// }
// }
// }
stage('Setup Vault SSH Tunnel') {
steps {
sshagent(credentials: ['vault-server-ssh-key']) {
sh """#!/bin/bash
pkill -f 'ssh.*8200:127.0.0.1:8200' || true
sleep 1
ssh -fN \
-L 8200:127.0.0.1:8200 \
-o StrictHostKeyChecking=no \
-o ServerAliveInterval=30 \
-o ExitOnForwardFailure=yes \
aimo@192.168.1.105
for i in \$(seq 1 10); do
if curl -sf --connect-timeout 2 'http://127.0.0.1:8200/v1/sys/health' > /dev/null 2>&1; then
echo "✓ Vault tunnel is ready"
exit 0
fi
echo "Waiting... (\$i/10)"
sleep 2
done
exit 1
"""
}
}
}
stage('Fetch Runtime Env from Vault') {
steps {
script {
withCredentials([
string(credentialsId: 'vault-approle-role-id', variable: 'CI_VAULT_ROLE_ID'),
string(credentialsId: 'vault-approle-secret-id', variable: 'CI_VAULT_SECRET_ID')
]) {
def roleId = env.CI_VAULT_ROLE_ID
def secretId = env.CI_VAULT_SECRET_ID
def loginPayload = """{"role_id":"${roleId}","secret_id":"${secretId}"}"""
def loginResponse = sh(
script: '''curl -sS --fail --connect-timeout 10 \
--request POST \
--header 'Content-Type: application/json' \
--data "{\\"role_id\\":\\"${CI_VAULT_ROLE_ID}\\",\\"secret_id\\":\\"${CI_VAULT_SECRET_ID}\\"}" \
'http://127.0.0.1:8200/v1/auth/approle/login' ''',
returnStdout: true
).trim()
def vaultToken = sh(
script: "echo '${loginResponse}' | python3 -c \"import json,sys; print(json.load(sys.stdin)['auth']['client_token'])\"",
returnStdout: true
).trim()
def secretResponse = sh(
script: """curl -sS --fail --connect-timeout 10 \
--header "X-Vault-Token: ${vaultToken}" \
'${VAULT_ADDR}/v1/${VAULT_KV_PATH}'""",
returnStdout: true
).trim()
def pythonScript = '''import json, os
with open('/tmp/vault_response.json') as f:
payload = json.load(f)
data = payload.get("data", {}).get("data", {})
if not data:
raise SystemExit("ERROR: Vault secret is empty")
def render_env_line(key, value):
if value is None:
return key + "="
text = str(value)
has_special = text == "" or " " in text or "#" in text or "\\t" in text
if has_special:
escaped = text.replace('"', '\\\\"')
return key + "=" + '"' + escaped + '"'
return key + "=" + text
workspace = os.environ.get("WORKSPACE", ".")
with open(workspace + "/vault.env", "w") as f:
for key in sorted(data.keys()):
f.write(render_env_line(key, data[key]) + "\\n")
print("Generated vault.env with " + str(len(data)) + " keys")
'''
writeFile(file: '/tmp/vault_response.json', text: secretResponse)
writeFile(file: '/tmp/process_vault.py', text: pythonScript)
sh "python3 /tmp/process_vault.py"
sh "chmod 600 vault.env"
sh "echo '=== Vault.env content ===' && cat vault.env | head -20"
echo "✓ vault.env ready"
}
}
}
}
stage('Build & Tag') { stage('Build & Tag') {
steps { steps {
script { script {
def fullImageName = "${DOCKER_HUB_USERNAME}/${IMAGE_NAME}" def fullImageName = "${DOCKER_HUB_USERNAME}/${IMAGE_NAME}"
// Build image TANPA vault.env - image tetap bersih
sh "docker build -t ${fullImageName}:${IMAGE_TAG} ." sh "docker build -t ${fullImageName}:${IMAGE_TAG} ."
if (SECONDARY_TAG) { if (SECONDARY_TAG) {
sh "docker tag ${fullImageName}:${IMAGE_TAG} ${fullImageName}:${SECONDARY_TAG}" sh "docker tag ${fullImageName}:${IMAGE_TAG} ${fullImageName}:${SECONDARY_TAG}"
} }
@ -254,63 +68,29 @@ print("Generated vault.env with " + str(len(data)) + " keys")
script { script {
def fullImageName = "${DOCKER_HUB_USERNAME}/${IMAGE_NAME}" def fullImageName = "${DOCKER_HUB_USERNAME}/${IMAGE_NAME}"
sh "docker push ${fullImageName}:${IMAGE_TAG}" sh "docker push ${fullImageName}:${IMAGE_TAG}"
if (SECONDARY_TAG) { if (SECONDARY_TAG) {
sh "docker push ${fullImageName}:${SECONDARY_TAG}" sh "docker push ${fullImageName}:${SECONDARY_TAG}"
} }
} }
} }
} }
stage('Deploy to Server') {
steps {
sshagent(credentials: ['vault-server-ssh-key']) {
sh """#!/bin/bash
# Transfer vault.env ke server deployment via SSH
# File disimpan di /tmp — tidak persistent
echo "=== SCP transferring env file ==="
scp -o StrictHostKeyChecking=no \
vault.env \
aimo@192.168.1.105:/tmp/${SERVICE_NAME}.env
# Set permission ketat
ssh -o StrictHostKeyChecking=no aimo@192.168.1.105 \
'chmod 600 /tmp/${SERVICE_NAME}.env && \
echo "=== Env file transferred ===" && \
ls -lh /tmp/${SERVICE_NAME}.env'
# Jalankan deploy dengan env-file dari /tmp
ssh -o StrictHostKeyChecking=no aimo@192.168.1.105 \
'cd ~ && \
echo "=== Checking env file ===" && \
ls -lh /tmp/${SERVICE_NAME}.env && \
echo "=== lines of env file ===" && \
cat /tmp/${SERVICE_NAME}.env && \
cp /tmp/${SERVICE_NAME}.env .env && \
echo "=== Copied to .env for docker compose ===" && \
docker compose pull ${SERVICE_NAME} && \
docker rm -f ${SERVICE_NAME} || true && \
docker compose -p digitaltwin up -d --no-deps ${SERVICE_NAME} && \
rm -f /tmp/${SERVICE_NAME}.env && \
echo "✓ ${SERVICE_NAME} deployed, env file removed"'
"""
}
}
}
} }
post { post {
always { always {
script { script {
// Hapus vault.env dari Jenkins workspace sh 'docker logout'
sh 'rm -f vault.env /tmp/vault_response.json /tmp/process_vault.py || true'
sh 'pkill -f "ssh.*8200:127.0.0.1:8200" || true'
sh 'docker logout || true'
def fullImageName = "${DOCKER_HUB_USERNAME}/${IMAGE_NAME}" def fullImageName = "${DOCKER_HUB_USERNAME}/${IMAGE_NAME}"
// Clean up images to save agent disk space
sh "docker rmi ${fullImageName}:${IMAGE_TAG} || true" sh "docker rmi ${fullImageName}:${IMAGE_TAG} || true"
if (SECONDARY_TAG) { if (SECONDARY_TAG) {
sh "docker rmi ${fullImageName}:${SECONDARY_TAG} || true" sh "docker rmi ${fullImageName}:${SECONDARY_TAG} || true"
} }
} }
} }
success {
echo "Successfully processed ${env.BRANCH_NAME}."
}
} }
} }

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

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

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

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

@ -1,8 +0,0 @@
{"timestamp": "2026-05-26T15:34:23.850085+07:00", "service_name": "Be-OptimumOH", "level": "INFO", "method": null, "path": null, "status_code": null, "duration_ms": null, "request_id": null, "user_id": null, "user_ip": null, "error_id": null, "result": null, "error_type": null, "error_message": null}
{"timestamp": "2026-05-26T15:34:23.850303+07:00", "service_name": "Be-OptimumOH", "level": "INFO", "method": null, "path": null, "status_code": null, "duration_ms": null, "request_id": null, "user_id": null, "user_ip": null, "error_id": null, "result": null, "error_type": null, "error_message": null}
{"timestamp": "2026-05-26T15:34:23.850685+07:00", "service_name": "Be-OptimumOH", "level": "INFO", "method": null, "path": null, "status_code": null, "duration_ms": null, "request_id": null, "user_id": null, "user_ip": null, "error_id": null, "result": null, "error_type": null, "error_message": null}
{"timestamp": "2026-05-26T15:34:26.442755+07:00", "service_name": "Be-OptimumOH", "level": "INFO", "method": null, "path": null, "status_code": null, "duration_ms": null, "request_id": null, "user_id": null, "user_ip": null, "error_id": null, "result": null, "error_type": null, "error_message": null}
{"timestamp": "2026-05-26T15:34:26.442988+07:00", "service_name": "Be-OptimumOH", "level": "INFO", "method": null, "path": null, "status_code": null, "duration_ms": null, "request_id": null, "user_id": null, "user_ip": null, "error_id": null, "result": null, "error_type": null, "error_message": null}
{"timestamp": "2026-05-26T15:34:26.443255+07:00", "service_name": "Be-OptimumOH", "level": "INFO", "method": null, "path": null, "status_code": null, "duration_ms": null, "request_id": null, "user_id": null, "user_ip": null, "error_id": null, "result": null, "error_type": null, "error_message": null}
{"timestamp": "2026-05-26T15:34:43.799353+07:00", "service_name": "Be-OptimumOH", "level": "WARNING", "method": null, "path": null, "status_code": null, "duration_ms": null, "request_id": null, "user_id": null, "user_ip": null, "error_id": null, "result": null, "error_type": null, "error_message": null}
{"timestamp": "2026-05-26T15:34:43.799965+07:00", "service_name": "Be-OptimumOH", "level": "WARNING", "method": "POST", "path": "/overhaul-gantt/spreadsheet", "status_code": 403, "duration_ms": 79.18, "request_id": "becb4296-58dd-11f1-b957-00155de829e0", "user_id": "2d26235f-a61c-4541-b817-be65b780e3eb", "user_ip": "127.0.0.1", "error_id": "0dbb51ac-0e3b-43a7-80ce-a1d8edf1945c", "result": "Request failed", "error_type": "Forbidden", "error_message": "CSRF token missing in header"}

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

538
poetry.lock generated

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

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

@ -1,5 +1,6 @@
import uvicorn import uvicorn
from src.config import HOST, PORT, ENV
if __name__ == '__main__': from src.config import HOST, PORT
is_dev = str(ENV).lower() == 'development'
uvicorn.run('src.main:app', host=HOST, port=PORT, reload=is_dev) if __name__ == "__main__":
uvicorn.run("src.main:app", host=HOST, port=PORT, reload=True)

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

@ -1,45 +1,197 @@
from typing import List, Optional from typing import List, Optional
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from pydantic import BaseModel from pydantic import BaseModel
from src.auth.service import JWTBearer from src.auth.service import JWTBearer
from src.calculation_budget_constrains.router import router as calculation_budget_constraint from src.calculation_budget_constrains.router import \
from src.calculation_target_reliability.router import router as calculation_target_reliability router as calculation_budget_constraint
from src.calculation_time_constrains.router import router as calculation_time_constrains_router, get_calculation from src.calculation_target_reliability.router import \
router as calculation_target_reliability
from src.calculation_time_constrains.router import \
router as calculation_time_constrains_router, get_calculation
from src.optimum_time_constraint.router import router as optimum_time_constraint_router
from src.optimum_time_constraint.router import get_calculation as optimum_get_calculation
# from src.job.router import router as job_router
from src.overhaul.router import router as overhaul_router from src.overhaul.router import router as overhaul_router
from src.standard_scope.router import router as standard_scope_router from src.standard_scope.router import router as standard_scope_router
from src.overhaul_activity.router import router as overhaul_activity_router from src.overhaul_activity.router import router as overhaul_activity_router
from src.workscope_group.router import router as workscope_group_router from src.workscope_group.router import router as workscope_group_router
from src.equipment_workscope_group.router import router as equipment_workscope_group_router from src.equipment_workscope_group.router import router as equipment_workscope_group_router
# from src.overhaul_job.router import router as job_overhaul_router
# from src.overhaul_scope.router import router as scope_router
# from src.scope_equipment.router import router as scope_equipment_router
# from src.scope_equipment_job.router import router as scope_equipment_job_router
# from src.overhaul_schedule.router import router as overhaul_schedule_router
from src.overhaul_gantt.router import router as gantt_router from src.overhaul_gantt.router import router as gantt_router
from src.sparepart.router import router as sparepart_router from src.sparepart.router import router as sparepart_router
from src.equipment_sparepart.router import router as equipment_sparepart_router from src.equipment_sparepart.router import router as equipment_sparepart_router
# from src.overhaul_scope.router import router as scope_router
# from src.scope_equipment.router import router as scope_equipment_router
# from src.overhaul.router import router as overhaul_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_scope.router import router as ovehaul_schedule_router from src.overhaul_scope.router import router as ovehaul_schedule_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.master_activity.router import router as activity_router
class ErrorMessage(BaseModel): class ErrorMessage(BaseModel):
msg: str msg: str
class ErrorResponse(BaseModel): class ErrorResponse(BaseModel):
detail: Optional[List[ErrorMessage]] detail: Optional[List[ErrorMessage]]
api_router = APIRouter(default_response_class=JSONResponse, responses={400: {'model': ErrorResponse}, 401: {'model': ErrorResponse}, 403: {'model': ErrorResponse}, 404: {'model': ErrorResponse}, 500: {'model': ErrorResponse}})
@api_router.get('/healthcheck', include_in_schema=False)
api_router = APIRouter(
default_response_class=JSONResponse,
responses={
400: {"model": ErrorResponse},
401: {"model": ErrorResponse},
403: {"model": ErrorResponse},
404: {"model": ErrorResponse},
500: {"model": ErrorResponse},
},
)
@api_router.get("/healthcheck", include_in_schema=False)
def healthcheck(): def healthcheck():
return {'status': 'ok'} return {"status": "ok"}
authenticated_api_router = APIRouter(dependencies=[Depends(JWTBearer())])
authenticated_api_router.include_router(overhaul_router, prefix='/overhauls', tags=['overhaul'])
authenticated_api_router.include_router(standard_scope_router, prefix='/scope-equipments', tags=['scope_equipment']) authenticated_api_router = APIRouter(
authenticated_api_router.include_router(overhaul_activity_router, prefix='/overhaul-activity', tags=['activity']) dependencies=[Depends(JWTBearer())],
authenticated_api_router.include_router(workscope_group_router, prefix='/workscopes', tags=['workscope_groups']) )
authenticated_api_router.include_router(sparepart_router, prefix='/spareparts', tags=['sparepart']) # overhaul data
authenticated_api_router.include_router(equipment_workscope_group_router, prefix='/equipment-workscopes', tags=['equipment_workscope_groups']) authenticated_api_router.include_router(
authenticated_api_router.include_router(equipment_sparepart_router, prefix='/equipment-spareparts', tags=['equipment_workscope_sparepart_router']) overhaul_router, prefix="/overhauls", tags=["overhaul"]
authenticated_api_router.include_router(gantt_router, prefix='/overhaul-gantt', tags=['gantt']) )
authenticated_api_router.include_router(ovehaul_schedule_router, prefix='/overhaul-schedules', tags=['overhaul_schedules'])
calculation_router = APIRouter(prefix='/calculation', tags=['calculations'])
calculation_router.include_router(calculation_time_constrains_router, prefix='/time-constraint', tags=['calculation', 'time_constraint']) # authenticated_api_router.include_router(job_router, prefix="/jobs", tags=["job"])
calculation_router.include_router(calculation_target_reliability, prefix='/target-reliability', tags=['calculation', 'target_reliability'])
calculation_router.include_router(calculation_budget_constraint, prefix='/budget-constraint', tags=['calculation', 'budget_constraint']) # # # Overhaul session data
# authenticated_api_router.include_router(
# scope_router, prefix="/overhaul-session", tags=["overhaul-session"]
# )
authenticated_api_router.include_router(
standard_scope_router, prefix="/scope-equipments", tags=["scope_equipment"]
)
authenticated_api_router.include_router(
overhaul_activity_router, prefix="/overhaul-activity", tags=["activity"]
)
authenticated_api_router.include_router(
workscope_group_router, prefix="/workscopes", tags=["workscope_groups"]
)
authenticated_api_router.include_router(
sparepart_router, prefix="/spareparts", tags=["sparepart"]
)
authenticated_api_router.include_router(
equipment_workscope_group_router,
prefix="/equipment-workscopes",
tags=["equipment_workscope_groups"],
)
authenticated_api_router.include_router(
equipment_sparepart_router,
prefix="/equipment-spareparts",
tags=["equipment_workscope_sparepart_router"]
)
# authenticated_api_router.include_router(
# scope_equipment_job_router,
# prefix="/scope-equipment-jobs",
# tags=["scope_equipment", "job"],
# )
# authenticated_api_router.include_router(
# overhaul_schedule_router,
# prefix="/overhaul-schedules",
# tags=["overhaul_schedule"],
# )
# authenticated_api_router.include_router(
# job_overhaul_router, prefix="/overhaul-jobs", tags=["job", "overhaul"]
# )
authenticated_api_router.include_router(
gantt_router, prefix="/overhaul-gantt", tags=["gantt"]
)
# authenticated_api_router.include_router(
# overhaul_history_router, prefix="/overhaul-history", tags=["overhaul_history"]
# )
# authenticated_api_router.include_router(
# scope_equipment_activity_router, prefix="/equipment-activities", tags=["scope_equipment_activities"]
# )
# authenticated_api_router.include_router(
# activity_router, prefix="/activities", tags=["activities"]
# )
# authenticated_api_router.include_router(
# scope_equipment_part_router, prefix="/equipment-parts", tags=["scope_equipment_parts"]
# )
authenticated_api_router.include_router(
ovehaul_schedule_router, prefix="/overhaul-schedules", tags=["overhaul_schedules"]
)
# calculation
calculation_router = APIRouter(prefix="/calculation", tags=["calculations"])
# Time constrains
calculation_router.include_router(
calculation_time_constrains_router,
prefix="/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
calculation_router.include_router(
calculation_target_reliability,
prefix="/target-reliability",
tags=["calculation", "target_reliability"],
)
# # Budget Constrain
calculation_router.include_router(
calculation_budget_constraint,
prefix="/budget-constraint",
tags=["calculation", "budget_constraint"],
)
authenticated_api_router.include_router(calculation_router) authenticated_api_router.include_router(calculation_router)
authenticated_api_router.include_router(get_calculation, prefix='/calculation/time-constraint', tags=['calculation', 'time_constraint'])
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,64 +0,0 @@
import logging
import logging.handlers
import json
import datetime
import os
import sys
try:
from zoneinfo import ZoneInfo
except ImportError:
from backports.zoneinfo import ZoneInfo
from src.config import LOG_LEVEL, SERVICE_NAME
from src.enums import OptimumOHEnum
_TZ_LOCAL = ZoneInfo('Asia/Jakarta')
RESET = '\x1b[0m'
COLORS = {'DEBUG': '\x1b[36m', 'INFO': '\x1b[32m', 'WARNING': '\x1b[33m', 'WARN': '\x1b[33m', 'ERROR': '\x1b[31m', 'CRITICAL': '\x1b[1;31m'}
_REQUEST_FIELDS = frozenset({'method', 'path', 'status_code', 'duration_ms', 'request_id', 'user_id', 'user_ip', 'error_id', 'result', 'error_type', 'error_message'})
class LogLevels(OptimumOHEnum):
info = 'INFO'
warn = 'WARN'
error = 'ERROR'
debug = 'DEBUG'
class JSONFormatter(logging.Formatter):
def format(self, record: logging.LogRecord) -> str:
dt = datetime.datetime.fromtimestamp(record.created, tz=_TZ_LOCAL)
timestamp = dt.isoformat(timespec='microseconds')
status_code = getattr(record, 'status_code', None)
result = getattr(record, 'result', None)
if result is None and status_code is not None:
result = 'Request completed' if int(status_code) < 400 else 'Request failed'
log_record = {'timestamp': timestamp, 'service_name': SERVICE_NAME, 'level': record.levelname, 'method': getattr(record, 'method', None), 'path': getattr(record, 'path', None), 'status_code': status_code, 'duration_ms': getattr(record, 'duration_ms', None), 'request_id': getattr(record, 'request_id', None), 'user_id': getattr(record, 'user_id', None), 'user_ip': getattr(record, 'user_ip', None), 'error_id': getattr(record, 'error_id', None), 'result': result, 'error_type': getattr(record, 'error_type', None), 'error_message': getattr(record, 'error_message', None)}
log_json = json.dumps(log_record, default=str)
if sys.stdout.isatty():
color = COLORS.get(record.levelname, '')
return f'{color}{log_json}{RESET}'
return log_json
def configure_logging() -> None:
log_level = str(LOG_LEVEL).upper()
if log_level not in list(LogLevels):
log_level = LogLevels.error
root_logger = logging.getLogger()
root_logger.setLevel(log_level)
if root_logger.hasHandlers():
root_logger.handlers.clear()
formatter = JSONFormatter()
stream_handler = logging.StreamHandler(sys.stdout)
stream_handler.setFormatter(formatter)
root_logger.addHandler(stream_handler)
os.makedirs('logs', exist_ok=True)
file_handler = logging.handlers.RotatingFileHandler('logs/app.log', maxBytes=10 * 1024 * 1024, backupCount=5, encoding='utf-8')
file_handler.setFormatter(formatter)
root_logger.addHandler(file_handler)
for logger_name in ['uvicorn', 'uvicorn.access', 'uvicorn.error', 'fastapi']:
uvicorn_logger = logging.getLogger(logger_name)
uvicorn_logger.handlers = []
uvicorn_logger.propagate = True
logging.getLogger('uvicorn.access').setLevel(logging.WARNING)
slowapi_logger = logging.getLogger('slowapi')
slowapi_logger.setLevel(logging.ERROR)
slowapi_logger.propagate = False
logging.getLogger('slack_sdk.web.base_client').setLevel(logging.CRITICAL)

@ -1,105 +0,0 @@
from dataclasses import dataclass
from enum import Enum
from typing import Any, List, Union
from fastapi import Request, HTTPException, status
Allow: str = 'allow'
Deny: str = 'deny'
@dataclass(frozen=True)
class Principal:
key: str
value: str
def __repr__(self) -> str:
return f'{self.key}:{self.value}'
def __str__(self) -> str:
return self.__repr__()
@dataclass(frozen=True)
class SystemPrincipal(Principal):
def __init__(self, value: str):
super().__init__(key='system', value=value)
@dataclass(frozen=True)
class RolePrincipal(Principal):
def __init__(self, value: str):
super().__init__(key='role', value=value)
Everyone = SystemPrincipal(value='everyone')
Authenticated = SystemPrincipal(value='authenticated')
class OHPermission(Enum):
CREATE = 'create'
READ = 'read'
EDIT = 'edit'
DELETE = 'delete'
class AccessControl:
def __init__(self, permission_exception: Any=None):
self.permission_exception = permission_exception or HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail='Permission denied')
def _acl(self, resource):
acl = getattr(resource, '__acl__', [])
if callable(acl):
return acl()
return acl
def has_permission(self, principals: List[Principal], required_permissions: List[OHPermission], resource: Any) -> bool:
if not isinstance(resource, list):
resource = [resource]
permits = []
for res in resource:
granted = False
acl = self._acl(res)
for action, principal, permissions in acl:
is_permitted = any((p in permissions for p in required_permissions))
if (action == Allow and is_permitted) and (principal in principals or principal == Everyone):
granted = True
break
if (action == Deny and is_permitted) and (principal in principals or principal == Everyone):
granted = False
break
permits.append(granted)
return all(permits)
def assert_access(self, principals: List[Principal], required_permissions: List[OHPermission], resource: Any):
if not self.has_permission(principals, required_permissions, resource):
raise self.permission_exception
ALLOWED_ROLES = {'Admin', 'Super Admin', 'Engineer', 'Application Administrator'}
def require_any_role(*allowed_roles: str):
normalized = {r.lower() for r in allowed_roles}
async def dependency(request: Request):
user = getattr(request.state, 'user', None)
if not user:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail='Not authenticated')
user_role = user.get('role') if isinstance(user, dict) else getattr(user, 'role', None)
if not user_role or user_role.lower() not in normalized:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail='Your role does not have permission to access this resource.')
return True
return dependency
def required_permission(permissions: Union[OHPermission, List[OHPermission]], resources: Any):
if isinstance(permissions, OHPermission):
permissions = [permissions]
async def dependency(request: Request):
user = getattr(request.state, 'user', None)
if not user:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail='Not authenticated')
user_role = None
if isinstance(user, dict):
user_role = user.get('role')
else:
user_role = getattr(user, 'role', None)
if not user_role:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail='No user role found')
user_principals = [Authenticated, RolePrincipal(user_role)]
ac = AccessControl()
ac.assert_access(user_principals, permissions, resources)
return True
return dependency

@ -1,7 +1,7 @@
from typing import Optional
from pydantic import BaseModel from pydantic import BaseModel
class UserBase(BaseModel): class UserBase(BaseModel):
name: Optional[str] = None name: str
role: str role: str
user_id: str user_id: str

@ -1,135 +1,235 @@
# app/auth/auth_bearer.py
import json import json
from typing import Annotated from typing import Annotated, Optional
import requests import requests
from fastapi import Depends, HTTPException, Request from fastapi import Depends, HTTPException, Request
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy.sql.expression import false
import src.config as config import src.config as config
from .model import UserBase from .model import UserBase
from .util import extract_template from .util import extract_template
class JWTBearer(HTTPBearer): class JWTBearer(HTTPBearer):
def __init__(self, auto_error: bool = True): def __init__(self, auto_error: bool = True):
super(JWTBearer, self).__init__(auto_error=False) super(JWTBearer, self).__init__(auto_error=auto_error)
async def __call__(self, request: Request): async def __call__(self, request: Request):
credentials: HTTPAuthorizationCredentials = await super(JWTBearer, self).__call__(request) credentials: HTTPAuthorizationCredentials = await super(
JWTBearer, self
).__call__(request)
if credentials: if credentials:
if not credentials.scheme == 'Bearer': if not credentials.scheme == "Bearer":
raise HTTPException(status_code=401, detail='Invalid authentication scheme.') raise HTTPException(
status_code=403, detail="Invalid authentication scheme."
)
method = request.method method = request.method
if method == 'OPTIONS':
return None if method == "OPTIONS":
return
path = extract_template(request.url.path, request.path_params) path = extract_template(request.url.path, request.path_params)
endpoint = f'/optimumoh{path}'
endpoint = f"/optimumoh{path}"
user_info, message = self.verify_jwt(credentials.credentials, method, endpoint) user_info, message = self.verify_jwt(credentials.credentials, method, endpoint)
if not user_info: if not user_info:
if isinstance(message, dict): message = message.get("message", "Invalid token or expired token.")
message = message.get('message') or message.get('detail') or 'Invalid token or expired token.' raise HTTPException(
else: status_code=403, detail=message
message = str(message) if message else 'Invalid token or expired token.' )
raise HTTPException(status_code=401, detail=message)
request.state.user = message request.state.user = message
from src.context import set_user_id, set_username, set_role from src.context import set_user_id, set_username, set_role
if hasattr(message, 'user_id'): if hasattr(message, "user_id"):
set_user_id(str(message.user_id)) set_user_id(str(message.user_id))
username = getattr(message, 'username', None) or getattr(message, 'name', None) if hasattr(message, "username"):
if username: set_username(message.username)
set_username(username) elif hasattr(message, "name"):
if hasattr(message, 'role'): set_username(message.name)
if hasattr(message, "role"):
set_role(message.role) set_role(message.role)
return message return message
raise HTTPException(status_code=401, detail='Invalid authorization code.') else:
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:
url_to_verify = f'{config.AUTH_SERVICE_API}/verify-token' response = requests.get(
response = requests.get(url_to_verify, headers={'Authorization': f'Bearer {jwtoken}'}) f"{config.AUTH_SERVICE_API}/verify-token",
headers={"Authorization": f"Bearer {jwtoken}"},
)
if not response.ok: if not response.ok:
return (False, response.json()) return False, response.json()
user_data = response.json() user_data = response.json()
return (True, UserBase(**user_data['data'])) return True, UserBase(**user_data["data"])
except Exception as e: except Exception as e:
print(f'Token verification error: {str(e)}') print(f"Token verification error: {str(e)}")
return (False, str(e)) return False, str(e)
# Create dependency to get current user from request state
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): async def get_token(request: Request):
token = request.headers.get('Authorization') token = request.headers.get("Authorization")
if token: if token:
return token.replace('Bearer ', '') return token.replace("Bearer ", "") # Menghapus prefix "Bearer "
return request.cookies.get('access_token') else:
return '' 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 internal_key(request: Request):
token = request.headers.get('Authorization') token = request.headers.get("Authorization")
if not token: if not token:
api_key = request.headers.get('X-Internal-Key') api_key = request.headers.get("X-Internal-Key")
if api_key != config.API_KEY: if api_key != config.API_KEY:
raise HTTPException(status_code=401, detail='Invalid Key.') raise HTTPException(
status_code=403, detail="Invalid Key."
)
try: try:
headers = {'Content-Type': 'application/json'} headers = {
response = requests.post(f'{config.AUTH_SERVICE_API}/sign-in', headers=headers, data=json.dumps({'username': 'ohuser', 'password': '123456789'})) '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: if not response.ok:
print(str(response.json())) print(str(response.json()))
raise Exception('error auth') raise Exception("error auth")
user_data = response.json() user_data = response.json()
return user_data['data']['access_token'] return user_data['data']['access_token']
except Exception as e: except Exception as e:
raise Exception(str(e)) raise Exception(str(e))
else: else:
try: try:
verify_url = f'{config.AUTH_SERVICE_API}/verify-token' response = requests.get(
response = requests.get(verify_url, headers={'Authorization': f'{token}'}) f"{config.AUTH_SERVICE_API}/verify-token",
headers={"Authorization": f"{token}"},
)
if not response.ok: if not response.ok:
raise HTTPException(status_code=401, detail='Invalid token.') raise HTTPException(
return token.split(' ')[1] status_code=403, detail="Invalid token."
)
return token.split(" ")[1]
except Exception as e: except Exception as e:
print(f'Token verification error: {str(e)}') print(f"Token verification error: {str(e)}")
return (False, str(e)) return False, str(e)
import asyncio
import httpx
import logging import logging
from typing import Dict, Any from typing import Dict, Any
import src.config as config import src.config as config
log = logging.getLogger(__name__) 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, request: Request, method: str='POST', cooldown: int=900, timeout: int=5) -> Dict[str, Any]:
payload = {'endpoint_name': f'oh/{endpoint_name}'.replace('//', ''), 'ip_address': ip_address, 'method': method, 'cooldown': cooldown} AUTH_NOTIFY_ENDPOINT = f"{config.AUTH_SERVICE_API}/admin/notify-limit"
token = request.headers.get('Authorization')
headers = {'Authorization': token} if token else {}
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: try:
response = await asyncio.to_thread(requests.post, AUTH_NOTIFY_ENDPOINT, json=payload, headers=headers, timeout=timeout) async with httpx.AsyncClient(timeout=timeout) as client:
response = await client.post(AUTH_NOTIFY_ENDPOINT, json=payload)
response.raise_for_status() response.raise_for_status()
result = response.json() result = response.json()
log.info(f'Notifikasi admin sent | Endpoint: {endpoint_name}') log.info(f"Notifikasi admin sent | Endpoint: {endpoint_name}")
return result return result
except Exception as e: except Exception as e:
log.error(f'Error notifying admin: {str(e)}') log.error(f"Error notifying admin: {str(e)}")
return {'status': False, 'message': str(e), 'data': payload} 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,
}
def notify_admin_on_rate_limit_sync(endpoint_name: str, ip_address: str, request: Request, method: str='POST', cooldown: int=900, timeout: int=5) -> Dict[str, Any]:
payload = {'endpoint_name': f'oh/{endpoint_name}'.replace('//', '/'), 'ip_address': ip_address, 'method': method, 'cooldown': cooldown}
token = request.headers.get('Authorization')
headers = {'Authorization': token} if token else {}
try: try:
response = requests.post(AUTH_NOTIFY_ENDPOINT, json=payload, headers=headers, timeout=timeout) response = httpx.post(AUTH_NOTIFY_ENDPOINT, json=payload, timeout=timeout)
response.raise_for_status() response.raise_for_status()
result = response.json() result = response.json()
log.info(f'Notifikasi admin sent | Endpoint: {endpoint_name}') log.info(f"Notifikasi admin sent | Endpoint: {endpoint_name}")
return result return result
except Exception as e: except Exception as e:
log.error(f'Error notifying admin: {str(e)}') log.error(f"Error notifying admin: {str(e)}")
return {'status': False, 'message': str(e), 'data': payload} return {"status": False, "message": str(e), "data": payload}
async def admin_required(request: Request):
user = await get_current_user(request)
if user.role != 'Admin' or user.role != 'Superadmin':
raise HTTPException(status_code=403, detail='Invalid authorization code.')
return user
Admin = Annotated[UserBase, Depends(admin_required)]
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)] InternalKey = Annotated[str, Depends(internal_key)]

@ -1,6 +1,9 @@
def extract_template(path_string, value_dict): def extract_template(path_string, value_dict):
template = path_string template = path_string
# Replace each value in the dict with its corresponding key placeholder
for key, value in value_dict.items(): for key, value in value_dict.items():
if str(value) in template: if str(value) in template:
template = template.replace(str(value), f'<{key}>') template = template.replace(str(value), f'<{key}>')
return template return template

@ -1,21 +1,43 @@
from typing import Annotated, Dict from typing import Annotated, Dict, List, Optional
from fastapi import APIRouter
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_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.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
from .service import get_all_budget_constrains from .service import get_all_budget_constrains
from src.auth.access_control import required_permission, require_any_role, OHPermission, ALLOWED_ROLES
from src.overhaul_scope.model import OverhaulScope
from fastapi import Depends
router = APIRouter(dependencies=[Depends(require_any_role(*ALLOWED_ROLES))])
@router.get('/{session_id}', response_model=StandardResponse[Dict], dependencies=[Depends(required_permission(OHPermission.READ, OverhaulScope))]) router = APIRouter()
async def get_target_reliability(db_session: DbSession, token: Token, session_id: str, collector_db: CollectorDbSession, params: Annotated[BudgetContraintQuery, Query()]):
@router.get("/{session_id}", response_model=StandardResponse[Dict])
async def get_target_reliability(
db_session: DbSession,
token: Token,
session_id: str,
collector_db: CollectorDbSession,
params: Annotated[BudgetContraintQuery, Query()],
):
"""Get all scope pagination."""
cost_threshold = params.cost_threshold cost_threshold = params.cost_threshold
results = await get_simulation_results(simulation_id=TC_RBD_ID, token=token) results = await get_simulation_results(
results, consequence = await get_all_budget_constrains(db_session=db_session, session_id=session_id, cost_threshold=cost_threshold, simulation_result=results, collector_db=collector_db) simulation_id = TC_RBD_ID,
return StandardResponse(data={'results': results, 'consequence': consequence}, message='Data retrieved successfully') token=token
)
results, consequence = await get_all_budget_constrains(
db_session=db_session, session_id=session_id, cost_threshold=cost_threshold, simulation_result=results, collector_db=collector_db
)
return StandardResponse(
data={
"results": results,
"consequence": consequence
},
message="Data retrieved successfully",
)

@ -1,18 +1,29 @@
from typing import Any, Dict, List from datetime import datetime
from typing import Any, Dict, List, Optional
from uuid import UUID
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from src.models import DefultBase
from src.models import DefultBase, Pagination
class OverhaulBase(BaseModel): class OverhaulBase(BaseModel):
pass pass
class OverhaulCriticalParts(OverhaulBase): class OverhaulCriticalParts(OverhaulBase):
criticalParts: List[str] = Field(..., description='List of critical parts') criticalParts: List[str] = Field(..., description="List of critical parts")
class OverhaulSchedules(OverhaulBase): class OverhaulSchedules(OverhaulBase):
schedules: List[Dict[str, Any]] = Field(..., description='List of schedules') schedules: List[Dict[str, Any]] = Field(..., description="List of schedules")
class OverhaulSystemComponents(OverhaulBase): class OverhaulSystemComponents(OverhaulBase):
systemComponents: Dict[str, Any] = Field(..., description='List of system components') systemComponents: Dict[str, Any] = Field(
..., description="List of system components"
)
class OverhaulRead(OverhaulBase): class OverhaulRead(OverhaulBase):
overview: Dict[str, Any] overview: Dict[str, Any]
@ -20,5 +31,44 @@ class OverhaulRead(OverhaulBase):
schedules: List[Dict[str, Any]] schedules: List[Dict[str, Any]]
systemComponents: Dict[str, Any] systemComponents: Dict[str, Any]
class BudgetContraintQuery(DefultBase): class BudgetContraintQuery(DefultBase):
cost_threshold: float = Field(100, ge=0, le=1000000000000000) cost_threshold: float = 100
# {
# "overview": {
# "totalEquipment": 30,
# "nextSchedule": {
# "date": "2025-01-12",
# "Overhaul": "B",
# "equipmentCount": 30
# }
# },
# "criticalParts": [
# "Boiler feed pump",
# "Boiler reheater system",
# "Drum Level (Right) Root Valve A",
# "BCP A Discharge Valve",
# "BFPT A EXH Press HI Root VLV"
# ],
# "schedules": [
# {
# "date": "2025-01-12",
# "Overhaul": "B",
# "status": "upcoming"
# }
# // ... other scheduled overhauls
# ],
# "systemComponents": {
# "boiler": {
# "status": "operational",
# "lastOverhaul": "2024-06-15"
# },
# "turbine": {
# "hpt": { "status": "operational" },
# "ipt": { "status": "operational" },
# "lpt": { "status": "operational" }
# }
# // ... other major components
# }
# }

@ -1,91 +1,166 @@
from collections import defaultdict
import math import math
from typing import Optional, List, Dict, Tuple
from uuid import UUID
from src.database.core import CollectorDbSession, DbSession
from src.overhaul_activity.service import get_standard_scope_by_session_id
from collections import defaultdict from collections import defaultdict
import random
from typing import Optional, List, Dict, Tuple
from uuid import UUID from uuid import UUID
from typing import List, Dict, Tuple
from sqlalchemy import Delete, Select
from src.auth.service import CurrentUser
from src.contribution_util import calculate_contribution_accurate
from src.database.core import CollectorDbSession, DbSession from src.database.core import CollectorDbSession, DbSession
from src.overhaul_activity.service import 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(
# *, db_session: DbSession, session_id: str, cost_threshold: float = 100000000
# ):
async def get_all_budget_constrains(
*,
db_session: DbSession,
collector_db: CollectorDbSession,
session_id: UUID,
simulation_result: Dict,
cost_threshold: float = 100_000_000,
use_optimal: bool = True, # default = optimal (knapsack)
) -> Tuple[List[Dict], List[Dict]]:
"""
Select equipment under budget constraint using contribution + cost efficiency.
Returns (priority_list, consequence_list).
"""
async def get_all_budget_constrains(*, db_session: DbSession, collector_db: CollectorDbSession, session_id: UUID, simulation_result: Dict, cost_threshold: float=100000000, use_optimal: bool=True) -> Tuple[List[Dict], List[Dict]]: calc_result = simulation_result["calc_result"]
calc_result = simulation_result['calc_result'] plant_result = simulation_result["plant_result"]
plant_result = simulation_result['plant_result']
equipments = await get_standard_scope_by_session_id(db_session=db_session, overhaul_session_id=session_id, collector_db=collector_db) equipments = await get_standard_scope_by_session_id(
db_session=db_session,
overhaul_session_id=session_id,
collector_db=collector_db,
)
if not equipments: if not equipments:
return ([], []) return [], []
# Flatten results
eq_results = calc_result if isinstance(calc_result, list) else [calc_result] eq_results = calc_result if isinstance(calc_result, list) else [calc_result]
equipments_eaf_contribution = calculate_asset_eaf_contributions(plant_result=plant_result, eq_results=eq_results)
# Calculate contribution map (node_name → contribution)
equipments_eaf_contribution = calculate_asset_eaf_contributions(
plant_result=plant_result, eq_results=eq_results
)
# Build base result list
result = [] result = []
for equipment in equipments: for equipment in equipments:
contribution = equipments_eaf_contribution.get(equipment.location_tag, 0.0) contribution = equipments_eaf_contribution.get(equipment.location_tag, 0.0)
total_cost = (equipment.overhaul_cost or 0) + (equipment.service_cost or 0) total_cost = (equipment.overhaul_cost or 0) + (equipment.service_cost or 0)
result.append({'id': equipment.id, 'location_tag': equipment.location_tag, 'name': equipment.equipment_name, 'total_cost': total_cost, 'eaf_contribution': contribution})
total_contrib = sum((item['eaf_contribution'] for item in result)) or 1.0 result.append(
{
"id": equipment.id,
"location_tag": equipment.location_tag,
"name": equipment.equipment_name,
"total_cost": total_cost,
"eaf_contribution": contribution,
}
)
# Normalize contribution so sum = 1.0
total_contrib = sum(item["eaf_contribution"] for item in result) or 1.0
for item in result: for item in result:
item['contribution_norm'] = item['eaf_contribution'] / total_contrib item["contribution_norm"] = item["eaf_contribution"] / total_contrib
# Calculate efficiency and composite score
for item in result: for item in result:
cost = item['total_cost'] or 1.0 cost = item["total_cost"] or 1.0
item['contribution_norm'] / cost efficiency = item["contribution_norm"] / cost
item['priority_score'] = item['contribution_norm'] item["priority_score"] = item["contribution_norm"]
# Choose method
if use_optimal: if use_optimal:
priority_list, consequence_list = knapsack_selection(result, cost_threshold) priority_list, consequence_list = knapsack_selection(result, cost_threshold)
else: else:
priority_list, consequence_list = greedy_selection(result, cost_threshold) priority_list, consequence_list = greedy_selection(result, cost_threshold)
return (priority_list, consequence_list)
return priority_list, consequence_list
def calculate_asset_eaf_contributions(plant_result, eq_results): def calculate_asset_eaf_contributions(plant_result, eq_results):
"""
Calculate each asset's negative contribution to plant EAF.
Key assumption: eq_results have aeros_node.node_name matching equipment.location_tag.
"""
results = defaultdict(float) results = defaultdict(float)
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_factor", 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]]:
equipments_sorted = sorted(equipments, key=lambda x: x['priority_score'], reverse=True) """Greedy selection: select items by score until budget is used."""
current_cost = 0.0 # Sort by priority_score descending
selected, excluded = ([], []) equipments_sorted = sorted(equipments, key=lambda x: x["priority_score"], reverse=True)
current_cost_total = 0.0
selected, excluded = [], []
for eq in equipments_sorted: for eq in equipments_sorted:
cost = eq.get('total_cost', 0.0) cost = eq.get("total_cost", 0.0)
if current_cost + cost <= budget: if current_cost_total + cost <= budget:
selected.append(eq) selected.append(eq)
current_cost += cost current_cost_total += cost
else: else:
excluded.append(eq) excluded.append(eq)
return (selected, excluded)
return selected, excluded
def knapsack_selection(equipments: List[dict], budget: float, scale: Optional[float] = None) -> Tuple[List[dict], List[dict]]: def knapsack_selection(equipments: List[dict], budget: float, scale: Optional[float] = None) -> Tuple[List[dict], List[dict]]:
"""
Select equipment optimally within budget using 0/1 knapsack DP.
Uses dynamic scaling + 1D DP optimization to ensure accuracy for very large numbers.
"""
if not equipments: if not equipments:
return ([], []) return [], []
# Pre-filter: strictly impossible items
eligible_items = [] eligible_items = []
strictly_excluded = [] strictly_excluded = []
for eq in equipments: for eq in equipments:
if eq['total_cost'] > budget: if eq["total_cost"] > budget:
strictly_excluded.append(eq) strictly_excluded.append(eq)
else: else:
eligible_items.append(eq) eligible_items.append(eq)
if not eligible_items: if not eligible_items:
return ([], strictly_excluded) return [], strictly_excluded
n = len(eligible_items) n = len(eligible_items)
# Dynamic scaling for big numbers: target higher W (10,000) for better precision
if scale is None: if scale is None:
target_W = 2000 target_W = 10000
scale = max(1.0, budget / target_W) 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] 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)
if W > 1000000:
return greedy_selection(equipments, budget) # 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)] 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
selected = [] selected = []
backtrack_excluded = [] backtrack_excluded = []
w = W w = W
@ -95,14 +170,22 @@ def knapsack_selection(equipments: List[dict], budget: float, scale: Optional[fl
w -= costs[i] w -= costs[i]
else: else:
backtrack_excluded.append(eligible_items[i]) backtrack_excluded.append(eligible_items[i])
excluded = backtrack_excluded + strictly_excluded excluded = backtrack_excluded + strictly_excluded
current_total_cost = sum((eq['total_cost'] for eq in selected))
remaining_budget = budget - current_total_cost # Precision correction: greedy fill leftover budget with actual values
current_total_spent = sum(eq["total_cost"] for eq in selected)
remaining_budget = budget - current_total_spent
if remaining_budget > 0: if remaining_budget > 0:
excluded.sort(key=lambda x: x['priority_score'], reverse=True) # Sort by priority score DESC then cost ASC
excluded.sort(key=lambda x: (-x["priority_score"], x["total_cost"]))
for eq in excluded[:]: for eq in excluded[:]:
if eq['total_cost'] <= remaining_budget: if eq["total_cost"] <= remaining_budget:
selected.append(eq) selected.append(eq)
excluded.remove(eq) excluded.remove(eq)
remaining_budget -= eq['total_cost'] remaining_budget -= eq["total_cost"]
return (selected, excluded)
return selected, excluded

@ -1,45 +1,150 @@
import asyncio
from typing import Dict, List, Optional
from typing_extensions import Annotated from typing_extensions import Annotated
from temporalio.client import Client 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.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 .service import get_simulation_results, identify_worst_eaf_contributors
from pydantic import BaseModel, Field
from .service import run_rbd_simulation, get_simulation_results, identify_worst_eaf_contributors
from .schema import OptimizationResult, TargetReliabiltiyQuery from .schema import OptimizationResult, TargetReliabiltiyQuery
from src.auth.access_control import required_permission, OHPermission
from src.overhaul_scope.model import OverhaulScope
from fastapi import Depends
router = APIRouter() router = APIRouter()
@router.get('', response_model=StandardResponse[OptimizationResult], dependencies=[Depends(required_permission(OHPermission.READ, OverhaulScope))]) class SimulateRequest(BaseModel):
async def get_target_reliability(db_session: DbSession, token: Token, collector_db: CollectorDbSession, params: Annotated[TargetReliabiltiyQuery, Query()]): 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]])
# async def get_target_reliability(
# db_session: DbSession,
# scope_name: Optional[str] = Query(None),
# eaf_threshold: float = Query(100),
# ):
# """Get all scope pagination."""
# results = await get_all_target_reliability(
# db_session=db_session, scope_name=scope_name, eaf_threshold=eaf_threshold
# )
# return StandardResponse(
# data=results,
# message="Data retrieved successfully",
# )
@router.get("", response_model=StandardResponse[OptimizationResult])
async def get_target_reliability(
db_session: DbSession,
token: Token,
collector_db: CollectorDbSession,
params: Annotated[TargetReliabiltiyQuery, Query()],
# oh_session_id: Optional[str] = Query(None),
# eaf_input: float = Query(99.8),
# duration: int = Query(17520),
# simulation_id: Optional[str] = Query(None),
# cut_hours = Query(0)
):
"""Get all scope pagination."""
oh_session_id = params.oh_session_id oh_session_id = params.oh_session_id
eaf_input = params.eaf_input eaf_input = params.eaf_input
duration = params.duration duration = params.duration
simulation_id = params.simulation_id simulation_id = params.simulation_id
cut_hours = params.cut_hours cut_hours = params.cut_hours
oh_duration = params.oh_duration
if not oh_session_id: if not oh_session_id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='oh_session_id is required') raise HTTPException(
if duration != 17520: status_code=status.HTTP_400_BAD_REQUEST,
if not simulation_id: detail="oh_session_id is required",
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Simulation ID is required for non-default duration. Please run simulation first.') )
# results = await get_eaf_timeline(
# db_session=db_session,
# oh_session_id=oh_session_id,
# eaf_input=eaf_input,
# oh_duration=duration
# )
if simulation_id:
try: try:
temporal_client = await Client.connect(TEMPORAL_URL) temporal_client = await Client.connect(TEMPORAL_URL)
handle = temporal_client.get_workflow_handle(f'simulation-{simulation_id}') handle = temporal_client.get_workflow_handle(f"simulation-{simulation_id}")
desc = await handle.describe() desc = await handle.describe()
status_name = desc.status.name 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.') if status_name in ["RUNNING", "CONTINUED_AS_NEW"]:
if status_name != 'COMPLETED': raise HTTPException(
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f'Simulation failed with status: {status_name}') 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: except HTTPException:
raise raise
except Exception as e: except Exception as e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f'Simulation not found or error checking status: {str(e)}') # Handle connection errors or invalid workflow IDs
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Simulation not found or error checking status: {str(e)}",
)
else: else:
if duration != 17520 or oh_duration != 1200:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Simulation ID is required for non-default duration or OH duration. Please run simulation first.",
)
simulation_id = TR_RBD_ID simulation_id = TR_RBD_ID
results = await get_simulation_results(simulation_id=simulation_id, token=token)
optimize_result = await identify_worst_eaf_contributors(simulation_result=results, target_eaf=eaf_input, db_session=db_session, oh_session_id=oh_session_id, collector_db=collector_db, simulation_id=simulation_id, duration=duration, po_duration=1200, cut_hours=float(cut_hours))
return StandardResponse(data=optimize_result, message='Data retrieved successfully') results = await get_simulation_results(
simulation_id=simulation_id,
token=token
)
optimize_result = await identify_worst_eaf_contributors(
simulation_result=results,
target_eaf=eaf_input,
db_session=db_session,
oh_session_id=oh_session_id,
collector_db=collector_db,
simulation_id=simulation_id,
duration=duration,
po_duration=oh_duration,
cut_hours=float(cut_hours)
)
return StandardResponse(
data=optimize_result,
message="Data retrieved successfully",
)

@ -1,18 +1,29 @@
from datetime import datetime
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
from uuid import UUID
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from src.models import DefultBase
from src.models import DefultBase, Pagination
class OverhaulBase(BaseModel): class OverhaulBase(BaseModel):
pass pass
class OverhaulCriticalParts(OverhaulBase): class OverhaulCriticalParts(OverhaulBase):
criticalParts: List[str] = Field(..., description='List of critical parts') criticalParts: List[str] = Field(..., description="List of critical parts")
class OverhaulSchedules(OverhaulBase): class OverhaulSchedules(OverhaulBase):
schedules: List[Dict[str, Any]] = Field(..., description='List of schedules') schedules: List[Dict[str, Any]] = Field(..., description="List of schedules")
class OverhaulSystemComponents(OverhaulBase): class OverhaulSystemComponents(OverhaulBase):
systemComponents: Dict[str, Any] = Field(..., description='List of system components') systemComponents: Dict[str, Any] = Field(
..., description="List of system components"
)
class OverhaulRead(OverhaulBase): class OverhaulRead(OverhaulBase):
overview: Dict[str, Any] overview: Dict[str, Any]
@ -39,6 +50,7 @@ class MaintenanceScenario(OverhaulBase):
plant_level_benefit: float = 0.0 plant_level_benefit: float = 0.0
capacity_weight : float capacity_weight : float
class OptimizationResult(OverhaulBase): class OptimizationResult(OverhaulBase):
current_plant_eaf: float current_plant_eaf: float
target_plant_eaf: float target_plant_eaf: float
@ -51,9 +63,49 @@ class OptimizationResult(OverhaulBase):
optimization_success: bool = False optimization_success: bool = False
simulation_id: Optional[str] = None simulation_id: Optional[str] = None
class TargetReliabiltiyQuery(DefultBase): class TargetReliabiltiyQuery(DefultBase):
oh_session_id: Optional[str] = Field(None) oh_session_id: Optional[str] = Field(None)
eaf_input: float = Field(99.8) eaf_input: float = Field(99.8)
duration: int = Field(17520) duration: int = Field(17520)
simulation_id: Optional[str] = Field(None) simulation_id: Optional[str] = Field(None)
cut_hours:int = Field(0) cut_hours:int = Field(0)
oh_duration: int = Field(1200)
# {
# "overview": {
# "totalEquipment": 30,
# "nextSchedule": {
# "date": "2025-01-12",
# "Overhaul": "B",
# "equipmentCount": 30
# }
# },
# "criticalParts": [
# "Boiler feed pump",
# "Boiler reheater system",
# "Drum Level (Right) Root Valve A",
# "BCP A Discharge Valve",
# "BFPT A EXH Press HI Root VLV"
# ],
# "schedules": [
# {
# "date": "2025-01-12",
# "Overhaul": "B",
# "status": "upcoming"
# }
# // ... other scheduled overhauls
# ],
# "systemComponents": {
# "boiler": {
# "status": "operational",
# "lastOverhaul": "2024-06-15"
# },
# "turbine": {
# "hpt": { "status": "operational" },
# "ipt": { "status": "operational" },
# "lpt": { "status": "operational" }
# }
# // ... other major components
# }
# }

@ -1,142 +1,387 @@
import math import math
from typing import Optional, List
from dataclasses import dataclass
from sqlalchemy import Delete, Select, select
import httpx import httpx
from src.auth.service import CurrentUser
from src.config import RBD_SERVICE_API from src.config import RBD_SERVICE_API
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
import random
from .utils import generate_down_periods
from src.overhaul_scope.service import get as get_overhaul
from bisect import bisect_left
from collections import defaultdict
import asyncio import asyncio
from .schema import AssetWeight, OptimizationResult 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) client = httpx.AsyncClient(timeout=300.0)
async def run_rbd_simulation(*, sim_hours: int, token):
sim_data = {'SimulationName': f'Simulasi TR OH {sim_hours}', 'SchematicName': '- TJB - Unit 3 -', 'SimSeed': 1, 'SimDuration': sim_hours, 'OverhaulInterval': sim_hours - 1201, 'DurationUnit': 'UHour', 'SimNumRun': 1, 'IsDefault': False, 'OverhaulDuration': 1200} from src.calculation_time_constrains.model import CalculationData
headers = {'Authorization': f'Bearer {token}', 'Content-Type': 'application/json'}
rbd_simulation_url = f'{RBD_SERVICE_API}/aeros/simulation/run' async def run_rbd_simulation(*, sim_hours: int, oh_duration: int = 1200, oh_session_id: str, db_session: DbSession, token: str):
# Check if a simulation with these parameters already exists
stmt = select(CalculationData).where(
CalculationData.overhaul_session_id == oh_session_id,
CalculationData.analysis_metadata["type"].as_string() == "target_reliability",
CalculationData.analysis_metadata["sim_hours"].as_string() == str(sim_hours),
CalculationData.analysis_metadata["oh_duration"].as_string() == str(oh_duration)
)
result = await db_session.execute(stmt)
existing_calc = result.scalars(yield).first()
if existing_calc and existing_calc.rbd_simulation_id:
return {"data": str(existing_calc.rbd_simulation_id), "status": "success", "message": "Loaded existing simulation"}
sim_data = {
"SimulationName": f"Simulasi TR OH {sim_hours}_{oh_duration}",
"SchematicName": "- TJB - Unit 3 -",
"SimSeed": 1,
"SimDuration": sim_hours,
"OverhaulInterval": max(sim_hours - oh_duration - 1, 1),
"DurationUnit": "UHour",
"SimNumRun": 1,
"IsDefault": False,
"OverhaulDuration": oh_duration
}
headers = {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json"
}
rbd_simulation_url = f"{RBD_SERVICE_API}/aeros/simulation/run"
async with httpx.AsyncClient(timeout=300.0) as client: async with httpx.AsyncClient(timeout=300.0) as client:
response = await client.post(rbd_simulation_url, json=sim_data, headers=headers) response = await client.post(rbd_simulation_url, json=sim_data, headers=headers)
response.raise_for_status() response.raise_for_status()
return response.json() resp_json = response.json()
sim_id = resp_json.get("data")
if sim_id:
new_calc = CalculationData(
overhaul_session_id=oh_session_id,
rbd_simulation_id=sim_id,
analysis_metadata={
"type": "target_reliability",
"sim_hours": str(sim_hours),
"oh_duration": str(oh_duration)
}
)
db_session.add(new_calc)
await db_session.commit()
return resp_json
async def get_simulation_results(*, simulation_id: str, token: str): async def get_simulation_results(*, simulation_id: str, token: str):
headers = {'Authorization': f'Bearer {token}', 'Content-Type': 'application/json'} headers = {
calc_result_url = f'{RBD_SERVICE_API}/aeros/simulation/result/calc/{simulation_id}?nodetype=RegularNode' "Authorization": f"Bearer {token}",
calc_plant_result = f'{RBD_SERVICE_API}/aeros/simulation/result/calc/{simulation_id}/plant' "Content-Type": "application/json"
}
calc_result_url = f"{RBD_SERVICE_API}/aeros/simulation/result/calc/{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"
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)
# plot_task = client.get(plot_result_url, headers=headers)
plant_task = client.get(calc_plant_result, headers=headers) plant_task = client.get(calc_plant_result, headers=headers)
# Run all three requests concurrently
calc_response, plant_response = await asyncio.gather(calc_task, plant_task) calc_response, plant_response = await asyncio.gather(calc_task, plant_task)
calc_response.raise_for_status() calc_response.raise_for_status()
# plot_response.raise_for_status()
plant_response.raise_for_status() plant_response.raise_for_status()
calc_data = calc_response.json()['data']
plant_data = plant_response.json()['data'] calc_data = calc_response.json()["data"]
return {'calc_result': calc_data, 'plant_result': plant_data} # plot_data = plot_response.json()["data"]
plant_data = plant_response.json()["data"]
return {
"calc_result": calc_data,
# "plot_result": plot_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, scheduled_outage):
"""
Calculate each asset's contribution to plant EAF with realistic, fair improvement allocation.
The total EAF gap is distributed among assets proportionally to their contribution potential.
Automatically skips equipment with no unplanned downtime (only scheduled outages).
"""
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')
total_hours = plant_result.get("total_uptime") + plant_result.get("total_downtime")
plant_operating_fraction = (total_hours - scheduled_outage) / total_hours plant_operating_fraction = (total_hours - scheduled_outage) / total_hours
REALISTIC_MAX_TECHNICAL = 0.995 REALISTIC_MAX_TECHNICAL = 0.995
REALISTIC_MAX_AVAILABILITY = REALISTIC_MAX_TECHNICAL * plant_operating_fraction REALISTIC_MAX_AVAILABILITY = REALISTIC_MAX_TECHNICAL * plant_operating_fraction
MIN_IMPROVEMENT_PERCENT = 0.0001 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 = [] 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') node = asset.get("aeros_node")
if not node: if not node:
continue continue
asset_name = node.get('node_name')
num_of_events = asset.get('num_events', 0) 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)
birbaum = asset.get('contribution', 0.0) contribution_factor = asset.get("contribution_factor", 0.0)
current_availability = asset.get('availability', 0.0) birbaum = asset.get("contribution", 0.0)
downtime = asset.get('total_downtime', 0.0) current_availability = asset.get("availability", 0.0)
if num_of_events < 2 or contribution_factor <= 0: downtime = asset.get("total_downtime", 0.0)
# --- NEW: Skip equipment with no failures and near-maximum availability ---
if (
num_of_events < 2 # no unplanned events
or contribution_factor <= 0
):
# This equipment has nothing to improve realistically
continue continue
# --- Compute realistic possible improvement ---
if REALISTIC_MAX_AVAILABILITY > current_availability: if REALISTIC_MAX_AVAILABILITY > current_availability:
max_possible_improvement = REALISTIC_MAX_AVAILABILITY - current_availability max_possible_improvement = REALISTIC_MAX_AVAILABILITY - current_availability
else: else:
max_possible_improvement = 0.0 max_possible_improvement = 0.0 # No improvement possible
# Compute weighted importance (Birnbaum × FV)
raw_weight = birbaum raw_weight = birbaum
weight = math.sqrt(max(raw_weight, 0.0)) weight = math.sqrt(max(raw_weight, 0.0))
weighted_assets.append((asset, weight, 0)) weighted_assets.append((asset, weight, 0))
total_weight = sum((w for _, w, _ in weighted_assets)) or 1.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
for asset, weight, max_possible_improvement in weighted_assets: for asset, weight, max_possible_improvement in weighted_assets:
node = asset.get('aeros_node') node = asset.get("aeros_node")
contribution_factor = asset.get('contribution_factor', 0.0) contribution_factor = asset.get("contribution_factor", 0.0)
birbaum = 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)
required_improvement = eaf_gap_fraction * (weight/total_weight) required_improvement = eaf_gap_fraction * (weight/total_weight)
required_improvement = min(required_improvement, max_possible_improvement) required_improvement = min(required_improvement, max_possible_improvement)
required_improvement = max(required_improvement, min_improvement_fraction) required_improvement = max(required_improvement, min_improvement_fraction)
improvement_impact = required_improvement * contribution_factor improvement_impact = required_improvement * contribution_factor
efficiency = birbaum / downtime if downtime > 0 else birbaum efficiency = birbaum / downtime if downtime > 0 else birbaum
contribution = AssetWeight(node=node, availability=current_availability, contribution=contribution_factor, required_improvement=required_improvement, improvement_impact=improvement_impact, num_of_failures=asset.get('num_events', 0), down_time=downtime, efficiency=efficiency, birbaum=birbaum)
contribution = AssetWeight(
node=node,
availability=current_availability,
contribution=contribution_factor,
required_improvement=required_improvement,
improvement_impact=improvement_impact,
num_of_failures=asset.get("num_events", 0),
down_time=downtime,
efficiency=efficiency,
birbaum=birbaum,
)
results.append(contribution) results.append(contribution)
# Step 4: Sort by Birnbaum importance
results.sort(key=lambda x: x.birbaum, reverse=True) results.sort(key=lambda x: x.birbaum, 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
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
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) return min(projected_eaf, 99.9) # Cap at 99.9%
async def identify_worst_eaf_contributors(
*,
simulation_result,
target_eaf: float,
db_session: DbSession,
oh_session_id: str,
collector_db: CollectorDbSession,
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,
evaluate if target EAF is physically achievable, and optionally
calculate the additional improvement if user cuts scheduled outage.
"""
calc_result = simulation_result["calc_result"]
plant_result = simulation_result["plant_result"]
async def identify_worst_eaf_contributors(*, simulation_result, target_eaf: float, db_session: DbSession, oh_session_id: str, collector_db: CollectorDbSession, simulation_id: str, duration: int, po_duration: int, cut_hours: float=0):
calc_result = simulation_result['calc_result']
plant_result = simulation_result['plant_result']
eq_results = calc_result if isinstance(calc_result, list) else [calc_result] eq_results = calc_result if isinstance(calc_result, list) else [calc_result]
current_plant_eaf = plant_result.get('eaf', 0)
# Base parameters
current_plant_eaf = plant_result.get("eaf", 0)
total_hours = duration total_hours = duration
scheduled_outage = int(po_duration) scheduled_outage = int(po_duration)
reduced_outage = max(scheduled_outage - cut_hours, 0) reduced_outage = max(scheduled_outage - cut_hours, 0)
max_eaf_possible = (total_hours - reduced_outage) / total_hours * 100 max_eaf_possible = (total_hours - reduced_outage) / total_hours * 100
scheduled_eaf_gain = cut_hours / total_hours * 100 if cut_hours > 0 else 0.0
# 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 warning_message = None
if target_eaf > max_eaf_possible: if target_eaf > max_eaf_possible:
target_eaf - max_eaf_possible impossible_gap = target_eaf - max_eaf_possible
required_scheduled_hours = total_hours * (1 - target_eaf / 100) required_scheduled_hours = total_hours * (1 - target_eaf / 100)
required_reduction = reduced_outage - required_scheduled_hours required_reduction = reduced_outage - required_scheduled_hours
# Build dynamic phrase for clarity
if cut_hours > 0: if cut_hours > 0:
reduction_phrase = f' even after reducing planned outage by {cut_hours}h' reduction_phrase = f" even after reducing planned outage by {cut_hours}h"
else: else:
reduction_phrase = '' reduction_phrase = ""
warning_message = f'⚠️ Target EAF {target_eaf:.2f}% exceeds theoretical maximum {max_eaf_possible:.2f}%{reduction_phrase}.\nTo achieve it, planned outage must be further reduced by approximately {required_reduction:.1f} hours (from {reduced_outage:.0f}h → {required_scheduled_hours:.0f}h).'
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 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: 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='') return OptimizationResult(
standard_scope = await get_standard_scope_by_session_id(db_session=db_session, overhaul_session_id=oh_session_id, collector_db=collector_db) 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)
standard_scope = await get_standard_scope_by_session_id(
db_session=db_session,
overhaul_session_id=oh_session_id,
collector_db=collector_db,
)
standard_scope_location_tags = [tag.location_tag for tag in standard_scope] standard_scope_location_tags = [tag.location_tag for tag in standard_scope]
asset_contributions = calculate_asset_eaf_contributions(plant_result, eq_results, standard_scope_location_tags, eaf_gap, reduced_outage)
# Compute contributions for reliability improvements
asset_contributions = calculate_asset_eaf_contributions(
plant_result, eq_results, standard_scope_location_tags, eaf_gap, reduced_outage
)
# Greedy improvement allocation
project_eaf_improvement_total = 0.0 project_eaf_improvement_total = 0.0
selected_eq = [] selected_eq = []
for asset in asset_contributions: for asset in asset_contributions:
if project_eaf_improvement_total >= eaf_gap: if project_eaf_improvement_total >= eaf_gap:
break break
if project_eaf_improvement_total + asset.improvement_impact <= eaf_gap:
if (project_eaf_improvement_total + asset.improvement_impact) <= eaf_gap:
selected_eq.append(asset) selected_eq.append(asset)
project_eaf_improvement_total += asset.improvement_impact project_eaf_improvement_total += asset.improvement_impact
else: else:
continue continue
# Total EAF after improvements + optional outage cut
possible_eaf_plant = current_plant_eaf + project_eaf_improvement_total * 100 + scheduled_eaf_gain possible_eaf_plant = current_plant_eaf + project_eaf_improvement_total * 100 + scheduled_eaf_gain
possible_eaf_plant = min(possible_eaf_plant, max_eaf_possible) possible_eaf_plant = min(possible_eaf_plant, max_eaf_possible)
selected_eq.sort(key=lambda x: x.birbaum, reverse=True) selected_eq.sort(key=lambda x: x.birbaum, reverse=True)
required_cut_hours = 0 required_cut_hours = 0
# --- 2. Optimization feasible but cannot reach target (underperformance case) ---
if possible_eaf_plant < target_eaf: if possible_eaf_plant < target_eaf:
# Calculate shortfall
performance_gap = target_eaf - possible_eaf_plant performance_gap = target_eaf - possible_eaf_plant
required_cut_hours = performance_gap / 100 * total_hours
reliability_limit_msg = f'⚠️ Optimization was unable to reach target EAF {target_eaf:.2f}%.\nThe best achievable EAF based on current reliability is {possible_eaf_plant:.2f}% (short by {performance_gap:.2f}%).' # Estimate how many scheduled outage hours must be reduced to close the remaining gap
recommendation_msg = f'To achieve the target EAF, consider reducing planned outage by approximately {required_cut_hours:.1f} hours or {int(required_cut_hours / 24)} days (from {reduced_outage:.0f}h → {reduced_outage - required_cut_hours:.0f}h).' # 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: if warning_message:
warning_message = warning_message + '\n\n' + reliability_limit_msg + '\n' + recommendation_msg warning_message = warning_message + "\n\n" + reliability_limit_msg + "\n" + recommendation_msg
else: else:
warning_message = reliability_limit_msg + '\n' + recommendation_msg warning_message = reliability_limit_msg + "\n" + recommendation_msg
eaf_improvement_points = possible_eaf_plant - current_plant_eaf
# --- EAF improvement reporting ---
eaf_improvement_points = (possible_eaf_plant - current_plant_eaf)
# Express as text for user readability
if eaf_improvement_points > 0: if eaf_improvement_points > 0:
improvement_text = f'{eaf_improvement_points:.6f} percentage points increase' improvement_text = f"{eaf_improvement_points:.6f} percentage points increase"
else: else:
improvement_text = 'No measurable improvement achieved' improvement_text = "No measurable improvement achieved"
return OptimizationResult(current_plant_eaf=current_plant_eaf, target_plant_eaf=target_eaf, possible_plant_eaf=possible_eaf_plant, eaf_gap=eaf_gap, warning_message=warning_message, eaf_improvement_text=improvement_text, recommended_reduced_outage=required_cut_hours, asset_contributions=[{'node': asset.node, 'availability': asset.availability, 'contribution': asset.contribution, 'sensitivy': asset.birbaum, 'required_improvement': asset.required_improvement, 'system_impact': asset.improvement_impact, 'num_of_failures': asset.num_of_failures, 'down_time': asset.down_time, 'efficiency': asset.efficiency} for asset in selected_eq], outage_reduction_hours=cut_hours, optimization_success=current_plant_eaf + project_eaf_improvement_total * 100 + scheduled_eaf_gain >= target_eaf, simulation_id=simulation_id)
# Build result
return OptimizationResult(
current_plant_eaf=current_plant_eaf,
target_plant_eaf=target_eaf,
possible_plant_eaf=possible_eaf_plant,
eaf_gap=eaf_gap,
warning_message=warning_message, # numeric
eaf_improvement_text=improvement_text,
recommended_reduced_outage=required_cut_hours,
asset_contributions=[
{
"node": asset.node,
"availability": asset.availability,
"contribution": asset.contribution,
"sensitivy": asset.birbaum,
"required_improvement": asset.required_improvement,
"system_impact": asset.improvement_impact,
"num_of_failures": asset.num_of_failures,
"down_time": asset.down_time,
"efficiency": asset.efficiency,
}
for asset in selected_eq
],
outage_reduction_hours=cut_hours,
optimization_success=(current_plant_eaf + project_eaf_improvement_total * 100 + scheduled_eaf_gain)
>= target_eaf,
simulation_id=simulation_id,
)

@ -1,49 +1,91 @@
import asyncio import asyncio
from datetime import datetime, timedelta from datetime import datetime, timedelta
import random import random
from typing import Optional from typing import List, Optional
from temporalio.client import Client from temporalio.client import Client
from src.config import TEMPORAL_URL
def generate_down_periods(start_date: datetime, end_date: datetime, num_periods: Optional[int]=None, min_duration: int=3, max_duration: int=7) -> list[tuple[datetime, datetime]]: from src.config import TEMPORAL_URL, TR_RBD_ID
def generate_down_periods(start_date: datetime, end_date: datetime,
num_periods: Optional[int] = None, min_duration: int = 3,
max_duration: int = 7) -> list[tuple[datetime, datetime]]:
"""
Generate random system down periods within a date range.
Args:
start_date (datetime): Start date of the overall period
end_date (datetime): End date of the overall period
num_periods (int, optional): Number of down periods to generate.
If None, generates 1-3 periods randomly
min_duration (int): Minimum duration of each down period in days
max_duration (int): Maximum duration of each down period in days
Returns:
list[tuple[datetime, datetime]]: List of (start_date, end_date) tuples
for each down period
"""
if num_periods is None: if num_periods is None:
num_periods = random.randint(1, 3) num_periods = random.randint(1, 3)
total_days = (end_date - start_date).days total_days = (end_date - start_date).days
down_periods = [] down_periods = []
# Generate random down periods
for _ in range(num_periods): for _ in range(num_periods):
# Random duration for this period
duration = random.randint(min_duration, max_duration) duration = random.randint(min_duration, max_duration)
# Ensure we don't exceed the total date range
latest_possible_start = total_days - duration latest_possible_start = total_days - duration
if latest_possible_start < 0: if latest_possible_start < 0:
continue continue
# Random start day within available range
start_day = random.randint(0, latest_possible_start) start_day = random.randint(0, latest_possible_start)
period_start = start_date + timedelta(days=start_day) period_start = start_date + timedelta(days=start_day)
period_end = period_start + timedelta(days=duration) period_end = period_start + timedelta(days=duration)
overlaps = any((p_start <= period_end and period_start <= p_end for p_start, p_end in down_periods))
# Check for overlaps with existing periods
overlaps = any(
(p_start <= period_end and period_start <= p_end)
for p_start, p_end in down_periods
)
if not overlaps: if not overlaps:
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): async def wait_for_workflow(simulation_id, max_retries=3):
workflow_id = f'simulation-{simulation_id}' workflow_id = f"simulation-{simulation_id}" # use returned ID
retries = 0 retries = 0
temporal_client = await Client.connect(TEMPORAL_URL) temporal_client = await Client.connect(TEMPORAL_URL)
while True: while True:
try: try:
handle = temporal_client.get_workflow_handle(workflow_id=workflow_id) handle = temporal_client.get_workflow_handle(workflow_id=workflow_id)
desc = await handle.describe() desc = await handle.describe()
status = desc.status.name status = desc.status.name
if status not in ['RUNNING', 'CONTINUED_AS_NEW']:
print(f'✅ Workflow {workflow_id} finished with status: {status}') if status not in ["RUNNING", "CONTINUED_AS_NEW"]:
print(f"✅ Workflow {workflow_id} finished with status: {status}")
break break
print(f'⏳ Workflow {workflow_id} still {status}, checking again in 10s...')
print(f"⏳ Workflow {workflow_id} still {status}, checking again in 10s...")
except Exception as e: except Exception as e:
retries += 1 retries += 1
if retries > max_retries: if retries > max_retries:
print(f'⚠️ Workflow {workflow_id} not found after {max_retries} retries, treating as done. Error: {e}') print(f"⚠️ Workflow {workflow_id} not found after {max_retries} retries, treating as done. Error: {e}")
break break
print(f'⚠️ Workflow {workflow_id} not found (retry {retries}/{max_retries}), waiting 10s before retry...') else:
print(f"⚠️ Workflow {workflow_id} not found (retry {retries}/{max_retries}), waiting 10s before retry...")
await asyncio.sleep(10) await asyncio.sleep(10)
continue continue
retries = 0
retries = 0 # reset retries if describe() worked
await asyncio.sleep(30) await asyncio.sleep(30)
return simulation_id return simulation_id

@ -3,105 +3,230 @@ import numpy as np
import pandas as pd import pandas as pd
from datetime import timedelta from datetime import timedelta
from collections import defaultdict from collections import defaultdict
from src.config import BASE_URL
class OptimumCostModel: class OptimumCostModel:
def __init__(self, token, last_oh_date, next_oh_date,interval =30, base_url: str = "http://192.168.1.82:8000"):
def __init__(self, token, last_oh_date, next_oh_date, interval=30, base_url: str=f'{BASE_URL}'):
self.api_base_url = base_url self.api_base_url = base_url
self.token = token self.token = token
self.last_oh_date = last_oh_date self.last_oh_date = last_oh_date
self.next_oh_date = next_oh_date self.next_oh_date = next_oh_date
self.interval=30 self.interval=30
def get_reliability_from_api(self, target_date, location_tag): def get_reliability_from_api(self, target_date, location_tag):
"""
Get reliability value from API for a specific date.
Parameters:
target_date: datetime object for the target date
Returns:
reliability value (float)
"""
# Format date for API call
date_str = target_date.strftime('%Y-%m-%d %H:%M:%S.%f') date_str = target_date.strftime('%Y-%m-%d %H:%M:%S.%f')
url = f'{self.api_base_url}/reliability/calculate/reliability/{location_tag}/{date_str}'
header = {'Content-Type': 'application/json', 'Authorization': 'Bearer ' + self.token} # Construct API URL
url = f"{self.api_base_url}/reliability/calculate/reliability/{location_tag}/{date_str}"
header = {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + self.token
}
try: try:
response = requests.get(url, headers=header) response = requests.get(url, headers=header)
response.raise_for_status() response.raise_for_status()
data = response.json() data = response.json()
return data['data']['value'] reliability = data['data']['value']
return reliability
except requests.RequestException as e: except requests.RequestException as e:
print(f'API Error: {e}') print(f"API Error: {e}")
return None return None
except KeyError as e: except KeyError as e:
print(f'Data parsing error: {e}') print(f"Data parsing error: {e}")
return None return None
def get_reliability_equipment(self, location_tag): def get_reliability_equipment(self, location_tag):
current_date = self.last_oh_date current_date = self.last_oh_date
results = defaultdict() results = defaultdict()
print('Fetching reliability data from API...')
print("Fetching reliability data from API...")
while current_date <= self.next_oh_date: while current_date <= self.next_oh_date:
reliability = self.get_reliability_from_api(current_date, location_tag) reliability = self.get_reliability_from_api(current_date, location_tag)
results[current_date] = reliability results[current_date] = reliability
if reliability is not None: if reliability is not None:
print(f"Date: {current_date.strftime('%Y-%m-%d')}, Reliability: {reliability:.6f}") print(f"Date: {current_date.strftime('%Y-%m-%d')}, Reliability: {reliability:.6f}")
else: else:
print(f"Date: {current_date.strftime('%Y-%m-%d')}, Reliability: Failed to fetch") print(f"Date: {current_date.strftime('%Y-%m-%d')}, Reliability: Failed to fetch")
current_date += timedelta(days=self.interval) current_date += timedelta(days=self.interval)
return results return results
def calculate_costs_each_point(self, reliabilities, preventive_cost: float, failure_replacement_cost: float): def calculate_costs_each_point(self, reliabilities, preventive_cost: float, failure_replacement_cost: float):
# Calculate costs for each time point
results = [] results = []
dates = list(reliabilities.keys()) dates = list(reliabilities.keys())
for i, (date, reliability) in enumerate(reliabilities.items()): for i, (date, reliability) in enumerate(reliabilities.items()):
if reliability is None: if reliability is None:
continue continue
# Calculate time from last OH in days
time_from_last_oh = (date - self.last_oh_date).days time_from_last_oh = (date - self.last_oh_date).days
# Calculate failure replacement cost
failure_prob = 1 - reliability failure_prob = 1 - reliability
# For expected operating time, we need to integrate R(t) from 0 to T
# Since we have discrete points, we'll approximate using trapezoidal rule
if i == 0: if i == 0:
expected_operating_time = time_from_last_oh expected_operating_time = time_from_last_oh # First point
else: else:
# Approximate integral using available reliability values
time_points = [(dates[j] - self.last_oh_date).days for j in range(i+1)] time_points = [(dates[j] - self.last_oh_date).days for j in range(i+1)]
rel_values = [rel for rel in reliabilities[:i+1] if rel is not None] rel_values = [rel for rel in reliabilities[:i+1] if rel is not None]
if len(rel_values) > 1: if len(rel_values) > 1:
expected_operating_time = np.trapezoid(rel_values, time_points) expected_operating_time = np.trapezoid(rel_values, time_points)
else: else:
expected_operating_time = time_from_last_oh * reliability expected_operating_time = time_from_last_oh * reliability
# Calculate costs
if expected_operating_time > 0: if expected_operating_time > 0:
failure_cost = failure_prob * failure_replacement_cost / expected_operating_time failure_cost = (failure_prob * failure_replacement_cost) / expected_operating_time
preventive_cost = reliability * preventive_cost / expected_operating_time preventive_cost = (reliability * preventive_cost) / expected_operating_time
else: else:
# failure_cost = failure_prob * self.IDRu
# preventive_cost = reliability * self.IDRp
continue continue
total_cost = failure_cost + preventive_cost total_cost = failure_cost + preventive_cost
results.append({'date': date, 'days_from_last_oh': time_from_last_oh, 'reliability': reliability, 'failure_probability': failure_prob, 'expected_operating_time': expected_operating_time, 'failure_replacement_cost': failure_cost, 'preventive_replacement_cost': preventive_cost, 'total_cost': total_cost, 'procurement_cost': 0})
results.append({
'date': date,
'days_from_last_oh': time_from_last_oh,
'reliability': reliability,
'failure_probability': failure_prob,
'expected_operating_time': expected_operating_time,
'failure_replacement_cost': failure_cost,
'preventive_replacement_cost': preventive_cost,
'total_cost': total_cost,
'procurement_cost': 0
})
return results return results
def find_optimal_timing(self, results_df): def find_optimal_timing(self, results_df):
"""
Find the timing that gives the lowest total cost.
Parameters:
results_df: DataFrame from calculate_costs_over_period
Returns:
Dictionary with optimal timing information
"""
if results_df.empty: if results_df.empty:
return None return None
# Find minimum total cost
min_cost_idx = results_df['total_cost'].idxmin() min_cost_idx = results_df['total_cost'].idxmin()
optimal_row = results_df.loc[min_cost_idx] optimal_row = results_df.loc[min_cost_idx]
return {'optimal_index': min_cost_idx, 'optimal_date': optimal_row['date'], 'days_from_last_oh': optimal_row['days_from_last_oh'], 'reliability': optimal_row['reliability'], 'failure_cost': optimal_row['failure_replacement_cost'], 'preventive_cost': optimal_row['preventive_replacement_cost'], 'total_cost': optimal_row['total_cost'], 'procurement_cost': 0}
async def calculate_cost_all_equipment(self, db_session: DbSession, equipments: list, preventive_cost: float, calculation): return {
'optimal_index': min_cost_idx,
'optimal_date': optimal_row['date'],
'days_from_last_oh': optimal_row['days_from_last_oh'],
'reliability': optimal_row['reliability'],
'failure_cost': optimal_row['failure_replacement_cost'],
'preventive_cost': optimal_row['preventive_replacement_cost'],
'total_cost': optimal_row['total_cost'],
'procurement_cost': 0
}
async def calculate_cost_all_equipment(
self,
db_session: DbSession,
equipments: list,
preventive_cost: float,
calculation,
) :
"""
Calculate optimization for entire fleet of equipment
"""
max_interval = self._get_months_between(start_date, end_date) max_interval = self._get_months_between(start_date, end_date)
preventive_cost / len(equipments) preventive_cost_per_equipment = preventive_cost / len(equipments)
fleet_results = [] fleet_results = []
total_corrective_costs = np.zeros(max_interval) total_corrective_costs = np.zeros(max_interval)
total_preventive_costs = np.zeros(max_interval) total_preventive_costs = np.zeros(max_interval)
total_procurement_costs = np.zeros(max_interval) total_procurement_costs = np.zeros(max_interval)
np.zeros(max_interval) total_failures = np.zeros(max_interval)
for equipment in equipments: for equipment in equipments:
# Get reliability data
cost_per_failure = equipment.material_cost cost_per_failure = equipment.material_cost
reliabilities = await self.get_reliability_equipment(location_tag=equipment.equipment.location_tag) reliabilities = await self.get_reliability_equipment(
location_tag=equipment.equipment.location_tag,
)
predicted_cost = self.calculate_costs_each_point(reliabilities=reliabilities, preventive_cost=preventive_cost, failure_replacement_cost=cost_per_failure) predicted_cost = self.calculate_costs_each_point(reliabilities=reliabilities, preventive_cost=preventive_cost, failure_replacement_cost=cost_per_failure)
optimum_cost = self.find_optimal_timing(pd.DataFrame(predicted_cost)) optimum_cost = self.find_optimal_timing(pd.DataFrame(predicted_cost))
corrective_costs = [r['failure_cost'] for r in predicted_cost]
preventive_costs = [r['preventive_cost'] for r in predicted_cost] # Aggregate costs
procurement_costs = [r['procurement_cost'] for r in predicted_cost] corrective_costs = [r["failure_cost"] for r in predicted_cost]
procurement_details = [r['procurement_details'] for r in predicted_cost] preventive_costs = [r["preventive_cost"] for r in predicted_cost]
failures = [1 - r['reliability'] for r in predicted_cost] procurement_costs = [r["procurement_cost"] for r in predicted_cost]
fleet_results.append(CalculationEquipmentResult(corrective_costs=corrective_costs, overhaul_costs=preventive_costs, procurement_costs=procurement_costs, daily_failures=failures, assetnum=equipment.assetnum, material_cost=equipment.material_cost, service_cost=equipment.service_cost, optimum_day=optimum_cost['optimal_index'], calculation_data_id=calculation.id, master_equipment=equipment.equipment, procurement_details=procurement_details)) procurement_details = [r["procurement_details"] for r in predicted_cost]
failures = [(1-r["reliability"]) for r in predicted_cost]
fleet_results.append(
CalculationEquipmentResult(
corrective_costs=corrective_costs,
overhaul_costs=preventive_costs,
procurement_costs=procurement_costs,
daily_failures=failures,
assetnum=equipment.assetnum,
material_cost=equipment.material_cost,
service_cost=equipment.service_cost,
optimum_day=optimum_cost['optimal_index'],
calculation_data_id=calculation.id,
master_equipment=equipment.equipment,
procurement_details=procurement_details
)
)
total_corrective_costs += np.array(corrective_costs) total_corrective_costs += np.array(corrective_costs)
total_preventive_costs += np.array(preventive_costs) total_preventive_costs += np.array(preventive_costs)
total_procurement_costs += np.array(procurement_costs) total_procurement_costs += np.array(procurement_costs)
# Calculate fleet optimal interval
total_costs = total_corrective_costs + total_preventive_costs + total_procurement_costs total_costs = total_corrective_costs + total_preventive_costs + total_procurement_costs
fleet_optimal_index = np.argmin(total_costs) fleet_optimal_index = np.argmin(total_costs)
calculation.optimum_oh_day =fleet_optimal_index + 1 calculation.optimum_oh_day =fleet_optimal_index + 1
db_session.add_all(fleet_results) db_session.add_all(fleet_results)
await db_session.commit() await db_session.commit()
return {'id': calculation.id, 'fleet_results': fleet_results, 'fleet_optimal_interval': fleet_optimal_index + 1, 'fleet_optimal_cost': total_costs[fleet_optimal_index], 'total_corrective_costs': total_corrective_costs.tolist(), 'total_preventive_costs': total_preventive_costs.tolist(), 'total_procurement_costs': total_procurement_costs.tolist()}
return {
'id': calculation.id,
'fleet_results': fleet_results,
'fleet_optimal_interval': fleet_optimal_index + 1,
'fleet_optimal_cost': total_costs[fleet_optimal_index],
'total_corrective_costs': total_corrective_costs.tolist(),
'total_preventive_costs': total_preventive_costs.tolist(),
'total_procurement_costs': total_procurement_costs.tolist(),
}

@ -1,36 +1,216 @@
from typing import Optional from typing import Optional
from uuid import UUID
import numpy as np
from fastapi import HTTPException, status from fastapi import HTTPException, status
from sqlalchemy import func, select from sqlalchemy import Select, func, select
from sqlalchemy.orm import joinedload
from src.auth.service import Token
from src.config import TC_RBD_ID 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
from src.workorder.model import MasterWorkOrder from src.workorder.model import MasterWorkOrder
from .schema import CalculationTimeConstrainsParametersCreate, CalculationTimeConstrainsParametersRead, CalculationTimeConstrainsParametersRetrive
from .service import create_param_and_data, get_calculation_data_by_id, run_simulation_with_spareparts from .schema import (CalculationTimeConstrainsParametersCreate,
CalculationTimeConstrainsParametersRead,
CalculationTimeConstrainsParametersRetrive,
CalculationTimeConstrainsRead)
from .service import (create_calculation_result_service, create_param_and_data,
get_avg_cost_by_asset,
get_calculation_by_reference_and_parameter,
get_calculation_data_by_id, get_calculation_result,
run_simulation_with_spareparts)
from src.database.core import CollectorDbSession from src.database.core import CollectorDbSession
async def get_create_calculation_parameters(*, db_session: DbSession, calculation_id: Optional[str]=None):
async def get_create_calculation_parameters(
*, db_session: DbSession, calculation_id: Optional[str] = None
):
if calculation_id is not None: if calculation_id is not None:
calculation = await get_calculation_data_by_id(calculation_id=calculation_id, db_session=db_session) calculation = await get_calculation_data_by_id(
calculation_id=calculation_id, db_session=db_session
)
if not calculation: if not calculation:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='A data with this id does not exist.') raise HTTPException(
return CalculationTimeConstrainsParametersRead(costPerFailure=calculation.parameter.avg_failure_cost, overhaulCost=calculation.parameter.overhaul_cost, reference=calculation) status_code=status.HTTP_404_NOT_FOUND,
stmt = select(StandardScope, func.avg(MasterWorkOrder.total_cost_max).label('average_cost')).outerjoin(MasterWorkOrder, StandardScope.location_tag == MasterWorkOrder.location_tag).group_by(StandardScope.id) 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) results = await db_session.execute(stmt)
costFailure = results.all() costFailure = results.all()
scopes = await get_all(db_session=db_session) scopes = await get_all(db_session=db_session)
avaiableScopes = {scope.id: scope.scope_name for scope in scopes} avaiableScopes = {scope.id: scope.scope_name for scope in scopes}
costFailurePerScope = {avaiableScopes.get(costPerFailure[0]): costPerFailure[1] for costPerFailure in costFailure} costFailurePerScope = {
return CalculationTimeConstrainsParametersRetrive(costPerFailure=costFailurePerScope, availableScopes=avaiableScopes.values(), recommendedScope='A') avaiableScopes.get(costPerFailure[0]): costPerFailure[1]
for costPerFailure in costFailure
}
return CalculationTimeConstrainsParametersRetrive(
costPerFailure=costFailurePerScope,
availableScopes=avaiableScopes.values(),
recommendedScope="A",
# historicalData={
# "averageOverhaulCost": 10000000,
# "lastCalculation": {
# "id": "calc_122",
# "date": "2024-10-15",
# "scope": "B",
# },
# },
)
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
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
)
async def create_calculation(*, token: str, db_session: DbSession, collector_db_session: CollectorDbSession, calculation_time_constrains_in: CalculationTimeConstrainsParametersCreate, created_by: str, simulation_id): return {
calculation_data = await create_param_and_data(db_session=db_session, calculation_param_in=calculation_time_constrains_in, created_by=created_by) "data": workflow_id,
rbd_simulation_id = simulation_id or TC_RBD_ID "status": "success",
return 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) "message": "Calculation workflow started successfully"
}
else:
calculation_data = await create_param_and_data(
db_session=db_session,
calculation_param_in=calculation_time_constrains_in,
created_by=created_by
)
await run_simulation_with_spareparts(
db_session=db_session,
calculation=calculation_data,
token=token,
collector_db_session=collector_db_session,
simulation_id=simulation_id
)
return await get_calculation_result(
db_session=db_session,
calculation_id=str(calculation_data.id),
token=token,
include_risk_cost=0
)
async def recalculate_calculation(
*,
token: str,
db_session: DbSession,
collector_db_session: CollectorDbSession,
calculation_id: str,
simulation_id: Optional[str] = None
):
calculation_data = await get_calculation_data_by_id(
db_session=db_session, calculation_id=calculation_id
)
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(
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 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
)
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: if not scope_calculation:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='A data with this id does not exist.') raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="A data with this id does not exist.",
)
return scope_calculation.id return scope_calculation.id
# Check if calculation already exist
# return CalculationTimeConstrainsRead(
# id=scope_calculation.id,
# reference=scope_calculation.overhaul_session_id,
# results=scope_calculation.results,
# optimum_oh=scope_calculation.optimum_oh_day,
# equipment_results=scope_calculation.equipment_results,
# )

@ -1,70 +1,172 @@
from enum import Enum from enum import Enum
from typing import Optional from typing import List, Optional, Union
from sqlalchemy import JSON, UUID, Boolean, Column, Float, ForeignKey, Integer, String
from sqlalchemy import (JSON, UUID, Boolean, Column, Float, ForeignKey,
Integer, Numeric, String)
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from src.database.core import Base, DbSession from src.database.core import Base, DbSession
from src.models import DefaultMixin, IdentityMixin from src.models import DefaultMixin, IdentityMixin, TimeStampMixin, UUIDMixin
class OverhaulReferenceType(str, Enum): class OverhaulReferenceType(str, Enum):
SCOPE = 'SCOPE' SCOPE = "SCOPE"
ASSET = 'ASSET' ASSET = "ASSET"
class CalculationParam(Base, DefaultMixin, IdentityMixin): class CalculationParam(Base, DefaultMixin, IdentityMixin):
__tablename__ = 'oh_ms_calculation_param' __tablename__ = "oh_ms_calculation_param"
avg_failure_cost = Column(Float, nullable=False) avg_failure_cost = Column(Float, nullable=False)
overhaul_cost = Column(Float, nullable=False) overhaul_cost = Column(Float, nullable=False)
calculation_data = relationship('CalculationData', back_populates='parameter')
results = relationship('CalculationResult', back_populates='parameter') # Relationships
calculation_data = relationship("CalculationData", back_populates="parameter")
results = relationship("CalculationResult", back_populates="parameter")
# @classmethod
# async def create_with_references(
# cls,
# db: DbSession,
# avg_failure_cost: float,
# overhaul_cost: float,
# created_by: str,
# # list of {"reference_type": OverhaulReferenceType, "reference_id": str}
# references: List[dict]
# ):
# # Create parameter
# param = cls(
# avg_failure_cost=avg_failure_cost,
# overhaul_cost=overhaul_cost,
# created_by=created_by
# )
# db.add(param)
# await db.flush() # Flush to get the param.id
# # Create reference links
# for ref in references:
# reference_link = ReferenceLink(
# parameter_id=param.id,
# overhaul_reference_type=ref["reference_type"],
# reference_id=ref["reference_id"]
# )
# db.add(reference_link)
# await db.commit()
# await db.refresh(param)
# return param
class CalculationData(Base, DefaultMixin, IdentityMixin): class CalculationData(Base, DefaultMixin, IdentityMixin):
__tablename__ = 'oh_tr_calculation_data' __tablename__ = "oh_tr_calculation_data"
parameter_id = Column(UUID(as_uuid=True), ForeignKey('oh_ms_calculation_param.id'), nullable=True)
overhaul_session_id = Column(UUID(as_uuid=True), ForeignKey('oh_ms_overhaul.id')) parameter_id = Column(
UUID(as_uuid=True), ForeignKey("oh_ms_calculation_param.id"), nullable=True
)
overhaul_session_id = Column(
UUID(as_uuid=True), ForeignKey("oh_ms_overhaul.id")
)
optimum_oh_day = Column(Integer, nullable=True) optimum_oh_day = Column(Integer, nullable=True)
max_interval = Column(Integer, nullable=True) max_interval = Column(Integer, nullable=True)
rbd_simulation_id = Column(UUID(as_uuid=True), nullable=True) rbd_simulation_id = Column(UUID(as_uuid=True), nullable=True)
optimum_analysis = Column(JSON, nullable=True) optimum_analysis = Column(JSON, nullable=True)
session = relationship('OverhaulScope', lazy='raise') plant_results = Column(JSON, nullable=True)
parameter = relationship('CalculationParam', back_populates='calculation_data') fleet_statistics = Column(JSON, nullable=True)
equipment_results = relationship('CalculationEquipmentResult', lazy='raise', viewonly=True) analysis_metadata = Column(JSON, nullable=True)
results = relationship('CalculationResult', lazy='raise', viewonly=True)
session = relationship("OverhaulScope", lazy="raise")
parameter = relationship("CalculationParam", back_populates="calculation_data")
equipment_results = relationship(
"CalculationEquipmentResult", lazy="raise", viewonly=True
)
results = relationship("CalculationResult", lazy="raise", viewonly=True)
@classmethod @classmethod
async def create_with_param(cls, overhaul_session_id: str, db: DbSession, avg_failure_cost: Optional[float], overhaul_cost: Optional[float], created_by: str, params_id: Optional[UUID]): async def create_with_param(
cls,
overhaul_session_id: str,
db: DbSession,
avg_failure_cost: Optional[float],
overhaul_cost: Optional[float],
created_by: str,
params_id: Optional[UUID],
):
if not params_id: if not params_id:
params = CalculationParam(avg_failure_cost=avg_failure_cost, overhaul_cost=overhaul_cost, created_by=created_by) # Create Params
params = CalculationParam(
avg_failure_cost=avg_failure_cost,
overhaul_cost=overhaul_cost,
created_by=created_by,
)
db.add(params) db.add(params)
await db.flush() await db.flush()
params_id = params.id params_id = params.id
calculation_data = cls(overhaul_session_id=overhaul_session_id, created_by=created_by, parameter_id=params_id)
calculation_data = cls(
overhaul_session_id=overhaul_session_id,
created_by=created_by,
parameter_id=params_id,
)
db.add(calculation_data) db.add(calculation_data)
await db.commit() await db.commit()
await db.refresh(calculation_data) await db.refresh(calculation_data)
return calculation_data return calculation_data
class CalculationResult(Base, DefaultMixin): class CalculationResult(Base, DefaultMixin):
__tablename__ = 'oh_tr_calculation_result'
parameter_id = Column(UUID(as_uuid=True), ForeignKey('oh_ms_calculation_param.id'), nullable=False) __tablename__ = "oh_tr_calculation_result"
calculation_data_id = Column(UUID(as_uuid=True), ForeignKey('oh_tr_calculation_data.id'), nullable=False)
parameter_id = Column(
UUID(as_uuid=True), ForeignKey("oh_ms_calculation_param.id"), nullable=False
)
calculation_data_id = Column(
UUID(as_uuid=True), ForeignKey("oh_tr_calculation_data.id"), nullable=False
)
day = Column(Integer, nullable=False) day = Column(Integer, nullable=False)
corrective_cost = Column(Float, nullable=False) corrective_cost = Column(Float, nullable=False)
overhaul_cost = Column(Float, nullable=False) overhaul_cost = Column(Float, nullable=False)
num_failures = Column(Integer, nullable=False) num_failures = Column(Integer, nullable=False)
parameter = relationship('CalculationParam', back_populates='results')
reference_link = relationship('CalculationData') parameter = relationship("CalculationParam", back_populates="results")
reference_link = relationship("CalculationData")
class CalculationEquipmentResult(Base, DefaultMixin): class CalculationEquipmentResult(Base, DefaultMixin):
__tablename__ = 'oh_tr_calculation_equipment_result'
__tablename__ = "oh_tr_calculation_equipment_result"
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)
service_cost = Column(Float, nullable=False) service_cost = Column(Float, nullable=False)
calculation_data_id = Column(UUID(as_uuid=True), ForeignKey('oh_tr_calculation_data.id'), nullable=True) calculation_data_id = Column(
UUID(as_uuid=True), ForeignKey("oh_tr_calculation_data.id"), nullable=True
)
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) is_initial = Column(Boolean, default=True)
master_equipment = relationship('MasterEquipment', lazy='joined', primaryjoin='and_(CalculationEquipmentResult.location_tag == foreign(MasterEquipment.location_tag))', uselist=False)
master_equipment = relationship(
"MasterEquipment",
lazy="joined",
primaryjoin="and_(CalculationEquipmentResult.location_tag == foreign(MasterEquipment.location_tag))",
uselist=False, # Add this if it's a one-to-one relationship
)

@ -1,61 +1,230 @@
from typing import Annotated, List, Optional, Union from typing import Annotated, 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, InternalKey, Token from src.auth.service import CurrentUser, InternalKey, Token
from src.config import DEFAULT_TC_ID 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
from .flows import create_calculation, get_create_calculation_parameters, get_or_create_scope_equipment_calculation
from .schema import CalculationResultsRead, CalculationSelectedEquipmentUpdate, CalculationTimeConstrainsCreate, CalculationTimeConstrainsParametersCreate, CalculationTimeConstrainsParametersRead, CalculationTimeConstrainsParametersRetrive, CalculationTimeConstrainsRead, CreateCalculationQuery, EquipmentResult, CalculationTimeConstrainsReadNoResult from .flows import (create_calculation, get_create_calculation_parameters,
from .service import bulk_update_equipment, get_calculation_result, get_calculation_result_by_day, get_calculation_by_assetnum, get_all_calculations get_or_create_scope_equipment_calculation)
from .schema import (CalculationResultsRead,
CalculationSelectedEquipmentUpdate,
CalculationTimeConstrainsCreate,
CalculationTimeConstrainsParametersCreate,
CalculationTimeConstrainsParametersRead,
CalculationTimeConstrainsParametersRetrive,
CalculationTimeConstrainsRead, CreateCalculationQuery, EquipmentResult, CalculationTimeConstrainsReadNoResult)
from .service import (bulk_update_equipment, get_calculation_result,
get_calculation_result_by_day, get_calculation_by_assetnum, get_all_calculations)
from src.database.core import CollectorDbSession from src.database.core import CollectorDbSession
from src.auth.access_control import required_permission, OHPermission
from src.overhaul_scope.model import OverhaulScope
from fastapi import Depends
from src.csrf_protect import csrf_protect
router = APIRouter() router = APIRouter()
get_calculation = APIRouter() get_calculation = APIRouter()
@router.post('', response_model=StandardResponse[Union[dict, CalculationTimeConstrainsRead]], dependencies=[Depends(required_permission(OHPermission.CREATE, OverhaulScope))])
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()]): @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()],
# scope_calculation_id: Optional[str] = Query(None),
# with_results: Optional[int] = Query(0),
# simulation_id = Query(None)
):
"""Save calculation time constrains Here"""
scope_calculation_id = params.scope_calculation_id scope_calculation_id = params.scope_calculation_id
simulation_id = params.simulation_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(db_session=db_session, scope_calculation_id=scope_calculation_id, calculation_time_constrains_in=calculation_time_constrains_in) 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: 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) results = await create_calculation(
return StandardResponse(data=results, message='Data created successfully') 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
)
@router.get('', response_model=StandardResponse[List[CalculationTimeConstrainsReadNoResult]], dependencies=[Depends(required_permission(OHPermission.READ, OverhaulScope))]) return StandardResponse(
async def get_all_simulation_calculations(db_session: DbSession, token: Token, current_user: CurrentUser): data=parameters,
calculations = await get_all_calculations(db_session=db_session) message="Data retrieved successfully",
return StandardResponse(data=calculations, message='Data retrieved successfully') )
@router.get('/parameters', response_model=StandardResponse[Union[CalculationTimeConstrainsParametersRetrive, CalculationTimeConstrainsParametersRead]], dependencies=[Depends(required_permission(OHPermission.READ, OverhaulScope))])
async def get_calculation_parameters(db_session: DbSession, calculation_id: Optional[str]=Query(default=None)):
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], dependencies=[Depends(required_permission(OHPermission.READ, OverhaulScope))]) @get_calculation.get(
async def get_calculation_results(db_session: DbSession, calculation_id, token: InternalKey, include_risk_cost: int=Query(1, alias='risk_cost')): "/{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': if calculation_id == 'default':
calculation_id = DEFAULT_TC_ID 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], dependencies=[Depends(required_permission(OHPermission.READ, OverhaulScope))]) results = await get_calculation_result(
db_session=db_session, calculation_id=calculation_id, token=token, include_risk_cost=include_risk_cost
)
# requests.post(f"{config.AUTH_SERVICE_API}/sign-out", headers={
# "Authorization": f"Bearer {token}"
# })
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): 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], dependencies=[Depends(required_permission(OHPermission.READ, OverhaulScope))]) results = await get_calculation_by_assetnum(
async def get_simulation_result(db_session: DbSession, calculation_id, calculation_simuation_in: CalculationTimeConstrainsCreate): db_session=db_session, assetnum=assetnum, calculation_id=calculation_id
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')
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]], dependencies=[Depends(required_permission(OHPermission.EDIT, OverhaulScope)), Depends(csrf_protect)]) @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]): async def update_selected_equipment(
db_session: DbSession,
calculation_id,
calculation_time_constrains_in: List[CalculationSelectedEquipmentUpdate],
):
if calculation_id == 'default': if calculation_id == 'default':
calculation_id = '3b9a73a2-bde6-418c-9e2f-19046f501a05' 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') 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"""
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,32 +1,51 @@
from src.overhaul_scope.schema import ScopeRead from src.overhaul_scope.schema import ScopeRead
from dataclasses import dataclass
from datetime import datetime from datetime import datetime
from typing import Dict, List, Optional, Union from typing import Any, Dict, List, Optional, Union
from uuid import UUID from uuid import UUID
from pydantic import Field from pydantic import Field
from src.models import DefultBase from src.models import DefultBase
from src.standard_scope.schema import MasterEquipmentBase from src.standard_scope.schema import MasterEquipmentBase
class CalculationTimeConstrainsBase(DefultBase): class CalculationTimeConstrainsBase(DefultBase):
pass pass
class ReferenceLinkBase(DefultBase): class ReferenceLinkBase(DefultBase):
reference_id: str = Field(..., description='Reference ID') reference_id: str = Field(..., description="Reference ID")
overhaul_reference_type: str = Field(..., description='Overhaul reference type') overhaul_reference_type: str = Field(..., description="Overhaul reference type")
class CalculationTimeConstrainsParametersRetrive(CalculationTimeConstrainsBase): class CalculationTimeConstrainsParametersRetrive(CalculationTimeConstrainsBase):
costPerFailure: Union[dict, float] = Field(..., description='Cost per failure') # type: ignore
availableScopes: List[str] = Field(..., description='Available scopes') costPerFailure: Union[dict, float] = Field(..., description="Cost per failure")
recommendedScope: str = Field(..., description='Recommended scope') availableScopes: List[str] = Field(..., description="Available scopes")
recommendedScope: str = Field(..., description="Recommended scope")
# historicalData: Dict[str, Any] = Field(..., description="Historical data")
class CalculationTimeConstrainsParametersRead(CalculationTimeConstrainsBase): class CalculationTimeConstrainsParametersRead(CalculationTimeConstrainsBase):
costPerFailure: Union[dict, float] = Field(..., description='Cost per failure') costPerFailure: Union[dict, float] = Field(..., description="Cost per failure")
overhaulCost: Optional[float] = Field(None, description='Overhaul cost') overhaulCost: Optional[float] = Field(None, description="Overhaul cost")
reference: Optional[List[ReferenceLinkBase]] = Field(None, description='Reference') reference: Optional[List[ReferenceLinkBase]] = Field(None, description="Reference")
class CalculationTimeConstrainsParametersCreate(CalculationTimeConstrainsBase): 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):
# overhaulCost: float = Field(..., description="Overhaul cost")
# scopeOH: str = Field(..., description="Scope OH")
# costPerFailure: float = Field(..., description="Cost per failure")
# metadata: Dict[str, Any] = Field(..., description="Metadata")
class CalculationResultsRead(CalculationTimeConstrainsBase): class CalculationResultsRead(CalculationTimeConstrainsBase):
overhaul_cost: float overhaul_cost: float
@ -39,22 +58,27 @@ class CalculationResultsRead(CalculationTimeConstrainsBase):
procurement_details: Dict procurement_details: Dict
sparepart_summary: dict sparepart_summary: dict
class OptimumResult(CalculationTimeConstrainsBase): class OptimumResult(CalculationTimeConstrainsBase):
overhaul_cost: float overhaul_cost: float
corrective_cost: float corrective_cost: float
num_failures: int num_failures: int
days: int days: int
class EquipmentResult(CalculationTimeConstrainsBase): class EquipmentResult(CalculationTimeConstrainsBase):
id: UUID id: UUID
corrective_costs: List[float] corrective_costs: List[float]
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
optimum_day: int optimum_day: int # Added optimum result for each equipment
is_included: bool is_included: bool
master_equipment: Optional[MasterEquipmentBase] = Field(None) master_equipment: Optional[MasterEquipmentBase] = Field(None)
@ -84,6 +108,7 @@ class AnalysisMetadata(CalculationTimeConstrainsBase):
calculation_type: str calculation_type: str
total_equipment_analyzed: int total_equipment_analyzed: int
included_in_optimization: int included_in_optimization: int
optimal_stat: Optional[dict]
class CalculationTimeConstrainsReadNoResult(CalculationTimeConstrainsBase): class CalculationTimeConstrainsReadNoResult(CalculationTimeConstrainsBase):
id: UUID id: UUID
@ -92,6 +117,10 @@ class CalculationTimeConstrainsReadNoResult(CalculationTimeConstrainsBase):
max_interval: Optional[int] max_interval: Optional[int]
optimum_analysis: Optional[dict] optimum_analysis: Optional[dict]
session: ScopeRead 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
@ -105,12 +134,15 @@ class CalculationTimeConstrainsRead(CalculationTimeConstrainsBase):
optimal_analysis: dict optimal_analysis: dict
analysis_metadata: dict analysis_metadata: dict
class CalculationTimeConstrainsCreate(CalculationTimeConstrainsBase): class CalculationTimeConstrainsCreate(CalculationTimeConstrainsBase):
intervalDays: int intervalDays: int
class CalculationTimeConstrainsSimulationRead(CalculationTimeConstrainsBase): class CalculationTimeConstrainsSimulationRead(CalculationTimeConstrainsBase):
simulation: CalculationResultsRead simulation: CalculationResultsRead
class CalculationSelectedEquipmentUpdate(CalculationTimeConstrainsBase): class CalculationSelectedEquipmentUpdate(CalculationTimeConstrainsBase):
is_included: bool is_included: bool
location_tag: str location_tag: str

File diff suppressed because it is too large Load Diff

@ -1,134 +1,345 @@
import datetime import datetime
import json
import pandas as pd import pandas as pd
import requests import requests
from src.config import RBD_SERVICE_API from src.config import RBD_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:
return (end_date.year - start_date.year) * 12 + (end_date.month - start_date.month) """
Calculate number of months between two dates.
"""
months = (end_date.year - start_date.year) * 12 + (end_date.month - start_date.month)
# Add 1 to include both start and end months
return months
def create_time_series_data(chart_data, max_hours=None): def create_time_series_data(chart_data, max_hours=None):
filtered_data = [d for d in chart_data if d['currentEvent'] != 'ON_OH'] # Filter out ON_OH
sorted_data = sorted(filtered_data, key=lambda x: x['cumulativeTime']) filtered_data = [d for d in chart_data if d["currentEvent"] != "ON_OH"]
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"]
last_time = int(sorted_data[-1]['cumulativeTime'])
# Determine maximum bound (either given or from data)
last_time = int(sorted_data[-1]["cumulativeTime"])
if max_hours is None: if max_hours is None:
max_hours = last_time max_hours = last_time
for hour in range(0, max_hours + 1):
while current_state_index < len(sorted_data) - 1 and hour >= sorted_data[current_state_index + 1]['cumulativeTime']: for hour in range(0, max_hours + 1): # start from 0
# Advance state if needed
while (current_state_index < len(sorted_data) - 1 and
hour >= 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"]
hourly_data.append({'cumulativeTime': hour, 'flowRate': current_flow_rate, 'currentEQStatus': current_eq_status})
hourly_data.append({
"cumulativeTime": hour,
"flowRate": current_flow_rate,
"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.
A failure is defined as when currentEQStatus = "OoS".
Only counts the start of each failure period (transition from "Svc" to "OoS").
Args:
hourly_data: List of dicts with 'hour', 'flowrate', and 'currentEQStatus' keys
Returns:
List of dicts with 'month' and 'failures' keys (cumulative count)
"""
total_failures = 0 total_failures = 0
previous_eq_status = None previous_eq_status = None
monthly_data = {} monthly_data = {}
for data_point in hourly_data: for data_point in hourly_data:
hour = data_point['hour'] hour = data_point['cumulativeTime']
current_eq_status = data_point['currentEQStatus'] current_eq_status = data_point['currentEQStatus']
month = (hour - 1) // 720 + 1
if current_eq_status == 'OoS' and previous_eq_status is not None and (previous_eq_status != 'OoS'): # Calculate which month this hour belongs to (1-based)
# Assuming 30 days per month = 720 hours per month
month = ((hour - 1) // 720) + 1
# 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":
total_failures += 1 total_failures += 1
elif current_eq_status == 'OoS' and previous_eq_status is None:
# Special case: if the very first data point is a failure
elif current_eq_status == "OoS" and previous_eq_status is None:
total_failures += 1 total_failures += 1
# Store the current cumulative count for this month
monthly_data[month] = total_failures monthly_data[month] = total_failures
previous_eq_status = current_eq_status previous_eq_status = current_eq_status
# 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,
'failures': monthly_data.get(month, monthly_data.get(month-1, 0))
})
return result
def calculate_oos_per_month(hourly_data):
"""
Calculate the discrete OOS hours for each month from hourly data.
A failure is defined as when currentEQStatus = "OoS".
Args:
hourly_data: List of dicts with 'cumulativeTime', 'flowRate', and 'currentEQStatus' keys
Returns:
List of dicts with 'month' and 'oos_hours' keys (discrete monthly count)
"""
monthly_data = {}
for data_point in hourly_data:
hour = data_point['cumulativeTime']
current_eq_status = data_point['currentEQStatus']
# Calculate which month this hour belongs to (1-based)
# Assuming 30 days per month = 720 hours per month
month = ((hour - 1) // 720) + 1
if month not in monthly_data:
monthly_data[month] = 0
if current_eq_status == "OoS":
monthly_data[month] += 1
# Convert to list format
result = [] result = []
if monthly_data: if monthly_data:
max_month = max(monthly_data.keys()) max_month = max(monthly_data.keys())
for month in range(1, max_month + 1): for month in range(1, max_month + 1):
result.append({'month': month, 'failures': monthly_data.get(month, monthly_data.get(month - 1, 0))}) result.append({
'month': month,
'oos_hours': monthly_data.get(month, 0)
})
return result return result
import pandas as pd import pandas as pd
import datetime import datetime
import datetime import datetime
import pandas as pd 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): async def plant_simulation_metrics(simulation_id: str, location_tag: str, max_interval, token, last_oh_date, use_location_tag: int = 1):
calc_result_url = f'{RBD_SERVICE_API}/aeros/simulation/result/calc/{simulation_id}/{location_tag}' """Get failure predictions for equipment from simulation service"""
calc_result_url = f"{RBD_SERVICE_API}/aeros/simulation/result/calc/{simulation_id}/{location_tag}"
try: try:
response = requests.get(calc_result_url, headers={'Content-Type': 'application/json', 'Authorization': f'Bearer {token}'}, timeout=30) response = requests.get(
calc_result_url,
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {token}",
},
timeout=30
)
response.raise_for_status() response.raise_for_status()
prediction_data = response.json()['data'] prediction_data = response.json()['data']
except (requests.RequestException, ValueError) as e: except (requests.RequestException, ValueError) as e:
raise Exception(str(e)) raise Exception(str(e))
return prediction_data return prediction_data
def analyze_monthly_metrics(timestamp_outs, start_date, max_flow_rate: float = 550): def analyze_monthly_metrics(timestamp_outs, start_date, max_flow_rate: float = 550):
if not timestamp_outs: if not timestamp_outs:
return {} return {}
df = pd.DataFrame(timestamp_outs) df = pd.DataFrame(timestamp_outs)
required_columns = ['cumulativeTime', 'currentEQStatus', 'flowRate'] required_columns = ['cumulativeTime', 'currentEQStatus', 'flowRate']
if not all((col in df.columns for col in required_columns)): if not all(col in df.columns for col in required_columns):
return {} return {}
start_oh = datetime.datetime(start_date.year, start_date.month, start_date.day) start_oh = datetime.datetime(start_date.year, start_date.month, start_date.day)
# Actual datetime from cumulative hours
df['datetime'] = df['cumulativeTime'].apply(lambda x: start_oh + datetime.timedelta(hours=x)) df['datetime'] = df['cumulativeTime'].apply(lambda x: start_oh + datetime.timedelta(hours=x))
df['month_year'] = df['datetime'].dt.to_period('M') 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['cumulativeTime'].shift(-1) - df['cumulativeTime']
df['duration_hours'] = df['duration_hours'].fillna(0) df['duration_hours'] = df['duration_hours'].fillna(0)
# Failure detection
df['status_change'] = df['currentEQStatus'].shift() != df['currentEQStatus'] df['status_change'] = df['currentEQStatus'].shift() != df['currentEQStatus']
df['failure'] = (df['currentEQStatus'] == 'OoS') & df['status_change'] df['failure'] = (df['currentEQStatus'] == 'OoS') & df['status_change']
# Cumulative tracking
df['cumulative_failures'] = df['failure'].cumsum() df['cumulative_failures'] = df['failure'].cumsum()
df['cumulative_oos'] = (df['duration_hours'] * (df['currentEQStatus'] == 'OoS')).cumsum() df['cumulative_oos'] = (df['duration_hours'] * (df['currentEQStatus'] == 'OoS')).cumsum()
# Derating calculation
# Derating = capacity reduction below max but not outage
df['derating'] = (max_flow_rate - df['flowRate']).clip(lower=0) df['derating'] = (max_flow_rate - df['flowRate']).clip(lower=0)
df['is_derated'] = (df['currentEQStatus'] == 'Svc') & (df['derating'] > 0) df['is_derated'] = (df['currentEQStatus'] == 'Svc') & (df['derating'] > 0)
# Equivalent Derated Hours (EFDH) → sum of derating * hours, then normalized by max capacity
df['derated_mwh'] = df['derating'] * df['duration_hours'] df['derated_mwh'] = df['derating'] * df['duration_hours']
df['derated_hours_equivalent'] = df['derated_mwh'] / max_flow_rate df['derated_hours_equivalent'] = df['derated_mwh'] / max_flow_rate
monthly_results = {} monthly_results = {}
for month_period, group in df.groupby('month_year', sort=True): for month_period, group in df.groupby('month_year', sort=True):
month_str = str(month_period) month_str = str(month_period)
monthly_results[month_str] = {} monthly_results[month_str] = {}
# Failures
monthly_results[month_str]['failures_count'] = int(group['failure'].sum()) monthly_results[month_str]['failures_count'] = int(group['failure'].sum())
monthly_results[month_str]['cumulative_failures'] = int(group['cumulative_failures'].max()) monthly_results[month_str]['cumulative_failures'] = int(group['cumulative_failures'].max())
# OOS hours
oos_time = group.loc[group['currentEQStatus'] == 'OoS', 'duration_hours'].sum() oos_time = group.loc[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()) monthly_results[month_str]['cummulative_oos'] = float(group['cumulative_oos'].max())
# Flow rate (weighted average)
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
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() service_hours = group.loc[group['currentEQStatus'] == 'Svc', 'duration_hours'].sum()
monthly_results[month_str]['service_hours'] = float(service_hours) monthly_results[month_str]['service_hours'] = float(service_hours)
monthly_results[month_str]['availability_percentage'] = float(service_hours / total_time * 100 if total_time > 0 else 0) monthly_results[month_str]['availability_percentage'] = float(
(service_hours / total_time * 100) if total_time > 0 else 0
)
# Derating metrics
derating_hours = group.loc[group['is_derated'], 'duration_hours'].sum() derating_hours = group.loc[group['is_derated'], 'duration_hours'].sum()
derated_mwh = group['derated_mwh'].sum() derated_mwh = group['derated_mwh'].sum()
equivalent_derated_hours = group['derated_hours_equivalent'].sum() equivalent_derated_hours = group['derated_hours_equivalent'].sum()
monthly_results[month_str]['derating_hours'] = float(derating_hours) monthly_results[month_str]['derating_hours'] = float(derating_hours)
monthly_results[month_str]['derated_mwh'] = float(derated_mwh) monthly_results[month_str]['derated_mwh'] = float(derated_mwh)
monthly_results[month_str]['equivalent_derated_hours'] = float(equivalent_derated_hours) 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:
1. Equipment capacity contribution to system (flowrate * birnbaum_importance * availability)
2. Capacity lost to downtime per month
3. Energy price
Parameters:
- monthly_results: Output from analyze_monthly_metrics()
- birnbaum_importance: Birnbaum importance factor for this equipment
- energy_price: Price per unit of energy/flow
Returns:
- Dictionary with monthly risk costs and array of risk costs per failure
"""
risk_costs = {} risk_costs = {}
risk_cost_array = [] risk_cost_array = []
for month, data in monthly_results.items(): for month, data in monthly_results.items():
# Extract monthly data
avg_flow_rate = data['avg_flow_rate'] avg_flow_rate = data['avg_flow_rate']
availability = data['availability_percentage'] / 100 availability = data['availability_percentage'] / 100 # Convert to decimal
total_oos_hours = data['total_oos_hours'] total_oos_hours = data['total_oos_hours']
failures_count = data['failures_count'] failures_count = data['failures_count']
# 1. Calculate equipment capacity contribution to system
# Capacity = avg_flowrate * birnbaum_importance * availability
equipment_capacity = avg_flow_rate * birnbaum_importance * availability equipment_capacity = avg_flow_rate * birnbaum_importance * availability
# 2. Calculate capacity lost to downtime per month
# Lost capacity = avg_flowrate * birnbaum_importance * downtime_hours
capacity_lost_to_downtime = avg_flow_rate * birnbaum_importance * total_oos_hours capacity_lost_to_downtime = avg_flow_rate * birnbaum_importance * total_oos_hours
# 3. Calculate total risk cost for the month
# Risk cost = capacity_lost * energy_price
monthly_risk_cost = capacity_lost_to_downtime * energy_price monthly_risk_cost = capacity_lost_to_downtime * energy_price
# 4. Calculate risk cost per failure for this month
if failures_count > 0: if failures_count > 0:
risk_cost_per_failure = monthly_risk_cost / failures_count risk_cost_per_failure = monthly_risk_cost / failures_count
else: else:
# If no failures, set to 0 or use alternative approach
risk_cost_per_failure = 0 risk_cost_per_failure = 0
risk_costs[month] = {'equipment_capacity': equipment_capacity, 'capacity_lost_to_downtime': capacity_lost_to_downtime, 'monthly_risk_cost': monthly_risk_cost, 'failures_count': failures_count, 'risk_cost_per_failure': risk_cost_per_failure}
# Store results
risk_costs[month] = {
'equipment_capacity': equipment_capacity,
'capacity_lost_to_downtime': capacity_lost_to_downtime,
'monthly_risk_cost': monthly_risk_cost,
'failures_count': failures_count,
'risk_cost_per_failure': risk_cost_per_failure
}
# Add to array
risk_cost_array.append(risk_cost_per_failure) risk_cost_array.append(risk_cost_per_failure)
return {'monthly_details': risk_costs, 'risk_cost_per_failure_array': risk_cost_array}
return {
'monthly_details': risk_costs,
'risk_cost_per_failure_array': risk_cost_array
}
# Example usage:
def get_monthly_risk_analysis(timestamp_outs, birnbaum_importance, energy_price): def get_monthly_risk_analysis(timestamp_outs, birnbaum_importance, energy_price):
"""
Complete analysis combining monthly metrics with risk cost calculation
"""
# Get monthly metrics
monthly_metrics = analyze_monthly_metrics(timestamp_outs) monthly_metrics = analyze_monthly_metrics(timestamp_outs)
risk_analysis = calculate_risk_cost_per_failure(monthly_metrics, birnbaum_importance, energy_price)
# Calculate risk costs
risk_analysis = calculate_risk_cost_per_failure(
monthly_metrics,
birnbaum_importance,
energy_price
)
# Combine results for comprehensive view
combined_results = {} combined_results = {}
for month in monthly_metrics.keys(): for month in monthly_metrics.keys():
combined_results[month] = {**monthly_metrics[month], **risk_analysis['monthly_details'][month]} combined_results[month] = {
return {'monthly_data': combined_results, 'risk_cost_array': risk_analysis['risk_cost_per_failure_array']} **monthly_metrics[month],
**risk_analysis['monthly_details'][month]
}
return {
'monthly_data': combined_results,
'risk_cost_array': risk_analysis['risk_cost_per_failure_array']
}
# Usage example:
# birnbaum_importance = 0.85 # Example value
# energy_price = 100 # Example: $100 per unit
#
# results = get_monthly_risk_analysis(timestamp_outs, birnbaum_importance, energy_price)
# risk_cost_array = results['risk_cost_array']
# print("Risk cost per failure each month:", risk_cost_array)

@ -1,64 +1,97 @@
import base64
import logging import logging
import os import os
from typing import List from typing import List
from urllib import parse from urllib import parse
from pydantic import BaseModel from pydantic import BaseModel
from starlette.config import Config from starlette.config import Config
from starlette.datastructures import CommaSeparatedStrings
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
class BaseConfigurationModel(BaseModel): class BaseConfigurationModel(BaseModel):
"""Base configuration model used by all config options."""
pass pass
def get_env_tags(tag_list: List[str]) -> dict: def get_env_tags(tag_list: List[str]) -> dict:
"""Create dictionary of available env tags."""
tags = {} tags = {}
for t in tag_list: for t in tag_list:
tag_key, env_key = t.split(':') tag_key, env_key = t.split(":")
env_value = os.environ.get(env_key) env_value = os.environ.get(env_key)
if env_value: if env_value:
tags.update({tag_key: env_value}) tags.update({tag_key: env_value})
return tags return tags
def get_config(): def get_config():
try: try:
config = Config('.env') # Try to load from .env file first
config = Config(".env")
except FileNotFoundError: except FileNotFoundError:
# If .env doesn't exist, use environment variables
config = Config(environ=os.environ) config = Config(environ=os.environ)
return config return config
config = get_config() config = get_config()
LOG_LEVEL = config('LOG_LEVEL', default='INFO')
SERVICE_NAME = config('SERVICE_NAME', default='Be-OptimumOH')
ENV = config('ENV') LOG_LEVEL = config("LOG_LEVEL", default="INFO")
PORT = config('PORT', cast=int) ENV = (os.getenv("ENV") or os.getenv("ENVIRONMENT") or os.getenv("ENVIRONEMENT") or "production").strip()
HOST = config('HOST') DEBUG = 1 if (ENV.upper() in ("DEV", "DEVELOPMENT", "LOCAL") and "PROD" not in ENV.upper()) else 0
DATABASE_HOSTNAME = config('DATABASE_HOSTNAME') PORT = config("PORT", cast=int, default=8000)
_DATABASE_CREDENTIAL_USER = config('DATABASE_CREDENTIAL_USER') HOST = config("HOST", default="localhost")
_DATABASE_CREDENTIAL_PASSWORD = config('DATABASE_CREDENTIAL_PASSWORD')
# database
DATABASE_HOSTNAME = config("DATABASE_HOSTNAME")
_DATABASE_CREDENTIAL_USER = config("DATABASE_CREDENTIAL_USER")
_DATABASE_CREDENTIAL_PASSWORD = config("DATABASE_CREDENTIAL_PASSWORD")
_QUOTED_DATABASE_PASSWORD = parse.quote(str(_DATABASE_CREDENTIAL_PASSWORD)) _QUOTED_DATABASE_PASSWORD = parse.quote(str(_DATABASE_CREDENTIAL_PASSWORD))
DATABASE_NAME = config('DATABASE_NAME') DATABASE_NAME = config("DATABASE_NAME", default="digital_twin")
DATABASE_PORT = config('DATABASE_PORT') DATABASE_PORT = config("DATABASE_PORT", default="5432")
DATABASE_ENGINE_POOL_SIZE = config('DATABASE_ENGINE_POOL_SIZE', cast=int, default=20)
DATABASE_ENGINE_MAX_OVERFLOW = config('DATABASE_ENGINE_MAX_OVERFLOW', cast=int, default=0) DATABASE_ENGINE_POOL_SIZE = config("DATABASE_ENGINE_POOL_SIZE", cast=int, default=20)
COLLECTOR_HOSTNAME = config('COLLECTOR_HOSTNAME') DATABASE_ENGINE_MAX_OVERFLOW = config(
COLLECTOR_PORT = config('COLLECTOR_PORT', cast=int) "DATABASE_ENGINE_MAX_OVERFLOW", cast=int, default=0
COLLECTOR_CREDENTIAL_USER = config('COLLECTOR_CREDENTIAL_USER') )
COLLECTOR_CREDENTIAL_PASSWORD = config('COLLECTOR_CREDENTIAL_PASSWORD')
COLLECTOR_HOSTNAME = config("COLLECTOR_HOSTNAME")
COLLECTOR_PORT = config("COLLECTOR_PORT", default="5432")
COLLECTOR_CREDENTIAL_USER = config("COLLECTOR_CREDENTIAL_USER")
COLLECTOR_CREDENTIAL_PASSWORD = config("COLLECTOR_CREDENTIAL_PASSWORD")
QUOTED_COLLECTOR_CREDENTIAL_PASSWORD = parse.quote(str(COLLECTOR_CREDENTIAL_PASSWORD)) QUOTED_COLLECTOR_CREDENTIAL_PASSWORD = parse.quote(str(COLLECTOR_CREDENTIAL_PASSWORD))
COLLECTOR_NAME = config('COLLECTOR_NAME') COLLECTOR_NAME = config("COLLECTOR_NAME")
SQLALCHEMY_DATABASE_URI = f'postgresql+asyncpg://{_DATABASE_CREDENTIAL_USER}:{_QUOTED_DATABASE_PASSWORD}@{DATABASE_HOSTNAME}:{DATABASE_PORT}/{DATABASE_NAME}'
SQLALCHEMY_COLLECTOR_URI = f'postgresql+asyncpg://{COLLECTOR_CREDENTIAL_USER}:{QUOTED_COLLECTOR_CREDENTIAL_PASSWORD}@{COLLECTOR_HOSTNAME}:{COLLECTOR_PORT}/{COLLECTOR_NAME}' # Deal w
TIMEZONE = config('TIMEZONE') SQLALCHEMY_DATABASE_URI = f"postgresql+asyncpg://{_DATABASE_CREDENTIAL_USER}:{_QUOTED_DATABASE_PASSWORD}@{DATABASE_HOSTNAME}:{DATABASE_PORT}/{DATABASE_NAME}"
MAXIMO_BASE_URL = config('MAXIMO_BASE_URL') SQLALCHEMY_COLLECTOR_URI = f"postgresql+asyncpg://{COLLECTOR_CREDENTIAL_USER}:{QUOTED_COLLECTOR_CREDENTIAL_PASSWORD}@{COLLECTOR_HOSTNAME}:{COLLECTOR_PORT}/{COLLECTOR_NAME}"
MAXIMO_API_KEY = config('MAXIMO_API_KEY')
AUTH_SERVICE_API = config('AUTH_SERVICE_API')
REALIBILITY_SERVICE_API = config('REALIBILITY_SERVICE_API') TIMEZONE = "Asia/Jakarta"
RBD_SERVICE_API = config('RBD_SERVICE_API')
TEMPORAL_URL = config('TEMPORAL_URL') MAXIMO_BASE_URL = config("MAXIMO_BASE_URL", default="http://example.com")
BASE_URL = config('BASE_URL') MAXIMO_API_KEY = config("MAXIMO_API_KEY", default="keys")
API_KEY = config('API_KEY')
TR_RBD_ID = config('TR_RBD_ID') AUTH_SERVICE_API = config("AUTH_SERVICE_API", default="http://192.168.1.82:8000/auth")
TC_RBD_ID = config('TC_RBD_ID') REALIBILITY_SERVICE_API = config("REALIBILITY_SERVICE_API", default="http://192.168.1.82:8000/reliability")
DEFAULT_TC_ID = config('DEFAULT_TC_ID') RBD_SERVICE_API = config("RBD_SERVICE_API", default="http://192.168.1.82:8000/rbd")
REDIS_HOST = config('REDIS_HOST') TEMPORAL_URL = config("TEMPORAL_URL", default="http://192.168.1.86:7233")
REDIS_PORT = config('REDIS_PORT', cast=int)
RATELIMIT_STORAGE_URI = f'redis://{REDIS_HOST}:{REDIS_PORT}'
REDIS_AUTH_DB = config('REDIS_AUTH_DB', cast=int) 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,20 +1,25 @@
from contextvars import ContextVar from contextvars import ContextVar
from typing import Optional, Final from typing import Optional, Final
REQUEST_ID_CTX_KEY: Final[str] = 'request_id'
USER_ID_CTX_KEY: Final[str] = 'user_id' REQUEST_ID_CTX_KEY: Final[str] = "request_id"
USERNAME_CTX_KEY: Final[str] = 'username' USER_ID_CTX_KEY: Final[str] = "user_id"
ROLE_CTX_KEY: Final[str] = 'role' 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) _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) _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) _username_ctx_var: ContextVar[Optional[str]] = ContextVar(USERNAME_CTX_KEY, default=None)
_role_ctx_var: ContextVar[Optional[str]] = ContextVar(ROLE_CTX_KEY, default=None) _role_ctx_var: ContextVar[Optional[str]] = ContextVar(ROLE_CTX_KEY, default=None)
def get_request_id() -> Optional[str]: def get_request_id() -> Optional[str]:
return _request_id_ctx_var.get() return _request_id_ctx_var.get()
def set_request_id(request_id: str): def set_request_id(request_id: str):
return _request_id_ctx_var.set(request_id) return _request_id_ctx_var.set(request_id)
def reset_request_id(token): def reset_request_id(token):
_request_id_ctx_var.reset(token) _request_id_ctx_var.reset(token)

@ -1,12 +1,18 @@
import json import json
import logging import logging
from typing import Dict, Union from typing import Dict, Union, Tuple
from decimal import Decimal, getcontext from decimal import Decimal, getcontext
import math
# Set high precision for decimal calculations
getcontext().prec = 50 getcontext().prec = 50
Structure = Union[str, Dict[str, list]] Structure = Union[str, Dict[str, list]]
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
def prod(iterable): def prod(iterable):
"""Compute product of all elements in iterable with high precision."""
result = Decimal('1.0') result = Decimal('1.0')
for x in iterable: for x in iterable:
if isinstance(x, (int, float)): if isinstance(x, (int, float)):
@ -14,41 +20,56 @@ def prod(iterable):
result *= x result *= x
return float(result) return float(result)
def system_availability(structure: Structure, availabilities: Dict[str, float]) -> float: def system_availability(structure: Structure, availabilities: Dict[str, float]) -> float:
if isinstance(structure, str): """Recursively compute system availability with precise calculations."""
if isinstance(structure, str): # base case - component
if structure not in availabilities: if structure not in availabilities:
raise ValueError(f"Component '{structure}' not found in availabilities") raise ValueError(f"Component '{structure}' not found in availabilities")
return float(Decimal(str(availabilities[structure]))) return float(Decimal(str(availabilities[structure])))
if isinstance(structure, dict): if isinstance(structure, dict):
if 'series' in structure: if "series" in structure:
components = structure['series'] components = structure["series"]
if not components: if not components: # Handle empty series
return 1.0 return 1.0
# Series: A_system = A1 * A2 * ... * An
product = Decimal('1.0') product = Decimal('1.0')
for s in components: for s in components:
availability = system_availability(s, availabilities) availability = system_availability(s, availabilities)
product *= Decimal(str(availability)) product *= Decimal(str(availability))
return float(product) return float(product)
if 'parallel' in structure:
components = structure['parallel'] elif "parallel" in structure:
if not components: components = structure["parallel"]
if not components: # Handle empty parallel
return 0.0 return 0.0
# Parallel: A_system = 1 - (1-A1) * (1-A2) * ... * (1-An)
product = Decimal('1.0') product = Decimal('1.0')
for s in components: for s in components:
availability = system_availability(s, availabilities) availability = system_availability(s, availabilities)
unavailability = Decimal('1.0') - Decimal(str(availability)) unavailability = Decimal('1.0') - Decimal(str(availability))
product *= unavailability product *= unavailability
result = Decimal('1.0') - product result = Decimal('1.0') - product
return float(result) return float(result)
if 'parallel_no_redundancy' in structure:
components = structure['parallel_no_redundancy'] elif "parallel_no_redundancy" in structure:
# Load sharing - system availability is minimum of components
components = structure["parallel_no_redundancy"]
if not components: if not components:
return 0.0 return 0.0
availabilities_list = [system_availability(s, availabilities) for s in components] availabilities_list = [system_availability(s, availabilities) for s in components]
return min(availabilities_list) return min(availabilities_list)
raise ValueError(f'Invalid structure definition: {structure}')
raise ValueError(f"Invalid structure definition: {structure}")
def get_all_components(structure: Structure) -> set: def get_all_components(structure: Structure) -> set:
"""Extract all component names from a structure."""
components = set() components = set()
def extract_components(substructure): def extract_components(substructure):
@ -58,59 +79,131 @@ def get_all_components(structure: Structure) -> set:
for component_list in substructure.values(): for component_list in substructure.values():
for component in component_list: for component in component_list:
extract_components(component) extract_components(component)
extract_components(structure) extract_components(structure)
return components return components
def birnbaum_importance(structure: Structure, availabilities: Dict[str, float], component: str) -> float: def birnbaum_importance(structure: Structure, availabilities: Dict[str, float], component: str) -> float:
"""
Calculate Birnbaum importance for a component.
Birnbaum importance = A_system/A_component
This is approximated as:
I_B = A_system(A_i=1) - A_system(A_i=0)
Where A_i is the availability of component i.
"""
# Create copies for calculations
avail_up = availabilities.copy() avail_up = availabilities.copy()
avail_down = availabilities.copy() avail_down = availabilities.copy()
# Set component availability to 1 (perfect)
avail_up[component] = 1.0 avail_up[component] = 1.0
# Set component availability to 0 (failed)
avail_down[component] = 0.0 avail_down[component] = 0.0
# Calculate system availability in both cases
system_up = system_availability(structure, avail_up) system_up = system_availability(structure, avail_up)
system_down = system_availability(structure, avail_down) system_down = system_availability(structure, avail_down)
# Birnbaum importance is the difference
return system_up - system_down return system_up - system_down
def criticality_importance(structure: Structure, availabilities: Dict[str, float], component: str) -> float: def criticality_importance(structure: Structure, availabilities: Dict[str, float], component: str) -> float:
"""
Calculate Criticality importance for a component.
Criticality importance = Birnbaum importance * (1 - A_component) / (1 - A_system)
This represents the probability that component i is critical to system failure.
"""
birnbaum = birnbaum_importance(structure, availabilities, component) birnbaum = birnbaum_importance(structure, availabilities, component)
system_avail = system_availability(structure, availabilities) system_avail = system_availability(structure, availabilities)
component_avail = availabilities[component] component_avail = availabilities[component]
if system_avail >= 1.0:
if system_avail >= 1.0: # Perfect system
return 0.0 return 0.0
return birnbaum * (1.0 - component_avail) / (1.0 - system_avail)
criticality = birnbaum * (1.0 - component_avail) / (1.0 - system_avail)
return criticality
def fussell_vesely_importance(structure: Structure, availabilities: Dict[str, float], component: str) -> float: def fussell_vesely_importance(structure: Structure, availabilities: Dict[str, float], component: str) -> float:
"""
Calculate Fussell-Vesely importance for a component.
FV importance = (A_system - A_system(A_i=0)) / A_system
This represents the fractional decrease in system availability when component i fails.
"""
system_avail = system_availability(structure, availabilities) system_avail = system_availability(structure, availabilities)
if system_avail <= 0.0: if system_avail <= 0.0:
return 0.0 return 0.0
# Calculate system availability with component failed
avail_down = availabilities.copy() avail_down = availabilities.copy()
avail_down[component] = 0.0 avail_down[component] = 0.0
system_down = system_availability(structure, avail_down) system_down = system_availability(structure, avail_down)
return (system_avail - system_down) / system_avail
fv = (system_avail - system_down) / system_avail
return fv
def compute_all_importance_measures(structure: Structure, availabilities: Dict[str, float]) -> Dict[str, Dict[str, float]]: def compute_all_importance_measures(structure: Structure, availabilities: Dict[str, float]) -> Dict[str, Dict[str, float]]:
"""
Compute all importance measures for each component.
Returns:
Dictionary with component names as keys and importance measures as values
"""
# Normalize availabilities to 0-1 range if needed
normalized_availabilities = {} normalized_availabilities = {}
for k, v in availabilities.items(): for k, v in availabilities.items():
if v > 1.0: if v > 1.0:
normalized_availabilities[k] = v / 100.0 normalized_availabilities[k] = v / 100.0
else: else:
normalized_availabilities[k] = v normalized_availabilities[k] = v
# Clamp to valid range [0, 1]
normalized_availabilities[k] = max(0.0, min(1.0, normalized_availabilities[k])) normalized_availabilities[k] = max(0.0, min(1.0, normalized_availabilities[k]))
# Get all components in the system
all_components = get_all_components(structure) all_components = get_all_components(structure)
# Check for missing components
missing_components = all_components - set(normalized_availabilities.keys()) missing_components = all_components - set(normalized_availabilities.keys())
if missing_components: if missing_components:
log.warning(f'Missing components (assuming 100% availability): {missing_components}') log.warning(f"Missing components (assuming 100% availability): {missing_components}")
for comp in missing_components: for comp in missing_components:
normalized_availabilities[comp] = 1.0 normalized_availabilities[comp] = 1.0
# Calculate system baseline availability
system_avail = system_availability(structure, normalized_availabilities) system_avail = system_availability(structure, normalized_availabilities)
# Calculate importance measures for each component
results = {} results = {}
total_birnbaum = 0.0 total_birnbaum = 0.0
for component in all_components: for component in all_components:
if component in normalized_availabilities: if component in normalized_availabilities:
birnbaum = birnbaum_importance(structure, normalized_availabilities, component) birnbaum = birnbaum_importance(structure, normalized_availabilities, component)
criticality = criticality_importance(structure, normalized_availabilities, component) criticality = criticality_importance(structure, normalized_availabilities, component)
fv = fussell_vesely_importance(structure, normalized_availabilities, component) fv = fussell_vesely_importance(structure, normalized_availabilities, component)
results[component] = {'birnbaum_importance': birnbaum, 'criticality_importance': criticality, 'fussell_vesely_importance': fv, 'component_availability': normalized_availabilities[component]}
results[component] = {
'birnbaum_importance': birnbaum,
'criticality_importance': criticality,
'fussell_vesely_importance': fv,
'component_availability': normalized_availabilities[component]
}
total_birnbaum += birnbaum total_birnbaum += birnbaum
# Calculate contribution percentages based on Birnbaum importance
if total_birnbaum > 0: if total_birnbaum > 0:
for component in results: for component in results:
contribution_pct = results[component]['birnbaum_importance'] / total_birnbaum contribution_pct = results[component]['birnbaum_importance'] / total_birnbaum
@ -118,29 +211,76 @@ def compute_all_importance_measures(structure: Structure, availabilities: Dict[s
else: else:
for component in results: for component in results:
results[component]['contribution_percentage'] = 0.0 results[component]['contribution_percentage'] = 0.0
results['_system_info'] = {'system_availability': system_avail, 'system_unavailability': 1.0 - system_avail, 'total_birnbaum_importance': total_birnbaum}
# Add system-level information
results['_system_info'] = {
'system_availability': system_avail,
'system_unavailability': 1.0 - system_avail,
'total_birnbaum_importance': total_birnbaum
}
return results return results
def calculate_contribution_accurate(availabilities: Dict[str, float], structure_file: str = 'src/overhaul/rbd_structure.json') -> Dict[str, Dict[str, float]]: def calculate_contribution_accurate(availabilities: Dict[str, float], structure_file: str = 'src/overhaul/rbd_structure.json') -> Dict[str, Dict[str, float]]:
"""
Calculate component contributions using proper importance measures.
Args:
availabilities: Dictionary of component availabilities
structure_file: Path to RBD structure JSON file
Returns:
Dictionary containing all importance measures and contributions
"""
try: try:
with open(structure_file, 'r') as model_file: with open(structure_file, 'r') as model_file:
structure = json.load(model_file) structure = json.load(model_file)
except FileNotFoundError: except FileNotFoundError:
raise FileNotFoundError(f'Structure file not found: {structure_file}') raise FileNotFoundError(f"Structure file not found: {structure_file}")
except json.JSONDecodeError: except json.JSONDecodeError:
raise ValueError(f'Invalid JSON in structure file: {structure_file}') raise ValueError(f"Invalid JSON in structure file: {structure_file}")
# Compute all importance measures
results = compute_all_importance_measures(structure, availabilities) results = compute_all_importance_measures(structure, availabilities)
# Extract system information
system_info = results.pop('_system_info') system_info = results.pop('_system_info')
# Log results
log.info(f"System Availability: {system_info['system_availability']:.6f}") log.info(f"System Availability: {system_info['system_availability']:.6f}")
log.info(f"System Unavailability: {system_info['system_unavailability']:.6f}") log.info(f"System Unavailability: {system_info['system_unavailability']:.6f}")
sorted_components = sorted(results.items(), key=lambda x: x[1]['birnbaum_importance'], reverse=True)
# Sort components by Birnbaum importance (most critical first)
sorted_components = sorted(results.items(),
key=lambda x: x[1]['birnbaum_importance'],
reverse=True)
# print("\n=== COMPONENT IMPORTANCE ANALYSIS ===")
# 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("\nComponent Rankings (by Birnbaum Importance):")
# print(f"{'Component':<20} {'Availability':<12} {'Birnbaum':<12} {'Criticality':<12} {'F-V':<12} {'Contribution%':<12}")
# print("-" * 92)
for component, measures in sorted_components: for component, measures in sorted_components:
print(f"{component:<20} {measures['component_availability']:<12.6f} {measures['birnbaum_importance']:<12.6f} {measures['criticality_importance']:<12.6f} {measures['fussell_vesely_importance']:<12.6f} {measures['contribution_percentage'] * 100:<12.4f}") print(f"{component:<20} {measures['component_availability']:<12.6f} "
f"{measures['birnbaum_importance']:<12.6f} {measures['criticality_importance']:<12.6f} "
f"{measures['fussell_vesely_importance']:<12.6f} {measures['contribution_percentage']*100:<12.4f}")
# Return results with system info included
# results['_system_info'] = system_info
return results return results
# Legacy function for backwards compatibility
def calculate_contribution(availabilities): def calculate_contribution(availabilities):
"""Legacy function - redirects to improved version."""
try: try:
return calculate_contribution_accurate(availabilities) return calculate_contribution_accurate(availabilities)
except Exception as e: except Exception as e:
log.error(f'Error in contribution calculation: {e}') log.error(f"Error in contribution calculation: {e}")
raise raise

@ -1,36 +0,0 @@
import hashlib
import logging
import secrets
import redis
from fastapi import HTTPException, Request
import src.config as config
from src.context import get_user_id
log = logging.getLogger(__name__)
_redis_auth = redis.Redis(host=config.REDIS_HOST, port=int(config.REDIS_PORT), db=config.REDIS_AUTH_DB)
async def csrf_protect(request: Request) -> None:
user = getattr(request.state, 'user', None)
user_id = str(user.user_id) if user else get_user_id()
if not user_id:
raise HTTPException(status_code=401, detail='Authentication required')
csrf_token_header = request.headers.get('x-csrf-token') or request.headers.get('x-xsrf-token') or request.headers.get('x-csrf-token')
if not csrf_token_header:
log.warning('[CSRF] Missing X-CSRF-Token header | user_id=%s | IP: %s', user_id, request.client.host if request.client else 'unknown')
raise HTTPException(status_code=403, detail='CSRF token missing in header')
csrf_token_header = csrf_token_header.strip()
current_path = request.url.path
path_hash = hashlib.sha256(current_path.encode()).hexdigest()[:16]
redis_key = f'csrf:{user_id}:{path_hash}'
stored_token_hash = _redis_auth.get(redis_key)
if not stored_token_hash:
log.warning('[CSRF] Token not found in Redis | user_id=%s | path=%s | IP: %s', user_id, current_path, request.client.host if request.client else 'unknown')
raise HTTPException(status_code=403, detail='CSRF token expired or invalid')
if isinstance(stored_token_hash, bytes):
stored_token_hash = stored_token_hash.decode('utf-8')
incoming_hash = hashlib.sha256(csrf_token_header.encode()).hexdigest()
if not secrets.compare_digest(incoming_hash, stored_token_hash):
log.warning('[CSRF] Token mismatch | user_id=%s | path=%s | IP: %s', user_id, current_path, request.client.host if request.client else 'unknown')
_redis_auth.delete(redis_key)
raise HTTPException(status_code=403, detail='CSRF token verification failed')
_redis_auth.delete(redis_key)
log.info('[CSRF] Verified & deleted | user_id=%s | path=%s', user_id, current_path)

@ -1,44 +1,66 @@
# src/database.py
import re import re
import operator import operator
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from typing import Annotated, Any from typing import Annotated, Any
from fastapi import Depends from fastapi import Depends
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
from sqlalchemy.ext.declarative import declared_attr from sqlalchemy.ext.declarative import declarative_base, declared_attr
from sqlalchemy.orm import DeclarativeBase from sqlalchemy.orm import DeclarativeBase
from starlette.requests import Request from starlette.requests import Request
from src.config import SQLALCHEMY_DATABASE_URI, SQLALCHEMY_COLLECTOR_URI from src.config import SQLALCHEMY_DATABASE_URI, SQLALCHEMY_COLLECTOR_URI
engine = create_async_engine(SQLALCHEMY_DATABASE_URI, echo=False, future=True) engine = create_async_engine(SQLALCHEMY_DATABASE_URI, echo=False, future=True)
collector_engine = create_async_engine(SQLALCHEMY_COLLECTOR_URI, echo=False, future=True) collector_engine = create_async_engine(SQLALCHEMY_COLLECTOR_URI, echo=False, future=True)
async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False, autocommit=False, autoflush=False)
async_collector_session = async_sessionmaker(collector_engine, class_=AsyncSession, expire_on_commit=False, autocommit=False, autoflush=False) async_session = async_sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False,
autocommit=False,
autoflush=False,
)
async_collector_session = async_sessionmaker(
collector_engine,
class_=AsyncSession,
expire_on_commit=False,
autocommit=False,
autoflush=False,
)
async def get_collector_db(request: Request): async def get_collector_db(request: Request):
return request.state.collector_db return request.state.collector_db
def get_db(request: Request): def get_db(request: Request):
return request.state.db return request.state.db
DbSession = Annotated[AsyncSession, Depends(get_db)] DbSession = Annotated[AsyncSession, Depends(get_db)]
CollectorDbSession = Annotated[AsyncSession, Depends(get_collector_db)] CollectorDbSession = Annotated[AsyncSession, Depends(get_collector_db)]
class Base(DeclarativeBase):
class Base(DeclarativeBase):
@declared_attr.directive @declared_attr.directive
def __tablename__(cls) -> str: def __tablename__(cls) -> str:
return resolve_table_name(cls.__name__) return resolve_table_name(cls.__name__)
def dict(self): def dict(self):
"""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: operator.attrgetter(c.name)(self) for c in self.__table__.columns}
return {} return {}
class CollectorBase(DeclarativeBase): class CollectorBase(DeclarativeBase):
@declared_attr.directive @declared_attr.directive
def __tablename__(cls) -> str: def __tablename__(cls) -> str:
return resolve_table_name(cls.__name__) return resolve_table_name(cls.__name__)
def dict(self): def dict(self):
"""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: operator.attrgetter(c.name)(self) for c in self.__table__.columns}
return {} return {}
@ -68,16 +90,21 @@ async def get_collector_session():
await session.close() await session.close()
def resolve_table_name(name): def resolve_table_name(name):
names = re.split('(?=[A-Z])', name) """Resolves table names to their mapped names."""
return '_'.join([x.lower() for x in names if x]) names = re.split("(?=[A-Z])", name) # noqa
return "_".join([x.lower() for x in names if x])
def get_class_by_tablename(table_fullname: str) -> Any: def get_class_by_tablename(table_fullname: str) -> Any:
"""Return class reference mapped to table."""
def _find_class(name): def _find_class(name):
for c in Base.registry._class_registry.values(): for c in Base.registry._class_registry.values():
if hasattr(c, '__table__'): if hasattr(c, "__table__"):
if c.__table__.fullname.lower() == name.lower(): if c.__table__.fullname.lower() == name.lower():
return c return c
return None
mapped_name = resolve_table_name(table_fullname) mapped_name = resolve_table_name(table_fullname)
return _find_class(mapped_name) mapped_class = _find_class(mapped_name)
return mapped_class

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

@ -1,58 +1,149 @@
import logging import logging
from typing import Annotated, List, Type, TypeVar from typing import Annotated, List, Type, TypeVar
from fastapi import Depends, Query from fastapi import Depends, Query
from pydantic.types import Json, constr from pydantic.types import Json, constr
from sqlalchemy import Select, desc, func, or_ from sqlalchemy import Select, desc, func, or_
from sqlalchemy.exc import ProgrammingError
from sqlalchemy_filters import apply_pagination
from src.database.schema import CommonParams from src.database.schema import CommonParams
from .core import DbSession from .core import DbSession
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
QueryStr = constr(pattern='^[ -~]+$', min_length=1)
def common_parameters(db_session: DbSession, current_user: QueryStr=Query(None, alias='currentUser'), page: int=Query(1, gt=0, le=1000000), items_per_page: int=Query(5, alias='itemsPerPage', gt=-2, le=1000000), query_str: QueryStr=Query(None, alias='q'), filter_spec: QueryStr=Query(None, alias='filter'), sort_by: List[str]=Query([], alias='sortBy[]'), descending: List[bool]=Query([], alias='descending[]'), exclude: List[str]=Query([], alias='exclude[]'), all: int=Query(0, ge=0, le=1000000)): # allows only printable characters
return {'db_session': db_session, 'page': page, 'items_per_page': items_per_page, 'query_str': query_str, 'filter_spec': filter_spec, 'sort_by': sort_by, 'descending': descending, 'current_user': current_user, 'all': bool(all)} QueryStr = constr(pattern=r"^[ -~]+$", min_length=1)
CommonParameters = Annotated[dict[str, int | str | DbSession | QueryStr | Json | List[str] | List[bool]] | bool, Depends(common_parameters)]
T = TypeVar('T', bound=CommonParams)
def common_parameters(
db_session: DbSession, # type: ignore
current_user: QueryStr = Query(None, alias="currentUser"), # type: ignore
page: int = Query(1, gt=0, lt=2147483647),
items_per_page: int = Query(5, alias="itemsPerPage", gt=-2, lt=2147483647),
query_str: QueryStr = Query(None, alias="q"), # type: ignore
filter_spec: QueryStr = Query(None, alias="filter"), # type: ignore
sort_by: List[str] = Query([], alias="sortBy[]"),
descending: List[bool] = Query([], alias="descending[]"),
exclude: List[str] = Query([], alias="exclude[]"),
all: int = Query(0),
# role: QueryStr = Depends(get_current_role),
):
return {
"db_session": db_session,
"page": page,
"items_per_page": items_per_page,
"query_str": query_str,
"filter_spec": filter_spec,
"sort_by": sort_by,
"descending": descending,
"current_user": current_user,
"all": bool(all),
# "role": role,
}
def get_params_factory(model_type: Type[T]):
async def wrapper(db_session: DbSession, params: Annotated[model_type, Query()]): CommonParameters = Annotated[
dict[str, int | str | DbSession | QueryStr | Json | List[str] | List[bool]] | bool,
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() res = params.model_dump()
return {'db_session': db_session, 'all': params.is_all, **res} return {
"db_session": db_session,
"all": params.is_all,
**res
}
return wrapper 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."""
search_model = model search_model = model
if not query_str.strip(): if not query_str.strip():
return query return query
search = [] search = []
if hasattr(search_model, 'search_vector'): if hasattr(search_model, "search_vector"):
vector = search_model.search_vector vector = search_model.search_vector
search.append(vector.op('@@')(func.tsq_parse(query_str))) search.append(vector.op("@@")(func.tsq_parse(query_str)))
if hasattr(search_model, 'name'):
search.append(search_model.name.ilike(f'%{query_str}%')) if hasattr(search_model, "name"):
search.append(
search_model.name.ilike(f"%{query_str}%"),
)
search.append(search_model.name == query_str) search.append(search_model.name == query_str)
if not search: if not search:
raise Exception(f'Search not supported for model: {model}') raise Exception(f"Search not supported for model: {model}")
query = query.filter(or_(*search)) query = query.filter(or_(*search))
if sort: if sort:
query = query.order_by(desc(func.ts_rank_cd(vector, func.tsq_parse(query_str)))) query = query.order_by(desc(func.ts_rank_cd(vector, func.tsq_parse(query_str))))
return query.params(term=query_str) return query.params(term=query_str)
async def search_filter_sort_paginate(db_session: DbSession, model, query_str: str=None, filter_spec: str | dict | None=None, page: int=1, items_per_page: int=5, sort_by: List[str]=None, descending: List[bool]=None, current_user: str=None, exclude: List[str]=None, all: bool=False):
async def search_filter_sort_paginate(
db_session: DbSession,
model,
query_str: str = None,
filter_spec: str | dict | None = None,
page: int = 1,
items_per_page: int = 5,
sort_by: List[str] = None,
descending: List[bool] = None,
current_user: str = None,
exclude: List[str] = None,
all: bool = False,
):
"""Common functionality for searching, filtering, sorting, and pagination."""
# try:
# Check if model is Select
if not isinstance(model, Select): if not isinstance(model, Select):
query = Select(model) query = Select(model)
else: else:
query = model query = model
if query_str: if query_str:
sort = False if sort_by else True sort = False if sort_by else True
query = search(query_str=query_str, query=query, model=model, sort=sort) query = search(query_str=query_str, query=query, model=model, sort=sort)
# Get total count
count_query = Select(func.count()).select_from(query.subquery()) count_query = Select(func.count()).select_from(query.subquery())
total = await db_session.scalar(count_query) total = await db_session.scalar(count_query)
if all: if all:
result = await db_session.execute(query) result = await db_session.execute(query)
items = result.scalars().all() items = result.scalars().all()
return {'items': items, 'itemsPerPage': total, 'page': 1, 'total': total, 'totalPages': 1}
return {
"items": items,
"itemsPerPage": total,
"page": 1,
"total": total,
"totalPages": 1,
}
query = query.offset((page - 1) * items_per_page).limit(items_per_page) query = query.offset((page - 1) * items_per_page).limit(items_per_page)
result = await db_session.execute(query) result = await db_session.execute(query)
items = result.scalars().all() items = result.scalars().all()
return {'items': items, 'itemsPerPage': items_per_page, 'page': page, 'total': total, 'totalPages': (total + items_per_page - 1) // items_per_page}
return {
"items": items,
"itemsPerPage": items_per_page,
"page": page,
"total": total,
"totalPages": (total + items_per_page - 1) // items_per_page,
}

@ -1,12 +1,24 @@
import sys
if sys.version_info >= (3, 11):
from enum import StrEnum from enum import StrEnum
else:
from strenum import StrEnum
class OptimumOHEnum(StrEnum): class OptimumOHEnum(StrEnum):
pass """
A custom Enum class that extends StrEnum.
This class inherits all functionality from StrEnum, including
string representation and automatic value conversion to strings.
Example:
class Visibility(DispatchEnum):
OPEN = "Open"
RESTRICTED = "Restricted"
assert str(Visibility.OPEN) == "Open"
"""
pass # No additional implementation needed
class ResponseStatus(OptimumOHEnum): class ResponseStatus(OptimumOHEnum):
SUCCESS = 'success' SUCCESS = "success"
ERROR = 'error' ERROR = "error"

@ -1,12 +1,21 @@
from sqlalchemy import UUID, Column, Float, ForeignKey, String from sqlalchemy import UUID, Column, Float, ForeignKey, Integer, String
from sqlalchemy.ext.hybrid import hybrid_property
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from src.database.core import Base from src.database.core import Base
from src.models import DefaultMixin from src.models import DefaultMixin, IdentityMixin, TimeStampMixin
from src.workorder.model import MasterWorkOrder
class ScopeEquipmentPart(Base, DefaultMixin): class ScopeEquipmentPart(Base, DefaultMixin):
__tablename__ = 'oh_ms_scope_equipment_part' __tablename__ = "oh_ms_scope_equipment_part"
required_stock = Column(Float, nullable=False, default=0) required_stock = Column(Float, nullable=False, default=0)
sparepart_id = Column(UUID(as_uuid=True), ForeignKey('oh_ms_sparepart.id'), nullable=False) sparepart_id = Column(UUID(as_uuid=True), ForeignKey("oh_ms_sparepart.id"), nullable=False)
location_tag = Column(String, nullable=False) location_tag = Column(String, nullable=False)
location_tag = Column(String, nullable=False) location_tag = Column(String, nullable=False)
part = relationship('MasterSparePart', lazy='selectin')
part = relationship(
"MasterSparePart",
lazy="selectin",
)

@ -1,12 +1,27 @@
from typing import Dict, List from typing import Dict, List
from fastapi import APIRouter, Depends
from fastapi import APIRouter, HTTPException, Query, status
from src.database.core import CollectorDbSession from src.database.core import CollectorDbSession
from src.database.service import (CommonParameters, DbSession,
search_filter_sort_paginate)
from src.models import StandardResponse from src.models import StandardResponse
from src.auth.access_control import require_any_role, ALLOWED_ROLES
from .schema import (ScopeEquipmentActivityCreate,
ScopeEquipmentActivityPagination,
ScopeEquipmentActivityRead, ScopeEquipmentActivityUpdate)
from .service import get_all from .service import get_all
router = APIRouter(dependencies=[Depends(require_any_role(*ALLOWED_ROLES))])
@router.get('/{location_tag}', response_model=StandardResponse[List[Dict]]) router = APIRouter()
@router.get("/{location_tag}", response_model=StandardResponse[List[Dict]])
async def get_scope_equipment_parts(collector_db_session: CollectorDbSession, location_tag): async def get_scope_equipment_parts(collector_db_session: CollectorDbSession, location_tag):
"""Get all scope activity pagination."""
# return
data = await get_all(db_session=collector_db_session, location_tag=location_tag) data = await get_all(db_session=collector_db_session, location_tag=location_tag)
return StandardResponse(data=data, message='Data retrieved successfully')
return StandardResponse(
data=data,
message="Data retrieved successfully",
)

@ -1,21 +1,69 @@
from typing import List, Optional from datetime import datetime
from pydantic import Field from typing import Any, Dict, List, Optional
from uuid import UUID
from pydantic import BaseModel, Field
from src.models import DefultBase, Pagination from src.models import DefultBase, Pagination
class ScopeEquipmentActivityBase(DefultBase): class ScopeEquipmentActivityBase(DefultBase):
assetnum: str = Field(..., description='Assetnum is required') assetnum: str = Field(..., description="Assetnum is required")
class ScopeEquipmentActivityCreate(ScopeEquipmentActivityBase): class ScopeEquipmentActivityCreate(ScopeEquipmentActivityBase):
name: str name: str
cost: Optional[float] = Field(0, ge=0, le=1000000000000000) cost: Optional[float] = Field(0)
class ScopeEquipmentActivityUpdate(ScopeEquipmentActivityBase): class ScopeEquipmentActivityUpdate(ScopeEquipmentActivityBase):
name: Optional[str] = Field(None) name: Optional[str] = Field(None)
cost: Optional[str] = Field(0) cost: Optional[str] = Field(0)
class ScopeEquipmentActivityRead(ScopeEquipmentActivityBase): class ScopeEquipmentActivityRead(ScopeEquipmentActivityBase):
name: str name: str
cost: float = Field(..., ge=0, le=1000000000000000) cost: float
class ScopeEquipmentActivityPagination(Pagination): class ScopeEquipmentActivityPagination(Pagination):
items: List[ScopeEquipmentActivityRead] = [] items: List[ScopeEquipmentActivityRead] = []
# {
# "overview": {
# "totalEquipment": 30,
# "nextSchedule": {
# "date": "2025-01-12",
# "Overhaul": "B",
# "equipmentCount": 30
# }
# },
# "criticalParts": [
# "Boiler feed pump",
# "Boiler reheater system",
# "Drum Level (Right) Root Valve A",
# "BCP A Discharge Valve",
# "BFPT A EXH Press HI Root VLV"
# ],
# "schedules": [
# {
# "date": "2025-01-12",
# "Overhaul": "B",
# "status": "upcoming"
# }
# // ... other scheduled overhauls
# ],
# "systemComponents": {
# "boiler": {
# "status": "operational",
# "lastOverhaul": "2024-06-15"
# },
# "turbine": {
# "hpt": { "status": "operational" },
# "ipt": { "status": "operational" },
# "lpt": { "status": "operational" }
# }
# // ... other major components
# }
# }

@ -1,29 +1,218 @@
import random
from typing import Optional from typing import Optional
from sqlalchemy import text
from sqlalchemy import Delete, Select, and_, text
from sqlalchemy.orm import selectinload
from src.auth.service import CurrentUser
from src.database.core import CollectorDbSession, DbSession
from src.database.service import CommonParameters, search_filter_sort_paginate
from .model import ScopeEquipmentPart
from .schema import ScopeEquipmentActivityCreate, ScopeEquipmentActivityUpdate
# async def get(*, db_session: DbSession, scope_equipment_activity_id: str) -> Optional[ScopeEquipmentActivity]:
# """Returns a document based on the given document id."""
# result = await db_session.get(ScopeEquipmentActivity, scope_equipment_activity_id)
# return result
from typing import Optional, List, Dict, Any from typing import Optional, List, Dict, Any
from sqlalchemy.ext.asyncio import AsyncSession as DbSession
from sqlalchemy.sql import text from sqlalchemy.sql import text
import logging import logging
logger = logging.getLogger(__name__) 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 typing import List, Dict, Any, Optional
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.sql import text from sqlalchemy.sql import text
async def get_all(db_session: AsyncSession, location_tag: Optional[str]=None, start_year: int=2023, end_year: Optional[int]=None) -> List[Dict[str, Any]]:
async def get_all(
db_session: AsyncSession,
location_tag: Optional[str] = None,
start_year: int = 2023,
end_year: Optional[int] = None
) -> List[Dict[str, Any]]:
"""
Get overhaul spare parts consumption data with optimized query.
Args:
db_session: SQLAlchemy async database session
location_tag: Optional filter for location (asset_location)
start_year: Starting year (default: 2023)
end_year: Ending year (default: start_year + 1)
Returns:
List of dictionaries with spare parts consumption data
"""
# Set default end year
if end_year is None: if end_year is None:
end_year = start_year + 1 end_year = start_year + 1
query_str = "\n WITH filtered_wo AS (\n SELECT DISTINCT wonum, asset_location, asset_unit\n FROM public.wo_maximo ma\n WHERE ma.xx_parent IN ('155026', '155027', '155029', '155030')\n "
# Build query dynamically
query_str = """
WITH filtered_wo AS (
SELECT DISTINCT wonum, asset_location, asset_unit
FROM public.wo_maximo ma
WHERE ma.xx_parent IN ('155026', '155027', '155029', '155030')
"""
params = {} params = {}
# Optional filter for location
if location_tag: if location_tag:
query_str += ' AND asset_location = :location_tag' query_str += " AND asset_location = :location_tag"
params['location_tag'] = location_tag params["location_tag"] = location_tag
query_str += "\n ),\n filtered_materials AS (\n SELECT \n mat.wonum,\n mat.itemnum,\n mat.itemqty,\n mat.inv_curbaltotal AS inv_curbaltotal,\n mat.inv_avgcost AS inv_avgcost\n FROM public.wo_maximo_material AS mat\n WHERE mat.wonum IN (SELECT wonum FROM filtered_wo)\n )\n SELECT \n fwo.asset_location AS location_tag,\n ft.itemnum,\n COALESCE(spl.description, 'Unknown') AS sparepart_name,\n AVG(ft.itemqty) AS total_parts_used,\n COALESCE(AVG(ft.inv_avgcost), 0) AS avg_cost,\n COALESCE(AVG(ft.inv_curbaltotal), 0) AS avg_inventory_balance\n FROM filtered_wo AS fwo\n INNER JOIN filtered_materials AS ft \n ON fwo.wonum = ft.wonum\n LEFT JOIN public.maximo_sparepart_pr_po_line AS spl \n ON ft.itemnum = spl.item_num\n GROUP BY fwo.asset_location, ft.itemnum, spl.description\n ORDER BY fwo.asset_location, ft.itemnum;\n "
query_str += """
),
filtered_materials AS (
SELECT
mat.wonum,
mat.itemnum,
mat.itemqty,
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
fwo.asset_location AS location_tag,
ft.itemnum,
COALESCE(spl.description, 'Unknown') AS sparepart_name,
AVG(ft.itemqty) AS total_parts_used,
COALESCE(AVG(ft.inv_avgcost), 0) AS avg_cost,
COALESCE(AVG(ft.inv_curbaltotal), 0) AS avg_inventory_balance
FROM filtered_wo AS fwo
INNER JOIN filtered_materials AS ft
ON fwo.wonum = ft.wonum
LEFT JOIN public.maximo_sparepart_pr_po_line AS spl
ON ft.itemnum = spl.item_num
GROUP BY fwo.asset_location, ft.itemnum, spl.description
ORDER BY fwo.asset_location, ft.itemnum;
"""
try: try:
result = await db_session.execute(text(query_str), params) result = await db_session.execute(text(query_str), params)
rows = result.fetchall() rows = result.fetchall()
equipment_parts = [] equipment_parts = []
for row in rows: for row in rows:
equipment_parts.append({'location_tag': row.location_tag, 'itemnum': row.itemnum, 'sparepart_name': row.sparepart_name, 'parts_consumed_in_oh': float(row.total_parts_used or 0), 'avg_cost': float(row.avg_cost or 0), 'inv_curbaltotal': float(row.avg_inventory_balance or 0)}) equipment_parts.append({
"location_tag": row.location_tag,
"itemnum": row.itemnum,
"sparepart_name": row.sparepart_name,
"parts_consumed_in_oh": float(row.total_parts_used or 0),
"avg_cost": float(row.avg_cost or 0),
"inv_curbaltotal": float(row.avg_inventory_balance or 0),
})
return equipment_parts return equipment_parts
except Exception as e: except Exception as e:
print(f'[get_all] Database query error: {e}') print(f"[get_all] Database query error: {e}")
raise raise

@ -1,11 +1,22 @@
from sqlalchemy import UUID, Column, ForeignKey, String from sqlalchemy import UUID, Column, Float, ForeignKey, Integer, String
from sqlalchemy.ext.hybrid import hybrid_property
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from src.database.core import Base from src.database.core import Base
from src.models import DefaultMixin from src.models import DefaultMixin, IdentityMixin, TimeStampMixin
from src.workorder.model import MasterWorkOrder
class EquipmentWorkscopeGroup(Base, DefaultMixin): class EquipmentWorkscopeGroup(Base, DefaultMixin):
__tablename__ = 'oh_tr_equipment_workscope_group' __tablename__ = "oh_tr_equipment_workscope_group"
workscope_group_id = Column(UUID(as_uuid=True), ForeignKey('oh_ms_workscope_group.id')) workscope_group_id = Column(UUID(as_uuid=True), ForeignKey('oh_ms_workscope_group.id'))
location_tag = Column(String, nullable=False) location_tag = Column(String, nullable=False)
workscope_group = relationship('MasterActivity', lazy='selectin', back_populates='equipment_workscope_groups')
equipment = relationship('StandardScope', lazy='raise', primaryjoin='and_(EquipmentWorkscopeGroup.location_tag == foreign(StandardScope.location_tag))', uselist=False) workscope_group = relationship("MasterActivity", lazy="selectin", back_populates="equipment_workscope_groups")
equipment = relationship(
"StandardScope",
lazy="raise",
primaryjoin="and_(EquipmentWorkscopeGroup.location_tag == foreign(StandardScope.location_tag))",
uselist=False, # Add this if it's a one-to-one relationship
)

@ -1,22 +1,51 @@
from typing import Optional from typing import Dict, List, Optional
from fastapi import APIRouter, Query
from src.database.service import CommonParameters, DbSession from fastapi import APIRouter, HTTPException, Query, status
from src.database.service import (CommonParameters, DbSession,
search_filter_sort_paginate)
from src.models import StandardResponse from src.models import StandardResponse
from .schema import ScopeEquipmentJobCreate, ScopeEquipmentJobPagination from .schema import ScopeEquipmentJobCreate, ScopeEquipmentJobPagination
from .service import create, delete, get_all from .service import create, delete, get_all
router = APIRouter() router = APIRouter()
@router.get('/{location_tag}', response_model=StandardResponse[ScopeEquipmentJobPagination])
@router.get(
"/{location_tag}", response_model=StandardResponse[ScopeEquipmentJobPagination]
)
async def get_jobs(common: CommonParameters, location_tag: str, scope: Optional[str] = Query(None)): async def get_jobs(common: CommonParameters, location_tag: str, scope: Optional[str] = Query(None)):
"""Get all scope pagination."""
# return
results = await get_all(common=common, location_tag=location_tag, scope=scope) results = await get_all(common=common, location_tag=location_tag, scope=scope)
return StandardResponse(data=results, message='Data retrieved successfully')
@router.post('/{assetnum}', response_model=StandardResponse[None]) return StandardResponse(
async def create_scope_equipment_jobs(db_session: DbSession, assetnum, scope_job_in: ScopeEquipmentJobCreate): data=results,
message="Data retrieved successfully",
)
@router.post("/{assetnum}", response_model=StandardResponse[None])
async def create_scope_equipment_jobs(
db_session: DbSession, assetnum, scope_job_in: ScopeEquipmentJobCreate
):
"""Get all scope activity pagination."""
# return
await create(db_session=db_session, assetnum=assetnum, scope_job_in=scope_job_in) await create(db_session=db_session, assetnum=assetnum, scope_job_in=scope_job_in)
return StandardResponse(data=None, message='Data created successfully')
@router.post('/delete/{scope_job_id}', response_model=StandardResponse[None]) return StandardResponse(
data=None,
message="Data created successfully",
)
@router.post("/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)
return StandardResponse(data=None, message='Data deleted successfully')
return StandardResponse(
data=None,
message="Data deleted successfully",
)

@ -1,32 +1,80 @@
from typing import List, Optional from datetime import datetime
from typing import Any, Dict, List, Optional
from uuid import UUID from uuid import UUID
from pydantic import Field
from pydantic import BaseModel, Field
from src.workscope_group.schema import ActivityMasterRead from src.workscope_group.schema import ActivityMasterRead
from src.models import DefultBase, Pagination from src.models import DefultBase, Pagination
from src.overhaul_scope.schema import ScopeRead from src.overhaul_scope.schema import ScopeRead
class ScopeEquipmentJobBase(DefultBase): class ScopeEquipmentJobBase(DefultBase):
pass pass
class ScopeEquipmentJobCreate(ScopeEquipmentJobBase): class ScopeEquipmentJobCreate(ScopeEquipmentJobBase):
job_ids: Optional[List[UUID]] = [] job_ids: Optional[List[UUID]] = []
class ScopeEquipmentJobUpdate(ScopeEquipmentJobBase): class ScopeEquipmentJobUpdate(ScopeEquipmentJobBase):
name: Optional[str] = Field(None) name: Optional[str] = Field(None)
cost: Optional[str] = Field(0) cost: Optional[str] = Field(0)
class OverhaulActivity(DefultBase): class OverhaulActivity(DefultBase):
id: UUID id: UUID
overhaul_scope: ScopeRead overhaul_scope: ScopeRead
class OverhaulJob(DefultBase): class OverhaulJob(DefultBase):
id: UUID id: UUID
overhaul_activity: OverhaulActivity overhaul_activity: OverhaulActivity
class ScopeEquipmentJobRead(ScopeEquipmentJobBase): class ScopeEquipmentJobRead(ScopeEquipmentJobBase):
id: UUID id: UUID
workscope_group: Optional[ActivityMasterRead] workscope_group: Optional[ActivityMasterRead]
location_tag: str location_tag: str
class ScopeEquipmentJobPagination(Pagination): class ScopeEquipmentJobPagination(Pagination):
items: List[ScopeEquipmentJobRead] = [] items: List[ScopeEquipmentJobRead] = []
# {
# "overview": {
# "totalEquipment": 30,
# "nextSchedule": {
# "date": "2025-01-12",
# "Overhaul": "B",
# "equipmentCount": 30
# }
# },
# "criticalParts": [
# "Boiler feed pump",
# "Boiler reheater system",
# "Drum Level (Right) Root Valve A",
# "BCP A Discharge Valve",
# "BFPT A EXH Press HI Root VLV"
# ],
# "schedules": [
# {
# "date": "2025-01-12",
# "Overhaul": "B",
# "status": "upcoming"
# }
# // ... other scheduled overhauls
# ],
# "systemComponents": {
# "boiler": {
# "status": "operational",
# "lastOverhaul": "2024-06-15"
# },
# "turbine": {
# "hpt": { "status": "operational" },
# "ipt": { "status": "operational" },
# "lpt": { "status": "operational" }
# }
# // ... other major components
# }
# }

@ -1,46 +1,151 @@
import random
from typing import Optional from typing import Optional
from fastapi import HTTPException, status from fastapi import HTTPException, status
from sqlalchemy import Select from sqlalchemy import Delete, Select, and_
from sqlalchemy.orm import selectinload
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 CommonParameters, search_filter_sort_paginate
from src.overhaul_activity.model import OverhaulActivity
from src.standard_scope.model import MasterEquipment from src.standard_scope.model import MasterEquipment
from src.standard_scope.service import get_equipment_level_by_no
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
from src.overhaul_scope.model import MaintenanceType from src.overhaul_scope.model import MaintenanceType
from .model import EquipmentWorkscopeGroup from .model import EquipmentWorkscopeGroup
from .schema import ScopeEquipmentJobCreate from .schema import ScopeEquipmentJobCreate
from src.soft_delete import apply_not_deleted_filter
# async def get(*, db_session: DbSession, scope_equipment_activity_id: str) -> Optional[ScopeEquipmentActivity]:
# """Returns a document based on the given document id."""
# result = await db_session.get(ScopeEquipmentActivity, scope_equipment_activity_id)
# return result
# async def get_all(db_session: DbSession, assetnum: Optional[str], common):
# # Example usage
# if not assetnum:
# raise ValueError("assetnum parameter is required")
# # First get the parent equipment
# equipment_stmt = Select(MasterEquipment).where(MasterEquipment.assetnum == assetnum)
# equipment: MasterEquipment = await db_session.scalar(equipment_stmt)
# if not equipment:
# raise ValueError(f"No equipment found with assetnum: {assetnum}")
# # Build query for parts
# stmt = (
# Select(ScopeEquipmentJob)
# .where(ScopeEquipmentJob.assetnum == assetnum)
# .options(
# selectinload(ScopeEquipmentJob.job),
# selectinload(ScopeEquipmentJob.overhaul_jobs)
# .selectinload(OverhaulJob.overhaul_activity)
# .selectinload(OverhaulActivity.overhaul_scope),
# )
# )
# results = await search_filter_sort_paginate(model=stmt, **common)
# return results
async def get_all(*, common, location_tag: str, scope: Optional[str] = None): async def get_all(*, common, location_tag: str, scope: Optional[str] = None):
query = Select(EquipmentWorkscopeGroup).where(EquipmentWorkscopeGroup.location_tag == location_tag).filter(EquipmentWorkscopeGroup.workscope_group_id.is_not(None)) """Returns all documents."""
query = apply_not_deleted_filter(query, EquipmentWorkscopeGroup) query = (
Select(EquipmentWorkscopeGroup)
.where(EquipmentWorkscopeGroup.location_tag == location_tag).filter(EquipmentWorkscopeGroup.workscope_group_id.is_not(None))
)
if scope: if scope:
query = query.join(EquipmentWorkscopeGroup.workscope_group).join(MasterActivity.oh_types).join(WorkscopeOHType.oh_type).filter(MaintenanceType.name == scope) query = (
return await search_filter_sort_paginate(model=query, **common) query
.join(EquipmentWorkscopeGroup.workscope_group)
.join(MasterActivity.oh_types)
.join(WorkscopeOHType.oh_type)
.filter(MaintenanceType.name == scope)
)
async def create(*, db_session: DbSession, assetnum, scope_job_in: ScopeEquipmentJobCreate): results = await search_filter_sort_paginate(model=query, **common)
return results
async def create(
*, db_session: DbSession, assetnum, scope_job_in: ScopeEquipmentJobCreate
):
scope_jobs = [] scope_jobs = []
if not assetnum: if not assetnum:
raise ValueError('assetnum parameter is required') raise ValueError("assetnum parameter is required")
# First get the parent equipment
equipment_stmt = Select(MasterEquipment).where(MasterEquipment.assetnum == assetnum) equipment_stmt = Select(MasterEquipment).where(MasterEquipment.assetnum == assetnum)
equipment: MasterEquipment = await db_session.scalar(equipment_stmt) equipment: MasterEquipment = await db_session.scalar(equipment_stmt)
if not equipment: if not equipment:
raise ValueError(f'No equipment found with assetnum: {assetnum}') raise ValueError(f"No equipment found with assetnum: {assetnum}")
for job_id in scope_job_in.job_ids: for job_id in scope_job_in.job_ids:
scope_equipment_job = ScopeEquipmentJob(assetnum=assetnum, job_id=job_id) scope_equipment_job = ScopeEquipmentJob(assetnum=assetnum, job_id=job_id)
scope_jobs.append(scope_equipment_job) scope_jobs.append(scope_equipment_job)
db_session.add_all(scope_jobs) db_session.add_all(scope_jobs)
await db_session.commit() await db_session.commit()
return return
async def delete(*, db_session: DbSession, scope_job_id: int) -> bool:
# 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_job_id: int,
) -> bool:
"""
Deletes a scope job and returns success status.
Args:
db_session: Database session
scope_job_id: ID of the scope job to delete
user_id: ID of user performing the deletion
Returns:
bool: True if deletion was successful, False otherwise
Raises:
NotFoundException: If scope job doesn't exist
AuthorizationError: If user lacks delete permission
"""
try: try:
# Check if job exists
scope_job = await db_session.get(ScopeEquipmentJob, scope_job_id) scope_job = await db_session.get(ScopeEquipmentJob, scope_job_id)
if not scope_job: if not scope_job:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='A data with this id does not exist.') raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="A data with this id does not exist.",
)
# Perform deletion
await db_session.delete(scope_job) await db_session.delete(scope_job)
await db_session.commit() await db_session.commit()
return True return True
except Exception:
except Exception as e:
await db_session.rollback() await db_session.rollback()
raise raise

@ -1,52 +1,24 @@
# Define base error model
import logging import logging
import re
import uuid
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
from asyncpg.exceptions import DataError as AsyncPGDataError from asyncpg.exceptions import DataError as AsyncPGDataError
from fastapi import Request from asyncpg.exceptions import PostgresError
from fastapi import FastAPI, HTTPException, Request
from fastapi.exceptions import RequestValidationError from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from pydantic import BaseModel from pydantic import BaseModel
from slowapi import _rate_limit_exceeded_handler
from slowapi.errors import RateLimitExceeded from slowapi.errors import RateLimitExceeded
from sqlalchemy.exc import DataError, DBAPIError, IntegrityError, SQLAlchemyError, NoResultFound from sqlalchemy.exc import (DataError, DBAPIError, IntegrityError,
from starlette.exceptions import HTTPException as StarletteHTTPException SQLAlchemyError)
from src.enums import ResponseStatus
log = logging.getLogger(__name__)
class OptimumOHBaseException(Exception):
def __init__(self, message: str, data: Any=None):
self.message = message
self.data = data
super().__init__(message)
class BadRequestError(OptimumOHBaseException):
pass
class UnauthorizedError(OptimumOHBaseException):
pass
class ForbiddenError(OptimumOHBaseException):
pass
class NotFoundError(OptimumOHBaseException):
pass
class RequestTimeoutError(OptimumOHBaseException):
pass
class UnprocessableEntityError(OptimumOHBaseException): from src.enums import ResponseStatus
pass from src.auth.service import notify_admin_on_rate_limit_sync
class TooManyRequestsError(OptimumOHBaseException): from starlette.exceptions import HTTPException as StarletteHTTPException
pass
class InternalServerError(OptimumOHBaseException): log = logging.getLogger(__name__)
pass
_FIXED_MESSAGES = {400: 'Malformed JSON body input', 401: 'Invalid authorization token', 403: 'Forbidden Access', 404: 'Resource not found', 408: 'Request timeout', 422: 'Invalid request parameter', 429: 'Please try again later'}
_ERROR_TYPE_MAP = {BadRequestError: 'Bad Request', UnauthorizedError: 'Unauthorized', ForbiddenError: 'Forbidden', NotFoundError: 'Not Found', RequestTimeoutError: 'Request Timeout', UnprocessableEntityError: 'Unprocessable Entity', TooManyRequestsError: 'Too Many Requests', InternalServerError: 'Internal Server Error'}
_EXCEPTION_STATUS_MAP = {BadRequestError: 400, UnauthorizedError: 401, ForbiddenError: 403, NotFoundError: 404, RequestTimeoutError: 408, UnprocessableEntityError: 422, TooManyRequestsError: 429, InternalServerError: 500}
_HTTP_STATUS_NAMES = {400: 'Bad Request', 401: 'Unauthorized', 403: 'Forbidden', 404: 'Not Found', 405: 'Method Not Allowed', 408: 'Request Timeout', 409: 'Conflict', 413: 'Payload Too Large', 414: 'URI Too Long', 415: 'Unsupported Media Type', 422: 'Unprocessable Entity', 429: 'Too Many Requests', 500: 'Internal Server Error'}
class ErrorDetail(BaseModel): class ErrorDetail(BaseModel):
field: Optional[str] = None field: Optional[str] = None
@ -54,188 +26,209 @@ class ErrorDetail(BaseModel):
code: Optional[str] = None code: Optional[str] = None
params: Optional[Dict[str, Any]] = None params: Optional[Dict[str, Any]] = None
class ErrorResponse(BaseModel): class ErrorResponse(BaseModel):
data: Optional[Any] = None data: Optional[Any] = None
message: str message: str
status: ResponseStatus = ResponseStatus.ERROR status: ResponseStatus = ResponseStatus.ERROR
errors: Optional[List[ErrorDetail]] = None errors: Optional[List[ErrorDetail]] = None
def get_request_context(request: Request) -> dict:
# Custom exception handler setup
def get_client_ip() -> str:
if 'X-Real-IP' in request.headers:
return request.headers['X-Real-IP'] def get_request_context(request: Request):
if 'X-Forwarded-For' in request.headers: """
return request.headers['X-Forwarded-For'].split(',')[0].strip() Get detailed request context for logging.
return request.client.host if request.client else 'unknown' """
return {'endpoint': request.url.path, 'url': str(request.url), 'method': request.method, 'remote_addr': get_client_ip()}
def get_client_ip():
def _json_response(status_code: int, message: str, data: Any=None) -> JSONResponse: """
return JSONResponse(status_code=status_code, content={'data': data, 'message': message, 'status': ResponseStatus.ERROR}) Get the real client IP address from Kong Gateway headers.
Kong sets X-Real-IP and X-Forwarded-For headers by default.
def _store_log_state(request: Request, status_code: int, error_id: str, error_type: str, error_message: str) -> None: """
request.state.log_status_code = status_code # Kong specific headers
request.state.log_error_id = error_id if "X-Real-IP" in request.headers:
request.state.log_error_type = error_type return request.headers["X-Real-IP"]
request.state.log_error_message = error_message
# Fallback to X-Forwarded-For
def _short_db_error(orig_str: str) -> str: if "X-Forwarded-For" in request.headers:
s = orig_str.lower() # Get the first IP (original client)
if 'uuid' in s: return request.headers["X-Forwarded-For"].split(",")[0].strip()
if 'length must be between' in s or 'got ' in s:
return 'Invalid UUID length' # Last resort
if 'badly formed' in s or 'invalid hexadecimal' in s or 'invalid uuid' in s: return request.client.host
return 'Invalid UUID format'
if 'invalid input syntax for type' in s: return {
m = re.search('invalid input syntax for type (\\w+)', s) "endpoint": request.url.path,
type_name = m.group(1) if m else 'value' "url": str(request.url),
return f'Invalid format for type {type_name}' "method": request.method,
if 'invalid input for query argument' in s: "remote_addr": get_client_ip(),
m = re.search('\\(([^)]+)\\)\\s*$', orig_str) }
if m:
return _short_db_error(m.group(1))
return 'Invalid query argument value' def handle_sqlalchemy_error(error: SQLAlchemyError):
if 'unique constraint' in s: """
return 'Unique constraint violation' Handle SQLAlchemy errors and return user-friendly error messages.
if 'foreign key constraint' in s: """
return 'Foreign key constraint violation'
if 'null value in column' in s:
m = re.search('null value in column "([^"]+)"', orig_str)
col = f' "{m.group(1)}"' if m else ''
return f'Required field{col} is missing'
if 'check constraint' in s:
return 'Check constraint violation'
if 'out of range' in s:
return 'Value out of allowed range'
if 'numeric field overflow' in s:
return 'Numeric field overflow'
first_line = orig_str.split('\n')[0].split('. ')[0].strip()
first_line = re.sub("^<class '[^']+'>:\\s*", '', first_line)
return first_line if first_line else 'Database error'
def _build_error_response(request: Request, exc: Exception, error_id: str) -> JSONResponse:
for exc_class, status_code in _EXCEPTION_STATUS_MAP.items():
if isinstance(exc, exc_class):
error_type = _ERROR_TYPE_MAP[exc_class]
client_msg = _FIXED_MESSAGES.get(status_code, exc.message)
_store_log_state(request, status_code, error_id, error_type, exc.message)
return _json_response(status_code, f'{client_msg}. Error ID: {error_id}')
if isinstance(exc, RateLimitExceeded):
try: try:
from src.auth.service import notify_admin_on_rate_limit_sync original_error = error.orig
except AttributeError:
original_error = None
if isinstance(error, IntegrityError):
if "unique constraint" in str(error).lower():
return "This record already exists.", 422
elif "foreign key constraint" in str(error).lower():
return "Related record not found.", 422
else:
return "Data integrity error.", 422
elif isinstance(error, DataError) or isinstance(original_error, AsyncPGDataError):
return "Invalid data provided.", 422
elif isinstance(error, DBAPIError):
if "unique constraint" in str(error).lower():
return "This record already exists.", 422
elif "foreign key constraint" in str(error).lower():
return "Related record not found.", 422
elif "null value in column" in str(error).lower():
return "Required data missing.", 422
elif "invalid input for query argument" in str(error).lower():
return "Invalid data provided.", 422
else:
return "Database error.", 500
else:
# Log the full error for debugging purposes
log.error(f"Unexpected database error: {str(error)}")
return "An unexpected database error occurred.", 500
def handle_exception(request: Request, exc: Exception):
"""
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)
notify_admin_on_rate_limit_sync(endpoint_name=request_info['endpoint'], ip_address=request_info['remote_addr'], method=request_info['method'], request=request)
except Exception: # Store error_id in request.state for middleware/logging
pass request.state.error_id = error_id
_store_log_state(request, 429, error_id, 'Too Many Requests', 'Please try again later')
return _json_response(429, f'Please try again later. Error ID: {error_id}') if isinstance(exc, RateLimitExceeded):
# Kirim notifikasi ke admin
notify_admin_on_rate_limit_sync(
endpoint_name=request_info["endpoint"],
ip_address=request_info["remote_addr"],
method=request_info["method"],
)
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): if isinstance(exc, RequestValidationError):
errors = exc.errors() log.warning(
detail_parts = [] f"Validation error: {exc.errors()} | Error ID: {error_id}",
for err in errors: extra={
loc = ' -> '.join((str(part) for part in err.get('loc', []))) "error_id": error_id,
msg = err.get('msg', '') "error_category": "validation",
detail_parts.append(f'{loc}: {msg}' if loc else msg) "errors": exc.errors(),
detail_str = '; '.join(detail_parts) if detail_parts else 'Input validation failed' "request": request_info,
_store_log_state(request, 422, error_id, 'Unprocessable Entity', detail_str) },
return _json_response(422, f'{_FIXED_MESSAGES[422]}. Error ID: {error_id}') )
if isinstance(exc, StarletteHTTPException): return JSONResponse(
status_code = exc.status_code status_code=422,
error_type = _HTTP_STATUS_NAMES.get(status_code, f'HTTP {status_code}') content={
if status_code == 401: "data": exc.errors(),
log_msg = str(exc.detail) if exc.detail else 'Invalid authorization token' "message": "Validation Error",
_store_log_state(request, 401, error_id, 'Unauthorized', log_msg) "status": ResponseStatus.ERROR,
return _json_response(401, f'{_FIXED_MESSAGES[401]}. Error ID: {error_id}') "error_id": error_id
if status_code == 422: },
log_msg = str(exc.detail) if exc.detail else _FIXED_MESSAGES[422] )
_store_log_state(request, 422, error_id, 'Unprocessable Entity', log_msg)
return _json_response(422, f'{_FIXED_MESSAGES[422]}. Error ID: {error_id}') if isinstance(exc, (HTTPException, StarletteHTTPException)):
if isinstance(exc.detail, dict): # Log as warning for 4xx, error for 5xx
log_msg = exc.detail.get('error', error_type) status_code = exc.status_code if hasattr(exc, "status_code") else 500
else: detail = exc.detail if hasattr(exc, "detail") else str(exc)
log_msg = str(exc.detail) if exc.detail else error_type
client_msg = _FIXED_MESSAGES.get(status_code, log_msg) log_level = logging.WARNING if 400 <= status_code < 500 else logging.ERROR
_store_log_state(request, status_code, error_id, error_type, log_msg) log.log(
return _json_response(status_code, f'{client_msg}. Error ID: {error_id}') 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(
status_code=status_code,
content={
"data": None,
"message": str(detail),
"status": ResponseStatus.ERROR,
"error_id": error_id
},
)
if isinstance(exc, SQLAlchemyError): if isinstance(exc, SQLAlchemyError):
orig = getattr(exc, 'orig', None) error_message, status_code = handle_sqlalchemy_error(exc)
orig_str = str(orig) if orig else str(exc) # Log integrity errors as warning, others as error
short = _short_db_error(orig_str) log_level = logging.WARNING if 400 <= status_code < 500 else logging.ERROR
if isinstance(exc, NoResultFound): log.log(
_store_log_state(request, 404, error_id, 'Not Found', 'Resource not found') log_level,
return _json_response(404, f'Resource not found. Error ID: {error_id}') f"Database error: {error_message} | Error ID: {error_id}",
if isinstance(exc, IntegrityError): extra={
orig_lower = orig_str.lower() "error_id": error_id,
if 'unique constraint' in orig_lower: "error_category": "database",
_store_log_state(request, 409, error_id, 'Conflict', f'Unique constraint violation: {short}') "violation": str(exc).split('\n')[0],
return _json_response(409, f'This record already exists. Error ID: {error_id}') "request": request_info,
if 'foreign key constraint' in orig_lower: },
_store_log_state(request, 422, error_id, 'Unprocessable Entity', f'Foreign key constraint violation: {short}') )
return _json_response(422, f'{_FIXED_MESSAGES[422]}. Error ID: {error_id}')
_store_log_state(request, 422, error_id, 'Unprocessable Entity', f'Database integrity error: {short}') return JSONResponse(
return _json_response(422, f'{_FIXED_MESSAGES[422]}. Error ID: {error_id}') status_code=status_code,
if isinstance(exc, DataError): content={
_store_log_state(request, 422, error_id, 'Unprocessable Entity', f'Invalid data format: {short}') "data": None,
return _json_response(422, f'{_FIXED_MESSAGES[422]}. Error ID: {error_id}') "message": error_message,
if isinstance(exc, DBAPIError): "status": ResponseStatus.ERROR,
orig_lower = orig_str.lower() "error_id": error_id
if 'invalid input syntax' in orig_lower or 'invalid uuid' in orig_lower or 'invalid input for query argument' in orig_lower: },
_store_log_state(request, 422, error_id, 'Unprocessable Entity', f'Invalid input format: {short}') )
return _json_response(422, f'{_FIXED_MESSAGES[422]}. Error ID: {error_id}')
if 'unique constraint' in orig_lower: # Log unexpected errors
_store_log_state(request, 409, error_id, 'Conflict', f'Unique constraint violation: {short}') log.error(
return _json_response(409, f'This record already exists. Error ID: {error_id}') f"Unexpected error: {str(exc)} | Error ID: {error_id}",
if 'foreign key constraint' in orig_lower: extra={
_store_log_state(request, 422, error_id, 'Unprocessable Entity', f'Foreign key constraint violation: {short}') "error_id": error_id,
return _json_response(422, f'{_FIXED_MESSAGES[422]}. Error ID: {error_id}') "error_category": "unexpected",
_store_log_state(request, 500, error_id, 'Internal Server Error', f'Database query error: {short}') "request": request_info,
return _json_response(500, f'An unexpected error occurred. Error ID: {error_id}') },
_store_log_state(request, 500, error_id, 'Internal Server Error', f'Database error: {short}') )
return _json_response(500, f'An unexpected error occurred. Error ID: {error_id}')
try: return JSONResponse(
import httpx as _httpx status_code=500,
if isinstance(exc, _httpx.HTTPStatusError): content={
upstream_status = exc.response.status_code "data": None,
error_type = _HTTP_STATUS_NAMES.get(upstream_status, f'HTTP {upstream_status}') "message": "An unexpected error occurred",
client_msg = _FIXED_MESSAGES.get(upstream_status, 'Upstream service error') "status": ResponseStatus.ERROR,
log_msg = f'Upstream service returned {upstream_status}: {exc.request.url}' "error_id": error_id
_store_log_state(request, upstream_status, error_id, error_type, log_msg) },
return _json_response(upstream_status, f'{client_msg}. Error ID: {error_id}') )
if isinstance(exc, (_httpx.ConnectError, _httpx.TimeoutException, _httpx.RequestError)):
log_msg = f'Upstream service unreachable: {type(exc).__name__}: {exc}'
_store_log_state(request, 502, error_id, 'Bad Gateway', log_msg)
return _json_response(502, f'Upstream service unavailable. Error ID: {error_id}')
except ImportError:
pass
detail = type(exc).__name__
_store_log_state(request, 500, error_id, 'Internal Server Error', detail)
return _json_response(500, f'An unexpected error occurred. Error ID: {error_id}')
async def handle_exception(request: Request, exc: Exception) -> JSONResponse:
error_id = str(uuid.uuid4())
if not hasattr(request.state, 'request_id'):
request.state.request_id = error_id
return _build_error_response(request, exc, error_id)
def handle_sqlalchemy_error(error: SQLAlchemyError):
original_error = error.orig if hasattr(error, 'orig') else None
if isinstance(error, IntegrityError):
if 'unique constraint' in str(error).lower():
return ('This record already exists.', 409)
if 'foreign key constraint' in str(error).lower():
return ('Related record not found.', 400)
return ('Data integrity error.', 400)
if isinstance(error, DataError) or isinstance(original_error, AsyncPGDataError):
return ('Invalid data provided.', 400)
if isinstance(error, DBAPIError):
if 'unique constraint' in str(error).lower():
return ('This record already exists.', 409)
if 'foreign key constraint' in str(error).lower():
return ('Related record not found.', 400)
if 'null value in column' in str(error).lower():
return ('Required data missing.', 400)
if 'invalid input for query argument' in str(error).lower():
return ('Invalid data provided.', 400)
return ('Database error.', 500)
log.error(f'Unexpected database error: {str(error)}')
return ('An unexpected database error occurred.', 500)

@ -0,0 +1,133 @@
import logging
import json
import datetime
import os
import sys
from typing import Optional
from src.config import LOG_LEVEL
from src.enums import OptimumOHEnum
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):
info = "INFO"
warn = "WARN"
error = "ERROR"
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():
log_level = str(LOG_LEVEL).upper() # cast to string
log_levels = list(LogLevels)
if log_level not in log_levels:
log_level = LogLevels.error
# Get the root logger
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
handler = logging.StreamHandler(sys.stdout)
# Use JSONFormatter for all environments, or could be conditional
# 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:
# if log_level == LogLevels.debug:
# 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
logging.getLogger("uvicorn.access").setLevel(logging.WARNING)
# sometimes the slack client can be too verbose
logging.getLogger("slack_sdk.web.base_client").setLevel(logging.CRITICAL)

@ -1,153 +1,247 @@
import logging import logging
import asyncio
import time import time
from src.context import set_request_id, reset_request_id, get_request_id from src.context import set_request_id, reset_request_id, get_request_id
from contextvars import ContextVar
from os import path
from typing import Final, Optional
from uuid import uuid1 from uuid import uuid1
from fastapi import Depends, FastAPI, HTTPException
from src.auth.service import JWTBearer from fastapi import FastAPI, HTTPException, status
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from pydantic import ValidationError
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.ext.asyncio import async_scoped_session from sqlalchemy.ext.asyncio import async_scoped_session
from starlette.middleware.base import BaseHTTPMiddleware from sqlalchemy.orm import scoped_session
from starlette.middleware.base import (BaseHTTPMiddleware,
RequestResponseEndpoint)
from starlette.middleware.gzip import GZipMiddleware from starlette.middleware.gzip import GZipMiddleware
from starlette.requests import Request from starlette.requests import Request
from starlette.responses import Response from starlette.responses import FileResponse, Response, StreamingResponse
import uuid as _uuid_module from starlette.routing import compile_path
from starlette.staticfiles import StaticFiles
from src.api import api_router from src.api import api_router
from src.database.core import async_session, async_collector_session 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.app_logging import configure_logging from src.logging import configure_logging
from src.middleware import RequestValidationMiddleware, RequestTimeoutMiddleware from src.middleware import RequestValidationMiddleware
from src.rate_limiter import limiter
from fastapi.exceptions import RequestValidationError from fastapi.exceptions import RequestValidationError
from starlette.exceptions import HTTPException as StarletteHTTPException from starlette.exceptions import HTTPException as StarletteHTTPException
from src.csrf_protect import csrf_protect
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
# we configure the logging level and format
configure_logging() configure_logging()
REQUEST_TIMEOUT_SECONDS = 20
app = FastAPI(openapi_url='', title='Optimum OH API', description="Welcome to Optimum OH's API documentation!", version='0.1.0') from src.config import DEBUG
def _sync_rate_limit_handler(request: Request, exc: RateLimitExceeded) -> JSONResponse: # we create the ASGI for the app
error_id = str(_uuid_module.uuid4()) app = FastAPI(
request.state.log_status_code = 429 openapi_url="",
request.state.log_error_id = error_id title="LCCA API",
request.state.log_error_type = 'Too Many Requests' description="Welcome to LCCA's API documentation!",
request.state.log_error_message = 'Please try again later' version="0.1.0",
return JSONResponse(status_code=429, content={'data': None, 'message': f'Please try again later. Error ID: {error_id}', 'status': ResponseStatus.ERROR}) debug=bool(DEBUG),
)
# we define the exception handlers
app.add_exception_handler(Exception, handle_exception) app.add_exception_handler(Exception, handle_exception)
app.add_exception_handler(HTTPException, handle_exception) app.add_exception_handler(HTTPException, handle_exception)
app.add_exception_handler(StarletteHTTPException, handle_exception) app.add_exception_handler(StarletteHTTPException, handle_exception)
app.add_exception_handler(RequestValidationError, handle_exception) app.add_exception_handler(RequestValidationError, handle_exception)
app.add_exception_handler(RateLimitExceeded, _sync_rate_limit_handler) app.add_exception_handler(RateLimitExceeded, handle_exception)
app.state.limiter = limiter
app.add_middleware(GZipMiddleware, minimum_size=1000) app.add_middleware(GZipMiddleware, minimum_size=1000)
app.add_middleware(RequestTimeoutMiddleware, timeout=REQUEST_TIMEOUT_SECONDS) app.add_middleware(SlowAPIMiddleware)
# credentials: "include",
@app.route('/slow', methods=['GET'])
async def slow_endpoint(request: Request):
await asyncio.sleep(REQUEST_TIMEOUT_SECONDS + 10)
return JSONResponse(content={'message': 'This should timeout'}, status_code=200)
@app.post('/testcsrf', dependencies=[Depends(JWTBearer()), Depends(csrf_protect)])
async def test_csrf(request: Request):
return JSONResponse(content={'data': None, 'message': 'CSRF token is valid', 'status': ResponseStatus.SUCCESS}, status_code=200)
def security_headers_middleware(app: FastAPI): def security_headers_middleware(app: FastAPI):
is_production = False is_production = False
csp_policy = {'default-src': "'self'", 'script-src': "'self' 'unsafe-inline' https://cdnjs.cloudflare.com https://cdn.jsdelivr.net", 'style-src': "'self' 'unsafe-inline' https://fonts.googleapis.com https://cdn.jsdelivr.net", 'img-src': "'self' data: https: blob:", 'font-src': "'self' https://fonts.gstatic.com data:", 'connect-src': "'self' https://api.your-domain.com wss://ws.your-domain.com", 'frame-src': "'none'", 'object-src': "'none'", 'base-uri': "'self'", 'form-action': "'self'"}
feature_policy = {'geolocation': "'none'", 'midi': "'none'", 'notifications': "'none'", 'push': "'none'", 'sync-xhr': "'none'", 'microphone': "'none'", 'camera': "'none'", 'magnetometer': "'none'", 'gyroscope': "'none'", 'speaker': "'none'", 'vibrate': "'none'", 'fullscreen': "'self'", 'payment': "'none'"}
csp_header_value = '; '.join((f'{k} {v}' for k, v in csp_policy.items()))
feature_header_value = '; '.join((f'{k}={v}' for k, v in feature_policy.items()))
class SecurityHeadersMiddleware(BaseHTTPMiddleware): # CSP rules
csp_policy = {
"default-src": "'self'",
"script-src": "'self' 'unsafe-inline' https://cdnjs.cloudflare.com https://cdn.jsdelivr.net",
"style-src": "'self' 'unsafe-inline' https://fonts.googleapis.com https://cdn.jsdelivr.net",
"img-src": "'self' data: https: blob:",
"font-src": "'self' https://fonts.gstatic.com data:",
"connect-src": "'self' https://api.your-domain.com wss://ws.your-domain.com",
"frame-src": "'none'",
"object-src": "'none'",
"base-uri": "'self'",
"form-action": "'self'",
}
# Feature / Permissions Policy
feature_policy = {
"geolocation": "'none'",
"midi": "'none'",
"notifications": "'none'",
"push": "'none'",
"sync-xhr": "'none'",
"microphone": "'none'",
"camera": "'none'",
"magnetometer": "'none'",
"gyroscope": "'none'",
"speaker": "'none'",
"vibrate": "'none'",
"fullscreen": "'self'",
"payment": "'none'",
}
csp_header_value = "; ".join(f"{k} {v}" for k, v in csp_policy.items())
feature_header_value = "; ".join(f"{k}={v}" for k, v in feature_policy.items())
# Middleware definition
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next): async def dispatch(self, request: Request, call_next):
response: Response = await call_next(request) response: Response = await call_next(request)
if is_production: if is_production:
response.headers['Strict-Transport-Security'] = 'max-age=15724800; includeSubDomains; preload' response.headers["Strict-Transport-Security"] = "max-age=15724800; includeSubDomains; preload"
response.headers['X-Frame-Options'] = 'DENY' response.headers["X-Frame-Options"] = "DENY"
response.headers['X-Content-Type-Options'] = 'nosniff' response.headers["X-Content-Type-Options"] = "nosniff"
response.headers['X-XSS-Protection'] = '1; mode=block' response.headers["X-XSS-Protection"] = "1; mode=block"
response.headers['Referrer-Policy'] = 'strict-origin-when-cross-origin' response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
response.headers['Content-Security-Policy'] = csp_header_value response.headers["Content-Security-Policy"] = csp_header_value
response.headers['Permissions-Policy'] = feature_header_value response.headers["Permissions-Policy"] = feature_header_value
else: else:
response.headers['Content-Security-Policy'] = "default-src 'self' 'unsafe-inline' 'unsafe-eval' *" # Relaxed settings for development
response.headers["Content-Security-Policy"] = "default-src 'self' 'unsafe-inline' 'unsafe-eval' *"
# You can skip some headers here for local testing
return response return response
app.add_middleware(SecurityHeadersMiddleware) app.add_middleware(SecurityHeadersMiddleware)
security_headers_middleware(app) security_headers_middleware(app)
app.add_middleware(RequestValidationMiddleware) 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.
# see: https://github.com/tiangolo/fastapi/issues/726
ctx_token = set_request_id(request_id) ctx_token = set_request_id(request_id)
request.state.request_id = request_id
try: try:
session = async_scoped_session(async_session, scopefunc=get_request_id) session = async_scoped_session(async_session, scopefunc=get_request_id)
request.state.db = session() request.state.db = session()
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() start_time = time.time()
try:
response = await call_next(request) response = await call_next(request)
except Exception as exc: process_time = (time.time() - start_time) * 1000
response = await handle_exception(request, exc)
duration_ms = round((time.time() - start_time) * 1000, 2) # Skip logging in middleware if it's an error (already logged in handle_exception)
timed_out = request.scope.get('x_timed_out', False) if response.status_code >= 400:
if timed_out: return response
status_code = 408
error_id = request.scope.get('x_timeout_error_id', None) from src.context import get_username, get_role, get_user_id, set_user_id, set_username, set_role
error_type = 'Request Timeout'
error_message = 'Request timeout over 20 seconds' # Pull from context or fallback to request.state.user
else: username = get_username()
status_code = getattr(request.state, 'log_status_code', None) or response.status_code role = get_role()
error_id = getattr(request.state, 'log_error_id', None)
error_type = getattr(request.state, 'log_error_type', None)
error_message = getattr(request.state, 'log_error_message', None)
_FALLBACK_ERROR_TYPES = {400: 'Bad Request', 401: 'Unauthorized', 403: 'Forbidden', 404: 'Not Found', 408: 'Request Timeout', 409: 'Conflict', 422: 'Unprocessable Entity', 429: 'Too Many Requests', 500: 'Internal Server Error'}
_FALLBACK_ERROR_MESSAGES = {400: 'Bad request', 401: 'Invalid authorization token', 403: 'Forbidden Access', 404: 'Resource not found', 408: 'Request timeout', 409: 'This record already exists', 422: 'Input validation failed', 429: 'Please try again later', 500: 'An unexpected error occurred'}
if status_code >= 400 and (not error_type):
error_type = _FALLBACK_ERROR_TYPES.get(status_code, f'HTTP {status_code}')
if status_code >= 400 and (not error_message):
error_message = _FALLBACK_ERROR_MESSAGES.get(status_code, 'Request failed')
from src.context import get_user_id, set_user_id
user_id = get_user_id() user_id = get_user_id()
user_obj = getattr(request.state, 'user', None)
if user_obj and (not 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): if isinstance(user_obj, dict):
u_id = user_obj.get('user_id') 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: else:
u_id = getattr(user_obj, 'user_id', None) u_id = getattr(user_obj, "user_id", None)
if u_id: 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) user_id = str(u_id)
set_user_id(user_id) set_user_id(user_id)
headers = request.headers if not username and u_name:
if 'X-Real-IP' in headers: username = u_name
user_ip = headers['X-Real-IP'] set_username(username)
elif 'X-Forwarded-For' in headers: if not role and u_role:
user_ip = headers['X-Forwarded-For'].split(',')[0].strip() role = u_role
else: set_role(role)
user_ip = request.client.host if request.client else None
path = request.url.path user_info_str = ""
if request.url.query: if user_id:
path = f'{path}?{request.url.query}' user_info_str = f" | User ID: {user_id}"
result = 'Request completed' if status_code < 400 else 'Request failed'
if status_code >= 500: error_id = getattr(request.state, "error_id", None)
log_level = logging.ERROR log_msg = f"HTTP {request.method} {request.url.path} completed in {round(process_time, 2)}ms{user_info_str}"
elif status_code >= 400: if error_id:
log_level = logging.WARNING log_msg += f" | Error ID: {error_id}"
else:
log_level = logging.INFO log.info(
log.log(log_level, f'{request.method} {path} {status_code}', extra={'method': request.method, 'path': path, 'status_code': status_code, 'duration_ms': duration_ms, 'request_id': request_id, 'user_id': user_id, 'user_ip': user_ip, 'error_id': error_id, 'result': result, 'error_type': error_type if status_code >= 400 else None, 'error_message': error_message if status_code >= 400 else None}) 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) reset_request_id(ctx_token)
return response return response
@app.middleware('http')
@app.middleware("http")
async def add_security_headers(request: Request, call_next): async def add_security_headers(request: Request, call_next):
response = await call_next(request) response = await call_next(request)
response.headers['Strict-Transport-Security'] = 'max-age=31536000 ; includeSubDomains' response.headers["Strict-Transport-Security"] = (
"max-age=31536000 ; includeSubDomains"
)
return response return response
# class MetricsMiddleware(BaseHTTPMiddleware):
# async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
# method = request.method
# endpoint = request.url.path
# tags = {"method": method, "endpoint": endpoint}
# try:
# start = time.perf_counter()
# response = await call_next(request)
# elapsed_time = time.perf_counter() - start
# tags.update({"status_code": response.status_code})
# metric_provider.counter("server.call.counter", tags=tags)
# metric_provider.timer("server.call.elapsed", value=elapsed_time, tags=tags)
# log.debug(f"server.call.elapsed.{endpoint}: {elapsed_time}")
# except Exception as e:
# metric_provider.counter("server.call.exception.counter", tags=tags)
# raise e from None
# return response
# app.add_middleware(ExceptionMiddleware)
app.include_router(api_router) app.include_router(api_router)

@ -1,8 +1,10 @@
from sqlalchemy import Column, BigInteger, Integer, Float, String, Text, DateTime from sqlalchemy import Column, BigInteger, Integer, Float, String, Text, DateTime
from src.database.core import CollectorBase from src.database.core import CollectorBase
class WorkOrderData(CollectorBase): class WorkOrderData(CollectorBase):
__tablename__ = 'wo_staging_3' __tablename__ = "wo_staging_3"
id = Column(BigInteger, primary_key=True, autoincrement=True) id = Column(BigInteger, primary_key=True, autoincrement=True)
assetnum = Column(Text, nullable=True) assetnum = Column(Text, nullable=True)
description1 = Column(Text, nullable=True) description1 = Column(Text, nullable=True)

@ -1,37 +1,332 @@
from datetime import datetime from datetime import datetime
from typing import Optional, Union from typing import Optional, Union
from sqlalchemy import select, text from sqlalchemy import select, func, cast, Numeric, text
from sqlalchemy.orm import Session
from sqlalchemy import and_
from sqlalchemy.sql import not_
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, DbSession
from src.overhaul_scope.model import OverhaulScope 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 (\n SELECT \n mu.wonum,\n SUM(mu.itemqty * COALESCE(inv.avgcost, po.unit_cost, 0)) AS parts_total_cost\n FROM maximo_workorder_materials mu\n LEFT JOIN maximo_inventory inv\n ON mu.itemnum = inv.itemnum\n LEFT JOIN (\n SELECT item_num, AVG(unit_cost) AS unit_cost\n FROM maximo_sparepart_pr_po_line\n GROUP BY item_num\n ) po\n ON mu.itemnum = po.item_num\n GROUP BY mu.wonum\n),\nwo_costs AS (\n SELECT \n w.wonum,\n w.asset_location,\n (COALESCE(w.mat_cost_max, 0) + COALESCE(pc.parts_total_cost, 0)) AS total_wo_cost\n FROM wo_staging_maximo_2 w\n LEFT JOIN part_costs pc\n ON w.wonum = pc.wonum\n WHERE \n w.worktype IN ('CM', 'EM', 'PROACTIVE')\n AND w.asset_system IN (\n 'HPB','AH','APC','SCR','CL','DM','CRH','ASH','BAD','DS','WTP',\n 'MT','SUP','DCS','FF','EG','AI','SPS','EVM','SCW','KLH','CH',\n 'TUR','LOT','HRH','ESP','CAE','GMC','BFT','LSH','CHB','BSS',\n 'LOS','LPB','SAC','CP','EHS','RO','GG','MS','CW','SO','ATT',\n 'AFG','EHB','RP','FO','PC','APE','AF','DMW','BRS','GEN','ABS',\n 'CHA','TR','H2','BDW','LOM','ACR','AL','FW','COND','CCCW','IA',\n 'GSS','BOL','SSB','CO','OA','CTH-UPD','AS','DP'\n )\n AND w.reportdate IS NOT NULL\n AND w.actstart IS NOT NULL\n AND w.actfinish IS NOT NULL\n AND w.asset_unit IN ('3','00')\n AND w.reportdate >= '2015-01-01'\n AND w.wonum NOT LIKE 'T%'\n),\n-- find max cost per location\nlocation_max AS (\n SELECT asset_location, MAX(total_wo_cost) AS max_cost\n FROM wo_costs\n WHERE total_wo_cost > 0\n GROUP BY asset_location\n),\n-- filter WO costs to only reasonable range (e.g. >0 and >=10% of max)\nfiltered_wo AS (\n SELECT w.*\n FROM wo_costs w\n JOIN location_max lm ON w.asset_location = lm.asset_location\n WHERE w.total_wo_cost > 0\n)\nSELECT \n asset_location,\n SUM(total_wo_cost)::numeric / COUNT(wonum) AS avg_cost\nFROM filtered_wo\nGROUP BY asset_location\nORDER BY avg_cost DESC;\n ") query = text("""WITH part_costs AS (
SELECT
mu.wonum,
SUM(mu.itemqty * COALESCE(inv.avgcost, po.unit_cost, 0)) AS parts_total_cost
FROM maximo_workorder_materials mu
LEFT JOIN maximo_inventory inv
ON mu.itemnum = inv.itemnum
LEFT JOIN (
SELECT item_num, AVG(unit_cost) AS unit_cost
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',
'TUR','LOT','HRH','ESP','CAE','GMC','BFT','LSH','CHB','BSS',
'LOS','LPB','SAC','CP','EHS','RO','GG','MS','CW','SO','ATT',
'AFG','EHB','RP','FO','PC','APE','AF','DMW','BRS','GEN','ABS',
'CHA','TR','H2','BDW','LOM','ACR','AL','FW','COND','CCCW','IA',
'GSS','BOL','SSB','CO','OA','CTH-UPD','AS','DP'
)
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.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
asset_location,
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) results = await collector_db.execute(query)
data = [] data = []
for row in results: for row in results:
data.append({'location_tag': row.asset_location, 'avg_cost': row.avg_cost}) data.append({
return {item['location_tag']: item['avg_cost'] for item in data} "location_tag": row.asset_location,
"avg_cost": row.avg_cost
})
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):
# 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("\n WITH part_costs AS (\n SELECT \n wm.wonum,\n SUM(wm.itemqty * COALESCE(inv.avgcost, po.unit_cost, 0)) AS parts_total_cost\n FROM public.maximo_workorder_materials wm\n JOIN public.maximo_inventory AS inv on inv.itemnum = wm.itemnum\n LEFT JOIN (\n SELECT item_num, AVG(unit_cost) AS unit_cost\n FROM public.maximo_sparepart_pr_po_line\n GROUP BY item_num\n ) po ON wm.itemnum = po.item_num\n WHERE wm.itemnum IS NOT NULL\n GROUP BY wm.wonum\n ),\n wo_costs AS (\n SELECT \n w.wonum,\n w.asset_location,\n -- Use mat_cost_max if parts_total_cost = 0\n CASE \n WHEN COALESCE(pc.parts_total_cost, 0) = 0 THEN COALESCE(w.mat_cost_max , 0)\n ELSE COALESCE(pc.parts_total_cost, 0)\n END AS total_wo_cost\n FROM wo_staging_maximo_2 w\n LEFT JOIN part_costs pc\n ON w.wonum = pc.wonum\n WHERE \n w.worktype = 'OH'\n AND w.reportdate IS NOT NULL\n AND w.actstart IS NOT NULL\n AND w.actfinish IS NOT NULL\n AND w.asset_unit IN ('3', '00')\n AND w.wonum NOT LIKE 'T%'\n )\n SELECT \n asset_location,\n AVG(total_wo_cost) AS avg_cost\n FROM wo_costs\n GROUP BY asset_location\n ORDER BY COUNT(wonum) DESC;\n ") # query = text("""
# WITH part_costs AS (
# 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,
# -- 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;
# """)
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 = []
for row in result: for row in result:
data.append({'location_tag': row.asset_location, 'avg_cost': row.avg_cost}) data.append({
return {item['location_tag']: item['avg_cost'] for item in data} "location_tag": row.asset_location,
"avg_cost": row.avg_cost
})
return {
item["location_tag"]: item["avg_cost"] for item in data
}
from uuid import UUID 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): 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: if not parent_wo_num:
query = select(OverhaulScope.wo_parent).where(OverhaulScope.id == oh_session_id) query = select(OverhaulScope.wo_parent).where(OverhaulScope.id == oh_session_id)
result = await db_session.execute(query) result = await db_session.execute(query)
parent_wo_num = result.scalar() parent_wo_num = result.scalar()
if not parent_wo_num: if not parent_wo_num:
return [] return []
# Ensure parent_wo_num is a list and removed duplicates if any
if isinstance(parent_wo_num, str): if isinstance(parent_wo_num, str):
parent_wo_num = [parent_wo_num] parent_wo_num = [parent_wo_num]
else: else:
parent_wo_num = list(set(parent_wo_num)) parent_wo_num = list(set(parent_wo_num))
sql_query = text("\n WITH target_wos AS (\n SELECT \n w.wonum,\n w.assetnum,\n COALESCE(w.actmatcost, 0) as actmatcost,\n COALESCE(w.actservcost, 0) as actservcost\n FROM public.wo_maximo w\n WHERE w.xx_parent = ANY(:parent_wo_num)\n ),\n wo_tasks AS (\n SELECT \n t.xx_parent AS parent_wonum,\n JSON_AGG(t.description) AS task_list\n FROM public.wo_maximo t\n JOIN target_wos tw ON t.xx_parent = tw.wonum\n GROUP BY t.xx_parent\n )\n SELECT \n w.assetnum,\n e.name AS equipment_name,\n e.location_tag,\n JSON_OBJECT_AGG(w.wonum, COALESCE(wt.task_list, '[]'::json)) AS wonum_list,\n COUNT(w.wonum) AS total_wo_count,\n COALESCE(SUM(w.actmatcost), 0) AS total_material_cost,\n COALESCE(SUM(w.actservcost), 0) AS total_service_cost,\n COALESCE(SUM(w.actmatcost + w.actservcost), 0) AS total_actual_cost\n FROM target_wos w\n INNER JOIN public.ms_equipment_master e \n ON w.assetnum = e.assetnum\n LEFT JOIN wo_tasks wt \n ON w.wonum = wt.parent_wonum\n GROUP BY \n w.assetnum, \n e.name, \n e.location_tag\n ORDER BY total_actual_cost DESC;\n ")
results = await collector_db_session.execute(sql_query, {'parent_wo_num': parent_wo_num}) sql_query = text("""
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] 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 +1,46 @@
# import logging
# from dispatch.plugins.base import plugins
# from .config import METRIC_PROVIDERS
# log = logging.getLogger(__file__)
# class Metrics(object):
# _providers = []
# def __init__(self):
# if not METRIC_PROVIDERS:
# log.info(
# "No metric providers defined via METRIC_PROVIDERS env var. Metrics will not be sent."
# )
# else:
# self._providers = METRIC_PROVIDERS
# def gauge(self, name, value, tags=None):
# for provider in self._providers:
# log.debug(
# f"Sending gauge metric {name} to provider {provider}. Value: {value} Tags: {tags}"
# )
# p = plugins.get(provider)
# p.gauge(name, value, tags=tags)
# def counter(self, name, value=None, tags=None):
# for provider in self._providers:
# log.debug(
# f"Sending counter metric {name} to provider {provider}. Value: {value} Tags: {tags}"
# )
# p = plugins.get(provider)
# p.counter(name, value=value, tags=tags)
# def timer(self, name, value, tags=None):
# for provider in self._providers:
# log.debug(
# f"Sending timer metric {name} to provider {provider}. Value: {value} Tags: {tags}"
# )
# p = plugins.get(provider)
# p.timer(name, value, tags=tags)
# provider = Metrics()

@ -4,142 +4,423 @@ import logging
from collections import Counter from collections import Counter
from fastapi import Request, HTTPException from fastapi import Request, HTTPException
from starlette.middleware.base import BaseHTTPMiddleware from starlette.middleware.base import BaseHTTPMiddleware
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'} # =========================
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'} # 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_PARAMS = 50
MAX_QUERY_LENGTH = 2000 MAX_QUERY_LENGTH = 2000
MAX_JSON_BODY_SIZE = 1024 * 500 MAX_JSON_BODY_SIZE = 1024 * 500 # 500 KB
XSS_PATTERN = re.compile('(<(script|iframe|embed|object|svg|img|video|audio|base|link|meta|form|button|details|animate)\\b|javascript\\s*:|vbscript\\s*:|data\\s*:[^,]*base64[^,]*|data\\s*:text/html|\\bon[a-z]+\\s*=|style\\s*=.*expression\\s*\\(|\\b(eval|setTimeout|setInterval|Function)\\s*\\()', re.IGNORECASE)
SQLI_PATTERN = re.compile('(\\b(UNION|SELECT|INSERT|UPDATE|DELETE|DROP|ALTER|CREATE|TRUNCATE|EXEC(UTE)?|DECLARE)\\b\\s+[\\w\\*\\(\\\']|\\b(WAITFOR\\b\\s+DELAY|PG_SLEEP|SLEEP\\s*\\()|\\b(INFORMATION_SCHEMA|SYS\\.|SYSOBJECTS|XP_CMDSHELL|LOAD_FILE|INTO\\s+OUTFILE)\\b|\\b(OR|AND)\\b\\s+[\'\\"]?\\d+[\'\\"]?\\s*=\\s*[\'\\"]?\\d+|(?<!\\S)--|(?<!\\*)/\\*|(?<!\\*)\\*/(?!\\*)|(?<!\\S)#|;\\s*\\b(SELECT|DROP|DELETE|UPDATE|INSERT)\\b)', re.IGNORECASE) XSS_PATTERN = re.compile(
RCE_PATTERN = re.compile('(\\$\\(.*\\)|`.*`|[;&|]\\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)|\\b(/etc/passwd|/etc/shadow|/etc/group|/etc/issue|/proc/self/|/windows/system32/|C:\\\\Windows\\\\)\\b)', re.IGNORECASE) r"("
TRAVERSAL_PATTERN = re.compile('(\\.\\.[/\\\\]|%2e%2e%2f|%2e%2e/|\\.\\.%2f|%2e%2e%5c|%252e%252e%252f|\\\\00)', re.IGNORECASE) r"<(script|iframe|embed|object|svg|img|video|audio|base|link|meta|form|button|details|animate)\b|"
FORBIDDEN_JSON_KEYS = {'__proto__', 'constructor', 'prototype'} r"javascript\s*:|vbscript\s*:|data\s*:[^,]*base64[^,]*|data\s*:text/html|"
DYNAMIC_KEYS = {'data', 'results', 'analysis_metadata', 'asset_contributions', 'equipment_results', 'optimal_analysis', 'optimum_analysis', 'schedules', 'tasks', 'all_params', 'parameters', 'program_data'} r"\bon[a-z]+\s*=|" # Catch-all for any 'on' event (onerror, onclick, etc.)
log = logging.getLogger('security_logger') 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: def has_control_chars(value: str) -> bool:
return any((ord(c) < 32 and c not in ('\n', '\r', '\t') for c in value)) return any(ord(c) < 32 and c not in ("\n", "\r", "\t") for c in value)
def inspect_value(value: str, source: str): def inspect_value(value: str, source: str):
if not isinstance(value, str) or value == '*/*': if not isinstance(value, str) or value == "*/*":
return return
if XSS_PATTERN.search(value): if XSS_PATTERN.search(value):
raise HTTPException(status_code=422, detail=f'XSS payload detected in {source}') 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): if SQLI_PATTERN.search(value):
raise HTTPException(status_code=422, detail=f'SQL injection payload detected in {source}') 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): if RCE_PATTERN.search(value):
raise HTTPException(status_code=422, detail=f'Remote code execution payload detected in {source}') 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): if TRAVERSAL_PATTERN.search(value):
raise HTTPException(status_code=422, detail=f'Path traversal payload detected in {source}') 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): if has_control_chars(value):
raise HTTPException(status_code=422, detail=f'Invalid control characters detected in {source}') 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):
def inspect_json(obj, path="body", check_whitelist=True):
if isinstance(obj, dict): if isinstance(obj, dict):
for key, value in obj.items(): for key, value in obj.items():
if key in FORBIDDEN_JSON_KEYS: if key in FORBIDDEN_JSON_KEYS:
raise HTTPException(status_code=422, detail=f'Forbidden JSON key detected: {path}.{key}') log.warning(f"Security violation: Forbidden JSON key detected: {path}.{key}")
if check_whitelist and key not in ALLOWED_DATA_PARAMS: raise HTTPException(
raise HTTPException(status_code=422, detail=f'Unknown JSON key detected: {path}.{key}') status_code=422,
should_check_subkeys = check_whitelist and key not in DYNAMIC_KEYS detail="Invalid request parameters",
inspect_json(value, f'{path}.{key}', check_whitelist=should_check_subkeys) )
# 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): elif isinstance(obj, list):
for i, item in enumerate(obj): for i, item in enumerate(obj):
inspect_json(item, f'{path}[{i}]', check_whitelist=check_whitelist) inspect_json(item, f"{path}[{i}]", check_whitelist=check_whitelist)
elif isinstance(obj, str): elif isinstance(obj, str):
inspect_value(obj, path) inspect_value(obj, path)
class RequestValidationMiddleware(BaseHTTPMiddleware):
# =========================
# Middleware
# =========================
class RequestValidationMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next): async def dispatch(self, request: Request, call_next):
try: # -------------------------
return await self._validate_and_forward(request, call_next) # 0. Header validation
except HTTPException as exc: # -------------------------
from src.exceptions import handle_exception header_keys = [key.lower() for key, _ in request.headers.items()]
return await handle_exception(request, exc)
# 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",
)
async def _validate_and_forward(self, request: Request, call_next): # 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: if len(request.url.query) > MAX_QUERY_LENGTH:
raise HTTPException(status_code=422, detail='Query string too long') log.warning(f"Security violation: Query string too long")
raise HTTPException(
status_code=422,
detail="Invalid request parameters",
)
params = request.query_params.multi_items() params = request.query_params.multi_items()
if len(params) > MAX_QUERY_PARAMS: if len(params) > MAX_QUERY_PARAMS:
raise HTTPException(status_code=422, detail='Too many query parameters') log.warning(f"Security violation: Too many query parameters")
unknown_params = [key for key, _ in params if key not in ALLOWED_DATA_PARAMS] raise HTTPException(
if unknown_params: status_code=422,
raise HTTPException(status_code=422, detail=f'Unknown query parameters detected: {unknown_params}') detail="Invalid request 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]
# 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: if duplicates:
raise HTTPException(status_code=422, detail=f'Duplicate query parameters: {duplicates}') log.warning(f"Security violation: Duplicate query parameters detected: {duplicates}")
pagination_size_keys = {'size', 'itemsPerPage', 'per_page', 'limit', 'items_per_page'} 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: for key, value in params:
if value: if value:
inspect_value(value, f"query param '{key}'") inspect_value(value, f"query param '{key}'")
if key in pagination_size_keys and value: if key in pagination_size_keys and value:
try: try:
size_val = int(value) size_val = int(value)
if size_val > 50: if size_val > 100:
raise HTTPException(status_code=422, detail=f'Pagination size too large: {size_val} (max 50)') 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: if size_val % 5 != 0:
raise HTTPException(status_code=422, detail=f'Pagination size not a multiple of 5: {size_val}') log.warning(f"Security violation: Pagination size not multiple of 5 ({size_val})")
raise HTTPException(
status_code=422,
detail="Invalid request parameters",
)
except ValueError: except ValueError:
raise HTTPException(status_code=422, detail=f"Pagination size invalid value for '{key}'") log.warning(f"Security violation: Pagination size invalid value")
content_type = request.headers.get('content-type', '') raise HTTPException(
if content_type and (not any((content_type.startswith(t) for t in ('application/json', 'multipart/form-data', 'application/x-www-form-urlencoded')))): status_code=422,
raise HTTPException(status_code=422, detail=f'Unsupported Content-Type: {content_type}') 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_query = len(params) > 0
has_body = False has_body = False
if content_type.startswith('application/json'):
content_length = request.headers.get('content-length') 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: if content_length and int(content_length) > 0:
has_body = True has_body = True
if has_query and has_body: if has_query and has_body:
raise HTTPException(status_code=422, detail='Mixed parameters: both query string and JSON body are not allowed') log.warning(f"Security violation: Mixed parameters (query + JSON body)")
if content_type.startswith('application/json'): raise HTTPException(
status_code=422,
detail="Invalid request parameters",
)
# -------------------------
# 6. JSON body inspection
# -------------------------
if content_type.startswith("application/json"):
body = await request.body() body = await request.body()
# if len(body) > MAX_JSON_BODY_SIZE:
# raise HTTPException(status_code=422, detail="JSON body too large")
if body: if body:
try: try:
payload = json.loads(body) payload = json.loads(body)
except json.JSONDecodeError: except json.JSONDecodeError:
raise HTTPException(status_code=400, detail='Invalid JSON body: malformed JSON') log.warning(f"Security violation: Invalid JSON body")
raise HTTPException(status_code=422, detail="Invalid request parameters")
inspect_json(payload) inspect_json(payload)
# Re-inject body for downstream handlers
async def receive(): async def receive():
return {'type': 'http.request', 'body': body} return {"type": "http.request", "body": body}
request._receive = receive request._receive = receive
return await call_next(request)
import asyncio
import json
import logging
REQUEST_TIMEOUT_SECONDS = 20
log = logging.getLogger('security_logger')
class RequestTimeoutMiddleware:
def __init__(self, app, timeout: int=REQUEST_TIMEOUT_SECONDS): return await call_next(request)
self.app = app
self.timeout = timeout
async def __call__(self, scope, receive, send):
if scope['type'] != 'http':
await self.app(scope, receive, send)
return
response_started = False
async def send_wrapper(message):
nonlocal response_started
if message['type'] == 'http.response.start':
response_started = True
await send(message)
try:
await asyncio.wait_for(self.app(scope, receive, send_wrapper), timeout=self.timeout)
except asyncio.TimeoutError:
if not response_started:
import uuid as _uuid
error_id = str(_uuid.uuid4())
scope['x_timed_out'] = True
scope['x_timeout_error_id'] = error_id
body = json.dumps({'data': None, 'message': f'Request timeout. Error ID: {error_id}', 'status': False}).encode('utf-8')
await send({'type': 'http.response.start', 'status': 408, 'headers': [[b'content-type', b'application/json; charset=utf-8'], [b'content-length', str(len(body)).encode()]]})
await send({'type': 'http.response.body', 'body': body})

@ -1,17 +1,31 @@
# src/common/models.py
import uuid import uuid
from datetime import datetime from datetime import datetime
from typing import Generic, Optional, TypeVar from typing import Generic, Optional, TypeVar
import pytz import pytz
from pydantic import BaseModel, SecretStr from pydantic import BaseModel, Field, SecretStr
from sqlalchemy import Column, DateTime, String, event from sqlalchemy import Column, DateTime, String, event, func
from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from src.auth.service import CurrentUser
from src.config import TIMEZONE from src.config import TIMEZONE
from src.enums import ResponseStatus from src.enums import ResponseStatus
# SQLAlchemy Mixins
class TimeStampMixin(object): class TimeStampMixin(object):
created_at = Column(DateTime(timezone=True), default=datetime.now(pytz.timezone(TIMEZONE))) """Timestamping mixin"""
created_at = Column(
DateTime(timezone=True), default=datetime.now(pytz.timezone(TIMEZONE))
)
created_at._creation_order = 9998 created_at._creation_order = 9998
updated_at = Column(DateTime(timezone=True), default=datetime.now(pytz.timezone(TIMEZONE))) updated_at = Column(
DateTime(timezone=True), default=datetime.now(pytz.timezone(TIMEZONE))
)
updated_at._creation_order = 9998 updated_at._creation_order = 9998
@staticmethod @staticmethod
@ -20,32 +34,50 @@ class TimeStampMixin(object):
@classmethod @classmethod
def __declare_last__(cls): def __declare_last__(cls):
event.listen(cls, 'before_update', cls._updated_at) event.listen(cls, "before_update", cls._updated_at)
class UUIDMixin: class UUIDMixin:
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4, unique=True, nullable=False) """UUID mixin"""
id = Column(
UUID(as_uuid=True),
primary_key=True,
default=uuid.uuid4,
unique=True,
nullable=False,
)
class SoftDeleteMixin:
deleted_at = Column(DateTime(timezone=True), nullable=True, default=None)
deleted_at._creation_order = 9999
class DefaultMixin(SoftDeleteMixin, TimeStampMixin, UUIDMixin): class DefaultMixin(TimeStampMixin, UUIDMixin):
"""Default mixin"""
pass pass
class IdentityMixin: class IdentityMixin:
"""Identity mixin"""
created_by = Column(String(100), nullable=True) created_by = Column(String(100), nullable=True)
updated_by = Column(String(100), nullable=True) updated_by = Column(String(100), nullable=True)
class DefultBase(BaseModel):
# Pydantic Models
class DefultBase(BaseModel):
class Config: class Config:
from_attributes = True from_attributes = True
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 populate_by_name = True
extra = 'forbid' extra="forbid"
json_encoders = {datetime: lambda v: v.strftime('%Y-%m-%dT%H:%M:%S.%fZ') if v else None, SecretStr: lambda v: v.get_secret_value() if v else None}
json_encoders = {
# custom output conversion for datetime
datetime: lambda v: v.strftime("%Y-%m-%dT%H:%M:%S.%fZ") if v else None,
SecretStr: lambda v: v.get_secret_value() if v else None,
}
class Pagination(DefultBase): class Pagination(DefultBase):
itemsPerPage: int itemsPerPage: int
@ -53,11 +85,16 @@ class Pagination(DefultBase):
page: int page: int
total: int total: int
class PrimaryKeyModel(BaseModel): class PrimaryKeyModel(BaseModel):
id: uuid.UUID id: uuid.UUID
T = TypeVar('T')
# Define data type variable for generic response
T = TypeVar("T")
class StandardResponse(BaseModel, Generic[T]): class StandardResponse(BaseModel, Generic[T]):
data: Optional[T] = None data: Optional[T] = None
message: str = 'Success' message: str = "Success"
status: ResponseStatus = ResponseStatus.SUCCESS status: ResponseStatus = ResponseStatus.SUCCESS

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

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

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

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

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

@ -1,35 +1,68 @@
from typing import List from typing import List
from fastapi import APIRouter
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 CollectorDbSession, DbSession
from src.models import StandardResponse from src.models import StandardResponse
from src.overhaul.service import get_overhaul_critical_parts, get_overhaul_overview, get_overhaul_schedules, get_overhaul_system_components from src.overhaul.service import (get_overhaul_critical_parts,
get_overhaul_overview,
get_overhaul_schedules,
get_overhaul_system_components)
from src.overhaul_scope.schema import ScopeRead from src.overhaul_scope.schema import ScopeRead
from .schema import OverhaulCriticalParts, OverhaulRead, OverhaulSystemComponents
from src.auth.access_control import required_permission, OHPermission from .schema import (OverhaulCriticalParts, OverhaulRead,
from src.overhaul_scope.model import OverhaulScope OverhaulSystemComponents)
from fastapi import Depends
router = APIRouter() router = APIRouter()
@router.get('', response_model=StandardResponse[OverhaulRead], dependencies=[Depends(required_permission(OHPermission.READ, OverhaulScope))])
@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, collector_db_session:CollectorDbSession):
"""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, collector_db_session=collector_db_session)
systemComponents = get_overhaul_system_components() systemComponents = get_overhaul_system_components()
return StandardResponse(data=OverhaulRead(overview=overview, schedules=schedules, criticalParts=criticalParts, systemComponents=systemComponents), message='Data retrieved successfully')
@router.get('/schedules', response_model=StandardResponse[List[ScopeRead]], dependencies=[Depends(required_permission(OHPermission.READ, OverhaulScope))]) return StandardResponse(
data=OverhaulRead(
overview=overview,
schedules=schedules,
criticalParts=criticalParts,
systemComponents=systemComponents,
),
message="Data retrieved successfully",
)
@router.get("/schedules", response_model=StandardResponse[List[ScopeRead]])
async def get_schedules(): async def get_schedules():
"""Get all overhaul schedules."""
schedules = get_overhaul_schedules() schedules = get_overhaul_schedules()
return StandardResponse(data=schedules, message='Data retrieved successfully') return StandardResponse(
data=schedules,
message="Data retrieved successfully",
)
@router.get('/critical-parts', response_model=StandardResponse[OverhaulCriticalParts], dependencies=[Depends(required_permission(OHPermission.READ, OverhaulScope))])
@router.get("/critical-parts", response_model=StandardResponse[OverhaulCriticalParts])
async def get_critical_parts(): async def get_critical_parts():
"""Get all overhaul critical parts."""
criticalParts = get_overhaul_critical_parts() criticalParts = get_overhaul_critical_parts()
return StandardResponse(data=OverhaulCriticalParts(criticalParts=criticalParts), message='Data retrieved successfully') return StandardResponse(
data=OverhaulCriticalParts(criticalParts=criticalParts),
message="Data retrieved successfully",
)
@router.get('/system-components', response_model=StandardResponse[OverhaulSystemComponents], dependencies=[Depends(required_permission(OHPermission.READ, OverhaulScope))]) @router.get(
"/system-components", response_model=StandardResponse[OverhaulSystemComponents]
)
async def get_system_components(): async def get_system_components():
"""Get all overhaul system components."""
systemComponents = get_overhaul_system_components() systemComponents = get_overhaul_system_components()
return StandardResponse(data=OverhaulSystemComponents(systemComponents=systemComponents), message='Data retrieved successfully') return StandardResponse(
data=OverhaulSystemComponents(systemComponents=systemComponents),
message="Data retrieved successfully",
)

@ -1,21 +1,72 @@
from typing import Any, Dict, List from datetime import datetime
from typing import Any, Dict, List, Optional
from uuid import UUID
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from src.models import DefultBase, Pagination
from src.overhaul_scope.schema import ScopeRead from src.overhaul_scope.schema import ScopeRead
class OverhaulBase(BaseModel): class OverhaulBase(BaseModel):
pass pass
class OverhaulCriticalParts(OverhaulBase): class OverhaulCriticalParts(OverhaulBase):
criticalParts: List[str] = Field(..., description='List of critical parts') criticalParts: List[str] = Field(..., description="List of critical parts")
class OverhaulSchedules(OverhaulBase): class OverhaulSchedules(OverhaulBase):
schedules: List[Dict[str, Any]] = Field(..., description='List of schedules') schedules: List[Dict[str, Any]] = Field(..., description="List of schedules")
class OverhaulSystemComponents(OverhaulBase): class OverhaulSystemComponents(OverhaulBase):
systemComponents: Dict[str, Any] = Field(..., description='List of system components') systemComponents: Dict[str, Any] = Field(
..., description="List of system components"
)
class OverhaulRead(OverhaulBase): class OverhaulRead(OverhaulBase):
overview: Dict[str, Any] overview: Dict[str, Any]
criticalParts: dict criticalParts: dict
schedules: List[ScopeRead] schedules: List[ScopeRead]
systemComponents: Dict[str, Any] systemComponents: Dict[str, Any]
# {
# "overview": {
# "totalEquipment": 30,
# "nextSchedule": {
# "date": "2025-01-12",
# "Overhaul": "B",
# "equipmentCount": 30
# }
# },
# "criticalParts": [
# "Boiler feed pump",
# "Boiler reheater system",
# "Drum Level (Right) Root Valve A",
# "BCP A Discharge Valve",
# "BFPT A EXH Press HI Root VLV"
# ],
# "schedules": [
# {
# "date": "2025-01-12",
# "Overhaul": "B",
# "status": "upcoming"
# }
# // ... other scheduled overhauls
# ],
# "systemComponents": {
# "boiler": {
# "status": "operational",
# "lastOverhaul": "2024-06-15"
# },
# "turbine": {
# "hpt": { "status": "operational" },
# "ipt": { "status": "operational" },
# "lpt": { "status": "operational" }
# }
# // ... other major components
# }
# }

@ -1,51 +1,355 @@
import asyncio import asyncio
from typing import Optional
import httpx import httpx
from sqlalchemy import Select from sqlalchemy import Delete, Select
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.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
from src.overhaul_scope.model import OverhaulScope from src.overhaul_scope.model import OverhaulScope
from src.overhaul_scope.service import get_all as get_all_session
from src.overhaul_scope.service import get_overview_overhaul from src.overhaul_scope.service import get_overview_overhaul
from src.standard_scope.service import get_by_oh_session_id
async def get_overhaul_overview(db_session: DbSession): async def get_overhaul_overview(db_session: DbSession):
return await get_overview_overhaul(db_session=db_session) """Get all overhaul overview."""
results = await get_overview_overhaul(db_session=db_session)
return results
async def get_simulation_results(*, simulation_id: str, token: str): async def get_simulation_results(*, simulation_id: str, token: str):
headers = {'Authorization': f'Bearer {token}', 'Content-Type': 'application/json'} headers = {
calc_result_url = f'{RBD_SERVICE_API}/aeros/simulation/result/calc/{simulation_id}?nodetype=RegularNode' "Authorization": f"Bearer {token}",
calc_plant_result = f'{RBD_SERVICE_API}/aeros/simulation/result/calc/{simulation_id}/plant' "Content-Type": "application/json"
}
calc_result_url = f"{RBD_SERVICE_API}/aeros/simulation/result/calc/{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"
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)
# plot_task = client.get(plot_result_url, headers=headers)
plant_task = client.get(calc_plant_result, headers=headers) plant_task = client.get(calc_plant_result, headers=headers)
# Run all three requests concurrently
calc_response, plant_response = await asyncio.gather(calc_task, plant_task) calc_response, plant_response = await asyncio.gather(calc_task, plant_task)
calc_response.raise_for_status() calc_response.raise_for_status()
# plot_response.raise_for_status()
plant_response.raise_for_status() plant_response.raise_for_status()
calc_data = calc_response.json()['data']
plant_data = plant_response.json()['data'] calc_data = calc_response.json()["data"]
return {'calc_result': calc_data, 'plant_result': plant_data} # plot_data = plot_response.json()["data"]
plant_data = plant_response.json()["data"]
return {
"calc_result": calc_data,
# "plot_result": plot_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, collector_db_session):
equipments = await get_standard_scope_by_session_id(db_session=db_session, overhaul_session_id=session_id, collector_db=collector_db_session) """Get all overhaul critical parts."""
criticality_simulation = await get_simulation_results(simulation_id=TC_RBD_ID, token=token) equipments = await get_standard_scope_by_session_id(
rbd_simulation = {asset['aeros_node']['node_name']: {'availability': asset['availability'], 'criticality': asset['criticality']} for asset in criticality_simulation['calc_result']} db_session=db_session,
base_result = [{'id': equipment.id, 'location_tag': equipment.location_tag, 'name': equipment.equipment_name, 'matrix': rbd_simulation.get(equipment.location_tag)} for equipment in equipments] overhaul_session_id=session_id,
filtered_result = [item for item in base_result if item['matrix'] is not None] collector_db=collector_db_session
availability_result = sorted(filtered_result, key=lambda x: x['matrix']['availability'])[:10] )
criticality_result = sorted(filtered_result, key=lambda x: x['matrix']['criticality'], reverse=True)[:10]
return {'availability': availability_result, 'criticality': criticality_result}
criticality_simulation = await get_simulation_results(
simulation_id = TC_RBD_ID,
token=token
)
rbd_simulation = {asset['aeros_node']["node_name"]: {
"availability": asset["availability"],
"criticality": asset["criticality"]
} for asset in criticality_simulation["calc_result"]}
# Create the base result list
base_result = [
{
"id": equipment.id,
"location_tag": equipment.location_tag,
"name": equipment.equipment_name,
"matrix": rbd_simulation.get(equipment.location_tag)
} for equipment in equipments
]
# Filter out items without matrix data (where rbd_simulation.get() returned None)
filtered_result = [item for item in base_result if item["matrix"] is not None]
# Sort by availability (lowest to highest) and limit to 10
availability_result = sorted(
filtered_result,
key=lambda x: x["matrix"]["availability"]
)[:10]
# Sort by criticality (highest to lowest) and limit to 10
criticality_result = sorted(
filtered_result,
key=lambda x: x["matrix"]["criticality"],
reverse=True
)[:10]
return {
"availability" :availability_result,
"criticality": criticality_result
}
async def get_overhaul_schedules(*, db_session: DbSession): async def get_overhaul_schedules(*, db_session: DbSession):
"""Get all overhaul schedules."""
query = Select(OverhaulScope) query = Select(OverhaulScope)
results = await db_session.execute(query) results = await db_session.execute(query)
return results.scalars().all() return results.scalars().all()
def get_overhaul_system_components(): def get_overhaul_system_components():
powerplant_reliability = {'Plant Control': {'availability': 0.9994866529774127, 'efficiency': 0.9994956204510826, 'total_uptime': 17523.0}, 'SPS': {'availability': 0.9932694501483038, 'efficiency': 0.9810193821516867, 'total_uptime': 17414.000000000062}, 'Turbine': {'availability': 0.9931553730321733, 'efficiency': 0.9666721976783572, 'total_uptime': 17412.000000000062}, 'Generator': {'availability': 0.9934405658224995, 'efficiency': 0.9625424646119242, 'total_uptime': 17417.000000000062}, 'Condensate Water': {'availability': 0.9934405658224995, 'efficiency': 0.9531653517377116, 'total_uptime': 17417.000000000062}, 'Feedwater System': {'availability': 0.9936116814966953, 'efficiency': 0.9687512254370128, 'total_uptime': 17420.000000000062}, 'Cooling Water': {'availability': 0.99355464293863, 'efficiency': 0.9749999189617731, 'total_uptime': 17419.000000000062}, 'SCR': {'availability': 0.9196577686516085, 'efficiency': 0.9996690612127086, 'total_uptime': 17526.0}, 'Ash Handling': {'availability': 0.9931838923112059, 'efficiency': 0.9933669638506657, 'total_uptime': 17412.500000000062}, 'Air Flue Gas': {'availability': 0.9906456764773022, 'efficiency': 0.8565868045084128, 'total_uptime': 17368.000000000062}, 'Boiler': {'availability': 0.9934976043805648, 'efficiency': 0.9693239775686654, 'total_uptime': 17418.000000000062}, 'SAC-IAC': {'availability': 0.9936116814966953, 'efficiency': 0.9878742473840645, 'total_uptime': 17420.000000000062}, 'KLH': {'availability': 0.992185717545064, 'efficiency': 0.9426658166507496, 'total_uptime': 17395.000000000062}, 'CL': {'availability': 0.9984029203741729, 'efficiency': 0.9984546987034779, 'total_uptime': 17504.0}, 'Desalination': {'total_uptime': 17275.500000000062, 'availability': 0.9853696098562663, 'efficiency': 0.9118366404915063}, 'FGD': {'availability': 0.9933835272644342, 'efficiency': 0.9623105228919693, 'total_uptime': 17416.000000000062}, 'CHS': {'availability': 1.0, 'efficiency': 0.9665857756206829, 'total_uptime': 17532.0}, 'SSB': {'availability': 0.9933264887063691, 'efficiency': 0.993508327346586, 'total_uptime': 17415.000000000062}, 'WTP': {'availability': 0.9925849874515208, 'efficiency': 0.9925849874515206, 'total_uptime': 17402.000000000062}} """Get all overhaul system components with dummy data."""
powerplant_reliability = {
"Plant Control": {
"availability": 0.9994866529774127,
"efficiency": 0.9994956204510826,
"total_uptime": 17523.0,
},
"SPS": {
"availability": 0.9932694501483038,
"efficiency": 0.9810193821516867,
"total_uptime": 17414.000000000062,
},
"Turbine": {
"availability": 0.9931553730321733,
"efficiency": 0.9666721976783572,
"total_uptime": 17412.000000000062,
},
"Generator": {
"availability": 0.9934405658224995,
"efficiency": 0.9625424646119242,
"total_uptime": 17417.000000000062,
},
"Condensate Water": {
"availability": 0.9934405658224995,
"efficiency": 0.9531653517377116,
"total_uptime": 17417.000000000062,
},
"Feedwater System": {
"availability": 0.9936116814966953,
"efficiency": 0.9687512254370128,
"total_uptime": 17420.000000000062,
},
"Cooling Water": {
"availability": 0.99355464293863,
"efficiency": 0.9749999189617731,
"total_uptime": 17419.000000000062,
},
"SCR": {
"availability": 0.9196577686516085,
"efficiency": 0.9996690612127086,
"total_uptime": 17526.0,
},
"Ash Handling": {
"availability": 0.9931838923112059,
"efficiency": 0.9933669638506657,
"total_uptime": 17412.500000000062,
},
"Air Flue Gas": {
"availability": 0.9906456764773022,
"efficiency": 0.8565868045084128,
"total_uptime": 17368.000000000062,
},
"Boiler": {
"availability": 0.9934976043805648,
"efficiency": 0.9693239775686654,
"total_uptime": 17418.000000000062,
},
"SAC-IAC": {
"availability": 0.9936116814966953,
"efficiency": 0.9878742473840645,
"total_uptime": 17420.000000000062,
},
"KLH": {
"availability": 0.992185717545064,
"efficiency": 0.9426658166507496,
"total_uptime": 17395.000000000062,
},
"CL": {
"availability": 0.9984029203741729,
"efficiency": 0.9984546987034779,
"total_uptime": 17504.0,
},
"Desalination": {
"total_uptime": 17275.500000000062,
"availability": 0.9853696098562663,
"efficiency": 0.9118366404915063,
},
"FGD": {
"availability": 0.9933835272644342,
"efficiency": 0.9623105228919693,
"total_uptime": 17416.000000000062,
},
"CHS": {
"availability": 1.0,
"efficiency": 0.9665857756206829,
"total_uptime": 17532.0,
},
"SSB": {
"availability": 0.9933264887063691,
"efficiency": 0.993508327346586,
"total_uptime": 17415.000000000062,
},
"WTP": {
"availability": 0.9925849874515208,
"efficiency": 0.9925849874515206,
"total_uptime": 17402.000000000062,
},
}
availabilities = {schematic: item['availability'] for schematic, item in powerplant_reliability.items() } availabilities = {schematic: item['availability'] for schematic, item in powerplant_reliability.items() }
percentages = calculate_contribution(availabilities) percentages = calculate_contribution(availabilities)
for schema, contribution in percentages.items(): for schema, contribution in percentages.items():
powerplant_reliability[schema]['critical_contribution'] = contribution['criticality_importance'] powerplant_reliability[schema]["critical_contribution"] = contribution['criticality_importance']
return dict(sorted(powerplant_reliability.items(), key=lambda x: x[1]['critical_contribution'], reverse=True))
return {'HPT': {'efficiency': '92%', 'work_hours': '1200', 'reliability': '96%'}, 'IPT': {'efficiency': '91%', 'work_hours': '1100', 'reliability': '95%'}, 'LPT': {'efficiency': '90%', 'work_hours': '1000', 'reliability': '94%'}, 'EG': {'efficiency': '88%', 'work_hours': '950', 'reliability': '93%'}, 'boiler': {'efficiency': '90%', 'work_hours': '1000', 'reliability': '95%'}, 'HPH1': {'efficiency': '89%', 'work_hours': '1050', 'reliability': '94%'}, 'HPH2': {'efficiency': '88%', 'work_hours': '1020', 'reliability': '93%'}, 'HPH3': {'efficiency': '87%', 'work_hours': '1010', 'reliability': '92%'}, 'HPH5': {'efficiency': '86%', 'work_hours': '980', 'reliability': '91%'}, 'HPH6': {'efficiency': '85%', 'work_hours': '970', 'reliability': '90%'}, 'HPH7': {'efficiency': '84%', 'work_hours': '960', 'reliability': '89%'}, 'Condensor': {'efficiency': '83%', 'work_hours': '940', 'reliability': '88%'}, 'Deaerator': {'efficiency': '82%', 'work_hours': '930', 'reliability': '87%'}} # Sort the powerplant_reliability dictionary by critical_contribution in descending order
sorted_powerplant_reliability = dict(sorted(
powerplant_reliability.items(),
key=lambda x: x[1]["critical_contribution"],
reverse=True # Set to True for high to low sorting
))
return sorted_powerplant_reliability
return {
"HPT": {
"efficiency": "92%",
"work_hours": "1200",
"reliability": "96%",
},
"IPT": {
"efficiency": "91%",
"work_hours": "1100",
"reliability": "95%",
},
"LPT": {
"efficiency": "90%",
"work_hours": "1000",
"reliability": "94%",
},
"EG": {
"efficiency": "88%",
"work_hours": "950",
"reliability": "93%",
},
"boiler": {
"efficiency": "90%",
"work_hours": "1000",
"reliability": "95%",
},
"HPH1": {
"efficiency": "89%",
"work_hours": "1050",
"reliability": "94%",
},
"HPH2": {
"efficiency": "88%",
"work_hours": "1020",
"reliability": "93%",
},
"HPH3": {
"efficiency": "87%",
"work_hours": "1010",
"reliability": "92%",
},
"HPH5": {
"efficiency": "86%",
"work_hours": "980",
"reliability": "91%",
},
"HPH6": {
"efficiency": "85%",
"work_hours": "970",
"reliability": "90%",
},
"HPH7": {
"efficiency": "84%",
"work_hours": "960",
"reliability": "89%",
},
"Condensor": {
"efficiency": "83%",
"work_hours": "940",
"reliability": "88%",
},
"Deaerator": {
"efficiency": "82%",
"work_hours": "930",
"reliability": "87%",
},
}
# async def get(*, db_session: DbSession, scope_id: str) -> Optional[Scope]:
# """Returns a document based on the given document id."""
# query = Select(Scope).filter(Scope.id == scope_id)
# result = await db_session.execute(query)
# return result.scalars().one_or_none()
# async def get_all(*, db_session: DbSession):
# """Returns all documents."""
# query = Select(Scope)
# result = await db_session.execute(query)
# return result.scalars().all()
# async def create(*, db_session: DbSession, scope_id: ScopeCreate):
# """Creates a new document."""
# scope = Scope(**scope_id.model_dump())
# db_session.add(scope)
# await db_session.commit()
# return scope
# async def update(*, db_session: DbSession, scope: Scope, scope_id: ScopeUpdate):
# """Updates a document."""
# data = scope_id.model_dump()
# update_data = scope_id.model_dump(exclude_defaults=True)
# for field in data:
# if field in update_data:
# setattr(scope, field, update_data[field])
# await db_session.commit()
# return scope
# async def delete(*, db_session: DbSession, scope_id: str):
# """Deletes a document."""
# query = Delete(Scope).where(Scope.id == scope_id)
# await db_session.execute(query)
# await db_session.commit()

@ -1,11 +1,43 @@
from sqlalchemy import UUID, Column, Float, ForeignKey, String from sqlalchemy import UUID, Column, Float, ForeignKey, Integer, String
from sqlalchemy.ext.hybrid import hybrid_property
from sqlalchemy.orm import relationship
from src.database.core import Base from src.database.core import Base
from src.models import DefaultMixin from src.models import DefaultMixin, IdentityMixin, TimeStampMixin
from src.workorder.model import MasterWorkOrder
class OverhaulActivity(Base, DefaultMixin): class OverhaulActivity(Base, DefaultMixin):
__tablename__ = 'oh_tr_overhaul_activity' __tablename__ = "oh_tr_overhaul_activity"
assetnum = Column(String, nullable=True) assetnum = Column(String, nullable=True)
overhaul_scope_id = Column(UUID(as_uuid=True), ForeignKey('oh_ms_overhaul_scope.id'), nullable=False) overhaul_scope_id = Column(
UUID(as_uuid=True), ForeignKey("oh_ms_overhaul_scope.id"), nullable=False
)
material_cost = Column(Float, nullable=False, default=0) material_cost = Column(Float, nullable=False, default=0)
service_cost = Column(Float, nullable=False, default=0) service_cost = Column(Float, nullable=False, default=0)
status = Column(String, nullable=False, default='pending') status = Column(String, nullable=False, default="pending")
# equipment = relationship(
# "MasterEquipment",
# lazy="raise",
# primaryjoin="and_(OverhaulActivity.assetnum == foreign(MasterEquipment.assetnum))",
# uselist=False, # Add this if it's a one-to-one relationship
# )
# # sparepart_equipments = relationship(
# # "ScopeEquipmentPart",
# # lazy="select", # or "joined", "subquery", "dynamic" depending on your needs
# # primaryjoin="OverhaulActivity.assetnum == foreign(ScopeEquipmentPart.assetnum)",
# # uselist=True
# # )
# overhaul_scope = relationship(
# "OverhaulScope",
# lazy="raise",
# )
# overhaul_jobs = relationship(
# "OverhaulJob", back_populates="overhaul_activity", lazy="raise"
# )

@ -1,33 +1,116 @@
from typing import Optional from typing import List, Optional
from uuid import UUID from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, status
from src.csrf_protect import csrf_protect from fastapi import APIRouter, HTTPException, Query, status
from src.database.core import CollectorDbSession from src.database.core import CollectorDbSession
from src.database.service import CommonParameters, DbSession from src.database.service import (CommonParameters, DbSession,
search_filter_sort_paginate)
from src.models import StandardResponse from src.models import StandardResponse
from src.auth.access_control import require_any_role, ALLOWED_ROLES
from .schema import OverhaulActivityCreate, OverhaulActivityRead from .schema import (OverhaulActivityCreate, OverhaulActivityPagination,
from .service import add_multiple_equipment_to_session, get, get_all, remove_equipment_from_session OverhaulActivityRead, OverhaulActivityUpdate)
router = APIRouter(dependencies=[Depends(require_any_role(*ALLOWED_ROLES))]) from .service import add_multiple_equipment_to_session, get, get_all, remove_equipment_from_session, update
@router.get('/{overhaul_session}', response_model=StandardResponse[dict]) router = APIRouter()
async def get_scope_equipments(common: CommonParameters, overhaul_session: str, collector_db: CollectorDbSession, location_tag: Optional[str]=Query(None), scope_name: Optional[str]=Query(None)):
data = await get_all(common=common, location_tag=location_tag, scope_name=scope_name, overhaul_session_id=overhaul_session, collector_db=collector_db)
return StandardResponse(data=data, message='Data retrieved successfully') @router.get(
"/{overhaul_session}", response_model=StandardResponse[dict]
@router.post('/{overhaul_session_id}', response_model=StandardResponse[None]) )
async def create_overhaul_equipment(db_session: DbSession, collector_db_session: CollectorDbSession, overhaul_activty_in: OverhaulActivityCreate, overhaul_session_id: UUID): async def get_scope_equipments(
await add_multiple_equipment_to_session(db_session=db_session, collector_db=collector_db_session, overhaul_session_id=overhaul_session_id, location_tags=overhaul_activty_in.location_tags) common: CommonParameters,
return StandardResponse(data=None, message='Data created successfully') overhaul_session: str,
collector_db: CollectorDbSession,
@router.get('/{overhaul_session}/{assetnum}', response_model=StandardResponse[OverhaulActivityRead]) location_tag: Optional[str] = Query(None),
async def get_overhaul_equipment(db_session: DbSession, assetnum: str, overhaul_session): scope_name: Optional[str] = Query(None),
equipment = await get(db_session=db_session, assetnum=assetnum, overhaul_session_id=overhaul_session) ):
"""Get all scope activity pagination."""
# return
data = await get_all(
common=common,
location_tag=location_tag,
scope_name=scope_name,
overhaul_session_id=overhaul_session,
collector_db=collector_db,
)
return StandardResponse(
data=data,
message="Data retrieved successfully",
)
@router.post("/{overhaul_session_id}", response_model=StandardResponse[None])
async def create_overhaul_equipment(
db_session: DbSession,
collector_db_session: CollectorDbSession,
overhaul_activty_in: OverhaulActivityCreate,
overhaul_session_id: UUID,
):
activity = await add_multiple_equipment_to_session(
db_session=db_session,
collector_db=collector_db_session,
overhaul_session_id=overhaul_session_id,
location_tags=overhaul_activty_in.location_tags
)
return StandardResponse(data=None, message="Data created successfully")
@router.get(
"/{overhaul_session}/{assetnum}",
response_model=StandardResponse[OverhaulActivityRead],
)
async def get_overhaul_equipment(
db_session: DbSession, assetnum: str, overhaul_session
):
equipment = await get(
db_session=db_session, assetnum=assetnum, overhaul_session_id=overhaul_session
)
if not equipment: if not equipment:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='A data with this id does not exist.') raise HTTPException(
return StandardResponse(data=equipment, message='Data retrieved successfully') status_code=status.HTTP_404_NOT_FOUND,
detail="A data with this id does not exist.",
)
return StandardResponse(data=equipment, message="Data retrieved successfully")
@router.post('/delete/{overhaul_session}/{location_tag}', response_model=StandardResponse[None], dependencies=[Depends(csrf_protect)]) # @router.put(
# "/{overhaul_session}/{assetnum}",
# response_model=StandardResponse[OverhaulActivityRead],
# )
# async def update_scope(
# db_session: DbSession,
# scope_equipment_activity_in: OverhaulActivityUpdate,
# assetnum: str,
# ):
# activity = await get(db_session=db_session, assetnum=assetnum)
# if not activity:
# raise HTTPException(
# status_code=status.HTTP_404_NOT_FOUND,
# detail="A data with this id does not exist.",
# )
# return StandardResponse(
# data=await update(
# db_session=db_session,
# activity=activity,
# scope_equipment_activity_in=scope_equipment_activity_in,
# ),
# message="Data updated successfully",
# )
@router.post(
"/delete/{overhaul_session}/{location_tag}",
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):
await remove_equipment_from_session(db_session=db_session, overhaul_session_id=overhaul_session, location_tag=location_tag) await remove_equipment_from_session(db_session=db_session, overhaul_session_id=overhaul_session, location_tag=location_tag)
return StandardResponse(message='Data deleted successfully', data=None)
return StandardResponse(message="Data deleted successfully", data=None)

@ -1,25 +1,31 @@
from datetime import datetime from datetime import datetime
from typing import List, Optional from typing import Any, Dict, List, Optional
from uuid import UUID from uuid import UUID
from pydantic import Field from pydantic import Field
from src.models import DefultBase, Pagination from src.models import DefultBase, Pagination
from src.standard_scope.schema import MasterEquipmentTree
from src.workscope_group.schema import ActivityMasterRead from src.workscope_group.schema import ActivityMasterRead
class OverhaulActivityBase(DefultBase): class OverhaulActivityBase(DefultBase):
pass pass
class OverhaulActivityCreate(OverhaulActivityBase): class OverhaulActivityCreate(OverhaulActivityBase):
location_tags: List[str] location_tags: List[str]
class OverhaulActivityUpdate(OverhaulActivityBase): class OverhaulActivityUpdate(OverhaulActivityBase):
material_cost: Optional[float] = Field(0, ge=0, le=1000000000000000) material_cost: Optional[float] = Field(0)
service_cost: Optional[float] = Field(0, ge=0, le=1000000000000000) service_cost: Optional[float] = Field(0)
class OverhaulScope(DefultBase): class OverhaulScope(DefultBase):
type: str type: str
start_date: datetime start_date: datetime
end_date: datetime end_date: datetime
duration_oh: int = Field(..., ge=0, le=1000000) duration_oh: int
class ScopeEquipmentJob(DefultBase): class ScopeEquipmentJob(DefultBase):
job: ActivityMasterRead job: ActivityMasterRead
@ -29,12 +35,16 @@ class OverhaulJob(DefultBase):
class OverhaulActivityRead(OverhaulActivityBase): class OverhaulActivityRead(OverhaulActivityBase):
id: UUID id: UUID
material_cost: Optional[float] = Field(0, ge=0, le=1000000000000000) material_cost: Optional[float] = Field(0)
service_cost: Optional[float] = Field(0, ge=0, le=1000000000000000) service_cost: Optional[float] = Field(0)
location_tag: str location_tag: str
equipment_name: Optional[str] equipment_name: Optional[str]
oh_scope: str oh_scope: str
overhaul_cost: Optional[float] = Field(0, ge=0, le=1000000000000000) overhaul_cost: Optional[float] = Field(0)
# equipment: MasterEquipmentTree
# overhaul_scope: OverhaulScope
# overhaul_jobs: Optional[List[OverhaulJob]] = Field([])
class OverhaulActivityPagination(Pagination): class OverhaulActivityPagination(Pagination):
items: List[OverhaulActivityRead] = [] items: List[OverhaulActivityRead] = []

@ -1,14 +1,22 @@
import asyncio
import datetime import datetime
from typing import List, Optional from typing import List, Optional
from uuid import UUID, uuid4 from uuid import UUID, uuid4
from fastapi import HTTPException, status
from sqlalchemy import Select, and_, select from sqlalchemy import Delete, Select, and_, func, select
from sqlalchemy import update as sqlUpdate
from sqlalchemy.dialects.postgresql import insert
from sqlalchemy.orm import joinedload, selectinload from sqlalchemy.orm import joinedload, selectinload
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.utils import update_model from src.utils import update_model
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
from src.standard_scope.service import get_by_oh_session_id
from src.workscope_group.model import MasterActivity from src.workscope_group.model import MasterActivity
from src.equipment_workscope_group.model import EquipmentWorkscopeGroup from src.equipment_workscope_group.model import EquipmentWorkscopeGroup
from src.overhaul_scope.model import MaintenanceType from src.overhaul_scope.model import MaintenanceType
@ -16,183 +24,523 @@ from src.workscope_group_maintenance_type.model import WorkscopeOHType
from src.overhaul_scope.service import get as get_overhaul from src.overhaul_scope.service import get as get_overhaul
from src.standard_scope.model import EquipmentOHHistory from src.standard_scope.model import EquipmentOHHistory
from .model import OverhaulActivity from .model import OverhaulActivity
from .schema import OverhaulActivityRead, OverhaulActivityUpdate from .schema import (OverhaulActivityCreate, OverhaulActivityRead,
OverhaulActivityUpdate)
import json import json
from src.database.core import CollectorDbSession from src.database.core import CollectorDbSession
from src.maximo.service import get_cm_cost_summary, get_oh_cost_summary from src.maximo.service import get_cm_cost_summary, get_oh_cost_summary
from src.soft_delete import apply_not_deleted_filter
async def get(*, db_session: DbSession, assetnum: str, overhaul_session_id: Optional[UUID]=None) -> Optional[OverhaulActivityRead]: async def get(
query = Select(OverhaulActivity).where(OverhaulActivity.assetnum == assetnum).options(joinedload(OverhaulActivity.equipment)) *, db_session: DbSession, assetnum: str, overhaul_session_id: Optional[UUID] = None
query = apply_not_deleted_filter(query, OverhaulActivity) ) -> Optional[OverhaulActivityRead]:
"""Returns a document based on the given document id."""
query = (
Select(OverhaulActivity)
.where(OverhaulActivity.assetnum == assetnum)
.options(joinedload(OverhaulActivity.equipment))
)
if overhaul_session_id: if overhaul_session_id:
query = query.filter(OverhaulActivity.overhaul_scope_id == overhaul_session_id) query = query.filter(OverhaulActivity.overhaul_scope_id == overhaul_session_id)
result = await db_session.execute(query) result = await db_session.execute(query)
return result.scalar() return result.scalar()
def get_cost_per_failute(): def get_cost_per_failute():
with open('src/overhaul_activity/cost_failure.json', 'r') as f: with open('src/overhaul_activity/cost_failure.json', 'r') as f:
data = json.load(f) data = json.load(f)
return data['data'] return data['data']
async def get_all(*, common: CommonParameters, overhaul_session_id: UUID, location_tag: Optional[str]=None, scope_name: Optional[str]=None, all: bool=False, collector_db: CollectorDbSession): async def get_all(
*,
common: CommonParameters,
overhaul_session_id: UUID,
location_tag: Optional[str] = None,
scope_name: Optional[str] = None,
all: bool = False,
collector_db: CollectorDbSession
):
# query = (
# Select(OverhaulActivity)
# .where(OverhaulActivity.overhaul_scope_id == overhaul_session_id)
# .options(joinedload(OverhaulActivity.equipment).options(joinedload(MasterEquipment.parent).options(joinedload(MasterEquipment.parent))))
# .options(selectinload(OverhaulActivity.overhaul_scope))
# .options(selectinload(OverhaulActivity.overhaul_jobs).options(joinedload(OverhaulJob.scope_equipment_job).options(joinedload(ScopeEquipmentJob.job))))
# )
# if assetnum:
# query = query.filter(OverhaulActivity.assetnum == assetnum).options(
# joinedload(OverhaulActivity.overhaul_scope)
# )
# if scope_name:
# query = query.filter(OverhaulActivity.scope_name == scope_name).options(
# joinedload(OverhaulActivity.overhaul_scope)
# )
# results = await search_filter_sort_paginate(model=query, **common)
##raise Exception(results['items'][0].equipment.parent.__dict__)
# equipments, overhaul = await get_by_oh_session_id(
# db_session=db_session, oh_session_id=overhaul_session_id
# )
overhaul = await get_overhaul(db_session=common['db_session'], overhaul_session_id=overhaul_session_id) overhaul = await get_overhaul(db_session=common['db_session'], overhaul_session_id=overhaul_session_id)
prev_oh_scope = await get_prev_oh(db_session=common['db_session'], overhaul_session=overhaul) prev_oh_scope = await get_prev_oh(db_session=common['db_session'], overhaul_session=overhaul)
query = Select(StandardScope).outerjoin(StandardScope.oh_history).join(StandardScope.workscope_groups).join(EquipmentWorkscopeGroup.workscope_group).join(MasterActivity.oh_types).join(WorkscopeOHType.oh_type).join(MasterEquipment, StandardScope.location_tag == MasterEquipment.location_tag).filter(MaintenanceType.name == overhaul.maintenance_type.name).filter((StandardScope.is_alternating_oh == False) | (StandardScope.oh_history == None) | StandardScope.oh_history.has(EquipmentOHHistory.last_oh_type != overhaul.maintenance_type.name)).distinct()
query = apply_not_deleted_filter(query, StandardScope) query = (
Select(StandardScope)
.outerjoin(StandardScope.oh_history) # Use outerjoin to handle None values
.join(StandardScope.workscope_groups)
.join(EquipmentWorkscopeGroup.workscope_group)
.join(MasterActivity.oh_types)
.join(WorkscopeOHType.oh_type)
.join(MasterEquipment, StandardScope.location_tag == MasterEquipment.location_tag)
.filter(MaintenanceType.name == overhaul.maintenance_type.name).filter(
(StandardScope.is_alternating_oh == False) |
(StandardScope.oh_history == None) |
(StandardScope.oh_history.has(EquipmentOHHistory.last_oh_type != overhaul.maintenance_type.name))
).distinct()
)
if location_tag: if location_tag:
query = query.filter(StandardScope.location_tag == location_tag) query = query.filter(StandardScope.location_tag == location_tag)
common_params = {**common, 'all': all or common.get('all', False)}
paginated_results = await search_filter_sort_paginate(model=query, **common_params) # Use search_filter_sort_paginate for server-side pagination
equipments = paginated_results['items'] # 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)
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 = []
for equipment in equipments: for equipment in equipments:
if not equipment.master_equipment: if not equipment.master_equipment:
continue continue
cost = material_cost.get(equipment.location_tag, 0) cost = material_cost.get(equipment.location_tag, 0)
oh_cost = overhaul_cost.get(equipment.location_tag, 0) oh_cost = overhaul_cost.get(equipment.location_tag, 0)
res = OverhaulActivityRead(id=equipment.id, material_cost=float(cost), service_cost=equipment.service_cost, overhaul_cost=float(oh_cost), location_tag=equipment.location_tag, equipment_name=equipment.master_equipment.name, oh_scope=overhaul.maintenance_type.name)
res = OverhaulActivityRead(
id=equipment.id,
material_cost=float(cost),
service_cost=equipment.service_cost,
overhaul_cost=float(oh_cost),
location_tag=equipment.location_tag,
equipment_name=equipment.master_equipment.name,
oh_scope=overhaul.maintenance_type.name,
)
results.append(res) results.append(res)
return {**paginated_results, 'items': results}
# Return paginated structure with transformed items
return {
**paginated_results,
"items": results
}
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)
prev_oh_scope = await get_prev_oh(db_session=db_session, overhaul_session=overhaul) prev_oh_scope = await get_prev_oh(db_session=db_session, overhaul_session=overhaul)
query = Select(StandardScope).outerjoin(StandardScope.oh_history).join(StandardScope.workscope_groups).join(EquipmentWorkscopeGroup.workscope_group).join(MasterActivity.oh_types).join(WorkscopeOHType.oh_type).join(MasterEquipment, StandardScope.location_tag == MasterEquipment.location_tag).filter(MaintenanceType.name == overhaul.maintenance_type.name).filter((StandardScope.is_alternating_oh == False) | (StandardScope.oh_history is None) | StandardScope.oh_history.has(EquipmentOHHistory.last_oh_type != overhaul.maintenance_type.name)).distinct()
query = (
Select(StandardScope)
.outerjoin(
StandardScope.oh_history
) # Use outerjoin to handle None values
.join(StandardScope.workscope_groups)
.join(EquipmentWorkscopeGroup.workscope_group)
.join(MasterActivity.oh_types)
.join(WorkscopeOHType.oh_type)
.join(
MasterEquipment,
StandardScope.location_tag == MasterEquipment.location_tag,
)
.filter(MaintenanceType.name == overhaul.maintenance_type.name)
.filter(
(StandardScope.is_alternating_oh == False)
| (StandardScope.oh_history is None)
| (
StandardScope.oh_history.has(
EquipmentOHHistory.last_oh_type
!= overhaul.maintenance_type.name
)
)
)
.distinct()
)
data = await db_session.execute(query) data = await db_session.execute(query)
eqs = data.scalars().all() eqs = data.scalars().all()
results = [] results = []
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(eqs))
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)
for equipment in eqs: for equipment in eqs:
cost = material_cost.get(equipment.location_tag,0) cost = material_cost.get(equipment.location_tag,0)
oh_cost = overhaul_cost.get(equipment.location_tag,0) oh_cost = overhaul_cost.get(equipment.location_tag,0)
res = OverhaulActivityRead(id=equipment.id, material_cost=float(cost), service_cost=equipment.service_cost, overhaul_cost=float(oh_cost), location_tag=equipment.location_tag, equipment_name=equipment.master_equipment.name if equipment.master_equipment else None, oh_scope=overhaul.maintenance_type.name) res = OverhaulActivityRead(
id=equipment.id,
material_cost=float(cost),
service_cost=equipment.service_cost,
overhaul_cost=float(oh_cost),
location_tag=equipment.location_tag,
equipment_name=equipment.master_equipment.name if equipment.master_equipment else None,
oh_scope=overhaul.maintenance_type.name,
)
results.append(res) results.append(res)
return results return results
async def add_equipment_to_session(*, db_session: DbSession, collector_db: CollectorDbSession, overhaul_session_id: UUID, location_tag: str) -> Optional[StandardScope]:
async def add_equipment_to_session(
*,
db_session: DbSession,
collector_db: CollectorDbSession,
overhaul_session_id: UUID,
location_tag: str
) -> Optional[StandardScope]:
"""
Add a new equipment to an existing overhaul session.
If equipment's workscope already maps to the overhaul type, skip.
Otherwise, attach a dummy workscope group for inclusion.
Args:
db_session: Database session
collector_db: Collector database session
overhaul_session_id: The UUID of the overhaul session
location_tag: The location tag of the equipment to add
Returns:
StandardScope: The newly created or updated StandardScope record, or None if skipped
"""
try: try:
overhaul = await get_session(db_session=db_session, overhaul_session_id=overhaul_session_id) # Get the overhaul session
master_eq_query = select(MasterEquipment).filter(MasterEquipment.location_tag == location_tag) overhaul = await get_session(
db_session=db_session, overhaul_session_id=overhaul_session_id
)
# Check if MasterEquipment exists
master_eq_query = select(MasterEquipment).filter(
MasterEquipment.location_tag == location_tag
)
master_eq_result = await db_session.execute(master_eq_query) master_eq_result = await db_session.execute(master_eq_query)
master_equipment = master_eq_result.scalar_one_or_none() master_equipment = master_eq_result.scalar_one_or_none()
if not master_equipment: if not master_equipment:
print('Equipment not found in master') print("Equipment not found in master")
return None return None
existing_query = select(StandardScope).filter(StandardScope.location_tag == location_tag)
# Check if equipment already exists in StandardScope
existing_query = select(StandardScope).filter(
StandardScope.location_tag == location_tag
)
existing_result = await db_session.execute(existing_query) existing_result = await db_session.execute(existing_query)
existing_equipment = existing_result.scalar_one_or_none() existing_equipment = existing_result.scalar_one_or_none()
eq_workscope_query = select(EquipmentWorkscopeGroup).join(EquipmentWorkscopeGroup.workscope_group).join(WorkscopeOHType, WorkscopeOHType.workscope_group_id == MasterActivity.id).join(MaintenanceType, MaintenanceType.id == WorkscopeOHType.maintenance_type_id).filter(EquipmentWorkscopeGroup.location_tag == location_tag)
# --- Step 1: Fetch equipment's actual workscope mappings ---
eq_workscope_query = (
select(EquipmentWorkscopeGroup)
.join(EquipmentWorkscopeGroup.workscope_group)
.join(
WorkscopeOHType,
WorkscopeOHType.workscope_group_id == MasterActivity.id,
)
.join(
MaintenanceType,
MaintenanceType.id == WorkscopeOHType.maintenance_type_id,
)
.filter(EquipmentWorkscopeGroup.location_tag == location_tag)
)
eq_workscopes = (await db_session.execute(eq_workscope_query)).scalars().all() eq_workscopes = (await db_session.execute(eq_workscope_query)).scalars().all()
is_already_included = any((ws.workscope_group and any((wot.oh_type.name == overhaul.maintenance_type.name for wot in ws.workscope_group.oh_types)) for ws in eq_workscopes))
# --- Step 2: Check if already included via natural workscope ---
is_already_included = any(
ws.workscope_group
and any(
wot.oh_type.name == overhaul.maintenance_type.name
for wot in ws.workscope_group.oh_types
)
for ws in eq_workscopes
)
if is_already_included: if is_already_included:
# Already belongs to this overhaul naturally → skip
return None return None
# --- Step 3: Handle existing equipment ---
if existing_equipment: if existing_equipment:
dummy_workscope = await get_or_create_dummy_workscope(db_session=db_session, maintenance_type_name=overhaul.maintenance_type.name) # Add dummy workscope if not already attached
dummy_workscope = await get_or_create_dummy_workscope(
db_session=db_session,
maintenance_type_name=overhaul.maintenance_type.name,
)
if dummy_workscope not in existing_equipment.workscope_groups: if dummy_workscope not in existing_equipment.workscope_groups:
dummy_workscope.location_tag = location_tag dummy_workscope.location_tag = location_tag
await db_session.commit() await db_session.commit()
await db_session.refresh(existing_equipment) await db_session.refresh(existing_equipment)
return existing_equipment return existing_equipment
dummy_workscope = await get_or_create_dummy_workscope(db_session=db_session, maintenance_type_name=overhaul.maintenance_type.name)
new_equipment = StandardScope(location_tag=location_tag, is_alternating_oh=True, assigned_date=datetime.date.today()) # --- Step 4: Create new StandardScope for fresh equipment ---
dummy_workscope = await get_or_create_dummy_workscope(
db_session=db_session,
maintenance_type_name=overhaul.maintenance_type.name,
)
new_equipment = StandardScope(
location_tag=location_tag,
is_alternating_oh=True,
assigned_date=datetime.date.today(),
)
dummy_workscope.location_tag = location_tag dummy_workscope.location_tag = location_tag
db_session.add(new_equipment) db_session.add(new_equipment)
await db_session.commit() await db_session.commit()
await db_session.refresh(new_equipment) await db_session.refresh(new_equipment)
return new_equipment return new_equipment
except Exception as e: except Exception as e:
print(f'Error adding equipment {location_tag}: {str(e)}') print(f"Error adding equipment {location_tag}: {str(e)}")
await db_session.rollback() await db_session.rollback()
return None return None
async def get_or_create_dummy_workscope(*, db_session: DbSession, maintenance_type_name: str) -> EquipmentWorkscopeGroup:
dummy_name = f'Included Equipment Workscope - {maintenance_type_name}' async def get_or_create_dummy_workscope(
*,
db_session: DbSession,
maintenance_type_name: str,
) -> EquipmentWorkscopeGroup:
"""
Get or create a dummy workscope group for included equipment.
Args:
db_session: Database session
maintenance_type_name: Name of the maintenance type (e.g., "A", "B")
Returns:
EquipmentWorkscopeGroup: The dummy workscope group
"""
dummy_name = f"Included Equipment Workscope - {maintenance_type_name}"
# --- Step 1: Check if dummy MasterActivity exists ---
query = select(MasterActivity).filter(MasterActivity.workscope == dummy_name) query = select(MasterActivity).filter(MasterActivity.workscope == dummy_name)
result = await db_session.execute(query) result = await db_session.execute(query)
master_activity = result.scalar_one_or_none() master_activity = result.scalar_one_or_none()
if not master_activity: if not master_activity:
master_activity = MasterActivity(workscope=dummy_name) master_activity = MasterActivity(workscope=dummy_name)
db_session.add(master_activity) db_session.add(master_activity)
await db_session.commit() await db_session.commit()
await db_session.refresh(master_activity) await db_session.refresh(master_activity)
# --- Step 2: Ensure WorkscopeOHType link exists ---
mt_query = select(MaintenanceType).filter(MaintenanceType.name == maintenance_type_name) mt_query = select(MaintenanceType).filter(MaintenanceType.name == maintenance_type_name)
mt_result = await db_session.execute(mt_query) mt_result = await db_session.execute(mt_query)
maintenance_type = mt_result.scalar_one() maintenance_type = mt_result.scalar_one()
wo_oh_type_query = select(WorkscopeOHType).filter(and_(WorkscopeOHType.workscope_group_id == master_activity.id, WorkscopeOHType.maintenance_type_id == maintenance_type.id))
wo_oh_type_query = select(WorkscopeOHType).filter(
and_(
WorkscopeOHType.workscope_group_id == master_activity.id,
WorkscopeOHType.maintenance_type_id == maintenance_type.id,
)
)
workscope_oh_type = (await db_session.execute(wo_oh_type_query)).scalar_one_or_none() workscope_oh_type = (await db_session.execute(wo_oh_type_query)).scalar_one_or_none()
if not workscope_oh_type: if not workscope_oh_type:
workscope_oh_type = WorkscopeOHType(id=uuid4(), workscope_group=master_activity, oh_type=maintenance_type) workscope_oh_type = WorkscopeOHType(
id=uuid4(),
workscope_group=master_activity,
oh_type=maintenance_type,
)
db_session.add(workscope_oh_type) db_session.add(workscope_oh_type)
await db_session.commit() await db_session.commit()
await db_session.refresh(workscope_oh_type) await db_session.refresh(workscope_oh_type)
equipment_workscope_group = EquipmentWorkscopeGroup(workscope_group=master_activity)
# --- Step 3: Create new EquipmentWorkscopeGroup ---
equipment_workscope_group = EquipmentWorkscopeGroup(
workscope_group=master_activity,
)
db_session.add(equipment_workscope_group) db_session.add(equipment_workscope_group)
await db_session.commit() await db_session.commit()
await db_session.refresh(equipment_workscope_group) await db_session.refresh(equipment_workscope_group)
return equipment_workscope_group return equipment_workscope_group
async def add_multiple_equipment_to_session(*, db_session: DbSession, collector_db: CollectorDbSession, overhaul_session_id: UUID, location_tags: List[str]) -> List[StandardScope]:
async def add_multiple_equipment_to_session(
*,
db_session: DbSession,
collector_db: CollectorDbSession,
overhaul_session_id: UUID,
location_tags: List[str]
) -> List[StandardScope]:
"""
Add multiple equipment to an existing overhaul session.
Silently skips equipment that already exist or have errors.
Args:
db_session: Database session
collector_db: Collector database session
overhaul_session_id: The UUID of the overhaul session
location_tags: List of location tags to add
Returns:
List[StandardScope]: List of newly created/updated StandardScope records
"""
results = [] results = []
for location_tag in location_tags: for location_tag in location_tags:
equipment = await add_equipment_to_session(db_session=db_session, collector_db=collector_db, overhaul_session_id=overhaul_session_id, location_tag=location_tag) equipment = await add_equipment_to_session(
db_session=db_session,
collector_db=collector_db,
overhaul_session_id=overhaul_session_id,
location_tag=location_tag
)
if equipment: if equipment:
results.append(equipment) results.append(equipment)
return results return results
async def get_all_by_session_id(*, db_session: DbSession, overhaul_session_id): async def get_all_by_session_id(*, db_session: DbSession, overhaul_session_id):
query = Select(OverhaulActivity).where(OverhaulActivity.overhaul_scope_id == overhaul_session_id).options(joinedload(OverhaulActivity.equipment).options(joinedload(MasterEquipment.parent).options(joinedload(MasterEquipment.parent)))).options(selectinload(OverhaulActivity.overhaul_scope)) query = (
query = apply_not_deleted_filter(query, OverhaulActivity) Select(OverhaulActivity)
.where(OverhaulActivity.overhaul_scope_id == overhaul_session_id)
.options(joinedload(OverhaulActivity.equipment).options(joinedload(MasterEquipment.parent).options(joinedload(MasterEquipment.parent))))
.options(selectinload(OverhaulActivity.overhaul_scope))
)
results = await db_session.execute(query) results = await db_session.execute(query)
return results.scalars().all() return results.scalars().all()
async def update(*, db_session: DbSession, activity_id: str, overhaul_session_id: Optional[UUID]=None, overhaul_activity_in: OverhaulActivityUpdate):
query = Select(OverhaulActivity).where(OverhaulActivity.assetnum == activity_id).options(joinedload(OverhaulActivity.equipment)).with_for_update()
query = apply_not_deleted_filter(query, OverhaulActivity) async def update(
if overhaul_session_id: *,
query = query.filter(OverhaulActivity.overhaul_scope_id == overhaul_session_id) db_session: DbSession,
result = await db_session.execute(query) activity: OverhaulActivity,
activity = result.scalar() overhaul_activity_in: OverhaulActivityUpdate
if not activity: ):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='A data with this id does not exist.') """Updates a document."""
await db_session.refresh(activity) data = overhaul_activity_in.model_dump()
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) update_model(activity, update_data)
await db_session.commit() await db_session.commit()
return activity return activity
async def remove_equipment_from_session(*, db_session: DbSession, overhaul_session_id: UUID, location_tag: str) -> bool:
async def remove_equipment_from_session(
*,
db_session: DbSession,
overhaul_session_id: UUID,
location_tag: str
) -> bool:
"""
Remove equipment from a given overhaul session.
Only removes dummy workscope group links; natural workscope mappings are preserved.
Args:
db_session: Database session
overhaul_session_id: The UUID of the overhaul session
location_tag: The location tag of the equipment to remove
Returns:
bool: True if removed, False if skipped (not found or naturally included)
"""
try: try:
overhaul = await get_session(db_session=db_session, overhaul_session_id=overhaul_session_id) # Get the overhaul session
existing_query = select(StandardScope).filter(StandardScope.location_tag == location_tag) overhaul = await get_session(
db_session=db_session, overhaul_session_id=overhaul_session_id
)
# Find the equipment in StandardScope
existing_query = select(StandardScope).filter(
StandardScope.location_tag == location_tag
)
existing_result = await db_session.execute(existing_query) existing_result = await db_session.execute(existing_query)
equipment = existing_result.scalar_one_or_none() equipment = existing_result.scalar_one_or_none()
if not equipment: if not equipment:
print(f'Equipment {location_tag} not found in StandardScope') print(f"Equipment {location_tag} not found in StandardScope")
return False return False
eq_workscope_query = select(EquipmentWorkscopeGroup).join(EquipmentWorkscopeGroup.workscope_group).join(WorkscopeOHType, WorkscopeOHType.workscope_group_id == MasterActivity.id).join(MaintenanceType, MaintenanceType.id == WorkscopeOHType.maintenance_type_id).filter(EquipmentWorkscopeGroup.location_tag == location_tag).filter(~MasterActivity.workscope.like('Included Equipment Workscope%'))
# --- Step 1: Check if equipment belongs naturally (exclude dummy groups) ---
eq_workscope_query = (
select(EquipmentWorkscopeGroup)
.join(EquipmentWorkscopeGroup.workscope_group)
.join(
WorkscopeOHType,
WorkscopeOHType.workscope_group_id == MasterActivity.id,
)
.join(
MaintenanceType,
MaintenanceType.id == WorkscopeOHType.maintenance_type_id,
)
.filter(EquipmentWorkscopeGroup.location_tag == location_tag)
.filter(~MasterActivity.workscope.like("Included Equipment Workscope%")) # 🚨 exclude dummy
)
eq_workscopes = (await db_session.execute(eq_workscope_query)).scalars().all() eq_workscopes = (await db_session.execute(eq_workscope_query)).scalars().all()
is_natural_inclusion = any((ws.workscope_group and any((wot.oh_type.name == overhaul.maintenance_type.name for wot in ws.workscope_group.oh_types)) for ws in eq_workscopes))
is_natural_inclusion = any(
ws.workscope_group
and any(
wot.oh_type.name == overhaul.maintenance_type.name
for wot in ws.workscope_group.oh_types
)
for ws in eq_workscopes
)
if is_natural_inclusion: if is_natural_inclusion:
print(f'Equipment {location_tag} is naturally part of OH {overhaul.maintenance_type.name}, skip removal') print(f"Equipment {location_tag} is naturally part of OH {overhaul.maintenance_type.name}, skip removal")
return False return False
dummy_name = f'Included Equipment Workscope - {overhaul.maintenance_type.name}'
dummy_group = next((wg for wg in equipment.workscope_groups if wg.workscope_group.workscope == dummy_name), None) # --- Step 2: Remove dummy workscope group for this overhaul ---
dummy_name = f"Included Equipment Workscope - {overhaul.maintenance_type.name}"
dummy_group = next(
(wg for wg in equipment.workscope_groups if wg.workscope_group.workscope == dummy_name),
None,
)
if dummy_group: if dummy_group:
equipment.workscope_groups.remove(dummy_group) equipment.workscope_groups.remove(dummy_group)
await db_session.commit() await db_session.commit()
await db_session.refresh(equipment) await db_session.refresh(equipment)
# --- Step 3 (optional): Delete StandardScope if no groups left ---
if not equipment.workscope_groups: if not equipment.workscope_groups:
await db_session.delete(equipment) await db_session.delete(equipment)
await db_session.commit() await db_session.commit()
print(f'Equipment {location_tag} completely removed from StandardScope') print(f"Equipment {location_tag} completely removed from StandardScope")
return True return True
print(f'No dummy workscope group found for {location_tag} in OH {overhaul.maintenance_type.name}')
print(f"No dummy workscope group found for {location_tag} in OH {overhaul.maintenance_type.name}")
return False return False
except Exception as e: except Exception as e:
print(f'Error removing equipment {location_tag}: {str(e)}') print(f"Error removing equipment {location_tag}: {str(e)}")
await db_session.rollback() await db_session.rollback()
return False return False

@ -1,24 +1,37 @@
from decimal import Decimal, getcontext from decimal import Decimal, getcontext
def get_material_cost(scope, total_equipment): def get_material_cost(scope, total_equipment):
# Set precision to 28 digits (maximum precision for Decimal)
getcontext().prec = 28 getcontext().prec = 28
if not total_equipment:
if not total_equipment: # Guard against division by zero
return float(0) return float(0)
cost = 365539731101 / 10 cost = 365539731101 / 10
if scope == 'B':
result = Decimal(f'{cost}') / Decimal(str(total_equipment)) if scope == "B":
result = Decimal(f"{cost}") / Decimal(str(total_equipment))
return float(result) return float(result)
result = Decimal('8565468127') / Decimal(str(total_equipment)) else:
result = Decimal("8565468127") / Decimal(str(total_equipment))
return float(result) return float(result)
return float(0) return float(0)
def get_service_cost(scope, total_equipment): def get_service_cost(scope, total_equipment):
# Set precision to 28 digits (maximum precision for Decimal)
getcontext().prec = 28 getcontext().prec = 28
if not total_equipment:
if not total_equipment: # Guard against division by zero
return float(0) return float(0)
if scope == 'B':
result = Decimal('36405830225') / Decimal(str(total_equipment)) if scope == "B":
result = Decimal("36405830225") / Decimal(str(total_equipment))
return float(result) return float(result)
result = Decimal('36000000000') / Decimal(str(total_equipment)) else:
result = Decimal("36000000000") / Decimal(str(total_equipment))
return float(result) return float(result)
return float(0) return float(0)

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

@ -1,46 +1,142 @@
import re import re
from fastapi import APIRouter, Depends from typing import List, Optional
from fastapi import APIRouter, HTTPException, status
from sqlalchemy import select from sqlalchemy import select
from src.auth.service import Admin
from src.csrf_protect import csrf_protect 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.models import StandardResponse from src.models import StandardResponse
from src.overhaul_gantt.model import OverhaulGantt from src.overhaul_gantt.model import OverhaulGantt
from src.overhaul_gantt.schema import OverhaulGanttIn from src.overhaul_gantt.schema import OverhaulGanttIn
# from .schema import (OverhaulScheduleCreate, OverhaulSchedulePagination, OverhaulScheduleUpdate)
from .service import get_gantt_performance_chart from .service import get_gantt_performance_chart
router = APIRouter() router = APIRouter()
@router.get('', response_model=StandardResponse[dict])
@router.get(
"", response_model=StandardResponse[dict]
)
async def get_gantt_performance(db_session: DbSession): async def get_gantt_performance(db_session: DbSession):
"""Get all scope pagination."""
# return
query = select(OverhaulGantt).limit(1) query = select(OverhaulGantt).limit(1)
data = (await db_session.execute(query)).scalar_one_or_none() data = (await db_session.execute(query)).scalar_one_or_none()
results, gantt_data = await get_gantt_performance_chart(spreadsheet_id=data.spreadsheet_id) results, gantt_data = await get_gantt_performance_chart(spreadsheet_id=data.spreadsheet_id)
return StandardResponse(data={'chart_data': results, 'gantt_data': gantt_data}, message='Data retrieved successfully')
@router.get('/spreadsheet', response_model=StandardResponse[dict]) return StandardResponse(
data={
"chart_data": results,
"gantt_data": gantt_data
},
message="Data retrieved successfully",
)
@router.get(
"/spreadsheet", response_model=StandardResponse[dict]
)
async def get_gantt_spreadsheet(db_session: DbSession): async def get_gantt_spreadsheet(db_session: DbSession):
"""Get all scope pagination."""
# return
query = select(OverhaulGantt).limit(1) query = select(OverhaulGantt).limit(1)
data = (await db_session.execute(query)).scalar_one_or_none() data = (await db_session.execute(query)).scalar_one_or_none()
result = {'spreadsheet_id': None, 'spreadsheet_link': None} result = {
"spreadsheet_id": None,
"spreadsheet_link": None
}
if data: if data:
result = {'spreadsheet_id': data.spreadsheet_id, 'spreadsheet_link': data.spreadsheet_link} result = {
return StandardResponse(data=result, message='Data retrieved successfully') "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], dependencies=[Depends(csrf_protect)]) @router.post(
async def update_gantt_spreadsheet(db_session: DbSession, spreadsheet_in: OverhaulGanttIn, admin: Admin): "/spreadsheet", response_model=StandardResponse[dict]
match = re.search('/d/([a-zA-Z0-9-_]+)', spreadsheet_in.spreadsheet_link) )
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: if not match:
raise ValueError('Invalid Google Sheets URL') raise ValueError("Invalid Google Sheets URL")
spreadsheet_id = match.group(1) spreadsheet_id = match.group(1)
query = select(OverhaulGantt).limit(1) query = select(OverhaulGantt).limit(1)
data = (await db_session.execute(query)).scalar_one_or_none() data = (await db_session.execute(query)).scalar_one_or_none()
if data: if data:
data.spreadsheet_link = spreadsheet_in.spreadsheet_link data.spreadsheet_link = spreadsheet_in.spreadsheet_link
data.spreadsheet_id = spreadsheet_id data.spreadsheet_id = spreadsheet_id
else: else:
spreadsheet = OverhaulGantt(spreadsheet_id=spreadsheet_id, spreadsheet_link=spreadsheet_in.spreadsheet_link) spreadsheet = OverhaulGantt(
spreadsheet_id=spreadsheet_id,
spreadsheet_link=spreadsheet_in.spreadsheet_link
)
db_session.add(spreadsheet) db_session.add(spreadsheet)
await db_session.commit() await db_session.commit()
if data: if data:
result = {'spreadsheet_id': spreadsheet_id} result = {
return StandardResponse(data=result, message='Data retrieved successfully') "spreadsheet_id": spreadsheet_id
}
return StandardResponse(
data=result,
message="Data retrieved successfully",
)
# @router.post("", response_model=StandardResponse[None])
# async def create_overhaul_equipment_jobs(
# db_session: DbSession, overhaul_job_in: OverhaulScheduleCreate
# ):
# await create(
# db_session=db_session,
# overhaul_job_in=overhaul_job_in,
# )
# return StandardResponse(
# data=None,
# message="Data created successfully",
# )
# @router.put("/{overhaul_job_id}", response_model=StandardResponse[None])
# async def update_overhaul_schedule(
# db_session: DbSession, overhaul_job_id: str, overhaul_job_in: OverhaulScheduleUpdate
# ):
# await update(db_session=db_session, overhaul_schedule_id=overhaul_job_id, overhaul_job_in=overhaul_job_in)
# return StandardResponse(
# data=None,
# message="Data updated successfully",
# )
# @router.delete("/{overhaul_job_id}", response_model=StandardResponse[None])
# async def delete_overhaul_equipment_job(db_session: DbSession, overhaul_job_id):
# await delete(db_session=db_session, overhaul_schedule_id=overhaul_job_id)
# return StandardResponse(
# data=None,
# message="Data deleted successfully",
# )

@ -1,5 +1,48 @@
# from datetime import datetime
# from typing import List, Optional
# from uuid import UUID
# from pydantic import Field
# from src.models import DefultBase, Pagination
# from src.overhaul_scope.schema import ScopeRead
# from src.scope_equipment_job.schema import ScopeEquipmentJobRead
# from src.job.schema import ActivityMasterRead
from pydantic import Field from pydantic import Field
from src.models import DefultBase from src.models import DefultBase
class OverhaulGanttIn(DefultBase): class OverhaulGanttIn(DefultBase):
spreadsheet_link: str = Field(...) spreadsheet_link: str = Field(...)
# class OverhaulScheduleCreate(OverhaulScheduleBase):
# year: int
# plan_duration: Optional[int] = Field(None)
# planned_outage: Optional[int] = Field(None)
# actual_shutdown: Optional[int] = Field(None)
# start: datetime
# finish: datetime
# remark: Optional[str] = Field(None)
# class OverhaulScheduleUpdate(OverhaulScheduleBase):
# start: datetime
# finish: datetime
# class OverhaulScheduleRead(OverhaulScheduleBase):
# id: UUID
# year: int
# plan_duration: Optional[int]
# planned_outage: Optional[int]
# actual_shutdown: Optional[int]
# start: datetime
# finish: datetime
# remark: Optional[str]
# class OverhaulSchedulePagination(Pagination):
# items: List[OverhaulScheduleRead] = []

@ -1,34 +1,112 @@
from typing import Optional
from fastapi import HTTPException, status from fastapi import HTTPException, status
from sqlalchemy import Delete, Select, func
from sqlalchemy.orm import selectinload
# from .model import OverhaulSchedule
# from .schema import OverhaulScheduleCreate, OverhaulScheduleUpdate
from .utils import fetch_all_sections, get_google_creds, get_spreatsheed_service, process_spreadsheet_data from .utils import fetch_all_sections, get_google_creds, get_spreatsheed_service, process_spreadsheet_data
async def get_gantt_performance_chart(*, spreadsheet_id='1gZXuwA97zU1v4QBv56wKeiqadc6skHUucGKYG8qVFRk'): # async def get_all(*, common):
# """Returns all documents."""
# query = Select(OverhaulSchedule).order_by(OverhaulSchedule.start.desc())
# results = await search_filter_sort_paginate(model=query, **common)
# return results
# async def create(
# *, db_session: DbSession, overhaul_job_in: OverhaulScheduleCreate
# ):
# schedule = OverhaulSchedule(**overhaul_job_in.model_dump())
# db_session.add(schedule)
# await db_session.commit()
# return schedule
# async def update(*, db_session: DbSession, overhaul_schedule_id: str, overhaul_job_in: OverhaulScheduleUpdate):
# """Updates a document."""
# data = overhaul_job_in.model_dump()
# overhaul_schedule = await db_session.get(OverhaulSchedule, overhaul_schedule_id)
# update_data = overhaul_job_in.model_dump(exclude_defaults=True)
# for field in data:
# if field in update_data:
# setattr(overhaul_schedule, field, update_data[field])
# await db_session.commit()
# return overhaul_schedule
# async def delete(*, db_session: DbSession, overhaul_schedule_id: str):
# """Deletes a document."""
# query = Delete(OverhaulSchedule).where(OverhaulSchedule.id == overhaul_schedule_id)
# await db_session.execute(query)
# await db_session.commit()
async def get_gantt_performance_chart(*, spreadsheet_id = "1gZXuwA97zU1v4QBv56wKeiqadc6skHUucGKYG8qVFRk"):
creds = get_google_creds() creds = get_google_creds()
RANGE_NAME = "'SUMMARY'!K34:AZ38" RANGE_NAME = "'SUMMARY'!K34:AZ38" # Or just "2024 schedule"
GANTT_DATA_NAME = 'ACTUAL PROGRESS' 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()
values = response.get('values', []) response = sheet.values().get(
spreadsheetId=spreadsheet_id,
range=RANGE_NAME
).execute()
values = response.get("values", [])
if len(values) < 4: if len(values) < 4:
raise Exception('Spreadsheet format invalid: need 4 rows (DAY, DATE, PLAN, ACTUAL).') raise Exception("Spreadsheet format invalid: need 4 rows (DAY, DATE, PLAN, ACTUAL).")
# Extract rows
day_row = values[0][1:] day_row = values[0][1:]
date_row = values[1][1:] date_row = values[1][1:]
plan_row = values[3][1:] plan_row = values[3][1:]
actual_row = values[4][1:] actual_row = values[4][1:]
total_days = len(day_row) total_days = len(day_row)
date_row += [''] * (total_days - len(date_row))
plan_row += [''] * (total_days - len(plan_row)) # PAD rows so lengths match day count
actual_row += [''] * (total_days - len(actual_row)) date_row += [""] * (total_days - len(date_row))
plan_row += [""] * (total_days - len(plan_row))
actual_row += [""] * (total_days - len(actual_row))
results = [] results = []
for i in range(total_days): for i in range(total_days):
day = day_row[i] day = day_row[i]
date = date_row[i] date = date_row[i]
plan = plan_row[i] plan = plan_row[i]
actual = actual_row[i] if actual_row[i] else '0%' actual = actual_row[i] if actual_row[i] else "0%" # <-- FIX HERE
results.append({'day': day, 'date': date, 'plan': plan, 'actual': actual})
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=str(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) gantt_data = fetch_all_sections(service=service, spreadsheet_id=spreadsheet_id, sheet_name=GANTT_DATA_NAME)
return (processed_data, gantt_data)
return processed_data, gantt_data

@ -1,12 +1,22 @@
import urllib
from google.oauth2.service_account import Credentials from google.oauth2.service_account import Credentials
from google_auth_oauthlib.flow import InstalledAppFlow
from googleapiclient.discovery import build from googleapiclient.discovery import build
SCOPES = ['https://www.googleapis.com/auth/spreadsheets.readonly'] from googleapiclient.errors import HttpError
SCOPES = ["https://www.googleapis.com/auth/spreadsheets.readonly"]
def get_spreatsheed_service(credentials): def get_spreatsheed_service(credentials):
return build('sheets', 'v4', credentials=credentials, cache_discovery=False) return build("sheets", "v4", credentials=credentials, cache_discovery=False)
def get_google_creds(): def get_google_creds():
return Credentials.from_service_account_file('credentials.json', scopes=SCOPES) creds = None
creds = Credentials.from_service_account_file("credentials.json", scopes=SCOPES)
return creds
def process_spreadsheet_data(rows): def process_spreadsheet_data(rows):
processed_data = [] processed_data = []
@ -14,61 +24,107 @@ def process_spreadsheet_data(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) if processed_row else None
return processed_data return processed_data
from datetime import datetime from datetime import datetime
from datetime import datetime from datetime import datetime
def convert_spreadsheet_data(data, default_year=None): def convert_spreadsheet_data(data, default_year=None):
if not data.get('day') or not data['day'].isdigit(): """
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 return None
result = {} result = {}
result['day'] = int(data['day'])
# Convert day
result["day"] = int(data["day"])
# Determine default year
if default_year is None: if default_year is None:
default_year = datetime.now().year default_year = datetime.now().year
date_str = data.get('date', '').strip()
date_str = data.get("date", "").strip()
# ---------- DATE HANDLING ----------
# Accept formats like: "Nov 20", "Dec 3", "Jan 1"
parsed_date = None parsed_date = None
if date_str: if date_str:
try: try:
parsed_date = datetime.strptime(f'{date_str} {default_year}', '%b %d %Y') parsed_date = datetime.strptime(f"{date_str} {default_year}", "%b %d %Y")
except ValueError: except ValueError:
try: try:
parsed_date = datetime.strptime(f'{date_str} {default_year}', '%B %d %Y') parsed_date = datetime.strptime(f"{date_str} {default_year}", "%B %d %Y")
except: except:
parsed_date = None parsed_date = None
if parsed_date and parsed_date.month == 1 and ('Dec' in data.get('date', '')):
# 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) parsed_date = parsed_date.replace(year=default_year + 1)
result['date'] = parsed_date
result["date"] = parsed_date
# ---------- PERCENT HANDLING ----------
def parse_percent(value): def parse_percent(value):
if not value: if not value:
return 0.0 return 0.0
v = value.strip().replace(',', '.').replace('%', '') v = value.strip().replace(",", ".").replace("%", "")
try: try:
return float(v) / 100.0 return float(v) / 100.0
except: except:
return 0.0 return 0.0
result['plan'] = parse_percent(data.get('plan', '0'))
result['actual'] = parse_percent(data.get('actual', '0')) result["plan"] = parse_percent(data.get("plan", "0"))
result['gap'] = result['actual'] - result['plan'] result["actual"] = parse_percent(data.get("actual", "0"))
# Gap calculation
result["gap"] = result["actual"] - result["plan"]
return result return result
def fetch_all_sections(service, spreadsheet_id, sheet_name): def fetch_all_sections(service, spreadsheet_id, sheet_name):
result = service.spreadsheets().values().get(spreadsheetId=spreadsheet_id, range=f'{sheet_name}!A5:M5000').execute() # Fetch a wide range including columns AL
values = result.get('values', []) result = service.spreadsheets().values().get(
spreadsheetId=spreadsheet_id,
range=f"{sheet_name}!A5:M5000"
).execute()
values = result.get("values", [])
if not values: if not values:
raise ValueError('No data found in sheet') raise ValueError("No data found in sheet")
data = [] data = []
current_section = None current_section = None
current_subsystem = None current_subsystem = None
for row in values: for row in values:
row += [''] * (13 - len(row)) # 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 colA, colB, colC, colD, colE, colF, colG, colH, colI, colJ, colK, colL, colM = row
if colC and (not colD) and (not colE):
# Detect a SECTION — bold blue rows in Column C
if colC and not colD and not colE:
current_section = colC.strip() current_section = colC.strip()
current_subsystem = None current_subsystem = None
continue continue
if colD and (not colE):
# Detect a SUBSYSTEM — indented header in Column D
if colD and not colE:
current_subsystem = colD.strip() current_subsystem = colD.strip()
continue continue
# Detect a TASK — Column E populated
if colE: if colE:
task = colE.strip() task = colE.strip()
pic = colF.strip() pic = colF.strip()
@ -78,21 +134,64 @@ def fetch_all_sections(service, spreadsheet_id, sheet_name):
plan = colK.strip() plan = colK.strip()
actual = colL.strip() actual = colL.strip()
gap = colM.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})
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 return data
def indo_formatted_date(date_str, base_year=2025): def indo_formatted_date(date_str, base_year=2025):
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'} Convert short date like 'Nov 20', '30-Dec', 'Jan 1'
if '-' in date_str: into: 'Rabu, November 20, 2025'
d, m = date_str.split('-') If month is January, year becomes 2026.
date_str = f'{m} {d}' """
# 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")
if "-" in date_str:
d, m = date_str.split("-")
date_str = f"{m} {d}"
# Parse using English abbreviation
try: try:
dt = datetime.strptime(f'{date_str} {base_year}', '%b %d %Y') dt = datetime.strptime(f"{date_str} {base_year}", "%b %d %Y")
except: except:
return None return None
# Handle year rollover (Jan -> next year)
if dt.month == 1: if dt.month == 1:
dt = dt.replace(year=base_year + 1) dt = dt.replace(year=base_year + 1)
# Convert to Indonesian components
day_name = indo_days[dt.weekday()] day_name = indo_days[dt.weekday()]
month_name = eng_to_indo_month[dt.strftime('%b')] month_name = eng_to_indo_month[dt.strftime("%b")]
return f'{day_name}, {month_name} {dt.day}, {dt.year}'
return f"{day_name}, {month_name} {dt.day}, {dt.year}"

@ -1 +1,43 @@
from sqlalchemy import (UUID, Column, DateTime, Float, ForeignKey, Integer,
String)
from sqlalchemy.orm import relationship
from src.database.core import Base
from src.models import DefaultMixin, IdentityMixin, TimeStampMixin
# class EquipmentWorkscopeGroup(Base, DefaultMixin):
# __tablename__ = "oh_tr_equipment_workscope_group"
# # overhaul_activity_id = Column(
# # UUID(as_uuid=True), ForeignKey("oh_tr_overhaul_activity.id"), nullable=False
# # )
# # scope_equipment_job_id = Column(
# # UUID(as_uuid=True),
# # ForeignKey("oh_ms_scope_equipment_job.id", ondelete="cascade"),
# # nullable=False,
# # )
# # notes = Column(String, nullable=True)
# # status = Column(String, nullable=True, default="pending")
# workscope_group_id = Column(
# UUID(as_uuid=True), ForeignKey("oh_ms_workscope_group.id"), nullable=False
# )
# location_tag = Column(String, nullable=False)
# equipment = relationship(
# "StandardScope",
# lazy="selectin",
# primaryjoin="and_(OverhaulJob.location_tag == foreign(StandardScope.location_tag))",
# uselist=False, # Add this if it's a one-to-one relationship
# )
# workscope_group = relationship("MasterActivity", lazy="selectin", back_populates="equipment_workscope_groups")
# # scope_equipment_job = relationship(
# # "ScopeEquipmentJob", lazy="raise", back_populates="overhaul_jobs"
# # )
# # overhaul_activity = relationship("OverhaulActivity", lazy="raise", back_populates="overhaul_jobs")

@ -1,23 +1,91 @@
from typing import Optional from typing import List, Optional
from fastapi import APIRouter, Query
from fastapi import APIRouter, HTTPException, status, Query
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 .schema import OverhaulJobCreate, OverhaulJobPagination
from .schema import (OverhaulJobBase, OverhaulJobCreate, OverhaulJobPagination,
OverhaulJobRead)
from .service import create, delete, get_all from .service import create, delete, get_all
router = APIRouter() router = APIRouter()
@router.get('/{location_tag}', response_model=StandardResponse[OverhaulJobPagination])
@router.get(
"/{location_tag}", response_model=StandardResponse[OverhaulJobPagination]
)
async def get_jobs(common: CommonParameters, location_tag: str, scope: Optional[str] = Query(None)): async def get_jobs(common: CommonParameters, location_tag: str, scope: Optional[str] = Query(None)):
"""Get all scope pagination."""
# return
results = await get_all(common=common, location_tag=location_tag, scope=scope) results = await get_all(common=common, location_tag=location_tag, scope=scope)
return StandardResponse(data=results, message='Data retrieved successfully')
@router.post('/{overhaul_equipment_id}', response_model=StandardResponse[None]) return StandardResponse(
async def create_overhaul_equipment_jobs(db_session: DbSession, overhaul_equipment_id, overhaul_job_in: OverhaulJobCreate): data=results,
await create(db_session=db_session, overhaul_equipment_id=overhaul_equipment_id, overhaul_job_in=overhaul_job_in) message="Data retrieved successfully",
return StandardResponse(data=None, message='Data created successfully') )
@router.post('/delete/{overhaul_job_id}', response_model=StandardResponse[None]) @router.post("/{overhaul_equipment_id}", response_model=StandardResponse[None])
async def create_overhaul_equipment_jobs(
db_session: DbSession, overhaul_equipment_id, overhaul_job_in: OverhaulJobCreate
):
"""Get all scope activity pagination."""
# return
await create(
db_session=db_session,
overhaul_equipment_id=overhaul_equipment_id,
overhaul_job_in=overhaul_job_in,
)
return StandardResponse(
data=None,
message="Data created successfully",
)
@router.post("/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)
return StandardResponse(data=None, message='Data deleted successfully')
return StandardResponse(
data=None,
message="Data deleted successfully",
)
# @router.post("", response_model=StandardResponse[List[str]])
# async def create_scope(db_session: DbSession, scope_in: OverhaulJobCreate):
# overhaul_job = await create(db_session=db_session, scope_in=scope_in)
# return StandardResponse(data=overhaul_job, message="Data created successfully")
# @router.put("/{scope_id}", response_model=StandardResponse[ScopeRead])
# async def update_scope(db_session: DbSession, scope_id: str, scope_in: ScopeUpdate, current_user: CurrentUser):
# scope = await get(db_session=db_session, scope_id=scope_id)
# if not scope:
# raise HTTPException(
# status_code=status.HTTP_404_NOT_FOUND,
# detail="A data with this id does not exist.",
# )
# return StandardResponse(data=await update(db_session=db_session, scope=scope, scope_in=scope_in), message="Data updated successfully")
# @router.delete("/{scope_id}", response_model=StandardResponse[ScopeRead])
# async def delete_scope(db_session: DbSession, scope_id: str):
# scope = await get(db_session=db_session, scope_id=scope_id)
# if not scope:
# raise HTTPException(
# status_code=status.HTTP_404_NOT_FOUND,
# detail=[{"msg": "A data with this id does not exist."}],
# )
# await delete(db_session=db_session, scope_id=scope_id)
# return StandardResponse(message="Data deleted successfully", data=scope)

@ -1,18 +1,26 @@
from datetime import datetime
from typing import List, Optional from typing import List, Optional
from uuid import UUID from uuid import UUID
from pydantic import Field
from src.models import DefultBase, Pagination from src.models import DefultBase, Pagination
from src.overhaul_scope.schema import ScopeRead from src.overhaul_scope.schema import ScopeRead
from src.scope_equipment_job.schema import ScopeEquipmentJobRead
from src.job.schema import ActivityMasterRead from src.job.schema import ActivityMasterRead
class OverhaulJobBase(DefultBase): class OverhaulJobBase(DefultBase):
pass pass
class OverhaulJobCreate(OverhaulJobBase): class OverhaulJobCreate(OverhaulJobBase):
job_ids: Optional[List[UUID]] = [] job_ids: Optional[List[UUID]] = []
class OverhaulJobUpdate(OverhaulJobBase): class OverhaulJobUpdate(OverhaulJobBase):
pass pass
class OverhaulActivity(DefultBase): class OverhaulActivity(DefultBase):
id: UUID id: UUID
overhaul_scope_id: UUID overhaul_scope_id: UUID
@ -26,5 +34,7 @@ class OverhaulJobRead(OverhaulJobBase):
scope_equipment_job: ScopeEquipment scope_equipment_job: ScopeEquipment
overhaul_activity: OverhaulActivity overhaul_activity: OverhaulActivity
class OverhaulJobPagination(Pagination): class OverhaulJobPagination(Pagination):
items: List[OverhaulJobRead] = [] items: List[OverhaulJobRead] = []

@ -1,41 +1,125 @@
from typing import Optional from typing import Optional
from fastapi import HTTPException, status from fastapi import HTTPException, status
from sqlalchemy import Select from sqlalchemy import Delete, Select, func
from sqlalchemy.orm import selectinload
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.scope_equipment_job.model import ScopeEquipmentJob
# from src.overhaul_activity.model import OverhaulActivity
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
from src.overhaul_scope.model import MaintenanceType from src.overhaul_scope.model import MaintenanceType
from src.equipment_workscope_group.model import EquipmentWorkscopeGroup from .model import EquipmentWorkscopeGroup
from .schema import OverhaulJobCreate from .schema import OverhaulJobCreate
from src.soft_delete import apply_not_deleted_filter, soft_delete_record
async def get_all(*, common, location_tag: str, scope: Optional[str] = None): async def get_all(*, common, location_tag: str, scope: Optional[str] = None):
query = Select(EquipmentWorkscopeGroup).where(EquipmentWorkscopeGroup.location_tag == location_tag) """Returns all documents."""
query = apply_not_deleted_filter(query, EquipmentWorkscopeGroup) query = (
Select(EquipmentWorkscopeGroup)
.where(EquipmentWorkscopeGroup.location_tag == location_tag)
)
if scope: if scope:
query = query.join(EquipmentWorkscopeGroup.workscope_group).join(MasterActivity.oh_types).join(WorkscopeOHType.oh_type).filter(MaintenanceType.name == scope) query = (
return await search_filter_sort_paginate(model=query, **common) query
.join(EquipmentWorkscopeGroup.workscope_group)
.join(MasterActivity.oh_types)
.join(WorkscopeOHType.oh_type)
.filter(MaintenanceType.name == scope)
)
results = await search_filter_sort_paginate(model=query, **common)
return results
async def create(*, db_session: DbSession, overhaul_equipment_id, overhaul_job_in: OverhaulJobCreate):
async def create(
*, db_session: DbSession, overhaul_equipment_id, overhaul_job_in: OverhaulJobCreate
):
overhaul_jobs = [] overhaul_jobs = []
if not overhaul_equipment_id: if not overhaul_equipment_id:
raise ValueError('assetnum parameter is required') raise ValueError("assetnum parameter is required")
equipment_stmt = Select(OverhaulJob).where(OverhaulJob.overhaul_activity_id == overhaul_equipment_id)
await db_session.scalar(equipment_stmt) equipment_stmt = Select(OverhaulJob).where(
OverhaulJob.overhaul_activity_id == overhaul_equipment_id
)
equipment = await db_session.scalar(equipment_stmt)
for job_id in overhaul_job_in.job_ids: for job_id in overhaul_job_in.job_ids:
overhaul_equipment_job = OverhaulJob(overhaul_activity_id=overhaul_equipment_id, scope_equipment_job_id=job_id) overhaul_equipment_job = OverhaulJob(
overhaul_activity_id=overhaul_equipment_id, scope_equipment_job_id=job_id
)
overhaul_jobs.append(overhaul_equipment_job) overhaul_jobs.append(overhaul_equipment_job)
db_session.add_all(overhaul_jobs) db_session.add_all(overhaul_jobs)
await db_session.commit() await db_session.commit()
return overhaul_job_in.job_ids return overhaul_job_in.job_ids
async def delete(*, db_session: DbSession, overhaul_job_id: str) -> bool:
async def delete(
*,
db_session: DbSession,
overhaul_job_id: str,
) -> bool:
"""
Deletes a scope job and returns success status.
Args:
db_session: Database session
scope_job_id: ID of the scope job to delete
user_id: ID of user performing the deletion
Returns:
bool: True if deletion was successful, False otherwise
Raises:
NotFoundException: If scope job doesn't exist
AuthorizationError: If user lacks delete permission
"""
try: try:
deleted = await soft_delete_record(db_session, EquipmentWorkscopeGroup, overhaul_job_id) # Check if job exists
if not deleted: scope_job = await db_session.get(OverhaulJob, overhaul_job_id)
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='A data with this id does not exist.') if not scope_job:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="A data with this id does not exist.",
)
# Perform deletion
await db_session.delete(scope_job)
await db_session.commit()
return True return True
except Exception:
except Exception as e:
await db_session.rollback() await db_session.rollback()
raise raise
# async def update(*, db_session: DbSession, scope: OverhaulScope, scope_in: ScopeUpdate):
# """Updates a document."""
# data = scope_in.model_dump()
# update_data = scope_in.model_dump(exclude_defaults=True)
# for field in data:
# if field in update_data:
# setattr(scope, field, update_data[field])
# await db_session.commit()
# return scope
# async def delete(*, db_session: DbSession, scope_id: str):
# """Deletes a document."""
# query = Delete(OverhaulScope).where(OverhaulScope.id == scope_id)
# await db_session.execute(query)
# await db_session.commit()

@ -1,13 +1,18 @@
from sqlalchemy import Column, DateTime, Integer, String from sqlalchemy import (UUID, Column, DateTime, Float, ForeignKey, Integer,
String)
from sqlalchemy.orm import relationship
from src.database.core import Base from src.database.core import Base
from src.models import DefaultMixin from src.models import DefaultMixin, IdentityMixin, TimeStampMixin
class OverhaulSchedule(Base, DefaultMixin): class OverhaulSchedule(Base, DefaultMixin):
__tablename__ = 'rp_oh_schedule' __tablename__ = "rp_oh_schedule"
year = Column(Integer, nullable=False) year = Column(Integer, nullable=False)
plan_duration = Column(Integer, nullable=True) plan_duration = Column(Integer, nullable=True)
planned_outage = Column(Integer, nullable=True) planned_outage = Column(Integer, nullable=True)
actual_shutdown = Column(Integer, nullable=True) actual_shutdown = Column(Integer, nullable=True)
start = Column(DateTime(timezone=True)) start = Column(DateTime(timezone=True)) # This will be TIMESTAMP WITH TIME ZONE
finish = Column(DateTime(timezone=True)) finish = Column(DateTime(timezone=True))
remark = Column(String, nullable=True) remark = Column(String, nullable=True)

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save