de|en

Zeitzone mit GeoTools aus Shapefiles bestimmen

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 Eric Muller unter efele.net/maps/tz/ Shapefiles für verschiedene Regionen zum Download zur Verfügung. Die Datei tz_world.zip enthält die Zeitzonen der ganzen Welt als Polygone. Sie stellt einen guten Ausgangspunkt für weitere Experimente dar. 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 tz_world.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="14.1"/>

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 14.1. Allerdings ist GeoTools nicht immer in den Standard Maven/Ivy Repositories zu finden, daher habe ich per

<ibiblio name="osgeo" m2compatible="true" root="http://download.osgeo.org/webdav/geotools/"/> 

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 tz_world.shp gibt

tz_world: the_geom:MultiPolygon:srid=4326,TZID:String

aus. Das bedeutet, dass tz_world 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, 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);

    // search in coastal waters - see below ...

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

Suche in territorialen Küstengewässern

Mit den bisher beschriebenen zwei Verfahren ist es jetzt möglich, die Zeitzone auf dem Festland, Inseln und in internationalen Gewässern zu bestimmen. Was noch fehlt, sind die Küstengewässer, die mit zum Staatsterritorium gehören. Deren Grenzen sind in tz_world.shp leider nicht enthalten.

Man kann sich hier mit der Näherung behelfen, dass Küstengewässer eine maximale Ausdehnung von 12 Seemeilen (etwa 22 km) haben sollen. Mit dieser Information kann man den Filter dwithin benutzen, der auch Punkte findet, die in der Nähe eines Polygons liegen. Leider ist die eigentlich vorgesehene Möglichkeit, dem Filter eine Längeneinheit, wie «km» zu übergeben, nicht implementiert. GeoTools interpretiert den Zahlenwert immer relativ zum Bezugssystem der Karte. Der im Beispielcode verwendete Wert 0.1 bedeutet also 0.1° und damit ca. 11 km in Nord-Süd-Richtung.

    // search in coastal waters
    if (features.size() == 0) {
      Filter dWithin = filterFactory.dwithin(
          filterFactory.property("the_geom"), filterFactory.literal(point), 0.1, "");
      features = featureSource.getFeatures(dWithin);
    }

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.

Java 7 definiert mit JAX-RS einen Standard zum Deklarieren von RESTful Webservices. Um diese Services mit Java 7 SE zum Laufen zu bringen, benötigt man noch eine JAX-RS Implementierung. Naheliegend ist die Verwendung der Referenzimplementierung Jersey. Bei der Verwendung von Ivy erreicht man den Download aller notwendigen Jersey-JARs durch das Hinzufügen der Zeilen

<dependency org="com.sun.jersey" name="jersey-bundle" rev="1.19" conf="master"/>
<dependency org="javax.ws.rs" name="jsr311-api" rev="1.1.1" conf="master"/>

in die dependencies Sektion der ivy.xml. Danach kann es direkt an die Implementierung der Webservice-Methode gehen. Die JAX-RS Annotationen @GET, @Path, und @Produces definieren die zum Aufruf des Service erforderliche HTTP Methode (GET), den relativen URL-Pfad (bylonlat/{lon}/{lat}) und die Art der Bereitstellung des Resultats (als Plaintext). Die Implementierung versucht zuerst, die Zeitzone aus dem Shapefile zu ermitteln. Kommt es dabei zu keinem Ergebnis, dann erfolgt eine Berechnung der Zeitzone aus dem Längengrad (TzDataEtc):

  /**
   * REST method to compute the timezone id.
   * 
   * @param lon Latitude (deg)
   * @param lat Longitude (deg)
   * @return Timezone id.
   */
  @GET
  @Produces(MediaType.TEXT_PLAIN)
  @Path("bylonlat/{lon}/{lat}")
  public String bylonlat(@PathParam("lon") String lon, @PathParam("lat") String lat) {
    try {
      double x = Double.parseDouble(lon);
      double y = Double.parseDouble(lat);
      String tzid = tzdata.process(x, y);
      if (tzid.length() == 0) {
        tzid = TzDataEtc.tzNameFromLon(x);
      }
      return tzid;
    } catch (NumberFormatException e) {
      throw new WebApplicationException(Status.BAD_REQUEST);
    } catch (IOException e) {
      throw new WebApplicationException(Status.INTERNAL_SERVER_ERROR);
    }
  }

Zur Vervollständigung des Programms fehlt noch die main-Funktion. Sie übernimmt das einmalige Laden des Shapefiles und das Starten des in Java 7 eingebauten HTTP-Servers auf Port 28100. Man kann natürlich auch andere Ports verwenden. Der Server ist aus Sicherheitsgründen nur an das Loopback-Interface mit der IP-Adresse 127.0.0.1 gebunden. Die Klassendefinition trägt die JAX-RS Annotation @Path. Beim Starten des HTTP-Servers durchsucht Jersey alle Klassen nach dieser Annotation und meldet sie als Webservice unter dem angegebenen Pfad an.

/**
 * REST service to compute the timezone id from latitude and longitude.
 */
@Path("/tz")
public class TzDataService {

  private static TzDataShpFileReadAndLocate tzdata;

  /**
   * MAIN program.
   * Starts the rest service and runs forever.
   * 
   * @param args path to the tz_world.shp file
   */
  public static void main(String[] args) throws IOException {
    if (args.length != 1) {
      System.err.println("Usage: java " + TzDataService.class.getName() + " path/to/tz_world.shp");
      System.exit(1);
    }
    tzdata = new TzDataShpFileReadAndLocate();
    tzdata.openInputShapefile(args[0]);

    HttpServer server = createHttpServer(28100, "/");
    server.start();
  }

  /**
   * Create HTTP server that is bound to the loopback address only.
   */
  private static HttpServer createHttpServer(int port, String path) 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());
    HttpHandler handler = ContainerFactory.createContainer(HttpHandler.class);
    server.createContext(path, handler);      
    return server;
  }

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

java de.kompf.tzdata.rest.TzDataService world/tz_world.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 Wochen 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 Meerespiegel 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.