193 lines
12 KiB
Python
193 lines
12 KiB
Python
#!/usr/bin/env python3
|
||
"""Adresanalyse voor koopwoningen: bereikbaarheid, gezin, buurt, leefomgeving en klimaat."""
|
||
|
||
import html
|
||
import json
|
||
import os
|
||
import re
|
||
import sys
|
||
import urllib.parse
|
||
import urllib.request
|
||
from datetime import datetime
|
||
from pathlib import Path
|
||
|
||
from voorzieningen import (
|
||
analyse_gezinsvoorzieningen, analyse_voorzieningen, format_voorzieningen_markdown,
|
||
)
|
||
|
||
ORS_BASE = os.environ.get("ORS_BASE", "http://192.168.1.71:9080/ors/v2")
|
||
PDOK_BASE = "https://api.pdok.nl/bzk/locatieserver/search/v3_1"
|
||
CBS_WFS = "https://service.pdok.nl/cbs/wijkenbuurten/2025/wfs/v1_0"
|
||
MKDOCS_DIR = Path("/root/mkdocs/docs/docs/Huizenjacht")
|
||
|
||
|
||
def fetch_json(url, timeout=30, data=None, headers=None):
|
||
request = urllib.request.Request(url, data=data, headers=headers or {})
|
||
with urllib.request.urlopen(request, timeout=timeout) as response:
|
||
return json.loads(response.read())
|
||
|
||
|
||
def geocode_adres(adres):
|
||
params = urllib.parse.urlencode({"q": adres, "rows": 10, "fl": "centroide_ll,weergavenaam,postcode,woonplaatsnaam,type,huisnummer"})
|
||
docs = fetch_json(f"{PDOK_BASE}/free?{params}")["response"]["docs"]
|
||
exact = [d for d in docs if d.get("type") == "adres"]
|
||
if not exact:
|
||
raise ValueError(f"Geen exact adres gevonden voor: {adres}")
|
||
doc = exact[0]
|
||
match = re.fullmatch(r"POINT\(([-\d.]+) ([-\d.]+)\)", doc["centroide_ll"])
|
||
if not match:
|
||
raise ValueError("PDOK gaf onleesbare coördinaten terug")
|
||
lon, lat = map(float, match.groups())
|
||
return {"adres": doc.get("weergavenaam", adres), "postcode": doc.get("postcode", ""), "woonplaats": doc.get("woonplaatsnaam", ""), "lat": lat, "lon": lon}
|
||
|
||
|
||
def haal_isochroon(lon, lat, profiel, minuten):
|
||
body = json.dumps({"locations": [[lon, lat]], "range": [minuten * 60], "attributes": ["area", "reachfactor", "total_pop"]}).encode()
|
||
return fetch_json(f"{ORS_BASE}/isochrones/{profiel}", timeout=45, data=body, headers={"Content-Type": "application/json"})
|
||
|
||
|
||
def format_oppervlakte(m2):
|
||
return f"{m2 / 1_000_000:.1f} km²" if m2 >= 1_000_000 else f"{m2 / 10_000:.1f} ha"
|
||
|
||
|
||
def hoogste_rapportnummer():
|
||
MKDOCS_DIR.mkdir(parents=True, exist_ok=True)
|
||
numbers = [int(m.group(1)) for path in MKDOCS_DIR.glob("*.md") if (m := re.match(r"^(\d+)-", path.name))]
|
||
return max(numbers, default=0) + 1
|
||
|
||
|
||
def schrijf_naar_mkdocs(content, adres, nummer, ext):
|
||
safe = re.sub(r"[^a-zA-Z0-9]+", "-", adres).strip("-")
|
||
filename = f"{nummer}-{safe}_{datetime.now().strftime('%Y%m%d_%H%M%S')}{ext}"
|
||
path = MKDOCS_DIR / filename
|
||
path.write_text(content, encoding="utf-8")
|
||
return path, filename
|
||
|
||
|
||
def print_isochroon_sectie(titel, icoon, iso_data):
|
||
if not iso_data or not iso_data.get("features"):
|
||
return f"{icoon} **{titel}**\nNiet beschikbaar (route-service reageerde niet).\n"
|
||
props = iso_data["features"][0].get("properties", {})
|
||
lines = [f"{icoon} **{titel}**", f"**Oppervlakte:** {format_oppervlakte(props.get('area', 0))}", f"**Reach factor:** {props.get('reachfactor', 0):.3f}"]
|
||
if len(iso_data.get("bbox", [])) == 4:
|
||
b = iso_data["bbox"]
|
||
lines.append(f"**Bereik:** {b[1]:.3f}°–{b[3]:.3f} N, {b[0]:.3f}°–{b[2]:.3f} E")
|
||
return "\n".join(lines) + "\n"
|
||
|
||
|
||
def wgs84_naar_rd(lon, lat):
|
||
"""WGS84 naar RD New (EPSG:28992), voldoende nauwkeurig voor point-in-buurt-WFS."""
|
||
dphi, dlambda = 0.36 * (lat - 52.15517440), 0.36 * (lon - 5.38720621)
|
||
x = 155000 + (190094.945 * dlambda - 11832.228 * dphi * dlambda - 114.221 * dlambda**3 - 32.391 * dphi**2 * dlambda - .705 * dphi * dlambda**3 - 2.34 * dphi**3 * dlambda - .608 * dphi * dlambda**5 - .008 * dphi**2 * dlambda**3)
|
||
y = 463000 + (309056.544 * dphi + 3638.893 * dlambda**2 + 73.077 * dphi**2 - 157.984 * dphi * dlambda**2 + 59.788 * dphi**3 + .433 * dlambda**4 - 6.439 * dphi**2 * dlambda**2 - .032 * dphi * dlambda**4 + .092 * dlambda**6 - .054 * dphi**3 * dlambda**2)
|
||
return x, y
|
||
|
||
|
||
def cbs_buurtprofiel(lon, lat):
|
||
"""Bepaal de CBS-buurt via een puntselectie in de officiële PDOK WFS."""
|
||
x, y = wgs84_naar_rd(lon, lat)
|
||
query = {"service": "WFS", "version": "2.0.0", "request": "GetFeature", "typeNames": "wijkenbuurten:buurten", "outputFormat": "json", "bbox": f"{x},{y},{x},{y},EPSG:28992", "count": "1"}
|
||
try:
|
||
features = fetch_json(f"{CBS_WFS}?{urllib.parse.urlencode(query)}", timeout=45).get("features", [])
|
||
return features[0]["properties"] if features else None
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
def format_pct(value):
|
||
return "n.b." if value is None or value < 0 else f"{value}%"
|
||
|
||
|
||
def buurt_markdown(buurt):
|
||
if not buurt:
|
||
return "CBS-buurtprofiel kon niet betrouwbaar aan deze postcode worden gekoppeld."
|
||
p = buurt
|
||
stedelijk = {1: "zeer sterk stedelijk", 2: "sterk stedelijk", 3: "matig stedelijk", 4: "weinig stedelijk", 5: "niet-stedelijk"}.get(p.get("stedelijkheidAdressenPerKm2"), "n.b.")
|
||
return "\n".join([
|
||
f"**CBS-buurt:** {p.get('buurtnaam', 'n.b.')} ({p.get('buurtcode', 'n.b.')})",
|
||
f"• Inwoners: **{p.get('aantalInwoners', 'n.b.')}** · 0–15 jaar: **{format_pct(p.get('percentagePersonen0Tot15Jaar'))}** · huishoudens met kinderen: **{format_pct(p.get('percentageHuishoudensMetKinderen'))}**",
|
||
f"• Woningvoorraad: eengezinswoningen **{format_pct(p.get('percentageEengezinswoning'))}** · koopwoningen **{format_pct(p.get('percentageKoopwoningen'))}** · gemiddelde WOZ **€ {p.get('gemiddeldeWOZWaarde', 'n.b.')}k**",
|
||
f"• Dichtheid: **{p.get('omgevingsadressendichtheid', 'n.b.')} adressen/km²** ({stedelijk}).",
|
||
"*CBS-cijfers zijn buurtgemiddelden; ze zeggen niets definitiefs over de individuele straat of woning.*",
|
||
])
|
||
|
||
|
||
def leefomgeving_markdown(locatie):
|
||
lat, lon = locatie["lat"], locatie["lon"]
|
||
atlas = f"https://www.atlasleefomgeving.nl/kaart?x={lon:.5f}&y={lat:.5f}&z=14"
|
||
politie = f"https://data.politie.nl/#/Politie/nl/dataset/47013NED"
|
||
return "\n".join([
|
||
"De volgende thema's zijn essentieel, maar worden niet als automatische groen/rood-score gepresenteerd: kaartlagen kunnen verschillende peiljaren en resoluties hebben.",
|
||
f"• [Geluid, luchtkwaliteit, groen en externe veiligheid – Atlas Leefomgeving]({atlas})",
|
||
f"• [Geregistreerde criminaliteit per buurt – Politie/CBS]({politie})",
|
||
"• Controleer bij bezichtiging zelf verkeersdrukte, oversteekplaatsen, verlichting en geluid op ochtend-, spits- én avondmomenten.",
|
||
])
|
||
|
||
|
||
def klimaat_markdown(locatie):
|
||
link = f"https://www.klimaateffectatlas.nl/nl/kaartviewer?x={locatie['lon']:.5f}&y={locatie['lat']:.5f}&z=14"
|
||
return "\n".join([
|
||
f"[Open de Klimaateffectatlas op deze locatie]({link}) en bekijk ten minste wateroverlast bij piekbui, hitte, droogte en overstroming.",
|
||
"*Dit is een eerste locatie-indicatie. Vraag bij serieuze koopintentie naar wateroverlastgeschiedenis, kruipruimte, drainage, fundering en verzekering.*",
|
||
])
|
||
|
||
|
||
def gezins_score(gezins, buurt):
|
||
checks = [
|
||
("Kinderopvang", bool(gezins.get("kinderopvang"))), ("Speelplek", bool(gezins.get("speelplekken"))),
|
||
("Basisschool", True), ("Huisarts", bool(gezins.get("huisartsen"))),
|
||
("Supermarkt", True), ("Buurt met kinderen", bool(buurt and buurt.get("percentageHuishoudensMetKinderen", -1) >= 15)),
|
||
]
|
||
lines = ["Dit is een transparante startcheck, geen koopadvies. Oranje betekent: nader onderzoeken."]
|
||
for label, ok in checks:
|
||
lines.append(f"• {'🟢' if ok else '🟠'} **{label}:** {'aanwezig in brondata' if ok else 'niet bevestigd; zelf controleren'}")
|
||
lines += ["• 🟠 **Verkeersveiligheid / geluid:** beoordeel ter plekke op meerdere momenten.", "• 🟠 **Klimaat / water:** controleer de kaartlagen en woninghistorie."]
|
||
return "\n".join(lines)
|
||
|
||
|
||
def genereer_isochroon_html(adres, lat, lon, auto_iso, fiets_iso):
|
||
safe = html.escape(adres)
|
||
auto_json, fiets_json = json.dumps(auto_iso or {}, ensure_ascii=False), json.dumps(fiets_iso or {}, ensure_ascii=False)
|
||
return f'''<!doctype html><html lang="nl"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Kaart – {safe}</title><link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"><style>html,body,#map{{height:100%;margin:0}}</style></head><body><div id="map"></div><script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script><script>const map=L.map('map').setView([{lat},{lon}],12);L.tileLayer('https://{{s}}.tile.openstreetmap.org/{{z}}/{{x}}/{{y}}.png',{{attribution:'© OpenStreetMap'}}).addTo(map);const layers=[];for(const [data,color,label] of [[{auto_json},'#2563eb','Auto 20 min'],[{fiets_json},'#16a34a','Fiets 30 min']]){{if(data.features?.length){{const l=L.geoJSON(data,{{style:{{color,fillColor:color,fillOpacity:.18}}}}).bindPopup(label).addTo(map);layers.push(l)}}}}L.marker([{lat},{lon}]).addTo(map).bindPopup('<b>{safe}</b>').openPopup();if(layers.length)map.fitBounds(L.featureGroup(layers).getBounds());</script></body></html>'''
|
||
|
||
|
||
def genereer_rapport(adres_input):
|
||
nummer, waarschuwingen = hoogste_rapportnummer(), []
|
||
locatie = geocode_adres(adres_input)
|
||
auto_iso = fiets_iso = None
|
||
for naam, profiel, minuten in [("auto", "driving-car", 20), ("fiets", "cycling-regular", 30)]:
|
||
try:
|
||
data = haal_isochroon(locatie["lon"], locatie["lat"], profiel, minuten)
|
||
if naam == "auto": auto_iso = data
|
||
else: fiets_iso = data
|
||
except Exception as exc:
|
||
waarschuwingen.append(f"Isochroon {naam} niet beschikbaar: {exc}")
|
||
try:
|
||
voorzieningen = analyse_voorzieningen(locatie["lon"], locatie["lat"])
|
||
except Exception as exc:
|
||
voorzieningen = {"supermarkten": [], "scholen": []}; waarschuwingen.append(f"Basisvoorzieningen niet beschikbaar: {exc}")
|
||
gezins = analyse_gezinsvoorzieningen(locatie["lon"], locatie["lat"])
|
||
buurt = cbs_buurtprofiel(locatie["lon"], locatie["lat"])
|
||
lines = [f"# {nummer}. {locatie['adres']}", f"*Gegenereerd: {datetime.now().strftime('%d-%m-%Y %H:%M')}*", "", "## 🔍 Geocoding", f"**Adres:** {locatie['adres']}", f"**Coördinaten:** {locatie['lat']:.6f}, {locatie['lon']:.6f}", "", "## 🚗 Bereikbaarheid", print_isochroon_sectie("Auto (20 min)", "🚗", auto_iso), print_isochroon_sectie("Fiets (30 min)", "🚲", fiets_iso), "## 🏙️ Basisvoorzieningen", format_voorzieningen_markdown("🛒 **Supermarkten**", voorzieningen["supermarkten"]), "", format_voorzieningen_markdown("🏫 **Scholen**", voorzieningen["scholen"]), "", "## 👨👩👧👦 Gezinsvoorzieningen"]
|
||
titles = {"kinderopvang": "🧸 **Kinderopvang / BSO**", "speelplekken": "🛝 **Speelplekken**", "huisartsen": "🩺 **Huisartsen**", "apotheken": "💊 **Apotheken**", "bibliotheken": "📚 **Bibliotheken**", "sport": "⚽ **Sportvoorzieningen**"}
|
||
for key, title in titles.items(): lines.extend([format_voorzieningen_markdown(title, gezins.get(key, [])), ""])
|
||
lines += ["## 🏫 Schoolprofiel", "De scholen hierboven zijn nabijheidsinformatie uit OSM. Controleer onderwijsconcept, capaciteit, opvang en inspectie-/schoolcijfers rechtstreeks bij de school en DUO; deze veranderen regelmatig.", "", "## 🏘️ Buurt- en gezinsprofiel", buurt_markdown(buurt), "", "## 🛡️ Veiligheid & leefomgeving", leefomgeving_markdown(locatie), "", "## 🌦️ Klimaat & water", klimaat_markdown(locatie), "", "## ✅ Praktische gezinsscorekaart", gezins_score(gezins, buurt), ""]
|
||
map_file = None
|
||
try:
|
||
_, map_file = schrijf_naar_mkdocs(genereer_isochroon_html(locatie["adres"], locatie["lat"], locatie["lon"], auto_iso, fiets_iso), locatie["adres"], nummer, ".html")
|
||
lines += ["## 🗺️ Interactieve kaart", f"**[📍 Bekijk de isochroonkaart]({map_file})**{{:target=\"_blank\"}}", ""]
|
||
except Exception as exc: waarschuwingen.append(f"Kaart niet opgeslagen: {exc}")
|
||
if waarschuwingen:
|
||
lines += ["## ⚠️ Datakwaliteit / tijdelijke storingen"] + [f"• {x}" for x in waarschuwingen] + [""]
|
||
lines += ["---", "*Bronnen: PDOK, OpenStreetMap, ORS, CBS Wijken en Buurten 2025, Atlas Leefomgeving en Klimaateffectatlas. Geen vervanging voor bezichtiging, bouwkundige keuring of professioneel advies.*"]
|
||
text = "\n".join(lines)
|
||
path, _ = schrijf_naar_mkdocs(text, locatie["adres"], nummer, ".md")
|
||
print(text); print(f"\n📝 Rapport opgeslagen: {path}")
|
||
return path
|
||
|
||
|
||
if __name__ == "__main__":
|
||
try: genereer_rapport(sys.argv[1] if len(sys.argv) > 1 else "Griendvelden 14 Best")
|
||
except Exception as exc:
|
||
print(f"❌ Rapportgeneratie mislukt: {exc}", file=sys.stderr); raise
|