Disaggregate zone-level demand to locations with time-of-day periods (including wrap-around)

Disaggregate zone-level demand to locations with time-of-day periods (including wrap-around)#

This example shows how to: - Load zones and locations from the Grid model - Create a small synthetic zone-to-zone demand table - Disaggregate those trips to origin/destination locations - Assign random start times using four time periods, one of which wraps around midnight (20:00–06:00)

Imports

from pathlib import Path

import numpy as np
import pandas as pd

from polaris import Polaris
from polaris.prepare.demand.demand_disaggregation import (
    disaggregate_column,
    assign_random_start_time,
)
from polaris.utils.database.data_table_access import DataTableAccess
model_dir = Path("/tmp/Grid")
supply_file = Polaris.from_dir(model_dir).supply_file

Read Zones and Locations tables from the Supply database

dta = DataTableAccess(supply_file)
zones = dta.get("Zone")[["zone"]]  # GeoDataFrame with at least columns: zone, geo
locations = dta.get("Location")[["location", "zone"]]  # GeoDataFrame with at least: location, zone, land_use, link, geo

Build a small synthetic zone-to-zone demand table We’ll pick a handful of zones that actually have locations and create random flows between them.

rng = np.random.default_rng(123)

origs = zones.sample(10, random_state=42).zone.to_numpy()  # For display purposes only
dests = zones.sample(10, random_state=24).zone.to_numpy()  # For display purposes only
flows = np.random.rand(10) * 5  # random number of trips between 0 and 5

zone_demand = pd.DataFrame({"zone_origin": origs, "zone_dest": dests, "trips": flows})
zone_demand.head()
zone_origin zone_dest trips
0 10 6 3.266219
1 12 13 0.850886
2 1 9 3.066716
3 14 15 2.995581
4 6 7 1.766465


This is what the locations dataframe need to look like

locations.head()
location zone
0 63 1
1 64 1
2 65 1
3 66 1
4 67 1


Disaggregate to locations using the helper in polaris.prepare.demand.demand_disaggregation This expands each OD row into individual trips and assigns origin/destination locations in the matching zones.

# Robust rounding ensures a better match to the original totals by making multiple attempts of
# stochastic rounding. It is not needed in the vast majority of cases
trips_df = disaggregate_column(zone_demand, "trips", locations, seed=42, robust_rounding=False)
print("\nTrip-level disaggregated data (columns):", trips_df.columns.tolist())

# This is what the trips dataframe with your aggregate trips need to look like (the name of the trips colum can be
# different, as that is parameter in the disaggregation procedure
trips_df.head()
Trip-level disaggregated data (columns): ['zone_origin', 'zone_dest', 'trips', 'trip_id', 'origin_location', 'dest_location']
zone_origin zone_dest trips trip_id origin_location dest_location
16 2 5 2.0 17 76 119
17 2 5 2.0 18 84 117
1 10 6 3.0 2 973 137
2 10 6 3.0 3 979 129
0 10 6 3.0 1 156 134


Define four time periods and assign random start times One period wraps around midnight (20:00 → 06:00 next day).

temporal_dist = pd.DataFrame(
    {
        "start_hour": [6.0, 10.0, 16.0, 20.0],
        # end_hour <= 24; wrap-around handled internally for start > end by adding 24 to end
        "end_hour": [10, 16, 20, 6.0],
        # proportions must sum to 1.0
        "proportion": [0.20, 0.40, 0.20, 0.20],
    }
)

This is what the temporal_dist dataframe looks like

temporal_dist
start_hour end_hour proportion
0 6.0 10.0 0.2
1 10.0 16.0 0.4
2 16.0 20.0 0.2
3 20.0 6.0 0.2


trips_with_time = assign_random_start_time(trips_df, temporal_dist)
trips_with_time.head()
zone_origin zone_dest trips trip_id origin_location dest_location start
16 2 5 2.0 17 76 119 40140
17 2 5 2.0 18 84 117 51147
1 10 6 3.0 2 973 137 1785
2 10 6 3.0 3 979 129 3437
0 10 6 3.0 1 156 134 57056


Let’s verify the assigned start times roughly match the desired proportions

starts_h = trips_with_time["start"].to_numpy() / 3600.0
period_masks = [
    (starts_h >= 6) & (starts_h < 10),
    (starts_h >= 10) & (starts_h < 16),
    (starts_h >= 16) & (starts_h < 20),
    (starts_h >= 20) | (starts_h < 6),  # wrap-around
]
counts = np.array([m.sum() for m in period_masks], dtype=float)
print("\nAssigned starts by period (counts):", counts.astype(int).tolist())
print("Proportions:", np.round(counts / counts.sum(), 3).tolist())
Assigned starts by period (counts): [2, 11, 6, 5]
Proportions: [0.083, 0.458, 0.25, 0.208]

Show a compact preview: origin/destination locations and departure time in HH:MM

preview = trips_with_time[["zone_origin", "zone_dest", "origin_location", "dest_location", "start"]].copy()
preview["start_hm"] = (preview["start"] // 60).astype(int).map(lambda m: f"{m // 60:02d}:{m % 60:02d}")
print("\nPreview (first 10 trips):")
Preview (first 10 trips):
preview.head(10)
zone_origin zone_dest origin_location dest_location start start_hm
16 2 5 76 119 40140 11:09
17 2 5 84 117 51147 14:12
1 10 6 973 137 1785 00:29
2 10 6 979 129 3437 00:57
0 10 6 156 134 57056 15:50
10 6 7 636 742 74163 20:36
11 6 7 125 148 69333 19:15
6 1 9 67 131 70331 19:32
5 1 9 193 132 43712 12:08
4 1 9 104 958 36716 10:11


Total running time of the script: (0 minutes 0.138 seconds)

Gallery generated by Sphinx-Gallery