Wiki:Packs/Dedicated Pool Performance/Modules

From WFM Labs

Modules for the Dedicated Pool Performance pack (CP-WFM-020). Save each block under the filename in its heading and upload it as project knowledge with the rest of the pack. dpp.py needs numpy, plus pandas and openpyxl for the Excel output; all are present in Claude's analysis environment. explorer.html needs nothing: open it in any browser.

Read Wiki:Packs/Dedicated Pool Performance first.

Block 5 — dpp.py

"""dpp.py — Dedicated Pool Performance model.

A fixed pool of agents serves a seasonal book of phone and deferred (email) work. For each
month the model answers three questions:

  1. What does the work require?   required FTE at the service-level target
  2. What does the pool deliver?   service level, abandonment and email shortfall at the FTE held
  3. How sensitive is that?        service level per FTE, per 1% of volume, per point of shrink

Phone is sized interval by interval (Erlang C, or Erlang A when callers abandon) over an
intraday arrival profile. Deferred work is sized as workload: volume x handle time / occupancy.
Shrinkage is applied once, on the supply side: a productive FTE-month is paid hours x (1 - s)
("net", the correct form) or paid hours / (1 + s) ("gross_up", the form many plans use, which
understates the requirement as shrinkage rises). Calibration reports which one the plan uses.

Usage:
    python dpp.py params.json                      # summary table
    python dpp.py params.json --xlsx results.xlsx  # full workbook
    python dpp.py params.json --calibrate          # back-solve email handle time to the plan
"""
from __future__ import annotations

import argparse
import calendar
import copy
import itertools
import json
import math
from functools import lru_cache

import numpy as np

MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
DRIVERS = ["phone", "aht", "email", "shrink"]


# ---------------------------------------------------------------- queueing core

@lru_cache(maxsize=200_000)
def erlang_c(lam_hr: float, aht_s: float, n: int, t_s: float) -> tuple:
    """Erlang C (M/M/N). Returns (sl, pdelay, occ). Nobody abandons."""
    a = lam_hr * aht_s / 3600.0
    if lam_hr <= 0:
        return (1.0, 0.0, 0.0)
    if n <= a:
        return (0.0, 1.0, 1.0)
    b = 1.0
    for j in range(1, n + 1):  # Erlang B recursion, stable for any n
        b = a * b / (j + a * b)
    c = n * b / (n - a * (1 - b))
    sl = 1.0 - c * math.exp(-(n - a) * t_s / aht_s)
    return (max(0.0, min(1.0, sl)), c, a / n)


@lru_cache(maxsize=200_000)
def erlang_a(lam_hr: float, aht_s: float, n: int, patience_s: float, t_s: float) -> tuple:
    """Erlang A (M/M/N+M), exact state distribution. Returns (sl, aban, occ).

    sl is the share of ALL arrivals answered within t_s; abandoned callers count against it.
    The wait of an arriving caller is computed on the absorbing chain over queue position:
    from k ahead, the position advances at N*mu + k*theta and the caller abandons at theta.
    """
    if lam_hr <= 0:
        return (1.0, 0.0, 0.0)
    if n <= 0:
        return (0.0, 1.0, 0.0)
    lam, mu, th = lam_hr / 3600.0, 1.0 / aht_s, 1.0 / patience_s
    a = lam / mu
    p = [1.0]
    for j in range(1, n + 1):
        p.append(p[-1] * a / j)
    tail, total = [p[n]], sum(p)
    k = 0
    while k < 20_000:
        k += 1
        nxt = tail[-1] * lam / (n * mu + k * th)
        tail.append(nxt)
        total += nxt
        if nxt < 1e-13 * total and k > a:
            break
    probs = np.array(p[:n] + tail)
    probs /= probs.sum()
    q = probs[n:]
    ks = np.arange(len(q))
    aban = th * float((ks * q).sum()) / lam
    occ = lam * (1 - aban) / (n * mu)
    served = float(probs[:n].sum())
    adv_rate = n * mu + ks * th
    dt = 0.2 / float(adv_rate[-1] + th)
    steps = max(1, int(math.ceil(t_s / dt)))
    dt = t_s / steps
    adv, abn = adv_rate * dt, th * dt
    v = q.copy()
    for _ in range(steps):
        moved = v * adv
        v = v * (1.0 - adv - abn)
        served += moved[0]
        v[:-1] += moved[1:]
    return (max(0.0, min(1.0, served)), aban, min(1.0, occ))


def interval_kpi(lam_hr, aht_s, n, svc) -> tuple:
    """(sl, aban, occ) for a possibly fractional n, by linear interpolation between integers."""
    if lam_hr <= 0:
        return (1.0, 0.0, 0.0)

    def at(k):
        if k <= 0:
            return (0.0, 1.0 if svc["model"] == "A" else 0.0, 0.0)
        if svc["model"] == "A":
            return erlang_a(round(lam_hr, 6), aht_s, k, svc["patience_s"], svc["answer_s"])
        sl, _, occ = erlang_c(round(lam_hr, 6), aht_s, k, svc["answer_s"])
        return (sl, 0.0, occ)

    lo = int(math.floor(n))
    w = n - lo
    a, b = at(lo), at(lo + 1) if w > 1e-9 else at(lo)
    return tuple((1 - w) * x + w * y for x, y in zip(a, b))


def required_agents(lam_hr, aht_s, svc) -> int:
    """Smallest integer N that meets the SL target and the occupancy cap. Sized on Erlang C,
    as almost every planning tool sizes; Erlang A is used to score delivery, not to size."""
    if lam_hr <= 0:
        return 0
    a = lam_hr * aht_s / 3600.0
    n = max(1, int(math.floor(a)) + 1)
    while True:
        sl, _, occ = erlang_c(round(lam_hr, 6), aht_s, n, svc["answer_s"])
        if sl >= svc["target_sl"] and occ <= svc["occupancy_cap"]:
            return n
        n += 1


def required_agents_frac(lam_hr, aht_s, svc) -> float:
    """The continuous agent count at which the interpolated SL meets the target (or the occupancy
    cap binds). Used only to SHAPE how scarce hours are spread across intervals: it moves smoothly
    with volume and handle time, where the integer requirement moves in steps."""
    if lam_hr <= 0:
        return 0.0
    n = required_agents(lam_hr, aht_s, svc)
    hi = erlang_c(round(lam_hr, 6), aht_s, n, svc["answer_s"])[0]
    lo = erlang_c(round(lam_hr, 6), aht_s, n - 1, svc["answer_s"])[0] if n > 1 else 0.0
    frac = n - 1 + ((svc["target_sl"] - lo) / (hi - lo) if hi > lo else 1.0)
    a = lam_hr * aht_s / 3600.0
    return max(min(frac, float(n)), a / svc["occupancy_cap"])


# ---------------------------------------------------------------- calendar and profile

def default_profile(n_intervals: int) -> list:
    """A generic single-hump intraday shape. An [estimated] placeholder, never a client fact."""
    x = (np.arange(n_intervals) + 0.5) / n_intervals
    w = 0.35 + np.sin(np.pi * x) ** 1.5
    return list(w / w.sum())


def business_days(year: int, month_idx: int, days_per_week: int) -> int:
    _, ndays = calendar.monthrange(year, month_idx + 1)
    return sum(1 for d in range(1, ndays + 1)
               if calendar.weekday(year, month_idx + 1, d) < days_per_week)


def prepare(params: dict) -> dict:
    """Fill defaults and derived fields. Returns a new dict; the input is not modified."""
    p = copy.deepcopy(params)
    svc = p.setdefault("service", {})
    svc.setdefault("target_sl", 0.80); svc.setdefault("answer_s", 30)
    svc.setdefault("model", "C"); svc.setdefault("patience_s", 180)
    svc.setdefault("occupancy_cap", 0.95)
    h = p.setdefault("hours", {})
    h.setdefault("open_hours_per_day", 10); h.setdefault("days_per_week", 5)
    h.setdefault("interval_min", 30); h.setdefault("paid_hours_per_fte_day", 8)
    n_int = int(round(h["open_hours_per_day"] * 60 / h["interval_min"]))
    prof = h.get("profile") or default_profile(n_int)
    if len(prof) != n_int:
        raise ValueError(f"profile has {len(prof)} intervals; open hours imply {n_int}")
    s = float(sum(prof))
    h["profile"] = [x / s for x in prof]
    e = p.setdefault("email", {})
    e.setdefault("handle_s", 600); e.setdefault("occupancy", 0.85); e.setdefault("blend_credit", 0.0)
    p.setdefault("policy", "proportional")
    p.setdefault("shrink_mode", "net")
    if p["shrink_mode"] not in ("net", "gross_up"):
        raise ValueError("shrink_mode must be net or gross_up")
    if p["policy"] not in ("proportional", "phones_first", "email_first"):
        raise ValueError("policy must be proportional, phones_first or email_first")
    year = int(p.get("year", 2026))
    for i, m in enumerate(p["months"]):
        m.setdefault("month", MONTHS[i])
        m.setdefault("business_days", business_days(year, i, h["days_per_week"]))
    return p


