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_NAME=digital_twin
COLLECTOR_HOSTNAME=192.168.1.82
COLLECTOR_PORT=1111
COLLECTOR_CREDENTIAL_USER=digital_twin
COLLECTOR_CREDENTIAL_PASSWORD=Pr0jec7@D!g!tTwiN
COLLECTOR_HOSTNAME=192.168.1.86
COLLECTOR_PORT=5432
COLLECTOR_CREDENTIAL_USER=postgres
COLLECTOR_CREDENTIAL_PASSWORD=postgres
COLLECTOR_NAME=digital_twin
TEMPORAL_URL=http://192.168.1.86:7233
# COLLECTOR_PORT=1111
# COLLECTOR_CREDENTIAL_USER=digital_twin
# COLLECTOR_CREDENTIAL_PASSWORD=Pr0jec7@D!g!tTwiN
# COLLECTOR_NAME=digital_twin
# COLLECTOR_HOSTNAME=192.168.1.86
# COLLECTOR_PORT=5432

3
.gitignore vendored

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

@ -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 |

238
Jenkinsfile vendored

@ -6,22 +6,16 @@ pipeline {
// This creates DOCKER_AUTH_USR and DOCKER_AUTH_PSW
DOCKER_AUTH = credentials('aimodocker')
IMAGE_NAME = 'oh-service'
SERVICE_NAME = 'be-optimumoh'
SERVICE_NAME = 'ahm-app'
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
GIT_COMMIT_HASH = ""
IMAGE_TAG = ""
SECONDARY_TAG = ""
}
stages {
stage('Checkout & Setup') {
steps {
@ -48,7 +42,6 @@ pipeline {
}
}
}
stage('Docker Login') {
steps {
@ -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') {
steps {
script {
def fullImageName = "${DOCKER_HUB_USERNAME}/${IMAGE_NAME}"
// Build image TANPA vault.env - image tetap bersih
sh "docker build -t ${fullImageName}:${IMAGE_TAG} ."
if (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 {
def fullImageName = "${DOCKER_HUB_USERNAME}/${IMAGE_NAME}"
sh "docker push ${fullImageName}:${IMAGE_TAG}"
if (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 {
always {
script {
// Hapus vault.env dari Jenkins workspace
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'
sh 'docker logout'
def fullImageName = "${DOCKER_HUB_USERNAME}/${IMAGE_NAME}"
// Clean up images to save agent disk space
sh "docker rmi ${fullImageName}:${IMAGE_TAG} || true"
if (SECONDARY_TAG) {
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]]
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\""]
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]]
name = "asyncpg"
version = "0.30.0"
@ -464,6 +451,114 @@ files = [
{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]]
name = "deprecated"
version = "1.2.15"
@ -600,6 +695,79 @@ uvicorn = {version = ">=0.15.0", extras = ["standard"]}
[package.extras]
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]]
name = "frozenlist"
version = "1.7.0"
@ -738,7 +906,7 @@ requests = ">=2.18.0,<3.0.0"
[package.extras]
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)"]
grpcio-gcp = ["grpcio-gcp (>=0.2.2,<1.0.dev0)"]
@ -1136,6 +1304,133 @@ MarkupSafe = ">=2.0"
[package.extras]
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]]
name = "limits"
version = "3.13.0"
@ -1262,6 +1557,85 @@ files = [
{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]]
name = "mdurl"
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)"]
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]]
name = "pluggy"
version = "1.5.0"
@ -2218,29 +2701,6 @@ files = [
{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]]
name = "requests"
version = "2.32.3"
@ -3082,4 +3542,4 @@ propcache = ">=0.2.1"
[metadata]
lock-version = "2.1"
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"
aiohttp = "^3.12.14"
ortools = "^9.14.6206"
redis = "^7.3.0"
matplotlib = "^3.10.9"
[build-system]

@ -1,5 +1,6 @@
import uvicorn
from src.config import HOST, PORT, ENV
if __name__ == '__main__':
is_dev = str(ENV).lower() == 'development'
uvicorn.run('src.main:app', host=HOST, port=PORT, reload=is_dev)
from src.config import HOST, PORT
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 fastapi import APIRouter, Depends
from fastapi.responses import JSONResponse
from pydantic import BaseModel
from src.auth.service import JWTBearer
from src.calculation_budget_constrains.router import router as calculation_budget_constraint
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.calculation_budget_constrains.router import \
router as calculation_budget_constraint
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.standard_scope.router import router as standard_scope_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.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.sparepart.router import router as 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.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):
msg: str
class ErrorResponse(BaseModel):
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():
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.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(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'])
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'])
return {"status": "ok"}
authenticated_api_router = APIRouter(
dependencies=[Depends(JWTBearer())],
)
# overhaul data
authenticated_api_router.include_router(
overhaul_router, prefix="/overhauls", tags=["overhaul"]
)
# authenticated_api_router.include_router(job_router, prefix="/jobs", tags=["job"])
# # # 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(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)

@ -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
class UserBase(BaseModel):
name: Optional[str] = None
name: str
role: str
user_id: str

@ -1,135 +1,235 @@
# app/auth/auth_bearer.py
import json
from typing import Annotated
from typing import Annotated, Optional
import requests
from fastapi import Depends, HTTPException, Request
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy.sql.expression import false
import src.config as config
from .model import UserBase
from .util import extract_template
class JWTBearer(HTTPBearer):
def __init__(self, auto_error: bool=True):
super(JWTBearer, self).__init__(auto_error=False)
def __init__(self, auto_error: bool = True):
super(JWTBearer, self).__init__(auto_error=auto_error)
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 not credentials.scheme == 'Bearer':
raise HTTPException(status_code=401, detail='Invalid authentication scheme.')
if not credentials.scheme == "Bearer":
raise HTTPException(
status_code=403, detail="Invalid authentication scheme."
)
method = request.method
if method == 'OPTIONS':
return None
if method == "OPTIONS":
return
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)
if not user_info:
if isinstance(message, dict):
message = message.get('message') or message.get('detail') or 'Invalid token or expired token.'
else:
message = str(message) if message else 'Invalid token or expired token.'
raise HTTPException(status_code=401, detail=message)
message = message.get("message", "Invalid token or expired token.")
raise HTTPException(
status_code=403, detail=message
)
request.state.user = message
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))
username = getattr(message, 'username', None) or getattr(message, 'name', None)
if username:
set_username(username)
if hasattr(message, 'role'):
if hasattr(message, "username"):
set_username(message.username)
elif hasattr(message, "name"):
set_username(message.name)
if hasattr(message, "role"):
set_role(message.role)
return message
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):
try:
url_to_verify = f'{config.AUTH_SERVICE_API}/verify-token'
response = requests.get(url_to_verify, headers={'Authorization': f'Bearer {jwtoken}'})
response = requests.get(
f"{config.AUTH_SERVICE_API}/verify-token",
headers={"Authorization": f"Bearer {jwtoken}"},
)
if not response.ok:
return (False, response.json())
return False, response.json()
user_data = response.json()
return (True, UserBase(**user_data['data']))
return True, UserBase(**user_data["data"])
except Exception as e:
print(f'Token verification error: {str(e)}')
return (False, str(e))
print(f"Token verification error: {str(e)}")
return False, str(e)
# Create dependency to get current user from request state
async def get_current_user(request: Request) -> UserBase:
return request.state.user
async def get_token(request: Request):
token = request.headers.get('Authorization')
token = request.headers.get("Authorization")
if token:
return token.replace('Bearer ', '')
return request.cookies.get('access_token')
return ''
return token.replace("Bearer ", "") # Menghapus prefix "Bearer "
else:
return request.cookies.get("access_token") # Fallback ke cookie
return "" # Mengembalikan token atau None jika tidak ada
async def internal_key(request: Request):
token = request.headers.get('Authorization')
if not token:
api_key = request.headers.get('X-Internal-Key')
token = request.headers.get("Authorization")
if not token:
api_key = request.headers.get("X-Internal-Key")
if api_key != config.API_KEY:
raise HTTPException(status_code=401, detail='Invalid Key.')
raise HTTPException(
status_code=403, detail="Invalid Key."
)
try:
headers = {'Content-Type': 'application/json'}
response = requests.post(f'{config.AUTH_SERVICE_API}/sign-in', headers=headers, data=json.dumps({'username': 'ohuser', 'password': '123456789'}))
headers = {
'Content-Type': 'application/json'
}
response = requests.post(
f"{config.AUTH_SERVICE_API}/sign-in",
headers=headers,
data=json.dumps({
"username": "ohuser",
"password": "123456789"
})
)
if not response.ok:
print(str(response.json()))
raise Exception('error auth')
raise Exception("error auth")
user_data = response.json()
return user_data['data']['access_token']
except Exception as e:
raise Exception(str(e))
else:
try:
verify_url = f'{config.AUTH_SERVICE_API}/verify-token'
response = requests.get(verify_url, headers={'Authorization': f'{token}'})
response = requests.get(
f"{config.AUTH_SERVICE_API}/verify-token",
headers={"Authorization": f"{token}"},
)
if not response.ok:
raise HTTPException(status_code=401, detail='Invalid token.')
return token.split(' ')[1]
raise HTTPException(
status_code=403, detail="Invalid token."
)
return token.split(" ")[1]
except Exception as e:
print(f'Token verification error: {str(e)}')
return (False, str(e))
import asyncio
print(f"Token verification error: {str(e)}")
return False, str(e)
import httpx
import logging
from typing import Dict, Any
import src.config as config
log = logging.getLogger(__name__)
AUTH_NOTIFY_ENDPOINT = f'{config.AUTH_SERVICE_API}/admin/notify-limit'
async def notify_admin_on_rate_limit(endpoint_name: str, ip_address: str, 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 {}
AUTH_NOTIFY_ENDPOINT = f"{config.AUTH_SERVICE_API}/admin/notify-limit"
async def notify_admin_on_rate_limit(
endpoint_name: str,
ip_address: str,
method: str = "POST",
cooldown: int = 900,
timeout: int = 5
) -> Dict[str, Any]:
"""
Kirim notifikasi ke admin via be-auth service ketika rate limit terlampaui.
Async version - gunakan di async context.
"""
payload = {
"endpoint_name": endpoint_name,
"ip_address": ip_address,
"method": method,
"cooldown": cooldown,
}
try:
response = await asyncio.to_thread(requests.post, AUTH_NOTIFY_ENDPOINT, json=payload, headers=headers, timeout=timeout)
response.raise_for_status()
result = response.json()
log.info(f'Notifikasi admin sent | Endpoint: {endpoint_name}')
return result
async with httpx.AsyncClient(timeout=timeout) as client:
response = await client.post(AUTH_NOTIFY_ENDPOINT, json=payload)
response.raise_for_status()
result = response.json()
log.info(f"Notifikasi admin sent | Endpoint: {endpoint_name}")
return result
except Exception as e:
log.error(f'Error notifying admin: {str(e)}')
return {'status': False, 'message': str(e), 'data': payload}
log.error(f"Error notifying admin: {str(e)}")
return {"status": False, "message": str(e), "data": payload}
def notify_admin_on_rate_limit_sync(
endpoint_name: str,
ip_address: str,
method: str = "POST",
cooldown: int = 900,
timeout: int = 5
) -> Dict[str, Any]:
"""
Kirim notifikasi ke admin via be-auth service.
Sync version - gunakan di exception handler atau sync context.
RECOMMENDED untuk use case ini.
"""
payload = {
"endpoint_name": endpoint_name,
"ip_address": ip_address,
"method": method,
"cooldown": cooldown,
}
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:
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()
result = response.json()
log.info(f'Notifikasi admin sent | Endpoint: {endpoint_name}')
log.info(f"Notifikasi admin sent | Endpoint: {endpoint_name}")
return result
except Exception as e:
log.error(f'Error notifying admin: {str(e)}')
return {'status': False, 'message': str(e), 'data': payload}
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)]
log.error(f"Error notifying admin: {str(e)}")
return {"status": False, "message": str(e), "data": payload}
CurrentUser = Annotated[UserBase, Depends(get_current_user)]
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):
template = path_string
# Replace each value in the dict with its corresponding key placeholder
for key, value in value_dict.items():
if str(value) in template:
template = template.replace(str(value), f'<{key}>')
return template

