TerenceGrover/lightmapClean

★ 0Forks 0PythonGitHub ↗Compare

README

LightMap — Runtime Architecture

The control layer for the LightMap Monaco installation: mode system, transitions, operator panel, event scheduling, offline-first state. Built to run unattended on a Pi 5 after the installation is sold.

What this is

The original lightmap.py was a REPL — you typed commands, modes ran their own blocking loops. That doesn't survive being sold and walked away from. This is the persistent-installation version: a render loop, four (well, three) interchangeable modes, live mode-switching with crossfades, a boot sequence, a phone operator panel, and an event scheduler that's offline-tolerant.

The three modes

Mode Role Notes
ambient the hero — runs ~95% of the time day/night colour-temp cycle + a texture layer (wave or twinkle — both implemented, A/B on hardware)
data the civic upsell mode building height now; layer hook for OSM roads/density/elevation later
event the closer — operator-triggered radial ripple (yachts etc.) or sweep along a path (the GP circuit)

All three are deliberately generic. That's the sales strategy: a buyer watching data thinks "I want my data", a buyer watching event thinks "I want my events". Tailoring them now gives away the supplement. Generic ≠ unfinished though — ambient especially is meant to be genuinely polished.

A 4th purely-aesthetic mode slot is reserved in lm_modes.py (the standalone signature ripple got absorbed into event's radial style). Subclass Mode, register it in build_default_modes(), nothing else changes.

Architecture

                      runtime.py  (one process on the Pi)
                           |
   +--------------+--------+--------+----------------+
   |              |                 |                |
ModeManager   SchedulerThread   SyncThread      Flask operator app
(main thread) (daemon)          (daemon)        (daemon)
render loop   reads             refreshes       phone panel on the LAN
@ 30fps       events.json       events.json     four buttons + slider
              -> drives         from backend
              event mode        (offline-OK)
   |              |                 |                |
   +--------------+--------+--------+----------------+
                           |
              StateStore  (state.json + events.json)
              the Pi's LOCAL source of truth

One rule that drives the whole design: nothing in the render path or the scheduler ever does network I/O. The backend is a sync source, never a command source. Backend down — venue wifi drops, VPS dies, domain lapses in 2027 — is a non-event. The Pi runs forever off its local events.json cache.

One mechanism for mode changes: the operator panel, the scheduler, and the boot default all call manager.request_mode(). No privileged path. A human tap and a scheduler nudge are the same kind of call; last write wins, so the operator can always override an automated trigger.

Transitions

  • Crossfade (ModeManager.CROSSFADE_S, 0.7s) — mode-to-mode. A hard buffer swap reads as a glitch on RGBW strips. The manager renders both modes into scratch buffers and alpha-blends, ease-in-out.
  • Boot sweep (ModeManager.BOOT_S, 3.2s) — on power-up, a band of light sweeps west→east across the map and settles into the active mode. Theatre (the map arrives) and diagnostics: the sweep touches every pixel, so if strip 10 or 13 misbehaves again, the boot wash shows it before any visitor is there.

Files

File What Status
runtime.py production entry point — wires everything real
lm_modes.py Mode base + 3 modes real
mode_manager.py render loop, crossfade, boot real
state_store.py state.json + events.json, atomic writes real
threads.py SyncThread (stub) + SchedulerThread scheduler real, sync stubbed
operator_app.py Flask operator panel real
geo_utils.py shared spatial math, polyline resampling real
circuit_monaco.geojson the F1 circuit polyline real
dev_geometry.py synthetic geometry generator DEV ONLY — delete when real mapping lands
test_harness.py headless full-stack test DEV ONLY
geometry.py, framebuffer.py, transport.py your originals, untouched real
modes.py, lightmap.py your originals — REPL debug fallback kept, not used by runtime

Running it

# dev — fake serial, synthetic geometry, no hardware needed
python3 runtime.py --dev

# on the Pi — auto-detect Scorpio boards
python3 runtime.py

# explicit ports
python3 runtime.py /dev/ttyACM0 /dev/ttyACM1

# full headless test (22 checks)
python3 test_harness.py

Operator panel: http://<pi-ip>:8080/

Needs flask and pyserial (pyserial only for real hardware — --dev fakes it).

What's stubbed, and the contract

The backend doesn't exist yet — and shouldn't be built first. It's a standard CRUD-over-an-event-table with a calendar UI; zero dependency on the mapping or hardware; the least risky thing in the project. Build it from home, second, so the data contract is already proven by the thing consuming it.

SyncThread._fetch_remote() is the only thing that changes when the backend is real — swap the stub for an HTTP GET. The contract is locked: the backend must return JSON matching events.json's shape:

{
  "version": 1,
  "events": [
    {
      "name": "Monaco Grand Prix",
      "geometry": {"type": "path", "coords": [[lat,lng], ...]},
      "style": "sweep",
      "color": [200, 0, 0, 0],
      "radius_m": 70,
      "speed_mps": 90,
      "active": false,
      "window": {"start": "ISO8601", "end": "ISO8601"}
    }
  ]
}

geometry.type is point or path; style is radial or sweep. A point + radial is a yacht ripple; a path + sweep is the GP. Same schema, same EventMode.

The window field is where datetime-based auto-activation goes — right now active is a manual flag (operator panel / set_event_active()). When the scheduler computes active from window start/end, that's a change inside SchedulerThread._tick() only.

When the real mapping lands

  1. Drop the real geometry.json + buildings.csv in. Delete dev_geometry.py.
  2. The mode code depends only on the interface (geo.all_pixels with .lat/.lng, geo.buildings with .height, geo.channels, the pixel→channel mapping) — all of which geometry.py already provides. So the modes shouldn't need changes. If the real geometry has very different pixel density, re-check ambient's per-frame cost (see Performance).
  3. Re-run test_harness.py against the real geometry to confirm.

Performance notes (verified against real geometry)

Profiled on the real 804-pixel geometry. All modes well under the 33ms/frame budget, with massive headroom — even on a Pi 5 (~3-5x slower for pure-Python loops):

mode ms/frame (dev) est. Pi 5 headroom
ambient 2.3ms ~7-10ms comfortable
data 0.1ms <1ms trivial
event (sweep + real circuit) 0.2ms <1ms trivial

You could enable Gaussian blur in production without breaking 30fps. (My earlier "ambient is the expensive mode, watch for frame drops" note was based on a 2016-px synthetic stand-in — disregard it.)

The _pixel_path_dist precompute in EventMode._build_geometry() is the only notable cost — O(pixels × path-points) per set_event() call. Runs off the render thread (scheduler calls it), so the display doesn't stutter, but the crossfade into event mode may visibly lag the trigger by a fraction of a second. If that bugs you on hardware, cache _pixel_path_dist keyed by event name so re-triggering the GP is instant. Not done yet — premature until you see it.

Real-geometry observations worth knowing

(From running against the real geometry.json: 21 strips, 804 pixels.)

GP sweep coverage: only 63 of 804 pixels are within 50m of the circuit, 108 within 100m. With the default radius_m=90, the sweep only lights ~13% of the map — a precise head tracing the track through a mostly-dark map. That may or may not be the look you want; bump radius_m to 200-300 on the GP event for a wider, more theatrical glow. Two paths:

  • Surgical (current default): small bright head, precise circuit tracing, most of map dark. Reads as data/information.
  • Theatrical (bump to 200-300m): wider glow following the car. Track shape blurs but the mode lands harder visually.

I'd lean theatrical for the demo (event mode is the closer, needs to land); keep surgical as a custom-mode upsell for an F1-team buyer.

Data mode coverage: the map physically can't show every tall building — strips don't cover everywhere. Expect roughly 60-70% of tall buildings to map to a pixel within 300m. Data mode shows a constellation of where tall buildings cluster, not a one-to-one rendering. Not a bug, but know it.

Sanity check before the next workshop visit: the boot sweep west->east assumes geometry coordinates correspond to physical position. If a strip's pixels are misaligned in geometry.json, the boot wash will look wrong on that strip — a free diagnostic the first time you power it up.

Known minor thing

framebuffer.py flush() has its docstring inside the with self._lock: block — it's a no-op string statement, not a docstring. Harmless, didn't touch your file, but worth a one-line fix when you're next in there.