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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/source/api_reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -393,7 +397,9 @@ Main parameter objects:
- `mobility.GroupDayTripsBehaviorChangeParameters`
- `mobility.GroupDayTripsDestinationSequenceParameters`
- `mobility.GroupDayTripsModeSequenceParameters`
- `mobility.GroupDayTripsDemandGroupParameters`
- `mobility.GroupDayTripsPlanUpdateParameters`
- `mobility.PopulationSegment`
- `mobility.BehaviorChangePhase`
- `mobility.BehaviorChangeScope`

Expand Down
39 changes: 39 additions & 0 deletions docs/source/population.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
82 changes: 82 additions & 0 deletions docs/source/run_parameters.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
8 changes: 7 additions & 1 deletion mobility/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
BehaviorChangeScope,
GroupDayTripsActivitySequenceParameters,
GroupDayTripsBehaviorChangeParameters,
GroupDayTripsDemandGroupParameters,
GroupDayTripsDestinationSequenceParameters,
GroupDayTripsModeSequenceParameters,
GroupDayTripsOutputParameters,
Expand Down Expand Up @@ -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

Expand Down
33 changes: 31 additions & 2 deletions mobility/population/population.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,18 @@
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
from mobility.population.census_localized_individuals import CensusLocalizedIndividuals
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


Expand All @@ -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",
)
Expand All @@ -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
Expand Down Expand Up @@ -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
2 changes: 2 additions & 0 deletions mobility/runtime/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
collect_sensitivity_values,
resolve_parameter_values,
)
from .population_segments import PopulationSegment
from .scenarios import (
Scenario,
ScenarioParameterChange,
Expand All @@ -21,6 +22,7 @@
"DEFAULT_SCENARIO",
"DEFAULT_SENSITIVITY_CASE",
"ParameterValue",
"PopulationSegment",
"SensitivityCase",
"SensitivityValue",
"Scenario",
Expand Down
36 changes: 36 additions & 0 deletions mobility/runtime/parameter_values.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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()
Expand Down
Loading
Loading