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
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _fetch_wind_sync(lat: float, lon: float) -> dict | None:
|
def _fetch_weather_sync(lat: float, lon: float) -> dict | None:
|
||||||
"""Синхронный запрос к Open-Meteo Forecast API для данных о ветре.
|
"""Open-Meteo Forecast API — 7-day weather + climate context.
|
||||||
|
|
||||||
Возвращает доминирующее направление ветра (circular mean по 7 дням) и
|
Free, no API key, JSON by coordinates. Покрывает РФ полностью.
|
||||||
максимальную скорость. None если API недоступен.
|
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
with httpx.Client(timeout=5) as c:
|
with httpx.Client(timeout=5) as c:
|
||||||
|
|
@ -83,28 +82,58 @@ def _fetch_wind_sync(lat: float, lon: float) -> dict | None:
|
||||||
params={
|
params={
|
||||||
"latitude": lat,
|
"latitude": lat,
|
||||||
"longitude": lon,
|
"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,
|
"forecast_days": 7,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
daily = r.json().get("daily", {})
|
daily = r.json().get("daily", {})
|
||||||
dirs = daily.get("winddirection_10m_dominant") or []
|
if not daily.get("time"):
|
||||||
speeds = daily.get("windspeed_10m_max") or []
|
|
||||||
if not dirs:
|
|
||||||
return None
|
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
|
# Circular mean направления ветра (vector sum) — избегает jump 359→1
|
||||||
x = sum(math.cos(math.radians(d)) for d in dirs)
|
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 dirs)
|
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
|
dominant = (math.degrees(math.atan2(y, x)) + 360) % 360 if wind_d else 0.0
|
||||||
|
rose = ["Север", "С-В", "Восток", "Ю-В", "Юг", "Ю-З", "Запад", "С-З"]
|
||||||
|
wind_label = rose[round(dominant / 45) % 8]
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"dominant_direction_deg": round(dominant),
|
"forecast_days": len(daily.get("time", [])),
|
||||||
"dominant_direction_label": _wind_label(dominant),
|
"temperature": {
|
||||||
"max_speed_m_s": round(max(speeds), 1) if speeds else None,
|
"min_c": round(min(t_min), 1) if t_min else None,
|
||||||
"forecast_days": len(dirs),
|
"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",
|
"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
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -403,8 +432,8 @@ def analyze_parcel(
|
||||||
# 8) Air quality — Open-Meteo (best-effort, null при недоступности)
|
# 8) Air quality — Open-Meteo (best-effort, null при недоступности)
|
||||||
air_q = _fetch_air_quality_sync(centroid_lat, centroid_lon)
|
air_q = _fetch_air_quality_sync(centroid_lat, centroid_lon)
|
||||||
|
|
||||||
# 9) Wind — Open-Meteo (best-effort, null при недоступности)
|
# 9) Weather — Open-Meteo 7-day forecast (best-effort, null при недоступности)
|
||||||
wind_data = _fetch_wind_sync(centroid_lat, centroid_lon)
|
weather = _fetch_weather_sync(centroid_lat, centroid_lon)
|
||||||
|
|
||||||
# 10) Market trend — динамика цен ДДУ в радиусе 3 км за 6 vs предыдущие 6 месяцев
|
# 10) Market trend — динамика цен ДДУ в радиусе 3 км за 6 vs предыдущие 6 месяцев
|
||||||
market_trend: dict[str, Any] | None = None
|
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)
|
logger.warning("market_trend query failed for %s: %s", cad_num, e)
|
||||||
market_trend = None
|
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 {
|
return {
|
||||||
"cad_num": cad_num,
|
"cad_num": cad_num,
|
||||||
"source": source,
|
"source": source,
|
||||||
|
|
@ -499,6 +550,8 @@ def analyze_parcel(
|
||||||
"sources": nearby_noise_sources[:10],
|
"sources": nearby_noise_sources[:10],
|
||||||
},
|
},
|
||||||
"air_quality": air_q,
|
"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,
|
"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,
|
ParcelAnalysisNoise,
|
||||||
ParcelAnalysisAirQuality,
|
ParcelAnalysisAirQuality,
|
||||||
ParcelAnalysisWind,
|
ParcelAnalysisWind,
|
||||||
|
ParcelAnalysisWeather,
|
||||||
} from "@/types/site-finder";
|
} from "@/types/site-finder";
|
||||||
import { MarketTrendBlock } from "./MarketTrendBlock";
|
import { MarketTrendBlock } from "./MarketTrendBlock";
|
||||||
|
import { GeologyBlock } from "./GeologyBlock";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
data: ParcelAnalysis;
|
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 ─────────────────────────────────────────────────────────────────
|
// ── ScoreCard ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function ScoreCard({ data }: Props) {
|
export function ScoreCard({ data }: Props) {
|
||||||
|
|
@ -354,7 +480,8 @@ export function ScoreCard({ data }: Props) {
|
||||||
const hasEnvironmental =
|
const hasEnvironmental =
|
||||||
data.noise !== undefined ||
|
data.noise !== undefined ||
|
||||||
data.air_quality !== undefined ||
|
data.air_quality !== undefined ||
|
||||||
data.wind !== undefined;
|
data.wind !== undefined ||
|
||||||
|
data.weather !== undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
@ -566,7 +693,11 @@ export function ScoreCard({ data }: Props) {
|
||||||
|
|
||||||
<AirQualityBlock aq={data.air_quality ?? null} />
|
<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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -582,6 +713,30 @@ export function ScoreCard({ data }: Props) {
|
||||||
<MarketTrendBlock trend={data.market_trend} />
|
<MarketTrendBlock trend={data.market_trend} />
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,33 @@ export interface ParcelAnalysisPoi {
|
||||||
lon: number;
|
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 {
|
export interface MarketTrend {
|
||||||
recent_avg_price_per_m2: number;
|
recent_avg_price_per_m2: number;
|
||||||
prior_avg_price_per_m2: number;
|
prior_avg_price_per_m2: number;
|
||||||
|
|
@ -81,6 +108,8 @@ export interface ParcelAnalysis {
|
||||||
noise: ParcelAnalysisNoise | null;
|
noise: ParcelAnalysisNoise | null;
|
||||||
air_quality: ParcelAnalysisAirQuality | null;
|
air_quality: ParcelAnalysisAirQuality | null;
|
||||||
wind: ParcelAnalysisWind | null;
|
wind: ParcelAnalysisWind | null;
|
||||||
|
weather?: ParcelAnalysisWeather | null;
|
||||||
|
geology?: ParcelAnalysisGeology;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PoiCategory =
|
export type PoiCategory =
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue