Skip to content

Commit 4da69ff

Browse files
authored
Add parquet skim reading support (dense row/col-major + sparse)
1 parent 7d454ca commit 4da69ff

12 files changed

Lines changed: 458 additions & 5 deletions

File tree

activitysim/core/configuration/network.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,18 @@ class TAZ_Settings(PydanticBase):
125125
126126
This is treated as a fallback for the raw input data, if ZARR format data
127127
is not available.
128+
129+
As an alternative to OMX, skim files can instead be provided in Parquet
130+
format (using a ``.parquet`` file extension). The input format is
131+
auto-detected from the file extension, so no other settings need to
132+
change to use Parquet input. Parquet skim files should have an origin
133+
column and a destination column (the first two columns in the file),
134+
followed by one column for each named skim matrix (matching the naming
135+
conventions used for OMX skims, including double-underscore delimited
136+
time periods). Parquet skim data may be dense (one row for every
137+
origin-destination combination, sorted in row-major or column-major
138+
order) or sparse (only some origin-destination combinations present, in
139+
any order).
128140
"""
129141

130142
zarr: str = None

activitysim/core/skim_dict_factory.py

Lines changed: 82 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515

1616
from activitysim.core import skim_dictionary, util
1717
from activitysim.core.exceptions import TableTypeError
18+
from activitysim.core.skim_parquet import ParquetSkimFile, is_parquet_file
1819

1920
logger = logging.getLogger(__name__)
2021

@@ -60,18 +61,24 @@ def __init__(self, state, skim_tag, network_los):
6061
6162
skim_tag: str (e.g. 'TAZ')
6263
dtype_name: str (e.g. 'float32')
63-
omx_manifest: dict dict mapping { omx_key: omx_file_name }
64-
omx_shape: 2D tuple shape of omx matrix: (<number_of_zones>, <number_of_zones>)
65-
num_skims: int total number of individual skim matrices in omx files
64+
omx_manifest: dict dict mapping { omx_key: skim_file_name }, whether the skim
65+
file is an omx file or a parquet file
66+
omx_shape: 2D tuple shape of skim matrix: (<number_of_zones>, <number_of_zones>)
67+
num_skims: int total number of individual skim matrices in omx/parquet files
6668
skim_data_shape: 3D tuple (num_skims, omx_shape[0], omx_shape[1]) if ROW_MAJOR_LAYOUT
67-
offset_map: dict or None 1D ndarray as returned by omx_file.mapentries, if omx file has mappings
69+
offset_map: dict or None 1D ndarray as returned by omx_file.mapentries, if omx file has
70+
mappings, or the (sorted) zone ids found in a parquet skim file
6871
offset_map_name: str name of offset_map in omx_filecorresponding to offset_map, if there was one
6972
omx_keys: dict dict mapping skim key (str or tuple) to skim key in omx file
7073
{DISTWALK: DISTWALK,
7174
('DRV_COM_WLK_BOARDS', 'AM'): DRV_COM_WLK_BOARDS__AM, ...}
7275
base_keys: list of str e.g. 'BIKEDIST' or 'SOVTOLL_VTOLL' (base key of 3d skim)
7376
block_offsets: dict dict mapping skim key tuple to offset
7477
78+
Skim files can be in either OMX or Parquet format; the format is auto-detected
79+
from each file's extension (``.omx`` vs ``.parquet``/``.pq``), and OMX and Parquet
80+
files can be freely mixed within the list of files for a single skim_tag.
81+
7582
Parameters
7683
----------
7784
skim_tag
@@ -92,6 +99,10 @@ def __init__(self, state, skim_tag, network_los):
9299
self.base_keys = None
93100
self.block_offsets = None
94101

102+
# cache of ParquetSkimFile instances, keyed by file path, so files
103+
# opened during load_skim_info are not re-parsed when reading data
104+
self.parquet_files = {}
105+
95106
if skim_tag:
96107
self.load_skim_info(state, skim_tag)
97108

@@ -119,6 +130,44 @@ def load_skim_info(self, state, skim_tag):
119130
for omx_file_path in self.omx_file_paths:
120131
logger.debug(f"load_skim_info {skim_tag} reading {omx_file_path}")
121132

133+
if is_parquet_file(omx_file_path):
134+
# Skim data provided as a parquet file (auto-detected by extension)
135+
# instead of an omx file. The file is inspected here (and cached)
136+
# to determine its zone list, shape, and dense/sparse layout.
137+
parquet_skim_file = ParquetSkimFile(omx_file_path)
138+
self.parquet_files[omx_file_path] = parquet_skim_file
139+
140+
# Check the shape of the skims, same as is done for omx files below.
141+
if self.omx_shape is None:
142+
self.omx_shape = parquet_skim_file.shape
143+
else:
144+
assert (
145+
self.omx_shape == parquet_skim_file.shape
146+
), f"Mismatch shape {self.omx_shape} != {parquet_skim_file.shape}"
147+
148+
for skim_name in parquet_skim_file.data_cols:
149+
if skim_name in self.omx_manifest:
150+
warnings.warn(
151+
f"duplicate skim '{skim_name}' found in {self.omx_manifest[skim_name]} and {omx_file_path}"
152+
)
153+
self.omx_manifest[skim_name] = omx_file_path
154+
155+
# The origin/destination (zone id) values found in the parquet file
156+
# serve the same purpose as an omx file's offset mapping. Each parquet
157+
# file is checked independently (it need not have zones in the same
158+
# order as other files) but the set of zone ids found must match.
159+
if self.offset_map is None:
160+
self.offset_map_name = f"{omx_file_path} zone ids"
161+
self.offset_map = parquet_skim_file.zone_ids
162+
assert len(self.offset_map) == self.omx_shape[0]
163+
else:
164+
if not np.array_equal(self.offset_map, parquet_skim_file.zone_ids):
165+
raise RuntimeError(
166+
f"Mismatched zone ids in parquet skim file {omx_file_path}: "
167+
f"expected zone ids consistent with {self.offset_map_name}"
168+
)
169+
continue
170+
122171
with omx.open_file(omx_file_path, mode="r") as omx_file:
123172

124173
# Check the shape of the skims. All skim files loaded within this
@@ -322,7 +371,7 @@ def load_skim_info(self, state, skim_tag):
322371

323372
def _read_skims_from_omx(self, skim_info, skim_data):
324373
"""
325-
read skims from omx file into skim_data
374+
read skims from omx and/or parquet files into skim_data
326375
"""
327376

328377
skim_tag = skim_info.skim_tag
@@ -334,6 +383,34 @@ def _read_skims_from_omx(self, skim_info, skim_data):
334383

335384
logger.info(f"_read_skims_from_omx {omx_file_path}")
336385

386+
if is_parquet_file(omx_file_path):
387+
parquet_skim_file = skim_info.parquet_files.get(omx_file_path)
388+
if parquet_skim_file is None:
389+
parquet_skim_file = ParquetSkimFile(omx_file_path)
390+
for skim_key, omx_key in omx_keys.items():
391+
if omx_manifest[omx_key] == omx_file_path:
392+
offset = skim_info.block_offsets[skim_key]
393+
logger.debug(
394+
f"_read_skims_from_omx (parquet) file {omx_file_path} "
395+
f"omx_key {omx_key} skim_key {skim_key} to offset {offset}"
396+
)
397+
398+
if skim_dictionary.ROW_MAJOR_LAYOUT:
399+
a = skim_data[offset, :, :]
400+
else:
401+
a = skim_data[:, :, offset]
402+
403+
a[:] = parquet_skim_file.read_matrix(
404+
omx_key, dtype=skim_info.dtype_name
405+
)
406+
407+
num_skims_loaded += 1
408+
409+
logger.info(
410+
f"_read_skims_from_omx loaded {num_skims_loaded} skims from {omx_file_path}"
411+
)
412+
continue
413+
337414
# read skims into skim_data
338415
with omx.open_file(omx_file_path, mode="r") as omx_file:
339416
for skim_key, omx_key in omx_keys.items():

activitysim/core/skim_parquet.py

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
# ActivitySim
2+
# See full license in LICENSE.txt.
3+
from __future__ import annotations
4+
5+
import logging
6+
import os
7+
8+
import numpy as np
9+
import pyarrow.parquet as pq
10+
11+
logger = logging.getLogger(__name__)
12+
13+
PARQUET_SUFFIXES = (".parquet", ".pq")
14+
15+
# layout tags
16+
ROW_MAJOR = "row_major"
17+
COL_MAJOR = "col_major"
18+
SPARSE = "sparse"
19+
20+
21+
def is_parquet_file(file_path):
22+
"""
23+
Return True if file_path appears to be a parquet skim file, based on its extension.
24+
25+
Parameters
26+
----------
27+
file_path : str or Path
28+
29+
Returns
30+
-------
31+
bool
32+
"""
33+
return os.fspath(file_path).lower().endswith(PARQUET_SUFFIXES)
34+
35+
36+
class ParquetSkimFile:
37+
"""
38+
Inspect and read a single skim matrix table stored in parquet format.
39+
40+
The parquet file is expected to have an origin column and a destination column
41+
(the first two columns in the file, whatever their names), followed by one
42+
column per named skim matrix (analogous to the matrices in an omx file). The
43+
origin/destination values are used to determine the (square) shape of the
44+
matrices, and whether the data is arranged 'densely' (i.e. every combination
45+
of origin and destination is present exactly once) in row-major or column-major
46+
order, or is instead 'sparse' (i.e. not every combination is present, or the
47+
dense data is not sorted in row-major or column-major order).
48+
"""
49+
50+
def __init__(self, file_path):
51+
self.file_path = file_path
52+
53+
parquet_file = pq.ParquetFile(file_path)
54+
column_names = list(parquet_file.schema_arrow.names)
55+
if len(column_names) < 3:
56+
raise ValueError(
57+
f"parquet skim file {file_path} must have at least 3 columns "
58+
f"(origin, destination, and at least one data column), "
59+
f"found {len(column_names)}: {column_names}"
60+
)
61+
62+
self.orig_col = column_names[0]
63+
self.dest_col = column_names[1]
64+
self.data_cols = column_names[2:]
65+
66+
od_table = parquet_file.read(columns=[self.orig_col, self.dest_col])
67+
origins = od_table.column(self.orig_col).to_numpy(zero_copy_only=False)
68+
destinations = od_table.column(self.dest_col).to_numpy(zero_copy_only=False)
69+
70+
zone_ids = np.unique(np.concatenate([origins, destinations]))
71+
self.zone_ids = zone_ids
72+
self.n_zones = len(zone_ids)
73+
74+
self.shape = (self.n_zones, self.n_zones)
75+
76+
n_rows = len(origins)
77+
self.is_dense = n_rows == self.n_zones * self.n_zones
78+
79+
zone_index = {z: i for i, z in enumerate(zone_ids)}
80+
orig_idx = np.fromiter(
81+
(zone_index[o] for o in origins), dtype=np.int64, count=n_rows
82+
)
83+
dest_idx = np.fromiter(
84+
(zone_index[d] for d in destinations), dtype=np.int64, count=n_rows
85+
)
86+
self._orig_idx = orig_idx
87+
self._dest_idx = dest_idx
88+
89+
if self.is_dense:
90+
self.layout = self._detect_dense_layout(orig_idx, dest_idx)
91+
else:
92+
self.layout = SPARSE
93+
94+
def _detect_dense_layout(self, orig_idx, dest_idx):
95+
"""
96+
Determine whether dense data is arranged in row-major or column-major
97+
order. Raises a ValueError if the data is dense but not sorted in
98+
either of these orders.
99+
"""
100+
n = self.n_zones
101+
102+
row_major_orig = np.repeat(np.arange(n), n)
103+
row_major_dest = np.tile(np.arange(n), n)
104+
if np.array_equal(orig_idx, row_major_orig) and np.array_equal(
105+
dest_idx, row_major_dest
106+
):
107+
return ROW_MAJOR
108+
109+
col_major_orig = np.tile(np.arange(n), n)
110+
col_major_dest = np.repeat(np.arange(n), n)
111+
if np.array_equal(orig_idx, col_major_orig) and np.array_equal(
112+
dest_idx, col_major_dest
113+
):
114+
return COL_MAJOR
115+
116+
raise ValueError(
117+
f"parquet skim file {self.file_path} appears to contain dense data "
118+
f"(one row for every origin-destination pair) but the rows are not "
119+
f"sorted in row-major or column-major order. Dense parquet skim data "
120+
f"must be sorted so it can be read efficiently; alternatively, omit "
121+
f"rows to store the data in (unsorted or sorted) sparse format."
122+
)
123+
124+
def read_matrix(self, column_name, dtype=None):
125+
"""
126+
Read a single named skim matrix from the parquet file as a dense 2D array.
127+
128+
Parameters
129+
----------
130+
column_name : str
131+
dtype : dtype convertible, optional
132+
133+
Returns
134+
-------
135+
np.ndarray, shape (n_zones, n_zones)
136+
"""
137+
table = pq.read_table(self.file_path, columns=[column_name])
138+
values = table.column(column_name).to_numpy(zero_copy_only=False)
139+
if dtype is not None:
140+
values = values.astype(dtype, copy=False)
141+
142+
n = self.n_zones
143+
if self.layout == ROW_MAJOR:
144+
return np.ascontiguousarray(values.reshape(n, n))
145+
elif self.layout == COL_MAJOR:
146+
return np.ascontiguousarray(values.reshape(n, n, order="F"))
147+
else:
148+
# sparse layout (may or may not be sorted); scatter into dense matrix
149+
matrix = np.zeros((n, n), dtype=values.dtype)
150+
matrix[self._orig_idx, self._dest_idx] = values
151+
return matrix
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
zone_system: 1
2+
3+
taz_skims: z1_taz_skims.parquet
4+
5+
skim_time_periods:
6+
time_window: 1440
7+
period_minutes: 60
8+
periods: [0, 6, 11, 16, 20, 24]
9+
labels: ['EA', 'AM', 'MD', 'PM', 'EV']
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
2+
multiprocess: False
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
zone_system: 1
2+
3+
taz_skims:
4+
- z1_taz_skims_part1.parquet
5+
- z1_taz_skims_part2.parquet
6+
7+
skim_time_periods:
8+
time_window: 1440
9+
period_minutes: 60
10+
periods: [0, 6, 11, 16, 20, 24]
11+
labels: ['EA', 'AM', 'MD', 'PM', 'EV']
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
2+
multiprocess: False
8.06 KB
Binary file not shown.
4.07 KB
Binary file not shown.
2.8 KB
Binary file not shown.

0 commit comments

Comments
 (0)