2aa45e8932
Решение Виктора: СП(лес) 0.5→1.0. Прежняя таблица учитывала лес дважды (в НормС и в СП) — подросток 14 лет за 3 часа получал 2.55 км вместо реалистичных 5.1. Согласуется с априором ПСО (94% находок в 3 км): радиусы стали верхне-перцентилями, не медианой. - terrain_map пересчитан относительно среднего леса: лес/простой лес/поле/ луг/город = 1.0; сложный/густой лес 0.5; болото 0.4; горы/овраг 0.6; дорога/тропа 0.8; дефолт 1.0. - Контрольные точки тестов пересчитаны (6 шт: 1.92→3.84, 0.33→0.665, 2.43→2.7, 5.4→10.8, 13.5→15.0, stats 0.5→1.0) + операционный тест подростка 5.1 км. - settlement_score пока НЕ тронут — на обдумывание (Виктор). 193 passed.
358 lines
12 KiB
Python
358 lines
12 KiB
Python
"""
|
||
Tests for distance_service.py
|
||
|
||
Tests the distance calculation formulas and prior probabilities
|
||
based on §9 ВЕКТОР-контекст.md specifications.
|
||
"""
|
||
import pytest
|
||
from services.distance_service import (
|
||
calculate_max_distance,
|
||
get_base_speed,
|
||
get_terrain_coefficient,
|
||
get_time_of_day_coefficient,
|
||
get_weather_coefficient,
|
||
get_distance_priors,
|
||
get_distance_zone,
|
||
get_distance_statistics
|
||
)
|
||
|
||
|
||
class TestCalculateMaxDistance:
|
||
"""Test the main distance calculation function.
|
||
|
||
Ожидания соответствуют каноничным displacement-скоростям B5 (3e2692f):
|
||
get_base_speed 0.3–2.5 км/ч по априорам ПСО «Экстремум» (94% в 3 км).
|
||
Контрольная точка B1 2026-09-09: bike 8yo 2h лес = 10.8 км (СП(лес)=1.0).
|
||
"""
|
||
|
||
def test_boy_10_years_4_hours_forest_rain(self):
|
||
"""
|
||
Мальчик 10 лет, 4 часа, лес, дождь.
|
||
Пересчитано под канон B5: НормС(10)=1.5
|
||
B1 2026-09-09: СП(лес)=1.0 (было 0.5): 4 * 1.5 * 1.0 * 0.8 * 1.0 * 0.8 = 3.84 km
|
||
"""
|
||
case_data = {
|
||
'age': 10,
|
||
'elapsed_hours': 4.0,
|
||
'terrain_primary': 'лес',
|
||
'time_of_day': 'день',
|
||
'weather': 'дождь'
|
||
}
|
||
|
||
distance = calculate_max_distance(case_data)
|
||
|
||
assert distance == pytest.approx(3.84, rel=0.01)
|
||
|
||
def test_young_child_short_time(self):
|
||
"""Test for young child (3 years) with short elapsed time."""
|
||
case_data = {
|
||
'age': 3,
|
||
'elapsed_hours': 1.0,
|
||
'terrain_primary': 'лес',
|
||
'time_of_day': 'день',
|
||
'weather': 'нет'
|
||
}
|
||
|
||
distance = calculate_max_distance(case_data)
|
||
|
||
# B1 2026-09-09 СП(лес)=1.0: 1 * 0.7 * 1.0 * 0.95 * 1.0 * 1.0 = 0.665 km
|
||
assert distance == pytest.approx(0.665, rel=0.01)
|
||
|
||
def test_teenager_long_time_road(self):
|
||
"""Test for teenager on road with longer elapsed time."""
|
||
case_data = {
|
||
'age': 15,
|
||
'elapsed_hours': 6.0,
|
||
'terrain_primary': 'дорога',
|
||
'time_of_day': 'день',
|
||
'weather': 'нет'
|
||
}
|
||
|
||
distance = calculate_max_distance(case_data)
|
||
|
||
# 6 * 2.0 * 0.8 * 0.7 * 1.0 * 1.0 = 6.72 km
|
||
assert distance == pytest.approx(6.72, rel=0.01)
|
||
|
||
def test_elderly_night_swamp(self):
|
||
"""Test for elderly person at night in swamp."""
|
||
case_data = {
|
||
'age': 70,
|
||
'elapsed_hours': 3.0,
|
||
'terrain_primary': 'болото',
|
||
'time_of_day': 'ночь',
|
||
'weather': 'туман'
|
||
}
|
||
|
||
distance = calculate_max_distance(case_data)
|
||
|
||
# 3 * 1.5 * 0.2 * 0.85 * 0.0(ночь) * 0.7 = 0.0 км —
|
||
# методичка §12.3.5: без фонаря ночью движение = 0
|
||
assert distance == pytest.approx(0.0, rel=0.01)
|
||
|
||
def test_adult_heavy_rain_field(self):
|
||
"""Test for adult in heavy rain on field."""
|
||
case_data = {
|
||
'age': 35,
|
||
'elapsed_hours': 2.0,
|
||
'terrain_primary': 'поле',
|
||
'time_of_day': 'день',
|
||
'weather': 'ливень'
|
||
}
|
||
|
||
distance = calculate_max_distance(case_data)
|
||
|
||
# B1 2026-09-09 СП(поле)=1.0: 2 * 2.5 * 1.0 * 0.9 * 1.0 * 0.6 = 2.7 km
|
||
assert distance == pytest.approx(2.7, rel=0.01)
|
||
|
||
def test_bike_8yo_canonical_b5(self):
|
||
"""Контрольная точка B1 2026-09-09: bike 8yo 2h лес = 10.8 км (СП(лес)=1.0, СУ bike ×5)."""
|
||
case_data = {
|
||
'age': 8,
|
||
'elapsed_hours': 2.0,
|
||
'terrain_primary': 'лес',
|
||
'time_of_day': 'день',
|
||
'weather': 'нет',
|
||
'has_transport': 'bike',
|
||
'diagnosis_type': [],
|
||
}
|
||
|
||
distance = calculate_max_distance(case_data)
|
||
|
||
assert distance == pytest.approx(10.8, rel=0.01)
|
||
|
||
|
||
class TestBaseSpeed:
|
||
"""Test НормС (base speed) by age — каноничные displacement-скорости B5."""
|
||
|
||
def test_infant(self):
|
||
assert get_base_speed(1) == 0.3
|
||
assert get_base_speed(2) == 0.3
|
||
|
||
def test_preschool(self):
|
||
assert get_base_speed(3) == 0.7
|
||
assert get_base_speed(5) == 0.7
|
||
|
||
def test_young_child(self):
|
||
assert get_base_speed(6) == 1.2
|
||
assert get_base_speed(8) == 1.2
|
||
|
||
def test_preteen(self):
|
||
assert get_base_speed(10) == 1.5
|
||
assert get_base_speed(12) == 1.5
|
||
|
||
def test_teenager(self):
|
||
assert get_base_speed(13) == 2.0
|
||
assert get_base_speed(15) == 2.0
|
||
assert get_base_speed(16) == 2.5
|
||
assert get_base_speed(17) == 2.5
|
||
|
||
def test_adult(self):
|
||
assert get_base_speed(25) == 2.5
|
||
assert get_base_speed(50) == 2.5
|
||
assert get_base_speed(64) == 2.5
|
||
|
||
def test_elderly(self):
|
||
assert get_base_speed(65) == 1.5
|
||
assert get_base_speed(80) == 1.5
|
||
|
||
|
||
class TestTerrainCoefficient:
|
||
"""Test СП (terrain coefficient). Семантика: СП=1.0 — средний лес
|
||
(НормС — скорость смещения по среднему лесу; B1-ревью 2026-09-09)."""
|
||
|
||
def test_road_terrain(self):
|
||
assert get_terrain_coefficient('дорога') == 0.8
|
||
assert get_terrain_coefficient('лесная дорога') == 0.8
|
||
assert get_terrain_coefficient('тропа') == 0.8
|
||
|
||
def test_forest_terrain(self):
|
||
assert get_terrain_coefficient('лес') == 1.0
|
||
assert get_terrain_coefficient('простой лес') == 1.0
|
||
assert get_terrain_coefficient('сложный лес') == 0.5
|
||
assert get_terrain_coefficient('густой лес') == 0.5
|
||
|
||
def test_open_terrain(self):
|
||
assert get_terrain_coefficient('поле') == 1.0
|
||
assert get_terrain_coefficient('луг') == 1.0
|
||
|
||
def test_difficult_terrain(self):
|
||
assert get_terrain_coefficient('болото') == 0.4
|
||
assert get_terrain_coefficient('горы') == 0.6
|
||
assert get_terrain_coefficient('овраг') == 0.6
|
||
|
||
def test_urban_terrain(self):
|
||
assert get_terrain_coefficient('город') == 1.0
|
||
assert get_terrain_coefficient('населённый пункт') == 1.0
|
||
|
||
def test_unknown_terrain(self):
|
||
assert get_terrain_coefficient('неизвестно') == 1.0
|
||
|
||
|
||
class TestTimeOfDayCoefficient:
|
||
"""Test ВВС (time of day coefficient)."""
|
||
|
||
def test_day(self):
|
||
assert get_time_of_day_coefficient('день') == 1.0
|
||
|
||
def test_night(self):
|
||
# Методичка §12.3.5: ночь = 0 (без фонаря движение прекращается)
|
||
assert get_time_of_day_coefficient('ночь') == 0.0
|
||
|
||
def test_twilight(self):
|
||
# Методичка §12.3.3: за час до заката скорость 50% от обычной
|
||
assert get_time_of_day_coefficient('сумерки') == 0.5
|
||
assert get_time_of_day_coefficient('вечер') == 0.5
|
||
|
||
|
||
class TestWeatherCoefficient:
|
||
"""Test ВП (weather coefficient)."""
|
||
|
||
def test_clear_weather(self):
|
||
assert get_weather_coefficient('нет') == 1.0
|
||
assert get_weather_coefficient('ясно') == 1.0
|
||
|
||
def test_rain(self):
|
||
assert get_weather_coefficient('дождь') == 0.8
|
||
assert get_weather_coefficient('ливень') == 0.6
|
||
assert get_weather_coefficient('сильный дождь') == 0.6
|
||
|
||
def test_fog(self):
|
||
assert get_weather_coefficient('туман') == 0.7
|
||
|
||
def test_snow(self):
|
||
assert get_weather_coefficient('снег') == 0.6
|
||
assert get_weather_coefficient('метель') == 0.6
|
||
|
||
def test_heat(self):
|
||
assert get_weather_coefficient('жара') == 0.8
|
||
|
||
|
||
class TestDistancePriors:
|
||
"""Test get_distance_priors() - априорные вероятности зон."""
|
||
|
||
def test_young_child_priors(self):
|
||
"""Children under 8 stay close."""
|
||
priors = get_distance_priors(5)
|
||
|
||
assert priors['0_500m'] == 0.45
|
||
assert priors['500_1500m'] == 0.35
|
||
assert priors['1500_2500m'] == 0.15
|
||
assert sum(priors.values()) == pytest.approx(1.0, rel=0.01)
|
||
|
||
def test_preteen_priors(self):
|
||
"""Children 8-12 have more even distribution."""
|
||
priors = get_distance_priors(10)
|
||
|
||
assert priors['0_500m'] == 0.28
|
||
assert priors['500_1500m'] == 0.25
|
||
assert priors['1500_2500m'] == 0.22
|
||
assert sum(priors.values()) == pytest.approx(1.0, rel=0.01)
|
||
|
||
def test_teenager_priors(self):
|
||
"""Teenagers can go farther."""
|
||
priors = get_distance_priors(15)
|
||
|
||
assert priors['1500_2500m'] == 0.25
|
||
assert priors['5000_plus'] == 0.08
|
||
assert sum(priors.values()) == pytest.approx(1.0, rel=0.01)
|
||
|
||
def test_adult_priors(self):
|
||
"""Adults have highest far-distance probability."""
|
||
priors = get_distance_priors(35)
|
||
|
||
assert priors['0_500m'] == 0.12
|
||
assert priors['5000_plus'] == 0.13
|
||
assert sum(priors.values()) == pytest.approx(1.0, rel=0.01)
|
||
|
||
def test_elderly_priors(self):
|
||
"""Elderly stay closer like young children."""
|
||
priors = get_distance_priors(70)
|
||
|
||
assert priors['0_500m'] == 0.35
|
||
assert priors['5000_plus'] == 0.02
|
||
assert sum(priors.values()) == pytest.approx(1.0, rel=0.01)
|
||
|
||
|
||
class TestDistanceZone:
|
||
"""Test get_distance_zone() classification."""
|
||
|
||
def test_zone_classification(self):
|
||
assert get_distance_zone(0.3) == '0_500m'
|
||
assert get_distance_zone(0.5) == '500_1500m'
|
||
assert get_distance_zone(1.0) == '500_1500m'
|
||
assert get_distance_zone(1.5) == '1500_2500m'
|
||
assert get_distance_zone(2.5) == '2500_3500m'
|
||
assert get_distance_zone(4.0) == '3500_5000m'
|
||
assert get_distance_zone(6.0) == '5000_plus'
|
||
|
||
|
||
class TestDistanceStatistics:
|
||
"""Test get_distance_statistics() comprehensive output."""
|
||
|
||
def test_statistics_structure(self):
|
||
stats = get_distance_statistics(
|
||
age_years=10,
|
||
elapsed_hours=4.0,
|
||
terrain='лес'
|
||
)
|
||
|
||
assert 'max_distance_km' in stats
|
||
assert 'current_zone' in stats
|
||
assert 'zone_probability' in stats
|
||
assert 'all_priors' in stats
|
||
assert 'base_speed_kmh' in stats
|
||
assert 'terrain_coefficient' in stats
|
||
|
||
def test_statistics_values(self):
|
||
stats = get_distance_statistics(
|
||
age_years=10,
|
||
elapsed_hours=4.0,
|
||
terrain='лес'
|
||
)
|
||
|
||
assert stats['base_speed_kmh'] == 1.5
|
||
assert stats['terrain_coefficient'] == 1.0
|
||
assert stats['max_distance_km'] > 0
|
||
assert 0 <= stats['zone_probability'] <= 1.0
|
||
|
||
|
||
class TestFatigueCoefficient:
|
||
"""Test СУТ (fatigue coefficient) behavior."""
|
||
|
||
def test_fatigue_progression(self):
|
||
"""Fatigue increases with time (5% per hour)."""
|
||
case_1h = {
|
||
'age': 30,
|
||
'elapsed_hours': 1.0,
|
||
'terrain_primary': 'поле',
|
||
'time_of_day': 'день',
|
||
'weather': 'нет'
|
||
}
|
||
dist_1h = calculate_max_distance(case_1h)
|
||
|
||
case_5h = case_1h.copy()
|
||
case_5h['elapsed_hours'] = 5.0
|
||
dist_5h = calculate_max_distance(case_5h)
|
||
|
||
case_10h = case_1h.copy()
|
||
case_10h['elapsed_hours'] = 10.0
|
||
dist_10h = calculate_max_distance(case_10h)
|
||
|
||
assert dist_5h < dist_1h * 5
|
||
assert dist_10h < dist_5h * 2
|
||
|
||
def test_fatigue_minimum(self):
|
||
"""Fatigue coefficient has minimum of 0.3."""
|
||
case_data = {
|
||
'age': 30,
|
||
'elapsed_hours': 20.0,
|
||
'terrain_primary': 'поле',
|
||
'time_of_day': 'день',
|
||
'weather': 'нет'
|
||
}
|
||
|
||
distance = calculate_max_distance(case_data)
|
||
|
||
# B1 2026-09-09 СП(поле)=1.0: 20 * 2.5 * 1.0 * 0.3 * 1.0 * 1.0 = 15.0
|
||
assert distance == pytest.approx(15.0, rel=0.01)
|