From 32a96a27c556f97783265b307f0b487ee084a651 Mon Sep 17 00:00:00 2001 From: zwitschi Date: Sun, 9 Nov 2025 16:54:46 +0100 Subject: [PATCH] feat: enhance database models with metadata and new resource types --- models/__init__.py | 20 ++++- models/financial_input.py | 25 ++++++ models/metadata.py | 146 +++++++++++++++++++++++++++++++++ models/project.py | 2 + models/scenario.py | 15 +++- models/simulation_parameter.py | 7 ++ 6 files changed, 213 insertions(+), 2 deletions(-) create mode 100644 models/metadata.py diff --git a/models/__init__.py b/models/__init__.py index 9b98963..55e89ac 100644 --- a/models/__init__.py +++ b/models/__init__.py @@ -1,6 +1,16 @@ -"""Database models for the CalMiner domain.""" +"""Database models and shared metadata for the CalMiner domain.""" from .financial_input import FinancialCategory, FinancialInput +from .metadata import ( + COST_BUCKET_METADATA, + RESOURCE_METADATA, + STOCHASTIC_VARIABLE_METADATA, + CostBucket, + ResourceDescriptor, + ResourceType, + StochasticVariable, + StochasticVariableDescriptor, +) from .project import MiningOperationType, Project from .scenario import Scenario, ScenarioStatus from .simulation_parameter import DistributionType, SimulationParameter @@ -14,4 +24,12 @@ __all__ = [ "ScenarioStatus", "DistributionType", "SimulationParameter", + "ResourceType", + "CostBucket", + "StochasticVariable", + "RESOURCE_METADATA", + "COST_BUCKET_METADATA", + "STOCHASTIC_VARIABLE_METADATA", + "ResourceDescriptor", + "StochasticVariableDescriptor", ] diff --git a/models/financial_input.py b/models/financial_input.py index 092d77a..eecdea2 100644 --- a/models/financial_input.py +++ b/models/financial_input.py @@ -4,6 +4,18 @@ from datetime import date, datetime from enum import Enum from typing import TYPE_CHECKING +from sqlalchemy import ( + Date, + DateTime, + Enum as SQLEnum, + ForeignKey, + Integer, + Numeric, + String, + Text, +) +from sqlalchemy.orm import Mapped, mapped_column, relationship, validates + from sqlalchemy import ( Date, DateTime, @@ -18,6 +30,7 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.sql import func from config.database import Base +from .metadata import CostBucket if TYPE_CHECKING: # pragma: no cover from .scenario import Scenario @@ -46,6 +59,9 @@ class FinancialInput(Base): category: Mapped[FinancialCategory] = mapped_column( SQLEnum(FinancialCategory), nullable=False ) + cost_bucket: Mapped[CostBucket | None] = mapped_column( + SQLEnum(CostBucket), nullable=True + ) amount: Mapped[float] = mapped_column(Numeric(18, 2), nullable=False) currency: Mapped[str | None] = mapped_column(String(3), nullable=True) effective_date: Mapped[date | None] = mapped_column(Date, nullable=True) @@ -59,5 +75,14 @@ class FinancialInput(Base): 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 + def __repr__(self) -> str: # pragma: no cover return f"FinancialInput(id={self.id!r}, scenario_id={self.scenario_id!r}, name={self.name!r})" diff --git a/models/metadata.py b/models/metadata.py new file mode 100644 index 0000000..7aedc2f --- /dev/null +++ b/models/metadata.py @@ -0,0 +1,146 @@ +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum + + +class ResourceType(str, Enum): + """Primary consumables and resources used in mining operations.""" + + DIESEL = "diesel" + ELECTRICITY = "electricity" + WATER = "water" + EXPLOSIVES = "explosives" + REAGENTS = "reagents" + LABOR = "labor" + EQUIPMENT_HOURS = "equipment_hours" + TAILINGS_CAPACITY = "tailings_capacity" + + +class CostBucket(str, Enum): + """Granular cost buckets aligned with project accounting.""" + + CAPITAL_INITIAL = "capital_initial" + CAPITAL_SUSTAINING = "capital_sustaining" + OPERATING_FIXED = "operating_fixed" + OPERATING_VARIABLE = "operating_variable" + MAINTENANCE = "maintenance" + RECLAMATION = "reclamation" + ROYALTIES = "royalties" + GENERAL_ADMIN = "general_admin" + + +class StochasticVariable(str, Enum): + """Domain variables that typically require probabilistic modelling.""" + + ORE_GRADE = "ore_grade" + RECOVERY_RATE = "recovery_rate" + METAL_PRICE = "metal_price" + OPERATING_COST = "operating_cost" + CAPITAL_COST = "capital_cost" + DISCOUNT_RATE = "discount_rate" + THROUGHPUT = "throughput" + + +@dataclass(frozen=True) +class ResourceDescriptor: + """Describes canonical metadata for a resource type.""" + + unit: str + description: str + + +RESOURCE_METADATA: dict[ResourceType, ResourceDescriptor] = { + ResourceType.DIESEL: ResourceDescriptor(unit="L", description="Diesel fuel consumption"), + ResourceType.ELECTRICITY: ResourceDescriptor(unit="kWh", description="Electrical power usage"), + ResourceType.WATER: ResourceDescriptor(unit="m3", description="Process and dust suppression water"), + ResourceType.EXPLOSIVES: ResourceDescriptor(unit="kg", description="Blasting agent consumption"), + ResourceType.REAGENTS: ResourceDescriptor(unit="kg", description="Processing reagents"), + ResourceType.LABOR: ResourceDescriptor(unit="hours", description="Direct labor hours"), + ResourceType.EQUIPMENT_HOURS: ResourceDescriptor(unit="hours", description="Mobile equipment operating hours"), + ResourceType.TAILINGS_CAPACITY: ResourceDescriptor(unit="m3", description="Tailings storage usage"), +} + + +@dataclass(frozen=True) +class CostBucketDescriptor: + """Describes reporting label and guidance for a cost bucket.""" + + label: str + description: str + + +COST_BUCKET_METADATA: dict[CostBucket, CostBucketDescriptor] = { + CostBucket.CAPITAL_INITIAL: CostBucketDescriptor( + label="Initial Capital", + description="Pre-production capital required to construct the mine", + ), + CostBucket.CAPITAL_SUSTAINING: CostBucketDescriptor( + label="Sustaining Capital", + description="Ongoing capital investments to maintain operations", + ), + CostBucket.OPERATING_FIXED: CostBucketDescriptor( + label="Fixed Operating", + description="Fixed operating costs independent of production rate", + ), + CostBucket.OPERATING_VARIABLE: CostBucketDescriptor( + label="Variable Operating", + description="Costs that scale with throughput or production", + ), + CostBucket.MAINTENANCE: CostBucketDescriptor( + label="Maintenance", + description="Maintenance and repair expenditures", + ), + CostBucket.RECLAMATION: CostBucketDescriptor( + label="Reclamation", + description="Mine closure and reclamation liabilities", + ), + CostBucket.ROYALTIES: CostBucketDescriptor( + label="Royalties", + description="Royalty and streaming obligations", + ), + CostBucket.GENERAL_ADMIN: CostBucketDescriptor( + label="G&A", + description="Corporate and site general and administrative costs", + ), +} + + +@dataclass(frozen=True) +class StochasticVariableDescriptor: + """Metadata describing how a stochastic variable is typically modelled.""" + + unit: str + description: str + + +STOCHASTIC_VARIABLE_METADATA: dict[StochasticVariable, StochasticVariableDescriptor] = { + StochasticVariable.ORE_GRADE: StochasticVariableDescriptor( + unit="g/t", + description="Head grade variability across the ore body", + ), + StochasticVariable.RECOVERY_RATE: StochasticVariableDescriptor( + unit="%", + description="Metallurgical recovery uncertainty", + ), + StochasticVariable.METAL_PRICE: StochasticVariableDescriptor( + unit="$/unit", + description="Commodity price fluctuations", + ), + StochasticVariable.OPERATING_COST: StochasticVariableDescriptor( + unit="$/t", + description="Operating cost per tonne volatility", + ), + StochasticVariable.CAPITAL_COST: StochasticVariableDescriptor( + unit="$", + description="Capital cost overrun/underrun potential", + ), + StochasticVariable.DISCOUNT_RATE: StochasticVariableDescriptor( + unit="%", + description="Discount rate sensitivity", + ), + StochasticVariable.THROUGHPUT: StochasticVariableDescriptor( + unit="t/d", + description="Plant throughput variability", + ), +} diff --git a/models/project.py b/models/project.py index e12b3bc..de6e0ff 100644 --- a/models/project.py +++ b/models/project.py @@ -19,8 +19,10 @@ class MiningOperationType(str, Enum): OPEN_PIT = "open_pit" UNDERGROUND = "underground" + IN_SITU_LEACH = "in_situ_leach" PLACER = "placer" QUARRY = "quarry" + MOUNTAINTOP_REMOVAL = "mountaintop_removal" OTHER = "other" diff --git a/models/scenario.py b/models/scenario.py index 6198278..b7f08b0 100644 --- a/models/scenario.py +++ b/models/scenario.py @@ -4,11 +4,21 @@ from datetime import date, datetime from enum import Enum from typing import TYPE_CHECKING, List -from sqlalchemy import Date, DateTime, Enum as SQLEnum, ForeignKey, Integer, Numeric, String, Text +from sqlalchemy import ( + Date, + DateTime, + Enum as SQLEnum, + ForeignKey, + Integer, + Numeric, + String, + Text, +) from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.sql import func from config.database import Base +from .metadata import ResourceType if TYPE_CHECKING: # pragma: no cover from .financial_input import FinancialInput @@ -42,6 +52,9 @@ class Scenario(Base): end_date: Mapped[date | None] = mapped_column(Date, 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 + ) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now() ) diff --git a/models/simulation_parameter.py b/models/simulation_parameter.py index 6e6f89c..e77b1b2 100644 --- a/models/simulation_parameter.py +++ b/models/simulation_parameter.py @@ -17,6 +17,7 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.sql import func from config.database import Base +from .metadata import ResourceType, StochasticVariable if TYPE_CHECKING: # pragma: no cover from .scenario import Scenario @@ -45,6 +46,12 @@ class SimulationParameter(Base): distribution: Mapped[DistributionType] = mapped_column( SQLEnum(DistributionType), nullable=False ) + variable: Mapped[StochasticVariable | None] = mapped_column( + SQLEnum(StochasticVariable), nullable=True + ) + resource_type: Mapped[ResourceType | None] = mapped_column( + SQLEnum(ResourceType), nullable=True + ) mean_value: Mapped[float | None] = mapped_column(Numeric(18, 4), nullable=True) standard_deviation: Mapped[float | None] = mapped_column(Numeric(18, 4), nullable=True) minimum_value: Mapped[float | None] = mapped_column(Numeric(18, 4), nullable=True)