Import Vector lab project

This commit is contained in:
root
2026-06-06 18:31:55 +00:00
commit fcc22063ed
105 changed files with 31639 additions and 0 deletions
+23
View File
@@ -0,0 +1,23 @@
from .claude_service import analyze_case
from .stats_service import get_statistical_recommendation
from .scoring_service import WeightedScorer, create_scorer_for_case, get_weight_explanation
from .geo_service import build_search_zones, haversine, Zone
from .distance_service import calculate_max_distance, get_distance_priors, get_distance_statistics
from .psychotype_service import detect_psychotype, get_psychotype_modifiers, get_search_recommendations
__all__ = [
"analyze_case",
"get_statistical_recommendation",
"WeightedScorer",
"create_scorer_for_case",
"get_weight_explanation",
"build_search_zones",
"haversine",
"Zone",
"calculate_max_distance",
"get_distance_priors",
"get_distance_statistics",
"detect_psychotype",
"get_psychotype_modifiers",
"get_search_recommendations"
]
+312
View File
@@ -0,0 +1,312 @@
"""
Claude AI service for case analysis with fallback to scoring service.
Integrates geo_service zones and scoring_service rankings.
"""
import os
import json
from typing import Dict, List, Optional
from pydantic import BaseModel
import httpx
from .geo_service import build_search_zones
from .scoring_service import WeightedScorer
class PrimaryZone(BaseModel):
priority: int
name: str
direction: str
distance: float
reason: str
class AnalysisResult(BaseModel):
urgency: str # "критическая", "высокая", "средняя", "низкая"
primary_zones: List[PrimaryZone]
search_radius_km: float
key_locations: List[str]
behavioral_prediction: str
immediate_actions: List[str]
summary: str
fallback_used: bool = False
async def analyze_case(case_data: dict) -> AnalysisResult:
"""
Анализирует данные случая с помощью Claude API с fallback на scoring_service.
Интегрирует:
- geo_service: построение зон поиска
- scoring_service: оценка и ранжирование зон
- Claude API: интеллектуальный анализ (если доступен)
Args:
case_data: Словарь с данными случая
- age: возраст
- gender: пол
- terrain: местность
- weather: погода
- time_missing: время пропажи
- last_location: последнее местоположение
- lat, lon: координаты (опционально)
- profiles: поведенческие профили (опционально)
- season: сезон (опционально)
Returns:
AnalysisResult: Структурированный результат анализа
"""
# Попытка использовать Claude API
api_key = os.getenv("ANTHROPIC_API_KEY")
if api_key:
try:
return await analyze_with_claude(case_data, api_key)
except Exception as e:
# Логируем ошибку и переходим на fallback
print(f"Claude API unavailable: {e}. Using fallback scoring service.")
# Fallback: используем только scoring_service
return await analyze_with_fallback(case_data)
async def analyze_with_claude(case_data: dict, api_key: str) -> AnalysisResult:
"""
Анализ с помощью Claude API с интеграцией geo и scoring сервисов.
"""
# Построить зоны поиска если есть координаты
zones_data = ""
if case_data.get('lat') and case_data.get('lon'):
try:
zones = await build_search_zones(
case_data['lat'],
case_data['lon'],
case_data
)
# Ранжировать зоны
scorer = WeightedScorer()
zones_dict = [
{
'direction': z.direction,
'distance_km': z.distance_km,
'forest_pct': z.forest_pct,
'road_density': z.road_density,
'water_distance_km': z.water_distance_km,
'settlement_distance_km': z.settlement_distance_km
}
for z in zones
]
ranked_zones = scorer.rank_zones(zones_dict, case_data)
# Топ-5 зон для промпта
top_zones = ranked_zones[:5]
zones_data = "\n\nТОП-5 ПРИОРИТЕТНЫХ ЗОН (по scoring_service):\n"
for zone in top_zones:
zones_data += f"- {zone['direction']} направление, {zone['distance_km']}км: "
zones_data += f"оценка {zone['score']}/100, "
zones_data += f"лес {zone['forest_pct']}%, "
zones_data += f"дороги {zone['road_density']} км/км²\n"
except Exception as e:
print(f"Geo/scoring service error: {e}")
# Формируем промпт на русском языке
prompt = f"""Ты — эксперт по поисково-спасательным операциям (ПСО) МЧС Республики Беларусь. Проанализируй следующий случай пропажи человека и дай структурированные рекомендации.
ДАННЫЕ СЛУЧАЯ:
- Возраст пропавшего: {case_data.get('age', 'не указан')} лет
- Пол: {case_data.get('gender', 'не указан')}
- Местность: {case_data.get('terrain', 'не указана')}
- Погодные условия: {case_data.get('weather', 'не указаны')}
- Время пропажи: {case_data.get('time_missing', 'не указано')}
- Последнее известное местоположение: {case_data.get('last_location', 'не указано')}
- Особые обстоятельства: {case_data.get('circumstances', 'нет')}
- Физическое состояние: {case_data.get('physical_condition', 'не указано')}
- Опыт нахождения на природе: {case_data.get('experience', 'не указан')}
- Поведенческие профили: {', '.join(case_data.get('profiles', [])) if case_data.get('profiles') else 'нет'}
- Сезон: {case_data.get('season', 'не указан')}{zones_data}
ЗАДАЧА:
Предоставь детальный анализ в формате JSON со следующими полями:
1. urgency: Уровень срочности ("критическая", "высокая", "средняя", "низкая")
2. primary_zones: Массив из 3-5 приоритетных зон поиска, каждая с полями:
- priority: номер приоритета (1 = самый высокий)
- name: название зоны (например "Ближний лес", "Водоём на севере")
- direction: направление от последней точки (N, NE, E, SE, S, SW, W, NW)
- distance: расстояние в км
- reason: обоснование выбора этой зоны
3. search_radius_km: Рекомендуемый радиус поиска в километрах
4. key_locations: Массив ключевых типов локаций для проверки (водоемы, дороги, постройки и т.д.)
5. behavioral_prediction: Прогноз поведения пропавшего на основе возраста и обстоятельств
6. immediate_actions: Массив немедленных действий, которые нужно предпринять
7. summary: Краткое резюме анализа (2-3 предложения)
ВАЖНО: Учитывай данные из scoring_service при формировании primary_zones. Отвечай ТОЛЬКО валидным JSON без дополнительного текста."""
# Вызываем Anthropic API
async with httpx.AsyncClient(timeout=60.0) as client:
response = await client.post(
"https://api.anthropic.com/v1/messages",
headers={
"x-api-key": api_key,
"anthropic-version": "2023-06-01",
"content-type": "application/json"
},
json={
"model": "claude-sonnet-4-20250514",
"max_tokens": 4096,
"messages": [
{
"role": "user",
"content": prompt
}
]
}
)
if response.status_code != 200:
raise Exception(f"Anthropic API error: {response.status_code} - {response.text}")
result = response.json()
content = result["content"][0]["text"]
# Парсим JSON из ответа
# Убираем возможные markdown блоки кода
if "```json" in content:
content = content.split("```json")[1].split("```")[0].strip()
elif "```" in content:
content = content.split("```")[1].split("```")[0].strip()
analysis_data = json.loads(content)
analysis_data['fallback_used'] = False
# Преобразуем в Pydantic модель
return AnalysisResult(**analysis_data)
async def analyze_with_fallback(case_data: dict) -> AnalysisResult:
"""
Fallback анализ используя только scoring_service без Claude API.
"""
# Определяем срочность на основе возраста и профилей
age = case_data.get('age', 10)
profiles = case_data.get('profiles', [])
if age <= 4 or 'эпилепсия' in profiles or 'РАС' in profiles:
urgency = "критическая"
elif age <= 7 or 'велосипед' in profiles:
urgency = "высокая"
elif age <= 12:
urgency = "средняя"
else:
urgency = "средняя"
# Построить зоны если есть координаты
primary_zones = []
search_radius_km = 5.0
if case_data.get('lat') and case_data.get('lon'):
try:
zones = await build_search_zones(
case_data['lat'],
case_data['lon'],
case_data
)
# Ранжировать зоны
scorer = WeightedScorer()
zones_dict = [
{
'direction': z.direction,
'distance_km': z.distance_km,
'forest_pct': z.forest_pct,
'road_density': z.road_density,
'water_distance_km': z.water_distance_km,
'settlement_distance_km': z.settlement_distance_km
}
for z in zones
]
ranked_zones = scorer.rank_zones(zones_dict, case_data)
# Топ-5 зон
for i, zone in enumerate(ranked_zones[:5]):
primary_zones.append(PrimaryZone(
priority=i + 1,
name=f"Зона {zone['direction']} {zone['distance_km']}км",
direction=zone['direction'],
distance=zone['distance_km'],
reason=f"Оценка {zone['score']}/100 по scoring_service"
))
# Радиус на основе дистанции
search_radius_km = scorer.distance_multiplier * 2.0
except Exception as e:
print(f"Geo/scoring service error in fallback: {e}")
# Если зоны не построены, используем базовые
if not primary_zones:
primary_zones = [
PrimaryZone(
priority=1,
name="Ближняя зона",
direction="N",
distance=0.5,
reason="Базовая зона поиска"
),
PrimaryZone(
priority=2,
name="Средняя зона",
direction="E",
distance=1.0,
reason="Расширенная зона поиска"
)
]
# Ключевые локации на основе возраста
if age <= 7:
key_locations = ["водоёмы", "укрытия", "густая растительность", "ближайшие постройки"]
elif age <= 12:
key_locations = ["водоёмы", "дороги", "тропы", "лесные массивы"]
else:
key_locations = ["дороги", "населённые пункты", "транспортные узлы", "водоёмы"]
# Поведенческий прогноз
if 'РАС' in profiles:
behavioral_prediction = "Высокий риск движения к водоёмам и ж/д путям. Может не откликаться на имя."
elif age <= 4:
behavioral_prediction = "Минимальное движение, вероятно находится близко к точке потери."
elif age <= 12:
behavioral_prediction = "Умеренное движение, может следовать по тропам или дорогам."
else:
behavioral_prediction = "Целенаправленное движение, возможен выход к населённым пунктам."
# Немедленные действия
immediate_actions = [
"Организовать поисковые группы",
"Проверить ближайшие водоёмы",
"Опросить свидетелей в районе последнего местоположения"
]
if 'РАС' in profiles:
immediate_actions.insert(0, "КРИТИЧНО: Перекрыть все водоёмы и ж/д пути в радиусе 5 км")
if 'велосипед' in profiles:
immediate_actions.insert(0, "Расширить зону поиска до 10-15 км, проверить дорожные камеры")
summary = f"Случай классифицирован как {urgency} срочность. "
summary += f"Рекомендуемый радиус поиска: {search_radius_km} км. "
summary += f"Приоритет: {key_locations[0]}."
return AnalysisResult(
urgency=urgency,
primary_zones=primary_zones,
search_radius_km=search_radius_km,
key_locations=key_locations,
behavioral_prediction=behavioral_prediction,
immediate_actions=immediate_actions,
summary=summary,
fallback_used=True
)
+316
View File
@@ -0,0 +1,316 @@
"""
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 coefficient (placeholder, can be expanded)
diagnosis_coef = 1.0
# СУ - Urgency coefficient (placeholder, can be expanded)
urgency_coef = 1.0
# СУТ - 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 км
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)
"""
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_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)
"""
time_lower = time_of_day.lower()
if 'ночь' in time_lower:
return 0.5
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)
"""
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)
}
+381
View File
@@ -0,0 +1,381 @@
"""
Geo service for building search zones and querying OpenStreetMap data via Overpass API.
"""
import math
import json
import hashlib
from datetime import datetime, timedelta
from pathlib import Path
from typing import List, Dict, Optional, Tuple
import httpx
from pydantic import BaseModel
class Zone(BaseModel):
"""Search zone with geographic features."""
direction: str # N, NE, E, SE, S, SW, W, NW
distance_km: float
forest_pct: float
road_density: float # km of roads per km²
water_distance_km: Optional[float]
settlement_distance_km: Optional[float]
# Cache configuration
CACHE_DIR = Path("/tmp/overpass_cache")
CACHE_TTL_HOURS = 24
OVERPASS_URL = "https://overpass-api.de/api/interpreter"
# Direction mappings
DIRECTIONS = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"]
DIRECTION_ANGLES = {
"N": 0,
"NE": 45,
"E": 90,
"SE": 135,
"S": 180,
"SW": 225,
"W": 270,
"NW": 315
}
# Search distances computed dynamically from max_distance_km
def _build_search_distances(max_distance_km: float) -> list:
max_m = int(max_distance_km * 1000)
raw = [int(max_m * 0.25), int(max_m * 0.50), int(max_m * 0.75), max_m]
clamped = [max(200, min(5000, d)) for d in raw]
return sorted(set(clamped))
def haversine(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
"""
Calculate distance between two points on Earth using Haversine formula.
Args:
lat1, lon1: First point coordinates
lat2, lon2: Second point coordinates
Returns:
Distance in kilometers
"""
R = 6371 # Earth radius in km
lat1_rad = math.radians(lat1)
lat2_rad = math.radians(lat2)
dlat = math.radians(lat2 - lat1)
dlon = math.radians(lon2 - lon1)
a = (math.sin(dlat / 2) ** 2 +
math.cos(lat1_rad) * math.cos(lat2_rad) * math.sin(dlon / 2) ** 2)
c = 2 * math.asin(math.sqrt(a))
return R * c
def get_sector_bounds(lat: float, lon: float, direction: str, radius_m: int) -> Tuple[float, float, float, float]:
"""
Calculate bounding box for a sector.
Args:
lat, lon: Center point
direction: Sector direction (N, NE, E, etc.)
radius_m: Radius in meters
Returns:
(min_lat, min_lon, max_lat, max_lon)
"""
# Convert radius to degrees (approximate)
radius_deg = radius_m / 111320 # 1 degree ≈ 111.32 km at equator
angle = DIRECTION_ANGLES[direction]
angle_rad = math.radians(angle)
# Calculate sector boundaries (45° sectors)
angle_start = angle - 22.5
angle_end = angle + 22.5
# Simple bounding box (can be optimized for actual sector shape)
lat_offset = radius_deg * math.cos(angle_rad)
lon_offset = radius_deg * math.sin(angle_rad) / math.cos(math.radians(lat))
min_lat = min(lat, lat + lat_offset) - radius_deg * 0.5
max_lat = max(lat, lat + lat_offset) + radius_deg * 0.5
min_lon = min(lon, lon + lon_offset) - radius_deg * 0.5
max_lon = max(lon, lon + lon_offset) + radius_deg * 0.5
return (min_lat, min_lon, max_lat, max_lon)
def get_cache_key(query: str) -> str:
"""Generate cache key from query."""
return hashlib.md5(query.encode()).hexdigest()
def get_cached_result(cache_key: str) -> Optional[Dict]:
"""Get cached Overpass API result if not expired."""
CACHE_DIR.mkdir(exist_ok=True)
cache_file = CACHE_DIR / f"{cache_key}.json"
if not cache_file.exists():
return None
try:
with open(cache_file, 'r') as f:
cached = json.load(f)
cached_time = datetime.fromisoformat(cached['timestamp'])
if datetime.now() - cached_time > timedelta(hours=CACHE_TTL_HOURS):
cache_file.unlink()
return None
return cached['data']
except Exception:
return None
def save_to_cache(cache_key: str, data: Dict):
"""Save Overpass API result to cache."""
CACHE_DIR.mkdir(exist_ok=True)
cache_file = CACHE_DIR / f"{cache_key}.json"
try:
with open(cache_file, 'w') as f:
json.dump({
'timestamp': datetime.now().isoformat(),
'data': data
}, f)
except Exception:
pass
async def query_overpass(query: str) -> Dict:
"""
Query Overpass API with caching.
Args:
query: Overpass QL query
Returns:
API response as dict
"""
cache_key = get_cache_key(query)
# Check cache
cached = get_cached_result(cache_key)
if cached is not None:
return cached
# Query API
async with httpx.AsyncClient(timeout=30.0) as client:
try:
response = await client.post(
OVERPASS_URL,
data={'data': query},
headers={'Content-Type': 'application/x-www-form-urlencoded'}
)
response.raise_for_status()
data = response.json()
# Save to cache
save_to_cache(cache_key, data)
return data
except Exception as e:
# Return empty result on error
return {'elements': []}
def calculate_road_length(elements: List[Dict]) -> float:
"""
Calculate total road length from Overpass way elements.
Args:
elements: List of way elements from Overpass
Returns:
Total length in kilometers
"""
total_length = 0.0
for element in elements:
if element.get('type') != 'way':
continue
nodes = element.get('geometry', [])
if len(nodes) < 2:
continue
# Calculate length by summing distances between consecutive nodes
for i in range(len(nodes) - 1):
lat1, lon1 = nodes[i]['lat'], nodes[i]['lon']
lat2, lon2 = nodes[i + 1]['lat'], nodes[i + 1]['lon']
total_length += haversine(lat1, lon1, lat2, lon2)
return total_length
def find_nearest_distance(lat: float, lon: float, elements: List[Dict]) -> Optional[float]:
"""
Find distance to nearest element.
Args:
lat, lon: Reference point
elements: List of node elements from Overpass
Returns:
Distance in kilometers, or None if no elements
"""
if not elements:
return None
min_distance = float('inf')
for element in elements:
if element.get('type') != 'node':
continue
elem_lat = element.get('lat')
elem_lon = element.get('lon')
if elem_lat is None or elem_lon is None:
continue
distance = haversine(lat, lon, elem_lat, elem_lon)
min_distance = min(min_distance, distance)
return min_distance if min_distance != float('inf') else None
def calculate_forest_coverage(elements: List[Dict], radius_m: int) -> float:
"""
Estimate forest coverage percentage.
Args:
elements: List of way elements from Overpass
radius_m: Search radius in meters
Returns:
Forest coverage as percentage (0-100)
"""
if not elements:
return 0.0
# Approximate: count forest ways and estimate coverage
# This is a simplified calculation
forest_ways = len([e for e in elements if e.get('type') == 'way'])
# Rough heuristic: each forest way covers ~0.1 km²
# Total search area = π * r²
search_area_km2 = math.pi * (radius_m / 1000) ** 2
estimated_forest_km2 = forest_ways * 0.1
coverage_pct = min(100.0, (estimated_forest_km2 / search_area_km2) * 100)
return round(coverage_pct, 1)
async def get_zone_features(lat: float, lon: float, direction: str, radius_m: int) -> Dict:
"""
Get geographic features for a zone using Overpass API.
Args:
lat, lon: Center point
direction: Sector direction
radius_m: Search radius in meters
Returns:
Dict with roads_km, water_distance_km, settlement_distance_km, forest_pct
"""
# Query roads
roads_query = f"""
[out:json];
(
way[highway](around:{radius_m},{lat},{lon});
);
out geom;
"""
roads_data = await query_overpass(roads_query)
roads_km = calculate_road_length(roads_data.get('elements', []))
# Query water bodies
water_query = f"""
[out:json];
(
node[natural=water](around:{radius_m},{lat},{lon});
way[natural=water](around:{radius_m},{lat},{lon});
);
out center;
"""
water_data = await query_overpass(water_query)
water_distance = find_nearest_distance(lat, lon, water_data.get('elements', []))
# Query settlements
settlement_query = f"""
[out:json];
(
node[place~"village|town|city"](around:{radius_m},{lat},{lon});
);
out;
"""
settlement_data = await query_overpass(settlement_query)
settlement_distance = find_nearest_distance(lat, lon, settlement_data.get('elements', []))
# Query forests
forest_query = f"""
[out:json];
(
way[landuse=forest](around:{radius_m},{lat},{lon});
way[natural=wood](around:{radius_m},{lat},{lon});
);
out geom;
"""
forest_data = await query_overpass(forest_query)
forest_pct = calculate_forest_coverage(forest_data.get('elements', []), radius_m)
# Calculate road density (km of roads per km²)
search_area_km2 = math.pi * (radius_m / 1000) ** 2
road_density = roads_km / search_area_km2 if search_area_km2 > 0 else 0.0
return {
'roads_km': roads_km,
'road_density': round(road_density, 2),
'water_distance_km': water_distance,
'settlement_distance_km': settlement_distance,
'forest_pct': forest_pct
}
async def build_search_zones(lat: float, lon: float, case_data: dict, max_distance_km: float = 3.0) -> List[Zone]:
"""
Build search zones around a point.
Creates 8 directional sectors (N, NE, E, SE, S, SW, W, NW) at multiple distances
(500m, 1000m, 2000m, 5000m) and queries geographic features for each.
Args:
lat: Latitude of search origin
lon: Longitude of search origin
case_data: Case information (for future enhancements)
Returns:
List of Zone objects with geographic features
"""
zones = []
for distance_m in _build_search_distances(max_distance_km):
for direction in DIRECTIONS:
# Get features for this zone
features = await get_zone_features(lat, lon, direction, distance_m)
zone = Zone(
direction=direction,
distance_km=distance_m / 1000,
forest_pct=features['forest_pct'],
road_density=features['road_density'],
water_distance_km=features['water_distance_km'],
settlement_distance_km=features['settlement_distance_km']
)
zones.append(zone)
return zones
+377
View File
@@ -0,0 +1,377 @@
"""
Geo service for building search zones and querying OpenStreetMap data via Overpass API.
"""
import math
import json
import hashlib
from datetime import datetime, timedelta
from pathlib import Path
from typing import List, Dict, Optional, Tuple
import httpx
from pydantic import BaseModel
class Zone(BaseModel):
"""Search zone with geographic features."""
direction: str # N, NE, E, SE, S, SW, W, NW
distance_km: float
forest_pct: float
road_density: float # km of roads per km²
water_distance_km: Optional[float]
settlement_distance_km: Optional[float]
# Cache configuration
CACHE_DIR = Path("/tmp/overpass_cache")
CACHE_TTL_HOURS = 24
OVERPASS_URL = "https://overpass-api.de/api/interpreter"
# Direction mappings
DIRECTIONS = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"]
DIRECTION_ANGLES = {
"N": 0,
"NE": 45,
"E": 90,
"SE": 135,
"S": 180,
"SW": 225,
"W": 270,
"NW": 315
}
# Search distances in meters
SEARCH_DISTANCES = [500, 1000, 2000, 5000]
def haversine(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
"""
Calculate distance between two points on Earth using Haversine formula.
Args:
lat1, lon1: First point coordinates
lat2, lon2: Second point coordinates
Returns:
Distance in kilometers
"""
R = 6371 # Earth radius in km
lat1_rad = math.radians(lat1)
lat2_rad = math.radians(lat2)
dlat = math.radians(lat2 - lat1)
dlon = math.radians(lon2 - lon1)
a = (math.sin(dlat / 2) ** 2 +
math.cos(lat1_rad) * math.cos(lat2_rad) * math.sin(dlon / 2) ** 2)
c = 2 * math.asin(math.sqrt(a))
return R * c
def get_sector_bounds(lat: float, lon: float, direction: str, radius_m: int) -> Tuple[float, float, float, float]:
"""
Calculate bounding box for a sector.
Args:
lat, lon: Center point
direction: Sector direction (N, NE, E, etc.)
radius_m: Radius in meters
Returns:
(min_lat, min_lon, max_lat, max_lon)
"""
# Convert radius to degrees (approximate)
radius_deg = radius_m / 111320 # 1 degree ≈ 111.32 km at equator
angle = DIRECTION_ANGLES[direction]
angle_rad = math.radians(angle)
# Calculate sector boundaries (45° sectors)
angle_start = angle - 22.5
angle_end = angle + 22.5
# Simple bounding box (can be optimized for actual sector shape)
lat_offset = radius_deg * math.cos(angle_rad)
lon_offset = radius_deg * math.sin(angle_rad) / math.cos(math.radians(lat))
min_lat = min(lat, lat + lat_offset) - radius_deg * 0.5
max_lat = max(lat, lat + lat_offset) + radius_deg * 0.5
min_lon = min(lon, lon + lon_offset) - radius_deg * 0.5
max_lon = max(lon, lon + lon_offset) + radius_deg * 0.5
return (min_lat, min_lon, max_lat, max_lon)
def get_cache_key(query: str) -> str:
"""Generate cache key from query."""
return hashlib.md5(query.encode()).hexdigest()
def get_cached_result(cache_key: str) -> Optional[Dict]:
"""Get cached Overpass API result if not expired."""
CACHE_DIR.mkdir(exist_ok=True)
cache_file = CACHE_DIR / f"{cache_key}.json"
if not cache_file.exists():
return None
try:
with open(cache_file, 'r') as f:
cached = json.load(f)
cached_time = datetime.fromisoformat(cached['timestamp'])
if datetime.now() - cached_time > timedelta(hours=CACHE_TTL_HOURS):
cache_file.unlink()
return None
return cached['data']
except Exception:
return None
def save_to_cache(cache_key: str, data: Dict):
"""Save Overpass API result to cache."""
CACHE_DIR.mkdir(exist_ok=True)
cache_file = CACHE_DIR / f"{cache_key}.json"
try:
with open(cache_file, 'w') as f:
json.dump({
'timestamp': datetime.now().isoformat(),
'data': data
}, f)
except Exception:
pass
async def query_overpass(query: str) -> Dict:
"""
Query Overpass API with caching.
Args:
query: Overpass QL query
Returns:
API response as dict
"""
cache_key = get_cache_key(query)
# Check cache
cached = get_cached_result(cache_key)
if cached is not None:
return cached
# Query API
async with httpx.AsyncClient(timeout=30.0) as client:
try:
response = await client.post(
OVERPASS_URL,
data={'data': query},
headers={'Content-Type': 'application/x-www-form-urlencoded'}
)
response.raise_for_status()
data = response.json()
# Save to cache
save_to_cache(cache_key, data)
return data
except Exception as e:
# Return empty result on error
return {'elements': []}
def calculate_road_length(elements: List[Dict]) -> float:
"""
Calculate total road length from Overpass way elements.
Args:
elements: List of way elements from Overpass
Returns:
Total length in kilometers
"""
total_length = 0.0
for element in elements:
if element.get('type') != 'way':
continue
nodes = element.get('geometry', [])
if len(nodes) < 2:
continue
# Calculate length by summing distances between consecutive nodes
for i in range(len(nodes) - 1):
lat1, lon1 = nodes[i]['lat'], nodes[i]['lon']
lat2, lon2 = nodes[i + 1]['lat'], nodes[i + 1]['lon']
total_length += haversine(lat1, lon1, lat2, lon2)
return total_length
def find_nearest_distance(lat: float, lon: float, elements: List[Dict]) -> Optional[float]:
"""
Find distance to nearest element.
Args:
lat, lon: Reference point
elements: List of node elements from Overpass
Returns:
Distance in kilometers, or None if no elements
"""
if not elements:
return None
min_distance = float('inf')
for element in elements:
if element.get('type') != 'node':
continue
elem_lat = element.get('lat')
elem_lon = element.get('lon')
if elem_lat is None or elem_lon is None:
continue
distance = haversine(lat, lon, elem_lat, elem_lon)
min_distance = min(min_distance, distance)
return min_distance if min_distance != float('inf') else None
def calculate_forest_coverage(elements: List[Dict], radius_m: int) -> float:
"""
Estimate forest coverage percentage.
Args:
elements: List of way elements from Overpass
radius_m: Search radius in meters
Returns:
Forest coverage as percentage (0-100)
"""
if not elements:
return 0.0
# Approximate: count forest ways and estimate coverage
# This is a simplified calculation
forest_ways = len([e for e in elements if e.get('type') == 'way'])
# Rough heuristic: each forest way covers ~0.1 km²
# Total search area = π * r²
search_area_km2 = math.pi * (radius_m / 1000) ** 2
estimated_forest_km2 = forest_ways * 0.1
coverage_pct = min(100.0, (estimated_forest_km2 / search_area_km2) * 100)
return round(coverage_pct, 1)
async def get_zone_features(lat: float, lon: float, direction: str, radius_m: int) -> Dict:
"""
Get geographic features for a zone using Overpass API.
Args:
lat, lon: Center point
direction: Sector direction
radius_m: Search radius in meters
Returns:
Dict with roads_km, water_distance_km, settlement_distance_km, forest_pct
"""
# Query roads
roads_query = f"""
[out:json];
(
way[highway](around:{radius_m},{lat},{lon});
);
out geom;
"""
roads_data = await query_overpass(roads_query)
roads_km = calculate_road_length(roads_data.get('elements', []))
# Query water bodies
water_query = f"""
[out:json];
(
node[natural=water](around:{radius_m},{lat},{lon});
way[natural=water](around:{radius_m},{lat},{lon});
);
out center;
"""
water_data = await query_overpass(water_query)
water_distance = find_nearest_distance(lat, lon, water_data.get('elements', []))
# Query settlements
settlement_query = f"""
[out:json];
(
node[place~"village|town|city"](around:{radius_m},{lat},{lon});
);
out;
"""
settlement_data = await query_overpass(settlement_query)
settlement_distance = find_nearest_distance(lat, lon, settlement_data.get('elements', []))
# Query forests
forest_query = f"""
[out:json];
(
way[landuse=forest](around:{radius_m},{lat},{lon});
way[natural=wood](around:{radius_m},{lat},{lon});
);
out geom;
"""
forest_data = await query_overpass(forest_query)
forest_pct = calculate_forest_coverage(forest_data.get('elements', []), radius_m)
# Calculate road density (km of roads per km²)
search_area_km2 = math.pi * (radius_m / 1000) ** 2
road_density = roads_km / search_area_km2 if search_area_km2 > 0 else 0.0
return {
'roads_km': roads_km,
'road_density': round(road_density, 2),
'water_distance_km': water_distance,
'settlement_distance_km': settlement_distance,
'forest_pct': forest_pct
}
async def build_search_zones(lat: float, lon: float, case_data: dict) -> List[Zone]:
"""
Build search zones around a point.
Creates 8 directional sectors (N, NE, E, SE, S, SW, W, NW) at multiple distances
(500m, 1000m, 2000m, 5000m) and queries geographic features for each.
Args:
lat: Latitude of search origin
lon: Longitude of search origin
case_data: Case information (for future enhancements)
Returns:
List of Zone objects with geographic features
"""
zones = []
for distance_m in SEARCH_DISTANCES:
for direction in DIRECTIONS:
# Get features for this zone
features = await get_zone_features(lat, lon, direction, distance_m)
zone = Zone(
direction=direction,
distance_km=distance_m / 1000,
forest_pct=features['forest_pct'],
road_density=features['road_density'],
water_distance_km=features['water_distance_km'],
settlement_distance_km=features['settlement_distance_km']
)
zones.append(zone)
return zones
+237
View File
@@ -0,0 +1,237 @@
"""
Сервис определения психотипа пропавшего ребёнка.
Маппинг согласно §6 контекста ВЕКТОР (Шаг 2б).
Основан на методике Рындиной О.Г., Ивановой О.Ю. (Чебоксары, 2015)
8 психотипов по Грановской–Никольской (Кеттелл).
"""
from typing import Dict, List, Literal
PsychotypeStr = Literal[
'dominant',
'harmonic',
'anxious',
'introvert_passive',
'introvert_active'
]
def detect_psychotype(answers: Dict[str, str]) -> PsychotypeStr:
"""
Определяет психотип на основе 4 вопросов родителям (§6 контекста).
Args:
answers: Словарь с ответами:
- unfamiliar_behavior: 'explore' | 'wait' | 'freeze' | 'panic'
- stress_reaction: 'angry' | 'cry' | 'calm'
- leadership: 'always_leader' | 'sometimes' | 'always_follower'
- risk_taking: 'very' | 'sometimes' | 'no_cautious'
Returns:
Определенный психотип
Маппинг из §6:
- активно + лидер + рискует → dominant
- спокойно + лидер + осторожный → harmonic
- плачет + ведомый + осторожный → anxious
- замирает + ведомый + осторожный → introvert_passive
- активно/паникует + иногда → introvert_active
"""
unfamiliar = answers.get('unfamiliar_behavior', '').lower()
stress = answers.get('stress_reaction', '').lower()
leadership = answers.get('leadership', '').lower()
risk = answers.get('risk_taking', '').lower()
# Маппинг согласно §6 контекста
# активно + лидер + рискует → dominant
if unfamiliar == 'explore' and leadership == 'always_leader' and risk == 'very':
return 'dominant'
# спокойно + лидер + осторожный → harmonic
if stress == 'calm' and leadership == 'always_leader' and risk == 'no_cautious':
return 'harmonic'
# плачет + ведомый + осторожный → anxious
if stress == 'cry' and leadership == 'always_follower' and risk == 'no_cautious':
return 'anxious'
# замирает + ведомый + осторожный → introvert_passive
if unfamiliar == 'freeze' and leadership == 'always_follower' and risk == 'no_cautious':
return 'introvert_passive'
# активно/паникует + иногда → introvert_active
if (unfamiliar in ['explore', 'panic']) and leadership == 'sometimes':
return 'introvert_active'
# Дополнительные правила для неоднозначных случаев
# Лидер + рискует → скорее dominant
if leadership == 'always_leader' and risk in ['very', 'sometimes']:
return 'dominant'
# Ведомый + осторожный + плачет/замирает → anxious или introvert_passive
if leadership == 'always_follower' and risk == 'no_cautious':
if stress == 'cry':
return 'anxious'
elif unfamiliar == 'freeze':
return 'introvert_passive'
# Активно исследует + иногда лидер → introvert_active
if unfamiliar == 'explore' and leadership == 'sometimes':
return 'introvert_active'
# Дефолт: harmonic (сбалансированный)
return 'harmonic'
def get_psychotype_modifiers(psychotype: PsychotypeStr) -> Dict:
"""
Возвращает модификаторы вероятности для зон поиска и модель движения.
Args:
psychotype: Определенный психотип
Returns:
Словарь с модификаторами зон и моделью движения
"""
modifiers = {
'dominant': {
'zone_0_500': 0.7,
'zone_500_1500': 1.2,
'zone_1500_2500': 1.4,
'zone_2500plus': 1.1,
'movement_model': 'chaotic_far',
'description': 'Доминантный: активное движение, большие расстояния, хаотичное поведение'
},
'harmonic': {
'zone_0_500': 0.8,
'zone_500_1500': 1.0,
'zone_1500_2500': 1.1,
'zone_2500plus': 0.9,
'movement_model': 'linear_landmark',
'description': 'Гармоничный: рациональное движение по ориентирам, средние расстояния'
},
'anxious': {
'zone_0_500': 1.4,
'zone_500_1500': 1.1,
'zone_1500_2500': 0.5,
'zone_2500plus': 0.3,
'movement_model': 'stay',
'description': 'Тревожный: минимальное движение, остается близко к точке потери'
},
'introvert_passive': {
'zone_0_500': 1.3,
'zone_500_1500': 0.9,
'zone_1500_2500': 0.6,
'zone_2500plus': 0.2,
'movement_model': 'stay_hidden',
'description': 'Интроверт пассивный: прячется, минимальное движение, близко к точке потери'
},
'introvert_active': {
'zone_0_500': 0.8,
'zone_500_1500': 1.1,
'zone_1500_2500': 1.2,
'zone_2500plus': 0.9,
'movement_model': 'linear_landmark',
'description': 'Интроверт активный: целенаправленное движение по ориентирам, средние расстояния'
}
}
return modifiers.get(psychotype, modifiers['harmonic'])
def get_search_recommendations(psychotype: PsychotypeStr) -> Dict[str, str]:
"""
Возвращает рекомендации по тактике поиска для данного психотипа.
Args:
psychotype: Определенный психотип
Returns:
Словарь с рекомендациями по поиску
"""
recommendations = {
'dominant': {
'priority_zones': 'Средние и дальние зоны (500-2500м)',
'search_pattern': 'Широкий охват, проверка нелинейных маршрутов',
'key_locations': 'Возвышенности, открытые пространства, необычные объекты',
'communication': 'Громкие сигналы, яркие маркеры'
},
'harmonic': {
'priority_zones': 'Все зоны равномерно, акцент на 500-1500м',
'search_pattern': 'Систематический поиск вдоль троп и ориентиров',
'key_locations': 'Тропы, дороги, видимые ориентиры, укрытия',
'communication': 'Стандартные сигналы, информационные знаки'
},
'anxious': {
'priority_zones': 'Ближняя зона (0-500м) - критически важна',
'search_pattern': 'Тщательный осмотр ближайшей территории',
'key_locations': 'Укрытия, углубления, густая растительность рядом с точкой потери',
'communication': 'Спокойные голосовые сигналы, избегать резких звуков'
},
'introvert_passive': {
'priority_zones': 'Ближняя зона (0-500м), укрытия',
'search_pattern': 'Детальный осмотр укрытий и труднодоступных мест',
'key_locations': 'Заросли, ямы, под деревьями, за камнями',
'communication': 'Мягкие голосовые сигналы, визуальный контакт важнее звука'
},
'introvert_active': {
'priority_zones': 'Средние зоны (500-2500м) вдоль линейных ориентиров',
'search_pattern': 'Поиск вдоль троп, ручьев, границ леса',
'key_locations': 'Линейные ориентиры, перекрестки троп, характерные объекты',
'communication': 'Стандартные сигналы вдоль вероятных маршрутов'
}
}
return recommendations.get(psychotype, recommendations['harmonic'])
def get_psychotype_questions() -> List[Dict]:
"""
Возвращает 4 вопроса для определения психотипа (§6 контекста).
Returns:
Список вопросов с вариантами ответов
"""
return [
{
'id': 'unfamiliar_behavior',
'question': 'Как ведёт себя в незнакомой обстановке?',
'options': [
{'value': 'explore', 'label': 'Активно исследует'},
{'value': 'wait', 'label': 'Ждёт и наблюдает'},
{'value': 'freeze', 'label': 'Замирает'},
{'value': 'panic', 'label': 'Паникует'}
]
},
{
'id': 'stress_reaction',
'question': 'Реакция на стресс и неудачи?',
'options': [
{'value': 'angry', 'label': 'Злится, кричит'},
{'value': 'cry', 'label': 'Плачет, замыкается'},
{'value': 'calm', 'label': 'Спокойно ищет выход'}
]
},
{
'id': 'leadership',
'question': 'Лидер или ведомый?',
'options': [
{'value': 'always_leader', 'label': 'Всегда лидер'},
{'value': 'sometimes', 'label': 'Иногда'},
{'value': 'always_follower', 'label': 'Всегда ведомый'}
]
},
{
'id': 'risk_taking',
'question': 'Любит рисковать?',
'options': [
{'value': 'very', 'label': 'Очень'},
{'value': 'sometimes', 'label': 'Иногда'},
{'value': 'no_cautious', 'label': 'Нет, осторожный'}
]
}
]
+403
View File
@@ -0,0 +1,403 @@
"""
Сервис оценки и ранжирования зон поиска на основе взвешенных факторов.
Реализация согласно §8 и §9 контекста ВЕКТОР.
"""
from typing import Dict, List, Optional
from copy import deepcopy
class WeightedScorer:
"""
Система взвешенной оценки зон поиска с учетом множественных факторов.
Базовые веса из §9 контекста.
"""
# Базовые веса факторов из §9 (сумма = 1.0)
BASE_WEIGHTS = {
'forest': 0.25,
'water': 0.20,
'roads': 0.18,
'settlement': 0.15,
'historical': 0.12,
'direction': 0.07,
'shelter': 0.03
}
# Возрастные модификаторы
AGE_MODIFIERS = {
'0-4': {
'forest': 0.6,
'water': 2.5,
'roads': 1.3,
'settlement': 1.8,
'shelter': 1.5,
'distance_mult': 0.3
},
'5-7': {
'forest': 0.8,
'water': 2.2,
'roads': 1.4,
'settlement': 1.6,
'shelter': 1.4,
'distance_mult': 0.5
},
'8-11': {
'forest': 1.1,
'water': 1.8,
'roads': 1.2,
'settlement': 1.3,
'shelter': 1.2,
'distance_mult': 0.8
},
'12-14': {
'forest': 1.3,
'water': 1.4,
'roads': 1.1,
'settlement': 1.0,
'shelter': 1.0,
'distance_mult': 1.2
},
'15-17': {
'forest': 1.4,
'water': 1.2,
'roads': 1.3,
'settlement': 0.9,
'shelter': 0.9,
'distance_mult': 1.5
}
}
# Сезонные модификаторы
SEASON_MODIFIERS = {
'зима': {
'forest': 0.8,
'water': 0.6,
'roads': 1.3,
'settlement': 1.5,
'shelter': 2.0,
'distance_mult': 0.7
},
'весна': {
'forest': 1.1,
'water': 1.8,
'roads': 1.0,
'settlement': 1.0,
'shelter': 1.2,
'distance_mult': 1.0
},
'лето': {
'forest': 1.2,
'water': 1.3,
'roads': 0.9,
'settlement': 0.8,
'shelter': 0.8,
'distance_mult': 1.3
},
'осень': {
'forest': 1.3,
'water': 1.1,
'roads': 1.0,
'settlement': 1.1,
'shelter': 1.1,
'distance_mult': 1.0
}
}
# Поведенческие профили — ТОЧНЫЕ коэффициенты из §8 контекста
BEHAVIORAL_PROFILES = {
'РАС': {
'water': 3.0,
'railway': 2.5,
'shelter': 2.0,
'settlement': 0.4,
'distance_mult': 2.0,
'critical_warning': 'НЕ использовать громкоговоритель с именем ребёнка! Немедленно перекрыть ВСЕ водоёмы и ж/д пути.'
},
'эпилепсия': {
'water': 3.5,
'shelter': 2.5,
'distance_mult': 0.6,
'critical_warning': 'Медицинский приоритет — возможна потеря сознания. Радиус поиска МЕНЬШЕ среднего.'
},
'СДВГ': {
'roads': 1.6,
'distance_mult': 1.4,
'note': 'Импульсивное движение, меняет направление. Откликается, но может не идти целенаправленно.'
},
'ЗПР': {
'settlement': 0.7,
'shelter': 1.5,
'distance_mult': 0.8,
'note': 'Не ориентируется в пространстве'
},
'велосипед': {
'distance_mult': 5.0,
'roads': 1.8,
'forest': 0.8,
'critical_warning': 'Немедленно расширить зону до 10-15 км! Приоритет: дороги и велодорожки. Запросить данные дорожных камер.'
},
'самокат': {
'distance_mult': 3.0,
'roads': 1.6,
'forest': 0.9
},
'намеренный_уход': {
'forest': 0.2,
'roads': 2.5,
'settlement': 3.0,
'note': 'Не прочёсывание леса, а розыск. Транспортные узлы, камеры, соцсети, друзья.'
}
}
def __init__(self):
"""Инициализация скорера с базовыми весами."""
self.weights = deepcopy(self.BASE_WEIGHTS)
self.distance_multiplier = 1.0
self.active_profiles = []
self.critical_warnings = []
def _get_age_group(self, age: int) -> str:
"""Определяет возрастную группу."""
if age <= 4:
return '0-4'
elif age <= 7:
return '5-7'
elif age <= 11:
return '8-11'
elif age <= 14:
return '12-14'
elif age <= 17:
return '15-17'
else:
return '18-64'
def apply_age_modifiers(self, age: int):
"""Применяет возрастные модификаторы к весам."""
age_group = self._get_age_group(age)
modifiers = self.AGE_MODIFIERS.get(age_group, {})
for factor, modifier in modifiers.items():
if factor == 'distance_mult':
self.distance_multiplier *= modifier
elif factor in self.weights:
self.weights[factor] *= modifier
def apply_season_modifiers(self, season: str):
"""Применяет сезонные модификаторы к весам."""
season_lower = season.lower() if season else 'лето'
modifiers = self.SEASON_MODIFIERS.get(season_lower, {})
for factor, modifier in modifiers.items():
if factor == 'distance_mult':
self.distance_multiplier *= modifier
elif factor in self.weights:
self.weights[factor] *= modifier
def apply_profile(self, profile_list: List[str]):
"""
Применяет поведенческие профили к весам.
Точные коэффициенты из §8 контекста.
Args:
profile_list: Список профилей (РАС, эпилепсия, СДВГ, велосипед и т.д.)
"""
if not profile_list:
return
for profile_name in profile_list:
profile = self.BEHAVIORAL_PROFILES.get(profile_name)
if not profile:
continue
self.active_profiles.append(profile_name)
# Сохранить критические предупреждения
if 'critical_warning' in profile:
self.critical_warnings.append({
'profile': profile_name,
'warning': profile['critical_warning']
})
for factor, modifier in profile.items():
if factor in ['critical_warning', 'note']:
continue
elif factor == 'distance_mult':
self.distance_multiplier *= modifier
elif factor in self.weights:
self.weights[factor] *= modifier
elif factor == 'railway':
# Ж/д пути — добавляем как отдельный фактор для РАС
if 'railway' not in self.weights:
self.weights['railway'] = 0.05
self.weights['railway'] *= modifier
def _normalize_weights(self):
"""Нормализует веса так, чтобы их сумма была 1.0."""
# Фильтруем None значения
valid_weights = {k: v for k, v in self.weights.items() if v is not None}
total = sum(valid_weights.values())
if total > 0:
for key in self.weights:
if self.weights[key] is not None:
self.weights[key] /= total
else:
self.weights[key] = 0.0
def score_zone(self, zone: Dict, case: Dict, max_distance_km: float = 2.0) -> float:
"""
Оценивает зону поиска на основе её характеристик и данных случая.
Args:
zone: Словарь с характеристиками зоны
case: Данные случая (age, season, profiles и т.д.)
Returns:
float: Оценка зоны (0-100)
"""
# Сбрасываем веса к базовым
self.weights = deepcopy(self.BASE_WEIGHTS)
self.distance_multiplier = 1.0
self.active_profiles = []
self.critical_warnings = []
# Применяем модификаторы
if 'age' in case and case['age']:
self.apply_age_modifiers(case['age'])
if 'season' in case and case['season']:
self.apply_season_modifiers(case['season'])
if 'profiles' in case and case['profiles']:
self.apply_profile(case['profiles'])
# Нормализуем веса
self._normalize_weights()
# Вычисляем оценку
score = 0.0
# Лес
forest_score = zone.get('forest_pct', 0.5)
score += self.weights['forest'] * forest_score
# Вода (чем ближе, тем важнее)
water_dist = zone.get('water_distance_km', 5.0)
water_score = max(0, 1.0 - (water_dist / 10.0)) if water_dist is not None else 0.5
score += self.weights['water'] * water_score
# Дороги
road_density = zone.get('road_density', 0.5)
road_score = min(1.0, road_density / 2.0) if road_density is not None else 0.5
score += self.weights['roads'] * road_score
# Населенные пункты
settlement_dist = zone.get('settlement_distance_km', 10.0)
settlement_score = max(0, 1.0 - (settlement_dist / 20.0)) if settlement_dist is not None else 0.5
score += self.weights['settlement'] * settlement_score
# Историческая частота
historical_score = zone.get('historical_freq', 0.5)
score += self.weights['historical'] * historical_score
# Совпадение направления
direction_score = zone.get('direction_match', 0.5)
score += self.weights['direction'] * direction_score
# Укрытия
shelter_score = zone.get('shelter_pct', 0.3)
score += self.weights['shelter'] * shelter_score
# Ж/д пути (для РАС)
if 'railway' in self.weights:
railway_dist = zone.get('railway_distance_km', 10.0)
railway_score = max(0, 1.0 - (railway_dist / 5.0))
score += self.weights['railway'] * railway_score
# Применяем множитель расстояния
zone_distance = zone.get('distance_km', 1.0)
expected_distance = max_distance_km * self.distance_multiplier
distance_factor = 1.0 - abs(zone_distance - expected_distance) / (expected_distance * 2)
distance_factor = max(0.3, min(1.0, distance_factor))
score *= distance_factor
# Конвертируем в шкалу 0-100
return round(score * 100, 2)
def rank_zones(self, zones: List[Dict], case: Dict, max_distance_km: float = 2.0) -> List[Dict]:
"""
Ранжирует зоны по приоритету на основе оценок.
Args:
zones: Список зон с характеристиками
case: Данные случая
Returns:
List[Dict]: Отсортированный список зон с оценками и приоритетами
"""
scored_zones = []
for zone in zones:
zone_copy = deepcopy(zone)
zone_copy['score'] = self.score_zone(zone, case, max_distance_km=max_distance_km)
scored_zones.append(zone_copy)
scored_zones.sort(key=lambda x: x['score'], reverse=True)
for i, zone in enumerate(scored_zones):
zone['priority'] = i + 1
return scored_zones
def get_active_profiles_info(self) -> List[Dict]:
"""Возвращает информацию об активных профилях с предупреждениями."""
profiles_info = []
for profile_name in self.active_profiles:
profile = self.BEHAVIORAL_PROFILES.get(profile_name, {})
info = {
'name': profile_name,
'modifiers': {k: v for k, v in profile.items() if k not in ['critical_warning', 'note']},
}
if 'critical_warning' in profile:
info['critical_warning'] = profile['critical_warning']
if 'note' in profile:
info['note'] = profile['note']
profiles_info.append(info)
return profiles_info
def create_scorer_for_case(case: Dict) -> WeightedScorer:
"""Создает и настраивает скорер для конкретного случая."""
scorer = WeightedScorer()
if 'age' in case and case['age']:
scorer.apply_age_modifiers(case['age'])
if 'season' in case and case['season']:
scorer.apply_season_modifiers(case['season'])
if 'profiles' in case and case['profiles']:
scorer.apply_profile(case['profiles'])
scorer._normalize_weights()
return scorer
def get_weight_explanation(case: Dict) -> Dict:
"""Возвращает объяснение весов для данного случая."""
scorer = create_scorer_for_case(case)
return {
'weights': scorer.weights,
'distance_multiplier': scorer.distance_multiplier,
'age_group': scorer._get_age_group(case.get('age', 10)) if case.get('age') else None,
'season': case.get('season'),
'profiles': scorer.get_active_profiles_info(),
'critical_warnings': scorer.critical_warnings
}
+403
View File
@@ -0,0 +1,403 @@
"""
Сервис оценки и ранжирования зон поиска на основе взвешенных факторов.
Реализация согласно §8 и §9 контекста ВЕКТОР.
"""
from typing import Dict, List, Optional
from copy import deepcopy
class WeightedScorer:
"""
Система взвешенной оценки зон поиска с учетом множественных факторов.
Базовые веса из §9 контекста.
"""
# Базовые веса факторов из §9 (сумма = 1.0)
BASE_WEIGHTS = {
'forest': 0.25,
'water': 0.20,
'roads': 0.18,
'settlement': 0.15,
'historical': 0.12,
'direction': 0.07,
'shelter': 0.03
}
# Возрастные модификаторы
AGE_MODIFIERS = {
'0-4': {
'forest': 0.6,
'water': 2.5,
'roads': 1.3,
'settlement': 1.8,
'shelter': 1.5,
'distance_mult': 0.3
},
'5-7': {
'forest': 0.8,
'water': 2.2,
'roads': 1.4,
'settlement': 1.6,
'shelter': 1.4,
'distance_mult': 0.5
},
'8-11': {
'forest': 1.1,
'water': 1.8,
'roads': 1.2,
'settlement': 1.3,
'shelter': 1.2,
'distance_mult': 0.8
},
'12-14': {
'forest': 1.3,
'water': 1.4,
'roads': 1.1,
'settlement': 1.0,
'shelter': 1.0,
'distance_mult': 1.2
},
'15-17': {
'forest': 1.4,
'water': 1.2,
'roads': 1.3,
'settlement': 0.9,
'shelter': 0.9,
'distance_mult': 1.5
}
}
# Сезонные модификаторы
SEASON_MODIFIERS = {
'зима': {
'forest': 0.8,
'water': 0.6,
'roads': 1.3,
'settlement': 1.5,
'shelter': 2.0,
'distance_mult': 0.7
},
'весна': {
'forest': 1.1,
'water': 1.8,
'roads': 1.0,
'settlement': 1.0,
'shelter': 1.2,
'distance_mult': 1.0
},
'лето': {
'forest': 1.2,
'water': 1.3,
'roads': 0.9,
'settlement': 0.8,
'shelter': 0.8,
'distance_mult': 1.3
},
'осень': {
'forest': 1.3,
'water': 1.1,
'roads': 1.0,
'settlement': 1.1,
'shelter': 1.1,
'distance_mult': 1.0
}
}
# Поведенческие профили — ТОЧНЫЕ коэффициенты из §8 контекста
BEHAVIORAL_PROFILES = {
'РАС': {
'water': 3.0,
'railway': 2.5,
'shelter': 2.0,
'settlement': 0.4,
'distance_mult': 2.0,
'critical_warning': 'НЕ использовать громкоговоритель с именем ребёнка! Немедленно перекрыть ВСЕ водоёмы и ж/д пути.'
},
'эпилепсия': {
'water': 3.5,
'shelter': 2.5,
'distance_mult': 0.6,
'critical_warning': 'Медицинский приоритет — возможна потеря сознания. Радиус поиска МЕНЬШЕ среднего.'
},
'СДВГ': {
'roads': 1.6,
'distance_mult': 1.4,
'note': 'Импульсивное движение, меняет направление. Откликается, но может не идти целенаправленно.'
},
'ЗПР': {
'settlement': 0.7,
'shelter': 1.5,
'distance_mult': 0.8,
'note': 'Не ориентируется в пространстве'
},
'велосипед': {
'distance_mult': 5.0,
'roads': 1.8,
'forest': 0.8,
'critical_warning': 'Немедленно расширить зону до 10-15 км! Приоритет: дороги и велодорожки. Запросить данные дорожных камер.'
},
'самокат': {
'distance_mult': 3.0,
'roads': 1.6,
'forest': 0.9
},
'намеренный_уход': {
'forest': 0.2,
'roads': 2.5,
'settlement': 3.0,
'note': 'Не прочёсывание леса, а розыск. Транспортные узлы, камеры, соцсети, друзья.'
}
}
def __init__(self):
"""Инициализация скорера с базовыми весами."""
self.weights = deepcopy(self.BASE_WEIGHTS)
self.distance_multiplier = 1.0
self.active_profiles = []
self.critical_warnings = []
def _get_age_group(self, age: int) -> str:
"""Определяет возрастную группу."""
if age <= 4:
return '0-4'
elif age <= 7:
return '5-7'
elif age <= 11:
return '8-11'
elif age <= 14:
return '12-14'
elif age <= 17:
return '15-17'
else:
return '18-64'
def apply_age_modifiers(self, age: int):
"""Применяет возрастные модификаторы к весам."""
age_group = self._get_age_group(age)
modifiers = self.AGE_MODIFIERS.get(age_group, {})
for factor, modifier in modifiers.items():
if factor == 'distance_mult':
self.distance_multiplier *= modifier
elif factor in self.weights:
self.weights[factor] *= modifier
def apply_season_modifiers(self, season: str):
"""Применяет сезонные модификаторы к весам."""
season_lower = season.lower() if season else 'лето'
modifiers = self.SEASON_MODIFIERS.get(season_lower, {})
for factor, modifier in modifiers.items():
if factor == 'distance_mult':
self.distance_multiplier *= modifier
elif factor in self.weights:
self.weights[factor] *= modifier
def apply_profile(self, profile_list: List[str]):
"""
Применяет поведенческие профили к весам.
Точные коэффициенты из §8 контекста.
Args:
profile_list: Список профилей (РАС, эпилепсия, СДВГ, велосипед и т.д.)
"""
if not profile_list:
return
for profile_name in profile_list:
profile = self.BEHAVIORAL_PROFILES.get(profile_name)
if not profile:
continue
self.active_profiles.append(profile_name)
# Сохранить критические предупреждения
if 'critical_warning' in profile:
self.critical_warnings.append({
'profile': profile_name,
'warning': profile['critical_warning']
})
for factor, modifier in profile.items():
if factor in ['critical_warning', 'note']:
continue
elif factor == 'distance_mult':
self.distance_multiplier *= modifier
elif factor in self.weights:
self.weights[factor] *= modifier
elif factor == 'railway':
# Ж/д пути — добавляем как отдельный фактор для РАС
if 'railway' not in self.weights:
self.weights['railway'] = 0.05
self.weights['railway'] *= modifier
def _normalize_weights(self):
"""Нормализует веса так, чтобы их сумма была 1.0."""
# Фильтруем None значения
valid_weights = {k: v for k, v in self.weights.items() if v is not None}
total = sum(valid_weights.values())
if total > 0:
for key in self.weights:
if self.weights[key] is not None:
self.weights[key] /= total
else:
self.weights[key] = 0.0
def score_zone(self, zone: Dict, case: Dict) -> float:
"""
Оценивает зону поиска на основе её характеристик и данных случая.
Args:
zone: Словарь с характеристиками зоны
case: Данные случая (age, season, profiles и т.д.)
Returns:
float: Оценка зоны (0-100)
"""
# Сбрасываем веса к базовым
self.weights = deepcopy(self.BASE_WEIGHTS)
self.distance_multiplier = 1.0
self.active_profiles = []
self.critical_warnings = []
# Применяем модификаторы
if 'age' in case and case['age']:
self.apply_age_modifiers(case['age'])
if 'season' in case and case['season']:
self.apply_season_modifiers(case['season'])
if 'profiles' in case and case['profiles']:
self.apply_profile(case['profiles'])
# Нормализуем веса
self._normalize_weights()
# Вычисляем оценку
score = 0.0
# Лес
forest_score = zone.get('forest_pct', 0.5)
score += self.weights['forest'] * forest_score
# Вода (чем ближе, тем важнее)
water_dist = zone.get('water_distance_km', 5.0)
water_score = max(0, 1.0 - (water_dist / 10.0)) if water_dist is not None else 0.5
score += self.weights['water'] * water_score
# Дороги
road_density = zone.get('road_density', 0.5)
road_score = min(1.0, road_density / 2.0) if road_density is not None else 0.5
score += self.weights['roads'] * road_score
# Населенные пункты
settlement_dist = zone.get('settlement_distance_km', 10.0)
settlement_score = max(0, 1.0 - (settlement_dist / 20.0)) if settlement_dist is not None else 0.5
score += self.weights['settlement'] * settlement_score
# Историческая частота
historical_score = zone.get('historical_freq', 0.5)
score += self.weights['historical'] * historical_score
# Совпадение направления
direction_score = zone.get('direction_match', 0.5)
score += self.weights['direction'] * direction_score
# Укрытия
shelter_score = zone.get('shelter_pct', 0.3)
score += self.weights['shelter'] * shelter_score
# Ж/д пути (для РАС)
if 'railway' in self.weights:
railway_dist = zone.get('railway_distance_km', 10.0)
railway_score = max(0, 1.0 - (railway_dist / 5.0))
score += self.weights['railway'] * railway_score
# Применяем множитель расстояния
zone_distance = zone.get('distance_km', 1.0)
expected_distance = 2.0 * self.distance_multiplier
distance_factor = 1.0 - abs(zone_distance - expected_distance) / (expected_distance * 2)
distance_factor = max(0.3, min(1.0, distance_factor))
score *= distance_factor
# Конвертируем в шкалу 0-100
return round(score * 100, 2)
def rank_zones(self, zones: List[Dict], case: Dict) -> List[Dict]:
"""
Ранжирует зоны по приоритету на основе оценок.
Args:
zones: Список зон с характеристиками
case: Данные случая
Returns:
List[Dict]: Отсортированный список зон с оценками и приоритетами
"""
scored_zones = []
for zone in zones:
zone_copy = deepcopy(zone)
zone_copy['score'] = self.score_zone(zone, case)
scored_zones.append(zone_copy)
scored_zones.sort(key=lambda x: x['score'], reverse=True)
for i, zone in enumerate(scored_zones):
zone['priority'] = i + 1
return scored_zones
def get_active_profiles_info(self) -> List[Dict]:
"""Возвращает информацию об активных профилях с предупреждениями."""
profiles_info = []
for profile_name in self.active_profiles:
profile = self.BEHAVIORAL_PROFILES.get(profile_name, {})
info = {
'name': profile_name,
'modifiers': {k: v for k, v in profile.items() if k not in ['critical_warning', 'note']},
}
if 'critical_warning' in profile:
info['critical_warning'] = profile['critical_warning']
if 'note' in profile:
info['note'] = profile['note']
profiles_info.append(info)
return profiles_info
def create_scorer_for_case(case: Dict) -> WeightedScorer:
"""Создает и настраивает скорер для конкретного случая."""
scorer = WeightedScorer()
if 'age' in case and case['age']:
scorer.apply_age_modifiers(case['age'])
if 'season' in case and case['season']:
scorer.apply_season_modifiers(case['season'])
if 'profiles' in case and case['profiles']:
scorer.apply_profile(case['profiles'])
scorer._normalize_weights()
return scorer
def get_weight_explanation(case: Dict) -> Dict:
"""Возвращает объяснение весов для данного случая."""
scorer = create_scorer_for_case(case)
return {
'weights': scorer.weights,
'distance_multiplier': scorer.distance_multiplier,
'age_group': scorer._get_age_group(case.get('age', 10)) if case.get('age') else None,
'season': case.get('season'),
'profiles': scorer.get_active_profiles_info(),
'critical_warnings': scorer.critical_warnings
}
+588
View File
@@ -0,0 +1,588 @@
"""
Statistics service for case analysis and dashboard aggregates.
Provides statistical recommendations based on historical data:
- Similar cases filtering (age ±2 years, season, terrain)
- Median distance, top directions, survival rate
- Dashboard aggregates
"""
from typing import Dict, List, Optional, Any
from pydantic import BaseModel
from sqlalchemy.orm import Session
from sqlalchemy import func, and_, or_, text
from models import Case
class DirectionFrequency(BaseModel):
"""Direction frequency statistics"""
direction: str
count: int
percentage: float
class StatisticalRecommendation(BaseModel):
"""Statistical recommendation based on historical data"""
median_distance_km: float
top_directions: List[DirectionFrequency]
top_location_types: List[str]
survival_rate: float
sample_size: int
filters_used: Dict[str, Any]
class DashboardStats(BaseModel):
"""Dashboard aggregate statistics"""
total_cases: int
active_cases: int
closed_cases: int
by_gender: Dict[str, int]
by_age_group: Dict[str, int]
by_psychotype: Dict[str, int]
by_diagnosis: Dict[str, int]
by_season: Dict[str, int]
avg_distance_km: Optional[float]
avg_search_duration_hours: Optional[float]
survival_rate: float
def get_statistical_recommendation(
case_data: dict,
db: Session,
min_sample_size: int = 5
) -> StatisticalRecommendation:
"""
Получает статистические рекомендации на основе похожих исторических случаев.
Фильтры (в порядке приоритета):
1. Возраст ±2 года + сезон + terrain
2. Возраст ±2 года + сезон (если < 5 случаев)
3. Возраст ±2 года (если < 5 случаев)
4. Все случаи (если < 5 случаев)
Args:
case_data: Словарь с данными случая
- age: возраст (обязательно)
- season: сезон (опционально)
- terrain_primary: тип местности (опционально)
db: SQLAlchemy Session
min_sample_size: Минимальный размер выборки (по умолчанию 5)
Returns:
StatisticalRecommendation: Статистические рекомендации
"""
age = case_data.get('age')
season = case_data.get('season')
terrain = case_data.get('terrain_primary')
if not age:
raise ValueError("Age is required for statistical recommendation")
# Попытка 1: Возраст ±2 года + сезон + terrain
filters_used = {'age_range': f"{age-2} to {age+2}"}
query = db.query(Case).filter(
Case.age_years.between(age - 2, age + 2),
Case.found_distance_km.isnot(None)
)
if season:
query = query.filter(Case.season == season)
filters_used['season'] = season
if terrain and season:
query = query.filter(Case.terrain.any(terrain))
filters_used['terrain'] = terrain
cases = query.all()
sample_size = len(cases)
# Попытка 2: Убираем terrain, если мало данных
if sample_size < min_sample_size and terrain:
filters_used.pop('terrain', None)
query = db.query(Case).filter(
Case.age_years.between(age - 2, age + 2),
Case.found_distance_km.isnot(None)
)
if season:
query = query.filter(Case.season == season)
cases = query.all()
sample_size = len(cases)
# Попытка 3: Убираем season, если мало данных
if sample_size < min_sample_size and season:
filters_used.pop('season', None)
query = db.query(Case).filter(
Case.age_years.between(age - 2, age + 2),
Case.found_distance_km.isnot(None)
)
cases = query.all()
sample_size = len(cases)
# Попытка 4: Все случаи с найденными детьми
if sample_size < min_sample_size:
filters_used = {"age_range": "all"}
query = db.query(Case).filter(
Case.found_distance_km.isnot(None)
)
cases = query.all()
sample_size = len(cases)
# Если данных нет совсем, возвращаем дефолтные значения
if sample_size == 0:
return StatisticalRecommendation(
median_distance_km=2.0,
top_directions=[
DirectionFrequency(direction="N", count=0, percentage=0.0),
DirectionFrequency(direction="S", count=0, percentage=0.0),
DirectionFrequency(direction="E", count=0, percentage=0.0)
],
top_location_types=["водоёмы", "дороги", "постройки"],
survival_rate=0.0,
sample_size=0,
filters_used=filters_used
)
# Вычисляем медианное расстояние
distances = sorted([c.found_distance_km for c in cases if c.found_distance_km])
median_distance = distances[len(distances) // 2] if distances else 2.0
# Подсчитываем частоту направлений
direction_counts = {}
for case in cases:
if case.found_direction:
direction = case.found_direction
direction_counts[direction] = direction_counts.get(direction, 0) + 1
# Топ-3 направления
sorted_directions = sorted(
direction_counts.items(),
key=lambda x: x[1],
reverse=True
)[:3]
top_directions = [
DirectionFrequency(
direction=direction,
count=count,
percentage=round(count / sample_size * 100, 1)
)
for direction, count in sorted_directions
]
# Если направлений меньше 3, добавляем пустые
while len(top_directions) < 3:
top_directions.append(
DirectionFrequency(direction="unknown", count=0, percentage=0.0)
)
# Топ типов локаций
location_counts = {}
for case in cases:
if case.found_location_type:
location_type = case.found_location_type
location_counts[location_type] = location_counts.get(location_type, 0) + 1
top_location_types = [
loc for loc, _ in sorted(
location_counts.items(),
key=lambda x: x[1],
reverse=True
)[:5]
]
if not top_location_types:
top_location_types = ["водоёмы", "дороги", "лес"]
# Процент выживаемости
survived_count = sum(1 for case in cases if case.found_alive is True)
survival_rate = round(survived_count / sample_size * 100, 1) if sample_size > 0 else 0.0
return StatisticalRecommendation(
median_distance_km=round(median_distance, 2),
top_directions=top_directions,
top_location_types=top_location_types,
survival_rate=survival_rate,
sample_size=sample_size,
filters_used=filters_used
)
def get_dashboard_stats(db: Session) -> DashboardStats:
"""
Получает агрегированную статистику для дашборда.
Args:
db: SQLAlchemy Session
Returns:
DashboardStats: Агрегированная статистика
"""
cases = db.query(Case).all()
total = len(cases)
active = sum(1 for c in cases if c.status == 'active')
closed = sum(1 for c in cases if c.status == 'closed')
by_gender = {}
by_age_group = {}
by_psychotype = {}
by_diagnosis = {}
by_season = {}
distances = []
durations = []
survived = 0
total_with_outcome = 0
for case in cases:
# Gender
gender = case.gender or 'unknown'
by_gender[gender] = by_gender.get(gender, 0) + 1
# Age groups
age = case.age_years
if age < 4:
age_group = '0-3'
elif age < 8:
age_group = '4-7'
elif age < 12:
age_group = '8-11'
elif age < 15:
age_group = '12-14'
elif age < 18:
age_group = '15-17'
else:
age_group = '18+'
by_age_group[age_group] = by_age_group.get(age_group, 0) + 1
# Psychotype
if case.psychotype:
by_psychotype[case.psychotype] = by_psychotype.get(case.psychotype, 0) + 1
# Diagnosis
if case.diagnosis_type:
for diag in case.diagnosis_type:
by_diagnosis[diag] = by_diagnosis.get(diag, 0) + 1
# Season
if case.season:
by_season[case.season] = by_season.get(case.season, 0) + 1
# Distance
if case.found_distance_km:
distances.append(case.found_distance_km)
# Duration
if case.search_duration_hours:
durations.append(case.search_duration_hours)
# Survival rate
if case.found_alive is not None:
total_with_outcome += 1
if case.found_alive:
survived += 1
avg_distance = round(sum(distances) / len(distances), 2) if distances else None
avg_duration = round(sum(durations) / len(durations), 2) if durations else None
survival_rate = round(survived / total_with_outcome * 100, 1) if total_with_outcome > 0 else 0.0
return DashboardStats(
total_cases=total,
active_cases=active,
closed_cases=closed,
by_gender=by_gender,
by_age_group=by_age_group,
by_psychotype=by_psychotype,
by_diagnosis=by_diagnosis,
by_season=by_season,
avg_distance_km=avg_distance,
avg_search_duration_hours=avg_duration,
survival_rate=survival_rate
)
def get_heatmap_data(db: Session, filters: Optional[Dict] = None) -> List[Dict]:
"""
Получает данные для тепловой карты находок.
Args:
db: SQLAlchemy Session
filters: Опциональные фильтры (age_min, age_max, season, outcome)
Returns:
List[Dict]: Список точек с координатами и интенсивностью
"""
query = db.query(Case).filter(
Case.found_lat.isnot(None),
Case.found_lon.isnot(None)
)
if filters:
if 'age_min' in filters:
query = query.filter(Case.age_years >= filters['age_min'])
if 'age_max' in filters:
query = query.filter(Case.age_years <= filters['age_max'])
if 'season' in filters:
query = query.filter(Case.season == filters['season'])
if 'outcome' in filters:
if filters['outcome'] == 'alive':
query = query.filter(Case.found_alive == True)
elif filters['outcome'] == 'deceased':
query = query.filter(Case.found_alive == False)
cases = query.all()
points = []
for case in cases:
points.append({
'lat': case.found_lat,
'lon': case.found_lon,
'intensity': 1.0,
'case_id': str(case.id),
'distance_km': case.found_distance_km,
'outcome': 'alive' if case.found_alive else 'deceased' if case.found_alive is False else 'unknown'
})
return points
"""
Extended heatmap functions with caching and multiple map types.
"""
from functools import lru_cache
from typing import Dict, List, Optional, Literal
from datetime import datetime
from sqlalchemy.orm import Session
from models import Case
HeatmapType = Literal['all', 'age', 'season', 'outcome']
def get_heatmap_data_cached(
db: Session,
map_type: HeatmapType = 'all',
age_group: Optional[str] = None,
season: Optional[str] = None,
year_from: Optional[int] = None,
year_to: Optional[int] = None,
outcome: Optional[str] = None
) -> Dict:
"""
Получает данные для тепловой карты с кэшированием.
Args:
db: SQLAlchemy Session
map_type: Тип карты (all, age, season, outcome)
age_group: Возрастная группа (0-3, 4-7, 8-11, 12-14, 15-17)
season: Сезон (зима, весна, лето, осень)
year_from: Год начала периода
year_to: Год окончания периода
outcome: Исход (alive, deceased)
Returns:
Dict: {points: List[Dict], total: int, filters_applied: Dict}
"""
# Базовый запрос
query = db.query(Case).filter(
Case.found_lat.isnot(None),
Case.found_lon.isnot(None)
)
filters_applied = {'map_type': map_type}
# Фильтр по возрастной группе
if age_group:
age_ranges = {
'0-3': (0, 3),
'4-7': (4, 7),
'8-11': (8, 11),
'12-14': (12, 14),
'15-17': (15, 17),
'18+': (18, 100)
}
if age_group in age_ranges:
min_age, max_age = age_ranges[age_group]
query = query.filter(Case.age_years.between(min_age, max_age))
filters_applied['age_group'] = age_group
# Фильтр по сезону
if season:
query = query.filter(Case.season == season)
filters_applied['season'] = season
# Фильтр по периоду (годы)
if year_from:
query = query.filter(
db.func.extract('year', Case.created_at) >= year_from
)
filters_applied['year_from'] = year_from
if year_to:
query = query.filter(
db.func.extract('year', Case.created_at) <= year_to
)
filters_applied['year_to'] = year_to
# Фильтр по исходу
if outcome:
if outcome == 'alive':
query = query.filter(Case.found_alive == True)
elif outcome == 'deceased':
query = query.filter(Case.found_alive == False)
filters_applied['outcome'] = outcome
cases = query.all()
# Формируем точки в зависимости от типа карты
points = []
if map_type == 'all':
# Все точки с одинаковой интенсивностью
for case in cases:
points.append({
'lat': case.found_lat,
'lon': case.found_lon,
'intensity': 1.0,
'case_id': str(case.id),
'metadata': {
'age': case.age_years,
'season': case.season,
'outcome': 'alive' if case.found_alive else 'deceased' if case.found_alive is False else 'unknown'
}
})
elif map_type == 'age':
# Интенсивность зависит от возраста (младше = выше интенсивность)
for case in cases:
# Младшие дети = выше интенсивность (более критично)
intensity = max(0.3, 1.0 - (case.age_years / 18.0))
points.append({
'lat': case.found_lat,
'lon': case.found_lon,
'intensity': round(intensity, 2),
'case_id': str(case.id),
'metadata': {
'age': case.age_years,
'age_group': _get_age_group(case.age_years)
}
})
elif map_type == 'season':
# Интенсивность зависит от сезона (зима = выше)
season_intensity = {
'зима': 1.0,
'осень': 0.8,
'весна': 0.6,
'лето': 0.4
}
for case in cases:
intensity = season_intensity.get(case.season, 0.5)
points.append({
'lat': case.found_lat,
'lon': case.found_lon,
'intensity': intensity,
'case_id': str(case.id),
'metadata': {
'season': case.season
}
})
elif map_type == 'outcome':
# Интенсивность зависит от исхода
for case in cases:
if case.found_alive is True:
intensity = 0.5 # Зеленый (выжил)
elif case.found_alive is False:
intensity = 1.0 # Красный (погиб)
else:
intensity = 0.3 # Серый (неизвестно)
points.append({
'lat': case.found_lat,
'lon': case.found_lon,
'intensity': intensity,
'case_id': str(case.id),
'metadata': {
'outcome': 'alive' if case.found_alive else 'deceased' if case.found_alive is False else 'unknown',
'distance_km': case.found_distance_km
}
})
return {
'points': points,
'total': len(points),
'filters_applied': filters_applied
}
def _get_age_group(age: int) -> str:
"""Определяет возрастную группу"""
if age <= 3:
return '0-3'
elif age <= 7:
return '4-7'
elif age <= 11:
return '8-11'
elif age <= 14:
return '12-14'
elif age <= 17:
return '15-17'
else:
return '18+'
# Кэшированная версия для быстрого доступа
# Кэш на 1 час (3600 секунд), максимум 128 комбинаций параметров
@lru_cache(maxsize=128)
def _get_heatmap_cache_key(
map_type: str,
age_group: Optional[str],
season: Optional[str],
year_from: Optional[int],
year_to: Optional[int],
outcome: Optional[str],
timestamp_hour: int # Меняется каждый час
) -> str:
"""Генерирует ключ кэша для heatmap"""
return f"{map_type}_{age_group}_{season}_{year_from}_{year_to}_{outcome}_{timestamp_hour}"
def get_heatmap_with_cache(
db: Session,
map_type: HeatmapType = 'all',
age_group: Optional[str] = None,
season: Optional[str] = None,
year_from: Optional[int] = None,
year_to: Optional[int] = None,
outcome: Optional[str] = None
) -> Dict:
"""
Обертка с кэшированием на 1 час.
Кэш инвалидируется каждый час автоматически через timestamp_hour.
"""
# Текущий час для кэша (меняется каждый час)
current_hour = datetime.utcnow().hour
# Генерируем ключ кэша
cache_key = _get_heatmap_cache_key(
map_type,
age_group,
season,
year_from,
year_to,
outcome,
current_hour
)
# Получаем данные (кэш работает через lru_cache на уровне ключа)
return get_heatmap_data_cached(
db,
map_type,
age_group,
season,
year_from,
year_to,
outcome
)