2d264a5779
Сверка с методикой ПСР ред. 12.17 (Евдокимов, Лейтес): - ночь: 0.5 → 0.0 — методичка §12.3.5 прямо даёт «ночь = 0 км»: без фонаря в лесу ночью пострадавший не перемещается. Ранее 0.5 ЗАВЫШАЛО радиус ночного поиска. - сумерки/вечер: 0.7 → 0.5 — «за час до заката скорость 50% от обычной». B5 переносил 0.7 из старого фронта вопреки методичке. - get_base_speed: документирована семантика «скорость смещения» (не асфальтовая НормС из §12.3.2) — оператору не нужно вводить асфальтовую скорость, она уже свёрнута; СП = поправка конкретной местности поверх базового среднего леса. Тесты обновлены под методичку, добавлен тест ночи = 0. 201 passed, 0 failed.
393 lines
12 KiB
Python
393 lines
12 KiB
Python
"""
|
||
Distance calculation service based on search and rescue statistics.
|
||
|
||
Implements distance formulas and prior probabilities based on:
|
||
- PSO EXTREMUM data (400 cases, 2015)
|
||
- Age-based movement speeds
|
||
- Terrain and weather modifiers
|
||
"""
|
||
from typing import Dict
|
||
|
||
|
||
def calculate_max_distance(case_data: dict) -> float:
|
||
"""
|
||
Calculate maximum probable distance using the formula:
|
||
Distance = Time × НормС × СП × СКД × СУ × СУТ × ВВС × ВП
|
||
|
||
Where:
|
||
- Time: elapsed time in hours
|
||
- НормС: base speed by age (km/h)
|
||
- СП: terrain coefficient
|
||
- СКД: coefficient for diagnosis (not implemented yet)
|
||
- СУ: coefficient for urgency (not implemented yet)
|
||
- СУТ: fatigue coefficient (5% reduction per hour)
|
||
- ВВС: time of day coefficient
|
||
- ВП: weather coefficient
|
||
|
||
Args:
|
||
case_data: Dictionary with case information
|
||
- age: age in years
|
||
- elapsed_hours: time elapsed since last seen
|
||
- terrain_primary: terrain type
|
||
- time_of_day: time of day (день/ночь/сумерки)
|
||
- weather: weather conditions
|
||
|
||
Returns:
|
||
Maximum probable distance in kilometers
|
||
"""
|
||
# Extract data
|
||
age = case_data.get('age', 10)
|
||
elapsed_hours = case_data.get('elapsed_hours', 1.0)
|
||
terrain = case_data.get('terrain_primary', 'лес')
|
||
time_of_day = case_data.get('time_of_day', 'день')
|
||
weather = case_data.get('weather', 'нет')
|
||
|
||
# НормС - Base speed by age (km/h)
|
||
base_speed = get_base_speed(age)
|
||
|
||
# СП - Terrain coefficient
|
||
terrain_coef = get_terrain_coefficient(terrain)
|
||
|
||
# СКД - Коэффициент диагноза (влияние на способность двигаться)
|
||
diagnosis_coef = get_diagnosis_coefficient(case_data.get('diagnosis_type'))
|
||
|
||
# СУ - Коэффициент транспорта (велосипед/машина расширяют зону)
|
||
urgency_coef = get_transport_coefficient(case_data.get('has_transport'))
|
||
|
||
# СУТ - Fatigue coefficient (5% reduction per hour)
|
||
fatigue_coef = max(0.3, 1.0 - (0.05 * elapsed_hours))
|
||
|
||
# ВВС - Time of day coefficient
|
||
time_coef = get_time_of_day_coefficient(time_of_day)
|
||
|
||
# ВП - Weather coefficient
|
||
weather_coef = get_weather_coefficient(weather)
|
||
|
||
# Calculate distance
|
||
distance = (
|
||
elapsed_hours *
|
||
base_speed *
|
||
terrain_coef *
|
||
diagnosis_coef *
|
||
urgency_coef *
|
||
fatigue_coef *
|
||
time_coef *
|
||
weather_coef
|
||
)
|
||
|
||
return round(distance, 2)
|
||
|
||
|
||
def get_base_speed(age: int) -> float:
|
||
"""
|
||
Get base movement speed by age (НормС).
|
||
|
||
ВАЖНО (семантика): в ВЕКТОРе это НЕ «нормальная скорость по асфальту»
|
||
из методички ПСО (§12.3.2, стандарт 5 км/ч), а уже готовая скорость
|
||
СМЕЩЕНИЯ потерявшегося по лесу: НормС_асфальт × СП, усреднённое
|
||
с учётом кружения/остановок/испуга. Канон перенесён из фронтового
|
||
расчёта (B5, 3e2692f) и согласован с априорами ПСО «Экстремум»:
|
||
400 случаев 2015 г., 94% найдены в пределах 3 км.
|
||
|
||
Следствие: в формуле ВЕКТОРА коэффициент СП применяется ПОВЕРХ этих
|
||
значений как поправка конкретной местности (густой лес < простой лес),
|
||
поэтому СП = 1.0 соответствует «среднему лесу», а не асфальту.
|
||
Оператору НЕ нужно вводить асфальтовую скорость — она уже свёрнута.
|
||
|
||
Args:
|
||
age: Age in years
|
||
|
||
Returns:
|
||
Base displacement speed in km/h
|
||
"""
|
||
# Скорость смещения потерявшегося (не скорость ходьбы).
|
||
# Реальное смещение: испуг, кружение, остановки. Основа — ПСО ЭКСТРЕМУМ
|
||
# (400 случаев, 94% найдены в пределах 3 км). Значения перенесены из
|
||
# фронтендового расчёта как каноничные (согласовано с методологией).
|
||
if age <= 2:
|
||
return 0.3
|
||
elif age <= 5:
|
||
return 0.7
|
||
elif age <= 8:
|
||
return 1.2
|
||
elif age <= 12:
|
||
return 1.5
|
||
elif age <= 15:
|
||
return 2.0
|
||
elif age <= 17:
|
||
return 2.5
|
||
elif age <= 64:
|
||
return 2.5
|
||
else: # 65+
|
||
return 1.5
|
||
|
||
|
||
def get_terrain_coefficient(terrain: str) -> float:
|
||
"""
|
||
Get terrain movement coefficient (СП).
|
||
|
||
Args:
|
||
terrain: Terrain type
|
||
|
||
Returns:
|
||
Terrain coefficient (0.0 - 1.0)
|
||
"""
|
||
if not terrain:
|
||
return 0.5
|
||
terrain_lower = terrain.lower()
|
||
|
||
terrain_map = {
|
||
'лесная дорога': 0.8,
|
||
'сложный лес': 0.25,
|
||
'густой лес': 0.25,
|
||
'простой лес': 0.5,
|
||
'лес': 0.5,
|
||
'дорога': 0.8,
|
||
'тропа': 0.8,
|
||
'болото': 0.2,
|
||
'поле': 0.9,
|
||
'луг': 0.9,
|
||
'город': 1.0,
|
||
'населённый пункт': 1.0,
|
||
'горы': 0.3,
|
||
'овраг': 0.3
|
||
}
|
||
|
||
for key, value in terrain_map.items():
|
||
if key in terrain_lower:
|
||
return value
|
||
|
||
# Default for unknown terrain
|
||
return 0.5
|
||
|
||
|
||
def get_diagnosis_coefficient(diagnosis_types) -> float:
|
||
"""
|
||
Коэффициент диагноза (СКД) — влияние на физическую способность двигаться.
|
||
Порт фронтендовой логики (§6 контекста ВЕКТОР).
|
||
|
||
Args:
|
||
diagnosis_types: список диагнозов (diagnosis_type) или строка/None
|
||
|
||
Returns:
|
||
Коэффициент: эпилепсия 0.6, ЗПР 0.8, РАС 1.0, СДВГ 1.1, иной 0.9, нет 1.0
|
||
"""
|
||
if not diagnosis_types:
|
||
return 1.0
|
||
if isinstance(diagnosis_types, str):
|
||
diagnosis_types = [diagnosis_types]
|
||
diags = [str(d).lower() for d in diagnosis_types if d and str(d).lower() not in ('нет', 'none', 'здоров', 'здоровый')]
|
||
if not diags:
|
||
return 1.0
|
||
if any('эпилепси' in d for d in diags):
|
||
return 0.6
|
||
if any('зпр' in d for d in diags):
|
||
return 0.8
|
||
if any(('рас' in d) or ('аутизм' in d) for d in diags):
|
||
return 1.0
|
||
if any('сдвг' in d for d in diags):
|
||
return 1.1
|
||
return 0.9
|
||
|
||
|
||
def get_transport_coefficient(has_transport) -> float:
|
||
"""
|
||
Коэффициент транспорта (СУ) — расширяет зону поиска.
|
||
Порт фронтендовой логики. bike ×5 — критично: «расширить зону до 10-15 км».
|
||
|
||
Args:
|
||
has_transport: 'bike' | 'scooter' | 'car' | 'other' | 'none' | None
|
||
|
||
Returns:
|
||
Коэффициент: bike 5.0, car 8.0, иное 3.0, none/пусто 1.0
|
||
"""
|
||
if not has_transport or str(has_transport).lower() == 'none':
|
||
return 1.0
|
||
t = str(has_transport).lower()
|
||
if t == 'bike':
|
||
return 5.0
|
||
if t == 'car':
|
||
return 8.0
|
||
return 3.0
|
||
|
||
|
||
def get_time_of_day_coefficient(time_of_day: str) -> float:
|
||
"""
|
||
ВВС — влияние времени суток (методичка ПСО «Экстремум» §12.3.3).
|
||
|
||
- день: 1.0
|
||
- сумерки/вечер: 0.5 («за час до заката скорость 50% от обычной»)
|
||
- ночь: 0.0 — в примере §12.3.5 этап «ночь» даёт 0 км движения:
|
||
без фонаря в лесу ночью пострадавший не перемещается.
|
||
(Свет/фонарь/Луна — редкий случай, учитывается оператором вручную.)
|
||
|
||
Args:
|
||
time_of_day: 'день' | 'сумерки' | 'вечер' | 'ночь'
|
||
|
||
Returns:
|
||
Time coefficient (0.0 - 1.0)
|
||
"""
|
||
if not time_of_day:
|
||
return 1.0
|
||
time_lower = time_of_day.lower()
|
||
|
||
if 'ночь' in time_lower:
|
||
return 0.0
|
||
elif 'сумерки' in time_lower or 'вечер' in time_lower:
|
||
return 0.5
|
||
else: # день
|
||
return 1.0
|
||
|
||
|
||
def get_weather_coefficient(weather: str) -> float:
|
||
"""
|
||
Get weather movement coefficient (ВП).
|
||
|
||
Args:
|
||
weather: Weather conditions
|
||
|
||
Returns:
|
||
Weather coefficient (0.0 - 1.0)
|
||
"""
|
||
if not weather:
|
||
return 1.0
|
||
weather_lower = weather.lower()
|
||
|
||
if 'ливень' in weather_lower or 'сильный дождь' in weather_lower:
|
||
return 0.6
|
||
elif 'дождь' in weather_lower:
|
||
return 0.8
|
||
elif 'туман' in weather_lower:
|
||
return 0.7
|
||
elif 'снег' in weather_lower or 'метель' in weather_lower:
|
||
return 0.6
|
||
elif 'жара' in weather_lower:
|
||
return 0.8
|
||
else: # нет / ясно
|
||
return 1.0
|
||
|
||
|
||
def get_distance_priors(age_years: int) -> Dict[str, float]:
|
||
"""
|
||
Get prior probabilities for distance zones based on age.
|
||
|
||
Based on PSO EXTREMUM data (400 cases, 2015).
|
||
|
||
Args:
|
||
age_years: Age in years
|
||
|
||
Returns:
|
||
Dictionary with distance zone probabilities
|
||
"""
|
||
if age_years < 8:
|
||
# До 8 лет - дети младшего возраста
|
||
return {
|
||
'0_500m': 0.45,
|
||
'500_1500m': 0.35,
|
||
'1500_2500m': 0.15,
|
||
'2500_3500m': 0.04,
|
||
'3500_plus': 0.01
|
||
}
|
||
elif age_years <= 12:
|
||
# 8-12 лет - дети среднего возраста
|
||
return {
|
||
'0_500m': 0.28,
|
||
'500_1500m': 0.25,
|
||
'1500_2500m': 0.22,
|
||
'2500_3500m': 0.19,
|
||
'3500_5000m': 0.03,
|
||
'5000_plus': 0.03
|
||
}
|
||
elif age_years <= 17:
|
||
# 13-17 лет - подростки
|
||
return {
|
||
'0_500m': 0.15,
|
||
'500_1500m': 0.20,
|
||
'1500_2500m': 0.25,
|
||
'2500_3500m': 0.20,
|
||
'3500_5000m': 0.12,
|
||
'5000_plus': 0.08
|
||
}
|
||
elif age_years <= 64:
|
||
# 18-64 года - взрослые
|
||
return {
|
||
'0_500m': 0.12,
|
||
'500_1500m': 0.18,
|
||
'1500_2500m': 0.22,
|
||
'2500_3500m': 0.20,
|
||
'3500_5000m': 0.15,
|
||
'5000_plus': 0.13
|
||
}
|
||
else:
|
||
# 65+ лет - пожилые
|
||
return {
|
||
'0_500m': 0.35,
|
||
'500_1500m': 0.30,
|
||
'1500_2500m': 0.20,
|
||
'2500_3500m': 0.10,
|
||
'3500_5000m': 0.03,
|
||
'5000_plus': 0.02
|
||
}
|
||
|
||
|
||
def get_distance_zone(distance_km: float) -> str:
|
||
"""
|
||
Get distance zone name for a given distance.
|
||
|
||
Args:
|
||
distance_km: Distance in kilometers
|
||
|
||
Returns:
|
||
Zone name
|
||
"""
|
||
if distance_km < 0.5:
|
||
return '0_500m'
|
||
elif distance_km < 1.5:
|
||
return '500_1500m'
|
||
elif distance_km < 2.5:
|
||
return '1500_2500m'
|
||
elif distance_km < 3.5:
|
||
return '2500_3500m'
|
||
elif distance_km < 5.0:
|
||
return '3500_5000m'
|
||
else:
|
||
return '5000_plus'
|
||
|
||
|
||
def get_distance_statistics(age_years: int, elapsed_hours: float, terrain: str) -> Dict:
|
||
"""
|
||
Get comprehensive distance statistics for a case.
|
||
|
||
Args:
|
||
age_years: Age in years
|
||
elapsed_hours: Time elapsed since last seen
|
||
terrain: Terrain type
|
||
|
||
Returns:
|
||
Dictionary with distance statistics
|
||
"""
|
||
# Calculate max distance
|
||
case_data = {
|
||
'age': age_years,
|
||
'elapsed_hours': elapsed_hours,
|
||
'terrain_primary': terrain,
|
||
'time_of_day': 'день',
|
||
'weather': 'нет'
|
||
}
|
||
max_distance = calculate_max_distance(case_data)
|
||
|
||
# Get priors
|
||
priors = get_distance_priors(age_years)
|
||
|
||
# Get current zone
|
||
current_zone = get_distance_zone(max_distance)
|
||
|
||
return {
|
||
'max_distance_km': max_distance,
|
||
'current_zone': current_zone,
|
||
'zone_probability': priors.get(current_zone, 0.0),
|
||
'all_priors': priors,
|
||
'base_speed_kmh': get_base_speed(age_years),
|
||
'terrain_coefficient': get_terrain_coefficient(terrain)
|
||
}
|