feat(generator): добавлено ускорение модельного времени

- Зачем:
  - ускоренный стенд должен проходить больше модельного времени и давать соответствующий событийный бюджет.
- Что:
  - расчёт интенсивности переведён на модельную длительность тика.
  - добавлена устойчивая выборка бюджета при больших λ.
  - усилены тесты ×K, дневного коэффициента и независимости от настенного часа.
- Проверка:
  - make generator-test.
  - review gate после issue 03 пройден после исправления underflow Poisson.
This commit is contained in:
2026-06-14 17:48:51 +03:00
parent efb07b0283
commit 589e2321b3
7 changed files with 237 additions and 47 deletions
@@ -129,7 +129,7 @@ class EventGenerator:
def _hour_factor(self, now: datetime | None = None) -> float:
"""Совместимый wrapper над расчётом часового коэффициента."""
return hour_factor(now, self.config.model_timezone)
return hour_factor(now or self.config.model_t0, self.config.model_timezone)
def _calculate_events_count(self, now: datetime | None = None) -> int:
"""Совместимый wrapper над расчётом событийного бюджета."""
@@ -2,17 +2,22 @@
import math
import random
from datetime import datetime, timezone
from datetime import datetime
from zoneinfo import ZoneInfo
from clickstream_generator.config import Config
POISSON_KNUTH_MAX_LAMBDA = 100.0
def hour_factor(now: datetime | None = None, model_timezone: str = "UTC") -> float:
"""Возвращает коэффициент интенсивности в зависимости от часа дня."""
current = now or datetime.now(timezone.utc)
current = now
if current is None:
raise ValueError("now is required for hour_factor")
if current.tzinfo is None:
current = current.replace(tzinfo=timezone.utc)
current = current.replace(tzinfo=ZoneInfo("UTC"))
current = current.astimezone(ZoneInfo(model_timezone))
hour = current.hour
if 9 <= hour <= 18:
@@ -22,18 +27,10 @@ def hour_factor(now: datetime | None = None, model_timezone: str = "UTC") -> flo
return 1.0
def calculate_events_count(
config: Config,
rng: random.Random,
now: datetime | None = None,
) -> int:
"""Вычисляет количество событий для текущего тика (Poisson + jitter)."""
if now is None:
factor = hour_factor()
else:
factor = hour_factor(now, config.model_timezone)
lambda_minute = config.lambda_base_per_min * factor
lambda_tick = lambda_minute * (config.tick_seconds / 60.0)
def _sample_poisson(lambda_tick: float, rng: random.Random) -> int:
"""Разыгрывает Poisson без underflow на больших λ."""
if lambda_tick >= POISSON_KNUTH_MAX_LAMBDA:
return max(0, round(rng.gauss(lambda_tick, math.sqrt(lambda_tick))))
count = 0
threshold = math.exp(-lambda_tick)
@@ -41,7 +38,22 @@ def calculate_events_count(
while product > threshold:
product *= rng.random()
count += 1
count -= 1
return count - 1
def calculate_events_count(
config: Config,
rng: random.Random,
now: datetime | None = None,
) -> int:
"""Вычисляет количество событий для текущего тика (Poisson + jitter)."""
model_time = now or config.model_t0
factor = hour_factor(model_time, config.model_timezone)
lambda_minute = config.lambda_base_per_min * factor
model_tick_seconds = config.tick_seconds * config.model_time_speed
lambda_tick = lambda_minute * (model_tick_seconds / 60.0)
count = _sample_poisson(lambda_tick, rng)
if config.jitter_pct > 0:
jitter_factor = 1.0 + rng.uniform(