Files
jiggly/scripts/tune_runtime.py
T
swaitsandClaude Opus 4.7 d0d9c451aa chore: cut 0.2.0 — README, LICENSE, publish metadata, runtime tune, USB identity, F13
Release-prep work for a publish-worthy 0.2.0:

- LICENSE: MIT, © 2026 Stephen Waits.
- README.md: tagline, what-it-does, hardware, build/flash recipes,
  ASCII statechart overview, "Why four hours…" runtime-tuning rationale,
  USB identity section.
- Cargo.toml: 0.1.0 → 0.2.0; description, license, repository, readme,
  keywords, categories; publish = false (firmware, not a library);
  release profile tightened (lto = "fat", opt-level = "z", panic =
  "abort"). Flashed binary stays 47 KB; the size knobs are explicit
  rather than relying on defaults.
- CHANGELOG.md: collapse Unreleased → [0.2.0] - 2026-05-01.
- scripts/tune_runtime.py: 4-D Monte Carlo over (RUN_DURATION, YELLOW_AT,
  RED_AT, FAST_RED_AT). PEP 723 inline deps so `uv run` just works.

Runtime + LED thresholds re-derived from a typical office workday
distribution with a per-minute press-on-warning user model. Joint
optimum:

  RUN_DURATION:                                          4h00m
  YELLOW_AT  / RED_AT  / FAST_RED_AT  (min remaining):   30 / 25 / 20

USB identity:

  VID/PID:      046d:c07d (G502)  →  1209:b0b0 (pid.codes)
  manufacturer: "Logitech"        →  "swaits.com"
  product:      "G502 Mouse"      →  "jiggly"
  bcdDevice:    default 0x0010    →  0x0200 (matches firmware version)
  serial:       (none)            →  RP2040 chip ID as 16 hex chars

Bug fix in the descriptor change: an interim version spoofed the
Logitech Unifying Receiver (046d:c52b). On Linux, `hid-logitech-dj`
matches that exact PID and tries to talk Logitech's HID++ protocol to
enumerate paired wireless devices. The firmware doesn't speak HID++,
so the driver waits through ~10–20 s of control-transfer timeouts on
every plug before unbinding and letting `hid-generic` actually start
polling. macOS has no such driver and was always fast. Moving to a
pid.codes VID routes the device straight to `hid-generic`.

Wake key: tapped Left Shift in early versions to wake the host. In
practice that turned out to be exactly the nightmare scenario it
sounds like — if the deadline preempted the loop between a Shift-down
report and its Shift-up, the host would silently capitalise every
keystroke from the user's real keyboard until the device was unplugged.
Switched to F13: still wakes any modern OS, but no mainstream OS maps
F13 by default, so a stuck F13 has zero visible effect. Also added an
unconditional all-keys-released cleanup report at the end of the wake
action, bounded by KBD_RELEASE_DEADLINE = 100 ms, so even a deadline
that fires mid-press can't leave anything held.

Wake refactor: WakingWithKeyboard and WakingWithMouse use oneshot
`entry:` actions that race their work against an internal deadline via
`embassy_futures::select`; the chart timer (`on(after KBD_PHASE_DURATION)`
/ `on(after MOUSE_PHASE_DURATION)`) advances. Removes a class of "slow
USB ⇒ chart stalls" failure modes from the wake path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 22:32:17 -06:00

