MobilityDB is a PostgreSQL extension layered on top of PostGIS that targets the processing and analysis of spatio-temporal data. It contributes additional types and functions to PostGIS so that spatial-temporal questions can be answered directly in the database. At the time of writing it is available for PostgreSQL 11 and PostGIS 2.5 as a v1.0-beta, with a first release expected in early 2020. The codewit/mobility Docker container is the quickest way to get started.

Setting up the trips

The starting point is the mobile_points table, which holds points of individuals. It is used to build trajectories, and those trajectories are stored in mobile_trips using MobilityDB's tgeompoints data type. Instances of traj are derived from a trip's geometry and used for visualization.

PgSQL

1

2

3

4

5

6

7

8

9

10

11

12

13

create table if not exists mobile_points

(

   gid serial not null

      constraint mobile_points_ok

         primary key,

   customer_id integer,

   geom geometry(Point,31286),

   infected boolean,

   recorded timestamp

);

create index if not exists mobile_points_geom_idx

   on mobile_points using gist (geom);

PgSQL

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

CREATE TABLE public.mobile_trips

(

    gid        serial  not null

        constraint mobile_trips_ok primary key,

    customer_id integer NOT NULL,

    trip       public.tgeompoint,

    infected   boolean,

    traj       public.geometry

);

create index if not exists mobile_trip_geom_idx

    on mobile_trips using gist (traj);

create index if not exists mobile_trip_traj_idx

    on mobile_trips using gist (trip);

create unique index if not exists mobile_tracks_customer_id_uindex

    on mobile_trips (customer_id);

Generating trips from points

Trips are produced from the stored points with the following statement:

PgSQL

1

2

3

4

5

6

7

INSERT INTO mobile_trips(customerid, trip, traj, infected)

SELECT customer_id,

       tgeompointseq(array_agg(tgeompointinst(geom, recorded) order by recorded)),

       trajectory(tgeompointseq(array_agg(tgeompointinst(geom, recorded) order by recorded)))

           infected

FROM mobile_points

GROUP BY customer_id, infected;

Each customer gets a trip expressed as a sequence of instants of tgeompoint, MobilityDB's continuous temporal type. Such a sequence interpolates spatio-temporally between the fulcrums. The traj visualization of the resulting trip geometries is shown in Figure 1.

Figure 1 Customer trips as sequence of tgeompoint instants with mobilitydb
Figure 1 Customer trips as sequence of tgeompoint instants

Finding overlapping segments

The analysis begins by identifying segments where individuals overlap in space and time. The query below returns overlapping segments within 2 meters as geometries, grouped by customer pairs. Expanded bounding boxes are intersected first so that spatio-temporal indexes on the trips can be used.

PgSQL

1

2

3

4

5

6

7

8

9

10

11

12

SELECT  T1.customer_id AS customer_1,

        T2.customer_id AS customer_2,

        getvalues(

                atPeriodSet(T1.Trip,

                            getTime(atValue(tdwithin(T1.Trip, T2.Trip, 2), TRUE))))

FROM mobile_trips T1,

     mobile_trips T2

WHERE t1.customer_id < t2.customer_id

  AND t1.infected <> t2.infected

  AND T1.Trip && expandSpatial(T2.Trip, 2)

  AND atPeriodSet(T1.Trip, getTime(atValue(tdwithin(T1.Trip, T2.Trip, 2), TRUE))) IS NOT NULL

ORDER BY T1.customer_id, T2.customer_id

Whether two trips overlap spatio-temporally is then decided by filtering:

PgSQL

1

atPeriodSet(T1.Trip, getTime(atValue(tdwithin(T1.Trip, T2.Trip, 2), TRUE))) IS NOT NULL

getTime returns a detailed temporal profile of the overlapping sections as a set of periods, constrained by tdwithin. A period is a MobilityDB type derived from tstzrange. To keep only the intersecting spatial segments, periodset restricts the trip through atPeriodSet, and getValues then extracts the geometries from the tgeompoint that atPeriodSet returns.

