Files
palladium/core/tei_client/models.py
Robert Helewka a2420ed692 refactor: restructure repo into core/app modules with per-study folders
Reorganize Palladium codebase into a modular architecture with `core/`
shared logic and `app/` Streamlit UI, separating per-study assets into
`studies/YYYYMM_<Vendor>/` folders containing notebooks, seed data, and
configuration. Update README to reflect new structure, add `.gitignore`
entries for `.env` and study exports, and refresh component documentation.
2026-05-20 22:28:12 -04:00

195 lines
6.6 KiB
Python

"""
Lightweight dataclasses for TEI API responses.
These are *optional* — the client returns raw dicts. Use these when you want
attribute access or IDE help in notebooks.
>>> from core.tei_client import TEIClient, TEIReport
>>> raw = TEIClient().get_report("abc123")
>>> report = TEIReport.from_dict(raw)
>>> report.discount_rate
0.10
"""
from __future__ import annotations
from dataclasses import dataclass, field
from decimal import Decimal
from typing import Any
def _as_float(value: Any, default: float = 0.0) -> float:
"""Coerce Decimal/str/None into float."""
if value is None or value == "":
return default
if isinstance(value, Decimal):
return float(value)
try:
return float(value)
except (TypeError, ValueError):
return default
@dataclass
class TEIReport:
"""A TEI report template (model definition)."""
id: str # public_id
name: str
vendor: str
version: str
description: str = ""
analysis_period_years: int = 3
discount_rate: float = 0.10
status: str = "active"
field_count: int = 0
instance_count: int = 0
created_at: str = ""
updated_at: str = ""
@classmethod
def from_dict(cls, data: dict) -> TEIReport:
return cls(
id=str(data.get("id", "")),
name=data.get("name", ""),
vendor=data.get("vendor", ""),
version=data.get("version", ""),
description=data.get("description", "") or "",
analysis_period_years=int(data.get("analysis_period_years") or 3),
discount_rate=_as_float(data.get("discount_rate"), 0.10),
status=data.get("status", "active"),
field_count=int(data.get("field_count") or 0),
instance_count=int(data.get("instance_count") or 0),
created_at=data.get("created_at", "") or "",
updated_at=data.get("updated_at", "") or "",
)
@dataclass
class TEIField:
"""A field definition belonging to a TEI report template."""
id: int
table: str # 'benefits' | 'costs'
field_key: str
label: str
field_type: str # currency | percentage | integer | decimal | text
description: str = ""
category: str = ""
default_value: str = ""
is_annual: bool = True
risk_adjustment: float | None = None
sort_order: int = 0
is_required: bool = False
source_notes: str = ""
@classmethod
def from_dict(cls, data: dict) -> TEIField:
ra = data.get("risk_adjustment")
return cls(
id=int(data.get("id") or 0),
table=data.get("table", "benefits"),
field_key=data.get("field_key", ""),
label=data.get("label", ""),
field_type=data.get("field_type", "decimal"),
description=data.get("description", "") or "",
category=data.get("category", "") or "",
default_value=data.get("default_value", "") or "",
is_annual=bool(data.get("is_annual", True)),
risk_adjustment=_as_float(ra) if ra is not None else None,
sort_order=int(data.get("sort_order") or 0),
is_required=bool(data.get("is_required", False)),
source_notes=data.get("source_notes", "") or "",
)
@dataclass
class TEIValue:
"""
A field value for a specific TEI tool instance.
The exact wire format is not fully pinned in the OpenAPI spec; we use a
convention that the client `_normalize_value` helper builds:
- annual fields: {field_key, year_values: {"1": ..., "2": ...},
risk_adjustment, notes}
- non-annual scalar: {field_key, value, risk_adjustment, notes}
"""
field_key: str
year_values: dict[str, float] = field(default_factory=dict)
value: float | None = None
risk_adjustment: float | None = None
notes: str = ""
@classmethod
def from_dict(cls, data: dict) -> TEIValue:
ra = data.get("risk_adjustment")
yv_raw = data.get("year_values") or {}
year_values = {str(k): _as_float(v) for k, v in yv_raw.items()}
v = data.get("value")
return cls(
field_key=data.get("field_key", ""),
year_values=year_values,
value=_as_float(v) if v is not None else None,
risk_adjustment=_as_float(ra) if ra is not None else None,
notes=data.get("notes", "") or "",
)
def to_dict(self) -> dict:
out: dict[str, Any] = {"field_key": self.field_key}
if self.year_values:
out["year_values"] = self.year_values
if self.value is not None:
out["value"] = self.value
if self.risk_adjustment is not None:
out["risk_adjustment"] = self.risk_adjustment
if self.notes:
out["notes"] = self.notes
return out
@dataclass
class TEISummary:
"""Calculated financial summary for a TEI tool instance."""
npv: float = 0.0
roi: float = 0.0
payback_months: float | None = None
discount_rate: float = 0.10
analysis_years: int = 3
total_benefits_nominal: float = 0.0
total_benefits_risk_adjusted: float = 0.0
total_benefits_pv: float = 0.0
total_costs_nominal: float = 0.0
total_costs_risk_adjusted: float = 0.0
total_costs_pv: float = 0.0
yearly_breakdown: list[dict] = field(default_factory=list)
category_breakdown: list[dict] = field(default_factory=list)
raw: dict = field(default_factory=dict)
@classmethod
def from_dict(cls, data: dict) -> TEISummary:
return cls(
npv=_as_float(data.get("npv")),
roi=_as_float(data.get("roi")),
payback_months=(
_as_float(data.get("payback_months"))
if data.get("payback_months") is not None
else None
),
discount_rate=_as_float(data.get("discount_rate"), 0.10),
analysis_years=int(data.get("analysis_years") or 3),
total_benefits_nominal=_as_float(data.get("total_benefits_nominal")),
total_benefits_risk_adjusted=_as_float(
data.get("total_benefits_risk_adjusted")
),
total_benefits_pv=_as_float(data.get("total_benefits_pv")),
total_costs_nominal=_as_float(data.get("total_costs_nominal")),
total_costs_risk_adjusted=_as_float(data.get("total_costs_risk_adjusted")),
total_costs_pv=_as_float(data.get("total_costs_pv")),
yearly_breakdown=list(data.get("yearly_breakdown") or []),
category_breakdown=list(data.get("category_breakdown") or []),
raw=dict(data),
)