diff --git a/backend/app/api/intelligence.py b/backend/app/api/intelligence.py new file mode 100644 index 0000000..2b33234 --- /dev/null +++ b/backend/app/api/intelligence.py @@ -0,0 +1,33 @@ +from pathlib import Path +from fastapi import APIRouter, Depends + +from app.intelligence.service import SafetyIntelligenceService, provider_from_environment +from app.rag.retriever import Retriever +from app.rag.vector_store import JsonVectorStore +from app.rag.embedder import DeterministicEmbedder +from app.schemas import RiskEngineInput, SafetyIntelligenceResponse + +router = APIRouter( + prefix="/intelligence", + tags=["Safety Intelligence"] +) + +ROOT = Path(__file__).resolve().parents[3] +STORE_PATH = ROOT / "backend/data/knowledge/vector_store/chunks.json" + +def get_intelligence_service() -> SafetyIntelligenceService: + store = JsonVectorStore(STORE_PATH) + embedder = DeterministicEmbedder() + retriever = Retriever(store, embedder) + provider = provider_from_environment() + return SafetyIntelligenceService(retriever, provider) + +@router.post("/generate", response_model=SafetyIntelligenceResponse) +def generate_intelligence( + risk: RiskEngineInput, + service: SafetyIntelligenceService = Depends(get_intelligence_service) +) -> SafetyIntelligenceResponse: + """ + Generate an advisory safety intelligence response for a given risk engine input. + """ + return service.generate(risk) diff --git a/backend/app/api/interventions.py b/backend/app/api/interventions.py index 54f9aed..416d309 100644 --- a/backend/app/api/interventions.py +++ b/backend/app/api/interventions.py @@ -1,12 +1,27 @@ from fastapi import APIRouter +from pydantic import BaseModel +import time -router= APIRouter( - prefix="/interventions", +router = APIRouter( + prefix="/api/interventions", tags=["Interventions"] ) +class InterventionRequest(BaseModel): + zone_id: str + action_taken: str + @router.get("/") def get_interventions(): return { "message": "Coming Soon" + } + +@router.post("/") +def trigger_intervention(req: InterventionRequest): + return { + "intervention_id": f"INT-{int(time.time())}", + "status": "success", + "action": req.action_taken, + "zone": req.zone_id } \ No newline at end of file diff --git a/backend/app/api/simulation.py b/backend/app/api/simulation.py new file mode 100644 index 0000000..c995dd5 --- /dev/null +++ b/backend/app/api/simulation.py @@ -0,0 +1,275 @@ +import os +import sys +import time +from typing import Dict, Any, List +from pathlib import Path +from pydantic import BaseModel +from fastapi import APIRouter +from datetime import datetime + +# Setup paths for digital twin +BASE_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) +sys.path.append(os.path.join(BASE_DIR, "digital twin")) + +try: + from simulator.plant import Plant, SCENARIO_CLASSES + from simulator.config import ScenarioID +except ImportError as e: + print(f"Warning: digital twin could not be imported. {e}") + Plant = None + +# Intelligence Integration +from app.api.intelligence import get_intelligence_service +from app.schemas import RiskEngineInput + +router = APIRouter(prefix="/api/simulation", tags=["simulation"]) + +class SimulationController: + def __init__(self): + self.plant = Plant() if Plant else None + self.is_running = True + self.current_scenario = "normal" + self.speed = 1.0 + self.last_state = None + self.last_tick_time = time.time() + self.zones_data = {} + self.history = {} # type: Dict[str, List[float]] + self.alerts = [] + + # Intelligence cache + self.last_ai_update = 0 + self.ai_cache = {} + + # Instantiate intelligence service once + self.intelligence_service = get_intelligence_service() + + if self.plant: + self._set_scenario(self.current_scenario) + + def _set_scenario(self, scenario_id: str): + self.current_scenario = scenario_id + self.last_ai_update = 0 # Force immediate AI update on scenario change + self.history = {} # Clear history + self.ai_cache = {} # Clear AI cache + if Plant: + sc_cls = SCENARIO_CLASSES.get(scenario_id, SCENARIO_CLASSES["normal"]) + self.plant.reset() + self.plant.set_scenario(sc_cls(duration=10000)) + + def tick(self): + if not self.is_running or not self.plant: + return + + now = time.time() + if now - self.last_tick_time >= (1.0 / self.speed): + row = self.plant.tick() + self._process_row(row) + self.last_tick_time = now + + def _process_row(self, row: dict): + self.last_state = row + + zone_ids = ["ZONE_A", "ZONE_B", "ZONE_C", "ZONE_D", "ZONE_E", "ZONE_F", "ZONE_G"] + + for zid in zone_ids: + if zid not in self.zones_data: + self.zones_data[zid] = { + "risk_score": 0, + "worker_count": 0, + "sensors": { + "hydrocarbon": {"value": 0.0}, + "h2s": {"value": 0.0}, + "oxygen": {"value": 20.9}, + "pressure": {"value": 1.01}, + "temperature": {"value": 30.0} + }, + "cctv": [], + "workers": [], + "ai_fusion": {} + } + if zid not in self.history: + self.history[zid] = [] + + # Map some digital twin properties to Zone D for display + base_risk = 5 + if self.current_scenario == "gas_leak": + base_risk = 60 + elif self.current_scenario == "explosion_risk": + base_risk = 90 + elif self.current_scenario == "ventilation_failure": + base_risk = 55 + elif self.current_scenario == "pump_failure": + base_risk = 70 + elif self.current_scenario == "hot_work_gas_leak": + base_risk = 85 + elif self.current_scenario == "confined_space": + base_risk = 65 + + self.zones_data["ZONE_D"]["risk_score"] = base_risk + self.zones_data["ZONE_D"]["worker_count"] = 3 if self.current_scenario != "normal" else 2 + + import math + t = time.time() + + hc_val = row.get("hc_gas_lel", 0.0) + h2s_val = row.get("h2s_ppm", 0.0) + pressure_val = row.get("pipeline_pressure", 1.01) + temp_val = row.get("pipeline_temperature", 120.0) + + # Normalize wild pressure values from digital twin + if pressure_val > 100: + pressure_val = 1.0 + (pressure_val % 2.0) + + # Apply scenario-specific realistic boosts + if self.current_scenario in ["gas_leak", "ventilation_failure"]: + hc_val += 25.0 + math.sin(t * 0.5) * 5.0 + h2s_val += 50.0 + math.cos(t * 0.3) * 10.0 + elif self.current_scenario in ["explosion_risk", "hot_work_gas_leak"]: + pressure_val += 8.0 + math.sin(t * 0.8) * 2.0 + hc_val += 10.0 + math.sin(t * 0.2) * 2.0 + temp_val += 40.0 + math.cos(t * 0.5) * 5.0 + elif self.current_scenario == "confined_space": + h2s_val += 20.0 + math.sin(t * 0.4) * 5.0 + pressure_val += 3.0 + math.cos(t * 0.6) * 1.0 + + hc_val = max(0.0, hc_val) + h2s_val = max(0.0, h2s_val) + pressure_val = max(0.0, pressure_val) + + self.zones_data["ZONE_D"]["sensors"]["hydrocarbon"]["value"] = hc_val + self.history["ZONE_D"].append(hc_val) + if len(self.history["ZONE_D"]) > 20: + self.history["ZONE_D"].pop(0) + + self.zones_data["ZONE_D"]["sensors"]["h2s"]["value"] = h2s_val + self.zones_data["ZONE_D"]["sensors"]["oxygen"]["value"] = row.get("oxygen_pct", 20.9) + self.zones_data["ZONE_D"]["sensors"]["temperature"]["value"] = temp_val + self.zones_data["ZONE_D"]["sensors"]["pressure"]["value"] = pressure_val + + self.alerts = [] + if base_risk > 50: + self.alerts.append({ + "zone_id": "ZONE_D", + "primary_hazard": self.current_scenario.replace("_", " ").upper(), + "explanation": row.get("event_label", "Anomalous readings detected"), + "alert_id": "ALT-" + str(int(time.time())) + }) + +sim_ctrl = SimulationController() + +class StartRequest(BaseModel): + scenario: str + speed: float = 1.0 + +@router.post("/start") +def start_sim(req: StartRequest): + sim_ctrl._set_scenario(req.scenario) + sim_ctrl.speed = req.speed + sim_ctrl.is_running = True + return {"status": "started"} + +@router.post("/pause") +def pause_sim(): + sim_ctrl.is_running = False + return {"status": "paused"} + +@router.post("/resume") +def resume_sim(): + sim_ctrl.is_running = True + return {"status": "resumed"} + +@router.post("/step") +def step_sim(): + sim_ctrl.tick() + return {"status": "stepped"} + +@router.get("/state") +def get_state(zone_id: str = "ZONE_D"): + sim_ctrl.tick() + + # Intelligence integration - update every 10 seconds if risk is high + now = time.time() + if now - sim_ctrl.last_ai_update > 10: + sim_ctrl.last_ai_update = now + zdata = sim_ctrl.zones_data.get(zone_id, {}) + + if zdata.get("risk_score", 0) > 40: + risk_mapping = { + "gas_leak": "TOXIC_GAS_EXPOSURE", + "ventilation_failure": "OXYGEN_DEFICIENCY", + "pump_failure": "EQUIPMENT_FAILURE", + "hot_work_gas_leak": "FIRE_EXPLOSION", + "confined_space": "CONFINED_SPACE", + "explosion_risk": "FIRE_EXPLOSION" + } + mapped_risk = risk_mapping.get(sim_ctrl.current_scenario, "UNKNOWN") + + factor_mapping = { + "gas_leak": "HIGH_H2S", + "ventilation_failure": "VENTILATION_FAILURE", + "pump_failure": "ABNORMAL_FLOW", + "hot_work_gas_leak": "HOT_WORK_ACTIVE", + "confined_space": "CONFINED_SPACE_ACTIVE", + "explosion_risk": "RISING_LEL" + } + mapped_factor = factor_mapping.get(sim_ctrl.current_scenario, "UNKNOWN") + + # Construct input + risk_input = RiskEngineInput( + alert_id="ALT-" + str(int(now)), + timestamp=datetime.fromtimestamp(now), + zone_id=zone_id, + equipment_ids=["DC-100"], + risk_type=mapped_risk, + risk_score=float(zdata["risk_score"]) / 100.0, + severity="HIGH", + predicted_incident=sim_ctrl.current_scenario.replace("_", " ").title() + " Potential Incident", + contributing_factors=[mapped_factor], + sensor_evidence={ + "hydrocarbon": zdata["sensors"]["hydrocarbon"]["value"], + "temperature": zdata["sensors"]["temperature"]["value"] + }, + estimated_lead_time_minutes=2.0 + ) + + try: + # Call Intelligence Service + response = sim_ctrl.intelligence_service.generate(risk_input) + + sim_ctrl.ai_cache[zone_id] = { + "triggered_rules": [a.title for a in response.recommended_actions[:2]], + "primary_hazard": response.executive_summary.split(".")[0], + "explanation": response.risk_explanation, + "confidence": response.intelligence_confidence, + "lead_time_vs_baseline": risk_input.estimated_lead_time_minutes * 60 + } + except Exception as e: + sim_ctrl.ai_cache[zone_id] = { + "triggered_rules": ["AI_ERROR"], + "primary_hazard": "Intelligence Unavailable", + "explanation": f"Failed to generate intelligence: {str(e)}", + "confidence": 0.0, + "lead_time_vs_baseline": 0.0 + } + else: + sim_ctrl.ai_cache[zone_id] = { + "triggered_rules": [], + "primary_hazard": "NONE / HEALTHY", + "explanation": "Sensors reporting normal operations. No hazard indicators detected.", + "confidence": 1.0, + "lead_time_vs_baseline": 0.0 + } + + if zone_id in sim_ctrl.zones_data: + sim_ctrl.zones_data[zone_id]["ai_fusion"] = sim_ctrl.ai_cache.get(zone_id, {}) + + return { + "simulation": { + "scenario": sim_ctrl.current_scenario, + "is_running": sim_ctrl.is_running + }, + "zones": {k: {"risk_score": v["risk_score"], "worker_count": v["worker_count"]} for k,v in sim_ctrl.zones_data.items()}, + "selected_zone": sim_ctrl.zones_data.get(zone_id, {}), + "history": sim_ctrl.history, + "alerts": sim_ctrl.alerts + } diff --git a/backend/app/main.py b/backend/app/main.py index c3ff2db..0807900 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,4 +1,7 @@ from fastapi import FastAPI +from fastapi.staticfiles import StaticFiles +from fastapi.middleware.cors import CORSMiddleware +import os from app.api.health import router as health_router from app.api.sensor import router as sensor_router @@ -7,6 +10,8 @@ from app.api.cctv import router as cctv_router from app.api.alerts import router as alerts_router from app.api.interventions import router as interventions_router +from app.api.intelligence import router as intelligence_router +from app.api.simulation import router as simulation_router from app.database.database import engine from app.database.base import Base @@ -17,6 +22,14 @@ version="1.0.0" ) +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + Base.metadata.create_all(bind=engine) app.include_router(health_router) @@ -25,4 +38,13 @@ app.include_router(maintenance_router) app.include_router(cctv_router) app.include_router(alerts_router) -app.include_router(interventions_router) \ No newline at end of file +app.include_router(interventions_router) +app.include_router(intelligence_router) +app.include_router(simulation_router) + +# Mount frontend +FRONTEND_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "frontend") +if os.path.exists(FRONTEND_DIR): + app.mount("/", StaticFiles(directory=FRONTEND_DIR, html=True), name="frontend") +else: + print(f"Warning: frontend directory not found at {FRONTEND_DIR}") \ No newline at end of file