@ -1,21 +1,43 @@
from typing import Annotated, Dict
from fastapi import APIRouter
from typing import Annotated, Dict, List, Optional
from fastapi import APIRouter, HTTPException, status
from fastapi.params import Query
from src.auth.service import Token
from src.calculation_budget_constrains.schema import BudgetContraintQuery
from src.calculation_target_reliability.service import get_simulation_results
from src.config import TC_RBD_ID
from src.database.core import CollectorDbSession, DbSession
from src.models import StandardResponse
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))])
async def get_target_reliability(db_session: DbSession, token: Token, session_id: str, collector_db: CollectorDbSession, params: Annotated[BudgetContraintQuery, Query()]):
router = APIRouter()
@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
results = await get_simulation_results(simulation_id=TC_RBD_ID, 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')
results = await get_simulation_results(
simulation_id = TC_RBD_ID,
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 src.models import DefultBase
from src.models import DefultBase, Pagination
class OverhaulBase(BaseModel):
pass
class OverhaulCriticalParts(OverhaulBase):
criticalParts: List[str] = Field(..., description='List of critical parts')
criticalParts: List[str] = Field(..., description="List of critical parts")
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):
systemComponents: Dict[str, Any] = Field(..., description='List of system components')
systemComponents: Dict[str, Any] = Field(
..., description="List of system components"
)
class OverhaulRead(OverhaulBase):
overview: Dict[str, Any]
@ -20,5 +31,44 @@ class OverhaulRead(OverhaulBase):
schedules: List[Dict[str, Any]]
systemComponents: Dict[str, Any]
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
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
import random
from typing import Optional, List, Dict, Tuple
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.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=100000000, use_optimal: bool=True) -> Tuple[List[Dict], List[Dict]]:
calc_result = simulation_result['calc_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)
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).
"""
calc_result = simulation_result["calc_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,
)
if not equipments:
return ([], [])
return [], []
# Flatten results
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 = []
for equipment in equipments:
contribution = equipments_eaf_contribution.get(equipment.location_tag, 0.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:
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:
cost = item['total_cost'] or 1.0
item['contribution_norm'] / cost
item['priority_score'] = item['contribution_norm']
cost = item["total_cost"] or 1.0
efficiency = item["contribution_norm"] / cost
item["priority_score"] = item["contribution_norm"]
# Choose method
if use_optimal:
priority_list, consequence_list = knapsack_selection(result, cost_threshold)
else:
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):
"""
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)
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:
results[node_name] = asset.get('contribution_factor', 0.0)
results[node_name] = asset.get("contribution_factor", 0.0)
return results
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)
current_cost = 0.0
selected, excluded = ([], [])
"""Greedy selection: select items by score until budget is used."""
# Sort by priority_score descending
equipments_sorted = sorted(equipments, key=lambda x: x["priority_score"], reverse=True)
current_cost_total = 0.0
selected, excluded = [], []
for eq in equipments_sorted:
cost = eq.get('total_cost', 0.0)
if current_cost + cost <= budget:
cost = eq.get("total_cost", 0.0)
if current_cost_total + cost <= budget:
selected.append(eq)
current_cost += cost
current_cost_total += cost
else:
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:
return ([], [])
return [], []
# Pre-filter: strictly impossible items
eligible_items = []
strictly_excluded = []
for eq in equipments:
if eq['total_cost'] > budget:
if eq["total_cost"] > budget:
strictly_excluded.append(eq)
else:
eligible_items.append(eq)
if not eligible_items:
return ([], strictly_excluded)
return [], strictly_excluded
n = len(eligible_items)
# Dynamic scaling for big numbers: target higher W (10,000) for better precision
if scale is None:
target_W = 2000
target_W = 10000
scale = max(1.0, budget / target_W)
costs = [int(math.ceil(eq['total_cost'] / scale)) for eq in eligible_items]
values = [eq['priority_score'] for eq in eligible_items]
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)
if W > 1000000:
return greedy_selection(equipments, budget)
# 1D DP array
dp = [0.0] * (W + 1)
# 2D table for backtracking
keep = [[False] * (W + 1) for _ 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):
if dp[w - cost] + value >= dp[w]:
dp[w] = dp[w - cost] + value
keep[i][w] = True
# Backtrack
selected = []
backtrack_excluded = []
w = W
@ -95,14 +170,22 @@ def knapsack_selection(equipments: List[dict], budget: float, scale: Optional[fl
w -= costs[i]
else:
backtrack_excluded.append(eligible_items[i])
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:
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[:]:
if eq['total_cost'] <= remaining_budget:
if eq["total_cost"] <= remaining_budget:
selected.append(eq)
excluded.remove(eq)
remaining_budget -= eq['total_cost']
return (selected, excluded)
remaining_budget -= eq["total_cost"]
return selected, excluded

@ -1,45 +1,150 @@
import asyncio
from typing import Dict, List, Optional
from typing_extensions import Annotated
from temporalio.client import Client
from fastapi import APIRouter, HTTPException, status
from fastapi.params import Query
from src.calculation_target_reliability.utils import wait_for_workflow
from src.config import TEMPORAL_URL, TR_RBD_ID
from src.database.core import DbSession, CollectorDbSession
from src.auth.service import Token
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 src.auth.access_control import required_permission, OHPermission
from src.overhaul_scope.model import OverhaulScope
from fastapi import Depends
router = APIRouter()
@router.get('', response_model=StandardResponse[OptimizationResult], dependencies=[Depends(required_permission(OHPermission.READ, OverhaulScope))])
async def get_target_reliability(db_session: DbSession, token: Token, collector_db: CollectorDbSession, params: Annotated[TargetReliabiltiyQuery, Query()]):
class SimulateRequest(BaseModel):
duration: int = Field(17520, description="Simulation duration in hours")
oh_duration: int = Field(1200, description="Overhaul duration in hours")
oh_session_id: str = Field(..., description="Overhaul session ID")
@router.post("/simulate", response_model=StandardResponse)
async def start_simulation(
token: Token,
db_session: DbSession,
payload: SimulateRequest
):
"""Start RBD simulation for target reliability."""
token_str = token.token if hasattr(token, 'token') else str(token)
result = await run_rbd_simulation(
sim_hours=payload.duration,
oh_duration=payload.oh_duration,
oh_session_id=payload.oh_session_id,
db_session=db_session,
token=token_str
)
return StandardResponse(
data={"simulation_id": result.get("data")},
message=result.get("message", "Simulation started successfully")
)
# @router.get("", response_model=StandardResponse[List[Dict]])
# 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
eaf_input = params.eaf_input
duration = params.duration
simulation_id = params.simulation_id
cut_hours = params.cut_hours
oh_duration = params.oh_duration
if not oh_session_id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='oh_session_id is required')
if duration != 17520:
if not simulation_id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Simulation ID is required for non-default duration. Please run simulation first.')
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="oh_session_id is required",
)
# 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:
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()
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 != 'COMPLETED':
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f'Simulation failed with status: {status_name}')
if status_name in ["RUNNING", "CONTINUED_AS_NEW"]:
raise HTTPException(
status_code=status.HTTP_425_TOO_EARLY,
detail="Simulation is still running.",
)
elif status_name != "COMPLETED":
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Simulation failed with status: {status_name}",
)
except HTTPException:
raise
except Exception as e:
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:
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
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 uuid import UUID
from pydantic import BaseModel, Field
from src.models import DefultBase
from src.models import DefultBase, Pagination
class OverhaulBase(BaseModel):
pass
class OverhaulCriticalParts(OverhaulBase):
criticalParts: List[str] = Field(..., description='List of critical parts')
criticalParts: List[str] = Field(..., description="List of critical parts")
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):
systemComponents: Dict[str, Any] = Field(..., description='List of system components')
systemComponents: Dict[str, Any] = Field(
..., description="List of system components"
)
class OverhaulRead(OverhaulBase):
overview: Dict[str, Any]
@ -22,13 +33,13 @@ class OverhaulRead(OverhaulBase):
class AssetWeight(OverhaulBase):
node: dict
availability: float
availability:float
contribution: float
required_improvement: float
num_of_failures: int
down_time: float
efficiency: float
improvement_impact: float
improvement_impact:float
birbaum: float
class MaintenanceScenario(OverhaulBase):
@ -37,23 +48,64 @@ class MaintenanceScenario(OverhaulBase):
projected_eaf_improvement: float
priority_score: float
plant_level_benefit: float = 0.0
capacity_weight: float
capacity_weight : float
class OptimizationResult(OverhaulBase):
current_plant_eaf: float
target_plant_eaf: float
possible_plant_eaf: float
possible_plant_eaf:float
eaf_gap: float
eaf_improvement_text: str
recommended_reduced_outage: Optional[float] = 0
warning_message: Optional[str]
eaf_improvement_text:str
recommended_reduced_outage:Optional[float] = 0
warning_message:Optional[str]
asset_contributions: List[dict]
optimization_success: bool = False
simulation_id: Optional[str] = None
class TargetReliabiltiyQuery(DefultBase):
oh_session_id: Optional[str] = Field(None)
eaf_input: float = Field(99.8)
duration: int = Field(17520)
simulation_id: Optional[str] = Field(None)
cut_hours: int = Field(0)
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
from typing import Optional, List
from dataclasses import dataclass
from sqlalchemy import Delete, Select, select
import httpx
from src.auth.service import CurrentUser
from src.config import RBD_SERVICE_API
from src.contribution_util import calculate_contribution, calculate_contribution_accurate
from src.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
from .schema import AssetWeight, OptimizationResult
from .schema import AssetWeight,MaintenanceScenario,OptimizationResult
from src.overhaul_activity.service import get_standard_scope_by_session_id
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}
headers = {'Authorization': f'Bearer {token}', 'Content-Type': 'application/json'}
rbd_simulation_url = f'{RBD_SERVICE_API}/aeros/simulation/run'
from src.calculation_time_constrains.model import CalculationData
async def run_rbd_simulation(*, sim_hours: int, oh_duration: int = 1200, oh_session_id: str, db_session: DbSession, token: str):
# Check if a simulation with these parameters already exists
stmt = select(CalculationData).where(
CalculationData.overhaul_session_id == oh_session_id,
CalculationData.analysis_metadata["type"].as_string() == "target_reliability",
CalculationData.analysis_metadata["sim_hours"].as_string() == str(sim_hours),
CalculationData.analysis_metadata["oh_duration"].as_string() == str(oh_duration)
)
result = await db_session.execute(stmt)
existing_calc = result.scalars(yield).first()
if existing_calc and existing_calc.rbd_simulation_id:
return {"data": str(existing_calc.rbd_simulation_id), "status": "success", "message": "Loaded existing simulation"}
sim_data = {
"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:
response = await client.post(rbd_simulation_url, json=sim_data, headers=headers)
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):
headers = {'Authorization': f'Bearer {token}', 'Content-Type': 'application/json'}
calc_result_url = f'{RBD_SERVICE_API}/aeros/simulation/result/calc/{simulation_id}?nodetype=RegularNode'
calc_plant_result = f'{RBD_SERVICE_API}/aeros/simulation/result/calc/{simulation_id}/plant'
headers = {
"Authorization": f"Bearer {token}",
"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:
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)
# Run all three requests concurrently
calc_response, plant_response = await asyncio.gather(calc_task, plant_task)
calc_response.raise_for_status()
# plot_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}
calc_data = calc_response.json()["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):
"""
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
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
REALISTIC_MAX_TECHNICAL = 0.995
REALISTIC_MAX_AVAILABILITY = REALISTIC_MAX_TECHNICAL * plant_operating_fraction
MIN_IMPROVEMENT_PERCENT = 0.0001
min_improvement_fraction = MIN_IMPROVEMENT_PERCENT / 100.0
EPSILON = 0.001 # 1 ms or a fraction of an hour for comparison tolerance
results = []
weighted_assets = []
# Step 1: Collect eligible assets and their weights
for asset in eq_results:
node = asset.get('aeros_node')
node = asset.get("aeros_node")
if not node:
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:
continue
contribution_factor = asset.get('contribution_factor', 0.0)
birbaum = asset.get('contribution', 0.0)
current_availability = asset.get('availability', 0.0)
downtime = asset.get('total_downtime', 0.0)
if num_of_events < 2 or contribution_factor <= 0:
contribution_factor = asset.get("contribution_factor", 0.0)
birbaum = asset.get("contribution", 0.0)
current_availability = asset.get("availability", 0.0)
downtime = asset.get("total_downtime", 0.0)
# --- 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
# --- Compute realistic possible improvement ---
if REALISTIC_MAX_AVAILABILITY > current_availability:
max_possible_improvement = REALISTIC_MAX_AVAILABILITY - current_availability
else:
max_possible_improvement = 0.0
max_possible_improvement = 0.0 # No improvement possible
# Compute weighted importance (Birnbaum × FV)
raw_weight = birbaum
weight = math.sqrt(max(raw_weight, 0.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:
node = asset.get('aeros_node')
contribution_factor = asset.get('contribution_factor', 0.0)
birbaum = asset.get('contribution', 0.0)
current_availability = asset.get('availability', 0.0)
downtime = asset.get('total_downtime', 0.0)
required_improvement = eaf_gap_fraction * (weight / total_weight)
node = asset.get("aeros_node")
contribution_factor = asset.get("contribution_factor", 0.0)
birbaum = asset.get("contribution", 0.0)
current_availability = asset.get("availability", 0.0)
downtime = asset.get("total_downtime", 0.0)
required_improvement = eaf_gap_fraction * (weight/total_weight)
required_improvement = min(required_improvement, max_possible_improvement)
required_improvement = max(required_improvement, min_improvement_fraction)
improvement_impact = required_improvement * contribution_factor
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)
# Step 4: Sort by Birnbaum importance
results.sort(key=lambda x: x.birbaum, reverse=True)
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
improved_downtime_pct = current_downtime_pct * (1 - improvement_factor)
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]
current_plant_eaf = plant_result.get('eaf', 0)
# Base parameters
current_plant_eaf = plant_result.get("eaf", 0)
total_hours = duration
scheduled_outage = int(po_duration)
reduced_outage = max(scheduled_outage - cut_hours, 0)
max_eaf_possible = (total_hours - reduced_outage) / total_hours * 100
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
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_reduction = reduced_outage - required_scheduled_hours
# Build dynamic phrase for clarity
if cut_hours > 0:
reduction_phrase = f' even after reducing planned outage by {cut_hours}h'
reduction_phrase = f" even after reducing planned outage by {cut_hours}h"
else:
reduction_phrase = ''
warning_message = f'⚠️ Target EAF {target_eaf:.2f}% exceeds theoretical maximum {max_eaf_possible:.2f}%{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).'
reduction_phrase = ""
warning_message = (
f"⚠️ Target EAF {target_eaf:.2f}% exceeds theoretical maximum {max_eaf_possible:.2f}%"
f"{reduction_phrase}.\n"
f"To achieve it, planned outage must be further reduced by approximately "
f"{required_reduction:.1f} hours (from {reduced_outage:.0f}h → {required_scheduled_hours:.0f}h)."
)
# Cap target EAF to max achievable for calculation
target_eaf = max_eaf_possible
eaf_gap = (target_eaf - current_plant_eaf) / 100.0
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='')
standard_scope = await get_standard_scope_by_session_id(db_session=db_session, overhaul_session_id=oh_session_id, collector_db=collector_db)
return OptimizationResult(
current_plant_eaf=current_plant_eaf,
target_plant_eaf=target_eaf,
possible_plant_eaf=current_plant_eaf,
eaf_gap=0,
warning_message=warning_message or "Target already achieved or exceeded.",
asset_contributions=[],
optimization_success=True,
simulation_id=simulation_id,
eaf_improvement_text=""
)
# Get standard scope (equipment in OH)
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]
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
selected_eq = []
for asset in asset_contributions:
if project_eaf_improvement_total >= eaf_gap:
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)
project_eaf_improvement_total += asset.improvement_impact
else:
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 = min(possible_eaf_plant, max_eaf_possible)
selected_eq.sort(key=lambda x: x.birbaum, reverse=True)
required_cut_hours = 0
# --- 2. Optimization feasible but cannot reach target (underperformance case) ---
if possible_eaf_plant < target_eaf:
# Calculate shortfall
performance_gap = target_eaf - possible_eaf_plant
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}%).'
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).'
# Estimate how many scheduled outage hours must be reduced to close the remaining gap
# Each hour reduced adds (1 / total_hours) * 100 % to plant EAF
required_cut_hours = (performance_gap / 100) * total_hours
reliability_limit_msg = (
f"⚠️ Optimization was unable to reach target EAF {target_eaf:.2f}%.\n"
f"The best achievable EAF based on current reliability is "
f"{possible_eaf_plant:.2f}% (short by {performance_gap:.2f}%)."
)
# Add actionable recommendation
recommendation_msg = (
f"To achieve the target EAF, consider reducing planned outage by approximately "
f"{required_cut_hours:.1f} hours or {int(required_cut_hours/24)} days (from {reduced_outage:.0f}h → {reduced_outage - required_cut_hours:.0f}h)."
)
if warning_message:
warning_message = warning_message + '\n\n' + reliability_limit_msg + '\n' + recommendation_msg
warning_message = warning_message + "\n\n" + reliability_limit_msg + "\n" + recommendation_msg
else:
warning_message = reliability_limit_msg + '\n' + recommendation_msg
eaf_improvement_points = possible_eaf_plant - current_plant_eaf
warning_message = reliability_limit_msg + "\n" + recommendation_msg
# --- EAF improvement reporting ---
eaf_improvement_points = (possible_eaf_plant - current_plant_eaf)
# Express as text for user readability
if eaf_improvement_points > 0:
improvement_text = f'{eaf_improvement_points:.6f} percentage points increase'
improvement_text = f"{eaf_improvement_points:.6f} percentage points increase"
else:
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)
improvement_text = "No measurable improvement achieved"
# 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
from datetime import datetime, timedelta
import random
from typing import Optional
from typing import List, Optional
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:
num_periods = random.randint(1, 3)
total_days = (end_date - start_date).days
down_periods = []
# Generate random down periods
for _ in range(num_periods):
# Random duration for this period
duration = random.randint(min_duration, max_duration)
# Ensure we don't exceed the total date range
latest_possible_start = total_days - duration
if latest_possible_start < 0:
continue
# Random start day within available range
start_day = random.randint(0, latest_possible_start)
period_start = start_date + timedelta(days=start_day)
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:
down_periods.append((period_start, period_end))
return sorted(down_periods)
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
temporal_client = await Client.connect(TEMPORAL_URL)
while True:
try:
handle = temporal_client.get_workflow_handle(workflow_id=workflow_id)
desc = await handle.describe()
status = desc.status.name
if status not in ['RUNNING', 'CONTINUED_AS_NEW']:
print(f'✅ Workflow {workflow_id} finished with status: {status}')
if status not in ["RUNNING", "CONTINUED_AS_NEW"]:
print(f"✅ Workflow {workflow_id} finished with status: {status}")
break
print(f'⏳ Workflow {workflow_id} still {status}, checking again in 10s...')
print(f"⏳ Workflow {workflow_id} still {status}, checking again in 10s...")
except Exception as e:
retries += 1
if retries > max_retries:
print(f'⚠️ Workflow {workflow_id} not found after {max_retries} retries, treating as done. Error: {e}')
print(f"⚠️ Workflow {workflow_id} not found after {max_retries} retries, treating as done. Error: {e}")
break
print(f'⚠️ Workflow {workflow_id} not found (retry {retries}/{max_retries}), waiting 10s before retry...')
await asyncio.sleep(10)
continue
retries = 0
else:
print(f"⚠️ Workflow {workflow_id} not found (retry {retries}/{max_retries}), waiting 10s before retry...")
await asyncio.sleep(10)
continue
retries = 0 # reset retries if describe() worked
await asyncio.sleep(30)
return simulation_id
return simulation_id

@ -3,105 +3,230 @@ import numpy as np
import pandas as pd
from datetime import timedelta
from collections import defaultdict
from src.config import BASE_URL
class OptimumCostModel:
def __init__(self, token, last_oh_date, next_oh_date, interval=30, base_url: str=f'{BASE_URL}'):
def __init__(self, token, last_oh_date, next_oh_date,interval =30, base_url: str = "http://192.168.1.82:8000"):
self.api_base_url = base_url
self.token = token
self.last_oh_date = last_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):
"""
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')
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:
response = requests.get(url, headers=header)
response.raise_for_status()
data = response.json()
return data['data']['value']
reliability = data['data']['value']
return reliability
except requests.RequestException as e:
print(f'API Error: {e}')
print(f"API Error: {e}")
return None
except KeyError as e:
print(f'Data parsing error: {e}')
print(f"Data parsing error: {e}")
return None
def get_reliability_equipment(self, location_tag):
current_date = self.last_oh_date
results = defaultdict()
print('Fetching reliability data from API...')
print("Fetching reliability data from API...")
while current_date <= self.next_oh_date:
reliability = self.get_reliability_from_api(current_date, location_tag)
results[current_date] = reliability
if reliability is not None:
print(f"Date: {current_date.strftime('%Y-%m-%d')}, Reliability: {reliability:.6f}")
else:
print(f"Date: {current_date.strftime('%Y-%m-%d')}, Reliability: Failed to fetch")
current_date += timedelta(days=self.interval)
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 = []
dates = list(reliabilities.keys())
for i, (date, reliability) in enumerate(reliabilities.items()):
if reliability is None:
continue
# Calculate time from last OH in days
time_from_last_oh = (date - self.last_oh_date).days
# Calculate failure replacement cost
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:
expected_operating_time = time_from_last_oh
expected_operating_time = time_from_last_oh # First point
else:
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]
# Approximate integral using available reliability values
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]
if len(rel_values) > 1:
expected_operating_time = np.trapezoid(rel_values, time_points)
else:
expected_operating_time = time_from_last_oh * reliability
# Calculate costs
if expected_operating_time > 0:
failure_cost = failure_prob * failure_replacement_cost / expected_operating_time
preventive_cost = reliability * preventive_cost / expected_operating_time
failure_cost = (failure_prob * failure_replacement_cost) / expected_operating_time
preventive_cost = (reliability * preventive_cost) / expected_operating_time
else:
# failure_cost = failure_prob * self.IDRu
# preventive_cost = reliability * self.IDRp
continue
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
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:
return None
# Find minimum total cost
min_cost_idx = results_df['total_cost'].idxmin()
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)
preventive_cost / len(equipments)
preventive_cost_per_equipment = preventive_cost / len(equipments)
fleet_results = []
total_corrective_costs = np.zeros(max_interval)
total_preventive_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:
# Get reliability data
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)
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]
procurement_costs = [r['procurement_cost'] for r in predicted_cost]
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))
# Aggregate costs
corrective_costs = [r["failure_cost"] for r in predicted_cost]
preventive_costs = [r["preventive_cost"] for r in predicted_cost]
procurement_costs = [r["procurement_cost"] for r in predicted_cost]
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_preventive_costs += np.array(preventive_costs)
total_procurement_costs += np.array(procurement_costs)
# Calculate fleet optimal interval
total_costs = total_corrective_costs + total_preventive_costs + total_procurement_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)
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 uuid import UUID
import numpy as np
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.database.core import DbSession
from src.overhaul_scope.service import get_all
from src.standard_scope.model import StandardScope
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
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:
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:
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)
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')
costFailurePerScope = {
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
async def create_calculation(*, token: str, db_session: DbSession, collector_db_session: CollectorDbSession, calculation_time_constrains_in: CalculationTimeConstrainsParametersCreate, created_by: str, simulation_id):
calculation_data = await create_param_and_data(db_session=db_session, calculation_param_in=calculation_time_constrains_in, created_by=created_by)
rbd_simulation_id = simulation_id or TC_RBD_ID
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)
if simulation_id is None:
temporal_client = await Client.connect(TEMPORAL_URL)
workflow_id = f"optimum-oh-calc-{uuid.uuid4()}"
args = {
"token": token.token if hasattr(token, 'token') else str(token),
"calculation_in": calculation_time_constrains_in.model_dump(mode="json"),
"created_by": created_by,
"callback_workflow_id": workflow_id,
}
handle = await temporal_client.start_workflow(
OptimumOHCalculationWorkflow.run,
args,
id=workflow_id,
task_queue="oh-sim-queue" # or whatever task queue they use
)
return {
"data": workflow_id,
"status": "success",
"message": "Calculation workflow started successfully"
}
else:
calculation_data = await create_param_and_data(
db_session=db_session,
calculation_param_in=calculation_time_constrains_in,
created_by=created_by
)
await run_simulation_with_spareparts(
db_session=db_session,
calculation=calculation_data,
token=token,
collector_db_session=collector_db_session,
simulation_id=simulation_id
)
return await get_calculation_result(
db_session=db_session,
calculation_id=str(calculation_data.id),
token=token,
include_risk_cost=0
)
async def recalculate_calculation(
*,
token: str,
db_session: DbSession,
collector_db_session: CollectorDbSession,
calculation_id: str,
simulation_id: Optional[str] = None
):
calculation_data = await get_calculation_data_by_id(
db_session=db_session, calculation_id=calculation_id
)
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:
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
# 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 typing import Optional
from sqlalchemy import JSON, UUID, Boolean, Column, Float, ForeignKey, Integer, String
from typing import List, Optional, Union
from sqlalchemy import (JSON, UUID, Boolean, Column, Float, ForeignKey,
Integer, Numeric, String)
from sqlalchemy.orm import relationship
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):
SCOPE = 'SCOPE'
ASSET = 'ASSET'
SCOPE = "SCOPE"
ASSET = "ASSET"
class CalculationParam(Base, DefaultMixin, IdentityMixin):
__tablename__ = 'oh_ms_calculation_param'
__tablename__ = "oh_ms_calculation_param"
avg_failure_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):
__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'))
__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")
)
optimum_oh_day = Column(Integer, nullable=True)
max_interval = Column(Integer, nullable=True)
rbd_simulation_id = Column(UUID(as_uuid=True), nullable=True)
optimum_analysis = Column(JSON, nullable=True)
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)
plant_results = Column(JSON, nullable=True)
fleet_statistics = Column(JSON, nullable=True)
analysis_metadata = Column(JSON, nullable=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
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:
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)
await db.flush()
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)
await db.commit()
await db.refresh(calculation_data)
return calculation_data
class CalculationResult(Base, DefaultMixin):
__tablename__ = 'oh_tr_calculation_result'
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)
__tablename__ = "oh_tr_calculation_result"
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)
corrective_cost = Column(Float, nullable=False)
overhaul_cost = Column(Float, 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):
__tablename__ = 'oh_tr_calculation_equipment_result'
__tablename__ = "oh_tr_calculation_equipment_result"
corrective_costs = Column(JSON, nullable=False)
overhaul_costs = 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)
location_tag = Column(String(255), nullable=False)
material_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)
is_included = Column(Boolean, default=True)
procurement_details = Column(JSON, nullable=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 fastapi import APIRouter
from fastapi.params import Query
import requests
from src import config
from src.auth.service import CurrentUser, InternalKey, Token
from src.config import DEFAULT_TC_ID
from src.database.core import DbSession
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 .service import bulk_update_equipment, get_calculation_result, get_calculation_result_by_day, get_calculation_by_assetnum, get_all_calculations
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 .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.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()
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
simulation_id = params.simulation_id
with_results = params.with_results
simulation_id = calculation_time_constrains_in.simulationRBDId
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:
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')
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('', response_model=StandardResponse[List[CalculationTimeConstrainsReadNoResult]], dependencies=[Depends(required_permission(OHPermission.READ, OverhaulScope))])
async def get_all_simulation_calculations(db_session: DbSession, token: Token, current_user: CurrentUser):
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]], 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')
@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."""
@get_calculation.get('/{calculation_id}', response_model=StandardResponse[CalculationTimeConstrainsRead], dependencies=[Depends(required_permission(OHPermission.READ, OverhaulScope))])
async def get_calculation_results(db_session: DbSession, calculation_id, token: InternalKey, include_risk_cost: int=Query(1, alias='risk_cost')):
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')
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], dependencies=[Depends(required_permission(OHPermission.READ, OverhaulScope))])
@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], dependencies=[Depends(required_permission(OHPermission.READ, OverhaulScope))])
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')
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('/update/{calculation_id}', response_model=StandardResponse[List[str]], dependencies=[Depends(required_permission(OHPermission.EDIT, OverhaulScope)), Depends(csrf_protect)])
async def update_selected_equipment(db_session: DbSession, calculation_id, calculation_time_constrains_in: List[CalculationSelectedEquipmentUpdate]):
@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')
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"""
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 dataclasses import dataclass
from datetime import datetime
from typing import Dict, List, Optional, Union
from typing import Any, Dict, List, Optional, Union
from uuid import UUID
from pydantic import Field
from src.models import DefultBase
from src.standard_scope.schema import MasterEquipmentBase
class CalculationTimeConstrainsBase(DefultBase):
pass
class ReferenceLinkBase(DefultBase):
reference_id: str = Field(..., description='Reference ID')
overhaul_reference_type: str = Field(..., description='Overhaul reference type')
reference_id: str = Field(..., description="Reference ID")
overhaul_reference_type: str = Field(..., description="Overhaul reference type")
class CalculationTimeConstrainsParametersRetrive(CalculationTimeConstrainsBase):
costPerFailure: Union[dict, float] = Field(..., description='Cost per failure')
availableScopes: List[str] = Field(..., description='Available scopes')
recommendedScope: str = Field(..., description='Recommended scope')
# type: ignore
costPerFailure: Union[dict, float] = Field(..., description="Cost per failure")
availableScopes: List[str] = Field(..., description="Available scopes")
recommendedScope: str = Field(..., description="Recommended scope")
# historicalData: Dict[str, Any] = Field(..., description="Historical data")
class CalculationTimeConstrainsParametersRead(CalculationTimeConstrainsBase):
costPerFailure: Union[dict, float] = Field(..., description='Cost per failure')
overhaulCost: Optional[float] = Field(None, description='Overhaul cost')
reference: Optional[List[ReferenceLinkBase]] = Field(None, description='Reference')
costPerFailure: Union[dict, float] = Field(..., description="Cost per failure")
overhaulCost: Optional[float] = Field(None, description="Overhaul cost")
reference: Optional[List[ReferenceLinkBase]] = Field(None, description="Reference")
class CalculationTimeConstrainsParametersCreate(CalculationTimeConstrainsBase):
overhaulCost: Optional[float] = Field(0, description='Overhaul cost')
ohSessionId: Optional[UUID] = Field(None, description='Scope OH')
costPerFailure: Optional[float] = Field(0, description='Cost per failure')
overhaulCost: Optional[float] = Field(0, description="Overhaul cost")
ohSessionId: Optional[UUID] = Field(None, description="Scope OH")
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):
overhaul_cost: float
@ -39,22 +58,27 @@ class CalculationResultsRead(CalculationTimeConstrainsBase):
procurement_details: Dict
sparepart_summary: dict
class OptimumResult(CalculationTimeConstrainsBase):
overhaul_cost: float
corrective_cost: float
num_failures: int
days: int
class EquipmentResult(CalculationTimeConstrainsBase):
id: UUID
corrective_costs: List[float]
overhaul_costs: List[float]
procurement_costs: List[float]
daily_failures: List[float]
is_actual: Optional[List[bool]] = None
location_tag: str
material_cost: float
service_cost: float
optimum_day: int
optimum_day: int # Added optimum result for each equipment
is_included: bool
master_equipment: Optional[MasterEquipmentBase] = Field(None)
@ -84,6 +108,7 @@ class AnalysisMetadata(CalculationTimeConstrainsBase):
calculation_type: str
total_equipment_analyzed: int
included_in_optimization: int
optimal_stat: Optional[dict]
class CalculationTimeConstrainsReadNoResult(CalculationTimeConstrainsBase):
id: UUID
@ -92,6 +117,10 @@ class CalculationTimeConstrainsReadNoResult(CalculationTimeConstrainsBase):
max_interval: Optional[int]
optimum_analysis: Optional[dict]
session: ScopeRead
# optimum_oh_day: int
# max_interval: int
# optimal_analysis: dict
# analysis_metadata: dict
class CalculationTimeConstrainsRead(CalculationTimeConstrainsBase):
id: UUID
@ -105,18 +134,21 @@ class CalculationTimeConstrainsRead(CalculationTimeConstrainsBase):
optimal_analysis: dict
analysis_metadata: dict
class CalculationTimeConstrainsCreate(CalculationTimeConstrainsBase):
intervalDays: int
class CalculationTimeConstrainsSimulationRead(CalculationTimeConstrainsBase):
simulation: CalculationResultsRead
class CalculationSelectedEquipmentUpdate(CalculationTimeConstrainsBase):
is_included: bool
location_tag: str
name: str
name:str
class CreateCalculationQuery(DefultBase):
scope_calculation_id: Optional[str] = Field(None)
with_results: Optional[int] = Field(0)
simulation_id: Optional[UUID] = Field(None)
simulation_id: Optional[UUID] = Field(None)

