feat(site-finder): v3.5 - seasonal weather + hydrology + geotech risk

This commit is contained in:
lekss361 2026-05-11 21:32:07 +03:00
parent 7bb51aa838
commit 2855a01ca7
7 changed files with 705 additions and 1 deletions

View file

@ -70,6 +70,82 @@ def _fetch_air_quality_sync(lat: float, lon: float) -> dict | None:
return None
def _fetch_seasonal_weather_sync(lat: float, lon: float) -> dict | None:
"""Open-Meteo Climate API — 30-летние нормали по сезонам.
Использует модель MRI-AGCM3.2-S (японская, точная для всего мира).
Группирует 1995-2024 по четырём сезонам. Медленнее прогноза timeout 15s.
"""
try:
with httpx.Client(timeout=15) as c:
r = c.get(
"https://climate-api.open-meteo.com/v1/climate",
params={
"latitude": lat,
"longitude": lon,
"start_date": "1995-01-01",
"end_date": "2024-12-31",
"models": "MRI_AGCM3_2_S",
"daily": "temperature_2m_max,temperature_2m_min,precipitation_sum",
},
)
r.raise_for_status()
data = r.json()
daily = data.get("daily", {})
times = daily.get("time") or []
t_max = daily.get("temperature_2m_max") or []
t_min = daily.get("temperature_2m_min") or []
precip = daily.get("precipitation_sum") or []
if not times:
return None
seasons_months = {
"winter": [12, 1, 2],
"spring": [3, 4, 5],
"summer": [6, 7, 8],
"autumn": [9, 10, 11],
}
buckets: dict[str, dict[str, list[float]]] = {
k: {"t_max": [], "t_min": [], "precip": []} for k in seasons_months
}
for i, t in enumerate(times):
month = int(t[5:7]) # 'YYYY-MM-DD'
for season, months in seasons_months.items():
if month in months:
if i < len(t_max) and t_max[i] is not None:
buckets[season]["t_max"].append(t_max[i])
if i < len(t_min) and t_min[i] is not None:
buckets[season]["t_min"].append(t_min[i])
if i < len(precip) and precip[i] is not None:
buckets[season]["precip"].append(precip[i])
break
seasons: dict[str, Any] = {}
for season, vals in buckets.items():
if not vals["t_max"]:
seasons[season] = None
continue
seasons[season] = {
"avg_t_max_c": round(sum(vals["t_max"]) / len(vals["t_max"]), 1),
"avg_t_min_c": round(sum(vals["t_min"]) / len(vals["t_min"]), 1),
"max_t_c": round(max(vals["t_max"]), 1),
"min_t_c": round(min(vals["t_min"]), 1),
"avg_precip_per_day_mm": round(sum(vals["precip"]) / len(vals["precip"]), 1),
"total_precip_mm": round(sum(vals["precip"]), 0),
"days_observed": len(vals["t_max"]),
}
return {
"seasons": seasons,
"period": "1995-2024 (30 лет)",
"model": "MRI-AGCM3-2-S",
"source": "open-meteo-climate",
"note": ("Климатические нормали. " "Текущая погода — отдельный API."),
}
except Exception as e:
logger.warning("seasonal weather fetch failed: %s", e)
return None
def _fetch_weather_sync(lat: float, lon: float) -> dict | None:
"""Open-Meteo Forecast API — 7-day weather + climate context.
@ -165,6 +241,71 @@ _POI_WEIGHTS: dict[str, float] = {
}
# Сейсмика по ОСР-2016 карта B (среднее повторяемое за 500 лет).
# Добавляй регионы по мере расширения географии продукта.
GEOTECH_BY_REGION: dict[int, dict[str, Any]] = {
66: { # Свердловская обл.
"seismic_intensity_balls": 5,
"seismic_label": "минимальная сейсмика (≤5 баллов)",
"seismic_description": "Обычное строительство без специальных мер.",
"permafrost": False,
},
77: { # Москва
"seismic_intensity_balls": 4,
"seismic_label": "практически нет сейсмики",
"seismic_description": "Обычное строительство.",
"permafrost": False,
},
}
def _geotech_risk(region_code: int, db: Session, geom_wkt: str) -> dict[str, Any]:
"""Геотехнические риски: сейсмика (ОСР-2016) + промышленная близость.
industrial_within_500m proxy для возможного загрязнения почв (без
реальных шейпов зон загрязнения). Точная геология/гидрогеология требует
инженерных изысканий (bore-holes).
"""
region_data: dict[str, Any] = GEOTECH_BY_REGION.get(
region_code,
{
"seismic_intensity_balls": None,
"seismic_label": "нет данных в встроенной таблице",
"seismic_description": "ОСР-2016: уточняйте по карте.",
"permafrost": False,
},
)
industrial_close: int = (
db.execute(
text("""
SELECT COUNT(*) AS n
FROM osm_noise_sources_ekb
WHERE source_type = 'industrial'
AND ST_DWithin(
geom::geography,
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography,
500
)
"""),
{"wkt": geom_wkt},
).scalar()
or 0
)
return {
**region_data,
"industrial_within_500m": int(industrial_close),
"industrial_contamination_flag": int(industrial_close) > 0,
"note": (
"Сейсмика — ОСР-2016 карта B (среднее повторяемое за 500 лет). "
"Для строительства обычно достаточно ≤7 баллов без спецмер. "
"Industrial proximity — proxy для возможного загрязнения почв. "
"Точная геология/гидрогеология — требует bore-holes (изыскания)."
),
}
@router.post("/search", response_model=ParcelSearchResponse)
async def search_parcels(payload: ParcelSearchRequest) -> ParcelSearchResponse:
"""Search parcels by filters + scoring.
@ -435,6 +576,58 @@ def analyze_parcel(
# 9) Weather — Open-Meteo 7-day forecast (best-effort, null при недоступности)
weather = _fetch_weather_sync(centroid_lat, centroid_lon)
# 9b) Seasonal weather — 30-летние нормали Climate API
seasonal_weather = _fetch_seasonal_weather_sync(centroid_lat, centroid_lon)
# 9c) Hydrology — водоёмы и реки в радиусе 2 км из osm_noise_sources_ekb
hydrology: dict[str, Any] | None = None
try:
hydro_rows = (
db.execute(
text("""
SELECT source_type, road_class, name,
ST_Distance(
n.geom::geography,
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography
) AS distance_m
FROM osm_noise_sources_ekb n
WHERE source_type = 'water'
AND ST_DWithin(
n.geom::geography,
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography,
2000
)
ORDER BY distance_m ASC
LIMIT 10
"""),
{"wkt": geom_wkt},
)
.mappings()
.all()
)
hydrology = {
"nearest": [
{
"subtype": r["road_class"],
"name": r["name"],
"distance_m": round(float(r["distance_m"])),
}
for r in hydro_rows[:5]
],
"flood_risk_flag": any(
float(r["distance_m"]) < 200 and r["road_class"] in ("river", "canal")
for r in hydro_rows
),
"note": (
"Пойма реки (<200м) — повышенный риск подтопления. Точные данные о "
"зонах затопления — в Росреестре (ЗОУИТ типа 33: 'Зона затопления, "
"подтопления') через ФГИС ТП."
),
}
except Exception as e:
logger.warning("hydrology query failed for %s: %s", cad_num, e)
hydrology = None
# 10) Market trend — динамика цен ДДУ в радиусе 3 км за 6 vs предыдущие 6 месяцев
market_trend: dict[str, Any] | None = None
try:
@ -551,7 +744,10 @@ def analyze_parcel(
},
"air_quality": air_q,
"weather": weather,
"seasonal_weather": seasonal_weather,
"wind": (weather or {}).get("wind") if weather else None, # backward compat
"geology": geology,
"hydrology": hydrology,
"geotech_risk": _geotech_risk(66, db, geom_wkt),
"market_trend": market_trend,
}

