"""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 __future__ import annotations 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, )