feat: enhance database models with metadata and new resource types

This commit is contained in:
2025-11-09 16:54:46 +01:00
parent 203a5d08f2
commit 32a96a27c5
6 changed files with 213 additions and 2 deletions

View File

@@ -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 .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 .project import MiningOperationType, Project
from .scenario import Scenario, ScenarioStatus from .scenario import Scenario, ScenarioStatus
from .simulation_parameter import DistributionType, SimulationParameter from .simulation_parameter import DistributionType, SimulationParameter
@@ -14,4 +24,12 @@ __all__ = [
"ScenarioStatus", "ScenarioStatus",
"DistributionType", "DistributionType",
"SimulationParameter", "SimulationParameter",
"ResourceType",
"CostBucket",
"StochasticVariable",
"RESOURCE_METADATA",
"COST_BUCKET_METADATA",
"STOCHASTIC_VARIABLE_METADATA",
"ResourceDescriptor",
"StochasticVariableDescriptor",
] ]

View File

@@ -4,6 +4,18 @@ from datetime import date, datetime
from enum import Enum from enum import Enum
from typing import TYPE_CHECKING 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 ( from sqlalchemy import (
Date, Date,
DateTime, DateTime,
@@ -18,6 +30,7 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.sql import func from sqlalchemy.sql import func
from config.database import Base from config.database import Base
from .metadata import CostBucket
if TYPE_CHECKING: # pragma: no cover if TYPE_CHECKING: # pragma: no cover
from .scenario import Scenario from .scenario import Scenario
@@ -46,6 +59,9 @@ class FinancialInput(Base):
category: Mapped[FinancialCategory] = mapped_column( category: Mapped[FinancialCategory] = mapped_column(
SQLEnum(FinancialCategory), nullable=False 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) amount: Mapped[float] = mapped_column(Numeric(18, 2), nullable=False)
currency: Mapped[str | None] = mapped_column(String(3), nullable=True) currency: Mapped[str | None] = mapped_column(String(3), nullable=True)
effective_date: Mapped[date | None] = mapped_column(Date, 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") 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 def __repr__(self) -> str: # pragma: no cover
return f"FinancialInput(id={self.id!r}, scenario_id={self.scenario_id!r}, name={self.name!r})" return f"FinancialInput(id={self.id!r}, scenario_id={self.scenario_id!r}, name={self.name!r})"

146
models/metadata.py Normal file
View File

@@ -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",
),
}

View File

@@ -19,8 +19,10 @@ class MiningOperationType(str, Enum):
OPEN_PIT = "open_pit" OPEN_PIT = "open_pit"
UNDERGROUND = "underground" UNDERGROUND = "underground"
IN_SITU_LEACH = "in_situ_leach"
PLACER = "placer" PLACER = "placer"
QUARRY = "quarry" QUARRY = "quarry"
MOUNTAINTOP_REMOVAL = "mountaintop_removal"
OTHER = "other" OTHER = "other"

View File

@@ -4,11 +4,21 @@ from datetime import date, datetime
from enum import Enum from enum import Enum
from typing import TYPE_CHECKING, List 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.orm import Mapped, mapped_column, relationship
from sqlalchemy.sql import func from sqlalchemy.sql import func
from config.database import Base from config.database import Base
from .metadata import ResourceType
if TYPE_CHECKING: # pragma: no cover if TYPE_CHECKING: # pragma: no cover
from .financial_input import FinancialInput from .financial_input import FinancialInput
@@ -42,6 +52,9 @@ class Scenario(Base):
end_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) 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( created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now() DateTime(timezone=True), nullable=False, server_default=func.now()
) )

View File

@@ -17,6 +17,7 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.sql import func from sqlalchemy.sql import func
from config.database import Base from config.database import Base
from .metadata import ResourceType, StochasticVariable
if TYPE_CHECKING: # pragma: no cover if TYPE_CHECKING: # pragma: no cover
from .scenario import Scenario from .scenario import Scenario
@@ -45,6 +46,12 @@ class SimulationParameter(Base):
distribution: Mapped[DistributionType] = mapped_column( distribution: Mapped[DistributionType] = mapped_column(
SQLEnum(DistributionType), nullable=False 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) mean_value: Mapped[float | None] = mapped_column(Numeric(18, 4), nullable=True)
standard_deviation: 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) minimum_value: Mapped[float | None] = mapped_column(Numeric(18, 4), nullable=True)