feat: ж/д слой + cant_swim в скоринг + профили вне модели
Определение специфических рекомендаций (матрица профилей §8):
1. Ж/д слой (закрыт мёртвый railway ×2.5 у РАС):
- /api/v1/water/{case_id} отдаёт railway=rail как LineString
(без service/industrial/military веток), кэш общий v2;
- railway_warning «перекрыть/проверить немедленно» по профилям;
- SearchMap: Polyline слой ж/д (тёмно-красный), счётчики 💧/🚂.
2. cant_swim → профиль не_умеет_плавать (water ×3.0, без изменения
радиуса, critical_warning «обследовать водоёмы НЕМЕДЛЕННО»):
- раньше чекбокс влиял только на текст, в скоринге был пробел;
- derive в analyze._derive_profiles — работает и для closed_cases.
3. unmodeled_profiles: ДЦП/слабое зрение/слух — честная пометка
«вне поведенческой модели» с пояснением (vector_tasks B12:
профили без аналога не выдавать за учтённые); блок на фронте
в карточке здоровья.
Площадь воды: сферический эксцесс, проверен на квадрате 53° (744017 м²
vs 743272 точного). Тесты: 202 passed (новый test_cant_swim_profile).
This commit is contained in:
@@ -87,6 +87,26 @@ def _as_case_data(payload: AnalysisRequest) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
# Диагнозы, которые форма собирает, но поведенческая модель не покрывает
|
||||
# (vector_tasks.md B12: профили без аналога/данных — честно помечать «вне модели»).
|
||||
UNMODELED_DIAGNOSES: dict[str, str] = {
|
||||
'ДЦП': 'Двигательные нарушения — модель не учитывает; учесть вручную при планировании (доступность местности, темп).',
|
||||
'слабое_зрение': 'Слабое зрение — модель не учитывает; вероятность следования по открытым/звуковым ориентирам выше.',
|
||||
'слабый_слух': 'Слабый слух — модель не учитывает; голосовой поиск менее эффективен, приоритет визуальным сигналам.',
|
||||
}
|
||||
|
||||
|
||||
def _unmodeled_profiles(case_data: dict[str, Any]) -> list[dict[str, str]]:
|
||||
"""Диагнозы вне поведенческой модели (не влияют на скоринг) + пояснение."""
|
||||
out: list[dict[str, str]] = []
|
||||
for diag in (case_data.get('diagnosis_type') or []):
|
||||
d = str(diag).lower()
|
||||
for key, note in UNMODELED_DIAGNOSES.items():
|
||||
if d == key.lower() and not any(o['profile'] == key for o in out):
|
||||
out.append({'profile': key, 'note': note})
|
||||
return out
|
||||
|
||||
|
||||
def _derive_profiles(case_data: dict[str, Any]) -> list[str]:
|
||||
"""
|
||||
Формирует список поведенческих профилей из диагнозов и транспорта, если они
|
||||
@@ -112,6 +132,8 @@ def _derive_profiles(case_data: dict[str, Any]) -> list[str]:
|
||||
derived.append('велосипед')
|
||||
elif transport == 'scooter':
|
||||
derived.append('самокат')
|
||||
if case_data.get('cant_swim'):
|
||||
derived.append('не_умеет_плавать')
|
||||
seen: set[str] = set()
|
||||
out: list[str] = []
|
||||
for p in derived:
|
||||
@@ -213,6 +235,7 @@ async def analyze_case(payload: AnalysisRequest) -> dict[str, Any]:
|
||||
'distance_multiplier': scorer.distance_multiplier,
|
||||
'active_profiles': scorer.get_active_profiles_info(),
|
||||
'critical_warnings': scorer.critical_warnings,
|
||||
'unmodeled_profiles': _unmodeled_profiles(case_data),
|
||||
'urgency': claude_result.urgency,
|
||||
'primary_zones': [zone.model_dump() for zone in claude_result.primary_zones],
|
||||
'search_radius_km': claude_result.search_radius_km,
|
||||
|
||||
+128
-79
@@ -1,14 +1,18 @@
|
||||
"""Водоёмы в зоне поиска — геослой по рекомендациям анализа.
|
||||
"""Геослой по рекомендациям анализа: водоёмы + ж/д в зоне поиска.
|
||||
|
||||
Рекомендации профилей (scoring_service.BEHAVIORAL_PROFILES) говорят
|
||||
«водоёмы — критичный тип локаций»: РАС ×3.0 + «перекрыть ВСЕ водоёмы и ж/д»,
|
||||
эпилепсия ×3.5, дети 0–7 лет ×2.2–2.5. Этот эндпоинт отдаёт полигоны водоёмов
|
||||
(OSM/Overpass) вокруг ТНП случая, чтобы подсветить их на карте зон поиска
|
||||
(AnalysisResult → SearchMap), а не только называть их текстом.
|
||||
Рекомендации профилей (scoring_service.BEHAVIORAL_PROFILES) указывают
|
||||
критичные типы локаций, но коэффициенты railway/shelter долгое время были
|
||||
мёртвыми (railway_distance_km никто не вычислял — вклад в score_zone был
|
||||
всегда 0). Этот эндпоинт отдаёт реальные объекты OSM вокруг ТНП случая,
|
||||
чтобы подсветить их на карте зон поиска (SearchMap):
|
||||
|
||||
- водоёмы/болота: РАС water ×3.0, эпилепсия ×3.5, возраст 0–7 ×2.2–2.5,
|
||||
критическое предупреждение «перекрыть ВСЕ водоёмы и ж/д»;
|
||||
- ж/д пути: РАС railway ×2.5 — «немедленно перекрыть ВСЕ ж/д пути».
|
||||
|
||||
Данные: OpenStreetMap, лицензия ODbL — attribution обязателен на карте.
|
||||
Кэш дисковый как у geo_service; circuit breaker переиспользуем оттуда же,
|
||||
чтобы анализ и водоёмы отключались синхронно при отсутствии сети.
|
||||
Кэш дисковый; circuit breaker переиспользуем от geo_service, чтобы анализ
|
||||
и геослои отключались синхронно при отсутствии сети.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -16,7 +20,6 @@ import hashlib
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
@@ -31,14 +34,10 @@ logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix='/api/v1/water', tags=['water'],
|
||||
dependencies=[Depends(require_roles(['operator', 'field', 'admin']))])
|
||||
|
||||
# Кэш водоёмов — свой TTL (медленнее устаревает, чем дорожные данные):
|
||||
# гидрография OSM стабильна в масштабе дней.
|
||||
# Кэш — свой TTL (гидрография/ж/д OSM стабильны в масштабе дней).
|
||||
_CACHE_DIR = '/tmp/overpass_cache/water'
|
||||
_CACHE_TTL_HOURS = 72
|
||||
|
||||
# Overpass-теги водных объектов, релевантных для поиска пропавшего.
|
||||
# natural=water покрывает озера/водохранилища/пруды/реки (полигоны),
|
||||
# wetland — болота (застревание, сложная проходимость).
|
||||
_WATER_QUERY_TEMPLATE = """
|
||||
[out:json][timeout:25];
|
||||
(
|
||||
@@ -50,23 +49,27 @@ _WATER_QUERY_TEMPLATE = """
|
||||
out geom meta;
|
||||
"""
|
||||
|
||||
# Порядок = приоритет отрисовки (последний рисуется сверху).
|
||||
_KIND_STYLE = [
|
||||
# (ключ фильтра, значение тега, цвет, заливка)
|
||||
('natural', 'wetland', '#4a6a8a', 0.25),
|
||||
('natural', 'water', '#2f81f7', 0.35),
|
||||
]
|
||||
# Ж/д: railway=rail общего пользования (без промышленных/сервисных веток).
|
||||
_RAILWAY_QUERY_TEMPLATE = """
|
||||
[out:json][timeout:25];
|
||||
(
|
||||
way["railway"="rail"](around:{radius_m},{lat},{lon});
|
||||
);
|
||||
out geom;
|
||||
"""
|
||||
|
||||
_CLASS_WARNING = 'Водоём в зоне поиска — приоритетная проверка (профиль: {profiles})'
|
||||
_RAIL_WARNING = 'Ж/д пути в зоне поиска — перекрыть/проверить немедленно (профиль: {profiles})'
|
||||
|
||||
|
||||
class WaterFeature(BaseModel):
|
||||
"""Полигон водоёма в GeoJSON-геометрии + атрибуты для попапа."""
|
||||
kind: str # water | wetland
|
||||
class GeoFeature(BaseModel):
|
||||
"""Объект геослоя в GeoJSON-геометрии + атрибуты для попапа."""
|
||||
layer: str # water | wetland | railway
|
||||
name: Optional[str] = None
|
||||
area_m2: Optional[float] = None
|
||||
area_m2: Optional[float] = None # для полигонов воды
|
||||
length_m: Optional[float] = None # для линий ж/д
|
||||
distance_m: Optional[float] = None
|
||||
geom: dict[str, Any] # GeoJSON Polygon/MultiPolygon (WGS84)
|
||||
geom: dict[str, Any] # GeoJSON (WGS84)
|
||||
|
||||
|
||||
class WaterResponse(BaseModel):
|
||||
@@ -76,7 +79,8 @@ class WaterResponse(BaseModel):
|
||||
count: int
|
||||
profiles: list[str] = []
|
||||
warning: Optional[str] = None
|
||||
features: list[WaterFeature] = []
|
||||
railway_warning: Optional[str] = None
|
||||
features: list[GeoFeature] = []
|
||||
cached: bool = False
|
||||
attribution: str = '© OpenStreetMap contributors'
|
||||
|
||||
@@ -91,19 +95,25 @@ def _haversine(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
|
||||
return 2 * r * math.asin(math.sqrt(a))
|
||||
|
||||
|
||||
def _polygon_centroid_area(ring: list[dict]) -> tuple[float, float, float]:
|
||||
"""(площадь м², средняя широта, средняя долгота) по внешнему кольцу.
|
||||
def _way_length_mid(geometry: list[dict]) -> tuple[float, float, float]:
|
||||
"""(длина линии м, средняя широта, средняя долгота) по геометрии way."""
|
||||
length = sum(
|
||||
_haversine(a['lat'], a['lon'], b['lat'], b['lon'])
|
||||
for a, b in zip(geometry, geometry[1:])
|
||||
)
|
||||
lat = sum(p['lat'] for p in geometry) / len(geometry)
|
||||
lon = sum(p['lon'] for p in geometry) / len(geometry)
|
||||
return length, lat, lon
|
||||
|
||||
Площадь — формула сферического избытка; для наших масштабов (пруды,
|
||||
водохранилища в радиусе поиска) достаточно точна для порогов «крупный/мелкий».
|
||||
"""
|
||||
|
||||
def _polygon_centroid_area(ring: list[dict]) -> tuple[float, float, float]:
|
||||
"""(площадь м², средняя широта, средняя долгота) по внешнему кольцу."""
|
||||
n = len(ring)
|
||||
if n < 3:
|
||||
return 0.0, 0.0, 0.0
|
||||
lat = sum(p['lat'] for p in ring) / n
|
||||
lon = sum(p['lon'] for p in ring) / n
|
||||
r = 6371000.0
|
||||
# spherical excess (approx, unit sphere × r²)
|
||||
area = 0.0
|
||||
for i in range(n - 1):
|
||||
p1, p2 = ring[i], ring[i + 1]
|
||||
@@ -126,25 +136,26 @@ def _way_to_polygon(element: dict) -> Optional[dict[str, Any]]:
|
||||
return {'type': 'Polygon', 'coordinates': [coords]}
|
||||
|
||||
|
||||
def _element_geoms(elements: list[dict]) -> list[tuple[str, Optional[str], dict, float, float, float]]:
|
||||
"""Из ответа Overpass → (kind, name, geom, area_m2, lat, lon)."""
|
||||
out: list[tuple[str, Optional[str], dict, float, float, float]] = []
|
||||
def _water_geoms(elements: list[dict]) -> list[dict]:
|
||||
"""Ответ Overpass → список feature-словарей воды/болот (layer, name, geom, area)."""
|
||||
out: list[dict] = []
|
||||
for el in elements:
|
||||
tags = el.get('tags') or {}
|
||||
kind = 'wetland' if tags.get('natural') == 'wetland' else 'water'
|
||||
layer = 'wetland' if tags.get('natural') == 'wetland' else 'water'
|
||||
name = tags.get('name')
|
||||
if el.get('type') == 'way':
|
||||
poly = _way_to_polygon(el)
|
||||
if poly is None:
|
||||
continue
|
||||
ring = el['geometry']
|
||||
area, lat, lon = _polygon_centroid_area(ring)
|
||||
out.append((kind, name, poly, area, lat, lon))
|
||||
area, lat, lon = _polygon_centroid_area(el['geometry'])
|
||||
out.append({'layer': layer, 'name': name, 'geom': poly,
|
||||
'area_m2': area, 'lat': lat, 'lon': lon})
|
||||
elif el.get('type') == 'relation' and el.get('members'):
|
||||
# Мультиполигон из внешних колец way-членов; без top-level geometry.
|
||||
# Мультиполигон из внешних колец way-членов.
|
||||
polygons: list[list[list[float]]] = []
|
||||
areas = []
|
||||
lats, lons = [], []
|
||||
areas: list[float] = []
|
||||
lats: list[float] = []
|
||||
lons: list[float] = []
|
||||
for member in el.get('members', []):
|
||||
if member.get('role') not in ('outer', ''):
|
||||
continue
|
||||
@@ -161,16 +172,38 @@ def _element_geoms(elements: list[dict]) -> list[tuple[str, Optional[str], dict,
|
||||
lons.append(lon)
|
||||
if not polygons:
|
||||
continue
|
||||
geom: dict[str, Any] = (
|
||||
{'type': 'MultiPolygon', 'coordinates': [p for p in polygons]}
|
||||
geom = (
|
||||
{'type': 'MultiPolygon', 'coordinates': polygons}
|
||||
if len(polygons) > 1 else
|
||||
{'type': 'Polygon', 'coordinates': polygons[0]}
|
||||
)
|
||||
out.append((kind, name, geom, sum(areas), sum(lats) / len(lats), sum(lons) / len(lons)))
|
||||
out.append({'layer': layer, 'name': name, 'geom': geom,
|
||||
'area_m2': sum(areas),
|
||||
'lat': sum(lats) / len(lats), 'lon': sum(lons) / len(lons)})
|
||||
return out
|
||||
|
||||
|
||||
def _cache_get(key: str) -> Optional[dict]:
|
||||
def _railway_geoms(elements: list[dict]) -> list[dict]:
|
||||
"""Ответ Overpass → список feature-словарей ж/д (LineString, длина)."""
|
||||
out: list[dict] = []
|
||||
for el in elements:
|
||||
if el.get('type') != 'way':
|
||||
continue
|
||||
tags = el.get('tags') or {}
|
||||
# Только магистральные ж/д общего пользования: без сервисных/промышленных веток.
|
||||
if tags.get('service') or tags.get('usage') in ('industrial', 'military', 'test'):
|
||||
continue
|
||||
geometry = el.get('geometry') or []
|
||||
if len(geometry) < 2:
|
||||
continue
|
||||
length, lat, lon = _way_length_mid(geometry)
|
||||
line = {'type': 'LineString', 'coordinates': [[p['lon'], p['lat']] for p in geometry]}
|
||||
out.append({'layer': 'railway', 'name': tags.get('name'), 'geom': line,
|
||||
'length_m': length, 'lat': lat, 'lon': lon})
|
||||
return out
|
||||
|
||||
|
||||
def _cache_get(key: str) -> Optional[list[dict]]:
|
||||
import os
|
||||
from datetime import datetime, timedelta
|
||||
path = os.path.join(_CACHE_DIR, f'{key}.json')
|
||||
@@ -188,7 +221,7 @@ def _cache_get(key: str) -> Optional[dict]:
|
||||
return None
|
||||
|
||||
|
||||
def _cache_put(key: str, data: dict) -> None:
|
||||
def _cache_put(key: str, data: list[dict]) -> None:
|
||||
import os
|
||||
from datetime import datetime
|
||||
try:
|
||||
@@ -197,11 +230,11 @@ def _cache_put(key: str, data: dict) -> None:
|
||||
json.dump({'timestamp': datetime.now().isoformat(), 'data': data}, f,
|
||||
ensure_ascii=False)
|
||||
except Exception:
|
||||
logger.warning('water cache write failed', exc_info=True)
|
||||
logger.warning('geo layer cache write failed', exc_info=True)
|
||||
|
||||
|
||||
def _detect_profiles(case_detail: dict[str, Any]) -> list[str]:
|
||||
"""Профили из диагнозов (та же логика, что analyze._derive_profiles, по detail)."""
|
||||
"""Профили из диагнозов + флагов (логика как analyze._derive_profiles + cant_swim)."""
|
||||
profiles: list[str] = []
|
||||
for diag in (case_detail.get('diagnosis_type') or []):
|
||||
d = str(diag).lower()
|
||||
@@ -214,9 +247,20 @@ def _detect_profiles(case_detail: dict[str, Any]) -> list[str]:
|
||||
return profiles
|
||||
|
||||
|
||||
def _sort_features(features: list[dict]) -> list[dict]:
|
||||
"""Крупные водные объекты вперёд (по площади), затем ж/д (по длине)."""
|
||||
|
||||
def key(f: dict) -> tuple[int, float]:
|
||||
if f['layer'] == 'railway':
|
||||
return (1, -(f.get('length_m') or 0))
|
||||
return (0, -(f.get('area_m2') or 0))
|
||||
|
||||
return sorted(features, key=key)
|
||||
|
||||
|
||||
@router.get('/{case_id}', response_model=WaterResponse)
|
||||
async def water_for_case(case_id: str) -> WaterResponse:
|
||||
"""Полигоны водоёмов вокруг ТНП случая (для слоя на карте зон поиска)."""
|
||||
"""Геослой рекомендаций вокруг ТНП случая (водоёмы + ж/д, для карты зон)."""
|
||||
case = db.get_case(str(case_id))
|
||||
if case is None:
|
||||
raise HTTPException(status_code=404, detail='Case not found')
|
||||
@@ -226,44 +270,49 @@ async def water_for_case(case_id: str) -> WaterResponse:
|
||||
raise HTTPException(status_code=409, detail='Case has no TNP coordinates (tnp_lat/tnp_lon)')
|
||||
|
||||
profiles = _detect_profiles(detail)
|
||||
warning = None
|
||||
if profiles:
|
||||
warning = _CLASS_WARNING.format(profiles=', '.join(profiles))
|
||||
warning = _CLASS_WARNING.format(profiles=', '.join(profiles)) if profiles else None
|
||||
railway_warning = _RAIL_WARNING.format(profiles=', '.join(profiles)) if profiles else None
|
||||
|
||||
radius_m = 3000 # перекрывает дефолтную зону анализа (max_distance_km ≤ ~3)
|
||||
cache_key = hashlib.md5(f'{lat:.5f}:{lon:.5f}:{radius_m}'.encode()).hexdigest()
|
||||
cache_key = hashlib.md5(f'{lat:.5f}:{lon:.5f}:{radius_m}:v2'.encode()).hexdigest()
|
||||
|
||||
cached = _cache_get(cache_key)
|
||||
if cached is not None:
|
||||
features_payload = cached
|
||||
is_cached = True
|
||||
else:
|
||||
if _overpass_circuit_open():
|
||||
return WaterResponse(case_id=str(case_id), tnp={'lat': lat, 'lon': lon},
|
||||
radius_m=radius_m, count=0, profiles=profiles,
|
||||
features=[], cached=False)
|
||||
query = _WATER_QUERY_TEMPLATE.format(lat=lat, lon=lon, radius_m=radius_m)
|
||||
data = await query_overpass(query)
|
||||
if isinstance(data, list):
|
||||
elements = data
|
||||
else:
|
||||
elements = data.get('elements', [])
|
||||
features_payload = []
|
||||
for kind, name, geom, area, flat, flon in _element_geoms(elements):
|
||||
features_payload.append({
|
||||
'kind': kind,
|
||||
'name': name,
|
||||
'area_m2': round(area) if area else None,
|
||||
'distance_m': round(_haversine(lat, lon, flat, flon)) if area else None,
|
||||
'geom': geom,
|
||||
})
|
||||
# Крупные объекты вперёд, безымянные малые — в хвост.
|
||||
features_payload.sort(key=lambda f: f['area_m2'] or 0, reverse=True)
|
||||
if elements: # не кэшируем пустой ответ от трипнутого breaker'а
|
||||
_cache_put(cache_key, features_payload)
|
||||
if not _overpass_circuit_open():
|
||||
for template, extractor in (
|
||||
(_WATER_QUERY_TEMPLATE, _water_geoms),
|
||||
(_RAILWAY_QUERY_TEMPLATE, _railway_geoms),
|
||||
):
|
||||
query = template.format(lat=lat, lon=lon, radius_m=radius_m)
|
||||
data = await query_overpass(query)
|
||||
elements = data if isinstance(data, list) else data.get('elements', [])
|
||||
for item in extractor(elements):
|
||||
distance = round(_haversine(lat, lon, item['lat'], item['lon']))
|
||||
feature = {
|
||||
'layer': item['layer'],
|
||||
'name': item.get('name'),
|
||||
'distance_m': distance,
|
||||
'geom': item['geom'],
|
||||
}
|
||||
if item['layer'] == 'railway':
|
||||
feature['length_m'] = round(item['length_m'])
|
||||
else:
|
||||
feature['area_m2'] = round(item['area_m2'])
|
||||
features_payload.append(feature)
|
||||
if not elements:
|
||||
break # сеть недоступна — не тянем второй запрос впустую
|
||||
if features_payload: # пустой ответ не кэшируем (breaker/сеть)
|
||||
_cache_put(cache_key, features_payload)
|
||||
is_cached = False
|
||||
|
||||
features = [WaterFeature(**f) for f in features_payload]
|
||||
features_sorted = _sort_features(features_payload)
|
||||
return WaterResponse(case_id=str(case_id), tnp={'lat': lat, 'lon': lon},
|
||||
radius_m=radius_m, count=len(features), profiles=profiles,
|
||||
warning=warning, features=features, cached=is_cached)
|
||||
radius_m=radius_m, count=len(features_sorted),
|
||||
profiles=profiles, warning=warning,
|
||||
railway_warning=railway_warning,
|
||||
features=[GeoFeature(**f) for f in features_sorted],
|
||||
cached=is_cached)
|
||||
@@ -137,6 +137,19 @@ class TestBehavioralProfiles:
|
||||
# Intentional runaway: roads x2.5, settlement x3.0
|
||||
assert scorer.weights['roads'] > base_roads
|
||||
|
||||
def test_cant_swim_profile(self):
|
||||
"""Test не_умеет_плавать profile — вода ×3.0 + медицинский приоритет."""
|
||||
scorer = WeightedScorer()
|
||||
base_water = scorer.weights['water']
|
||||
scorer.apply_profile(['не_умеет_плавать'])
|
||||
|
||||
# Cant swim: water x3.0, no distance change
|
||||
assert scorer.distance_multiplier == 1.0
|
||||
assert scorer.weights['water'] == base_water * 3.0
|
||||
assert 'не_умеет_плавать' in scorer.active_profiles
|
||||
assert len(scorer.critical_warnings) == 1
|
||||
assert 'НЕМЕДЛЕННО' in scorer.critical_warnings[0]['warning']
|
||||
|
||||
def test_multiple_profiles(self):
|
||||
"""Test applying multiple profiles."""
|
||||
scorer = WeightedScorer()
|
||||
|
||||
Reference in New Issue
Block a user