diff options
| author | Guilhem Moulin <guilhem@fripost.org> | 2025-06-09 14:17:32 +0200 | 
|---|---|---|
| committer | Guilhem Moulin <guilhem@fripost.org> | 2025-06-09 22:21:31 +0200 | 
| commit | 575f8a460d25868cda4e9cef522e43650164f89b (patch) | |
| tree | 8898c76b9c7e9c228cd6920b53a378b845c87228 | |
| parent | 5197bb5a5fa50fc04a68306b32f8abf7a411933b (diff) | |
Export feature "age" to MVT.
This allows client-side filtering.
The "age" attribute is a signed short (int16) expressing the number of
days since 1970-01-01.  This covers the range 1880-04-15 to 2059-09-18
which should be more than enough.  The source value is a Date or
Datetime and depends on the source layer.
  - For vbk:*, it's the date at which the project was last saved in
    Vindbrukskollen.
  - For mrr:*, it's the date at which the application came to
    Bergsstaten for applications, and decision date for granted permits.
  - For avverk:*, it's the date at which the application came to
    Skogsstyrelsen for applications, and the clearcut date (according to
    Skogsstyrelsen) for completed objects.
For other layers, we don't export that attribute.
This makes the tiles a bit larger.  Before (without the extra
attribute):
  vbk: 1261× tiles, 599kiB uncompressed (avg=487B/t), 389kiB brotli (35%, avg=316B/t)
  avverk: 3734× tiles 115MiB uncompressed (avg=32kiB/t), 72MiB brotli (37%, avg=20kiB/t)
  mrr: 1324× 331kiB uncompressed (avg=257kiB/t), 289kiB brotli (13%, avg=223B/t)
  → total 121631367B uncompressed (avg=19kiB/t), 76692807B brotli (37%, avg=12kiB/t)
After (with the extra attribute):
  vbk: 1261× tiles, 714kiB uncompressed (avg=580B/t), 425kiB brotli (40%, avg=345B/t)
  avverk: 3734× tiles 127MiB uncompressed (avg=35kiB/t), 78MiB brotli (39%, avg=21kiB/t)
  mrr: 1324× 323kiB uncompressed (avg=322kiB/t), 342kiB brotli (18%, avg=265B/t)
  → total 134274796B uncompressed (avg=21kiB/t), 82264731B brotli (39%, avg=13kiB/t)
Summary:
  +12.1MiB uncompressed (+10.4%, avg=+1.95kiB/t)
  +5.3MiB compressed (+7.3%, avg=+882B/t)
