feat(site-finder): v3.4 - weather enrichment (temp/precip/UV) + geology stub
This commit is contained in:
parent
7900dc5238
commit
7bb51aa838
4 changed files with 328 additions and 21 deletions
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
70
frontend/src/components/site-finder/GeologyBlock.tsx
Normal file
70
frontend/src/components/site-finder/GeologyBlock.tsx
Normal 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",
|
||||
}}
|
||||
>
|
||||
Открыть ВСЕГЕИ ГИС-Атлас →
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue