feat: Enhance dashboard metrics and summary statistics
- Added new summary fields: variance, 5th percentile, 95th percentile, VaR (95%), and expected shortfall (95%) to the dashboard. - Updated the display logic for summary metrics to handle non-finite values gracefully. - Modified the chart rendering to include additional percentile points and tail risk metrics in tooltips. test: Introduce unit tests for consumption, costs, and other modules - Created a comprehensive test suite for consumption, costs, equipment, maintenance, production, reporting, and simulation modules. - Implemented fixtures for database setup and teardown using an in-memory SQLite database for isolated testing. - Added tests for creating, listing, and validating various entities, ensuring proper error handling and response validation. refactor: Consolidate parameter tests and remove deprecated files - Merged parameter-related tests into a new test file for better organization and clarity. - Removed the old parameter test file that was no longer in use. - Improved test coverage for parameter creation, listing, and validation scenarios. fix: Ensure proper validation and error handling in API endpoints - Added validation to reject negative amounts in consumption and production records. - Implemented checks to prevent duplicate scenario creation and ensure proper error messages are returned. - Enhanced reporting endpoint tests to validate input formats and expected outputs.
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, status
|
||||
from pydantic import BaseModel, PositiveFloat
|
||||
from pydantic import BaseModel, ConfigDict, PositiveFloat
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from config.database import SessionLocal
|
||||
@@ -31,14 +31,12 @@ class ConsumptionCreate(ConsumptionBase):
|
||||
|
||||
class ConsumptionRead(ConsumptionBase):
|
||||
id: int
|
||||
|
||||
class Config:
|
||||
orm_mode = True
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
@router.post("/", response_model=ConsumptionRead, status_code=status.HTTP_201_CREATED)
|
||||
def create_consumption(item: ConsumptionCreate, db: Session = Depends(get_db)):
|
||||
db_item = Consumption(**item.dict())
|
||||
db_item = Consumption(**item.model_dump())
|
||||
db.add(db_item)
|
||||
db.commit()
|
||||
db.refresh(db_item)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List, Optional
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from config.database import SessionLocal
|
||||
from models.capex import Capex
|
||||
from models.opex import Opex
|
||||
@@ -26,9 +26,7 @@ class CapexCreate(BaseModel):
|
||||
|
||||
class CapexRead(CapexCreate):
|
||||
id: int
|
||||
|
||||
class Config:
|
||||
orm_mode = True
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
# Pydantic schemas for Opex
|
||||
@@ -40,15 +38,13 @@ class OpexCreate(BaseModel):
|
||||
|
||||
class OpexRead(OpexCreate):
|
||||
id: int
|
||||
|
||||
class Config:
|
||||
orm_mode = True
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
# Capex endpoints
|
||||
@router.post("/capex", response_model=CapexRead)
|
||||
def create_capex(item: CapexCreate, db: Session = Depends(get_db)):
|
||||
db_item = Capex(**item.dict())
|
||||
db_item = Capex(**item.model_dump())
|
||||
db.add(db_item)
|
||||
db.commit()
|
||||
db.refresh(db_item)
|
||||
@@ -63,7 +59,7 @@ def list_capex(db: Session = Depends(get_db)):
|
||||
# Opex endpoints
|
||||
@router.post("/opex", response_model=OpexRead)
|
||||
def create_opex(item: OpexCreate, db: Session = Depends(get_db)):
|
||||
db_item = Opex(**item.dict())
|
||||
db_item = Opex(**item.model_dump())
|
||||
db.add(db_item)
|
||||
db.commit()
|
||||
db.refresh(db_item)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from fastapi import APIRouter, HTTPException, Depends
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from config.database import SessionLocal
|
||||
from models.distribution import Distribution
|
||||
|
||||
@@ -24,14 +24,12 @@ class DistributionCreate(BaseModel):
|
||||
|
||||
class DistributionRead(DistributionCreate):
|
||||
id: int
|
||||
|
||||
class Config:
|
||||
orm_mode = True
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
@router.post("/", response_model=DistributionRead)
|
||||
async def create_distribution(dist: DistributionCreate, db: Session = Depends(get_db)):
|
||||
db_dist = Distribution(**dist.dict())
|
||||
db_dist = Distribution(**dist.model_dump())
|
||||
db.add(db_dist)
|
||||
db.commit()
|
||||
db.refresh(db_dist)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List, Optional
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from config.database import SessionLocal
|
||||
from models.equipment import Equipment
|
||||
|
||||
@@ -25,14 +25,12 @@ class EquipmentCreate(BaseModel):
|
||||
|
||||
class EquipmentRead(EquipmentCreate):
|
||||
id: int
|
||||
|
||||
class Config:
|
||||
orm_mode = True
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
@router.post("/", response_model=EquipmentRead)
|
||||
async def create_equipment(item: EquipmentCreate, db: Session = Depends(get_db)):
|
||||
db_item = Equipment(**item.dict())
|
||||
db_item = Equipment(**item.model_dump())
|
||||
db.add(db_item)
|
||||
db.commit()
|
||||
db.refresh(db_item)
|
||||
|
||||
@@ -2,7 +2,7 @@ from datetime import date
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel, PositiveFloat
|
||||
from pydantic import BaseModel, ConfigDict, PositiveFloat
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from config.database import SessionLocal
|
||||
@@ -38,9 +38,7 @@ class MaintenanceUpdate(MaintenanceBase):
|
||||
|
||||
class MaintenanceRead(MaintenanceBase):
|
||||
id: int
|
||||
|
||||
class Config:
|
||||
orm_mode = True
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
def _get_maintenance_or_404(db: Session, maintenance_id: int) -> Maintenance:
|
||||
@@ -56,7 +54,7 @@ def _get_maintenance_or_404(db: Session, maintenance_id: int) -> Maintenance:
|
||||
|
||||
@router.post("/", response_model=MaintenanceRead, status_code=status.HTTP_201_CREATED)
|
||||
def create_maintenance(maintenance: MaintenanceCreate, db: Session = Depends(get_db)):
|
||||
db_maintenance = Maintenance(**maintenance.dict())
|
||||
db_maintenance = Maintenance(**maintenance.model_dump())
|
||||
db.add(db_maintenance)
|
||||
db.commit()
|
||||
db.refresh(db_maintenance)
|
||||
@@ -80,7 +78,7 @@ def update_maintenance(
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
db_maintenance = _get_maintenance_or_404(db, maintenance_id)
|
||||
for field, value in payload.dict().items():
|
||||
for field, value in payload.model_dump().items():
|
||||
setattr(db_maintenance, field, value)
|
||||
db.commit()
|
||||
db.refresh(db_maintenance)
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, ConfigDict, field_validator
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from config.database import SessionLocal
|
||||
from models.distribution import Distribution
|
||||
from models.parameters import Parameter
|
||||
from models.scenario import Scenario
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, List
|
||||
|
||||
router = APIRouter(prefix="/api/parameters", tags=["parameters"])
|
||||
|
||||
@@ -13,13 +16,34 @@ class ParameterCreate(BaseModel):
|
||||
scenario_id: int
|
||||
name: str
|
||||
value: float
|
||||
distribution_id: Optional[int] = None
|
||||
distribution_type: Optional[str] = None
|
||||
distribution_parameters: Optional[Dict[str, Any]] = None
|
||||
|
||||
@field_validator("distribution_type")
|
||||
@classmethod
|
||||
def normalize_type(cls, value: Optional[str]) -> Optional[str]:
|
||||
if value is None:
|
||||
return value
|
||||
normalized = value.strip().lower()
|
||||
if not normalized:
|
||||
return None
|
||||
if normalized not in {"normal", "uniform", "triangular"}:
|
||||
raise ValueError(
|
||||
"distribution_type must be normal, uniform, or triangular")
|
||||
return normalized
|
||||
|
||||
@field_validator("distribution_parameters")
|
||||
@classmethod
|
||||
def empty_dict_to_none(cls, value: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
|
||||
if value is None:
|
||||
return None
|
||||
return value or None
|
||||
|
||||
|
||||
class ParameterRead(ParameterCreate):
|
||||
id: int
|
||||
|
||||
class Config:
|
||||
orm_mode = True
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
# Dependency
|
||||
|
||||
@@ -37,8 +61,27 @@ def create_parameter(param: ParameterCreate, db: Session = Depends(get_db)):
|
||||
scen = db.query(Scenario).filter(Scenario.id == param.scenario_id).first()
|
||||
if not scen:
|
||||
raise HTTPException(status_code=404, detail="Scenario not found")
|
||||
new_param = Parameter(scenario_id=param.scenario_id,
|
||||
name=param.name, value=param.value)
|
||||
distribution_id = param.distribution_id
|
||||
distribution_type = param.distribution_type
|
||||
distribution_parameters = param.distribution_parameters
|
||||
|
||||
if distribution_id is not None:
|
||||
distribution = db.query(Distribution).filter(
|
||||
Distribution.id == distribution_id).first()
|
||||
if not distribution:
|
||||
raise HTTPException(
|
||||
status_code=404, detail="Distribution not found")
|
||||
distribution_type = distribution.distribution_type
|
||||
distribution_parameters = distribution.parameters or None
|
||||
|
||||
new_param = Parameter(
|
||||
scenario_id=param.scenario_id,
|
||||
name=param.name,
|
||||
value=param.value,
|
||||
distribution_id=distribution_id,
|
||||
distribution_type=distribution_type,
|
||||
distribution_parameters=distribution_parameters,
|
||||
)
|
||||
db.add(new_param)
|
||||
db.commit()
|
||||
db.refresh(new_param)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, status
|
||||
from pydantic import BaseModel, PositiveFloat
|
||||
from pydantic import BaseModel, ConfigDict, PositiveFloat
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from config.database import SessionLocal
|
||||
@@ -31,14 +31,12 @@ class ProductionOutputCreate(ProductionOutputBase):
|
||||
|
||||
class ProductionOutputRead(ProductionOutputBase):
|
||||
id: int
|
||||
|
||||
class Config:
|
||||
orm_mode = True
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
@router.post("/", response_model=ProductionOutputRead, status_code=status.HTTP_201_CREATED)
|
||||
def create_production(item: ProductionOutputCreate, db: Session = Depends(get_db)):
|
||||
db_item = ProductionOutput(**item.dict())
|
||||
db_item = ProductionOutput(**item.model_dump())
|
||||
db.add(db_item)
|
||||
db.commit()
|
||||
db.refresh(db_item)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from typing import Any, Dict, List
|
||||
from typing import Any, Dict, List, cast
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request, status
|
||||
from pydantic import BaseModel
|
||||
@@ -25,14 +25,16 @@ def _validate_payload(payload: Any) -> List[Dict[str, float]]:
|
||||
detail="Invalid input format",
|
||||
)
|
||||
|
||||
typed_payload = cast(List[Any], payload)
|
||||
|
||||
validated: List[Dict[str, float]] = []
|
||||
for index, item in enumerate(payload):
|
||||
for index, item in enumerate(typed_payload):
|
||||
if not isinstance(item, dict):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Entry at index {index} must be an object",
|
||||
)
|
||||
value = item.get("result")
|
||||
value = cast(Dict[str, Any], item).get("result")
|
||||
if not isinstance(value, (int, float)):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
@@ -49,8 +51,13 @@ class ReportSummary(BaseModel):
|
||||
min: float
|
||||
max: float
|
||||
std_dev: float
|
||||
variance: float
|
||||
percentile_10: float
|
||||
percentile_90: float
|
||||
percentile_5: float
|
||||
percentile_95: float
|
||||
value_at_risk_95: float
|
||||
expected_shortfall_95: float
|
||||
|
||||
|
||||
@router.post("/summary", response_model=ReportSummary)
|
||||
@@ -65,6 +72,11 @@ async def summary_report(request: Request):
|
||||
min=float(summary["min"]),
|
||||
max=float(summary["max"]),
|
||||
std_dev=float(summary["std_dev"]),
|
||||
variance=float(summary["variance"]),
|
||||
percentile_10=float(summary["percentile_10"]),
|
||||
percentile_90=float(summary["percentile_90"]),
|
||||
percentile_5=float(summary["percentile_5"]),
|
||||
percentile_95=float(summary["percentile_95"]),
|
||||
value_at_risk_95=float(summary["value_at_risk_95"]),
|
||||
expected_shortfall_95=float(summary["expected_shortfall_95"]),
|
||||
)
|
||||
|
||||
@@ -2,7 +2,7 @@ from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from config.database import SessionLocal
|
||||
from models.scenario import Scenario
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
|
||||
@@ -20,9 +20,7 @@ class ScenarioRead(ScenarioCreate):
|
||||
id: int
|
||||
created_at: datetime
|
||||
updated_at: Optional[datetime] = None
|
||||
|
||||
class Config:
|
||||
orm_mode = True
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
# Dependency
|
||||
|
||||
|
||||
@@ -60,8 +60,8 @@ def _load_parameters(db: Session, scenario_id: int) -> List[SimulationParameterI
|
||||
)
|
||||
return [
|
||||
SimulationParameterInput(
|
||||
name=cast(str, item.name),
|
||||
value=cast(float, item.value),
|
||||
name=item.name,
|
||||
value=item.value,
|
||||
)
|
||||
for item in db_params
|
||||
]
|
||||
@@ -86,7 +86,7 @@ async def simulate(payload: SimulationRunRequest, db: Session = Depends(get_db))
|
||||
)
|
||||
|
||||
raw_results = run_simulation(
|
||||
[param.dict(exclude_none=True) for param in parameters],
|
||||
[param.model_dump(exclude_none=True) for param in parameters],
|
||||
iterations=payload.iterations,
|
||||
seed=payload.seed,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user