Files
vector/services/distance_service.py
T
root 3e2692f8a5 B5: перенести расчёт анализа в бэкенд (единый источник правды)
AnalysisResult.jsx теперь тонкий клиент над POST /api/v1/analyze — локальные
формулы (calculateFullDistance / generateZonesWithPsychotype / getPsychotypeModifiers)
удалены. Бэкенд становится единственным источником правды.

distance_service:
- get_base_speed: перенесены каноничные displacement-скорости фронта (0.3–2.5)
  вместо расходящихся значений бэкенда (1.0–5.5) — согласовано с априорами ПСО
- реализованы заглушки СКД (get_diagnosis_coefficient: эпилепсия 0.6, ЗПР 0.8,
  РАС 1.0, СДВГ 1.1, здоров 1.0) и СУ (get_transport_coefficient: bike ×5,
  car ×8, иное ×3, none ×1) — критичная безопасность bike×5 теперь в бэкендe
- get_time_of_day_coefficient: сумерки/вечер 0.7 (было 0.5)

analyze.py:
- _derive_profiles: профили (РАС/эпилепсия/СДВГ/ЗПР/велосипед/самокат) формируются
  из diagnosis_type + has_transport, закрывая рассинхрон bike/велосипед на границе
- _derive_time_of_day: время суток из loss_time (порт getTimeOfDay фронта)
- психотип детектится ДО claude_analyze, модификаторы прокидываются в score_zone
- _as_case_data: фильтр пустых контейнеров ([],{}) — фикс затирания данных карточки
  дефолтами payload (psychotype_answers={} затирал реальные ответы)
- в ответ добавлены coefficients, time_of_day, active_profiles, critical_warnings

scoring_service.score_zone: применяется множитель полосы психотипа по дистанции
(порт getPsychotypeModifiers; таблица уже есть в psychotype_service).

AnalysisResult.jsx: рендер бэкенд-ответа, адаптер primary_zones→SearchMap
(score из приоритета, reasoning←reason), локализация пола m/f→М/Ж, бейдж
fallback/ИИ, блоки активных профилей + критических предупреждений + рекомендаций
по психотипу, исправлены ключи отображения (loss_time/behavior_description/
temperature_c). Поведение сохранено (bike 8yo 2h лес = 5.4 км, как во фронте).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-20 16:36:35 +00:00

377 lines
11 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 (НормС).
Args:
age: Age in years
Returns:
Base speed in km/h
"""
# Скорость смещения потерявшегося ребёнка (не скорость ходьбы)
# ПСО ЭКСТРЕМУМ: 94% найдены в пределах 3 км
# Скорость смещения потерявшегося ребёнка (не скорость ходьбы).
# Реальное смещение: испуг, кружение, остановки. Основа — ПСО ЭКСТРЕМУМ
# (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:
"""
Get time of day movement coefficient (ВВС).
Args:
time_of_day: 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.5
elif 'сумерки' in time_lower or 'вечер' in time_lower:
return 0.7
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)
}