Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 11 additions & 8 deletions benchmarks/bench_log_to_card.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@
import time
from csv import reader
from pathlib import Path
from typing import List

import pandas as pd

Expand Down Expand Up @@ -61,28 +60,31 @@
class Timer:
"""Simple context-manager timer."""

def __init__(self, label: str, results: dict):
def __init__(self, label: str, results: dict) -> None:
"""Record the label used when storing elapsed time in ``results``."""
self.label = label
self.results = results

def __enter__(self):
def __enter__(self) -> Timer:
"""Start the timer."""
self._start = time.perf_counter()
return self

def __exit__(self, *args):
def __exit__(self, *args) -> None:
"""Stop the timer and store elapsed seconds under ``label``."""
elapsed = time.perf_counter() - self._start
self.results[self.label] = elapsed
print(f" {self.label:<55} {elapsed:7.3f}s")


# ---------------------------------------------------------------------------
# Stage 1 read_logfile (replica of Project.read_logfile)
# Stage 1 - read_logfile (replica of Project.read_logfile)
# ---------------------------------------------------------------------------


def benchmark_read_logfile(log_path: Path) -> pd.DataFrame:
"""Parse a Cube log file into a DataFrame."""
with open(log_path) as f:
with log_path.open() as f:
content = f.readlines()

link_lines = [x.strip().replace(";", ",") for x in content if x.startswith("L")]
Expand All @@ -102,7 +104,7 @@ def split_log(x):


# ---------------------------------------------------------------------------
# Stage 2 consolidate_actions (replica of _consolidate_actions)
# Stage 2 - consolidate_actions (replica of _consolidate_actions)
# ---------------------------------------------------------------------------


Expand Down Expand Up @@ -142,7 +144,8 @@ def _final_op(x):
# ---------------------------------------------------------------------------


def run_benchmark(sizes: List[int]):
def run_benchmark(sizes: list[int]) -> None:
"""Load the base network and time each pipeline stage for each log size."""
print(f"\nLoading base network from {LINK_JSON} …")
t0 = time.perf_counter()
base_links_df = pd.read_json(LINK_JSON)
Expand Down
3 changes: 2 additions & 1 deletion benchmarks/generate_test_logfile.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,8 @@
OUT_DIR = Path(__file__).parent.parent / "tests" / "data"


def main():
def main() -> None:
"""Write synthetic change log files under ``tests/data/`` for each configured size."""
OUT_DIR.mkdir(parents=True, exist_ok=True)

print(f"Reading {STPAUL_LINK_JSON} …")
Expand Down
6 changes: 2 additions & 4 deletions cube_wrangler/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -817,13 +817,11 @@ def _process_link_changes(link_changes_df, changeable_col):
return []

# Pre-build (A, B) → positional index once — O(N_network).
# Avoids a full boolean mask scan per change row (was O(N_network × N_changes)).
# Avoids a full boolean mask scan per change row (was O(N_network x N_changes)).
base_links = self.base_roadway_network.links_df
ab_lookup: dict[tuple, int] = {
(int(a), int(b)): i
for i, (a, b) in enumerate(
zip(base_links["A"], base_links["B"], strict=True)
)
for i, (a, b) in enumerate(zip(base_links["A"], base_links["B"], strict=True))
}

card_frames: list[pd.DataFrame] = [] # collect first, concat once at the end
Expand Down
41 changes: 21 additions & 20 deletions cube_wrangler/roadway.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
import numpy as np
import pandas as pd
from geopandas import GeoDataFrame
from network_wrangler.roadway.links.scopes import prop_for_scope
from network_wrangler.roadway.links.scopes import props_for_scopes
from network_wrangler.roadway.network import RoadwayNetwork
from pandas import DataFrame

Expand Down Expand Up @@ -44,7 +44,7 @@ def split_properties_by_time_period_and_category(

link_attrs = copy.deepcopy(roadway_net.links_df.attrs)

if properties_to_split == None:
if properties_to_split is None:
properties_to_split = parameters.properties_to_split

for out_var, params in properties_to_split.items():
Expand All @@ -63,25 +63,26 @@ def split_properties_by_time_period_and_category(
for time_suffix in params["time_periods"]:
roadway_net.links_df[out_var + "_" + time_suffix] = 0
elif params.get("time_periods") and params.get("categories"):
for time_suffix, category_suffix in itertools.product(
params["time_periods"], params["categories"]
):
roadway_net.links_df[out_var + "_" + category_suffix + "_" + time_suffix] = (
prop_for_scope(
roadway_net.links_df,
params["v"],
category=params["categories"][category_suffix],
timespan=params["time_periods"][time_suffix],
)[params["v"]]
)
scopes = [
{
"label": f"{out_var}_{cat_sfx}_{ts_sfx}",
"timespan": params["time_periods"][ts_sfx],
"category": params["categories"][cat_sfx],
}
for ts_sfx in params["time_periods"]
for cat_sfx in params["categories"]
]
resolved = props_for_scopes(roadway_net.links_df, params["v"], scopes)
for label, series in resolved.items():
roadway_net.links_df[label] = series
elif params.get("time_periods"):
for time_suffix in params["time_periods"]:
roadway_net.links_df[out_var + "_" + time_suffix] = prop_for_scope(
roadway_net.links_df,
params["v"],
category=None,
timespan=params["time_periods"][time_suffix],
)[params["v"]]
scopes = [
{"label": f"{out_var}_{ts_sfx}", "timespan": params["time_periods"][ts_sfx]}
for ts_sfx in params["time_periods"]
]
resolved = props_for_scopes(roadway_net.links_df, params["v"], scopes)
for label, series in resolved.items():
roadway_net.links_df[label] = series
else:
msg = f"Shoudn't have a category without a time period: {params}"
raise ValueError(msg)
Expand Down
50 changes: 50 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,56 @@ def stpaul_links_df() -> pd.DataFrame:
return pd.read_json(STPAUL_LINK_JSON)


@pytest.fixture(scope="session")
def stpaul_net():
"""Fully loaded stpaul RoadwayNetwork from network_wrangler's example data.

Uses network_wrangler's example stpaul data (which fully conforms to
RoadLinksTable) rather than the cube_wrangler test JSON, which contains
NaN lanes that fail schema coercion.

Session-scoped so the expensive load (geojson parse + validation) only
happens once per test run.
"""
import network_wrangler
from network_wrangler import load_roadway

nw_examples = Path(network_wrangler.__file__).parent.parent / "examples" / "stpaul"
return load_roadway(
links_file=nw_examples / "link.json",
nodes_file=nw_examples / "node.geojson",
shapes_file=nw_examples / "shape.geojson",
)


@pytest.fixture(scope="session")
def stpaul_net_with_scoped(stpaul_net):
"""Network with synthetic scoped lanes values injected (stpaul fixture).

Without scoped values both old and new split_properties paths are trivially
fast (no sc_ column → return default immediately). This fixture populates
sc_lanes on ~10% of links so the benchmark exercises the explode/filter path.
"""
import copy

import numpy as np
from network_wrangler.models.roadway.types import ScopedLinkValueItem

net = copy.copy(stpaul_net) # shallow copy — we'll replace links_df
links = stpaul_net.links_df.copy()

# Build sc_lanes as a full-length object array (None for un-scoped links).
sc_lanes = np.empty(len(links), dtype=object)
sc_lanes[:] = None
for i, (_idx, row) in enumerate(links.iterrows()):
if i % 10 == 0:
sc_lanes[i] = [ScopedLinkValueItem(timespan=["6:00", "10:00"], value=int(row["lanes"]) + 1)]

links["sc_lanes"] = sc_lanes
net.links_df = links
return net


# ---------------------------------------------------------------------------
# Log file fixtures (pre-generated files in tests/data/)
# ---------------------------------------------------------------------------
Expand Down
27 changes: 27 additions & 0 deletions tests/test_benchmark.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,3 +115,30 @@ def test_current_500(self, benchmark, change_500, stpaul_links_df):
assert len(result) >= 0


# ---------------------------------------------------------------------------
# Stage 3: split_properties_by_time_period_and_category
# ---------------------------------------------------------------------------


class TestBenchmarkSplitProperties:
"""Benchmark split_properties_by_time_period_and_category on the stpaul network.

Uses a network with synthetic scoped values so the benchmark exercises the
explode/filter code path, not just the fast-path (no sc_ column).

Repeated rounds overwrite the same output columns, which is the realistic
hot-path: the function is always called on an already-loaded network.
"""

def test_split_properties(self, benchmark, stpaul_net_with_scoped, cube_parameters):
"""Benchmark resolving all scoped properties across time periods and categories."""
from cube_wrangler.roadway import split_properties_by_time_period_and_category

result = benchmark(
split_properties_by_time_period_and_category,
roadway_net=stpaul_net_with_scoped,
parameters=cube_parameters,
)
assert result is not None