Files
palladium/studies/202512_GenesysCX/ctm-token-calculator/tokencalc/inputs.py
2026-06-10 14:28:16 -04:00

156 lines
5.6 KiB
Python

"""
Input bundles — validated dataclasses, no untyped dicts.
All volumes are MONTHLY; all AHT/ACW figures are SECONDS; all labour
costs are ANNUAL fully-loaded USD.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from .meters import Confidence
#: Sanity bounds for handle times (seconds).
AHT_MIN_SECONDS = 10
AHT_MAX_SECONDS = 3600
#: Working hours per FTE-year used to derive per-second labour rates.
WORKING_HOURS_PER_YEAR = 2_080
WORKING_SECONDS_PER_YEAR = WORKING_HOURS_PER_YEAR * 3600
@dataclass
class SiteInput:
site_name: str # "NAM", "EMEA", "AUZ", "APAC HK", …
region_pricing: str # "US", "AU", "EU", "APAC"
agents: int # excluding supervisors
supervisors: int
voice_volume_monthly: int
email_volume_monthly: int
chat_volume_monthly: int
sms_volume_monthly: int
voice_aht_seconds: int
email_aht_seconds: int
chat_aht_seconds: int
voice_acw_seconds: int
fully_loaded_agent_cost_annual: float
fully_loaded_supervisor_cost_annual: float
licence_type: str = "named" # "named" | "concurrent"
languages: list[str] = field(default_factory=list)
def __post_init__(self) -> None:
if self.licence_type not in ("named", "concurrent"):
raise ValueError(
f"{self.site_name}: licence_type must be 'named' or 'concurrent', "
f"got {self.licence_type!r}"
)
if self.agents < 0 or self.supervisors < 0:
raise ValueError(f"{self.site_name}: agent/supervisor counts must be >= 0")
for name in (
"voice_volume_monthly",
"email_volume_monthly",
"chat_volume_monthly",
"sms_volume_monthly",
):
if getattr(self, name) < 0:
raise ValueError(f"{self.site_name}: {name} must be >= 0")
for name in ("voice_aht_seconds", "email_aht_seconds", "chat_aht_seconds"):
v = getattr(self, name)
if v and not AHT_MIN_SECONDS <= v <= AHT_MAX_SECONDS:
raise ValueError(
f"{self.site_name}: {name}={v}s outside sensible bounds "
f"({AHT_MIN_SECONDS}-{AHT_MAX_SECONDS}s)"
)
if self.voice_acw_seconds < 0:
raise ValueError(f"{self.site_name}: voice_acw_seconds must be >= 0")
@property
def named_users(self) -> int:
return self.agents + self.supervisors
@property
def agent_cost_per_second(self) -> float:
"""Fully-loaded agent labour rate per working second (DBZ-safe)."""
return self.fully_loaded_agent_cost_annual / WORKING_SECONDS_PER_YEAR
@property
def supervisor_cost_per_second(self) -> float:
return self.fully_loaded_supervisor_cost_annual / WORKING_SECONDS_PER_YEAR
@dataclass
class FeatureScope:
"""Which feature is enabled at which sites, in which phase.
``phase`` is the model year (1-3) the feature switches on;
``adoption_curve`` maps model year -> adoption fraction (0.0-1.0)
applied to consumption-metered features (per-user licenses are paid
in full from the phase year onward).
"""
feature: str
enabled_sites: list[str]
phase: int = 1
adoption_curve: dict[int, float] = field(default_factory=dict)
deflection_target: float | None = None
eligibility_pct: float | None = None
def __post_init__(self) -> None:
if self.phase < 1:
raise ValueError(f"{self.feature}: phase must be >= 1")
for year, pct in self.adoption_curve.items():
if not 0.0 <= pct <= 1.0:
raise ValueError(
f"{self.feature}: adoption_curve[{year}]={pct} outside 0-1"
)
for name in ("deflection_target", "eligibility_pct"):
v = getattr(self, name)
if v is not None and not 0.0 <= v <= 1.0:
raise ValueError(f"{self.feature}: {name}={v} outside 0-1")
def active(self, site_name: str, year: int) -> bool:
return site_name in self.enabled_sites and year >= self.phase
def adoption(self, year: int) -> float:
"""Adoption fraction for ``year`` (1.0 when no curve given)."""
if not self.adoption_curve:
return 1.0
if year in self.adoption_curve:
return self.adoption_curve[year]
# Past the last defined year → hold the last value.
last = max(self.adoption_curve)
return self.adoption_curve[last] if year > last else 0.0
@dataclass
class CostTakeout:
"""A retired platform/licence whose cost the programme reclaims.
``start_month`` (1-12, within ``start_year``) prorates the first
active year — e.g. NICE IEX can only be switched off once NAM is
live, so start_year=1, start_month=7 reclaims 6/12 of Y1.
"""
name: str # "NICE IEX (NAM)", "Legacy CC platform", …
annual_cost: float
start_year: int = 1
confidence: Confidence = Confidence.ESTIMATED
notes: str = ""
start_month: int = 1
def __post_init__(self) -> None:
if self.annual_cost < 0:
raise ValueError(f"{self.name}: annual_cost must be >= 0")
if self.start_year < 1:
raise ValueError(f"{self.name}: start_year must be >= 1")
if not 1 <= self.start_month <= 12:
raise ValueError(f"{self.name}: start_month must be 1-12")
def value_in_year(self, year: int) -> float:
if year < self.start_year:
return 0.0
if year == self.start_year:
return self.annual_cost * (12 - (self.start_month - 1)) / 12
return self.annual_cost