Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

* [#5](https://github.com/itk-dev/enter/pull/5)
* Added osm-handicap-parking.
* [#3](https://github.com/itk-dev/enter/pull/3)
* Import command that reads a geospatial feed, reprojects it to WGS84 and upserts it to an NGSI-LD broker.
* A committed record per data set — feed, CRS, model, DCAT-AP metadata.
Expand Down
47 changes: 47 additions & 0 deletions config/sources.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,50 @@ sources:
oprettet_dato: 'Describes the register record.'
rettet_dato: 'Describes the register record.'
mi_style: 'MapInfo rendering style, empty throughout the export.'

osm-handicap-parking:
title: 'Handicapparkering (OpenStreetMap), Aarhus Kommune'
description: >-
Disabled parking mapped in OpenStreetMap within Aarhus
Municipality: single reserved bays, and parking facilities that
state how many of their bays are reserved. Read via the Overpass
API.
publisher: 'OpenStreetMap contributors'

# Community-maintained data; there is no single contact.
contact: ~
landing_page: 'https://wiki.openstreetmap.org/wiki/Tag:parking_space%3Ddisabled'

# The Overpass QL in the URL: within Aarhus Municipality (OSM relation
# 1784663), select every element tagged as a disabled parking space
# (parking_space=disabled) or as reserving bays for disabled parking
# (capacity:disabled, excluding "no" and "0").
access_url: 'https://overpass-api.de/api/interpreter?data=%5Bout%3Ajson%5D%5Btimeout%3A180%5D%3Barea%283601784663%29-%3E.a%3B%28nwr%5B%22parking_space%22%3D%22disabled%22%5D%28area.a%29%3Bnwr%5B%22capacity%3Adisabled%22%5D%5B%22capacity%3Adisabled%22%21~%22%5E%28no%7C0%29%24%22%5D%28area.a%29%3B%29%3Bout%20geom%20tags%3B'
media_type: application/json
crs: 'EPSG:4326'
model: OnStreetParking
context_url: 'https://raw.githubusercontent.com/smart-data-models/dataModel.Parking/master/context.jsonld'
update_frequency: continuous

# Republication must attribute "© OpenStreetMap contributors" and
# share under the same licence.
licence: 'https://opendatacommons.org/licenses/odbl/1-0/'

# OpenStreetMap tagging is open-ended, so unlike a fixed-schema feed
# this cannot list everything an element may carry. These are the
# recurring tags in the current extract that are not published;
# coverage figures count records carrying the tag in the September
# 2026 extract.
omitted_fields:
amenity: 'Selector distinguishing a single bay (parking_space) from a facility (parking); the model carries no such distinction.'
capacity: 'Published for single bays only; on a facility it counts all bays and would overstate the reserved capacity.'
parking: 'Facility siting (street_side, surface, underground); every record is published under the one model the manifest names.'
orientation: 'How bays lie relative to the road; the model has no counterpart.'
disabled: 'Access restriction on street-side parking; redundant with the category every entity is published with.'
access: 'Who may enter; mapping it onto permit attributes needs an interpretation the tag values do not support.'
'fee:conditional': 'Time-qualified refinement of fee; the category values the plain fee tag maps onto carry no schedule.'
surface: 'Paving material; the model has no counterpart. 6% of records carry it.'
wheelchair: 'Step-free access to the place, not the parking capacity. 3% of records carry it.'
'capacity:charging': 'Bays with charging points; a different subset than the reserved bays this data set publishes. 2% of records carry it.'
operator: 'Who runs the facility; a fact about the business rather than its reserved bays. 1% of records carry it.'
brand: 'Commercial brand of the facility; the name already identifies it. Under 1% of records carry it.'
226 changes: 226 additions & 0 deletions src/Source/Osm/HandicapParking.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
<?php

declare(strict_types=1);

namespace App\Source\Osm;

use App\Geo\Wgs84Transformer;
use App\Ngsi\NgsiEntity;
use App\Source\DataSourceReader;
use App\Source\Manifest\Catalog;
use App\Source\Manifest\Descriptor;
use App\Source\SourceInterface;

/**
* Disabled parking in Aarhus Municipality, as mapped in OpenStreetMap.
*
* The feed carries two kinds of record: single reserved bays
* (parking_space=disabled) and parking facilities that reserve bays
* (capacity:disabled). Both are published as one entity with the number of
* reserved bays, so the distinction only matters for how that number is read.
*
* @see config/sources.yaml
* @see https://github.com/smart-data-models/dataModel.Parking/tree/master/OnStreetParking
*/
final readonly class HandicapParking implements SourceInterface
{
private const string KEY = 'osm-handicap-parking';

public function __construct(
private DataSourceReader $reader,
private Wgs84Transformer $transformer,
private Catalog $catalog,
) {
}

public function key(): string
{
return self::KEY;
}

public function entities(): iterable
{
$source = $this->catalog->get(self::KEY);

// Overpass wraps the matched OSM objects in an envelope with version
// and timestamp metadata; the records live under `elements`.
foreach ($this->reader->read($source->accessUrl)['elements'] ?? [] as $element) {
if (\is_array($element) && null !== $entity = $this->toEntity($element, $source)) {
yield $entity;
}
}
}

/**
* @param array<string, mixed> $element Overpass JSON element
*/
private function toEntity(array $element, Descriptor $source): ?NgsiEntity
{
$type = $element['type'] ?? null;
$id = $element['id'] ?? null;

// OSM ids are only unique per element type, so both are needed to
// address the same object again on the next import.
if (!\is_string($type) || !\is_int($id)) {
return null;
}

$geometry = $this->geometry($element);
if (null === $geometry) {
return null;
}

$tags = \is_array($element['tags'] ?? null) ? $element['tags'] : [];

$entity = new NgsiEntity(
\sprintf('urn:ngsi-ld:%s:aarhus-handicap-osm-%s-%d', $source->model, $type, $id),
$source->model
);

return $entity
->setProperty('name', trim((string) ($tags['name'] ?? '')))
->setProperty('description', trim((string) ($tags['description'] ?? '')))
->setProperty('category', $this->category($tags))
->setProperty('totalSpotNumber', $this->reservedBays($tags))
->setProperty('source', $source->accessUrl)
->geoProperty('location', $this->transformer->transformGeometry($source->crs, $geometry));
}

/**
* Every record is disabled parking; the fee tag refines that with the
* model's charging categories. Only its two plain values map — an
* untagged or unrecognised value states nothing about charging rather
* than assuming free.
*
* @param array<string, mixed> $tags
*
* @return list<string>
*/
private function category(array $tags): array
{
return match ($tags['fee'] ?? null) {
'yes' => ['forDisabled', 'feeCharged'],
'no' => ['forDisabled', 'free'],
default => ['forDisabled'],
};
}

/**
* Number of reserved bays the record carries.
*
* capacity:disabled counts them directly whatever the record is. A single
* bay (parking_space=disabled) is reserved in its entirety, so its own
* capacity applies — one when untagged, per the tag's definition. A
* facility's plain capacity counts all its bays and is never used, and
* capacity:disabled=yes states that reserved bays exist without counting
* them, so nothing is published for it.
*
* @param array<string, mixed> $tags
*/
private function reservedBays(array $tags): ?int
{
if (null !== $count = $this->count($tags, 'capacity:disabled')) {
return $count;
}

if ('disabled' === ($tags['parking_space'] ?? null)) {
return $this->count($tags, 'capacity') ?? 1;
}

return null;
}

/**
* @param array<string, mixed> $tags
*/
private function count(array $tags, string $tag): ?int
{
$value = $tags[$tag] ?? null;

return \is_string($value) && ctype_digit($value) ? (int) $value : null;
}

/**
* @param array<string, mixed> $element
*
* @return array{type: string, coordinates: mixed}|null
*/
private function geometry(array $element): ?array
{
return match ($element['type'] ?? null) {
'node' => $this->point($element['lon'] ?? null, $element['lat'] ?? null),
'way' => $this->wayGeometry($element['geometry'] ?? null),
// The feed's output mode carries no member geometry for
// relations, only their bounding box, so the centre of that box
// is the best location available.
'relation' => $this->boundsCentre($element['bounds'] ?? null),
default => null,
};
}

/**
* @return array{type: string, coordinates: array{float, float}}|null
*/
private function point(mixed $longitude, mixed $latitude): ?array
{
if (!is_numeric($longitude) || !is_numeric($latitude)) {
return null;
}

return ['type' => 'Point', 'coordinates' => [(float) $longitude, (float) $latitude]];
}

/**
* @return array{type: string, coordinates: mixed}|null
*/
private function wayGeometry(mixed $vertices): ?array
{
if (!\is_array($vertices)) {
return null;
}

$positions = [];
foreach ($vertices as $vertex) {
if (!\is_array($vertex) || !is_numeric($vertex['lon'] ?? null) || !is_numeric($vertex['lat'] ?? null)) {
return null;
}

$positions[] = [(float) $vertex['lon'], (float) $vertex['lat']];
}

// A way that returns to its first vertex outlines an area — here a
// bay or a parking lot — so it becomes a Polygon ring rather than a
// line along its edge. Four positions are a ring's minimum: three
// corners plus the repeated first.
if (\count($positions) >= 4 && $positions[0] === $positions[array_key_last($positions)]) {
return ['type' => 'Polygon', 'coordinates' => [$positions]];
}

if (\count($positions) >= 2) {
return ['type' => 'LineString', 'coordinates' => $positions];
}

return null;
}

/**
* @return array{type: string, coordinates: array{float, float}}|null
*/
private function boundsCentre(mixed $bounds): ?array
{
if (!\is_array($bounds)) {
return null;
}

foreach (['minlon', 'minlat', 'maxlon', 'maxlat'] as $edge) {
if (!is_numeric($bounds[$edge] ?? null)) {
return null;
}
}

return $this->point(
((float) $bounds['minlon'] + (float) $bounds['maxlon']) / 2,
((float) $bounds['minlat'] + (float) $bounds['maxlat']) / 2
);
}
}
Loading