Compare commits
1 Commits
v1.0
..
c254463001
| Author | SHA1 | Date | |
|---|---|---|---|
| c254463001 |
@@ -4,3 +4,5 @@
|
|||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
|
data/
|
||||||
|
*.osm.pbf
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
services:
|
||||||
|
postgis:
|
||||||
|
image: postgis/postgis:16-3.4
|
||||||
|
container_name: adres-analyse-postgis
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: adresanalyse
|
||||||
|
POSTGRES_USER: adres
|
||||||
|
POSTGRES_PASSWORD: adres
|
||||||
|
TZ: Europe/Amsterdam
|
||||||
|
ports:
|
||||||
|
- "5433:5432"
|
||||||
|
volumes:
|
||||||
|
- ./data/postgis:/var/lib/postgresql/data
|
||||||
|
shm_size: 1g
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U adres -d adresanalyse"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
|
||||||
|
osm2pgsql:
|
||||||
|
image: iboates/osm2pgsql:1.11.0
|
||||||
|
container_name: adres-analyse-osm2pgsql
|
||||||
|
profiles: ["import"]
|
||||||
|
depends_on:
|
||||||
|
postgis:
|
||||||
|
condition: service_healthy
|
||||||
|
volumes:
|
||||||
|
- ./data/osm:/data/osm:ro
|
||||||
|
- ./osm2pgsql.style:/data/osm2pgsql.style:ro
|
||||||
|
environment:
|
||||||
|
PGPASSWORD: adres
|
||||||
|
command: >
|
||||||
|
osm2pgsql
|
||||||
|
--create
|
||||||
|
--slim
|
||||||
|
--drop
|
||||||
|
--cache 2000
|
||||||
|
--number-processes 4
|
||||||
|
--hstore
|
||||||
|
--latlong
|
||||||
|
--style /data/osm2pgsql.style
|
||||||
|
-d adresanalyse
|
||||||
|
-U adres
|
||||||
|
-H postgis
|
||||||
|
-P 5432
|
||||||
|
/data/osm/netherlands-latest.osm.pbf
|
||||||
+174
@@ -0,0 +1,174 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Genereer interactieve kaart voor adres analyse.
|
||||||
|
Toont isochronen plus dichtstbijzijnde supermarkten en scholen uit lokale PostGIS OSM-data.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
import folium
|
||||||
|
from folium.plugins import MarkerCluster
|
||||||
|
|
||||||
|
from voorzieningen import analyse_voorzieningen, format_afstand, format_duur
|
||||||
|
|
||||||
|
ORS_BASE = "http://192.168.1.71:9080/ors/v2"
|
||||||
|
PDOK_BASE = "https://api.pdok.nl/bzk/locatieserver/search/v3_1"
|
||||||
|
|
||||||
|
|
||||||
|
def geocode_adres(adres):
|
||||||
|
params = urllib.parse.urlencode({
|
||||||
|
"q": adres,
|
||||||
|
"rows": 5,
|
||||||
|
"fl": "centroide_ll,weergavenaam,postcode,woonplaatsnaam,type,huisnummer",
|
||||||
|
})
|
||||||
|
url = f"{PDOK_BASE}/free?{params}"
|
||||||
|
with urllib.request.urlopen(url, timeout=10) as resp:
|
||||||
|
data = json.loads(resp.read())
|
||||||
|
|
||||||
|
docs = data.get("response", {}).get("docs", [])
|
||||||
|
if not docs:
|
||||||
|
raise ValueError(f"Adres niet gevonden: {adres}")
|
||||||
|
|
||||||
|
doc = next((d for d in docs if d.get("type") == "adres"), docs[0])
|
||||||
|
point = doc["centroide_ll"].replace("POINT(", "").replace(")", "").split()
|
||||||
|
lon, lat = float(point[0]), float(point[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, minuten):
|
||||||
|
payload = json.dumps({
|
||||||
|
"locations": [[lon, lat]],
|
||||||
|
"range": [minuten * 60],
|
||||||
|
"attributes": ["area", "reachfactor", "total_pop"],
|
||||||
|
}).encode()
|
||||||
|
req = urllib.request.Request(
|
||||||
|
f"{ORS_BASE}/isochrones/{profiel}",
|
||||||
|
data=payload,
|
||||||
|
headers={"Content-Type": "application/json"},
|
||||||
|
method="POST",
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(req, timeout=60) as resp:
|
||||||
|
return json.loads(resp.read())
|
||||||
|
|
||||||
|
|
||||||
|
def polygon_bounds(feature_collection):
|
||||||
|
bbox = feature_collection.get("bbox", [])
|
||||||
|
if len(bbox) == 4:
|
||||||
|
return [[bbox[1], bbox[0]], [bbox[3], bbox[2]]]
|
||||||
|
bounds = []
|
||||||
|
for feature in feature_collection.get("features", []):
|
||||||
|
coords = feature.get("geometry", {}).get("coordinates", [[]])[0]
|
||||||
|
bounds.extend([[lat, lon] for lon, lat in coords])
|
||||||
|
return bounds
|
||||||
|
|
||||||
|
|
||||||
|
def voeg_isochroon_toe(group, iso, label, kleur, tooltip):
|
||||||
|
for feature in iso.get("features", []):
|
||||||
|
coords = feature["geometry"]["coordinates"][0]
|
||||||
|
props = feature.get("properties", {})
|
||||||
|
area_km2 = props.get("area", 0) / 1_000_000
|
||||||
|
reach = props.get("reachfactor", 0)
|
||||||
|
folium.Polygon(
|
||||||
|
locations=[[lat, lon] for lon, lat in coords],
|
||||||
|
color=kleur,
|
||||||
|
weight=3,
|
||||||
|
fill=True,
|
||||||
|
fill_color=kleur,
|
||||||
|
fill_opacity=0.18,
|
||||||
|
popup=(
|
||||||
|
f"<b>{label}</b><br>"
|
||||||
|
f"Oppervlakte: {area_km2:.1f} km²<br>"
|
||||||
|
f"Reach factor: {reach:.3f}"
|
||||||
|
),
|
||||||
|
tooltip=tooltip,
|
||||||
|
).add_to(group)
|
||||||
|
|
||||||
|
|
||||||
|
def voeg_pois_toe(group, pois, kleur, icon):
|
||||||
|
for poi in pois:
|
||||||
|
popup = (
|
||||||
|
f"<b>{poi.naam}</b><br>"
|
||||||
|
f"Categorie: {poi.categorie}<br>"
|
||||||
|
f"Hemelsbreed: {format_afstand(poi.hemelsbreed_m)}<br>"
|
||||||
|
f"Fiets: {format_afstand(poi.fiets_afstand_m)} / {format_duur(poi.fiets_duur_s)}<br>"
|
||||||
|
f"Auto: {format_afstand(poi.auto_afstand_m)} / {format_duur(poi.auto_duur_s)}<br>"
|
||||||
|
f"Bron: {poi.bron}"
|
||||||
|
)
|
||||||
|
folium.Marker(
|
||||||
|
[poi.lat, poi.lon],
|
||||||
|
popup=popup,
|
||||||
|
tooltip=f"{poi.categorie}: {poi.naam}",
|
||||||
|
icon=folium.Icon(color=kleur, icon=icon, prefix="fa"),
|
||||||
|
).add_to(group)
|
||||||
|
|
||||||
|
|
||||||
|
def genereer_kaart(adres, output_file="/root/adres-analyse/kaart.html"):
|
||||||
|
locatie = geocode_adres(adres)
|
||||||
|
lat, lon = locatie["lat"], locatie["lon"]
|
||||||
|
|
||||||
|
auto_iso = haal_isochroon(lon, lat, "driving-car", 20)
|
||||||
|
fiets_iso = haal_isochroon(lon, lat, "cycling-regular", 30)
|
||||||
|
voorzieningen = analyse_voorzieningen(lon, lat, limit=5)
|
||||||
|
|
||||||
|
m = folium.Map(location=[lat, lon], zoom_start=11, tiles="OpenStreetMap")
|
||||||
|
|
||||||
|
folium.Marker(
|
||||||
|
[lat, lon],
|
||||||
|
popup=locatie["adres"],
|
||||||
|
tooltip=locatie["adres"],
|
||||||
|
icon=folium.Icon(color="red", icon="home"),
|
||||||
|
).add_to(m)
|
||||||
|
|
||||||
|
auto_group = folium.FeatureGroup(name="Isochroon: 20 min auto", show=True)
|
||||||
|
fiets_group = folium.FeatureGroup(name="Isochroon: 30 min fiets", show=True)
|
||||||
|
super_group = MarkerCluster(name="Supermarkten", show=True)
|
||||||
|
school_group = MarkerCluster(name="Scholen", show=True)
|
||||||
|
|
||||||
|
voeg_isochroon_toe(auto_group, auto_iso, "Auto 20 min", "#2196F3", "Bereikbaar in 20 min (auto)")
|
||||||
|
voeg_isochroon_toe(fiets_group, fiets_iso, "Fiets 30 min", "#2E7D32", "Bereikbaar in 30 min (fiets)")
|
||||||
|
voeg_pois_toe(super_group, voorzieningen["supermarkten"], "orange", "shopping-cart")
|
||||||
|
voeg_pois_toe(school_group, voorzieningen["scholen"], "purple", "graduation-cap")
|
||||||
|
|
||||||
|
auto_group.add_to(m)
|
||||||
|
fiets_group.add_to(m)
|
||||||
|
super_group.add_to(m)
|
||||||
|
school_group.add_to(m)
|
||||||
|
folium.LayerControl(collapsed=False).add_to(m)
|
||||||
|
|
||||||
|
all_bounds = polygon_bounds(auto_iso) + polygon_bounds(fiets_iso)
|
||||||
|
all_bounds += [[p.lat, p.lon] for groep in voorzieningen.values() for p in groep]
|
||||||
|
if all_bounds:
|
||||||
|
m.fit_bounds(all_bounds)
|
||||||
|
|
||||||
|
title_html = f'''
|
||||||
|
<div style="position:fixed;top:10px;left:60px;z-index:1000;background:white;padding:10px 15px;border-radius:8px;box-shadow:0 2px 6px rgba(0,0,0,0.3);font-family:sans-serif;font-size:14px;line-height:1.35;">
|
||||||
|
<b>📍 Adres Analyse</b><br>
|
||||||
|
{locatie["adres"]}<br>
|
||||||
|
<span style="color:#2196F3">■</span> 20 min autorijden<br>
|
||||||
|
<span style="color:#2E7D32">■</span> 30 min fietsen<br>
|
||||||
|
🛒 Supermarkten · 🏫 Scholen
|
||||||
|
</div>
|
||||||
|
'''
|
||||||
|
m.get_root().html.add_child(folium.Element(title_html))
|
||||||
|
|
||||||
|
m.save(output_file)
|
||||||
|
print(f"Kaart gegenereerd: {output_file}")
|
||||||
|
print(f"Adres: {locatie['adres']}")
|
||||||
|
print(f"Coördinaten: {lat}, {lon}")
|
||||||
|
print(f"Supermarkten: {len(voorzieningen['supermarkten'])}")
|
||||||
|
print(f"Scholen: {len(voorzieningen['scholen'])}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
adres = sys.argv[1] if len(sys.argv) > 1 else "Griendvelden 14 Best"
|
||||||
|
output = sys.argv[2] if len(sys.argv) > 2 else "/root/adres-analyse/kaart.html"
|
||||||
|
genereer_kaart(adres, output)
|
||||||
Executable
+28
@@ -0,0 +1,28 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
mkdir -p data/osm data/postgis
|
||||||
|
|
||||||
|
PBF="data/osm/netherlands-latest.osm.pbf"
|
||||||
|
URL="https://download.geofabrik.de/europe/netherlands-latest.osm.pbf"
|
||||||
|
|
||||||
|
if [[ ! -s "$PBF" ]]; then
|
||||||
|
echo "Downloading Netherlands OSM extract..."
|
||||||
|
curl -L --fail --continue-at - -o "$PBF" "$URL"
|
||||||
|
else
|
||||||
|
echo "Using existing $PBF"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Starting PostGIS..."
|
||||||
|
docker compose up -d postgis
|
||||||
|
|
||||||
|
echo "Waiting for PostGIS healthcheck..."
|
||||||
|
until docker compose exec -T postgis pg_isready -U adres -d adresanalyse >/dev/null 2>&1; do
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "Importing supermarkets and schools from full Netherlands OSM extract..."
|
||||||
|
python3 osm-poi-import.py "$PBF"
|
||||||
|
|
||||||
|
echo "Done."
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Importeer alleen benodigde POI's uit een OSM PBF naar PostGIS.
|
||||||
|
Veel lichter dan volledige osm2pgsql-import voor heel Nederland.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
|
||||||
|
import osmium
|
||||||
|
import psycopg2
|
||||||
|
from psycopg2.extras import execute_values
|
||||||
|
|
||||||
|
DB_DSN = os.environ.get(
|
||||||
|
"ADRES_ANALYSE_DB_DSN",
|
||||||
|
"host=127.0.0.1 port=5433 dbname=adresanalyse user=adres password=adres",
|
||||||
|
)
|
||||||
|
|
||||||
|
SCHEMA_SQL = """
|
||||||
|
CREATE EXTENSION IF NOT EXISTS postgis;
|
||||||
|
DROP TABLE IF EXISTS osm_pois;
|
||||||
|
CREATE TABLE osm_pois (
|
||||||
|
id bigserial PRIMARY KEY,
|
||||||
|
osm_type text NOT NULL,
|
||||||
|
osm_id bigint NOT NULL,
|
||||||
|
category text NOT NULL,
|
||||||
|
name text NOT NULL,
|
||||||
|
brand text,
|
||||||
|
operator text,
|
||||||
|
lon double precision NOT NULL,
|
||||||
|
lat double precision NOT NULL,
|
||||||
|
geom geometry(Point, 4326) NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX osm_pois_category_geom_gix ON osm_pois USING GIST (geom);
|
||||||
|
CREATE INDEX osm_pois_category_idx ON osm_pois (category);
|
||||||
|
CREATE INDEX osm_pois_name_idx ON osm_pois (lower(name));
|
||||||
|
|
||||||
|
CREATE OR REPLACE VIEW poi_supermarkten AS
|
||||||
|
SELECT osm_id, name, 'supermarkt'::text AS category, 'supermarket'::text AS shop, NULL::text AS amenity, brand, operator, geom
|
||||||
|
FROM osm_pois WHERE category = 'supermarkt';
|
||||||
|
|
||||||
|
CREATE OR REPLACE VIEW poi_scholen AS
|
||||||
|
SELECT osm_id, name, 'school'::text AS category, NULL::text AS shop, 'school'::text AS amenity, brand, operator, geom
|
||||||
|
FROM osm_pois WHERE category = 'school';
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def poi_from_tags(tags):
|
||||||
|
if tags.get("shop") == "supermarket":
|
||||||
|
return "supermarkt", tags.get("name") or tags.get("brand") or "Supermarkt"
|
||||||
|
if tags.get("amenity") == "school":
|
||||||
|
return "school", tags.get("name") or "School"
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
|
class PoiHandler(osmium.SimpleHandler):
|
||||||
|
def __init__(self, conn, batch_size=1000):
|
||||||
|
super().__init__()
|
||||||
|
self.conn = conn
|
||||||
|
self.batch_size = batch_size
|
||||||
|
self.rows = []
|
||||||
|
self.count = 0
|
||||||
|
self.factory = osmium.geom.WKBFactory()
|
||||||
|
|
||||||
|
def flush(self):
|
||||||
|
if not self.rows:
|
||||||
|
return
|
||||||
|
with self.conn.cursor() as cur:
|
||||||
|
execute_values(
|
||||||
|
cur,
|
||||||
|
"""
|
||||||
|
INSERT INTO osm_pois (osm_type, osm_id, category, name, brand, operator, lon, lat, geom)
|
||||||
|
VALUES %s
|
||||||
|
""",
|
||||||
|
self.rows,
|
||||||
|
)
|
||||||
|
self.conn.commit()
|
||||||
|
self.rows.clear()
|
||||||
|
|
||||||
|
def add_row(self, osm_type, osm_id, tags, lon, lat):
|
||||||
|
category, name = poi_from_tags(tags)
|
||||||
|
if not category:
|
||||||
|
return
|
||||||
|
self.rows.append((
|
||||||
|
osm_type,
|
||||||
|
int(osm_id),
|
||||||
|
category,
|
||||||
|
name,
|
||||||
|
tags.get("brand"),
|
||||||
|
tags.get("operator"),
|
||||||
|
float(lon),
|
||||||
|
float(lat),
|
||||||
|
f"SRID=4326;POINT({float(lon)} {float(lat)})",
|
||||||
|
))
|
||||||
|
self.count += 1
|
||||||
|
if len(self.rows) >= self.batch_size:
|
||||||
|
self.flush()
|
||||||
|
|
||||||
|
def node(self, n):
|
||||||
|
if not n.location.valid():
|
||||||
|
return
|
||||||
|
self.add_row("node", n.id, n.tags, n.location.lon, n.location.lat)
|
||||||
|
|
||||||
|
def area(self, a):
|
||||||
|
category, _ = poi_from_tags(a.tags)
|
||||||
|
if not category:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
wkb = self.factory.create_multipolygon(a)
|
||||||
|
with self.conn.cursor() as cur:
|
||||||
|
cur.execute("SELECT ST_X(p), ST_Y(p) FROM (SELECT ST_PointOnSurface(ST_GeomFromWKB(%s, 4326)) p) s", (psycopg2.Binary(wkb),))
|
||||||
|
lon, lat = cur.fetchone()
|
||||||
|
self.add_row("area", a.orig_id(), a.tags, lon, lat)
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("pbf")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
with psycopg2.connect(DB_DSN) as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(SCHEMA_SQL)
|
||||||
|
conn.commit()
|
||||||
|
handler = PoiHandler(conn)
|
||||||
|
handler.apply_file(args.pbf, locations=True)
|
||||||
|
handler.flush()
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("ANALYZE osm_pois")
|
||||||
|
conn.commit()
|
||||||
|
print(f"Imported {handler.count} POIs into osm_pois")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# OsmType Tag DataType Flags
|
||||||
|
node,way name text linear
|
||||||
|
node,way amenity text polygon
|
||||||
|
node,way shop text polygon
|
||||||
|
node,way brand text linear
|
||||||
|
node,way operator text linear
|
||||||
|
node,way addr:street text linear
|
||||||
|
node,way addr:housenumber text linear
|
||||||
|
node,way addr:postcode text linear
|
||||||
|
node,way addr:city text linear
|
||||||
|
node,way wheelchair text linear
|
||||||
|
node,way opening_hours text linear
|
||||||
+35
-33
@@ -1,7 +1,7 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""
|
"""
|
||||||
Adres Analyse - Rapport generator
|
Adres Analyse - Rapport generator
|
||||||
Gebruikt PDOK voor geocoding en ORS voor isochronen
|
Gebruikt PDOK voor geocoding, ORS voor isochronen/routes en PostGIS OSM-data voor voorzieningen.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
@@ -10,13 +10,14 @@ import urllib.request
|
|||||||
import urllib.parse
|
import urllib.parse
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
|
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"
|
||||||
|
|
||||||
def geocode_adres(adres):
|
def geocode_adres(adres):
|
||||||
"""Geocode adres naar coördinaten via PDOK Locatieserver"""
|
"""Geocode adres naar coördinaten via PDOK Locatieserver"""
|
||||||
# Gebruik suggest endpoint voor betere adres matching
|
|
||||||
params = urllib.parse.urlencode({
|
params = urllib.parse.urlencode({
|
||||||
'q': adres,
|
'q': adres,
|
||||||
'rows': 5,
|
'rows': 5,
|
||||||
@@ -30,7 +31,6 @@ def geocode_adres(adres):
|
|||||||
if not data['response']['docs']:
|
if not data['response']['docs']:
|
||||||
raise ValueError(f"Adres niet gevonden: {adres}")
|
raise ValueError(f"Adres niet gevonden: {adres}")
|
||||||
|
|
||||||
# Zoek naar exact adres match (type=adres)
|
|
||||||
doc = None
|
doc = None
|
||||||
for d in data['response']['docs']:
|
for d in data['response']['docs']:
|
||||||
if d.get('type') == 'adres':
|
if d.get('type') == 'adres':
|
||||||
@@ -38,9 +38,8 @@ def geocode_adres(adres):
|
|||||||
break
|
break
|
||||||
|
|
||||||
if not doc:
|
if not doc:
|
||||||
doc = data['response']['docs'][0] # Fallback naar eerste resultaat
|
doc = data['response']['docs'][0]
|
||||||
|
|
||||||
# Parse POINT(lon lat) format
|
|
||||||
point = doc['centroide_ll']
|
point = doc['centroide_ll']
|
||||||
coords = point.replace('POINT(', '').replace(')', '').split()
|
coords = point.replace('POINT(', '').replace(')', '').split()
|
||||||
lon, lat = float(coords[0]), float(coords[1])
|
lon, lat = float(coords[0]), float(coords[1])
|
||||||
@@ -58,7 +57,7 @@ def haal_isochroon(lon, lat, profiel='driving-car', minuten=20):
|
|||||||
url = f"{ORS_BASE}/isochrones/{profiel}"
|
url = f"{ORS_BASE}/isochrones/{profiel}"
|
||||||
payload = {
|
payload = {
|
||||||
"locations": [[lon, lat]],
|
"locations": [[lon, lat]],
|
||||||
"range": [minuten * 60], # seconden
|
"range": [minuten * 60],
|
||||||
"attributes": ["area", "reachfactor", "total_pop"]
|
"attributes": ["area", "reachfactor", "total_pop"]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,49 +80,52 @@ def format_oppervlakte(m2):
|
|||||||
else:
|
else:
|
||||||
return f"{m2:.0f} m²"
|
return f"{m2:.0f} m²"
|
||||||
|
|
||||||
|
def print_isochroon_sectie(titel, icoon, iso_data):
|
||||||
|
print(f"{icoon} **{titel}**")
|
||||||
|
if 'features' in iso_data and iso_data['features']:
|
||||||
|
props = iso_data['features'][0]['properties']
|
||||||
|
area_m2 = props.get('area', 0)
|
||||||
|
reach = props.get('reachfactor', 0)
|
||||||
|
print(f"**Oppervlakte:** {format_oppervlakte(area_m2)}")
|
||||||
|
print(f"**Reach factor:** {reach:.3f}")
|
||||||
|
bbox = iso_data.get('bbox', [])
|
||||||
|
if len(bbox) == 4:
|
||||||
|
print(f"**Bereik:** {bbox[1]:.3f}°-{bbox[3]:.3f}°N, {bbox[0]:.3f}°-{bbox[2]:.3f}°E")
|
||||||
|
else:
|
||||||
|
print("❌ Geen isochroon data ontvangen")
|
||||||
|
print()
|
||||||
|
|
||||||
def genereer_rapport(adres_input):
|
def genereer_rapport(adres_input):
|
||||||
"""Genereer volledig adres analyse rapport"""
|
"""Genereer volledig adres analyse rapport"""
|
||||||
print(f"📍 **Adres Analyse Rapport**")
|
print(f"📍 **Adres Analyse Rapport**")
|
||||||
print(f"Gegenereerd: {datetime.now().strftime('%d-%m-%Y %H:%M')}")
|
print(f"Gegenereerd: {datetime.now().strftime('%d-%m-%Y %H:%M')}")
|
||||||
print()
|
print()
|
||||||
|
|
||||||
# Geocoding
|
|
||||||
print("🔍 Geocoding...")
|
print("🔍 Geocoding...")
|
||||||
locatie = geocode_adres(adres_input)
|
locatie = geocode_adres(adres_input)
|
||||||
print(f"**Adres:** {locatie['adres']}")
|
print(f"**Adres:** {locatie['adres']}")
|
||||||
print(f"**Coördinaten:** {locatie['lat']:.6f}, {locatie['lon']:.6f}")
|
print(f"**Coördinaten:** {locatie['lat']:.6f}, {locatie['lon']:.6f}")
|
||||||
print()
|
print()
|
||||||
|
|
||||||
# Isochroon
|
auto_iso = haal_isochroon(locatie['lon'], locatie['lat'], 'driving-car', 20)
|
||||||
print("🚗 **Bereikbaarheid (Auto 20 min)**")
|
fiets_iso = haal_isochroon(locatie['lon'], locatie['lat'], 'cycling-regular', 30)
|
||||||
iso_data = haal_isochroon(locatie['lon'], locatie['lat'], 'driving-car', 20)
|
print_isochroon_sectie("Bereikbaarheid (Auto 20 min)", "🚗", auto_iso)
|
||||||
|
print_isochroon_sectie("Bereikbaarheid (Fiets 30 min)", "🚲", fiets_iso)
|
||||||
|
|
||||||
if 'features' in iso_data and iso_data['features']:
|
print("🏙️ **Voorzieningen dichtbij**")
|
||||||
props = iso_data['features'][0]['properties']
|
voorzieningen = analyse_voorzieningen(locatie['lon'], locatie['lat'], limit=5)
|
||||||
area_m2 = props.get('area', 0)
|
print(format_voorzieningen_markdown("🛒 **Supermarkten**", voorzieningen['supermarkten']))
|
||||||
reach = props.get('reachfactor', 0)
|
print()
|
||||||
|
print(format_voorzieningen_markdown("🏫 **Scholen**", voorzieningen['scholen']))
|
||||||
print(f"**Oppervlakte:** {format_oppervlakte(area_m2)}")
|
print()
|
||||||
print(f"**Reach factor:** {reach:.3f}")
|
|
||||||
print()
|
|
||||||
|
|
||||||
# Bereken bbox
|
|
||||||
bbox = iso_data.get('bbox', [])
|
|
||||||
if len(bbox) == 4:
|
|
||||||
print(f"**Bereik:** {bbox[1]:.3f}°-{bbox[3]:.3f}°N, {bbox[0]:.3f}°-{bbox[2]:.3f}°E")
|
|
||||||
print()
|
|
||||||
|
|
||||||
# Save GeoJSON voor kaart generatie
|
|
||||||
output_file = f"/tmp/isochroon_{datetime.now().strftime('%Y%m%d_%H%M%S')}.geojson"
|
|
||||||
with open(output_file, 'w') as f:
|
|
||||||
json.dump(iso_data, f)
|
|
||||||
print(f"💾 Data opgeslagen: `{output_file}`")
|
|
||||||
else:
|
|
||||||
print("❌ Geen isochroon data ontvangen")
|
|
||||||
|
|
||||||
|
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("---")
|
print("---")
|
||||||
print("_Onderdeel 1/?: Isochroon bereikbaarheid_")
|
print("_Onderdelen: geocoding, isochronen, voorzieningen_")
|
||||||
|
|
||||||
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"
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
CREATE EXTENSION IF NOT EXISTS postgis;
|
||||||
|
CREATE EXTENSION IF NOT EXISTS hstore;
|
||||||
|
|
||||||
|
CREATE OR REPLACE VIEW poi_supermarkten AS
|
||||||
|
SELECT
|
||||||
|
osm_id,
|
||||||
|
COALESCE(NULLIF(name, ''), NULLIF(brand, ''), 'Supermarkt') AS name,
|
||||||
|
'supermarkt'::text AS category,
|
||||||
|
shop,
|
||||||
|
amenity,
|
||||||
|
brand,
|
||||||
|
operator,
|
||||||
|
way AS geom
|
||||||
|
FROM planet_osm_point
|
||||||
|
WHERE shop = 'supermarket'
|
||||||
|
UNION ALL
|
||||||
|
SELECT
|
||||||
|
osm_id,
|
||||||
|
COALESCE(NULLIF(name, ''), NULLIF(brand, ''), 'Supermarkt') AS name,
|
||||||
|
'supermarkt'::text AS category,
|
||||||
|
shop,
|
||||||
|
amenity,
|
||||||
|
brand,
|
||||||
|
operator,
|
||||||
|
ST_PointOnSurface(way) AS geom
|
||||||
|
FROM planet_osm_polygon
|
||||||
|
WHERE shop = 'supermarket';
|
||||||
|
|
||||||
|
CREATE OR REPLACE VIEW poi_scholen AS
|
||||||
|
SELECT
|
||||||
|
osm_id,
|
||||||
|
COALESCE(NULLIF(name, ''), 'School') AS name,
|
||||||
|
'school'::text AS category,
|
||||||
|
shop,
|
||||||
|
amenity,
|
||||||
|
brand,
|
||||||
|
operator,
|
||||||
|
way AS geom
|
||||||
|
FROM planet_osm_point
|
||||||
|
WHERE amenity = 'school'
|
||||||
|
UNION ALL
|
||||||
|
SELECT
|
||||||
|
osm_id,
|
||||||
|
COALESCE(NULLIF(name, ''), 'School') AS name,
|
||||||
|
'school'::text AS category,
|
||||||
|
shop,
|
||||||
|
amenity,
|
||||||
|
brand,
|
||||||
|
operator,
|
||||||
|
ST_PointOnSurface(way) AS geom
|
||||||
|
FROM planet_osm_polygon
|
||||||
|
WHERE amenity = 'school';
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS planet_osm_point_way_gix ON planet_osm_point USING GIST (way);
|
||||||
|
CREATE INDEX IF NOT EXISTS planet_osm_polygon_way_gix ON planet_osm_polygon USING GIST (way);
|
||||||
|
ANALYZE planet_osm_point;
|
||||||
|
ANALYZE planet_osm_polygon;
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Voorzieningenanalyse op basis van lokale PostGIS OSM-data + ORS Matrix."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
import os
|
||||||
|
import urllib.request
|
||||||
|
from dataclasses import dataclass, asdict
|
||||||
|
|
||||||
|
import psycopg2
|
||||||
|
import psycopg2.extras
|
||||||
|
|
||||||
|
DB_DSN = os.environ.get(
|
||||||
|
"ADRES_ANALYSE_DB_DSN",
|
||||||
|
"host=127.0.0.1 port=5433 dbname=adresanalyse user=adres password=adres",
|
||||||
|
)
|
||||||
|
ORS_BASE = os.environ.get("ORS_BASE", "http://192.168.1.71:9080/ors/v2")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Voorziening:
|
||||||
|
categorie: str
|
||||||
|
naam: str
|
||||||
|
lat: float
|
||||||
|
lon: float
|
||||||
|
hemelsbreed_m: float
|
||||||
|
fiets_afstand_m: float | None = None
|
||||||
|
fiets_duur_s: float | None = None
|
||||||
|
auto_afstand_m: float | None = None
|
||||||
|
auto_duur_s: float | None = None
|
||||||
|
bron: str = "OSM/PostGIS"
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
return asdict(self)
|
||||||
|
|
||||||
|
|
||||||
|
def _matrix(lon, lat, pois, profiel):
|
||||||
|
if not pois:
|
||||||
|
return []
|
||||||
|
locations = [[lon, lat]] + [[p.lon, p.lat] for p in pois]
|
||||||
|
payload = json.dumps({
|
||||||
|
"locations": locations,
|
||||||
|
"sources": [0],
|
||||||
|
"destinations": list(range(1, len(locations))),
|
||||||
|
"metrics": ["distance", "duration"],
|
||||||
|
}).encode()
|
||||||
|
req = urllib.request.Request(
|
||||||
|
f"{ORS_BASE}/matrix/{profiel}",
|
||||||
|
data=payload,
|
||||||
|
headers={"Content-Type": "application/json"},
|
||||||
|
method="POST",
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(req, timeout=60) as resp:
|
||||||
|
data = json.loads(resp.read())
|
||||||
|
distances = data.get("distances", [[]])[0]
|
||||||
|
durations = data.get("durations", [[]])[0]
|
||||||
|
return list(zip(distances, durations))
|
||||||
|
|
||||||
|
|
||||||
|
def verrijk_met_routes(lon, lat, pois):
|
||||||
|
fiets = _matrix(lon, lat, pois, "cycling-regular")
|
||||||
|
auto = _matrix(lon, lat, pois, "driving-car")
|
||||||
|
for i, poi in enumerate(pois):
|
||||||
|
if i < len(fiets):
|
||||||
|
poi.fiets_afstand_m, poi.fiets_duur_s = fiets[i]
|
||||||
|
if i < len(auto):
|
||||||
|
poi.auto_afstand_m, poi.auto_duur_s = auto[i]
|
||||||
|
return pois
|
||||||
|
|
||||||
|
|
||||||
|
def zoek_voorzieningen(lon, lat, categorie, limit=5, radius_m=10000):
|
||||||
|
if categorie == "supermarkt":
|
||||||
|
view = "poi_supermarkten"
|
||||||
|
elif categorie == "school":
|
||||||
|
view = "poi_scholen"
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Onbekende categorie: {categorie}")
|
||||||
|
|
||||||
|
sql = f"""
|
||||||
|
WITH origin AS (
|
||||||
|
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,
|
||||||
|
ST_Distance(geom::geography, origin.geog) AS hemelsbreed_m
|
||||||
|
FROM {view}, origin
|
||||||
|
WHERE geom IS NOT NULL
|
||||||
|
AND ST_DWithin(geom::geography, origin.geog, %s)
|
||||||
|
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 conn.cursor(cursor_factory=psycopg2.extras.DictCursor) as cur:
|
||||||
|
cur.execute(wrapped, (lon, lat, radius_m, limit))
|
||||||
|
rows = cur.fetchall()
|
||||||
|
return [
|
||||||
|
Voorziening(
|
||||||
|
categorie=categorie,
|
||||||
|
naam=row["name"],
|
||||||
|
lat=float(row["lat"]),
|
||||||
|
lon=float(row["lon"]),
|
||||||
|
hemelsbreed_m=float(row["hemelsbreed_m"]),
|
||||||
|
)
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def analyse_voorzieningen(lon, lat, limit=5):
|
||||||
|
supermarkten = zoek_voorzieningen(lon, lat, "supermarkt", limit=limit, radius_m=10000)
|
||||||
|
scholen = zoek_voorzieningen(lon, lat, "school", limit=limit, radius_m=15000)
|
||||||
|
verrijk_met_routes(lon, lat, supermarkten)
|
||||||
|
verrijk_met_routes(lon, lat, scholen)
|
||||||
|
return {"supermarkten": supermarkten, "scholen": scholen}
|
||||||
|
|
||||||
|
|
||||||
|
def format_afstand(m):
|
||||||
|
if m is None:
|
||||||
|
return "n.b."
|
||||||
|
if m < 1000:
|
||||||
|
return f"{m:.0f} m"
|
||||||
|
return f"{m/1000:.1f} km".replace(".", ",")
|
||||||
|
|
||||||
|
|
||||||
|
def format_duur(s):
|
||||||
|
if s is None:
|
||||||
|
return "n.b."
|
||||||
|
minutes = max(1, round(s / 60))
|
||||||
|
return f"{minutes} min"
|
||||||
|
|
||||||
|
|
||||||
|
def format_voorzieningen_markdown(titel, pois):
|
||||||
|
lines = [titel]
|
||||||
|
if not pois:
|
||||||
|
lines.append("Geen voorzieningen gevonden in zoekradius.")
|
||||||
|
return "\n".join(lines)
|
||||||
|
for i, p in enumerate(pois, 1):
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
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))
|
||||||
Reference in New Issue
Block a user