File diff suppressed because it is too large Load Diff

@ -1,134 +1,345 @@
import datetime
import json
import pandas as pd
import requests
from src.config import RBD_SERVICE_API
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):
filtered_data = [d for d in chart_data if d['currentEvent'] != 'ON_OH']
sorted_data = sorted(filtered_data, key=lambda x: x['cumulativeTime'])
# Filter out ON_OH
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:
return []
hourly_data = []
current_state_index = 0
current_flow_rate = sorted_data[0]['flowRate']
current_eq_status = sorted_data[0]['currentEQStatus']
last_time = int(sorted_data[-1]['cumulativeTime'])
current_flow_rate = sorted_data[0]["flowRate"]
current_eq_status = sorted_data[0]["currentEQStatus"]
# Determine maximum bound (either given or from data)
last_time = int(sorted_data[-1]["cumulativeTime"])
if max_hours is None:
max_hours = last_time
for hour in range(0, max_hours + 1):
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_flow_rate = sorted_data[current_state_index]['flowRate']
current_eq_status = sorted_data[current_state_index]['currentEQStatus']
hourly_data.append({'cumulativeTime': hour, 'flowRate': current_flow_rate, 'currentEQStatus': current_eq_status})
current_flow_rate = sorted_data[current_state_index]["flowRate"]
current_eq_status = sorted_data[current_state_index]["currentEQStatus"]
hourly_data.append({
"cumulativeTime": hour,
"flowRate": current_flow_rate,
"currentEQStatus": current_eq_status
})
return 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
previous_eq_status = None
monthly_data = {}
for data_point in hourly_data:
hour = data_point['hour']
hour = data_point['cumulativeTime']
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
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
# Store the current cumulative count for this month
monthly_data[month] = total_failures
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))})
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 = []
if monthly_data:
max_month = max(monthly_data.keys())
for month in range(1, max_month + 1):
result.append({
'month': month,
'oos_hours': monthly_data.get(month, 0)
})
return result
import pandas as pd
import datetime
import datetime
import pandas as pd
async def plant_simulation_metrics(simulation_id: str, location_tag: str, max_interval, token, last_oh_date, use_location_tag: int=1):
calc_result_url = f'{RBD_SERVICE_API}/aeros/simulation/result/calc/{simulation_id}/{location_tag}'
try:
response = requests.get(calc_result_url, headers={'Content-Type': 'application/json', 'Authorization': f'Bearer {token}'}, timeout=30)
response.raise_for_status()
prediction_data = response.json()['data']
except (requests.RequestException, ValueError) as e:
raise Exception(str(e))
return prediction_data
def analyze_monthly_metrics(timestamp_outs, start_date, max_flow_rate: float=550):
async def plant_simulation_metrics(simulation_id: str, location_tag: str, max_interval, token, last_oh_date, use_location_tag: int = 1):
"""Get failure predictions for equipment from simulation service"""
calc_result_url = f"{RBD_SERVICE_API}/aeros/simulation/result/calc/{simulation_id}/{location_tag}"
try:
response = requests.get(
calc_result_url,
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {token}",
},
timeout=30
)
response.raise_for_status()
prediction_data = response.json()['data']
except (requests.RequestException, ValueError) as e:
raise Exception(str(e))
return prediction_data
def analyze_monthly_metrics(timestamp_outs, start_date, max_flow_rate: float = 550):
if not timestamp_outs:
return {}
df = pd.DataFrame(timestamp_outs)
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 {}
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['month_year'] = df['datetime'].dt.to_period('M')
# Duration until next timestamp
df['duration_hours'] = df['cumulativeTime'].shift(-1) - df['cumulativeTime']
df['duration_hours'] = df['duration_hours'].fillna(0)
# Failure detection
df['status_change'] = df['currentEQStatus'].shift() != df['currentEQStatus']
df['failure'] = (df['currentEQStatus'] == 'OoS') & df['status_change']
# Cumulative tracking
df['cumulative_failures'] = df['failure'].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['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_hours_equivalent'] = df['derated_mwh'] / max_flow_rate
monthly_results = {}
for month_period, group in df.groupby('month_year', sort=True):
month_str = str(month_period)
monthly_results[month_str] = {}
# Failures
monthly_results[month_str]['failures_count'] = int(group['failure'].sum())
monthly_results[month_str]['cumulative_failures'] = int(group['cumulative_failures'].max())
# OOS hours
oos_time = group.loc[group['currentEQStatus'] == 'OoS', 'duration_hours'].sum()
monthly_results[month_str]['total_oos_hours'] = float(oos_time)
monthly_results[month_str]['cummulative_oos'] = float(group['cumulative_oos'].max())
# Flow rate (weighted average)
total_flow_time = (group['flowRate'] * group['duration_hours']).sum()
total_time = group['duration_hours'].sum()
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)
# Extra metrics
monthly_results[month_str]['total_hours'] = float(total_time)
service_hours = group.loc[group['currentEQStatus'] == 'Svc', 'duration_hours'].sum()
monthly_results[month_str]['service_hours'] = float(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()
derated_mwh = group['derated_mwh'].sum()
equivalent_derated_hours = group['derated_hours_equivalent'].sum()
monthly_results[month_str]['derating_hours'] = float(derating_hours)
monthly_results[month_str]['derated_mwh'] = float(derated_mwh)
monthly_results[month_str]['equivalent_derated_hours'] = float(equivalent_derated_hours)
return monthly_results
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_cost_array = []
for month, data in monthly_results.items():
# Extract monthly data
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']
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
# 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
# 3. Calculate total risk cost for the month
# Risk cost = capacity_lost * energy_price
monthly_risk_cost = capacity_lost_to_downtime * energy_price
# 4. Calculate risk cost per failure for this month
if failures_count > 0:
risk_cost_per_failure = monthly_risk_cost / failures_count
else:
# If no failures, set to 0 or use alternative approach
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)
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):
"""
Complete analysis combining monthly metrics with risk cost calculation
"""
# Get monthly metrics
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 = {}
for month in monthly_metrics.keys():
combined_results[month] = {**monthly_metrics[month], **risk_analysis['monthly_details'][month]}
return {'monthly_data': combined_results, 'risk_cost_array': risk_analysis['risk_cost_per_failure_array']}
combined_results[month] = {
**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 os
from typing import List
from urllib import parse
from pydantic import BaseModel
from starlette.config import Config
from starlette.datastructures import CommaSeparatedStrings
log = logging.getLogger(__name__)
class BaseConfigurationModel(BaseModel):
"""Base configuration model used by all config options."""
pass
def get_env_tags(tag_list: List[str]) -> dict:
"""Create dictionary of available env tags."""
tags = {}
for t in tag_list:
tag_key, env_key = t.split(':')
tag_key, env_key = t.split(":")
env_value = os.environ.get(env_key)
if env_value:
tags.update({tag_key: env_value})
return tags
def get_config():
try:
config = Config('.env')
# Try to load from .env file first
config = Config(".env")
except FileNotFoundError:
# If .env doesn't exist, use environment variables
config = Config(environ=os.environ)
return config
config = get_config()
LOG_LEVEL = config('LOG_LEVEL', default='INFO')
SERVICE_NAME = config('SERVICE_NAME', default='Be-OptimumOH')
ENV = config('ENV')
PORT = config('PORT', cast=int)
HOST = config('HOST')
DATABASE_HOSTNAME = config('DATABASE_HOSTNAME')
_DATABASE_CREDENTIAL_USER = config('DATABASE_CREDENTIAL_USER')
_DATABASE_CREDENTIAL_PASSWORD = config('DATABASE_CREDENTIAL_PASSWORD')
LOG_LEVEL = config("LOG_LEVEL", default="INFO")
ENV = (os.getenv("ENV") or os.getenv("ENVIRONMENT") or os.getenv("ENVIRONEMENT") or "production").strip()
DEBUG = 1 if (ENV.upper() in ("DEV", "DEVELOPMENT", "LOCAL") and "PROD" not in ENV.upper()) else 0
PORT = config("PORT", cast=int, default=8000)
HOST = config("HOST", default="localhost")
# 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))
DATABASE_NAME = config('DATABASE_NAME')
DATABASE_PORT = config('DATABASE_PORT')
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)
COLLECTOR_HOSTNAME = config('COLLECTOR_HOSTNAME')
COLLECTOR_PORT = config('COLLECTOR_PORT', cast=int)
COLLECTOR_CREDENTIAL_USER = config('COLLECTOR_CREDENTIAL_USER')
COLLECTOR_CREDENTIAL_PASSWORD = config('COLLECTOR_CREDENTIAL_PASSWORD')
DATABASE_NAME = config("DATABASE_NAME", default="digital_twin")
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
)
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))
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}'
TIMEZONE = config('TIMEZONE')
MAXIMO_BASE_URL = config('MAXIMO_BASE_URL')
MAXIMO_API_KEY = config('MAXIMO_API_KEY')
AUTH_SERVICE_API = config('AUTH_SERVICE_API')
REALIBILITY_SERVICE_API = config('REALIBILITY_SERVICE_API')
RBD_SERVICE_API = config('RBD_SERVICE_API')
TEMPORAL_URL = config('TEMPORAL_URL')
BASE_URL = config('BASE_URL')
API_KEY = config('API_KEY')
TR_RBD_ID = config('TR_RBD_ID')
TC_RBD_ID = config('TC_RBD_ID')
DEFAULT_TC_ID = config('DEFAULT_TC_ID')
REDIS_HOST = config('REDIS_HOST')
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)
COLLECTOR_NAME = config("COLLECTOR_NAME")
# Deal w
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}"
TIMEZONE = "Asia/Jakarta"
MAXIMO_BASE_URL = config("MAXIMO_BASE_URL", default="http://example.com")
MAXIMO_API_KEY = config("MAXIMO_API_KEY", default="keys")
AUTH_SERVICE_API = config("AUTH_SERVICE_API", default="http://192.168.1.82:8000/auth")
REALIBILITY_SERVICE_API = config("REALIBILITY_SERVICE_API", default="http://192.168.1.82:8000/reliability")
RBD_SERVICE_API = config("RBD_SERVICE_API", default="http://192.168.1.82:8000/rbd")
TEMPORAL_URL = config("TEMPORAL_URL", default="http://192.168.1.86:7233")
API_KEY = config("API_KEY", default="0KFvcB7zWENyKVjoma9FKZNofVSViEshYr59zEQNGaYjyUP34gCJKDuqHuk9VfvE")
TR_RBD_ID = config("TR_RBD_ID", default="f04f365e-25d8-4036-87c2-ba1bfe1f9229")
TC_RBD_ID = config("TC_RBD_ID", default="f8523cb0-dc3c-4edb-bcf1-eea7b62582f1")
DEFAULT_TC_ID = config("DEFAULT_TC_ID", default="44f483f3-bfe4-4094-a59f-b97a10f2fea6")
TEMPORAL_URL

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

