1 Commits

Author SHA1 Message Date
Hermes Agent 84f9353a51 v3.0: add family, neighbourhood and living environment analysis 2026-07-20 05:48:46 +02:00
2 changed files with 257 additions and 370 deletions
+162 -291
View File
@@ -1,321 +1,192 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """Adresanalyse voor koopwoningen: bereikbaarheid, gezin, buurt, leefomgeving en klimaat."""
Adres Analyse - Rapport generator
Gebruikt PDOK voor geocoding, ORS voor isochronen/routes en PostGIS OSM-data voor voorzieningen.
Slaat rapport en HTML-kaarten op in /root/mkdocs/docs/docs/Huizenjacht/
"""
import html
import json import json
import sys
import os import os
import re import re
import urllib.request import sys
import urllib.parse import urllib.parse
import urllib.request
from datetime import datetime from datetime import datetime
import base64 from pathlib import Path
from io import BytesIO
try: from voorzieningen import (
from PIL import Image analyse_gezinsvoorzieningen, analyse_voorzieningen, format_voorzieningen_markdown,
HAS_PIL = True )
except ImportError:
HAS_PIL = False
from voorzieningen import analyse_voorzieningen, format_voorzieningen_markdown, format_afstand, format_duur ORS_BASE = os.environ.get("ORS_BASE", "http://192.168.1.71:9080/ors/v2")
# Config
ORS_BASE = "http://192.168.1.71:9080/ors/v2"
PDOK_BASE = "https://api.pdok.nl/bzk/locatieserver/search/v3_1" PDOK_BASE = "https://api.pdok.nl/bzk/locatieserver/search/v3_1"
MKDOCS_DIR = "/root/mkdocs/docs/docs/Huizenjacht" 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): def geocode_adres(adres):
"""Geocode adres naar coördinaten via PDOK Locatieserver""" params = urllib.parse.urlencode({"q": adres, "rows": 10, "fl": "centroide_ll,weergavenaam,postcode,woonplaatsnaam,type,huisnummer"})
params = urllib.parse.urlencode({ docs = fetch_json(f"{PDOK_BASE}/free?{params}")["response"]["docs"]
'q': adres, exact = [d for d in docs if d.get("type") == "adres"]
'rows': 5, if not exact:
'fl': 'centroide_ll,weergavenaam,postcode,woonplaatsnaam,type,huisnummer' 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}
url = f"{PDOK_BASE}/free?{params}"
with urllib.request.urlopen(url, timeout=10) as resp:
data = json.loads(resp.read())
if not data['response']['docs']: def haal_isochroon(lon, lat, profiel, minuten):
raise ValueError(f"Adres niet gevonden: {adres}") 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"})
doc = None
for d in data['response']['docs']:
if d.get('type') == 'adres':
doc = d
break
if not doc:
doc = data['response']['docs'][0]
point = doc['centroide_ll']
coords = point.replace('POINT(', '').replace(')', '').split()
lon, lat = float(coords[0]), float(coords[1])
return {
'adres': doc.get('weergavenaam', adres),
'postcode': doc.get('postcode', ''),
'woonplaats': doc.get('woonplaatsnaam', ''),
'lat': lat,
'lon': lon
}
def haal_isochroon(lon, lat, profiel='driving-car', minuten=20):
"""Haal isochroon op van ORS"""
url = f"{ORS_BASE}/isochrones/{profiel}"
payload = {
"locations": [[lon, lat]],
"range": [minuten * 60],
"attributes": ["area", "reachfactor", "total_pop"]
}
req = urllib.request.Request(
url,
data=json.dumps(payload).encode(),
headers={'Content-Type': 'application/json'},
method='POST'
)
with urllib.request.urlopen(req, timeout=30) as resp:
return json.loads(resp.read())
def format_oppervlakte(m2): def format_oppervlakte(m2):
"""Converteer m² naar leesbaar formaat""" return f"{m2 / 1_000_000:.1f} km²" if m2 >= 1_000_000 else f"{m2 / 10_000:.1f} ha"
if m2 >= 1_000_000:
return f"{m2/1_000_000:.1f} km²"
elif m2 >= 10_000:
return f"{m2/10_000:.1f} ha"
else:
return f"{m2:.0f} m²"
def schrijf_naar_mkdocs(content, adres, nummer, ext=".md"):
"""Schrijf content naar MkDocs docs map als bestand met oplopend nummer"""
os.makedirs(MKDOCS_DIR, exist_ok=True)
safe_name = re.sub(r'[^a-zA-Z0-9]+', '-', adres)
safe_name = safe_name.strip('-')
filename = f"{nummer}-{safe_name}_{datetime.now().strftime('%Y%m%d_%H%M%S')}{ext}"
filepath = os.path.join(MKDOCS_DIR, filename)
with open(filepath, 'w', encoding='utf-8') as f: def hoogste_rapportnummer():
f.write(content) 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
return filepath, filename
def genereer_isochroon_html(adres_naam, lat, lon, auto_iso, fiets_iso, voorzieningen=None): def schrijf_naar_mkdocs(content, adres, nummer, ext):
"""Genereer een zelf-contained HTML kaart met Leaflet + POI markers""" safe = re.sub(r"[^a-zA-Z0-9]+", "-", adres).strip("-")
# Bouw JSON voor POI markers filename = f"{nummer}-{safe}_{datetime.now().strftime('%Y%m%d_%H%M%S')}{ext}"
poi_json = {"supermarkten": [], "scholen": []} path = MKDOCS_DIR / filename
if voorzieningen: path.write_text(content, encoding="utf-8")
for s in voorzieningen.get("supermarkten", []): return path, filename
poi_json["supermarkten"].append({
"naam": s.naam, "lat": s.lat, "lon": s.lon,
"hemelsbreed": round(s.hemelsbreed_m),
"fiets": f"{format_afstand(s.fiets_afstand_m)} / {format_duur(s.fiets_duur_s)}" if s.fiets_afstand_m else "n.b.",
"auto": f"{format_afstand(s.auto_afstand_m)} / {format_duur(s.auto_duur_s)}" if s.auto_afstand_m else "n.b.",
})
for s in voorzieningen.get("scholen", []):
poi_json["scholen"].append({
"naam": s.naam, "lat": s.lat, "lon": s.lon,
"hemelsbreed": round(s.hemelsbreed_m),
"fiets": f"{format_afstand(s.fiets_afstand_m)} / {format_duur(s.fiets_duur_s)}" if s.fiets_afstand_m else "n.b.",
"auto": f"{format_afstand(s.auto_afstand_m)} / {format_duur(s.auto_duur_s)}" if s.auto_afstand_m else "n.b.",
})
html = f'''<!DOCTYPE html>
<html lang="nl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Isochroon - {adres_naam}</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<style>
body {{ margin: 0; padding: 0; font-family: system-ui, -apple-system, sans-serif; }}
#map {{ height: 100vh; width: 100vw; }}
.legend {{ background: white; padding: 10px; border-radius: 4px; box-shadow: 0 0 15px rgba(0,0,0,0.2); line-height: 1.5; }}
.legend i {{ width: 18px; height: 18px; float: left; margin-right: 8px; opacity: 0.7; border: 1px solid #333; }}
</style>
</head>
<body>
<div id="map"></div>
<script>
var autoGeojson = {json.dumps(auto_iso, ensure_ascii=False)};
var fietsGeojson = {json.dumps(fiets_iso, ensure_ascii=False)};
var voorzieningen = {json.dumps(poi_json, ensure_ascii=False)};
var map = L.map('map').setView([{lat}, {lon}], 11);
L.tileLayer('https://{{s}}.tile.openstreetmap.org/{{z}}/{{x}}/{{y}}.png', {{
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
}}).addTo(map);
var autoLayer = L.geoJSON(autoGeojson, {{
style: {{ color: '#2563eb', weight: 2, fillColor: '#3b82f6', fillOpacity: 0.2 }},
onEachFeature: function(feature, layer) {{
var props = feature.properties;
layer.bindPopup('<b>Auto 20 min</b><br>Oppervlakte: ' + (props.area/1000000).toFixed(1) + ' km²<br>Reach factor: ' + props.reachfactor.toFixed(3));
}}
}}).addTo(map);
var fietsLayer = L.geoJSON(fietsGeojson, {{
style: {{ color: '#16a34a', weight: 2, fillColor: '#22c55e', fillOpacity: 0.2 }},
onEachFeature: function(feature, layer) {{
var props = feature.properties;
layer.bindPopup('<b>Fiets 30 min</b><br>Oppervlakte: ' + (props.area/1000000).toFixed(1) + ' km²<br>Reach factor: ' + props.reachfactor.toFixed(3));
}}
}}).addTo(map);
// Adres marker
L.marker([{lat}, {lon}]).addTo(map)
.bindPopup('<b>{adres_naam}</b>').openPopup();
// POI markers
var supermarktIcon = L.divIcon({{
className: 'poi-marker-supermarkt',
html: '<div style="background:#2563eb;color:white;width:24px;height:24px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:14px;font-weight:bold;border:2px solid white;box-shadow:0 1px 4px rgba(0,0,0,0.3)">S</div>',
iconSize: [24, 24],
iconAnchor: [12, 12],
popupAnchor: [0, -14]
}});
var schoolIcon = L.divIcon({{
className: 'poi-marker-school',
html: '<div style="background:#ea580c;color:white;width:24px;height:24px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:14px;font-weight:bold;border:2px solid white;box-shadow:0 1px 4px rgba(0,0,0,0.3)">S</div>',
iconSize: [24, 24],
iconAnchor: [12, 12],
popupAnchor: [0, -14]
}});
// Supermarkten
if (voorzieningen.supermarkten) {{
voorzieningen.supermarkten.forEach(function(p) {{
L.marker([p.lat, p.lon], {{icon: supermarktIcon}}).addTo(map)
.bindPopup(
'<b>🛒 ' + p.naam + '</b><br>' +
'Hemelsbreed: ' + p.hemelsbreed + ' m<br>' +
'Fiets: ' + p.fiets + '<br>' +
'Auto: ' + p.auto
);
}});
}}
// Scholen
if (voorzieningen.scholen) {{
voorzieningen.scholen.forEach(function(p) {{
L.marker([p.lat, p.lon], {{icon: schoolIcon}}).addTo(map)
.bindPopup(
'<b>🏫 ' + p.naam + '</b><br>' +
'Hemelsbreed: ' + p.hemelsbreed + ' m<br>' +
'Fiets: ' + p.fiets + '<br>' +
'Auto: ' + p.auto
);
}});
}}
var legend = L.control({{position: 'bottomright'}});
legend.onAdd = function(map) {{
var div = L.DomUtil.create('div', 'legend');
div.innerHTML += '<h4 style="margin:0 0 5px 0">Isochronen</h4>';
div.innerHTML += '<i style="background:#3b82f6"></i> Auto 20 min<br>';
div.innerHTML += '<i style="background:#22c55e"></i> Fiets 30 min<br>';
div.innerHTML += '<i style="background:#ef4444"></i> Adres<br>';
div.innerHTML += '<i style="background:#2563eb;border-radius:50%;width:12px;height:12px;border:1px solid #999"></i> Supermarkt<br>';
div.innerHTML += '<i style="background:#ea580c;border-radius:50%;width:12px;height:12px;border:1px solid #999"></i> School';
return div;
}};
legend.addTo(map);
map.fitBounds(autoLayer.getBounds().extend(fietsLayer.getBounds()));
</script>
</body>
</html>'''
return html
def print_isochroon_sectie(titel, icoon, iso_data): def print_isochroon_sectie(titel, icoon, iso_data):
"""Genereer isochroon sectie als tekst""" if not iso_data or not iso_data.get("features"):
lines = [] return f"{icoon} **{titel}**\nNiet beschikbaar (route-service reageerde niet).\n"
lines.append(f"{icoon} **{titel}**") props = iso_data["features"][0].get("properties", {})
if 'features' in iso_data and iso_data['features']: lines = [f"{icoon} **{titel}**", f"**Oppervlakte:** {format_oppervlakte(props.get('area', 0))}", f"**Reach factor:** {props.get('reachfactor', 0):.3f}"]
props = iso_data['features'][0]['properties'] if len(iso_data.get("bbox", [])) == 4:
area_m2 = props.get('area', 0) b = iso_data["bbox"]
reach = props.get('reachfactor', 0) lines.append(f"**Bereik:** {b[1]:.3f}°–{b[3]:.3f} N, {b[0]:.3f}°–{b[2]:.3f} E")
lines.append(f"**Oppervlakte:** {format_oppervlakte(area_m2)}") return "\n".join(lines) + "\n"
lines.append(f"**Reach factor:** {reach:.3f}")
bbox = iso_data.get('bbox', [])
if len(bbox) == 4: def wgs84_naar_rd(lon, lat):
lines.append(f"**Bereik:** {bbox[1]:.3f}°-{bbox[3]:.3f}°N, {bbox[0]:.3f}°-{bbox[2]:.3f}°E") """WGS84 naar RD New (EPSG:28992), voldoende nauwkeurig voor point-in-buurt-WFS."""
else: dphi, dlambda = 0.36 * (lat - 52.15517440), 0.36 * (lon - 5.38720621)
lines.append("❌ Geen isochroon data ontvangen") 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)
lines.append("") 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) 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): def genereer_rapport(adres_input):
"""Genereer volledig adres analyse rapport""" nummer, waarschuwingen = hoogste_rapportnummer(), []
lines = []
# Tel bestaande genummerde rapporten voor oplopend nummer
# Alleen bestanden die beginnen met cijfer-dash (bv. "1-Griendvelden...")
alle_files = os.listdir(MKDOCS_DIR) if os.path.exists(MKDOCS_DIR) else []
genummerde_files = [f for f in alle_files if f.endswith('.md') and re.match(r'^\d+-', f)]
nummer = len(genummerde_files) + 1
lines.append(f"# {nummer}. {adres_input}")
lines.append(f"*Gegenereerd: {datetime.now().strftime('%d-%m-%Y %H:%M')}*")
lines.append("")
lines.append("## 🔍 Geocoding")
locatie = geocode_adres(adres_input) locatie = geocode_adres(adres_input)
lines.append(f"**Adres:** {locatie['adres']}") auto_iso = fiets_iso = None
lines.append(f"**Coördinaten:** {locatie['lat']:.6f}, {locatie['lon']:.6f}") for naam, profiel, minuten in [("auto", "driving-car", 20), ("fiets", "cycling-regular", 30)]:
lines.append("") 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
auto_iso = haal_isochroon(locatie['lon'], locatie['lat'], 'driving-car', 20)
fiets_iso = haal_isochroon(locatie['lon'], locatie['lat'], 'cycling-regular', 30)
lines.append("## 🚗 Bereikbaarheid") if __name__ == "__main__":
lines.append(print_isochroon_sectie("Auto (20 min)", "🚗", auto_iso)) try: genereer_rapport(sys.argv[1] if len(sys.argv) > 1 else "Griendvelden 14 Best")
lines.append(print_isochroon_sectie("Fiets (30 min)", "🚲", fiets_iso)) except Exception as exc:
print(f"❌ Rapportgeneratie mislukt: {exc}", file=sys.stderr); raise
lines.append("## 🏙️ Voorzieningen dichtbij")
voorzieningen = analyse_voorzieningen(locatie['lon'], locatie['lat'], limit=5)
lines.append(format_voorzieningen_markdown("🛒 **Supermarkten**", voorzieningen['supermarkten']))
lines.append("")
lines.append(format_voorzieningen_markdown("🏫 **Scholen**", voorzieningen['scholen']))
lines.append("")
# Genereer interactieve HTML kaart en sla op in MkDocs
html_content = genereer_isochroon_html(
locatie['adres'], locatie['lat'], locatie['lon'], auto_iso, fiets_iso,
voorzieningen=voorzieningen
)
html_filepath, html_filename = schrijf_naar_mkdocs(html_content, locatie['adres'], nummer, ext=".html")
# Voeg link toe aan markdown rapport (betrouwbaar via directe link)
lines.append("## 🗺️ Interactieve kaart")
lines.append(f"")
lines.append(f"**[📍 Bekijk interactieve isochroon kaart]({html_filename})**{{:target=\"_blank\"}}")
lines.append("")
lines.append("*Klik op de link om de kaart in een nieuw tabblad te openen. Hier kun je zoomen, pannen en de isochronen bekijken.*")
lines.append("")
# Ruwe geojson
output_file = f"/tmp/isochronen_{datetime.now().strftime('%Y%m%d_%H%M%S')}.geojson"
with open(output_file, 'w') as f:
json.dump({'auto_20_min': auto_iso, 'fiets_30_min': fiets_iso}, f)
lines.append("---")
lines.append("*Onderdelen: geocoding, isochronen, voorzieningen, interactieve kaart*")
rapport_text = "\n".join(lines)
# Print naar stdout
print(rapport_text)
# Sla op in MkDocs
md_filepath, md_filename = schrijf_naar_mkdocs(rapport_text, adres_input, nummer, ext=".md")
print(f"\n📝 Rapport opgeslagen: {md_filepath}")
if __name__ == '__main__':
adres = sys.argv[1] if len(sys.argv) > 1 else "Griendvelden 14 Best"
genereer_rapport(adres)
+89 -73
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Voorzieningenanalyse op basis van lokale PostGIS OSM-data + ORS Matrix.""" """Voorzieningenanalyse met lokale PostGIS-OSM-data en ORS Matrix-routes."""
import json import json
import math import math
@@ -45,113 +45,129 @@ def _matrix(lon, lat, pois, profiel):
"metrics": ["distance", "duration"], "metrics": ["distance", "duration"],
}).encode() }).encode()
req = urllib.request.Request( req = urllib.request.Request(
f"{ORS_BASE}/matrix/{profiel}", f"{ORS_BASE}/matrix/{profiel}", data=payload,
data=payload, headers={"Content-Type": "application/json"}, method="POST",
headers={"Content-Type": "application/json"},
method="POST",
) )
with urllib.request.urlopen(req, timeout=60) as resp: with urllib.request.urlopen(req, timeout=60) as resp:
data = json.loads(resp.read()) data = json.loads(resp.read())
distances = data.get("distances", [[]])[0] return list(zip(data.get("distances", [[]])[0], data.get("durations", [[]])[0]))
durations = data.get("durations", [[]])[0]
return list(zip(distances, durations))
def verrijk_met_routes(lon, lat, pois): def verrijk_met_routes(lon, lat, pois):
fiets = _matrix(lon, lat, pois, "cycling-regular") """Verrijk POI's; een defect profiel maakt het hele rapport niet onbruikbaar."""
auto = _matrix(lon, lat, pois, "driving-car") for profiel, afstand_attr, duur_attr in [
for i, poi in enumerate(pois): ("cycling-regular", "fiets_afstand_m", "fiets_duur_s"),
if i < len(fiets): ("driving-car", "auto_afstand_m", "auto_duur_s"),
poi.fiets_afstand_m, poi.fiets_duur_s = fiets[i] ]:
if i < len(auto): try:
poi.auto_afstand_m, poi.auto_duur_s = auto[i] routes = _matrix(lon, lat, pois, profiel)
except Exception:
routes = []
for poi, route in zip(pois, routes):
setattr(poi, afstand_attr, route[0])
setattr(poi, duur_attr, route[1])
return pois return pois
def zoek_voorzieningen(lon, lat, categorie, limit=5, radius_m=10000): def _haversine_m(lon1, lat1, lon2, lat2):
if categorie == "supermarkt": radius = 6_371_000
view = "poi_supermarkten" phi1, phi2 = math.radians(lat1), math.radians(lat2)
elif categorie == "school": dphi, dlambda = math.radians(lat2 - lat1), math.radians(lon2 - lon1)
view = "poi_scholen" a = math.sin(dphi / 2) ** 2 + math.cos(phi1) * math.cos(phi2) * math.sin(dlambda / 2) ** 2
else: return 2 * radius * math.asin(math.sqrt(a))
raise ValueError(f"Onbekende categorie: {categorie}")
def zoek_voorzieningen(lon, lat, categorie, limit=5, radius_m=10000):
views = {"supermarkt": "poi_supermarkten", "school": "poi_scholen"}
if categorie not in views:
raise ValueError(f"Onbekende lokale categorie: {categorie}")
sql = f""" sql = f"""
WITH origin AS ( WITH origin AS (SELECT ST_SetSRID(ST_MakePoint(%s, %s), 4326)::geography AS geog)
SELECT ST_SetSRID(ST_MakePoint(%s, %s), 4326)::geography AS geog SELECT DISTINCT ON (lower(name)) name, ST_Y(geom) AS lat, ST_X(geom) AS lon,
)
SELECT DISTINCT ON (lower(name))
name,
ST_Y(geom) AS lat,
ST_X(geom) AS lon,
ST_Distance(geom::geography, origin.geog) AS hemelsbreed_m ST_Distance(geom::geography, origin.geog) AS hemelsbreed_m
FROM {view}, origin FROM {views[categorie]}, origin
WHERE geom IS NOT NULL WHERE geom IS NOT NULL AND ST_DWithin(geom::geography, origin.geog, %s)
AND ST_DWithin(geom::geography, origin.geog, %s)
ORDER BY lower(name), hemelsbreed_m ASC ORDER BY lower(name), hemelsbreed_m ASC
""" """
# DISTINCT ON kiest dichtstbijzijnde per naam; buitenquery sorteert weer op afstand.
wrapped = f"SELECT * FROM ({sql}) q ORDER BY hemelsbreed_m ASC LIMIT %s"
with psycopg2.connect(DB_DSN) as conn: with psycopg2.connect(DB_DSN) as conn:
with conn.cursor(cursor_factory=psycopg2.extras.DictCursor) as cur: with conn.cursor(cursor_factory=psycopg2.extras.DictCursor) as cur:
cur.execute(wrapped, (lon, lat, radius_m, limit)) cur.execute(f"SELECT * FROM ({sql}) q ORDER BY hemelsbreed_m ASC LIMIT %s", (lon, lat, radius_m, limit))
rows = cur.fetchall() rows = cur.fetchall()
return [ return [Voorziening(categorie, row["name"], float(row["lat"]), float(row["lon"]), float(row["hemelsbreed_m"])) for row in rows]
Voorziening(
categorie=categorie,
naam=row["name"], def zoek_osm_voorzieningen(lon, lat, categorie, tags, limit=5, radius_m=10000):
lat=float(row["lat"]), """Vraag kleinschalige gezins-POI's live op via Overpass, met een vaste fallback-endpoint."""
lon=float(row["lon"]), south, west = lat - radius_m / 111_320, lon - radius_m / (111_320 * math.cos(math.radians(lat)))
hemelsbreed_m=float(row["hemelsbreed_m"]), north, east = lat + radius_m / 111_320, lon + radius_m / (111_320 * math.cos(math.radians(lat)))
tag_filter = "".join(f'["{key}"="{value}"]' for key, value in tags.items())
query = f"[out:json][timeout:45];(nwr{tag_filter}({south},{west},{north},{east}););out center;"
request = urllib.request.Request(
"https://overpass.kumi.systems/api/interpreter", data=query.encode(),
headers={"Content-Type": "application/x-www-form-urlencoded", "User-Agent": "adres-analyse/3.0"},
) )
for row in rows with urllib.request.urlopen(request, timeout=70) as response:
] elements = json.loads(response.read()).get("elements", [])
found = []
for element in elements:
point = element.get("center", element)
if "lat" not in point or "lon" not in point:
continue
name = element.get("tags", {}).get("name") or categorie.title()
found.append(Voorziening(categorie, name, float(point["lat"]), float(point["lon"]), _haversine_m(lon, lat, point["lon"], point["lat"]), bron="OpenStreetMap/Overpass"))
# Een naam komt vaak als node én als vlak voor; behoud de dichtstbijzijnde.
unique = {}
for poi in sorted(found, key=lambda p: p.hemelsbreed_m):
unique.setdefault(poi.naam.lower(), poi)
return list(unique.values())[:limit]
def analyse_voorzieningen(lon, lat, limit=5): def analyse_voorzieningen(lon, lat, limit=5):
supermarkten = zoek_voorzieningen(lon, lat, "supermarkt", limit=limit, radius_m=10000) result = {
scholen = zoek_voorzieningen(lon, lat, "school", limit=limit, radius_m=15000) "supermarkten": zoek_voorzieningen(lon, lat, "supermarkt", limit, 10000),
verrijk_met_routes(lon, lat, supermarkten) "scholen": zoek_voorzieningen(lon, lat, "school", limit, 15000),
verrijk_met_routes(lon, lat, scholen) }
return {"supermarkten": supermarkten, "scholen": scholen} for pois in result.values():
verrijk_met_routes(lon, lat, pois)
return result
def analyse_gezinsvoorzieningen(lon, lat, limit=5):
specs = {
"kinderopvang": ({"amenity": "kindergarten"}, 10000),
"speelplekken": ({"leisure": "playground"}, 5000),
"huisartsen": ({"amenity": "doctors"}, 10000),
"apotheken": ({"amenity": "pharmacy"}, 10000),
"bibliotheken": ({"amenity": "library"}, 15000),
"sport": ({"leisure": "sports_centre"}, 10000),
}
result = {}
for key, (tags, radius) in specs.items():
try:
result[key] = zoek_osm_voorzieningen(lon, lat, key, tags, limit, radius)
verrijk_met_routes(lon, lat, result[key])
except Exception as exc:
result[key] = []
result[f"{key}_fout"] = str(exc)
return result
def format_afstand(m): def format_afstand(m):
if m is None: if m is None:
return "n.b." return "n.b."
if m < 1000: return f"{m:.0f} m" if m < 1000 else f"{m/1000:.1f} km".replace(".", ",")
return f"{m:.0f} m"
return f"{m/1000:.1f} km".replace(".", ",")
def format_duur(s): def format_duur(s):
if s is None: if s is None:
return "n.b." return "n.b."
minutes = max(1, round(s / 60)) return f"{max(1, round(s / 60))} min"
return f"{minutes} min"
def format_voorzieningen_markdown(titel, pois): def format_voorzieningen_markdown(titel, pois):
lines = [titel] lines = [titel]
if not pois: if not pois:
lines.append("Geen voorzieningen gevonden in zoekradius.") return "\n".join(lines + ["Geen voorzieningen gevonden of bron tijdelijk niet beschikbaar."])
return "\n".join(lines)
for p in pois: for p in pois:
lines.append( lines.append(f"• **{p.naam}** — Hemelsbreed: {format_afstand(p.hemelsbreed_m)} | Fiets: {format_afstand(p.fiets_afstand_m)} / {format_duur(p.fiets_duur_s)} | Auto: {format_afstand(p.auto_afstand_m)} / {format_duur(p.auto_duur_s)}")
f"• **{p.naam}** — "
f"Hemelsbreed: {format_afstand(p.hemelsbreed_m)} | "
f"Fiets: {format_afstand(p.fiets_afstand_m)} / {format_duur(p.fiets_duur_s)} | "
f"Auto: {format_afstand(p.auto_afstand_m)} / {format_duur(p.auto_duur_s)}"
)
return "\n".join(lines) return "\n".join(lines)
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser()
parser.add_argument("lon", type=float)
parser.add_argument("lat", type=float)
parser.add_argument("--limit", type=int, default=5)
args = parser.parse_args()
result = analyse_voorzieningen(args.lon, args.lat, args.limit)
print(json.dumps({k: [p.to_dict() for p in v] for k, v in result.items()}, indent=2, ensure_ascii=False))