en|de

Determine time zone with GeoTools from shapefiles

Martin Kompf

Karte der Zeitzonen der Welt

Time zones define the difference of the respective local time to Greenwich Mean Time (GMT) and the rules for daylight saving time changeover. With the existing possibilities of global online exchange of data and information, the exact knowledge of the time zone of the communication partner plays a major role in order to avoid misunderstandings and errors.

Time Zones

Due to the widespread use of GPS devices or geolocation based on IP addresses, it is often not a big problem to get to the geographic coordinates of a place. The determination of the time zone from these coordinates is then however not a trivial matter, because the time zone boundaries do not follow uniform rules, but are based on - temporarily changing - political and geographical circumstances. Thus, the time zone boundaries are the same as the state borders in Central Europe. Their naming follows the scheme "Europe/capital". For example, Germany belongs to the Time Zone Europe/Berlin, France to Europe/Paris and Croatia to Europe/Zagreb.

For large area countries, such as Canada or the United States, this rule no longer applies, because a subdivision of the territory into several time zones is required. Every country may handle the concrete division as it would like. In the US there are beside the great time zones such as America/Los_Angeles, America/Chicago or America/New_York some small time zones for counties, for example, America/Kentucky/Louisville or America/Indiana/Indianapolis.

On the high seas in international waters on the other hand the time zone keeps strictly within the longitude. The time zones are here 15° wide and have names like Etc/GMT for the zone around the zero meridian, Etc/GMT+1 for the zone around 15° West or Etc/GMT-1 at 15° East. The calculation of the time zone for a ship in international waters is therefore possible by means of a simple equation. Written in Java it could look like this:

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: A map in Software

On land, this simple method no longer works. Here one needs an accurate map, which contains the time zone boundaries and their names. Logically, this map should not consist of paper but should be evaluated by software. An often-used format for such "electronic" maps is the ESRI shapefile. This is a vector map that allows the mapping of exact geometrical shapes by points, lines and polygons. In addition, attributes can be defined to attach geographic names and properties to the geometry.

Thankfully, Evan Siroky provides shapefiles generated from OpenStreetMap data for download in the Github project timezone-boundary-builder. Various shapefiles can be found on the Releases page, the variant timezones-with-oceans.shapefile.zip also includes coastal and international waters.

Before that, Eric Muller maintained shapefiles for different regions for many years at efele.net/maps/tz/.

First you should extract the zip file into an empty directory. The contained files always belong together! Even if the file with the extension .shp has to be specified when loading the shapefile in common geographic software, the other files (.dbf, .prj, ...) must always be in the same directory.

Java and GeoTools

A good tool for dealing with shapefiles and geographical data is the Open Source Java Toolkit GeoTools. The download and set up of the development environment are described in the Quickstart guide. I have used Ivy and Ant for this: The line

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

in the dependencies section of ivy.xml causes the download of all necessary JAR files of GeoTools version 28.0 to work with shapefiles. However GeoTools is not always to be found in the default Maven/Ivy repositories, so I had to add the Osgeo repository explicitely to ivysettings.xml:

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

After this preparatory work, you can start programming straight away. The complete source code is available on Github as Git repository tzdataservice.

Reading the shapefiles

Since the reading of the shapefiles can be a relatively lengthy operation, it makes sense to outsource this step into its own method. Responsible for loading of shapefiles are the classes ShapefileDataStoreFactory and ShapefileDataStore from the GeoTools libraries. The setting of appropriate properties before loading causes the generation of a spatial index, which speeds up the location-based search later. The result of the loading is a SimpleFeatureSource which is the starting point for all further operations:

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();
  }

Analysis of the scheme

After successful loading you can print the properties of the shapefile:

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

This function applied to tz_world.shp gives the result:

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

This means that tz_world contains two features: Once the polygon geometry the_geom and on the other the names (IDs) of the time zones as a string in tzid. The geometry has the reference system SRID 4326, that is, the points are encoded in the reference system WGS84 by means of geographical coordinates from 180° West to 180° East and 90° South to 90° North. That's exactly the same system which popular map applications like Google Maps and OpenStreetMap or the GPS system are using.

Filter by coordinates

The determination of the time zone from given geographical coordinates is performed in two steps: First, the filter function contains is applied to the the featureSource. The filter function gets the name of the geometry the_geom and the geographic coordinates (x, y) as parameters. The result of the filtering is a SimpleFeatureCollection containing the polygon in which the searched point is located. (If there is no such a polygon, then the collection is empty.) The second step determines the value of the attribute tzid (resp. TZID for efele.net) from the result that contains the name of the time zone:

  /**
   * 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

Particularly interesting is the speed of the algorithm and its accuracy. For that purpose I used the text file cities15000.txt from geonames.org as input. It contains the coordinates and time zones of 23461 places all over the world. A computer with a Core2 Quad Q8400 processor manufactured in 2010 needed four and a half minutes to determine the time zones of all 23461 records, which are just 11 ms per location. The results differed for 407 records, that is an error of 1.7%. An analysis of the errors brought no clarity as to whether the errors are in the shapefile, in the algorithm or in the geonames database.

However, the very good performance comes about only when the the shapefile is loaded only once for all 23461 records. A repeated startup that calls openInputShapefile for each coordinate would degrade the performance dramatically.

Web service

As a finale, a REST web service should now provide the method to determine the time zone from coordinates. The service can be implemented in the way that loading and indexing of the shapefile takes place only once. The determination of a single time zone can be performed then very fast, because all the necessary data are already in the memory. The use of HTTP and REST as communication protocols enables even clients, that are not programmed in Java, to determine the time zone. For my tool GEOPosition I use for example PHP with curl as a client.

I implemented the first version of the software with Java 7. This version defined a standard for declaring RESTful web services with JAX-RS. To get these services running with Java 7 SE, you still needed a JAX-RS implementation. The obvious choice was to use the Jersey reference implementation.

A lot has changed in the meantime. The javax.ws.rs package is no longer installed in Java 11, Jersey is available in a new, incompatible version. The use of frameworks such as Spring or RESTEasy is possible, but seems overdosed for the task.

The HTTP server built into Java Standard Edition can do this job with far less effort. The following function shows how to create this server, which for security reasons is only bound to the loopback interface with the IP address 127.0.0.1, so that no external access is possible:

  /**
    * 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;
  }  

A client makes a request by encoding the geographic coordinates as part of the URL: http://localhost:28100/tz/bylonlat/{lon}/{lat}, for example http://localhost:28100/tz/bylonlat/9/50 for the position 50° north, 9° East. It then expects the time zone in the body of the response sent by the server.

These requests are processed on the server in an HttpHandler, which is passed in createContext in the function shown above. An implementation of the HttpHandler is relatively easy to do for the application:

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);
      }
    }
  }

  // ...
}

The main function is still missing to complete the program. It takes care of loading the shapefile once and starting the server created by createHttpServer. My implementation binds the server to port 28100. Of course you can use other ports as well.

  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();
  }

The server program requires the path to the shapefile as command line argument:

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

Now you can enter a URL like http://localhost:28100/tz/bylonlat/9/50 into the browser. It should then answer the time zone for the coordinates longitude 9° East and latitude 50° North. Or you can use curl, the "Swiss army knife" for the web developer:

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

Conclusion

A new test with cities15000.txt demonstrates the function of the web service. As expected the performance is worse than the direct call, but is still quite good with about 60 ms per query. In addition, the server is able to process simultaneous requests in multiple threads in parallel.

The web service has been running for several years stable on my website. It provides time zone information for the tool GEOPosition. It shows - in addition to the time zone - the coordinates, the altitude above sea level and the times for sunrise and sunset for any location on Earth. To be able to display the latter in local time, the knowledge of the exact time zone is required as well.

The complete software is available at Github. I works with the older shapefiles from efele.net as well as with the actual version from timezone-boundary-builder.