""" 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 )