def month_inputs(m: dict, basis: str) -> dict:
    """Driver values for one month. basis 'fc' = forecast; 'act' = actual where it exists."""
    out = {}
    for d in DRIVERS:
        v = m[d]
        val = v.get("fc")
        if basis == "act" and v.get("act") not in (None, 0):
            val = v["act"]
        out[d] = float(val)
    return out


# ---------------------------------------------------------------- the monthly model

def evaluate(p: dict, m: dict, x: dict, fte: float, levers: dict | None = None) -> dict:
    """Requirement and delivery for one month.

    x: driver values {phone, aht, email, shrink}. fte: FTE held (before shrinkage).
    levers (optional): {volume_mult, aht_mult, shrink_delta, email_mult}.
    """
    lv = levers or {}
    svc, h, em = p["service"], p["hours"], p["email"]
    days = m["business_days"]
    dt_h = h["interval_min"] / 60.0
    phone = x["phone"] * lv.get("volume_mult", 1.0)
    email = x["email"] * lv.get("email_mult", lv.get("volume_mult", 1.0))
    aht = x["aht"] * lv.get("aht_mult", 1.0)
    shrink = min(0.95, max(0.0, x["shrink"] + lv.get("shrink_delta", 0.0)))

    lam = [phone * s / days / dt_h for s in h["profile"]]          # calls per hour, per interval
    n_req = [required_agents(l, aht, svc) for l in lam]           # sizing: whole agents
    n_shape = [required_agents_frac(l, aht, svc) for l in lam]    # spreading: smooth curve
    phone_hours_req = sum(n_req) * dt_h * days
    shape_hours = sum(n_shape) * dt_h * days
    erlang_hours = phone * aht / 3600.0
    idle_hours = max(0.0, shape_hours - erlang_hours)
    email_hours_req = max(0.0, email * em["handle_s"] / 3600.0 / em["occupancy"]
                          - em["blend_credit"] * idle_hours)
    paid = h["paid_hours_per_fte_day"] * days
    prod_per_fte = paid * (1 - shrink) if p["shrink_mode"] == "net" else paid / (1 + shrink)
    req_hours = phone_hours_req + email_hours_req
    req_fte = req_hours / prod_per_fte if prod_per_fte > 0 else float("inf")
    avail = fte * prod_per_fte

    # Delivery splits the hours held between phone and deferred work on the SMOOTH phone need,
    # so a one-agent step in some interval's integer requirement cannot jolt the split.
    pol, need = p["policy"], shape_hours + email_hours_req
    if avail >= need:
        email_avail = email_hours_req
    elif pol == "phones_first":
        email_avail = max(0.0, avail - shape_hours)
    elif pol == "email_first":
        email_avail = min(email_hours_req, avail)
    else:
        email_avail = email_hours_req * avail / need if need else 0.0
    phone_avail = max(0.0, avail - email_avail)
    ratio = phone_avail / shape_hours if shape_hours else 1.0

    calls = sl_w = ab_w = 0.0
    peak_agents = 0.0
    for l, n in zip(lam, n_shape):
        c = l * dt_h
        sl, ab, _ = interval_kpi(l, aht, n * ratio, svc)
        calls += c; sl_w += c * sl; ab_w += c * ab
        peak_agents = max(peak_agents, n * ratio)
    sl = sl_w / calls if calls else 1.0
    email_short_h = max(0.0, email_hours_req - email_avail)
    return dict(
        month=m["month"], fte=fte, req_fte=req_fte, gap_fte=fte - req_fte,
        sl=sl, aban=ab_w / calls if calls else 0.0,
        phone_hours_req=phone_hours_req, email_hours_req=email_hours_req,
        avail_hours=avail, phone_ratio=ratio, peak_phone_agents=peak_agents,
        phone_occupancy=erlang_hours / phone_avail if phone_avail else 1.0,
        email_unworked=email_short_h * 3600.0 * em["occupancy"] / em["handle_s"],
        phone=phone, aht=aht, email=email, shrink=shrink,
    )


def fte_for(p, mo, basis, override):
    if override is not None:
        return float(override)
    return float(mo.get("fte_available") or p["contract_fte"])


def year(p: dict, basis: str = "fc", fte: float | None = None, levers: dict | None = None) -> list:
    """Evaluate every month. fte=None uses each month's fte_available (else the contract)."""
    return [evaluate(p, mo, month_inputs(mo, basis), fte_for(p, mo, basis, fte), levers)
            for mo in p["months"]]


def annual_sl(rows: list) -> float:
    calls = sum(r["phone"] for r in rows)
    return sum(r["sl"] * r["phone"] for r in rows) / calls if calls else 1.0


# ---------------------------------------------------------------- sensitivity and attribution

def power_of_one(p, mo, basis="fc", fte=None, span=3) -> list:
    """Service level at the FTE held, plus and minus whole FTE."""
    x = month_inputs(mo, basis)
    base = fte_for(p, mo, basis, fte)
    return [dict(delta=k, fte=base + k, **{key: evaluate(p, mo, x, base + k)[key]
                                           for key in ("sl", "aban", "email_unworked")})
            for k in range(-span, span + 1) if base + k > 0]


def elasticities(p, mo, basis="fc", fte=None) -> dict:
    """Service-level points moved by one unit of each lever, at the FTE held."""
    x = month_inputs(mo, basis)
    f = fte_for(p, mo, basis, fte)
    base = evaluate(p, mo, x, f)["sl"]

    def d(**lv):
        return 100 * (evaluate(p, mo, x, f, lv)["sl"] - base)
    return {
        "sl": base,
        "per_fte_plus": 100 * (evaluate(p, mo, x, f + 1)["sl"] - base),
        "per_fte_minus": 100 * (evaluate(p, mo, x, f - 1)["sl"] - base),
        "per_volume_plus_1pct": d(volume_mult=1.01),
        "per_volume_plus_10pct": d(volume_mult=1.10),
        "per_aht_plus_1pct": d(aht_mult=1.01),
        "per_shrink_plus_1pt": d(shrink_delta=0.01),
    }


def attribution(p, mo, fte=None) -> dict:
    """Decompose actual-basis FTE gap into supply delivery, planned structure and forecast misses.

    gap_actual = (held - contract) + (contract - req_fc) + (req_fc - req_act)
    The last term is split across drivers by exact Shapley values (order does not matter).
    """
    fc, act = month_inputs(mo, "fc"), month_inputs(mo, "act")
    held = fte_for(p, mo, "act", fte)
    contract = float(p["contract_fte"])

    def req(keys):
        x = {d: (act[d] if d in keys else fc[d]) for d in DRIVERS}
        return evaluate(p, mo, x, held)["req_fte"]

    phi = {}
    nd = len(DRIVERS)
    for d in DRIVERS:
        others = [o for o in DRIVERS if o != d]
        tot = 0.0
        for r in range(nd):
            for s in itertools.combinations(others, r):
                wgt = math.factorial(r) * math.factorial(nd - r - 1) / math.factorial(nd)
                tot += wgt * (req(set(s) | {d}) - req(set(s)))
        phi[d] = -tot  # more requirement = more negative gap
    req_fc, req_act = req(set()), req(set(DRIVERS))
    return dict(month=mo["month"], gap_actual=held - req_act, supply_delivery=held - contract,
                structural=contract - req_fc, **{f"miss_{d}": v for d, v in phi.items()},
                check=held - contract + contract - req_fc + sum(phi.values()) - (held - req_act))


# ---------------------------------------------------------------- calibration