| -rw-r--r-- | config.yml | 81 | ||||
| -rw-r--r-- | export_mvt.py | 110 | 
2 files changed, 174 insertions, 17 deletions
@@ -2709,7 +2709,10 @@ layers:          - AvvSasong          - AvvHa          - AvverkningsanmalanKlass -    publish: anmald +    publish: +      anmald: +        fields: +          age: Inkomdatum    'sks:avverk_utford':      # https://geodpags.skogsstyrelsen.se/geodataport/feeds/UtfordAvverk.xml @@ -2799,7 +2802,10 @@ layers:          Beteckn:            - replace: 'Visas ej'              with: null -    publish: utford +    publish: +      utford: +        fields: +          age: Avvdatum    'sametinget:betesomrade':      description: 'Samebyarnas betesområden: Renbetesområden' @@ -3217,30 +3223,44 @@ layers:          minzoom: 4          where: |            "Raderat" IS FALSE AND "Statuskod" = 4 +        fields: +          age: SenasteUppdaterat        station_processed:          minzoom: 4          where: |            "Raderat" IS FALSE AND "Statuskod" = 1 +        fields: +          age: SenasteUppdaterat        station_approved:          minzoom: 4          where: |            "Raderat" IS FALSE AND "Statuskod" = 3 +        fields: +          age: SenasteUppdaterat        station_revoked:          minzoom: 4          where: |            "Raderat" IS FALSE AND ("Statuskod" = 6 OR "Statuskod" = 9) +        fields: +          age: SenasteUppdaterat        station_rejected:          minzoom: 4          where: |            "Raderat" IS FALSE AND "Statuskod" = 7 +        fields: +          age: SenasteUppdaterat        station_dismounted:          minzoom: 4          where: |            "Raderat" IS FALSE AND "Statuskod" = 5 +        fields: +          age: SenasteUppdaterat        station_appealed:          minzoom: 4          where: |            "Raderat" IS FALSE AND "Statuskod" = 8 +        fields: +          age: SenasteUppdaterat    'vbk:projekteringsomraden':      description: Vindbrukskollen landbaserade projekteringsområden (Länsstyrelsen) @@ -3395,9 +3415,13 @@ layers:        area_current:          where: |            "Raderat" IS FALSE AND "EjAktuell" IS FALSE +        fields: +          age: SenasteUppdaterat        area_notcurrent:          where: |            "Raderat" IS FALSE AND "EjAktuell" IS NOT FALSE +        fields: +          age: SenasteUppdaterat    'vbk:havsbaserad_vindkraft':      description: Vindbrukskollen havsbaserad vindkraft (Länsstyrelsen) @@ -3613,30 +3637,48 @@ layers:        offshore_completed:          where: |            "Raderat" IS FALSE AND "Projektstatus" = 'Uppförd' +        fields: +          age: SenasteUppdaterat        offshore_approved:          where: |            "Raderat" IS FALSE AND "Projektstatus" = 'Tillståndsansökan beviljad' +        fields: +          age: SenasteUppdaterat        offshore_amended:          where: |            "Raderat" IS FALSE AND "Projektstatus" = 'Ändringsansökan' +        fields: +          age: SenasteUppdaterat        offshore_rejected:          where: |            "Raderat" IS FALSE AND "Projektstatus" = 'Tillståndsansökan avslagen' +        fields: +          age: SenasteUppdaterat        offshore_appealed:          where: |            "Raderat" IS FALSE AND "Projektstatus" = 'Överklagad' +        fields: +          age: SenasteUppdaterat        offshore_applied:          where: |            "Raderat" IS FALSE AND "Projektstatus" = 'Tillståndsansökan inlämnad' +        fields: +          age: SenasteUppdaterat        offshore_consultation:          where: |            "Raderat" IS FALSE AND "Projektstatus" = 'Samråd inför tillståndsansökan' +        fields: +          age: SenasteUppdaterat        offshore_investigation:          where: |            "Raderat" IS FALSE AND "Projektstatus" = 'Inledande undersökningar' +        fields: +          age: SenasteUppdaterat        offshore_revoked:          where: |            "Raderat" IS FALSE AND "Projektstatus" = 'Inte aktuell eller återkallad' +        fields: +          age: SenasteUppdaterat    'mrr:ut_metaller_industrimineral_ansokta':      # https://resource.sgu.se/dokument/produkter/mineralrattigheter-beskrivning.pdf @@ -3678,7 +3720,10 @@ layers:        path: mineralrattigheter.gpkg        format: GPKG        layername: ut_metaller_industrimineral_ansokta -    publish: appl_met +    publish: +      appl_met: +        fields: +          age: appl_date    'mrr:ut_diamant_ansokta':      # https://resource.sgu.se/dokument/produkter/mineralrattigheter-beskrivning.pdf @@ -3720,7 +3765,10 @@ layers:        path: mineralrattigheter.gpkg        format: GPKG        layername: ut_diamant_ansokta -    publish: appl_ogd +    publish: +      appl_ogd: +        fields: +          age: appl_date    'mrr:bearbetningskoncessioner_ansokta':      # https://resource.sgu.se/dokument/produkter/mineralrattigheter-beskrivning.pdf @@ -3762,7 +3810,10 @@ layers:        path: mineralrattigheter.gpkg        format: GPKG        layername: bearbetningskoncessioner_ansokta -    publish: appl_ec +    publish: +      appl_ec: +        fields: +          age: appl_date    'mrr:markanvisningar_bk_ansokta':      # https://resource.sgu.se/dokument/produkter/mineralrattigheter-beskrivning.pdf @@ -3861,7 +3912,10 @@ layers:          licenceid:            - replace: '-'              with: null -    publish: appr_met +    publish: +      appr_met: +        fields: +          age: dec_date    'mrr:ut_diamant_beviljade':      # https://resource.sgu.se/dokument/produkter/mineralrattigheter-beskrivning.pdf @@ -3924,7 +3978,10 @@ layers:          licenceid:            - replace: '-'              with: null -    publish: appr_ogd +    publish: +      appr_ogd: +        fields: +          age: dec_date    'mrr:bearbetningskoncessioner_beviljade':      # https://resource.sgu.se/dokument/produkter/mineralrattigheter-beskrivning.pdf @@ -3988,7 +4045,10 @@ layers:          licenceid:            - replace: '-'              with: null -    publish: appr_ec +    publish: +      appr_ec: +        fields: +          age: dec_date    'mrr:markanvisningar_bk_beviljade':      # https://resource.sgu.se/dokument/produkter/mineralrattigheter-beskrivning.pdf @@ -4038,7 +4098,10 @@ layers:          licenceid:            - replace: '-'              with: null -    publish: appr_dl +    publish: +      appr_dl: +        fields: +          age: dec_date    'mrr:ut_metaller_industrimineral_forbud':      # https://resource.sgu.se/dokument/produkter/mineralrattigheter-beskrivning.pdf diff --git a/export_mvt.py b/export_mvt.py index d19909c..31c7044 100644 --- a/export_mvt.py +++ b/export_mvt.py @@ -121,6 +121,7 @@ def exportSourceLayer(ds_src : gdal.Dataset,                        lyr_src : ogr.Layer,                        lyr_dst : ogr.Layer,                        layerdef : dict[str,Any], +                      fieldMap : tuple[list[str],list[int]],                        extent : ogr.Geometry|None = None) -> int:      """Export a source layer."""      count0 = -1 @@ -168,7 +169,7 @@ def exportSourceLayer(ds_src : gdal.Dataset,          spatialFilter = getSpatialFilterFromGeometry(extent, srs_src)      transform_geometry = layerdef.get('transform-geometry', None) -    columns = [ 'm.' + escape_identifier(lyr_src.GetFIDColumn()) ] +    columns = [ 'm.' + escape_identifier(lyr_src.GetFIDColumn()) ] + fieldMap[0]      geomFieldName_esc = escape_identifier(geomField.GetName())      if transform_geometry is None:          columns.append('m.' + geomFieldName_esc) @@ -194,16 +195,26 @@ def exportSourceLayer(ds_src : gdal.Dataset,              logging.debug('Source layer "%s" has %d features, of which %d are to be exported',                            layername, count0, count1) +        fieldMap = fieldMap[1] +        logging.debug('Field map: %s', str(fieldMap)) + +        geom_type = lyr_src.GetGeomType() +        bFlatten = geom_type == ogr.wkbUnknown or ogr.GT_HasM(geom_type) or ogr.GT_HasZ(geom_type) +        bTransform = bFlatten or ct is not None +          feature_count = 0          defn_dst = lyr_dst.GetLayerDefn()          feature = lyr_src.GetNextFeature()          while feature is not None: -            geom = feature.GetGeometryRef().Clone() -            if ct is not None and geom.Transform(ct) != ogr.OGRERR_NONE: -                raise RuntimeError('Could not apply coordinate transformation') -            geom.FlattenTo2D()              feature2 = ogr.Feature(defn_dst) -            feature2.SetGeometryDirectly(geom) +            feature2.SetFromWithMap(feature, False, fieldMap) +            if bTransform: +                geom = feature2.GetGeometryRef() +                if ct is not None and geom.Transform(ct) != ogr.OGRERR_NONE: +                    raise RuntimeError('Could not apply coordinate transformation') +                if bFlatten: +                    geom.FlattenTo2D() +                feature2.SetGeometryDirectly(geom)              feature2.SetFID(feature.GetFID())              if lyr_dst.CreateFeature(feature2) != ogr.OGRERR_NONE:                  raise RuntimeError(f'Could not transfer source feature #{feature.GetFID()}') @@ -351,7 +362,85 @@ def exportMetadata(basedir : Path, data : dict[str,Any],      finally:          os.close(fd) -# pylint: disable-next=too-many-branches, too-many-statements +def getFieldMap(lyr_dst : ogr.Layer, lyr_src : ogr.Layer, +                drv_src : gdal.Driver, +                fieldMap : dict[str,str]|None) -> tuple[list[str],list[int]]: +    """Create fields on the destination MVT layer, and return a list of +    column statements along with a field map for the MVT export.""" +    if fieldMap is None or len(fieldMap) == 0: +        return [], [] + +    if not lyr_dst.TestCapability(ogr.OLCCreateField): +        raise RuntimeError(f'Destination layer "{lyr_dst.GetName()}" lacks ' +                           'field creation capability') + +    columns = {} +    defn_src = lyr_src.GetLayerDefn() +    for fld_dst, fld_src in fieldMap.items(): +        idx_src = defn_src.GetFieldIndex(fld_src) +        if idx_src < 0: +            raise RuntimeError(f'Source layer "{lyr_src.GetName()}" has no field named "{fld_src}"') + +        defn_dst = ogr.FieldDefn() +        defn_src_fld = defn_src.GetFieldDefn(idx_src) +        if fld_dst == 'age': +            if defn_src_fld.GetType() not in (ogr.OFTDate, ogr.OFTDateTime): +                raise RuntimeError(f'Field "{fld_src}" of source layer "{lyr_src.GetName()}"' +                                   ' has type ' + ogr.GetFieldTypeName(defn_src_fld.GetType()) + +                                   ' (Date or DateTime expected)') +            defn_dst.SetType(ogr.OFTInteger) +            # signed int16 allows expressing dates from 1880-04-15 to 2059-09-18 +            # which should be more than enough (it's not clear if the MVT format takes +            # advantage of the reduced storage though) +            defn_dst.SetSubType(ogr.OFSTInt16) + +            # TODO[GDAL >=3.9] use lyr_src.GetDataset().GetDriver() +            if drv_src.ShortName == 'PostgreSQL': +                column = 'CAST(m.' + escape_identifier(fld_src) +                column += ' - date \'1970-01-01\' AS smallint)' +            elif drv_src.ShortName in ('SQLite', 'GPKG'): +                column = 'CAST(floor(julianday(m.' + escape_identifier(fld_src) + ')' +                column += ' - 2440587.5) AS smallint)' +            else: +                raise NotImplementedError(f'Unsupported source driver {drv_src.ShortName} for ' +                                          f'field "{fld_src}" (MVT field "{fld_dst}")') + +        else: +            raise NotImplementedError(f'Destination MVT field "{fld_dst}"') + +        columns[fld_dst] = column + +        defn_dst.SetName(fld_dst) +        defn_dst.SetNullable(defn_src_fld.IsNullable()) +        logging.debug('Create output field "%s" with type=%s, subtype=%s, nullable=%d', +                      defn_dst.GetName(), +                      ogr.GetFieldTypeName(defn_dst.GetType()), +                      ogr.GetFieldSubTypeName(defn_dst.GetSubType()), +                      defn_dst.IsNullable()) + +        if lyr_dst.CreateField(defn_dst, approx_ok=False) != gdal.CE_None: +            raise RuntimeError(f'Could not create field "{fld_dst}" ' +                               f'in destination MVT layer "{lyr_dst.GetName()}"') + +    indices = {} +    defn_dst = lyr_dst.GetLayerDefn() +    for i in range(defn_dst.GetFieldCount()): +        fld = defn_dst.GetFieldDefn(i) +        name = fld.GetName() +        if name in columns: +            indices[name] = i +        else: +            logging.warning('Destination layer has unknown field #%d "%s"', i, name) + +    ret = [None] * len(columns) +    fieldMap = [-1] * defn_dst.GetFieldCount() +    for idx, name in enumerate(columns.keys()): +        i = indices[name] # intentionally crash if we didn't create that field +        fieldMap[i] = idx +        ret[idx] = columns[name] + ' AS ' + escape_identifier(name) +    return (ret, fieldMap) + +# pylint: disable-next=too-many-branches, too-many-locals, too-many-statements  def exportMVT(ds : gdal.Dataset,                layers : dict[str,dict[str,Any]],                sources : dict[str,Any], @@ -436,10 +525,15 @@ def exportMVT(ds : gdal.Dataset,              if lyr_dst is None:                  raise RuntimeError(f'Could not create destination layer "{layername}"') +            fieldMap = getFieldMap(lyr_dst, lyr_src, drv_src=ds.GetDriver(), +                                   fieldMap=layerdef.get('fields', None)) +              # TODO: GDAL exports features to a temporary SQLite database even though the source              # is PostGIS hence is able to generate MVT with ST_AsMVT().  Letting PostGIS generate              # tiles is likely to speed up things. -            feature_count += exportSourceLayer(ds, lyr_src, lyr_dst, layerdef, extent=extent) +            feature_count += exportSourceLayer(ds, lyr_src, lyr_dst, layerdef, +                                               fieldMap=fieldMap, +                                               extent=extent)              layer_count += 1              lyr_dst = None              lyr_src = None  | 
