B5: перенести расчёт анализа в бэкенд (единый источник правды)
AnalysisResult.jsx теперь тонкий клиент над POST /api/v1/analyze — локальные
формулы (calculateFullDistance / generateZonesWithPsychotype / getPsychotypeModifiers)
удалены. Бэкенд становится единственным источником правды.
distance_service:
- get_base_speed: перенесены каноничные displacement-скорости фронта (0.3–2.5)
вместо расходящихся значений бэкенда (1.0–5.5) — согласовано с априорами ПСО
- реализованы заглушки СКД (get_diagnosis_coefficient: эпилепсия 0.6, ЗПР 0.8,
РАС 1.0, СДВГ 1.1, здоров 1.0) и СУ (get_transport_coefficient: bike ×5,
car ×8, иное ×3, none ×1) — критичная безопасность bike×5 теперь в бэкендe
- get_time_of_day_coefficient: сумерки/вечер 0.7 (было 0.5)
analyze.py:
- _derive_profiles: профили (РАС/эпилепсия/СДВГ/ЗПР/велосипед/самокат) формируются
из diagnosis_type + has_transport, закрывая рассинхрон bike/велосипед на границе
- _derive_time_of_day: время суток из loss_time (порт getTimeOfDay фронта)
- психотип детектится ДО claude_analyze, модификаторы прокидываются в score_zone
- _as_case_data: фильтр пустых контейнеров ([],{}) — фикс затирания данных карточки
дефолтами payload (psychotype_answers={} затирал реальные ответы)
- в ответ добавлены coefficients, time_of_day, active_profiles, critical_warnings
scoring_service.score_zone: применяется множитель полосы психотипа по дистанции
(порт getPsychotypeModifiers; таблица уже есть в psychotype_service).
AnalysisResult.jsx: рендер бэкенд-ответа, адаптер primary_zones→SearchMap
(score из приоритета, reasoning←reason), локализация пола m/f→М/Ж, бейдж
fallback/ИИ, блоки активных профилей + критических предупреждений + рекомендаций
по психотипу, исправлены ключи отображения (loss_time/behavior_description/
temperature_c). Поведение сохранено (bike 8yo 2h лес = 5.4 км, как во фронте).
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
+113
-10
@@ -10,8 +10,16 @@ from pydantic import BaseModel, Field
|
|||||||
from backend.database import db
|
from backend.database import db
|
||||||
from backend.routers.auth import require_roles
|
from backend.routers.auth import require_roles
|
||||||
from services.claude_service import analyze_case as claude_analyze
|
from services.claude_service import analyze_case as claude_analyze
|
||||||
from services.distance_service import calculate_max_distance
|
from services.distance_service import (
|
||||||
from services.psychotype_service import (
|
calculate_max_distance,
|
||||||
|
get_base_speed,
|
||||||
|
get_diagnosis_coefficient,
|
||||||
|
get_terrain_coefficient,
|
||||||
|
get_time_of_day_coefficient,
|
||||||
|
get_transport_coefficient,
|
||||||
|
get_weather_coefficient,
|
||||||
|
)
|
||||||
|
from services.psychotype_service import (
|
||||||
detect_psychotype,
|
detect_psychotype,
|
||||||
get_psychotype_modifiers,
|
get_psychotype_modifiers,
|
||||||
get_search_recommendations,
|
get_search_recommendations,
|
||||||
@@ -34,6 +42,7 @@ class AnalysisRequest(BaseModel):
|
|||||||
experience: str | None = None
|
experience: str | None = None
|
||||||
season: str | None = None
|
season: str | None = None
|
||||||
diagnosis_type: list[str] = Field(default_factory=list)
|
diagnosis_type: list[str] = Field(default_factory=list)
|
||||||
|
has_transport: str | None = None
|
||||||
psychotype_answers: dict[str, Any] = Field(default_factory=dict)
|
psychotype_answers: dict[str, Any] = Field(default_factory=dict)
|
||||||
tnp_lat: float | None = None
|
tnp_lat: float | None = None
|
||||||
tnp_lon: float | None = None
|
tnp_lon: float | None = None
|
||||||
@@ -63,12 +72,74 @@ def _as_case_data(payload: AnalysisRequest) -> dict[str, Any]:
|
|||||||
'experience': payload.experience,
|
'experience': payload.experience,
|
||||||
'season': payload.season,
|
'season': payload.season,
|
||||||
'diagnosis_type': payload.diagnosis_type,
|
'diagnosis_type': payload.diagnosis_type,
|
||||||
|
'has_transport': payload.has_transport,
|
||||||
'profiles': list(payload.profiles or []),
|
'profiles': list(payload.profiles or []),
|
||||||
'psychotype_answers': payload.psychotype_answers,
|
'psychotype_answers': payload.psychotype_answers,
|
||||||
'lat': payload.lat if payload.lat is not None else payload.tnp_lat,
|
'lat': payload.lat if payload.lat is not None else payload.tnp_lat,
|
||||||
'lon': payload.lon if payload.lon is not None else payload.tnp_lon,
|
'lon': payload.lon if payload.lon is not None else payload.tnp_lon,
|
||||||
}
|
}
|
||||||
return {k: v for k, v in case_data.items() if v is not None}
|
# Отфильтровываем None и пустые контейнеры ([], {}), чтобы при merge
|
||||||
|
# {**to_detail, **case_data} значения из карточки случая не затирались
|
||||||
|
# «пустышками» из дефолтов payload (psychotype_answers={}, diagnosis_type=[]).
|
||||||
|
return {
|
||||||
|
k: v for k, v in case_data.items()
|
||||||
|
if v is not None and not (isinstance(v, (list, dict)) and len(v) == 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _derive_profiles(case_data: dict[str, Any]) -> list[str]:
|
||||||
|
"""
|
||||||
|
Формирует список поведенческих профилей из диагнозов и транспорта, если они
|
||||||
|
не переданы явно. Ключи WeightedScorer.PROFILES — русские (§8 контекста).
|
||||||
|
Это закрывает рассинхрон bike/велосипед на границе бэкенд↔фронтенд.
|
||||||
|
"""
|
||||||
|
explicit = list(case_data.get('profiles') or [])
|
||||||
|
if explicit:
|
||||||
|
return explicit
|
||||||
|
derived: list[str] = []
|
||||||
|
for diag in (case_data.get('diagnosis_type') or []):
|
||||||
|
d = str(diag).lower()
|
||||||
|
if 'рас' in d or 'аутизм' in d:
|
||||||
|
derived.append('РАС')
|
||||||
|
elif 'эпилепси' in d:
|
||||||
|
derived.append('эпилепсия')
|
||||||
|
elif 'сдвг' in d:
|
||||||
|
derived.append('СДВГ')
|
||||||
|
elif 'зпр' in d:
|
||||||
|
derived.append('ЗПР')
|
||||||
|
transport = str(case_data.get('has_transport') or '').lower()
|
||||||
|
if transport == 'bike':
|
||||||
|
derived.append('велосипед')
|
||||||
|
elif transport == 'scooter':
|
||||||
|
derived.append('самокат')
|
||||||
|
seen: set[str] = set()
|
||||||
|
out: list[str] = []
|
||||||
|
for p in derived:
|
||||||
|
if p not in seen:
|
||||||
|
seen.add(p)
|
||||||
|
out.append(p)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _derive_time_of_day(loss_time: Any) -> str:
|
||||||
|
"""
|
||||||
|
Выводит время суток из loss_time: 6-18 день, 18-22 сумерки, иначе ночь.
|
||||||
|
Порт фронтендовой getTimeOfDay. Принимает datetime или ISO-строку.
|
||||||
|
"""
|
||||||
|
if not loss_time:
|
||||||
|
return 'день'
|
||||||
|
try:
|
||||||
|
if isinstance(loss_time, str):
|
||||||
|
hour = int(loss_time.split('T')[1].split(':')[0])
|
||||||
|
else:
|
||||||
|
hour = loss_time.hour
|
||||||
|
except Exception:
|
||||||
|
return 'день'
|
||||||
|
if 6 <= hour < 18:
|
||||||
|
return 'день'
|
||||||
|
if 18 <= hour < 22:
|
||||||
|
return 'сумерки'
|
||||||
|
return 'ночь'
|
||||||
|
|
||||||
|
|
||||||
@router.post('', dependencies=[Depends(require_roles(['operator', 'field', 'admin']))])
|
@router.post('', dependencies=[Depends(require_roles(['operator', 'field', 'admin']))])
|
||||||
@@ -81,6 +152,29 @@ async def analyze_case(payload: AnalysisRequest) -> dict[str, Any]:
|
|||||||
raise HTTPException(status_code=404, detail='Case not found')
|
raise HTTPException(status_code=404, detail='Case not found')
|
||||||
case_data = {**case.to_detail(), **case_data}
|
case_data = {**case.to_detail(), **case_data}
|
||||||
|
|
||||||
|
# Время суток из loss_time (порт фронтендовой getTimeOfDay)
|
||||||
|
if not case_data.get('time_of_day'):
|
||||||
|
case_data['time_of_day'] = _derive_time_of_day(case_data.get('loss_time'))
|
||||||
|
# terrain_primary из terrain, если вдруг не дошёл
|
||||||
|
if not case_data.get('terrain_primary') and case_data.get('terrain'):
|
||||||
|
case_data['terrain_primary'] = _first_terrain(case_data['terrain'])
|
||||||
|
|
||||||
|
# Поведенческие профили из диагнозов + транспорта (если не заданы явно)
|
||||||
|
case_data['profiles'] = _derive_profiles(case_data)
|
||||||
|
|
||||||
|
# Психотип детектим ДО анализа, чтобы применить его к ранжированию зон.
|
||||||
|
# get_psychotype_modifiers возвращает таблицу полос по дистанции, которую
|
||||||
|
# WeightedScorer.score_zone читает из case['psychotype_modifiers'].
|
||||||
|
psychotype = None
|
||||||
|
psychotype_modifiers = None
|
||||||
|
psychotype_recommendations = None
|
||||||
|
if case_data.get('psychotype_answers'):
|
||||||
|
psychotype = detect_psychotype(case_data['psychotype_answers'])
|
||||||
|
psychotype_modifiers = get_psychotype_modifiers(psychotype)
|
||||||
|
psychotype_recommendations = get_search_recommendations(psychotype)
|
||||||
|
case_data['psychotype'] = psychotype
|
||||||
|
case_data['psychotype_modifiers'] = psychotype_modifiers
|
||||||
|
|
||||||
max_distance_km = calculate_max_distance(case_data)
|
max_distance_km = calculate_max_distance(case_data)
|
||||||
claude_result = await claude_analyze(case_data)
|
claude_result = await claude_analyze(case_data)
|
||||||
|
|
||||||
@@ -93,23 +187,32 @@ async def analyze_case(payload: AnalysisRequest) -> dict[str, Any]:
|
|||||||
scorer.apply_profile(list(case_data['profiles']))
|
scorer.apply_profile(list(case_data['profiles']))
|
||||||
scorer._normalize_weights()
|
scorer._normalize_weights()
|
||||||
|
|
||||||
psychotype = None
|
# Разбивка коэффициентов формулы Экстремум для отображения на фронте
|
||||||
psychotype_modifiers = None
|
elapsed = float(case_data.get('elapsed_hours') or 1)
|
||||||
psychotype_recommendations = None
|
age_val = int(case_data.get('age') or 10)
|
||||||
if case_data.get('psychotype_answers'):
|
coefficients = {
|
||||||
psychotype = detect_psychotype(case_data['psychotype_answers'])
|
'base_speed': get_base_speed(age_val),
|
||||||
psychotype_modifiers = get_psychotype_modifiers(psychotype)
|
'terrain': get_terrain_coefficient(case_data.get('terrain_primary', 'лес')),
|
||||||
psychotype_recommendations = get_search_recommendations(psychotype)
|
'diagnosis': get_diagnosis_coefficient(case_data.get('diagnosis_type')),
|
||||||
|
'urgency': get_transport_coefficient(case_data.get('has_transport')),
|
||||||
|
'fatigue': max(0.3, 1.0 - 0.05 * elapsed),
|
||||||
|
'time_of_day': get_time_of_day_coefficient(case_data.get('time_of_day', 'день')),
|
||||||
|
'weather': get_weather_coefficient(case_data.get('weather', 'нет')),
|
||||||
|
}
|
||||||
|
|
||||||
result = {
|
result = {
|
||||||
'case_id': str(payload.case_id) if payload.case_id else None,
|
'case_id': str(payload.case_id) if payload.case_id else None,
|
||||||
'analyzed_at': datetime.now(timezone.utc).isoformat(),
|
'analyzed_at': datetime.now(timezone.utc).isoformat(),
|
||||||
'max_distance_km': max_distance_km,
|
'max_distance_km': max_distance_km,
|
||||||
|
'coefficients': coefficients,
|
||||||
|
'time_of_day': case_data.get('time_of_day', 'день'),
|
||||||
'psychotype': psychotype,
|
'psychotype': psychotype,
|
||||||
'psychotype_modifiers': psychotype_modifiers,
|
'psychotype_modifiers': psychotype_modifiers,
|
||||||
'psychotype_recommendations': psychotype_recommendations,
|
'psychotype_recommendations': psychotype_recommendations,
|
||||||
'weights': scorer.weights,
|
'weights': scorer.weights,
|
||||||
'distance_multiplier': scorer.distance_multiplier,
|
'distance_multiplier': scorer.distance_multiplier,
|
||||||
|
'active_profiles': scorer.get_active_profiles_info(),
|
||||||
|
'critical_warnings': scorer.critical_warnings,
|
||||||
'urgency': claude_result.urgency,
|
'urgency': claude_result.urgency,
|
||||||
'primary_zones': [zone.model_dump() for zone in claude_result.primary_zones],
|
'primary_zones': [zone.model_dump() for zone in claude_result.primary_zones],
|
||||||
'search_radius_km': claude_result.search_radius_km,
|
'search_radius_km': claude_result.search_radius_km,
|
||||||
|
|||||||
@@ -4,6 +4,29 @@ import SearchMap from '../../components/SearchMap';
|
|||||||
import { apiFetch } from "../../api/client";
|
import { apiFetch } from "../../api/client";
|
||||||
import "./AnalysisResult.css";
|
import "./AnalysisResult.css";
|
||||||
|
|
||||||
|
const GENDER_LABEL = { m: 'М', f: 'Ж' };
|
||||||
|
|
||||||
|
// Маппинг уровня срочности бэкенда → CSS-класс (фронтовые классы в Cyrillic)
|
||||||
|
const urgencyClass = (urgency) => {
|
||||||
|
const u = (urgency || '').toLowerCase();
|
||||||
|
if (u.includes('высок')) return 'высокий';
|
||||||
|
if (u.includes('сред')) return 'средний';
|
||||||
|
return 'стандартный';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Адаптер зон бэкенда (PrimaryZone: priority/name/direction/distance/reason)
|
||||||
|
// → формат SearchMap (нужен score для радиуса и reasoning для попапа).
|
||||||
|
// score выводим из приоритета — даёт визуальный градиент (топ-зоны крупнее),
|
||||||
|
// как в прежней синтетической версии.
|
||||||
|
const adaptZones = (primaryZones) => (primaryZones || []).map(z => ({
|
||||||
|
priority: z.priority,
|
||||||
|
name: z.name,
|
||||||
|
direction: z.direction,
|
||||||
|
distance: `${z.distance} км`,
|
||||||
|
score: Math.max(10, 100 - ((z.priority || 1) - 1) * 10),
|
||||||
|
reasoning: z.reason || '',
|
||||||
|
}));
|
||||||
|
|
||||||
const AnalysisResult = () => {
|
const AnalysisResult = () => {
|
||||||
const { caseId } = useParams();
|
const { caseId } = useParams();
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -17,14 +40,19 @@ const AnalysisResult = () => {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
|
// 1. Карточка случая — для шапки и обстоятельств
|
||||||
const caseResponse = await apiFetch(`/api/v1/cases/${caseId}`);
|
const caseResponse = await apiFetch(`/api/v1/cases/${caseId}`);
|
||||||
if (!caseResponse.ok) {
|
if (!caseResponse.ok) throw new Error("Не удалось загрузить карточку");
|
||||||
throw new Error("Не удалось загрузить карточку");
|
|
||||||
}
|
|
||||||
const caseResult = await caseResponse.json();
|
const caseResult = await caseResponse.json();
|
||||||
setCaseData(caseResult);
|
setCaseData(caseResult);
|
||||||
|
|
||||||
const analysis = calculateFullDistance(caseResult);
|
// 2. Анализ — единый источник правды на бэкенде
|
||||||
|
const analyzeResponse = await apiFetch(`/api/v1/analyze`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ case_id: caseId }),
|
||||||
|
});
|
||||||
|
if (!analyzeResponse.ok) throw new Error("Не удалось запустить анализ");
|
||||||
|
const analysis = await analyzeResponse.json();
|
||||||
setAnalysisData(analysis);
|
setAnalysisData(analysis);
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -40,205 +68,12 @@ const AnalysisResult = () => {
|
|||||||
}
|
}
|
||||||
}, [caseId]);
|
}, [caseId]);
|
||||||
|
|
||||||
const getTimeOfDay = (lostTime) => {
|
|
||||||
if (!lostTime) return 'день';
|
|
||||||
// Значение может прийти как "YYYY-MM-DDTHH:MM" (input type="datetime-local")
|
|
||||||
// или как "HH:MM" — берём часы из обоих форматов.
|
|
||||||
const timePart = lostTime.includes('T') ? lostTime.split('T')[1] : lostTime;
|
|
||||||
const hour = parseInt(timePart.split(':')[0]);
|
|
||||||
if (hour >= 6 && hour < 18) return 'день';
|
|
||||||
if (hour >= 18 && hour < 22) return 'сумерки';
|
|
||||||
return 'ночь';
|
|
||||||
};
|
|
||||||
|
|
||||||
const getPsychotypeModifiers = (psychotype) => {
|
|
||||||
const modifiers = {
|
|
||||||
'dominant': {
|
|
||||||
zone_0_500: 0.7,
|
|
||||||
zone_500_1500: 1.2,
|
|
||||||
zone_1500_2500: 1.4,
|
|
||||||
zone_2500plus: 1.1,
|
|
||||||
description: 'Доминантный: активное движение, большие расстояния'
|
|
||||||
},
|
|
||||||
'harmonic': {
|
|
||||||
zone_0_500: 0.8,
|
|
||||||
zone_500_1500: 1.0,
|
|
||||||
zone_1500_2500: 1.1,
|
|
||||||
zone_2500plus: 0.9,
|
|
||||||
description: 'Гармоничный: рациональное движение по ориентирам'
|
|
||||||
},
|
|
||||||
'anxious': {
|
|
||||||
zone_0_500: 1.4,
|
|
||||||
zone_500_1500: 1.1,
|
|
||||||
zone_1500_2500: 0.5,
|
|
||||||
zone_2500plus: 0.3,
|
|
||||||
description: 'Тревожный: минимальное движение, остается близко'
|
|
||||||
},
|
|
||||||
'introvert_passive': {
|
|
||||||
zone_0_500: 1.3,
|
|
||||||
zone_500_1500: 0.9,
|
|
||||||
zone_1500_2500: 0.6,
|
|
||||||
zone_2500plus: 0.2,
|
|
||||||
description: 'Интроверт пассивный: прячется, минимальное движение'
|
|
||||||
},
|
|
||||||
'introvert_active': {
|
|
||||||
zone_0_500: 0.8,
|
|
||||||
zone_500_1500: 1.1,
|
|
||||||
zone_1500_2500: 1.2,
|
|
||||||
zone_2500plus: 0.9,
|
|
||||||
description: 'Интроверт активный: целенаправленное движение'
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return modifiers[psychotype] || modifiers['harmonic'];
|
|
||||||
};
|
|
||||||
|
|
||||||
const calculateFullDistance = (caseData) => {
|
|
||||||
const age = caseData.age_years || 10;
|
|
||||||
const elapsed = caseData.elapsed_hours || 1;
|
|
||||||
const terrain = caseData.terrain?.[0] || 'лес';
|
|
||||||
const timeOfDay = getTimeOfDay(caseData.lost_time);
|
|
||||||
const weather = caseData.precipitation || 'нет';
|
|
||||||
|
|
||||||
// НормС - Скорость смещения потерявшегося ребёнка (км/ч)
|
|
||||||
// Не скорость ходьбы, а реальное смещение: испуг, кружение, остановки
|
|
||||||
// Основа: ПСО ЭКСТРЕМУМ, 400 случаев — 94% найдены в пределах 3 км
|
|
||||||
let baseSpeed = 1.5;
|
|
||||||
if (age <= 2) baseSpeed = 0.3;
|
|
||||||
else if (age <= 5) baseSpeed = 0.7;
|
|
||||||
else if (age <= 8) baseSpeed = 1.2;
|
|
||||||
else if (age <= 12) baseSpeed = 1.5;
|
|
||||||
else if (age <= 15) baseSpeed = 2.0;
|
|
||||||
else if (age <= 17) baseSpeed = 2.5;
|
|
||||||
else if (age <= 64) baseSpeed = 2.5;
|
|
||||||
else baseSpeed = 1.5;
|
|
||||||
|
|
||||||
// СП - Коэффициент местности
|
|
||||||
let terrainCoef = 0.5;
|
|
||||||
const terrainLower = terrain.toLowerCase();
|
|
||||||
if (terrainLower.includes('дорог') || terrainLower.includes('троп')) terrainCoef = 0.8;
|
|
||||||
else if (terrainLower.includes('поле') || terrainLower.includes('луг')) terrainCoef = 0.9;
|
|
||||||
else if (terrainLower.includes('болот')) terrainCoef = 0.2;
|
|
||||||
else if (terrainLower.includes('лес')) terrainCoef = 0.5;
|
|
||||||
else if (terrainLower.includes('населённый')) terrainCoef = 1.0;
|
|
||||||
else if (terrainLower.includes('овраг')) terrainCoef = 0.3;
|
|
||||||
|
|
||||||
// СКД - Коэффициент диагноза (влияние на физическую способность двигаться)
|
|
||||||
let diagnosisCoef = 1.0;
|
|
||||||
const diagTypes = (caseData.diagnosis_type || [])
|
|
||||||
.filter(d => d && d !== 'нет' && d !== 'none')
|
|
||||||
.map(d => d.toLowerCase());
|
|
||||||
if (caseData.has_diagnosis && diagTypes.length > 0) {
|
|
||||||
if (diagTypes.some(d => d.includes('эпилепси'))) diagnosisCoef = 0.6;
|
|
||||||
else if (diagTypes.some(d => d.includes('зпр'))) diagnosisCoef = 0.8;
|
|
||||||
else if (diagTypes.some(d => d.includes('рас') || d.includes('аутизм'))) diagnosisCoef = 1.0;
|
|
||||||
else if (diagTypes.some(d => d.includes('сдвг'))) diagnosisCoef = 1.1;
|
|
||||||
else diagnosisCoef = 0.9;
|
|
||||||
}
|
|
||||||
|
|
||||||
// СУ - Коэффициент транспорта
|
|
||||||
// 'none' — это строка, проверяем явно чтобы не получить false positive
|
|
||||||
let urgencyCoef = 1.0;
|
|
||||||
if (caseData.has_transport && caseData.has_transport !== 'none') {
|
|
||||||
if (caseData.has_transport === 'bike') urgencyCoef = 5.0;
|
|
||||||
else if (caseData.has_transport === 'car') urgencyCoef = 8.0;
|
|
||||||
else urgencyCoef = 3.0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// СУТ - Коэффициент усталости
|
|
||||||
const fatigueCoef = Math.max(0.3, 1.0 - (0.05 * elapsed));
|
|
||||||
|
|
||||||
// ВВС - Коэффициент времени суток
|
|
||||||
let timeCoef = 1.0;
|
|
||||||
if (timeOfDay === 'ночь') timeCoef = 0.5;
|
|
||||||
else if (timeOfDay === 'сумерки') timeCoef = 0.7;
|
|
||||||
|
|
||||||
// ВП - Коэффициент погоды
|
|
||||||
let weatherCoef = 1.0;
|
|
||||||
const weatherLower = weather.toLowerCase();
|
|
||||||
if (weatherLower.includes('дождь')) weatherCoef = 0.8;
|
|
||||||
else if (weatherLower.includes('снег')) weatherCoef = 0.6;
|
|
||||||
else if (weatherLower.includes('туман')) weatherCoef = 0.7;
|
|
||||||
|
|
||||||
// Полная формула
|
|
||||||
const distance = elapsed * baseSpeed * terrainCoef * diagnosisCoef * urgencyCoef * fatigueCoef * timeCoef * weatherCoef;
|
|
||||||
|
|
||||||
// Получаем модификаторы психотипа
|
|
||||||
const psychotypeModifiers = caseData.psychotype ? getPsychotypeModifiers(caseData.psychotype) : null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
max_distance_km: distance.toFixed(2),
|
|
||||||
coefficients: {
|
|
||||||
base_speed: baseSpeed,
|
|
||||||
terrain: terrainCoef,
|
|
||||||
diagnosis: diagnosisCoef,
|
|
||||||
urgency: urgencyCoef,
|
|
||||||
fatigue: fatigueCoef,
|
|
||||||
time_of_day: timeCoef,
|
|
||||||
weather: weatherCoef
|
|
||||||
},
|
|
||||||
time_of_day: timeOfDay,
|
|
||||||
psychotype_modifiers: psychotypeModifiers,
|
|
||||||
zones: generateZonesWithPsychotype(distance, caseData, psychotypeModifiers)
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const generateZonesWithPsychotype = (maxDist, caseData, psychotypeModifiers) => {
|
|
||||||
const directions = [
|
|
||||||
{ dir: 'N', name: 'Север' },
|
|
||||||
{ dir: 'NE', name: 'Северо-Восток' },
|
|
||||||
{ dir: 'E', name: 'Восток' },
|
|
||||||
{ dir: 'SE', name: 'Юго-Восток' },
|
|
||||||
{ dir: 'S', name: 'Юг' },
|
|
||||||
{ dir: 'SW', name: 'Юго-Запад' },
|
|
||||||
{ dir: 'W', name: 'Запад' },
|
|
||||||
{ dir: 'NW', name: 'Северо-Запад' }
|
|
||||||
];
|
|
||||||
|
|
||||||
const preferredDir = caseData.direction;
|
|
||||||
|
|
||||||
return directions.map((d, i) => {
|
|
||||||
let score = 100 - (i * 10);
|
|
||||||
|
|
||||||
// Применяем модификатор направления
|
|
||||||
if (preferredDir && d.dir === preferredDir) {
|
|
||||||
score += 30;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Применяем модификатор психотипа в зависимости от дистанции
|
|
||||||
if (psychotypeModifiers) {
|
|
||||||
const distKm = maxDist * 0.75; // средняя дистанция сектора
|
|
||||||
let psychoMod = 1.0;
|
|
||||||
|
|
||||||
if (distKm < 0.5) psychoMod = psychotypeModifiers.zone_0_500;
|
|
||||||
else if (distKm < 1.5) psychoMod = psychotypeModifiers.zone_500_1500;
|
|
||||||
else if (distKm < 2.5) psychoMod = psychotypeModifiers.zone_1500_2500;
|
|
||||||
else psychoMod = psychotypeModifiers.zone_2500plus;
|
|
||||||
|
|
||||||
score = score * psychoMod;
|
|
||||||
}
|
|
||||||
|
|
||||||
let reasoning = 'Расчет по алгоритму Экстремум';
|
|
||||||
if (preferredDir === d.dir) reasoning += ' (указанное направление)';
|
|
||||||
if (psychotypeModifiers) reasoning += ` (психотип учтен)`;
|
|
||||||
|
|
||||||
return {
|
|
||||||
priority: i + 1,
|
|
||||||
name: `Сектор ${d.name}`,
|
|
||||||
direction: d.dir,
|
|
||||||
distance: `${(maxDist * 0.5).toFixed(1)}-${maxDist.toFixed(1)} км`,
|
|
||||||
score: Math.min(100, Math.round(score)),
|
|
||||||
reasoning
|
|
||||||
};
|
|
||||||
}).sort((a, b) => b.score - a.score);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="analysis-result">
|
<div className="analysis-result">
|
||||||
<div className="loading-state">
|
<div className="loading-state">
|
||||||
<div className="spinner"></div>
|
<div className="spinner"></div>
|
||||||
<p>Загрузка данных и расчет зон поиска...</p>
|
<p>Загрузка данных и расчёт зон поиска...</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -267,84 +102,127 @@ const AnalysisResult = () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const urgencyLevel = caseData.elapsed_hours > 6 ? "ВЫСОКИЙ" : caseData.elapsed_hours > 3 ? "СРЕДНИЙ" : "СТАНДАРТНЫЙ";
|
const zones = adaptZones(analysisData?.primary_zones);
|
||||||
const urgencyReason = `Прошло ${caseData.elapsed_hours || 1} часов. ${caseData.has_transport ? `Транспорт: ${caseData.has_transport} - расширенный радиус.` : ''} ${caseData.has_diagnosis ? 'Есть диагноз - требуется особое внимание.' : ''}`;
|
|
||||||
|
|
||||||
const zones = analysisData?.zones || [];
|
|
||||||
const coeffs = analysisData?.coefficients || {};
|
const coeffs = analysisData?.coefficients || {};
|
||||||
|
const psyMods = analysisData?.psychotype_modifiers;
|
||||||
|
const psyRec = analysisData?.psychotype_recommendations;
|
||||||
|
const genderLabel = GENDER_LABEL[caseData.gender] || caseData.gender || '';
|
||||||
|
const urgencyLevel = urgencyClass(analysisData?.urgency);
|
||||||
|
const profiles = analysisData?.active_profiles || [];
|
||||||
|
const warnings = analysisData?.critical_warnings || [];
|
||||||
|
const tnpLat = caseData.tnp_lat || caseData.gps_lat;
|
||||||
|
const tnpLon = caseData.tnp_lon || caseData.gps_lon;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="analysis-result">
|
<div className="analysis-result">
|
||||||
<div className="result-header">
|
<div className="result-header">
|
||||||
<h1><span className="logo">ВЕКТОР</span> Результаты анализа</h1>
|
<h1><span className="logo">ВЕКТОР</span> Результаты анализа</h1>
|
||||||
<div className="case-info">
|
<div className="case-info">
|
||||||
<span>{caseData.child_name || 'Ребенок'}, {caseData.age_years} лет ({caseData.gender})</span>
|
<span>{caseData.child_name || 'Ребёнок'}, {caseData.age_years} лет{genderLabel ? ` (${genderLabel})` : ''}</span>
|
||||||
<span className="separator">•</span>
|
<span className="separator">•</span>
|
||||||
<span>Время потери: {caseData.lost_time || 'не указано'}</span>
|
<span>Время потери: {caseData.loss_time || 'не указано'}</span>
|
||||||
<span className="separator">•</span>
|
<span className="separator">•</span>
|
||||||
<span>Прошло: {caseData.elapsed_hours || 1} ч</span>
|
<span>Прошло: {caseData.elapsed_hours || 1} ч</span>
|
||||||
|
{analysisData && (
|
||||||
|
<>
|
||||||
|
<span className="separator">•</span>
|
||||||
|
<span style={{ color: analysisData.fallback_used ? '#8b949e' : '#34c759' }}>
|
||||||
|
{analysisData.fallback_used ? 'Локальный расчёт' : 'ИИ-анализ'}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="result-content">
|
<div className="result-content">
|
||||||
{/* Блок 1: Уровень срочности */}
|
{/* Блок 1: Уровень срочности */}
|
||||||
<div className={`urgency-block urgency-${urgencyLevel.toLowerCase()}`}>
|
{analysisData?.urgency && (
|
||||||
<div className="urgency-level">{urgencyLevel}</div>
|
<div className={`urgency-block urgency-${urgencyLevel}`}>
|
||||||
<div className="urgency-reason">{urgencyReason}</div>
|
<div className="urgency-level">{analysisData.urgency}</div>
|
||||||
</div>
|
<div className="urgency-reason">{analysisData.summary || ''}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Блок 2: Расчет по алгоритму Экстремум */}
|
{/* Блок 2: Расчёт по алгоритму Экстремум */}
|
||||||
<div className="info-card">
|
<div className="info-card">
|
||||||
<h3>📏 Расчет по алгоритму Экстремум</h3>
|
<h3>📏 Расчёт по алгоритму Экстремум</h3>
|
||||||
<div className="distance-info">
|
<div className="distance-info">
|
||||||
<div className="distance-value">{analysisData?.max_distance_km} км</div>
|
<div className="distance-value">{analysisData?.max_distance_km} км</div>
|
||||||
<div className="distance-details">
|
<div className="distance-details">
|
||||||
<p><strong>Формула:</strong> Distance = Time × НормС × СП × СКД × СУ × СУТ × ВВС × ВП</p>
|
<p><strong>Формула:</strong> Distance = Time × НормС × СП × СКД × СУ × СУТ × ВВС × ВП</p>
|
||||||
<hr style={{margin: '8px 0', border: 'none', borderTop: '1px solid #30363d'}} />
|
<hr style={{ margin: '8px 0', border: 'none', borderTop: '1px solid #30363d' }} />
|
||||||
<p><strong>Время (Time):</strong> {caseData.elapsed_hours || 1} ч</p>
|
<p><strong>Время (Time):</strong> {caseData.elapsed_hours || 1} ч</p>
|
||||||
<p><strong>Базовая скорость (НормС):</strong> {coeffs.base_speed} км/ч (возраст {caseData.age_years} лет)</p>
|
<p><strong>Базовая скорость (НормС):</strong> {coeffs.base_speed} км/ч (возраст {caseData.age_years} лет)</p>
|
||||||
<p><strong>Местность (СП):</strong> {coeffs.terrain} ({caseData.terrain?.join(', ') || 'не указана'})</p>
|
<p><strong>Местность (СП):</strong> {coeffs.terrain} ({caseData.terrain?.join(', ') || 'не указана'})</p>
|
||||||
<p><strong>Диагноз (СКД):</strong> {coeffs.diagnosis} {caseData.has_diagnosis ? `(${caseData.diagnosis_type?.join(', ')})` : '(нет)'}</p>
|
<p><strong>Диагноз (СКД):</strong> {coeffs.diagnosis} {caseData.has_diagnosis ? `(${caseData.diagnosis_type?.join(', ')})` : '(нет)'}</p>
|
||||||
<p><strong>Транспорт (СУ):</strong> {coeffs.urgency} {caseData.has_transport ? `(${caseData.has_transport})` : '(нет)'}</p>
|
<p><strong>Транспорт (СУ):</strong> {coeffs.urgency} {caseData.has_transport && caseData.has_transport !== 'none' ? `(${caseData.has_transport})` : '(нет)'}</p>
|
||||||
<p><strong>Усталость (СУТ):</strong> {coeffs.fatigue?.toFixed(2)} (снижение 5%/час)</p>
|
<p><strong>Усталость (СУТ):</strong> {coeffs.fatigue?.toFixed(2)} (снижение 5%/час)</p>
|
||||||
<p><strong>Время суток (ВВС):</strong> {coeffs.time_of_day} ({analysisData?.time_of_day})</p>
|
<p><strong>Время суток (ВВС):</strong> {coeffs.time_of_day} ({analysisData?.time_of_day})</p>
|
||||||
<p><strong>Погода (ВП):</strong> {coeffs.weather} ({caseData.precipitation || 'нет'})</p>
|
<p><strong>Погода (ВП):</strong> {coeffs.weather} ({caseData.weather || caseData.precipitation || 'нет'})</p>
|
||||||
|
<hr style={{ margin: '8px 0', border: 'none', borderTop: '1px solid #30363d' }} />
|
||||||
|
<p><strong>Радиус поиска:</strong> {analysisData?.search_radius_km} км × {analysisData?.distance_multiplier} (профили)</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Блок 3: Психотип */}
|
{/* Блок 3: Активные профили и критические предупреждения */}
|
||||||
{caseData.psychotype && analysisData?.psychotype_modifiers && (
|
{(profiles.length > 0 || warnings.length > 0) && (
|
||||||
<div className="info-card">
|
<div className="info-card">
|
||||||
<h3>🧠 Психотип и модель поведения</h3>
|
<h3>⚠️ Активные профили</h3>
|
||||||
<p><strong>Психотип:</strong> {caseData.psychotype}</p>
|
{profiles.map((p, i) => (
|
||||||
<p><strong>Модель движения:</strong> {analysisData.psychotype_modifiers.description}</p>
|
<div key={i} style={{ marginBottom: '10px', padding: '10px', background: '#161b22', borderRadius: '6px' }}>
|
||||||
<div style={{marginTop: '12px', padding: '12px', background: '#161b22', borderRadius: '6px'}}>
|
<p style={{ margin: '0 0 4px 0' }}><strong>{p.name}</strong></p>
|
||||||
<p style={{fontSize: '14px', color: '#8b949e', margin: 0}}>
|
{p.note && <p style={{ fontSize: '13px', color: '#8b949e', margin: '2px 0' }}>{p.note}</p>}
|
||||||
<strong>Модификаторы зон:</strong>
|
{p.critical_warning && (
|
||||||
</p>
|
<p style={{ fontSize: '13px', color: '#ff4d00', margin: '4px 0 0 0' }}>🚨 {p.critical_warning}</p>
|
||||||
<p style={{fontSize: '13px', color: '#8b949e', margin: '4px 0 0 0'}}>
|
)}
|
||||||
• 0-500м: ×{analysisData.psychotype_modifiers.zone_0_500}<br/>
|
</div>
|
||||||
• 500-1500м: ×{analysisData.psychotype_modifiers.zone_500_1500}<br/>
|
))}
|
||||||
• 1500-2500м: ×{analysisData.psychotype_modifiers.zone_1500_2500}<br/>
|
|
||||||
• 2500м+: ×{analysisData.psychotype_modifiers.zone_2500plus}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Блок 4: Обстоятельства */}
|
{/* Блок 4: Психотип и модель поведения */}
|
||||||
|
{analysisData?.psychotype && psyMods && (
|
||||||
|
<div className="info-card">
|
||||||
|
<h3>🧠 Психотип и модель поведения</h3>
|
||||||
|
<p><strong>Психотип:</strong> {analysisData.psychotype}</p>
|
||||||
|
<p><strong>Модель движения:</strong> {psyMods.description}</p>
|
||||||
|
<div style={{ marginTop: '12px', padding: '12px', background: '#161b22', borderRadius: '6px' }}>
|
||||||
|
<p style={{ fontSize: '14px', color: '#8b949e', margin: 0 }}><strong>Модификаторы зон:</strong></p>
|
||||||
|
<p style={{ fontSize: '13px', color: '#8b949e', margin: '4px 0 0 0' }}>
|
||||||
|
• 0-500м: ×{psyMods.zone_0_500}<br />
|
||||||
|
• 500-1500м: ×{psyMods.zone_500_1500}<br />
|
||||||
|
• 1500-2500м: ×{psyMods.zone_1500_2500}<br />
|
||||||
|
• 2500м+: ×{psyMods.zone_2500plus}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{psyRec && (
|
||||||
|
<div style={{ marginTop: '10px' }}>
|
||||||
|
<p style={{ fontSize: '14px', color: '#8b949e', margin: '0 0 4px 0' }}><strong>Тактика поиска:</strong></p>
|
||||||
|
<p style={{ fontSize: '13px', margin: '2px 0' }}><strong>Приоритетные зоны:</strong> {psyRec.priority_zones}</p>
|
||||||
|
<p style={{ fontSize: '13px', margin: '2px 0' }}><strong>Патрун:</strong> {psyRec.search_pattern}</p>
|
||||||
|
<p style={{ fontSize: '13px', margin: '2px 0' }}><strong>Ключевые объекты:</strong> {psyRec.key_locations}</p>
|
||||||
|
<p style={{ fontSize: '13px', margin: '2px 0' }}><strong>Связь:</strong> {psyRec.communication}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Блок 5: Обстоятельства потери */}
|
||||||
<div className="info-card">
|
<div className="info-card">
|
||||||
<h3>📋 Обстоятельства потери</h3>
|
<h3>📋 Обстоятельства потери</h3>
|
||||||
<p><strong>Причина:</strong> {caseData.reason || 'не указана'}</p>
|
<p><strong>Причина:</strong> {caseData.reason || caseData.loss_reason || 'не указана'}</p>
|
||||||
<p><strong>Последнее известное место:</strong> {caseData.last_known_place || 'не указано'}</p>
|
<p><strong>Последнее известное место:</strong> {caseData.last_known_place || 'не указано'}</p>
|
||||||
{caseData.direction && <p><strong>Направление движения:</strong> {caseData.direction}</p>}
|
{caseData.direction && <p><strong>Направление движения:</strong> {caseData.direction}</p>}
|
||||||
{caseData.circumstances_description && (
|
{caseData.behavior_description && (
|
||||||
<p><strong>Описание:</strong> {caseData.circumstances_description}</p>
|
<p><strong>Описание:</strong> {caseData.behavior_description}</p>
|
||||||
|
)}
|
||||||
|
{analysisData?.behavioral_prediction && (
|
||||||
|
<p><strong>Поведенческий прогноз:</strong> {analysisData.behavioral_prediction}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Блок 5: Зоны поиска */}
|
{/* Блок 6: Зоны поиска */}
|
||||||
<div className="zones-section">
|
<div className="zones-section">
|
||||||
<h2>Приоритетные зоны поиска</h2>
|
<h2>Приоритетные зоны поиска</h2>
|
||||||
<div className="zones-list">
|
<div className="zones-list">
|
||||||
@@ -368,24 +246,36 @@ const AnalysisResult = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Блок 6: Карта */}
|
{/* Блок 7: Немедленные действия */}
|
||||||
{caseData.tnp_lat && caseData.tnp_lon && (
|
{analysisData?.immediate_actions?.length > 0 && (
|
||||||
|
<div className="info-card">
|
||||||
|
<h3>🚑 Немедленные действия</h3>
|
||||||
|
<ul style={{ margin: 0, paddingLeft: '20px' }}>
|
||||||
|
{analysisData.immediate_actions.map((a, i) => (
|
||||||
|
<li key={i} style={{ marginBottom: '4px' }}>{a}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Блок 8: Карта */}
|
||||||
|
{tnpLat && tnpLon && (
|
||||||
<div className="map-section">
|
<div className="map-section">
|
||||||
<h2>Карта зон поиска</h2>
|
<h2>Карта зон поиска</h2>
|
||||||
<SearchMap zones={zones}
|
<SearchMap zones={zones}
|
||||||
maxDistance={parseFloat(analysisData?.max_distance_km)}
|
maxDistance={parseFloat(analysisData?.max_distance_km)}
|
||||||
tnpLat={parseFloat(caseData.tnp_lat)}
|
tnpLat={parseFloat(tnpLat)}
|
||||||
tnpLon={parseFloat(caseData.tnp_lon)}
|
tnpLon={parseFloat(tnpLon)}
|
||||||
/>
|
/>
|
||||||
<div className="map-info" style={{ marginTop: "12px", fontSize: "14px", color: "#8b949e" }}>
|
<div className="map-info" style={{ marginTop: "12px", fontSize: "14px", color: "#8b949e" }}>
|
||||||
<p>Координаты ТНП: {caseData.tnp_lat}, {caseData.tnp_lon}</p>
|
<p>Координаты ТНП: {tnpLat}, {tnpLon}</p>
|
||||||
{caseData.tnp_address && <p>Адрес: {caseData.tnp_address}</p>}
|
{caseData.tnp_address && <p>Адрес: {caseData.tnp_address}</p>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Блок 7: Здоровье и особенности */}
|
{/* Блок 9: Здоровье и особенности */}
|
||||||
{(caseData.has_diagnosis || caseData.cant_swim || caseData.fitness_level) && (
|
{(caseData.has_diagnosis || caseData.cant_swim || caseData.fitness_level || caseData.has_transport) && (
|
||||||
<div className="info-card">
|
<div className="info-card">
|
||||||
<h3>🏥 Здоровье и особенности</h3>
|
<h3>🏥 Здоровье и особенности</h3>
|
||||||
{caseData.has_diagnosis && (
|
{caseData.has_diagnosis && (
|
||||||
@@ -395,18 +285,18 @@ const AnalysisResult = () => {
|
|||||||
<p><strong>Физическая подготовка:</strong> {caseData.fitness_level}</p>
|
<p><strong>Физическая подготовка:</strong> {caseData.fitness_level}</p>
|
||||||
)}
|
)}
|
||||||
{caseData.cant_swim && <p>⚠️ <strong>Не умеет плавать</strong></p>}
|
{caseData.cant_swim && <p>⚠️ <strong>Не умеет плавать</strong></p>}
|
||||||
{caseData.has_transport && (
|
{caseData.has_transport && caseData.has_transport !== 'none' && (
|
||||||
<p><strong>Транспорт:</strong> {caseData.has_transport}</p>
|
<p><strong>Транспорт:</strong> {caseData.has_transport}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Блок 8: Условия среды */}
|
{/* Блок 10: Условия среды */}
|
||||||
<div className="info-card">
|
<div className="info-card">
|
||||||
<h3>🌤 Условия среды</h3>
|
<h3>🌤 Условия среды</h3>
|
||||||
<p><strong>Сезон:</strong> {caseData.season || 'не указан'}</p>
|
<p><strong>Сезон:</strong> {caseData.season || 'не указан'}</p>
|
||||||
{caseData.temperature && <p><strong>Температура:</strong> {caseData.temperature}°C</p>}
|
{caseData.temperature_c != null && <p><strong>Температура:</strong> {caseData.temperature_c}°C</p>}
|
||||||
<p><strong>Осадки:</strong> {caseData.precipitation || 'нет'}</p>
|
<p><strong>Осадки:</strong> {caseData.weather || caseData.precipitation || 'нет'}</p>
|
||||||
{caseData.visibility && <p><strong>Видимость:</strong> {caseData.visibility}</p>}
|
{caseData.visibility && <p><strong>Видимость:</strong> {caseData.visibility}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -48,11 +48,11 @@ def calculate_max_distance(case_data: dict) -> float:
|
|||||||
# СП - Terrain coefficient
|
# СП - Terrain coefficient
|
||||||
terrain_coef = get_terrain_coefficient(terrain)
|
terrain_coef = get_terrain_coefficient(terrain)
|
||||||
|
|
||||||
# СКД - Diagnosis coefficient (placeholder, can be expanded)
|
# СКД - Коэффициент диагноза (влияние на способность двигаться)
|
||||||
diagnosis_coef = 1.0
|
diagnosis_coef = get_diagnosis_coefficient(case_data.get('diagnosis_type'))
|
||||||
|
|
||||||
# СУ - Urgency coefficient (placeholder, can be expanded)
|
# СУ - Коэффициент транспорта (велосипед/машина расширяют зону)
|
||||||
urgency_coef = 1.0
|
urgency_coef = get_transport_coefficient(case_data.get('has_transport'))
|
||||||
|
|
||||||
# СУТ - Fatigue coefficient (5% reduction per hour)
|
# СУТ - Fatigue coefficient (5% reduction per hour)
|
||||||
fatigue_coef = max(0.3, 1.0 - (0.05 * elapsed_hours))
|
fatigue_coef = max(0.3, 1.0 - (0.05 * elapsed_hours))
|
||||||
@@ -90,22 +90,26 @@ def get_base_speed(age: int) -> float:
|
|||||||
"""
|
"""
|
||||||
# Скорость смещения потерявшегося ребёнка (не скорость ходьбы)
|
# Скорость смещения потерявшегося ребёнка (не скорость ходьбы)
|
||||||
# ПСО ЭКСТРЕМУМ: 94% найдены в пределах 3 км
|
# ПСО ЭКСТРЕМУМ: 94% найдены в пределах 3 км
|
||||||
|
# Скорость смещения потерявшегося ребёнка (не скорость ходьбы).
|
||||||
|
# Реальное смещение: испуг, кружение, остановки. Основа — ПСО ЭКСТРЕМУМ
|
||||||
|
# (400 случаев, 94% найдены в пределах 3 км). Значения перенесены из
|
||||||
|
# фронтендового расчёта как каноничные (согласовано с методологией).
|
||||||
if age <= 2:
|
if age <= 2:
|
||||||
return 1.0
|
return 0.3
|
||||||
elif age <= 5:
|
elif age <= 5:
|
||||||
return 2.0
|
return 0.7
|
||||||
elif age <= 8:
|
elif age <= 8:
|
||||||
return 3.0
|
return 1.2
|
||||||
elif age <= 12:
|
elif age <= 12:
|
||||||
return 4.0
|
return 1.5
|
||||||
elif age <= 15:
|
elif age <= 15:
|
||||||
return 5.0
|
return 2.0
|
||||||
elif age <= 17:
|
elif age <= 17:
|
||||||
return 5.5
|
return 2.5
|
||||||
elif age <= 64:
|
elif age <= 64:
|
||||||
return 5.0
|
return 2.5
|
||||||
else: # 65+
|
else: # 65+
|
||||||
return 3.0
|
return 1.5
|
||||||
|
|
||||||
|
|
||||||
def get_terrain_coefficient(terrain: str) -> float:
|
def get_terrain_coefficient(terrain: str) -> float:
|
||||||
@@ -147,6 +151,56 @@ def get_terrain_coefficient(terrain: str) -> float:
|
|||||||
return 0.5
|
return 0.5
|
||||||
|
|
||||||
|
|
||||||
|
def get_diagnosis_coefficient(diagnosis_types) -> float:
|
||||||
|
"""
|
||||||
|
Коэффициент диагноза (СКД) — влияние на физическую способность двигаться.
|
||||||
|
Порт фронтендовой логики (§6 контекста ВЕКТОР).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
diagnosis_types: список диагнозов (diagnosis_type) или строка/None
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Коэффициент: эпилепсия 0.6, ЗПР 0.8, РАС 1.0, СДВГ 1.1, иной 0.9, нет 1.0
|
||||||
|
"""
|
||||||
|
if not diagnosis_types:
|
||||||
|
return 1.0
|
||||||
|
if isinstance(diagnosis_types, str):
|
||||||
|
diagnosis_types = [diagnosis_types]
|
||||||
|
diags = [str(d).lower() for d in diagnosis_types if d and str(d).lower() not in ('нет', 'none', 'здоров', 'здоровый')]
|
||||||
|
if not diags:
|
||||||
|
return 1.0
|
||||||
|
if any('эпилепси' in d for d in diags):
|
||||||
|
return 0.6
|
||||||
|
if any('зпр' in d for d in diags):
|
||||||
|
return 0.8
|
||||||
|
if any(('рас' in d) or ('аутизм' in d) for d in diags):
|
||||||
|
return 1.0
|
||||||
|
if any('сдвг' in d for d in diags):
|
||||||
|
return 1.1
|
||||||
|
return 0.9
|
||||||
|
|
||||||
|
|
||||||
|
def get_transport_coefficient(has_transport) -> float:
|
||||||
|
"""
|
||||||
|
Коэффициент транспорта (СУ) — расширяет зону поиска.
|
||||||
|
Порт фронтендовой логики. bike ×5 — критично: «расширить зону до 10-15 км».
|
||||||
|
|
||||||
|
Args:
|
||||||
|
has_transport: 'bike' | 'scooter' | 'car' | 'other' | 'none' | None
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Коэффициент: bike 5.0, car 8.0, иное 3.0, none/пусто 1.0
|
||||||
|
"""
|
||||||
|
if not has_transport or str(has_transport).lower() == 'none':
|
||||||
|
return 1.0
|
||||||
|
t = str(has_transport).lower()
|
||||||
|
if t == 'bike':
|
||||||
|
return 5.0
|
||||||
|
if t == 'car':
|
||||||
|
return 8.0
|
||||||
|
return 3.0
|
||||||
|
|
||||||
|
|
||||||
def get_time_of_day_coefficient(time_of_day: str) -> float:
|
def get_time_of_day_coefficient(time_of_day: str) -> float:
|
||||||
"""
|
"""
|
||||||
Get time of day movement coefficient (ВВС).
|
Get time of day movement coefficient (ВВС).
|
||||||
@@ -164,7 +218,7 @@ def get_time_of_day_coefficient(time_of_day: str) -> float:
|
|||||||
if 'ночь' in time_lower:
|
if 'ночь' in time_lower:
|
||||||
return 0.5
|
return 0.5
|
||||||
elif 'сумерки' in time_lower or 'вечер' in time_lower:
|
elif 'сумерки' in time_lower or 'вечер' in time_lower:
|
||||||
return 0.5
|
return 0.7
|
||||||
else: # день
|
else: # день
|
||||||
return 1.0
|
return 1.0
|
||||||
|
|
||||||
|
|||||||
@@ -322,6 +322,23 @@ class WeightedScorer:
|
|||||||
distance_factor = max(0.3, min(1.0, distance_factor))
|
distance_factor = max(0.3, min(1.0, distance_factor))
|
||||||
|
|
||||||
score *= distance_factor
|
score *= distance_factor
|
||||||
|
|
||||||
|
# Психотип: множитель полосы дистанции (§6 контекста) — порт
|
||||||
|
# фронтендовой getPsychotypeModifiers. Таблицу формирует бэкенд
|
||||||
|
# (psychotype_service.get_psychotype_modifiers) и прокидывает через
|
||||||
|
# case['psychotype_modifiers']. Без психотипа — множитель 1.0.
|
||||||
|
mods = case.get('psychotype_modifiers')
|
||||||
|
if mods:
|
||||||
|
zdist = zone.get('distance_km', 1.0)
|
||||||
|
if zdist < 0.5:
|
||||||
|
band = mods.get('zone_0_500', 1.0)
|
||||||
|
elif zdist < 1.5:
|
||||||
|
band = mods.get('zone_500_1500', 1.0)
|
||||||
|
elif zdist < 2.5:
|
||||||
|
band = mods.get('zone_1500_2500', 1.0)
|
||||||
|
else:
|
||||||
|
band = mods.get('zone_2500plus', 1.0)
|
||||||
|
score *= band
|
||||||
|
|
||||||
# Конвертируем в шкалу 0-100
|
# Конвертируем в шкалу 0-100
|
||||||
return round(score * 100, 2)
|
return round(score * 100, 2)
|
||||||
|
|||||||
Reference in New Issue
Block a user