def calibrate(p: dict) -> dict:
    """Back-solve email handle time so the model's forecast-basis requirement matches the plan's
    'fte_required_plan' row (least squares over the months that carry it), under each shrinkage
    convention. Returns the better fit plus both, so the plan's convention is identified, not assumed."""
    rows = [mo for mo in p["months"] if mo.get("fte_required_plan")]
    if not rows:
        raise ValueError("no fte_required_plan values to calibrate against")

    def fit(mode):
        q = copy.deepcopy(p); q["shrink_mode"] = mode

        def sse(hs):
            q["email"]["handle_s"] = hs
            return sum((evaluate(q, mo, month_inputs(mo, "fc"), 1)["req_fte"] - mo["fte_required_plan"]) ** 2
                       for mo in rows)
        lo, hi = 30.0, 7200.0
        for _ in range(60):  # golden-section search; sse is unimodal in handle time
            a, b = lo + 0.382 * (hi - lo), lo + 0.618 * (hi - lo)
            if sse(a) < sse(b):
                hi = b
            else:
                lo = a
        hs = (lo + hi) / 2
        rmse = math.sqrt(sse(hs) / len(rows))
        resid = [(mo["month"], mo["fte_required_plan"],
                  evaluate(q, mo, month_inputs(mo, "fc"), 1)["req_fte"]) for mo in rows]
        return dict(shrink_mode=mode, handle_s=hs, rmse=rmse, residuals=resid)
    fits = [fit("net"), fit("gross_up")]
    best = min(fits, key=lambda f: f["rmse"])
    return dict(best, fits=fits)


def calibrate_patience(p: dict) -> dict:
    """Fit mean caller patience so modelled abandonment matches the months that carry an actual
    abandonment rate ('aban_act'), at actual drivers and the FTE held. Erlang A only.
    Abandonment falls as patience rises, so a bisection on log patience is enough."""
    rows = [mo for mo in p["months"] if mo.get("aban_act") is not None]
    if not rows:
        raise ValueError("no aban_act values to calibrate against")
    target = sum(mo["aban_act"] * mo["phone"]["act"] for mo in rows) / sum(mo["phone"]["act"] for mo in rows)
    q = copy.deepcopy(p); q["service"]["model"] = "A"

    def modelled(pat):
        q["service"]["patience_s"] = pat
        ev = [evaluate(q, mo, month_inputs(mo, "act"), fte_for(q, mo, "act", None)) for mo in rows]
        return sum(r["aban"] * r["phone"] for r in ev) / sum(r["phone"] for r in ev)
    lo, hi = math.log(5), math.log(7200)
    for _ in range(40):
        mid = (lo + hi) / 2
        if modelled(math.exp(mid)) > target:
            lo = mid
        else:
            hi = mid
    pat = math.exp((lo + hi) / 2)
    return dict(patience_s=pat, target_aban=target, modelled_aban=modelled(pat), months=len(rows))


# ---------------------------------------------------------------- output

def summary(p: dict) -> str:
    fc, act = year(p, "fc"), year(p, "act")
    lines = [f"{p.get('label', 'pool')} | contract {p['contract_fte']} FTE | target "
             f"{p['service']['target_sl']:.0%}/{p['service']['answer_s']}s | Erlang {p['service']['model']} | "
             f"shrink {p['shrink_mode']} | email {p['email']['handle_s']:.0f}s | {p['policy']}",
             f"{'mo':4} {'held':>5} {'reqFC':>6} {'gapFC':>6} {'SL_FC':>6} {'reqACT':>7} {'SL_ACT':>7} {'aban':>5}"]
    for f, a in zip(fc, act):
        lines.append(f"{f['month']:4} {f['fte']:5.1f} {f['req_fte']:6.1f} {f['gap_fte']:6.1f} "
                     f"{f['sl']:6.1%} {a['req_fte']:7.1f} {a['sl']:7.1%} {a['aban']:5.1%}")
    reqs = [r["req_fte"] for r in fc]
    lines.append(f"annual SL (forecast basis) {annual_sl(fc):.1%} | mean req {np.mean(reqs):.1f} "
                 f"| max req {max(reqs):.1f} | months short {sum(r['gap_fte'] < 0 for r in fc)}")
    flat = year(p, "fc", fte=float(np.mean(reqs)))
    lines.append(f"sized to the average ({np.mean(reqs):.1f} FTE every month): annual SL {annual_sl(flat):.1%}, "
                 f"months below target {sum(r['sl'] < p['service']['target_sl'] - 1e-9 for r in flat)}")
    return "\n".join(lines)


def to_xlsx(p: dict, path: str):
    import pandas as pd
    with pd.ExcelWriter(path) as xw:
        for basis in ("fc", "act"):
            pd.DataFrame(year(p, basis)).to_excel(xw, sheet_name=f"year_{basis}", index=False)
        pd.DataFrame([attribution(p, mo) for mo in p["months"]
                      if any(mo[d].get("act") for d in DRIVERS)]).to_excel(xw, "attribution", index=False)
        pd.DataFrame([dict(month=mo["month"], **elasticities(p, mo)) for mo in p["months"]]
                     ).to_excel(xw, "sensitivity", index=False)
        rows = []
        for mo in p["months"]:
            for r in power_of_one(p, mo):
                rows.append(dict(month=mo["month"], **r))
        pd.DataFrame(rows).to_excel(xw, "power_of_one", index=False)
        pd.DataFrame([dict(key=k, value=json.dumps(v)) for k, v in p.items() if k != "months"]
                     ).to_excel(xw, "config", index=False)


if __name__ == "__main__":
    ap = argparse.ArgumentParser()
    ap.add_argument("params")
    ap.add_argument("--xlsx")
    ap.add_argument("--calibrate", action="store_true")
    ap.add_argument("--model", choices=["A", "C"])
    ap.add_argument("--fit-patience", action="store_true")
    args = ap.parse_args()
    P = prepare(json.load(open(args.params)))
    if args.model:
        P["service"]["model"] = args.model
    if args.calibrate:
        fit = calibrate(P)
        for f in fit["fits"]:
            print(f"shrink_mode {f['shrink_mode']:8}  email handle {f['handle_s']:5.0f}s  rmse {f['rmse']:.2f} FTE")
        print(f"best: {fit['shrink_mode']}")
        for mname, plan, model in fit["residuals"]:
            print(f"  {mname}  plan {plan:5.1f}  model {model:5.1f}  diff {model - plan:+5.1f}")
        P["email"]["handle_s"] = fit["handle_s"]
        P["shrink_mode"] = fit["shrink_mode"]
    if args.fit_patience:
        fp = calibrate_patience(P)
        print(f"patience {fp['patience_s']:.0f}s reproduces {fp['target_aban']:.1%} abandonment "
              f"over {fp['months']} months (model {fp['modelled_aban']:.1%})")
        P["service"].update(model="A", patience_s=fp["patience_s"])
    print(summary(P))
    if args.xlsx:
        to_xlsx(P, args.xlsx)
        print(f"wrote {args.xlsx}")