This is not yet the final result. Disjoint trip segments yield multi-geometries (Figure 2 shows two disjoint segments for customers 1 and 2), so passage times per disjoint segment and customer require splitting those multi-geometries first with st_dump. The periods of the periodset must then be matched to the resulting simple, disjoint geometries:

  1. Turn the periodset into an array of periods.
  2. Flatten the array with unnest.
  3. Call timespan on the periods to collect passage times per disjoint segment.

PgSQL

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

SELECT  T1.customer_id AS customer_1,

        T2.customer_id AS customer_2,

        (st_dump(

                getvalues(

                         atPeriodSet(T1.Trip,

                                     getTime(atValue(tdwithin(T1.Trip, T2.Trip, 2), TRUE)))))).geom,

        extract('epoch' from

                timespan(

                        unnest(

                                periods(getTime(atValue(tdwithin(T1.Trip, T2.Trip, 2), TRUE))))))

FROM mobile_trips T1,

     mobile_trips T2

WHERE t1.customer_id < t2.customer_id

  AND t1.infected <> t2.infected

  AND T1.Trip && expandSpatial(T2.Trip, 2)

  AND atPeriodSet(T1.Trip, getTime(atValue(tdwithin(T1.Trip, T2.Trip, 2), TRUE))) IS NOT NULL

ORDER BY T1.customer_id, T2.customer_id

An improved variant that extracts passage times per disjoint trip segment using MobilityDB functions only is attached at the end of the article.

Visualizing the contacts

The figure below shows the results of both queries, with contact segments for the individuals highlighted in blue and labelled with their passage times.

Figure 2 Overlapping segments of healthy/infected individuals with mobilitydb
Figure 2 Overlapping segments of healthy/infected individuals

These results match those of the earlier approach.

Removing fulcrums, changing speeds

Because tgeompoint interpolates between instants, trips can be regenerated from a reduced set of points and still produce meaningful results. After removing a number of fulcrums, Figure 3 shows the generalized point sequence and Figure 4 the resulting trips, whose visualization indicates that the interpolation behaved as expected. Despite the removed fulcrums, the passage times still agree with the initial assessment (Figure 5).

Figure 3 Generalized points
Figure 3 Generalized points
Figure 4 Generated trips
Figure 4 Generated trips
Figure 5 Overlapping segments of healthy/infected individuals, reduced input set, mobilitydb
Figure 5 Overlapping segments of healthy/infected individuals, generalized points

Another step is to alter the customers' speeds by manipulating the timestamps of points at the beginning of one of the trips only (Figures 6 and 7). Figure 8 illustrates the effect on the results.

Figure 6 Generalized points
Figure 6 Generalized points
Figure 7 Different start times
Figure 7 Different start times
Figure 8 Overlapping segments of healthy/infected individuals, diverging speeds
Figure 8 Overlapping segments of healthy/infected individuals, diverging speeds

The examples here only scratch the surface of MobilityDB's capabilities. The main contributors are Esteban Zimanyi and Mahmoud Sakr.

PgSQL

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

SELECT  T1.customer_id AS customer_1,

        T2.customer_id AS customer_2,

        trajectory(

                unnest(

                        sequences(

                                atPeriodSet(T1.Trip,

                                            getTime(atValue(tdwithin(T1.Trip, T2.Trip, 2), TRUE)))))),

        extract('epoch' from timespan(

                unnest(

                        sequences(

                                atPeriodSet(T1.Trip,

                                            getTime(atValue(tdwithin(T1.Trip, T2.Trip, 2), TRUE)))))))

FROM mobile_trips T1,

     mobile_trips T2

WHERE t1.customer_id < t2.customer_id

  AND t1.infected <> t2.infected

  AND T1.Trip && expandSpatial(T2.Trip, 2)

  AND atPeriodSet(T1.Trip, getTime(atValue(tdwithin(T1.Trip, T2.Trip, 2), TRUE))) IS NOT NULL

ORDER BY T1.customer_id, T2.customer_id