589 lines
19 KiB
Python
589 lines
19 KiB
Python
"""
|
|
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
|
|
)
|