#!/usr/bin/env python3
"""
analyze-fseq.py — Gothic Folly offline sequence power/brightness analyzer (issue #113)

Answers the question: **does a real show ever get close to 100% (every LED full white)?**
If it doesn't come close, we may be able to raise the system brightness above the
current 50% single-generator cap.

What it does (nothing real-time — this is a show-prep step, run once per sequence):
  1. Reads a finished xLights .fseq and expands the rose window in-memory
     (reusing expand-fseq.py), so the rose is analyzed at its true ~8,272 physical
     LEDs, not the 224 design cells. (Pass --expanded if the file is already expanded.)
  2. Scans every frame and, for every real LED, sums channel bytes.
  3. Reports, globally and per zone:
       - brightness utilization %  (100% = every LED at 255,255,255) — NO power model
       - estimated Watts / rail Amps  (uses the per-LED specs below — an ESTIMATE)
       - peak frame + timestamp, mean, and a histogram over the whole show
  4. Frames the answer against the 50% port cap: the .fseq holds *design* brightness;
     the F48V5 applies the 50% cap AFTER this file, so real draw = value x cap.

Usage:
    python3 pixel-map/analyze-fseq.py SOURCE.fseq              # expand rose in-memory, analyze
    python3 pixel-map/analyze-fseq.py --expanded SOURCE.fseq   # already-expanded .fseq
    python3 pixel-map/analyze-fseq.py --cap 0.5 SOURCE.fseq    # cap to model (default 0.50)
    python3 pixel-map/analyze-fseq.py --json out.json SOURCE.fseq

Needs the wiring DB (wiring-db/strands.db) for the channel->zone map and the `zstd`
CLI for compressed .fseq (same as expand-fseq.py).
"""
import sys, os, json, sqlite3, importlib.util

HERE = os.path.dirname(os.path.abspath(__file__))
PROJ = os.path.dirname(HERE)
DB   = os.path.join(PROJ, 'wiring-db', 'strands.db')
CH_PER_UNIVERSE = 510          # matches expand-fseq.py / universe-map.json packing

# ---- import expand-fseq.py (hyphenated filename) as a module -----------------
def _load_expand():
    spec = importlib.util.spec_from_file_location('expand_fseq', os.path.join(HERE, 'expand-fseq.py'))
    m = importlib.util.module_from_spec(spec); spec.loader.exec_module(m)
    return m
EF = _load_expand()

# ---- power model: full-white WATTS PER xLIGHTS PIXEL and rail voltage, by zone -
# Drives the Watts/Amps estimate only; the brightness-% metric needs none of it.
#
# Every non-rose xLights pixel = ONE hardware pixel = one 6-LED WS2811 module
# (one IC drives 6 RGB LEDs; strips and globe strands alike). A 24V 6-LED WS2811
# module draws ~1.45 W at full white — confirmed by strip specs (superlightingled /
# BTF 24V 6-LED pixel modules; 108 LED/m = 18 px/m ≈ 16 W/m ÷ 18 ≈ 0.89 W... vendors
# rate the discrete 6-LED module at 1.45 W max, which we use as the conservative
# per-pixel figure). John, 2026-07-26: no grouping beyond the 6-LED module.
#
# Rose is analyzed at PHYSICAL LED level (expanded): WS2815 12V = 0.20 W per LED,
# 1 LED per expanded channel-triple.
POWER = {   # type -> (watts_per_xlights_pixel_fullwhite, rail_volts, confidence)
    'rose window petal': (0.20, 12, 'spec (WS2815, per physical LED)'),
    'minor arch':        (1.45, 24, 'spec (6-LED WS2811 module)'),
    'major arch':        (1.45, 24, 'spec (6-LED WS2811 module)'),
    'quad arch':         (1.45, 24, 'spec (6-LED WS2811 module)'),
    'canopy':            (1.44, 24, 'spec (6-LED WS2811 globe, AliExpress-confirmed)'),
    'spire':             (1.44, 24, 'spec (6-LED WS2811 globe, AliExpress-confirmed)'),
    'spirelet':          (1.45, 12, 'ESTIMATE (12V flood fixture — confirm)'),
    'wash':              (1.45, 12, 'ESTIMATE (12V flood fixture — confirm)'),
}

# ============================ channel -> zone map ============================

def build_zone_ranges():
    """Return {zone_type: [(abs_channel_start, nbytes), ...]} of REAL LED channels.

    Non-rose zones come from the wiring DB (every non-spare strand). The rose comes
    from expand-fseq's physical layout + petal segments (only mapped LEDs; passage
    LEDs are excluded). Design-level rose cells (u1/u2) are intentionally omitted."""
    zones = {}
    # --- non-rose strands from the wiring DB ---
    con = sqlite3.connect(DB); con.row_factory = sqlite3.Row
    for r in con.execute("SELECT type, universe, start_ch, px FROM strands "
                          "WHERE (what IS NULL OR what NOT LIKE '%SPARE%') "
                          "AND type != 'rose window petal'"):
        if r['universe'] is None or r['px'] is None:
            continue
        start = (r['universe'] - 1) * CH_PER_UNIVERSE + (r['start_ch'] - 1)
        zones.setdefault(r['type'], []).append((start, r['px'] * 3))
    con.close()
    # --- rose physical LEDs from the expander's own layout ---
    segs = EF.load_petal_segments()
    rose = []
    for P in range(1, 17):
        ebase = EF.exp_petal_base(P); idx = 0
        for seg in segs[P]:
            idx += seg.get('skip_before', 0)
            rose.append((ebase + idx * 3, seg['count'] * 3))
            idx += seg['count']
    zones['rose window petal'] = rose
    return zones