272 lines
9.4 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# /// script
# requires-python = ">=3.10"
# dependencies = ["numpy"]
# ///
"""
Monte Carlo tuner for the four lifecycle constants in src/main.rs:
RUN_DURATION full cycle length, in minutes
YELLOW_AT remaining-minute threshold where breathing-yellow begins
RED_AT remaining-minute threshold where breathing-red begins
FAST_RED_AT remaining-minute threshold where the fast-pulse blink begins
Sweeps a 4-D grid (with the constraint YELLOW_AT > RED_AT > FAST_RED_AT > 0)
across 50 000 simulated workdays and picks the combination that lands the
screen-sleep in the lunch hour as often as possible.
The model
- Day starts at Triangular(8:00, mode 8:30, 9:30) and ends at
Triangular(16:00, mode 17:30, 19:00). Lunch is 12:0013:00 (fixed).
- Free RESET at start (boot) and at 13:00 (re-login after lunch).
- User-at-desk minute-by-minute, sees the LED, and may tap RESET to
extend the cycle:
yellow 1.5 % / min
red 4.0 % / min
fast-red 6.0 % / min
warning10/5 one-shot bumps the minute the spiral animation fires
- A composite score rewards lunch-hour expiration (especially the
12:1512:45 sweet spot) and penalizes daytime failures.
Usage
uv run scripts/tune_runtime.py # default 50 000 days
uv run scripts/tune_runtime.py --n 100000 # finer Monte Carlo
Adjust the per-phase press probabilities and grid ranges at the top of
main() to match your own behavior or your own workday distribution.
"""
from __future__ import annotations
import argparse
import itertools
import time
import numpy as np
LUNCH_START = 12 * 60
LUNCH_END = 13 * 60
# Per-minute press probabilities per LED phase.
P_PRESS_YELLOW = 0.015
P_PRESS_RED = 0.040
P_PRESS_FAST_RED = 0.060
# One-shot bumps when the on-screen spiral animations fire (10 / 5 min before
# death). Independent of LED-phase boundaries — the firmware fires those at
# fixed offsets from death.
P_WARN10_BUMP = 0.04
P_WARN5_BUMP = 0.03
def sample_days(n: int, rng: np.random.Generator) -> tuple[np.ndarray, np.ndarray]:
s = (rng.triangular(8.0, 8.5, 9.5, n) * 60).astype(np.int32)
e = (rng.triangular(16.0, 17.5, 19.0, n) * 60).astype(np.int32)
return s, e
def simulate(
rt: int,
yellow_at: int,
red_at: int,
fast_red_at: int,
s: np.ndarray,
e: np.ndarray,
rng: np.random.Generator,
) -> dict:
n = len(s)
expire = s + rt
presses = np.zeros(n, dtype=np.int32)
slept_work = np.zeros(n, dtype=np.int32)
slept_lunch = np.zeros(n, dtype=np.int32)
after_hours = np.zeros(n, dtype=np.int32)
t_min = int(s.min())
t_max = int(max(e.max(), expire.max())) + 1
for t in range(t_min, t_max):
# Free re-tap when the user re-logs in at 13:00.
if t == LUNCH_END:
in_workday = (t >= s) & (t < e)
expire = np.where(in_workday, t + rt, expire)
in_workday = (t >= s) & (t < e)
at_lunch = LUNCH_START <= t < LUNCH_END
device_running = t < expire
device_dead = ~device_running
if at_lunch:
slept_lunch += (in_workday & device_dead).astype(np.int32)
else:
slept_work += (in_workday & device_dead).astype(np.int32)
past_end = (t >= e) & device_running
after_hours += past_end.astype(np.int32)
if not at_lunch:
eligible = in_workday & device_running
if eligible.any():
remaining = expire - t
p = np.zeros(n, dtype=np.float32)
yellow = (remaining > red_at) & (remaining <= yellow_at)
red = (remaining > fast_red_at) & (remaining <= red_at)
fast_red = (remaining > 0) & (remaining <= fast_red_at)
p[yellow] = P_PRESS_YELLOW
p[red] = P_PRESS_RED
p[fast_red] = P_PRESS_FAST_RED
p[remaining == 10] += P_WARN10_BUMP
p[remaining == 5] += P_WARN5_BUMP
roll = rng.random(n).astype(np.float32)
press = eligible & (roll < p)
np.putmask(expire, press, t + rt)
presses += press.astype(np.int32)
return {
"rt": rt,
"yellow_at": yellow_at,
"red_at": red_at,
"fast_red_at": fast_red_at,
"presses": presses,
"slept_work": slept_work,
"slept_lunch": slept_lunch,
"after_hours": after_hours,
}
def summarize(r: dict) -> dict:
sw = r["slept_work"]
sl = r["slept_lunch"]
ah = r["after_hours"]
pr = r["presses"]
sweet = (sl >= 15) & (sl <= 45)
return {
"rt": r["rt"],
"yellow_at": r["yellow_at"],
"red_at": r["red_at"],
"fast_red_at": r["fast_red_at"],
"p_sweet": sweet.mean(),
"p_lunch_any": (sl > 0).mean(),
"p_no_work_sleep": (sw == 0).mean(),
"mean_lunch": sl.mean(),
"mean_work_sleep": sw.mean(),
"mean_presses": pr.mean(),
"mean_after": ah.mean(),
}
def score(r: dict) -> float:
return (
r["p_sweet"]
+ 0.5 * r["p_lunch_any"]
- 1.5 * (1 - r["p_no_work_sleep"])
- 0.05 * r["mean_after"] / 60
)
def fmt_h(m: float) -> str:
m = int(round(m))
h, mm = divmod(m, 60)
return f"{h}h{mm:02d}m" if h else f"{mm}m"
def fmt_rt(m: int) -> str:
h, mm = divmod(int(m), 60)
return f"{h}h{mm:02d}m"
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--n", type=int, default=50_000, help="days per combo")
ap.add_argument("--seed", type=int, default=2026)
args = ap.parse_args()
# 4-D search grid. Wider/finer is more honest; narrower is faster.
rt_range = list(range(230, 251, 5)) # 230..250 step 5 (5)
yellow_at_range = list(range(20, 71, 5)) # 20..70 step 5 (11)
red_at_range = list(range(10, 41, 5)) # 10..40 step 5 (7)
fast_red_at_range = list(range(4, 21, 2)) # 4..20 step 2 (9)
print(f"tune_runtime — N={args.n} days/combo")
print(f" RT {rt_range[0]}..{rt_range[-1]} step 5 ({len(rt_range)})")
print(f" YELLOW_AT {yellow_at_range[0]}..{yellow_at_range[-1]} step 5 ({len(yellow_at_range)})")
print(f" RED_AT {red_at_range[0]}..{red_at_range[-1]} step 5 ({len(red_at_range)})")
print(f" FAST_RED_AT {fast_red_at_range[0]}..{fast_red_at_range[-1]} step 2 ({len(fast_red_at_range)})")
print(f" press: yellow {P_PRESS_YELLOW}/min, red {P_PRESS_RED}/min, "
f"fast {P_PRESS_FAST_RED}/min")
print()
rng = np.random.default_rng(args.seed)
s, e = sample_days(args.n, rng)
combos = [
(rt, ya, ra, fra)
for rt, ya, ra, fra in itertools.product(
rt_range, yellow_at_range, red_at_range, fast_red_at_range
)
if ya > ra > fra > 0
]
print(f" {len(combos)} valid combos to evaluate...")
t0 = time.time()
results = []
for i, (rt, ya, ra, fra) in enumerate(combos):
sim_rng = np.random.default_rng(args.seed + 1 + i)
r = simulate(rt, ya, ra, fra, s, e, sim_rng)
results.append(summarize(r))
if (i + 1) % 200 == 0:
elapsed = time.time() - t0
rate = (i + 1) / elapsed
eta = (len(combos) - i - 1) / rate
print(f" ... {i+1}/{len(combos)} ({rate:.1f}/sec, ETA {eta:.0f}s)")
print(f" done in {time.time() - t0:.0f}s")
print()
by_score = sorted(results, key=lambda r: -score(r))[:25]
print("=== top 25 by composite score ===")
print(f"{'RT':>6} {'YEL':>4} {'RED':>4} {'FST':>4} | "
f"{'p_sweet':>7} {'p_any':>6} {'p_no_fail':>9} | "
f"{'lunch':>5} {'work':>4} {'press':>5} {'score':>6}")
print("-" * 86)
for r in by_score:
print(f"{fmt_rt(r['rt']):>6} {r['yellow_at']:>4} {r['red_at']:>4} {r['fast_red_at']:>4} | "
f"{r['p_sweet']*100:>6.1f}% {r['p_lunch_any']*100:>5.1f}% "
f"{r['p_no_work_sleep']*100:>8.1f}% | "
f"{fmt_h(r['mean_lunch']):>5} {fmt_h(r['mean_work_sleep']):>4} "
f"{r['mean_presses']:>5.2f} {score(r):>6.3f}")
# Where does the firmware's currently-shipping combo land?
shipping = next(
(r for r in results
if r["rt"] == 240 and r["yellow_at"] == 30
and r["red_at"] == 25 and r["fast_red_at"] == 20),
None,
)
if shipping is not None:
rank = 1 + sum(1 for r in results if score(r) > score(shipping))
print()
print("=== current firmware (RT=4h00 YEL=30 RED=25 FST=20) ===")
print(f" p_sweet={shipping['p_sweet']*100:.1f}% "
f"p_any={shipping['p_lunch_any']*100:.1f}% "
f"p_no_fail={shipping['p_no_work_sleep']*100:.1f}% "
f"score={score(shipping):.3f}")
print(f" rank = {rank} / {len(results)}")
best = by_score[0]
print()
print(f"PICK: RT={fmt_rt(best['rt'])} YELLOW_AT={best['yellow_at']} "
f"RED_AT={best['red_at']} FAST_RED_AT={best['fast_red_at']}")
print(f" P(sweet 12:15-12:45) = {best['p_sweet']*100:.1f}%")
print(f" P(any lunch sleep) = {best['p_lunch_any']*100:.1f}%")
print(f" P(no work fail) = {best['p_no_work_sleep']*100:.1f}%")
print(f" mean lunch dead = {fmt_h(best['mean_lunch'])}")
print(f" mean work sleep = {fmt_h(best['mean_work_sleep'])}")
print(f" mean presses = {best['mean_presses']:.2f}/day")
print(f" mean after-hrs = {fmt_h(best['mean_after'])}")
if __name__ == "__main__":
main()