Files
vector/backend/services/stats_service.py
T
2026-06-06 18:31:55 +00:00

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
)