de|en

Zeitzone mit GeoTools aus Shapefiles bestimmen

Martin Kompf

Karte der Zeitzonen der Welt

Zeitzonen definieren die Differenz der jeweiligen Ortszeit zur Greenwich Mean Time (GMT) sowie die Regeln zur Sommer- und Winterzeitumstellung. Mit den heute gegebenen Möglichkeiten des weltweiten Online-Austauschs von Daten und Informationen spielt die genaue Kenntnis der Zeitzone des Kommunikationspartners eine große Rolle, um Missverständnisse und Fehler von vornherein zu vermeiden.

Zeitzonen

Aufgrund der weiten Verbreitung von GPS-Geräten oder Geolokalisierung auf Basis von IP-Adressen ist es oft kein großes Problem, an die geografische Koordinaten eines Ortes zu kommen. Die Bestimmung der Zeitzone aus diesen Koordinaten ist dann allerdings keine triviale Angelegenheit mehr, denn die Zeitzonengrenzen folgen nicht einheitlichen Regeln, sondern orientieren sich an - zeitweise wechselnden - politischen und geografischen Gegebenheiten. So sind in Mitteleuropa die Zeitzonengrenzen identisch mit den Staatsgrenzen. Die Benennung folgt dem Schema Europe/Hauptstadt. Zum Beispiel gehört Deutschland zur Zeitzone Europe/Berlin, Frankreich zu Europe/Paris und Kroatien zu Europe/Zagreb.

Für große Flächenländer, wie Kanada oder die USA gilt diese Regel nicht mehr, da hier eine Unterteilung des Staatsgebietes in mehrere Zeitzonen erforderlich ist. Die konkrete Unterteilung kann jedes Land handhaben wie es möchte, in den USA gibt es so neben den großen Zeitzonen wie America/Los_Angeles, America/Chicago oder America/New_York auch eigene Zeitzonen für kleine Counties, zum Beispiel America/Kentucky/Louisville oder America/Indiana/Indianapolis.

Auf hoher See in internationalen Gewässern richtet sich dagegen die Zeitzone streng nach der geografischen Länge. Die Zeitzonen sind hier 15° breit und tragen Namen wie Etc/GMT für die Zone um den Nullmeridian, Etc/GMT+1 für die Zone um 15° West oder Etc/GMT-1 bei 15° Ost. Die Berechnung der Zeitzone für ein Schiff in internationalen Gewässern ist daher mittels einer einfachen Gleichung möglich. In Java programmiert könnte das folgendermaßen aussehen:

public class TzDataEtc {
  /**
   * Compute the TZ offset from the longitude (valid in international waters).
   */
  public static int tzOffsetFromLon(double lon) {
    return (int) Math.floor((lon - 7.500000001) / 15) + 1;
  }

  /**
   * Compute the TZ name from the longitude (valid in international waters).
   */
  public static String tzNameFromLon(double lon) {
    String tzname;
    int tzOffset = tzOffsetFromLon(lon);
    if (tzOffset == 0) {
      tzname = "Etc/GMT";
    } else {
      tzname = String.format("Etc/GMT%+d", -tzOffset);
    }
    return tzname;
  }
}

Shapefiles: Eine Landkarte in Software

An Land funktioniert diese einfache Methode nicht mehr. Hier benötigt man eine genaue Landkarte, welche die Zeitzonengrenzen und ihre Namen enthält. Sinnvollerweise sollte diese Karte auch nicht aus Papier bestehen, sondern per Software auswertbar sein. Ein oft genutztes Format für solche elektronischen Landkarten ist das ESRI-Shapefile. Dabei handelt es sich um Vektorkarten, die eine Abbildung von exakten geometrischen Formen durch Punkte, Linien und Polygone erlauben. Zusätzlich lassen sich Attribute zur Beschreibung hinterlegen, wie geografische Namen oder Eigenschaften.

Dankenswerterweise stellt Evan Siroky aus OpenStreetMap Daten erzeugte Shapefiles zum Download im Github Projekt timezone-boundary-builder zur Verfügung. Auf der Releases Seite findet man verschiedene Shapefiles, die Variante timezones-with-oceans.shapefile.zip bezieht auch die Küsten- und internationalen Gewässer mit ein.

Vorher pflegte Eric Muller viele Jahre lang unter efele.net/maps/tz/ Shapefiles für verschiedene Regionen.

Zuerst sollte man das Zipfile in ein leeres Verzeichnis entpacken. Die enthaltenen Dateien gehören immer zusammen! Auch wenn in gängiger geografischer Software beim Laden des Shapefiles immer die Datei mit der Endung .shp anzugeben ist, müssen die anderen Dateien (.dbf, .prj, ...) immer im gleichen Verzeichnis vorhanden sein.

