Files
vector/services/distance_service.py
T
Sadmin 2d264a5779 fix: коэффициенты времени суток по методичке ПСО «Экстремум» (§12.3.3, §12.3.5)
Сверка с методикой ПСР ред. 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.
2026-09-09 11:11:34 +03:00

393 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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)
}