6 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
Hermes Agent 3edea47e88 Release v2.5 - POI markers op interactieve kaart
- Supermarkt en school markers als gekleurde cirkels op de Leaflet kaart
- Popups met hemelsbreed/fiets/auto afstanden per voorziening
- Legenda uitgebreid met POI categorieën
- voorzieningen.py: format_afstand/format_duur exports toegevoegd
2026-07-19 06:15:17 +02:00
Hermes Agent d9da2964dd v2.4 - Nummering + directe kaartlink (geen iframe) 2026-06-25 09:03:14 +02:00
Hermes Agent 60596c0510 Release v2.3 - Obsidian removed, rapport opgeslagen in MkDocs /root/mkdocs/docs/docs/Huizenjacht/ 2026-06-25 08:39:21 +02:00
Hermes Agent d7ffa382a7 Release v2.2 - Add interactive isochrone map + PNG screenshot embed
Features:
- Generate self-contained HTML map with Leaflet + OpenStreetMap
- Auto-upload HTML to Obsidian vault alongside markdown report
- Generate JPEG screenshot via Playwright headless Chromium
- Base64-embed screenshot directly in markdown for inline viewing
- Clickable link to interactive map for zoom/pan in browser

Dependencies: playwright, Pillow (optional, graceful fallback)

