diff --git a/design_decisions/data_sources/barcelona.md b/design_decisions/data_sources/barcelona.md index 8624072d..06b757ea 100644 --- a/design_decisions/data_sources/barcelona.md +++ b/design_decisions/data_sources/barcelona.md @@ -20,3 +20,7 @@ Barcelona data sets that can be used for testing. - API Key request form: https://www.ambmobilitat.cat/Principales/OpenData.aspx - Swagger documentation: https://api.ambmobilitat.cat/v1/swagger-ui.html - **Format**: JSON + +## Demo + +Scripts that validate and profile the AMB GTFS feed and an EDC workflow that publishes it as a data product: [`barcelona_demo/`](barcelona_demo/README.md). diff --git a/design_decisions/data_sources/barcelona_demo/README.md b/design_decisions/data_sources/barcelona_demo/README.md new file mode 100644 index 00000000..c867adb0 --- /dev/null +++ b/design_decisions/data_sources/barcelona_demo/README.md @@ -0,0 +1,32 @@ +# Barcelona demo: AMB bus GTFS through the data space + +An end-to-end demo of the AMB bus GTFS feed listed in [`../barcelona.md`](../barcelona.md): +validate and profile the feed, then publish, discover, negotiate and transfer it through EDC +connectors using the same request shapes as test +[2.2.3.1A](../../../tests/data_product_publication/publication/publication_on_emds_catalogue/test_2_2_3_1a/test.md). + +| File | What it does | Run where | +| --- | --- | --- | +| `validate_gtfs.sh` | Checks required files, record counts, referential integrity and feed validity dates | Anywhere with `sh`, `awk`, `sort`, `comm` | +| `profile_gtfs.sh` | Operators, busiest lines, wheelchair-accessible stops, modes | Same | +| `bcn-amb-gtfs.workflow.yaml` | EDC management API calls: asset, policy, contract definition, catalogue, negotiation, transfer | Bruno or curl, against a provider and a consumer connector | + +## Run the data scripts + +```sh +curl -sL -o amb_gtfs.zip https://www.ambmobilitat.cat/OpenData/google_transit.zip +unzip -o -q amb_gtfs.zip -d gtfs +sh validate_gtfs.sh gtfs +sh profile_gtfs.sh gtfs +``` + +Run on 2026-10-21 against feed version 20261021 (8,295,351 bytes, +sha256 `bc87858b215c1bdf9069a593a0247edd848ad66fb910d5a651aac64823cddc3b`): validation PASS, +11 operators, 140 routes, 4,970 stops, 30,145 trips, 950,579 stop times. + +## Connector steps + +The workflow file reuses the request shapes from test 2.2.3.1A. It needs a running provider and +consumer connector and their management API key. These steps have not been executed against a +live connector yet; fill in the hosts, the key and the MobilityDCAT-AP theme URI from your own +environment, and never commit them. diff --git a/design_decisions/data_sources/barcelona_demo/bcn-amb-gtfs.workflow.yaml b/design_decisions/data_sources/barcelona_demo/bcn-amb-gtfs.workflow.yaml new file mode 100644 index 00000000..3dfa4d7e --- /dev/null +++ b/design_decisions/data_sources/barcelona_demo/bcn-amb-gtfs.workflow.yaml @@ -0,0 +1,97 @@ +# Barcelona demo: publish the AMB bus GTFS feed as an EDC data product and consume it. +# Same request shapes as the sanitized Bruno workflow in +# tests/data_product_publication/publication/publication_on_emds_catalogue/test_2_2_3_1a. +# Fill in the hosts and the management API key from your own environment; never commit them. + +variables: + provider: "" + consumer: "" + asset-id: "bcn-amb-gtfs-bus" + policy-id: "bcn-amb-open-use" + contract-id: "bcn-amb-gtfs-offer" + +requests: + - name: 1 Create asset (provider) + method: POST + url: https://{{provider}}/api/management/v3/assets + expected_status: 200 + body: + "@id": "{{asset-id}}" + "properties": + "dct:title": "AMB metropolitan bus timetables (GTFS)" + "dct:description": "Scheduled services of the Barcelona metropolitan bus network" + "dct:publisher": + "foaf:name": "AMB Mobilitat" + "dct:format": "GTFS" + "mobilitydcatap:mobilityTheme": "" + "dqv:hasQualityAnnotation": "validate_gtfs.sh: PASS (0 orphan references)" + "dataAddress": + "edc:type": "HttpData" + "edc:baseUrl": "https://www.ambmobilitat.cat/OpenData/google_transit.zip" + + - name: 2 Create policy (provider) + method: POST + url: https://{{provider}}/api/management/v3/policydefinitions + expected_status: 200 + body: + "@id": "{{policy-id}}" + "policy": + "@type": "odrl:Set" + "permission": + - "odrl:action": "odrl:use" + "prohibition": [] + "obligation": [] + + - name: 3 Create contract definition (provider) + method: POST + url: https://{{provider}}/api/management/v3/contractdefinitions + expected_status: 200 + body: + "@id": "{{contract-id}}" + "accessPolicyId": "{{policy-id}}" + "contractPolicyId": "{{policy-id}}" + "assetsSelector": + "operandLeft": "https://w3id.org/edc/v0.0.1/ns/id" + "operator": "=" + "operandRight": "{{asset-id}}" + + - name: 4 Query catalogue (consumer) + method: POST + url: https://{{consumer}}/api/management/v3/catalog/request + expected_status: 200 + body: + "counterPartyAddress": "https://{{provider}}/api/dsp" + "counterPartyId": "did:web:" + "protocol": "dataspace-protocol-http" + "querySpec": + "offset": 0 + "limit": 100 + # assert: a dcat:dataset with @id == {{asset-id}} is present; save its odrl:hasPolicy @id as offer-id + + - name: 5 Start contract negotiation (consumer) + method: POST + url: https://{{consumer}}/api/management/v3/contractnegotiations + expected_status: 200 + body: + "@type": "ContractRequest" + "counterPartyAddress": "https://{{provider}}/api/dsp" + "protocol": "dataspace-protocol-http" + "policy": + "@id": "{{offer-id}}" + "@type": "odrl:Offer" + "assigner": "did:web:" + "target": "{{asset-id}}" + # poll GET /v3/contractnegotiations/{{negotiation-id}} until state == FINALIZED; save contractAgreementId + + - name: 6 Start transfer (consumer) + method: POST + url: https://{{consumer}}/api/management/v3/transferprocesses + expected_status: 200 + body: + "@type": "TransferRequest" + "counterPartyAddress": "https://{{provider}}/api/dsp" + "protocol": "dataspace-protocol-http" + "contractId": "{{agreement-id}}" + "transferType": "HttpData-PULL" + # poll until state == STARTED, then GET /v3/edrs/{{transfer-id}}/dataaddress for endpoint + token, + # download through the provider data plane and compare its sha256 with the source feed diff --git a/design_decisions/data_sources/barcelona_demo/profile_gtfs.sh b/design_decisions/data_sources/barcelona_demo/profile_gtfs.sh new file mode 100755 index 00000000..dc2f090b --- /dev/null +++ b/design_decisions/data_sources/barcelona_demo/profile_gtfs.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env sh +# Profile a GTFS feed: the quick analysis a consumer runs after the transfer. +# Usage: ./profile_gtfs.sh +set -eu +d="${1:-gtfs}" + +echo "== Operators (routes per agency)" +awk -F, 'NR==FNR{if(FNR>1)name[$1]=$2;next} FNR>1{c[$2]++} END{for(a in c)print c[a]"\t"name[a]}' \ + "$d/agency.txt" "$d/routes.txt" | sort -rn | awk -F'\t' '{printf " %-24s %3d routes\n",$2,$1}' + +echo "== Busiest lines (scheduled trips in feed window)" +awk -F'"' 'NR>1{split($0,f,",");print f[1]"\t"$2"\t"$4}' "$d/routes.txt" > /tmp/routes.$$ +awk -F, 'NR>1{c[$1]++} END{for(r in c)print r"\t"c[r]}' "$d/trips.txt" \ + | sort -t"$(printf '\t')" -k2 -rn | head -8 \ + | awk -F'\t' 'NR==FNR{n[$1]=$2" "$3;next}{printf " %-42s %5d trips\n",n[$1],$2}' /tmp/routes.$$ - +rm -f /tmp/routes.$$ + +echo "== Accessibility" +awk -F, 'NR>1{n++; if($(NF-1)==1)w++} END{printf " wheelchair-boarding stops: %d of %d (%.0f%%)\n",w,n,100*w/n}' "$d/stops.txt" + +echo "== Modes" +awk -F, 'NR==1{for(i=1;i<=NF;i++)if($i=="route_type")t=i} NR>1{c[$t]++} END{for(k in c)printf " route_type %s: %d routes%s\n",k,c[k],(k==3?" (bus)":"")}' "$d/routes.txt" diff --git a/design_decisions/data_sources/barcelona_demo/validate_gtfs.sh b/design_decisions/data_sources/barcelona_demo/validate_gtfs.sh new file mode 100755 index 00000000..24387d1e --- /dev/null +++ b/design_decisions/data_sources/barcelona_demo/validate_gtfs.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env sh +# Validate an unpacked GTFS feed before it is registered as an EDC asset. +# Usage: ./validate_gtfs.sh +set -eu +d="${1:-gtfs}" +fail=0 + +echo "== Required files" +for f in agency stops routes trips stop_times; do + if [ -f "$d/$f.txt" ]; then echo " ok $f.txt"; else echo " MISS $f.txt"; fail=1; fi +done +if [ -f "$d/calendar.txt" ] || [ -f "$d/calendar_dates.txt" ]; then echo " ok calendar(_dates).txt"; else echo " MISS calendar"; fail=1; fi + +echo "== Record counts" +for f in agency routes stops trips stop_times; do + printf ' %-11s %8d\n' "$f" "$(($(wc -l < "$d/$f.txt") - 1))" +done + +echo "== Referential integrity" +tmp=$(mktemp -d) +awk -F, 'NR>1{print $1}' "$d/routes.txt" | sort -u > "$tmp/routes" +awk -F, 'NR>1{print $1}' "$d/stops.txt" | sort -u > "$tmp/stops" +awk -F, 'NR>1{print $3}' "$d/trips.txt" | sort -u > "$tmp/trips" +r=$(awk -F, 'NR>1{print $1}' "$d/trips.txt" | sort -u | comm -23 - "$tmp/routes" | wc -l) +s=$(awk -F, 'NR>1{print $4}' "$d/stop_times.txt" | sort -u | comm -23 - "$tmp/stops" | wc -l) +t=$(awk -F, 'NR>1{print $1}' "$d/stop_times.txt" | sort -u | comm -23 - "$tmp/trips" | wc -l) +rm -rf "$tmp" +echo " trips -> routes orphans: $r" +echo " stop_times -> stops orphans: $s" +echo " stop_times -> trips orphans: $t" +[ "$r$s$t" = "000" ] || fail=1 + +echo "== Feed validity" +awk -F, 'NR==2{printf " publisher %s, valid %s to %s, version %s\n",$1,$4,$5,$6}' "$d/feed_info.txt" + +if [ "$fail" -eq 0 ]; then echo "RESULT: PASS"; else echo "RESULT: FAIL"; exit 1; fi diff --git a/design_decisions/data_sources/stockholm.md b/design_decisions/data_sources/stockholm.md new file mode 100644 index 00000000..b15f6a30 --- /dev/null +++ b/design_decisions/data_sources/stockholm.md @@ -0,0 +1,20 @@ +Stockholm data sets that can be used for testing, from SLB-analys (City of Stockholm). + +# SLB-analys + +## Air quality time series + +- **URL**: +- **Type**: Open Data (public access key `lvl1`) +- **Format**: JSON (`format=FME`), hourly values +- **Example**: station `Norrlandsgatan 21` (street canyon, urban traffic), parameters `NO2,PM10` + +## Noise + +- **Source**: EnviroSuite noise meters on Sveavägen and Norrlandsgatan +- **Type**: API accessible via API key (held by SLB-analys; do not commit keys) +- **Format**: JSON, daily Lday (dB) + +## Demo + +Scripts that fetch and check the air data against the WHO guidelines, and an EDC workflow in which SLB shares air and noise data with Trafikkontoret for low emission zone follow-up: [`stockholm_demo/`](stockholm_demo/README.md). diff --git a/design_decisions/data_sources/stockholm_demo/README.md b/design_decisions/data_sources/stockholm_demo/README.md new file mode 100644 index 00000000..c51e50d0 --- /dev/null +++ b/design_decisions/data_sources/stockholm_demo/README.md @@ -0,0 +1,28 @@ +# Stockholm LEZ demo: SLB air and noise data for Trafikkontoret + +SLB-analys (data provider) shares street-level NO2, PM10 and noise with Trafikkontoret (TK, data +consumer) to follow up the low emission zone. TK uses the data in the Hus för hus service. +Data sources are listed in [`../stockholm.md`](../stockholm.md); the connector requests reuse the shapes from test +[2.2.3.1A](../../../tests/data_product_publication/publication/publication_on_emds_catalogue/test_2_2_3_1a/test.md). + +| File | What it does | +| --- | --- | +| `fetch_slb.sh` | Downloads hourly NO2 and PM10 for one station from the open SLB API (`open.slb.nu`) | +| `analyse_slb.sh` | Hours, mean, peak, latest, busiest hour and days above the WHO 2021 24 h guidelines (NO2 25, PM10 45 µg/m³) | +| `slb-tk-lez.workflow.yaml` | EDC calls: air and noise assets, a purpose-bound policy for TK, catalogue, negotiation, transfer | + +## Run + +```sh +sh fetch_slb.sh 14 "Norrlandsgatan 21" +sh analyse_slb.sh slb.json +``` + +Run on 2026-10-21 (07–21 Oct, 675 hourly records): NO2 mean 11.8 µg/m³, peak 44.3 on 19 Oct +11:00, above the WHO 24 h guideline on 1 of 13 full days; PM10 mean 9.1 µg/m³, 0 days above. + +## Notes + +- The connector steps have not been run against live connectors; hosts, DIDs and keys are placeholders. +- Noise comes from EnviroSuite and needs an API key. Keep it in the provider connector's vault + (`envirosuite-key`), never in a browser page or this repository. diff --git a/design_decisions/data_sources/stockholm_demo/analyse_slb.sh b/design_decisions/data_sources/stockholm_demo/analyse_slb.sh new file mode 100755 index 00000000..fe04883b --- /dev/null +++ b/design_decisions/data_sources/stockholm_demo/analyse_slb.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env sh +# Summarise an SLB timeseries response against the WHO 2021 guidelines. +# Usage: ./analyse_slb.sh [slb.json] +set -eu +f="${1:-slb.json}" + +# One record per line: parameter local datetime value +tr '{' '\n' < "$f" | awk ' + match($0,/"parameter":"[^"]*"/) { p=substr($0,RSTART+13,RLENGTH-14) } + match($0,/"value":[-0-9.]+/) { v=substr($0,RSTART+8,RLENGTH-8) } + match($0,/"Datetime local":"[^"]*"/) { t=substr($0,RSTART+18,19) } + p!="" && v!="" && t!="" { print p "\t" t "\t" v; p=v=t="" }' > records.tsv + +echo "== Station" +grep -o '"station":"[^"]*","kommun":"[^"]*","stationstyp":"[^"]*"' "$f" | head -1 | tr -d '"' | sed 's/,/ /g' + +awk -F'\t' ' + BEGIN { who["NO2"]=25; who["PM10"]=45 } + { n[$1]++; s[$1]+=$3 + if (!($1 in mx) || $3>mx[$1]) { mx[$1]=$3; mxt[$1]=$2 } + if ($2>lt[$1]) { lt[$1]=$2; lv[$1]=$3 } + if (!($1 in first) || $2=18) { days++; if (ds[k]/dn[k]>who[p]) above++ } } + best=-1; for (k in hn) { split(k,a,SUBSEP); if (a[1]==p && hs[k]/hn[k]>best) { best=hs[k]/hn[k]; bh=a[2] } } + printf "== %s (ug/m3)\n", p + printf " hours %d (from %s)\n", n[p], substr(first[p],1,16) + printf " mean %.1f\n", s[p]/n[p] + printf " peak hour %.1f at %s\n", mx[p], substr(mxt[p],1,16) + printf " latest %.1f at %s\n", lv[p], substr(lt[p],1,16) + printf " busiest hour %s:00 local, mean %.1f\n", bh, best + printf " days above WHO 24h guideline (%d): %d of %d\n", who[p], above, days + } + }' records.tsv | sed 's/ug\/m3/µg\/m³/' diff --git a/design_decisions/data_sources/stockholm_demo/fetch_slb.sh b/design_decisions/data_sources/stockholm_demo/fetch_slb.sh new file mode 100755 index 00000000..0348e196 --- /dev/null +++ b/design_decisions/data_sources/stockholm_demo/fetch_slb.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env sh +# Fetch hourly NO2 and PM10 for one SLB station from the open SLB API. +# Usage: ./fetch_slb.sh [days] [station] (defaults: 14, "Norrlandsgatan 21") +set -eu +days="${1:-14}" +station="${2:-Norrlandsgatan 21}" +from=$(date -u -d "$days days ago" '+%Y-%m-%d %H:%M') + +curl -s --max-time 60 -G "https://open.slb.nu/api/timeseries/v1.1b/get-timeseries/" \ + --data-urlencode "key=lvl1" \ + --data-urlencode "stations=$station" \ + --data-urlencode "parameters=NO2,PM10" \ + --data-urlencode "format=FME" \ + --data-urlencode "from_datetime=$from" \ + -o slb.json -w "HTTP %{http_code} %{size_download} bytes from $from\n" diff --git a/design_decisions/data_sources/stockholm_demo/slb-tk-lez.workflow.yaml b/design_decisions/data_sources/stockholm_demo/slb-tk-lez.workflow.yaml new file mode 100644 index 00000000..c52d57c1 --- /dev/null +++ b/design_decisions/data_sources/stockholm_demo/slb-tk-lez.workflow.yaml @@ -0,0 +1,111 @@ +# Stockholm LEZ demo: SLB-analys (provider) shares NO2, PM10 and noise with Trafikkontoret (consumer). +# Request shapes follow tests/data_product_publication/publication/publication_on_emds_catalogue/test_2_2_3_1a. +# Fill in hosts, DIDs, the management API key and the theme URI from your environment; never commit them. +# The EnviroSuite API key is stored as a secret in the SLB connector's vault under "envirosuite-key". + +variables: + slb: "" + tk: "" + air-asset: "slb-norrlandsgatan21-no2-pm10" + noise-asset: "slb-noise-lday-central" + policy-id: "lez-follow-up-tk" + contract-id: "slb-lez-offer" + +requests: + - name: 1 Register air asset (SLB) + method: POST + url: https://{{slb}}/api/management/v3/assets + expected_status: 200 + body: + "@id": "{{air-asset}}" + "properties": + "dct:title": "Hourly NO2 and PM10, Norrlandsgatan 21" + "dct:spatial": "Norrlandsgatan 21, Stockholm" + "mobilitydcatap:mobilityTheme": "" + "dataAddress": + "edc:type": "HttpData" + "edc:baseUrl": "https://open.slb.nu/api/timeseries/v1.1b/get-timeseries/" + "edc:proxyQueryParams": "true" + + - name: 2 Register noise asset (SLB) + method: POST + url: https://{{slb}}/api/management/v3/assets + expected_status: 200 + body: + "@id": "{{noise-asset}}" + "properties": + "dct:title": "Daily Lday noise, Sveavägen and Norrlandsgatan" + "dataAddress": + "edc:type": "HttpData" + "edc:baseUrl": "https://app.envirosuite.com/api/query/" + "edc:authKey": "apiKey" + "edc:secretName": "envirosuite-key" + + - name: 3 Policy: LEZ follow-up, TK only (SLB) + method: POST + url: https://{{slb}}/api/management/v3/policydefinitions + expected_status: 200 + body: + "@id": "{{policy-id}}" + "policy": + "@type": "odrl:Set" + "permission": + - "action": "use" + "constraint": + - "leftOperand": "purpose" + "operator": "eq" + "rightOperand": "LEZ-follow-up" + - "leftOperand": "participant" + "operator": "eq" + "rightOperand": "did:web:" + + - name: 4 Contract definition for both assets (SLB) + method: POST + url: https://{{slb}}/api/management/v3/contractdefinitions + expected_status: 200 + body: + "@id": "{{contract-id}}" + "accessPolicyId": "{{policy-id}}" + "contractPolicyId": "{{policy-id}}" + "assetsSelector": + - "operandLeft": "https://w3id.org/edc/v0.0.1/ns/id" + "operator": "in" + "operandRight": ["{{air-asset}}", "{{noise-asset}}"] + + - name: 5 Catalogue request (TK) + method: POST + url: https://{{tk}}/api/management/v3/catalog/request + expected_status: 200 + body: + "counterPartyAddress": "https://{{slb}}/api/dsp" + "counterPartyId": "did:web:" + "protocol": "dataspace-protocol-http" + # assert: both datasets present; save each odrl:hasPolicy @id + + - name: 6 Contract negotiation (TK) + method: POST + url: https://{{tk}}/api/management/v3/contractnegotiations + expected_status: 200 + body: + "@type": "ContractRequest" + "counterPartyAddress": "https://{{slb}}/api/dsp" + "protocol": "dataspace-protocol-http" + "policy": + "@id": "{{offer-air}}" + "@type": "odrl:Offer" + "assigner": "did:web:" + "target": "{{air-asset}}" + # poll until FINALIZED; save contractAgreementId + + - name: 7 Transfer (TK) + method: POST + url: https://{{tk}}/api/management/v3/transferprocesses + expected_status: 200 + body: + "@type": "TransferRequest" + "counterPartyAddress": "https://{{slb}}/api/dsp" + "protocol": "dataspace-protocol-http" + "contractId": "{{agreement-id}}" + "transferType": "HttpData-PULL" + # then GET /v3/edrs/{{transfer-id}}/dataaddress and call the endpoint with + # ?stations=Norrlandsgatan%2021¶meters=NO2,PM10&format=FME&from_datetime=...