#!/usr/bin/env python3
"""
Worst-case classification margin for the port-ID sense chain.

Three different numbers get confused with each other, so compute all three:
  separation   - distance between two nominal band voltages
  gap          - distance between the worst-case EDGES of adjacent bands,
                 once resistor tolerance and mux on-resistance are included
  margin       - distance from a worst-case reading to the decision threshold.
                 With the threshold at the middle of the gap this is gap/2,
                 and it is the only one of the three that bounds a misread.
"""
V33, RPULL, TOL = 3.3, 10_000.0, 0.01
RON_MIN, RON_MAX = 0.0, 400.0          # CD74HC4051 at 3.3 V, over temperature
VARIANTS = [("no leg", None), ("short shin", 22_000.0),
            ("default", 10_000.0), ("long shin", 4_700.0)]

def band(r):
    """Interval the reading can land in, worst case over both tolerances,
    the mux resistance and its drift."""
    if r is None:
        return (V33, V33)                       # pull-up only; nothing to divide
    lo = V33 * (r*(1-TOL) + RON_MIN) / (r*(1-TOL) + RON_MIN + RPULL*(1+TOL))
    hi = V33 * (r*(1+TOL) + RON_MAX) / (r*(1+TOL) + RON_MAX + RPULL*(1-TOL))
    return (lo, hi)

def nominal(r):
    return V33 if r is None else V33 * r / (r + RPULL)

rows = [(n, r, nominal(r), band(r)) for n, r in VARIANTS]
rows.sort(key=lambda t: t[2])
LSB = V33 / 4096

print(f"{'variant':12}{'R':>8}{'nominal':>10}{'worst-case band':>22}{'counts':>9}")
for n, r, v, (lo, hi) in rows:
    rs = "open" if r is None else f"{r/1000:.1f}k"
    print(f"{n:12}{rs:>8}{v:10.3f}{f'{lo:.3f} .. {hi:.3f} V':>22}{int(v/LSB):9d}")

print()
seps = [rows[i+1][2] - rows[i][2] for i in range(len(rows)-1)]
gaps = [rows[i+1][3][0] - rows[i][3][1] for i in range(len(rows)-1)]
thr  = [(rows[i][3][1] + rows[i+1][3][0]) / 2 for i in range(len(rows)-1)]
print(f"smallest nominal separation : {min(seps)*1000:6.0f} mV   ({min(seps)/V33*100:.0f}% of full scale)")
print(f"smallest worst-case gap     : {min(gaps)*1000:6.0f} mV")
print(f"margin to nearest threshold : {min(gaps)/2*1000:6.0f} mV  = {min(gaps)/2/LSB:.0f} ADC counts")
print(f"                              (this is the number that bounds a misread)")
print()
print("decision thresholds, midway across each worst-case gap:")
for t, (a, b) in zip(thr, zip(rows[:-1], rows[1:])):
    print(f"  {a[0]:12} | {b[0]:12}  at {t:.3f} V  ({int(t/LSB)} counts)")
print()
print(f"1 LSB at 12 bits = {LSB*1000:.3f} mV")
print(f"guard band for a 'reading is not valid' verdict, +/-20% of each gap:")
for t, g in zip(thr, gaps):
    print(f"  reject {t-0.2*g:.3f} .. {t+0.2*g:.3f} V")

# ---------------------------------------------------------------------------
# Guaranteed margin, per boundary, against the thresholds firmware actually uses.
#
#   M_i = min( T_i - V_i,max ,  V_i+1,min - T_i )
#
# Taking half the gap is only correct when T_i sits at the midpoint of the two
# worst-case band edges. That is how the thresholds above were chosen, so the
# equality holds here by construction, but it is worth checking rather than
# asserting, because the obvious thing to type into firmware is round numbers.
def margins_for(thresholds, label):
    print(f"\n{label}")
    print(f"  {'boundary':26}{'T':>9}{'T - Vmax':>11}{'Vmin - T':>11}{'M_i':>9}")
    worst = 1e9
    for i, T in enumerate(thresholds):
        lo_band, hi_band = rows[i][3], rows[i+1][3]
        a, b = T - lo_band[1], hi_band[0] - T
        M = min(a, b); worst = min(worst, M)
        flag = "" if M > 0 else "   <-- OVERLAPS"
        print(f"  {rows[i][0]+' | '+rows[i+1][0]:26}{T:9.3f}{a*1000:10.0f}m{b*1000:10.0f}m{M*1000:8.0f}m{flag}")
    print(f"  guaranteed margin = min over boundaries = {worst*1000:.0f} mV "
          f"({worst/LSB:.0f} counts)")
    return worst

m_opt = margins_for(thr, "thresholds at the midpoint of the worst-case edges:")
round_thr = [1.4, 2.0, 2.8]
m_rnd = margins_for(round_thr, "round numbers someone might type into firmware instead:")
print(f"\ncost of rounding: {(m_opt-m_rnd)*1000:.0f} mV of guaranteed margin "
      f"({(1-m_rnd/m_opt)*100:.0f}% worse). Use the computed values.")