@ -1,12 +1,18 @@
import json
import logging
from typing import Dict, Union
from typing import Dict, Union, Tuple
from decimal import Decimal, getcontext
import math
# Set high precision for decimal calculations
getcontext().prec = 50
Structure = Union[str, Dict[str, list]]
log = logging.getLogger(__name__)
def prod(iterable):
"""Compute product of all elements in iterable with high precision."""
result = Decimal('1.0')
for x in iterable:
if isinstance(x, (int, float)):
@ -14,43 +20,58 @@ def prod(iterable):
result *= x
return float(result)
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:
raise ValueError(f"Component '{structure}' not found in availabilities")
return float(Decimal(str(availabilities[structure])))
if isinstance(structure, dict):
if 'series' in structure:
components = structure['series']
if not components:
if "series" in structure:
components = structure["series"]
if not components: # Handle empty series
return 1.0
# Series: A_system = A1 * A2 * ... * An
product = Decimal('1.0')
for s in components:
availability = system_availability(s, availabilities)
product *= Decimal(str(availability))
return float(product)
if 'parallel' in structure:
components = structure['parallel']
if not components:
elif "parallel" in structure:
components = structure["parallel"]
if not components: # Handle empty parallel
return 0.0
# Parallel: A_system = 1 - (1-A1) * (1-A2) * ... * (1-An)
product = Decimal('1.0')
for s in components:
availability = system_availability(s, availabilities)
unavailability = Decimal('1.0') - Decimal(str(availability))
product *= unavailability
result = Decimal('1.0') - product
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:
return 0.0
availabilities_list = [system_availability(s, availabilities) for s in components]
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:
"""Extract all component names from a structure."""
components = set()
def extract_components(substructure):
if isinstance(substructure, str):
components.add(substructure)
@ -58,59 +79,131 @@ def get_all_components(structure: Structure) -> set:
for component_list in substructure.values():
for component in component_list:
extract_components(component)
extract_components(structure)
return components
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_down = availabilities.copy()
# Set component availability to 1 (perfect)
avail_up[component] = 1.0
# Set component availability to 0 (failed)
avail_down[component] = 0.0
# Calculate system availability in both cases
system_up = system_availability(structure, avail_up)
system_down = system_availability(structure, avail_down)
# Birnbaum importance is the difference
return system_up - system_down
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)
system_avail = system_availability(structure, availabilities)
component_avail = availabilities[component]
if system_avail >= 1.0:
if system_avail >= 1.0: # Perfect system
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:
"""
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)
if system_avail <= 0.0:
return 0.0
# Calculate system availability with component failed
avail_down = availabilities.copy()
avail_down[component] = 0.0
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]]:
"""
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 = {}
for k, v in availabilities.items():
if v > 1.0:
normalized_availabilities[k] = v / 100.0
else:
normalized_availabilities[k] = v
# Clamp to valid range [0, 1]
normalized_availabilities[k] = max(0.0, min(1.0, normalized_availabilities[k]))
# Get all components in the system
all_components = get_all_components(structure)
# Check for missing components
missing_components = all_components - set(normalized_availabilities.keys())
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:
normalized_availabilities[comp] = 1.0
# Calculate system baseline availability
system_avail = system_availability(structure, normalized_availabilities)
# Calculate importance measures for each component
results = {}
total_birnbaum = 0.0
for component in all_components:
if component in normalized_availabilities:
birnbaum = birnbaum_importance(structure, normalized_availabilities, component)
criticality = criticality_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
# Calculate contribution percentages based on Birnbaum importance
if total_birnbaum > 0:
for component in results:
contribution_pct = results[component]['birnbaum_importance'] / total_birnbaum
@ -118,29 +211,76 @@ def compute_all_importance_measures(structure: Structure, availabilities: Dict[s
else:
for component in results:
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
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:
with open(structure_file, 'r') as model_file:
structure = json.load(model_file)
except FileNotFoundError:
raise FileNotFoundError(f'Structure file not found: {structure_file}')
raise FileNotFoundError(f"Structure file not found: {structure_file}")
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)
# Extract system information
system_info = results.pop('_system_info')
# Log results
log.info(f"System Availability: {system_info['system_availability']:.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:
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
# Legacy function for backwards compatibility
def calculate_contribution(availabilities):
"""Legacy function - redirects to improved version."""
try:
return calculate_contribution_accurate(availabilities)
except Exception as e:
log.error(f'Error in contribution calculation: {e}')
log.error(f"Error in contribution calculation: {e}")
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 operator
from contextlib import asynccontextmanager
from typing import Annotated, Any
from fastapi import Depends
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 starlette.requests import Request
from src.config import SQLALCHEMY_DATABASE_URI, SQLALCHEMY_COLLECTOR_URI
engine = create_async_engine(SQLALCHEMY_DATABASE_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):
return request.state.collector_db
def get_db(request: Request):
return request.state.db
DbSession = Annotated[AsyncSession, Depends(get_db)]
CollectorDbSession = Annotated[AsyncSession, Depends(get_collector_db)]
class Base(DeclarativeBase):
class Base(DeclarativeBase):
@declared_attr.directive
def __tablename__(cls) -> str:
return resolve_table_name(cls.__name__)
def dict(self):
"""Returns a dict representation of a model."""
if hasattr(self, '__table__'):
return {c.name: operator.attrgetter(c.name)(self) for c in self.__table__.columns}
return {}
class CollectorBase(DeclarativeBase):
@declared_attr.directive
def __tablename__(cls) -> str:
return resolve_table_name(cls.__name__)
def dict(self):
"""Returns a dict representation of a model."""
if hasattr(self, '__table__'):
return {c.name: operator.attrgetter(c.name)(self) for c in self.__table__.columns}
return {}
@ -68,16 +90,21 @@ async def get_collector_session():
await session.close()
def resolve_table_name(name):
names = re.split('(?=[A-Z])', name)
return '_'.join([x.lower() for x in names if x])
"""Resolves table names to their mapped names."""
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:
"""Return class reference mapped to table."""
def _find_class(name):
for c in Base.registry._class_registry.values():
if hasattr(c, '__table__'):
if hasattr(c, "__table__"):
if c.__table__.fullname.lower() == name.lower():
return c
return None
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 pydantic import Field
from src.models import DefultBase
class CommonParams(DefultBase):
current_user: Optional[str] = Field(None, alias='currentUser')
page: int = Field(1, gt=0, le=1000000)
items_per_page: int = Field(5, gt=0, le=50, multiple_of=5, alias='itemsPerPage')
query_str: Optional[str] = Field(None, alias='q')
filter_spec: Optional[str] = Field(None, alias='filter')
sort_by: List[str] = Field(default_factory=list, alias='sortBy[]')
descending: List[bool] = Field(default_factory=list, alias='descending[]')
exclude: List[str] = Field(default_factory=list, alias='exclude[]')
all_params: int = Field(0, alias='all', ge=0, le=1000000)
# This ensures no extra query params are allowed
current_user: Optional[str] = Field(None, alias="currentUser")
page: int = Field(1, gt=0, lt=2147483647)
items_per_page: int = Field(5, gt=0, le=50, multiple_of=5, alias="itemsPerPage")
query_str: Optional[str] = Field(None, alias="q")
filter_spec: Optional[str] = Field(None, alias="filter")
sort_by: List[str] = Field(default_factory=list, alias="sortBy[]")
descending: List[bool] = Field(default_factory=list, alias="descending[]")
exclude: List[str] = Field(default_factory=list, alias="exclude[]")
all_params: int = Field(0, alias="all")
# Property to mirror your original return dict's bool conversion
@property
def is_all(self) -> bool:
return bool(self.all_params)
return bool(self.all_params)

@ -1,58 +1,149 @@
import logging
from typing import Annotated, List, Type, TypeVar
from fastapi import Depends, Query
from pydantic.types import Json, constr
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 .core import DbSession
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)):
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)}
CommonParameters = Annotated[dict[str, int | str | DbSession | QueryStr | Json | List[str] | List[bool]] | bool, Depends(common_parameters)]
T = TypeVar('T', bound=CommonParams)
# allows only printable characters
QueryStr = constr(pattern=r"^[ -~]+$", min_length=1)
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()
return {'db_session': db_session, 'all': params.is_all, **res}
return {
"db_session": db_session,
"all": params.is_all,
**res
}
return wrapper
def search(*, query_str: str, query: Query, model, sort=False):
"""Perform a search based on the query."""
search_model = model
if not query_str.strip():
return query
search = []
if hasattr(search_model, 'search_vector'):
if hasattr(search_model, "search_vector"):
vector = search_model.search_vector
search.append(vector.op('@@')(func.tsq_parse(query_str)))
if hasattr(search_model, 'name'):
search.append(search_model.name.ilike(f'%{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}%"),
)
search.append(search_model.name == query_str)
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))
if sort:
query = query.order_by(desc(func.ts_rank_cd(vector, func.tsq_parse(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):
query = Select(model)
else:
query = model
if query_str:
sort = False if sort_by else True
query = search(query_str=query_str, query=query, model=model, sort=sort)
# Get total count
count_query = Select(func.count()).select_from(query.subquery())
total = await db_session.scalar(count_query)
if all:
result = await db_session.execute(query)
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)
result = await db_session.execute(query)
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
else:
from strenum import StrEnum
from enum import 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):
SUCCESS = 'success'
ERROR = 'error'
SUCCESS = "success"
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 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):
__tablename__ = 'oh_ms_scope_equipment_part'
__tablename__ = "oh_ms_scope_equipment_part"
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)
part = relationship('MasterSparePart', lazy='selectin')
part = relationship(
"MasterSparePart",
lazy="selectin",
)