View file

@ -35,6 +35,11 @@ _NOISE_QUERIES: list[tuple[str, str, str, str | None]] = [
("railway", "rail", "railway", None),
("landuse", "industrial", "industrial", None),
("aeroway", "aerodrome", "aerodrome", None),
# Гидрология — реки, ручьи, каналы (way-линии) и водоёмы (way-полигоны)
("waterway", "river", "water", "river"),
("waterway", "stream", "water", "stream"),
("waterway", "canal", "water", "canal"),
("natural", "water", "water", "lake_or_pond"),
]
@ -104,6 +109,21 @@ def _way_to_linestring_wkt(nodes: list[dict]) -> str | None:
return f"LINESTRING({', '.join(points)})"
def _way_to_polygon_wkt(nodes: list[dict]) -> str | None:
"""Построить WKT Polygon из замкнутого way Overpass (out geom).
Используется для natural=water (озёра, пруды). Если кольцо не замкнуто
автоматически добавляем первую точку в конец.
"""
points = [f"{n['lon']} {n['lat']}" for n in nodes if "lon" in n and "lat" in n]
if len(points) < 3:
return None
# Замкнуть кольцо если нужно
if points[0] != points[-1]:
points.append(points[0])
return f"POLYGON(({', '.join(points)}))"
def sync_noise_sources_to_db() -> dict[str, int]:
"""UPSERT шумовых источников из Overpass в osm_noise_sources_ekb.
@ -135,7 +155,14 @@ def sync_noise_sources_to_db() -> dict[str, int]:
geom_params: dict
if osm_type == "way":
nodes: list[dict] = el.get("geometry") or []
wkt = _way_to_linestring_wkt(nodes)
# natural=water → замкнутый полигон; waterway → линия
if source_type == "water" and road_class == "lake_or_pond":
wkt = _way_to_polygon_wkt(nodes)
if wkt is None:
# Fallback: попробовать как линию (частично загруженный bbox-обрезок)
wkt = _way_to_linestring_wkt(nodes)
else:
wkt = _way_to_linestring_wkt(nodes)
if wkt is None:
skipped += 1
continue

View file

@ -0,0 +1,131 @@
"use client";
import type { GeotechRisk } from "@/types/site-finder";
interface Props {
risk: GeotechRisk;
}
function seismicBadgeStyle(balls: number): { bg: string; color: string } {
if (balls <= 5) return { bg: "#dcfce7", color: "#15803d" };
if (balls <= 7) return { bg: "#fef3c7", color: "#92400e" };
return { bg: "#fecaca", color: "#b91c1c" };
}
export function GeotechRiskBlock({ risk }: Props) {
return (
<div
style={{
borderRadius: 10,
padding: "14px 16px",
background: "#fafafa",
border: "1px solid #e5e7eb",
display: "flex",
flexDirection: "column",
gap: 10,
}}
>
<div style={{ fontSize: 13, fontWeight: 700, color: "#374151" }}>
Геотехнический риск
</div>
{/* Seismics */}
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
flexWrap: "wrap",
}}
>
{risk.seismic_intensity_balls !== null ? (
<>
<div
style={{
width: 36,
height: 36,
borderRadius: "50%",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: 14,
fontWeight: 800,
flexShrink: 0,
...seismicBadgeStyle(risk.seismic_intensity_balls),
}}
>
{risk.seismic_intensity_balls}
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 2 }}>
<div style={{ fontSize: 13, fontWeight: 600, color: "#374151" }}>
{risk.seismic_label}
</div>
{risk.seismic_description && (
<div style={{ fontSize: 11, color: "#6b7280" }}>
{risk.seismic_description}
</div>
)}
</div>
</>
) : (
<div
style={{
borderRadius: 8,
padding: "6px 12px",
background: "#f3f4f6",
fontSize: 13,
color: "#6b7280",
}}
>
{risk.seismic_label}
</div>
)}
</div>
{/* Permafrost */}
{risk.permafrost && (
<div
style={{
display: "flex",
alignItems: "center",
gap: 6,
fontSize: 13,
color: "#374151",
}}
>
<span>🟡</span>
<span>Вечная мерзлота особые фундаменты</span>
</div>
)}
{/* Industrial contamination */}
{risk.industrial_contamination_flag && (
<div
style={{
borderRadius: 8,
padding: "10px 14px",
background: "#fff7ed",
border: "1px solid #fdba74",
fontSize: 13,
fontWeight: 600,
color: "#c2410c",
display: "flex",
alignItems: "center",
gap: 6,
}}
>
<span></span>
<span>
{risk.industrial_within_500m} промзон(ы) в &lt;500м (риск
загрязнения почв)
</span>
</div>
)}
{/* Note */}
{risk.note && (
<div style={{ fontSize: 11, color: "#9ca3af" }}>{risk.note}</div>
)}
</div>
);
}

View file

@ -0,0 +1,109 @@
"use client";
import type { Hydrology, HydrologyNearest } from "@/types/site-finder";
interface Props {
hydrology: Hydrology;
}
const SUBTYPE_ICON: Record<string, string> = {
river: "🏞",
stream: "💦",
lake_or_pond: "🪷",
canal: "🚣",
};
function subtypeLabel(subtype: HydrologyNearest["subtype"]): string {
switch (subtype) {
case "river":
return "река";
case "stream":
return "ручей";
case "lake_or_pond":
return "озеро/пруд";
case "canal":
return "канал";
default:
return "водоём";
}
}
export function HydrologyBlock({ hydrology }: Props) {
const top5 = hydrology.nearest.slice(0, 5);
return (
<div
style={{
borderRadius: 10,
padding: "14px 16px",
background: "#f0f9ff",
border: "1px solid #bae6fd",
display: "flex",
flexDirection: "column",
gap: 10,
}}
>
<div style={{ fontSize: 13, fontWeight: 700, color: "#0369a1" }}>
Гидрология
</div>
{/* Flood risk banner */}
{hydrology.flood_risk_flag && (
<div
style={{
borderRadius: 8,
padding: "10px 14px",
background: "#fecaca",
border: "1px solid #f87171",
fontSize: 13,
fontWeight: 600,
color: "#b91c1c",
display: "flex",
alignItems: "center",
gap: 6,
}}
>
<span></span>
<span>Возможная пойма река/канал в &lt;200м</span>
</div>
)}
{/* Nearest water bodies */}
{top5.length === 0 ? (
<div style={{ fontSize: 13, color: "#6b7280" }}>
Поблизости водных объектов нет
</div>
) : (
<div style={{ display: "flex", flexDirection: "column", gap: 5 }}>
{top5.map((item, i) => {
const icon = SUBTYPE_ICON[item.subtype ?? ""] ?? "💧";
const label = subtypeLabel(item.subtype);
const name = item.name ?? "б/н";
return (
<div
key={i}
style={{
display: "flex",
alignItems: "center",
gap: 6,
fontSize: 13,
color: "#374151",
}}
>
<span>{icon}</span>
<span>
{name} {label} {Math.round(item.distance_m)} м
</span>
</div>
);
})}
</div>
)}
{/* Note */}
{hydrology.note && (
<div style={{ fontSize: 11, color: "#9ca3af" }}>{hydrology.note}</div>
)}
</div>
);
}

View file

@ -10,6 +10,9 @@ import type {
} from "@/types/site-finder";
import { MarketTrendBlock } from "./MarketTrendBlock";
import { GeologyBlock } from "./GeologyBlock";
import { SeasonalWeatherBlock } from "./SeasonalWeatherBlock";
import { HydrologyBlock } from "./HydrologyBlock";
import { GeotechRiskBlock } from "./GeotechRiskBlock";
interface Props {
data: ParcelAnalysis;
@ -714,6 +717,42 @@ export function ScoreCard({ data }: Props) {
</div>
)}
{/* Seasonal weather */}
{"seasonal_weather" in data && (
<div
style={{
padding: "12px 24px 16px",
borderTop: "1px solid #e5e7eb",
}}
>
<SeasonalWeatherBlock seasonal={data.seasonal_weather} />
</div>
)}
{/* Hydrology */}
{data.hydrology !== undefined && (
<div
style={{
padding: "12px 24px 16px",
borderTop: "1px solid #e5e7eb",
}}
>
<HydrologyBlock hydrology={data.hydrology} />
</div>
)}
{/* Geotech risk */}
{data.geotech_risk !== undefined && (
<div
style={{
padding: "12px 24px 16px",
borderTop: "1px solid #e5e7eb",
}}
>
<GeotechRiskBlock risk={data.geotech_risk} />
</div>
)}
{/* Geology */}
{data.geology !== undefined && (
<div

View file

@ -0,0 +1,154 @@
"use client";
import type { SeasonalWeather, SeasonStats } from "@/types/site-finder";
interface Props {
seasonal: SeasonalWeather | null | undefined;
}
const SEASON_CONFIG: Array<{
key: keyof SeasonalWeather["seasons"];
label: string;
bg: string;
color: string;
}> = [
{ key: "winter", label: "Зима", bg: "#dbeafe", color: "#1e40af" },
{ key: "spring", label: "Весна", bg: "#dcfce7", color: "#15803d" },
{ key: "summer", label: "Лето", bg: "#fef3c7", color: "#92400e" },
{ key: "autumn", label: "Осень", bg: "#fed7aa", color: "#c2410c" },
];
function SeasonCard({
label,
bg,
color,
stats,
}: {
label: string;
bg: string;
color: string;
stats: SeasonStats;
}) {
return (
<div
style={{
borderRadius: 10,
padding: "12px 14px",
background: bg,
display: "flex",
flexDirection: "column",
gap: 6,
flex: "1 1 0",
minWidth: 120,
}}
>
<div style={{ fontSize: 13, fontWeight: 700, color }}>{label}</div>
<div style={{ fontSize: 15, fontWeight: 700, color: "#111827" }}>
{stats.avg_t_min_c}{stats.avg_t_max_c}°C
</div>
<div style={{ fontSize: 11, color: "#6b7280" }}>
(макс {stats.min_t_c} / {stats.max_t_c}°C)
</div>
<div style={{ fontSize: 12, color: "#374151" }}>
{stats.total_precip_mm} мм осадков
</div>
</div>
);
}
export function SeasonalWeatherBlock({ seasonal }: Props) {
if (!seasonal) {
return (
<div
style={{
borderRadius: 10,
padding: "14px 16px",
background: "#f3f4f6",
display: "flex",
flexDirection: "column",
gap: 6,
}}
>
<div style={{ fontSize: 12, fontWeight: 700, color: "#374151" }}>
Климат (нормали)
</div>
<div style={{ fontSize: 13, color: "#9ca3af" }}>
Климат-данные недоступны
</div>
</div>
);
}
return (
<div
style={{
borderRadius: 10,
padding: "14px 16px",
background: "#fafafa",
border: "1px solid #e5e7eb",
display: "flex",
flexDirection: "column",
gap: 10,
}}
>
{/* Header */}
<div
style={{
display: "flex",
alignItems: "baseline",
gap: 8,
flexWrap: "wrap",
}}
>
<div style={{ fontSize: 13, fontWeight: 700, color: "#374151" }}>
Климат (нормали)
</div>
<div style={{ fontSize: 11, color: "#9ca3af" }}>{seasonal.period}</div>
</div>
{/* Season cards */}
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
{SEASON_CONFIG.map(({ key, label, bg, color }) => {
const stats = seasonal.seasons[key];
if (!stats) {
return (
<div
key={key}
style={{
borderRadius: 10,
padding: "12px 14px",
background: "#f3f4f6",
flex: "1 1 0",
minWidth: 120,
}}
>
<div
style={{ fontSize: 13, fontWeight: 700, color: "#9ca3af" }}
>
{label}
</div>
<div style={{ fontSize: 12, color: "#9ca3af", marginTop: 4 }}>
Нет данных
</div>
</div>
);
}
return (
<SeasonCard
key={key}
label={label}
bg={bg}
color={color}
stats={stats}
/>
);
})}
</div>
{/* Footer */}
<div style={{ fontSize: 11, color: "#9ca3af" }}>
{seasonal.source} · {seasonal.model}
</div>
</div>
);
}

View file

@ -82,6 +82,51 @@ export interface ParcelAnalysisGeology {
lon: number;
}
export interface SeasonStats {
avg_t_max_c: number;
avg_t_min_c: number;
max_t_c: number;
min_t_c: number;
avg_precip_per_day_mm: number;
total_precip_mm: number;
days_observed: number;
}
export interface SeasonalWeather {
seasons: {
winter: SeasonStats | null;
spring: SeasonStats | null;
summer: SeasonStats | null;
autumn: SeasonStats | null;
};
period: string;
model: string;
source: string;
note: string;
}
export interface HydrologyNearest {
subtype: "river" | "stream" | "lake_or_pond" | "canal" | null;
name: string | null;
distance_m: number;
}
export interface Hydrology {
nearest: HydrologyNearest[];
flood_risk_flag: boolean;
note: string;
}
export interface GeotechRisk {
seismic_intensity_balls: number | null;
seismic_label: string;
seismic_description: string;
permafrost: boolean;
industrial_within_500m: number;
industrial_contamination_flag: boolean;
note: string;
}
export interface MarketTrend {
recent_avg_price_per_m2: number;
prior_avg_price_per_m2: number;
@ -109,6 +154,9 @@ export interface ParcelAnalysis {
air_quality: ParcelAnalysisAirQuality | null;
wind: ParcelAnalysisWind | null;
weather?: ParcelAnalysisWeather | null;
seasonal_weather?: SeasonalWeather | null;
hydrology?: Hydrology;
geotech_risk?: GeotechRisk;
geology?: ParcelAnalysisGeology;
}