diff --git a/docs/source/api_reference.md b/docs/source/api_reference.md index f0ebd113..09cd1231 100644 --- a/docs/source/api_reference.md +++ b/docs/source/api_reference.md @@ -338,6 +338,10 @@ mobility.ParameterValue.by_scenario_and_iteration( See [scenarios](scenarios.md). +Use `ParameterValue.by_population_segment(...)` when named population +segments need different values. The default and segment values can themselves +vary by scenario or iteration. See [run parameters](run_parameters.md). + ## Network Modifiers Network modifiers describe project-specific changes to road-network assumptions: @@ -393,7 +397,9 @@ Main parameter objects: - `mobility.GroupDayTripsBehaviorChangeParameters` - `mobility.GroupDayTripsDestinationSequenceParameters` - `mobility.GroupDayTripsModeSequenceParameters` +- `mobility.GroupDayTripsDemandGroupParameters` - `mobility.GroupDayTripsPlanUpdateParameters` +- `mobility.PopulationSegment` - `mobility.BehaviorChangePhase` - `mobility.BehaviorChangeScope` diff --git a/docs/source/population.md b/docs/source/population.md index 628ade2a..141619e4 100644 --- a/docs/source/population.md +++ b/docs/source/population.md @@ -30,6 +30,45 @@ Typical computational use: There is no universal sample size. The useful size depends on the territory, the indicators you report, and how much variability you can accept. +### Population segments + +Population segments let mode-cost assumptions vary for selected people without +running a separate model for each group. Define each segment on the population: + +```python +population = mobility.Population( + transport_zones, + sample_size=1_000, + population_segments=[ + mobility.PopulationSegment(name="pupils", csp="8a"), + mobility.PopulationSegment( + name="localist_pupils", + csp="8a", + share=0.30, + ), + ], +) +``` + +A segment can select people by `country`, `csp`, `home_zone_id`, +`city_category`, or `n_cars`. Several selectors can be combined. Here every +pupil belongs to `pupils`, while 30% also belong to `localist_pupils`; the +remaining 70% retain the default value. + +Use `ParameterValue.by_population_segment(...)` on a mode's cost constant, +cost of time, or cost of distance. Segment values may also vary by scenario or +iteration. When several segment values match, Mobility uses the most specific +selector and reports ambiguous definitions as errors. + +Segment shares are represented as weighted demand subgroups. They are split +before `max_persons_per_demand_subgroup` is applied, and their weights still +sum to the original population. Mobility deduplicates identical coefficient +combinations and searches all distinct profiles together. Enable complete +destination-plan search when the segment-specific costs should also affect +destination ranking. + +See [run parameters](run_parameters.md) for a complete example. + ## Surveys For a French study area, use the EMP survey: diff --git a/docs/source/run_parameters.md b/docs/source/run_parameters.md index fdd56c81..26540366 100644 --- a/docs/source/run_parameters.md +++ b/docs/source/run_parameters.md @@ -120,3 +120,85 @@ 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. + +## Vary Mode Costs Between Population Segments + +Define population segments once on the population, then refer to +their names from generalized-cost parameters. A segment can select on +`country`, `csp`, `home_zone_id`, `city_category`, and `n_cars`. + +```python +population = mobility.Population( + transport_zones, + sample_size=1_000, + population_segments=[ + mobility.PopulationSegment( + name="pupils", + csp="8a", + ), + mobility.PopulationSegment( + name="localist_pupils", + csp="8a", + share=0.30, + ), + mobility.PopulationSegment( + name="zone_30_pupils", + csp="8a", + home_zone_id=30, + ), + ], +) + +parameters = mobility.GroupDayTripsParameters( + demand_groups=mobility.GroupDayTripsDemandGroupParameters( + max_persons_per_demand_subgroup=50, + ), + destination_sequences=mobility.GroupDayTripsDestinationSequenceParameters( + use_destination_plan_search=True, + ), +) + +pupil_value_of_time = mobility.ParameterValue.by_population_segment( + default=mobility.ParameterValue.by_iteration({1: 20.0, 5: 24.0}), + segment_values={ + "localist_pupils": mobility.ParameterValue.by_scenario( + default=10.0, + school_policy=8.0, + ), + "zone_30_pupils": 6.0, + }, +) + +car = mobility.CarMode( + transport_zones, + generalized_cost_parameters=mobility.GeneralizedCostParameters( + cost_constant=0.0, + cost_of_time=mobility.CostOfTimeParameters( + intercept=pupil_value_of_time, + max_value=pupil_value_of_time, + ), + cost_of_distance=0.1, + ), +) +``` + +The most specific matching segment value is used. In this example, +`zone_30_pupils` takes precedence over `localist_pupils` for pupils living in +zone 30. Mobility raises an error when two matching values are equally +specific and neither selector contains the other. + +`share=0.30` creates a 30% subgroup and a 70% complement in every matching +demand group. The share split happens before +`max_persons_per_demand_subgroup`, so both parts can then be split further to +respect the size limit. Population weights are preserved. + +Scenario and iteration values are resolved inside each segment. A segment +value replaces the default value: it does not inherit the default iteration +curve. Define an iteration curve explicitly inside the segment value when the +segment should also change over time. + +Mobility deduplicates identical coefficient combinations into utility +profiles. The same profile is used to rank destination plans, search mode +sequences, and compute final plan utility. Segment shares do not start separate +model runs: Mobility resolves each distinct segment-membership combination once +and searches all resulting profiles together. diff --git a/mobility/__init__.py b/mobility/__init__.py index 2037f092..39e1e846 100644 --- a/mobility/__init__.py +++ b/mobility/__init__.py @@ -42,6 +42,7 @@ BehaviorChangeScope, GroupDayTripsActivitySequenceParameters, GroupDayTripsBehaviorChangeParameters, + GroupDayTripsDemandGroupParameters, GroupDayTripsDestinationSequenceParameters, GroupDayTripsModeSequenceParameters, GroupDayTripsOutputParameters, @@ -74,7 +75,12 @@ GTFSSources, ) from .transport.modes.core import IntermodalTransfer, ModeRegistry -from .runtime.parameter_values import DEFAULT_SCENARIO, ParameterValue, SensitivityValue +from .runtime.parameter_values import ( + DEFAULT_SCENARIO, + ParameterValue, + SensitivityValue, +) +from .runtime.population_segments import PopulationSegment from .runtime.project_cache import ProjectCache from .runtime.scenarios import Scenario, Scenarios diff --git a/mobility/population/population.py b/mobility/population/population.py index b8f63e6d..61c28a19 100644 --- a/mobility/population/population.py +++ b/mobility/population/population.py @@ -7,7 +7,7 @@ import numpy as np import pandas as pd import shortuuid -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict, Field, model_validator from typing import Annotated from mobility.countries import normalize_country_codes @@ -15,6 +15,10 @@ from mobility.population.city_legal_population import CityLegalPopulation from mobility.population.countries import available_population_groups from mobility.runtime.assets.file_asset import FileAsset +from mobility.runtime.population_segments import ( + PopulationSegment, + validate_population_segments, +) from mobility.spatial.admin_units import FrenchAdminUnits @@ -26,12 +30,16 @@ def __init__( transport_zones, sample_size: int | None = None, switzerland_census: CensusLocalizedIndividuals = None, + population_segments: list[PopulationSegment] | None = None, parameters: "PopulationParameters" | None = None, ): parameters = self.prepare_parameters( parameters=parameters, parameters_cls=PopulationParameters, - explicit_args={"sample_size": sample_size}, + explicit_args={ + "sample_size": sample_size, + "population_segments": population_segments, + }, required_fields=["sample_size"], owner_name="Population", ) @@ -47,6 +55,11 @@ def __init__( } super().__init__(inputs, cache_path) + @property + def population_segments(self) -> list[PopulationSegment]: + """Return the named segments defined for this population.""" + return self.parameters.population_segments + def get_cached_asset(self) -> pd.DataFrame: logging.info("Population already prepared. Reusing the files : " + str(self.cache_path)) return self.cache_path @@ -189,3 +202,19 @@ class PopulationParameters(BaseModel): description="Number of inhabitants to sample within the selected transport zones.", ), ] + population_segments: Annotated[ + list[PopulationSegment], + Field( + default_factory=list, + title="Population segments", + description=( + "Named population subsets that can receive specific model " + "parameter values." + ), + ), + ] + + @model_validator(mode="after") + def validate_segments(self) -> "PopulationParameters": + validate_population_segments(self.population_segments) + return self diff --git a/mobility/runtime/__init__.py b/mobility/runtime/__init__.py index 59dfaadb..a892218b 100644 --- a/mobility/runtime/__init__.py +++ b/mobility/runtime/__init__.py @@ -10,6 +10,7 @@ collect_sensitivity_values, resolve_parameter_values, ) +from .population_segments import PopulationSegment from .scenarios import ( Scenario, ScenarioParameterChange, @@ -21,6 +22,7 @@ "DEFAULT_SCENARIO", "DEFAULT_SENSITIVITY_CASE", "ParameterValue", + "PopulationSegment", "SensitivityCase", "SensitivityValue", "Scenario", diff --git a/mobility/runtime/parameter_values.py b/mobility/runtime/parameter_values.py index c24ae154..d5391aaf 100644 --- a/mobility/runtime/parameter_values.py +++ b/mobility/runtime/parameter_values.py @@ -270,6 +270,23 @@ def by_scenario_and_iteration( } ) + @classmethod + def by_population_segment( + cls, + *, + default: Any, + segment_values: dict[str, Any], + ) -> "PopulationSegmentValue": + """Return a default value with replacements for named segments. + + Each value may itself vary by scenario or iteration. A segment value + replaces the default value entirely. + """ + return PopulationSegmentValue( + default=default, + segment_values=segment_values, + ) + @model_validator(mode="after") def validate_values(self) -> "ParameterValue": """Validate scenario names and iteration points.""" @@ -393,6 +410,25 @@ def _copy_plain_value(value: Any) -> Any: return value +class PopulationSegmentValue(BaseModel): + """A default parameter value plus replacements for named segments.""" + + model_config = ConfigDict(arbitrary_types_allowed=True, frozen=True) + + default: Any + segment_values: dict[str, Any] + + @model_validator(mode="after") + def validate_segment_values(self) -> "PopulationSegmentValue": + if not self.segment_values: + raise ValueError( + "ParameterValue.by_population_segment needs at least one segment value." + ) + if any(not name for name in self.segment_values): + raise ValueError("Population segment names should not be empty.") + return self + + def _contains_parameter_value(value: Any) -> bool: """Return whether a value contains a ParameterValue object.""" seen = set() diff --git a/mobility/runtime/population_segments.py b/mobility/runtime/population_segments.py new file mode 100644 index 00000000..358fa9fd --- /dev/null +++ b/mobility/runtime/population_segments.py @@ -0,0 +1,209 @@ +from __future__ import annotations + +from copy import deepcopy +from typing import Any, Annotated + +from pydantic import BaseModel, ConfigDict, Field, model_validator + +from .parameter_values import PopulationSegmentValue + + +SEGMENT_SELECTOR_FIELDS = ( + "country", + "csp", + "home_zone_id", + "city_category", + "n_cars", +) + + +class PopulationSegment(BaseModel): + """A named population subset selected from demand-group attributes.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + name: Annotated[str, Field(min_length=1)] + share: Annotated[float, Field(default=1.0, gt=0.0, le=1.0)] + country: str | list[str] | None = None + csp: str | list[str] | None = None + home_zone_id: int | list[int] | None = None + city_category: str | list[str] | None = None + n_cars: str | int | list[str | int] | None = None + + @model_validator(mode="after") + def validate_selector(self) -> "PopulationSegment": + if not self.selector: + raise ValueError( + f"Population segment '{self.name}' needs at least one selector." + ) + return self + + @property + def selector(self) -> dict[str, tuple[Any, ...]]: + """Return normalized selector values by demand-group column.""" + selector = {} + for field in SEGMENT_SELECTOR_FIELDS: + value = getattr(self, field) + if value is None: + continue + values = value if isinstance(value, list) else [value] + if not values: + raise ValueError( + f"Population segment '{self.name}' selector '{field}' is empty." + ) + selector[field] = tuple(values) + return selector + +def validate_population_segments( + segments: list[PopulationSegment], +) -> list[PopulationSegment]: + """Validate segment names and return the definitions unchanged.""" + names = [segment.name for segment in segments] + duplicates = sorted({name for name in names if names.count(name) > 1}) + if duplicates: + raise ValueError( + "Population segment names should be unique. Duplicates: " + + ", ".join(duplicates) + ) + return segments + + +def resolve_population_segment_values( + value: Any, + *, + memberships: set[str], + segments: list[PopulationSegment], +) -> Any: + """Resolve segment-aware values recursively for one demand subgroup.""" + definitions = {segment.name: segment for segment in segments} + + if isinstance(value, PopulationSegmentValue): + unknown = sorted(set(value.segment_values) - set(definitions)) + if unknown: + raise ValueError( + "Parameter values refer to undefined population segments: " + + ", ".join(unknown) + ) + matching = [ + definitions[name] + for name in value.segment_values + if name in memberships + ] + selected = _most_specific_segment(matching) + selected_value = ( + value.default + if selected is None + else value.segment_values[selected.name] + ) + return resolve_population_segment_values( + selected_value, + memberships=memberships, + segments=segments, + ) + + if isinstance(value, BaseModel): + data = { + field: resolve_population_segment_values( + getattr(value, field), + memberships=memberships, + segments=segments, + ) + for field in value.__class__.model_fields + } + return value.__class__.model_validate(data) + if isinstance(value, dict): + return { + key: resolve_population_segment_values( + item, + memberships=memberships, + segments=segments, + ) + for key, item in value.items() + } + if isinstance(value, list): + return [ + resolve_population_segment_values( + item, + memberships=memberships, + segments=segments, + ) + for item in value + ] + if isinstance(value, tuple): + return tuple( + resolve_population_segment_values( + item, + memberships=memberships, + segments=segments, + ) + for item in value + ) + return deepcopy(value) + + +def population_segment_defaults(value: Any) -> Any: + """Replace all segment-aware values with their global defaults.""" + if isinstance(value, PopulationSegmentValue): + return population_segment_defaults(value.default) + if isinstance(value, BaseModel): + return value.__class__.model_validate( + { + field: population_segment_defaults(getattr(value, field)) + for field in value.__class__.model_fields + } + ) + if isinstance(value, dict): + return {key: population_segment_defaults(item) for key, item in value.items()} + if isinstance(value, list): + return [population_segment_defaults(item) for item in value] + if isinstance(value, tuple): + return tuple(population_segment_defaults(item) for item in value) + return deepcopy(value) + + +def _most_specific_segment( + matching: list[PopulationSegment], +) -> PopulationSegment | None: + if not matching: + return None + + maximal = [ + candidate + for candidate in matching + if not any( + other.name != candidate.name and _is_more_specific(other, candidate) + for other in matching + ) + ] + if len(maximal) != 1: + names = ", ".join(sorted(segment.name for segment in maximal)) + raise ValueError( + "Population segment values are ambiguous for this demand subgroup: " + f"{names}. Add a more specific segment or remove one value." + ) + return maximal[0] + + +def _is_more_specific( + candidate: PopulationSegment, + other: PopulationSegment, +) -> bool: + candidate_selector = candidate.selector + other_selector = other.selector + if not set(candidate_selector).issuperset(other_selector): + return False + selector_is_strict = set(candidate_selector) != set(other_selector) + for field, other_values in other_selector.items(): + candidate_values = set(candidate_selector[field]) + if not candidate_values.issubset(other_values): + return False + selector_is_strict = ( + selector_is_strict or candidate_values != set(other_values) + ) + + if selector_is_strict: + return True + + # With identical selectors, a partial-share membership is a subset of the + # matching full segment. Two partial shares remain ambiguous. + return candidate.share < 1.0 and other.share == 1.0 diff --git a/mobility/transport/costs/parameters/cost_of_time_parameters.py b/mobility/transport/costs/parameters/cost_of_time_parameters.py index 6c9c4cd4..9106513b 100644 --- a/mobility/transport/costs/parameters/cost_of_time_parameters.py +++ b/mobility/transport/costs/parameters/cost_of_time_parameters.py @@ -4,7 +4,11 @@ from numpy.typing import NDArray from pydantic import BaseModel, ConfigDict, Field, model_validator -from mobility.runtime.parameter_values import ParameterValue, SensitivityValue +from mobility.runtime.parameter_values import ( + ParameterValue, + PopulationSegmentValue, + SensitivityValue, +) class CostOfTimeParameters(BaseModel): @@ -12,10 +16,22 @@ class CostOfTimeParameters(BaseModel): model_config = ConfigDict(extra="forbid") - intercept: Annotated[float | ParameterValue | SensitivityValue, Field(default=20.0)] - breaks: Annotated[list[float] | ParameterValue | SensitivityValue, Field(default_factory=lambda: [0.0, 10000000.0])] - slopes: Annotated[list[float] | ParameterValue | SensitivityValue, Field(default_factory=lambda: [0.0])] - max_value: Annotated[float | ParameterValue | SensitivityValue, Field(default=20.0)] + intercept: Annotated[ + float | ParameterValue | SensitivityValue | PopulationSegmentValue, + Field(default=20.0), + ] + breaks: Annotated[ + list[float] | ParameterValue | SensitivityValue | PopulationSegmentValue, + Field(default_factory=lambda: [0.0, 10000000.0]), + ] + slopes: Annotated[ + list[float] | ParameterValue | SensitivityValue | PopulationSegmentValue, + Field(default_factory=lambda: [0.0]), + ] + max_value: Annotated[ + float | ParameterValue | SensitivityValue | PopulationSegmentValue, + Field(default=20.0), + ] country_coefficients: Annotated[dict[str, float], Field(default_factory=dict)] @@ -29,7 +45,8 @@ def validate_breaks_and_slopes(self) -> "CostOfTimeParameters": Raises: ValueError: If slope count does not match break count minus one. """ - if isinstance(self.breaks, (ParameterValue, SensitivityValue)) or isinstance(self.slopes, (ParameterValue, SensitivityValue)): + unresolved_types = (ParameterValue, SensitivityValue, PopulationSegmentValue) + if isinstance(self.breaks, unresolved_types) or isinstance(self.slopes, unresolved_types): return self if len(self.slopes) != len(self.breaks) - 1: diff --git a/mobility/transport/costs/parameters/generalized_cost_parameters.py b/mobility/transport/costs/parameters/generalized_cost_parameters.py index 79c15f52..3987e716 100644 --- a/mobility/transport/costs/parameters/generalized_cost_parameters.py +++ b/mobility/transport/costs/parameters/generalized_cost_parameters.py @@ -2,7 +2,11 @@ from pydantic import BaseModel, ConfigDict, Field -from mobility.runtime.parameter_values import ParameterValue, SensitivityValue +from mobility.runtime.parameter_values import ( + ParameterValue, + PopulationSegmentValue, + SensitivityValue, +) from mobility.transport.costs.parameters.cost_of_time_parameters import CostOfTimeParameters @@ -11,6 +15,15 @@ class GeneralizedCostParameters(BaseModel): model_config = ConfigDict(extra="forbid") - cost_constant: Annotated[float | ParameterValue | SensitivityValue, Field(default=0.0)] - cost_of_time: Annotated[CostOfTimeParameters, Field(default_factory=CostOfTimeParameters)] - cost_of_distance: Annotated[float | ParameterValue | SensitivityValue, Field(default=0.0)] + cost_constant: Annotated[ + float | ParameterValue | SensitivityValue | PopulationSegmentValue, + Field(default=0.0), + ] + cost_of_time: Annotated[ + CostOfTimeParameters | PopulationSegmentValue, + Field(default_factory=CostOfTimeParameters), + ] + cost_of_distance: Annotated[ + float | ParameterValue | SensitivityValue | PopulationSegmentValue, + Field(default=0.0), + ] diff --git a/mobility/transport/costs/transport_costs.py b/mobility/transport/costs/transport_costs.py index 2c718fa0..33ba7b74 100644 --- a/mobility/transport/costs/transport_costs.py +++ b/mobility/transport/costs/transport_costs.py @@ -6,8 +6,14 @@ import polars as pl -from mobility.runtime.parameter_values import SensitivityCase from mobility.runtime.assets.file_asset import FileAsset +from mobility.runtime.assets.in_memory_asset import InMemoryAsset +from mobility.runtime.parameter_values import SensitivityCase +from mobility.runtime.population_segments import ( + PopulationSegment, + population_segment_defaults, + resolve_population_segment_values, +) from mobility.transport.costs.od_flows_asset import VehicleODFlowsAsset from mobility.transport.costs.road_flow_manager import RoadFlowManager from mobility.transport.costs.travel_costs_asset import TravelCostsBase @@ -133,6 +139,12 @@ def _build_full_detail_costs(self) -> pl.DataFrame: for mode in modes: generalized_cost = mode.inputs["generalized_cost"] + generalized_cost = self._resolved_generalized_cost( + generalized_cost, + memberships=set(), + population_segments=[], + use_defaults=True, + ) gc = pl.DataFrame( generalized_cost.get( ["cost", "distance", "time"], @@ -159,6 +171,148 @@ def _build_full_detail_costs(self) -> pl.DataFrame: pl.col("to").cast(pl.Int32), ) + def get_utility_profiles( + self, + demand_groups: pl.DataFrame, + population_segments: list[PopulationSegment], + ) -> tuple[pl.DataFrame, dict[int, list[InMemoryAsset]]]: + """Assign compact generalized-cost profiles to demand subgroups. + + Demand subgroups with identical resolved parameters share one profile. + The returned table preserves the demand-unit columns and adds + ``utility_profile_id``. + """ + required = { + "demand_group_id", + "demand_subgroup_id", + "population_segments", + } + missing = sorted(required - set(demand_groups.columns)) + if missing: + raise ValueError( + "Utility profiles need demand-group columns: " + ", ".join(missing) + ) + + profile_ids: dict[tuple[str, ...], int] = {} + profile_id_by_memberships: dict[tuple[str, ...], int] = {} + profiles: dict[int, list[InMemoryAsset]] = {} + assignments = [] + rows = demand_groups.select(sorted(required)).iter_rows(named=True) + for row in rows: + memberships_key = tuple(sorted(set(row["population_segments"] or []))) + profile_id = profile_id_by_memberships.get(memberships_key) + if profile_id is None: + resolved_assets = [ + self._resolved_generalized_cost( + mode.inputs["generalized_cost"], + memberships=set(memberships_key), + population_segments=population_segments, + ) + for mode in self.modes + ] + profile_key = tuple(asset.inputs_hash for asset in resolved_assets) + profile_id = profile_ids.get(profile_key) + if profile_id is None: + profile_id = len(profile_ids) + profile_ids[profile_key] = profile_id + profiles[profile_id] = resolved_assets + profile_id_by_memberships[memberships_key] = profile_id + assignments.append( + { + "demand_group_id": row["demand_group_id"], + "demand_subgroup_id": row["demand_subgroup_id"], + "utility_profile_id": profile_id, + } + ) + + assignment_schema = { + "demand_group_id": pl.UInt32, + "demand_subgroup_id": pl.UInt32, + "utility_profile_id": pl.UInt32, + } + return pl.DataFrame(assignments, schema=assignment_schema), profiles + + def get_profile_costs_by_od_and_mode( + self, + profiles: dict[int, list[InMemoryAsset]], + metrics: list[str], + ) -> pl.DataFrame: + """Compute OD-by-mode costs once for every distinct utility profile.""" + profile_costs = [] + default_generalized_costs = [ + self._resolved_generalized_cost( + mode.inputs["generalized_cost"], + memberships=set(), + population_segments=[], + use_defaults=True, + ) + for mode in self.modes + ] + default_costs = self.get_costs_by_od_and_mode( + metrics, + detail_distances=False, + ) + + # Cache by generalized-cost asset, not by population profile. Profiles + # that inherit one mode's default coefficients reuse its existing OD + # rows, and profiles with the same override compute that mode only once. + costs_by_generalized_cost = { + generalized_cost.inputs_hash: default_costs + .filter(pl.col("mode") == mode.inputs["parameters"].name) + .drop("mode") + for mode, generalized_cost in zip( + self.modes, + default_generalized_costs, + ) + } + for profile_id, generalized_costs in profiles.items(): + for mode, generalized_cost in zip(self.modes, generalized_costs): + costs = costs_by_generalized_cost.get(generalized_cost.inputs_hash) + if costs is None: + costs = pl.DataFrame( + generalized_cost.get( + list(metrics), + congestion=self.inputs["congestion"], + detail_distances=False, + road_flow_asset=self.inputs["road_flow_asset"], + ) + ) + costs_by_generalized_cost[generalized_cost.inputs_hash] = costs + profile_costs.append( + costs.with_columns( + utility_profile_id=pl.lit(profile_id, dtype=pl.UInt32), + mode=pl.lit(mode.inputs["parameters"].name), + ) + ) + return pl.concat(profile_costs, how="diagonal") + + @staticmethod + def _resolved_generalized_cost( + generalized_cost: InMemoryAsset, + *, + memberships: set[str], + population_segments: list[PopulationSegment], + use_defaults: bool = False, + ) -> InMemoryAsset: + inputs = ( + population_segment_defaults(generalized_cost.inputs) + if use_defaults + else resolve_population_segment_values( + generalized_cost.inputs, + memberships=memberships, + segments=population_segments, + ) + ) + if inputs == generalized_cost.inputs: + return generalized_cost + clone = generalized_cost.__class__.__new__(generalized_cost.__class__) + clone.__dict__ = dict(generalized_cost.__dict__) + clone.inputs = inputs + clone.inputs_hash = clone.compute_inputs_hash() + for name, value in inputs.items(): + setattr(clone, name, value) + return clone + def _ghg_emissions_per_trip_expr(self, columns: list[str], modes) -> pl.Expr: """Compute per-trip GHG emissions from detailed distance columns.""" expressions = [] diff --git a/mobility/transport/modes/carpool/detailed/detailed_carpool_generalized_cost.py b/mobility/transport/modes/carpool/detailed/detailed_carpool_generalized_cost.py index a00f74b4..2251e55b 100644 --- a/mobility/transport/modes/carpool/detailed/detailed_carpool_generalized_cost.py +++ b/mobility/transport/modes/carpool/detailed/detailed_carpool_generalized_cost.py @@ -3,12 +3,14 @@ import pandas as pd import numpy as np from typing import Annotated -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict, Field, field_validator from mobility.runtime.assets.in_memory_asset import InMemoryAsset +from mobility.runtime.parameter_values import PopulationSegmentValue from mobility.transport.costs.od_flows_asset import VehicleODFlowsAsset from mobility.transport.costs.parameters.cost_of_time_parameters import CostOfTimeParameters + class DetailedCarpoolGeneralizedCost(InMemoryAsset): def __init__(self, travel_costs, parameters): @@ -109,8 +111,14 @@ class DetailedCarpoolGeneralizedCostParameters(BaseModel): number_persons: Annotated[int, Field(default=2, ge=1)] - car_cost_of_time: Annotated[CostOfTimeParameters, Field(default_factory=CostOfTimeParameters)] - carpooling_cost_of_time: Annotated[CostOfTimeParameters, Field(default_factory=CostOfTimeParameters)] + car_cost_of_time: Annotated[ + CostOfTimeParameters | PopulationSegmentValue, + Field(default_factory=CostOfTimeParameters), + ] + carpooling_cost_of_time: Annotated[ + CostOfTimeParameters | PopulationSegmentValue, + Field(default_factory=CostOfTimeParameters), + ] cost_of_time_od_coeffs: Annotated[list[dict[str, list[str] | float]], Field( default_factory=lambda: [{ @@ -120,11 +128,26 @@ class DetailedCarpoolGeneralizedCostParameters(BaseModel): }] )] - car_cost_of_distance: Annotated[float, Field(default=0.1, ge=0.0)] - carpooling_cost_of_distance: Annotated[float, Field(default=0.05, ge=0.0)] + car_cost_of_distance: Annotated[ + float | PopulationSegmentValue, Field(default=0.1) + ] + carpooling_cost_of_distance: Annotated[ + float | PopulationSegmentValue, Field(default=0.05) + ] + + car_cost_constant: Annotated[float | PopulationSegmentValue, Field(default=0.0)] + carpooling_cost_constant: Annotated[ + float | PopulationSegmentValue, Field(default=0.0) + ] - car_cost_constant: Annotated[float, Field(default=0.0)] - carpooling_cost_constant: Annotated[float, Field(default=0.0)] + @field_validator("car_cost_of_distance", "carpooling_cost_of_distance") + @classmethod + def validate_non_negative_distance_cost( + cls, value: float | PopulationSegmentValue + ) -> float | PopulationSegmentValue: + if isinstance(value, (int, float)) and value < 0.0: + raise ValueError("Distance costs must be non-negative.") + return value revenue_distance_local_admin_units_ids: Annotated[list[str], Field(default_factory=list)] revenue_distance_r0: Annotated[float, Field(default=1.5, ge=0.0)] diff --git a/mobility/trips/group_day_trips/core/run.py b/mobility/trips/group_day_trips/core/run.py index b12f782c..fb037873 100644 --- a/mobility/trips/group_day_trips/core/run.py +++ b/mobility/trips/group_day_trips/core/run.py @@ -263,6 +263,7 @@ def _build_iteration_state_assets( sensitivity_case=sensitivity_case, transport_zones=population.transport_zones, transport_costs=resolved_transport_costs, + population_segments=population.population_segments, parameters=parameters, ) mode_sequences = ModeSequences( @@ -272,6 +273,7 @@ def _build_iteration_state_assets( previous_mode_sequences=previous_mode_sequences, destination_sequences=destination_sequences, transport_costs=resolved_transport_costs, + population_segments=population.population_segments, working_folder=base_folder, parameters=parameters, ) diff --git a/mobility/trips/group_day_trips/iterations/iteration_assets.py b/mobility/trips/group_day_trips/iterations/iteration_assets.py index e7466c02..87fbb74c 100644 --- a/mobility/trips/group_day_trips/iterations/iteration_assets.py +++ b/mobility/trips/group_day_trips/iterations/iteration_assets.py @@ -587,6 +587,8 @@ def __init__( n_iter_per_cost_update=n_iter_per_cost_update, ) self.modes = self.transport_costs.modes + self._profile_costs_cache = None + self._profile_costs_cache_key = None inputs = { "version": 2, "is_weekday": is_weekday, @@ -656,6 +658,34 @@ def get_costs_by_od(self, metrics: list) -> pl.DataFrame: costs = costs.with_columns((pl.col("prob") * pl.col("cost")).alias("cost")) return costs.group_by(["from", "to"]).agg(pl.col("cost").sum()) + def get_utility_profiles(self, demand_groups, population_segments): + """Delegate compact utility-profile construction to transport costs.""" + return self.transport_costs.get_utility_profiles( + demand_groups, + population_segments, + ) + + def get_profile_costs_by_od_and_mode(self, profiles, metrics): + """Return profile-specific costs with this iteration's congestion state.""" + cache_key = tuple( + (profile_id, tuple(asset.inputs_hash for asset in assets)) + for profile_id, assets in sorted(profiles.items()) + ) + if self._profile_costs_cache_key != cache_key: + effective_transport_costs = self.transport_costs.asset_for_road_flows( + self.congestion_flows.get() + ) + self._profile_costs_cache = ( + effective_transport_costs.get_profile_costs_by_od_and_mode( + profiles, + ["cost", "distance", "time"], + ) + ) + self._profile_costs_cache_key = cache_key + return self._profile_costs_cache.select( + ["from", "to", "mode", "utility_profile_id"] + list(metrics) + ) + class IterationStateAsset(FileAsset): """Cached model state after one completed behavior update iteration.""" diff --git a/mobility/trips/group_day_trips/iterations/iterations.py b/mobility/trips/group_day_trips/iterations/iterations.py index 1de7e4d8..9fac8209 100644 --- a/mobility/trips/group_day_trips/iterations/iterations.py +++ b/mobility/trips/group_day_trips/iterations/iterations.py @@ -55,6 +55,7 @@ def destination_sequences( destination_saturation: pl.DataFrame | None = None, demand_groups: pl.DataFrame | None = None, costs: pl.DataFrame | None = None, + population_segments: list[Any] | None = None, parameters: Any = None, seed: int | None = None, ) -> DestinationSequences: @@ -72,6 +73,7 @@ def destination_sequences( destination_saturation=destination_saturation, demand_groups=demand_groups, costs=costs, + population_segments=population_segments, parameters=parameters, seed=seed, ) @@ -105,6 +107,7 @@ def mode_sequences( *, destination_sequences: DestinationSequences, transport_costs: Any = None, + population_segments: list[Any] | None = None, parameters: Any = None, ) -> ModeSequences: """Return the mode-sequences asset for this iteration.""" @@ -114,6 +117,7 @@ def mode_sequences( base_folder=self.iterations.folder_paths["modes"], destination_sequences=destination_sequences, transport_costs=transport_costs, + population_segments=population_segments, working_folder=self.iterations.base_folder, parameters=parameters, ) diff --git a/mobility/trips/group_day_trips/plans/demand_subgroups.py b/mobility/trips/group_day_trips/plans/demand_subgroups.py index c19c53a7..df0fcaa8 100644 --- a/mobility/trips/group_day_trips/plans/demand_subgroups.py +++ b/mobility/trips/group_day_trips/plans/demand_subgroups.py @@ -2,6 +2,7 @@ import polars as pl +from mobility.runtime.population_segments import PopulationSegment DEMAND_UNIT_COLS = ["demand_group_id", "demand_subgroup_id"] DEMAND_UNIT_SCHEMA = { @@ -65,3 +66,98 @@ def split_large_demand_groups( .explode("demand_subgroup_id") .drop("n_subgroups") ) + + +def split_demand_groups( + demand_groups: pl.DataFrame, + *, + population_segments: list[PopulationSegment], + max_persons_per_demand_subgroup: int | None, +) -> pl.DataFrame: + """Create reusable segment shares, then enforce the subgroup-size limit.""" + if "demand_subgroup_id" in demand_groups.columns: + raise ValueError( + "`split_demand_groups()` expects raw demand groups because it owns " + "subgroup creation." + ) + + result = demand_groups.with_columns( + population_segments=pl.lit([], dtype=pl.List(pl.String)), + _segment_order=pl.lit("", dtype=pl.String), + ) + + for segment in population_segments: + missing = sorted(set(segment.selector) - set(result.columns)) + if missing: + raise ValueError( + f"Population segment '{segment.name}' uses unavailable demand-group " + f"columns: {', '.join(missing)}." + ) + + matches = pl.lit(True) + for column, values in segment.selector.items(): + matches = matches & pl.col(column).cast(pl.String).is_in( + [str(value) for value in values] + ) + + selected_segments = pl.concat_list( + "population_segments", + pl.lit([segment.name], dtype=pl.List(pl.String)), + ) + if segment.share == 1.0: + result = result.with_columns( + population_segments=pl.when(matches) + .then(selected_segments) + .otherwise(pl.col("population_segments")) + ) + continue + + selected = ( + result.filter(matches) + .with_columns( + n_persons=pl.col("n_persons") * segment.share, + population_segments=selected_segments, + _segment_order=pl.col("_segment_order") + pl.lit("0"), + ) + ) + complement = result.with_columns( + n_persons=pl.when(matches) + .then(pl.col("n_persons") * (1.0 - segment.share)) + .otherwise(pl.col("n_persons")), + _segment_order=pl.when(matches) + .then(pl.col("_segment_order") + pl.lit("1")) + .otherwise(pl.col("_segment_order")), + ) + result = pl.concat([selected, complement], how="vertical").filter( + pl.col("n_persons") > 0.0 + ) + + result = result.sort(["demand_group_id", "_segment_order"]) + if max_persons_per_demand_subgroup is not None: + max_persons = float(max_persons_per_demand_subgroup) + result = ( + result.with_columns( + _size_parts=(pl.col("n_persons") / max_persons) + .ceil() + .clip(1) + .cast(pl.UInt32) + ) + .with_columns( + _size_part=pl.int_ranges(0, pl.col("_size_parts")), + n_persons=pl.col("n_persons") / pl.col("_size_parts"), + ) + .explode("_size_part") + .drop("_size_parts") + ) + else: + result = result.with_columns(_size_part=pl.lit(0, dtype=pl.UInt32)) + + return ( + result.sort(["demand_group_id", "_segment_order", "_size_part"]) + .with_columns( + demand_subgroup_id=( + pl.col("demand_group_id").cum_count().over("demand_group_id") - 1 + ).cast(pl.UInt32) + ) + .drop("_segment_order", "_size_part") + ) 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 ba87c6fd..abc3d8f8 100644 --- a/mobility/trips/group_day_trips/plans/destination_plan_search.py +++ b/mobility/trips/group_day_trips/plans/destination_plan_search.py @@ -19,6 +19,7 @@ def sample_destination_plans( demand_groups: pl.DataFrame, destination_saturation: pl.DataFrame, mode_costs: pl.DataFrame, + profile_assignments: pl.DataFrame | None = None, transport_zones: Any, activities: list[Any], resolved_activity_parameters: dict[str, Any], @@ -36,7 +37,15 @@ def sample_destination_plans( for activity_id, activity_name in enumerate(activity_names) } - od_costs = _prepare_od_costs(mode_costs, logit_scale) + if profile_assignments is None: + profile_assignments = demand_groups.select(DEMAND_UNIT_COLS).with_columns( + utility_profile_id=pl.lit(0, dtype=pl.UInt32) + ) + if "utility_profile_id" not in mode_costs.columns: + mode_costs = mode_costs.with_columns( + utility_profile_id=pl.lit(0, dtype=pl.UInt32) + ) + destination_inputs = _prepare_destination_inputs( destination_saturation=destination_saturation, transport_zones=transport_zones, @@ -51,10 +60,13 @@ def sample_destination_plans( resolved_activity_parameters=resolved_activity_parameters, activity_ids=activity_ids, min_activity_time_constant=min_activity_time_constant, + profile_assignments=profile_assignments, ) + # Search all profiles together. Rust selects the matching OD costs for each + # context and schedules every context in one shared thread pool. search = DestinationPlanSearch( - od_costs=od_costs, + od_costs=_prepare_od_costs(mode_costs, logit_scale), destination_inputs=destination_inputs, ) plans, report = search.top_k( @@ -67,12 +79,12 @@ def sample_destination_plans( top_k=top_k, skip_contexts_without_plan=True, ) - if report["contexts_without_plan"] > 0: + missing_contexts = report["contexts_without_plan"] + if missing_contexts > 0: logging.warning( "Destination plan search did not find a complete plan for %s unique contexts.", - report["contexts_without_plan"], + missing_contexts, ) - # Expand deduplicated search contexts back to each demand unit, then restore # the Mobility destination-sequence columns expected by mode search. return ( @@ -100,36 +112,39 @@ def sample_destination_plans( 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("utility_profile_id").cast(pl.UInt32), 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( + od_columns = ["utility_profile_id", "origin", "destination"] + minimum_costs = mode_costs.group_by(od_columns).agg( minimum_cost=pl.col("cost").min() ) return ( - mode_costs.join(minimum_costs, on=["origin", "destination"]) + mode_costs.join(minimum_costs, on=od_columns) .with_columns( mode_weight=( -pl.lit(logit_scale) * (pl.col("cost") - pl.col("minimum_cost")) ).exp() ) - .group_by(["origin", "destination"]) + .group_by(od_columns) .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( + "utility_profile_id", "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"]) + .sort(["utility_profile_id", "origin", "destination"]) ) @@ -228,14 +243,19 @@ def _prepare_contexts( resolved_activity_parameters: dict[str, Any], activity_ids: dict[str, int], min_activity_time_constant: float, + profile_assignments: pl.DataFrame, ) -> tuple[pl.DataFrame, pl.DataFrame, pl.DataFrame, pl.DataFrame]: """Prepare and deduplicate complete activity-plan contexts.""" - demand_groups = demand_groups.select( + demand_groups = demand_groups.join( + profile_assignments, + on=DEMAND_UNIT_COLS, + ).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), + "utility_profile_id", ] ) activity_durations = activity_durations.select( @@ -347,7 +367,7 @@ def _prepare_contexts( source_steps.with_columns( step_hash=pl.struct(step_value_columns).hash(seed=17) ) - .group_by(RAW_CONTEXT_COLUMNS + ["home_zone_id"]) + .group_by(RAW_CONTEXT_COLUMNS + ["home_zone_id", "utility_profile_id"]) .agg( sequence_hash=pl.col("step_hash") .sort_by("layer") @@ -356,7 +376,11 @@ def _prepare_contexts( ) .with_columns( profile_key=pl.concat_str( - [pl.col("home_zone_id").cast(pl.String), "sequence_hash"], + [ + pl.col("home_zone_id").cast(pl.String), + pl.col("utility_profile_id").cast(pl.String), + "sequence_hash", + ], separator="|", ) ) @@ -399,6 +423,7 @@ def _prepare_contexts( .select( "context_id", pl.col("home_zone_id").alias("initial_zone"), + "utility_profile_id", ) .unique() .sort("context_id") diff --git a/mobility/trips/group_day_trips/plans/destination_sequences.py b/mobility/trips/group_day_trips/plans/destination_sequences.py index ed14a7fe..02de92c6 100644 --- a/mobility/trips/group_day_trips/plans/destination_sequences.py +++ b/mobility/trips/group_day_trips/plans/destination_sequences.py @@ -72,6 +72,7 @@ def __init__( activity_durations: pl.DataFrame | None = None, demand_groups: pl.DataFrame | None = None, costs: pl.DataFrame | None = None, + population_segments: list[Any] | None = None, parameters: Any = None, seed: int | None = None, ) -> None: @@ -104,10 +105,11 @@ def __init__( self.activity_durations = activity_durations self.demand_groups = demand_groups self.costs = costs + self.population_segments = population_segments or [] self.parameters = parameters self.seed = seed inputs = { - "version": 11, + "version": 12, "is_weekday": is_weekday, "iteration": iteration, "sensitivity_case": sensitivity_case, @@ -120,6 +122,7 @@ def __init__( "resolved_activity_parameters": self.resolved_activity_parameters, "transport_zones": transport_zones, "transport_costs": transport_costs, + "population_segments": self.population_segments, "destination_sequence_parameters": ( parameters.destination_sequences if parameters is not None else None ), @@ -390,14 +393,25 @@ def run( raise ValueError( "Cannot use destination plan search without transport costs." ) + profile_assignments, utility_profiles = ( + self.transport_costs.get_utility_profiles( + demand_groups, + self.population_segments, + ) + ) + profile_mode_costs = ( + self.transport_costs.get_profile_costs_by_od_and_mode( + utility_profiles, + ["cost", "time"], + ) + ) 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"] - ), + mode_costs=profile_mode_costs, + profile_assignments=profile_assignments, transport_zones=transport_zones, activities=activities, resolved_activity_parameters=self.resolved_activity_parameters, diff --git a/mobility/trips/group_day_trips/plans/mode_sequence_search/assemble.py b/mobility/trips/group_day_trips/plans/mode_sequence_search/assemble.py index 5606d591..b929ddea 100644 --- a/mobility/trips/group_day_trips/plans/mode_sequence_search/assemble.py +++ b/mobility/trips/group_day_trips/plans/mode_sequence_search/assemble.py @@ -11,8 +11,11 @@ def assemble_mode_sequence_rows( ) -> pl.DataFrame: """Join search results back to grouped trips and map mode ids back to mode names.""" return ( - trip_chains.select(DEMAND_UNIT_COLS + ["activity_seq_id", "time_seq_id", "dest_seq_id"]) - .join(search_rows, on="dest_seq_id") + trip_chains.select( + DEMAND_UNIT_COLS + + ["utility_profile_id", "activity_seq_id", "time_seq_id", "dest_seq_id"] + ) + .join(search_rows, on=["utility_profile_id", "dest_seq_id"]) .with_columns(mode=pl.col("mode_index").replace_strict(mode_name_by_id)) ) diff --git a/mobility/trips/group_day_trips/plans/mode_sequence_search/mode_sequences.py b/mobility/trips/group_day_trips/plans/mode_sequence_search/mode_sequences.py index 4832c269..09e869ca 100644 --- a/mobility/trips/group_day_trips/plans/mode_sequence_search/mode_sequences.py +++ b/mobility/trips/group_day_trips/plans/mode_sequence_search/mode_sequences.py @@ -64,21 +64,24 @@ def __init__( previous_mode_sequences: FileAsset | None = None, destination_sequences: FileAsset, transport_costs: Any, + population_segments: list[Any] | None = None, working_folder: pathlib.Path, parameters: Any, ) -> None: self.previous_mode_sequences = previous_mode_sequences self.destination_sequences = destination_sequences self.transport_costs = transport_costs + self.population_segments = population_segments or [] self.working_folder = working_folder self.parameters = parameters inputs = { - "version": 5, + "version": 6, "is_weekday": is_weekday, "iteration": iteration, "previous_mode_sequences": previous_mode_sequences, "destination_sequences": destination_sequences, "transport_costs": transport_costs, + "population_segments": self.population_segments, "mode_sequence_parameters": ( parameters.mode_sequences if parameters is not None else None ), @@ -106,6 +109,7 @@ def create_and_get_asset(self) -> pl.DataFrame: get_group_day_trips_progress().iteration_step(self.iteration, "mode sequences") working_folder = self.working_folder destination_steps = self.destination_sequences.get_cached_asset() + demand_groups = getattr(self.destination_sequences, "demand_groups", None) log_memory_checkpoint( f"mode_sequences:iteration:{self.iteration}:destination_chains", @@ -114,6 +118,24 @@ def create_and_get_asset(self) -> pl.DataFrame: use_rust_search = self.parameters.mode_sequences.use_rust_mode_sequence_search + if demand_groups is None: + profile_assignments = destination_steps.select( + ["demand_group_id", "demand_subgroup_id"] + ).unique().with_columns( + utility_profile_id=pl.lit(0, dtype=pl.UInt32) + ) + utility_profiles = None + else: + profile_assignments, utility_profiles = ( + self.transport_costs.get_utility_profiles( + demand_groups, + self.population_segments, + ) + ) + destination_steps = destination_steps.join( + profile_assignments, + on=["demand_group_id", "demand_subgroup_id"], + ) trip_chains, unique_destination_chains = build_location_chains(destination_steps) log_memory_checkpoint( @@ -131,7 +153,20 @@ def create_and_get_asset(self) -> pl.DataFrame: unique_destination_chains=unique_destination_chains, ) - search_inputs = build_search_inputs(self.transport_costs) + if utility_profiles is None: + profile_costs = self.transport_costs.get_costs_by_od_and_mode( + ["cost"], + detail_distances=False, + ).with_columns(utility_profile_id=pl.lit(0, dtype=pl.UInt32)) + else: + profile_costs = self.transport_costs.get_profile_costs_by_od_and_mode( + utility_profiles, + ["cost"], + ) + search_inputs = build_search_inputs( + self.transport_costs, + leg_mode_costs=profile_costs, + ) if use_rust_search: search_rows = run_rust_mode_sequence_search( @@ -145,15 +180,33 @@ def create_and_get_asset(self) -> pl.DataFrame: k_mode_sequences=self.parameters.mode_sequences.k_mode_sequences, ) else: - search_rows = run_python_mode_sequence_search( - iteration=self.iteration, - parameters=self.parameters, - working_folder=working_folder, - unique_destination_chains=unique_destination_chains, - leg_mode_costs=search_inputs.leg_mode_costs, - modes_by_name=search_inputs.modes_by_name, - is_return_mode_by_id=search_inputs.is_return_mode_by_id, - ) + # The legacy Python backend still receives one profile at a time. + # Production Rust search batches all profiles above. + profile_results = [] + for profile_id in unique_destination_chains[ + "utility_profile_id" + ].unique().sort(): + profile_chains = unique_destination_chains.filter( + pl.col("utility_profile_id") == profile_id + ).drop("utility_profile_id") + profile_leg_costs = search_inputs.leg_mode_costs.filter( + pl.col("utility_profile_id") == profile_id + ).drop("utility_profile_id") + profile_rows = run_python_mode_sequence_search( + iteration=self.iteration, + parameters=self.parameters, + working_folder=working_folder, + unique_destination_chains=profile_chains, + leg_mode_costs=profile_leg_costs, + modes_by_name=search_inputs.modes_by_name, + is_return_mode_by_id=search_inputs.is_return_mode_by_id, + ) + profile_results.append( + profile_rows.with_columns( + utility_profile_id=pl.lit(profile_id, dtype=pl.UInt32) + ) + ) + search_rows = pl.concat(profile_results) search_rows = assemble_mode_sequence_rows( trip_chains=trip_chains, diff --git a/mobility/trips/group_day_trips/plans/mode_sequence_search/prepare.py b/mobility/trips/group_day_trips/plans/mode_sequence_search/prepare.py index add769fc..e66755b4 100644 --- a/mobility/trips/group_day_trips/plans/mode_sequence_search/prepare.py +++ b/mobility/trips/group_day_trips/plans/mode_sequence_search/prepare.py @@ -11,18 +11,28 @@ def build_location_chains(destination_steps: pl.DataFrame) -> tuple[pl.DataFrame, pl.DataFrame]: """Build grouped trip chains and one unique location chain per destination sequence.""" + if "utility_profile_id" not in destination_steps.columns: + destination_steps = destination_steps.with_columns( + utility_profile_id=pl.lit(0, dtype=pl.UInt32) + ) trip_chains = ( destination_steps - .group_by(DEMAND_UNIT_COLS + ["activity_seq_id", "time_seq_id", "dest_seq_id"]) + .group_by( + DEMAND_UNIT_COLS + + ["utility_profile_id", "activity_seq_id", "time_seq_id", "dest_seq_id"] + ) .agg(locations=pl.col("from").sort_by("seq_step_index")) - .sort(DEMAND_UNIT_COLS + ["activity_seq_id", "time_seq_id", "dest_seq_id"]) + .sort( + DEMAND_UNIT_COLS + + ["utility_profile_id", "activity_seq_id", "time_seq_id", "dest_seq_id"] + ) ) _validate_location_chains(trip_chains) unique_destination_chains = ( trip_chains - .group_by(["dest_seq_id"]) + .group_by(["utility_profile_id", "dest_seq_id"]) .agg(pl.col("locations").first()) - .sort("dest_seq_id") + .sort(["utility_profile_id", "dest_seq_id"]) ) return trip_chains, unique_destination_chains @@ -43,7 +53,7 @@ def _validate_location_chains(trip_chains: pl.DataFrame) -> None: conflicting_destination_sequences = ( with_location_key - .group_by("dest_seq_id") + .group_by(["utility_profile_id", "dest_seq_id"]) .agg(n_location_chains=pl.col("location_key").n_unique()) .filter(pl.col("n_location_chains") > 1) ) @@ -63,7 +73,11 @@ def _validate_location_chains(trip_chains: pl.DataFrame) -> None: ) -def build_search_inputs(transport_costs: Any) -> ModeSearchInputs: +def build_search_inputs( + transport_costs: Any, + *, + leg_mode_costs: pl.DataFrame | None = None, +) -> ModeSearchInputs: """Build normalized inputs shared by the Rust and Python search backends.""" modes_by_name = modes_list_to_dict(transport_costs.modes) mode_enum_values = get_mode_values(transport_costs.modes, "stay_home") @@ -83,11 +97,13 @@ def build_search_inputs(transport_costs: Any) -> ModeSearchInputs: mode_id_by_name[name]: props["vehicle"] is not None for name, props in modes_by_name.items() } - leg_mode_costs = ( - transport_costs.get_costs_by_od_and_mode( + if leg_mode_costs is None: + leg_mode_costs = transport_costs.get_costs_by_od_and_mode( ["cost"], detail_distances=False, ) + leg_mode_costs = ( + leg_mode_costs .with_columns( mode_id=pl.col("mode").replace_strict(mode_id_by_name, return_dtype=pl.UInt16()), cost=pl.col("cost").mul(1e6).cast(pl.Float64), diff --git a/mobility/trips/group_day_trips/plans/mode_sequence_search/search_rust.py b/mobility/trips/group_day_trips/plans/mode_sequence_search/search_rust.py index 778421f2..8f40ec06 100644 --- a/mobility/trips/group_day_trips/plans/mode_sequence_search/search_rust.py +++ b/mobility/trips/group_day_trips/plans/mode_sequence_search/search_rust.py @@ -24,10 +24,13 @@ def run_rust_mode_sequence_search( modes_by_name=modes_by_name, mode_name_by_id=mode_name_by_id, ) + cost_columns = ["origin", "destination", "mode_id", "cost"] + if "utility_profile_id" in leg_mode_costs.columns: + cost_columns.insert(0, "utility_profile_id") rust_cost_rows = ( leg_mode_costs .rename({"from": "origin", "to": "destination"}) - .select(["origin", "destination", "mode_id", "cost"]) + .select(cost_columns) ) return search_mode_sequences( location_chain_steps=unique_destination_chains, diff --git a/mobility/trips/group_day_trips/plans/plan_initializer.py b/mobility/trips/group_day_trips/plans/plan_initializer.py index 24c4f6b2..e0aa4c59 100644 --- a/mobility/trips/group_day_trips/plans/plan_initializer.py +++ b/mobility/trips/group_day_trips/plans/plan_initializer.py @@ -5,7 +5,7 @@ from mobility.activities.activity import ActivityParameters from mobility.surveys import SurveyPlanAssets from mobility.transport.modes.core.mode_values import get_mode_values -from .demand_subgroups import DEMAND_UNIT_COLS, split_large_demand_groups +from .demand_subgroups import DEMAND_UNIT_COLS, split_demand_groups from .plan_ids import add_plan_id @@ -101,8 +101,9 @@ def get_col_values(df1, df2, col): .sort(["home_zone_id", "country", "city_category", "csp", "n_cars"]) .with_row_index("demand_group_id") ) - demand_groups = split_large_demand_groups( + demand_groups = split_demand_groups( demand_groups, + population_segments=population.population_segments, max_persons_per_demand_subgroup=parameters.demand_groups.max_persons_per_demand_subgroup, ) diff --git a/mobility/trips/group_day_trips/plans/plan_updater.py b/mobility/trips/group_day_trips/plans/plan_updater.py index 121d2dd1..6850b1fc 100644 --- a/mobility/trips/group_day_trips/plans/plan_updater.py +++ b/mobility/trips/group_day_trips/plans/plan_updater.py @@ -240,6 +240,8 @@ def get_possible_plan_steps( return self.compute_plan_steps_candidates_utility( candidates=aggregated_candidates, transport_costs=transport_costs, + demand_groups=demand_groups, + population_segments=destination_sequences.population_segments, destination_saturation=destination_saturation, activity_dur=activity_dur, transport_zones=transport_zones, @@ -254,6 +256,8 @@ def compute_plan_steps_candidates_utility( *, candidates: pl.LazyFrame, transport_costs, + demand_groups: pl.DataFrame | None = None, + population_segments=None, destination_saturation: pl.DataFrame, activity_dur: pl.DataFrame, transport_zones, @@ -264,10 +268,24 @@ def compute_plan_steps_candidates_utility( ) -> pl.LazyFrame: """Score plan-step candidates under current costs and destination saturation.""" - cost_by_od_and_modes = transport_costs.get_costs_by_od_and_mode( - ["cost", "distance", "time"], - detail_distances=False, - ).with_columns( + if demand_groups is None: + profile_assignments = None + cost_by_od_and_modes = transport_costs.get_costs_by_od_and_mode( + ["cost", "distance", "time"], + detail_distances=False, + ) + cost_join_columns = ["from", "to", "mode"] + else: + profile_assignments, utility_profiles = transport_costs.get_utility_profiles( + demand_groups, + population_segments or [], + ) + cost_by_od_and_modes = transport_costs.get_profile_costs_by_od_and_mode( + utility_profiles, + ["cost", "distance", "time"], + ) + cost_join_columns = ["utility_profile_id", "from", "to", "mode"] + cost_by_od_and_modes = cost_by_od_and_modes.with_columns( mode=pl.col("mode").cast( pl.Enum(get_mode_values(transport_costs.modes, "stay_home")) ) @@ -342,14 +360,21 @@ def compute_plan_steps_candidates_utility( "activity_utility_scale", ] + candidate_cost_inputs = candidates + if profile_assignments is not None: + candidate_cost_inputs = candidate_cost_inputs.join( + profile_assignments.lazy(), + on=DEMAND_UNIT_COLS, + ) + scored_candidates = ( - candidates + candidate_cost_inputs .with_columns( activity=pl.col("activity").cast(pl.Enum(activity_dur["activity"].dtype.categories)) ) .join( cost_by_od_and_modes.lazy(), - on=["from", "to", "mode"], + on=cost_join_columns, how="left" if allow_missing_costs_for_current_plans else "inner", ) .join(activity_dur.lazy(), on=["country", "csp", "activity"]) diff --git a/pixi.lock b/pixi.lock index 448a4fad..6dc51e58 100644 --- a/pixi.lock +++ b/pixi.lock @@ -131,7 +131,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/37/00/1a2351a85d36b26c5b2b8cfbb37ad86084c98f592dd7590f8577d8b33993/inflate64-1.0.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/38/3d/2d244233ac4f76e38533cfcb2991c9eb4c7bf688ae0a036d30725b8faafe/importlib_metadata-9.0.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3a/13/547360d81e6d88d58492968ffda9f9542854f11310ee556fef14260cc886/zipp-4.1.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/3b/cb/84dd70aa64a8c3ebd6ef0e9b9e259f282cd76df25afe8562122c5e4a2eae/mobility_mode_sequence_search-0.1.0.tar.gz - pypi: https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/3c/78/6a04792ace63a93e162f1305392d500ae8ddcb620e7eb88a22fd622b35bb/geopandas-1.1.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/43/e3/fdc657359e919462369869f1c9f0e973f353f9a9ee295a39b1fea8ee1a77/pillow-12.2.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl @@ -144,7 +143,6 @@ 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 @@ -184,6 +182,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/b9/37/e781683abac55dde9771e086b790e554811a71ed0b2b8a1e789b7430dd44/shapely-2.1.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl - pypi: https://files.pythonhosted.org/packages/ba/6c/ff8bf52315064dbeb55cb5067e191120a5b2e58bb648d0d34cf7969dc2c2/choreographer-1.3.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/bd/24/12818598c362d7f300f18e74db45963dbcb85150324092410c8b49405e42/pyproject_hooks-1.2.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/be/e4/fc736e577653f3e254373967e16aa3d806695753dacf79f3a977fb52100d/mobility_mode_sequence_search-0.1.1.tar.gz - pypi: https://files.pythonhosted.org/packages/c0/44/21d6bf170bf40b41396480d8d49ad640bca3f2b02139cd52aa1e272830a5/shortuuid-1.0.13-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c0/da/977ded879c29cbd04de313843e76868e6e13408a94ed6b987245dc7c8506/openpyxl-3.1.5-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c1/8b/5fe2cc11fee489817272089c4203e679c63b570a5aaeb18d852ae3cbba6a/et_xmlfile-2.0.0-py3-none-any.whl @@ -193,6 +192,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/c7/0d/67e5b4109ea4a837e80daa87c2c696711955e40449a97e8926672534def2/click-8.4.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c8/a9/8862616a85cf450d2822dbd4fff1fcaba90877907a6ff5bc2672cafe42f8/pycryptodomex-3.23.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl - pypi: https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/cb/f2/accfc5fb6497a3cccc384bebfd388778460327a44717b1b703740310211d/mobility_destination_sequence_sampler-0.1.1.tar.gz - pypi: https://files.pythonhosted.org/packages/cc/8f/ec6289987824b29529d0dfda0d74a07cec60e54b9c92f3c9da4c0ac732de/contourpy-1.3.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/d1/75/e5d44be90525cd28503e7f836d077ae6663ec0687a13ba7810b4114b3668/rtree-1.4.1-py3-none-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/d7/27/a58ddaf8c588a3ef080db9d0b7e0b97215cee3a45df74f3a94dbbf5c893a/pycodestyle-2.14.0-py2.py3-none-any.whl @@ -341,7 +341,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/38/3d/2d244233ac4f76e38533cfcb2991c9eb4c7bf688ae0a036d30725b8faafe/importlib_metadata-9.0.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3a/13/547360d81e6d88d58492968ffda9f9542854f11310ee556fef14260cc886/zipp-4.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3b/74/1b41205f7368c9375ab1dea91178eaa20435fe3eff036390a53a7660b416/polars_runtime_32-1.39.3-cp310-abi3-macosx_10_12_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/3b/cb/84dd70aa64a8c3ebd6ef0e9b9e259f282cd76df25afe8562122c5e4a2eae/mobility_mode_sequence_search-0.1.0.tar.gz - pypi: https://files.pythonhosted.org/packages/3c/78/6a04792ace63a93e162f1305392d500ae8ddcb620e7eb88a22fd622b35bb/geopandas-1.1.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3d/b7/bdbb725ba02c5b42825b200c940f38b7a54fcad24627b7192f78f8110d76/coverage-7.14.1-cp312-cp312-macosx_10_13_x86_64.whl - pypi: https://files.pythonhosted.org/packages/44/a0/c815bea63117fa63e4e1c01f8a1110d2112fa003f838e6467094ec2432ce/fonttools-4.63.0-cp312-cp312-macosx_10_13_x86_64.whl @@ -352,7 +351,6 @@ 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 @@ -388,6 +386,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/ba/6c/ff8bf52315064dbeb55cb5067e191120a5b2e58bb648d0d34cf7969dc2c2/choreographer-1.3.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/bd/24/12818598c362d7f300f18e74db45963dbcb85150324092410c8b49405e42/pyproject_hooks-1.2.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/be/45/adfee365d9ea3d853550b2e735f9d66366701c65db7855cd07621732ccfc/contourpy-1.3.3-cp312-cp312-macosx_10_13_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/be/e4/fc736e577653f3e254373967e16aa3d806695753dacf79f3a977fb52100d/mobility_mode_sequence_search-0.1.1.tar.gz - pypi: https://files.pythonhosted.org/packages/bf/d9/405320f8077e8e1c5c4bd6adc45e1e6edf6d727b6da7f2e2533cf58bff71/kiwisolver-1.5.0-cp312-cp312-macosx_10_13_x86_64.whl - pypi: https://files.pythonhosted.org/packages/c0/44/21d6bf170bf40b41396480d8d49ad640bca3f2b02139cd52aa1e272830a5/shortuuid-1.0.13-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c0/da/977ded879c29cbd04de313843e76868e6e13408a94ed6b987245dc7c8506/openpyxl-3.1.5-py2.py3-none-any.whl @@ -396,6 +395,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/c2/ea/84509533e0f6477960b8a9179d240d93c909c6543e4dd8209932026d7815/pytest_dependency-0.6.1.tar.gz - pypi: https://files.pythonhosted.org/packages/c7/0d/67e5b4109ea4a837e80daa87c2c696711955e40449a97e8926672534def2/click-8.4.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/cb/f2/accfc5fb6497a3cccc384bebfd388778460327a44717b1b703740310211d/mobility_destination_sequence_sampler-0.1.1.tar.gz - pypi: https://files.pythonhosted.org/packages/cd/0a/583c7c2832da36e986c5758d0afb6f5944599e55c5b798b066a9ef63e581/inflate64-1.0.4-cp312-cp312-macosx_10_13_x86_64.whl - pypi: https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl - pypi: https://files.pythonhosted.org/packages/d7/27/a58ddaf8c588a3ef080db9d0b7e0b97215cee3a45df74f3a94dbbf5c893a/pycodestyle-2.14.0-py2.py3-none-any.whl @@ -547,7 +547,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/24/99/4772b8e00a136f3e01236de33b0efda31ee7077203ba5967fcc76da94d65/texttable-1.7.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/27/1a/1f68f9ba0c207934b35b86a8ca3aad8395a3d6dd7921c0686e23853ff5a9/mccabe-0.7.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/32/d5/f9a850d79b0851d1d4ef6456097579a9005b31fea68726a4ae5f2d82ddd9/threadpoolctl-3.6.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/37/8a/bbb90d66d6d4748b8b8ca7e2af4b6b186c2459e66ed980430018440621c2/mobility_mode_sequence_search-0.1.0-cp311-abi3-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/38/3d/2d244233ac4f76e38533cfcb2991c9eb4c7bf688ae0a036d30725b8faafe/importlib_metadata-9.0.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3a/13/547360d81e6d88d58492968ffda9f9542854f11310ee556fef14260cc886/zipp-4.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3c/78/6a04792ace63a93e162f1305392d500ae8ddcb620e7eb88a22fd622b35bb/geopandas-1.1.3-py3-none-any.whl @@ -557,6 +556,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/54/20/6aa79ba3570bddd1bf7e951c6123f806751e58e8cce736bad77b2cf348d7/logistro-2.0.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/66/27/fea29f8d0a20729ab746d23713cc85a0d829e21953347405a9143dfd334d/mobility_mode_sequence_search-0.1.1-cp311-abi3-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/67/f3/6cd296376653270ac1b423bb30bd70942d9916b6978c6f40472d6ac038e7/retrying-1.4.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/72/4b/9c6acfbe900e5c8698132244c68036b0455bd2169f46e356c83dc0366f11/inflate64-1.0.4-cp312-cp312-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/72/81/fdc0898a55c6219223291ec1a1fe89966ef212ce82276aa0899df84b5de0/coverage-7.14.1-cp312-cp312-macosx_11_0_arm64.whl @@ -572,7 +572,6 @@ 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 @@ -586,6 +585,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/9f/56/13ab06b4f93ca7cac71078fbe37fcea175d3216f31f85c3168a6bbd0bb9a/flake8-7.3.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a0/c4/c2971a3ba4c6103a3d10c4b0f24f461ddc027f0f09763220cf35ca1401b3/nest_asyncio-1.6.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/a6/f6/35145c9849ad7f71a72ef5be2296a82e191365b827dff3ef5c144cd1fa1e/mobility_destination_sequence_sampler-0.1.1-cp311-abi3-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/a7/5e/19fb53bd69379498c47bc234ca4d2851cfbca333d6d6929b10251916da25/mapclassify-2.10.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a8/a9/d23012099dc88ec69a29c6407b41d89681cb674c2043cd5b467c7e299c08/xyzservices-2026.3.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/aa/5e/fdd72167b57158d743353f71d453200719744d1e75f18b1c8230508db370/geojson-3.3.0-py3-none-any.whl @@ -737,9 +737,9 @@ environments: - pypi: https://files.pythonhosted.org/packages/58/38/40ba081c6c71f0f22c64d3d54b912ad75a4e6812caa1397cbb15b5693b12/backports_zstd-1.6.0-cp312-cp312-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/5d/7b/25a221d2c761c6a8ae21bfa3874988ff2583e19cf8a27bf2fee358df7942/pillow-12.2.0-cp312-cp312-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/65/b8/e4d90cf6378ce752c2f5f64060bad7a7fa3ccebf92023f703b4b9760d4b5/mobility_mode_sequence_search-0.1.1-cp311-abi3-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/67/f3/6cd296376653270ac1b423bb30bd70942d9916b6978c6f40472d6ac038e7/retrying-1.4.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/69/96/11f36f71a865dd6df03716d33bd07a67e9d20f6b8d39820470b766af323c/pycryptodomex-3.23.0-cp37-abi3-win_amd64.whl - - pypi: https://files.pythonhosted.org/packages/6b/75/4d7e76a1a0a860a2334e14096f7bc63c52c1493dfa7ef387bff37340b022/mobility_mode_sequence_search-0.1.0-cp311-abi3-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/74/25/5282c8270bfcd620d3e73beb35b40ac4ab00f0a898d98ebeb41ef0989ec8/rtree-1.4.1-py3-none-win_amd64.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 @@ -776,6 +776,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/b4/db/08f4ca10c5018813e7e0b59e4472302328b3d2ab1512f5a2157a814540e0/polars-1.39.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b5/a8/5f764f333204db0390362a4356d03a43626997f26818a0e9396f1b3bd8c9/folium-0.20.0-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/b7/7d/c30f11d13a0b7d46244ccf4c9b07894ce630b8de54abe4c52a3b9f0ffa4c/mobility_destination_sequence_sampler-0.1.1-cp311-abi3-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b9/c4/90de06b2d8737c68c05ff9274113f854dbf6a5f28b7a955212111672cb57/simplejson-4.1.1-cp312-cp312-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/b9/c5/fc1b368f303087d20e8c9bf3d6ceb186263cfac0ade735cd938538bea839/pandas-3.0.3-cp312-cp312-win_amd64.whl @@ -796,7 +797,6 @@ 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 @@ -5674,8 +5674,8 @@ packages: - kaleido>=1.2,<2 - pydantic>=2.12,<3 - tenacity>=9,<10 - - mobility-mode-sequence-search==0.1.0 - - mobility-destination-sequence-sampler==0.1.0 + - mobility-mode-sequence-search==0.1.1 + - mobility-destination-sequence-sampler==0.1.1 - truststore>=0.10,<1 ; extra == 'truststore' - build>=1.5,<2 ; extra == 'dev' - coverage>=7,<8 ; extra == 'dev' @@ -6201,14 +6201,6 @@ packages: - readme-renderer ; extra == 'check' - twine ; extra == 'check' requires_python: '>=3.9' -- pypi: https://files.pythonhosted.org/packages/37/8a/bbb90d66d6d4748b8b8ca7e2af4b6b186c2459e66ed980430018440621c2/mobility_mode_sequence_search-0.1.0-cp311-abi3-macosx_11_0_arm64.whl - name: mobility-mode-sequence-search - version: 0.1.0 - sha256: 5d4f4bd239f250150413bdb0fe550bdb5c42126e58e286e5db51956383257948 - requires_dist: - - polars>=1.39,<2 - - pytest>=8,<10 ; extra == 'dev' - requires_python: '>=3.11' - pypi: https://files.pythonhosted.org/packages/38/3d/2d244233ac4f76e38533cfcb2991c9eb4c7bf688ae0a036d30725b8faafe/importlib_metadata-9.0.0-py3-none-any.whl name: importlib-metadata version: 9.0.0 @@ -6261,14 +6253,6 @@ packages: version: 1.39.3 sha256: 425c0b220b573fa097b4042edff73114cc6d23432a21dfd2dc41adf329d7d2e9 requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/3b/cb/84dd70aa64a8c3ebd6ef0e9b9e259f282cd76df25afe8562122c5e4a2eae/mobility_mode_sequence_search-0.1.0.tar.gz - name: mobility-mode-sequence-search - version: 0.1.0 - sha256: 021cc39ad301afd33f2c307557747cf71be83f4e0691b46914dd24c54e8076de - requires_dist: - - polars>=1.39,<2 - - pytest>=8,<10 ; extra == 'dev' - requires_python: '>=3.11' - pypi: https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl name: markupsafe version: 3.0.3 @@ -6701,6 +6685,22 @@ packages: - xlsxwriter>=3.2.0 ; extra == 'all' - zstandard>=0.23.0 ; extra == 'all' requires_python: '>=3.11' +- pypi: https://files.pythonhosted.org/packages/65/b8/e4d90cf6378ce752c2f5f64060bad7a7fa3ccebf92023f703b4b9760d4b5/mobility_mode_sequence_search-0.1.1-cp311-abi3-win_amd64.whl + name: mobility-mode-sequence-search + version: 0.1.1 + sha256: 0b4aaebaaea87efafb5afa56d50213ebf4e41d5f1ba23dfcd55fd86a0c36566f + requires_dist: + - polars>=1.39,<2 + - pytest>=8,<10 ; extra == 'dev' + requires_python: '>=3.11' +- pypi: https://files.pythonhosted.org/packages/66/27/fea29f8d0a20729ab746d23713cc85a0d829e21953347405a9143dfd334d/mobility_mode_sequence_search-0.1.1-cp311-abi3-macosx_11_0_arm64.whl + name: mobility-mode-sequence-search + version: 0.1.1 + sha256: 8286da0e7f215747675cd5b825dc5696ceca7e0a6d7cfd44204569638c33f35d + requires_dist: + - polars>=1.39,<2 + - pytest>=8,<10 ; extra == 'dev' + requires_python: '>=3.11' - pypi: https://files.pythonhosted.org/packages/67/f3/6cd296376653270ac1b423bb30bd70942d9916b6978c6f40472d6ac038e7/retrying-1.4.2-py3-none-any.whl name: retrying version: 1.4.2 @@ -6711,30 +6711,11 @@ packages: version: 3.23.0 sha256: 52e5ca58c3a0b0bd5e100a9fbc8015059b05cffc6c66ce9d98b4b45e023443b9 requires_python: '>=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*' -- pypi: https://files.pythonhosted.org/packages/6b/75/4d7e76a1a0a860a2334e14096f7bc63c52c1493dfa7ef387bff37340b022/mobility_mode_sequence_search-0.1.0-cp311-abi3-win_amd64.whl - name: mobility-mode-sequence-search - version: 0.1.0 - sha256: ec1d644572c7576ac5dc34f286e28c79002a95f4bee7eda8e1745b6351669763 - requires_dist: - - polars>=1.39,<2 - - pytest>=8,<10 ; extra == 'dev' - requires_python: '>=3.11' - 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 name: backports-zstd 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 @@ -7073,17 +7054,6 @@ 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 @@ -7580,6 +7550,17 @@ packages: - ruff>=0.12.0 ; extra == 'dev' - cython-lint>=0.12.2 ; extra == 'dev' requires_python: '>=3.11' +- pypi: https://files.pythonhosted.org/packages/a6/f6/35145c9849ad7f71a72ef5be2296a82e191365b827dff3ef5c144cd1fa1e/mobility_destination_sequence_sampler-0.1.1-cp311-abi3-macosx_11_0_arm64.whl + name: mobility-destination-sequence-sampler + version: 0.1.1 + sha256: 20141c34c8ada1f3899d60c2d0107f9660393392f0be3c155d36856350ae27c2 + 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/a7/5e/19fb53bd69379498c47bc234ca4d2851cfbca333d6d6929b10251916da25/mapclassify-2.10.0-py3-none-any.whl name: mapclassify version: 2.10.0 @@ -7919,6 +7900,17 @@ packages: - xyzservices - pytest ; extra == 'testing' requires_python: '>=3.9' +- pypi: https://files.pythonhosted.org/packages/b7/7d/c30f11d13a0b7d46244ccf4c9b07894ce630b8de54abe4c52a3b9f0ffa4c/mobility_destination_sequence_sampler-0.1.1-cp311-abi3-win_amd64.whl + name: mobility-destination-sequence-sampler + version: 0.1.1 + sha256: 2e13a25fe482a818e8579f799445d33fd425b3f00ec39101c2eeca1efaa07d8c + 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/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl name: six version: 1.17.0 @@ -8090,6 +8082,14 @@ packages: - pytest-xdist ; extra == 'test-no-images' - wurlitzer ; extra == 'test-no-images' requires_python: '>=3.11' +- pypi: https://files.pythonhosted.org/packages/be/e4/fc736e577653f3e254373967e16aa3d806695753dacf79f3a977fb52100d/mobility_mode_sequence_search-0.1.1.tar.gz + name: mobility-mode-sequence-search + version: 0.1.1 + sha256: cd937d469b711eefe30629051c8da66a87ea87b0cc93462e265fecb5eade98f4 + requires_dist: + - polars>=1.39,<2 + - pytest>=8,<10 ; extra == 'dev' + requires_python: '>=3.11' - pypi: https://files.pythonhosted.org/packages/bf/d9/405320f8077e8e1c5c4bd6adc45e1e6edf6d727b6da7f2e2533cf58bff71/kiwisolver-1.5.0-cp312-cp312-macosx_10_13_x86_64.whl name: kiwisolver version: 1.5.0 @@ -8147,6 +8147,17 @@ packages: version: 2.3.0 sha256: f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12 requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/cb/f2/accfc5fb6497a3cccc384bebfd388778460327a44717b1b703740310211d/mobility_destination_sequence_sampler-0.1.1.tar.gz + name: mobility-destination-sequence-sampler + version: 0.1.1 + sha256: 892a52819e7dabb7d0538bdcc44fb8b65d80774a23ce5c93c76723dea6263a7c + 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/cc/8f/ec6289987824b29529d0dfda0d74a07cec60e54b9c92f3c9da4c0ac732de/contourpy-1.3.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl name: contourpy version: 1.3.3 @@ -8408,17 +8419,6 @@ 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 3de1d1ad..fa56d589 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,8 +32,8 @@ dependencies = [ "kaleido>=1.2,<2", "pydantic>=2.12,<3", "tenacity>=9,<10", - "mobility-mode-sequence-search==0.1.0", - "mobility-destination-sequence-sampler==0.1.0", + "mobility-mode-sequence-search==0.1.1", + "mobility-destination-sequence-sampler==0.1.1", ] requires-python = ">=3.11" diff --git a/tests/back/unit/costs/test_002_population_utility_profiles.py b/tests/back/unit/costs/test_002_population_utility_profiles.py new file mode 100644 index 00000000..7d7f17ef --- /dev/null +++ b/tests/back/unit/costs/test_002_population_utility_profiles.py @@ -0,0 +1,145 @@ +from types import SimpleNamespace + +import pandas as pd +import polars as pl + +from mobility.runtime.assets.in_memory_asset import InMemoryAsset +from mobility.runtime.parameter_values import ParameterValue +from mobility.runtime.population_segments import ( + PopulationSegment, + population_segment_defaults, +) +from mobility.transport.costs.parameters import GeneralizedCostParameters +from mobility.transport.costs.transport_costs import TransportCosts + + +class _GeneralizedCost(InMemoryAsset): + calls = 0 + + def get(self, metrics, **_kwargs): + self.__class__.calls += 1 + parameters = self.inputs["parameters"] + row = { + "from": 1, + "to": 2, + "cost": parameters.cost_constant + + 10.0 * parameters.cost_of_distance, + "distance": 10.0, + "time": 0.5, + } + return pd.DataFrame([{column: row[column] for column in ["from", "to"] + metrics}]) + + +def test_identical_segment_coefficients_share_one_utility_profile(): + _GeneralizedCost.calls = 0 + transport_costs = _transport_costs( + GeneralizedCostParameters( + cost_constant=ParameterValue.by_population_segment( + default=1.0, + segment_values={"pupils": 3.0}, + ), + cost_of_distance=2.0, + ) + ) + demand_groups = pl.DataFrame( + { + "demand_group_id": [1, 1, 2], + "demand_subgroup_id": [0, 1, 0], + "population_segments": [[], ["pupils"], ["pupils"]], + }, + schema={ + "demand_group_id": pl.UInt32, + "demand_subgroup_id": pl.UInt32, + "population_segments": pl.List(pl.String), + }, + ) + + assignments, profiles = transport_costs.get_utility_profiles( + demand_groups, + [PopulationSegment(name="pupils", csp="8a")], + ) + profile_costs = transport_costs.get_profile_costs_by_od_and_mode( + profiles, + ["cost", "time"], + ).sort("utility_profile_id") + + assert assignments["utility_profile_id"].to_list() == [0, 1, 1] + assert len(profiles) == 2 + assert profile_costs["cost"].to_list() == [21.0, 23.0] + assert _GeneralizedCost.calls == 1 + + +def test_generalized_costs_are_resolved_once_per_segment_membership(): + transport_costs = _transport_costs( + GeneralizedCostParameters( + cost_constant=ParameterValue.by_population_segment( + default=1.0, + segment_values={"pupils": 3.0}, + ) + ) + ) + demand_groups = pl.DataFrame( + { + "demand_group_id": [1, 2, 3, 4], + "demand_subgroup_id": [0, 0, 0, 0], + "population_segments": [[], ["pupils"], ["pupils"], []], + }, + schema={ + "demand_group_id": pl.UInt32, + "demand_subgroup_id": pl.UInt32, + "population_segments": pl.List(pl.String), + }, + ) + original_resolver = transport_costs._resolved_generalized_cost + resolved_memberships = [] + + def recording_resolver(generalized_cost, *, memberships, population_segments): + resolved_memberships.append(frozenset(memberships)) + return original_resolver( + generalized_cost, + memberships=memberships, + population_segments=population_segments, + ) + + transport_costs._resolved_generalized_cost = recording_resolver + + assignments, _ = transport_costs.get_utility_profiles( + demand_groups, + [PopulationSegment(name="pupils", csp="8a")], + ) + + assert resolved_memberships == [frozenset(), frozenset({"pupils"})] + assert assignments["utility_profile_id"].to_list() == [0, 1, 1, 0] + + +def _transport_costs(parameters): + default_parameters = population_segment_defaults(parameters) + generalized_cost = _GeneralizedCost({"parameters": parameters}) + mode = SimpleNamespace( + inputs={ + "generalized_cost": generalized_cost, + "parameters": SimpleNamespace(name="car"), + } + ) + transport_costs = TransportCosts.__new__(TransportCosts) + transport_costs.modes = [mode] + transport_costs.inputs = { + "congestion": False, + "road_flow_asset": None, + } + transport_costs.get_costs_by_od_and_mode = lambda metrics, **_kwargs: ( + pl.DataFrame( + { + "from": [1], + "to": [2], + "mode": ["car"], + "cost": [ + default_parameters.cost_constant + + 10.0 * default_parameters.cost_of_distance + ], + "distance": [10.0], + "time": [0.5], + } + ).select(["from", "to", "mode"] + metrics) + ) + return transport_costs diff --git a/tests/back/unit/domain/group_day_trips/test_002_mode_sequences_parallel_search.py b/tests/back/unit/domain/group_day_trips/test_002_mode_sequences_parallel_search.py index 074d541c..6440e5e1 100644 --- a/tests/back/unit/domain/group_day_trips/test_002_mode_sequences_parallel_search.py +++ b/tests/back/unit/domain/group_day_trips/test_002_mode_sequences_parallel_search.py @@ -97,6 +97,36 @@ def test_build_location_chains_fails_when_destination_sequence_id_has_multiple_l build_location_chains(destination_steps) +def test_same_destination_sequence_can_have_one_chain_per_utility_profile(): + destination_steps = pl.DataFrame( + { + "demand_group_id": [1, 1, 2, 2], + "demand_subgroup_id": [0, 0, 0, 0], + "utility_profile_id": [0, 0, 1, 1], + "activity_seq_id": [10, 10, 10, 10], + "time_seq_id": [20, 20, 20, 20], + "dest_seq_id": [30, 30, 30, 30], + "seq_step_index": [1, 2, 1, 2], + "from": [100, 200, 300, 400], + } + ) + + _, unique_chains = build_location_chains(destination_steps) + + assert unique_chains.sort("utility_profile_id").to_dicts() == [ + { + "utility_profile_id": 0, + "dest_seq_id": 30, + "locations": [100, 200], + }, + { + "utility_profile_id": 1, + "dest_seq_id": 30, + "locations": [300, 400], + }, + ] + + def test_run_python_mode_sequence_search_subprocess_serializes_inputs_for_worker(monkeypatch, tmp_path): parameters = _mode_sequence_parameters(k_mode_sequences=7) unique_destination_chains = pl.DataFrame({"dest_seq_id": [1], "locations": [[101, 202, 303]]}) @@ -288,6 +318,72 @@ def fake_search_mode_sequences(**kwargs): assert captured["k_sequences"] == 7 +def test_run_rust_mode_sequence_search_keeps_profiles_in_one_package_call(monkeypatch): + captured = {} + expected = pl.DataFrame( + { + "utility_profile_id": [0, 1], + "dest_seq_id": [1, 1], + "mode_seq_index": [0, 0], + "seq_step_index": [1, 1], + "location": [2, 2], + "mode_index": [0, 1], + } + ) + + def fake_search_mode_sequences(**kwargs): + captured.update(kwargs) + return expected + + monkeypatch.setitem( + __import__("sys").modules, + "mobility_mode_sequence_search", + SimpleNamespace(search_mode_sequences=fake_search_mode_sequences), + ) + + result = run_rust_mode_sequence_search( + unique_destination_chains=pl.DataFrame( + { + "utility_profile_id": [0, 1], + "dest_seq_id": [1, 1], + "locations": [[1, 2], [1, 2]], + } + ), + leg_mode_costs=pl.DataFrame( + { + "utility_profile_id": [0, 1], + "from": [1, 1], + "to": [2, 2], + "mode_id": [0, 1], + "cost": [1.0, 2.0], + } + ), + needs_vehicle_by_id={0: False, 1: False}, + return_mode_id_by_id={0: None, 1: None}, + is_return_mode_by_id={0: False, 1: False}, + modes_by_name={ + "walk": { + "vehicle": None, + "multimodal": False, + "is_return_mode": False, + "return_mode": None, + }, + "bike": { + "vehicle": None, + "multimodal": False, + "is_return_mode": False, + "return_mode": None, + }, + }, + mode_name_by_id={0: "walk", 1: "bike"}, + k_mode_sequences=3, + ) + + assert result.equals(expected) + assert captured["location_chain_steps"]["utility_profile_id"].to_list() == [0, 1] + assert captured["leg_mode_costs"]["utility_profile_id"].to_list() == [0, 1] + + def test_python_and_rust_mode_sequence_backends_match_on_same_inputs(tmp_path): unique_destination_chains = pl.DataFrame({"dest_seq_id": [1], "locations": [[1, 2]]}) leg_mode_costs = pl.DataFrame( 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 c854f660..06014d56 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 @@ -11,6 +11,7 @@ ) from mobility.trips.group_day_trips.plans.destination_sequences import DestinationSequences from mobility.trips.group_day_trips.plans.destination_plan_search import ( + _prepare_od_costs, sample_destination_plans, ) from mobility.trips.group_day_trips.plans.demand_subgroups import demand_unit_hash @@ -37,6 +38,25 @@ def test_destination_plan_search_does_not_use_legacy_alpha(): assert parameters.alpha == 0.0 +def test_destination_cost_aggregation_keeps_utility_profiles(): + costs = pl.DataFrame( + { + "utility_profile_id": [0, 0, 1, 1], + "from": [1, 1, 1, 1], + "to": [2, 2, 2, 2], + "cost": [1.0, 3.0, 5.0, 1.0], + "time": [1.0, 3.0, 5.0, 1.0], + } + ) + + result = _prepare_od_costs(costs, logit_scale=1.0).sort( + "utility_profile_id" + ) + + assert result["utility_profile_id"].to_list() == [0, 1] + assert result["cost"][0] != result["cost"][1] + + def test_plan_choice_logit_scale_is_part_of_destination_cache_key(tmp_path): parameters = GroupDayTripsParameters( destination_sequences=GroupDayTripsDestinationSequenceParameters( diff --git a/tests/back/unit/domain/group_day_trips/test_017_demand_subgroups.py b/tests/back/unit/domain/group_day_trips/test_017_demand_subgroups.py index 4f10e43d..2d50352d 100644 --- a/tests/back/unit/domain/group_day_trips/test_017_demand_subgroups.py +++ b/tests/back/unit/domain/group_day_trips/test_017_demand_subgroups.py @@ -9,9 +9,11 @@ _state_cache_paths, _write_run_state, ) +from mobility.runtime.population_segments import PopulationSegment from mobility.trips.group_day_trips.plans.candidate_plan_steps import CandidatePlanStepsAsset from mobility.trips.group_day_trips.plans.demand_subgroups import ( demand_unit_hash, + split_demand_groups, split_large_demand_groups, ) @@ -109,6 +111,86 @@ def test_split_large_demand_groups_owns_subgroup_creation(): ) +def test_population_share_is_split_before_maximum_subgroup_size(): + demand_groups = pl.DataFrame( + { + "demand_group_id": [1], + "csp": ["8a"], + "home_zone_id": [30], + "n_persons": [100.0], + }, + schema={ + "demand_group_id": pl.UInt32, + "csp": pl.String, + "home_zone_id": pl.Int32, + "n_persons": pl.Float64, + }, + ) + + result = split_demand_groups( + demand_groups, + population_segments=[ + PopulationSegment(name="pupils", csp="8a"), + PopulationSegment( + name="localist_pupils", + csp="8a", + share=0.3, + ), + ], + max_persons_per_demand_subgroup=25, + ) + + assert result.select( + "demand_subgroup_id", "n_persons", "population_segments" + ).to_dicts() == [ + { + "demand_subgroup_id": 0, + "n_persons": 15.0, + "population_segments": ["pupils", "localist_pupils"], + }, + { + "demand_subgroup_id": 1, + "n_persons": 15.0, + "population_segments": ["pupils", "localist_pupils"], + }, + { + "demand_subgroup_id": 2, + "n_persons": 23.333333333333332, + "population_segments": ["pupils"], + }, + { + "demand_subgroup_id": 3, + "n_persons": 23.333333333333332, + "population_segments": ["pupils"], + }, + { + "demand_subgroup_id": 4, + "n_persons": 23.333333333333332, + "population_segments": ["pupils"], + }, + ] + assert result["n_persons"].sum() == pytest.approx(100.0) + + +def test_population_segment_rejects_unavailable_selector_column(): + demand_groups = pl.DataFrame( + { + "demand_group_id": [1], + "csp": ["8a"], + "n_persons": [10.0], + } + ) + + with pytest.raises(ValueError, match="home_zone_id"): + split_demand_groups( + demand_groups, + population_segments=[ + PopulationSegment(name="zone_30", home_zone_id=30) + ], + max_persons_per_demand_subgroup=None, + ) + + def test_validate_cached_table_reports_schema_diff(tmp_path): table = pl.DataFrame( { diff --git a/tests/back/unit/domain/population/test_029_init_builds_inputs_and_cache.py b/tests/back/unit/domain/population/test_029_init_builds_inputs_and_cache.py index bc7277fe..cd308f7a 100644 --- a/tests/back/unit/domain/population/test_029_init_builds_inputs_and_cache.py +++ b/tests/back/unit/domain/population/test_029_init_builds_inputs_and_cache.py @@ -2,6 +2,7 @@ from pathlib import Path import mobility.population.population as population_module +from mobility import PopulationSegment def test_init_sets_inputs_and_hashed_cache_paths(project_dir, fake_inputs_hash, fake_transport_zones): @@ -29,3 +30,18 @@ def test_init_sets_inputs_and_hashed_cache_paths(project_dir, fake_inputs_hash, assert population_groups_cache_path.name.startswith(f"{fake_inputs_hash}-") assert population_groups_cache_path.name.endswith("population_groups.parquet") + + +def test_population_owns_population_segments( + project_dir, + fake_transport_zones, +): + pupils = PopulationSegment(name="pupils", csp="8a") + + population = population_module.Population( + transport_zones=fake_transport_zones, + sample_size=10, + population_segments=[pupils], + ) + + assert population.population_segments == [pupils] diff --git a/tests/back/unit/runtime/test_001_parameter_values.py b/tests/back/unit/runtime/test_001_parameter_values.py index cac1872a..adc94eaf 100644 --- a/tests/back/unit/runtime/test_001_parameter_values.py +++ b/tests/back/unit/runtime/test_001_parameter_values.py @@ -6,6 +6,10 @@ SensitivityValue, resolve_parameter_values, ) +from mobility.runtime.population_segments import ( + PopulationSegment, + resolve_population_segment_values, +) def test_parameter_value_accepts_scenario_mapping_with_non_identifier_names(): @@ -143,3 +147,106 @@ def test_sensitivity_value_inside_scenario_iteration_value(): iteration=5, sensitivity_case=case, ) == pytest.approx(0.144) + + +def test_population_segment_value_composes_with_iteration_values(): + value = ParameterValue.by_population_segment( + default=ParameterValue.by_iteration({1: 1.0, 5: 1.2}), + segment_values={ + "localist_pupils": ParameterValue.by_scenario( + default=2.0, + school_policy=2.5, + ) + }, + ) + resolved = resolve_parameter_values( + value, + scenario="school_policy", + iteration=5, + ) + + assert resolve_population_segment_values( + resolved, + memberships=set(), + segments=[ + PopulationSegment(name="localist_pupils", csp="8a", share=0.3) + ], + ) == 1.2 + assert resolve_population_segment_values( + resolved, + memberships={"localist_pupils"}, + segments=[ + PopulationSegment(name="localist_pupils", csp="8a", share=0.3) + ], + ) == 2.5 + + +def test_most_specific_population_segment_value_wins(): + segments = [ + PopulationSegment(name="pupils", csp=["8a", "8b"]), + PopulationSegment(name="csp_8a", csp="8a"), + PopulationSegment(name="zone_30_pupils", csp="8a", home_zone_id=30), + ] + value = ParameterValue.by_population_segment( + default=1.0, + segment_values={ + "pupils": 2.0, + "csp_8a": 3.0, + "zone_30_pupils": 4.0, + }, + ) + + assert resolve_population_segment_values( + value, + memberships={segment.name for segment in segments}, + segments=segments, + ) == 4.0 + + +def test_incomparable_population_segment_values_are_rejected(): + segments = [ + PopulationSegment(name="pupils", csp="8a"), + PopulationSegment(name="zone_30", home_zone_id=30), + ] + value = ParameterValue.by_population_segment( + default=1.0, + segment_values={"pupils": 2.0, "zone_30": 3.0}, + ) + + with pytest.raises(ValueError, match="ambiguous"): + resolve_population_segment_values( + value, + memberships={"pupils", "zone_30"}, + segments=segments, + ) + + +def test_population_segment_value_rejects_unknown_segment_name(): + value = ParameterValue.by_population_segment( + default=1.0, + segment_values={"missing": 2.0}, + ) + + with pytest.raises(ValueError, match="undefined population segments: missing"): + resolve_population_segment_values( + value, + memberships=set(), + segments=[PopulationSegment(name="pupils", csp="8a")], + ) + + +def test_partial_share_value_is_more_specific_than_its_full_segment(): + segments = [ + PopulationSegment(name="pupils", csp="8a"), + PopulationSegment(name="localist_pupils", csp="8a", share=0.3), + ] + value = ParameterValue.by_population_segment( + default=1.0, + segment_values={"pupils": 2.0, "localist_pupils": 3.0}, + ) + + assert resolve_population_segment_values( + value, + memberships={"pupils", "localist_pupils"}, + segments=segments, + ) == 3.0