Skip to content

Commit be7928f

Browse files
committed
Extract tram links from QGIS, export mbtiles
1 parent 04fbc4d commit be7928f

5 files changed

Lines changed: 390 additions & 28 deletions

File tree

README.md

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ of the Docker container by running:
4040
```
4141

4242
The shapefiles are, by default, imported into a database schema named `digiroad`.
43-
The schema name can be changed in `set_env_vars.sh` script.
43+
The schema name can be changed in `set_env.sh` script.
4444

4545
Within the script execution further processing for Digiroad data is done as well.
4646

@@ -260,6 +260,32 @@ exported with (assuming Digiroad shapefiles have already been imported):
260260
./export_mbtiles_dr_pysakki.sh
261261
```
262262

263+
## Import MML tram network
264+
265+
1. Download MML's tram network from their website:
266+
- [Lataa paikkatietoaineistoja](https://asiointi.maanmittauslaitos.fi/karttapaikka/tiedostopalvelu?lang=fi)
267+
- Maastotietokanta
268+
- Valitse tiedostomuoto: ```GeoPackage**```
269+
- Piirrä oma alue
270+
- valitse teema: ```Raideliikenne```
271+
- Lisää ostoskoriin
272+
- Download via the download link in the email you receive once the download is available.
273+
274+
2. Open the GeoPackage file in QGIS (drag to Layers)
275+
2. Open Python console
276+
3. Open the ```generate-tram-infralinks-from-qgis.py``` file into the Python File viewer
277+
4. Select the MML GeoPackage layer
278+
5. Run the python script
279+
280+
The sql containing the infra links, insertable to the database, can be found in ```/tmp/tram_infraLinks.sql```. Leave it there or copy to ```sql/```.
281+
282+
6. run ```export_mbtiles_tram_links.sh``` with the date the tram link material was obtained on:
283+
```sh
284+
./export_mbtiles_tram_links.sh 2026-01-28
285+
```
286+
287+
8. upload the ```sql/tram_infralinks.sql``` and ```workdir/mbtiles/tram_links_<MML_TRAM_IMPORT_DATE>_<today>.mbtiles``` to blob storage ```stjore4dev001 / jore4-ui```
288+
263289
## License
264290

265291
The project license is in [`LICENSE`](./LICENSE).

export_mbtiles_tram_links.sh

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
#!/usr/bin/env bash
2+
3+
# Stop on first error. Have meaningful error messages.
4+
set -euo pipefail
5+
6+
# Source common environment variables and functions.
7+
source "$(dirname "$0")/set_env.sh"
8+
9+
DB_TABLE_NAME="tram_links"
10+
11+
usage() {
12+
echo "Usage: $(basename "$0") <MML_TRAM_IMPORT_DATE>"
13+
echo " MML_TRAM_IMPORT_DATE must be in format YYYY-MM-DD"
14+
}
15+
16+
if [[ "${1:-}" == "" ]]; then
17+
usage
18+
exit 1
19+
fi
20+
21+
MML_TRAM_IMPORT_DATE="$1"
22+
23+
if [[ ! "$MML_TRAM_IMPORT_DATE" =~ ^20[2-9][0-9]-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$ ]]; then
24+
echo "Invalid date: $MML_TRAM_IMPORT_DATE"
25+
usage
26+
exit 1
27+
fi
28+
29+
MBTILES_MAX_ZOOM_LEVEL=17
30+
MBTILES_LAYER_NAME=$DB_TABLE_NAME
31+
MBTILES_DESCRIPTION="Tram track links"
32+
33+
MBTILES_OUTPUT_DIR="${WORK_DIR}/mbtiles"
34+
GEOJSON_OUTPUT_DIR="${MBTILES_OUTPUT_DIR}/geojson_input"
35+
SQL_INPUT_DIR="${CWD}/sql"
36+
37+
print_and_run_cmd mkdir -p "$GEOJSON_OUTPUT_DIR"
38+
print_and_run_cmd mkdir -p "$SQL_INPUT_DIR"
39+
print_and_run_cmd mkdir -p "${WORK_DIR}/shp"
40+
41+
OUTPUT_FILE_BASENAME="${DB_TABLE_NAME}_${MML_TRAM_IMPORT_DATE}_$(date "+%Y-%m-%d")"
42+
43+
GEOJSON_OUTPUT_FILE="${OUTPUT_FILE_BASENAME}.geojson"
44+
MBTILES_OUTPUT_FILE="${OUTPUT_FILE_BASENAME}.mbtiles"
45+
46+
if [ ! -f "${SQL_INPUT_DIR}/tram_infraLinks.sql" ]; then
47+
if [ ! -f "/tmp/tram_infraLinks.sql" ]; then
48+
echo "Expected SQL file for processing tram links does not exist: /tmp/tram_infraLinks.sql"
49+
exit 1
50+
fi
51+
print_and_run_cmd mv "/tmp/tram_infraLinks.sql" "${SQL_INPUT_DIR}/tram_infraLinks.sql"
52+
else
53+
echo "Using existing file: ${SQL_INPUT_DIR}/tram_infraLinks.sql"
54+
fi
55+
56+
print_and_run_cmd docker_kill
57+
print_and_run_cmd docker_run "${WORK_DIR}/shp"
58+
59+
# install pgcrypto extension for generating UUIDs
60+
time docker_exec postgres "exec $PSQL -v ON_ERROR_STOP=1 -c 'CREATE EXTENSION IF NOT EXISTS pgcrypto;'"
61+
62+
# import tram infra links
63+
time print_and_run_cmd docker_exec postgres "exec $PSQL -v ON_ERROR_STOP=1 -f /tmp/sql/tram_infraLinks.sql"
64+
65+
# Export filtered links directly from PostGIS to GeoJSON.
66+
rm -f "${GEOJSON_OUTPUT_DIR}/$GEOJSON_OUTPUT_FILE"
67+
time docker_exec "$CURRUSER" \
68+
"exec ogr2ogr -f GeoJSON -lco COORDINATE_PRECISION=7 /tmp/mbtiles/geojson_input/$GEOJSON_OUTPUT_FILE \
69+
\"PG:host=$DB_HOST port=$DB_PORT dbname=$DB_NAME user=$DB_USERNAME \" \
70+
-sql \"SELECT \
71+
infrastructure_link_id::text AS id, \
72+
external_link_id AS link_id,\
73+
ST_Force2D(shape::geometry) AS geom \
74+
FROM infrastructure_network.infrastructure_link \
75+
WHERE external_link_source = 'temp_hsl_tram'\" \
76+
-nln $MBTILES_LAYER_NAME"
77+
78+
# Convert from GeoJSON to MBTiles.
79+
rm -f "${MBTILES_OUTPUT_DIR}/${MBTILES_OUTPUT_FILE}"
80+
rm -f "${MBTILES_OUTPUT_DIR}/${MBTILES_OUTPUT_FILE}-journal"
81+
time docker_exec "$CURRUSER" \
82+
"tippecanoe /tmp/mbtiles/geojson_input/$GEOJSON_OUTPUT_FILE -o /tmp/$MBTILES_OUTPUT_FILE -z$MBTILES_MAX_ZOOM_LEVEL -X -l $MBTILES_LAYER_NAME -n \"$MBTILES_DESCRIPTION\" -f && exec mv /tmp/$MBTILES_OUTPUT_FILE /tmp/mbtiles/$MBTILES_OUTPUT_FILE"
83+
84+
# Stop Docker container.
85+
print_and_run_cmd docker_stop
Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
# python
2+
3+
# This script exports selected line features from the active QGIS layer
4+
# to a SQL file that can be used to insert them into the 'infrastructure_link'
5+
# table in a PostGIS database. It calculates the ellipsoidal length of each
6+
# feature using the GRS80 ellipsoid and transforms geometries to EPSG:4326
7+
# for storage as geography(LineStringZ,4326).
8+
#
9+
# Configuration options at the top of the script allow customization of
10+
# the output file path, table name, direction, external link source,
11+
# ID field, and batch size for inserts.
12+
#
13+
# Open the script in the QGIS Python console and run it with an active layer
14+
# containing selected (with one of the selection tools) line features to
15+
# generate the SQL file. If no features are selected, the entire layer will
16+
# be exported.
17+
18+
19+
from qgis.core import (
20+
QgsProject,
21+
QgsCoordinateReferenceSystem,
22+
QgsCoordinateTransform,
23+
QgsDistanceArea,
24+
)
25+
from qgis.utils import iface
26+
27+
import uuid
28+
29+
# Config
30+
output_path = '/tmp/tram_infraLinks.sql'
31+
table_name = 'infrastructure_network.infrastructure_link'
32+
direction = 'bidirectional'
33+
external_link_source = 'temp_hsl_tram'
34+
id_field = 'mtk_id'
35+
batch_size = 50
36+
37+
layer = iface.activeLayer()
38+
if layer is None:
39+
raise RuntimeError('No active layer.')
40+
41+
proj = QgsProject.instance()
42+
src_crs = layer.crs()
43+
dst_crs = QgsCoordinateReferenceSystem('EPSG:4326')
44+
transform = QgsCoordinateTransform(src_crs, dst_crs, proj)
45+
46+
# Ellipsoidal length calculator on GRS80 (EPSG:7019)
47+
dist = QgsDistanceArea()
48+
dist.setSourceCrs(src_crs, proj.transformContext())
49+
ok = False
50+
try:
51+
ok = dist.setEllipsoid('EPSG:7019')
52+
except Exception:
53+
ok = False
54+
if not ok:
55+
dist.setEllipsoid('GRS80') # fallback
56+
57+
def sql_quote(val):
58+
if val is None:
59+
return 'NULL'
60+
return "'" + str(val).replace("'", "''") + "'"
61+
62+
# Determine feature source: selected or full layer
63+
selected_count = layer.selectedFeatureCount()
64+
if selected_count > 0:
65+
features = list(layer.selectedFeatures())
66+
export_count = selected_count
67+
else:
68+
print("No features selected; exporting entire layer.")
69+
features = list(layer.getFeatures())
70+
export_count = layer.featureCount()
71+
72+
lines = []
73+
lines.append('-- SQL export generated by QGIS Python console')
74+
lines.append(f'-- Source layer: {layer.name()}')
75+
lines.append(f'-- Exported features (source): {export_count}')
76+
lines.append('BEGIN;')
77+
78+
# Add infralink type
79+
lines.append("""
80+
-- Create schema and tables if they don't exist. Needed in digiroad-import repo when generating mbtiles.
81+
CREATE SCHEMA IF NOT EXISTS infrastructure_network;
82+
83+
CREATE TABLE IF NOT EXISTS infrastructure_network.external_source (
84+
value text NOT NULL
85+
);
86+
87+
CREATE TABLE infrastructure_network.vehicle_submode_on_infrastructure_link (
88+
infrastructure_link_id uuid NOT NULL,
89+
vehicle_submode text NOT NULL
90+
);
91+
92+
CREATE TABLE IF NOT EXISTS infrastructure_network.infrastructure_link (
93+
infrastructure_link_id uuid DEFAULT public.gen_random_uuid() NOT NULL,
94+
direction text NOT NULL,
95+
shape public.geography(LineStringZ,4326) NOT NULL,
96+
estimated_length_in_metres double precision,
97+
external_link_id text NOT NULL,
98+
external_link_source text NOT NULL
99+
);
100+
101+
-- Add the temporary link external source type is it doesn't exist
102+
INSERT INTO infrastructure_network.external_source VALUES ('temp_hsl_tram') ON CONFLICT DO NOTHING;
103+
104+
""")
105+
106+
107+
batch_values = []
108+
109+
# Diagnostics
110+
written = 0 # rows added to VALUES
111+
seen = 0 # features iterated
112+
failed_transform = 0
113+
skipped_empty = 0
114+
skipped_non_line = 0
115+
non_tram = 0
116+
117+
def flush_batch():
118+
if not batch_values:
119+
return
120+
values_sql = ',\n '.join(batch_values)
121+
insert_sql = (
122+
f"INSERT INTO {table_name} "
123+
"(infrastructure_link_id, direction, shape, estimated_length_in_metres, external_link_id, external_link_source)\n"
124+
f"VALUES\n {values_sql};"
125+
)
126+
lines.append(insert_sql)
127+
batch_values.clear()
128+
129+
field_names = layer.fields().names()
130+
131+
for f in features:
132+
if f['kohdeluokka'] not in (14141, 14142):
133+
non_tram += 1
134+
continue
135+
136+
seen += 1
137+
geom = f.geometry()
138+
if geom is None or geom.isEmpty():
139+
skipped_empty += 1
140+
continue
141+
142+
# 1) Ellipsoidal length in metres
143+
try:
144+
length_m = dist.measureLength(geom)
145+
except Exception:
146+
length_m = None
147+
148+
# 2) Try to merge multipart lines to single, if any
149+
try:
150+
merged = geom.lineMerge()
151+
if merged and not merged.isEmpty():
152+
geom = merged
153+
except Exception:
154+
pass
155+
156+
# 3) Transform geometry to EPSG:4326 for WKT Z output
157+
try:
158+
geom.transform(transform)
159+
except Exception:
160+
failed_transform += 1
161+
continue
162+
163+
wkt = geom.asWkt() # LINESTRING Z (...) expected
164+
up = wkt.upper()
165+
if not up.startswith('LINESTRING'):
166+
# Skip non-lines if target column is geography(LineStringZ,4326)
167+
skipped_non_line += 1
168+
continue
169+
170+
external_id = f[id_field] if id_field in field_names else None
171+
length_sql = 'NULL' if length_m is None else f"{float(length_m)}"
172+
ext_id_sql = sql_quote(external_id)
173+
174+
infrastructure_link_id = str(uuid.uuid4())
175+
176+
# geography from EWKT in 4326 with Z
177+
shape_sql = (
178+
f"CAST(ST_GeogFromText('SRID=4326;{wkt}') AS geography(LineStringZ,4326))"
179+
)
180+
181+
value_sql = (
182+
f"('{infrastructure_link_id}', '{direction}', {shape_sql}, {length_sql}, {ext_id_sql}, '{external_link_source}')"
183+
)
184+
batch_values.append(value_sql)
185+
written += 1
186+
187+
if len(batch_values) >= batch_size:
188+
flush_batch()
189+
190+
flush_batch()
191+
192+
193+
lines.append("""
194+
-- Select all infralink ids from 'infrastructure_link' table and insert them to the 'vehicle_submode_on_infrastructure_link' table
195+
-- along with static 'generic_tram' vehicle submode info
196+
197+
INSERT INTO infrastructure_network.vehicle_submode_on_infrastructure_link
198+
SELECT il.infrastructure_link_id,
199+
'generic_tram' AS vehicle_submode
200+
FROM infrastructure_network.infrastructure_link il
201+
WHERE il.external_link_source = 'temp_hsl_tram'
202+
ON CONFLICT DO NOTHING;
203+
204+
205+
COMMIT;
206+
""")
207+
208+
with open(output_path, 'w', encoding='utf-8') as fh:
209+
fh.write('\n'.join(lines))
210+
211+
print(f"Wrote SQL to {output_path} with {written} value rows from {seen} features; skipped non_tram: {non_tram}, skipped empty: {skipped_empty}, failed_transform: {failed_transform}, non-line: {skipped_non_line}.")

import_digiroad_shapefiles.sh

Lines changed: 4 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -44,30 +44,10 @@ rm -fr "${DOWNLOAD_TARGET_DIR:?}/${AREA}"
4444
unzip -u "$DOWNLOAD_TARGET_FILE" Dokumentit/* -d "$DOWNLOAD_TARGET_DIR"
4545

4646
# Remove possibly running/existing Docker container.
47-
docker kill "$DOCKER_CONTAINER_NAME" &> /dev/null || true
48-
docker rm -v "$DOCKER_CONTAINER_NAME" &> /dev/null || true
49-
50-
# Create directories that will be mounted to Docker container.
51-
mkdir -p "$WORK_DIR"/csv
52-
mkdir -p "$WORK_DIR"/mbtiles
53-
mkdir -p "$WORK_DIR"/pgdump
54-
55-
# Create and start new Docker container. Mount all directories as volumes that
56-
# are needed by various processing scripts.
57-
docker run \
58-
--name "$DOCKER_CONTAINER_NAME" \
59-
-p 127.0.0.1:${DOCKER_CONTAINER_PORT}:5432 \
60-
-e POSTGRES_HOST_AUTH_METHOD=trust \
61-
-v "$CWD"/fixup/digiroad:/tmp/gpkg \
62-
-v "$CWD"/sql:/tmp/sql \
63-
-v "$SHP_FILE_DIR":/tmp/shp \
64-
-v "$WORK_DIR"/csv:/tmp/csv \
65-
-v "$WORK_DIR"/mbtiles:/tmp/mbtiles \
66-
-v "$WORK_DIR"/pgdump:/tmp/pgdump \
67-
-d "$DOCKER_IMAGE"
68-
69-
# Wait for PostgreSQL server to be ready.
70-
docker_exec postgres "exec $PG_WAIT"
47+
docker_kill
48+
49+
# Create and start new Docker container.
50+
docker_run "$SHP_FILE_DIR"
7151

7252
# Create digiroad import schema into database.
7353
docker_exec postgres "exec $PSQL -nt -c \"CREATE SCHEMA ${DB_SCHEMA_NAME_DIGIROAD};\""

0 commit comments

Comments
 (0)