def flatten(zones):
    """[(start,nbytes,type), ...] sorted, for quick per-frame summing."""
    out = []
    for ztype, ranges in zones.items():
        for s, n in ranges:
            out.append((s, n, ztype))
    out.sort()
    return out

# ============================== analysis ==============================

def analyze(frames, step_time, cap):
    zones = build_zone_ranges()
    flat = flatten(zones)
    max_needed = max((s + n for s, n, _ in flat), default=0)

    # per-zone denominators (real channels) and full-white watts
    z_nch = {z: sum(n for s, n, t in flat if t == z) for z in zones}
    z_fullwhite_w = {}
    for z, nch in z_nch.items():
        w_led, volts, _ = POWER.get(z, (0.24, 24, 'ESTIMATE-unknown-type'))
        z_fullwhite_w[z] = (nch / 3) * w_led
    total_nch = sum(z_nch.values())

    # running stats
    n = len(frames)
    global_peak = (0.0, -1)                 # (util, frame_index)
    global_sum = 0.0
    z_peak = {z: (0.0, -1) for z in zones}
    z_sum  = {z: 0.0 for z in zones}
    hist = [0] * 11                          # buckets 0-10%,...,90-100%,==100%
    rail_peak_w = {12: (0.0, -1), 24: (0.0, -1)}

    for fi, fr in enumerate(frames):
        if len(fr) < max_needed:
            fr = fr + bytearray(max_needed - len(fr))
        # per-zone byte sums this frame
        zbytes = {z: 0 for z in zones}
        for s, nby, t in flat:
            zbytes[t] += sum(fr[s:s + nby])
        gsum = sum(zbytes.values())
        gutil = gsum / (total_nch * 255) if total_nch else 0.0
        global_sum += gutil
        if gutil > global_peak[0]:
            global_peak = (gutil, fi)
        b = min(10, int(gutil * 10))
        hist[b] += 1
        rail_w = {12: 0.0, 24: 0.0}
        for z in zones:
            util = zbytes[z] / (z_nch[z] * 255) if z_nch[z] else 0.0
            z_sum[z] += util
            if util > z_peak[z][0]:
                z_peak[z] = (util, fi)
            _, volts, _ = POWER.get(z, (0.24, 24, ''))
            rail_w[volts] = rail_w.get(volts, 0.0) + z_fullwhite_w[z] * util
        for v in (12, 24):
            if rail_w[v] > rail_peak_w[v][0]:
                rail_peak_w[v] = (rail_w[v], fi)

    def ts(fi):
        ms = fi * step_time
        return f"{ms//60000:d}:{(ms%60000)/1000:06.3f}"

    return {
        'frames': n, 'fps': (1000 // step_time) if step_time else 0, 'step_time': step_time,
        'cap': cap, 'total_leds': total_nch // 3, 'total_channels': total_nch,
        'global': {'peak_util': global_peak[0], 'peak_frame': global_peak[1],
                   'peak_time': ts(global_peak[1]), 'mean_util': global_sum / n if n else 0.0},
        'histogram': hist,
        'zones': {z: {'leds': z_nch[z] // 3,
                      'peak_util': z_peak[z][0], 'peak_time': ts(z_peak[z][1]),
                      'mean_util': z_sum[z] / n if n else 0.0,
                      'fullwhite_w': z_fullwhite_w[z],
                      'peak_w': z_fullwhite_w[z] * z_peak[z][0],
                      'volts': POWER.get(z, (0, 24, ''))[1],
                      'confidence': POWER.get(z, (0, 0, '?'))[2]}
                  for z in zones},
        'rails': {v: {'peak_w': rail_peak_w[v][0], 'peak_time': ts(rail_peak_w[v][1]),
                      'fullwhite_w': sum(z_fullwhite_w[z] for z in zones
                                         if POWER.get(z, (0, 24, ''))[1] == v)}
                  for v in (12, 24)},
        '_ts': ts,
    }

# ============================== reporting ==============================

def bar(frac, width=30):
    f = max(0.0, min(1.0, frac))
    return '█' * int(f * width) + '·' * (width - int(f * width))

def report(a):
    cap = a['cap']
    print(f"\n{'='*70}\n  SEQUENCE POWER / BRIGHTNESS ANALYSIS  (issue #113)\n{'='*70}")
    print(f"  {a['frames']} frames @ {a['fps']}fps  ·  {a['total_leds']:,} real LEDs "
          f"({a['total_channels']:,} channels)")
    print(f"  Brightness cap modeled: {cap*100:.0f}% (applied at the F48V5 port, after this file)\n")

    g = a['global']
    print(f"  GLOBAL brightness (100% = every LED full white):")
    print(f"    peak   {g['peak_util']*100:5.1f}%  [{bar(g['peak_util'])}]  at {g['peak_time']}")
    print(f"    mean   {g['mean_util']*100:5.1f}%  [{bar(g['mean_util'])}]")
    print(f"    after {cap*100:.0f}% cap → peak effective draw ≈ {g['peak_util']*cap*100:4.1f}% of all-white\n")

    print(f"  PER-ZONE peak brightness (each zone's own worst frame):")
    print(f"    {'zone':<20}{'LEDs':>7}{'peak':>7}{'mean':>7}   {'peak W':>8}  bar")
    for z, zd in sorted(a['zones'].items(), key=lambda kv: -kv[1]['peak_util']):
        flag = '  ⚠' if 'ESTIMATE' in zd['confidence'] else ''
        print(f"    {z:<20}{zd['leds']:>7}{zd['peak_util']*100:>6.1f}%{zd['mean_util']*100:>6.1f}%"
              f"{zd['peak_w']:>8.0f}  [{bar(zd['peak_util'],20)}]{flag}")

    print(f"\n  PER-RAIL peak load (design brightness; full-white capacity in parens; ESTIMATE):")
    for v in (24, 12):
        rd = a['rails'][v]
        pct = rd['peak_w'] / max(rd['fullwhite_w'], 1) * 100
        print(f"    {v}V rail: peak ≈ {rd['peak_w']:6.0f} W = {rd['peak_w']/v:5.0f} A  at {rd['peak_time']}   "
              f"(full white ≈ {rd['fullwhite_w']:.0f} W = {rd['fullwhite_w']/v:.0f} A, {pct:.0f}%)")
    print(f"    (12V rail lumps rose + the small wash/spirelet floods; they're on separate PSUs.)")

    print(f"\n  BRIGHTNESS HISTOGRAM (share of frames by global brightness):")
    labels = [f"{i*10:>3}-{i*10+10}%" for i in range(10)] + ["  100%"]
    tot = max(1, a['frames'])
    for i, c in enumerate(a['histogram']):
        print(f"    {labels[i]}  {bar(c/tot,28)} {c/tot*100:5.1f}%")

    # verdict — the binding constraint is the RAIL that peaks highest vs its own
    # full-white PSU capacity (each rail at its own worst frame; cap-independent
    # because peak_w is design brightness). Max safe cap = 100% / binding fraction.
    binding_v, binding = max(((v, a['rails'][v]['peak_w'] / max(a['rails'][v]['fullwhite_w'], 1))
                              for v in (12, 24)), key=lambda t: t[1])
    max_cap = min(1.0, 1.0 / binding) if binding > 0 else 1.0
    print(f"\n  {'─'*66}\n  VERDICT  (design brightness = the .fseq as authored, before any cap)")
    print(f"    Whole-show brightness peaks at {g['peak_util']*100:.1f}% of all-white.")
    print(f"    Binding rail: {binding_v}V at {binding*100:.0f}% of its full-white PSU capacity (design peak).")
    if binding <= 1.0:
        print(f"    → Even at a 100% cap the {binding_v}V rail stays at {binding*100:.0f}% of capacity — within PSU")
        print(f"      rating. For THIS show the cap can go to 100% without exceeding any PSU's full-white")
        print(f"      budget; no compression needed on PSU-capacity grounds.")
    else:
        print(f"    → Above a {max_cap*100:.0f}% cap the {binding_v}V rail would exceed its PSU capacity; frames over")
        print(f"      that threshold are what the compressor pulls down.")
    print(f"    ⚠ The single-generator budget (not PSU rating) is the real operational ceiling for the cap;")
    print(f"      wiring in the generator + per-PSU amp budgets is the next step to fix the final safe cap.")
    print(f"  {'─'*66}\n")

# ============================== cli ==============================

def main():
    args = sys.argv[1:]
    if not args or args[0] in ('-h', '--help'):
        print(__doc__); return
    expanded = False; cap = 0.50; json_out = None
    rest = []
    i = 0
    while i < len(args):
        a = args[i]
        if a == '--expanded': expanded = True
        elif a == '--cap': i += 1; cap = float(args[i])
        elif a == '--json': i += 1; json_out = args[i]
        else: rest.append(a)
        i += 1
    src = rest[0]
    print(f"Reading {src} ...")
    meta, frames = EF.read_fseq(src)
    print(f"  {meta['num_frames']} frames, {meta['step_time']}ms/frame, source max channel {meta['max_chan']}")
    if not expanded:
        print("Expanding rose window in-memory ...")
        segs = EF.load_petal_segments()
        frames, _ = EF.expand_frames(frames, segs)
    a = analyze(frames, meta['step_time'], cap)
    report(a)
    if json_out:
        a2 = {k: v for k, v in a.items() if k != '_ts'}
        json.dump(a2, open(json_out, 'w'), indent=1)
        print(f"  wrote {json_out}")

if __name__ == '__main__':
    main()
