feat(lib): add sample_duration, truncnorm_sample (no scipy)

This commit is contained in:
2026-05-12 01:09:22 +08:00
parent b93d240641
commit 2231e4e24b
2 changed files with 78 additions and 0 deletions

View File

@@ -137,3 +137,44 @@ def build_timestamps(
if t_abs[i] <= t_abs[i - 1]:
t_abs[i] = t_abs[i - 1] + 1.0
return t_abs
def truncnorm_sample(
mu: float,
sigma: float,
low: float,
high: float,
rng: np.random.Generator,
max_tries: int = 32,
) -> float:
"""Sample from N(mu, sigma) truncated to [low, high] via rejection.
Falls back to clipping if rejection fails ``max_tries`` times.
"""
for _ in range(max_tries):
v = rng.normal(mu, sigma)
if low <= v <= high:
return float(v)
return float(np.clip(rng.normal(mu, sigma), low, high))
def sample_duration(
duration_dist: dict,
dist: float,
rng: np.random.Generator,
) -> float:
"""Sample total trajectory duration (ms) for the given pixel distance.
Uses per-bin log-normal parameters in ``duration_dist``.
"""
bins = duration_dist["bins"]
params = duration_dist["params"]
bin_idx = len(bins) - 1
for i in range(len(bins) - 1):
if dist < bins[i + 1]:
bin_idx = i
break
bin_idx = min(bin_idx, len(params) - 1)
mu_log = params[bin_idx]["mu_log"]
sigma_log = params[bin_idx]["sigma_log"]
return float(np.exp(rng.normal(mu_log, sigma_log)))

View File

@@ -104,3 +104,40 @@ def test_build_timestamps_total_close_to_target() -> None:
ts = build_timestamps(log_dt, total_duration_ms=300.0)
# Last timestamp should be roughly total - one slot
assert abs(ts[-1] - 270) < 60 # tolerant of clipping
from ai_mouse._postprocess import sample_duration, truncnorm_sample
def test_truncnorm_sample_within_bounds() -> None:
rng = np.random.default_rng(0)
samples = [
truncnorm_sample(80.0, 30.0, 20.0, 300.0, rng) for _ in range(500)
]
arr = np.array(samples)
assert arr.min() >= 20.0
assert arr.max() <= 300.0
# Mean roughly close to mu
assert abs(arr.mean() - 80.0) < 5.0
def test_truncnorm_sample_far_outside_falls_back_to_clip() -> None:
rng = np.random.default_rng(0)
# mu far outside [low, high] — rejection will fail
v = truncnorm_sample(mu=1000.0, sigma=1.0, low=20.0, high=30.0, rng=rng)
assert 20.0 <= v <= 30.0
def test_sample_duration_uses_correct_bin() -> None:
dist_dict = {
"bins": [0, 50, 100, 200, 400, 600, 800, 1200, float("inf")],
"params": [
{"mu_log": 4.0, "sigma_log": 0.01}, # bin 0: dist < 50
{"mu_log": 5.0, "sigma_log": 0.01}, # bin 1: 50 <= dist < 100
{"mu_log": 6.0, "sigma_log": 0.01}, # bin 2: 100 <= dist < 200
] + [{"mu_log": 7.0, "sigma_log": 0.01}] * 5,
}
rng = np.random.default_rng(0)
v = sample_duration(dist_dict, 150.0, rng)
# exp(6) ~ 403, with tiny sigma we should land near there
assert 350 < v < 460