""" 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 (СП). Семантика (согласовано с Виктором, B1-ревью 2026-09-09): НормС — скорость СМЕЩЕНИЯ потерявшегося по СРЕДНЕМУ лесу, поэтому СП=1.0 — средний лес. Отклонения от среднего леса режут/поднимают скорость относительно неё. Args: terrain: Terrain type Returns: Terrain coefficient (0.0 - 1.0) """ if not terrain: return 1.0 terrain_lower = terrain.lower() terrain_map = { 'лесная дорога': 0.8, 'сложный лес': 0.5, 'густой лес': 0.5, 'простой лес': 1.0, 'лес': 1.0, 'дорога': 0.8, 'тропа': 0.8, 'болото': 0.4, 'поле': 1.0, 'луг': 1.0, 'город': 1.0, 'населённый пункт': 1.0, 'горы': 0.6, 'овраг': 0.6 } for key, value in terrain_map.items(): if key in terrain_lower: return value # Default for unknown terrain: средний лес (как 'лес') return 1.0 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) }