@ -1,12 +1,27 @@
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.service import (CommonParameters, DbSession,
search_filter_sort_paginate)
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
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):
"""Get all scope activity pagination."""
# return
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 pydantic import Field
from datetime import datetime
from typing import Any, Dict, List, Optional
from uuid import UUID
from pydantic import BaseModel, Field
from src.models import DefultBase, Pagination
class ScopeEquipmentActivityBase(DefultBase):
assetnum: str = Field(..., description='Assetnum is required')
assetnum: str = Field(..., description="Assetnum is required")
class ScopeEquipmentActivityCreate(ScopeEquipmentActivityBase):
name: str
cost: Optional[float] = Field(0, ge=0, le=1000000000000000)
cost: Optional[float] = Field(0)
class ScopeEquipmentActivityUpdate(ScopeEquipmentActivityBase):
name: Optional[str] = Field(None)
cost: Optional[str] = Field(0)
class ScopeEquipmentActivityRead(ScopeEquipmentActivityBase):
name: str
cost: float = Field(..., ge=0, le=1000000000000000)
cost: float
class ScopeEquipmentActivityPagination(Pagination):
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 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 sqlalchemy.ext.asyncio import AsyncSession as DbSession
from sqlalchemy.sql import text
import logging
logger = logging.getLogger(__name__)
# async def get_all(
# db_session: CollectorDbSession,
# location_tag: Optional[str] = None,
# start_year: int = 2023,
# end_year: Optional[int] = None,
# parent_wonum: Optional[str] = None
# ) -> List[Dict[str, Any]]:
# """
# Retrieve overhaul spare parts consumption data.
# Handles missing data, null parent WO, and query safety.
# Args:
# db_session: Async SQLAlchemy session
# location_tag: Optional location filter
# start_year: Year to start analysis (default 2023)
# end_year: Optional year to end analysis (default start_year + 1)
# parent_wonum: Parent work order number (required for context)
# Returns:
# List of dictionaries with spare part usage per overhaul WO.
# """
# # --- 1. Basic validation ---
# if not parent_wonum:
# logger.warning("Parent WO number not provided. Returning empty result.")
# return []
# if start_year < 1900 or (end_year and end_year < start_year):
# raise ValueError("Invalid year range provided.")
# if end_year is None:
# end_year = start_year + 1
# # --- 2. Build SQL safely ---
# base_query = """
# WITH filtered_wo AS (
# SELECT wonum, location_tag
# FROM public.wo_max
# WHERE worktype = 'OH'
# AND xx_parent = :parent_wonum
# """
# params = {
# "parent_wonum": parent_wonum,
# }
# if location_tag:
# base_query += " AND location_tag = :location_tag"
# params["location_tag"] = location_tag
# base_query += """
# ),
# filtered_materials AS (
# SELECT wonum, itemnum, itemqty, inv_curbaltotal, inv_avgcost
# FROM public.wo_max_material
# WHERE wonum IN (SELECT wonum FROM filtered_wo)
# )
# SELECT
# fwo.location_tag AS location_tag,
# fm.itemnum,
# spl.description AS sparepart_name,
# COALESCE(SUM(fm.itemqty), 0) AS parts_consumed_in_oh,
# COALESCE(AVG(fm.inv_avgcost), 0) AS avgcost,
# COALESCE(AVG(fm.inv_curbaltotal), 0) AS inv_curbaltotal
# FROM filtered_wo fwo
# INNER JOIN filtered_materials fm ON fwo.wonum = fm.wonum
# LEFT JOIN public.maximo_sparepart_pr_po_line spl ON fm.itemnum = spl.item_num
# GROUP BY fwo.location_tag, fm.itemnum, spl.description
# ORDER BY fwo.location_tag, fm.itemnum;
# """
# # --- 3. Execute query ---
# try:
# result = await db_session.execute(text(base_query), params)
# rows = result.fetchall()
# # Handle "no data found"
# if not rows:
# logger.info(f"No spare part data found for parent WO {parent_wonum}.")
# return []
# # --- 4. Map results cleanly ---
# equipment_parts = []
# for row in rows:
# try:
# equipment_parts.append({
# "location_tag": row.location_tag,
# "itemnum": row.itemnum,
# "sparepart_name": row.sparepart_name or "-",
# "parts_consumed_in_oh": float(row.parts_consumed_in_oh or 0),
# "avgcost": float(row.avgcost or 0),
# "inv_curbaltotal": float(row.inv_curbaltotal or 0)
# })
# except Exception as parse_err:
# logger.error(f"Failed to parse row {row}: {parse_err}")
# continue # Skip malformed rows
# return equipment_parts
# except Exception as e:
# logger.exception(f"Database query failed: {e}")
# raise RuntimeError("Failed to fetch overhaul spare parts data.") from e
from typing import List, Dict, Any, Optional
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.sql import text
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:
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 = {}
# Optional filter for location
if location_tag:
query_str += ' AND asset_location = :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 += " AND asset_location = :location_tag"
params["location_tag"] = location_tag
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:
result = await db_session.execute(text(query_str), params)
rows = result.fetchall()
equipment_parts = []
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
except Exception as e:
print(f'[get_all] Database query error: {e}')
print(f"[get_all] Database query error: {e}")
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 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):
__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'))
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 fastapi import APIRouter, Query
from src.database.service import CommonParameters, DbSession
from typing import Dict, List, Optional
from fastapi import APIRouter, HTTPException, Query, status
from src.database.service import (CommonParameters, DbSession,
search_filter_sort_paginate)
from src.models import StandardResponse
from .schema import ScopeEquipmentJobCreate, ScopeEquipmentJobPagination
from .service import create, delete, get_all
router = APIRouter()
@router.get('/{location_tag}', response_model=StandardResponse[ScopeEquipmentJobPagination])
async def get_jobs(common: CommonParameters, location_tag: str, scope: Optional[str]=Query(None)):
@router.get(
"/{location_tag}", response_model=StandardResponse[ScopeEquipmentJobPagination]
)
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)
return StandardResponse(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):
return StandardResponse(
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)
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):
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 pydantic import Field
from pydantic import BaseModel, Field
from src.workscope_group.schema import ActivityMasterRead
from src.models import DefultBase, Pagination
from src.overhaul_scope.schema import ScopeRead
class ScopeEquipmentJobBase(DefultBase):
pass
class ScopeEquipmentJobCreate(ScopeEquipmentJobBase):
job_ids: Optional[List[UUID]] = []
class ScopeEquipmentJobUpdate(ScopeEquipmentJobBase):
name: Optional[str] = Field(None)
cost: Optional[str] = Field(0)
class OverhaulActivity(DefultBase):
id: UUID
overhaul_scope: ScopeRead
class OverhaulJob(DefultBase):
id: UUID
overhaul_activity: OverhaulActivity
class ScopeEquipmentJobRead(ScopeEquipmentJobBase):
id: UUID
workscope_group: Optional[ActivityMasterRead]
location_tag: str
class ScopeEquipmentJobPagination(Pagination):
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 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.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.service import get_equipment_level_by_no
from src.workscope_group.model import MasterActivity
from src.workscope_group_maintenance_type.model import WorkscopeOHType
from src.overhaul_scope.model import MaintenanceType
from .model import EquipmentWorkscopeGroup
from .schema import ScopeEquipmentJobCreate
from src.soft_delete import apply_not_deleted_filter
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))
query = apply_not_deleted_filter(query, EquipmentWorkscopeGroup)
# 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):
"""Returns all documents."""
query = (
Select(EquipmentWorkscopeGroup)
.where(EquipmentWorkscopeGroup.location_tag == location_tag).filter(EquipmentWorkscopeGroup.workscope_group_id.is_not(None))
)
if scope:
query = query.join(EquipmentWorkscopeGroup.workscope_group).join(MasterActivity.oh_types).join(WorkscopeOHType.oh_type).filter(MaintenanceType.name == scope)
return await search_filter_sort_paginate(model=query, **common)
query = (
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 = []
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: MasterEquipment = await db_session.scalar(equipment_stmt)
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:
scope_equipment_job = ScopeEquipmentJob(assetnum=assetnum, job_id=job_id)
scope_jobs.append(scope_equipment_job)
db_session.add_all(scope_jobs)
await db_session.commit()
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:
# Check if job exists
scope_job = await db_session.get(ScopeEquipmentJob, scope_job_id)
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.commit()
return True
except Exception:
except Exception as e:
await db_session.rollback()
raise

@ -1,52 +1,24 @@
# Define base error model
import logging
import re
import uuid
from typing import Any, Dict, List, Optional
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.responses import JSONResponse
from pydantic import BaseModel
from slowapi import _rate_limit_exceeded_handler
from slowapi.errors import RateLimitExceeded
from sqlalchemy.exc import DataError, DBAPIError, IntegrityError, SQLAlchemyError, NoResultFound
from starlette.exceptions import HTTPException as StarletteHTTPException
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
from sqlalchemy.exc import (DataError, DBAPIError, IntegrityError,
SQLAlchemyError)
class RequestTimeoutError(OptimumOHBaseException):
pass
class UnprocessableEntityError(OptimumOHBaseException):
pass
from src.enums import ResponseStatus
from src.auth.service import notify_admin_on_rate_limit_sync
class TooManyRequestsError(OptimumOHBaseException):
pass
from starlette.exceptions import HTTPException as StarletteHTTPException
class InternalServerError(OptimumOHBaseException):
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'}
log = logging.getLogger(__name__)
class ErrorDetail(BaseModel):
field: Optional[str] = None
@ -54,188 +26,209 @@ class ErrorDetail(BaseModel):
code: Optional[str] = None
params: Optional[Dict[str, Any]] = None
class ErrorResponse(BaseModel):
data: Optional[Any] = None
message: str
status: ResponseStatus = ResponseStatus.ERROR
errors: Optional[List[ErrorDetail]] = None
def get_request_context(request: Request) -> dict:
def get_client_ip() -> str:
if 'X-Real-IP' in request.headers:
return request.headers['X-Real-IP']
if 'X-Forwarded-For' in request.headers:
return request.headers['X-Forwarded-For'].split(',')[0].strip()
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 _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})
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
request.state.log_error_id = error_id
request.state.log_error_type = error_type
request.state.log_error_message = error_message
def _short_db_error(orig_str: str) -> str:
s = orig_str.lower()
if 'uuid' in s:
if 'length must be between' in s or 'got ' in s:
return 'Invalid UUID length'
if 'badly formed' in s or 'invalid hexadecimal' in s or 'invalid uuid' in s:
return 'Invalid UUID format'
if 'invalid input syntax for type' in s:
m = re.search('invalid input syntax for type (\\w+)', s)
type_name = m.group(1) if m else 'value'
return f'Invalid format for type {type_name}'
if 'invalid input for query argument' in s:
m = re.search('\\(([^)]+)\\)\\s*$', orig_str)
if m:
return _short_db_error(m.group(1))
return 'Invalid query argument value'
if 'unique constraint' in s:
return 'Unique constraint violation'
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}')
# Custom exception handler setup
def get_request_context(request: Request):
"""
Get detailed request context for logging.
"""
def get_client_ip():
"""
Get the real client IP address from Kong Gateway headers.
Kong sets X-Real-IP and X-Forwarded-For headers by default.
"""
# Kong specific headers
if "X-Real-IP" in request.headers:
return request.headers["X-Real-IP"]
# Fallback to X-Forwarded-For
if "X-Forwarded-For" in request.headers:
# Get the first IP (original client)
return request.headers["X-Forwarded-For"].split(",")[0].strip()
# Last resort
return request.client.host
return {
"endpoint": request.url.path,
"url": str(request.url),
"method": request.method,
"remote_addr": get_client_ip(),
}
def handle_sqlalchemy_error(error: SQLAlchemyError):
"""
Handle SQLAlchemy errors and return user-friendly error messages.
"""
try:
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)
# Store error_id in request.state for middleware/logging
request.state.error_id = error_id
if isinstance(exc, RateLimitExceeded):
try:
from src.auth.service import notify_admin_on_rate_limit_sync
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:
pass
_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}')
# 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):
errors = exc.errors()
detail_parts = []
for err in errors:
loc = ' -> '.join((str(part) for part in err.get('loc', [])))
msg = err.get('msg', '')
detail_parts.append(f'{loc}: {msg}' if loc else msg)
detail_str = '; '.join(detail_parts) if detail_parts else 'Input validation failed'
_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):
status_code = exc.status_code
error_type = _HTTP_STATUS_NAMES.get(status_code, f'HTTP {status_code}')
if status_code == 401:
log_msg = str(exc.detail) if exc.detail else 'Invalid authorization token'
_store_log_state(request, 401, error_id, 'Unauthorized', log_msg)
return _json_response(401, f'{_FIXED_MESSAGES[401]}. 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.detail, dict):
log_msg = exc.detail.get('error', error_type)
else:
log_msg = str(exc.detail) if exc.detail else error_type
client_msg = _FIXED_MESSAGES.get(status_code, log_msg)
_store_log_state(request, status_code, error_id, error_type, log_msg)
return _json_response(status_code, f'{client_msg}. Error ID: {error_id}')
log.warning(
f"Validation error: {exc.errors()} | Error ID: {error_id}",
extra={
"error_id": error_id,
"error_category": "validation",
"errors": exc.errors(),
"request": request_info,
},
)
return JSONResponse(
status_code=422,
content={
"data": exc.errors(),
"message": "Validation Error",
"status": ResponseStatus.ERROR,
"error_id": error_id
},
)
if isinstance(exc, (HTTPException, StarletteHTTPException)):
# Log as warning for 4xx, error for 5xx
status_code = exc.status_code if hasattr(exc, "status_code") else 500
detail = exc.detail if hasattr(exc, "detail") else str(exc)
log_level = logging.WARNING if 400 <= status_code < 500 else logging.ERROR
log.log(
log_level,
f"HTTP {status_code}: {detail} | Error ID: {error_id}",
extra={
"error_id": error_id,
"error_category": "http",
"status_code": status_code,
"request": request_info,
},
)
return JSONResponse(
status_code=status_code,
content={
"data": None,
"message": str(detail),
"status": ResponseStatus.ERROR,
"error_id": error_id
},
)
if isinstance(exc, SQLAlchemyError):
orig = getattr(exc, 'orig', None)
orig_str = str(orig) if orig else str(exc)
short = _short_db_error(orig_str)
if isinstance(exc, NoResultFound):
_store_log_state(request, 404, error_id, 'Not Found', 'Resource not found')
return _json_response(404, f'Resource not found. Error ID: {error_id}')
if isinstance(exc, IntegrityError):
orig_lower = orig_str.lower()
if 'unique constraint' in orig_lower:
_store_log_state(request, 409, error_id, 'Conflict', f'Unique constraint violation: {short}')
return _json_response(409, f'This record already exists. Error ID: {error_id}')
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 _json_response(422, f'{_FIXED_MESSAGES[422]}. Error ID: {error_id}')
if isinstance(exc, DataError):
_store_log_state(request, 422, error_id, 'Unprocessable Entity', f'Invalid data format: {short}')
return _json_response(422, f'{_FIXED_MESSAGES[422]}. Error ID: {error_id}')
if isinstance(exc, DBAPIError):
orig_lower = orig_str.lower()
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:
_store_log_state(request, 409, error_id, 'Conflict', f'Unique constraint violation: {short}')
return _json_response(409, f'This record already exists. Error ID: {error_id}')
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, 500, error_id, 'Internal Server Error', f'Database query error: {short}')
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:
import httpx as _httpx
if isinstance(exc, _httpx.HTTPStatusError):
upstream_status = exc.response.status_code
error_type = _HTTP_STATUS_NAMES.get(upstream_status, f'HTTP {upstream_status}')
client_msg = _FIXED_MESSAGES.get(upstream_status, 'Upstream service error')
log_msg = f'Upstream service returned {upstream_status}: {exc.request.url}'
_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)
error_message, status_code = handle_sqlalchemy_error(exc)
# Log integrity errors as warning, others as error
log_level = logging.WARNING if 400 <= status_code < 500 else logging.ERROR
log.log(
log_level,
f"Database error: {error_message} | Error ID: {error_id}",
extra={
"error_id": error_id,
"error_category": "database",
"violation": str(exc).split('\n')[0],
"request": request_info,
},
)
return JSONResponse(
status_code=status_code,
content={
"data": None,
"message": error_message,
"status": ResponseStatus.ERROR,
"error_id": error_id
},
)
# Log unexpected errors
log.error(
f"Unexpected error: {str(exc)} | Error ID: {error_id}",
extra={
"error_id": error_id,
"error_category": "unexpected",
"request": request_info,
},
)
return JSONResponse(
status_code=500,
content={
"data": None,
"message": "An unexpected error occurred",
"status": ResponseStatus.ERROR,
"error_id": 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 asyncio
import time
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 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 pydantic import ValidationError
from slowapi import _rate_limit_exceeded_handler
from slowapi.errors import RateLimitExceeded
from slowapi.middleware import SlowAPIMiddleware
from sqlalchemy import inspect
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.requests import Request
from starlette.responses import Response
import uuid as _uuid_module
from starlette.responses import FileResponse, Response, StreamingResponse
from starlette.routing import compile_path
from starlette.staticfiles import StaticFiles
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.exceptions import handle_exception
from src.app_logging import configure_logging
from src.middleware import RequestValidationMiddleware, RequestTimeoutMiddleware
from src.logging import configure_logging
from src.middleware import RequestValidationMiddleware
from src.rate_limiter import limiter
from fastapi.exceptions import RequestValidationError
from starlette.exceptions import HTTPException as StarletteHTTPException
from src.csrf_protect import csrf_protect
log = logging.getLogger(__name__)
# we configure the logging level and format
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')
def _sync_rate_limit_handler(request: Request, exc: RateLimitExceeded) -> JSONResponse:
error_id = str(_uuid_module.uuid4())
request.state.log_status_code = 429
request.state.log_error_id = error_id
request.state.log_error_type = 'Too Many Requests'
request.state.log_error_message = 'Please try again later'
return JSONResponse(status_code=429, content={'data': None, 'message': f'Please try again later. Error ID: {error_id}', 'status': ResponseStatus.ERROR})
from src.config import DEBUG
# we create the ASGI for the app
app = FastAPI(
openapi_url="",
title="LCCA API",
description="Welcome to LCCA's API documentation!",
version="0.1.0",
debug=bool(DEBUG),
)
# we define the exception handlers
app.add_exception_handler(Exception, handle_exception)
app.add_exception_handler(HTTPException, handle_exception)
app.add_exception_handler(StarletteHTTPException, handle_exception)
app.add_exception_handler(RequestValidationError, handle_exception)
app.add_exception_handler(RateLimitExceeded, _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(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):
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):
response: Response = await call_next(request)
if is_production:
response.headers['Strict-Transport-Security'] = 'max-age=15724800; includeSubDomains; preload'
response.headers['X-Frame-Options'] = 'DENY'
response.headers['X-Content-Type-Options'] = 'nosniff'
response.headers['X-XSS-Protection'] = '1; mode=block'
response.headers['Referrer-Policy'] = 'strict-origin-when-cross-origin'
response.headers['Content-Security-Policy'] = csp_header_value
response.headers['Permissions-Policy'] = feature_header_value
response.headers["Strict-Transport-Security"] = "max-age=15724800; includeSubDomains; preload"
response.headers["X-Frame-Options"] = "DENY"
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["X-XSS-Protection"] = "1; mode=block"
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
response.headers["Content-Security-Policy"] = csp_header_value
response.headers["Permissions-Policy"] = feature_header_value
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
app.add_middleware(SecurityHeadersMiddleware)
security_headers_middleware(app)
app.add_middleware(RequestValidationMiddleware)
@app.middleware('http')
@app.middleware("http")
async def db_session_middleware(request: Request, call_next):
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)
request.state.request_id = request_id
try:
session = async_scoped_session(async_session, scopefunc=get_request_id)
request.state.db = session()
collector_session = async_scoped_session(async_collector_session, scopefunc=get_request_id)
request.state.collector_db = collector_session()
start_time = time.time()
try:
response = await call_next(request)
except Exception as exc:
response = await handle_exception(request, exc)
duration_ms = round((time.time() - start_time) * 1000, 2)
timed_out = request.scope.get('x_timed_out', False)
if timed_out:
status_code = 408
error_id = request.scope.get('x_timeout_error_id', None)
error_type = 'Request Timeout'
error_message = 'Request timeout over 20 seconds'
else:
status_code = getattr(request.state, 'log_status_code', None) or response.status_code
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
response = await call_next(request)
process_time = (time.time() - start_time) * 1000
# Skip logging in middleware if it's an error (already logged in handle_exception)
if response.status_code >= 400:
return response
from src.context import get_username, get_role, get_user_id, set_user_id, set_username, set_role
# Pull from context or fallback to request.state.user
username = get_username()
role = get_role()
user_id = get_user_id()
user_obj = getattr(request.state, 'user', None)
if user_obj 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):
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:
u_id = getattr(user_obj, 'user_id', None)
if u_id:
u_id = getattr(user_obj, "user_id", None)
u_name = getattr(user_obj, "name", None) or getattr(user_obj, "username", None)
u_role = getattr(user_obj, "role", None)
if not user_id and u_id:
user_id = str(u_id)
set_user_id(user_id)
headers = request.headers
if 'X-Real-IP' in headers:
user_ip = headers['X-Real-IP']
elif 'X-Forwarded-For' in headers:
user_ip = headers['X-Forwarded-For'].split(',')[0].strip()
else:
user_ip = request.client.host if request.client else None
path = request.url.path
if request.url.query:
path = f'{path}?{request.url.query}'
result = 'Request completed' if status_code < 400 else 'Request failed'
if status_code >= 500:
log_level = logging.ERROR
elif status_code >= 400:
log_level = logging.WARNING
else:
log_level = logging.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})
if not username and u_name:
username = u_name
set_username(username)
if not role and u_role:
role = u_role
set_role(role)
user_info_str = ""
if user_id:
user_info_str = f" | User ID: {user_id}"
error_id = getattr(request.state, "error_id", None)
log_msg = f"HTTP {request.method} {request.url.path} completed in {round(process_time, 2)}ms{user_info_str}"
if error_id:
log_msg += f" | Error ID: {error_id}"
log.info(
log_msg,
extra={
"method": request.method,
"path": request.url.path,
"status_code": response.status_code,
"duration_ms": round(process_time, 2),
"user_id": user_id,
"error_id": error_id,
},
)
finally:
await request.state.db.close()
await request.state.collector_db.close()
reset_request_id(ctx_token)
return response
@app.middleware('http')
@app.middleware("http")
async def add_security_headers(request: Request, call_next):
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
# 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)

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

