feat(generator): откалиброван поток steady-stream генератора
- Зачем: - поток генератора должен соответствовать модели интенсивности и профилю сида перед реализацией состояния версии 2. - Что: - событийный бюджет тика переведён в рождения визитов через ожидаемую среднюю длину визита. - дефолты интенсивности и обычный docker-compose запуск синхронизированы с целевыми 30 событиями в минуту. - добавлены статистические проверки длины визита, воронки, новых пользователей, межсессионных пауз и долгого окна потока. - обновлены README, OPERATIONS и карточка задачи 05. - Проверка: - uv run --with-requirements generator/requirements.txt pytest generator/tests -q. - git diff --check.
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
Status: ready-for-agent
|
Status: ready-for-human
|
||||||
|
|
||||||
# Интенсивность и калибровка потока
|
# Интенсивность и калибровка потока
|
||||||
|
|
||||||
@@ -19,16 +19,16 @@ Status: ready-for-agent
|
|||||||
|
|
||||||
## Acceptance criteria
|
## Acceptance criteria
|
||||||
|
|
||||||
- [ ] Средняя интенсивность событий за длинное окно соответствует
|
- [x] Средняя интенсивность событий за длинное окно соответствует
|
||||||
`GEN_LAMBDA_BASE_PER_MIN * часовой коэффициент` с разумным отклонением.
|
`GEN_LAMBDA_BASE_PER_MIN * часовой коэффициент` с разумным отклонением.
|
||||||
- [ ] Рождения визитов рассчитываются из бюджета событий и средней длины визита,
|
- [x] Рождения визитов рассчитываются из бюджета событий и средней длины визита,
|
||||||
а не из старых границ `GEN_MIN/MAX_EVENTS_PER_TICK` в прежнем смысле.
|
а не из старых границ `GEN_MIN/MAX_EVENTS_PER_TICK` в прежнем смысле.
|
||||||
- [ ] Доля новых пользователей за длинное окно близка к `GEN_P_NEW_USER`.
|
- [x] Доля новых пользователей за длинное окно близка к `GEN_P_NEW_USER`.
|
||||||
- [ ] Межсессионные паузы одного пользователя не меньше кулдауна, а среднее на
|
- [x] Межсессионные паузы одного пользователя не меньше кулдауна, а среднее на
|
||||||
дефолтах находится в районе расчёта из спеки.
|
дефолтах находится в районе расчёта из спеки.
|
||||||
- [ ] Распределение длины визита на симуляции сопоставимо с сидом: медиана около
|
- [x] Распределение длины визита на симуляции сопоставимо с сидом: медиана около
|
||||||
10, среднее около 10, максимум не выше потолка.
|
10, среднее около 10, максимум не выше потолка.
|
||||||
- [ ] Воронка по шагам `/home -> товары -> /cart -> /payment -> /confirmation`
|
- [x] Воронка по шагам `/home -> товары -> /cart -> /payment -> /confirmation`
|
||||||
монотонно затухает на длинной симуляции.
|
монотонно затухает на длинной симуляции.
|
||||||
|
|
||||||
## Blocked by
|
## Blocked by
|
||||||
@@ -37,3 +37,19 @@ Status: ready-for-agent
|
|||||||
|
|
||||||
## Comments
|
## Comments
|
||||||
|
|
||||||
|
2026-06-11: реализована калибровка потока через `TickStreamGenerator`: событийный
|
||||||
|
бюджет тика копится как бюджет рождений визитов через ожидаемую среднюю длину
|
||||||
|
визита (~10 событий), а фактические события выходят по запланированным паузам
|
||||||
|
активных визитов. Дефолт `GEN_LAMBDA_BASE_PER_MIN` в коде снижен до 30
|
||||||
|
событий/мин и синхронизирован с обычным запуском через `docker-compose.yml`;
|
||||||
|
`GEN_MIN/MAX_EVENTS_PER_TICK` теперь ограничивают событийный бюджет тика до
|
||||||
|
пересчёта в рождения визитов.
|
||||||
|
|
||||||
|
Калибровка таблицы переходов и пауз проверена тестами: средняя интенсивность
|
||||||
|
длинного окна близка к целевому бюджету, доля новых пользователей близка к
|
||||||
|
`GEN_P_NEW_USER`, межсессионные паузы не меньше кулдауна и имеют часовой
|
||||||
|
масштаб около формульного ориентира из спеки, длина визита сопоставима с сидом,
|
||||||
|
доля `/confirmation` остаётся около 25%, воронка монотонно затухает.
|
||||||
|
|
||||||
|
Проверка: `uv run --with-requirements generator/requirements.txt pytest
|
||||||
|
generator/tests -q` — 96 passed.
|
||||||
|
|||||||
+2
-2
@@ -293,9 +293,9 @@ services:
|
|||||||
KAFKA_BOOTSTRAP_SERVERS: kafka:29092
|
KAFKA_BOOTSTRAP_SERVERS: kafka:29092
|
||||||
# Режим steady-stream: короткие тики 1-10 сек (по умолчанию 5)
|
# Режим steady-stream: короткие тики 1-10 сек (по умолчанию 5)
|
||||||
GEN_TICK_SECONDS: "5"
|
GEN_TICK_SECONDS: "5"
|
||||||
GEN_LAMBDA_BASE_PER_MIN: "200"
|
GEN_LAMBDA_BASE_PER_MIN: "30"
|
||||||
GEN_JITTER_PCT: "20"
|
GEN_JITTER_PCT: "20"
|
||||||
GEN_MIN_EVENTS_PER_TICK: "5"
|
GEN_MIN_EVENTS_PER_TICK: "1"
|
||||||
GEN_MAX_EVENTS_PER_TICK: "50"
|
GEN_MAX_EVENTS_PER_TICK: "50"
|
||||||
GEN_DATA_DIR: /data
|
GEN_DATA_DIR: /data
|
||||||
GEN_ENABLED: "true"
|
GEN_ENABLED: "true"
|
||||||
|
|||||||
+3
-3
@@ -93,10 +93,10 @@ make generator-logs
|
|||||||
| Переменная | Описание | По умолчанию |
|
| Переменная | Описание | По умолчанию |
|
||||||
|------------|----------|--------------|
|
|------------|----------|--------------|
|
||||||
| `GEN_TICK_SECONDS` | Интервал между тиками | `5` |
|
| `GEN_TICK_SECONDS` | Интервал между тиками | `5` |
|
||||||
| `GEN_LAMBDA_BASE_PER_MIN` | Базовая интенсивность (событий/мин) | `200` |
|
| `GEN_LAMBDA_BASE_PER_MIN` | Базовая интенсивность (событий/мин) | `30` |
|
||||||
| `GEN_JITTER_PCT` | Процент вариативности | `20` |
|
| `GEN_JITTER_PCT` | Процент вариативности | `20` |
|
||||||
| `GEN_MIN_EVENTS_PER_TICK` | Минимум событий за тик | `5` |
|
| `GEN_MIN_EVENTS_PER_TICK` | Минимальный событийный бюджет тика | `1` |
|
||||||
| `GEN_MAX_EVENTS_PER_TICK` | Максимум событий за тик | `50` |
|
| `GEN_MAX_EVENTS_PER_TICK` | Максимальный событийный бюджет тика | `50` |
|
||||||
|
|
||||||
### Топик истории
|
### Топик истории
|
||||||
|
|
||||||
|
|||||||
+10
-5
@@ -39,8 +39,8 @@ generator-service -> Kafka topics -> (потребители отдельно)
|
|||||||
- Публикуем постепенно, **короткими тиками** (по умолчанию каждые 5 секунд)
|
- Публикуем постепенно, **короткими тиками** (по умолчанию каждые 5 секунд)
|
||||||
- На каждом тике отправляем небольшую порцию сообщений
|
- На каждом тике отправляем небольшую порцию сообщений
|
||||||
- Держим целевую интенсивность `events/min` без крупных минутных batch:
|
- Держим целевую интенсивность `events/min` без крупных минутных batch:
|
||||||
рассчитанный событийный бюджет тика планирует новые визиты и списывается по
|
рассчитанный событийный бюджет тика копится как бюджет рождения визитов через
|
||||||
фактической длине этих визитов, а события выходят позже по своим
|
ожидаемую среднюю длину визита, а события выходят позже по своим
|
||||||
запланированным меткам времени
|
запланированным меткам времени
|
||||||
- Распределяем события по 4 топикам:
|
- Распределяем события по 4 топикам:
|
||||||
- `browser_events`
|
- `browser_events`
|
||||||
@@ -63,10 +63,10 @@ generator-service -> Kafka topics -> (потребители отдельно)
|
|||||||
|------------|----------|--------------|
|
|------------|----------|--------------|
|
||||||
| `KAFKA_BOOTSTRAP_SERVERS` | Адрес Kafka | `kafka:29092` |
|
| `KAFKA_BOOTSTRAP_SERVERS` | Адрес Kafka | `kafka:29092` |
|
||||||
| `GEN_TICK_SECONDS` | Интервал между тиками | `5` (1-10 сек рекомендуется) |
|
| `GEN_TICK_SECONDS` | Интервал между тиками | `5` (1-10 сек рекомендуется) |
|
||||||
| `GEN_LAMBDA_BASE_PER_MIN` | Базовая интенсивность (событий/мин) | `200` |
|
| `GEN_LAMBDA_BASE_PER_MIN` | Базовая интенсивность (событий/мин) | `30` |
|
||||||
| `GEN_JITTER_PCT` | Процент вариативности | `20` |
|
| `GEN_JITTER_PCT` | Процент вариативности | `20` |
|
||||||
| `GEN_MIN_EVENTS_PER_TICK` | Минимум событий за тик | `5` |
|
| `GEN_MIN_EVENTS_PER_TICK` | Минимальный событийный бюджет тика | `1` |
|
||||||
| `GEN_MAX_EVENTS_PER_TICK` | Максимум событий за тик | `50` |
|
| `GEN_MAX_EVENTS_PER_TICK` | Максимальный событийный бюджет тика | `50` |
|
||||||
| `GEN_MAX_SESSION_EVENTS` | Потолок длины одного визита, защита от петель | `30` |
|
| `GEN_MAX_SESSION_EVENTS` | Потолок длины одного визита, защита от петель | `30` |
|
||||||
| `GEN_MAX_ACTIVE_SESSIONS` | Потолок одновременных активных визитов | `200` |
|
| `GEN_MAX_ACTIVE_SESSIONS` | Потолок одновременных активных визитов | `200` |
|
||||||
| `GEN_POPULATION_MAX` | Потолок активной популяции пользователей | `300` |
|
| `GEN_POPULATION_MAX` | Потолок активной популяции пользователей | `300` |
|
||||||
@@ -88,6 +88,11 @@ GEN_MIN_EVENTS_PER_TICK=50
|
|||||||
GEN_MAX_EVENTS_PER_TICK=500
|
GEN_MAX_EVENTS_PER_TICK=500
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`GEN_MIN_EVENTS_PER_TICK` и `GEN_MAX_EVENTS_PER_TICK` задают нижнюю и верхнюю
|
||||||
|
границы событийного бюджета тика. Этот бюджет сначала превращается в рождения
|
||||||
|
визитов через среднюю длину визита, поэтому фактическое число отправленных
|
||||||
|
событий в конкретном тике может отличаться.
|
||||||
|
|
||||||
## Управление через Makefile
|
## Управление через Makefile
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ if str(SRC_DIR) not in sys.path:
|
|||||||
|
|
||||||
from clickstream_generator.config import Config
|
from clickstream_generator.config import Config
|
||||||
from clickstream_generator.dictionary import EventDictionary
|
from clickstream_generator.dictionary import EventDictionary
|
||||||
from clickstream_generator.generation import EventGenerator
|
from clickstream_generator.generation import EXPECTED_VISIT_EVENTS, EventGenerator
|
||||||
from clickstream_generator.intensity import calculate_events_count, hour_factor
|
from clickstream_generator.intensity import calculate_events_count, hour_factor
|
||||||
from clickstream_generator.kafka_io import (
|
from clickstream_generator.kafka_io import (
|
||||||
BatchRecord,
|
BatchRecord,
|
||||||
@@ -50,6 +50,7 @@ __all__ = [
|
|||||||
"Config",
|
"Config",
|
||||||
"EventDictionary",
|
"EventDictionary",
|
||||||
"EventGenerator",
|
"EventGenerator",
|
||||||
|
"EXPECTED_VISIT_EVENTS",
|
||||||
"GeneratorService",
|
"GeneratorService",
|
||||||
"GeneratorState",
|
"GeneratorState",
|
||||||
"KafkaBatchHistory",
|
"KafkaBatchHistory",
|
||||||
|
|||||||
@@ -16,13 +16,13 @@ class Config:
|
|||||||
default_factory=lambda: int(os.getenv("GEN_TICK_SECONDS", "5"))
|
default_factory=lambda: int(os.getenv("GEN_TICK_SECONDS", "5"))
|
||||||
)
|
)
|
||||||
lambda_base_per_min: int = field(
|
lambda_base_per_min: int = field(
|
||||||
default_factory=lambda: int(os.getenv("GEN_LAMBDA_BASE_PER_MIN", "200"))
|
default_factory=lambda: int(os.getenv("GEN_LAMBDA_BASE_PER_MIN", "30"))
|
||||||
)
|
)
|
||||||
jitter_pct: int = field(
|
jitter_pct: int = field(
|
||||||
default_factory=lambda: int(os.getenv("GEN_JITTER_PCT", "20"))
|
default_factory=lambda: int(os.getenv("GEN_JITTER_PCT", "20"))
|
||||||
)
|
)
|
||||||
min_events_per_tick: int = field(
|
min_events_per_tick: int = field(
|
||||||
default_factory=lambda: int(os.getenv("GEN_MIN_EVENTS_PER_TICK", "5"))
|
default_factory=lambda: int(os.getenv("GEN_MIN_EVENTS_PER_TICK", "1"))
|
||||||
)
|
)
|
||||||
max_events_per_tick: int = field(
|
max_events_per_tick: int = field(
|
||||||
default_factory=lambda: int(os.getenv("GEN_MAX_EVENTS_PER_TICK", "50"))
|
default_factory=lambda: int(os.getenv("GEN_MAX_EVENTS_PER_TICK", "50"))
|
||||||
|
|||||||
@@ -22,51 +22,57 @@ PAGE_START_DISTRIBUTION = [
|
|||||||
|
|
||||||
PAGE_TRANSITIONS = {
|
PAGE_TRANSITIONS = {
|
||||||
"/home": [
|
"/home": [
|
||||||
("/home", 0.40),
|
("/home", 0.41),
|
||||||
("/product_a", 0.28),
|
("/product_a", 0.28),
|
||||||
("/product_b", 0.18),
|
("/product_b", 0.18),
|
||||||
("/cart", 0.04),
|
("/cart", 0.04),
|
||||||
(None, 0.10),
|
|
||||||
],
|
|
||||||
"/product_a": [
|
|
||||||
("/home", 0.30),
|
|
||||||
("/product_a", 0.16),
|
|
||||||
("/product_b", 0.18),
|
|
||||||
("/cart", 0.27),
|
|
||||||
(None, 0.09),
|
(None, 0.09),
|
||||||
],
|
],
|
||||||
"/product_b": [
|
"/product_a": [
|
||||||
("/home", 0.32),
|
("/home", 0.34),
|
||||||
("/product_a", 0.15),
|
("/product_a", 0.13),
|
||||||
("/product_b", 0.18),
|
("/product_b", 0.18),
|
||||||
("/cart", 0.27),
|
("/cart", 0.27),
|
||||||
(None, 0.08),
|
(None, 0.08),
|
||||||
],
|
],
|
||||||
|
"/product_b": [
|
||||||
|
("/home", 0.36),
|
||||||
|
("/product_a", 0.15),
|
||||||
|
("/product_b", 0.15),
|
||||||
|
("/cart", 0.27),
|
||||||
|
(None, 0.07),
|
||||||
|
],
|
||||||
"/cart": [
|
"/cart": [
|
||||||
("/home", 0.20),
|
("/home", 0.20),
|
||||||
("/product_a", 0.12),
|
("/product_a", 0.12),
|
||||||
("/product_b", 0.10),
|
("/product_b", 0.10),
|
||||||
("/cart", 0.10),
|
("/cart", 0.11),
|
||||||
("/payment", 0.42),
|
("/payment", 0.42),
|
||||||
(None, 0.06),
|
(None, 0.05),
|
||||||
],
|
],
|
||||||
"/payment": [
|
"/payment": [
|
||||||
("/home", 0.12),
|
("/home", 0.12),
|
||||||
("/cart", 0.24),
|
("/cart", 0.24),
|
||||||
("/payment", 0.12),
|
("/payment", 0.14),
|
||||||
("/confirmation", 0.38),
|
("/confirmation", 0.38),
|
||||||
(None, 0.14),
|
(None, 0.12),
|
||||||
],
|
],
|
||||||
"/confirmation": [
|
"/confirmation": [
|
||||||
("/home", 0.35),
|
("/home", 0.37),
|
||||||
("/product_a", 0.15),
|
("/product_a", 0.15),
|
||||||
("/product_b", 0.10),
|
("/product_b", 0.10),
|
||||||
("/confirmation", 0.05),
|
("/confirmation", 0.05),
|
||||||
(None, 0.35),
|
(None, 0.33),
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Калибровочный ориентир из профиля сида: средний визит около 10 событий.
|
||||||
|
# Тиковый слой использует это число, чтобы переводить событийный бюджет в
|
||||||
|
# рождения визитов; тесты ниже защищают фактическую длину визита от дрейфа.
|
||||||
|
EXPECTED_VISIT_EVENTS = 10.0
|
||||||
|
|
||||||
|
|
||||||
class EventGenerator:
|
class EventGenerator:
|
||||||
"""Генератор одного связанного визита."""
|
"""Генератор одного связанного визита."""
|
||||||
|
|
||||||
@@ -118,7 +124,7 @@ class EventGenerator:
|
|||||||
|
|
||||||
def _visit_pause_seconds(self) -> float:
|
def _visit_pause_seconds(self) -> float:
|
||||||
"""Разыгрывает паузу между событиями визита."""
|
"""Разыгрывает паузу между событиями визита."""
|
||||||
pause = self.rng.lognormvariate(math.log(45.0), 1.0)
|
pause = self.rng.lognormvariate(math.log(20.0), 0.9)
|
||||||
return max(1.0, min(pause, 29 * 60.0))
|
return max(1.0, min(pause, 29 * 60.0))
|
||||||
|
|
||||||
def _hour_factor(self) -> float:
|
def _hour_factor(self) -> float:
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from dataclasses import dataclass
|
|||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from weakref import WeakKeyDictionary
|
from weakref import WeakKeyDictionary
|
||||||
|
|
||||||
from clickstream_generator.generation import EventGenerator
|
from clickstream_generator.generation import EXPECTED_VISIT_EVENTS, EventGenerator
|
||||||
|
|
||||||
|
|
||||||
TOPICS = ("browser_events", "location_events", "device_events", "geo_events")
|
TOPICS = ("browser_events", "location_events", "device_events", "geo_events")
|
||||||
@@ -136,7 +136,7 @@ class TickStreamGenerator:
|
|||||||
self.generator = generator
|
self.generator = generator
|
||||||
self.active_visits: list[ActiveVisit] = []
|
self.active_visits: list[ActiveVisit] = []
|
||||||
self.population = UserPopulation(generator)
|
self.population = UserPopulation(generator)
|
||||||
self._pending_event_budget = 0
|
self._pending_visit_births = 0.0
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def population_size(self) -> int:
|
def population_size(self) -> int:
|
||||||
@@ -165,18 +165,18 @@ class TickStreamGenerator:
|
|||||||
|
|
||||||
def _birth_visits(self, event_budget: int, tick_time: datetime) -> None:
|
def _birth_visits(self, event_budget: int, tick_time: datetime) -> None:
|
||||||
if len(self.active_visits) >= self.generator.config.max_active_sessions:
|
if len(self.active_visits) >= self.generator.config.max_active_sessions:
|
||||||
self._pending_event_budget = 0
|
self._pending_visit_births = 0.0
|
||||||
return
|
return
|
||||||
|
|
||||||
self._pending_event_budget += max(0, event_budget)
|
self._pending_visit_births += max(0, event_budget) / EXPECTED_VISIT_EVENTS
|
||||||
|
|
||||||
while (
|
while (
|
||||||
self._pending_event_budget > 0
|
self._pending_visit_births >= 1.0
|
||||||
and len(self.active_visits) < self.generator.config.max_active_sessions
|
and len(self.active_visits) < self.generator.config.max_active_sessions
|
||||||
):
|
):
|
||||||
user = self.population.choose_for_visit(tick_time)
|
user = self.population.choose_for_visit(tick_time)
|
||||||
if user is None:
|
if user is None:
|
||||||
self._pending_event_budget = 0
|
self._pending_visit_births = 0.0
|
||||||
return
|
return
|
||||||
|
|
||||||
visit_batch = self.generator.generate_batch(
|
visit_batch = self.generator.generate_batch(
|
||||||
@@ -196,10 +196,10 @@ class TickStreamGenerator:
|
|||||||
self.active_visits.append(
|
self.active_visits.append(
|
||||||
ActiveVisit(batch=visit_batch, timestamps=timestamps, user=user)
|
ActiveVisit(batch=visit_batch, timestamps=timestamps, user=user)
|
||||||
)
|
)
|
||||||
self._pending_event_budget -= len(timestamps)
|
self._pending_visit_births -= 1.0
|
||||||
|
|
||||||
if len(self.active_visits) >= self.generator.config.max_active_sessions:
|
if len(self.active_visits) >= self.generator.config.max_active_sessions:
|
||||||
self._pending_event_budget = 0
|
self._pending_visit_births = 0.0
|
||||||
|
|
||||||
def _release_due_events(
|
def _release_due_events(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -33,9 +33,9 @@ def base_config(data_dir):
|
|||||||
return Config(
|
return Config(
|
||||||
kafka_bootstrap_servers="localhost:9092",
|
kafka_bootstrap_servers="localhost:9092",
|
||||||
tick_seconds=5,
|
tick_seconds=5,
|
||||||
lambda_base_per_min=200,
|
lambda_base_per_min=30,
|
||||||
jitter_pct=20,
|
jitter_pct=20,
|
||||||
min_events_per_tick=5,
|
min_events_per_tick=1,
|
||||||
max_events_per_tick=50,
|
max_events_per_tick=50,
|
||||||
data_dir=data_dir,
|
data_dir=data_dir,
|
||||||
seed=42,
|
seed=42,
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ class TestConfigValidation:
|
|||||||
def test_valid_config_passes(self, base_config):
|
def test_valid_config_passes(self, base_config):
|
||||||
"""Валидная конфигурация создается без ошибок."""
|
"""Валидная конфигурация создается без ошибок."""
|
||||||
assert base_config.tick_seconds == 5
|
assert base_config.tick_seconds == 5
|
||||||
assert base_config.lambda_base_per_min == 200
|
assert base_config.lambda_base_per_min == 30
|
||||||
assert base_config.jitter_pct == 20
|
assert base_config.jitter_pct == 20
|
||||||
assert base_config.max_active_sessions < base_config.population_max
|
assert base_config.max_active_sessions < base_config.population_max
|
||||||
|
|
||||||
@@ -64,6 +64,17 @@ class TestConfigValidation:
|
|||||||
class TestConfigDefaults:
|
class TestConfigDefaults:
|
||||||
"""Тесты значений по умолчанию."""
|
"""Тесты значений по умолчанию."""
|
||||||
|
|
||||||
|
def test_default_lambda_base_per_min_is_calibrated_to_demo_scale(
|
||||||
|
self, monkeypatch, data_dir
|
||||||
|
):
|
||||||
|
"""По умолчанию целевая интенсивность равна 30 событиям в минуту."""
|
||||||
|
monkeypatch.setenv("GEN_DATA_DIR", str(data_dir))
|
||||||
|
monkeypatch.delenv("GEN_LAMBDA_BASE_PER_MIN", raising=False)
|
||||||
|
|
||||||
|
config = Config()
|
||||||
|
|
||||||
|
assert config.lambda_base_per_min == 30
|
||||||
|
|
||||||
def test_default_tick_seconds_is_5(self, data_dir):
|
def test_default_tick_seconds_is_5(self, data_dir):
|
||||||
"""По умолчанию tick_seconds = 5 (rev5)."""
|
"""По умолчанию tick_seconds = 5 (rev5)."""
|
||||||
import os
|
import os
|
||||||
@@ -75,9 +86,9 @@ class TestConfigDefaults:
|
|||||||
config = Config(
|
config = Config(
|
||||||
kafka_bootstrap_servers="localhost:9092",
|
kafka_bootstrap_servers="localhost:9092",
|
||||||
tick_seconds=int(os.getenv("GEN_TICK_SECONDS", "5")),
|
tick_seconds=int(os.getenv("GEN_TICK_SECONDS", "5")),
|
||||||
lambda_base_per_min=200,
|
lambda_base_per_min=30,
|
||||||
jitter_pct=20,
|
jitter_pct=20,
|
||||||
min_events_per_tick=5,
|
min_events_per_tick=1,
|
||||||
max_events_per_tick=50,
|
max_events_per_tick=50,
|
||||||
data_dir=data_dir,
|
data_dir=data_dir,
|
||||||
seed=None,
|
seed=None,
|
||||||
|
|||||||
@@ -2,12 +2,20 @@
|
|||||||
Тесты генерации событий.
|
Тесты генерации событий.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import random
|
||||||
import uuid
|
import uuid
|
||||||
from dataclasses import replace
|
from dataclasses import replace
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from generator import EventGenerator, EventDictionary, TickStreamGenerator, generate_tick_batch
|
from generator import (
|
||||||
|
EventGenerator,
|
||||||
|
EventDictionary,
|
||||||
|
EXPECTED_VISIT_EVENTS,
|
||||||
|
TickStreamGenerator,
|
||||||
|
calculate_events_count,
|
||||||
|
generate_tick_batch,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
ALLOWED_PAGE_PATHS = {
|
ALLOWED_PAGE_PATHS = {
|
||||||
@@ -143,7 +151,7 @@ class TestEventGeneration:
|
|||||||
def test_tick_stream_keeps_long_window_intensity_near_event_budget(
|
def test_tick_stream_keeps_long_window_intensity_near_event_budget(
|
||||||
self, event_dictionary, base_config
|
self, event_dictionary, base_config
|
||||||
):
|
):
|
||||||
"""Длинное окно не разгоняется от событийного бюджета к бюджету визитов."""
|
"""Длинное окно держит среднюю интенсивность около событийного бюджета."""
|
||||||
config = replace(
|
config = replace(
|
||||||
base_config,
|
base_config,
|
||||||
tick_seconds=5,
|
tick_seconds=5,
|
||||||
@@ -167,8 +175,35 @@ class TestEventGeneration:
|
|||||||
total_events += len(batch["browser_events"])
|
total_events += len(batch["browser_events"])
|
||||||
|
|
||||||
events_per_minute = total_events / (ticks_count * config.tick_seconds / 60)
|
events_per_minute = total_events / (ticks_count * config.tick_seconds / 60)
|
||||||
|
expected_events_per_minute = 17 * 60 / config.tick_seconds
|
||||||
|
|
||||||
assert events_per_minute < 300
|
assert events_per_minute == pytest.approx(expected_events_per_minute, rel=0.10)
|
||||||
|
|
||||||
|
def test_tick_stream_births_visits_from_average_visit_length_budget(
|
||||||
|
self, event_dictionary, base_config
|
||||||
|
):
|
||||||
|
"""Новый визит рождается после накопления бюджета средней длины визита."""
|
||||||
|
config = replace(
|
||||||
|
base_config,
|
||||||
|
tick_seconds=60,
|
||||||
|
max_active_sessions=100,
|
||||||
|
population_max=101,
|
||||||
|
)
|
||||||
|
generator = EventGenerator(event_dictionary, config)
|
||||||
|
stream = TickStreamGenerator(generator)
|
||||||
|
tick_at = datetime(2026, 6, 11, 12, 0)
|
||||||
|
|
||||||
|
partial_budget_tick = stream.generate_tick(
|
||||||
|
event_budget=9,
|
||||||
|
tick_started_at=tick_at,
|
||||||
|
)
|
||||||
|
completed_budget_tick = stream.generate_tick(
|
||||||
|
event_budget=1,
|
||||||
|
tick_started_at=tick_at + timedelta(seconds=config.tick_seconds),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert partial_budget_tick["browser_events"] == []
|
||||||
|
assert len(completed_budget_tick["browser_events"]) == 1
|
||||||
|
|
||||||
def test_tick_stream_keeps_active_visit_between_ticks(self, event_dictionary, base_config):
|
def test_tick_stream_keeps_active_visit_between_ticks(self, event_dictionary, base_config):
|
||||||
"""Один визит может выпускать события в нескольких последовательных тиках."""
|
"""Один визит может выпускать события в нескольких последовательных тиках."""
|
||||||
@@ -177,7 +212,7 @@ class TestEventGeneration:
|
|||||||
stream = TickStreamGenerator(generator)
|
stream = TickStreamGenerator(generator)
|
||||||
first_tick_at = datetime(2026, 6, 11, 12, 0)
|
first_tick_at = datetime(2026, 6, 11, 12, 0)
|
||||||
|
|
||||||
first_tick = stream.generate_tick(event_budget=1, tick_started_at=first_tick_at)
|
first_tick = stream.generate_tick(event_budget=10, tick_started_at=first_tick_at)
|
||||||
second_tick = stream.generate_tick(
|
second_tick = stream.generate_tick(
|
||||||
event_budget=0,
|
event_budget=0,
|
||||||
tick_started_at=first_tick_at + timedelta(seconds=config.tick_seconds),
|
tick_started_at=first_tick_at + timedelta(seconds=config.tick_seconds),
|
||||||
@@ -202,7 +237,7 @@ class TestEventGeneration:
|
|||||||
stream = TickStreamGenerator(generator)
|
stream = TickStreamGenerator(generator)
|
||||||
tick_at = datetime(2026, 6, 11, 12, 0)
|
tick_at = datetime(2026, 6, 11, 12, 0)
|
||||||
|
|
||||||
first_tick = stream.generate_tick(event_budget=1, tick_started_at=tick_at)
|
first_tick = stream.generate_tick(event_budget=10, tick_started_at=tick_at)
|
||||||
same_time_tick = stream.generate_tick(event_budget=0, tick_started_at=tick_at)
|
same_time_tick = stream.generate_tick(event_budget=0, tick_started_at=tick_at)
|
||||||
|
|
||||||
assert len(first_tick["browser_events"]) == 1
|
assert len(first_tick["browser_events"]) == 1
|
||||||
@@ -218,7 +253,7 @@ class TestEventGeneration:
|
|||||||
stream = TickStreamGenerator(generator)
|
stream = TickStreamGenerator(generator)
|
||||||
tick_at = datetime(2026, 6, 11, 12, 0)
|
tick_at = datetime(2026, 6, 11, 12, 0)
|
||||||
|
|
||||||
first_tick = stream.generate_tick(event_budget=1, tick_started_at=tick_at)
|
first_tick = stream.generate_tick(event_budget=10, tick_started_at=tick_at)
|
||||||
final_tick = stream.generate_tick(
|
final_tick = stream.generate_tick(
|
||||||
event_budget=0,
|
event_budget=0,
|
||||||
tick_started_at=tick_at + timedelta(hours=1),
|
tick_started_at=tick_at + timedelta(hours=1),
|
||||||
@@ -247,8 +282,8 @@ class TestEventGeneration:
|
|||||||
stream = TickStreamGenerator(generator)
|
stream = TickStreamGenerator(generator)
|
||||||
tick_at = datetime(2026, 6, 11, 12, 0)
|
tick_at = datetime(2026, 6, 11, 12, 0)
|
||||||
|
|
||||||
first_tick = stream.generate_tick(event_budget=5, tick_started_at=tick_at)
|
first_tick = stream.generate_tick(event_budget=10, tick_started_at=tick_at)
|
||||||
blocked_tick = stream.generate_tick(event_budget=5, tick_started_at=tick_at)
|
blocked_tick = stream.generate_tick(event_budget=10, tick_started_at=tick_at)
|
||||||
final_tick = stream.generate_tick(
|
final_tick = stream.generate_tick(
|
||||||
event_budget=0,
|
event_budget=0,
|
||||||
tick_started_at=tick_at + timedelta(hours=1),
|
tick_started_at=tick_at + timedelta(hours=1),
|
||||||
@@ -305,6 +340,55 @@ class TestEventGeneration:
|
|||||||
assert repeated_users
|
assert repeated_users
|
||||||
assert all(len(user_ids) == 1 for user_ids in users_by_session.values())
|
assert all(len(user_ids) == 1 for user_ids in users_by_session.values())
|
||||||
|
|
||||||
|
def test_tick_stream_new_user_share_matches_config_after_warmup(
|
||||||
|
self, event_dictionary, base_config
|
||||||
|
):
|
||||||
|
"""Доля новых пользователей в потоке близка к GEN_P_NEW_USER."""
|
||||||
|
config = replace(
|
||||||
|
base_config,
|
||||||
|
tick_seconds=60,
|
||||||
|
jitter_pct=0,
|
||||||
|
min_events_per_tick=30,
|
||||||
|
max_events_per_tick=30,
|
||||||
|
lambda_base_per_min=30,
|
||||||
|
max_active_sessions=200,
|
||||||
|
population_max=300,
|
||||||
|
p_new_user=0.15,
|
||||||
|
min_return_minutes=30,
|
||||||
|
)
|
||||||
|
generator = EventGenerator(event_dictionary, config)
|
||||||
|
stream = TickStreamGenerator(generator)
|
||||||
|
tick_at = datetime(2026, 6, 11, 12, 0)
|
||||||
|
warmup_ticks = 240
|
||||||
|
measure_ticks = 480
|
||||||
|
seen_users = set()
|
||||||
|
seen_sessions = set()
|
||||||
|
total_sessions = 0
|
||||||
|
new_user_sessions = 0
|
||||||
|
|
||||||
|
for tick_index in range(warmup_ticks + measure_ticks):
|
||||||
|
batch = stream.generate_tick(
|
||||||
|
event_budget=30,
|
||||||
|
tick_started_at=tick_at + timedelta(seconds=60 * tick_index),
|
||||||
|
)
|
||||||
|
users_by_session = {
|
||||||
|
event["click_id"]: event["user_domain_id"]
|
||||||
|
for event in batch["device_events"]
|
||||||
|
}
|
||||||
|
for click_id, user_id in users_by_session.items():
|
||||||
|
if click_id in seen_sessions:
|
||||||
|
continue
|
||||||
|
seen_sessions.add(click_id)
|
||||||
|
if tick_index >= warmup_ticks:
|
||||||
|
total_sessions += 1
|
||||||
|
if user_id not in seen_users:
|
||||||
|
new_user_sessions += 1
|
||||||
|
seen_users.add(user_id)
|
||||||
|
|
||||||
|
new_user_share = new_user_sessions / total_sessions
|
||||||
|
|
||||||
|
assert new_user_share == pytest.approx(config.p_new_user, rel=0.35)
|
||||||
|
|
||||||
def test_tick_stream_replays_same_flow_with_same_seed(
|
def test_tick_stream_replays_same_flow_with_same_seed(
|
||||||
self, event_dictionary, base_config
|
self, event_dictionary, base_config
|
||||||
):
|
):
|
||||||
@@ -409,6 +493,81 @@ class TestEventGeneration:
|
|||||||
minutes=config.min_return_minutes
|
minutes=config.min_return_minutes
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_returning_user_pause_average_matches_model_scale(
|
||||||
|
self, event_dictionary, base_config
|
||||||
|
):
|
||||||
|
"""Средняя пауза между визитами одного пользователя имеет часовой масштаб."""
|
||||||
|
config = replace(
|
||||||
|
base_config,
|
||||||
|
tick_seconds=60,
|
||||||
|
jitter_pct=0,
|
||||||
|
min_events_per_tick=30,
|
||||||
|
max_events_per_tick=30,
|
||||||
|
lambda_base_per_min=30,
|
||||||
|
max_active_sessions=200,
|
||||||
|
population_max=300,
|
||||||
|
p_new_user=0.15,
|
||||||
|
min_return_minutes=30,
|
||||||
|
)
|
||||||
|
generator = EventGenerator(event_dictionary, config)
|
||||||
|
stream = TickStreamGenerator(generator)
|
||||||
|
|
||||||
|
batches = _run_stream_batches(
|
||||||
|
stream=stream,
|
||||||
|
tick_at=datetime(2026, 6, 11, 12, 0),
|
||||||
|
ticks_count=16 * 60,
|
||||||
|
event_budget=30,
|
||||||
|
tick_seconds=config.tick_seconds,
|
||||||
|
)
|
||||||
|
users_by_session = {}
|
||||||
|
times_by_session = {}
|
||||||
|
for batch in batches:
|
||||||
|
for device_event in batch["device_events"]:
|
||||||
|
users_by_session.setdefault(
|
||||||
|
device_event["click_id"],
|
||||||
|
device_event["user_domain_id"],
|
||||||
|
)
|
||||||
|
for browser_event in batch["browser_events"]:
|
||||||
|
times_by_session.setdefault(browser_event["click_id"], []).append(
|
||||||
|
datetime.fromisoformat(
|
||||||
|
browser_event["event_timestamp"].replace(" ", "T")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
sessions_by_user = {}
|
||||||
|
for click_id, timestamps in times_by_session.items():
|
||||||
|
sessions_by_user.setdefault(users_by_session[click_id], []).append(
|
||||||
|
(min(timestamps), max(timestamps))
|
||||||
|
)
|
||||||
|
|
||||||
|
pauses_minutes = []
|
||||||
|
visit_durations_minutes = []
|
||||||
|
for sessions in sessions_by_user.values():
|
||||||
|
ordered_sessions = sorted(sessions)
|
||||||
|
visit_durations_minutes.extend(
|
||||||
|
(finished_at - started_at).total_seconds() / 60
|
||||||
|
for started_at, finished_at in ordered_sessions
|
||||||
|
)
|
||||||
|
for previous, current in zip(ordered_sessions, ordered_sessions[1:]):
|
||||||
|
pauses_minutes.append((current[0] - previous[1]).total_seconds() / 60)
|
||||||
|
|
||||||
|
mean_pause_minutes = sum(pauses_minutes) / len(pauses_minutes)
|
||||||
|
mean_visit_duration_minutes = (
|
||||||
|
sum(visit_durations_minutes) / len(visit_durations_minutes)
|
||||||
|
)
|
||||||
|
expected_pause_minutes = (
|
||||||
|
config.population_max
|
||||||
|
/ (
|
||||||
|
config.lambda_base_per_min
|
||||||
|
/ EXPECTED_VISIT_EVENTS
|
||||||
|
* (1 - config.p_new_user)
|
||||||
|
)
|
||||||
|
- mean_visit_duration_minutes
|
||||||
|
)
|
||||||
|
|
||||||
|
assert min(pauses_minutes) >= config.min_return_minutes
|
||||||
|
assert mean_pause_minutes == pytest.approx(expected_pause_minutes, rel=0.30)
|
||||||
|
|
||||||
def test_visit_cooldown_starts_from_planned_last_event_time(
|
def test_visit_cooldown_starts_from_planned_last_event_time(
|
||||||
self, event_dictionary, base_config
|
self, event_dictionary, base_config
|
||||||
):
|
):
|
||||||
@@ -426,7 +585,7 @@ class TestEventGeneration:
|
|||||||
stream = TickStreamGenerator(generator)
|
stream = TickStreamGenerator(generator)
|
||||||
tick_at = datetime(2026, 6, 11, 12, 0)
|
tick_at = datetime(2026, 6, 11, 12, 0)
|
||||||
|
|
||||||
first_tick = stream.generate_tick(event_budget=1, tick_started_at=tick_at)
|
first_tick = stream.generate_tick(event_budget=10, tick_started_at=tick_at)
|
||||||
user_id = first_tick["device_events"][0]["user_domain_id"]
|
user_id = first_tick["device_events"][0]["user_domain_id"]
|
||||||
planned_visit_end = stream.active_visits[0].timestamps[-1]
|
planned_visit_end = stream.active_visits[0].timestamps[-1]
|
||||||
|
|
||||||
@@ -459,13 +618,13 @@ class TestEventGeneration:
|
|||||||
stream = TickStreamGenerator(generator)
|
stream = TickStreamGenerator(generator)
|
||||||
tick_at = datetime(2026, 6, 11, 12, 0)
|
tick_at = datetime(2026, 6, 11, 12, 0)
|
||||||
|
|
||||||
first_tick = stream.generate_tick(event_budget=5, tick_started_at=tick_at)
|
first_tick = stream.generate_tick(event_budget=10, tick_started_at=tick_at)
|
||||||
second_tick = stream.generate_tick(
|
second_tick = stream.generate_tick(
|
||||||
event_budget=5,
|
event_budget=10,
|
||||||
tick_started_at=tick_at + timedelta(hours=1),
|
tick_started_at=tick_at + timedelta(hours=1),
|
||||||
)
|
)
|
||||||
third_tick = stream.generate_tick(
|
third_tick = stream.generate_tick(
|
||||||
event_budget=5,
|
event_budget=10,
|
||||||
tick_started_at=tick_at + timedelta(hours=2),
|
tick_started_at=tick_at + timedelta(hours=2),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -622,6 +781,21 @@ class TestEventGeneration:
|
|||||||
assert max(len(visit) for visit in visits) <= 4
|
assert max(len(visit) for visit in visits) <= 4
|
||||||
assert any(len(visit) == 4 for visit in visits)
|
assert any(len(visit) == 4 for visit in visits)
|
||||||
|
|
||||||
|
def test_visit_length_distribution_matches_seed_scale(
|
||||||
|
self, event_dictionary, base_config
|
||||||
|
):
|
||||||
|
"""Длина визита сопоставима с сидом: медиана и среднее около 10."""
|
||||||
|
generator = EventGenerator(event_dictionary, base_config)
|
||||||
|
|
||||||
|
visits = _page_path_visits(generator, 2000)
|
||||||
|
visit_lengths = sorted(len(visit) for visit in visits)
|
||||||
|
median_length = visit_lengths[len(visit_lengths) // 2]
|
||||||
|
mean_length = sum(visit_lengths) / len(visit_lengths)
|
||||||
|
|
||||||
|
assert 8 <= median_length <= 12
|
||||||
|
assert mean_length == pytest.approx(EXPECTED_VISIT_EVENTS, rel=0.10)
|
||||||
|
assert max(visit_lengths) <= base_config.max_session_events
|
||||||
|
|
||||||
def test_visit_pauses_stay_below_session_timeout_scale(self, event_dictionary, base_config):
|
def test_visit_pauses_stay_below_session_timeout_scale(self, event_dictionary, base_config):
|
||||||
"""Паузы внутри визита остаются меньше 30 минут, p95 — единицы минут."""
|
"""Паузы внутри визита остаются меньше 30 минут, p95 — единицы минут."""
|
||||||
generator = EventGenerator(event_dictionary, base_config)
|
generator = EventGenerator(event_dictionary, base_config)
|
||||||
@@ -651,6 +825,23 @@ class TestEventGeneration:
|
|||||||
|
|
||||||
assert 0.20 <= confirmation_share <= 0.30
|
assert 0.20 <= confirmation_share <= 0.30
|
||||||
|
|
||||||
|
def test_visit_funnel_monotonically_fades(self, event_dictionary, base_config):
|
||||||
|
"""Воронка по визитам монотонно затухает от главной до подтверждения."""
|
||||||
|
generator = EventGenerator(event_dictionary, base_config)
|
||||||
|
|
||||||
|
visits = _page_path_visits(generator, 2000)
|
||||||
|
home_visits = sum("/home" in visit for visit in visits)
|
||||||
|
product_visits = sum(
|
||||||
|
"/product_a" in visit or "/product_b" in visit
|
||||||
|
for visit in visits
|
||||||
|
)
|
||||||
|
cart_visits = sum("/cart" in visit for visit in visits)
|
||||||
|
payment_visits = sum("/payment" in visit for visit in visits)
|
||||||
|
confirmation_visits = sum("/confirmation" in visit for visit in visits)
|
||||||
|
|
||||||
|
assert home_visits >= product_visits >= cart_visits
|
||||||
|
assert cart_visits >= payment_visits >= confirmation_visits
|
||||||
|
|
||||||
def test_visit_can_continue_after_confirmation(self, event_dictionary, base_config):
|
def test_visit_can_continue_after_confirmation(self, event_dictionary, base_config):
|
||||||
"""/confirmation не обязан быть последним событием визита."""
|
"""/confirmation не обязан быть последним событием визита."""
|
||||||
generator = EventGenerator(event_dictionary, base_config)
|
generator = EventGenerator(event_dictionary, base_config)
|
||||||
@@ -697,6 +888,51 @@ class TestEventGeneration:
|
|||||||
class TestPoissonDistribution:
|
class TestPoissonDistribution:
|
||||||
"""Тесты статистической модели."""
|
"""Тесты статистической модели."""
|
||||||
|
|
||||||
|
def test_event_budget_mean_follows_lambda_and_hour_factor(
|
||||||
|
self, base_config, monkeypatch
|
||||||
|
):
|
||||||
|
"""Средний событийный бюджет следует λ и часовому коэффициенту."""
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"clickstream_generator.intensity.hour_factor",
|
||||||
|
lambda: 1.2,
|
||||||
|
)
|
||||||
|
config = replace(
|
||||||
|
base_config,
|
||||||
|
tick_seconds=60,
|
||||||
|
lambda_base_per_min=30,
|
||||||
|
jitter_pct=0,
|
||||||
|
min_events_per_tick=1,
|
||||||
|
max_events_per_tick=100,
|
||||||
|
)
|
||||||
|
rng = random.Random(config.seed)
|
||||||
|
|
||||||
|
samples = [calculate_events_count(config, rng) for _ in range(1000)]
|
||||||
|
mean_budget = sum(samples) / len(samples)
|
||||||
|
|
||||||
|
assert mean_budget == pytest.approx(30 * 1.2, rel=0.15)
|
||||||
|
|
||||||
|
def test_default_tick_budget_floor_does_not_outgrow_target_lambda(
|
||||||
|
self, base_config, monkeypatch
|
||||||
|
):
|
||||||
|
"""Дефолтная нижняя граница бюджета не разгоняет lambda=30 на тике 5 секунд."""
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"clickstream_generator.intensity.hour_factor",
|
||||||
|
lambda: 1.0,
|
||||||
|
)
|
||||||
|
config = replace(
|
||||||
|
base_config,
|
||||||
|
tick_seconds=5,
|
||||||
|
lambda_base_per_min=30,
|
||||||
|
jitter_pct=0,
|
||||||
|
max_events_per_tick=50,
|
||||||
|
)
|
||||||
|
rng = random.Random(config.seed)
|
||||||
|
|
||||||
|
samples = [calculate_events_count(config, rng) for _ in range(1000)]
|
||||||
|
events_per_minute = sum(samples) / len(samples) * 60 / config.tick_seconds
|
||||||
|
|
||||||
|
assert events_per_minute == pytest.approx(config.lambda_base_per_min, rel=0.20)
|
||||||
|
|
||||||
def test_calculate_events_respects_bounds(self, event_dictionary, base_config):
|
def test_calculate_events_respects_bounds(self, event_dictionary, base_config):
|
||||||
"""Расчет количества событий уважает границы."""
|
"""Расчет количества событий уважает границы."""
|
||||||
generator = EventGenerator(event_dictionary, base_config)
|
generator = EventGenerator(event_dictionary, base_config)
|
||||||
@@ -725,14 +961,19 @@ class TestPoissonDistribution:
|
|||||||
|
|
||||||
def test_mean_is_reasonable(self, event_dictionary, base_config):
|
def test_mean_is_reasonable(self, event_dictionary, base_config):
|
||||||
"""Среднее значение в разумных пределах."""
|
"""Среднее значение в разумных пределах."""
|
||||||
generator = EventGenerator(event_dictionary, base_config)
|
config = replace(
|
||||||
|
base_config,
|
||||||
|
jitter_pct=0,
|
||||||
|
min_events_per_tick=1,
|
||||||
|
)
|
||||||
|
generator = EventGenerator(event_dictionary, config)
|
||||||
|
|
||||||
samples = [generator._calculate_events_count() for _ in range(500)]
|
samples = [generator._calculate_events_count() for _ in range(500)]
|
||||||
mean = sum(samples) / len(samples)
|
mean = sum(samples) / len(samples)
|
||||||
|
|
||||||
# Ожидаем: lambda_base * tick_seconds / 60 * hour_factor
|
# Ожидаем: lambda_base * tick_seconds / 60 * hour_factor
|
||||||
# hour_factor обычно 0.7-1.2
|
# hour_factor обычно 0.7-1.2
|
||||||
expected_base = base_config.lambda_base_per_min * base_config.tick_seconds / 60.0
|
expected_base = config.lambda_base_per_min * config.tick_seconds / 60.0
|
||||||
|
|
||||||
# Допустимое отклонение до 50%
|
# Допустимое отклонение до 50%
|
||||||
assert mean > expected_base * 0.5, f"Mean {mean} too low (expected ~{expected_base})"
|
assert mean > expected_base * 0.5, f"Mean {mean} too low (expected ~{expected_base})"
|
||||||
|
|||||||
Reference in New Issue
Block a user