Java und GeoTools

Ein gutes Werkzeug zum Umgang mit Shapefiles und geografischen Daten ist das Open Source Java Toolkit GeoTools. Download und Einrichten der Entwicklungsumgebung sind im Quickstart beschrieben. Ich habe dafür Ivy und Ant verwendet: Die Zeile

<dependency org="org.geotools" name="gt-shapefile" rev="28.0"/>

in der dependencies Sektion der ivy.xml bewirkt den Download aller für die Arbeit mit Shapefiles notwendigen JAR-Files von GeoTools in der Version 28.0. Allerdings ist GeoTools nicht in den Standard Maven/Ivy Repositories zu finden, daher habe ich per

<ibiblio name="osgeo" m2compatible="true" root="https://repo.osgeo.org/repository/release/"/>

das Osgeo Repository in der ivysettings.xml bekannt gemacht.

Nach diesen Vorarbeiten kann es direkt ans Programmieren gehen. Der komplette Sourcecode ist als Git Repository tzdataservice auf Github verfügbar.

Einlesen des Shapefiles

Da das Einlesen des Shapefiles eine relativ langwierige Operation sein kann, lagert man diesen Schritt sinnvollerweise in eine eigene Methode aus. Zuständig für das Laden des Shapefiles sind die Klassen ShapefileDataStoreFactory und ShapefileDataStore aus den GeoTools Libraries. Das Setzen entsprechender Properties vor dem Laden bewirkt die Erzeugung eines Spatial Index, der später die ortsbasierte Suche beschleunigt. Als Ergebnis der Ladeoperation entsteht eine SimpleFeatureSource, welche Ausgangsunkt für alle weiteren Operationen ist:

public class TzDataShpFileReadAndLocate {

  private SimpleFeatureSource featureSource;
  private FilterFactory2 filterFactory;
  private GeometryFactory geometryFactory;

  /**
   * Open the input shape file and load it into memory.
   */
  public void openInputShapefile(String inputShapefile) throws IOException {
    File file = new File(inputShapefile);

    ShapefileDataStoreFactory dataStoreFactory = new ShapefileDataStoreFactory();
    Map<String, Serializable> params = new HashMap<>();
    params.put(ShapefileDataStoreFactory.URLP.key, file.toURI().toURL());
    params.put(ShapefileDataStoreFactory.CREATE_SPATIAL_INDEX.key, Boolean.TRUE);
    // ...

    ShapefileDataStore store = (ShapefileDataStore) dataStoreFactory.createNewDataStore(params);
    featureSource = store.getFeatureSource();

    filterFactory = CommonFactoryFinder.getFilterFactory2(GeoTools.getDefaultHints());
    geometryFactory = JTSFactoryFinder.getGeometryFactory();
  }

Analyse des Schemas

Nach dem erfolgreichen Laden kann man sich die Eigenschaften des Shapefiles zur Kontrolle ausgeben lassen:

  /**
   * Print info about the schema of the loaded shapefile.
   */
  public void printInputShapfileSchemaInfo() {
    SimpleFeatureType schema = featureSource.getSchema();
    System.out.println(schema.getTypeName() + ": " + DataUtilities.encodeType(schema));
  }

Die Funktion angewendet auf combined-shapefile-with-oceans.shp gibt

combined-shapefile-with-oceans: the_geom:MultiPolygon,tzid:String

aus. Das bedeutet, dass combined-shapefile-with-oceans zwei Features enthält: Einmal die Polygon-Geometrie the_geom und zum anderen die Namen (IDs) der Zeitzonen als String in tzid. Die Geometrie liegt im Referenzsystem mit der SRID 4326 vor, das heißt die Punkte sind mittels geografischer Koordinaten von 180° West bis 180° Ost beziehungsweise 90° Süd bis 90° Nord im Bezugssystem WGS84 kodiert. Das ist exakt das gleiche System, das populäre Kartenanwendungen wie OpenStreetMap oder Google Maps sowie das GPS System verwenden.

Filtern nach Koordinaten

Die Bestimmung der Zeitzone aus gegebenen geografischen Koordinaten erfolgt in zwei Schritten: Als erstes wendet man auf die beim Einlesen entstandene featureSource die Filterfunktion contains an, die den Namen der zu durchsuchenden Geometrie the_geom und die geografischen Koordinaten (x, y) als Parameter bekommt. Das Ergebnis der Filterung ist eine SimpleFeatureCollection, die das Polygon enthält, in dem der gesuchte Punkt liegt. (Falls kein solches Feature existiert, ist die Collection leer.) Im zweiten Schritt bestimmt man aus dem Ergebnis den Wert des Attributes tzid (bzw. TZID for efele.net), das den Namen der Zeitzone enthält:

