feat: add GenesysCX study and fix Streamlit chart key collisions
- Add 202512_GenesysCX TEI study (config, seed data, notebooks, README) with NPV $10.8M / ROI 266% including AI-token cost line - Add explicit `key` parameter to all chart wrappers in app/components to prevent StreamlitDuplicateElementId errors when the same figure type renders across Summary/Benefits/Costs tabs - Render benefits bar and cost pie charts on their respective tabs - Add benefits_vs_costs_by_year chart wrapper
This commit is contained in:
@@ -0,0 +1,131 @@
|
||||
"""
|
||||
Excel / CSV / JSON export.
|
||||
|
||||
Excel uses openpyxl via pandas — multi-sheet workbooks readable in
|
||||
Excel 2019+. JSON round-trips the full input state (sites, takeouts,
|
||||
feature scopes) so a scenario can be saved and reloaded.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from .inputs import CostTakeout, FeatureScope, SiteInput
|
||||
from .meters import Confidence, TokenMeter
|
||||
from .rollout import RolloutPlan
|
||||
|
||||
|
||||
def meters_dataframe(meters: dict[str, TokenMeter]) -> pd.DataFrame:
|
||||
"""Meter catalogue as a display/export-ready DataFrame."""
|
||||
return pd.DataFrame(
|
||||
[
|
||||
{
|
||||
"feature": m.feature,
|
||||
"meter_type": m.meter_type.value,
|
||||
"units_per_token": m.units_per_token or None,
|
||||
"tokens_per_unit": m.tokens_per_unit,
|
||||
"confidence": f"{m.confidence.icon} {m.confidence.value}",
|
||||
"notes": m.notes,
|
||||
"source": m.source_url or "",
|
||||
}
|
||||
for m in meters.values()
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def sites_dataframe(sites: list[SiteInput]) -> pd.DataFrame:
|
||||
rows = []
|
||||
for s in sites:
|
||||
d = dataclasses.asdict(s)
|
||||
d["languages"] = ", ".join(d["languages"])
|
||||
rows.append(d)
|
||||
return pd.DataFrame(rows)
|
||||
|
||||
|
||||
def export_excel(
|
||||
sheets: dict[str, pd.DataFrame],
|
||||
path: str | Path,
|
||||
) -> Path:
|
||||
"""Write a multi-sheet Excel workbook. Sheet names are truncated to
|
||||
Excel's 31-character limit."""
|
||||
path = Path(path)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with pd.ExcelWriter(path, engine="openpyxl") as writer:
|
||||
for name, df in sheets.items():
|
||||
df.to_excel(writer, sheet_name=name[:31], index=False)
|
||||
return path
|
||||
|
||||
|
||||
def export_csv(df: pd.DataFrame, path: str | Path) -> Path:
|
||||
path = Path(path)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
df.to_csv(path, index=False)
|
||||
return path
|
||||
|
||||
|
||||
# ── JSON scenario save / load ────────────────────────────────────────
|
||||
|
||||
def scenario_state_to_json(
|
||||
sites: list[SiteInput],
|
||||
takeouts: list[CostTakeout],
|
||||
feature_scopes: list[FeatureScope],
|
||||
path: str | Path | None = None,
|
||||
rollout: RolloutPlan | None = None,
|
||||
) -> str:
|
||||
"""Serialize the full input state; optionally write to ``path``."""
|
||||
state = {
|
||||
"sites": [dataclasses.asdict(s) for s in sites],
|
||||
"takeouts": [
|
||||
{**dataclasses.asdict(t), "confidence": t.confidence.value}
|
||||
for t in takeouts
|
||||
],
|
||||
"feature_scopes": [
|
||||
{
|
||||
**dataclasses.asdict(f),
|
||||
"adoption_curve": {str(k): v for k, v in f.adoption_curve.items()},
|
||||
}
|
||||
for f in feature_scopes
|
||||
],
|
||||
}
|
||||
if rollout is not None:
|
||||
state["rollout"] = dataclasses.asdict(rollout)
|
||||
text = json.dumps(state, indent=2)
|
||||
if path is not None:
|
||||
Path(path).write_text(text)
|
||||
return text
|
||||
|
||||
|
||||
def scenario_state_from_json(
|
||||
source: str | Path,
|
||||
) -> tuple[list[SiteInput], list[CostTakeout], list[FeatureScope], RolloutPlan | None]:
|
||||
"""Inverse of :func:`scenario_state_to_json`. ``source`` is a JSON
|
||||
string or a file path. The fourth element is None for legacy files
|
||||
saved without a rollout plan."""
|
||||
raw = (
|
||||
Path(source).read_text()
|
||||
if isinstance(source, Path) or (isinstance(source, str) and source.strip().endswith(".json"))
|
||||
else str(source)
|
||||
)
|
||||
state = json.loads(raw)
|
||||
sites = [SiteInput(**s) for s in state["sites"]]
|
||||
takeouts = [
|
||||
CostTakeout(**{**t, "confidence": Confidence(t["confidence"])})
|
||||
for t in state["takeouts"]
|
||||
]
|
||||
scopes = [
|
||||
FeatureScope(
|
||||
**{
|
||||
**f,
|
||||
"adoption_curve": {int(k): v for k, v in f["adoption_curve"].items()},
|
||||
}
|
||||
)
|
||||
for f in state["feature_scopes"]
|
||||
]
|
||||
rollout = (
|
||||
RolloutPlan(**state["rollout"]) if "rollout" in state else None
|
||||
)
|
||||
return sites, takeouts, scopes, rollout
|
||||
Reference in New Issue
Block a user