@ -1,37 +1,332 @@
from datetime import datetime
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.overhaul_scope.model import OverhaulScope
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 ")
async def get_cm_cost_summary(collector_db: CollectorDbSession, last_oh_date:datetime, upcoming_oh_date:datetime):
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)
data = []
for row in results:
data.append({'location_tag': row.asset_location, 'avg_cost': row.avg_cost})
return {item['location_tag']: item['avg_cost'] for item in data}
data.append({
"location_tag": row.asset_location,
"avg_cost": row.avg_cost
})
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 ")
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):
# 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)
data = []
for row in result:
data.append({'location_tag': row.asset_location, 'avg_cost': row.avg_cost})
return {item['location_tag']: item['avg_cost'] for item in data}
data.append({
"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
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:
query = select(OverhaulScope.wo_parent).where(OverhaulScope.id == oh_session_id)
result = await db_session.execute(query)
parent_wo_num = result.scalar()
if not parent_wo_num:
return []
# Ensure parent_wo_num is a list and removed duplicates if any
if isinstance(parent_wo_num, str):
parent_wo_num = [parent_wo_num]
else:
parent_wo_num = list(set(parent_wo_num))
sql_query = text("\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})
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]
sql_query = text("""
WITH target_wos AS (
SELECT
w.wonum,
w.assetnum,
COALESCE(w.actmatcost, 0) as actmatcost,
COALESCE(w.actservcost, 0) as actservcost
FROM public.wo_maximo w
WHERE w.xx_parent = ANY(:parent_wo_num)
),
wo_tasks AS (
SELECT
t.xx_parent AS parent_wonum,
JSON_AGG(t.description) AS task_list
FROM public.wo_maximo t
JOIN target_wos tw ON t.xx_parent = tw.wonum
GROUP BY t.xx_parent
)
SELECT
w.assetnum,
e.name AS equipment_name,
e.location_tag,
JSON_OBJECT_AGG(w.wonum, COALESCE(wt.task_list, '[]'::json)) AS wonum_list,
COUNT(w.wonum) AS total_wo_count,
COALESCE(SUM(w.actmatcost), 0) AS total_material_cost,
COALESCE(SUM(w.actservcost), 0) AS total_service_cost,
COALESCE(SUM(w.actmatcost + w.actservcost), 0) AS total_actual_cost
FROM target_wos w
INNER JOIN public.ms_equipment_master e
ON w.assetnum = e.assetnum
LEFT JOIN wo_tasks wt
ON w.wonum = wt.parent_wonum
GROUP BY
w.assetnum,
e.name,
e.location_tag
ORDER BY total_actual_cost DESC;
""")
results = await collector_db_session.execute(sql_query, {"parent_wo_num": parent_wo_num})
return [
{
"assetnum": row.assetnum,
"equipment_name": row.equipment_name,
"location_tag": row.location_tag,
"wonum_list": row.wonum_list,
"total_wo_count": row.total_wo_count,
"total_material_cost": float(row.total_material_cost),
"total_service_cost": float(row.total_service_cost),
"total_actual_cost": float(row.total_actual_cost)
}
for row in results
]

@ -1 +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 fastapi import Request, HTTPException
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_LENGTH = 2000
MAX_JSON_BODY_SIZE = 1024 * 500
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)
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)
TRAVERSAL_PATTERN = re.compile('(\\.\\.[/\\\\]|%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')
MAX_JSON_BODY_SIZE = 1024 * 500 # 500 KB
XSS_PATTERN = re.compile(
r"("
r"<(script|iframe|embed|object|svg|img|video|audio|base|link|meta|form|button|details|animate)\b|"
r"javascript\s*:|vbscript\s*:|data\s*:[^,]*base64[^,]*|data\s*:text/html|"
r"\bon[a-z]+\s*=|" # Catch-all for any 'on' event (onerror, onclick, etc.)
r"style\s*=.*expression\s*\(|" # Old IE specific
r"\b(eval|setTimeout|setInterval|Function)\s*\("
r")",
re.IGNORECASE,
)
SQLI_PATTERN = re.compile(
r"("
# 1. Keywords followed by whitespace and common SQL characters
r"\b(UNION|SELECT|INSERT|UPDATE|DELETE|DROP|ALTER|CREATE|TRUNCATE|EXEC(UTE)?|DECLARE)\b\s+[\w\*\(\']|"
# 2. Time-based attacks (more specific than just 'SLEEP')
r"\b(WAITFOR\b\s+DELAY|PG_SLEEP|SLEEP\s*\()|"
# 3. System tables/functions
r"\b(INFORMATION_SCHEMA|SYS\.|SYSOBJECTS|XP_CMDSHELL|LOAD_FILE|INTO\s+OUTFILE)\b|"
# 4. Logical Tautologies (OR 1=1) - Optimized for boundaries
r"\b(OR|AND)\b\s+['\"]?\d+['\"]?\s*=\s*['\"]?\d+|"
# 5. Comments
# Match '--' if at start or preceded by whitespace
r"(?<!\S)--|"
# Match block comments, ensuring they aren't part of mime patterns like */*
r"(?<!\*)/\*|(?<!\*)\*/(?!\*)|"
# Match '#' if at start or preceded by whitespace
r"(?<!\S)#|"
# 6. Hex / Stacked Queries
r";\s*\b(SELECT|DROP|DELETE|UPDATE|INSERT)\b"
r")",
re.IGNORECASE
)
RCE_PATTERN = re.compile(
r"("
r"\$\(.*\)|`.*`|" # Command substitution $(...) or `...`
r"[;&|]\s*(cat|ls|id|whoami|pwd|ifconfig|ip|netstat|nc|netcat|nmap|curl|wget|python|php|perl|ruby|bash|sh|cmd|powershell|pwsh|sc\s+|tasklist|taskkill|base64|sudo|crontab|ssh|ftp|tftp)|"
# Only flag naked commands if they are clearly standalone or system paths
r"(/etc/passwd|/etc/shadow|/etc/group|/etc/issue|/proc/self/|/windows/system32/|C:\\Windows\\)\b"
r")",
re.IGNORECASE,
)
TRAVERSAL_PATTERN = re.compile(
r"(\.\.[/\\]|%2e%2e%2f|%2e%2e/|\.\.%2f|%2e%2e%5c|%252e%252e%252f|\\00)",
re.IGNORECASE,
)
FORBIDDEN_JSON_KEYS = {"__proto__", "constructor", "prototype"}
DYNAMIC_KEYS = {
"data",
"results",
"analysis_metadata",
"asset_contributions",
"equipment_results",
"optimal_analysis",
"optimum_analysis",
"schedules",
"tasks",
"all_params",
"parameters",
"program_data"
}
log = logging.getLogger("security_logger")
def has_control_chars(value: str) -> bool:
return any((ord(c) < 32 and c not in ('\n', '\r', '\t') for c in value))
return any(ord(c) < 32 and c not in ("\n", "\r", "\t") for c in value)
def inspect_value(value: str, source: str):
if not isinstance(value, str) or value == '*/*':
if not isinstance(value, str) or value == "*/*":
return
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):
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):
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):
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):
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):
for key, value in obj.items():
if key in FORBIDDEN_JSON_KEYS:
raise HTTPException(status_code=422, detail=f'Forbidden JSON key detected: {path}.{key}')
if check_whitelist and key not in ALLOWED_DATA_PARAMS:
raise HTTPException(status_code=422, detail=f'Unknown JSON key detected: {path}.{key}')
should_check_subkeys = check_whitelist and key not in DYNAMIC_KEYS
inspect_json(value, f'{path}.{key}', check_whitelist=should_check_subkeys)
log.warning(f"Security violation: Forbidden JSON key detected: {path}.{key}")
raise HTTPException(
status_code=422,
detail="Invalid request parameters",
)
# if check_whitelist and key not in ALLOWED_DATA_PARAMS:
# log.warning(f"Security violation: Unknown JSON key detected: {path}.{key}")
# raise HTTPException(
# status_code=422,
# detail="Invalid request parameters",
# )
# Recurse. If the key is a dynamic container, we stop whitelist checking for children.
should_check_subkeys = check_whitelist and (key not in DYNAMIC_KEYS)
inspect_json(value, f"{path}.{key}", check_whitelist=should_check_subkeys)
elif isinstance(obj, list):
for i, item in enumerate(obj):
inspect_json(item, f'{path}[{i}]', check_whitelist=check_whitelist)
inspect_json(item, f"{path}[{i}]", check_whitelist=check_whitelist)
elif isinstance(obj, str):
inspect_value(obj, path)
class RequestValidationMiddleware(BaseHTTPMiddleware):
# =========================
# Middleware
# =========================
class RequestValidationMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
try:
return await self._validate_and_forward(request, call_next)
except HTTPException as exc:
from src.exceptions import handle_exception
return await handle_exception(request, exc)
# -------------------------
# 0. Header validation
# -------------------------
header_keys = [key.lower() for key, _ in request.headers.items()]
# Check for duplicate headers
header_counter = Counter(header_keys)
duplicate_headers = [key for key, count in header_counter.items() if count > 1]
ALLOW_DUPLICATE_HEADERS = {'accept', 'accept-encoding', 'accept-language', 'accept-charset', 'cookie'}
real_duplicates = [h for h in duplicate_headers if h not in ALLOW_DUPLICATE_HEADERS]
if real_duplicates:
log.warning(f"Security violation: Duplicate headers detected: {real_duplicates}")
raise HTTPException(
status_code=422,
detail="Invalid request parameters",
)
# Whitelist headers
unknown_headers = [key for key in header_keys if key not in ALLOWED_HEADERS]
if unknown_headers:
filtered_unknown = [h for h in unknown_headers if not h.startswith('sec-')]
if filtered_unknown:
log.warning(f"Security violation: Unknown headers detected: {filtered_unknown}")
raise HTTPException(
status_code=422,
detail="Invalid request parameters",
)
# Inspect header values
for key, value in request.headers.items():
if value:
inspect_value(value, f"header '{key}'")
async def _validate_and_forward(self, request: Request, call_next):
# -------------------------
# 1. Query string limits
# -------------------------
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()
if len(params) > MAX_QUERY_PARAMS:
raise HTTPException(status_code=422, detail='Too many query parameters')
unknown_params = [key for key, _ in params if key not in ALLOWED_DATA_PARAMS]
if unknown_params:
raise HTTPException(status_code=422, detail=f'Unknown query parameters detected: {unknown_params}')
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]
log.warning(f"Security violation: Too many query parameters")
raise HTTPException(
status_code=422,
detail="Invalid request parameters",
)
# Check for unknown query parameters
# unknown_params = [key for key, _ in params if key not in ALLOWED_DATA_PARAMS]
# if unknown_params:
# log.warning(f"Security violation: Unknown query parameters detected: {unknown_params}")
# raise HTTPException(
# status_code=422,
# detail="Invalid request parameters",
# )
# -------------------------
# 2. Duplicate parameters
# -------------------------
counter = Counter(key for key, _ in params)
duplicates = [
key for key, count in counter.items()
if count > 1 and key not in ALLOWED_MULTI_PARAMS
]
if duplicates:
raise HTTPException(status_code=422, detail=f'Duplicate query parameters: {duplicates}')
pagination_size_keys = {'size', 'itemsPerPage', 'per_page', 'limit', 'items_per_page'}
log.warning(f"Security violation: Duplicate query parameters detected: {duplicates}")
raise HTTPException(
status_code=422,
detail="Invalid request parameters",
)
# -------------------------
# 3. Query param inspection & Pagination
# -------------------------
pagination_size_keys = {"size", "itemsPerPage", "per_page", "limit", "items_per_page"}
for key, value in params:
if value:
inspect_value(value, f"query param '{key}'")
if key in pagination_size_keys and value:
try:
size_val = int(value)
if size_val > 50:
raise HTTPException(status_code=422, detail=f'Pagination size too large: {size_val} (max 50)')
if size_val > 100:
log.warning(f"Security violation: Pagination size too large ({size_val})")
raise HTTPException(
status_code=422,
detail="Invalid request parameters",
)
if size_val % 5 != 0:
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:
raise HTTPException(status_code=422, detail=f"Pagination size invalid value for '{key}'")
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')))):
raise HTTPException(status_code=422, detail=f'Unsupported Content-Type: {content_type}')
log.warning(f"Security violation: Pagination size invalid value")
raise HTTPException(
status_code=422,
detail="Invalid request parameters",
)
# -------------------------
# 4. Content-Type sanity
# -------------------------
content_type = request.headers.get("content-type", "")
if content_type and not any(
content_type.startswith(t)
for t in ("application/json", "multipart/form-data", "application/x-www-form-urlencoded")
):
log.warning(f"Security violation: Unsupported Content-Type: {content_type}")
raise HTTPException(status_code=422, detail="Invalid request parameters")
# -------------------------
# 5. Single source check (Query vs JSON Body)
# -------------------------
has_query = len(params) > 0
has_body = False
if content_type.startswith('application/json'):
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:
has_body = True
if has_query and has_body:
raise HTTPException(status_code=422, detail='Mixed parameters: both query string and JSON body are not allowed')
if content_type.startswith('application/json'):
log.warning(f"Security violation: Mixed parameters (query + JSON body)")
raise HTTPException(
status_code=422,
detail="Invalid request parameters",
)
# -------------------------
# 6. JSON body inspection
# -------------------------
if content_type.startswith("application/json"):
body = await request.body()
# if len(body) > MAX_JSON_BODY_SIZE:
# raise HTTPException(status_code=422, detail="JSON body too large")
if body:
try:
payload = json.loads(body)
except json.JSONDecodeError:
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)
# Re-inject body for downstream handlers
async def receive():
return {'type': 'http.request', 'body': body}
return {"type": "http.request", "body": body}
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):
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
from datetime import datetime
from typing import Generic, Optional, TypeVar
import pytz
from pydantic import BaseModel, SecretStr
from sqlalchemy import Column, DateTime, String, event
from pydantic import BaseModel, Field, SecretStr
from sqlalchemy import Column, DateTime, String, event, func
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.enums import ResponseStatus
# SQLAlchemy Mixins
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
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
@staticmethod
@ -20,32 +34,50 @@ class TimeStampMixin(object):
@classmethod
def __declare_last__(cls):
event.listen(cls, 'before_update', cls._updated_at)
event.listen(cls, "before_update", cls._updated_at)
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
class IdentityMixin:
"""Identity mixin"""
created_by = Column(String(100), nullable=True)
updated_by = Column(String(100), nullable=True)
class DefultBase(BaseModel):
# Pydantic Models
class DefultBase(BaseModel):
class Config:
from_attributes = True
validate_assignment = True
arbitrary_types_allowed = True
str_strip_whitespace = True
populate_by_name = True
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}
extra="forbid"
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):
itemsPerPage: int
@ -53,11 +85,16 @@ class Pagination(DefultBase):
page: int
total: int
class PrimaryKeyModel(BaseModel):
id: uuid.UUID
T = TypeVar('T')
# Define data type variable for generic response
T = TypeVar("T")
class StandardResponse(BaseModel, Generic[T]):
data: Optional[T] = None
message: str = 'Success'
message: str = "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 fastapi import APIRouter
from fastapi import APIRouter, HTTPException, status
from src.auth.service import Token
from src.database.core import CollectorDbSession, DbSession
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 .schema import OverhaulCriticalParts, OverhaulRead, OverhaulSystemComponents
from src.auth.access_control import required_permission, OHPermission
from src.overhaul_scope.model import OverhaulScope
from fastapi import Depends
from .schema import (OverhaulCriticalParts, OverhaulRead,
OverhaulSystemComponents)
router = APIRouter()
@router.get('', response_model=StandardResponse[OverhaulRead], dependencies=[Depends(required_permission(OHPermission.READ, OverhaulScope))])
async def get_overhaul(db_session: DbSession, token: Token, collector_db_session: CollectorDbSession):
@router.get("", response_model=StandardResponse[OverhaulRead])
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)
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()
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():
"""Get all 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():
"""Get all 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():
"""Get all 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 src.models import DefultBase, Pagination
from src.overhaul_scope.schema import ScopeRead
class OverhaulBase(BaseModel):
pass
class OverhaulCriticalParts(OverhaulBase):
criticalParts: List[str] = Field(..., description='List of critical parts')
criticalParts: List[str] = Field(..., description="List of critical parts")
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):
systemComponents: Dict[str, Any] = Field(..., description='List of system components')
systemComponents: Dict[str, Any] = Field(
..., description="List of system components"
)
class OverhaulRead(OverhaulBase):
overview: Dict[str, Any]
criticalParts: dict
schedules: List[ScopeRead]
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
from typing import Optional
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.config import TC_RBD_ID
from src.database.core import DbSession
from src.contribution_util import calculate_contribution
from src.overhaul_activity.service import get_standard_scope_by_session_id
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.standard_scope.service import get_by_oh_session_id
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):
headers = {'Authorization': f'Bearer {token}', 'Content-Type': 'application/json'}
calc_result_url = f'{RBD_SERVICE_API}/aeros/simulation/result/calc/{simulation_id}?nodetype=RegularNode'
calc_plant_result = f'{RBD_SERVICE_API}/aeros/simulation/result/calc/{simulation_id}/plant'
headers = {
"Authorization": f"Bearer {token}",
"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:
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)
# Run all three requests concurrently
calc_response, plant_response = await asyncio.gather(calc_task, plant_task)
calc_response.raise_for_status()
# plot_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}
calc_data = calc_response.json()["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):
equipments = await get_standard_scope_by_session_id(db_session=db_session, overhaul_session_id=session_id, collector_db=collector_db_session)
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']}
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]
filtered_result = [item for item in base_result if item['matrix'] is not None]
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}
"""Get all overhaul critical parts."""
equipments = await get_standard_scope_by_session_id(
db_session=db_session,
overhaul_session_id=session_id,
collector_db=collector_db_session
)
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):
"""Get all overhaul schedules."""
query = Select(OverhaulScope)
results = await db_session.execute(query)
return results.scalars().all()
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}}
availabilities = {schematic: item['availability'] for schematic, item in powerplant_reliability.items()}
"""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() }
percentages = calculate_contribution(availabilities)
for schema, contribution in percentages.items():
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%'}}
powerplant_reliability[schema]["critical_contribution"] = contribution['criticality_importance']
# 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.models import DefaultMixin
from src.models import DefaultMixin, IdentityMixin, TimeStampMixin
from src.workorder.model import MasterWorkOrder
class OverhaulActivity(Base, DefaultMixin):
__tablename__ = 'oh_tr_overhaul_activity'
__tablename__ = "oh_tr_overhaul_activity"
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)
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 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.service import CommonParameters, DbSession
from src.database.service import (CommonParameters, DbSession,
search_filter_sort_paginate)
from src.models import StandardResponse
from src.auth.access_control import require_any_role, ALLOWED_ROLES
from .schema import OverhaulActivityCreate, OverhaulActivityRead
from .service import add_multiple_equipment_to_session, get, get_all, remove_equipment_from_session
router = APIRouter(dependencies=[Depends(require_any_role(*ALLOWED_ROLES))])
@router.get('/{overhaul_session}', response_model=StandardResponse[dict])
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.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):
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)
from .schema import (OverhaulActivityCreate, OverhaulActivityPagination,
OverhaulActivityRead, OverhaulActivityUpdate)
from .service import add_multiple_equipment_to_session, get, get_all, remove_equipment_from_session, update
router = APIRouter()
@router.get(
"/{overhaul_session}", response_model=StandardResponse[dict]
)
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),
):
"""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:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='A data with this id does not exist.')
return StandardResponse(data=equipment, message='Data retrieved successfully')
raise HTTPException(
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)])
async def delete_scope(db_session: DbSession, location_tag: str, overhaul_session: UUID):
# @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):
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 typing import List, Optional
from typing import Any, Dict, List, Optional
from uuid import UUID
from pydantic import Field
from src.models import DefultBase, Pagination
from src.standard_scope.schema import MasterEquipmentTree
from src.workscope_group.schema import ActivityMasterRead
class OverhaulActivityBase(DefultBase):
pass
class OverhaulActivityCreate(OverhaulActivityBase):
location_tags: List[str]
class OverhaulActivityUpdate(OverhaulActivityBase):
material_cost: Optional[float] = Field(0, ge=0, le=1000000000000000)
service_cost: Optional[float] = Field(0, ge=0, le=1000000000000000)
material_cost: Optional[float] = Field(0)
service_cost: Optional[float] = Field(0)
class OverhaulScope(DefultBase):
type: str
start_date: datetime
end_date: datetime
duration_oh: int = Field(..., ge=0, le=1000000)
duration_oh: int
class ScopeEquipmentJob(DefultBase):
job: ActivityMasterRead
@ -29,12 +35,16 @@ class OverhaulJob(DefultBase):
class OverhaulActivityRead(OverhaulActivityBase):
id: UUID
material_cost: Optional[float] = Field(0, ge=0, le=1000000000000000)
service_cost: Optional[float] = Field(0, ge=0, le=1000000000000000)
material_cost: Optional[float] = Field(0)
service_cost: Optional[float] = Field(0)
location_tag: str
equipment_name: Optional[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):
items: List[OverhaulActivityRead] = []

@ -1,14 +1,22 @@
import asyncio
import datetime
from typing import List, Optional
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 src.auth.service import CurrentUser
from src.database.core import DbSession
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.overhaul_scope.model import OverhaulScope
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.service import get_by_oh_session_id
from src.workscope_group.model import MasterActivity
from src.equipment_workscope_group.model import EquipmentWorkscopeGroup
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.standard_scope.model import EquipmentOHHistory
from .model import OverhaulActivity
from .schema import OverhaulActivityRead, OverhaulActivityUpdate
from .schema import (OverhaulActivityCreate, OverhaulActivityRead,
OverhaulActivityUpdate)
import json
from src.database.core import CollectorDbSession
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]:
query = Select(OverhaulActivity).where(OverhaulActivity.assetnum == assetnum).options(joinedload(OverhaulActivity.equipment))
query = apply_not_deleted_filter(query, OverhaulActivity)
async def get(
*, db_session: DbSession, assetnum: str, overhaul_session_id: Optional[UUID] = None
) -> 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:
query = query.filter(OverhaulActivity.overhaul_scope_id == overhaul_session_id)
result = await db_session.execute(query)
return result.scalar()
def get_cost_per_failute():
with open('src/overhaul_activity/cost_failure.json', 'r') as f:
data = json.load(f)
return data['data']
with open('src/overhaul_activity/cost_failure.json', 'r') as f:
data = json.load(f)
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
):
# 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
# )
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):
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)
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:
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)
equipments = paginated_results['items']
# Use search_filter_sort_paginate for server-side pagination
# Prioritize the 'all' parameter passed to the function
common_params = {**common, "all": all or common.get("all", False)}
paginated_results = await search_filter_sort_paginate(
model=query,
**common_params
)
equipments = paginated_results["items"]
material_cost = await get_cm_cost_summary(collector_db=collector_db, last_oh_date=prev_oh_scope.end_date, upcoming_oh_date=overhaul.start_date)
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 = []
for equipment in equipments:
if not equipment.master_equipment:
continue
cost = material_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)
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):
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)
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)
eqs = data.scalars().all()
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)
#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)
for equipment in eqs:
cost = material_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)
cost = material_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,
)
results.append(res)
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:
overhaul = await get_session(db_session=db_session, overhaul_session_id=overhaul_session_id)
master_eq_query = select(MasterEquipment).filter(MasterEquipment.location_tag == location_tag)
# Get the overhaul session
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_equipment = master_eq_result.scalar_one_or_none()
if not master_equipment:
print('Equipment not found in master')
print("Equipment not found in master")
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_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()
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:
# Already belongs to this overhaul naturally → skip
return None
# --- Step 3: Handle 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:
dummy_workscope.location_tag = location_tag
await db_session.commit()
await db_session.refresh(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
db_session.add(new_equipment)
await db_session.commit()
await db_session.refresh(new_equipment)
return new_equipment
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()
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)
result = await db_session.execute(query)
master_activity = result.scalar_one_or_none()
if not master_activity:
master_activity = MasterActivity(workscope=dummy_name)
db_session.add(master_activity)
await db_session.commit()
await db_session.refresh(master_activity)
# --- Step 2: Ensure WorkscopeOHType link exists ---
mt_query = select(MaintenanceType).filter(MaintenanceType.name == maintenance_type_name)
mt_result = await db_session.execute(mt_query)
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()
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)
await db_session.commit()
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)
await db_session.commit()
await db_session.refresh(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 = []
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:
results.append(equipment)
return results
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 = apply_not_deleted_filter(query, OverhaulActivity)
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))
)
results = await db_session.execute(query)
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)
if overhaul_session_id:
query = query.filter(OverhaulActivity.overhaul_scope_id == overhaul_session_id)
result = await db_session.execute(query)
activity = result.scalar()
if not activity:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='A data with this id does not exist.')
await db_session.refresh(activity)
async def update(
*,
db_session: DbSession,
activity: OverhaulActivity,
overhaul_activity_in: OverhaulActivityUpdate
):
"""Updates a document."""
data = overhaul_activity_in.model_dump()
update_data = overhaul_activity_in.model_dump(exclude_defaults=True)
update_model(activity, update_data)
await db_session.commit()
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:
overhaul = await get_session(db_session=db_session, overhaul_session_id=overhaul_session_id)
existing_query = select(StandardScope).filter(StandardScope.location_tag == location_tag)
# Get the overhaul session
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)
equipment = existing_result.scalar_one_or_none()
if not equipment:
print(f'Equipment {location_tag} not found in StandardScope')
print(f"Equipment {location_tag} not found in StandardScope")
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()
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:
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
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:
equipment.workscope_groups.remove(dummy_group)
await db_session.commit()
await db_session.refresh(equipment)
# --- Step 3 (optional): Delete StandardScope if no groups left ---
if not equipment.workscope_groups:
await db_session.delete(equipment)
await db_session.commit()
print(f'Equipment {location_tag} completely removed from StandardScope')
print(f"Equipment {location_tag} completely removed from StandardScope")
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
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()
return False

@ -1,24 +1,37 @@
from decimal import Decimal, getcontext
def get_material_cost(scope, total_equipment):
# Set precision to 28 digits (maximum precision for Decimal)
getcontext().prec = 28
if not total_equipment:
if not total_equipment: # Guard against division by zero
return float(0)
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)
else:
result = Decimal("8565468127") / Decimal(str(total_equipment))
return float(result)
result = Decimal('8565468127') / Decimal(str(total_equipment))
return float(result)
return float(0)
def get_service_cost(scope, total_equipment):
# Set precision to 28 digits (maximum precision for Decimal)
getcontext().prec = 28
if not total_equipment:
if not total_equipment: # Guard against division by zero
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)
result = Decimal('36000000000') / Decimal(str(total_equipment))
return float(result)
else:
result = Decimal("36000000000") / Decimal(str(total_equipment))
return float(result)
return float(0)

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

@ -1,46 +1,142 @@
import re
from fastapi import APIRouter, Depends
from typing import List, Optional
from fastapi import APIRouter, HTTPException, status
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.service import CommonParameters
from src.models import StandardResponse
from src.overhaul_gantt.model import OverhaulGantt
from src.overhaul_gantt.schema import OverhaulGanttIn
# from .schema import (OverhaulScheduleCreate, OverhaulSchedulePagination, OverhaulScheduleUpdate)
from .service import get_gantt_performance_chart
router = APIRouter()
@router.get('', response_model=StandardResponse[dict])
@router.get(
"", response_model=StandardResponse[dict]
)
async def get_gantt_performance(db_session: DbSession):
"""Get all scope pagination."""
# return
query = select(OverhaulGantt).limit(1)
data = (await db_session.execute(query)).scalar_one_or_none()
results, gantt_data = await get_gantt_performance_chart(spreadsheet_id=data.spreadsheet_id)
return StandardResponse(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):
"""Get all scope pagination."""
# return
query = select(OverhaulGantt).limit(1)
data = (await db_session.execute(query)).scalar_one_or_none()
result = {'spreadsheet_id': None, 'spreadsheet_link': None}
result = {
"spreadsheet_id": None,
"spreadsheet_link": None
}
if data:
result = {'spreadsheet_id': data.spreadsheet_id, 'spreadsheet_link': data.spreadsheet_link}
return StandardResponse(data=result, message='Data retrieved successfully')
result = {
"spreadsheet_id": data.spreadsheet_id,
"spreadsheet_link": data.spreadsheet_link
}
@router.post('/spreadsheet', response_model=StandardResponse[dict], dependencies=[Depends(csrf_protect)])
async def update_gantt_spreadsheet(db_session: DbSession, spreadsheet_in: OverhaulGanttIn, admin: Admin):
match = re.search('/d/([a-zA-Z0-9-_]+)', spreadsheet_in.spreadsheet_link)
return StandardResponse(
data=result,
message="Data retrieved successfully",
)
@router.post(
"/spreadsheet", response_model=StandardResponse[dict]
)
async def update_gantt_spreadsheet(db_session: DbSession, spreadsheet_in: OverhaulGanttIn):
"""Get all scope pagination."""
# return
match = re.search(r"/d/([a-zA-Z0-9-_]+)", spreadsheet_in.spreadsheet_link)
if not match:
raise ValueError('Invalid Google Sheets URL')
raise ValueError("Invalid Google Sheets URL")
spreadsheet_id = match.group(1)
query = select(OverhaulGantt).limit(1)
data = (await db_session.execute(query)).scalar_one_or_none()
if data:
data.spreadsheet_link = spreadsheet_in.spreadsheet_link
data.spreadsheet_id = spreadsheet_id
else:
spreadsheet = OverhaulGantt(spreadsheet_id=spreadsheet_id, spreadsheet_link=spreadsheet_in.spreadsheet_link)
spreadsheet = OverhaulGantt(
spreadsheet_id=spreadsheet_id,
spreadsheet_link=spreadsheet_in.spreadsheet_link
)
db_session.add(spreadsheet)
await db_session.commit()
if data:
result = {'spreadsheet_id': spreadsheet_id}
return StandardResponse(data=result, message='Data retrieved successfully')
result = {
"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 src.models import DefultBase
class OverhaulGanttIn(DefultBase):
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 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
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()
RANGE_NAME = "'SUMMARY'!K34:AZ38"
GANTT_DATA_NAME = 'ACTUAL PROGRESS'
RANGE_NAME = "'SUMMARY'!K34:AZ38" # Or just "2024 schedule"
GANTT_DATA_NAME = "ACTUAL PROGRESS"
try:
service = get_spreatsheed_service(creds)
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:
raise Exception('Spreadsheet format invalid: need 4 rows (DAY, DATE, PLAN, ACTUAL).')
day_row = values[0][1:]
date_row = values[1][1:]
plan_row = values[3][1:]
raise Exception("Spreadsheet format invalid: need 4 rows (DAY, DATE, PLAN, ACTUAL).")
# Extract rows
day_row = values[0][1:]
date_row = values[1][1:]
plan_row = values[3][1:]
actual_row = values[4][1:]
total_days = len(day_row)
date_row += [''] * (total_days - len(date_row))
plan_row += [''] * (total_days - len(plan_row))
actual_row += [''] * (total_days - len(actual_row))
# PAD rows so lengths match day count
date_row += [""] * (total_days - len(date_row))
plan_row += [""] * (total_days - len(plan_row))
actual_row += [""] * (total_days - len(actual_row))
results = []
for i in range(total_days):
day = day_row[i]
date = date_row[i]
plan = plan_row[i]
actual = actual_row[i] if actual_row[i] else '0%'
results.append({'day': day, 'date': date, 'plan': plan, 'actual': actual})
actual = actual_row[i] if actual_row[i] else "0%" # <-- FIX HERE
results.append({
"day": day,
"date": date,
"plan": plan,
"actual": actual
})
except Exception as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
processed_data = process_spreadsheet_data(results)
gantt_data = fetch_all_sections(service=service, spreadsheet_id=spreadsheet_id, sheet_name=GANTT_DATA_NAME)
return (processed_data, gantt_data)
return processed_data, gantt_data

@ -1,12 +1,22 @@
import urllib
from google.oauth2.service_account import Credentials
from google_auth_oauthlib.flow import InstalledAppFlow
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):
return build('sheets', 'v4', credentials=credentials, cache_discovery=False)
return build("sheets", "v4", credentials=credentials, cache_discovery=False)
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):
processed_data = []
@ -14,61 +24,107 @@ def process_spreadsheet_data(rows):
processed_row = convert_spreadsheet_data(row)
processed_data.append(processed_row) if processed_row else None
return processed_data
from datetime import datetime
from datetime import datetime
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
result = {}
result['day'] = int(data['day'])
# Convert day
result["day"] = int(data["day"])
# Determine default year
if default_year is None:
default_year = datetime.now().year
date_str = data.get('date', '').strip()
date_str = data.get("date", "").strip()
# ---------- DATE HANDLING ----------
# Accept formats like: "Nov 20", "Dec 3", "Jan 1"
parsed_date = None
if date_str:
try:
parsed_date = datetime.strptime(f'{date_str} {default_year}', '%b %d %Y')
parsed_date = datetime.strptime(f"{date_str} {default_year}", "%b %d %Y")
except ValueError:
try:
parsed_date = datetime.strptime(f'{date_str} {default_year}', '%B %d %Y')
parsed_date = datetime.strptime(f"{date_str} {default_year}", "%B %d %Y")
except:
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)
result['date'] = parsed_date
result["date"] = parsed_date
# ---------- PERCENT HANDLING ----------
def parse_percent(value):
if not value:
return 0.0
v = value.strip().replace(',', '.').replace('%', '')
v = value.strip().replace(",", ".").replace("%", "")
try:
return float(v) / 100.0
except:
return 0.0
result['plan'] = parse_percent(data.get('plan', '0'))
result['actual'] = parse_percent(data.get('actual', '0'))
result['gap'] = result['actual'] - result['plan']
result["plan"] = parse_percent(data.get("plan", "0"))
result["actual"] = parse_percent(data.get("actual", "0"))
# Gap calculation
result["gap"] = result["actual"] - result["plan"]
return result
def fetch_all_sections(service, spreadsheet_id, sheet_name):
result = service.spreadsheets().values().get(spreadsheetId=spreadsheet_id, range=f'{sheet_name}!A5:M5000').execute()
values = result.get('values', [])
# Fetch a wide range including columns AL
result = service.spreadsheets().values().get(
spreadsheetId=spreadsheet_id,
range=f"{sheet_name}!A5:M5000"
).execute()
values = result.get("values", [])
if not values:
raise ValueError('No data found in sheet')
raise ValueError("No data found in sheet")
data = []
current_section = None
current_subsystem = None
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
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_subsystem = None
continue
if colD and (not colE):
# Detect a SUBSYSTEM — indented header in Column D
if colD and not colE:
current_subsystem = colD.strip()
continue
# Detect a TASK — Column E populated
if colE:
task = colE.strip()
pic = colF.strip()
@ -78,21 +134,64 @@ def fetch_all_sections(service, spreadsheet_id, sheet_name):
plan = colK.strip()
actual = colL.strip()
gap = colM.strip()
data.append({'system': current_section, 'subsystem': current_subsystem, 'task': task, 'PIC': pic, 'start_date': start_date, 'end_date': finish_date, 'duration': int(duration), 'plan': plan, 'actual': actual, 'gap': gap})
data.append({
"system": current_section,
"subsystem": current_subsystem,
"task": task,
"PIC": pic,
"start_date": start_date,
"end_date": finish_date,
"duration": int(duration),
"plan": plan,
"actual": actual,
"gap": gap
})
return data
def indo_formatted_date(date_str, base_year=2025):
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'}
if '-' in date_str:
d, m = date_str.split('-')
date_str = f'{m} {d}'
"""
Convert short date like 'Nov 20', '30-Dec', 'Jan 1'
into: 'Rabu, November 20, 2025'
If month is January, year becomes 2026.
"""
# Month mappings
eng_to_indo_month = {
"Jan": "Januari", "Feb": "Februari", "Mar": "Maret", "Apr": "April",
"May": "Mei", "Jun": "Juni", "Jul": "Juli", "Aug": "Agustus",
"Sep": "September", "Oct": "Oktober", "Nov": "November", "Dec": "Desember"
}
indo_days = {
0: "Senin",
1: "Selasa",
2: "Rabu",
3: "Kamis",
4: "Jumat",
5: "Sabtu",
6: "Minggu"
}
# Normalize formats ("30-Dec" → "Dec 30")
if "-" in date_str:
d, m = date_str.split("-")
date_str = f"{m} {d}"
# Parse using English abbreviation
try:
dt = datetime.strptime(f'{date_str} {base_year}', '%b %d %Y')
dt = datetime.strptime(f"{date_str} {base_year}", "%b %d %Y")
except:
return None
# Handle year rollover (Jan -> next year)
if dt.month == 1:
dt = dt.replace(year=base_year + 1)
# Convert to Indonesian components
day_name = indo_days[dt.weekday()]
month_name = eng_to_indo_month[dt.strftime('%b')]
return f'{day_name}, {month_name} {dt.day}, {dt.year}'
month_name = eng_to_indo_month[dt.strftime("%b")]
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 fastapi import APIRouter, Query
from typing import List, Optional
from fastapi import APIRouter, HTTPException, status, Query
from src.auth.service import CurrentUser
from src.database.core import DbSession
from src.database.service import CommonParameters
from src.models import StandardResponse
from .schema import OverhaulJobCreate, OverhaulJobPagination
from .schema import (OverhaulJobBase, OverhaulJobCreate, OverhaulJobPagination,
OverhaulJobRead)
from .service import create, delete, get_all
router = APIRouter()
@router.get('/{location_tag}', response_model=StandardResponse[OverhaulJobPagination])
async def get_jobs(common: CommonParameters, location_tag: str, scope: Optional[str]=Query(None)):
@router.get(
"/{location_tag}", response_model=StandardResponse[OverhaulJobPagination]
)
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)
return StandardResponse(data=results, message='Data retrieved successfully')
@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):
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')
return StandardResponse(
data=results,
message="Data retrieved 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):
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 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
class OverhaulJobBase(DefultBase):
pass
class OverhaulJobCreate(OverhaulJobBase):
job_ids: Optional[List[UUID]] = []
class OverhaulJobUpdate(OverhaulJobBase):
pass
class OverhaulActivity(DefultBase):
id: UUID
overhaul_scope_id: UUID
@ -26,5 +34,7 @@ class OverhaulJobRead(OverhaulJobBase):
scope_equipment_job: ScopeEquipment
overhaul_activity: OverhaulActivity
class OverhaulJobPagination(Pagination):
items: List[OverhaulJobRead] = []

@ -1,41 +1,125 @@
from typing import Optional
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.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_maintenance_type.model import WorkscopeOHType
from src.overhaul_scope.model import MaintenanceType
from src.equipment_workscope_group.model import EquipmentWorkscopeGroup
from .model import EquipmentWorkscopeGroup
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):
query = Select(EquipmentWorkscopeGroup).where(EquipmentWorkscopeGroup.location_tag == location_tag)
query = apply_not_deleted_filter(query, EquipmentWorkscopeGroup)
async def get_all(*, common, location_tag: str, scope: Optional[str] = None):
"""Returns all documents."""
query = (
Select(EquipmentWorkscopeGroup)
.where(EquipmentWorkscopeGroup.location_tag == location_tag)
)
if scope:
query = query.join(EquipmentWorkscopeGroup.workscope_group).join(MasterActivity.oh_types).join(WorkscopeOHType.oh_type).filter(MaintenanceType.name == scope)
return await search_filter_sort_paginate(model=query, **common)
query = (
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 = []
if not overhaul_equipment_id:
raise ValueError('assetnum parameter is required')
equipment_stmt = Select(OverhaulJob).where(OverhaulJob.overhaul_activity_id == overhaul_equipment_id)
await db_session.scalar(equipment_stmt)
raise ValueError("assetnum parameter is required")
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:
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)
db_session.add_all(overhaul_jobs)
await db_session.commit()
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:
deleted = await soft_delete_record(db_session, EquipmentWorkscopeGroup, overhaul_job_id)
if not deleted:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='A data with this id does not exist.')
# Check if job exists
scope_job = await db_session.get(OverhaulJob, overhaul_job_id)
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
except Exception:
except Exception as e:
await db_session.rollback()
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.models import DefaultMixin
from src.models import DefaultMixin, IdentityMixin, TimeStampMixin
class OverhaulSchedule(Base, DefaultMixin):
__tablename__ = 'rp_oh_schedule'
__tablename__ = "rp_oh_schedule"
year = Column(Integer, nullable=False)
plan_duration = Column(Integer, nullable=True)
planned_outage = 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))
remark = Column(String, nullable=True)

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

Loading…
Cancel
Save