  /**
   * Process a single coordinate.
   *
   * @param x Longitude in degrees.
   * @param y Latitude in degrees.
   * @return Timezone Id.
   */
  public String process(double x, double y) throws IOException {
    String result = "";

    Point point = geometryFactory.createPoint(new Coordinate(x, y));
    Filter pointInPolygon = filterFactory.contains(
      filterFactory.property("the_geom"), filterFactory.literal(point));

    SimpleFeatureCollection features = featureSource.getFeatures(pointInPolygon);

    try (FeatureIterator<SimpleFeature> iterator = features.features()) {
      if (iterator.hasNext()) {
        SimpleFeature feature = iterator.next();
        String tzid = (String) feature.getAttribute("tzid"); // TZID
        result = tzid;
      }
    }
    return result;
  }

Performance

Besonders interessant ist die Geschwindigkeit des Algorithmus und seine Genauigkeit. Dazu habe ich die Textdatei cities15000.txt von geonames.org als Input verwendet. Sie enthält die Koordinaten und Zeitzonen von 23461 Orten auf der ganzen Welt. Ein Computer mit einem Core2 Quad Q8400 Prozessor von 2010 benötigt für die Zeitzonenbestimmung aller 23461 Datensätze viereinhalb Minuten, das sind gerade einmal 11 ms pro Ort. Die Ergebnisse unterschieden sich für 407 Datensätze, das ist ein Fehler von 1.7 %. Eine Analyse der Fehler brachte keine Klarheit, ob die Fehler im Shapefile, im Algorithmus oder in der geonames Datenbank liegen.

Die sehr gute Performance kommt allerdings nur zustande, wenn das Laden des Shapefiles einmalig für die 23461 Datensätze erfolgt. Ein wiederholter Programmstart mit openInputShapefile für jede einzelne Koordinate würde die Performance dramatisch verschlechtern.

Webservice

Als Finale soll nun ein REST Webservice die Funktion zur Bestimmung der Zeitzone aus den Koordinaten bereitstellen. Den Service kann man so programmieren, dass das Laden und Indizieren des Shapefiles nur einmal erfolgt. Die Ermittlung einer einzelnen Zeitzone kann dann sehr schnell ablaufen, da alle notwendigen Daten bereits im Speicher liegen. Die Verwendung von HTTP und REST als Kommunikationsprotokolle befähigt auch nicht in Java programmierte Clients zur Bestimmung der Zeitzone. Für mein Tool GEOPosition verwende ich zum Beispiel PHP mit curl als Client.

Die erste Version der Software hatte ich mit Java 7 implementiert. Diese Version definierte mit JAX-RS einen Standard zum Deklarieren von RESTful Webservices. Um diese Services mit Java 7 SE zum Laufen zu bringen, benötigte man noch eine JAX-RS Implementierung. Naheliegend war die Verwendung der Referenzimplementierung Jersey.

In der Zwischenzeit hat sich viel geändert. In Java 11 ist das Paket javax.ws.rs nicht mehr eingebaut, Jersey gibt es in einer neuen, inkompatiblen Version. Die Verwendung von Frameworks wie Spring oder RESTEasy ist zwar möglich, erscheint aber für die Aufgabenstellung überdosiert.

Mit weit weniger Aufwand kann der in die Java Standard Edition eingebaute HTTP-Server diese Aufgabe erledigen. Die folgende Funktion zeigt das Erzeugen dieses Servers, der aus Sicherheitsgründen nur an das Loopback-Interface mit der IP-Adresse 127.0.0.1 gebunden wird, so dass kein Zugriff von außen möglich ist:

  /**
    * Create HTTP server that is bound to the loopback address only.
    */
  private static HttpServer createHttpServer(int port, String path, HttpHandler handler) throws IOException {
    // bind server to loopback interface only:
    InetSocketAddress bindAddr = new InetSocketAddress(InetAddress.getLoopbackAddress(), port);
    // bind server to any interface (may be a security risk!):
    // InetSocketAddress bindAddr = new InetSocketAddress(port);
    HttpServer server = HttpServer.create(bindAddr, 0);
    server.setExecutor(Executors.newCachedThreadPool());
    server.createContext(path, handler);      
    return server;
  }  

Ein Client stellt eine Anfrage, indem er die geografischen Koordinaten als Bestandteil der URL kodiert: http://localhost:28100/tz/bylonlat/{lon}/{lat}, also zum Beispiel http://localhost:28100/tz/bylonlat/9/50 für die Position 50° Nord, 9° Ost. Er erwartet die Zeitzone dann in der vom Server geschickten Antwort im Body.

Die Bearbeitung dieser Anfragen erfolgt auf dem Server in einem HttpHandler, der in der oben gezeigten Funktion in createContext übergeben wurde. Eine Implementierung des HttpHandler ist für den Anwendungsfall relativ einfach zu bewerkstelligen:

public class TzDataService implements HttpHandler {

