From 331717938411602d1ded84fc09b170eba50a7d80 Mon Sep 17 00:00:00 2001 From: FlxPo Date: Wed, 29 Jul 2026 16:22:43 +0200 Subject: [PATCH 1/2] Add optional destination plan search --- docs/source/run_parameters.md | 33 ++ .../trips/group_day_trips/core/parameters.py | 20 + .../plans/destination_plan_search.py | 426 ++++++++++++++++++ .../plans/destination_sequences.py | 103 +++-- pixi.lock | 1 + pyproject.toml | 4 + ...13_grand_geneve_destination_plan_search.py | 239 ++++++++++ .../test_012_destination_sequences.py | 121 +++++ 8 files changed, 914 insertions(+), 33 deletions(-) create mode 100644 mobility/trips/group_day_trips/plans/destination_plan_search.py create mode 100644 tests/back/integration/test_013_grand_geneve_destination_plan_search.py diff --git a/docs/source/run_parameters.md b/docs/source/run_parameters.md index f7fb6cd5..37531228 100644 --- a/docs/source/run_parameters.md +++ b/docs/source/run_parameters.md @@ -93,3 +93,36 @@ parameters_report = weekday_run.parameters_dataframe() ``` This helps you explain later how a result was produced, which parameters changed, and which assumptions were held constant. + +## Try Complete Destination-Plan Search + +The existing step-by-step destination sampler remains the default. To try the +bounded Rust search, install the optional dependency and enable it explicitly: + +```bash +pip install "mobility-tools[destination-plan-search]" +``` + +Until version `0.1.0` of the sampler is published, contributors can install a +local checkout instead: + +```bash +python -m pip install --no-deps -e ../mobility-destination-sequence-sampler +``` + +```python +from mobility import ( + GroupDayTripsDestinationSequenceParameters, + GroupDayTripsParameters, +) + +parameters = GroupDayTripsParameters( + destination_sequences=GroupDayTripsDestinationSequenceParameters( + use_destination_plan_search=True, + ) +) +``` + +This search chooses and ranks complete destination chains together. It returns +the best chains found by a bounded search; it does not prove that no better +chain was omitted. diff --git a/mobility/trips/group_day_trips/core/parameters.py b/mobility/trips/group_day_trips/core/parameters.py index bb35c109..38f3578c 100644 --- a/mobility/trips/group_day_trips/core/parameters.py +++ b/mobility/trips/group_day_trips/core/parameters.py @@ -280,6 +280,17 @@ class GroupDayTripsDestinationSequenceParameters(BaseModel): model_config = ConfigDict(extra="forbid") + use_destination_plan_search: Annotated[ + bool, + Field( + default=False, + title="Use destination plan search", + description=( + "Whether to use the bounded Rust top-k search to choose complete " + "destination plans. The default keeps the existing step-by-step sampler." + ), + ), + ] alpha: Annotated[ float, Field( @@ -326,6 +337,15 @@ class GroupDayTripsDestinationSequenceParameters(BaseModel): ), ] + @model_validator(mode="after") + def validate_destination_plan_search(self) -> "GroupDayTripsDestinationSequenceParameters": + """Validate settings required by the bounded destination-plan search.""" + if self.use_destination_plan_search and self.alpha <= 0.0: + raise ValueError( + "alpha must be greater than zero when destination plan search is enabled." + ) + return self + class GroupDayTripsModeSequenceParameters(BaseModel): """Settings used when searching mode sequences.""" diff --git a/mobility/trips/group_day_trips/plans/destination_plan_search.py b/mobility/trips/group_day_trips/plans/destination_plan_search.py new file mode 100644 index 00000000..d5efd901 --- /dev/null +++ b/mobility/trips/group_day_trips/plans/destination_plan_search.py @@ -0,0 +1,426 @@ +import logging +import math +from typing import Any + +import polars as pl + +from .demand_subgroups import DEMAND_UNIT_COLS, with_demand_subgroup_id + + +SEQUENCE_COLUMNS = ["activity_seq_id", "time_seq_id"] +RAW_CONTEXT_COLUMNS = DEMAND_UNIT_COLS + SEQUENCE_COLUMNS + + +def sample_destination_plans( + *, + activity_sequences: pl.DataFrame, + activity_durations: pl.DataFrame, + demand_groups: pl.DataFrame, + destination_saturation: pl.DataFrame, + mode_costs: pl.DataFrame, + transport_zones: Any, + activities: list[Any], + resolved_activity_parameters: dict[str, Any], + min_activity_time_constant: float, + logit_scale: float, + update_plan_timings: bool, + use_shadow_prices: bool, + exploration_seed: int, + top_k: int, +) -> pl.DataFrame: + """Return complete destination plans found by the bounded Rust search.""" + try: + from mobility_destination_sequence_sampler import DestinationPlanSearch + except ImportError as error: + raise ImportError( + "Destination plan search requires the optional sampler package. " + "Install Mobility with the `destination-plan-search` extra." + ) from error + + activity_names = sorted(resolved_activity_parameters) + activity_ids = { + activity_name: activity_id + for activity_id, activity_name in enumerate(activity_names) + } + + od_costs = _prepare_od_costs(mode_costs, logit_scale) + destination_inputs = _prepare_destination_inputs( + destination_saturation=destination_saturation, + transport_zones=transport_zones, + resolved_activity_parameters=resolved_activity_parameters, + activity_ids=activity_ids, + ) + steps, initial_locations, raw_to_context, source_steps = _prepare_contexts( + activity_sequences=activity_sequences, + activity_durations=activity_durations, + demand_groups=demand_groups, + activities=activities, + resolved_activity_parameters=resolved_activity_parameters, + activity_ids=activity_ids, + min_activity_time_constant=min_activity_time_constant, + ) + + search = DestinationPlanSearch( + od_costs=od_costs, + destination_inputs=destination_inputs, + ) + plans, report = search.top_k( + steps=steps, + initial_locations=initial_locations, + logit_scale=logit_scale, + update_plan_timings=update_plan_timings, + use_shadow_prices=use_shadow_prices, + exploration_seed=exploration_seed, + top_k=top_k, + skip_contexts_without_plan=True, + ) + if report["contexts_without_plan"] > 0: + logging.warning( + "Destination plan search did not find a complete plan for %s unique contexts.", + report["contexts_without_plan"], + ) + + # Expand deduplicated search contexts back to each demand unit, then restore + # the Mobility destination-sequence columns expected by mode search. + return ( + plans.join(raw_to_context, on="context_id") + .join(source_steps, on=RAW_CONTEXT_COLUMNS + ["layer"]) + .select( + RAW_CONTEXT_COLUMNS + + [ + pl.col("draw_id").alias("dest_draw_id"), + "activity", + "home_zone_id", + "seq_step_index", + "step_count", + pl.col("origin").alias("from"), + pl.col("destination").alias("to"), + "departure_time", + "arrival_time", + "next_departure_time", + ] + ) + .sort(RAW_CONTEXT_COLUMNS + ["dest_draw_id", "seq_step_index"]) + ) + + +def _prepare_od_costs(mode_costs: pl.DataFrame, logit_scale: float) -> pl.DataFrame: + """Average cost and time across modes with the destination-choice logit scale.""" + mode_costs = mode_costs.lazy().select( + pl.col("from").cast(pl.UInt32).alias("origin"), + pl.col("to").cast(pl.UInt32).alias("destination"), + pl.col("cost").cast(pl.Float64), + pl.col("time").cast(pl.Float64), + ) + minimum_costs = mode_costs.group_by(["origin", "destination"]).agg( + minimum_cost=pl.col("cost").min() + ) + return ( + mode_costs.join(minimum_costs, on=["origin", "destination"]) + .with_columns( + mode_weight=( + -pl.lit(logit_scale) + * (pl.col("cost") - pl.col("minimum_cost")) + ).exp() + ) + .group_by(["origin", "destination"]) + .agg( + weighted_cost=(pl.col("mode_weight") * pl.col("cost")).sum(), + weighted_time=(pl.col("mode_weight") * pl.col("time")).sum(), + total_weight=pl.col("mode_weight").sum(), + ) + .select( + "origin", + "destination", + cost=pl.col("weighted_cost") / pl.col("total_weight"), + time=pl.col("weighted_time") / pl.col("total_weight"), + ) + .collect(engine="streaming") + .sort(["origin", "destination"]) + ) + + +def _prepare_destination_inputs( + *, + destination_saturation: pl.DataFrame, + transport_zones: Any, + resolved_activity_parameters: dict[str, Any], + activity_ids: dict[str, int], +) -> pl.DataFrame: + """Prepare capacity and destination-utility values for each activity and zone.""" + zone_countries = ( + pl.from_pandas( + transport_zones.get() + .drop("geometry", axis=1, errors="ignore")[ + ["transport_zone_id", "country"] + ] + ) + .select( + pl.col("transport_zone_id").cast(pl.UInt32).alias("destination"), + pl.col("country").cast(pl.String).alias("destination_country"), + ) + .unique() + ) + country_coefficients = pl.from_dicts( + [ + { + "activity": activity_name, + "destination_country": destination_country, + "country_value_coefficient": coefficient, + } + for activity_name, activity_parameters in resolved_activity_parameters.items() + for destination_country, coefficient in ( + activity_parameters.country_value_coefficients or {} + ).items() + ], + schema={ + "activity": pl.String, + "destination_country": pl.String, + "country_value_coefficient": pl.Float64, + }, + ) + saturation_columns = set(destination_saturation.columns) + saturation_utility = ( + pl.col("k_saturation_utility").fill_null(1.0) + if "k_saturation_utility" in saturation_columns + else pl.lit(1.0) + ) + shadow_price = ( + pl.col("destination_shadow_price").fill_null(0.0) + if "destination_shadow_price" in saturation_columns + else pl.lit(0.0) + ) + + return ( + destination_saturation.lazy() + .with_columns( + activity=pl.col("activity").cast(pl.String), + destination=pl.col("to").cast(pl.UInt32), + ) + .join(zone_countries.lazy(), on="destination", how="left") + .join( + country_coefficients.lazy(), + on=["activity", "destination_country"], + how="left", + ) + .with_columns( + activity_id=pl.col("activity").replace_strict( + activity_ids, + return_dtype=pl.UInt32, + ), + country_value_coefficient=pl.col( + "country_value_coefficient" + ).fill_null(1.0), + saturation_utility=saturation_utility, + shadow_price=shadow_price, + ) + .select( + "activity_id", + "destination", + pl.col("opportunity_capacity").cast(pl.Float64), + "country_value_coefficient", + pl.col("saturation_utility").cast(pl.Float64), + pl.col("shadow_price").cast(pl.Float64), + ) + .collect(engine="streaming") + ) + + +def _prepare_contexts( + *, + activity_sequences: pl.DataFrame, + activity_durations: pl.DataFrame, + demand_groups: pl.DataFrame, + activities: list[Any], + resolved_activity_parameters: dict[str, Any], + activity_ids: dict[str, int], + min_activity_time_constant: float, +) -> tuple[pl.DataFrame, pl.DataFrame, pl.DataFrame, pl.DataFrame]: + """Prepare and deduplicate complete activity-plan contexts.""" + demand_groups = with_demand_subgroup_id(demand_groups).select( + RAW_CONTEXT_COLUMNS[:2] + + [ + pl.col("home_zone_id").cast(pl.UInt32), + pl.col("country").cast(pl.String), + pl.col("csp").cast(pl.String), + ] + ) + activity_durations = activity_durations.select( + pl.col("country").cast(pl.String), + pl.col("csp").cast(pl.String), + pl.col("activity").cast(pl.String), + pl.col("mean_duration_per_pers").cast(pl.Float64), + ) + value_of_time = { + activity_name: float(activity_parameters.value_of_time) + for activity_name, activity_parameters in resolved_activity_parameters.items() + } + activities_by_name = {activity.name: activity for activity in activities} + arrival_rigidity = {} + for activity_name, activity_parameters in resolved_activity_parameters.items(): + rigidity = activity_parameters.arrival_time_rigidity + if rigidity is None: + rigidity = 1.0 if activities_by_name[activity_name].is_anchor else 0.0 + arrival_rigidity[activity_name] = float(rigidity) + + source_steps = ( + with_demand_subgroup_id(activity_sequences) + .filter(pl.col("activity_seq_id") != 0) + .with_columns(activity=pl.col("activity").cast(pl.String)) + .join(demand_groups, on=DEMAND_UNIT_COLS) + .join(activity_durations, on=["country", "csp", "activity"]) + .sort(RAW_CONTEXT_COLUMNS + ["seq_step_index"]) + .with_columns( + layer=pl.int_range(pl.len()) + .over(RAW_CONTEXT_COLUMNS) + .cast(pl.UInt32), + activity_id=pl.col("activity").replace_strict( + activity_ids, + return_dtype=pl.UInt32, + ), + value_of_time=pl.col("activity").replace_strict( + value_of_time, + return_dtype=pl.Float64, + ), + duration_per_person=( + pl.col("next_departure_time") - pl.col("arrival_time") + ).cast(pl.Float64), + min_activity_time=( + pl.col("mean_duration_per_pers") + * math.exp(-min_activity_time_constant) + ), + ) + .with_columns( + anchor_id=( + pl.when(pl.col("is_anchor") & (pl.col("activity") != "home")) + .then(pl.col("activity_id")) + .otherwise(pl.lit(None, dtype=pl.UInt32)) + ), + fixed_destination=( + pl.when(pl.col("activity") == "home") + .then(pl.col("home_zone_id")) + .otherwise(pl.lit(None, dtype=pl.UInt32)) + ), + arrival_time_rigidity=( + pl.when( + pl.col("layer") + == pl.col("layer").max().over(RAW_CONTEXT_COLUMNS) + ) + .then(pl.lit(0.0)) + .otherwise( + pl.col("activity").replace_strict( + arrival_rigidity, + return_dtype=pl.Float64, + ) + ) + ), + origin_activity=( + pl.col("activity") + .shift(1) + .over(RAW_CONTEXT_COLUMNS) + .fill_null("home") + ), + ) + .with_columns( + departure_time_rigidity=( + pl.when(pl.col("origin_activity") == "home") + .then(pl.lit(0.0)) + .otherwise( + pl.col("origin_activity").replace_strict( + arrival_rigidity, + return_dtype=pl.Float64, + ) + ) + ) + ) + ) + + step_value_columns = [ + "layer", + "activity_id", + "anchor_id", + "fixed_destination", + "departure_time", + "arrival_time", + "arrival_time_rigidity", + "departure_time_rigidity", + "next_departure_time", + "duration_per_person", + "value_of_time", + "mean_duration_per_pers", + "min_activity_time", + ] + profiles = ( + source_steps.with_columns( + step_hash=pl.struct(step_value_columns).hash(seed=17) + ) + .group_by(RAW_CONTEXT_COLUMNS + ["home_zone_id"]) + .agg( + sequence_hash=pl.col("step_hash") + .sort_by("layer") + .cast(pl.String) + .str.join("-") + ) + .with_columns( + profile_key=pl.concat_str( + [pl.col("home_zone_id").cast(pl.String), "sequence_hash"], + separator="|", + ) + ) + ) + unique_profiles = ( + profiles.select("profile_key") + .unique() + .sort("profile_key") + .with_row_index("context_id") + .with_columns(pl.col("context_id").cast(pl.UInt64)) + ) + raw_to_context = profiles.join(unique_profiles, on="profile_key").select( + RAW_CONTEXT_COLUMNS + ["context_id"] + ) + steps = ( + source_steps.join(raw_to_context, on=RAW_CONTEXT_COLUMNS) + .sort(["context_id", "layer"]) + .unique(["context_id", "layer"], keep="first") + .select( + "context_id", + "layer", + "activity_id", + "anchor_id", + "fixed_destination", + pl.col("departure_time").cast(pl.Float64), + pl.col("next_departure_time").cast(pl.Float64), + "duration_per_person", + "value_of_time", + pl.col("mean_duration_per_pers").alias( + "mean_duration_per_person" + ), + "min_activity_time", + pl.col("arrival_time").cast(pl.Float64), + "arrival_time_rigidity", + "departure_time_rigidity", + ) + ) + initial_locations = ( + source_steps.join(raw_to_context, on=RAW_CONTEXT_COLUMNS) + .select( + "context_id", + pl.col("home_zone_id").alias("initial_zone"), + ) + .unique() + .sort("context_id") + ) + source_steps = source_steps.select( + RAW_CONTEXT_COLUMNS + + [ + "layer", + "seq_step_index", + "activity", + "home_zone_id", + "step_count", + "departure_time", + "arrival_time", + "next_departure_time", + ] + ) + return steps, initial_locations, raw_to_context, source_steps diff --git a/mobility/trips/group_day_trips/plans/destination_sequences.py b/mobility/trips/group_day_trips/plans/destination_sequences.py index e606448e..4fe5e7ce 100644 --- a/mobility/trips/group_day_trips/plans/destination_sequences.py +++ b/mobility/trips/group_day_trips/plans/destination_sequences.py @@ -16,6 +16,7 @@ from mobility.runtime.parameter_values import SensitivityCase from mobility.trips.group_day_trips.core.progress import get_group_day_trips_progress from .demand_subgroups import DEMAND_UNIT_COLS, demand_unit_hash, with_demand_subgroup_id +from .destination_plan_search import sample_destination_plans def _spatialization_cost_views(costs: pl.DataFrame) -> dict[str, pl.LazyFrame]: @@ -89,6 +90,7 @@ def __init__( current_plans: pl.DataFrame | None = None, current_plan_steps: pl.DataFrame | None = None, destination_saturation: pl.DataFrame | None = None, + activity_durations: pl.DataFrame | None = None, demand_groups: pl.DataFrame | None = None, costs: pl.DataFrame | None = None, parameters: Any = None, @@ -120,12 +122,13 @@ def __init__( self.current_plans = with_demand_subgroup_id(current_plans) if current_plans is not None else None self.current_plan_steps = with_demand_subgroup_id(current_plan_steps) if current_plan_steps is not None else None self.destination_saturation = destination_saturation + self.activity_durations = activity_durations self.demand_groups = with_demand_subgroup_id(demand_groups) if demand_groups is not None else None self.costs = costs self.parameters = parameters self.seed = seed inputs = { - "version": 10, + "version": 11, "is_weekday": is_weekday, "iteration": iteration, "sensitivity_case": sensitivity_case, @@ -216,6 +219,8 @@ def _load_missing_runtime_inputs_from_previous_state(self) -> None: self.destination_saturation = state.destination_saturation if self.demand_groups is None: self.demand_groups = with_demand_subgroup_id(state.demand_groups) + if self.activity_durations is None: + self.activity_durations = state.activity_dur if self.costs is None and self.transport_costs is not None: self.costs = self.transport_costs.get_costs_by_od(["cost", "distance"]) elif self.costs is None: @@ -409,18 +414,6 @@ def run( seed: int, ) -> pl.DataFrame: """Compute destination sequences for one iteration.""" - utility_inputs = self._get_destination_probability_inputs( - destination_saturation, - costs, - parameters.destination_sequences.cost_uncertainty_sd, - ) - destination_probability = self._get_destination_probability( - utility_inputs, - activities, - self.resolved_activity_parameters, - parameters.destination_sequences.dest_prob_cutoff, - ) - cost_views = _spatialization_cost_views(costs) activity_sequences = ( activity_sequences .filter(pl.col("activity_seq_id") != 0) @@ -446,26 +439,70 @@ def run( ) ) source_activity_sequences = activity_sequences - anchor_spatialized_sequences = self._spatialize_anchor_activities( - source_activity_sequences, - destination_probability, - costs, - parameters.destination_sequences.alpha, - seed, - cost_views, - ) - spatialized_activity_sequences = self._spatialize_other_activities( - anchor_spatialized_sequences, - destination_probability, - costs, - parameters.destination_sequences.alpha, - seed, - cost_views, - ) - complete_activity_sequences = self._drop_incomplete_destination_draws( - activity_sequences=spatialized_activity_sequences, - iteration=self.iteration, - ) + if parameters.destination_sequences.use_destination_plan_search: + if self.activity_durations is None: + raise ValueError( + "Cannot use destination plan search without activity durations." + ) + if self.transport_costs is None: + raise ValueError( + "Cannot use destination plan search without transport costs." + ) + complete_activity_sequences = sample_destination_plans( + activity_sequences=activity_sequences, + activity_durations=self.activity_durations, + demand_groups=demand_groups, + destination_saturation=destination_saturation, + mode_costs=self.transport_costs.get_costs_by_od_and_mode( + ["cost", "time"] + ), + transport_zones=transport_zones, + activities=activities, + resolved_activity_parameters=self.resolved_activity_parameters, + min_activity_time_constant=( + parameters.plan_update.min_activity_time_constant + ), + logit_scale=parameters.destination_sequences.alpha, + update_plan_timings=( + parameters.plan_update.update_plan_timings_from_modeled_travel_times + ), + use_shadow_prices=parameters.plan_update.use_destination_shadow_prices, + exploration_seed=seed, + top_k=parameters.destination_sequences.k_destination_sequences, + ) + else: + utility_inputs = self._get_destination_probability_inputs( + destination_saturation, + costs, + parameters.destination_sequences.cost_uncertainty_sd, + ) + destination_probability = self._get_destination_probability( + utility_inputs, + activities, + self.resolved_activity_parameters, + parameters.destination_sequences.dest_prob_cutoff, + ) + cost_views = _spatialization_cost_views(costs) + anchor_spatialized_sequences = self._spatialize_anchor_activities( + source_activity_sequences, + destination_probability, + costs, + parameters.destination_sequences.alpha, + seed, + cost_views, + ) + spatialized_activity_sequences = self._spatialize_other_activities( + anchor_spatialized_sequences, + destination_probability, + costs, + parameters.destination_sequences.alpha, + seed, + cost_views, + ) + complete_activity_sequences = self._drop_incomplete_destination_draws( + activity_sequences=spatialized_activity_sequences, + iteration=self.iteration, + ) destination_sequences = ( complete_activity_sequences diff --git a/pixi.lock b/pixi.lock index 2f5a7bcb..bb5dad2c 100644 --- a/pixi.lock +++ b/pixi.lock @@ -5672,6 +5672,7 @@ packages: - tenacity>=9,<10 - mobility-mode-sequence-search==0.1.0 - truststore>=0.10,<1 ; extra == 'truststore' + - mobility-destination-sequence-sampler==0.1.0 ; extra == 'destination-plan-search' - build>=1.5,<2 ; extra == 'dev' - coverage>=7,<8 ; extra == 'dev' - flake8>=7,<8 ; extra == 'dev' diff --git a/pyproject.toml b/pyproject.toml index ea7ff31c..874687c7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -56,6 +56,10 @@ truststore = [ "truststore>=0.10,<1", ] +destination-plan-search = [ + "mobility-destination-sequence-sampler==0.1.0", +] + dev = [ "build>=1.5,<2", "coverage>=7,<8", diff --git a/tests/back/integration/test_013_grand_geneve_destination_plan_search.py b/tests/back/integration/test_013_grand_geneve_destination_plan_search.py new file mode 100644 index 00000000..e1913a7f --- /dev/null +++ b/tests/back/integration/test_013_grand_geneve_destination_plan_search.py @@ -0,0 +1,239 @@ +import os +from pathlib import Path +from types import SimpleNamespace + +import polars as pl +import pytest + +from mobility.activities.activity import ActivityParameters +from mobility.trips.group_day_trips import ( + GroupDayTripsDestinationSequenceParameters, + GroupDayTripsParameters, + GroupDayTripsPlanUpdateParameters, +) +from mobility.trips.group_day_trips.plans.destination_sequences import ( + DestinationSequences, +) +from mobility.trips.group_day_trips.plans.destination_plan_search import ( + RAW_CONTEXT_COLUMNS, +) + + +SNAPSHOT_FILES = { + "activity_sequences": ( + "activity-sequences/" + "31568cd267580e15713a8458b22b0687-activity_sequences_5.parquet" + ), + "transport_costs": ( + "iteration-transport-costs/" + "0d1f446fa31903f5e734195585678c76-transport_costs_5.parquet" + ), + "destination_saturation": ( + "iteration-state-cache/" + "50489de7b4b351be3778dad7894caef0-destination_saturation_5.parquet" + ), + "activity_durations": ( + "iteration-state-cache/" + "50489de7b4b351be3778dad7894caef0-activity_dur_5.parquet" + ), + "demand_groups": ( + "iteration-state-cache/" + "50489de7b4b351be3778dad7894caef0-demand_groups_5.parquet" + ), +} + + +def _grand_geneve_snapshot() -> dict[str, Path]: + project_folder = os.environ.get("MOBILITY_GRAND_GENEVE_PROJECT_FOLDER") + if project_folder is None: + pytest.skip( + "Set MOBILITY_GRAND_GENEVE_PROJECT_FOLDER to run the real-data " + "destination-plan integration test." + ) + + group_day_trips_folder = Path(project_folder) / "group_day_trips" + files = { + name: group_day_trips_folder / relative_path + for name, relative_path in SNAPSHOT_FILES.items() + } + missing = [path for path in files.values() if not path.exists()] + if missing: + pytest.fail( + "The Grand Geneve iteration-5 snapshot is incomplete: " + + ", ".join(str(path) for path in missing) + ) + return files + + +def test_grand_geneve_destination_plan_search_returns_complete_chains( + tmp_path, +): + """Run Mobility's production adapter on a real Grand Geneve cache sample.""" + files = _grand_geneve_snapshot() + activity_sequences = pl.read_parquet(files["activity_sequences"]) + selected_contexts = ( + activity_sequences + .group_by(RAW_CONTEXT_COLUMNS) + .agg(context_steps=pl.len()) + .sort( + ["context_steps"] + RAW_CONTEXT_COLUMNS, + descending=[True] + [False] * len(RAW_CONTEXT_COLUMNS), + ) + .head(25) + .select(RAW_CONTEXT_COLUMNS) + ) + activity_sequences = activity_sequences.join( + selected_contexts, + on=RAW_CONTEXT_COLUMNS, + how="semi", + ) + demand_groups = pl.read_parquet(files["demand_groups"]) + + # The Grand Geneve home-zone table covers the modeled zone countries used + # to apply the French and Swiss destination-value coefficients. + zone_countries = ( + demand_groups + .select( + pl.col("home_zone_id").alias("transport_zone_id"), + pl.col("country").cast(pl.String), + ) + .unique() + .to_pandas() + ) + transport_zones = SimpleNamespace(get=lambda: zone_countries) + activities = [ + SimpleNamespace(name="home", is_anchor=True), + SimpleNamespace(name="work", is_anchor=True), + SimpleNamespace(name="studies", is_anchor=True), + SimpleNamespace(name="shopping", is_anchor=False), + SimpleNamespace(name="leisure", is_anchor=False), + SimpleNamespace(name="other", is_anchor=False), + ] + value_of_time = { + "home": 3.5, + "work": 2.0, + "studies": 3.0, + "shopping": 8.0, + "leisure": 4.0, + "other": 5.0, + } + resolved_activity_parameters = { + activity.name: ActivityParameters( + value_of_time=value_of_time[activity.name], + country_value_coefficients=( + {"fr": 1.0, "ch": 1.5} + if activity.name == "work" + else None + ), + arrival_time_rigidity=None, + ) + for activity in activities + } + + mode_costs = pl.read_parquet(files["transport_costs"]) + parameters = GroupDayTripsParameters( + destination_sequences=GroupDayTripsDestinationSequenceParameters( + use_destination_plan_search=True, + k_destination_sequences=3, + alpha=0.25, + ), + plan_update=GroupDayTripsPlanUpdateParameters( + update_plan_timings_from_modeled_travel_times=True, + use_destination_shadow_prices=True, + min_activity_time_constant=2.0, + ), + ) + transport_costs = SimpleNamespace( + get_costs_by_od_and_mode=lambda metrics: mode_costs + ) + # Build only the runtime side of the asset. The real cached dependencies + # are plain test doubles here and are not valid content-addressed assets. + destination_sequences = object.__new__(DestinationSequences) + destination_sequences.iteration = 5 + destination_sequences.activities = activities + destination_sequences.resolved_activity_parameters = ( + resolved_activity_parameters + ) + destination_sequences.transport_zones = transport_zones + destination_sequences.transport_costs = transport_costs + destination_sequences.destination_saturation = pl.read_parquet( + files["destination_saturation"] + ) + destination_sequences.activity_durations = pl.read_parquet( + files["activity_durations"] + ) + destination_sequences.previous_destination_sequences = None + destination_sequences.cache_path = { + "index": tmp_path / "destination_sequence_index_5.parquet" + } + result = destination_sequences.run( + activities=activities, + transport_zones=transport_zones, + destination_saturation=destination_sequences.destination_saturation, + activity_sequences=activity_sequences, + demand_groups=demand_groups, + costs=pl.DataFrame(), + parameters=parameters, + seed=17, + ) + + plan_columns = RAW_CONTEXT_COLUMNS + ["dest_seq_id"] + expected_counts = ( + activity_sequences + .group_by(RAW_CONTEXT_COLUMNS) + .agg(expected_step_count=pl.len()) + ) + initial_locations = demand_groups.select( + RAW_CONTEXT_COLUMNS[:2] + + [pl.col("home_zone_id").alias("initial_zone")] + ) + plan_summary = ( + result + .sort(plan_columns + ["seq_step_index"]) + .group_by(plan_columns) + .agg( + actual_step_count=pl.len(), + first_origin=pl.col("from").first(), + terminal_destination=pl.col("to").last(), + ) + .join(expected_counts, on=RAW_CONTEXT_COLUMNS) + .join(initial_locations, on=RAW_CONTEXT_COLUMNS[:2]) + ) + returned_context_count = result.select(RAW_CONTEXT_COLUMNS).unique().height + assert 0 < returned_context_count <= selected_contexts.height + assert plan_summary["expected_step_count"].max() >= 6 + assert ( + plan_summary + .group_by(RAW_CONTEXT_COLUMNS) + .agg(pl.len().alias("destination_plans")) + ["destination_plans"] + .max() + <= 3 + ) + assert ( + plan_summary["actual_step_count"] + == plan_summary["expected_step_count"] + ).all() + assert ( + plan_summary["first_origin"] == plan_summary["initial_zone"] + ).all() + assert ( + plan_summary["terminal_destination"] == plan_summary["initial_zone"] + ).all() + + ordered = ( + result + .sort(plan_columns + ["seq_step_index"]) + .with_columns( + previous_destination=pl.col("to").shift(1).over(plan_columns), + first_step=( + pl.col("seq_step_index") + == pl.col("seq_step_index").min().over(plan_columns) + ), + ) + ) + discontinuities = ordered.filter( + (~pl.col("first_step")) + & (pl.col("from") != pl.col("previous_destination")) + ) + assert discontinuities.height == 0 diff --git a/tests/back/unit/domain/group_day_trips/test_012_destination_sequences.py b/tests/back/unit/domain/group_day_trips/test_012_destination_sequences.py index 3f0d861b..ec5e2f3e 100644 --- a/tests/back/unit/domain/group_day_trips/test_012_destination_sequences.py +++ b/tests/back/unit/domain/group_day_trips/test_012_destination_sequences.py @@ -1,7 +1,9 @@ from pathlib import Path from types import SimpleNamespace +import pandas as pd import polars as pl +import pytest from mobility.trips.group_day_trips import ( GroupDayTripsDestinationSequenceParameters, @@ -9,6 +11,9 @@ GroupDayTripsPlanUpdateParameters, ) from mobility.trips.group_day_trips.plans.destination_sequences import DestinationSequences +from mobility.trips.group_day_trips.plans.destination_plan_search import ( + sample_destination_plans, +) def _make_local_tmp_path(tmp_path: Path, name: str) -> Path: @@ -17,6 +22,122 @@ def _make_local_tmp_path(tmp_path: Path, name: str) -> Path: return path +def test_destination_plan_search_flag_is_disabled_by_default(): + parameters = GroupDayTripsDestinationSequenceParameters() + + assert parameters.use_destination_plan_search is False + + +def test_destination_plan_search_requires_positive_alpha(): + with pytest.raises(ValueError, match="alpha must be greater than zero"): + GroupDayTripsDestinationSequenceParameters( + use_destination_plan_search=True, + alpha=0.0, + ) + + +def test_destination_plan_search_returns_mobility_sequence_rows(): + activity_sequences = pl.DataFrame( + { + "demand_group_id": [1, 1], + "demand_subgroup_id": [0, 0], + "home_zone_id": [1, 1], + "activity_seq_id": [10, 10], + "time_seq_id": [20, 20], + "activity": ["work", "home"], + "is_anchor": [True, True], + "seq_step_index": [1, 2], + "step_count": [2, 2], + "departure_time": [8.0, 17.0], + "arrival_time": [9.0, 18.0], + "next_departure_time": [17.0, 24.0], + } + ) + activity_durations = pl.DataFrame( + { + "country": ["fr", "fr"], + "csp": ["employee", "employee"], + "activity": ["work", "home"], + "mean_duration_per_pers": [8.0, 6.0], + } + ) + demand_groups = pl.DataFrame( + { + "demand_group_id": [1], + "demand_subgroup_id": [0], + "home_zone_id": [1], + "country": ["fr"], + "csp": ["employee"], + } + ) + destination_saturation = pl.DataFrame( + { + "to": [2, 3], + "activity": ["work", "work"], + "opportunity_capacity": [100.0, 1.0], + "k_saturation_utility": [1.0, 1.0], + "destination_shadow_price": [0.0, 0.0], + } + ) + mode_costs = pl.DataFrame( + { + "from": [1, 1, 2, 3], + "to": [2, 3, 1, 1], + "mode": ["car", "car", "car", "car"], + "cost": [1.0, 2.0, 1.0, 2.0], + "time": [1.0, 1.5, 1.0, 1.5], + } + ) + transport_zones = SimpleNamespace( + get=lambda: pd.DataFrame( + { + "transport_zone_id": [1, 2, 3], + "country": ["fr", "fr", "fr"], + } + ) + ) + activities = [ + SimpleNamespace(name="home", is_anchor=True), + SimpleNamespace(name="work", is_anchor=True), + ] + resolved_activity_parameters = { + "home": SimpleNamespace( + value_of_time=1.0, + country_value_coefficients=None, + arrival_time_rigidity=None, + ), + "work": SimpleNamespace( + value_of_time=2.0, + country_value_coefficients={"fr": 1.0}, + arrival_time_rigidity=None, + ), + } + + result = sample_destination_plans( + activity_sequences=activity_sequences, + activity_durations=activity_durations, + demand_groups=demand_groups, + destination_saturation=destination_saturation, + mode_costs=mode_costs, + transport_zones=transport_zones, + activities=activities, + resolved_activity_parameters=resolved_activity_parameters, + min_activity_time_constant=2.0, + logit_scale=0.5, + update_plan_timings=False, + use_shadow_prices=False, + exploration_seed=123, + top_k=2, + ) + + assert result.select("dest_draw_id").unique().height == 2 + assert result["seq_step_index"].to_list() == [1, 2, 1, 2] + assert result.group_by("dest_draw_id").agg("to").sort("dest_draw_id")["to"].to_list() == [ + [2, 1], + [3, 1], + ] + + def test_sample_active_destination_sequences_keeps_only_active_activity_sequences(tmp_path): class _StubAsset: def __init__(self, df): From c00896aebf5eec117baa4bdf477266a2584c7f55 Mon Sep 17 00:00:00 2001 From: FlxPo Date: Thu, 30 Jul 2026 16:49:06 +0200 Subject: [PATCH 2/2] Finalize destination plan search integration --- docs/source/run_parameters.md | 18 +- ...re_grand_geneve_destination_plan_search.py | 367 ++++++++++++++++++ .../trips/group_day_trips/core/parameters.py | 16 +- .../plans/destination_plan_search.py | 9 +- .../plans/destination_sequences.py | 7 +- pixi.lock | 39 +- pyproject.toml | 5 +- requirements-ci.txt | 3 + requirements-min.txt | 3 + ...13_grand_geneve_destination_plan_search.py | 2 +- .../test_012_destination_sequences.py | 33 +- 11 files changed, 458 insertions(+), 44 deletions(-) create mode 100644 experiments/compare_grand_geneve_destination_plan_search.py diff --git a/docs/source/run_parameters.md b/docs/source/run_parameters.md index 37531228..fdd56c81 100644 --- a/docs/source/run_parameters.md +++ b/docs/source/run_parameters.md @@ -97,18 +97,7 @@ This helps you explain later how a result was produced, which parameters changed ## Try Complete Destination-Plan Search The existing step-by-step destination sampler remains the default. To try the -bounded Rust search, install the optional dependency and enable it explicitly: - -```bash -pip install "mobility-tools[destination-plan-search]" -``` - -Until version `0.1.0` of the sampler is published, contributors can install a -local checkout instead: - -```bash -python -m pip install --no-deps -e ../mobility-destination-sequence-sampler -``` +bounded Rust search, enable it explicitly: ```python from mobility import ( @@ -123,6 +112,11 @@ parameters = GroupDayTripsParameters( ) ``` +The search uses `plan_update.transition_logit_scale` when ranking complete +destination plans. The same scale is then used when choosing between plans. +The destination-sequence `alpha` parameter only applies to the legacy +step-by-step sampler. + This search chooses and ranks complete destination chains together. It returns the best chains found by a bounded search; it does not prove that no better chain was omitted. diff --git a/experiments/compare_grand_geneve_destination_plan_search.py b/experiments/compare_grand_geneve_destination_plan_search.py new file mode 100644 index 00000000..8070167c --- /dev/null +++ b/experiments/compare_grand_geneve_destination_plan_search.py @@ -0,0 +1,367 @@ +"""Compare the legacy and destination-plan-search samplers. + +This script reuses the model definition in ``run_scenarios.py`` and runs it +twice. The only changed parameter is ``use_destination_plan_search``. The +configured mode-sequence search backend is therefore identical in both runs. + +Run from the Mobility repository: + + python experiments/compare_grand_geneve_destination_plan_search.py + +The benchmark covers the complete weekday/default-scenario model run. Results +and timings are written under the Mobility project data folder. +""" + +from __future__ import annotations + +import argparse +import ast +import json +import os +import pathlib +import subprocess +import sys +import time +from typing import Any + +import mobility +import polars as pl + + +SCRIPT_FOLDER = pathlib.Path(__file__).resolve().parent +VARIANTS = { + "legacy": False, + "destination_plan_search": True, +} + + +def parse_args() -> argparse.Namespace: + default_grand_geneve_folder = pathlib.Path( + os.environ.get( + "MOBILITY_GRAND_GENEVE_PROJECT_FOLDER", + SCRIPT_FOLDER.parent.parent / "mobility-grand-geneve", + ) + ) + parser = argparse.ArgumentParser( + description=( + "Compare Grand Genève results and runtime with " + "use_destination_plan_search disabled and enabled." + ) + ) + parser.add_argument( + "--grand-geneve-folder", + type=pathlib.Path, + default=default_grand_geneve_folder, + help=( + "Folder containing the Grand Genève run_scenarios.py file. " + "Defaults to MOBILITY_GRAND_GENEVE_PROJECT_FOLDER or the sibling " + "mobility-grand-geneve repository." + ), + ) + parser.add_argument( + "--order", + choices=["legacy-first", "search-first"], + default="legacy-first", + help="Execution order. Shared upstream inputs may be reused by the second run.", + ) + parser.add_argument( + "--output-folder", + type=pathlib.Path, + default=None, + help=( + "Defaults to /benchmarks/" + "destination-plan-search." + ), + ) + parser.add_argument( + "--rebuild-variant-chain", + action="store_true", + help=( + "Rebuild destination sequences, mode sequences, plan updates, and " + "final outputs while retaining their already-cached upstream inputs." + ), + ) + return parser.parse_args() + + +def load_grand_geneve_setup(grand_geneve_folder: pathlib.Path) -> Any: + """Execute ``run_scenarios.py`` only through its model setup.""" + scenario_script = grand_geneve_folder / "run_scenarios.py" + if not scenario_script.exists(): + raise FileNotFoundError( + f"Grand Genève scenario script not found: {scenario_script}" + ) + source = scenario_script.read_text(encoding="utf-8") + tree = ast.parse(source, filename=str(scenario_script)) + setup_nodes = [] + + for node in tree.body: + setup_nodes.append(node) + if isinstance(node, ast.Assign) and any( + isinstance(target, ast.Name) + and target.id == "pop_group_day_trips" + for target in node.targets + ): + break + else: + raise RuntimeError( + "Could not find `pop_group_day_trips` in run_scenarios.py." + ) + + namespace = { + "__file__": str(scenario_script), + "__name__": "_grand_geneve_benchmark_setup", + } + sys.path.insert(0, str(grand_geneve_folder)) + try: + module = ast.Module(body=setup_nodes, type_ignores=[]) + exec( + compile( + ast.fix_missing_locations(module), + str(scenario_script), + "exec", + ), + namespace, + ) + finally: + sys.path.remove(str(grand_geneve_folder)) + + return namespace["pop_group_day_trips"] + + +def with_destination_plan_search(setup: Any, enabled: bool) -> Any: + """Clone a setup while changing only the destination sampler flag.""" + destination_parameters = setup.parameters.destination_sequences.model_copy( + update={"use_destination_plan_search": enabled} + ) + parameters = setup.parameters.model_copy( + update={"destination_sequences": destination_parameters} + ) + return mobility.PopulationGroupDayTrips( + population=setup.population, + modes=setup.modes, + activities=setup.activities, + surveys=setup.surveys, + scenarios=setup.scenarios, + parameters=parameters, + ) + + +def summarize_plan_steps(plan_steps: pl.LazyFrame) -> dict[str, pl.DataFrame]: + """Build small, weighted summaries from final simulated trips.""" + weighted = plan_steps.with_columns( + weighted_distance=pl.col("n_persons") * pl.col("distance"), + weighted_time=pl.col("n_persons") * pl.col("time"), + weighted_ghg=( + pl.col("n_persons") * pl.col("ghg_emissions_per_trip") + ), + ) + metrics = [ + pl.len().alias("plan_step_rows"), + pl.col("n_persons").sum().alias("weighted_trip_count"), + pl.col("weighted_distance").sum().alias("weighted_distance"), + pl.col("weighted_time").sum().alias("weighted_time"), + pl.col("weighted_ghg").sum().alias("weighted_ghg"), + ] + + return { + "overall": weighted.select(metrics).collect(), + "by_mode": ( + weighted.group_by("mode") + .agg(metrics) + .sort("mode") + .collect() + ), + "by_activity": ( + weighted.group_by("activity") + .agg(metrics) + .sort("activity") + .collect() + ), + } + + +def run_variant( + name: str, + enabled: bool, + variant: Any, + output_folder: pathlib.Path, +) -> dict[str, Any]: + run = variant.run(day_type="weekday", scenario="default") + cached_before = all(path.exists() for path in run.cache_path.values()) + + print(f"\nRunning {name} (use_destination_plan_search={enabled})") + if cached_before: + print(" Final outputs are already cached; runtime is a cache-hit time.") + + started = time.perf_counter() + outputs = run.get() + seconds = time.perf_counter() - started + + summaries = summarize_plan_steps(outputs["plan_steps"]) + variant_folder = output_folder / name + variant_folder.mkdir(parents=True, exist_ok=True) + for summary_name, table in summaries.items(): + table.write_parquet(variant_folder / f"{summary_name}.parquet") + + result = { + "use_destination_plan_search": enabled, + "seconds": seconds, + "cached_before": cached_before, + "run_inputs_hash": run.inputs_hash, + "overall": summaries["overall"].row(0, named=True), + } + print(f" Completed in {seconds:.3f} s") + return result + + +def remove_variant_chain(variant: Any) -> None: + """Remove outputs downstream of destination sampling for a fair rerun.""" + run = variant.run(day_type="weekday", scenario="default") + run.remove() + for state in run.iteration_state_assets: + state.remove() + state.mode_sequences.remove() + state.destination_sequences.remove() + + +def write_comparison(output_folder: pathlib.Path) -> None: + """Write absolute and relative differences between variant summaries.""" + for summary_name, keys in ( + ("overall", []), + ("by_mode", ["mode"]), + ("by_activity", ["activity"]), + ): + legacy = pl.read_parquet( + output_folder / "legacy" / f"{summary_name}.parquet" + ) + search = pl.read_parquet( + output_folder + / "destination_plan_search" + / f"{summary_name}.parquet" + ) + metric_columns = [ + column for column in legacy.columns if column not in keys + ] + + if keys: + comparison = legacy.join( + search, + on=keys, + how="full", + suffix="_search", + coalesce=True, + ) + else: + comparison = pl.concat( + [ + legacy.rename( + {column: f"{column}_legacy" for column in metric_columns} + ), + search.rename( + {column: f"{column}_search" for column in metric_columns} + ), + ], + how="horizontal", + ) + + expressions = [] + for column in metric_columns: + legacy_column = ( + f"{column}_legacy" if not keys else column + ) + search_column = f"{column}_search" + expressions.extend( + [ + ( + pl.col(search_column) - pl.col(legacy_column) + ).alias(f"{column}_difference"), + ( + (pl.col(search_column) - pl.col(legacy_column)) + / pl.col(legacy_column) + ).alias(f"{column}_relative_difference"), + ] + ) + comparison.with_columns(expressions).write_parquet( + output_folder / f"{summary_name}_comparison.parquet" + ) + + +def git_revision(folder: pathlib.Path) -> str | None: + try: + return subprocess.check_output( + ["git", "rev-parse", "HEAD"], + cwd=folder, + text=True, + ).strip() + except (OSError, subprocess.CalledProcessError): + return None + + +def main() -> None: + args = parse_args() + grand_geneve_folder = args.grand_geneve_folder.resolve() + os.chdir(grand_geneve_folder) + setup = load_grand_geneve_setup(grand_geneve_folder) + + output_folder = args.output_folder + if output_folder is None: + output_folder = ( + pathlib.Path(os.environ["MOBILITY_PROJECT_DATA_FOLDER"]) + / "benchmarks" + / "destination-plan-search" + ) + output_folder.mkdir(parents=True, exist_ok=True) + + order = ( + ["legacy", "destination_plan_search"] + if args.order == "legacy-first" + else ["destination_plan_search", "legacy"] + ) + variants = { + name: with_destination_plan_search(setup, enabled) + for name, enabled in VARIANTS.items() + } + if args.rebuild_variant_chain: + for variant in variants.values(): + remove_variant_chain(variant) + + results = { + name: run_variant( + name, + VARIANTS[name], + variants[name], + output_folder, + ) + for name in order + } + write_comparison(output_folder) + + legacy_seconds = results["legacy"]["seconds"] + search_seconds = results["destination_plan_search"]["seconds"] + timings_are_comparable = not any( + result["cached_before"] for result in results.values() + ) + report = { + "execution_order": order, + "mobility_revision": git_revision(SCRIPT_FOLDER.parent), + "grand_geneve_revision": git_revision(grand_geneve_folder), + "variants": results, + "timings_are_comparable": timings_are_comparable, + "speedup_vs_legacy": ( + legacy_seconds / search_seconds + if timings_are_comparable and search_seconds > 0.0 + else None + ), + } + (output_folder / "summary.json").write_text( + json.dumps(report, indent=2), + encoding="utf-8", + ) + + print("\n" + json.dumps(report, indent=2)) + print(f"\nDetailed comparisons: {output_folder}") + + +if __name__ == "__main__": + main() diff --git a/mobility/trips/group_day_trips/core/parameters.py b/mobility/trips/group_day_trips/core/parameters.py index 38f3578c..66ed8d0b 100644 --- a/mobility/trips/group_day_trips/core/parameters.py +++ b/mobility/trips/group_day_trips/core/parameters.py @@ -337,15 +337,6 @@ class GroupDayTripsDestinationSequenceParameters(BaseModel): ), ] - @model_validator(mode="after") - def validate_destination_plan_search(self) -> "GroupDayTripsDestinationSequenceParameters": - """Validate settings required by the bounded destination-plan search.""" - if self.use_destination_plan_search and self.alpha <= 0.0: - raise ValueError( - "alpha must be greater than zero when destination plan search is enabled." - ) - return self - class GroupDayTripsModeSequenceParameters(BaseModel): """Settings used when searching mode sequences.""" @@ -479,8 +470,11 @@ class GroupDayTripsPlanUpdateParameters(BaseModel): Field( default=1.0, ge=0.0, - title="Transition logit scale", - description="Scale applied to plan utilities when choosing a new plan.", + title="Plan choice logit scale", + description=( + "Scale applied to plan utilities when ranking destination plans " + "and choosing a new plan." + ), ), ] transition_utility_pruning_delta: Annotated[ diff --git a/mobility/trips/group_day_trips/plans/destination_plan_search.py b/mobility/trips/group_day_trips/plans/destination_plan_search.py index d5efd901..70e3cfd4 100644 --- a/mobility/trips/group_day_trips/plans/destination_plan_search.py +++ b/mobility/trips/group_day_trips/plans/destination_plan_search.py @@ -3,6 +3,7 @@ from typing import Any import polars as pl +from mobility_destination_sequence_sampler import DestinationPlanSearch from .demand_subgroups import DEMAND_UNIT_COLS, with_demand_subgroup_id @@ -29,14 +30,6 @@ def sample_destination_plans( top_k: int, ) -> pl.DataFrame: """Return complete destination plans found by the bounded Rust search.""" - try: - from mobility_destination_sequence_sampler import DestinationPlanSearch - except ImportError as error: - raise ImportError( - "Destination plan search requires the optional sampler package. " - "Install Mobility with the `destination-plan-search` extra." - ) from error - activity_names = sorted(resolved_activity_parameters) activity_ids = { activity_name: activity_id diff --git a/mobility/trips/group_day_trips/plans/destination_sequences.py b/mobility/trips/group_day_trips/plans/destination_sequences.py index 4fe5e7ce..234bc779 100644 --- a/mobility/trips/group_day_trips/plans/destination_sequences.py +++ b/mobility/trips/group_day_trips/plans/destination_sequences.py @@ -149,6 +149,11 @@ def __init__( if parameters is not None else None ), + "plan_update_transition_logit_scale": ( + parameters.plan_update.transition_logit_scale + if parameters is not None + else None + ), "behavior_change_scope": ( parameters.behavior_change.scope_at(iteration) if parameters is not None @@ -462,7 +467,7 @@ def run( min_activity_time_constant=( parameters.plan_update.min_activity_time_constant ), - logit_scale=parameters.destination_sequences.alpha, + logit_scale=parameters.plan_update.transition_logit_scale, update_plan_timings=( parameters.plan_update.update_plan_timings_from_modeled_travel_times ), diff --git a/pixi.lock b/pixi.lock index bb5dad2c..448a4fad 100644 --- a/pixi.lock +++ b/pixi.lock @@ -144,6 +144,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/65/b6/09b01cdbc15224e2850365192d17b7bdebb8bdbd8780ed221fcdf0d9a515/pandas-3.0.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/67/f3/6cd296376653270ac1b423bb30bd70942d9916b6978c6f40472d6ac038e7/retrying-1.4.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/6b/b2/d17b2722c636d64b4e77ddc68d8d0625719d39f94021be8719a218af4c0a/backports_zstd-1.6.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/71/1e/80ed4e7951eaf2e7b248a2d7b7a261446c4a2b663ddba897aab8f0b947b4/mobility_destination_sequence_sampler-0.1.0.tar.gz - pypi: https://files.pythonhosted.org/packages/77/c7/2342da9830e3e9d4870305ca5d2091d2a83284f2953079b7bdd3b5e029d8/fonttools-4.63.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl - pypi: https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/7b/91/984aca2ec129e2757d1e4e3c81c3fcda9d0f85b74670a094cc443d9ee949/joblib-1.5.3-py3-none-any.whl @@ -351,6 +352,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl - pypi: https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/67/f3/6cd296376653270ac1b423bb30bd70942d9916b6978c6f40472d6ac038e7/retrying-1.4.2-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/71/1e/80ed4e7951eaf2e7b248a2d7b7a261446c4a2b663ddba897aab8f0b947b4/mobility_destination_sequence_sampler-0.1.0.tar.gz - pypi: https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/7a/62/f5221a191a97157d240cf6643747558759126c76ee92f29a3f4aee3197a5/pycryptodomex-3.23.0-cp37-abi3-macosx_10_9_x86_64.whl - pypi: https://files.pythonhosted.org/packages/7b/91/984aca2ec129e2757d1e4e3c81c3fcda9d0f85b74670a094cc443d9ee949/joblib-1.5.3-py3-none-any.whl @@ -570,6 +572,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/87/1b/9e33c09813d65e248f7f773119148a612516a4bea93e9c6f545f78455b7c/wheel-0.47.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/87/2e/8fa7d095f7ab28649ece149118ccbde8286be52037b02ab02fbe52c34601/dash-3.4.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/8b/5a/ba30a81239b909821b3153e303e7def45178bf353da4f72380e6c5e8793b/pytest-9.1.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/8c/ae/da68ccc1e484ae1805c8df0da1e7e248090adf4db935258916db9398db70/mobility_destination_sequence_sampler-0.1.0-cp311-abi3-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/90/bf/297716b3095fe719be20fcf7af1d2b6ab069c38199bbace2469608a69b3a/polars_runtime_32-1.39.3-cp310-abi3-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/93/8c/2e650f2afeb7ee576912636c23ddb621c91ac6a98e66dc8d29c3c69446e1/werkzeug-3.1.8-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/99/9f/795fedf35634f746151ca8839d05681ceb6287fbed6cc1c9bf235f7887c2/kiwisolver-1.5.0-cp312-cp312-macosx_11_0_arm64.whl @@ -793,6 +796,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/e5/2f/a58a4443a4d052a4ea77557478336aefc26c7981f6408d37adba763aa758/matplotlib-3.11.0-cp312-cp312-win_amd64.whl + - pypi: https://files.pythonhosted.org/packages/e6/de/9f25f03f7d30fb85c661aa7d733844e99ef17cfd18a919ae7832fb368b22/mobility_destination_sequence_sampler-0.1.0-cp311-abi3-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ea/b7/0d511af853024241dc3192bea77e4753ea606187bd2dd777a8209a5b01bb/dash_cytoscape-1.0.2.tar.gz - pypi: https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl @@ -5671,8 +5675,8 @@ packages: - pydantic>=2.12,<3 - tenacity>=9,<10 - mobility-mode-sequence-search==0.1.0 + - mobility-destination-sequence-sampler==0.1.0 - truststore>=0.10,<1 ; extra == 'truststore' - - mobility-destination-sequence-sampler==0.1.0 ; extra == 'destination-plan-search' - build>=1.5,<2 ; extra == 'dev' - coverage>=7,<8 ; extra == 'dev' - flake8>=7,<8 ; extra == 'dev' @@ -6720,6 +6724,17 @@ packages: version: 1.6.0 sha256: 1a99710fbb225d459d66def4dc2bb2cd4a9a0bdc8b799fc0621cfdd863be9c93 requires_python: '>=3.10,<3.14' +- pypi: https://files.pythonhosted.org/packages/71/1e/80ed4e7951eaf2e7b248a2d7b7a261446c4a2b663ddba897aab8f0b947b4/mobility_destination_sequence_sampler-0.1.0.tar.gz + name: mobility-destination-sequence-sampler + version: 0.1.0 + sha256: 35b99d772886d4d23643399533eed56442a4f1ef9a997a0b44fbe70e775e5d1d + requires_dist: + - polars>=1.39,<2 + - numpy>=2,<3 ; extra == 'dev' + - psutil>=6,<8 ; extra == 'dev' + - pytest>=8,<10 ; extra == 'dev' + - scipy>=1.15,<2 ; extra == 'dev' + requires_python: '>=3.11' - pypi: https://files.pythonhosted.org/packages/72/4b/9c6acfbe900e5c8698132244c68036b0455bd2169f46e356c83dc0366f11/inflate64-1.0.4-cp312-cp312-macosx_11_0_arm64.whl name: inflate64 version: 1.0.4 @@ -7058,6 +7073,17 @@ packages: - setuptools ; extra == 'dev' - xmlschema ; extra == 'dev' requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/8c/ae/da68ccc1e484ae1805c8df0da1e7e248090adf4db935258916db9398db70/mobility_destination_sequence_sampler-0.1.0-cp311-abi3-macosx_11_0_arm64.whl + name: mobility-destination-sequence-sampler + version: 0.1.0 + sha256: db13f790c5b9ce6abb9cf01f5104f67545b9d3ea53995e5d983477358d4522f9 + requires_dist: + - polars>=1.39,<2 + - numpy>=2,<3 ; extra == 'dev' + - psutil>=6,<8 ; extra == 'dev' + - pytest>=8,<10 ; extra == 'dev' + - scipy>=1.15,<2 ; extra == 'dev' + requires_python: '>=3.11' - pypi: https://files.pythonhosted.org/packages/8d/ab/9893ea9fb066be70ed9074ae543914a618c131ed8dff2da1e08b3a4df4db/pyproj-3.7.2-cp312-cp312-macosx_13_0_x86_64.whl name: pyproj version: 3.7.2 @@ -8382,6 +8408,17 @@ packages: - pyparsing>=3 - python-dateutil>=2.7 requires_python: '>=3.11' +- pypi: https://files.pythonhosted.org/packages/e6/de/9f25f03f7d30fb85c661aa7d733844e99ef17cfd18a919ae7832fb368b22/mobility_destination_sequence_sampler-0.1.0-cp311-abi3-win_amd64.whl + name: mobility-destination-sequence-sampler + version: 0.1.0 + sha256: d5f0233efe5fb6c8f549cab06759e79fa11ede6b886fdd4bd637735287c96c45 + requires_dist: + - polars>=1.39,<2 + - numpy>=2,<3 ; extra == 'dev' + - psutil>=6,<8 ; extra == 'dev' + - pytest>=8,<10 ; extra == 'dev' + - scipy>=1.15,<2 ; extra == 'dev' + requires_python: '>=3.11' - pypi: https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl name: cycler version: 0.12.1 diff --git a/pyproject.toml b/pyproject.toml index 874687c7..3de1d1ad 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,6 +33,7 @@ dependencies = [ "pydantic>=2.12,<3", "tenacity>=9,<10", "mobility-mode-sequence-search==0.1.0", + "mobility-destination-sequence-sampler==0.1.0", ] requires-python = ">=3.11" @@ -56,10 +57,6 @@ truststore = [ "truststore>=0.10,<1", ] -destination-plan-search = [ - "mobility-destination-sequence-sampler==0.1.0", -] - dev = [ "build>=1.5,<2", "coverage>=7,<8", diff --git a/requirements-ci.txt b/requirements-ci.txt index 160c95cc..5ad071f6 100644 --- a/requirements-ci.txt +++ b/requirements-ci.txt @@ -102,6 +102,8 @@ mccabe==0.7.0 # via flake8 mdurl==0.1.2 # via markdown-it-py +mobility-destination-sequence-sampler==0.1.0 + # via mobility-tools (pyproject.toml) mobility-mode-sequence-search==0.1.0 # via mobility-tools (pyproject.toml) multivolumefile==0.2.3 @@ -160,6 +162,7 @@ pluggy==1.6.0 polars==1.39.3 # via # mobility-tools (pyproject.toml) + # mobility-destination-sequence-sampler # mobility-mode-sequence-search polars-runtime-32==1.39.3 # via polars diff --git a/requirements-min.txt b/requirements-min.txt index 84b9b75c..7135d0d3 100644 --- a/requirements-min.txt +++ b/requirements-min.txt @@ -90,6 +90,8 @@ matplotlib==3.10.0 # via mobility-tools (pyproject.toml) mdurl==0.1.2 # via markdown-it-py +mobility-destination-sequence-sampler==0.1.0 + # via mobility-tools (pyproject.toml) mobility-mode-sequence-search==0.1.0 # via mobility-tools (pyproject.toml) multivolumefile==0.2.3 @@ -144,6 +146,7 @@ pluggy==1.6.0 polars==1.39.3 # via # mobility-tools (pyproject.toml) + # mobility-destination-sequence-sampler # mobility-mode-sequence-search polars-runtime-32==1.39.3 # via polars diff --git a/tests/back/integration/test_013_grand_geneve_destination_plan_search.py b/tests/back/integration/test_013_grand_geneve_destination_plan_search.py index e1913a7f..7814b5ed 100644 --- a/tests/back/integration/test_013_grand_geneve_destination_plan_search.py +++ b/tests/back/integration/test_013_grand_geneve_destination_plan_search.py @@ -135,12 +135,12 @@ def test_grand_geneve_destination_plan_search_returns_complete_chains( destination_sequences=GroupDayTripsDestinationSequenceParameters( use_destination_plan_search=True, k_destination_sequences=3, - alpha=0.25, ), plan_update=GroupDayTripsPlanUpdateParameters( update_plan_timings_from_modeled_travel_times=True, use_destination_shadow_prices=True, min_activity_time_constant=2.0, + transition_logit_scale=0.25, ), ) transport_costs = SimpleNamespace( diff --git a/tests/back/unit/domain/group_day_trips/test_012_destination_sequences.py b/tests/back/unit/domain/group_day_trips/test_012_destination_sequences.py index ec5e2f3e..dc9d1cab 100644 --- a/tests/back/unit/domain/group_day_trips/test_012_destination_sequences.py +++ b/tests/back/unit/domain/group_day_trips/test_012_destination_sequences.py @@ -3,7 +3,6 @@ import pandas as pd import polars as pl -import pytest from mobility.trips.group_day_trips import ( GroupDayTripsDestinationSequenceParameters, @@ -28,12 +27,34 @@ def test_destination_plan_search_flag_is_disabled_by_default(): assert parameters.use_destination_plan_search is False -def test_destination_plan_search_requires_positive_alpha(): - with pytest.raises(ValueError, match="alpha must be greater than zero"): - GroupDayTripsDestinationSequenceParameters( +def test_destination_plan_search_does_not_use_legacy_alpha(): + parameters = GroupDayTripsDestinationSequenceParameters( + use_destination_plan_search=True, + alpha=0.0, + ) + + assert parameters.alpha == 0.0 + + +def test_plan_choice_logit_scale_is_part_of_destination_cache_key(tmp_path): + parameters = GroupDayTripsParameters( + destination_sequences=GroupDayTripsDestinationSequenceParameters( use_destination_plan_search=True, - alpha=0.0, - ) + ), + plan_update=GroupDayTripsPlanUpdateParameters( + transition_logit_scale=0.25, + ), + ) + destination_sequences = DestinationSequences( + is_weekday=True, + iteration=1, + base_folder=_make_local_tmp_path(tmp_path, "plan_choice_logit_scale"), + activities=[], + resolved_activity_parameters={}, + parameters=parameters, + ) + + assert destination_sequences.inputs["plan_update_transition_logit_scale"] == 0.25 def test_destination_plan_search_returns_mobility_sequence_rows():