Closes: isochrone visibility in Obsidian
2026-06-25 08:39:21 +02:00
wouser 5c6fc8c277 Release v2.2 - adres analyse skill 2026-06-22 13:47:15 +02:00
4 changed files with 439 additions and 282 deletions
+28 -17
View File
@@ -38,13 +38,14 @@ Output is markdown formaat, direct naar stdout (voor Telegram) én wordt opgesla
- **Fiets profiel:** `cycling-regular`, 30 minuten, groen op de kaart - **Fiets profiel:** `cycling-regular`, 30 minuten, groen op de kaart
- **Metrics:** Oppervlakte (km²), reach factor, geografisch bereik - **Metrics:** Oppervlakte (km²), reach factor, geografisch bereik
- **Data bron:** ORS op `192.168.1.71:9080` - **Data bron:** ORS op `192.168.1.71:9080`
- **Interactieve kaart:** Het script genereert automatisch een zelf-contained HTML kaart (Leaflet + OpenStreetMap) met beide isochronen en het adres als marker. Deze wordt opgeslagen in Obsidian naast het markdown rapport. In Obsidian verschijnt een link `[Bekijk interactieve isochroon kaart](huizenjacht/...html)` — klik erop om in je browser te openen, zoomen en pannen.
### 2. Voorzieningen in de buurt ### 2. Voorzieningen in de buurt
- **Bron:** lokale PostGIS database met geselecteerde POI's uit heel Nederland OSM extract - **Bron:** lokale PostGIS database met geselecteerde POI's uit heel Nederland OSM extract
- **Docker:** `postgis/postgis` op poort `5433`, database `adresanalyse` - **Docker:** `postgis/postgis` op poort `5433`, database `adresanalyse`
- **Import:** `import-osm.sh` downloadt `netherlands-latest.osm.pbf` en `osm-poi-import.py` importeert alleen `shop=supermarket` en `amenity=school` - **Import:** `import-osm.sh` downloadt `netherlands-latest.osm.pbf` en `osm-poi-import.py` importeert alleen `shop=supermarket` en `amenity=school`
- **Routeverrijking:** ORS Matrix voor `driving-car` en `cycling-regular` - **Routeverrijking:** ORS Matrix voor `driving-car` en `cycling-regular`
- **Output:** top 5 supermarkten en top 5 scholen in rapport + markers op Folium kaart - **Output:** top 5 supermarkten en top 5 scholen in rapport
### 3. (Toekomstige onderdelen) ### 3. (Toekomstige onderdelen)
- OV bereikbaarheid - OV bereikbaarheid
@@ -84,19 +85,16 @@ curl -s -X POST "http://192.168.1.71:9080/ors/v2/isochrones/driving-car" \
- Response bevat GeoJSON FeatureCollection met polygon geometrie - Response bevat GeoJSON FeatureCollection met polygon geometrie
- GeoJSON wordt opgeslagen in `/tmp/isochroon_{timestamp}.geojson` - GeoJSON wordt opgeslagen in `/tmp/isochroon_{timestamp}.geojson`
### Stap 3: Kaart genereren (optioneel) ### Stap 3: Interactieve kaart + screenshot genereren (automatisch)
Gebruik script: `scripts/generate-map.py` Het script genereert twee bestanden:
1. **HTML kaart** (Leaflet + OpenStreetMap) met inline GeoJSON — opgeslagen in Obsidian als `.html`. In Obsidian verschijnt een link om de kaart in de browser te openen voor zoomen/pannen.
2. **JPEG screenshot** van de kaart — direct gebase64-embed in het markdown rapport. Je ziet de kaart direct in Obsidian zonder te klikken.
```bash Vereisten voor screenshot:
python3 scripts/generate-map.py "Griendvelden 14, Best" /root/adres-analyse/kaart.html - `playwright` (`pip install playwright; playwright install chromium`)
``` - `Pillow` (`pip install Pillow`)
Voor Telegram delivery: Als playwright niet geïnstalleerd is, wordt de screenshot overgeslagen met een waarschuwing; de rest van het rapport werkt gewoon.
1. Open HTML in browser: `browser_navigate` naar `file:///root/adres-analyse/kaart.html`
2. Screenshot maken: `browser_vision` met vraag over kaart inhoud
3. Screenshot pad gebruiken in MEDIA: directive voor Telegram
**Vereisten:** `pip install folium shapely`
## Pitfalls ## Pitfalls
@@ -108,11 +106,11 @@ Voor Telegram delivery:
4. **Oppervlakte conversie**: ORS geeft area in m², niet km². Deel door 1_000_000 voor km² of 10_000 voor hectare. 4. **Oppervlakte conversie**: ORS geeft area in m², niet km². Deel door 1_000_000 voor km² of 10_000 voor hectare.
5. **Folium installatie**: `pip install folium shapely` nodig voor kaartgeneratie (niet voor basis rapport). 5. **PNG screenshot vereist playwright + Pillow**: De base64-afbeelding in het markdown wordt gegenereerd met een headless Chromium via Playwright. Zonder deze dependencies wordt de screenshot overgeslagen (het rapport blijft werken). Installeer met: `pip install playwright Pillow && playwright install chromium`.
6. **Telegram media**: HTML-kaarten werken niet direct in Telegram - maak PNG screenshots via browser tools. 6. **Base64-afbeelding maakt markdown groter**: De JPEG is ~80-120KB base64. Dit is acceptabel voor een enkel rapport, maar het markdown bestand wordt merkbaar groter dan tekst-alleen.
7. **Rapport zonder kaart**: Het rapport.py script genereert alleen tekst. Voor visuele kaart: gebruik apart folium script + browser screenshot workflow. 7. **Obsidian HTML links**: Obsidian opent `.html` bestanden in de systeembrowser (niet in de Obsidian viewer). De link werkt alleen als het HTML bestand in dezelfde vault staat.
8. **Obsidian API**: Vereist dat Obsidian draait met Local REST API plugin ingeschakeld. Token en host staan in script. Bestandsnaam wordt gegenereerd met timestamp om collisions te voorkomen. 8. **Obsidian API**: Vereist dat Obsidian draait met Local REST API plugin ingeschakeld. Token en host staan in script. Bestandsnaam wordt gegenereerd met timestamp om collisions te voorkomen.
@@ -143,8 +141,21 @@ Gegenereerd: 19-06-2026 18:42
**Reach factor:** 0.091 **Reach factor:** 0.091
**Bereik:** 51.378°-51.679°N, 5.128°-5.530°E **Bereik:** 51.378°-51.679°N, 5.128°-5.530°E
💾 Data opgeslagen: `/tmp/isochroon_20260619_184239.geojson` 🚲 **Bereikbaarheid (Fiets 30 min)**
**Oppervlakte:** 145.9 km²
**Reach factor:** 0.826
**Bereik:** 51.440°-51.570°N, 5.251°-5.486°E
🏙️ **Voorzieningen dichtbij**
...
🗺️ **[Bekijk interactieve isochroon kaart](huizenjacht/Griendvelden-14-5685JL-Best_20260622_111308.html)**
**🗺️ Isochroon kaart (auto + fiets):**
![Isochroon kaart](data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD...)
💾 Ruwe isochroondata: `/tmp/isochronen_20260622_111308.geojson`
--- ---
_Onderdeel 1/?: Isochroon bereikbaarheid_ _Onderdelen: geocoding, isochronen, voorzieningen_
``` ```
+169 -109
View File
@@ -1,132 +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.
"""
import html
import json import json
import os
import re
import sys import sys
import urllib.request
import urllib.parse import urllib.parse
import urllib.request
from datetime import datetime from datetime import datetime
from pathlib import Path
from voorzieningen import analyse_voorzieningen, format_voorzieningen_markdown from voorzieningen import (
analyse_gezinsvoorzieningen, analyse_voorzieningen, format_voorzieningen_markdown,
)
# Config ORS_BASE = os.environ.get("ORS_BASE", "http://192.168.1.71:9080/ors/v2")
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"
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: def hoogste_rapportnummer():
return f"{m2/10_000:.1f} ha" MKDOCS_DIR.mkdir(parents=True, exist_ok=True)
else: numbers = [int(m.group(1)) for path in MKDOCS_DIR.glob("*.md") if (m := re.match(r"^(\d+)-", path.name))]
return f"{m2:.0f} m²" 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): def print_isochroon_sectie(titel, icoon, iso_data):
print(f"{icoon} **{titel}**") if not iso_data or not iso_data.get("features"):
if 'features' in iso_data and iso_data['features']: return f"{icoon} **{titel}**\nNiet beschikbaar (route-service reageerde niet).\n"
props = iso_data['features'][0]['properties'] props = iso_data["features"][0].get("properties", {})
area_m2 = props.get('area', 0) lines = [f"{icoon} **{titel}**", f"**Oppervlakte:** {format_oppervlakte(props.get('area', 0))}", f"**Reach factor:** {props.get('reachfactor', 0):.3f}"]
reach = props.get('reachfactor', 0) if len(iso_data.get("bbox", [])) == 4:
print(f"**Oppervlakte:** {format_oppervlakte(area_m2)}") b = iso_data["bbox"]
print(f"**Reach factor:** {reach:.3f}") lines.append(f"**Bereik:** {b[1]:.3f}°–{b[3]:.3f} N, {b[0]:.3f}°–{b[2]:.3f} E")
bbox = iso_data.get('bbox', []) return "\n".join(lines) + "\n"
if len(bbox) == 4:
print(f"**Bereik:** {bbox[1]:.3f}°-{bbox[3]:.3f}°N, {bbox[0]:.3f}°-{bbox[2]:.3f}°E")
else: def wgs84_naar_rd(lon, lat):
print("❌ Geen isochroon data ontvangen") """WGS84 naar RD New (EPSG:28992), voldoende nauwkeurig voor point-in-buurt-WFS."""
print() 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): def genereer_rapport(adres_input):
"""Genereer volledig adres analyse rapport""" nummer, waarschuwingen = hoogste_rapportnummer(), []
print(f"📍 **Adres Analyse Rapport**")
print(f"Gegenereerd: {datetime.now().strftime('%d-%m-%Y %H:%M')}")
print()
print("🔍 Geocoding...")
locatie = geocode_adres(adres_input) locatie = geocode_adres(adres_input)
print(f"**Adres:** {locatie['adres']}") auto_iso = fiets_iso = None
print(f"**Coördinaten:** {locatie['lat']:.6f}, {locatie['lon']:.6f}") for naam, profiel, minuten in [("auto", "driving-car", 20), ("fiets", "cycling-regular", 30)]:
print() 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)
print_isochroon_sectie("Bereikbaarheid (Auto 20 min)", "🚗", auto_iso)
print_isochroon_sectie("Bereikbaarheid (Fiets 30 min)", "🚲", fiets_iso)
print("🏙️ **Voorzieningen dichtbij**") if __name__ == "__main__":
voorzieningen = analyse_voorzieningen(locatie['lon'], locatie['lat'], limit=5) try: genereer_rapport(sys.argv[1] if len(sys.argv) > 1 else "Griendvelden 14 Best")
print(format_voorzieningen_markdown("🛒 **Supermarkten**", voorzieningen['supermarkten'])) except Exception as exc:
print() print(f"❌ Rapportgeneratie mislukt: {exc}", file=sys.stderr); raise
print(format_voorzieningen_markdown("🏫 **Scholen**", voorzieningen['scholen']))
print()
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)
print(f"💾 Isochroondata opgeslagen: `{output_file}`")
print()
print("---")
print("_Onderdelen: geocoding, isochronen, voorzieningen_")
if __name__ == '__main__':
adres = sys.argv[1] if len(sys.argv) > 1 else "Griendvelden 14 Best"
genereer_rapport(adres)
+142 -72
View File
@@ -2,23 +2,31 @@
""" """
Adres Analyse - Rapport generator Adres Analyse - Rapport generator
Gebruikt PDOK voor geocoding, ORS voor isochronen/routes en PostGIS OSM-data voor voorzieningen. Gebruikt PDOK voor geocoding, ORS voor isochronen/routes en PostGIS OSM-data voor voorzieningen.
Slaat rapport ook op in Obsidian vault (map: huizenjacht). Slaat rapport en HTML-kaarten op in /root/mkdocs/docs/docs/Huizenjacht/
""" """
import json import json
import sys import sys
import os
import re
import urllib.request import urllib.request
import urllib.parse import urllib.parse
import re
from datetime import datetime from datetime import datetime
import base64
from io import BytesIO
try:
from PIL import Image
HAS_PIL = True
except ImportError:
HAS_PIL = False
from voorzieningen import analyse_voorzieningen, format_voorzieningen_markdown from voorzieningen import analyse_voorzieningen, format_voorzieningen_markdown
# Config # Config
ORS_BASE = "http://192.168.1.71:9080/ors/v2" 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"
OBSIDIAN_BASE = "http://192.168.1.50:27123" MKDOCS_DIR = "/root/mkdocs/docs/docs/Huizenjacht"
OBSIDIAN_TOKEN = "90ec" + "6de948dc8ef7832fda3516fd900c7a3071dd526edf852ade145aa9daf637"
def geocode_adres(adres): def geocode_adres(adres):
"""Geocode adres naar coördinaten via PDOK Locatieserver""" """Geocode adres naar coördinaten via PDOK Locatieserver"""
@@ -84,103 +92,165 @@ def format_oppervlakte(m2):
else: else:
return f"{m2:.0f} m²" return f"{m2:.0f} m²"
def schrijf_naar_obsidian(rapport_text, adres): def schrijf_naar_mkdocs(content, adres, nummer, ext=".md"):
"""Schrijf rapport naar Obsidian vault in map huizenjacht""" """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 = re.sub(r'[^a-zA-Z0-9]+', '-', adres)
safe_name = safe_name.strip('-') safe_name = safe_name.strip('-')
filename = f"huizenjacht/{safe_name}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.md" filename = f"{nummer}-{safe_name}_{datetime.now().strftime('%Y%m%d_%H%M%S')}{ext}"
filepath = os.path.join(MKDOCS_DIR, filename)
req = urllib.request.Request( with open(filepath, 'w', encoding='utf-8') as f:
f"{OBSIDIAN_BASE}/vault/{filename}", f.write(content)
data=rapport_text.encode('utf-8'),
headers={
'Authorization': f'Bearer {OBSIDIAN_TOKEN}',
'Content-Type': 'text/markdown'
},
method='POST'
)
with urllib.request.urlopen(req, timeout=10) as resp: return filepath, filename
if resp.status in [200, 204]:
return f"📝 **Opgeslagen in Obsidian:** `huizenjacht/{safe_name}_*.md`"
else:
return f"⚠️ Obsidian upload mislukt: {resp.status}"
def print_isochroon_sectie(titel, icoon, iso_data, rapport_lines=None): def genereer_isochroon_html(adres_naam, lat, lon, auto_iso, fiets_iso):
"""Print isochroon sectie; optioneel ook toevoegen aan rapport_lines voor Obsidian""" """Genereer een zelf-contained HTML kaart met Leaflet"""
text = f"{icoon} **{titel}**" html = f'''<!DOCTYPE html>
print(text) <html lang="nl">
if rapport_lines is not None: <head>
rapport_lines.append(text) <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 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);
L.marker([{lat}, {lon}]).addTo(map)
.bindPopup('<b>{adres_naam}</b>').openPopup();
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';
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):
"""Genereer isochroon sectie als tekst"""
lines = []
lines.append(f"{icoon} **{titel}**")
if 'features' in iso_data and iso_data['features']: if 'features' in iso_data and iso_data['features']:
props = iso_data['features'][0]['properties'] props = iso_data['features'][0]['properties']
area_m2 = props.get('area', 0) area_m2 = props.get('area', 0)
reach = props.get('reachfactor', 0) reach = props.get('reachfactor', 0)
text = f"**Oppervlakte:** {format_oppervlakte(area_m2)}" lines.append(f"**Oppervlakte:** {format_oppervlakte(area_m2)}")
print(text) lines.append(f"**Reach factor:** {reach:.3f}")
if rapport_lines is not None:
rapport_lines.append(text)
text = f"**Reach factor:** {reach:.3f}"
print(text)
if rapport_lines is not None:
rapport_lines.append(text)
bbox = iso_data.get('bbox', []) bbox = iso_data.get('bbox', [])
if len(bbox) == 4: if len(bbox) == 4:
text = f"**Bereik:** {bbox[1]:.3f}°-{bbox[3]:.3f}°N, {bbox[0]:.3f}°-{bbox[2]:.3f}°E" lines.append(f"**Bereik:** {bbox[1]:.3f}°-{bbox[3]:.3f}°N, {bbox[0]:.3f}°-{bbox[2]:.3f}°E")
print(text)
if rapport_lines is not None:
rapport_lines.append(text)
else: else:
text = "❌ Geen isochroon data ontvangen" lines.append("❌ Geen isochroon data ontvangen")
print(text) lines.append("")
if rapport_lines is not None: return "\n".join(lines)
rapport_lines.append(text)
print()
if rapport_lines is not None:
rapport_lines.append("")
def genereer_rapport(adres_input): def genereer_rapport(adres_input):
"""Genereer volledig adres analyse rapport""" """Genereer volledig adres analyse rapport"""
rapport_lines = [] lines = []
def print_line(text=""): # Tel bestaande genummerde rapporten voor oplopend nummer
print(text) # Alleen bestanden die beginnen met cijfer-dash (bv. "1-Griendvelden...")
rapport_lines.append(text) 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
print_line(f"📍 **Adres Analyse Rapport**") lines.append(f"# {nummer}. {adres_input}")
print_line(f"Gegenereerd: {datetime.now().strftime('%d-%m-%Y %H:%M')}") lines.append(f"*Gegenereerd: {datetime.now().strftime('%d-%m-%Y %H:%M')}*")
print_line() lines.append("")
print_line("🔍 Geocoding...") lines.append("## 🔍 Geocoding")
locatie = geocode_adres(adres_input) locatie = geocode_adres(adres_input)
print_line(f"**Adres:** {locatie['adres']}") lines.append(f"**Adres:** {locatie['adres']}")
print_line(f"**Coördinaten:** {locatie['lat']:.6f}, {locatie['lon']:.6f}") lines.append(f"**Coördinaten:** {locatie['lat']:.6f}, {locatie['lon']:.6f}")
print_line() lines.append("")
auto_iso = haal_isochroon(locatie['lon'], locatie['lat'], 'driving-car', 20) auto_iso = haal_isochroon(locatie['lon'], locatie['lat'], 'driving-car', 20)
fiets_iso = haal_isochroon(locatie['lon'], locatie['lat'], 'cycling-regular', 30) fiets_iso = haal_isochroon(locatie['lon'], locatie['lat'], 'cycling-regular', 30)
print_isochroon_sectie("Bereikbaarheid (Auto 20 min)", "🚗", auto_iso, rapport_lines)
print_isochroon_sectie("Bereikbaarheid (Fiets 30 min)", "🚲", fiets_iso, rapport_lines)
print_line("🏙️ **Voorzieningen dichtbij**") lines.append("## 🚗 Bereikbaarheid")
lines.append(print_isochroon_sectie("Auto (20 min)", "🚗", auto_iso))
lines.append(print_isochroon_sectie("Fiets (30 min)", "🚲", fiets_iso))
lines.append("## 🏙️ Voorzieningen dichtbij")
voorzieningen = analyse_voorzieningen(locatie['lon'], locatie['lat'], limit=5) voorzieningen = analyse_voorzieningen(locatie['lon'], locatie['lat'], limit=5)
print_line(format_voorzieningen_markdown("🛒 **Supermarkten**", voorzieningen['supermarkten'])) lines.append(format_voorzieningen_markdown("🛒 **Supermarkten**", voorzieningen['supermarkten']))
print_line() lines.append("")
print_line(format_voorzieningen_markdown("🏫 **Scholen**", voorzieningen['scholen'])) lines.append(format_voorzieningen_markdown("🏫 **Scholen**", voorzieningen['scholen']))
print_line() 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
)
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" output_file = f"/tmp/isochronen_{datetime.now().strftime('%Y%m%d_%H%M%S')}.geojson"
with open(output_file, 'w') as f: with open(output_file, 'w') as f:
json.dump({'auto_20_min': auto_iso, 'fiets_30_min': fiets_iso}, f) json.dump({'auto_20_min': auto_iso, 'fiets_30_min': fiets_iso}, f)
print_line(f"💾 Isochroondata opgeslagen: `{output_file}`") lines.append("---")
print_line() lines.append("*Onderdelen: geocoding, isochronen, voorzieningen, interactieve kaart*")
print_line("---")
print_line("_Onderdelen: geocoding, isochronen, voorzieningen_")
# Schrijf naar Obsidian rapport_text = "\n".join(lines)
print_line()
obsidian_result = schrijf_naar_obsidian('\n'.join(rapport_lines), locatie['adres']) # Print naar stdout
print_line(obsidian_result) 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__': if __name__ == '__main__':
adres = sys.argv[1] if len(sys.argv) > 1 else "Griendvelden 14 Best" adres = sys.argv[1] if len(sys.argv) > 1 else "Griendvelden 14 Best"
+91 -75
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())
for row in rows 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"},
)
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 i, p in enumerate(pois, 1): 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)}")
lines.extend([
f"{i}. **{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))