  private static final String URL_PATTERN = "/bylonlat/([-+]?[0-9]*\\.?[0-9]*)/([-+]?[0-9]*\\.?[0-9]*$)";
  private TzDataShpFileReadAndLocate tzdata;
  private final Pattern urlPattern;

  public TzDataService(TzDataShpFileReadAndLocate tzdata) {
    this.tzdata = tzdata;
    this.urlPattern = Pattern.compile(URL_PATTERN);
  }

  /**
  * HTTP handler to compute the timezone id.
  * The input values lon and lat are taken from the 
  * request path "bylonlat/{lon}/{lat}".
  * The response body contains the computed timezone id.
  */
  @Override
  public void handle(HttpExchange httpExchange) throws IOException {
    if ("GET".equals(httpExchange.getRequestMethod())) {
      String path = httpExchange.getRequestURI().getPath();
      Matcher matcher = urlPattern.matcher(path);
      if (matcher.find()) {
        try {
          double x = Double.parseDouble(matcher.group(1));
          double y = Double.parseDouble(matcher.group(2));
          String tzid = tzdata.process(x, y);
          if (tzid.length() == 0) {
            tzid = TzDataEtc.tzNameFromLon(x);
          }
          final byte[] body = tzid.getBytes(StandardCharsets.UTF_8);
          
          httpExchange.sendResponseHeaders(200, body.length);
          OutputStream outputStream = httpExchange.getResponseBody();
          outputStream.write(body);
          outputStream.flush();
          outputStream.close();
        } catch (NumberFormatException e) {
          httpExchange.sendResponseHeaders(400, -1);
        }
      } else {
        httpExchange.sendResponseHeaders(400, -1);
      }
    }
  }

  // ...
}

Zur Vervollständigung des Programms fehlt noch die main-Funktion. Sie übernimmt das einmalige Laden des Shapefiles und den Start des von createHttpServer erzeugten Servers. Meine Implementierung bindet den Server an Port 28100. Man kann natürlich auch andere Ports verwenden.

  public static void main(String[] args) throws IOException {
    // Check and parse command line arguments
    // ...

    TzDataShpFileReadAndLocate tzdata = new TzDataShpFileReadAndLocate();
    tzdata.openInputShapefile(args[0]);

    TzDataService service = new TzDataService(tzdata);
    HttpServer server = createHttpServer(28100, "/tz", service);
    server.start();
  }

Das Serverprogramm verlangt bei seinem Start den Pfad zum Shapefile als Kommandozeilenparameter:

java -jar tzdataservice.jar /path/to/combined-shapefile-with-oceans.shp

Jetzt kann man eine URL wie http://localhost:28100/tz/bylonlat/9/50 mit dem Browser aufrufen und bekommt als Antwort die Zeitzone für die Koordinaten Länge 9° Ost und Breite 50° Nord. Oder man verwendet curl, das Schweizer Taschenmesser für den Webservice-Entwickler:

curl http://localhost:28100/tz/bylonlat/9/50
Europe/Berlin

Fazit

Ein erneuter Test mit cities15000.txt weist die Funktion des Webservice nach. Die Performance ist wie zu erwarten schlechter als beim direkten Aufruf, liegt mit 60 ms pro Abfrage aber noch deutlich unter den Werten, die beim wiederholten Laden des Shapefiles für jede Abfrage entstehen würden. Außerdem ist der Server in der Lage, gleichzeitige Anfragen in mehreren Threads parallel abzuarbeiten.

Der Webservice läuft seit mehreren Jahren stabil auf meiner Webpräsenz. Er stellt dort Zeitzoneninformationen für das Tool GEOPosition zur Verfügung. Es zeigt neben der Zeitzone die Koordinaten, die Höhe über dem Meeresspiegel und auch die Zeiten für Sonnenaufgang und -untergang für jeden Ort auf der Erde an. Um letztere in Ortszeit angeben zu können, ist ebenfalls die Kenntnis der genauen Zeitzone notwendig.

Die komplette Software ist auf Github verfügbar. Sie kann sowohl mit den älteren Shapefiles von efele.net als auch mit der aktuellen Variante des timezone-boundary-builder umgehen.