Block 6 — explorer.html

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Dedicated Pool Explorer</title>
<style>
:root{--navy:#0b2545;--blue:#1f6fb2;--pale:#e3edf7;--grey:#6b7785;--warm:#b23a48;--ink:#1d2433;--muted:#5b6475;--line:#d8dee8;--bg:#f6f8fb;--card:#fff}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--ink);font:14px/1.45 -apple-system,"Segoe UI",Roboto,Helvetica,Arial,sans-serif}
header{background:var(--navy);color:#fff;padding:18px 24px}
header h1{margin:0;font-size:21px;font-weight:600;letter-spacing:.2px}
header .meta{margin-top:4px;font-size:13.5px;color:#c9d6f0}
.verdict{background:var(--pale);border-left:5px solid var(--blue);padding:12px 24px;font-size:16px;color:var(--navy)}
.verdict b{color:var(--warm)}
.wrap{display:grid;grid-template-columns:290px 1fr;gap:18px;padding:18px 24px;max-width:1440px;margin:0 auto}
aside{background:var(--card);border:1px solid var(--line);border-radius:8px;padding:14px 16px;align-self:start;position:sticky;top:12px}
aside h2,.card h2{font-size:13.5px;text-transform:uppercase;letter-spacing:.6px;color:var(--navy);margin:0 0 10px}
.ctl{margin:0 0 11px}
.ctl label{display:flex;justify-content:space-between;font-size:13px;color:var(--muted);margin-bottom:3px}
.ctl output{color:var(--ink);font-weight:600}
.ctl input[type=range]{width:100%;accent-color:var(--blue)}
.ctl select,.ctl input[type=number]{width:100%;padding:5px 6px;font-size:13.5px;border:1px solid var(--line);border-radius:5px;background:#fff}
.ctl .chk{display:flex;gap:6px;align-items:center;font-size:13px;color:var(--ink);margin-bottom:5px}
button{background:var(--navy);color:#fff;border:0;border-radius:5px;padding:7px 12px;font-size:13.5px;cursor:pointer;width:100%}
main{display:grid;gap:18px;min-width:0}
.card{background:var(--card);border:1px solid var(--line);border-radius:8px;padding:14px 16px;min-width:0}
.legend{display:flex;flex-wrap:wrap;gap:14px;font-size:13px;color:var(--muted);margin:-4px 0 6px}
.legend i{display:inline-block;width:14px;height:10px;margin-right:5px;vertical-align:-1px;border-radius:2px}
svg{width:100%;height:auto;display:block}
svg text{font-size:13px;fill:var(--muted)}
.two{display:grid;grid-template-columns:1fr 1fr;gap:18px}
.stats{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin-bottom:10px}
.stat{background:var(--pale);border-radius:6px;padding:8px 10px}
.stat .v{font-size:20px;font-weight:600;color:var(--navy)}
.stat .k{font-size:13px;color:var(--muted)}
.note{font-size:13.5px;color:var(--muted);margin:6px 0 0}
.note b{color:var(--ink)}
table{border-collapse:collapse;width:100%;font-size:13.5px}
td,th{padding:6px 8px;border-bottom:1px solid var(--line);text-align:left}
td.n{text-align:right;font-variant-numeric:tabular-nums;font-weight:600}
.neg{color:var(--warm)}.pos{color:var(--blue)}
.avg{display:grid;grid-template-columns:repeat(4,1fr);gap:10px}
footer{max-width:1440px;margin:0 auto;padding:4px 24px 28px;font-size:13px;color:var(--muted)}
@media (max-width:980px){.wrap{grid-template-columns:1fr}aside{position:static}.two,.stats,.avg{grid-template-columns:1fr 1fr}}
@media print{body{background:#fff}aside{display:none}.wrap{grid-template-columns:1fr;padding:0}.card{break-inside:avoid;border-color:#ccc}header,.verdict,.stat{-webkit-print-color-adjust:exact;print-color-adjust:exact}}
</style>
</head>
<body>
<header><h1 id="title">Dedicated pool</h1><div class="meta" id="meta"></div></header>
<div class="verdict" id="verdict"></div>
<div class="wrap">
<aside>
  <h2>Assumptions</h2>
  <div class="ctl"><div class="chk"><input type="checkbox" id="useAvail" checked><span>Use each month's available FTE</span></div>
    <label>FTE held (flat) <output id="o_fte"></output></label><input type="number" id="fte" step="0.1" min="1"></div>
  <div class="ctl"><label>Basis</label><select id="basis"><option value="fc">Forecast</option><option value="act">Actual where available</option></select></div>
  <div class="ctl"><label>Queue model</label><select id="model"><option value="C">Erlang C (nobody abandons)</option><option value="A">Erlang A (callers abandon)</option></select></div>
  <div class="ctl"><label>Caller patience <output id="o_pat"></output></label><input type="range" id="pat" min="20" max="900" step="1"></div>
  <div class="ctl"><label>Service-level target <output id="o_tsl"></output></label><input type="range" id="tsl" min="50" max="95" step="1"></div>
  <div class="ctl"><label>Answer within <output id="o_ans"></output></label><input type="range" id="ans" min="10" max="120" step="5"></div>
  <div class="ctl"><label>Volume (phone + email) <output id="o_vol"></output></label><input type="range" id="vol" min="70" max="130" step="1"></div>
  <div class="ctl"><label>Handle time (phone) <output id="o_aht"></output></label><input type="range" id="aht" min="70" max="130" step="1"></div>
  <div class="ctl"><label>Shrinkage change <output id="o_shr"></output></label><input type="range" id="shr" min="-10" max="10" step="1"></div>
  <div class="ctl"><label>When short, the gap lands on</label><select id="policy"><option value="proportional">Both, in proportion</option><option value="phones_first">Email (phones protected)</option><option value="email_first">Phones (email protected)</option></select></div>
  <div class="ctl"><label>Email worked in phone idle time <output id="o_bl"></output></label><input type="range" id="bl" min="0" max="100" step="5"></div>
  <button id="reset">Reset to data</button>
</aside>
<main>
  <section class="card"><h2>Supply against demand, by month</h2>
    <div class="legend"><span><i style="background:var(--blue)"></i>FTE required (forecast)</span><span><i style="background:var(--warm);width:10px;height:10px;transform:rotate(45deg)"></i>FTE required (actual)</span><span><i style="background:var(--navy);height:3px"></i>FTE held</span><span><i style="border-top:2px dashed var(--grey);height:0"></i>Contract</span><span><i style="background:#f7e3d8"></i>Short month (selected basis)</span></div>
    <div id="chartA"></div></section>
  <section class="card"><h2>Projected service level at the FTE held</h2>
    <div class="legend" id="legB"></div><div id="chartB"></div></section>
  <section class="card"><h2>The power of one — <select id="month" style="font-size:13.5px"></select></h2>
    <div class="stats" id="stats"></div>
    <div class="two"><div><div class="legend"><span>Service level as FTE held moves ±4</span></div><div id="chartP"></div><p class="note" id="pNote"></p></div>
    <div><div class="legend"><span>Service level as volume moves ±20%</span></div><div id="chartV"></div></div></div>
    <table id="elas" style="margin-top:10px"></table></section>
  <section class="card"><h2>Sized to the average</h2><div class="avg" id="avg"></div><p class="note" id="avgNote"></p></section>
</main>
</div>
<footer id="foot"></footer>
<script>
/* ==== DATA: replace this object only ==== */
const DATA = {
  "label": "Synthetic dedicated pool (worked example)",
  "year": 2026,
  "contract_fte": 22,
  "service": {"target_sl": 0.8, "answer_s": 20, "model": "C", "patience_s": 150, "occupancy_cap": 0.95},
  "hours": {"open_hours_per_day": 12, "days_per_week": 5, "interval_min": 30, "paid_hours_per_fte_day": 8},
  "email": {"handle_s": 420, "occupancy": 0.85, "blend_credit": 0.0},
  "policy": "proportional",
  "shrink_mode": "net",
  "months": [
    {"month": "Jan", "fte_available": 21.6, "phone": {"fc": 4668, "act": 5089}, "aht": {"fc": 720, "act": 760}, "email": {"fc": 10195, "act": 9788}, "shrink": {"fc": 0.3, "act": 0.29}, "aban_act": 0.1},
    {"month": "Feb", "fte_available": 21.4, "phone": {"fc": 4352, "act": 4525}, "aht": {"fc": 720, "act": 745}, "email": {"fc": 9404, "act": 9310}, "shrink": {"fc": 0.31, "act": 0.31}, "aban_act": 0.115},
    {"month": "Mar", "fte_available": 21.0, "phone": {"fc": 4747, "act": 5365}, "aht": {"fc": 720, "act": 790}, "email": {"fc": 10394, "act": 10602}, "shrink": {"fc": 0.29, "act": 0.31}, "aban_act": 0.153},
    {"month": "Apr", "fte_available": 21.2, "phone": {"fc": 4154, "act": 4029}, "aht": {"fc": 720, "act": 770}, "email": {"fc": 8915, "act": 9360}, "shrink": {"fc": 0.3, "act": 0.33}, "aban_act": 0.09},
    {"month": "May", "fte_available": 21.5, "phone": {"fc": 3956, "act": 4193}, "aht": {"fc": 720, "act": 800}, "email": {"fc": 8428, "act": 9102}, "shrink": {"fc": 0.31, "act": 0.33}, "aban_act": 0.104},
    {"month": "Jun", "fte_available": 21.0, "phone": {"fc": 3640, "act": 3457}, "aht": {"fc": 720, "act": 765}, "email": {"fc": 7657, "act": 8499}, "shrink": {"fc": 0.33, "act": 0.37}, "aban_act": 0.081},
    {"month": "Jul", "fte_available": 21.3, "phone": {"fc": 3165, "act": null}, "aht": {"fc": 720, "act": null}, "email": {"fc": 6521, "act": null}, "shrink": {"fc": 0.36, "act": null}, "aban_act": null},
    {"month": "Aug", "fte_available": 21.1, "phone": {"fc": 2927, "act": null}, "aht": {"fc": 720, "act": null}, "email": {"fc": 5962, "act": null}, "shrink": {"fc": 0.37, "act": null}, "aban_act": null},
    {"month": "Sep", "fte_available": 20.4, "phone": {"fc": 4431, "act": null}, "aht": {"fc": 720, "act": null}, "email": {"fc": 9601, "act": null}, "shrink": {"fc": 0.29, "act": null}, "aban_act": null},
    {"month": "Oct", "fte_available": 21.0, "phone": {"fc": 4589, "act": null}, "aht": {"fc": 720, "act": null}, "email": {"fc": 9997, "act": null}, "shrink": {"fc": 0.3, "act": null}, "aban_act": null},
    {"month": "Nov", "fte_available": 21.4, "phone": {"fc": 3877, "act": null}, "aht": {"fc": 720, "act": null}, "email": {"fc": 8234, "act": null}, "shrink": {"fc": 0.33, "act": null}, "aban_act": null},
    {"month": "Dec", "fte_available": 21.5, "phone": {"fc": 2967, "act": null}, "aht": {"fc": 720, "act": null}, "email": {"fc": 6054, "act": null}, "shrink": {"fc": 0.38, "act": null}, "aban_act": null}
  ]
};
/* ==== END DATA ==== */

// ==== MODEL START ====
const MONTHS = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];
const DRIVERS = ["phone","aht","email","shrink"];
const _cC = new Map(), _cA = new Map();
const r6 = x => Math.round(x * 1e6) / 1e6;
const clamp01 = x => Math.max(0, Math.min(1, x));

function erlangC(lam, aht, n, t) {            // (sl, pdelay, occ); nobody abandons
  const key = lam + "|" + aht + "|" + n + "|" + t;
  let r = _cC.get(key); if (r) return r;
  const a = lam * aht / 3600;
  if (lam <= 0) r = [1, 0, 0];
  else if (n <= a) r = [0, 1, 1];
  else {
    let b = 1;
    for (let j = 1; j <= n; j++) b = a * b / (j + a * b);
    const c = n * b / (n - a * (1 - b));
    r = [clamp01(1 - c * Math.exp(-(n - a) * t / aht)), c, a / n];
  }
  _cC.set(key, r); return r;
}

function erlangA(lam, aht, n, pat, t) {       // (sl, aban, occ); exact M/M/N+M, absorbing chain
  const key = lam + "|" + aht + "|" + n + "|" + pat + "|" + t;
  let r = _cA.get(key); if (r) return r;
  if (lam <= 0) r = [1, 0, 0];
  else if (n <= 0) r = [0, 1, 0];
  else {
    const L = lam / 3600, mu = 1 / aht, th = 1 / pat, a = L / mu;
    const p = [1];
    for (let j = 1; j <= n; j++) p.push(p[j - 1] * a / j);
    const tail = [p[n]];
    let total = p.reduce((s, v) => s + v, 0), k = 0;
    while (k < 20000) {
      k++;
      const nxt = tail[k - 1] * L / (n * mu + k * th);
      tail.push(nxt); total += nxt;
      if (nxt < 1e-13 * total && k > a) break;
    }
    const probs = p.slice(0, n).concat(tail);
    const z = probs.reduce((s, v) => s + v, 0);
    const q = new Float64Array(tail.length);
    let served = 0, lq = 0;
    for (let i = 0; i < n; i++) served += probs[i] / z;
    for (let i = 0; i < q.length; i++) { q[i] = probs[n + i] / z; lq += i * q[i]; }
    const aban = th * lq / L, occ = L * (1 - aban) / (n * mu);
    const m = q.length, adv = new Float64Array(m);
    let dt = 0.2 / (n * mu + (m - 1) * th + th);
    const steps = Math.max(1, Math.ceil(t / dt)); dt = t / steps;
    for (let i = 0; i < m; i++) adv[i] = (n * mu + i * th) * dt;
    const abn = th * dt, v = q, moved = new Float64Array(m);
    for (let s = 0; s < steps; s++) {
      for (let i = 0; i < m; i++) { moved[i] = v[i] * adv[i]; v[i] = v[i] * (1 - adv[i] - abn); }
      served += moved[0];
      for (let i = 0; i < m - 1; i++) v[i] += moved[i + 1];
    }
    r = [clamp01(served), aban, Math.min(1, occ)];
  }
  _cA.set(key, r); return r;
}

function intervalKpi(lam, aht, n, svc) {      // fractional n by linear interpolation
  if (lam <= 0) return [1, 0, 0];
  const at = k => {
    if (k <= 0) return [0, svc.model === "A" ? 1 : 0, 0];
    if (svc.model === "A") return erlangA(r6(lam), aht, k, svc.patience_s, svc.answer_s);
    const c = erlangC(r6(lam), aht, k, svc.answer_s); return [c[0], 0, c[2]];
  };
  const lo = Math.floor(n), w = n - lo;
  const A = at(lo), B = w > 1e-9 ? at(lo + 1) : A;
  return A.map((x, i) => (1 - w) * x + w * B[i]);
}

function requiredAgents(lam, aht, svc) {      // sized on Erlang C with occupancy cap
  if (lam <= 0) return 0;
  let n = Math.max(1, Math.floor(lam * aht / 3600) + 1);
  for (;;) {
    const c = erlangC(r6(lam), aht, n, svc.answer_s);
    if (c[0] >= svc.target_sl && c[2] <= svc.occupancy_cap) return n;
    n++;
  }
}

function requiredAgentsFrac(lam, aht, svc) { // continuous count; shapes how scarce hours spread
  if (lam <= 0) return 0;
  const n = requiredAgents(lam, aht, svc);
  const hi = erlangC(r6(lam), aht, n, svc.answer_s)[0];
  const lo = n > 1 ? erlangC(r6(lam), aht, n - 1, svc.answer_s)[0] : 0;
  const frac = n - 1 + (hi > lo ? (svc.target_sl - lo) / (hi - lo) : 1);
  return Math.max(Math.min(frac, n), lam * aht / 3600 / svc.occupancy_cap);
}

function defaultProfile(n) {                  // generic hump; an [estimated] placeholder
  const w = []; for (let i = 0; i < n; i++) w.push(0.35 + Math.pow(Math.sin(Math.PI * (i + 0.5) / n), 1.5));
  const s = w.reduce((a, b) => a + b, 0); return w.map(x => x / s);
}

function businessDays(y, mi, dpw) {
  const nd = new Date(Date.UTC(y, mi + 1, 0)).getUTCDate(); let c = 0;
  for (let d = 1; d <= nd; d++) if ((new Date(Date.UTC(y, mi, d)).getUTCDay() + 6) % 7 < dpw) c++;
  return c;
}

function prepare(params) {
  const p = JSON.parse(JSON.stringify(params));
  const svc = p.service = Object.assign({target_sl: 0.8, answer_s: 30, model: "C", patience_s: 180, occupancy_cap: 0.95}, p.service || {});
  const h = p.hours = Object.assign({open_hours_per_day: 10, days_per_week: 5, interval_min: 30, paid_hours_per_fte_day: 8}, p.hours || {});
  const nInt = Math.round(h.open_hours_per_day * 60 / h.interval_min);
  const prof = (h.profile && h.profile.length) ? h.profile : defaultProfile(nInt);
  if (prof.length !== nInt) throw new Error(`profile has ${prof.length} intervals; open hours imply ${nInt}`);
  const s = prof.reduce((a, b) => a + b, 0); h.profile = prof.map(x => x / s);
  p.email = Object.assign({handle_s: 600, occupancy: 0.85, blend_credit: 0}, p.email || {});
  p.policy = p.policy || "proportional"; p.shrink_mode = p.shrink_mode || "net";
  if (!["net", "gross_up"].includes(p.shrink_mode)) throw new Error("shrink_mode must be net or gross_up");
  if (!["proportional", "phones_first", "email_first"].includes(p.policy)) throw new Error("bad policy");
  const yr = parseInt(p.year || 2026, 10);
  p.months.forEach((m, i) => { if (m.month == null) m.month = MONTHS[i]; if (m.business_days == null) m.business_days = businessDays(yr, i, h.days_per_week); });
  return p;
}

function monthInputs(m, basis) {
  const o = {};
  for (const d of DRIVERS) { const v = m[d]; o[d] = +((basis === "act" && v.act != null && v.act !== 0) ? v.act : v.fc); }
  return o;
}

function evaluate(p, m, x, fte, lv) {
  lv = lv || {};
  const svc = p.service, h = p.hours, em = p.email, days = m.business_days, dtH = h.interval_min / 60;
  const vm = lv.volume_mult ?? 1;
  const phone = x.phone * vm, email = x.email * (lv.email_mult ?? vm), aht = x.aht * (lv.aht_mult ?? 1);
  const shrink = Math.min(0.95, Math.max(0, x.shrink + (lv.shrink_delta ?? 0)));
  const lam = h.profile.map(s => phone * s / days / dtH);
  const nReq = lam.map(l => requiredAgents(l, aht, svc));        // sizing: whole agents
  const nShape = lam.map(l => requiredAgentsFrac(l, aht, svc));  // spreading: smooth curve
  const phoneReq = nReq.reduce((a, b) => a + b, 0) * dtH * days;
  const shapeHours = nShape.reduce((a, b) => a + b, 0) * dtH * days;
  const erlangHours = phone * aht / 3600, idle = Math.max(0, shapeHours - erlangHours);
  const emailReq = Math.max(0, email * em.handle_s / 3600 / em.occupancy - em.blend_credit * idle);
  const paid = h.paid_hours_per_fte_day * days;
  const prodPerFte = p.shrink_mode === "net" ? paid * (1 - shrink) : paid / (1 + shrink);
  const reqHours = phoneReq + emailReq;
  const reqFte = prodPerFte > 0 ? reqHours / prodPerFte : Infinity;
  const avail = fte * prodPerFte, need = shapeHours + emailReq;
  let emailAvail;
  if (avail >= need) emailAvail = emailReq;
  else if (p.policy === "phones_first") emailAvail = Math.max(0, avail - shapeHours);
  else if (p.policy === "email_first") emailAvail = Math.min(emailReq, avail);
  else emailAvail = need ? emailReq * avail / need : 0;
  const phoneAvail = Math.max(0, avail - emailAvail);
  const ratio = shapeHours ? phoneAvail / shapeHours : 1;
  let calls = 0, slw = 0, abw = 0, peak = 0;
  for (let i = 0; i < lam.length; i++) {
    const c = lam[i] * dtH, k = intervalKpi(lam[i], aht, nShape[i] * ratio, svc);
    calls += c; slw += c * k[0]; abw += c * k[1]; peak = Math.max(peak, nShape[i] * ratio);
  }
  const short = Math.max(0, emailReq - emailAvail);
  return {month: m.month, fte, req_fte: reqFte, gap_fte: fte - reqFte, sl: calls ? slw / calls : 1,
    aban: calls ? abw / calls : 0, phone_hours_req: phoneReq, email_hours_req: emailReq, avail_hours: avail,
    phone_ratio: ratio, peak_phone_agents: peak, phone_occupancy: phoneAvail ? erlangHours / phoneAvail : 1,
    email_unworked: short * 3600 * em.occupancy / em.handle_s, phone, aht, email, shrink};
}

function fteFor(p, mo, basis, ov) { return ov != null ? +ov : +(mo.fte_available || p.contract_fte); }
function year(p, basis, fte, lv) { return p.months.map(mo => evaluate(p, mo, monthInputs(mo, basis || "fc"), fteFor(p, mo, basis, fte), lv)); }
function annualSl(rows) { const c = rows.reduce((s, r) => s + r.phone, 0); return c ? rows.reduce((s, r) => s + r.sl * r.phone, 0) / c : 1; }

function compose(b, d) {                      // base levers then delta levers
  b = b || {}; d = d || {};
  return {volume_mult: (b.volume_mult ?? 1) * (d.volume_mult ?? 1),
    email_mult: (b.email_mult ?? b.volume_mult ?? 1) * (d.email_mult ?? d.volume_mult ?? 1),
    aht_mult: (b.aht_mult ?? 1) * (d.aht_mult ?? 1), shrink_delta: (b.shrink_delta ?? 0) + (d.shrink_delta ?? 0)};
}

function powerOfOne(p, mo, basis, fte, span, lv) {
  const x = monthInputs(mo, basis), base = fteFor(p, mo, basis, fte), out = [];
  for (let k = -span; k <= span; k++) if (base + k > 0) {
    const r = evaluate(p, mo, x, base + k, lv);
    out.push({delta: k, fte: base + k, sl: r.sl, aban: r.aban, email_unworked: r.email_unworked});
  }
  return out;
}

function elasticities(p, mo, basis, fte, lv) {
  const x = monthInputs(mo, basis), f = fteFor(p, mo, basis, fte), b = evaluate(p, mo, x, f, lv).sl;
  const d = dl => 100 * (evaluate(p, mo, x, f, compose(lv, dl)).sl - b);
  return {sl: b, per_fte_plus: 100 * (evaluate(p, mo, x, f + 1, lv).sl - b), per_fte_minus: 100 * (evaluate(p, mo, x, f - 1, lv).sl - b),
    per_volume_plus_1pct: d({volume_mult: 1.01}), per_volume_plus_10pct: d({volume_mult: 1.10}),
    per_aht_plus_1pct: d({aht_mult: 1.01}), per_shrink_plus_1pt: d({shrink_delta: 0.01})};
}
// ==== MODEL END ====

// ---------------------------------------------------------------- view
const $ = id => document.getElementById(id);
const pct = (v, d = 0) => (100 * v).toFixed(d) + "%";
const f1 = v => v.toFixed(1);
const sgn = (v, d = 1) => (v >= 0 ? "+" : "−") + Math.abs(v).toFixed(d);
const BASE = prepare(DATA);
let monthIdx = null;

function defaults() {
  const s = BASE.service;
  $("useAvail").checked = BASE.months.some(m => m.fte_available);
  $("fte").value = BASE.contract_fte; $("basis").value = "fc"; $("model").value = s.model;
  $("pat").value = s.patience_s; $("tsl").value = Math.round(s.target_sl * 100); $("ans").value = s.answer_s;
  $("vol").value = 100; $("aht").value = 100; $("shr").value = 0; $("policy").value = BASE.policy;
  $("bl").value = Math.round(BASE.email.blend_credit * 100);
}

function state() {
  const p = JSON.parse(JSON.stringify(BASE));
  Object.assign(p.service, {model: $("model").value, patience_s: +$("pat").value, target_sl: $("tsl").value / 100, answer_s: +$("ans").value});
  p.policy = $("policy").value; p.email.blend_credit = $("bl").value / 100;
  const lv = {volume_mult: $("vol").value / 100, aht_mult: $("aht").value / 100, shrink_delta: $("shr").value / 100};
  const fte = $("useAvail").checked ? null : Math.max(1, +$("fte").value || BASE.contract_fte);
  return {p, lv, fte, basis: $("basis").value};
}

// ---- tiny SVG chart kit (categorical x)
const W = 760, H = 290, M = {l: 46, r: 14, t: 12, b: 30};
function frame(n, ymin, ymax, ticks, fmt, labels) {
  const bw = (W - M.l - M.r) / n, X = i => M.l + (i + 0.5) * bw, Y = v => M.t + (H - M.t - M.b) * (1 - (v - ymin) / (ymax - ymin));
  let g = "";
  for (const t of ticks) g += `<line x1="${M.l}" x2="${W - M.r}" y1="${Y(t)}" y2="${Y(t)}" stroke="#e6eaf0"/><text x="${M.l - 6}" y="${Y(t) + 4}" text-anchor="end">${fmt(t)}</text>`;
  labels.forEach((l, i) => g += `<text x="${X(i)}" y="${H - 9}" text-anchor="middle">${l}</text>`);
  return {X, Y, bw, g};
}
const svg = inner => `<svg viewBox="0 0 ${W} ${H}" xmlns="http://www.w3.org/2000/svg">${inner}</svg>`;
const path = (pts, st) => `<path d="${pts.map((p, i) => (i ? "L" : "M") + p[0].toFixed(1) + "," + p[1].toFixed(1)).join("")}" fill="none" ${st}/>`;
const dots = (pts, col, tips) => pts.map((p, i) => `<circle cx="${p[0]}" cy="${p[1]}" r="4" fill="${col}"><title>${tips[i]}</title></circle>`).join("");
function niceMax(v) { const s = Math.pow(10, Math.floor(Math.log10(v))), m = v / s; return (m <= 2 ? 2 : m <= 2.5 ? 2.5 : m <= 5 ? 5 : 10) * s; }
function slTicks() { return [0, 0.2, 0.4, 0.6, 0.8, 1]; }

function render() {
  const S = state(), {p, lv, fte, basis} = S, tgt = p.service.target_sl;
  const fc = year(p, "fc", fte, lv), act = year(p, "act", fte, lv), rows = basis === "fc" ? fc : act;
  const hasAct = p.months.map(m => DRIVERS.some(d => m[d].act != null && m[d].act !== 0));
  const n = rows.length, labels = rows.map(r => r.month);

  $("title").textContent = p.label || "Dedicated pool";
  $("meta").textContent = `Contract ${p.contract_fte} FTE · target ${pct(tgt)} in ${p.service.answer_s}s · Erlang ${p.service.model}` +
    (p.service.model === "A" ? ` (patience ${p.service.patience_s}s)` : "") + ` · ${basis === "fc" ? "forecast" : "actual where available"} basis`;
  const short = rows.filter(r => r.gap_fte < -1e-9), worst = rows.reduce((a, r) => r.gap_fte < a.gap_fte ? r : a, rows[0]);
  const heldTxt = fte != null ? `Held at ${f1(fte)} FTE` : `Held at its available FTE (average ${f1(rows.reduce((s, r) => s + r.fte, 0) / n)})`;
  $("verdict").innerHTML = short.length
    ? `${heldTxt}, the pool is short of requirement in <b>${short.length} of ${n} months</b>; peak shortfall <b>${f1(-worst.gap_fte)} FTE in ${worst.month}</b>. Projected service level for the year: <b>${pct(annualSl(rows))}</b> against ${pct(tgt)}.`
    : `${heldTxt}, the pool covers requirement in every month. Projected service level for the year: ${pct(annualSl(rows))}.`;

  // Chart A
  const ymax = niceMax(Math.max(...fc.map(r => r.req_fte), ...act.map(r => r.req_fte), ...rows.map(r => r.fte), p.contract_fte) * 1.08);
  const step = ymax / 5, A = frame(n, 0, ymax, [0, 1, 2, 3, 4, 5].map(i => i * step), v => v.toFixed(0), labels);
  let g = "";
  rows.forEach((r, i) => { if (r.gap_fte < -1e-9) g += `<rect x="${A.X(i) - A.bw / 2}" y="${M.t}" width="${A.bw}" height="${H - M.t - M.b}" fill="#f7e3d8" opacity=".75"/>`; });
  g += A.g;
  fc.forEach((r, i) => g += `<rect x="${A.X(i) - A.bw * 0.3}" y="${A.Y(r.req_fte)}" width="${A.bw * 0.6}" height="${A.Y(0) - A.Y(r.req_fte)}" fill="#1f6fb2" rx="2"><title>${r.month}: ${f1(r.req_fte)} FTE required (forecast)</title></rect>`);
  g += `<line x1="${M.l}" x2="${W - M.r}" y1="${A.Y(p.contract_fte)}" y2="${A.Y(p.contract_fte)}" stroke="#6b7785" stroke-width="2" stroke-dasharray="6 5"/>`;
  const held = rows.map((r, i) => [A.X(i), A.Y(r.fte)]);
  g += path(held, 'stroke="#0b2545" stroke-width="3"') + dots(held, "#0b2545", rows.map(r => `${r.month}: ${f1(r.fte)} FTE held`));
  act.forEach((r, i) => { if (hasAct[i]) { const x = A.X(i), y = A.Y(r.req_fte); g += `<path d="M${x},${y - 7}L${x + 7},${y}L${x},${y + 7}L${x - 7},${y}Z" fill="#b23a48"><title>${r.month}: ${f1(r.req_fte)} FTE required at actuals</title></path>`; } });
  $("chartA").innerHTML = svg(g);

  // Chart B
  const isA = p.service.model === "A", B = frame(n, 0, 1, slTicks(), v => pct(v), labels);
  $("legB").innerHTML = `<span><i style="background:var(--navy);height:3px"></i>Service level</span><span><i style="border-top:2px dashed var(--warm);height:0"></i>Target ${pct(tgt)}</span>` + (isA ? `<span><i style="background:var(--grey);height:3px"></i>Abandonment</span>` : "");
  g = B.g + `<line x1="${M.l}" x2="${W - M.r}" y1="${B.Y(tgt)}" y2="${B.Y(tgt)}" stroke="#b23a48" stroke-width="2" stroke-dasharray="6 5"/>`;
  const slp = rows.map((r, i) => [B.X(i), B.Y(r.sl)]);
  g += path(slp, 'stroke="#0b2545" stroke-width="3"') + dots(slp, "#0b2545", rows.map(r => `${r.month}: ${pct(r.sl, 1)}`));
  rows.forEach((r, i) => { if (r.sl < tgt - 1e-9) g += `<circle cx="${slp[i][0]}" cy="${slp[i][1]}" r="6" fill="none" stroke="#b23a48" stroke-width="2"/>`; });
  if (isA) { const ab = rows.map((r, i) => [B.X(i), B.Y(r.aban)]); g += path(ab, 'stroke="#6b7785" stroke-width="2.5"') + dots(ab, "#6b7785", rows.map(r => `${r.month}: ${pct(r.aban, 1)} abandon`)); }
  $("chartB").innerHTML = svg(g);

  // Power of one
  if (monthIdx == null) monthIdx = rows.indexOf(worst);
  $("month").innerHTML = labels.map((l, i) => `<option value="${i}"${i === monthIdx ? " selected" : ""}>${l}</option>`).join("");
  const mo = p.months[monthIdx], r0 = rows[monthIdx];
  const p1 = powerOfOne(p, mo, basis, fte, 4, lv), e = elasticities(p, mo, basis, fte, lv);
  $("pat").disabled = p.service.model !== "A";
  $("stats").innerHTML = [[pct(r0.sl, 1), "service level"], [f1(r0.fte) + " / " + f1(r0.req_fte), "FTE held / required"],
    [f1(r0.peak_phone_agents), `agents on phones, peak ${p.hours.interval_min}-min interval`], [Math.round(r0.email_unworked).toLocaleString(), "deferred items not worked in month"]]
    .map(s => `<div class="stat"><div class="v">${s[0]}</div><div class="k">${s[1]}</div></div>`).join("");
  const P = frame(p1.length, 0, 1, slTicks(), v => pct(v), p1.map(q => (q.delta > 0 ? "+" : q.delta < 0 ? "−" : "") + (q.delta ? Math.abs(q.delta) : "held")));
  g = P.g + `<line x1="${M.l}" x2="${W - M.r}" y1="${P.Y(tgt)}" y2="${P.Y(tgt)}" stroke="#b23a48" stroke-width="2" stroke-dasharray="6 5"/>`;
  const pp = p1.map((q, i) => [P.X(i), P.Y(q.sl)]);
  g += path(pp, 'stroke="#1f6fb2" stroke-width="3"') + dots(pp, "#1f6fb2", p1.map(q => `${f1(q.fte)} FTE: ${pct(q.sl, 1)}`));
  const h0 = p1.findIndex(q => q.delta === 0); if (h0 >= 0) g += `<circle cx="${pp[h0][0]}" cy="${pp[h0][1]}" r="7" fill="none" stroke="#0b2545" stroke-width="2.5"/>`;
  $("chartP").innerHTML = svg(g);
  const per = (e.per_fte_plus - e.per_fte_minus) / 2;
  $("pNote").innerHTML = `In ${r0.month}, <b>each FTE here is worth about ±${per.toFixed(1)} points</b> of service level` +
    (r0.peak_phone_agents > 0 ? `. At the peak ${p.hours.interval_min}-minute interval only ${f1(r0.peak_phone_agents)} people are on phones, ; one more FTE adds about ${(evaluate(p, mo, monthInputs(mo, basis), fteFor(p, mo, basis, fte) + 1, lv).peak_phone_agents - r0.peak_phone_agents).toFixed(2)} of a person there, after shrinkage and the deferred share.` : ".");
  const vols = [-20, -15, -10, -5, 0, 5, 10, 15, 20], x = monthInputs(mo, basis), fh = fteFor(p, mo, basis, fte);
  const vs = vols.map(v => evaluate(p, mo, x, fh, compose(lv, {volume_mult: 1 + v / 100})).sl);
  const V = frame(vols.length, 0, 1, slTicks(), v => pct(v), vols.map(v => (v > 0 ? "+" : v < 0 ? "−" : "") + Math.abs(v) + "%"));
  g = V.g + `<line x1="${M.l}" x2="${W - M.r}" y1="${V.Y(tgt)}" y2="${V.Y(tgt)}" stroke="#b23a48" stroke-width="2" stroke-dasharray="6 5"/>`;
  const vp = vs.map((s, i) => [V.X(i), V.Y(s)]);
  g += path(vp, 'stroke="#0b2545" stroke-width="3"') + dots(vp, "#0b2545", vs.map((s, i) => `${vols[i]}% volume: ${pct(s, 1)}`));
  $("chartV").innerHTML = svg(g);
  const rowsE = [["Add one FTE", e.per_fte_plus], ["Lose one FTE", e.per_fte_minus], ["Volume +1%", e.per_volume_plus_1pct], ["Volume +10%", e.per_volume_plus_10pct], ["Phone handle time +1%", e.per_aht_plus_1pct], ["Shrinkage +1 point", e.per_shrink_plus_1pt]];
  $("elas").innerHTML = `<tr><th>In ${r0.month}, if…</th><th style="text-align:right">service level moves</th></tr>` +
    rowsE.map(r => `<tr><td>${r[0]}</td><td class="n ${r[1] < 0 ? "neg" : "pos"}">${sgn(r[1])} pts</td></tr>`).join("");

  // Sized to the average
  const reqs = rows.map(r => r.req_fte), mean = reqs.reduce((a, b) => a + b, 0) / n, mx = Math.max(...reqs);
  const flat = year(p, basis, mean, lv), below = flat.filter(r => r.sl < tgt - 1e-9).length;
  const ok = F => year(p, basis, F, lv).every(r => r.sl >= tgt - 1e-9);
  let lo = 0, hi = Math.max(mx, 1); while (!ok(hi) && hi < 2000) hi *= 1.5;
  for (let i = 0; i < 22; i++) { const mid = (lo + hi) / 2; if (ok(mid)) hi = mid; else lo = mid; }
  $("avg").innerHTML = [[f1(mean), "average FTE required"], [f1(mx), "peak-month FTE required"], [`${below} of ${n}`, `months below target at a flat ${f1(mean)}`], [f1(hi), "flat FTE to meet target every month"]]
    .map(s => `<div class="stat"><div class="v">${s[0]}</div><div class="k">${s[1]}</div></div>`).join("");
  $("avgNote").innerHTML = (below ? `A pool sized to the average requirement still misses target in ${below} month${below > 1 ? "s" : ""}: the surplus months cannot be banked for the short ones. ` : `Here a flat pool at the average requirement meets target every month, because whole-agent rounding leaves slack. `) +
    `At a flat ${f1(mean)} FTE the year blends to ${pct(annualSl(flat), 1)}, so whether that “passes” depends on whether service level is measured monthly or annually. Meeting target in every month takes ${f1(hi)} FTE, ${pct(hi / mean - 1)} above the average. (Required FTE is sized in whole agents per interval, as plans do; delivery assumes an ideal roster, so the FTE that meets target sits a little below the peak requirement. A real roster lands between the two.)`;

  const est = DATA.estimated || [!(DATA.hours && DATA.hours.profile) && "intraday profile (generic shape)", p.service.model === "A" && "caller patience"].filter(Boolean);
  $("foot").innerHTML = `<b>Method.</b> Phone is sized interval by interval on an intraday profile (${p.hours.profile.length} × ${p.hours.interval_min}-minute intervals, ${p.hours.days_per_week} days a week) using Erlang C, with an occupancy cap of ${pct(p.service.occupancy_cap)}; delivery is scored with Erlang ${p.service.model}${isA ? " (callers abandon; service level counts abandoned calls against it)" : " (nobody abandons)"}. ` +
    `Email is sized as workload at ${p.email.handle_s.toFixed(0)}s handle time and ${pct(p.email.occupancy)} occupancy. Shrinkage is applied once, on supply (${p.shrink_mode === "net" ? "paid hours × (1 − shrinkage)" : "paid hours ÷ (1 + shrinkage)"}). ` +
    (est.length ? `Inputs marked [estimated]: ${est.join(", ")}. ` : "") + `Every figure is a model of the inputs shown, not a measurement.`;
  for (const [id, f] of [["fte", v => v + " FTE"], ["pat", v => v + "s"], ["tsl", v => v + "%"], ["ans", v => v + "s"], ["vol", v => v + "%"], ["aht", v => v + "%"], ["shr", v => (v > 0 ? "+" : "") + v + " pts"], ["bl", v => v + "%"]])
    if ($("o_" + id)) $("o_" + id).textContent = f($(id).value);
  $("fte").disabled = $("useAvail").checked;
}

let pending = false;
const schedule = () => { if (!pending) { pending = true; requestAnimationFrame(() => { pending = false; render(); }); } };
document.querySelectorAll("aside input, aside select").forEach(el => el.addEventListener("input", schedule));
$("month").addEventListener("change", e => { monthIdx = +e.target.value; schedule(); });
$("reset").addEventListener("click", () => { defaults(); monthIdx = null; schedule(); });
defaults(); render();
</script>
</body>
</html>

Block 9 — example-params.json

{
 "label": "Synthetic dedicated pool (worked example)",
 "year": 2026,
 "contract_fte": 22,
 "service": {
  "target_sl": 0.8,
  "answer_s": 20,
  "model": "C",
  "patience_s": 150,
  "occupancy_cap": 0.95
 },
 "hours": {
  "open_hours_per_day": 12,
  "days_per_week": 5,
  "interval_min": 30,
  "paid_hours_per_fte_day": 8
 },
 "email": {
  "handle_s": 420,
  "occupancy": 0.85,
  "blend_credit": 0.0
 },
 "policy": "proportional",
 "shrink_mode": "net",
 "months": [
  {
   "month": "Jan",
   "fte_available": 21.6,
   "phone": {
    "fc": 4668,
    "act": 5089
   },
   "aht": {
    "fc": 720,
    "act": 760
   },
   "email": {
    "fc": 10195,
    "act": 9788
   },
   "shrink": {
    "fc": 0.3,
    "act": 0.29
   },
   "aban_act": 0.1
  },
  {
   "month": "Feb",
   "fte_available": 21.4,
   "phone": {
    "fc": 4352,
    "act": 4525
   },
   "aht": {
    "fc": 720,
    "act": 745
   },
   "email": {
    "fc": 9404,
    "act": 9310
   },
   "shrink": {
    "fc": 0.31,
    "act": 0.31
   },
   "aban_act": 0.115
  },
  {
   "month": "Mar",
   "fte_available": 21.0,
   "phone": {
    "fc": 4747,
    "act": 5365
   },
   "aht": {
    "fc": 720,
    "act": 790
   },
   "email": {
    "fc": 10394,
    "act": 10602
   },
   "shrink": {
    "fc": 0.29,
    "act": 0.31
   },
   "aban_act": 0.153
  },
  {
   "month": "Apr",
   "fte_available": 21.2,
   "phone": {
    "fc": 4154,
    "act": 4029
   },
   "aht": {
    "fc": 720,
    "act": 770
   },
   "email": {
    "fc": 8915,
    "act": 9360
   },
   "shrink": {
    "fc": 0.3,
    "act": 0.33
   },
   "aban_act": 0.09
  },
  {
   "month": "May",
   "fte_available": 21.5,
   "phone": {
    "fc": 3956,
    "act": 4193
   },
   "aht": {
    "fc": 720,
    "act": 800
   },
   "email": {
    "fc": 8428,
    "act": 9102
   },
   "shrink": {
    "fc": 0.31,
    "act": 0.33
   },
   "aban_act": 0.104
  },
  {
   "month": "Jun",
   "fte_available": 21.0,
   "phone": {
    "fc": 3640,
    "act": 3457
   },
   "aht": {
    "fc": 720,
    "act": 765
   },
   "email": {
    "fc": 7657,
    "act": 8499
   },
   "shrink": {
    "fc": 0.33,
    "act": 0.37
   },
   "aban_act": 0.081
  },
  {
   "month": "Jul",
   "fte_available": 21.3,
   "phone": {
    "fc": 3165,
    "act": null
   },
   "aht": {
    "fc": 720,
    "act": null
   },
   "email": {
    "fc": 6521,
    "act": null
   },
   "shrink": {
    "fc": 0.36,
    "act": null
   },
   "aban_act": null
  },
  {
   "month": "Aug",
   "fte_available": 21.1,
   "phone": {
    "fc": 2927,
    "act": null
   },
   "aht": {
    "fc": 720,
    "act": null
   },
   "email": {
    "fc": 5962,
    "act": null
   },
   "shrink": {
    "fc": 0.37,
    "act": null
   },
   "aban_act": null
  },
  {
   "month": "Sep",
   "fte_available": 20.4,
   "phone": {
    "fc": 4431,
    "act": null
   },
   "aht": {
    "fc": 720,
    "act": null
   },
   "email": {
    "fc": 9601,
    "act": null
   },
   "shrink": {
    "fc": 0.29,
    "act": null
   },
   "aban_act": null
  },
  {
   "month": "Oct",
   "fte_available": 21.0,
   "phone": {
    "fc": 4589,
    "act": null
   },
   "aht": {
    "fc": 720,
    "act": null
   },
   "email": {
    "fc": 9997,
    "act": null
   },
   "shrink": {
    "fc": 0.3,
    "act": null
   },
   "aban_act": null
  },
  {
   "month": "Nov",
   "fte_available": 21.4,
   "phone": {
    "fc": 3877,
    "act": null
   },
   "aht": {
    "fc": 720,
    "act": null
   },
   "email": {
    "fc": 8234,
    "act": null
   },
   "shrink": {
    "fc": 0.33,
    "act": null
   },
   "aban_act": null
  },
  {
   "month": "Dec",
   "fte_available": 21.5,
   "phone": {
    "fc": 2967,
    "act": null
   },
   "aht": {
    "fc": 720,
    "act": null
   },
   "email": {
    "fc": 6054,
    "act": null
   },
   "shrink": {
    "fc": 0.38,
    "act": null
   },
   "aban_act": null
  }
 ]
}

See also