From a740293e477b2971d7419e5ac43abe6b009dde30 Mon Sep 17 00:00:00 2001 From: FlxPo Date: Thu, 27 Aug 2026 15:50:56 +0200 Subject: [PATCH] Add scenario-aware speed modifiers --- docs/source/api_reference.md | 13 +- docs/source/scenarios.md | 25 ++ mobility/__init__.py | 4 + mobility/transport/costs/generalized_cost.py | 43 +++ .../costs/path/path_generalized_cost.py | 12 +- .../transport/costs/path/path_travel_costs.py | 65 ++++ .../transport/costs/travel_costs_asset.py | 13 + mobility/transport/graphs/__init__.py | 8 + .../transport/graphs/modified/__init__.py | 8 + .../graphs/modified/modifiers/__init__.py | 8 + .../modified/modifiers/speed_modifier.py | 348 ++++++++++++------ .../detailed_carpool_generalized_cost.py | 14 +- .../transport/modes/core/transport_mode.py | 83 +++-- .../public_transport_generalized_cost.py | 18 +- ...est_002_speed_modifier_parameter_values.py | 238 ++++++++++++ .../test_003_public_transport_mode.py | 10 +- 16 files changed, 765 insertions(+), 145 deletions(-) create mode 100644 mobility/transport/costs/generalized_cost.py create mode 100644 tests/back/unit/domain/transport_graphs/test_002_speed_modifier_parameter_values.py diff --git a/docs/source/api_reference.md b/docs/source/api_reference.md index 09cd1231..7738c694 100644 --- a/docs/source/api_reference.md +++ b/docs/source/api_reference.md @@ -351,7 +351,18 @@ Network modifiers describe project-specific changes to road-network assumptions: - `mobility.NewRoadModifier` - `mobility.RoadLaneNumberModifier` -Use them only when the scenario changes the road network or speed assumptions. Document the geometry, speed, capacity, or lane-number assumption with the scenario. +Their geometry, speed, capacity, lane-number, border-penalty, and extract-date +arguments accept `mobility.ParameterValue`. Mobility resolves these values for +each scenario and iteration before preparing the corresponding road graph. See +[scenarios](scenarios.md) for an example. + +Each modifier also accepts a pre-built parameter model when a project keeps its +configuration separate from mode construction: + +- `mobility.BorderCrossingSpeedModifierParameters` +- `mobility.LimitedSpeedZonesModifierParameters` +- `mobility.NewRoadModifierParameters` +- `mobility.RoadLaneNumberModifierParameters` ## Group-Day-Trip Model diff --git a/docs/source/scenarios.md b/docs/source/scenarios.md index 7fa48c79..0efce092 100644 --- a/docs/source/scenarios.md +++ b/docs/source/scenarios.md @@ -59,6 +59,31 @@ In this example: This pattern is useful when a scenario assumption should appear after a few warm-up iterations. Remember that the model still replans during those warm-up iterations unless the run parameters restrict behaviour-change phases. +Road speed modifiers use the same pattern. For example, this changes a speed +limit from iteration 5 in one scenario: + +```python +speed_zone = mobility.LimitedSpeedZonesModifier( + zones_geometry_file_path="inputs/speed-zones.gpkg", + max_speed=mobility.ParameterValue.by_scenario_and_iteration( + default=50.0, + safer_streets={ + 1: 50.0, + 5: 30.0, + }, + ), +) + +car = mobility.CarMode( + transport_zones, + speed_modifiers=[speed_zone], +) +``` + +All speed-modifier settings can vary this way, including GIS file paths. When +a selected value changes, Mobility prepares a matching modified road graph and +reuses it for later runs with the same inputs. + ## Complete Small Scenario Example This example declares a reference and a car-cost scenario, changes car distance cost from iteration 5, runs both scenarios, then compares final distance by mode. diff --git a/mobility/__init__.py b/mobility/__init__.py index 39e1e846..f9ad8440 100644 --- a/mobility/__init__.py +++ b/mobility/__init__.py @@ -86,7 +86,11 @@ from .transport.graphs.modified.modifiers import ( BorderCrossingSpeedModifier, + BorderCrossingSpeedModifierParameters, LimitedSpeedZonesModifier, + LimitedSpeedZonesModifierParameters, NewRoadModifier, + NewRoadModifierParameters, RoadLaneNumberModifier, + RoadLaneNumberModifierParameters, ) diff --git a/mobility/transport/costs/generalized_cost.py b/mobility/transport/costs/generalized_cost.py new file mode 100644 index 00000000..520d9e06 --- /dev/null +++ b/mobility/transport/costs/generalized_cost.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +from mobility.runtime.assets.in_memory_asset import InMemoryAsset +from mobility.runtime.parameter_values import ( + SensitivityCase, + resolve_parameter_values, +) + + +class GeneralizedCost(InMemoryAsset): + """Base class for generalized costs that can vary during a model run.""" + + def for_iteration( + self, + iteration: int, + *, + travel_costs, + scenario: str | None = None, + sensitivity_case: SensitivityCase | None = None, + ) -> "GeneralizedCost": + """Return a generalized-cost variant with resolved parameter values.""" + resolved_inputs = resolve_parameter_values( + self.inputs, + scenario=scenario, + iteration=iteration, + sensitivity_case=sensitivity_case, + ) + resolved_inputs["travel_costs"] = travel_costs + + if ( + resolved_inputs == self.inputs + and travel_costs is self.inputs["travel_costs"] + ): + return self + + return self._from_resolved_inputs(resolved_inputs) + + def _from_resolved_inputs(self, inputs: dict) -> "GeneralizedCost": + """Create the concrete generalized cost from resolved inputs.""" + raise NotImplementedError( + f"{self.__class__.__name__} should implement " + "_from_resolved_inputs()." + ) diff --git a/mobility/transport/costs/path/path_generalized_cost.py b/mobility/transport/costs/path/path_generalized_cost.py index 7b995d76..6979bbb2 100644 --- a/mobility/transport/costs/path/path_generalized_cost.py +++ b/mobility/transport/costs/path/path_generalized_cost.py @@ -2,10 +2,10 @@ import pandas as pd -from mobility.runtime.assets.in_memory_asset import InMemoryAsset +from mobility.transport.costs.generalized_cost import GeneralizedCost from mobility.transport.costs.od_flows_asset import VehicleODFlowsAsset -class PathGeneralizedCost(InMemoryAsset): +class PathGeneralizedCost(GeneralizedCost): def __init__(self, travel_costs, parameters, mode_name): inputs = { @@ -14,6 +14,14 @@ def __init__(self, travel_costs, parameters, mode_name): "mode_name": mode_name } super().__init__(inputs) + + def _from_resolved_inputs(self, inputs: dict) -> "PathGeneralizedCost": + """Create a path generalized cost from resolved inputs.""" + return PathGeneralizedCost( + travel_costs=inputs["travel_costs"], + parameters=inputs["parameters"], + mode_name=inputs["mode_name"], + ) def get( diff --git a/mobility/transport/costs/path/path_travel_costs.py b/mobility/transport/costs/path/path_travel_costs.py index 35dc30a5..ce54d171 100644 --- a/mobility/transport/costs/path/path_travel_costs.py +++ b/mobility/transport/costs/path/path_travel_costs.py @@ -10,6 +10,10 @@ from mobility.transport.costs.travel_costs_asset import TravelCostsBase from mobility.runtime.assets.file_asset import FileAsset from mobility.runtime.assets.in_memory_asset import InMemoryAsset +from mobility.runtime.parameter_values import ( + SensitivityCase, + resolve_parameter_values, +) from mobility.runtime.r_integration.r_script_runner import RScriptRunner from mobility.spatial.transport_zones import TransportZones from mobility.transport.costs.parameters.path_routing_parameters import PathRoutingParameters @@ -194,6 +198,9 @@ def __init__( "contracted_path_graph": contracted_path_graph, "routing_parameters": routing_parameters, "osm_capacity_parameters": osm_capacity_parameters, + "congestion": bool(congestion), + "congestion_flows_scaling_factor": congestion_flows_scaling_factor, + "speed_modifiers": list(speed_modifiers), "target_max_vehicles_per_od_endpoint": target_max_vehicles_per_od_endpoint, "congestion_assignment_max_iterations": congestion_assignment_max_iterations, "congestion_assignment_max_gap": congestion_assignment_max_gap, @@ -202,6 +209,64 @@ def __init__( } super().__init__(inputs) + def for_iteration( + self, + iteration: int, + scenario: str | None = None, + sensitivity_case: SensitivityCase | None = None, + ) -> "PathTravelCosts": + """Return the path-cost variant selected for one iteration.""" + routing_parameters = self.inputs["routing_parameters"] + resolved_routing_parameters = resolve_parameter_values( + routing_parameters, + scenario=scenario, + iteration=iteration, + sensitivity_case=sensitivity_case, + ) + + speed_modifiers = self.inputs["speed_modifiers"] + resolved_speed_modifiers = [ + modifier.for_iteration( + iteration, + scenario=scenario, + sensitivity_case=sensitivity_case, + ) + for modifier in speed_modifiers + ] + + if ( + resolved_routing_parameters == routing_parameters + and resolved_speed_modifiers == speed_modifiers + ): + return self + + # Routing inputs define every graph below the simplified OSM graph. + # Constructing a variant aligns every downstream asset hash with the + # selected iteration. The files remain lazy and are only built on get(). + return PathTravelCosts( + mode_name=self.inputs["mode_name"], + transport_zones=self.inputs["transport_zones"], + routing_parameters=resolved_routing_parameters, + osm_capacity_parameters=self.inputs["osm_capacity_parameters"], + congestion=self.inputs["congestion"], + congestion_flows_scaling_factor=self.inputs[ + "congestion_flows_scaling_factor" + ], + target_max_vehicles_per_od_endpoint=self.inputs[ + "target_max_vehicles_per_od_endpoint" + ], + congestion_assignment_max_iterations=self.inputs[ + "congestion_assignment_max_iterations" + ], + congestion_assignment_max_gap=self.inputs[ + "congestion_assignment_max_gap" + ], + congestion_assignment_retained_volume_share=self.inputs[ + "congestion_assignment_retained_volume_share" + ], + speed_modifiers=resolved_speed_modifiers, + ) + @property def freeflow_costs(self): """CH cost table, created only if static costs are requested.""" diff --git a/mobility/transport/costs/travel_costs_asset.py b/mobility/transport/costs/travel_costs_asset.py index 784fadaf..5dfa06ad 100644 --- a/mobility/transport/costs/travel_costs_asset.py +++ b/mobility/transport/costs/travel_costs_asset.py @@ -4,6 +4,19 @@ class TravelCostsBase: """Shared helpers for travel-cost selectors and file-backed assets.""" + def for_iteration( + self, + iteration: int, + scenario: str | None = None, + sensitivity_case=None, + ): + """Return this travel-cost asset for one iteration. + + Travel costs with scenario-dependent routing inputs override this + method. Static travel costs can safely reuse the same asset. + """ + return self + def asset_for_road_flows(self, road_flow_asset): """Return the effective asset for one road-flow asset.""" return self diff --git a/mobility/transport/graphs/__init__.py b/mobility/transport/graphs/__init__.py index cd521398..1cc2cd84 100644 --- a/mobility/transport/graphs/__init__.py +++ b/mobility/transport/graphs/__init__.py @@ -3,22 +3,30 @@ from .contracted import ContractedPathGraph from .modified import ( BorderCrossingSpeedModifier, + BorderCrossingSpeedModifierParameters, LimitedSpeedZonesModifier, + LimitedSpeedZonesModifierParameters, NewRoadModifier, + NewRoadModifierParameters, RoadLaneNumberModifier, + RoadLaneNumberModifierParameters, SpeedModifier, ) from .simplified import SimplifiedPathGraph __all__ = [ "BorderCrossingSpeedModifier", + "BorderCrossingSpeedModifierParameters", "CongestedPathGraph", "ContractedPathGraph", "GraphGPKGExporter", "LimitedSpeedZonesModifier", + "LimitedSpeedZonesModifierParameters", "NewRoadModifier", + "NewRoadModifierParameters", "PathGraph", "RoadLaneNumberModifier", + "RoadLaneNumberModifierParameters", "SimplifiedPathGraph", "SpeedModifier", ] diff --git a/mobility/transport/graphs/modified/__init__.py b/mobility/transport/graphs/modified/__init__.py index c01b0c2b..457b464d 100644 --- a/mobility/transport/graphs/modified/__init__.py +++ b/mobility/transport/graphs/modified/__init__.py @@ -1,17 +1,25 @@ from .modified_path_graph import ModifiedPathGraph from .modifiers import ( BorderCrossingSpeedModifier, + BorderCrossingSpeedModifierParameters, LimitedSpeedZonesModifier, + LimitedSpeedZonesModifierParameters, NewRoadModifier, + NewRoadModifierParameters, RoadLaneNumberModifier, + RoadLaneNumberModifierParameters, SpeedModifier, ) __all__ = [ "BorderCrossingSpeedModifier", + "BorderCrossingSpeedModifierParameters", "LimitedSpeedZonesModifier", + "LimitedSpeedZonesModifierParameters", "ModifiedPathGraph", "NewRoadModifier", + "NewRoadModifierParameters", "RoadLaneNumberModifier", + "RoadLaneNumberModifierParameters", "SpeedModifier", ] diff --git a/mobility/transport/graphs/modified/modifiers/__init__.py b/mobility/transport/graphs/modified/modifiers/__init__.py index 1d977584..0eaa4aab 100644 --- a/mobility/transport/graphs/modified/modifiers/__init__.py +++ b/mobility/transport/graphs/modified/modifiers/__init__.py @@ -1,15 +1,23 @@ from .speed_modifier import ( BorderCrossingSpeedModifier, + BorderCrossingSpeedModifierParameters, LimitedSpeedZonesModifier, + LimitedSpeedZonesModifierParameters, NewRoadModifier, + NewRoadModifierParameters, RoadLaneNumberModifier, + RoadLaneNumberModifierParameters, SpeedModifier, ) __all__ = [ "BorderCrossingSpeedModifier", + "BorderCrossingSpeedModifierParameters", "LimitedSpeedZonesModifier", + "LimitedSpeedZonesModifierParameters", "NewRoadModifier", + "NewRoadModifierParameters", "RoadLaneNumberModifier", + "RoadLaneNumberModifierParameters", "SpeedModifier", ] diff --git a/mobility/transport/graphs/modified/modifiers/speed_modifier.py b/mobility/transport/graphs/modified/modifiers/speed_modifier.py index 0267131f..881ab452 100644 --- a/mobility/transport/graphs/modified/modifiers/speed_modifier.py +++ b/mobility/transport/graphs/modified/modifiers/speed_modifier.py @@ -1,42 +1,157 @@ -import pathlib import geopandas as gpd +import pathlib +from typing import Annotated + +from pydantic import BaseModel, ConfigDict, Field from mobility.runtime.assets.in_memory_asset import InMemoryAsset -from mobility.spatial.transport_zones import TransportZones +from mobility.runtime.parameter_values import ( + ParameterValue, + SensitivityCase, + SensitivityValue, + resolve_parameter_values, +) from mobility.spatial.osm import GeofabrikRegions, GeofabrikExtract, OSMCountryBorder +from mobility.spatial.transport_zones import TransportZones + + +VariableFloat = float | ParameterValue | SensitivityValue +VariableInt = int | ParameterValue | SensitivityValue +VariablePath = pathlib.Path | str | ParameterValue | SensitivityValue +VariableString = str | ParameterValue | SensitivityValue + + +class BorderCrossingSpeedModifierParameters(BaseModel): + """Parameters used to change speeds at national borders.""" + + model_config = ConfigDict(extra="forbid") + + max_speed: Annotated[VariableFloat, Field(default=30.0)] + time_penalty: Annotated[VariableFloat, Field(default=0.0)] + geofabrik_extract_date: Annotated[VariableString, Field(default="240101")] + + +class LimitedSpeedZonesModifierParameters(BaseModel): + """Parameters used to apply a speed limit inside selected zones.""" + + model_config = ConfigDict(extra="forbid") + + zones_geometry_file_path: VariablePath + max_speed: Annotated[VariableFloat, Field(default=30.0)] + + +class RoadLaneNumberModifierParameters(BaseModel): + """Parameters used to change the lane count inside selected zones.""" + + model_config = ConfigDict(extra="forbid") + + zones_geometry_file_path: VariablePath + lane_delta: Annotated[VariableInt, Field(default=0)] + + +class NewRoadModifierParameters(BaseModel): + """Parameters used to add a road from a line geometry.""" + + model_config = ConfigDict(extra="forbid") + + zones_geometry_file_path: VariablePath + max_speed: Annotated[VariableFloat, Field(default=30.0)] + capacity: Annotated[VariableFloat, Field(default=1800.0)] + alpha: Annotated[VariableFloat, Field(default=0.15)] + beta: Annotated[VariableFloat, Field(default=4.0)] + class SpeedModifier(InMemoryAsset): - def __init__(self, inputs): + """Base class for road graph changes that can vary during a run.""" + + def __init__(self, inputs: dict): super().__init__(inputs) - + parameters = inputs["parameters"] + for field_name in parameters.__class__.model_fields: + setattr(self, field_name, getattr(parameters, field_name)) + + def for_iteration( + self, + iteration: int, + scenario: str | None = None, + sensitivity_case: SensitivityCase | None = None, + ) -> "SpeedModifier": + """Return this modifier with plain values for one run iteration.""" + parameters = self.inputs["parameters"] + resolved_parameters = resolve_parameter_values( + parameters, + scenario=scenario, + iteration=iteration, + sensitivity_case=sensitivity_case, + ) + if resolved_parameters == parameters: + return self + + constructor_inputs = { + name: value + for name, value in self.inputs.items() + if name != "parameters" + } + return self.__class__( + **constructor_inputs, + parameters=resolved_parameters, + ) + + def _check_parameters_are_resolved(self) -> None: + """Fail clearly when a modifier is used before selecting run values.""" + unresolved_types = (ParameterValue, SensitivityValue) + unresolved_fields = [ + field_name + for field_name in self.parameters.__class__.model_fields + if isinstance(getattr(self.parameters, field_name), unresolved_types) + ] + if unresolved_fields: + raise ValueError( + f"{self.__class__.__name__} has unresolved values for " + f"{unresolved_fields}. Resolve it with for_iteration(...) " + "before preparing the path graph." + ) + class BorderCrossingSpeedModifier(SpeedModifier): - + def __init__( - self, - transport_zones: TransportZones, - max_speed: float = 30.0, - time_penalty: float = 0.0, - geofabrik_extract_date: str = "240101" - ): - """ - Args: - - max_speed (float): maximum speed of border crossing (km/h). - - time_penalty (float): fixed time penalty of border crossing (min). - """ + self, + transport_zones: TransportZones, + max_speed: VariableFloat | None = None, + time_penalty: VariableFloat | None = None, + geofabrik_extract_date: VariableString | None = None, + parameters: BorderCrossingSpeedModifierParameters | None = None, + ): + """Create a national-border speed modifier. + Args: + transport_zones: Transport zones whose study boundary is inspected. + max_speed: Maximum border-crossing speed, in km/h. + time_penalty: Fixed border-crossing time penalty, in minutes. + geofabrik_extract_date: Geofabrik extract date in ``YYMMDD`` format. + parameters: Optional pre-built parameter model. + """ self.modifier_type = "border_crossing" - self.inputs = { + parameters = self.prepare_parameters( + parameters=parameters, + parameters_cls=BorderCrossingSpeedModifierParameters, + explicit_args={ + "max_speed": max_speed, + "time_penalty": time_penalty, + "geofabrik_extract_date": geofabrik_extract_date, + }, + owner_name="BorderCrossingSpeedModifier", + ) + inputs = { "transport_zones": transport_zones, - "max_speed": max_speed, - "time_penalty": time_penalty, - "geofabrik_extract_date": geofabrik_extract_date + "parameters": parameters, } + super().__init__(inputs) - super().__init__(self.inputs) - def get(self): - + """Return the plain values consumed by the graph modification script.""" + self._check_parameters_are_resolved() transport_zones = self.inputs["transport_zones"] transport_zones.get() boundary = gpd.read_file(transport_zones.inputs["study_area"].cache_path["boundary"]).geometry[0] @@ -52,7 +167,9 @@ def get(self): """ ) - regions = GeofabrikRegions(extract_date=self.inputs["geofabrik_extract_date"]).get() + regions = GeofabrikRegions( + extract_date=self.parameters.geofabrik_extract_date + ).get() regions = regions[regions.intersects(boundary)] borders = [] @@ -74,125 +191,140 @@ def get(self): return { "modifier_type": self.modifier_type, - "max_speed": self.inputs["max_speed"], - "time_penalty": self.inputs["time_penalty"], + "max_speed": self.parameters.max_speed, + "time_penalty": self.parameters.time_penalty, "borders": borders, "has_borders": has_borders } - - + class LimitedSpeedZonesModifier(SpeedModifier): def __init__( - self, - zones_geometry_file_path: pathlib.Path | str, - max_speed: float = 30.0 - ): - """ - Args: - - zones_geometry_file_path (pathlib.Path | str): - Path to a GIS file (geojson, gpkg, shp...) that defines the - geometries of the zones in which the max speed should be set. - - max_speed (float): maximum speed of border crossing (km/h). - """ + self, + zones_geometry_file_path: VariablePath | None = None, + max_speed: VariableFloat | None = None, + parameters: LimitedSpeedZonesModifierParameters | None = None, + ): + """Create a speed limit inside one or more geometries. + Args: + zones_geometry_file_path: GIS file containing the affected zones. + max_speed: Maximum speed inside the zones, in km/h. + parameters: Optional pre-built parameter model. + """ self.modifier_type = "limited_speed_zones" - self.inputs = { - "max_speed": max_speed, - "zones_geometry_file_path": zones_geometry_file_path - } + parameters = self.prepare_parameters( + parameters=parameters, + parameters_cls=LimitedSpeedZonesModifierParameters, + explicit_args={ + "zones_geometry_file_path": zones_geometry_file_path, + "max_speed": max_speed, + }, + required_fields=["zones_geometry_file_path"], + owner_name="LimitedSpeedZonesModifier", + ) + super().__init__({"parameters": parameters}) - super().__init__(self.inputs) - def get(self): - + """Return the plain values consumed by the graph modification script.""" + self._check_parameters_are_resolved() return { "modifier_type": self.modifier_type, - "max_speed": self.max_speed, - "zones_geometry_file_path": self.zones_geometry_file_path + "max_speed": self.parameters.max_speed, + "zones_geometry_file_path": str( + self.parameters.zones_geometry_file_path + ), } - class RoadLaneNumberModifier(SpeedModifier): def __init__( - self, - zones_geometry_file_path: pathlib.Path | str, - lane_delta: int = 0.0 - ): - """ - Args: - - zones_geometry_file_path (pathlib.Path | str): - Path to a GIS file (geojson, gpkg, shp...) that defines the - geometries of the zones in which the road capacity should be - modified. - - lane_delta (int): road lane number variation (the minimum - number of lanes is set to one lane, so no road would be - "closed" by a lane number modification). - """ + self, + zones_geometry_file_path: VariablePath | None = None, + lane_delta: VariableInt | None = None, + parameters: RoadLaneNumberModifierParameters | None = None, + ): + """Create a lane count change inside one or more geometries. + Args: + zones_geometry_file_path: GIS file containing the affected zones. + lane_delta: Number of lanes added or removed from affected roads. + parameters: Optional pre-built parameter model. + """ self.modifier_type = "lane_number_modification" - self.inputs = { - "lane_delta": lane_delta, - "zones_geometry_file_path": zones_geometry_file_path - } + parameters = self.prepare_parameters( + parameters=parameters, + parameters_cls=RoadLaneNumberModifierParameters, + explicit_args={ + "zones_geometry_file_path": zones_geometry_file_path, + "lane_delta": lane_delta, + }, + required_fields=["zones_geometry_file_path"], + owner_name="RoadLaneNumberModifier", + ) + super().__init__({"parameters": parameters}) - super().__init__(self.inputs) - def get(self): - + """Return the plain values consumed by the graph modification script.""" + self._check_parameters_are_resolved() return { "modifier_type": self.modifier_type, - "lane_delta": self.lane_delta, - "zones_geometry_file_path": self.zones_geometry_file_path + "lane_delta": self.parameters.lane_delta, + "zones_geometry_file_path": str( + self.parameters.zones_geometry_file_path + ), } - - class NewRoadModifier(SpeedModifier): def __init__( - self, - zones_geometry_file_path: pathlib.Path | str, - max_speed: float = 30.0, - capacity: float = 1800.0, - alpha: float = 0.15, - beta: float = 4.0 - ): - """ - Args: - - zones_geometry_file_path (pathlib.Path | str): - Path to a GIS file (geojson, gpkg, shp...) that defines the - geometries of the zones in which the max speed should be set. - - max_speed (float): max speed of the new road. - - capacity (float): capacity (vehicles/h) of the new road. - - alpha (float): - Parameter alpha of the volume decay function of the new road. - - beta (float): - Parameter beta of the volume decay function of the new road. - """ + self, + zones_geometry_file_path: VariablePath | None = None, + max_speed: VariableFloat | None = None, + capacity: VariableFloat | None = None, + alpha: VariableFloat | None = None, + beta: VariableFloat | None = None, + parameters: NewRoadModifierParameters | None = None, + ): + """Create a road from a line geometry. + Args: + zones_geometry_file_path: GIS file containing the new road line. + max_speed: Maximum speed of the new road, in km/h. + capacity: Capacity of the new road, in vehicles per hour. + alpha: Alpha parameter of the volume-delay function. + beta: Beta parameter of the volume-delay function. + parameters: Optional pre-built parameter model. + """ self.modifier_type = "new_road" - self.inputs = { - "capacity": capacity, - "alpha": alpha, - "beta": beta, - "max_speed": max_speed, - "zones_geometry_file_path": zones_geometry_file_path - } + parameters = self.prepare_parameters( + parameters=parameters, + parameters_cls=NewRoadModifierParameters, + explicit_args={ + "zones_geometry_file_path": zones_geometry_file_path, + "max_speed": max_speed, + "capacity": capacity, + "alpha": alpha, + "beta": beta, + }, + required_fields=["zones_geometry_file_path"], + owner_name="NewRoadModifier", + ) + super().__init__({"parameters": parameters}) - super().__init__(self.inputs) - def get(self): - + """Return the plain values consumed by the graph modification script.""" + self._check_parameters_are_resolved() return { "modifier_type": self.modifier_type, - "capacity": self.capacity, - "alpha": self.alpha, - "beta": self.beta, - "max_speed": self.max_speed, - "zones_geometry_file_path": self.zones_geometry_file_path + "capacity": self.parameters.capacity, + "alpha": self.parameters.alpha, + "beta": self.parameters.beta, + "max_speed": self.parameters.max_speed, + "zones_geometry_file_path": str( + self.parameters.zones_geometry_file_path + ), } 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 2251e55b..2a8825f3 100644 --- a/mobility/transport/modes/carpool/detailed/detailed_carpool_generalized_cost.py +++ b/mobility/transport/modes/carpool/detailed/detailed_carpool_generalized_cost.py @@ -5,13 +5,13 @@ from typing import Annotated 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.generalized_cost import GeneralizedCost from mobility.transport.costs.od_flows_asset import VehicleODFlowsAsset from mobility.transport.costs.parameters.cost_of_time_parameters import CostOfTimeParameters -class DetailedCarpoolGeneralizedCost(InMemoryAsset): +class DetailedCarpoolGeneralizedCost(GeneralizedCost): def __init__(self, travel_costs, parameters): inputs = { @@ -19,6 +19,16 @@ def __init__(self, travel_costs, parameters): "parameters": parameters } super().__init__(inputs) + + def _from_resolved_inputs( + self, + inputs: dict, + ) -> "DetailedCarpoolGeneralizedCost": + """Create a detailed carpool generalized cost from resolved inputs.""" + return DetailedCarpoolGeneralizedCost( + travel_costs=inputs["travel_costs"], + parameters=inputs["parameters"], + ) def get( diff --git a/mobility/transport/modes/core/transport_mode.py b/mobility/transport/modes/core/transport_mode.py index f3784a98..a29a76a4 100644 --- a/mobility/transport/modes/core/transport_mode.py +++ b/mobility/transport/modes/core/transport_mode.py @@ -1,7 +1,9 @@ from typing import Annotated, List from mobility.runtime.assets.in_memory_asset import InMemoryAsset -from mobility.runtime.parameter_values import SensitivityCase, resolve_parameter_values +from mobility.runtime.parameter_values import SensitivityCase +from mobility.transport.costs.generalized_cost import GeneralizedCost +from mobility.transport.costs.travel_costs_asset import TravelCostsBase from pydantic import BaseModel, ConfigDict, Field class TransportMode(InMemoryAsset): @@ -100,38 +102,69 @@ def for_iteration( when they need to rebuild child modes or other derived assets. """ generalized_cost = self.inputs.get("generalized_cost") - if not isinstance(generalized_cost, InMemoryAsset): + if not isinstance(generalized_cost, GeneralizedCost): return self - # Keep routing assets untouched here. They often own derived table - # assets, so modes that vary routing should rebuild themselves. - resolved_gc_inputs = resolve_parameter_values( - generalized_cost.inputs, + travel_costs = self.inputs.get("travel_costs") + if isinstance(travel_costs, TravelCostsBase): + resolved_travel_costs = travel_costs.for_iteration( + iteration, + scenario=scenario, + sensitivity_case=sensitivity_case, + ) + else: + resolved_travel_costs = travel_costs + + resolved_generalized_cost = generalized_cost.for_iteration( + iteration, + travel_costs=resolved_travel_costs, scenario=scenario, - iteration=iteration, sensitivity_case=sensitivity_case, ) - if resolved_gc_inputs == generalized_cost.inputs: + if ( + resolved_generalized_cost is generalized_cost + and resolved_travel_costs is travel_costs + ): return self - resolved_generalized_cost = self._copy_in_memory_asset( - generalized_cost, - resolved_gc_inputs, + return TransportModeVariant( + source_mode=self, + travel_costs=resolved_travel_costs, + generalized_cost=resolved_generalized_cost, ) - resolved_inputs = dict(self.inputs) - resolved_inputs["generalized_cost"] = resolved_generalized_cost - return self._copy_in_memory_asset(self, resolved_inputs) - - @staticmethod - def _copy_in_memory_asset(asset: InMemoryAsset, inputs: dict): - """Copy an in-memory asset with new inputs and a matching input hash.""" - clone = asset.__class__.__new__(asset.__class__) - clone.__dict__ = dict(asset.__dict__) - clone.inputs = inputs - clone.inputs_hash = clone.compute_inputs_hash() - for name, value in inputs.items(): - setattr(clone, name, value) - return clone + + +class TransportModeVariant(TransportMode): + """Concrete mode assets selected for one scenario and iteration.""" + + def __init__( + self, + *, + source_mode: TransportMode, + travel_costs, + generalized_cost: GeneralizedCost, + ) -> None: + """Create a mode variant without copying state from the source mode.""" + self.source_mode = source_mode + parameters = source_mode.inputs["parameters"] + super().__init__( + name=parameters.name, + travel_costs=travel_costs, + generalized_cost=generalized_cost, + ghg_intensity=parameters.ghg_intensity, + congestion=parameters.congestion, + vehicle=parameters.vehicle, + multimodal=parameters.multimodal, + return_mode=parameters.return_mode, + survey_ids=parameters.survey_ids, + parameters=parameters, + parameters_cls=parameters.__class__, + ) + + def build_congestion_flows(self, od_flows_by_mode): + """Use the source mode's road-flow conversion for this variant.""" + return self.source_mode.build_congestion_flows(od_flows_by_mode) + class TransportModeParameters(BaseModel): """Common parameters for transport mode definitions.""" diff --git a/mobility/transport/modes/public_transport/public_transport_generalized_cost.py b/mobility/transport/modes/public_transport/public_transport_generalized_cost.py index 5a744727..b5d47ab1 100644 --- a/mobility/transport/modes/public_transport/public_transport_generalized_cost.py +++ b/mobility/transport/modes/public_transport/public_transport_generalized_cost.py @@ -2,10 +2,10 @@ import pandas as pd -from mobility.runtime.assets.in_memory_asset import InMemoryAsset from mobility.transport.costs.od_flows_asset import VehicleODFlowsAsset +from mobility.transport.costs.generalized_cost import GeneralizedCost -class PublicTransportGeneralizedCost(InMemoryAsset): +class PublicTransportGeneralizedCost(GeneralizedCost): def __init__( self, @@ -27,6 +27,20 @@ def __init__( } super().__init__(inputs) + + def _from_resolved_inputs( + self, + inputs: dict, + ) -> "PublicTransportGeneralizedCost": + """Create a public transport generalized cost from resolved inputs.""" + return PublicTransportGeneralizedCost( + travel_costs=inputs["travel_costs"], + first_leg_mode_name=inputs["first_leg_mode_name"], + last_leg_mode_name=inputs["last_leg_mode_name"], + start_parameters=inputs["start_parameters"], + mid_parameters=inputs["mid_parameters"], + last_parameters=inputs["last_parameters"], + ) def get( diff --git a/tests/back/unit/domain/transport_graphs/test_002_speed_modifier_parameter_values.py b/tests/back/unit/domain/transport_graphs/test_002_speed_modifier_parameter_values.py new file mode 100644 index 00000000..f34211e7 --- /dev/null +++ b/tests/back/unit/domain/transport_graphs/test_002_speed_modifier_parameter_values.py @@ -0,0 +1,238 @@ +import pathlib + +import pytest + +from mobility.runtime.assets.in_memory_asset import InMemoryAsset +from mobility.runtime.parameter_values import ParameterValue +from mobility.runtime.scenarios import collect_parameter_value_changes +from mobility.transport.costs.parameters.generalized_cost_parameters import ( + GeneralizedCostParameters, +) +from mobility.transport.costs.parameters.path_routing_parameters import ( + PathRoutingParameters, +) +from mobility.transport.graphs.modified.modifiers import speed_modifier +from mobility.transport.modes.bicycle import BicycleMode +from mobility.transport.modes.car import CarMode +from mobility.transport.modes.core.osm_capacity_parameters import OSMCapacityParameters + + +def test_speed_modifier_parameters_resolve_by_scenario_and_iteration(): + """Resolve every user-facing modifier value before it reaches the R script.""" + def project_value(default, changed): + return ParameterValue.by_scenario_and_iteration( + default=default, + road_project={1: default, 3: changed}, + ) + + transport_zones = InMemoryAsset({}) + modifiers = [ + speed_modifier.BorderCrossingSpeedModifier( + transport_zones, + max_speed=project_value(30.0, 20.0), + time_penalty=project_value(0.0, 5.0), + geofabrik_extract_date=project_value("260101", "270101"), + ), + speed_modifier.LimitedSpeedZonesModifier( + zones_geometry_file_path=project_value( + "current-speed-zones.gpkg", + "project-speed-zones.gpkg", + ), + max_speed=project_value(30.0, 20.0), + ), + speed_modifier.RoadLaneNumberModifier( + zones_geometry_file_path=project_value( + "current-lanes.gpkg", + "project-lanes.gpkg", + ), + lane_delta=project_value(0, -1), + ), + speed_modifier.NewRoadModifier( + zones_geometry_file_path=project_value( + "current-roads.gpkg", + "project-roads.gpkg", + ), + max_speed=project_value(30.0, 80.0), + capacity=project_value(1800.0, 2400.0), + alpha=project_value(0.15, 0.2), + beta=project_value(4.0, 5.0), + ), + ] + + resolved = [ + modifier.for_iteration(3, scenario="road_project") + for modifier in modifiers + ] + + assert resolved[0].max_speed == 20.0 + assert resolved[0].time_penalty == 5.0 + assert resolved[0].geofabrik_extract_date == "270101" + assert resolved[1].get() == { + "modifier_type": "limited_speed_zones", + "max_speed": 20.0, + "zones_geometry_file_path": "project-speed-zones.gpkg", + } + assert resolved[2].get() == { + "modifier_type": "lane_number_modification", + "lane_delta": -1, + "zones_geometry_file_path": "project-lanes.gpkg", + } + assert resolved[3].get() == { + "modifier_type": "new_road", + "capacity": 2400.0, + "alpha": 0.2, + "beta": 5.0, + "max_speed": 80.0, + "zones_geometry_file_path": "project-roads.gpkg", + } + + # Keep the setup reusable for every scenario and iteration. + assert isinstance(modifiers[1].max_speed, ParameterValue) + assert resolved[1].inputs_hash != modifiers[1].inputs_hash + + +def test_speed_modifier_changes_are_listed_in_the_scenario_manifest(): + """Expose modifier fields in the same scenario report as other parameters.""" + modifier = speed_modifier.LimitedSpeedZonesModifier( + zones_geometry_file_path="speed-zones.gpkg", + max_speed=ParameterValue.by_scenario(default=30.0, road_project=20.0), + ) + + changes = collect_parameter_value_changes({"modifier": modifier}) + + assert len(changes) == 1 + assert changes[0].path.endswith("['parameters'].max_speed") + assert changes[0].scenario_names == ("default", "road_project") + + +@pytest.mark.parametrize("congestion", [False, True]) +def test_path_travel_costs_rebuild_the_graph_chain_for_resolved_modifiers( + monkeypatch, + congestion, +): + """Bind every downstream graph to the modifier selected for an iteration.""" + + class FakePathGraph: + def __init__( + self, + mode_name, + transport_zones, + osm_capacity_parameters, + congestion, + congestion_flows_scaling_factor, + target_max_vehicles_per_od_endpoint, + congestion_assignment_max_iterations, + congestion_assignment_max_gap, + congestion_assignment_retained_volume_share, + speed_modifiers, + ): + self.simplified = InMemoryAsset({"mode_name": mode_name}) + self.modified = InMemoryAsset( + { + "mode_name": mode_name, + "speed_modifiers": speed_modifiers, + } + ) + self.contracted = InMemoryAsset({"modified_graph": self.modified}) + if congestion: + self.cch = InMemoryAsset({"modified_graph": self.modified}) + self.congested = InMemoryAsset( + { + "modified_graph": self.modified, + "cch_graph": self.cch, + } + ) + else: + self.cch = None + self.congested = None + + monkeypatch.setattr( + "mobility.transport.costs.path.path_travel_costs.PathGraph", + FakePathGraph, + ) + + max_speed = ParameterValue.by_scenario_and_iteration( + default=30.0, + road_project={1: 30.0, 3: 20.0}, + ) + modifier = speed_modifier.LimitedSpeedZonesModifier( + "speed-zones.gpkg", + max_speed=max_speed, + ) + mode_class = CarMode if congestion else BicycleMode + mode_name = "car" if congestion else "bicycle" + mode = mode_class( + transport_zones=InMemoryAsset({}), + routing_parameters=PathRoutingParameters(max_beeline_distance=20.0), + osm_capacity_parameters=OSMCapacityParameters(mode_name), + generalized_cost_parameters=GeneralizedCostParameters(), + speed_modifiers=[modifier], + **({"congestion": True} if congestion else {}), + ) + + initial_mode = mode.for_iteration(1, scenario="road_project") + resolved_mode = mode.for_iteration(3, scenario="road_project") + + assert initial_mode is not mode + assert resolved_mode is not mode + assert ( + initial_mode.inputs["travel_costs"].inputs["speed_modifiers"][0].max_speed + == 30.0 + ) + assert ( + initial_mode.inputs["travel_costs"].inputs_hash + != resolved_mode.inputs["travel_costs"].inputs_hash + ) + + resolved_travel_costs = resolved_mode.inputs["travel_costs"] + resolved_modifier = resolved_travel_costs.inputs["speed_modifiers"][0] + assert resolved_modifier.max_speed == 20.0 + assert ( + resolved_travel_costs.modified_path_graph.inputs["speed_modifiers"][0] + is resolved_modifier + ) + assert ( + resolved_travel_costs.contracted_path_graph.inputs["modified_graph"] + is resolved_travel_costs.modified_path_graph + ) + if congestion: + assert ( + resolved_travel_costs.cch_path_graph.inputs["modified_graph"] + is resolved_travel_costs.modified_path_graph + ) + assert ( + resolved_travel_costs.congested_path_graph.inputs["modified_graph"] + is resolved_travel_costs.modified_path_graph + ) + assert ( + resolved_mode.inputs["generalized_cost"].inputs["travel_costs"] + is resolved_travel_costs + ) + + +def test_speed_modifier_parameter_models_keep_scalar_defaults(): + """Keep existing scalar constructor behaviour after introducing models.""" + modifier = speed_modifier.NewRoadModifier(pathlib.Path("new-road.gpkg")) + + assert modifier.max_speed == 30.0 + assert modifier.capacity == 1800.0 + assert modifier.alpha == 0.15 + assert modifier.beta == 4.0 + assert modifier.get()["zones_geometry_file_path"] == "new-road.gpkg" + + +def test_speed_modifier_requires_a_geometry_path(): + """Give a plain error when a geometry-based modifier has no GIS file.""" + with pytest.raises(ValueError, match="zones_geometry_file_path"): + speed_modifier.LimitedSpeedZonesModifier() + + +def test_unresolved_speed_modifier_cannot_prepare_graph_inputs(): + """Explain that a run context is needed instead of leaking model objects.""" + modifier = speed_modifier.LimitedSpeedZonesModifier( + "speed-zones.gpkg", + max_speed=ParameterValue.by_scenario(default=30.0, project=20.0), + ) + + with pytest.raises(ValueError, match=r"for_iteration\(\.\.\.\)"): + modifier.get() diff --git a/tests/back/unit/domain/transport_modes/test_003_public_transport_mode.py b/tests/back/unit/domain/transport_modes/test_003_public_transport_mode.py index 0ac33ea3..553d3884 100644 --- a/tests/back/unit/domain/transport_modes/test_003_public_transport_mode.py +++ b/tests/back/unit/domain/transport_modes/test_003_public_transport_mode.py @@ -3,6 +3,7 @@ from mobility.transport.costs.parameters.generalized_cost_parameters import ( GeneralizedCostParameters, ) +from mobility.transport.costs.path.path_generalized_cost import PathGeneralizedCost from mobility.transport.modes.core.transport_mode import TransportMode from mobility.transport.modes.public_transport import public_transport as pt_module @@ -56,11 +57,10 @@ def test_public_transport_for_iteration_resolves_walk_leg_parameters(monkeypatch cost_of_distance=walk_distance_cost, ) walk_travel_costs = InMemoryAsset({"parameters": {}}) - walk_generalized_cost = InMemoryAsset( - { - "travel_costs": walk_travel_costs, - "parameters": walk_parameters, - } + walk_generalized_cost = PathGeneralizedCost( + travel_costs=walk_travel_costs, + parameters=walk_parameters, + mode_name="walk", ) walk_mode = TransportMode( name="walk",