feat: Enhance currency handling and validation across scenarios
- Updated form template to prefill currency input with default value and added help text for clarity. - Modified integration tests to assert more descriptive error messages for invalid currency codes. - Introduced new tests for currency normalization and validation in various scenarios, including imports and exports. - Added comprehensive tests for pricing calculations, ensuring defaults are respected and overrides function correctly. - Implemented unit tests for pricing settings repository, ensuring CRUD operations and default settings are handled properly. - Enhanced scenario pricing evaluation tests to validate currency handling and metadata defaults. - Added simulation tests to ensure Monte Carlo runs are accurate and handle various distribution scenarios.
This commit is contained in:
14
changelog.md
14
changelog.md
@@ -24,7 +24,6 @@
|
||||
|
||||
## 2025-11-10
|
||||
|
||||
- Extended authorization helper layer with project/scenario ownership lookups, integrated them into FastAPI dependencies, refreshed pytest fixtures to keep the suite authenticated, and documented the new patterns across RBAC plan and security guides.
|
||||
- Added dedicated pytest coverage for guard dependencies, exercising success plus failure paths (missing session, inactive user, missing roles, project/scenario access errors) via `tests/test_dependencies_guards.py`.
|
||||
- Added integration tests in `tests/test_authorization_integration.py` verifying anonymous 401 responses, role-based 403s, and authorized project manager flows across API and UI endpoints.
|
||||
- Implemented environment-driven admin bootstrap settings, wired the `bootstrap_admin` helper into FastAPI startup, added pytest coverage for creation/idempotency/reset logic, and documented operational guidance in the RBAC plan and security concept.
|
||||
@@ -34,3 +33,16 @@
|
||||
- Added `services/importers.py` to load CSV/XLSX files into the new import schemas, pulled in `openpyxl` for Excel support, and covered the parsing behaviour with `tests/test_import_parsing.py`.
|
||||
- Expanded the import ingestion workflow with staging previews, transactional persistence commits, FastAPI preview/commit endpoints under `/imports`, and new API tests (`tests/test_import_ingestion.py`, `tests/test_import_api.py`) ensuring end-to-end coverage.
|
||||
- Added persistent audit logging via `ImportExportLog`, structured log emission, Prometheus metrics instrumentation, `/metrics` endpoint exposure, and updated operator/deployment documentation to guide monitoring setup.
|
||||
|
||||
## 2025-11-11
|
||||
|
||||
- Centralised ISO-4217 currency validation across scenarios, imports, and export filters (`models/scenario.py`, `routes/scenarios.py`, `schemas/scenario.py`, `schemas/imports.py`, `services/export_query.py`) so malformed codes are rejected consistently at every entry point.
|
||||
- Updated scenario services and UI flows to surface friendly validation errors and added regression coverage for imports, exports, API creation, and lifecycle flows ensuring currencies are normalised end-to-end.
|
||||
- Recorded the completed “Ensure currency is used consistently” work in `.github/instructions/DONE.md` and ran the full pytest suite (150 tests) to verify the refactor.
|
||||
- Linked projects to their pricing settings by updating SQLAlchemy models, repositories, seeding utilities, and migrations, and added regression tests to cover the new association and default backfill.
|
||||
- Bootstrapped database-stored pricing settings at application startup, aligned initial data seeding with the database-first metadata flow, and added tests covering pricing bootstrap creation, project assignment, and idempotency.
|
||||
- Extended pricing configuration support to prefer persisted metadata via `dependencies.get_pricing_metadata`, added retrieval tests for project/default fallbacks, and refreshed docs (`calminer-docs/specifications/price_calculation.md`, `pricing_settings_data_model.md`) to describe the database-backed workflow and bootstrap behaviour.
|
||||
- Added `services/financial.py` NPV, IRR, and payback helpers with robust cash-flow normalisation, convergence safeguards, and fractional period support, plus comprehensive pytest coverage exercising representative project scenarios and failure modes.
|
||||
- Authored `calminer-docs/specifications/financial_metrics.md` capturing DCF assumptions, solver behaviours, and worked examples, and cross-linked the architecture concepts to the new reference for consistent navigation.
|
||||
- Implemented `services/simulation.py` Monte Carlo engine with configurable distributions, summary aggregation, and reproducible RNG seeding, introduced regression tests in `tests/test_simulation.py`, and documented configuration/usage in `calminer-docs/specifications/monte_carlo_simulation.md` with architecture cross-links.
|
||||
- Polished reporting HTML contexts by cleaning stray fragments in `routes/reports.py`, adding download action metadata for project and scenario pages, and generating scenario comparison download URLs with correctly serialised repeated `scenario_ids` parameters.
|
||||
|
||||
@@ -7,6 +7,8 @@ from functools import lru_cache
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from services.pricing import PricingMetadata
|
||||
|
||||
from services.security import JWTSettings
|
||||
|
||||
|
||||
@@ -56,6 +58,10 @@ class Settings:
|
||||
admin_password: str = "ChangeMe123!"
|
||||
admin_roles: tuple[str, ...] = ("admin",)
|
||||
admin_force_reset: bool = False
|
||||
pricing_default_payable_pct: float = 100.0
|
||||
pricing_default_currency: str | None = "USD"
|
||||
pricing_moisture_threshold_pct: float = 8.0
|
||||
pricing_moisture_penalty_per_pct: float = 0.0
|
||||
|
||||
@classmethod
|
||||
def from_environment(cls) -> "Settings":
|
||||
@@ -105,6 +111,18 @@ class Settings:
|
||||
admin_force_reset=cls._bool_from_env(
|
||||
"CALMINER_SEED_FORCE", False
|
||||
),
|
||||
pricing_default_payable_pct=cls._float_from_env(
|
||||
"CALMINER_PRICING_DEFAULT_PAYABLE_PCT", 100.0
|
||||
),
|
||||
pricing_default_currency=cls._optional_str(
|
||||
"CALMINER_PRICING_DEFAULT_CURRENCY", "USD"
|
||||
),
|
||||
pricing_moisture_threshold_pct=cls._float_from_env(
|
||||
"CALMINER_PRICING_MOISTURE_THRESHOLD_PCT", 8.0
|
||||
),
|
||||
pricing_moisture_penalty_per_pct=cls._float_from_env(
|
||||
"CALMINER_PRICING_MOISTURE_PENALTY_PER_PCT", 0.0
|
||||
),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@@ -145,6 +163,23 @@ class Settings:
|
||||
seen.add(role_name)
|
||||
return tuple(ordered)
|
||||
|
||||
@staticmethod
|
||||
def _float_from_env(name: str, default: float) -> float:
|
||||
raw_value = os.getenv(name)
|
||||
if raw_value is None:
|
||||
return default
|
||||
try:
|
||||
return float(raw_value)
|
||||
except ValueError:
|
||||
return default
|
||||
|
||||
@staticmethod
|
||||
def _optional_str(name: str, default: str | None = None) -> str | None:
|
||||
raw_value = os.getenv(name)
|
||||
if raw_value is None or raw_value.strip() == "":
|
||||
return default
|
||||
return raw_value.strip()
|
||||
|
||||
def jwt_settings(self) -> JWTSettings:
|
||||
"""Build runtime JWT settings compatible with token helpers."""
|
||||
|
||||
@@ -180,6 +215,16 @@ class Settings:
|
||||
force_reset=self.admin_force_reset,
|
||||
)
|
||||
|
||||
def pricing_metadata(self) -> PricingMetadata:
|
||||
"""Build pricing metadata defaults."""
|
||||
|
||||
return PricingMetadata(
|
||||
default_payable_pct=self.pricing_default_payable_pct,
|
||||
default_currency=self.pricing_default_currency,
|
||||
moisture_threshold_pct=self.pricing_moisture_threshold_pct,
|
||||
moisture_penalty_per_pct=self.pricing_moisture_penalty_per_pct,
|
||||
)
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_settings() -> Settings:
|
||||
|
||||
@@ -22,6 +22,9 @@ from services.session import (
|
||||
)
|
||||
from services.unit_of_work import UnitOfWork
|
||||
from services.importers import ImportIngestionService
|
||||
from services.pricing import PricingMetadata
|
||||
from services.scenario_evaluation import ScenarioPricingConfig, ScenarioPricingEvaluator
|
||||
from services.repositories import pricing_settings_to_metadata
|
||||
|
||||
|
||||
def get_unit_of_work() -> Generator[UnitOfWork, None, None]:
|
||||
@@ -46,6 +49,29 @@ def get_application_settings() -> Settings:
|
||||
return get_settings()
|
||||
|
||||
|
||||
def get_pricing_metadata(
|
||||
settings: Settings = Depends(get_application_settings),
|
||||
uow: UnitOfWork = Depends(get_unit_of_work),
|
||||
) -> PricingMetadata:
|
||||
"""Return pricing metadata defaults sourced from persisted pricing settings."""
|
||||
|
||||
stored = uow.get_pricing_metadata()
|
||||
if stored is not None:
|
||||
return stored
|
||||
|
||||
fallback = settings.pricing_metadata()
|
||||
seed_result = uow.ensure_default_pricing_settings(metadata=fallback)
|
||||
return pricing_settings_to_metadata(seed_result.settings)
|
||||
|
||||
|
||||
def get_pricing_evaluator(
|
||||
metadata: PricingMetadata = Depends(get_pricing_metadata),
|
||||
) -> ScenarioPricingEvaluator:
|
||||
"""Provide a configured scenario pricing evaluator."""
|
||||
|
||||
return ScenarioPricingEvaluator(ScenarioPricingConfig(metadata=metadata))
|
||||
|
||||
|
||||
def get_jwt_settings() -> JWTSettings:
|
||||
"""Provide JWT runtime configuration derived from settings."""
|
||||
|
||||
|
||||
22
main.py
22
main.py
@@ -19,9 +19,10 @@ from routes.dashboard import router as dashboard_router
|
||||
from routes.imports import router as imports_router
|
||||
from routes.exports import router as exports_router
|
||||
from routes.projects import router as projects_router
|
||||
from routes.reports import router as reports_router
|
||||
from routes.scenarios import router as scenarios_router
|
||||
from monitoring import router as monitoring_router
|
||||
from services.bootstrap import bootstrap_admin
|
||||
from services.bootstrap import bootstrap_admin, bootstrap_pricing_settings
|
||||
|
||||
# Initialize database schema (imports above ensure models are registered)
|
||||
Base.metadata.create_all(bind=engine)
|
||||
@@ -47,9 +48,12 @@ async def health() -> dict[str, str]:
|
||||
|
||||
@app.on_event("startup")
|
||||
async def ensure_admin_bootstrap() -> None:
|
||||
settings = get_settings().admin_bootstrap_settings()
|
||||
settings = get_settings()
|
||||
admin_settings = settings.admin_bootstrap_settings()
|
||||
pricing_metadata = settings.pricing_metadata()
|
||||
try:
|
||||
role_result, admin_result = bootstrap_admin(settings=settings)
|
||||
role_result, admin_result = bootstrap_admin(settings=admin_settings)
|
||||
pricing_result = bootstrap_pricing_settings(metadata=pricing_metadata)
|
||||
logger.info(
|
||||
"Admin bootstrap completed: roles=%s created=%s updated=%s rotated=%s assigned=%s",
|
||||
role_result.ensured,
|
||||
@@ -58,8 +62,17 @@ async def ensure_admin_bootstrap() -> None:
|
||||
admin_result.password_rotated,
|
||||
admin_result.roles_granted,
|
||||
)
|
||||
logger.info(
|
||||
"Pricing settings bootstrap completed: slug=%s created=%s updated_fields=%s impurity_upserts=%s projects_assigned=%s",
|
||||
pricing_result.seed.settings.slug,
|
||||
pricing_result.seed.created,
|
||||
pricing_result.seed.updated_fields,
|
||||
pricing_result.seed.impurity_upserts,
|
||||
pricing_result.projects_assigned,
|
||||
)
|
||||
except Exception: # pragma: no cover - defensive logging
|
||||
logger.exception("Failed to bootstrap administrator account")
|
||||
logger.exception(
|
||||
"Failed to bootstrap administrator or pricing settings")
|
||||
|
||||
|
||||
app.include_router(dashboard_router)
|
||||
@@ -68,6 +81,7 @@ app.include_router(imports_router)
|
||||
app.include_router(exports_router)
|
||||
app.include_router(projects_router)
|
||||
app.include_router(scenarios_router)
|
||||
app.include_router(reports_router)
|
||||
app.include_router(monitoring_router)
|
||||
|
||||
app.mount("/static", StaticFiles(directory="static"), name="static")
|
||||
|
||||
@@ -11,6 +11,11 @@ from .metadata import (
|
||||
StochasticVariable,
|
||||
StochasticVariableDescriptor,
|
||||
)
|
||||
from .pricing_settings import (
|
||||
PricingImpuritySettings,
|
||||
PricingMetalSettings,
|
||||
PricingSettings,
|
||||
)
|
||||
from .project import MiningOperationType, Project
|
||||
from .scenario import Scenario, ScenarioStatus
|
||||
from .simulation_parameter import DistributionType, SimulationParameter
|
||||
@@ -21,6 +26,9 @@ __all__ = [
|
||||
"FinancialInput",
|
||||
"MiningOperationType",
|
||||
"Project",
|
||||
"PricingSettings",
|
||||
"PricingMetalSettings",
|
||||
"PricingImpuritySettings",
|
||||
"Scenario",
|
||||
"ScenarioStatus",
|
||||
"DistributionType",
|
||||
|
||||
@@ -31,6 +31,7 @@ from sqlalchemy.sql import func
|
||||
|
||||
from config.database import Base
|
||||
from .metadata import CostBucket
|
||||
from services.currency import normalise_currency
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from .scenario import Scenario
|
||||
@@ -73,16 +74,12 @@ class FinancialInput(Base):
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
scenario: Mapped["Scenario"] = relationship("Scenario", back_populates="financial_inputs")
|
||||
scenario: Mapped["Scenario"] = relationship(
|
||||
"Scenario", back_populates="financial_inputs")
|
||||
|
||||
@validates("currency")
|
||||
def _validate_currency(self, key: str, value: str | None) -> str | None:
|
||||
if value is None:
|
||||
return value
|
||||
value = value.upper()
|
||||
if len(value) != 3:
|
||||
raise ValueError("Currency code must be a 3-letter ISO 4217 value")
|
||||
return value
|
||||
return normalise_currency(value)
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover
|
||||
return f"FinancialInput(id={self.id!r}, scenario_id={self.scenario_id!r}, name={self.name!r})"
|
||||
|
||||
176
models/pricing_settings.py
Normal file
176
models/pricing_settings.py
Normal file
@@ -0,0 +1,176 @@
|
||||
from __future__ import annotations
|
||||
|
||||
"""Database models for persisted pricing configuration settings."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import (
|
||||
JSON,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Integer,
|
||||
Numeric,
|
||||
String,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship, validates
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
from config.database import Base
|
||||
from services.currency import normalise_currency
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from .project import Project
|
||||
|
||||
|
||||
class PricingSettings(Base):
|
||||
"""Persisted pricing defaults applied to scenario evaluations."""
|
||||
|
||||
__tablename__ = "pricing_settings"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(128), nullable=False, unique=True)
|
||||
slug: Mapped[str] = mapped_column(String(64), nullable=False, unique=True)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
default_currency: Mapped[str | None] = mapped_column(
|
||||
String(3), nullable=True)
|
||||
default_payable_pct: Mapped[float] = mapped_column(
|
||||
Numeric(5, 2), nullable=False, default=100.0
|
||||
)
|
||||
moisture_threshold_pct: Mapped[float] = mapped_column(
|
||||
Numeric(5, 2), nullable=False, default=8.0
|
||||
)
|
||||
moisture_penalty_per_pct: Mapped[float] = mapped_column(
|
||||
Numeric(14, 4), nullable=False, default=0.0
|
||||
)
|
||||
metadata_payload: Mapped[dict | None] = mapped_column(
|
||||
"metadata", JSON, nullable=True
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
metal_overrides: Mapped[list["PricingMetalSettings"]] = relationship(
|
||||
"PricingMetalSettings",
|
||||
back_populates="pricing_settings",
|
||||
cascade="all, delete-orphan",
|
||||
passive_deletes=True,
|
||||
)
|
||||
impurity_overrides: Mapped[list["PricingImpuritySettings"]] = relationship(
|
||||
"PricingImpuritySettings",
|
||||
back_populates="pricing_settings",
|
||||
cascade="all, delete-orphan",
|
||||
passive_deletes=True,
|
||||
)
|
||||
projects: Mapped[list["Project"]] = relationship(
|
||||
"Project",
|
||||
back_populates="pricing_settings",
|
||||
cascade="all",
|
||||
)
|
||||
|
||||
@validates("slug")
|
||||
def _normalise_slug(self, key: str, value: str) -> str:
|
||||
return value.strip().lower()
|
||||
|
||||
@validates("default_currency")
|
||||
def _validate_currency(self, key: str, value: str | None) -> str | None:
|
||||
return normalise_currency(value)
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover
|
||||
return f"PricingSettings(id={self.id!r}, slug={self.slug!r})"
|
||||
|
||||
|
||||
class PricingMetalSettings(Base):
|
||||
"""Contract-specific overrides for a particular metal."""
|
||||
|
||||
__tablename__ = "pricing_metal_settings"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"pricing_settings_id", "metal_code", name="uq_pricing_metal_settings_code"
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
pricing_settings_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("pricing_settings.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
metal_code: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||
payable_pct: Mapped[float | None] = mapped_column(
|
||||
Numeric(5, 2), nullable=True)
|
||||
moisture_threshold_pct: Mapped[float | None] = mapped_column(
|
||||
Numeric(5, 2), nullable=True)
|
||||
moisture_penalty_per_pct: Mapped[float | None] = mapped_column(
|
||||
Numeric(14, 4), nullable=True
|
||||
)
|
||||
data: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
pricing_settings: Mapped["PricingSettings"] = relationship(
|
||||
"PricingSettings", back_populates="metal_overrides"
|
||||
)
|
||||
|
||||
@validates("metal_code")
|
||||
def _normalise_metal_code(self, key: str, value: str) -> str:
|
||||
return value.strip().lower()
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover
|
||||
return (
|
||||
"PricingMetalSettings(" # noqa: ISC001
|
||||
f"id={self.id!r}, pricing_settings_id={self.pricing_settings_id!r}, "
|
||||
f"metal_code={self.metal_code!r})"
|
||||
)
|
||||
|
||||
|
||||
class PricingImpuritySettings(Base):
|
||||
"""Impurity penalty thresholds associated with pricing settings."""
|
||||
|
||||
__tablename__ = "pricing_impurity_settings"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"pricing_settings_id",
|
||||
"impurity_code",
|
||||
name="uq_pricing_impurity_settings_code",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
pricing_settings_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("pricing_settings.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
impurity_code: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||
threshold_ppm: Mapped[float] = mapped_column(
|
||||
Numeric(14, 4), nullable=False, default=0.0)
|
||||
penalty_per_ppm: Mapped[float] = mapped_column(
|
||||
Numeric(14, 4), nullable=False, default=0.0)
|
||||
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
pricing_settings: Mapped["PricingSettings"] = relationship(
|
||||
"PricingSettings", back_populates="impurity_overrides"
|
||||
)
|
||||
|
||||
@validates("impurity_code")
|
||||
def _normalise_impurity_code(self, key: str, value: str) -> str:
|
||||
return value.strip().upper()
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover
|
||||
return (
|
||||
"PricingImpuritySettings(" # noqa: ISC001
|
||||
f"id={self.id!r}, pricing_settings_id={self.pricing_settings_id!r}, "
|
||||
f"impurity_code={self.impurity_code!r})"
|
||||
)
|
||||
@@ -4,7 +4,7 @@ from datetime import datetime
|
||||
from enum import Enum
|
||||
from typing import TYPE_CHECKING, List
|
||||
|
||||
from sqlalchemy import DateTime, Enum as SQLEnum, Integer, String, Text
|
||||
from sqlalchemy import DateTime, Enum as SQLEnum, ForeignKey, Integer, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
@@ -12,6 +12,7 @@ from config.database import Base
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from .scenario import Scenario
|
||||
from .pricing_settings import PricingSettings
|
||||
|
||||
|
||||
class MiningOperationType(str, Enum):
|
||||
@@ -38,6 +39,10 @@ class Project(Base):
|
||||
SQLEnum(MiningOperationType), nullable=False, default=MiningOperationType.OTHER
|
||||
)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
pricing_settings_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("pricing_settings.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
@@ -51,6 +56,10 @@ class Project(Base):
|
||||
cascade="all, delete-orphan",
|
||||
passive_deletes=True,
|
||||
)
|
||||
pricing_settings: Mapped["PricingSettings | None"] = relationship(
|
||||
"PricingSettings",
|
||||
back_populates="projects",
|
||||
)
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover - helpful for debugging
|
||||
return f"Project(id={self.id!r}, name={self.name!r})"
|
||||
|
||||
@@ -14,10 +14,11 @@ from sqlalchemy import (
|
||||
String,
|
||||
Text,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship, validates
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
from config.database import Base
|
||||
from services.currency import normalise_currency
|
||||
from .metadata import ResourceType
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
@@ -50,7 +51,8 @@ class Scenario(Base):
|
||||
)
|
||||
start_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
end_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
discount_rate: Mapped[float | None] = mapped_column(Numeric(5, 2), nullable=True)
|
||||
discount_rate: Mapped[float | None] = mapped_column(
|
||||
Numeric(5, 2), nullable=True)
|
||||
currency: Mapped[str | None] = mapped_column(String(3), nullable=True)
|
||||
primary_resource: Mapped[ResourceType | None] = mapped_column(
|
||||
SQLEnum(ResourceType), nullable=True
|
||||
@@ -62,7 +64,8 @@ class Scenario(Base):
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
project: Mapped["Project"] = relationship("Project", back_populates="scenarios")
|
||||
project: Mapped["Project"] = relationship(
|
||||
"Project", back_populates="scenarios")
|
||||
financial_inputs: Mapped[List["FinancialInput"]] = relationship(
|
||||
"FinancialInput",
|
||||
back_populates="scenario",
|
||||
@@ -76,5 +79,10 @@ class Scenario(Base):
|
||||
passive_deletes=True,
|
||||
)
|
||||
|
||||
@validates("currency")
|
||||
def _normalise_currency(self, key: str, value: str | None) -> str | None:
|
||||
# Normalise to uppercase ISO-4217; raises when the code is malformed.
|
||||
return normalise_currency(value)
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover
|
||||
return f"Scenario(id={self.id!r}, name={self.name!r}, project_id={self.project_id!r})"
|
||||
|
||||
@@ -121,9 +121,32 @@ async def export_projects(
|
||||
) -> Response:
|
||||
project_repo = _ensure_repository(
|
||||
getattr(uow, "projects", None), "Project")
|
||||
start = time.perf_counter()
|
||||
try:
|
||||
start = time.perf_counter()
|
||||
projects = project_repo.filtered_for_export(request.filters)
|
||||
except ValueError as exc:
|
||||
_record_export_audit(
|
||||
uow=uow,
|
||||
dataset="projects",
|
||||
status="failure",
|
||||
export_format=request.format,
|
||||
row_count=0,
|
||||
filename=None,
|
||||
)
|
||||
logger.warning(
|
||||
"export.validation_failed",
|
||||
extra={
|
||||
"event": "export",
|
||||
"dataset": "projects",
|
||||
"status": "validation_failed",
|
||||
"format": request.format.value,
|
||||
"error": str(exc),
|
||||
},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail=str(exc),
|
||||
) from exc
|
||||
except Exception as exc:
|
||||
_record_export_audit(
|
||||
uow=uow,
|
||||
@@ -145,7 +168,6 @@ async def export_projects(
|
||||
raise exc
|
||||
|
||||
filename = f"projects-{_timestamp_suffix()}"
|
||||
start = time.perf_counter()
|
||||
|
||||
if request.format == ExportFormat.CSV:
|
||||
stream = stream_projects_to_csv(projects)
|
||||
@@ -226,10 +248,33 @@ async def export_scenarios(
|
||||
) -> Response:
|
||||
scenario_repo = _ensure_repository(
|
||||
getattr(uow, "scenarios", None), "Scenario")
|
||||
start = time.perf_counter()
|
||||
try:
|
||||
start = time.perf_counter()
|
||||
scenarios = scenario_repo.filtered_for_export(
|
||||
request.filters, include_project=True)
|
||||
except ValueError as exc:
|
||||
_record_export_audit(
|
||||
uow=uow,
|
||||
dataset="scenarios",
|
||||
status="failure",
|
||||
export_format=request.format,
|
||||
row_count=0,
|
||||
filename=None,
|
||||
)
|
||||
logger.warning(
|
||||
"export.validation_failed",
|
||||
extra={
|
||||
"event": "export",
|
||||
"dataset": "scenarios",
|
||||
"status": "validation_failed",
|
||||
"format": request.format.value,
|
||||
"error": str(exc),
|
||||
},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail=str(exc),
|
||||
) from exc
|
||||
except Exception as exc:
|
||||
_record_export_audit(
|
||||
uow=uow,
|
||||
@@ -251,7 +296,6 @@ async def export_scenarios(
|
||||
raise exc
|
||||
|
||||
filename = f"scenarios-{_timestamp_suffix()}"
|
||||
start = time.perf_counter()
|
||||
|
||||
if request.format == ExportFormat.CSV:
|
||||
stream = stream_scenarios_to_csv(scenarios)
|
||||
|
||||
@@ -7,6 +7,7 @@ from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from dependencies import (
|
||||
get_pricing_metadata,
|
||||
get_unit_of_work,
|
||||
require_any_role,
|
||||
require_project_resource,
|
||||
@@ -15,6 +16,7 @@ from dependencies import (
|
||||
from models import MiningOperationType, Project, ScenarioStatus, User
|
||||
from schemas.project import ProjectCreate, ProjectRead, ProjectUpdate
|
||||
from services.exceptions import EntityConflictError, EntityNotFoundError
|
||||
from services.pricing import PricingMetadata
|
||||
from services.unit_of_work import UnitOfWork
|
||||
|
||||
router = APIRouter(prefix="/projects", tags=["Projects"])
|
||||
@@ -54,6 +56,7 @@ def create_project(
|
||||
payload: ProjectCreate,
|
||||
_: User = Depends(require_roles(*MANAGE_ROLES)),
|
||||
uow: UnitOfWork = Depends(get_unit_of_work),
|
||||
metadata: PricingMetadata = Depends(get_pricing_metadata),
|
||||
) -> ProjectRead:
|
||||
project = Project(**payload.model_dump())
|
||||
try:
|
||||
@@ -62,6 +65,9 @@ def create_project(
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT, detail=str(exc)
|
||||
) from exc
|
||||
default_settings = uow.ensure_default_pricing_settings(
|
||||
metadata=metadata).settings
|
||||
uow.set_project_pricing_settings(created, default_settings)
|
||||
return _to_read_model(created)
|
||||
|
||||
|
||||
@@ -122,6 +128,7 @@ def create_project_submit(
|
||||
operation_type: str = Form(...),
|
||||
description: str | None = Form(None),
|
||||
uow: UnitOfWork = Depends(get_unit_of_work),
|
||||
metadata: PricingMetadata = Depends(get_pricing_metadata),
|
||||
):
|
||||
def _normalise(value: str | None) -> str | None:
|
||||
if value is None:
|
||||
@@ -152,7 +159,7 @@ def create_project_submit(
|
||||
description=_normalise(description),
|
||||
)
|
||||
try:
|
||||
_require_project_repo(uow).create(project)
|
||||
created = _require_project_repo(uow).create(project)
|
||||
except EntityConflictError as exc:
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
@@ -167,6 +174,10 @@ def create_project_submit(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
)
|
||||
|
||||
default_settings = uow.ensure_default_pricing_settings(
|
||||
metadata=metadata).settings
|
||||
uow.set_project_pricing_settings(created, default_settings)
|
||||
|
||||
return RedirectResponse(
|
||||
request.url_for("projects.project_list_page"),
|
||||
status_code=status.HTTP_303_SEE_OTHER,
|
||||
|
||||
512
routes/reports.py
Normal file
512
routes/reports.py
Normal file
@@ -0,0 +1,512 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from dependencies import (
|
||||
get_unit_of_work,
|
||||
require_any_role,
|
||||
require_project_resource,
|
||||
require_roles,
|
||||
require_scenario_resource,
|
||||
)
|
||||
from models import Project, Scenario, User
|
||||
from services.exceptions import EntityNotFoundError, ScenarioValidationError
|
||||
from services.reporting import (
|
||||
DEFAULT_ITERATIONS,
|
||||
IncludeOptions,
|
||||
ReportFilters,
|
||||
ReportingService,
|
||||
parse_include_tokens,
|
||||
validate_percentiles,
|
||||
)
|
||||
from services.unit_of_work import UnitOfWork
|
||||
|
||||
router = APIRouter(prefix="/reports", tags=["Reports"])
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
|
||||
READ_ROLES = ("viewer", "analyst", "project_manager", "admin")
|
||||
MANAGE_ROLES = ("project_manager", "admin")
|
||||
|
||||
|
||||
@router.get("/projects/{project_id}", name="reports.project_summary")
|
||||
def project_summary_report(
|
||||
project: Project = Depends(require_project_resource()),
|
||||
_: User = Depends(require_any_role(*READ_ROLES)),
|
||||
uow: UnitOfWork = Depends(get_unit_of_work),
|
||||
include: str | None = Query(
|
||||
None,
|
||||
description="Comma-separated include tokens (distribution,samples,all).",
|
||||
),
|
||||
scenario_ids: list[int] | None = Query(
|
||||
None,
|
||||
alias="scenario_ids",
|
||||
description="Repeatable scenario identifier filter.",
|
||||
),
|
||||
start_date: date | None = Query(
|
||||
None,
|
||||
description="Filter scenarios starting on or after this date.",
|
||||
),
|
||||
end_date: date | None = Query(
|
||||
None,
|
||||
description="Filter scenarios ending on or before this date.",
|
||||
),
|
||||
fmt: str = Query(
|
||||
"json",
|
||||
alias="format",
|
||||
description="Response format (json only for this endpoint).",
|
||||
),
|
||||
iterations: int | None = Query(
|
||||
None,
|
||||
gt=0,
|
||||
description="Override Monte Carlo iteration count when distribution is included.",
|
||||
),
|
||||
percentiles: list[float] | None = Query(
|
||||
None,
|
||||
description="Percentiles (0-100) for Monte Carlo summaries when included.",
|
||||
),
|
||||
) -> dict[str, object]:
|
||||
if fmt.lower() != "json":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_406_NOT_ACCEPTABLE,
|
||||
detail="Only JSON responses are supported; use the HTML endpoint for templates.",
|
||||
)
|
||||
|
||||
include_options = parse_include_tokens(include)
|
||||
try:
|
||||
percentile_values = validate_percentiles(percentiles)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=str(exc),
|
||||
) from exc
|
||||
|
||||
scenario_filter = ReportFilters(
|
||||
scenario_ids=set(scenario_ids) if scenario_ids else None,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
)
|
||||
|
||||
service = ReportingService(uow)
|
||||
report = service.project_summary(
|
||||
project,
|
||||
filters=scenario_filter,
|
||||
include=include_options,
|
||||
iterations=iterations or DEFAULT_ITERATIONS,
|
||||
percentiles=percentile_values,
|
||||
)
|
||||
return jsonable_encoder(report)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/projects/{project_id}/scenarios/compare",
|
||||
name="reports.project_scenario_comparison",
|
||||
)
|
||||
def project_scenario_comparison_report(
|
||||
project: Project = Depends(require_project_resource()),
|
||||
_: User = Depends(require_any_role(*READ_ROLES)),
|
||||
uow: UnitOfWork = Depends(get_unit_of_work),
|
||||
scenario_ids: list[int] = Query(
|
||||
..., alias="scenario_ids", description="Repeatable scenario identifier."),
|
||||
include: str | None = Query(
|
||||
None,
|
||||
description="Comma-separated include tokens (distribution,samples,all).",
|
||||
),
|
||||
fmt: str = Query(
|
||||
"json",
|
||||
alias="format",
|
||||
description="Response format (json only for this endpoint).",
|
||||
),
|
||||
iterations: int | None = Query(
|
||||
None,
|
||||
gt=0,
|
||||
description="Override Monte Carlo iteration count when distribution is included.",
|
||||
),
|
||||
percentiles: list[float] | None = Query(
|
||||
None,
|
||||
description="Percentiles (0-100) for Monte Carlo summaries when included.",
|
||||
),
|
||||
) -> dict[str, object]:
|
||||
unique_ids = list(dict.fromkeys(scenario_ids))
|
||||
if len(unique_ids) < 2:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="At least two unique scenario_ids must be provided for comparison.",
|
||||
)
|
||||
if fmt.lower() != "json":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_406_NOT_ACCEPTABLE,
|
||||
detail="Only JSON responses are supported; use the HTML endpoint for templates.",
|
||||
)
|
||||
|
||||
include_options = parse_include_tokens(include)
|
||||
try:
|
||||
percentile_values = validate_percentiles(percentiles)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=str(exc),
|
||||
) from exc
|
||||
|
||||
try:
|
||||
scenarios = uow.validate_scenarios_for_comparison(unique_ids)
|
||||
except ScenarioValidationError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail={
|
||||
"code": exc.code,
|
||||
"message": exc.message,
|
||||
"scenario_ids": list(exc.scenario_ids or []),
|
||||
},
|
||||
) from exc
|
||||
except EntityNotFoundError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=str(exc),
|
||||
) from exc
|
||||
|
||||
if any(scenario.project_id != project.id for scenario in scenarios):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="One or more scenarios are not associated with this project.",
|
||||
)
|
||||
|
||||
service = ReportingService(uow)
|
||||
report = service.scenario_comparison(
|
||||
project,
|
||||
scenarios,
|
||||
include=include_options,
|
||||
iterations=iterations or DEFAULT_ITERATIONS,
|
||||
percentiles=percentile_values,
|
||||
)
|
||||
return jsonable_encoder(report)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/scenarios/{scenario_id}/distribution",
|
||||
name="reports.scenario_distribution",
|
||||
)
|
||||
def scenario_distribution_report(
|
||||
scenario: Scenario = Depends(require_scenario_resource()),
|
||||
_: User = Depends(require_any_role(*READ_ROLES)),
|
||||
uow: UnitOfWork = Depends(get_unit_of_work),
|
||||
include: str | None = Query(
|
||||
None,
|
||||
description="Comma-separated include tokens (samples,all).",
|
||||
),
|
||||
fmt: str = Query(
|
||||
"json",
|
||||
alias="format",
|
||||
description="Response format (json only for this endpoint).",
|
||||
),
|
||||
iterations: int | None = Query(
|
||||
None,
|
||||
gt=0,
|
||||
description="Override Monte Carlo iteration count (default applies otherwise).",
|
||||
),
|
||||
percentiles: list[float] | None = Query(
|
||||
None,
|
||||
description="Percentiles (0-100) for Monte Carlo summaries.",
|
||||
),
|
||||
) -> dict[str, object]:
|
||||
if fmt.lower() != "json":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_406_NOT_ACCEPTABLE,
|
||||
detail="Only JSON responses are supported; use the HTML endpoint for templates.",
|
||||
)
|
||||
|
||||
requested = parse_include_tokens(include)
|
||||
include_options = IncludeOptions(
|
||||
distribution=True, samples=requested.samples)
|
||||
|
||||
try:
|
||||
percentile_values = validate_percentiles(percentiles)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=str(exc),
|
||||
) from exc
|
||||
|
||||
service = ReportingService(uow)
|
||||
report = service.scenario_distribution(
|
||||
scenario,
|
||||
include=include_options,
|
||||
iterations=iterations or DEFAULT_ITERATIONS,
|
||||
percentiles=percentile_values,
|
||||
)
|
||||
return jsonable_encoder(report)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/projects/{project_id}/ui",
|
||||
response_class=HTMLResponse,
|
||||
include_in_schema=False,
|
||||
name="reports.project_summary_page",
|
||||
)
|
||||
def project_summary_page(
|
||||
request: Request,
|
||||
project: Project = Depends(require_project_resource()),
|
||||
_: User = Depends(require_any_role(*READ_ROLES)),
|
||||
uow: UnitOfWork = Depends(get_unit_of_work),
|
||||
include: str | None = Query(
|
||||
None,
|
||||
description="Comma-separated include tokens (distribution,samples,all).",
|
||||
),
|
||||
scenario_ids: list[int] | None = Query(
|
||||
None,
|
||||
alias="scenario_ids",
|
||||
description="Repeatable scenario identifier filter.",
|
||||
),
|
||||
start_date: date | None = Query(
|
||||
None,
|
||||
description="Filter scenarios starting on or after this date.",
|
||||
),
|
||||
end_date: date | None = Query(
|
||||
None,
|
||||
description="Filter scenarios ending on or before this date.",
|
||||
),
|
||||
iterations: int | None = Query(
|
||||
None,
|
||||
gt=0,
|
||||
description="Override Monte Carlo iteration count when distribution is included.",
|
||||
),
|
||||
percentiles: list[float] | None = Query(
|
||||
None,
|
||||
description="Percentiles (0-100) for Monte Carlo summaries when included.",
|
||||
),
|
||||
) -> HTMLResponse:
|
||||
include_options = parse_include_tokens(include)
|
||||
try:
|
||||
percentile_values = validate_percentiles(percentiles)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=str(exc),
|
||||
) from exc
|
||||
|
||||
scenario_filter = ReportFilters(
|
||||
scenario_ids=set(scenario_ids) if scenario_ids else None,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
)
|
||||
|
||||
service = ReportingService(uow)
|
||||
report = service.project_summary(
|
||||
project,
|
||||
filters=scenario_filter,
|
||||
include=include_options,
|
||||
iterations=iterations or DEFAULT_ITERATIONS,
|
||||
percentiles=percentile_values,
|
||||
)
|
||||
context = {
|
||||
"request": request,
|
||||
"project": report["project"],
|
||||
"scenario_count": report["scenario_count"],
|
||||
"aggregates": report["aggregates"],
|
||||
"scenarios": report["scenarios"],
|
||||
"filters": report["filters"],
|
||||
"include_options": include_options,
|
||||
"iterations": iterations or DEFAULT_ITERATIONS,
|
||||
"percentiles": percentile_values,
|
||||
"title": f"Project Summary · {project.name}",
|
||||
"subtitle": "Aggregated financial and simulation insights across scenarios.",
|
||||
"actions": [
|
||||
{
|
||||
"href": request.url_for(
|
||||
"reports.project_summary",
|
||||
project_id=project.id,
|
||||
),
|
||||
"label": "Download JSON",
|
||||
}
|
||||
],
|
||||
}
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"reports/project_summary.html",
|
||||
context,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/projects/{project_id}/scenarios/compare/ui",
|
||||
response_class=HTMLResponse,
|
||||
include_in_schema=False,
|
||||
name="reports.project_scenario_comparison_page",
|
||||
)
|
||||
def project_scenario_comparison_page(
|
||||
request: Request,
|
||||
project: Project = Depends(require_project_resource()),
|
||||
_: User = Depends(require_any_role(*READ_ROLES)),
|
||||
uow: UnitOfWork = Depends(get_unit_of_work),
|
||||
scenario_ids: list[int] = Query(
|
||||
..., alias="scenario_ids", description="Repeatable scenario identifier."),
|
||||
include: str | None = Query(
|
||||
None,
|
||||
description="Comma-separated include tokens (distribution,samples,all).",
|
||||
),
|
||||
iterations: int | None = Query(
|
||||
None,
|
||||
gt=0,
|
||||
description="Override Monte Carlo iteration count when distribution is included.",
|
||||
),
|
||||
percentiles: list[float] | None = Query(
|
||||
None,
|
||||
description="Percentiles (0-100) for Monte Carlo summaries when included.",
|
||||
),
|
||||
) -> HTMLResponse:
|
||||
unique_ids = list(dict.fromkeys(scenario_ids))
|
||||
if len(unique_ids) < 2:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="At least two unique scenario_ids must be provided for comparison.",
|
||||
)
|
||||
|
||||
include_options = parse_include_tokens(include)
|
||||
try:
|
||||
percentile_values = validate_percentiles(percentiles)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=str(exc),
|
||||
) from exc
|
||||
|
||||
try:
|
||||
scenarios = uow.validate_scenarios_for_comparison(unique_ids)
|
||||
except ScenarioValidationError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail={
|
||||
"code": exc.code,
|
||||
"message": exc.message,
|
||||
"scenario_ids": list(exc.scenario_ids or []),
|
||||
},
|
||||
) from exc
|
||||
except EntityNotFoundError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=str(exc),
|
||||
) from exc
|
||||
|
||||
if any(scenario.project_id != project.id for scenario in scenarios):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="One or more scenarios are not associated with this project.",
|
||||
)
|
||||
|
||||
service = ReportingService(uow)
|
||||
report = service.scenario_comparison(
|
||||
project,
|
||||
scenarios,
|
||||
include=include_options,
|
||||
iterations=iterations or DEFAULT_ITERATIONS,
|
||||
percentiles=percentile_values,
|
||||
)
|
||||
comparison_json_url = request.url_for(
|
||||
"reports.project_scenario_comparison",
|
||||
project_id=project.id,
|
||||
)
|
||||
comparison_query = urlencode(
|
||||
[("scenario_ids", str(identifier)) for identifier in unique_ids]
|
||||
)
|
||||
if comparison_query:
|
||||
comparison_json_url = f"{comparison_json_url}?{comparison_query}"
|
||||
|
||||
context = {
|
||||
"request": request,
|
||||
"project": report["project"],
|
||||
"scenarios": report["scenarios"],
|
||||
"comparison": report["comparison"],
|
||||
"include_options": include_options,
|
||||
"iterations": iterations or DEFAULT_ITERATIONS,
|
||||
"percentiles": percentile_values,
|
||||
"title": f"Scenario Comparison · {project.name}",
|
||||
"subtitle": "Evaluate deterministic metrics and Monte Carlo trends side by side.",
|
||||
"actions": [
|
||||
{
|
||||
"href": comparison_json_url,
|
||||
"label": "Download JSON",
|
||||
}
|
||||
],
|
||||
}
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"reports/scenario_comparison.html",
|
||||
context,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/scenarios/{scenario_id}/distribution/ui",
|
||||
response_class=HTMLResponse,
|
||||
include_in_schema=False,
|
||||
name="reports.scenario_distribution_page",
|
||||
)
|
||||
def scenario_distribution_page(
|
||||
request: Request,
|
||||
scenario: Scenario = Depends(require_scenario_resource()),
|
||||
_: User = Depends(require_any_role(*READ_ROLES)),
|
||||
uow: UnitOfWork = Depends(get_unit_of_work),
|
||||
include: str | None = Query(
|
||||
None,
|
||||
description="Comma-separated include tokens (samples,all).",
|
||||
),
|
||||
iterations: int | None = Query(
|
||||
None,
|
||||
gt=0,
|
||||
description="Override Monte Carlo iteration count (default applies otherwise).",
|
||||
),
|
||||
percentiles: list[float] | None = Query(
|
||||
None,
|
||||
description="Percentiles (0-100) for Monte Carlo summaries.",
|
||||
),
|
||||
) -> HTMLResponse:
|
||||
requested = parse_include_tokens(include)
|
||||
include_options = IncludeOptions(
|
||||
distribution=True, samples=requested.samples)
|
||||
|
||||
try:
|
||||
percentile_values = validate_percentiles(percentiles)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=str(exc),
|
||||
) from exc
|
||||
|
||||
service = ReportingService(uow)
|
||||
report = service.scenario_distribution(
|
||||
scenario,
|
||||
include=include_options,
|
||||
iterations=iterations or DEFAULT_ITERATIONS,
|
||||
percentiles=percentile_values,
|
||||
)
|
||||
context = {
|
||||
"request": request,
|
||||
"scenario": report["scenario"],
|
||||
"summary": report["summary"],
|
||||
"metrics": report["metrics"],
|
||||
"monte_carlo": report["monte_carlo"],
|
||||
"include_options": include_options,
|
||||
"iterations": iterations or DEFAULT_ITERATIONS,
|
||||
"percentiles": percentile_values,
|
||||
"title": f"Scenario Distribution · {scenario.name}",
|
||||
"subtitle": "Deterministic and simulated distributions for a single scenario.",
|
||||
"actions": [
|
||||
{
|
||||
"href": request.url_for(
|
||||
"reports.scenario_distribution",
|
||||
scenario_id=scenario.id,
|
||||
),
|
||||
"label": "Download JSON",
|
||||
}
|
||||
],
|
||||
}
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"reports/scenario_distribution.html",
|
||||
context,
|
||||
)
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
from types import SimpleNamespace
|
||||
from typing import List
|
||||
|
||||
from fastapi import APIRouter, Depends, Form, HTTPException, Request, status
|
||||
@@ -8,6 +9,7 @@ from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from dependencies import (
|
||||
get_pricing_metadata,
|
||||
get_unit_of_work,
|
||||
require_any_role,
|
||||
require_roles,
|
||||
@@ -21,11 +23,13 @@ from schemas.scenario import (
|
||||
ScenarioRead,
|
||||
ScenarioUpdate,
|
||||
)
|
||||
from services.currency import CurrencyValidationError, normalise_currency
|
||||
from services.exceptions import (
|
||||
EntityConflictError,
|
||||
EntityNotFoundError,
|
||||
ScenarioValidationError,
|
||||
)
|
||||
from services.pricing import PricingMetadata
|
||||
from services.unit_of_work import UnitOfWork
|
||||
|
||||
router = APIRouter(tags=["Scenarios"])
|
||||
@@ -143,6 +147,7 @@ def create_scenario_for_project(
|
||||
payload: ScenarioCreate,
|
||||
_: User = Depends(require_roles(*MANAGE_ROLES)),
|
||||
uow: UnitOfWork = Depends(get_unit_of_work),
|
||||
metadata: PricingMetadata = Depends(get_pricing_metadata),
|
||||
) -> ScenarioRead:
|
||||
project_repo = _require_project_repo(uow)
|
||||
scenario_repo = _require_scenario_repo(uow)
|
||||
@@ -152,7 +157,10 @@ def create_scenario_for_project(
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||
|
||||
scenario = Scenario(project_id=project_id, **payload.model_dump())
|
||||
scenario_data = payload.model_dump()
|
||||
if not scenario_data.get("currency") and metadata.default_currency:
|
||||
scenario_data["currency"] = metadata.default_currency
|
||||
scenario = Scenario(project_id=project_id, **scenario_data)
|
||||
|
||||
try:
|
||||
created = scenario_repo.create(scenario)
|
||||
@@ -219,6 +227,33 @@ def _parse_discount_rate(value: str | None) -> float | None:
|
||||
return None
|
||||
|
||||
|
||||
def _scenario_form_state(
|
||||
*,
|
||||
project_id: int,
|
||||
name: str,
|
||||
description: str | None,
|
||||
status: ScenarioStatus,
|
||||
start_date: date | None,
|
||||
end_date: date | None,
|
||||
discount_rate: float | None,
|
||||
currency: str | None,
|
||||
primary_resource: ResourceType | None,
|
||||
scenario_id: int | None = None,
|
||||
) -> SimpleNamespace:
|
||||
return SimpleNamespace(
|
||||
id=scenario_id,
|
||||
project_id=project_id,
|
||||
name=name,
|
||||
description=description,
|
||||
status=status,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
discount_rate=discount_rate,
|
||||
currency=currency,
|
||||
primary_resource=primary_resource,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/projects/{project_id}/scenarios/new",
|
||||
response_class=HTMLResponse,
|
||||
@@ -230,6 +265,7 @@ def create_scenario_form(
|
||||
request: Request,
|
||||
_: User = Depends(require_roles(*MANAGE_ROLES)),
|
||||
uow: UnitOfWork = Depends(get_unit_of_work),
|
||||
metadata: PricingMetadata = Depends(get_pricing_metadata),
|
||||
) -> HTMLResponse:
|
||||
try:
|
||||
project = _require_project_repo(uow).get(project_id)
|
||||
@@ -252,6 +288,7 @@ def create_scenario_form(
|
||||
"cancel_url": request.url_for(
|
||||
"projects.view_project", project_id=project_id
|
||||
),
|
||||
"default_currency": metadata.default_currency,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -274,6 +311,7 @@ def create_scenario_submit(
|
||||
currency: str | None = Form(None),
|
||||
primary_resource: str | None = Form(None),
|
||||
uow: UnitOfWork = Depends(get_unit_of_work),
|
||||
metadata: PricingMetadata = Depends(get_pricing_metadata),
|
||||
):
|
||||
project_repo = _require_project_repo(uow)
|
||||
scenario_repo = _require_scenario_repo(uow)
|
||||
@@ -296,17 +334,59 @@ def create_scenario_submit(
|
||||
except ValueError:
|
||||
resource_enum = None
|
||||
|
||||
currency_value = _normalise(currency)
|
||||
currency_value = currency_value.upper() if currency_value else None
|
||||
name_value = name.strip()
|
||||
description_value = _normalise(description)
|
||||
start_date_value = _parse_date(start_date)
|
||||
end_date_value = _parse_date(end_date)
|
||||
discount_rate_value = _parse_discount_rate(discount_rate)
|
||||
currency_input = _normalise(currency)
|
||||
effective_currency = currency_input or metadata.default_currency
|
||||
|
||||
try:
|
||||
currency_value = (
|
||||
normalise_currency(effective_currency)
|
||||
if effective_currency else None
|
||||
)
|
||||
except CurrencyValidationError as exc:
|
||||
form_state = _scenario_form_state(
|
||||
project_id=project_id,
|
||||
name=name_value,
|
||||
description=description_value,
|
||||
status=status_enum,
|
||||
start_date=start_date_value,
|
||||
end_date=end_date_value,
|
||||
discount_rate=discount_rate_value,
|
||||
currency=currency_input or metadata.default_currency,
|
||||
primary_resource=resource_enum,
|
||||
)
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"scenarios/form.html",
|
||||
{
|
||||
"project": project,
|
||||
"scenario": form_state,
|
||||
"scenario_statuses": _scenario_status_choices(),
|
||||
"resource_types": _resource_type_choices(),
|
||||
"form_action": request.url_for(
|
||||
"scenarios.create_scenario_submit", project_id=project_id
|
||||
),
|
||||
"cancel_url": request.url_for(
|
||||
"projects.view_project", project_id=project_id
|
||||
),
|
||||
"error": str(exc),
|
||||
"default_currency": metadata.default_currency,
|
||||
},
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
scenario = Scenario(
|
||||
project_id=project_id,
|
||||
name=name.strip(),
|
||||
description=_normalise(description),
|
||||
name=name_value,
|
||||
description=description_value,
|
||||
status=status_enum,
|
||||
start_date=_parse_date(start_date),
|
||||
end_date=_parse_date(end_date),
|
||||
discount_rate=_parse_discount_rate(discount_rate),
|
||||
start_date=start_date_value,
|
||||
end_date=end_date_value,
|
||||
discount_rate=discount_rate_value,
|
||||
currency=currency_value,
|
||||
primary_resource=resource_enum,
|
||||
)
|
||||
@@ -329,6 +409,7 @@ def create_scenario_submit(
|
||||
"projects.view_project", project_id=project_id
|
||||
),
|
||||
"error": "Scenario could not be created.",
|
||||
"default_currency": metadata.default_currency,
|
||||
},
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
)
|
||||
@@ -392,6 +473,7 @@ def edit_scenario_form(
|
||||
require_scenario_resource(require_manage=True)
|
||||
),
|
||||
uow: UnitOfWork = Depends(get_unit_of_work),
|
||||
metadata: PricingMetadata = Depends(get_pricing_metadata),
|
||||
) -> HTMLResponse:
|
||||
project = _require_project_repo(uow).get(scenario.project_id)
|
||||
|
||||
@@ -409,6 +491,7 @@ def edit_scenario_form(
|
||||
"cancel_url": request.url_for(
|
||||
"scenarios.view_scenario", scenario_id=scenario.id
|
||||
),
|
||||
"default_currency": metadata.default_currency,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -432,22 +515,17 @@ def edit_scenario_submit(
|
||||
currency: str | None = Form(None),
|
||||
primary_resource: str | None = Form(None),
|
||||
uow: UnitOfWork = Depends(get_unit_of_work),
|
||||
metadata: PricingMetadata = Depends(get_pricing_metadata),
|
||||
):
|
||||
project = _require_project_repo(uow).get(scenario.project_id)
|
||||
|
||||
scenario.name = name.strip()
|
||||
scenario.description = _normalise(description)
|
||||
name_value = name.strip()
|
||||
description_value = _normalise(description)
|
||||
try:
|
||||
scenario.status = ScenarioStatus(status_value)
|
||||
except ValueError:
|
||||
scenario.status = ScenarioStatus.DRAFT
|
||||
scenario.start_date = _parse_date(start_date)
|
||||
scenario.end_date = _parse_date(end_date)
|
||||
|
||||
scenario.discount_rate = _parse_discount_rate(discount_rate)
|
||||
|
||||
currency_value = _normalise(currency)
|
||||
scenario.currency = currency_value.upper() if currency_value else None
|
||||
status_enum = scenario.status
|
||||
|
||||
resource_enum = None
|
||||
if primary_resource:
|
||||
@@ -455,6 +533,53 @@ def edit_scenario_submit(
|
||||
resource_enum = ResourceType(primary_resource)
|
||||
except ValueError:
|
||||
resource_enum = None
|
||||
|
||||
start_date_value = _parse_date(start_date)
|
||||
end_date_value = _parse_date(end_date)
|
||||
discount_rate_value = _parse_discount_rate(discount_rate)
|
||||
currency_input = _normalise(currency)
|
||||
|
||||
try:
|
||||
currency_value = normalise_currency(currency_input)
|
||||
except CurrencyValidationError as exc:
|
||||
form_state = _scenario_form_state(
|
||||
scenario_id=scenario.id,
|
||||
project_id=scenario.project_id,
|
||||
name=name_value,
|
||||
description=description_value,
|
||||
status=status_enum,
|
||||
start_date=start_date_value,
|
||||
end_date=end_date_value,
|
||||
discount_rate=discount_rate_value,
|
||||
currency=currency_input,
|
||||
primary_resource=resource_enum,
|
||||
)
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"scenarios/form.html",
|
||||
{
|
||||
"project": project,
|
||||
"scenario": form_state,
|
||||
"scenario_statuses": _scenario_status_choices(),
|
||||
"resource_types": _resource_type_choices(),
|
||||
"form_action": request.url_for(
|
||||
"scenarios.edit_scenario_submit", scenario_id=scenario.id
|
||||
),
|
||||
"cancel_url": request.url_for(
|
||||
"scenarios.view_scenario", scenario_id=scenario.id
|
||||
),
|
||||
"error": str(exc),
|
||||
"default_currency": metadata.default_currency,
|
||||
},
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
scenario.name = name_value
|
||||
scenario.description = description_value
|
||||
scenario.start_date = start_date_value
|
||||
scenario.end_date = end_date_value
|
||||
scenario.discount_rate = discount_rate_value
|
||||
scenario.currency = currency_value
|
||||
scenario.primary_resource = resource_enum
|
||||
|
||||
uow.flush()
|
||||
|
||||
@@ -7,6 +7,7 @@ from typing import Literal
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||
|
||||
from models import MiningOperationType, ResourceType, ScenarioStatus
|
||||
from services.currency import CurrencyValidationError, normalise_currency
|
||||
|
||||
PreviewStateLiteral = Literal["new", "update", "skip", "error"]
|
||||
|
||||
@@ -142,14 +143,13 @@ class ScenarioImportRow(BaseModel):
|
||||
@field_validator("currency", mode="before")
|
||||
@classmethod
|
||||
def normalise_currency(cls, value: Any | None) -> str | None:
|
||||
if value is None:
|
||||
text = _strip_or_none(value)
|
||||
if text is None:
|
||||
return None
|
||||
text = _normalise_string(value).upper()
|
||||
if not text:
|
||||
return None
|
||||
if len(text) != 3:
|
||||
raise ValueError("Currency code must be a 3-letter ISO value")
|
||||
return text
|
||||
try:
|
||||
return normalise_currency(text)
|
||||
except CurrencyValidationError as exc:
|
||||
raise ValueError(str(exc)) from exc
|
||||
|
||||
@field_validator("discount_rate", mode="before")
|
||||
@classmethod
|
||||
|
||||
@@ -5,6 +5,7 @@ from datetime import date, datetime
|
||||
from pydantic import BaseModel, ConfigDict, field_validator, model_validator
|
||||
|
||||
from models import ResourceType, ScenarioStatus
|
||||
from services.currency import CurrencyValidationError, normalise_currency
|
||||
|
||||
|
||||
class ScenarioBase(BaseModel):
|
||||
@@ -23,11 +24,15 @@ class ScenarioBase(BaseModel):
|
||||
@classmethod
|
||||
def normalise_currency(cls, value: str | None) -> str | None:
|
||||
if value is None:
|
||||
return value
|
||||
value = value.upper()
|
||||
if len(value) != 3:
|
||||
raise ValueError("Currency code must be a 3-letter ISO value")
|
||||
return value
|
||||
return None
|
||||
candidate = value if isinstance(value, str) else str(value)
|
||||
candidate = candidate.strip()
|
||||
if not candidate:
|
||||
return None
|
||||
try:
|
||||
return normalise_currency(candidate)
|
||||
except CurrencyValidationError as exc:
|
||||
raise ValueError(str(exc)) from exc
|
||||
|
||||
|
||||
class ScenarioCreate(ScenarioBase):
|
||||
@@ -50,11 +55,15 @@ class ScenarioUpdate(BaseModel):
|
||||
@classmethod
|
||||
def normalise_currency(cls, value: str | None) -> str | None:
|
||||
if value is None:
|
||||
return value
|
||||
value = value.upper()
|
||||
if len(value) != 3:
|
||||
raise ValueError("Currency code must be a 3-letter ISO value")
|
||||
return value
|
||||
return None
|
||||
candidate = value if isinstance(value, str) else str(value)
|
||||
candidate = candidate.strip()
|
||||
if not candidate:
|
||||
return None
|
||||
try:
|
||||
return normalise_currency(candidate)
|
||||
except CurrencyValidationError as exc:
|
||||
raise ValueError(str(exc)) from exc
|
||||
|
||||
|
||||
class ScenarioRead(ScenarioBase):
|
||||
@@ -75,7 +84,8 @@ class ScenarioComparisonRequest(BaseModel):
|
||||
def ensure_minimum_ids(self) -> "ScenarioComparisonRequest":
|
||||
unique_ids: list[int] = list(dict.fromkeys(self.scenario_ids))
|
||||
if len(unique_ids) < 2:
|
||||
raise ValueError("At least two unique scenario identifiers are required for comparison.")
|
||||
raise ValueError(
|
||||
"At least two unique scenario identifiers are required for comparison.")
|
||||
self.scenario_ids = unique_ids
|
||||
return self
|
||||
|
||||
|
||||
@@ -7,8 +7,15 @@ from typing import Callable, Iterable
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from config.settings import Settings
|
||||
from models import Role, User
|
||||
from services.repositories import DEFAULT_ROLE_DEFINITIONS, RoleRepository, UserRepository
|
||||
from services.repositories import (
|
||||
DEFAULT_ROLE_DEFINITIONS,
|
||||
PricingSettingsSeedResult,
|
||||
RoleRepository,
|
||||
UserRepository,
|
||||
ensure_default_pricing_settings,
|
||||
)
|
||||
from services.unit_of_work import UnitOfWork
|
||||
|
||||
|
||||
@@ -45,7 +52,8 @@ def parse_bool(value: str | None) -> bool:
|
||||
def normalise_role_list(raw_value: str | None) -> tuple[str, ...]:
|
||||
if not raw_value:
|
||||
return ("admin",)
|
||||
parts = [segment.strip() for segment in raw_value.split(",") if segment.strip()]
|
||||
parts = [segment.strip()
|
||||
for segment in raw_value.split(",") if segment.strip()]
|
||||
if "admin" not in parts:
|
||||
parts.insert(0, "admin")
|
||||
seen: set[str] = set()
|
||||
@@ -59,7 +67,8 @@ def normalise_role_list(raw_value: str | None) -> tuple[str, ...]:
|
||||
|
||||
def load_config() -> SeedConfig:
|
||||
load_dotenv()
|
||||
admin_email = os.getenv("CALMINER_SEED_ADMIN_EMAIL", "admin@calminer.local")
|
||||
admin_email = os.getenv("CALMINER_SEED_ADMIN_EMAIL",
|
||||
"admin@calminer.local")
|
||||
admin_username = os.getenv("CALMINER_SEED_ADMIN_USERNAME", "admin")
|
||||
admin_password = os.getenv("CALMINER_SEED_ADMIN_PASSWORD", "ChangeMe123!")
|
||||
admin_roles = normalise_role_list(os.getenv("CALMINER_SEED_ADMIN_ROLES"))
|
||||
@@ -140,12 +149,15 @@ def ensure_admin_user(
|
||||
for role_name in config.admin_roles:
|
||||
role = role_repo.get_by_name(role_name)
|
||||
if role is None:
|
||||
logging.warning("Role '%s' is not defined and will be skipped", role_name)
|
||||
logging.warning(
|
||||
"Role '%s' is not defined and will be skipped", role_name)
|
||||
continue
|
||||
already_assigned = any(assignment.role_id == role.id for assignment in user.role_assignments)
|
||||
already_assigned = any(assignment.role_id ==
|
||||
role.id for assignment in user.role_assignments)
|
||||
if already_assigned:
|
||||
continue
|
||||
user_repo.assign_role(user_id=user.id, role_id=role.id, granted_by=user.id)
|
||||
user_repo.assign_role(
|
||||
user_id=user.id, role_id=role.id, granted_by=user.id)
|
||||
roles_granted += 1
|
||||
|
||||
return AdminSeedResult(
|
||||
@@ -164,9 +176,33 @@ def seed_initial_data(
|
||||
logging.info("Starting initial data seeding")
|
||||
factory = unit_of_work_factory or UnitOfWork
|
||||
with factory() as uow:
|
||||
assert uow.roles is not None and uow.users is not None
|
||||
assert (
|
||||
uow.roles is not None
|
||||
and uow.users is not None
|
||||
and uow.pricing_settings is not None
|
||||
and uow.projects is not None
|
||||
)
|
||||
role_result = ensure_default_roles(uow.roles)
|
||||
admin_result = ensure_admin_user(uow.users, uow.roles, config)
|
||||
pricing_metadata = uow.get_pricing_metadata()
|
||||
metadata_source = "database"
|
||||
if pricing_metadata is None:
|
||||
pricing_metadata = Settings.from_environment().pricing_metadata()
|
||||
metadata_source = "environment"
|
||||
pricing_result: PricingSettingsSeedResult = ensure_default_pricing_settings(
|
||||
uow.pricing_settings,
|
||||
metadata=pricing_metadata,
|
||||
)
|
||||
|
||||
projects_without_pricing = [
|
||||
project
|
||||
for project in uow.projects.list(with_pricing=True)
|
||||
if project.pricing_settings is None
|
||||
]
|
||||
assigned_projects = 0
|
||||
for project in projects_without_pricing:
|
||||
uow.set_project_pricing_settings(project, pricing_result.settings)
|
||||
assigned_projects += 1
|
||||
logging.info(
|
||||
"Roles processed: %s total, %s created, %s updated",
|
||||
role_result.total,
|
||||
@@ -180,4 +216,16 @@ def seed_initial_data(
|
||||
admin_result.password_rotated,
|
||||
admin_result.roles_granted,
|
||||
)
|
||||
logging.info("Initial data seeding completed successfully")
|
||||
logging.info(
|
||||
"Pricing settings ensured (source=%s): slug=%s created=%s updated_fields=%s impurity_upserts=%s",
|
||||
metadata_source,
|
||||
pricing_result.settings.slug,
|
||||
pricing_result.created,
|
||||
pricing_result.updated_fields,
|
||||
pricing_result.impurity_upserts,
|
||||
)
|
||||
logging.info(
|
||||
"Projects updated with default pricing settings: %s",
|
||||
assigned_projects,
|
||||
)
|
||||
logging.info("Initial data seeding completed successfully")
|
||||
|
||||
@@ -1 +1,10 @@
|
||||
"""Service layer utilities."""
|
||||
|
||||
from .pricing import calculate_pricing, PricingInput, PricingMetadata, PricingResult
|
||||
|
||||
__all__ = [
|
||||
"calculate_pricing",
|
||||
"PricingInput",
|
||||
"PricingMetadata",
|
||||
"PricingResult",
|
||||
]
|
||||
|
||||
@@ -6,7 +6,11 @@ from typing import Callable
|
||||
|
||||
from config.settings import AdminBootstrapSettings
|
||||
from models import User
|
||||
from services.repositories import ensure_default_roles
|
||||
from services.pricing import PricingMetadata
|
||||
from services.repositories import (
|
||||
PricingSettingsSeedResult,
|
||||
ensure_default_roles,
|
||||
)
|
||||
from services.unit_of_work import UnitOfWork
|
||||
|
||||
|
||||
@@ -27,6 +31,12 @@ class AdminBootstrapResult:
|
||||
roles_granted: int
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class PricingBootstrapResult:
|
||||
seed: PricingSettingsSeedResult
|
||||
projects_assigned: int
|
||||
|
||||
|
||||
def bootstrap_admin(
|
||||
*,
|
||||
settings: AdminBootstrapSettings,
|
||||
@@ -127,3 +137,37 @@ def _bootstrap_admin_user(
|
||||
password_rotated=password_rotated,
|
||||
roles_granted=roles_granted,
|
||||
)
|
||||
|
||||
|
||||
def bootstrap_pricing_settings(
|
||||
*,
|
||||
metadata: PricingMetadata,
|
||||
unit_of_work_factory: Callable[[], UnitOfWork] = UnitOfWork,
|
||||
default_slug: str = "default",
|
||||
) -> PricingBootstrapResult:
|
||||
"""Ensure baseline pricing settings exist and projects reference them."""
|
||||
|
||||
with unit_of_work_factory() as uow:
|
||||
seed_result = uow.ensure_default_pricing_settings(
|
||||
metadata=metadata,
|
||||
slug=default_slug,
|
||||
)
|
||||
|
||||
assigned = 0
|
||||
if uow.projects:
|
||||
default_settings = seed_result.settings
|
||||
projects = uow.projects.list(with_pricing=True)
|
||||
for project in projects:
|
||||
if project.pricing_settings is None:
|
||||
uow.set_project_pricing_settings(project, default_settings)
|
||||
assigned += 1
|
||||
|
||||
logger.info(
|
||||
"Pricing bootstrap result: slug=%s created=%s updated_fields=%s impurity_upserts=%s projects_assigned=%s",
|
||||
seed_result.settings.slug,
|
||||
seed_result.created,
|
||||
seed_result.updated_fields,
|
||||
seed_result.impurity_upserts,
|
||||
assigned,
|
||||
)
|
||||
return PricingBootstrapResult(seed=seed_result, projects_assigned=assigned)
|
||||
|
||||
43
services/currency.py
Normal file
43
services/currency.py
Normal file
@@ -0,0 +1,43 @@
|
||||
from __future__ import annotations
|
||||
|
||||
"""Utilities for currency normalization within pricing and financial workflows."""
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
|
||||
VALID_CURRENCY_PATTERN = re.compile(r"^[A-Z]{3}$")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CurrencyValidationError(ValueError):
|
||||
"""Raised when a currency code fails validation."""
|
||||
|
||||
code: str
|
||||
|
||||
def __str__(self) -> str: # pragma: no cover - dataclass repr not required in tests
|
||||
return f"Invalid currency code: {self.code!r}"
|
||||
|
||||
|
||||
def normalise_currency(code: str | None) -> str | None:
|
||||
"""Normalise currency codes to uppercase ISO-4217 values."""
|
||||
|
||||
if code is None:
|
||||
return None
|
||||
candidate = code.strip().upper()
|
||||
if not VALID_CURRENCY_PATTERN.match(candidate):
|
||||
raise CurrencyValidationError(candidate)
|
||||
return candidate
|
||||
|
||||
|
||||
def require_currency(code: str | None, default: str | None = None) -> str:
|
||||
"""Return normalised currency code, falling back to default when missing."""
|
||||
|
||||
normalised = normalise_currency(code)
|
||||
if normalised is not None:
|
||||
return normalised
|
||||
if default is None:
|
||||
raise CurrencyValidationError("<missing currency>")
|
||||
fallback = normalise_currency(default)
|
||||
if fallback is None:
|
||||
raise CurrencyValidationError("<invalid default currency>")
|
||||
return fallback
|
||||
@@ -5,6 +5,7 @@ from datetime import date, datetime
|
||||
from typing import Iterable
|
||||
|
||||
from models import MiningOperationType, ResourceType, ScenarioStatus
|
||||
from services.currency import CurrencyValidationError, normalise_currency
|
||||
|
||||
|
||||
def _normalise_lower_strings(values: Iterable[str]) -> tuple[str, ...]:
|
||||
@@ -19,15 +20,22 @@ def _normalise_lower_strings(values: Iterable[str]) -> tuple[str, ...]:
|
||||
return tuple(sorted(unique))
|
||||
|
||||
|
||||
def _normalise_upper_strings(values: Iterable[str]) -> tuple[str, ...]:
|
||||
def _normalise_upper_strings(values: Iterable[str | None]) -> tuple[str, ...]:
|
||||
unique: set[str] = set()
|
||||
for value in values:
|
||||
if not value:
|
||||
if value is None:
|
||||
continue
|
||||
trimmed = value.strip().upper()
|
||||
if not trimmed:
|
||||
candidate = value if isinstance(value, str) else str(value)
|
||||
candidate = candidate.strip()
|
||||
if not candidate:
|
||||
continue
|
||||
unique.add(trimmed)
|
||||
try:
|
||||
normalised = normalise_currency(candidate)
|
||||
except CurrencyValidationError as exc:
|
||||
raise ValueError(str(exc)) from exc
|
||||
if normalised is None:
|
||||
continue
|
||||
unique.add(normalised)
|
||||
return tuple(sorted(unique))
|
||||
|
||||
|
||||
|
||||
248
services/financial.py
Normal file
248
services/financial.py
Normal file
@@ -0,0 +1,248 @@
|
||||
from __future__ import annotations
|
||||
|
||||
"""Financial calculation helpers for project evaluation metrics."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, datetime
|
||||
from math import isclose, isfinite
|
||||
from typing import Iterable, List, Sequence, Tuple
|
||||
|
||||
Number = float
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CashFlow:
|
||||
"""Represents a dated cash flow in scenario currency."""
|
||||
|
||||
amount: Number
|
||||
period_index: int | None = None
|
||||
date: date | datetime | None = None
|
||||
|
||||
|
||||
class ConvergenceError(RuntimeError):
|
||||
"""Raised when an iterative solver fails to converge."""
|
||||
|
||||
|
||||
class PaybackNotReachedError(RuntimeError):
|
||||
"""Raised when cumulative cash flows never reach a non-negative total."""
|
||||
|
||||
|
||||
def _coerce_date(value: date | datetime) -> date:
|
||||
if isinstance(value, datetime):
|
||||
return value.date()
|
||||
return value
|
||||
|
||||
|
||||
def normalize_cash_flows(
|
||||
cash_flows: Iterable[CashFlow],
|
||||
*,
|
||||
compounds_per_year: int = 1,
|
||||
) -> List[Tuple[Number, float]]:
|
||||
"""Normalise cash flows to ``(amount, periods)`` tuples.
|
||||
|
||||
When explicit ``period_index`` values are provided they take precedence. If
|
||||
only dates are supplied, the first dated cash flow anchors the timeline and
|
||||
subsequent cash flows convert their day offsets into fractional periods
|
||||
based on ``compounds_per_year``. When neither a period index nor a date is
|
||||
present, cash flows are treated as sequential periods in input order.
|
||||
"""
|
||||
|
||||
flows: Sequence[CashFlow] = list(cash_flows)
|
||||
if not flows:
|
||||
return []
|
||||
|
||||
if compounds_per_year <= 0:
|
||||
raise ValueError("compounds_per_year must be a positive integer")
|
||||
|
||||
base_date: date | None = None
|
||||
for flow in flows:
|
||||
if flow.date is not None:
|
||||
base_date = _coerce_date(flow.date)
|
||||
break
|
||||
|
||||
normalised: List[Tuple[Number, float]] = []
|
||||
for idx, flow in enumerate(flows):
|
||||
amount = float(flow.amount)
|
||||
if flow.period_index is not None:
|
||||
periods = float(flow.period_index)
|
||||
elif flow.date is not None and base_date is not None:
|
||||
current_date = _coerce_date(flow.date)
|
||||
delta_days = (current_date - base_date).days
|
||||
period_length_days = 365.0 / float(compounds_per_year)
|
||||
periods = delta_days / period_length_days
|
||||
else:
|
||||
periods = float(idx)
|
||||
normalised.append((amount, periods))
|
||||
|
||||
return normalised
|
||||
|
||||
|
||||
def discount_factor(rate: Number, periods: float, *, compounds_per_year: int = 1) -> float:
|
||||
"""Return the factor used to discount a value ``periods`` steps in the future."""
|
||||
|
||||
if compounds_per_year <= 0:
|
||||
raise ValueError("compounds_per_year must be a positive integer")
|
||||
|
||||
periodic_rate = rate / float(compounds_per_year)
|
||||
return (1.0 + periodic_rate) ** (-periods)
|
||||
|
||||
|
||||
def net_present_value(
|
||||
rate: Number,
|
||||
cash_flows: Iterable[CashFlow],
|
||||
*,
|
||||
residual_value: Number | None = None,
|
||||
residual_periods: float | None = None,
|
||||
compounds_per_year: int = 1,
|
||||
) -> float:
|
||||
"""Calculate Net Present Value for ``cash_flows``.
|
||||
|
||||
``rate`` is a decimal (``0.1`` for 10%). Cash flows are discounted using the
|
||||
given compounding frequency. When ``residual_value`` is provided it is
|
||||
discounted at ``residual_periods`` periods; by default the value occurs one
|
||||
period after the final cash flow.
|
||||
"""
|
||||
|
||||
normalised = normalize_cash_flows(
|
||||
cash_flows,
|
||||
compounds_per_year=compounds_per_year,
|
||||
)
|
||||
|
||||
if not normalised and residual_value is None:
|
||||
return 0.0
|
||||
|
||||
total = 0.0
|
||||
for amount, periods in normalised:
|
||||
factor = discount_factor(
|
||||
rate, periods, compounds_per_year=compounds_per_year)
|
||||
total += amount * factor
|
||||
|
||||
if residual_value is not None:
|
||||
if residual_periods is None:
|
||||
last_period = normalised[-1][1] if normalised else 0.0
|
||||
residual_periods = last_period + 1.0
|
||||
factor = discount_factor(
|
||||
rate, residual_periods, compounds_per_year=compounds_per_year)
|
||||
total += float(residual_value) * factor
|
||||
|
||||
return total
|
||||
|
||||
|
||||
def internal_rate_of_return(
|
||||
cash_flows: Iterable[CashFlow],
|
||||
*,
|
||||
guess: Number = 0.1,
|
||||
max_iterations: int = 100,
|
||||
tolerance: float = 1e-6,
|
||||
compounds_per_year: int = 1,
|
||||
) -> float:
|
||||
"""Return the internal rate of return for ``cash_flows``.
|
||||
|
||||
Uses Newton-Raphson iteration with a bracketed fallback when the derivative
|
||||
becomes unstable. Raises :class:`ConvergenceError` if no root is found.
|
||||
"""
|
||||
|
||||
flows = normalize_cash_flows(
|
||||
cash_flows,
|
||||
compounds_per_year=compounds_per_year,
|
||||
)
|
||||
if not flows:
|
||||
raise ValueError("cash_flows must contain at least one item")
|
||||
|
||||
amounts = [amount for amount, _ in flows]
|
||||
if not any(amount < 0 for amount in amounts) or not any(amount > 0 for amount in amounts):
|
||||
raise ValueError("cash_flows must include both negative and positive values")
|
||||
|
||||
def _npv_with_flows(rate: float) -> float:
|
||||
periodic_rate = rate / float(compounds_per_year)
|
||||
if periodic_rate <= -1.0:
|
||||
return float("inf")
|
||||
total = 0.0
|
||||
for amount, periods in flows:
|
||||
factor = (1.0 + periodic_rate) ** (-periods)
|
||||
total += amount * factor
|
||||
return total
|
||||
|
||||
def _derivative(rate: float) -> float:
|
||||
periodic_rate = rate / float(compounds_per_year)
|
||||
if periodic_rate <= -1.0:
|
||||
return float("inf")
|
||||
derivative = 0.0
|
||||
for amount, periods in flows:
|
||||
factor = (1.0 + periodic_rate) ** (-periods - 1.0)
|
||||
derivative += -amount * periods * factor / float(compounds_per_year)
|
||||
return derivative
|
||||
|
||||
rate = float(guess)
|
||||
for _ in range(max_iterations):
|
||||
value = _npv_with_flows(rate)
|
||||
if isclose(value, 0.0, abs_tol=tolerance):
|
||||
return rate
|
||||
derivative = _derivative(rate)
|
||||
if derivative == 0.0 or not isfinite(derivative):
|
||||
break
|
||||
next_rate = rate - value / derivative
|
||||
if abs(next_rate - rate) < tolerance:
|
||||
return next_rate
|
||||
rate = next_rate
|
||||
|
||||
# Fallback to bracketed bisection between sensible bounds.
|
||||
lower_bound = -0.99 * float(compounds_per_year)
|
||||
upper_bound = 10.0
|
||||
lower_value = _npv_with_flows(lower_bound)
|
||||
upper_value = _npv_with_flows(upper_bound)
|
||||
|
||||
attempts = 0
|
||||
while lower_value * upper_value > 0 and attempts < 12:
|
||||
upper_bound *= 2.0
|
||||
upper_value = _npv_with_flows(upper_bound)
|
||||
attempts += 1
|
||||
|
||||
if lower_value * upper_value > 0:
|
||||
raise ConvergenceError("IRR could not be bracketed within default bounds")
|
||||
|
||||
for _ in range(max_iterations * 2):
|
||||
midpoint = (lower_bound + upper_bound) / 2.0
|
||||
mid_value = _npv_with_flows(midpoint)
|
||||
if isclose(mid_value, 0.0, abs_tol=tolerance):
|
||||
return midpoint
|
||||
if lower_value * mid_value < 0:
|
||||
upper_bound = midpoint
|
||||
upper_value = mid_value
|
||||
else:
|
||||
lower_bound = midpoint
|
||||
lower_value = mid_value
|
||||
raise ConvergenceError("IRR solver failed to converge")
|
||||
|
||||
|
||||
def payback_period(
|
||||
cash_flows: Iterable[CashFlow],
|
||||
*,
|
||||
allow_fractional: bool = True,
|
||||
compounds_per_year: int = 1,
|
||||
) -> float:
|
||||
"""Return the period index where cumulative cash flow becomes non-negative."""
|
||||
|
||||
flows = normalize_cash_flows(
|
||||
cash_flows,
|
||||
compounds_per_year=compounds_per_year,
|
||||
)
|
||||
if not flows:
|
||||
raise ValueError("cash_flows must contain at least one item")
|
||||
|
||||
flows = sorted(flows, key=lambda item: item[1])
|
||||
cumulative = 0.0
|
||||
previous_period = flows[0][1]
|
||||
|
||||
for index, (amount, periods) in enumerate(flows):
|
||||
next_cumulative = cumulative + amount
|
||||
if next_cumulative >= 0.0:
|
||||
if not allow_fractional or isclose(amount, 0.0):
|
||||
return periods
|
||||
prev_period = previous_period if index > 0 else periods
|
||||
fraction = -cumulative / amount
|
||||
return prev_period + fraction * (periods - prev_period)
|
||||
cumulative = next_cumulative
|
||||
previous_period = periods
|
||||
|
||||
raise PaybackNotReachedError("Cumulative cash flow never becomes non-negative")
|
||||
176
services/pricing.py
Normal file
176
services/pricing.py
Normal file
@@ -0,0 +1,176 @@
|
||||
from __future__ import annotations
|
||||
|
||||
"""Pricing service implementing commodity revenue calculations.
|
||||
|
||||
This module exposes data models and helpers for computing product pricing
|
||||
according to the formulas outlined in
|
||||
``calminer-docs/specifications/price_calculation.md``. It focuses on the core
|
||||
calculation steps (payable metal, penalties, net revenue) and is intended to be
|
||||
composed within broader scenario evaluation workflows.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Mapping
|
||||
|
||||
from pydantic import BaseModel, Field, PositiveFloat, field_validator
|
||||
from services.currency import require_currency
|
||||
|
||||
|
||||
class PricingInput(BaseModel):
|
||||
"""Normalized inputs for pricing calculations."""
|
||||
|
||||
metal: str = Field(..., min_length=1)
|
||||
ore_tonnage: PositiveFloat = Field(
|
||||
..., description="Total ore mass processed (metric tonnes)")
|
||||
head_grade_pct: PositiveFloat = Field(..., gt=0,
|
||||
le=100, description="Head grade as percent")
|
||||
recovery_pct: PositiveFloat = Field(..., gt=0,
|
||||
le=100, description="Recovery rate percent")
|
||||
payable_pct: float | None = Field(
|
||||
None, gt=0, le=100, description="Contractual payable percentage")
|
||||
reference_price: PositiveFloat = Field(
|
||||
..., description="Reference price in base currency per unit")
|
||||
treatment_charge: float = Field(0, ge=0)
|
||||
smelting_charge: float = Field(0, ge=0)
|
||||
moisture_pct: float = Field(0, ge=0, le=100)
|
||||
moisture_threshold_pct: float | None = Field(None, ge=0, le=100)
|
||||
moisture_penalty_per_pct: float | None = Field(None)
|
||||
impurity_ppm: Mapping[str, float] = Field(default_factory=dict)
|
||||
impurity_thresholds: Mapping[str, float] = Field(default_factory=dict)
|
||||
impurity_penalty_per_ppm: Mapping[str, float] = Field(default_factory=dict)
|
||||
premiums: float = Field(0)
|
||||
fx_rate: PositiveFloat = Field(
|
||||
1, description="Multiplier to convert to scenario currency")
|
||||
currency_code: str | None = Field(
|
||||
None, description="Optional explicit currency override")
|
||||
|
||||
@field_validator("impurity_ppm", mode="before")
|
||||
@classmethod
|
||||
def _validate_impurity_mapping(cls, value):
|
||||
if isinstance(value, Mapping):
|
||||
return {k: float(v) for k, v in value.items()}
|
||||
return value
|
||||
|
||||
|
||||
class PricingResult(BaseModel):
|
||||
"""Structured output summarising pricing computation results."""
|
||||
|
||||
metal: str
|
||||
ore_tonnage: float
|
||||
head_grade_pct: float
|
||||
recovery_pct: float
|
||||
payable_metal_tonnes: float
|
||||
reference_price: float
|
||||
gross_revenue: float
|
||||
moisture_penalty: float
|
||||
impurity_penalty: float
|
||||
treatment_smelt_charges: float
|
||||
premiums: float
|
||||
net_revenue: float
|
||||
currency: str | None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PricingMetadata:
|
||||
"""Metadata defaults applied when explicit inputs are omitted."""
|
||||
|
||||
default_payable_pct: float = 100.0
|
||||
default_currency: str | None = "USD"
|
||||
moisture_threshold_pct: float = 8.0
|
||||
moisture_penalty_per_pct: float = 0.0
|
||||
impurity_thresholds: Mapping[str, float] = field(default_factory=dict)
|
||||
impurity_penalty_per_ppm: Mapping[str, float] = field(default_factory=dict)
|
||||
|
||||
|
||||
def calculate_pricing(
|
||||
pricing_input: PricingInput,
|
||||
*,
|
||||
metadata: PricingMetadata | None = None,
|
||||
currency: str | None = None,
|
||||
) -> PricingResult:
|
||||
"""Calculate pricing metrics for the provided commodity input.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
pricing_input:
|
||||
Normalised input data including ore tonnage, grades, charges, and
|
||||
optional penalties.
|
||||
metadata:
|
||||
Optional default metadata applied when specific values are omitted from
|
||||
``pricing_input``.
|
||||
currency:
|
||||
Optional override for the output currency label. Falls back to
|
||||
``metadata.default_currency`` when not provided.
|
||||
"""
|
||||
|
||||
applied_metadata = metadata or PricingMetadata()
|
||||
|
||||
payable_pct = (
|
||||
pricing_input.payable_pct
|
||||
if pricing_input.payable_pct is not None
|
||||
else applied_metadata.default_payable_pct
|
||||
)
|
||||
moisture_threshold = (
|
||||
pricing_input.moisture_threshold_pct
|
||||
if pricing_input.moisture_threshold_pct is not None
|
||||
else applied_metadata.moisture_threshold_pct
|
||||
)
|
||||
moisture_penalty_factor = (
|
||||
pricing_input.moisture_penalty_per_pct
|
||||
if pricing_input.moisture_penalty_per_pct is not None
|
||||
else applied_metadata.moisture_penalty_per_pct
|
||||
)
|
||||
|
||||
impurity_thresholds = {
|
||||
**applied_metadata.impurity_thresholds,
|
||||
**pricing_input.impurity_thresholds,
|
||||
}
|
||||
impurity_penalty_factors = {
|
||||
**applied_metadata.impurity_penalty_per_ppm,
|
||||
**pricing_input.impurity_penalty_per_ppm,
|
||||
}
|
||||
|
||||
q_metal = pricing_input.ore_tonnage * (pricing_input.head_grade_pct / 100.0) * (
|
||||
pricing_input.recovery_pct / 100.0
|
||||
)
|
||||
payable_metal = q_metal * (payable_pct / 100.0)
|
||||
|
||||
gross_revenue_ref = payable_metal * pricing_input.reference_price
|
||||
charges = pricing_input.treatment_charge + pricing_input.smelting_charge
|
||||
|
||||
moisture_excess = max(0.0, pricing_input.moisture_pct - moisture_threshold)
|
||||
moisture_penalty = moisture_excess * moisture_penalty_factor
|
||||
|
||||
impurity_penalty_total = 0.0
|
||||
for impurity, value in pricing_input.impurity_ppm.items():
|
||||
threshold = impurity_thresholds.get(impurity, 0.0)
|
||||
penalty_factor = impurity_penalty_factors.get(impurity, 0.0)
|
||||
impurity_penalty_total += max(0.0, value - threshold) * penalty_factor
|
||||
|
||||
net_revenue_ref = (
|
||||
gross_revenue_ref - charges - moisture_penalty - impurity_penalty_total
|
||||
)
|
||||
net_revenue_ref += pricing_input.premiums
|
||||
|
||||
net_revenue = net_revenue_ref * pricing_input.fx_rate
|
||||
|
||||
currency_code = require_currency(
|
||||
currency or pricing_input.currency_code,
|
||||
default=applied_metadata.default_currency,
|
||||
)
|
||||
|
||||
return PricingResult(
|
||||
metal=pricing_input.metal,
|
||||
ore_tonnage=pricing_input.ore_tonnage,
|
||||
head_grade_pct=pricing_input.head_grade_pct,
|
||||
recovery_pct=pricing_input.recovery_pct,
|
||||
payable_metal_tonnes=payable_metal,
|
||||
reference_price=pricing_input.reference_price,
|
||||
gross_revenue=gross_revenue_ref,
|
||||
moisture_penalty=moisture_penalty,
|
||||
impurity_penalty=impurity_penalty_total,
|
||||
treatment_smelt_charges=charges,
|
||||
premiums=pricing_input.premiums,
|
||||
net_revenue=net_revenue,
|
||||
currency=currency_code,
|
||||
)
|
||||
676
services/reporting.py
Normal file
676
services/reporting.py
Normal file
@@ -0,0 +1,676 @@
|
||||
from __future__ import annotations
|
||||
|
||||
"""Reporting service layer aggregating deterministic and simulation metrics."""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import date
|
||||
import math
|
||||
from typing import Iterable, Mapping, Sequence
|
||||
|
||||
from models import FinancialCategory, Project, Scenario
|
||||
from services.financial import (
|
||||
CashFlow,
|
||||
ConvergenceError,
|
||||
PaybackNotReachedError,
|
||||
internal_rate_of_return,
|
||||
net_present_value,
|
||||
payback_period,
|
||||
)
|
||||
from services.simulation import (
|
||||
CashFlowSpec,
|
||||
SimulationConfig,
|
||||
SimulationMetric,
|
||||
SimulationResult,
|
||||
run_monte_carlo,
|
||||
)
|
||||
from services.unit_of_work import UnitOfWork
|
||||
|
||||
DEFAULT_DISCOUNT_RATE = 0.1
|
||||
DEFAULT_ITERATIONS = 500
|
||||
DEFAULT_PERCENTILES: tuple[float, float, float] = (5.0, 50.0, 95.0)
|
||||
|
||||
_COST_CATEGORY_SIGNS: Mapping[FinancialCategory, float] = {
|
||||
FinancialCategory.REVENUE: 1.0,
|
||||
FinancialCategory.CAPITAL_EXPENDITURE: -1.0,
|
||||
FinancialCategory.OPERATING_EXPENDITURE: -1.0,
|
||||
FinancialCategory.CONTINGENCY: -1.0,
|
||||
FinancialCategory.OTHER: -1.0,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class IncludeOptions:
|
||||
"""Flags controlling optional sections in report payloads."""
|
||||
|
||||
distribution: bool = False
|
||||
samples: bool = False
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ReportFilters:
|
||||
"""Filter parameters applied when selecting scenarios for a report."""
|
||||
|
||||
scenario_ids: set[int] | None = None
|
||||
start_date: date | None = None
|
||||
end_date: date | None = None
|
||||
|
||||
def matches(self, scenario: Scenario) -> bool:
|
||||
if self.scenario_ids is not None and scenario.id not in self.scenario_ids:
|
||||
return False
|
||||
if self.start_date and scenario.start_date and scenario.start_date < self.start_date:
|
||||
return False
|
||||
if self.end_date and scenario.end_date and scenario.end_date > self.end_date:
|
||||
return False
|
||||
return True
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
payload: dict[str, object] = {}
|
||||
if self.scenario_ids is not None:
|
||||
payload["scenario_ids"] = sorted(self.scenario_ids)
|
||||
if self.start_date is not None:
|
||||
payload["start_date"] = self.start_date
|
||||
if self.end_date is not None:
|
||||
payload["end_date"] = self.end_date
|
||||
return payload
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ScenarioFinancialTotals:
|
||||
currency: str | None
|
||||
inflows: float
|
||||
outflows: float
|
||||
net: float
|
||||
by_category: dict[str, float]
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"currency": self.currency,
|
||||
"inflows": _round_optional(self.inflows),
|
||||
"outflows": _round_optional(self.outflows),
|
||||
"net": _round_optional(self.net),
|
||||
"by_category": {
|
||||
key: _round_optional(value) for key, value in sorted(self.by_category.items())
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ScenarioDeterministicMetrics:
|
||||
currency: str | None
|
||||
discount_rate: float
|
||||
compounds_per_year: int
|
||||
npv: float | None
|
||||
irr: float | None
|
||||
payback_period: float | None
|
||||
notes: list[str] = field(default_factory=list)
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"currency": self.currency,
|
||||
"discount_rate": _round_optional(self.discount_rate, digits=4),
|
||||
"compounds_per_year": self.compounds_per_year,
|
||||
"npv": _round_optional(self.npv),
|
||||
"irr": _round_optional(self.irr, digits=6),
|
||||
"payback_period": _round_optional(self.payback_period, digits=4),
|
||||
"notes": self.notes,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ScenarioMonteCarloResult:
|
||||
available: bool
|
||||
notes: list[str] = field(default_factory=list)
|
||||
result: SimulationResult | None = None
|
||||
include_samples: bool = False
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
if not self.available or self.result is None:
|
||||
return {
|
||||
"available": False,
|
||||
"notes": self.notes,
|
||||
}
|
||||
|
||||
metrics: dict[str, dict[str, object]] = {}
|
||||
for metric, summary in self.result.summaries.items():
|
||||
metrics[metric.value] = {
|
||||
"mean": _round_optional(summary.mean),
|
||||
"std_dev": _round_optional(summary.std_dev),
|
||||
"minimum": _round_optional(summary.minimum),
|
||||
"maximum": _round_optional(summary.maximum),
|
||||
"percentiles": {
|
||||
f"{percentile:g}": _round_optional(value)
|
||||
for percentile, value in sorted(summary.percentiles.items())
|
||||
},
|
||||
"sample_size": summary.sample_size,
|
||||
"failed_runs": summary.failed_runs,
|
||||
}
|
||||
|
||||
samples_payload: dict[str, list[float | None]] | None = None
|
||||
if self.include_samples and self.result.samples:
|
||||
samples_payload = {}
|
||||
for metric, samples in self.result.samples.items():
|
||||
samples_payload[metric.value] = [
|
||||
_sanitize_float(sample) for sample in samples.tolist()
|
||||
]
|
||||
|
||||
payload: dict[str, object] = {
|
||||
"available": True,
|
||||
"iterations": self.result.iterations,
|
||||
"metrics": metrics,
|
||||
"notes": self.notes,
|
||||
}
|
||||
if samples_payload:
|
||||
payload["samples"] = samples_payload
|
||||
return payload
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ScenarioReport:
|
||||
scenario: Scenario
|
||||
totals: ScenarioFinancialTotals
|
||||
deterministic: ScenarioDeterministicMetrics
|
||||
monte_carlo: ScenarioMonteCarloResult | None
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
scenario_info = {
|
||||
"id": self.scenario.id,
|
||||
"project_id": self.scenario.project_id,
|
||||
"name": self.scenario.name,
|
||||
"description": self.scenario.description,
|
||||
"status": self.scenario.status.value,
|
||||
"start_date": self.scenario.start_date,
|
||||
"end_date": self.scenario.end_date,
|
||||
"currency": self.scenario.currency,
|
||||
"primary_resource": self.scenario.primary_resource.value
|
||||
if self.scenario.primary_resource
|
||||
else None,
|
||||
"discount_rate": _round_optional(self.deterministic.discount_rate, digits=4),
|
||||
"created_at": self.scenario.created_at,
|
||||
"updated_at": self.scenario.updated_at,
|
||||
"simulation_parameter_count": len(self.scenario.simulation_parameters or []),
|
||||
}
|
||||
payload: dict[str, object] = {
|
||||
"scenario": scenario_info,
|
||||
"financials": self.totals.to_dict(),
|
||||
"metrics": self.deterministic.to_dict(),
|
||||
}
|
||||
if self.monte_carlo is not None:
|
||||
payload["monte_carlo"] = self.monte_carlo.to_dict()
|
||||
return payload
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class AggregatedMetric:
|
||||
average: float | None
|
||||
minimum: float | None
|
||||
maximum: float | None
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"average": _round_optional(self.average),
|
||||
"minimum": _round_optional(self.minimum),
|
||||
"maximum": _round_optional(self.maximum),
|
||||
}
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ProjectAggregates:
|
||||
total_inflows: float
|
||||
total_outflows: float
|
||||
total_net: float
|
||||
deterministic_metrics: dict[str, AggregatedMetric]
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"financials": {
|
||||
"total_inflows": _round_optional(self.total_inflows),
|
||||
"total_outflows": _round_optional(self.total_outflows),
|
||||
"total_net": _round_optional(self.total_net),
|
||||
},
|
||||
"deterministic_metrics": {
|
||||
metric: data.to_dict()
|
||||
for metric, data in sorted(self.deterministic_metrics.items())
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class MetricComparison:
|
||||
metric: str
|
||||
direction: str
|
||||
best: tuple[int, str, float] | None
|
||||
worst: tuple[int, str, float] | None
|
||||
average: float | None
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"metric": self.metric,
|
||||
"direction": self.direction,
|
||||
"best": _comparison_entry(self.best),
|
||||
"worst": _comparison_entry(self.worst),
|
||||
"average": _round_optional(self.average),
|
||||
}
|
||||
|
||||
|
||||
def parse_include_tokens(raw: str | None) -> IncludeOptions:
|
||||
tokens: set[str] = set()
|
||||
if raw:
|
||||
for part in raw.split(","):
|
||||
token = part.strip().lower()
|
||||
if token:
|
||||
tokens.add(token)
|
||||
if "all" in tokens:
|
||||
return IncludeOptions(distribution=True, samples=True)
|
||||
return IncludeOptions(
|
||||
distribution=bool({"distribution", "monte_carlo", "mc"} & tokens),
|
||||
samples="samples" in tokens,
|
||||
)
|
||||
|
||||
|
||||
def validate_percentiles(values: Sequence[float] | None) -> tuple[float, ...]:
|
||||
if not values:
|
||||
return DEFAULT_PERCENTILES
|
||||
seen: set[float] = set()
|
||||
cleaned: list[float] = []
|
||||
for value in values:
|
||||
percentile = float(value)
|
||||
if percentile < 0.0 or percentile > 100.0:
|
||||
raise ValueError("Percentiles must be between 0 and 100.")
|
||||
if percentile not in seen:
|
||||
seen.add(percentile)
|
||||
cleaned.append(percentile)
|
||||
if not cleaned:
|
||||
return DEFAULT_PERCENTILES
|
||||
return tuple(cleaned)
|
||||
|
||||
|
||||
class ReportingService:
|
||||
"""Coordinates project and scenario reporting aggregation."""
|
||||
|
||||
def __init__(self, uow: UnitOfWork) -> None:
|
||||
self._uow = uow
|
||||
|
||||
def project_summary(
|
||||
self,
|
||||
project: Project,
|
||||
*,
|
||||
filters: ReportFilters,
|
||||
include: IncludeOptions,
|
||||
iterations: int,
|
||||
percentiles: tuple[float, ...],
|
||||
) -> dict[str, object]:
|
||||
scenarios = self._load_scenarios(project.id, filters)
|
||||
reports = [
|
||||
self._build_scenario_report(
|
||||
scenario,
|
||||
include_distribution=include.distribution,
|
||||
include_samples=include.samples,
|
||||
iterations=iterations,
|
||||
percentiles=percentiles,
|
||||
)
|
||||
for scenario in scenarios
|
||||
]
|
||||
aggregates = self._aggregate_project(reports)
|
||||
return {
|
||||
"project": _project_payload(project),
|
||||
"scenario_count": len(reports),
|
||||
"filters": filters.to_dict(),
|
||||
"aggregates": aggregates.to_dict(),
|
||||
"scenarios": [report.to_dict() for report in reports],
|
||||
}
|
||||
|
||||
def scenario_comparison(
|
||||
self,
|
||||
project: Project,
|
||||
scenarios: Sequence[Scenario],
|
||||
*,
|
||||
include: IncludeOptions,
|
||||
iterations: int,
|
||||
percentiles: tuple[float, ...],
|
||||
) -> dict[str, object]:
|
||||
reports = [
|
||||
self._build_scenario_report(
|
||||
self._reload_scenario(scenario.id),
|
||||
include_distribution=include.distribution,
|
||||
include_samples=include.samples,
|
||||
iterations=iterations,
|
||||
percentiles=percentiles,
|
||||
)
|
||||
for scenario in scenarios
|
||||
]
|
||||
comparison = {
|
||||
metric: data.to_dict()
|
||||
for metric, data in self._build_comparisons(reports).items()
|
||||
}
|
||||
return {
|
||||
"project": _project_payload(project),
|
||||
"scenarios": [report.to_dict() for report in reports],
|
||||
"comparison": comparison,
|
||||
}
|
||||
|
||||
def scenario_distribution(
|
||||
self,
|
||||
scenario: Scenario,
|
||||
*,
|
||||
include: IncludeOptions,
|
||||
iterations: int,
|
||||
percentiles: tuple[float, ...],
|
||||
) -> dict[str, object]:
|
||||
report = self._build_scenario_report(
|
||||
self._reload_scenario(scenario.id),
|
||||
include_distribution=True,
|
||||
include_samples=include.samples,
|
||||
iterations=iterations,
|
||||
percentiles=percentiles,
|
||||
)
|
||||
return {
|
||||
"scenario": report.to_dict()["scenario"],
|
||||
"summary": report.totals.to_dict(),
|
||||
"metrics": report.deterministic.to_dict(),
|
||||
"monte_carlo": (
|
||||
report.monte_carlo.to_dict() if report.monte_carlo else {
|
||||
"available": False}
|
||||
),
|
||||
}
|
||||
|
||||
def _load_scenarios(self, project_id: int, filters: ReportFilters) -> list[Scenario]:
|
||||
repo = self._require_scenario_repo()
|
||||
scenarios = repo.list_for_project(project_id, with_children=True)
|
||||
return [scenario for scenario in scenarios if filters.matches(scenario)]
|
||||
|
||||
def _reload_scenario(self, scenario_id: int) -> Scenario:
|
||||
repo = self._require_scenario_repo()
|
||||
return repo.get(scenario_id, with_children=True)
|
||||
|
||||
def _build_scenario_report(
|
||||
self,
|
||||
scenario: Scenario,
|
||||
*,
|
||||
include_distribution: bool,
|
||||
include_samples: bool,
|
||||
iterations: int,
|
||||
percentiles: tuple[float, ...],
|
||||
) -> ScenarioReport:
|
||||
cash_flows, totals = _build_cash_flows(scenario)
|
||||
deterministic = _calculate_deterministic_metrics(
|
||||
scenario, cash_flows, totals)
|
||||
monte_carlo: ScenarioMonteCarloResult | None = None
|
||||
if include_distribution:
|
||||
monte_carlo = _run_monte_carlo(
|
||||
scenario,
|
||||
cash_flows,
|
||||
include_samples=include_samples,
|
||||
iterations=iterations,
|
||||
percentiles=percentiles,
|
||||
)
|
||||
return ScenarioReport(
|
||||
scenario=scenario,
|
||||
totals=totals,
|
||||
deterministic=deterministic,
|
||||
monte_carlo=monte_carlo,
|
||||
)
|
||||
|
||||
def _aggregate_project(self, reports: Sequence[ScenarioReport]) -> ProjectAggregates:
|
||||
total_inflows = sum(report.totals.inflows for report in reports)
|
||||
total_outflows = sum(report.totals.outflows for report in reports)
|
||||
total_net = sum(report.totals.net for report in reports)
|
||||
|
||||
metrics: dict[str, AggregatedMetric] = {}
|
||||
for metric_name in ("npv", "irr", "payback_period"):
|
||||
values = [
|
||||
getattr(report.deterministic, metric_name)
|
||||
for report in reports
|
||||
if getattr(report.deterministic, metric_name) is not None
|
||||
]
|
||||
if values:
|
||||
metrics[metric_name] = AggregatedMetric(
|
||||
average=sum(values) / len(values),
|
||||
minimum=min(values),
|
||||
maximum=max(values),
|
||||
)
|
||||
return ProjectAggregates(
|
||||
total_inflows=total_inflows,
|
||||
total_outflows=total_outflows,
|
||||
total_net=total_net,
|
||||
deterministic_metrics=metrics,
|
||||
)
|
||||
|
||||
def _build_comparisons(
|
||||
self, reports: Sequence[ScenarioReport]
|
||||
) -> Mapping[str, MetricComparison]:
|
||||
comparisons: dict[str, MetricComparison] = {}
|
||||
for metric_name, direction in (
|
||||
("npv", "higher_is_better"),
|
||||
("irr", "higher_is_better"),
|
||||
("payback_period", "lower_is_better"),
|
||||
):
|
||||
entries: list[tuple[int, str, float]] = []
|
||||
for report in reports:
|
||||
value = getattr(report.deterministic, metric_name)
|
||||
if value is None:
|
||||
continue
|
||||
entries.append(
|
||||
(report.scenario.id, report.scenario.name, value))
|
||||
if not entries:
|
||||
continue
|
||||
if direction == "higher_is_better":
|
||||
best = max(entries, key=lambda item: item[2])
|
||||
worst = min(entries, key=lambda item: item[2])
|
||||
else:
|
||||
best = min(entries, key=lambda item: item[2])
|
||||
worst = max(entries, key=lambda item: item[2])
|
||||
average = sum(item[2] for item in entries) / len(entries)
|
||||
comparisons[metric_name] = MetricComparison(
|
||||
metric=metric_name,
|
||||
direction=direction,
|
||||
best=best,
|
||||
worst=worst,
|
||||
average=average,
|
||||
)
|
||||
return comparisons
|
||||
|
||||
def _require_scenario_repo(self):
|
||||
if not self._uow.scenarios:
|
||||
raise RuntimeError("Scenario repository not initialised")
|
||||
return self._uow.scenarios
|
||||
|
||||
|
||||
def _build_cash_flows(scenario: Scenario) -> tuple[list[CashFlow], ScenarioFinancialTotals]:
|
||||
cash_flows: list[CashFlow] = []
|
||||
by_category: dict[str, float] = {}
|
||||
inflows = 0.0
|
||||
outflows = 0.0
|
||||
net = 0.0
|
||||
period_index = 0
|
||||
|
||||
for financial_input in scenario.financial_inputs or []:
|
||||
sign = _COST_CATEGORY_SIGNS.get(financial_input.category, -1.0)
|
||||
amount = float(financial_input.amount) * sign
|
||||
net += amount
|
||||
if amount >= 0:
|
||||
inflows += amount
|
||||
else:
|
||||
outflows += -amount
|
||||
by_category.setdefault(financial_input.category.value, 0.0)
|
||||
by_category[financial_input.category.value] += amount
|
||||
|
||||
if financial_input.effective_date is not None:
|
||||
cash_flows.append(
|
||||
CashFlow(amount=amount, date=financial_input.effective_date)
|
||||
)
|
||||
else:
|
||||
cash_flows.append(
|
||||
CashFlow(amount=amount, period_index=period_index))
|
||||
period_index += 1
|
||||
|
||||
currency = scenario.currency
|
||||
if currency is None and scenario.financial_inputs:
|
||||
currency = scenario.financial_inputs[0].currency
|
||||
|
||||
totals = ScenarioFinancialTotals(
|
||||
currency=currency,
|
||||
inflows=inflows,
|
||||
outflows=outflows,
|
||||
net=net,
|
||||
by_category=by_category,
|
||||
)
|
||||
return cash_flows, totals
|
||||
|
||||
|
||||
def _calculate_deterministic_metrics(
|
||||
scenario: Scenario,
|
||||
cash_flows: Sequence[CashFlow],
|
||||
totals: ScenarioFinancialTotals,
|
||||
) -> ScenarioDeterministicMetrics:
|
||||
notes: list[str] = []
|
||||
discount_rate = _normalise_discount_rate(scenario.discount_rate)
|
||||
if scenario.discount_rate is None:
|
||||
notes.append(
|
||||
f"Discount rate not set; defaulted to {discount_rate:.2%}."
|
||||
)
|
||||
|
||||
if not cash_flows:
|
||||
notes.append(
|
||||
"No financial inputs available for deterministic metrics.")
|
||||
return ScenarioDeterministicMetrics(
|
||||
currency=totals.currency,
|
||||
discount_rate=discount_rate,
|
||||
compounds_per_year=1,
|
||||
npv=None,
|
||||
irr=None,
|
||||
payback_period=None,
|
||||
notes=notes,
|
||||
)
|
||||
|
||||
npv_value: float | None
|
||||
try:
|
||||
npv_value = net_present_value(
|
||||
discount_rate,
|
||||
cash_flows,
|
||||
compounds_per_year=1,
|
||||
)
|
||||
except ValueError as exc:
|
||||
npv_value = None
|
||||
notes.append(f"NPV unavailable: {exc}.")
|
||||
|
||||
irr_value: float | None
|
||||
try:
|
||||
irr_value = internal_rate_of_return(
|
||||
cash_flows,
|
||||
compounds_per_year=1,
|
||||
)
|
||||
except (ValueError, ConvergenceError) as exc:
|
||||
irr_value = None
|
||||
notes.append(f"IRR unavailable: {exc}.")
|
||||
|
||||
payback_value: float | None
|
||||
try:
|
||||
payback_value = payback_period(
|
||||
cash_flows,
|
||||
compounds_per_year=1,
|
||||
)
|
||||
except (ValueError, PaybackNotReachedError) as exc:
|
||||
payback_value = None
|
||||
notes.append(f"Payback period unavailable: {exc}.")
|
||||
|
||||
return ScenarioDeterministicMetrics(
|
||||
currency=totals.currency,
|
||||
discount_rate=discount_rate,
|
||||
compounds_per_year=1,
|
||||
npv=npv_value,
|
||||
irr=irr_value,
|
||||
payback_period=payback_value,
|
||||
notes=notes,
|
||||
)
|
||||
|
||||
|
||||
def _run_monte_carlo(
|
||||
scenario: Scenario,
|
||||
cash_flows: Sequence[CashFlow],
|
||||
*,
|
||||
include_samples: bool,
|
||||
iterations: int,
|
||||
percentiles: tuple[float, ...],
|
||||
) -> ScenarioMonteCarloResult:
|
||||
if not cash_flows:
|
||||
return ScenarioMonteCarloResult(
|
||||
available=False,
|
||||
notes=["No financial inputs available for Monte Carlo simulation."],
|
||||
)
|
||||
|
||||
discount_rate = _normalise_discount_rate(scenario.discount_rate)
|
||||
specs = [CashFlowSpec(cash_flow=flow) for flow in cash_flows]
|
||||
notes: list[str] = []
|
||||
if not scenario.simulation_parameters:
|
||||
notes.append(
|
||||
"Scenario has no stochastic parameters; simulation mirrors deterministic cash flows."
|
||||
)
|
||||
config = SimulationConfig(
|
||||
iterations=iterations,
|
||||
discount_rate=discount_rate,
|
||||
metrics=(
|
||||
SimulationMetric.NPV,
|
||||
SimulationMetric.IRR,
|
||||
SimulationMetric.PAYBACK,
|
||||
),
|
||||
percentiles=percentiles,
|
||||
return_samples=include_samples,
|
||||
)
|
||||
try:
|
||||
result = run_monte_carlo(specs, config)
|
||||
except Exception as exc: # pragma: no cover - safeguard for unexpected failures
|
||||
notes.append(f"Simulation failed: {exc}.")
|
||||
return ScenarioMonteCarloResult(available=False, notes=notes)
|
||||
return ScenarioMonteCarloResult(
|
||||
available=True,
|
||||
notes=notes,
|
||||
result=result,
|
||||
include_samples=include_samples,
|
||||
)
|
||||
|
||||
|
||||
def _normalise_discount_rate(value: float | None) -> float:
|
||||
if value is None:
|
||||
return DEFAULT_DISCOUNT_RATE
|
||||
rate = float(value)
|
||||
if rate > 1.0:
|
||||
return rate / 100.0
|
||||
return rate
|
||||
|
||||
|
||||
def _sanitize_float(value: float | None) -> float | None:
|
||||
if value is None:
|
||||
return None
|
||||
if math.isnan(value) or math.isinf(value):
|
||||
return None
|
||||
return float(value)
|
||||
|
||||
|
||||
def _round_optional(value: float | None, *, digits: int = 2) -> float | None:
|
||||
clean = _sanitize_float(value)
|
||||
if clean is None:
|
||||
return None
|
||||
return round(clean, digits)
|
||||
|
||||
|
||||
def _comparison_entry(entry: tuple[int, str, float] | None) -> dict[str, object] | None:
|
||||
if entry is None:
|
||||
return None
|
||||
scenario_id, name, value = entry
|
||||
return {
|
||||
"scenario_id": scenario_id,
|
||||
"name": name,
|
||||
"value": _round_optional(value),
|
||||
}
|
||||
|
||||
|
||||
def _project_payload(project: Project) -> dict[str, object]:
|
||||
return {
|
||||
"id": project.id,
|
||||
"name": project.name,
|
||||
"location": project.location,
|
||||
"operation_type": project.operation_type.value,
|
||||
"description": project.description,
|
||||
"created_at": project.created_at,
|
||||
"updated_at": project.updated_at,
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Mapping, Sequence
|
||||
|
||||
@@ -11,6 +12,9 @@ from sqlalchemy.orm import Session, joinedload, selectinload
|
||||
from models import (
|
||||
FinancialInput,
|
||||
Project,
|
||||
PricingImpuritySettings,
|
||||
PricingMetalSettings,
|
||||
PricingSettings,
|
||||
ResourceType,
|
||||
Role,
|
||||
Scenario,
|
||||
@@ -21,6 +25,7 @@ from models import (
|
||||
)
|
||||
from services.exceptions import EntityConflictError, EntityNotFoundError
|
||||
from services.export_query import ProjectExportFilters, ScenarioExportFilters
|
||||
from services.pricing import PricingMetadata
|
||||
|
||||
|
||||
class ProjectRepository:
|
||||
@@ -29,10 +34,17 @@ class ProjectRepository:
|
||||
def __init__(self, session: Session) -> None:
|
||||
self.session = session
|
||||
|
||||
def list(self, *, with_children: bool = False) -> Sequence[Project]:
|
||||
def list(
|
||||
self,
|
||||
*,
|
||||
with_children: bool = False,
|
||||
with_pricing: bool = False,
|
||||
) -> Sequence[Project]:
|
||||
stmt = select(Project).order_by(Project.created_at)
|
||||
if with_children:
|
||||
stmt = stmt.options(selectinload(Project.scenarios))
|
||||
if with_pricing:
|
||||
stmt = stmt.options(selectinload(Project.pricing_settings))
|
||||
return self.session.execute(stmt).scalars().all()
|
||||
|
||||
def count(self) -> int:
|
||||
@@ -47,10 +59,18 @@ class ProjectRepository:
|
||||
)
|
||||
return self.session.execute(stmt).scalars().all()
|
||||
|
||||
def get(self, project_id: int, *, with_children: bool = False) -> Project:
|
||||
def get(
|
||||
self,
|
||||
project_id: int,
|
||||
*,
|
||||
with_children: bool = False,
|
||||
with_pricing: bool = False,
|
||||
) -> Project:
|
||||
stmt = select(Project).where(Project.id == project_id)
|
||||
if with_children:
|
||||
stmt = stmt.options(joinedload(Project.scenarios))
|
||||
if with_pricing:
|
||||
stmt = stmt.options(joinedload(Project.pricing_settings))
|
||||
result = self.session.execute(stmt)
|
||||
if with_children:
|
||||
result = result.unique()
|
||||
@@ -86,10 +106,13 @@ class ProjectRepository:
|
||||
filters: ProjectExportFilters | None = None,
|
||||
*,
|
||||
include_scenarios: bool = False,
|
||||
include_pricing: bool = False,
|
||||
) -> Sequence[Project]:
|
||||
stmt = select(Project)
|
||||
if include_scenarios:
|
||||
stmt = stmt.options(selectinload(Project.scenarios))
|
||||
if include_pricing:
|
||||
stmt = stmt.options(selectinload(Project.pricing_settings))
|
||||
|
||||
if filters:
|
||||
ids = filters.normalised_ids()
|
||||
@@ -131,6 +154,18 @@ class ProjectRepository:
|
||||
project = self.get(project_id)
|
||||
self.session.delete(project)
|
||||
|
||||
def set_pricing_settings(
|
||||
self,
|
||||
project: Project,
|
||||
pricing_settings: PricingSettings | None,
|
||||
) -> Project:
|
||||
project.pricing_settings = pricing_settings
|
||||
project.pricing_settings_id = (
|
||||
pricing_settings.id if pricing_settings is not None else None
|
||||
)
|
||||
self.session.flush()
|
||||
return project
|
||||
|
||||
|
||||
class ScenarioRepository:
|
||||
"""Persistence operations for Scenario entities."""
|
||||
@@ -138,13 +173,26 @@ class ScenarioRepository:
|
||||
def __init__(self, session: Session) -> None:
|
||||
self.session = session
|
||||
|
||||
def list_for_project(self, project_id: int) -> Sequence[Scenario]:
|
||||
def list_for_project(
|
||||
self,
|
||||
project_id: int,
|
||||
*,
|
||||
with_children: bool = False,
|
||||
) -> Sequence[Scenario]:
|
||||
stmt = (
|
||||
select(Scenario)
|
||||
.where(Scenario.project_id == project_id)
|
||||
.order_by(Scenario.created_at)
|
||||
)
|
||||
return self.session.execute(stmt).scalars().all()
|
||||
if with_children:
|
||||
stmt = stmt.options(
|
||||
selectinload(Scenario.financial_inputs),
|
||||
selectinload(Scenario.simulation_parameters),
|
||||
)
|
||||
result = self.session.execute(stmt)
|
||||
if with_children:
|
||||
result = result.unique()
|
||||
return result.scalars().all()
|
||||
|
||||
def count(self) -> int:
|
||||
stmt = select(func.count(Scenario.id))
|
||||
@@ -376,6 +424,101 @@ class SimulationParameterRepository:
|
||||
self.session.delete(entity)
|
||||
|
||||
|
||||
class PricingSettingsRepository:
|
||||
"""Persistence operations for pricing configuration entities."""
|
||||
|
||||
def __init__(self, session: Session) -> None:
|
||||
self.session = session
|
||||
|
||||
def list(self, *, include_children: bool = False) -> Sequence[PricingSettings]:
|
||||
stmt = select(PricingSettings).order_by(PricingSettings.created_at)
|
||||
if include_children:
|
||||
stmt = stmt.options(
|
||||
selectinload(PricingSettings.metal_overrides),
|
||||
selectinload(PricingSettings.impurity_overrides),
|
||||
)
|
||||
result = self.session.execute(stmt)
|
||||
if include_children:
|
||||
result = result.unique()
|
||||
return result.scalars().all()
|
||||
|
||||
def get(self, settings_id: int, *, include_children: bool = False) -> PricingSettings:
|
||||
stmt = select(PricingSettings).where(PricingSettings.id == settings_id)
|
||||
if include_children:
|
||||
stmt = stmt.options(
|
||||
selectinload(PricingSettings.metal_overrides),
|
||||
selectinload(PricingSettings.impurity_overrides),
|
||||
)
|
||||
result = self.session.execute(stmt)
|
||||
if include_children:
|
||||
result = result.unique()
|
||||
settings = result.scalar_one_or_none()
|
||||
if settings is None:
|
||||
raise EntityNotFoundError(
|
||||
f"Pricing settings {settings_id} not found")
|
||||
return settings
|
||||
|
||||
def find_by_slug(
|
||||
self,
|
||||
slug: str,
|
||||
*,
|
||||
include_children: bool = False,
|
||||
) -> PricingSettings | None:
|
||||
normalised = slug.strip().lower()
|
||||
stmt = select(PricingSettings).where(
|
||||
PricingSettings.slug == normalised)
|
||||
if include_children:
|
||||
stmt = stmt.options(
|
||||
selectinload(PricingSettings.metal_overrides),
|
||||
selectinload(PricingSettings.impurity_overrides),
|
||||
)
|
||||
result = self.session.execute(stmt)
|
||||
if include_children:
|
||||
result = result.unique()
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
def get_by_slug(self, slug: str, *, include_children: bool = False) -> PricingSettings:
|
||||
settings = self.find_by_slug(slug, include_children=include_children)
|
||||
if settings is None:
|
||||
raise EntityNotFoundError(
|
||||
f"Pricing settings slug '{slug}' not found"
|
||||
)
|
||||
return settings
|
||||
|
||||
def create(self, settings: PricingSettings) -> PricingSettings:
|
||||
self.session.add(settings)
|
||||
try:
|
||||
self.session.flush()
|
||||
except IntegrityError as exc: # pragma: no cover - relies on DB constraints
|
||||
raise EntityConflictError(
|
||||
"Pricing settings violates constraints") from exc
|
||||
return settings
|
||||
|
||||
def delete(self, settings_id: int) -> None:
|
||||
settings = self.get(settings_id, include_children=True)
|
||||
self.session.delete(settings)
|
||||
|
||||
def attach_metal_override(
|
||||
self,
|
||||
settings: PricingSettings,
|
||||
override: PricingMetalSettings,
|
||||
) -> PricingMetalSettings:
|
||||
settings.metal_overrides.append(override)
|
||||
self.session.add(override)
|
||||
self.session.flush()
|
||||
return override
|
||||
|
||||
def attach_impurity_override(
|
||||
self,
|
||||
settings: PricingSettings,
|
||||
override: PricingImpuritySettings,
|
||||
) -> PricingImpuritySettings:
|
||||
settings.impurity_overrides.append(override)
|
||||
self.session.add(override)
|
||||
self.session.flush()
|
||||
return override
|
||||
|
||||
|
||||
class RoleRepository:
|
||||
"""Persistence operations for Role entities."""
|
||||
|
||||
@@ -507,6 +650,159 @@ class UserRepository:
|
||||
self.session.flush()
|
||||
|
||||
|
||||
DEFAULT_PRICING_SETTINGS_NAME = "Default Pricing Settings"
|
||||
DEFAULT_PRICING_SETTINGS_DESCRIPTION = (
|
||||
"Default pricing configuration generated from environment metadata."
|
||||
)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class PricingSettingsSeedResult:
|
||||
settings: PricingSettings
|
||||
created: bool
|
||||
updated_fields: int
|
||||
impurity_upserts: int
|
||||
|
||||
|
||||
def ensure_default_pricing_settings(
|
||||
repo: PricingSettingsRepository,
|
||||
*,
|
||||
metadata: PricingMetadata,
|
||||
slug: str = "default",
|
||||
name: str | None = None,
|
||||
description: str | None = None,
|
||||
) -> PricingSettingsSeedResult:
|
||||
"""Ensure a baseline pricing settings record exists and matches metadata defaults."""
|
||||
|
||||
normalised_slug = (slug or "default").strip().lower() or "default"
|
||||
target_name = name or DEFAULT_PRICING_SETTINGS_NAME
|
||||
target_description = description or DEFAULT_PRICING_SETTINGS_DESCRIPTION
|
||||
|
||||
updated_fields = 0
|
||||
impurity_upserts = 0
|
||||
|
||||
try:
|
||||
settings = repo.get_by_slug(normalised_slug, include_children=True)
|
||||
created = False
|
||||
except EntityNotFoundError:
|
||||
settings = PricingSettings(
|
||||
name=target_name,
|
||||
slug=normalised_slug,
|
||||
description=target_description,
|
||||
default_currency=metadata.default_currency,
|
||||
default_payable_pct=metadata.default_payable_pct,
|
||||
moisture_threshold_pct=metadata.moisture_threshold_pct,
|
||||
moisture_penalty_per_pct=metadata.moisture_penalty_per_pct,
|
||||
)
|
||||
settings.metadata_payload = None
|
||||
settings = repo.create(settings)
|
||||
created = True
|
||||
else:
|
||||
if settings.name != target_name:
|
||||
settings.name = target_name
|
||||
updated_fields += 1
|
||||
if target_description and settings.description != target_description:
|
||||
settings.description = target_description
|
||||
updated_fields += 1
|
||||
if settings.default_currency != metadata.default_currency:
|
||||
settings.default_currency = metadata.default_currency
|
||||
updated_fields += 1
|
||||
if float(settings.default_payable_pct) != float(metadata.default_payable_pct):
|
||||
settings.default_payable_pct = metadata.default_payable_pct
|
||||
updated_fields += 1
|
||||
if float(settings.moisture_threshold_pct) != float(metadata.moisture_threshold_pct):
|
||||
settings.moisture_threshold_pct = metadata.moisture_threshold_pct
|
||||
updated_fields += 1
|
||||
if float(settings.moisture_penalty_per_pct) != float(metadata.moisture_penalty_per_pct):
|
||||
settings.moisture_penalty_per_pct = metadata.moisture_penalty_per_pct
|
||||
updated_fields += 1
|
||||
|
||||
impurity_thresholds = {
|
||||
code.strip().upper(): float(value)
|
||||
for code, value in (metadata.impurity_thresholds or {}).items()
|
||||
if code.strip()
|
||||
}
|
||||
impurity_penalties = {
|
||||
code.strip().upper(): float(value)
|
||||
for code, value in (metadata.impurity_penalty_per_ppm or {}).items()
|
||||
if code.strip()
|
||||
}
|
||||
|
||||
if impurity_thresholds or impurity_penalties:
|
||||
existing_map = {
|
||||
override.impurity_code: override
|
||||
for override in settings.impurity_overrides
|
||||
}
|
||||
target_codes = set(impurity_thresholds) | set(impurity_penalties)
|
||||
for code in sorted(target_codes):
|
||||
threshold_value = impurity_thresholds.get(code, 0.0)
|
||||
penalty_value = impurity_penalties.get(code, 0.0)
|
||||
existing = existing_map.get(code)
|
||||
if existing is None:
|
||||
repo.attach_impurity_override(
|
||||
settings,
|
||||
PricingImpuritySettings(
|
||||
impurity_code=code,
|
||||
threshold_ppm=threshold_value,
|
||||
penalty_per_ppm=penalty_value,
|
||||
),
|
||||
)
|
||||
impurity_upserts += 1
|
||||
continue
|
||||
changed = False
|
||||
if float(existing.threshold_ppm) != float(threshold_value):
|
||||
existing.threshold_ppm = threshold_value
|
||||
changed = True
|
||||
if float(existing.penalty_per_ppm) != float(penalty_value):
|
||||
existing.penalty_per_ppm = penalty_value
|
||||
changed = True
|
||||
if changed:
|
||||
updated_fields += 1
|
||||
|
||||
if updated_fields > 0 or impurity_upserts > 0:
|
||||
repo.session.flush()
|
||||
|
||||
return PricingSettingsSeedResult(
|
||||
settings=settings,
|
||||
created=created,
|
||||
updated_fields=updated_fields,
|
||||
impurity_upserts=impurity_upserts,
|
||||
)
|
||||
|
||||
|
||||
def pricing_settings_to_metadata(settings: PricingSettings) -> PricingMetadata:
|
||||
"""Convert a persisted pricing settings record into metadata defaults."""
|
||||
|
||||
payload = settings.metadata_payload or {}
|
||||
payload_thresholds = payload.get("impurity_thresholds") or {}
|
||||
payload_penalties = payload.get("impurity_penalty_per_ppm") or {}
|
||||
|
||||
thresholds: dict[str, float] = {
|
||||
code.strip().upper(): float(value)
|
||||
for code, value in payload_thresholds.items()
|
||||
if isinstance(code, str) and code.strip()
|
||||
}
|
||||
penalties: dict[str, float] = {
|
||||
code.strip().upper(): float(value)
|
||||
for code, value in payload_penalties.items()
|
||||
if isinstance(code, str) and code.strip()
|
||||
}
|
||||
|
||||
for override in settings.impurity_overrides:
|
||||
code = override.impurity_code.strip().upper()
|
||||
thresholds[code] = float(override.threshold_ppm)
|
||||
penalties[code] = float(override.penalty_per_ppm)
|
||||
|
||||
return PricingMetadata(
|
||||
default_payable_pct=float(settings.default_payable_pct),
|
||||
default_currency=settings.default_currency,
|
||||
moisture_threshold_pct=float(settings.moisture_threshold_pct),
|
||||
moisture_penalty_per_pct=float(settings.moisture_penalty_per_pct),
|
||||
impurity_thresholds=thresholds,
|
||||
impurity_penalty_per_ppm=penalties,
|
||||
)
|
||||
|
||||
|
||||
DEFAULT_ROLE_DEFINITIONS: tuple[dict[str, str], ...] = (
|
||||
{
|
||||
"name": "admin",
|
||||
|
||||
54
services/scenario_evaluation.py
Normal file
54
services/scenario_evaluation.py
Normal file
@@ -0,0 +1,54 @@
|
||||
from __future__ import annotations
|
||||
|
||||
"""Scenario evaluation services including pricing integration."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Iterable, Mapping
|
||||
|
||||
from models.scenario import Scenario
|
||||
from services.pricing import (
|
||||
PricingInput,
|
||||
PricingMetadata,
|
||||
PricingResult,
|
||||
calculate_pricing,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ScenarioPricingConfig:
|
||||
"""Configuration for pricing evaluation within a scenario."""
|
||||
|
||||
metadata: PricingMetadata | None = None
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ScenarioPricingSnapshot:
|
||||
"""Captured pricing results for a scenario."""
|
||||
|
||||
scenario_id: int
|
||||
results: list[PricingResult]
|
||||
|
||||
|
||||
class ScenarioPricingEvaluator:
|
||||
"""Evaluate scenario profitability inputs using pricing services."""
|
||||
|
||||
def __init__(self, config: ScenarioPricingConfig | None = None) -> None:
|
||||
self._config = config or ScenarioPricingConfig()
|
||||
|
||||
def evaluate(
|
||||
self,
|
||||
scenario: Scenario,
|
||||
*,
|
||||
inputs: Iterable[PricingInput],
|
||||
metadata_override: PricingMetadata | None = None,
|
||||
) -> ScenarioPricingSnapshot:
|
||||
metadata = metadata_override or self._config.metadata
|
||||
results: list[PricingResult] = []
|
||||
for pricing_input in inputs:
|
||||
result = calculate_pricing(
|
||||
pricing_input,
|
||||
metadata=metadata,
|
||||
currency=scenario.currency,
|
||||
)
|
||||
results.append(result)
|
||||
return ScenarioPricingSnapshot(scenario_id=scenario.id, results=results)
|
||||
352
services/simulation.py
Normal file
352
services/simulation.py
Normal file
@@ -0,0 +1,352 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import Any, Dict, Iterable, Mapping, Sequence
|
||||
|
||||
import numpy as np
|
||||
from numpy.random import Generator, default_rng
|
||||
|
||||
from .financial import (
|
||||
CashFlow,
|
||||
ConvergenceError,
|
||||
PaybackNotReachedError,
|
||||
internal_rate_of_return,
|
||||
net_present_value,
|
||||
payback_period,
|
||||
)
|
||||
|
||||
|
||||
class DistributionConfigError(ValueError):
|
||||
"""Raised when a distribution specification is invalid."""
|
||||
|
||||
|
||||
class SimulationMetric(Enum):
|
||||
"""Supported Monte Carlo summary metrics."""
|
||||
|
||||
NPV = "npv"
|
||||
IRR = "irr"
|
||||
PAYBACK = "payback"
|
||||
|
||||
|
||||
class DistributionType(Enum):
|
||||
"""Supported probability distribution families."""
|
||||
|
||||
NORMAL = "normal"
|
||||
LOGNORMAL = "lognormal"
|
||||
TRIANGULAR = "triangular"
|
||||
DISCRETE = "discrete"
|
||||
|
||||
|
||||
class DistributionSource(Enum):
|
||||
"""Origins for parameter values when sourcing dynamically."""
|
||||
|
||||
STATIC = "static"
|
||||
SCENARIO_FIELD = "scenario_field"
|
||||
METADATA_KEY = "metadata_key"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DistributionSpec:
|
||||
"""Defines the stochastic behaviour for a single cash flow."""
|
||||
|
||||
type: DistributionType
|
||||
parameters: Mapping[str, Any]
|
||||
source: DistributionSource = DistributionSource.STATIC
|
||||
source_key: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CashFlowSpec:
|
||||
"""Pairs a baseline cash flow with an optional distribution."""
|
||||
|
||||
cash_flow: CashFlow
|
||||
distribution: DistributionSpec | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SimulationConfig:
|
||||
"""Controls Monte Carlo simulation behaviour."""
|
||||
|
||||
iterations: int
|
||||
discount_rate: float
|
||||
seed: int | None = None
|
||||
metrics: Sequence[SimulationMetric] = (
|
||||
SimulationMetric.NPV, SimulationMetric.IRR, SimulationMetric.PAYBACK)
|
||||
percentiles: Sequence[float] = (5.0, 50.0, 95.0)
|
||||
compounds_per_year: int = 1
|
||||
return_samples: bool = False
|
||||
residual_value: float | None = None
|
||||
residual_periods: float | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class MetricSummary:
|
||||
"""Aggregated statistics for a simulated metric."""
|
||||
|
||||
mean: float
|
||||
std_dev: float
|
||||
minimum: float
|
||||
maximum: float
|
||||
percentiles: Mapping[float, float]
|
||||
sample_size: int
|
||||
failed_runs: int
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SimulationResult:
|
||||
"""Monte Carlo output including per-metric summaries."""
|
||||
|
||||
iterations: int
|
||||
summaries: Mapping[SimulationMetric, MetricSummary]
|
||||
samples: Mapping[SimulationMetric, np.ndarray] | None = None
|
||||
|
||||
|
||||
def run_monte_carlo(
|
||||
cash_flows: Sequence[CashFlowSpec],
|
||||
config: SimulationConfig,
|
||||
*,
|
||||
scenario_context: Mapping[str, Any] | None = None,
|
||||
metadata: Mapping[str, Any] | None = None,
|
||||
rng: Generator | None = None,
|
||||
) -> SimulationResult:
|
||||
"""Execute Monte Carlo simulation for the provided cash flows."""
|
||||
|
||||
if config.iterations <= 0:
|
||||
raise ValueError("iterations must be greater than zero")
|
||||
if config.compounds_per_year <= 0:
|
||||
raise ValueError("compounds_per_year must be greater than zero")
|
||||
for pct in config.percentiles:
|
||||
if pct < 0.0 or pct > 100.0:
|
||||
raise ValueError("percentiles must be within [0, 100]")
|
||||
|
||||
generator = rng or default_rng(config.seed)
|
||||
|
||||
metric_arrays: Dict[SimulationMetric, np.ndarray] = {
|
||||
metric: np.empty(config.iterations, dtype=float)
|
||||
for metric in config.metrics
|
||||
}
|
||||
|
||||
for idx in range(config.iterations):
|
||||
iteration_flows = [
|
||||
_realise_cash_flow(
|
||||
spec,
|
||||
generator,
|
||||
scenario_context=scenario_context,
|
||||
metadata=metadata,
|
||||
)
|
||||
for spec in cash_flows
|
||||
]
|
||||
|
||||
if SimulationMetric.NPV in metric_arrays:
|
||||
metric_arrays[SimulationMetric.NPV][idx] = net_present_value(
|
||||
config.discount_rate,
|
||||
iteration_flows,
|
||||
residual_value=config.residual_value,
|
||||
residual_periods=config.residual_periods,
|
||||
compounds_per_year=config.compounds_per_year,
|
||||
)
|
||||
if SimulationMetric.IRR in metric_arrays:
|
||||
try:
|
||||
metric_arrays[SimulationMetric.IRR][idx] = internal_rate_of_return(
|
||||
iteration_flows,
|
||||
compounds_per_year=config.compounds_per_year,
|
||||
)
|
||||
except (ValueError, ConvergenceError):
|
||||
metric_arrays[SimulationMetric.IRR][idx] = np.nan
|
||||
if SimulationMetric.PAYBACK in metric_arrays:
|
||||
try:
|
||||
metric_arrays[SimulationMetric.PAYBACK][idx] = payback_period(
|
||||
iteration_flows,
|
||||
compounds_per_year=config.compounds_per_year,
|
||||
)
|
||||
except (ValueError, PaybackNotReachedError):
|
||||
metric_arrays[SimulationMetric.PAYBACK][idx] = np.nan
|
||||
|
||||
summaries = {
|
||||
metric: _summarise(metric_arrays[metric], config.percentiles)
|
||||
for metric in metric_arrays
|
||||
}
|
||||
|
||||
samples = metric_arrays if config.return_samples else None
|
||||
return SimulationResult(
|
||||
iterations=config.iterations,
|
||||
summaries=summaries,
|
||||
samples=samples,
|
||||
)
|
||||
|
||||
|
||||
def _realise_cash_flow(
|
||||
spec: CashFlowSpec,
|
||||
generator: Generator,
|
||||
*,
|
||||
scenario_context: Mapping[str, Any] | None,
|
||||
metadata: Mapping[str, Any] | None,
|
||||
) -> CashFlow:
|
||||
if spec.distribution is None:
|
||||
return spec.cash_flow
|
||||
|
||||
distribution = spec.distribution
|
||||
base_amount = spec.cash_flow.amount
|
||||
params = _resolve_parameters(
|
||||
distribution,
|
||||
base_amount,
|
||||
scenario_context=scenario_context,
|
||||
metadata=metadata,
|
||||
)
|
||||
sample = _sample_distribution(
|
||||
distribution.type,
|
||||
params,
|
||||
generator,
|
||||
)
|
||||
return CashFlow(
|
||||
amount=float(sample),
|
||||
period_index=spec.cash_flow.period_index,
|
||||
date=spec.cash_flow.date,
|
||||
)
|
||||
|
||||
|
||||
def _resolve_parameters(
|
||||
distribution: DistributionSpec,
|
||||
base_amount: float,
|
||||
*,
|
||||
scenario_context: Mapping[str, Any] | None,
|
||||
metadata: Mapping[str, Any] | None,
|
||||
) -> Dict[str, Any]:
|
||||
params = dict(distribution.parameters)
|
||||
|
||||
if distribution.source == DistributionSource.SCENARIO_FIELD:
|
||||
if distribution.source_key is None:
|
||||
raise DistributionConfigError(
|
||||
"source_key is required for scenario_field sourcing")
|
||||
if not scenario_context or distribution.source_key not in scenario_context:
|
||||
raise DistributionConfigError(
|
||||
f"scenario field '{distribution.source_key}' not found for distribution"
|
||||
)
|
||||
params.setdefault("mean", float(
|
||||
scenario_context[distribution.source_key]))
|
||||
elif distribution.source == DistributionSource.METADATA_KEY:
|
||||
if distribution.source_key is None:
|
||||
raise DistributionConfigError(
|
||||
"source_key is required for metadata_key sourcing")
|
||||
if not metadata or distribution.source_key not in metadata:
|
||||
raise DistributionConfigError(
|
||||
f"metadata key '{distribution.source_key}' not found for distribution"
|
||||
)
|
||||
params.setdefault("mean", float(metadata[distribution.source_key]))
|
||||
else:
|
||||
params.setdefault("mean", float(base_amount))
|
||||
|
||||
return params
|
||||
|
||||
|
||||
def _sample_distribution(
|
||||
distribution_type: DistributionType,
|
||||
params: Mapping[str, Any],
|
||||
generator: Generator,
|
||||
) -> float:
|
||||
if distribution_type is DistributionType.NORMAL:
|
||||
return _sample_normal(params, generator)
|
||||
if distribution_type is DistributionType.LOGNORMAL:
|
||||
return _sample_lognormal(params, generator)
|
||||
if distribution_type is DistributionType.TRIANGULAR:
|
||||
return _sample_triangular(params, generator)
|
||||
if distribution_type is DistributionType.DISCRETE:
|
||||
return _sample_discrete(params, generator)
|
||||
raise DistributionConfigError(
|
||||
f"Unsupported distribution type: {distribution_type}")
|
||||
|
||||
|
||||
def _sample_normal(params: Mapping[str, Any], generator: Generator) -> float:
|
||||
if "std_dev" not in params:
|
||||
raise DistributionConfigError("normal distribution requires 'std_dev'")
|
||||
std_dev = float(params["std_dev"])
|
||||
if std_dev < 0:
|
||||
raise DistributionConfigError("std_dev must be non-negative")
|
||||
mean = float(params.get("mean", 0.0))
|
||||
if std_dev == 0:
|
||||
return mean
|
||||
return float(generator.normal(loc=mean, scale=std_dev))
|
||||
|
||||
|
||||
def _sample_lognormal(params: Mapping[str, Any], generator: Generator) -> float:
|
||||
if "sigma" not in params:
|
||||
raise DistributionConfigError(
|
||||
"lognormal distribution requires 'sigma'")
|
||||
sigma = float(params["sigma"])
|
||||
if sigma < 0:
|
||||
raise DistributionConfigError("sigma must be non-negative")
|
||||
if "mean" not in params:
|
||||
raise DistributionConfigError(
|
||||
"lognormal distribution requires 'mean' (mu in log space)")
|
||||
mean = float(params["mean"])
|
||||
return float(generator.lognormal(mean=mean, sigma=sigma))
|
||||
|
||||
|
||||
def _sample_triangular(params: Mapping[str, Any], generator: Generator) -> float:
|
||||
required = {"min", "mode", "max"}
|
||||
if not required.issubset(params):
|
||||
missing = ", ".join(sorted(required - params.keys()))
|
||||
raise DistributionConfigError(
|
||||
f"triangular distribution missing parameters: {missing}")
|
||||
left = float(params["min"])
|
||||
mode = float(params["mode"])
|
||||
right = float(params["max"])
|
||||
if not (left <= mode <= right):
|
||||
raise DistributionConfigError(
|
||||
"triangular distribution requires min <= mode <= max")
|
||||
if left == right:
|
||||
return mode
|
||||
return float(generator.triangular(left=left, mode=mode, right=right))
|
||||
|
||||
|
||||
def _sample_discrete(params: Mapping[str, Any], generator: Generator) -> float:
|
||||
values = params.get("values")
|
||||
probabilities = params.get("probabilities")
|
||||
if not isinstance(values, Sequence) or not isinstance(probabilities, Sequence):
|
||||
raise DistributionConfigError(
|
||||
"discrete distribution requires 'values' and 'probabilities' sequences")
|
||||
if len(values) != len(probabilities) or not values:
|
||||
raise DistributionConfigError(
|
||||
"values and probabilities must be non-empty and of equal length")
|
||||
probs = np.array(probabilities, dtype=float)
|
||||
if np.any(probs < 0):
|
||||
raise DistributionConfigError("probabilities must be non-negative")
|
||||
total = probs.sum()
|
||||
if not np.isclose(total, 1.0):
|
||||
raise DistributionConfigError("probabilities must sum to 1.0")
|
||||
probs = probs / total
|
||||
choices = np.array(values, dtype=float)
|
||||
return float(generator.choice(choices, p=probs))
|
||||
|
||||
|
||||
def _summarise(values: np.ndarray, percentiles: Sequence[float]) -> MetricSummary:
|
||||
clean = values[~np.isnan(values)]
|
||||
sample_size = clean.size
|
||||
failed_runs = values.size - sample_size
|
||||
|
||||
if sample_size == 0:
|
||||
percentile_map: Dict[float, float] = {
|
||||
pct: float("nan") for pct in percentiles}
|
||||
return MetricSummary(
|
||||
mean=float("nan"),
|
||||
std_dev=float("nan"),
|
||||
minimum=float("nan"),
|
||||
maximum=float("nan"),
|
||||
percentiles=percentile_map,
|
||||
sample_size=0,
|
||||
failed_runs=failed_runs,
|
||||
)
|
||||
|
||||
percentile_map = {
|
||||
pct: float(np.percentile(clean, pct)) for pct in percentiles
|
||||
}
|
||||
return MetricSummary(
|
||||
mean=float(np.mean(clean)),
|
||||
std_dev=float(np.std(clean, ddof=1)) if sample_size > 1 else 0.0,
|
||||
minimum=float(np.min(clean)),
|
||||
maximum=float(np.max(clean)),
|
||||
percentiles=percentile_map,
|
||||
sample_size=sample_size,
|
||||
failed_runs=failed_runs,
|
||||
)
|
||||
@@ -6,16 +6,21 @@ from typing import Callable, Sequence
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from config.database import SessionLocal
|
||||
from models import Role, Scenario
|
||||
from models import PricingSettings, Project, Role, Scenario
|
||||
from services.pricing import PricingMetadata
|
||||
from services.repositories import (
|
||||
FinancialInputRepository,
|
||||
PricingSettingsRepository,
|
||||
PricingSettingsSeedResult,
|
||||
ProjectRepository,
|
||||
RoleRepository,
|
||||
ScenarioRepository,
|
||||
SimulationParameterRepository,
|
||||
UserRepository,
|
||||
ensure_admin_user as ensure_admin_user_record,
|
||||
ensure_default_pricing_settings,
|
||||
ensure_default_roles,
|
||||
pricing_settings_to_metadata,
|
||||
)
|
||||
from services.scenario_validation import ScenarioComparisonValidator
|
||||
|
||||
@@ -33,6 +38,7 @@ class UnitOfWork(AbstractContextManager["UnitOfWork"]):
|
||||
self.simulation_parameters: SimulationParameterRepository | None = None
|
||||
self.users: UserRepository | None = None
|
||||
self.roles: RoleRepository | None = None
|
||||
self.pricing_settings: PricingSettingsRepository | None = None
|
||||
|
||||
def __enter__(self) -> "UnitOfWork":
|
||||
self.session = self._session_factory()
|
||||
@@ -43,6 +49,7 @@ class UnitOfWork(AbstractContextManager["UnitOfWork"]):
|
||||
self.session)
|
||||
self.users = UserRepository(self.session)
|
||||
self.roles = RoleRepository(self.session)
|
||||
self.pricing_settings = PricingSettingsRepository(self.session)
|
||||
self._scenario_validator = ScenarioComparisonValidator()
|
||||
return self
|
||||
|
||||
@@ -60,6 +67,7 @@ class UnitOfWork(AbstractContextManager["UnitOfWork"]):
|
||||
self.simulation_parameters = None
|
||||
self.users = None
|
||||
self.roles = None
|
||||
self.pricing_settings = None
|
||||
|
||||
def flush(self) -> None:
|
||||
if not self.session:
|
||||
@@ -116,3 +124,45 @@ class UnitOfWork(AbstractContextManager["UnitOfWork"]):
|
||||
username=username,
|
||||
password=password,
|
||||
)
|
||||
|
||||
def ensure_default_pricing_settings(
|
||||
self,
|
||||
*,
|
||||
metadata: PricingMetadata,
|
||||
slug: str = "default",
|
||||
name: str | None = None,
|
||||
description: str | None = None,
|
||||
) -> PricingSettingsSeedResult:
|
||||
if not self.pricing_settings:
|
||||
raise RuntimeError("UnitOfWork session is not initialised")
|
||||
return ensure_default_pricing_settings(
|
||||
self.pricing_settings,
|
||||
metadata=metadata,
|
||||
slug=slug,
|
||||
name=name,
|
||||
description=description,
|
||||
)
|
||||
|
||||
def get_pricing_metadata(
|
||||
self,
|
||||
*,
|
||||
slug: str = "default",
|
||||
) -> PricingMetadata | None:
|
||||
if not self.pricing_settings:
|
||||
raise RuntimeError("UnitOfWork session is not initialised")
|
||||
settings = self.pricing_settings.find_by_slug(
|
||||
slug,
|
||||
include_children=True,
|
||||
)
|
||||
if settings is None:
|
||||
return None
|
||||
return pricing_settings_to_metadata(settings)
|
||||
|
||||
def set_project_pricing_settings(
|
||||
self,
|
||||
project: Project,
|
||||
pricing_settings: PricingSettings | None,
|
||||
) -> Project:
|
||||
if not self.projects:
|
||||
raise RuntimeError("UnitOfWork session is not initialised")
|
||||
return self.projects.set_pricing_settings(project, pricing_settings)
|
||||
|
||||
@@ -1,3 +1,204 @@
|
||||
.report-overview {
|
||||
margin-bottom: 2.5rem;
|
||||
}
|
||||
|
||||
.report-grid {
|
||||
display: grid;
|
||||
gap: 1.5rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
}
|
||||
|
||||
.report-card {
|
||||
background: var(--card);
|
||||
border-radius: var(--radius);
|
||||
padding: 1.5rem;
|
||||
border: 1px solid var(--color-border);
|
||||
box-shadow: 0 12px 30px rgba(4, 7, 14, 0.35);
|
||||
}
|
||||
|
||||
.report-card h2 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.report-section + .report-section {
|
||||
margin-top: 3rem;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.section-header h2 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.section-subtitle {
|
||||
margin: 0.35rem 0 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.metric-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.metric-list.compact {
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.metric-list li {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
font-size: 0.95rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.metric-list strong {
|
||||
font-size: 1.05rem;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.metrics-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
background: rgba(21, 27, 35, 0.6);
|
||||
border-radius: var(--radius-sm);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.metrics-table th,
|
||||
.metrics-table td {
|
||||
padding: 0.65rem 0.9rem;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.metrics-table th {
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.metrics-table tr:last-child td,
|
||||
.metrics-table tr:last-child th {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.definition-list {
|
||||
margin: 0;
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.definition-list div {
|
||||
display: grid;
|
||||
grid-template-columns: 140px 1fr;
|
||||
gap: 0.5rem;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.definition-list dt {
|
||||
color: var(--muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.definition-list dd {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.scenario-card {
|
||||
background: var(--card);
|
||||
border-radius: var(--radius);
|
||||
padding: 1.5rem;
|
||||
border: 1px solid var(--color-border);
|
||||
box-shadow: 0 16px 32px rgba(4, 7, 14, 0.42);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.scenario-card + .scenario-card {
|
||||
margin-top: 1.75rem;
|
||||
}
|
||||
|
||||
.scenario-card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.scenario-card h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.scenario-meta {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.meta-label {
|
||||
display: block;
|
||||
color: var(--muted);
|
||||
font-size: 0.8rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.meta-value {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.scenario-grid {
|
||||
display: grid;
|
||||
gap: 1.25rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
}
|
||||
|
||||
.scenario-panel {
|
||||
background: rgba(15, 20, 27, 0.8);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 1.1rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.scenario-panel h4,
|
||||
.scenario-panel h5 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.note-list {
|
||||
padding-left: 1.1rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.page-actions .button {
|
||||
text-decoration: none;
|
||||
background: transparent;
|
||||
border: 1px solid var(--color-border);
|
||||
padding: 0.6rem 1rem;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
transition: background 0.2s ease, border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.page-actions .button:hover,
|
||||
.page-actions .button:focus {
|
||||
background: rgba(241, 178, 26, 0.14);
|
||||
border-color: var(--brand);
|
||||
}
|
||||
:root {
|
||||
--bg: #0b0f14;
|
||||
--bg-2: #0f141b;
|
||||
|
||||
33
templates/partials/reports/filters_card.html
Normal file
33
templates/partials/reports/filters_card.html
Normal file
@@ -0,0 +1,33 @@
|
||||
{% if filters %}
|
||||
<section class="report-filters">
|
||||
<div class="report-card">
|
||||
<h2>Active Filters</h2>
|
||||
<dl class="definition-list">
|
||||
{% if filters.scenario_ids %}
|
||||
<div>
|
||||
<dt>Scenario IDs</dt>
|
||||
<dd>{{ filters.scenario_ids | join(', ') }}</dd>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if filters.start_date %}
|
||||
<div>
|
||||
<dt>Start Date</dt>
|
||||
<dd>{{ filters.start_date }}</dd>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if filters.end_date %}
|
||||
<div>
|
||||
<dt>End Date</dt>
|
||||
<dd>{{ filters.end_date }}</dd>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if not (filters.scenario_ids or filters.start_date or filters.end_date) %}
|
||||
<div>
|
||||
<dt>Status</dt>
|
||||
<dd>No filters applied</dd>
|
||||
</div>
|
||||
{% endif %}
|
||||
</dl>
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
46
templates/partials/reports/monte_carlo_table.html
Normal file
46
templates/partials/reports/monte_carlo_table.html
Normal file
@@ -0,0 +1,46 @@
|
||||
{% set sorted_metrics = metrics | dictsort %}
|
||||
{% set ns = namespace(percentile_keys=[]) %}
|
||||
{% if percentiles %}
|
||||
{% set ns.percentile_keys = percentiles %}
|
||||
{% elif sorted_metrics %}
|
||||
{% set reference_percentiles = sorted_metrics[0][1].percentiles.keys() | list %}
|
||||
{% set ns.percentile_keys = reference_percentiles %}
|
||||
{% endif %}
|
||||
|
||||
{% if sorted_metrics %}
|
||||
<table class="metrics-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Metric</th>
|
||||
<th scope="col">Mean</th>
|
||||
{% for percentile in ns.percentile_keys %}
|
||||
{% set percentile_label = '%g' % percentile %}
|
||||
<th scope="col">P{{ percentile_label }}</th>
|
||||
{% endfor %}
|
||||
<th scope="col">Std Dev</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for metric_name, summary in sorted_metrics %}
|
||||
<tr>
|
||||
<th scope="row">{{ metric_name | replace('_', ' ') | title }}</th>
|
||||
<td>{{ summary.mean | format_metric(metric_name, currency) }}</td>
|
||||
{% for percentile in ns.percentile_keys %}
|
||||
{% set percentile_key = '%g' % percentile %}
|
||||
{% set percentile_value = summary.percentiles.get(percentile_key) %}
|
||||
<td>
|
||||
{% if percentile_value is not none %}
|
||||
{{ percentile_value | format_metric(metric_name, currency) }}
|
||||
{% else %}
|
||||
—
|
||||
{% endif %}
|
||||
</td>
|
||||
{% endfor %}
|
||||
<td>{{ summary.std_dev | format_metric(metric_name, currency) }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="muted">Monte Carlo metrics are unavailable.</p>
|
||||
{% endif %}
|
||||
56
templates/partials/reports/options_card.html
Normal file
56
templates/partials/reports/options_card.html
Normal file
@@ -0,0 +1,56 @@
|
||||
{% if options %}
|
||||
{% set distribution_enabled = options.distribution %}
|
||||
{% set samples_enabled = options.samples and options.distribution %}
|
||||
{% endif %}
|
||||
|
||||
<section class="report-options">
|
||||
<div class="report-card">
|
||||
<h2>Data Options</h2>
|
||||
<ul class="metric-list compact">
|
||||
<li>
|
||||
<span>Monte Carlo Distribution</span>
|
||||
<strong>
|
||||
{% if options %}
|
||||
{{ distribution_enabled and "Enabled" or "Disabled" }}
|
||||
{% else %}
|
||||
Not requested
|
||||
{% endif %}
|
||||
</strong>
|
||||
</li>
|
||||
<li>
|
||||
<span>Sample Storage</span>
|
||||
<strong>
|
||||
{% if options %}
|
||||
{% if options.samples %}
|
||||
{% if samples_enabled %}
|
||||
Enabled
|
||||
{% else %}
|
||||
Requires distribution
|
||||
{% endif %}
|
||||
{% else %}
|
||||
Disabled
|
||||
{% endif %}
|
||||
{% else %}
|
||||
Not requested
|
||||
{% endif %}
|
||||
</strong>
|
||||
</li>
|
||||
<li>
|
||||
<span>Iterations</span>
|
||||
<strong>{{ iterations }}</strong>
|
||||
</li>
|
||||
<li>
|
||||
<span>Percentiles</span>
|
||||
<strong>
|
||||
{% if percentiles %}
|
||||
{% for percentile in percentiles %}
|
||||
{{ '%g' % percentile }}{% if not loop.last %}, {% endif %}
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
Defaults
|
||||
{% endif %}
|
||||
</strong>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
14
templates/partials/reports/scenario_actions.html
Normal file
14
templates/partials/reports/scenario_actions.html
Normal file
@@ -0,0 +1,14 @@
|
||||
<div class="scenario-actions">
|
||||
<a
|
||||
href="{{ request.url_for('reports.scenario_distribution_page', scenario_id=scenario.id) }}"
|
||||
class="button button-secondary"
|
||||
>
|
||||
View Distribution
|
||||
</a>
|
||||
<a
|
||||
href="{{ request.url_for('reports.scenario_distribution', scenario_id=scenario.id) }}"
|
||||
class="button button-secondary"
|
||||
>
|
||||
Download JSON
|
||||
</a>
|
||||
</div>
|
||||
24
templates/partials/reports_header.html
Normal file
24
templates/partials/reports_header.html
Normal file
@@ -0,0 +1,24 @@
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<h1 class="page-title">{{ title }}</h1>
|
||||
{% if subtitle %}
|
||||
<p class="page-subtitle">{{ subtitle }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if actions %}
|
||||
<div class="page-actions">
|
||||
{% for action in actions %}
|
||||
{% set classes = action.classes or 'button button-secondary' %}
|
||||
<a
|
||||
href="{{ action.href }}"
|
||||
class="{{ classes }}"
|
||||
{% if action.target %}target="{{ action.target }}"{% endif %}
|
||||
{% if action.rel %}rel="{{ action.rel }}"{% endif %}
|
||||
{% if action.download %}download="{{ action.download }}"{% endif %}
|
||||
>
|
||||
{{ action.label }}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</header>
|
||||
205
templates/reports/project_summary.html
Normal file
205
templates/reports/project_summary.html
Normal file
@@ -0,0 +1,205 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Project Summary | CalMiner{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% include "partials/reports_header.html" with context %}
|
||||
|
||||
{% include "partials/reports/options_card.html" with options=include_options iterations=iterations percentiles=percentiles %}
|
||||
{% include "partials/reports/filters_card.html" with filters=filters %}
|
||||
|
||||
<section class="report-overview">
|
||||
<div class="report-grid">
|
||||
<article class="report-card">
|
||||
<h2>Project Details</h2>
|
||||
<dl class="definition-list">
|
||||
<div>
|
||||
<dt>Name</dt>
|
||||
<dd>{{ project.name }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Location</dt>
|
||||
<dd>{{ project.location or "—" }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Operation Type</dt>
|
||||
<dd>{{ project.operation_type | replace("_", " ") | title }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Scenarios</dt>
|
||||
<dd>{{ scenario_count }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Created</dt>
|
||||
<dd>{{ project.created_at | format_datetime }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Updated</dt>
|
||||
<dd>{{ project.updated_at | format_datetime }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</article>
|
||||
|
||||
<article class="report-card">
|
||||
<h2>Financial Summary</h2>
|
||||
<ul class="metric-list">
|
||||
<li>
|
||||
<span>Total Inflows</span>
|
||||
<strong>{{ aggregates.financials.total_inflows | currency_display(project.currency) }}</strong>
|
||||
</li>
|
||||
<li>
|
||||
<span>Total Outflows</span>
|
||||
<strong>{{ aggregates.financials.total_outflows | currency_display(project.currency) }}</strong>
|
||||
</li>
|
||||
<li>
|
||||
<span>Net Cash Flow</span>
|
||||
<strong>{{ aggregates.financials.total_net | currency_display(project.currency) }}</strong>
|
||||
</li>
|
||||
</ul>
|
||||
</article>
|
||||
|
||||
<article class="report-card">
|
||||
<h2>Deterministic Metrics</h2>
|
||||
{% if aggregates.deterministic_metrics %}
|
||||
<table class="metrics-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Metric</th>
|
||||
<th scope="col">Average</th>
|
||||
<th scope="col">Best</th>
|
||||
<th scope="col">Worst</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for key, metric in aggregates.deterministic_metrics.items() %}
|
||||
<tr>
|
||||
<th scope="row">{{ key | replace("_", " ") | title }}</th>
|
||||
<td>{{ metric.average | format_metric(key, project.currency) }}</td>
|
||||
<td>{{ metric.maximum | format_metric(key, project.currency) }}</td>
|
||||
<td>{{ metric.minimum | format_metric(key, project.currency) }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="muted">Deterministic metrics are unavailable for the current filters.</p>
|
||||
{% endif %}
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="report-section">
|
||||
<header class="section-header">
|
||||
<h2>Scenario Breakdown</h2>
|
||||
<p class="section-subtitle">Deterministic metrics and Monte Carlo summaries for each scenario.</p>
|
||||
</header>
|
||||
|
||||
{% if scenarios %}
|
||||
{% for item in scenarios %}
|
||||
<article class="scenario-card">
|
||||
<div class="scenario-card-header">
|
||||
<div>
|
||||
<h3>{{ item.scenario.name }}</h3>
|
||||
<p class="muted">{{ item.scenario.status | title }} · {{ item.scenario.primary_resource or "No primary resource" }}</p>
|
||||
</div>
|
||||
<div class="scenario-meta">
|
||||
<span class="meta-label">Currency</span>
|
||||
<span class="meta-value">{{ item.scenario.currency or project.currency or "—" }}</span>
|
||||
</div>
|
||||
{% include "partials/reports/scenario_actions.html" with scenario=item.scenario %}
|
||||
</div>
|
||||
|
||||
<div class="scenario-grid">
|
||||
<section class="scenario-panel">
|
||||
<h4>Financial Totals</h4>
|
||||
<ul class="metric-list compact">
|
||||
<li>
|
||||
<span>Inflows</span>
|
||||
<strong>{{ item.financials.inflows | currency_display(item.scenario.currency or project.currency) }}</strong>
|
||||
</li>
|
||||
<li>
|
||||
<span>Outflows</span>
|
||||
<strong>{{ item.financials.outflows | currency_display(item.scenario.currency or project.currency) }}</strong>
|
||||
</li>
|
||||
<li>
|
||||
<span>Net</span>
|
||||
<strong>{{ item.financials.net | currency_display(item.scenario.currency or project.currency) }}</strong>
|
||||
</li>
|
||||
</ul>
|
||||
<h5>By Category</h5>
|
||||
{% if item.financials.by_category %}
|
||||
<ul class="metric-list compact">
|
||||
{% for label, value in item.financials.by_category.items() %}
|
||||
<li>
|
||||
<span>{{ label | replace("_", " ") | title }}</span>
|
||||
<strong>{{ value | currency_display(item.scenario.currency or project.currency) }}</strong>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p class="muted">No financial inputs recorded.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<section class="scenario-panel">
|
||||
<h4>Deterministic Metrics</h4>
|
||||
<table class="metrics-table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th scope="row">Discount Rate</th>
|
||||
<td>{{ item.metrics.discount_rate | percentage_display }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">NPV</th>
|
||||
<td>{{ item.metrics.npv | currency_display(item.scenario.currency or project.currency) }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">IRR</th>
|
||||
<td>{{ item.metrics.irr | percentage_display }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Payback Period</th>
|
||||
<td>{{ item.metrics.payback_period | period_display }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
{% if item.metrics.notes %}
|
||||
<ul class="note-list">
|
||||
{% for note in item.metrics.notes %}
|
||||
<li>{{ note }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<section class="scenario-panel">
|
||||
<h4>Monte Carlo Summary</h4>
|
||||
{% if item.monte_carlo and item.monte_carlo.available %}
|
||||
<p class="muted">
|
||||
Iterations: {{ item.monte_carlo.iterations }}
|
||||
{% if percentiles %}
|
||||
· Percentiles:
|
||||
{% for percentile in percentiles %}
|
||||
{{ '%g' % percentile }}{% if not loop.last %}, {% endif %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</p>
|
||||
{% include "partials/reports/monte_carlo_table.html" with metrics=item.monte_carlo.metrics currency=item.scenario.currency or project.currency percentiles=percentiles %}
|
||||
{% else %}
|
||||
<p class="muted">Monte Carlo metrics are unavailable for this scenario.</p>
|
||||
{% if item.monte_carlo and item.monte_carlo.notes %}
|
||||
<ul class="note-list">
|
||||
{% for note in item.monte_carlo.notes %}
|
||||
<li>{{ note }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</section>
|
||||
</div>
|
||||
</article>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<p class="muted">No scenarios match the current filters.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endblock %}
|
||||
166
templates/reports/scenario_comparison.html
Normal file
166
templates/reports/scenario_comparison.html
Normal file
@@ -0,0 +1,166 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Scenario Comparison | CalMiner{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% include "partials/reports_header.html" with context %}
|
||||
|
||||
{% include "partials/reports/options_card.html" with options=include_options iterations=iterations percentiles=percentiles %}
|
||||
<section class="report-filters">
|
||||
<div class="report-card">
|
||||
<h2>Compared Scenarios</h2>
|
||||
<ul class="metric-list compact">
|
||||
{% for item in scenarios %}
|
||||
<li>
|
||||
<span>{{ item.scenario.name }}</span>
|
||||
<strong>#{{ item.scenario.id }}</strong>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="report-overview">
|
||||
<article class="report-card">
|
||||
<h2>Project Details</h2>
|
||||
<dl class="definition-list">
|
||||
<div>
|
||||
<dt>Name</dt>
|
||||
<dd>{{ project.name }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Location</dt>
|
||||
<dd>{{ project.location or "—" }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Operation Type</dt>
|
||||
<dd>{{ project.operation_type | replace("_", " ") | title }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Scenarios Compared</dt>
|
||||
<dd>{{ scenarios | length }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</article>
|
||||
|
||||
<article class="report-card">
|
||||
<h2>Comparison Summary</h2>
|
||||
{% if comparison %}
|
||||
<table class="metrics-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Metric</th>
|
||||
<th scope="col">Direction</th>
|
||||
<th scope="col">Best Performer</th>
|
||||
<th scope="col">Worst Performer</th>
|
||||
<th scope="col">Average</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for key, metric in comparison.items() %}
|
||||
<tr>
|
||||
<th scope="row">{{ key | replace("_", " ") | title }}</th>
|
||||
<td>{{ metric.direction | replace("_", " ") | title }}</td>
|
||||
<td>
|
||||
{% if metric.best %}
|
||||
<strong>{{ metric.best.name }}</strong>
|
||||
<span class="muted">({{ metric.best.value | format_metric(key, project.currency) }})</span>
|
||||
{% else %}
|
||||
—
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if metric.worst %}
|
||||
<strong>{{ metric.worst.name }}</strong>
|
||||
<span class="muted">({{ metric.worst.value | format_metric(key, project.currency) }})</span>
|
||||
{% else %}
|
||||
—
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ metric.average | format_metric(key, project.currency) }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="muted">No deterministic metrics available for comparison.</p>
|
||||
{% endif %}
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="report-section">
|
||||
<header class="section-header">
|
||||
<h2>Scenario Details</h2>
|
||||
<p class="section-subtitle">Each scenario includes deterministic metrics and Monte Carlo summaries.</p>
|
||||
</header>
|
||||
|
||||
{% for item in scenarios %}
|
||||
<article class="scenario-card">
|
||||
<div class="scenario-card-header">
|
||||
<div>
|
||||
<h3>{{ item.scenario.name }}</h3>
|
||||
<p class="muted">{{ item.scenario.status | title }} · Currency: {{ item.scenario.currency or project.currency }}</p>
|
||||
</div>
|
||||
<div class="scenario-meta">
|
||||
<span class="meta-label">Primary Resource</span>
|
||||
<span class="meta-value">{{ item.scenario.primary_resource or "—" }}</span>
|
||||
</div>
|
||||
{% include "partials/reports/scenario_actions.html" with scenario=item.scenario %}
|
||||
</div>
|
||||
|
||||
<div class="scenario-grid">
|
||||
<section class="scenario-panel">
|
||||
<h4>Deterministic Metrics</h4>
|
||||
<table class="metrics-table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th scope="row">NPV</th>
|
||||
<td>{{ item.metrics.npv | currency_display(item.scenario.currency or project.currency) }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">IRR</th>
|
||||
<td>{{ item.metrics.irr | percentage_display }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Payback Period</th>
|
||||
<td>{{ item.metrics.payback_period | period_display }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
{% if item.metrics.notes %}
|
||||
<ul class="note-list">
|
||||
{% for note in item.metrics.notes %}
|
||||
<li>{{ note }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<section class="scenario-panel">
|
||||
<h4>Monte Carlo Summary</h4>
|
||||
{% if item.monte_carlo and item.monte_carlo.available %}
|
||||
<p class="muted">
|
||||
Iterations: {{ item.monte_carlo.iterations }}
|
||||
{% if percentiles %}
|
||||
· Percentiles:
|
||||
{% for percentile in percentiles %}
|
||||
{{ '%g' % percentile }}{% if not loop.last %}, {% endif %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</p>
|
||||
{% include "partials/reports/monte_carlo_table.html" with metrics=item.monte_carlo.metrics currency=item.scenario.currency or project.currency percentiles=percentiles %}
|
||||
{% else %}
|
||||
<p class="muted">No Monte Carlo data available for this scenario.</p>
|
||||
{% if item.monte_carlo and item.monte_carlo.notes %}
|
||||
<ul class="note-list">
|
||||
{% for note in item.monte_carlo.notes %}
|
||||
<li>{{ note }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</section>
|
||||
</div>
|
||||
</article>
|
||||
{% endfor %}
|
||||
</section>
|
||||
{% endblock %}
|
||||
149
templates/reports/scenario_distribution.html
Normal file
149
templates/reports/scenario_distribution.html
Normal file
@@ -0,0 +1,149 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Scenario Distribution | CalMiner{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% include "partials/reports_header.html" with context %}
|
||||
|
||||
<section class="report-overview">
|
||||
<div class="report-grid">
|
||||
<article class="report-card">
|
||||
<h2>Scenario Details</h2>
|
||||
<dl class="definition-list">
|
||||
<div>
|
||||
<dt>Name</dt>
|
||||
<dd>{{ scenario.name }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Project ID</dt>
|
||||
<dd>{{ scenario.project_id }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Status</dt>
|
||||
<dd>{{ scenario.status | title }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Currency</dt>
|
||||
<dd>{{ scenario.currency or "—" }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Discount Rate</dt>
|
||||
<dd>{{ metrics.discount_rate | percentage_display }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Updated</dt>
|
||||
<dd>{{ scenario.updated_at | format_datetime }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</article>
|
||||
|
||||
<article class="report-card">
|
||||
<h2>Financial Totals</h2>
|
||||
<ul class="metric-list">
|
||||
<li>
|
||||
<span>Inflows</span>
|
||||
<strong>{{ summary.inflows | currency_display(scenario.currency) }}</strong>
|
||||
</li>
|
||||
<li>
|
||||
<span>Outflows</span>
|
||||
<strong>{{ summary.outflows | currency_display(scenario.currency) }}</strong>
|
||||
</li>
|
||||
<li>
|
||||
<span>Net Cash Flow</span>
|
||||
<strong>{{ summary.net | currency_display(scenario.currency) }}</strong>
|
||||
</li>
|
||||
</ul>
|
||||
{% if summary.by_category %}
|
||||
<h3>By Category</h3>
|
||||
<ul class="metric-list compact">
|
||||
{% for label, value in summary.by_category.items() %}
|
||||
<li>
|
||||
<span>{{ label | replace("_", " ") | title }}</span>
|
||||
<strong>{{ value | currency_display(scenario.currency) }}</strong>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="report-section">
|
||||
<header class="section-header">
|
||||
<h2>Deterministic Metrics</h2>
|
||||
<p class="section-subtitle">Key financial indicators calculated from deterministic cash flows.</p>
|
||||
</header>
|
||||
<table class="metrics-table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th scope="row">NPV</th>
|
||||
<td>{{ metrics.npv | currency_display(scenario.currency) }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">IRR</th>
|
||||
<td>{{ metrics.irr | percentage_display }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Payback Period</th>
|
||||
<td>{{ metrics.payback_period | period_display }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
{% if metrics.notes %}
|
||||
<ul class="note-list">
|
||||
{% for note in metrics.notes %}
|
||||
<li>{{ note }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<section class="report-section">
|
||||
<header class="section-header">
|
||||
<h2>Monte Carlo Distribution</h2>
|
||||
<p class="section-subtitle">Simulation-driven distributions contextualize stochastic variability.</p>
|
||||
</header>
|
||||
{% if monte_carlo and monte_carlo.available %}
|
||||
<div class="simulation-summary">
|
||||
<p>Iterations: {{ monte_carlo.iterations }} · Percentiles: {{ percentiles | join(", ") }}</p>
|
||||
<table class="metrics-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Metric</th>
|
||||
<th scope="col">Mean</th>
|
||||
<th scope="col">P5</th>
|
||||
<th scope="col">Median</th>
|
||||
<th scope="col">P95</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for metric, summary in monte_carlo.metrics.items() %}
|
||||
<tr>
|
||||
<th scope="row">{{ metric | replace("_", " ") | title }}</th>
|
||||
<td>{{ summary.mean | format_metric(metric, scenario.currency) }}</td>
|
||||
<td>{{ summary.percentiles['5'] | format_metric(metric, scenario.currency) }}</td>
|
||||
<td>{{ summary.percentiles['50'] | format_metric(metric, scenario.currency) }}</td>
|
||||
<td>{{ summary.percentiles['95'] | format_metric(metric, scenario.currency) }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% if monte_carlo.notes %}
|
||||
<ul class="note-list">
|
||||
{% for note in monte_carlo.notes %}
|
||||
<li>{{ note }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="muted">Monte Carlo output is unavailable for this scenario.</p>
|
||||
{% if monte_carlo and monte_carlo.notes %}
|
||||
<ul class="note-list">
|
||||
{% for note in monte_carlo.notes %}
|
||||
<li>{{ note }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -49,7 +49,11 @@
|
||||
|
||||
<div class="form-group">
|
||||
<label for="currency">Currency</label>
|
||||
<input id="currency" name="currency" type="text" maxlength="3" value="{{ scenario.currency if scenario else '' }}" />
|
||||
{% set currency_prefill = scenario.currency if scenario and scenario.currency else default_currency %}
|
||||
<input id="currency" name="currency" type="text" maxlength="3" value="{{ currency_prefill or '' }}" placeholder="{{ default_currency or '' }}" />
|
||||
{% if default_currency %}
|
||||
<p class="field-help">Defaults to {{ default_currency }} when left blank.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
|
||||
@@ -78,10 +78,8 @@ class TestScenarioLifecycle:
|
||||
json={"currency": "ca"},
|
||||
)
|
||||
assert invalid_update.status_code == 422
|
||||
assert (
|
||||
invalid_update.json()["detail"][0]["msg"]
|
||||
== "Value error, Currency code must be a 3-letter ISO value"
|
||||
)
|
||||
assert "Invalid currency code" in invalid_update.json()[
|
||||
"detail"][0]["msg"]
|
||||
|
||||
# Scenario detail should still show the previous (valid) currency
|
||||
scenario_detail = client.get(f"/scenarios/{scenario_id}/view")
|
||||
|
||||
@@ -10,7 +10,15 @@ from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from config.database import Base
|
||||
from config.settings import AdminBootstrapSettings
|
||||
from services.bootstrap import AdminBootstrapResult, RoleBootstrapResult, bootstrap_admin
|
||||
from models import MiningOperationType, Project
|
||||
from services.bootstrap import (
|
||||
AdminBootstrapResult,
|
||||
PricingBootstrapResult,
|
||||
RoleBootstrapResult,
|
||||
bootstrap_admin,
|
||||
bootstrap_pricing_settings,
|
||||
)
|
||||
from services.pricing import PricingMetadata
|
||||
from services.unit_of_work import UnitOfWork
|
||||
|
||||
|
||||
@@ -114,3 +122,86 @@ def test_bootstrap_respects_force_reset(unit_of_work_factory: Callable[[], UnitO
|
||||
user = users_repo.get_by_email(rotated_settings.email)
|
||||
assert user is not None
|
||||
assert user.verify_password("rotated")
|
||||
|
||||
|
||||
def test_bootstrap_pricing_creates_defaults(unit_of_work_factory: Callable[[], UnitOfWork]) -> None:
|
||||
metadata = PricingMetadata(
|
||||
default_payable_pct=95.0,
|
||||
default_currency="CAD",
|
||||
moisture_threshold_pct=3.0,
|
||||
moisture_penalty_per_pct=1.25,
|
||||
)
|
||||
|
||||
result = bootstrap_pricing_settings(
|
||||
metadata=metadata,
|
||||
unit_of_work_factory=unit_of_work_factory,
|
||||
)
|
||||
|
||||
assert isinstance(result, PricingBootstrapResult)
|
||||
assert result.seed.created is True
|
||||
assert result.projects_assigned == 0
|
||||
|
||||
with unit_of_work_factory() as uow:
|
||||
settings_repo = uow.pricing_settings
|
||||
assert settings_repo is not None
|
||||
stored = settings_repo.get_by_slug("default")
|
||||
assert stored.default_currency == "CAD"
|
||||
assert float(stored.default_payable_pct) == pytest.approx(95.0)
|
||||
assert float(stored.moisture_threshold_pct) == pytest.approx(3.0)
|
||||
assert float(stored.moisture_penalty_per_pct) == pytest.approx(1.25)
|
||||
|
||||
|
||||
def test_bootstrap_pricing_assigns_projects(unit_of_work_factory: Callable[[], UnitOfWork]) -> None:
|
||||
metadata = PricingMetadata(
|
||||
default_payable_pct=90.0,
|
||||
default_currency="USD",
|
||||
moisture_threshold_pct=5.0,
|
||||
moisture_penalty_per_pct=0.5,
|
||||
)
|
||||
|
||||
with unit_of_work_factory() as uow:
|
||||
projects_repo = uow.projects
|
||||
assert projects_repo is not None
|
||||
project = Project(
|
||||
name="Project Alpha",
|
||||
operation_type=MiningOperationType.OPEN_PIT,
|
||||
)
|
||||
created = projects_repo.create(project)
|
||||
project_id = created.id
|
||||
|
||||
result = bootstrap_pricing_settings(
|
||||
metadata=metadata,
|
||||
unit_of_work_factory=unit_of_work_factory,
|
||||
)
|
||||
|
||||
assert result.projects_assigned == 1
|
||||
assert result.seed.created is True
|
||||
|
||||
with unit_of_work_factory() as uow:
|
||||
projects_repo = uow.projects
|
||||
assert projects_repo is not None
|
||||
stored = projects_repo.get(project_id, with_pricing=True)
|
||||
assert stored.pricing_settings is not None
|
||||
assert stored.pricing_settings.default_currency == "USD"
|
||||
|
||||
|
||||
def test_bootstrap_pricing_is_idempotent(unit_of_work_factory: Callable[[], UnitOfWork]) -> None:
|
||||
metadata = PricingMetadata(
|
||||
default_payable_pct=92.5,
|
||||
default_currency="EUR",
|
||||
moisture_threshold_pct=4.5,
|
||||
moisture_penalty_per_pct=0.75,
|
||||
)
|
||||
|
||||
first = bootstrap_pricing_settings(
|
||||
metadata=metadata,
|
||||
unit_of_work_factory=unit_of_work_factory,
|
||||
)
|
||||
second = bootstrap_pricing_settings(
|
||||
metadata=metadata,
|
||||
unit_of_work_factory=unit_of_work_factory,
|
||||
)
|
||||
|
||||
assert first.seed.created is True
|
||||
assert second.seed.created is False
|
||||
assert second.projects_assigned == 0
|
||||
|
||||
42
tests/test_currency.py
Normal file
42
tests/test_currency.py
Normal file
@@ -0,0 +1,42 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from services.currency import CurrencyValidationError, normalise_currency, require_currency
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"raw,expected",
|
||||
[
|
||||
("usd", "USD"),
|
||||
(" Eur ", "EUR"),
|
||||
("JPY", "JPY"),
|
||||
(None, None),
|
||||
],
|
||||
)
|
||||
def test_normalise_currency_valid_inputs(raw: str | None, expected: str | None) -> None:
|
||||
assert normalise_currency(raw) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize("raw", ["usd1", "us", "", "12", "X Y Z"])
|
||||
def test_normalise_currency_invalid_inputs(raw: str) -> None:
|
||||
with pytest.raises(CurrencyValidationError):
|
||||
normalise_currency(raw)
|
||||
|
||||
|
||||
def test_require_currency_with_value() -> None:
|
||||
assert require_currency("gbp", default="usd") == "GBP"
|
||||
|
||||
|
||||
def test_require_currency_with_default() -> None:
|
||||
assert require_currency(None, default="cad") == "CAD"
|
||||
|
||||
|
||||
def test_require_currency_missing_default() -> None:
|
||||
with pytest.raises(CurrencyValidationError):
|
||||
require_currency(None)
|
||||
|
||||
|
||||
def test_require_currency_invalid_default() -> None:
|
||||
with pytest.raises(CurrencyValidationError):
|
||||
require_currency(None, default="invalid")
|
||||
@@ -128,3 +128,17 @@ def test_scenario_export_excel(client: TestClient, unit_of_work_factory) -> None
|
||||
|
||||
with ZipFile(BytesIO(response.content)) as archive:
|
||||
assert "xl/workbook.xml" in archive.namelist()
|
||||
|
||||
|
||||
def test_scenario_export_rejects_invalid_currency_filter(client: TestClient) -> None:
|
||||
response = client.post(
|
||||
"/exports/scenarios",
|
||||
json={
|
||||
"format": "csv",
|
||||
"filters": {"currencies": ["USD", "XX"]},
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 422
|
||||
detail = response.json()["detail"]
|
||||
assert "Invalid currency code" in detail
|
||||
|
||||
162
tests/test_financial.py
Normal file
162
tests/test_financial.py
Normal file
@@ -0,0 +1,162 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
|
||||
import pytest
|
||||
|
||||
from services.financial import (
|
||||
CashFlow,
|
||||
PaybackNotReachedError,
|
||||
internal_rate_of_return,
|
||||
net_present_value,
|
||||
normalize_cash_flows,
|
||||
payback_period,
|
||||
)
|
||||
|
||||
|
||||
def test_normalize_cash_flows_with_dates() -> None:
|
||||
base = date(2025, 1, 1)
|
||||
period_length = 365.0 / 4
|
||||
flows = [
|
||||
CashFlow(amount=-1_000_000, date=base),
|
||||
CashFlow(amount=350_000, date=date(2025, 4, 1)),
|
||||
CashFlow(amount=420_000, date=date(2025, 7, 1)),
|
||||
]
|
||||
|
||||
normalised = normalize_cash_flows(flows, compounds_per_year=4)
|
||||
|
||||
assert normalised[0] == (-1_000_000.0, 0.0)
|
||||
expected_second = (date(2025, 4, 1) - base).days / period_length
|
||||
expected_third = (date(2025, 7, 1) - base).days / period_length
|
||||
|
||||
assert normalised[1][1] == pytest.approx(expected_second, rel=1e-6)
|
||||
assert normalised[2][1] == pytest.approx(expected_third, rel=1e-6)
|
||||
|
||||
|
||||
def test_net_present_value_with_period_indices() -> None:
|
||||
rate = 0.10
|
||||
flows = [
|
||||
CashFlow(amount=-1_000, period_index=0),
|
||||
CashFlow(amount=500, period_index=1),
|
||||
CashFlow(amount=500, period_index=2),
|
||||
CashFlow(amount=500, period_index=3),
|
||||
]
|
||||
|
||||
expected = -1_000 + sum(500 / (1 + rate) **
|
||||
period for period in range(1, 4))
|
||||
|
||||
result = net_present_value(rate, flows)
|
||||
|
||||
assert result == pytest.approx(expected, rel=1e-9)
|
||||
|
||||
|
||||
def test_net_present_value_with_residual_value() -> None:
|
||||
rate = 0.08
|
||||
flows = [
|
||||
CashFlow(amount=-100_000, period_index=0),
|
||||
CashFlow(amount=30_000, period_index=1),
|
||||
CashFlow(amount=35_000, period_index=2),
|
||||
]
|
||||
|
||||
expected = (
|
||||
-100_000
|
||||
+ 30_000 / (1 + rate)
|
||||
+ 35_000 / (1 + rate) ** 2
|
||||
+ 25_000 / (1 + rate) ** 3
|
||||
)
|
||||
|
||||
result = net_present_value(rate, flows, residual_value=25_000)
|
||||
|
||||
assert result == pytest.approx(expected, rel=1e-9)
|
||||
|
||||
|
||||
def test_internal_rate_of_return_simple_case() -> None:
|
||||
flows = [
|
||||
CashFlow(amount=-1_000, period_index=0),
|
||||
CashFlow(amount=1_210, period_index=1),
|
||||
]
|
||||
|
||||
irr = internal_rate_of_return(flows, guess=0.05)
|
||||
|
||||
assert irr == pytest.approx(0.21, rel=1e-9)
|
||||
|
||||
|
||||
def test_internal_rate_of_return_multiple_sign_changes() -> None:
|
||||
flows = [
|
||||
CashFlow(amount=-500_000, period_index=0),
|
||||
CashFlow(amount=250_000, period_index=1),
|
||||
CashFlow(amount=-100_000, period_index=2),
|
||||
CashFlow(amount=425_000, period_index=3),
|
||||
]
|
||||
|
||||
irr = internal_rate_of_return(flows, guess=0.2)
|
||||
|
||||
npv = net_present_value(irr, flows)
|
||||
|
||||
assert npv == pytest.approx(0.0, abs=1e-6)
|
||||
|
||||
|
||||
def test_internal_rate_of_return_requires_mixed_signs() -> None:
|
||||
flows = [
|
||||
CashFlow(amount=100_000, period_index=0),
|
||||
CashFlow(amount=150_000, period_index=1),
|
||||
]
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
internal_rate_of_return(flows)
|
||||
|
||||
|
||||
def test_payback_period_exact_period() -> None:
|
||||
flows = [
|
||||
CashFlow(amount=-120_000, period_index=0),
|
||||
CashFlow(amount=40_000, period_index=1),
|
||||
CashFlow(amount=40_000, period_index=2),
|
||||
CashFlow(amount=40_000, period_index=3),
|
||||
]
|
||||
|
||||
period = payback_period(flows, allow_fractional=False)
|
||||
|
||||
assert period == pytest.approx(3.0)
|
||||
|
||||
|
||||
def test_payback_period_fractional_period() -> None:
|
||||
flows = [
|
||||
CashFlow(amount=-100_000, period_index=0),
|
||||
CashFlow(amount=80_000, period_index=1),
|
||||
CashFlow(amount=30_000, period_index=2),
|
||||
]
|
||||
|
||||
fractional = payback_period(flows)
|
||||
whole = payback_period(flows, allow_fractional=False)
|
||||
|
||||
assert fractional == pytest.approx(1 + 20_000 / 30_000, rel=1e-9)
|
||||
assert whole == pytest.approx(2.0)
|
||||
|
||||
|
||||
def test_payback_period_raises_when_never_recovered() -> None:
|
||||
flows = [
|
||||
CashFlow(amount=-250_000, period_index=0),
|
||||
CashFlow(amount=50_000, period_index=1),
|
||||
CashFlow(amount=60_000, period_index=2),
|
||||
CashFlow(amount=70_000, period_index=3),
|
||||
]
|
||||
|
||||
with pytest.raises(PaybackNotReachedError):
|
||||
payback_period(flows)
|
||||
|
||||
|
||||
def test_payback_period_with_quarterly_compounding() -> None:
|
||||
base = date(2025, 1, 1)
|
||||
flows = [
|
||||
CashFlow(amount=-120_000, date=base),
|
||||
CashFlow(amount=35_000, date=date(2025, 4, 1)),
|
||||
CashFlow(amount=35_000, date=date(2025, 7, 1)),
|
||||
CashFlow(amount=50_000, date=date(2025, 10, 1)),
|
||||
]
|
||||
|
||||
period = payback_period(flows, compounds_per_year=4)
|
||||
|
||||
period_length = 365.0 / 4
|
||||
expected_period = (date(2025, 10, 1) - base).days / period_length
|
||||
|
||||
assert period == pytest.approx(expected_period, abs=1e-6)
|
||||
@@ -68,3 +68,35 @@ def test_scenario_import_commit_invalid_token_returns_404(
|
||||
)
|
||||
assert response.status_code == 404
|
||||
assert "Unknown scenario import token" in response.json()["detail"]
|
||||
|
||||
|
||||
def test_scenario_import_preview_rejects_invalid_currency(
|
||||
client: TestClient,
|
||||
unit_of_work_factory,
|
||||
) -> None:
|
||||
with unit_of_work_factory() as uow:
|
||||
assert uow.projects is not None
|
||||
project = Project(
|
||||
name="Import Currency Project",
|
||||
operation_type=MiningOperationType.OPEN_PIT,
|
||||
)
|
||||
uow.projects.create(project)
|
||||
|
||||
csv_content = (
|
||||
"project_name,name,currency\n"
|
||||
"Import Currency Project,Invalid Currency,US\n"
|
||||
)
|
||||
|
||||
response = client.post(
|
||||
"/imports/scenarios/preview",
|
||||
files={"file": ("scenarios.csv", csv_content, "text/csv")},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["summary"]["accepted"] == 0
|
||||
assert payload["summary"]["errored"] == 1
|
||||
assert payload["parser_errors"]
|
||||
parser_error = payload["parser_errors"][0]
|
||||
assert parser_error["field"] == "currency"
|
||||
assert "Invalid currency code" in parser_error["message"]
|
||||
|
||||
@@ -140,3 +140,22 @@ def test_scenario_import_handles_large_dataset() -> None:
|
||||
|
||||
assert len(result.rows) == 500
|
||||
assert len(result.rows) == 500
|
||||
|
||||
|
||||
def test_scenario_import_rejects_invalid_currency() -> None:
|
||||
csv_content = dedent(
|
||||
"""
|
||||
project_name,name,currency
|
||||
Project A,Scenario Invalid,US
|
||||
"""
|
||||
).strip()
|
||||
stream = BytesIO(csv_content.encode("utf-8"))
|
||||
|
||||
result = load_scenario_imports(stream, "scenarios.csv")
|
||||
|
||||
assert not result.rows
|
||||
assert result.errors
|
||||
error = result.errors[0]
|
||||
assert error.row_number == 2
|
||||
assert error.field == "currency"
|
||||
assert "Invalid currency code" in error.message
|
||||
|
||||
155
tests/test_pricing.py
Normal file
155
tests/test_pricing.py
Normal file
@@ -0,0 +1,155 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
import pytest
|
||||
|
||||
from services.pricing import (
|
||||
PricingInput,
|
||||
PricingMetadata,
|
||||
PricingResult,
|
||||
calculate_pricing,
|
||||
)
|
||||
|
||||
|
||||
def test_calculate_pricing_with_explicit_penalties() -> None:
|
||||
pricing_input = PricingInput(
|
||||
metal="copper",
|
||||
ore_tonnage=100_000,
|
||||
head_grade_pct=1.2,
|
||||
recovery_pct=90,
|
||||
payable_pct=96,
|
||||
reference_price=8_500,
|
||||
treatment_charge=100_000,
|
||||
smelting_charge=0,
|
||||
moisture_pct=10,
|
||||
moisture_threshold_pct=8,
|
||||
moisture_penalty_per_pct=3_000,
|
||||
impurity_ppm={"As": 100},
|
||||
impurity_thresholds={"As": 0},
|
||||
impurity_penalty_per_ppm={"As": 2},
|
||||
premiums=50_000,
|
||||
fx_rate=1.0,
|
||||
currency_code="usd",
|
||||
)
|
||||
|
||||
result = calculate_pricing(pricing_input)
|
||||
|
||||
assert isinstance(result, PricingResult)
|
||||
assert math.isclose(result.payable_metal_tonnes, 1_036.8, rel_tol=1e-6)
|
||||
assert math.isclose(result.gross_revenue, 1_036.8 * 8_500, rel_tol=1e-6)
|
||||
assert math.isclose(result.moisture_penalty, 6_000, rel_tol=1e-6)
|
||||
assert math.isclose(result.impurity_penalty, 200, rel_tol=1e-6)
|
||||
assert math.isclose(result.net_revenue, 8_756_600, rel_tol=1e-6)
|
||||
assert result.treatment_smelt_charges == pytest.approx(100_000)
|
||||
assert result.currency == "USD"
|
||||
|
||||
|
||||
def test_calculate_pricing_with_metadata_defaults() -> None:
|
||||
metadata = PricingMetadata(
|
||||
default_payable_pct=95,
|
||||
default_currency="EUR",
|
||||
moisture_threshold_pct=7,
|
||||
moisture_penalty_per_pct=2_000,
|
||||
impurity_thresholds={"Pb": 50},
|
||||
impurity_penalty_per_ppm={"Pb": 1.5},
|
||||
)
|
||||
|
||||
pricing_input = PricingInput(
|
||||
metal="lead",
|
||||
ore_tonnage=50_000,
|
||||
head_grade_pct=5,
|
||||
recovery_pct=85,
|
||||
payable_pct=None,
|
||||
reference_price=2_000,
|
||||
treatment_charge=30_000,
|
||||
smelting_charge=20_000,
|
||||
moisture_pct=9,
|
||||
moisture_threshold_pct=None,
|
||||
moisture_penalty_per_pct=None,
|
||||
impurity_ppm={"Pb": 120},
|
||||
premiums=12_000,
|
||||
fx_rate=1.2,
|
||||
currency_code=None,
|
||||
)
|
||||
|
||||
result = calculate_pricing(pricing_input, metadata=metadata)
|
||||
|
||||
expected_payable = 50_000 * 0.05 * 0.85 * 0.95
|
||||
assert math.isclose(result.payable_metal_tonnes,
|
||||
expected_payable, rel_tol=1e-6)
|
||||
assert result.moisture_penalty == pytest.approx((9 - 7) * 2_000)
|
||||
assert result.impurity_penalty == pytest.approx((120 - 50) * 1.5)
|
||||
assert result.treatment_smelt_charges == pytest.approx(50_000)
|
||||
assert result.currency == "EUR"
|
||||
assert result.net_revenue > 0
|
||||
|
||||
|
||||
def test_calculate_pricing_currency_override() -> None:
|
||||
pricing_input = PricingInput(
|
||||
metal="gold",
|
||||
ore_tonnage=10_000,
|
||||
head_grade_pct=2.5,
|
||||
recovery_pct=92,
|
||||
payable_pct=98,
|
||||
reference_price=60_000,
|
||||
treatment_charge=40_000,
|
||||
smelting_charge=10_000,
|
||||
moisture_pct=5,
|
||||
moisture_threshold_pct=7,
|
||||
moisture_penalty_per_pct=1_000,
|
||||
premiums=25_000,
|
||||
fx_rate=1.0,
|
||||
currency_code="cad",
|
||||
)
|
||||
|
||||
metadata = PricingMetadata(default_currency="USD")
|
||||
|
||||
result = calculate_pricing(
|
||||
pricing_input, metadata=metadata, currency="CAD")
|
||||
|
||||
assert result.currency == "CAD"
|
||||
assert result.net_revenue > 0
|
||||
|
||||
|
||||
def test_calculate_pricing_multiple_inputs_aggregate() -> None:
|
||||
metadata = PricingMetadata(default_currency="USD")
|
||||
inputs = [
|
||||
PricingInput(
|
||||
metal="copper",
|
||||
ore_tonnage=10_000,
|
||||
head_grade_pct=1.5,
|
||||
recovery_pct=88,
|
||||
payable_pct=95,
|
||||
reference_price=8_000,
|
||||
treatment_charge=20_000,
|
||||
smelting_charge=5_000,
|
||||
moisture_pct=7,
|
||||
moisture_threshold_pct=8,
|
||||
moisture_penalty_per_pct=1_000,
|
||||
premiums=0,
|
||||
fx_rate=1.0,
|
||||
currency_code=None,
|
||||
),
|
||||
PricingInput(
|
||||
metal="copper",
|
||||
ore_tonnage=8_000,
|
||||
head_grade_pct=1.1,
|
||||
recovery_pct=90,
|
||||
payable_pct=96,
|
||||
reference_price=8_000,
|
||||
treatment_charge=18_000,
|
||||
smelting_charge=4_000,
|
||||
moisture_pct=9,
|
||||
moisture_threshold_pct=8,
|
||||
moisture_penalty_per_pct=1_000,
|
||||
premiums=0,
|
||||
fx_rate=1.0,
|
||||
currency_code="usd",
|
||||
),
|
||||
]
|
||||
|
||||
results = [calculate_pricing(i, metadata=metadata) for i in inputs]
|
||||
|
||||
assert all(result.currency == "USD" for result in results)
|
||||
assert sum(result.net_revenue for result in results) > 0
|
||||
209
tests/test_pricing_settings_repository.py
Normal file
209
tests/test_pricing_settings_repository.py
Normal file
@@ -0,0 +1,209 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from config.database import Base
|
||||
from models import PricingImpuritySettings, PricingMetalSettings, PricingSettings
|
||||
from services.pricing import PricingMetadata
|
||||
from services.repositories import (
|
||||
PricingSettingsRepository,
|
||||
ensure_default_pricing_settings,
|
||||
)
|
||||
from services.unit_of_work import UnitOfWork
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def engine() -> Iterator:
|
||||
engine = create_engine("sqlite:///:memory:", future=True)
|
||||
Base.metadata.create_all(bind=engine)
|
||||
try:
|
||||
yield engine
|
||||
finally:
|
||||
Base.metadata.drop_all(bind=engine)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def session(engine) -> Iterator[Session]:
|
||||
TestingSession = sessionmaker(
|
||||
bind=engine, expire_on_commit=False, future=True)
|
||||
db = TestingSession()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_pricing_settings_repository_crud(session: Session) -> None:
|
||||
repo = PricingSettingsRepository(session)
|
||||
|
||||
settings = PricingSettings(
|
||||
name="Contract A",
|
||||
slug="Contract-A",
|
||||
default_currency="usd",
|
||||
default_payable_pct=95.0,
|
||||
moisture_threshold_pct=7.5,
|
||||
moisture_penalty_per_pct=1500.0,
|
||||
)
|
||||
repo.create(settings)
|
||||
|
||||
metal_override = PricingMetalSettings(
|
||||
metal_code="Copper",
|
||||
payable_pct=96.0,
|
||||
moisture_threshold_pct=None,
|
||||
moisture_penalty_per_pct=None,
|
||||
)
|
||||
repo.attach_metal_override(settings, metal_override)
|
||||
|
||||
impurity_override = PricingImpuritySettings(
|
||||
impurity_code="as",
|
||||
threshold_ppm=100.0,
|
||||
penalty_per_ppm=3.5,
|
||||
)
|
||||
repo.attach_impurity_override(settings, impurity_override)
|
||||
|
||||
retrieved = repo.get_by_slug("CONTRACT-A", include_children=True)
|
||||
assert retrieved.slug == "contract-a"
|
||||
assert retrieved.default_currency == "USD"
|
||||
assert len(retrieved.metal_overrides) == 1
|
||||
assert retrieved.metal_overrides[0].metal_code == "copper"
|
||||
assert len(retrieved.impurity_overrides) == 1
|
||||
assert retrieved.impurity_overrides[0].impurity_code == "AS"
|
||||
|
||||
listed = repo.list(include_children=True)
|
||||
assert len(listed) == 1
|
||||
assert listed[0].id == settings.id
|
||||
|
||||
|
||||
def test_ensure_default_pricing_settings_creates_and_updates(session: Session) -> None:
|
||||
repo = PricingSettingsRepository(session)
|
||||
|
||||
metadata_initial = PricingMetadata(
|
||||
default_payable_pct=100.0,
|
||||
default_currency="USD",
|
||||
moisture_threshold_pct=8.0,
|
||||
moisture_penalty_per_pct=0.0,
|
||||
impurity_thresholds={"As": 50.0},
|
||||
impurity_penalty_per_ppm={"As": 2.0},
|
||||
)
|
||||
|
||||
result_create = ensure_default_pricing_settings(
|
||||
repo,
|
||||
metadata=metadata_initial,
|
||||
name="Seeded Pricing",
|
||||
description="Seeded from defaults",
|
||||
)
|
||||
|
||||
assert result_create.created is True
|
||||
assert result_create.settings.slug == "default"
|
||||
assert result_create.settings.default_currency == "USD"
|
||||
assert len(result_create.settings.impurity_overrides) == 1
|
||||
assert result_create.settings.impurity_overrides[0].penalty_per_ppm == 2.0
|
||||
|
||||
metadata_update = PricingMetadata(
|
||||
default_payable_pct=97.0,
|
||||
default_currency="EUR",
|
||||
moisture_threshold_pct=6.5,
|
||||
moisture_penalty_per_pct=250.0,
|
||||
impurity_thresholds={"As": 45.0, "Pb": 12.0},
|
||||
impurity_penalty_per_ppm={"As": 3.0, "Pb": 1.25},
|
||||
)
|
||||
|
||||
result_update = ensure_default_pricing_settings(
|
||||
repo,
|
||||
metadata=metadata_update,
|
||||
name="Seeded Pricing",
|
||||
description="Seeded from defaults",
|
||||
)
|
||||
|
||||
assert result_update.created is False
|
||||
assert result_update.updated_fields > 0
|
||||
assert result_update.impurity_upserts >= 1
|
||||
|
||||
updated = repo.get_by_slug("default", include_children=True)
|
||||
assert updated.default_currency == "EUR"
|
||||
as_override = {
|
||||
item.impurity_code: item for item in updated.impurity_overrides}["AS"]
|
||||
assert float(as_override.threshold_ppm) == 45.0
|
||||
assert float(as_override.penalty_per_ppm) == 3.0
|
||||
pb_override = {
|
||||
item.impurity_code: item for item in updated.impurity_overrides}["PB"]
|
||||
assert float(pb_override.threshold_ppm) == 12.0
|
||||
|
||||
|
||||
def test_unit_of_work_exposes_pricing_settings(engine) -> None:
|
||||
TestingSession = sessionmaker(
|
||||
bind=engine, expire_on_commit=False, future=True)
|
||||
metadata = PricingMetadata(
|
||||
default_payable_pct=99.0,
|
||||
default_currency="USD",
|
||||
moisture_threshold_pct=7.0,
|
||||
moisture_penalty_per_pct=125.0,
|
||||
impurity_thresholds={"Zn": 80.0},
|
||||
impurity_penalty_per_ppm={"Zn": 0.5},
|
||||
)
|
||||
|
||||
with UnitOfWork(session_factory=TestingSession) as uow:
|
||||
assert uow.pricing_settings is not None
|
||||
result = uow.ensure_default_pricing_settings(
|
||||
metadata=metadata,
|
||||
slug="contract-core",
|
||||
name="Contract Core",
|
||||
)
|
||||
assert result.settings.slug == "contract-core"
|
||||
assert result.created is True
|
||||
|
||||
with UnitOfWork(session_factory=TestingSession) as uow:
|
||||
assert uow.pricing_settings is not None
|
||||
stored = uow.pricing_settings.get_by_slug(
|
||||
"contract-core", include_children=True)
|
||||
assert stored.default_payable_pct == 99.0
|
||||
assert stored.impurity_overrides[0].impurity_code == "ZN"
|
||||
|
||||
|
||||
def test_unit_of_work_get_pricing_metadata_returns_defaults(engine) -> None:
|
||||
TestingSession = sessionmaker(
|
||||
bind=engine, expire_on_commit=False, future=True)
|
||||
seeded_metadata = PricingMetadata(
|
||||
default_payable_pct=96.5,
|
||||
default_currency="aud",
|
||||
moisture_threshold_pct=6.25,
|
||||
moisture_penalty_per_pct=210.0,
|
||||
impurity_thresholds={"As": 45.0, "Pb": 15.0},
|
||||
impurity_penalty_per_ppm={"As": 1.75, "Pb": 0.9},
|
||||
)
|
||||
|
||||
with UnitOfWork(session_factory=TestingSession) as uow:
|
||||
result = uow.ensure_default_pricing_settings(
|
||||
metadata=seeded_metadata,
|
||||
slug="default",
|
||||
name="Default Contract",
|
||||
description="Primary contract defaults",
|
||||
)
|
||||
assert result.created is True
|
||||
|
||||
with UnitOfWork(session_factory=TestingSession) as uow:
|
||||
retrieved = uow.get_pricing_metadata()
|
||||
|
||||
assert retrieved is not None
|
||||
assert retrieved.default_currency == "AUD"
|
||||
assert retrieved.default_payable_pct == 96.5
|
||||
assert retrieved.moisture_threshold_pct == 6.25
|
||||
assert retrieved.moisture_penalty_per_pct == 210.0
|
||||
assert retrieved.impurity_thresholds["AS"] == 45.0
|
||||
assert retrieved.impurity_thresholds["PB"] == 15.0
|
||||
assert retrieved.impurity_penalty_per_ppm["AS"] == 1.75
|
||||
assert retrieved.impurity_penalty_per_ppm["PB"] == 0.9
|
||||
|
||||
|
||||
def test_unit_of_work_get_pricing_metadata_returns_none_when_missing(engine) -> None:
|
||||
TestingSession = sessionmaker(
|
||||
bind=engine, expire_on_commit=False, future=True)
|
||||
|
||||
with UnitOfWork(session_factory=TestingSession) as uow:
|
||||
missing = uow.get_pricing_metadata(slug="non-existent")
|
||||
|
||||
assert missing is None
|
||||
@@ -13,6 +13,7 @@ from models import (
|
||||
FinancialCategory,
|
||||
FinancialInput,
|
||||
MiningOperationType,
|
||||
PricingSettings,
|
||||
Project,
|
||||
Scenario,
|
||||
ScenarioStatus,
|
||||
@@ -147,6 +148,30 @@ def test_unit_of_work_commit_and_rollback(engine) -> None:
|
||||
projects = ProjectRepository(session).list()
|
||||
assert len(projects) == 1
|
||||
|
||||
|
||||
def test_unit_of_work_set_project_pricing_settings(engine) -> None:
|
||||
TestingSession = sessionmaker(bind=engine, expire_on_commit=False, future=True)
|
||||
with UnitOfWork(session_factory=TestingSession) as uow:
|
||||
assert uow.projects is not None and uow.pricing_settings is not None
|
||||
project = Project(name="Project Pricing", operation_type=MiningOperationType.OTHER)
|
||||
uow.projects.create(project)
|
||||
pricing_settings = PricingSettings(
|
||||
name="Default Pricing",
|
||||
slug="default",
|
||||
default_currency="usd",
|
||||
default_payable_pct=100.0,
|
||||
moisture_threshold_pct=8.0,
|
||||
moisture_penalty_per_pct=0.0,
|
||||
)
|
||||
uow.pricing_settings.create(pricing_settings)
|
||||
uow.set_project_pricing_settings(project, pricing_settings)
|
||||
|
||||
with TestingSession() as session:
|
||||
repo = ProjectRepository(session)
|
||||
stored = repo.get(1, with_pricing=True)
|
||||
assert stored.pricing_settings is not None
|
||||
assert stored.pricing_settings.slug == "default"
|
||||
|
||||
# Rollback path
|
||||
with pytest.raises(RuntimeError):
|
||||
with UnitOfWork(session_factory=TestingSession) as uow:
|
||||
@@ -302,6 +327,44 @@ def test_project_repository_filtered_for_export(session: Session) -> None:
|
||||
assert results[0].scenarios[0].name == "Alpha Scenario"
|
||||
|
||||
|
||||
def test_project_repository_with_pricing_settings(session: Session) -> None:
|
||||
repo = ProjectRepository(session)
|
||||
settings = PricingSettings(
|
||||
name="Contract Core",
|
||||
slug="contract-core",
|
||||
default_currency="usd",
|
||||
default_payable_pct=95.0,
|
||||
moisture_threshold_pct=7.5,
|
||||
moisture_penalty_per_pct=100.0,
|
||||
)
|
||||
project = Project(
|
||||
name="Project Pricing",
|
||||
operation_type=MiningOperationType.OPEN_PIT,
|
||||
pricing_settings=settings,
|
||||
)
|
||||
session.add(project)
|
||||
session.flush()
|
||||
|
||||
fetched = repo.get(project.id, with_pricing=True)
|
||||
assert fetched.pricing_settings is not None
|
||||
assert fetched.pricing_settings.slug == "contract-core"
|
||||
assert fetched.pricing_settings.default_currency == "USD"
|
||||
|
||||
listed = repo.list(with_pricing=True)
|
||||
assert listed[0].pricing_settings is not None
|
||||
|
||||
repo.set_pricing_settings(project, None)
|
||||
session.refresh(project)
|
||||
assert project.pricing_settings is None
|
||||
|
||||
repo.set_pricing_settings(project, settings)
|
||||
session.refresh(project)
|
||||
assert project.pricing_settings is settings
|
||||
|
||||
export_results = repo.filtered_for_export(None, include_pricing=True)
|
||||
assert export_results[0].pricing_settings is not None
|
||||
|
||||
|
||||
def test_scenario_repository_filtered_for_export(session: Session) -> None:
|
||||
repo = ScenarioRepository(session)
|
||||
|
||||
|
||||
120
tests/test_scenario_pricing.py
Normal file
120
tests/test_scenario_pricing.py
Normal file
@@ -0,0 +1,120 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from models import MiningOperationType, Project, Scenario, ScenarioStatus
|
||||
from services.pricing import PricingInput, PricingMetadata
|
||||
from services.scenario_evaluation import ScenarioPricingConfig, ScenarioPricingEvaluator
|
||||
|
||||
|
||||
def build_scenario() -> Scenario:
|
||||
project = Project(name="Test Project",
|
||||
operation_type=MiningOperationType.OPEN_PIT)
|
||||
scenario = Scenario(
|
||||
project=project,
|
||||
project_id=1,
|
||||
name="Scenario A",
|
||||
status=ScenarioStatus.ACTIVE,
|
||||
currency="USD",
|
||||
)
|
||||
scenario.id = 1 # simulate persisted entity
|
||||
return scenario
|
||||
|
||||
|
||||
def test_scenario_pricing_evaluator_uses_metadata_defaults() -> None:
|
||||
scenario = build_scenario()
|
||||
evaluator = ScenarioPricingEvaluator(
|
||||
ScenarioPricingConfig(
|
||||
metadata=PricingMetadata(
|
||||
default_currency="USD", default_payable_pct=95)
|
||||
)
|
||||
)
|
||||
inputs = [
|
||||
PricingInput(
|
||||
metal="copper",
|
||||
ore_tonnage=50_000,
|
||||
head_grade_pct=1.0,
|
||||
recovery_pct=90,
|
||||
payable_pct=None,
|
||||
reference_price=9_000,
|
||||
treatment_charge=50_000,
|
||||
smelting_charge=10_000,
|
||||
moisture_pct=9,
|
||||
moisture_threshold_pct=None,
|
||||
moisture_penalty_per_pct=None,
|
||||
impurity_ppm={"As": 120},
|
||||
premiums=10_000,
|
||||
fx_rate=1.0,
|
||||
currency_code=None,
|
||||
)
|
||||
]
|
||||
|
||||
snapshot = evaluator.evaluate(scenario, inputs=inputs)
|
||||
|
||||
assert snapshot.scenario_id == scenario.id
|
||||
assert len(snapshot.results) == 1
|
||||
result = snapshot.results[0]
|
||||
assert result.currency == "USD"
|
||||
assert result.net_revenue > 0
|
||||
|
||||
|
||||
def test_scenario_pricing_evaluator_override_metadata() -> None:
|
||||
scenario = build_scenario()
|
||||
evaluator = ScenarioPricingEvaluator(ScenarioPricingConfig())
|
||||
metadata_override = PricingMetadata(
|
||||
default_currency="CAD",
|
||||
default_payable_pct=90,
|
||||
moisture_threshold_pct=5,
|
||||
moisture_penalty_per_pct=500,
|
||||
)
|
||||
|
||||
inputs = [
|
||||
PricingInput(
|
||||
metal="copper",
|
||||
ore_tonnage=20_000,
|
||||
head_grade_pct=1.2,
|
||||
recovery_pct=88,
|
||||
payable_pct=None,
|
||||
reference_price=8_200,
|
||||
treatment_charge=15_000,
|
||||
smelting_charge=6_000,
|
||||
moisture_pct=6,
|
||||
moisture_threshold_pct=None,
|
||||
moisture_penalty_per_pct=None,
|
||||
premiums=5_000,
|
||||
fx_rate=1.0,
|
||||
currency_code="cad",
|
||||
),
|
||||
PricingInput(
|
||||
metal="gold",
|
||||
ore_tonnage=5_000,
|
||||
head_grade_pct=2.0,
|
||||
recovery_pct=90,
|
||||
payable_pct=None,
|
||||
reference_price=60_000,
|
||||
treatment_charge=10_000,
|
||||
smelting_charge=5_000,
|
||||
moisture_pct=4,
|
||||
moisture_threshold_pct=None,
|
||||
moisture_penalty_per_pct=None,
|
||||
premiums=15_000,
|
||||
fx_rate=1.0,
|
||||
currency_code="cad",
|
||||
),
|
||||
]
|
||||
|
||||
snapshot = evaluator.evaluate(
|
||||
scenario,
|
||||
inputs=inputs,
|
||||
metadata_override=metadata_override,
|
||||
)
|
||||
|
||||
assert len(snapshot.results) == 2
|
||||
assert all(result.currency ==
|
||||
scenario.currency for result in snapshot.results)
|
||||
|
||||
copper_result = snapshot.results[0]
|
||||
expected_payable = 20_000 * 0.012 * 0.88 * 0.90
|
||||
assert copper_result.payable_metal_tonnes == pytest.approx(
|
||||
expected_payable)
|
||||
assert sum(result.net_revenue for result in snapshot.results) > 0
|
||||
@@ -278,3 +278,70 @@ class TestScenarioComparisonEndpoint:
|
||||
detail = response.json()["detail"]
|
||||
assert detail["code"] == "SCENARIO_PROJECT_MISMATCH"
|
||||
assert project_a_id != project_b_id
|
||||
|
||||
|
||||
class TestScenarioApiCurrencyValidation:
|
||||
def test_create_api_rejects_invalid_currency(
|
||||
self,
|
||||
api_client: TestClient,
|
||||
session_factory: sessionmaker,
|
||||
) -> None:
|
||||
with UnitOfWork(session_factory=session_factory) as uow:
|
||||
assert uow.projects is not None
|
||||
assert uow.scenarios is not None
|
||||
project = Project(
|
||||
name="Currency Validation Project",
|
||||
operation_type=MiningOperationType.OPEN_PIT,
|
||||
)
|
||||
uow.projects.create(project)
|
||||
project_id = project.id
|
||||
|
||||
response = api_client.post(
|
||||
f"/projects/{project_id}/scenarios",
|
||||
json={
|
||||
"name": "Invalid Currency Scenario",
|
||||
"currency": "US",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 422
|
||||
detail = response.json().get("detail", [])
|
||||
assert any(
|
||||
"Invalid currency code" in item.get("msg", "") for item in detail
|
||||
), detail
|
||||
|
||||
with UnitOfWork(session_factory=session_factory) as uow:
|
||||
assert uow.scenarios is not None
|
||||
scenarios = uow.scenarios.list_for_project(project_id)
|
||||
assert scenarios == []
|
||||
|
||||
def test_create_api_normalises_currency(
|
||||
self,
|
||||
api_client: TestClient,
|
||||
session_factory: sessionmaker,
|
||||
) -> None:
|
||||
with UnitOfWork(session_factory=session_factory) as uow:
|
||||
assert uow.projects is not None
|
||||
assert uow.scenarios is not None
|
||||
project = Project(
|
||||
name="Currency Normalisation Project",
|
||||
operation_type=MiningOperationType.OPEN_PIT,
|
||||
)
|
||||
uow.projects.create(project)
|
||||
project_id = project.id
|
||||
|
||||
response = api_client.post(
|
||||
f"/projects/{project_id}/scenarios",
|
||||
json={
|
||||
"name": "Normalised Currency Scenario",
|
||||
"currency": "cad",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
|
||||
with UnitOfWork(session_factory=session_factory) as uow:
|
||||
assert uow.scenarios is not None
|
||||
scenarios = uow.scenarios.list_for_project(project_id)
|
||||
assert len(scenarios) == 1
|
||||
assert scenarios[0].currency == "CAD"
|
||||
|
||||
158
tests/test_simulation.py
Normal file
158
tests/test_simulation.py
Normal file
@@ -0,0 +1,158 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from services.financial import CashFlow, net_present_value
|
||||
from services.simulation import (
|
||||
CashFlowSpec,
|
||||
DistributionConfigError,
|
||||
DistributionSource,
|
||||
DistributionSpec,
|
||||
DistributionType,
|
||||
SimulationConfig,
|
||||
SimulationMetric,
|
||||
run_monte_carlo,
|
||||
)
|
||||
|
||||
|
||||
def test_run_monte_carlo_deterministic_matches_financial_helpers() -> None:
|
||||
base_flows = [
|
||||
CashFlow(amount=-1000.0, period_index=0),
|
||||
CashFlow(amount=600.0, period_index=1),
|
||||
CashFlow(amount=600.0, period_index=2),
|
||||
]
|
||||
specs = [CashFlowSpec(cash_flow=flow) for flow in base_flows]
|
||||
config = SimulationConfig(
|
||||
iterations=10,
|
||||
discount_rate=0.1,
|
||||
percentiles=(50,),
|
||||
seed=123,
|
||||
)
|
||||
|
||||
result = run_monte_carlo(specs, config)
|
||||
summary = result.summaries[SimulationMetric.NPV]
|
||||
expected = net_present_value(0.1, base_flows)
|
||||
|
||||
assert summary.sample_size == config.iterations
|
||||
assert summary.failed_runs == 0
|
||||
assert summary.mean == pytest.approx(expected, rel=1e-6)
|
||||
assert summary.std_dev == 0.0
|
||||
assert summary.percentiles[50] == pytest.approx(expected, rel=1e-6)
|
||||
|
||||
|
||||
def test_run_monte_carlo_normal_distribution_uses_seed_for_reproducibility() -> None:
|
||||
base_flows = [
|
||||
CashFlow(amount=-100.0, period_index=0),
|
||||
CashFlow(amount=0.0, period_index=1),
|
||||
CashFlow(amount=0.0, period_index=2),
|
||||
]
|
||||
revenue_flow = CashFlowSpec(
|
||||
cash_flow=CashFlow(amount=120.0, period_index=1),
|
||||
distribution=DistributionSpec(
|
||||
type=DistributionType.NORMAL,
|
||||
parameters={"mean": 120.0, "std_dev": 10.0},
|
||||
),
|
||||
)
|
||||
specs = [CashFlowSpec(cash_flow=base_flows[0]), revenue_flow]
|
||||
config = SimulationConfig(
|
||||
iterations=1000,
|
||||
discount_rate=0.0,
|
||||
percentiles=(5.0, 50.0, 95.0),
|
||||
seed=42,
|
||||
)
|
||||
|
||||
result = run_monte_carlo(specs, config)
|
||||
summary = result.summaries[SimulationMetric.NPV]
|
||||
|
||||
assert summary.sample_size == config.iterations
|
||||
assert summary.failed_runs == 0
|
||||
# With zero discount rate the expected mean NPV equals mean sampled value minus investment.
|
||||
assert summary.mean == pytest.approx(20.0, abs=1.0)
|
||||
assert summary.std_dev == pytest.approx(10.0, abs=1.0)
|
||||
assert summary.percentiles[50.0] == pytest.approx(summary.mean, abs=1.0)
|
||||
|
||||
|
||||
def test_run_monte_carlo_supports_scenario_field_source() -> None:
|
||||
base_flow = CashFlow(amount=0.0, period_index=1)
|
||||
spec = CashFlowSpec(
|
||||
cash_flow=base_flow,
|
||||
distribution=DistributionSpec(
|
||||
type=DistributionType.NORMAL,
|
||||
parameters={"std_dev": 0.0},
|
||||
source=DistributionSource.SCENARIO_FIELD,
|
||||
source_key="salvage_mean",
|
||||
),
|
||||
)
|
||||
config = SimulationConfig(iterations=1, discount_rate=0.0, seed=7)
|
||||
|
||||
result = run_monte_carlo(
|
||||
[CashFlowSpec(cash_flow=CashFlow(
|
||||
amount=-100.0, period_index=0)), spec],
|
||||
config,
|
||||
scenario_context={"salvage_mean": 150.0},
|
||||
)
|
||||
|
||||
summary = result.summaries[SimulationMetric.NPV]
|
||||
assert summary.sample_size == 1
|
||||
assert summary.mean == pytest.approx(50.0)
|
||||
|
||||
|
||||
def test_run_monte_carlo_records_failed_metrics_when_not_defined() -> None:
|
||||
base_flows = [CashFlow(amount=100.0, period_index=0)]
|
||||
specs = [CashFlowSpec(cash_flow=flow) for flow in base_flows]
|
||||
config = SimulationConfig(
|
||||
iterations=5,
|
||||
discount_rate=0.1,
|
||||
metrics=(SimulationMetric.IRR,),
|
||||
seed=5,
|
||||
)
|
||||
|
||||
result = run_monte_carlo(specs, config)
|
||||
summary = result.summaries[SimulationMetric.IRR]
|
||||
|
||||
assert summary.sample_size == 0
|
||||
assert summary.failed_runs == config.iterations
|
||||
assert math.isnan(summary.mean)
|
||||
|
||||
|
||||
def test_run_monte_carlo_distribution_missing_context_raises() -> None:
|
||||
spec = DistributionSpec(
|
||||
type=DistributionType.NORMAL,
|
||||
parameters={"std_dev": 1.0},
|
||||
source=DistributionSource.SCENARIO_FIELD,
|
||||
source_key="unknown",
|
||||
)
|
||||
cash_flow_spec = CashFlowSpec(
|
||||
cash_flow=CashFlow(amount=0.0, period_index=0),
|
||||
distribution=spec,
|
||||
)
|
||||
config = SimulationConfig(iterations=1, discount_rate=0.0)
|
||||
|
||||
with pytest.raises(DistributionConfigError):
|
||||
run_monte_carlo([cash_flow_spec], config, scenario_context={})
|
||||
|
||||
|
||||
def test_run_monte_carlo_can_return_samples() -> None:
|
||||
base_flow = CashFlow(amount=50.0, period_index=1)
|
||||
specs = [
|
||||
CashFlowSpec(cash_flow=CashFlow(amount=-40.0, period_index=0)),
|
||||
CashFlowSpec(cash_flow=base_flow),
|
||||
]
|
||||
config = SimulationConfig(
|
||||
iterations=3,
|
||||
discount_rate=0.0,
|
||||
metrics=(SimulationMetric.NPV,),
|
||||
return_samples=True,
|
||||
seed=11,
|
||||
)
|
||||
|
||||
result = run_monte_carlo(specs, config)
|
||||
|
||||
assert result.samples is not None
|
||||
assert SimulationMetric.NPV in result.samples
|
||||
samples = result.samples[SimulationMetric.NPV]
|
||||
assert isinstance(samples, np.ndarray)
|
||||
assert samples.shape == (config.iterations,)
|
||||
Reference in New Issue
Block a user