feat(site-finder): v3.4 - weather enrichment (temp/precip/UV) + geology stub

This commit is contained in:
lekss361 2026-05-11 21:17:57 +03:00
parent 7900dc5238
commit 7bb51aa838
4 changed files with 328 additions and 21 deletions

View file

@ -70,11 +70,10 @@ def _fetch_air_quality_sync(lat: float, lon: float) -> dict | None:
return None
def _fetch_wind_sync(lat: float, lon: float) -> dict | None:
"""Синхронный запрос к Open-Meteo Forecast API для данных о ветре.
def _fetch_weather_sync(lat: float, lon: float) -> dict | None:
"""Open-Meteo Forecast API — 7-day weather + climate context.
Возвращает доминирующее направление ветра (circular mean по 7 дням) и
максимальную скорость. None если API недоступен.
Free, no API key, JSON by coordinates. Покрывает РФ полностью.
"""
try:
with httpx.Client(timeout=5) as c:
@ -83,28 +82,58 @@ def _fetch_wind_sync(lat: float, lon: float) -> dict | None:
params={
"latitude": lat,
"longitude": lon,
"daily": "winddirection_10m_dominant,windspeed_10m_max",
"daily": (
"temperature_2m_max,temperature_2m_min,"
"precipitation_sum,uv_index_max,"
"winddirection_10m_dominant,windspeed_10m_max"
),
"timezone": "Europe/Moscow",
"forecast_days": 7,
},
)
r.raise_for_status()
daily = r.json().get("daily", {})
dirs = daily.get("winddirection_10m_dominant") or []
speeds = daily.get("windspeed_10m_max") or []
if not dirs:
if not daily.get("time"):
return None
t_max = daily.get("temperature_2m_max") or []
t_min = daily.get("temperature_2m_min") or []
precip = daily.get("precipitation_sum") or []
uv = daily.get("uv_index_max") or []
wind_d = daily.get("winddirection_10m_dominant") or []
wind_s = daily.get("windspeed_10m_max") or []
# Circular mean направления ветра (vector sum) — избегает jump 359→1
x = sum(math.cos(math.radians(d)) for d in dirs)
y = sum(math.sin(math.radians(d)) for d in dirs)
dominant = (math.degrees(math.atan2(y, x)) + 360) % 360
x = sum(math.cos(math.radians(d)) for d in wind_d if d is not None)
y = sum(math.sin(math.radians(d)) for d in wind_d if d is not None)
dominant = (math.degrees(math.atan2(y, x)) + 360) % 360 if wind_d else 0.0
rose = ["Север", "С-В", "Восток", "Ю-В", "Юг", "Ю-З", "Запад", "С-З"]
wind_label = rose[round(dominant / 45) % 8]
return {
"dominant_direction_deg": round(dominant),
"dominant_direction_label": _wind_label(dominant),
"max_speed_m_s": round(max(speeds), 1) if speeds else None,
"forecast_days": len(dirs),
"forecast_days": len(daily.get("time", [])),
"temperature": {
"min_c": round(min(t_min), 1) if t_min else None,
"max_c": round(max(t_max), 1) if t_max else None,
"avg_max_c": round(sum(t_max) / len(t_max), 1) if t_max else None,
"avg_min_c": round(sum(t_min) / len(t_min), 1) if t_min else None,
},
"precipitation_total_mm": round(sum(precip), 1) if precip else 0,
"precipitation_days": sum(1 for p in precip if p and p > 0.5),
"uv_index_max": round(max(uv), 1) if uv else None,
"wind": {
"dominant_direction_deg": round(dominant),
"dominant_direction_label": wind_label,
"max_speed_m_s": round(max(wind_s), 1) if wind_s else None,
},
"source": "open-meteo",
"note": (
"7-day forecast. Для исторических норм и B2B-данных — "
"Yandex Business / Gismeteo (платно)."
),
}
except Exception:
except Exception as e:
logger.warning("weather fetch failed: %s", e)
return None
@ -403,8 +432,8 @@ def analyze_parcel(
# 8) Air quality — Open-Meteo (best-effort, null при недоступности)
air_q = _fetch_air_quality_sync(centroid_lat, centroid_lon)
# 9) Wind — Open-Meteo (best-effort, null при недоступности)
wind_data = _fetch_wind_sync(centroid_lat, centroid_lon)
# 9) Weather — Open-Meteo 7-day forecast (best-effort, null при недоступности)
weather = _fetch_weather_sync(centroid_lat, centroid_lon)
# 10) Market trend — динамика цен ДДУ в радиусе 3 км за 6 vs предыдущие 6 месяцев
market_trend: dict[str, Any] | None = None
@ -477,6 +506,28 @@ def analyze_parcel(
logger.warning("market_trend query failed for %s: %s", cad_num, e)
market_trend = None
# 10b) Geology stub — реальные данные требуют ВСЕГЕИ-200/1000 шейпы в PostGIS
karpinsky_url = (
f"https://www.karpinskyinstitute.ru/ru/gisatlas/web-gisatlas/"
f"?lat={centroid_lat:.6f}&lon={centroid_lon:.6f}&zoom=12"
)
efgi_url = "https://efgi.ru/"
geology: dict[str, Any] = {
"data_available": False,
"note": (
"Подробная геология (типы пород, грунты, мощность ОС) "
"доступна только через ВСЕГЕИ-200/1000 шейпы — требуется "
"ручной импорт в PostGIS (multi-day задача). Для drill-down "
"используй внешние ссылки ниже."
),
"links": {
"karpinsky_webgis": karpinsky_url,
"efgi_federal_registry": efgi_url,
},
"lat": centroid_lat,
"lon": centroid_lon,
}
return {
"cad_num": cad_num,
"source": source,
@ -499,6 +550,8 @@ def analyze_parcel(
"sources": nearby_noise_sources[:10],
},
"air_quality": air_q,
"wind": wind_data,
"weather": weather,
"wind": (weather or {}).get("wind") if weather else None, # backward compat
"geology": geology,
"market_trend": market_trend,
}

View file

@ -0,0 +1,70 @@
"use client";
import type { ParcelAnalysisGeology } from "@/types/site-finder";
interface Props {
geology: ParcelAnalysisGeology;
}
export function GeologyBlock({ geology }: Props) {
return (
<div
style={{
borderRadius: 10,
padding: "14px 16px",
background: "#f8fafc",
border: "1px solid #e2e8f0",
display: "flex",
flexDirection: "column",
gap: 10,
}}
>
<div style={{ fontSize: 12, fontWeight: 700, color: "#374151" }}>
Геология
</div>
<div style={{ fontSize: 13, color: "#4b5563", lineHeight: 1.5 }}>
{geology.note}
</div>
{/* Primary CTA */}
<a
href={geology.links.karpinsky_webgis}
target="_blank"
rel="noopener noreferrer"
style={{
display: "block",
borderRadius: 8,
padding: "10px 14px",
background: "#1e40af",
color: "#fff",
fontSize: 13,
fontWeight: 600,
textDecoration: "none",
textAlign: "center",
}}
>
Открыть ВСЕГЕИ ГИС-Атлас &rarr;
</a>
{/* Secondary link */}
<a
href={geology.links.efgi_federal_registry}
target="_blank"
rel="noopener noreferrer"
style={{
fontSize: 12,
color: "#6b7280",
textDecoration: "underline",
textAlign: "center",
}}
>
ЕФГИ (требуется ЕСИА)
</a>
<div style={{ fontSize: 11, color: "#9ca3af", textAlign: "center" }}>
{geology.lat.toFixed(6)}, {geology.lon.toFixed(6)}
</div>
</div>
);
}

View file

@ -6,8 +6,10 @@ import type {
ParcelAnalysisNoise,
ParcelAnalysisAirQuality,
ParcelAnalysisWind,
ParcelAnalysisWeather,
} from "@/types/site-finder";
import { MarketTrendBlock } from "./MarketTrendBlock";
import { GeologyBlock } from "./GeologyBlock";
interface Props {
data: ParcelAnalysis;
@ -342,6 +344,130 @@ function WindBlock({ wind }: { wind: ParcelAnalysisWind | null }) {
);
}
// ── Weather block ─────────────────────────────────────────────────────────────
function uvColor(uv: number): string {
if (uv < 3) return "#16a34a";
if (uv < 6) return "#d97706";
if (uv < 8) return "#ea580c";
return "#dc2626";
}
function WeatherBlock({
weather,
}: {
weather: ParcelAnalysisWeather | null | undefined;
}) {
if (!weather) {
return (
<div
style={{
borderRadius: 10,
padding: "14px 16px",
background: "#f3f4f6",
display: "flex",
flexDirection: "column",
gap: 8,
}}
>
<div style={{ fontSize: 12, fontWeight: 700, color: "#374151" }}>
Погода / Климат
</div>
<div style={{ fontSize: 13, color: "#9ca3af" }}>Прогноз недоступен</div>
</div>
);
}
const { temperature: t, wind } = weather;
return (
<div
style={{
borderRadius: 10,
padding: "14px 16px",
background: "#eff6ff",
display: "flex",
flexDirection: "column",
gap: 10,
}}
>
<div style={{ fontSize: 12, fontWeight: 700, color: "#374151" }}>
Погода / Климат
</div>
{/* Temperature */}
<div style={{ display: "flex", flexDirection: "column", gap: 2 }}>
<div style={{ fontSize: 11, color: "#6b7280" }}>Температура</div>
<div style={{ fontSize: 15, fontWeight: 700, color: "#111827" }}>
{t.min_c != null && t.max_c != null
? `${t.min_c}${t.max_c}°C`
: t.min_c != null
? `от ${t.min_c}°C`
: t.max_c != null
? `до ${t.max_c}°C`
: "—"}
</div>
{(t.avg_min_c != null || t.avg_max_c != null) && (
<div style={{ fontSize: 11, color: "#6b7280" }}>
ср. {t.avg_min_c != null ? `${t.avg_min_c}` : "?"}
{"/"}
{t.avg_max_c != null ? `${t.avg_max_c}` : "?"}
°C
</div>
)}
</div>
{/* Precipitation */}
<div style={{ display: "flex", flexDirection: "column", gap: 2 }}>
<div style={{ fontSize: 11, color: "#6b7280" }}>Осадки</div>
<div style={{ fontSize: 14, fontWeight: 600, color: "#374151" }}>
{weather.precipitation_total_mm} мм
<span style={{ fontSize: 11, fontWeight: 400, color: "#6b7280" }}>
{" "}
(за {weather.forecast_days} дн.)
</span>
</div>
<div style={{ fontSize: 11, color: "#6b7280" }}>
{weather.precipitation_days} дн. с осадками
</div>
</div>
{/* UV */}
{weather.uv_index_max != null && (
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<div style={{ fontSize: 11, color: "#6b7280" }}>UV макс:</div>
<div
style={{
fontSize: 14,
fontWeight: 700,
color: uvColor(weather.uv_index_max),
}}
>
{weather.uv_index_max}
</div>
</div>
)}
{/* Wind */}
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<WindArrow deg={wind.dominant_direction_deg} />
<div style={{ fontSize: 13, color: "#374151" }}>
{wind.dominant_direction_label}
{wind.max_speed_m_s != null ? ` · до ${wind.max_speed_m_s} м/с` : ""}
</div>
</div>
{/* Source + note */}
<div style={{ fontSize: 11, color: "#9ca3af" }} title={weather.note}>
{weather.source} ·{" "}
{weather.note.length > 60
? weather.note.slice(0, 60) + "…"
: weather.note}
</div>
</div>
);
}
// ── ScoreCard ─────────────────────────────────────────────────────────────────
export function ScoreCard({ data }: Props) {
@ -354,7 +480,8 @@ export function ScoreCard({ data }: Props) {
const hasEnvironmental =
data.noise !== undefined ||
data.air_quality !== undefined ||
data.wind !== undefined;
data.wind !== undefined ||
data.weather !== undefined;
return (
<div
@ -566,7 +693,11 @@ export function ScoreCard({ data }: Props) {
<AirQualityBlock aq={data.air_quality ?? null} />
<WindBlock wind={data.wind ?? null} />
{data.weather !== undefined ? (
<WeatherBlock weather={data.weather} />
) : (
<WindBlock wind={data.wind ?? null} />
)}
</div>
</div>
)}
@ -582,6 +713,30 @@ export function ScoreCard({ data }: Props) {
<MarketTrendBlock trend={data.market_trend} />
</div>
)}
{/* Geology */}
{data.geology !== undefined && (
<div
style={{
padding: "12px 24px 16px",
borderTop: "1px solid #e5e7eb",
}}
>
<div
style={{
fontSize: 12,
fontWeight: 600,
color: "#6b7280",
textTransform: "uppercase",
letterSpacing: "0.05em",
marginBottom: 12,
}}
>
Геология
</div>
<GeologyBlock geology={data.geology} />
</div>
)}
</div>
);
}

View file

@ -55,6 +55,33 @@ export interface ParcelAnalysisPoi {
lon: number;
}
export interface ParcelAnalysisWeather {
forecast_days: number;
temperature: {
min_c: number | null;
max_c: number | null;
avg_max_c: number | null;
avg_min_c: number | null;
};
precipitation_total_mm: number;
precipitation_days: number;
uv_index_max: number | null;
wind: ParcelAnalysisWind;
source: string;
note: string;
}
export interface ParcelAnalysisGeology {
data_available: boolean;
note: string;
links: {
karpinsky_webgis: string;
efgi_federal_registry: string;
};
lat: number;
lon: number;
}
export interface MarketTrend {
recent_avg_price_per_m2: number;
prior_avg_price_per_m2: number;
@ -81,6 +108,8 @@ export interface ParcelAnalysis {
noise: ParcelAnalysisNoise | null;
air_quality: ParcelAnalysisAirQuality | null;
wind: ParcelAnalysisWind | null;
weather?: ParcelAnalysisWeather | null;
geology?: ParcelAnalysisGeology;
}
export type PoiCategory =