Godstrument/normalize.py
type-two e69b7e2ad6 Accounts, socio-economic feeds, and hub fixes (track deployed work)
Captures backend work already running on godstrument.pro but never committed:
- auth.py: invite-only accounts + per-user presets (SQLite, scrypt, signed
  stateless sessions), mounted by hub.py at /api/*
- hub.py: /api static+API handler, readonly public mode, full route spec in hello
- socio-economic / market / fire / debt / crypto workers + normalize/transform
- .gitignore: never commit godstrument_users.db* or auth_secret
- test_fixes.py: framework-free self-check for the review fixes

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 16:18:35 +10:00

195 lines
6.8 KiB
Python

"""
normalize.py — turning messy real-world numbers into clean 0..1 control signals.
Every source in the Godstrument (your hand, the room mic, a satellite, the price
of bitcoin) speaks in its own units and its own range. Before any of it can
modulate sound, it has to be tamed:
* OneEuroFilter — kills jitter without adding lag (the gold standard for
interactive/sensor data: still when you're still, snappy
when you move).
* AdaptiveNormalizer — auto-scales an unknown, drifting range (a gold price, a
solar-wind speed) into 0..1 using a rolling window.
* ImpulseEnvelope — turns a discrete event (an earthquake, a wiki edit) into a
decaying 0..1 signal you can actually hear.
* Slew — smoothly chases a target so slow data doesn't step/click.
Pure stdlib. No numpy required.
"""
from __future__ import annotations
import math
from collections import deque
# ---------------------------------------------------------------------------
# One Euro Filter (Casiez, Roussel, Vogel 2012)
# ---------------------------------------------------------------------------
class _LowPass:
def __init__(self):
self.y = None
self.s = None
def __call__(self, x: float, alpha: float) -> float:
if self.s is None:
self.s = x
else:
self.s = alpha * x + (1.0 - alpha) * self.s
self.y = x
return self.s
def _alpha(cutoff: float, dt: float) -> float:
tau = 1.0 / (2.0 * math.pi * cutoff)
return 1.0 / (1.0 + tau / dt)
class OneEuroFilter:
"""Adaptive low-pass. Low speed -> heavy smoothing; high speed -> low lag.
f = OneEuroFilter(min_cutoff=1.0, beta=0.007)
y = f(x, t_seconds)
"""
def __init__(self, min_cutoff: float = 1.0, beta: float = 0.007,
d_cutoff: float = 1.0):
self.min_cutoff = float(min_cutoff)
self.beta = float(beta)
self.d_cutoff = float(d_cutoff)
self._x = _LowPass()
self._dx = _LowPass()
self._t_prev: float | None = None
self._x_prev: float | None = None
def __call__(self, x: float, t: float) -> float:
if self._t_prev is None:
self._t_prev = t
self._x_prev = x
self._x(x, 1.0)
return x
dt = t - self._t_prev
if dt <= 0:
dt = 1e-3
# derivative, low-passed
dx = (x - self._x_prev) / dt
edx = self._dx(dx, _alpha(self.d_cutoff, dt))
# signal, low-passed with speed-dependent cutoff
cutoff = self.min_cutoff + self.beta * abs(edx)
y = self._x(x, _alpha(cutoff, dt))
self._t_prev = t
self._x_prev = x
return y
# ---------------------------------------------------------------------------
# Adaptive normalizer — unknown, drifting ranges -> 0..1
# ---------------------------------------------------------------------------
class AdaptiveNormalizer:
"""Auto-scales a stream into 0..1 against a rolling window.
mode:
"minmax" — scale between the window's min and max
"zscore" — map (x-mean)/std through a sigmoid (robust to outliers)
"fixed" — use an explicit (lo, hi); no adaptation
"""
def __init__(self, window: int = 600, mode: str = "minmax",
fixed: tuple[float, float] | None = None):
self.mode = mode
self.fixed = fixed
self.buf: deque[float] = deque(maxlen=window)
self._last = 0.0
def __call__(self, x: float) -> float:
try:
x = float(x)
except (TypeError, ValueError):
return self._last
if math.isnan(x) or math.isinf(x):
return self._last
if self.mode == "fixed" and self.fixed:
lo, hi = self.fixed
self._last = _clamp01((x - lo) / (hi - lo) if hi != lo else 0.0)
return self._last
self.buf.append(x)
if len(self.buf) < 3:
self._last = 0.5
return self._last
if self.mode == "zscore":
mean = sum(self.buf) / len(self.buf)
var = sum((v - mean) ** 2 for v in self.buf) / len(self.buf)
std = math.sqrt(var) or 1e-6
self._last = _clamp01(0.5 + 0.5 * math.tanh((x - mean) / (2.0 * std)))
return self._last
lo, hi = min(self.buf), max(self.buf)
self._last = _clamp01((x - lo) / (hi - lo) if hi != lo else 0.5)
return self._last
# ---------------------------------------------------------------------------
# Impulse envelope — discrete events -> decaying 0..1
# ---------------------------------------------------------------------------
class ImpulseEnvelope:
"""A quake or a wiki edit is a single instant. This gives it a body:
a fast attack and an exponential decay you can route to anything."""
def __init__(self, attack: float = 0.01, decay: float = 0.6):
self.attack = max(attack, 1e-4)
self.decay = max(decay, 1e-3)
self._peak = 0.0
self._t_trigger: float | None = None
self._amount = 0.0
def trigger(self, amount: float = 1.0, t: float | None = None):
amt = _clamp01(amount)
# peak-hold: a smaller event arriving mid-decay must not chop a bigger
# one that's still ringing (nor zero it out on a negative magnitude).
if t is not None and self._t_trigger is not None and amt <= self.value(t):
return
self._amount = amt
self._t_trigger = t
self._peak = 0.0
def value(self, t: float) -> float:
if self._t_trigger is None:
return 0.0
age = t - self._t_trigger
if age < 0:
return 0.0
if age < self.attack:
return self._amount * (age / self.attack)
decay_age = age - self.attack
v = self._amount * math.exp(-decay_age / self.decay)
return v if v > 1e-4 else 0.0
# ---------------------------------------------------------------------------
# Slew — chase a target smoothly (fills gaps between slow polls)
# ---------------------------------------------------------------------------
class Slew:
"""rate = units per second the value is allowed to move toward target."""
def __init__(self, rate: float = 4.0):
self.rate = rate
self.value: float | None = None
def __call__(self, target: float, dt: float) -> float:
if self.value is None:
self.value = target
return target
step = self.rate * dt
delta = target - self.value
if abs(delta) <= step:
self.value = target
else:
self.value += step * (1 if delta > 0 else -1)
return self.value
def _clamp01(x: float) -> float:
return 0.0 if x < 0.0 else 1.0 if x > 1.0 else x