aperta.utility

Utility-based travel costs and accessibility.

Aperta supports linear utility specifications of the form:

U_ij = constant
     + cost_coefficient * cost_ij
     + Σ_f f.coefficient * aggregated_route_feature_f(i, j)
     + Σ_o origin_features[o] * feature_o(i)
     + Σ_d destination_features[d] * feature_d(j)

where:

cost_ij                       shortest-path cost on a chosen routing weight
aggregated_route_feature(i,j) per-edge feature aggregated along the realised
                              shortest path (sum / mean / etc.)
feature(i), feature(j)        per-origin / per-destination scalar attributes
constant, *_coefficient       per-mode parameters (typically calibrated from
                              travel-survey data)

Computation is split in two steps to keep the routing pass cheap and to let downstream cell-mode accessibility handle per-cell additions on its own terms:

  1. route_utility(…) routes shortest paths once and computes the route-dependent components (cost + per-edge feature aggregations). Wraps routing.tiered_path_aggregate.

  2. add_endpoint_utility(…) augments the route utility with the constant, origin, and destination components. Returns the full per-node-pair utility.

For cell-mode accessibility (per-cell overheads), the cell-overhead contribution to utility (β_cost · cell_walking_overhead) is added by the downstream accessibility function (gravity, cumulative_opportunities, nearest_k) via the existing cell_overhead_column mechanism. Precompute the per-cell utility-overhead column in user code:

cells[‘util_overhead’] = utility.cost_coefficient * cells[‘walk_overhead_s’]

and pass it as cell_overhead_column=’util_overhead’. The accessibility function will add it to every cell’s destination utilities — units match (utils), no library changes needed.

For logsum (cross-modal) accessibility, combine per-mode utility ODMs with od_pairs.aggregate_across_modes(utilities_by_mode, aggregator=’logsum’), then run gravity (with β = 1) or other downstream metrics on the combined ODM. See the walkthrough notebook for a worked demonstration.

Known limitations.

Self-pair utility under positive route-feature contributions. For a cell-to-itself OD pair, the realised shortest path has zero edges. The route-feature contribution to utility is then 0 (see the handling inside route_utility — this avoids a NaN-propagation bug where mean/min/max aggregators over empty edge arrays would otherwise corrupt downstream gravity / logsum sums).

The empty-path-as-zero treatment is the right call for utility specs where route features contribute negatively to utility on net (e.g. perceived road-class penalties, gradient costs). For utility specs where route features contribute positively (e.g. greenery benefit, attractive-route bonus), the self-pair can still appear less attractive than neighbouring cells whose short routes earn a small positive route-feature contribution. Semantically odd — the cell containing the opportunity shouldn’t be worse than its neighbours at accessing it — but mathematically correct under the empty-path convention.

A more general fix would synthesise a representative within-cell route for self-pairs (e.g. infer the typical route-feature value from neighbouring cells, or apply the per-mode-defaults from the user’s input). Not currently implemented; flagged for future work.

class aperta.utility.RouteFeature(name, attribute, coefficient, aggregator='sum')[source]

Bases: NamedTuple

A per-edge feature aggregated along the shortest path, with a utility coefficient. Used in Utility.route_features.

During utility computation, the per-edge values are aggregated along the realised route (sum / mean / etc.) and then multiplied by coefficient to contribute coefficient * aggregated_feature to the OD-pair utility.

attribute, aggregator: as in routing.PathAggregation. coefficient is the utility weight (β); typically negative for costs / penalties.

Parameters:
name: str

Alias for field number 0

attribute: str | Callable

Alias for field number 1

coefficient: float

Alias for field number 2

aggregator: str | Callable

Alias for field number 3

class aperta.utility.Utility(constant=0.0, cost_coefficient=0.0, route_features=<factory>, origin_features=<factory>, destination_features=<factory>)[source]

Bases: object

Linear utility specification.

U_ij = constant
  • cost_coefficient * cost_ij

  • Σ_f f.coefficient * aggregated_f(i, j) (route features)

  • Σ_o origin_features[o] * feature_o(i) (origin features)

  • Σ_d destination_features[d] * feature_d(j) (destination features)

Coefficients can be positive or negative. Cost typically has a negative coefficient (cost reduces utility). The constant is the alternative- specific constant from a discrete-choice estimation; the cost coefficient and per-feature betas come from the same source.

Cell-level features (cell-overhead, per-cell origin attributes) are NOT part of this spec — they are added by the downstream accessibility function via cell_overhead_column. See module docstring.

Parameters:
constant: float = 0.0
cost_coefficient: float = 0.0
route_features: list[RouteFeature]
origin_features: dict[str, float]
destination_features: dict[str, float]
aperta.utility.route_utility(pairs, graph, weight, utility, *, mask=None, cutoff=None, dtype=<class 'numpy.float32'>)[source]

Compute the route-dependent components of utility for every OD pair.

For each OD pair (i, j):
U_route(i, j) = cost_coefficient * cost(i, j)
  • Σ_f f.coefficient * aggregated_f(i, j)

where cost(i, j) is the shortest-path cost under weight and the aggregations are over the edges of the realised (i, j) path.

Origin, destination, and constant components are NOT included — add them via add_endpoint_utility. Cell-mode overhead is NOT included — handle that via the downstream accessibility function’s cell_overhead_column.

Internally calls tiered_path_aggregate (or tiered_path_costs when no route features are needed) so the routing pass is shared across the cost and all route features.

Parameters:
  • pairs (TieredODPairs) – tiered destination IDs (typically from od_pairs.get_pairs).

  • graph (Graph) – routable networkx graph.

  • weight (str) – edge attribute name used for routing AND as the cost contribution to utility (multiplied by utility.cost_coefficient).

  • utility (Utility) – the Utility spec.

  • mask (TieredODPairs | None) – as in tiered_path_aggregate. Beyond-cutoff destinations become np.nan in the returned utility ODM (same convention as unreachable / masked-out).

  • cutoff (float | None) – as in tiered_path_aggregate. Beyond-cutoff destinations become np.nan in the returned utility ODM (same convention as unreachable / masked-out).

  • dtype (dtype | type) – as in tiered_path_aggregate. Beyond-cutoff destinations become np.nan in the returned utility ODM (same convention as unreachable / masked-out).

Returns:

TieredODPairs of route-utility values (float64 by default). Unreachable / masked-out destinations are np.nan (NOT np.inf — utility is a signed quantity).

Return type:

TieredODPairs

aperta.utility.add_endpoint_utility(route_utility, pairs, utility, *, cells=None, zones=None, node_column='node_id')[source]

Add constant, origin, and destination components to route utility.

For each OD pair (i, j):
U_full(i, j) = U_route(i, j)
  • constant

  • Σ_o origin_features[o] * feature_o(i)

  • Σ_d destination_features[d] * feature_d(j)

Origin features are looked up at the origin node. For the cells_to_cells and cells_to_zones tiers, origins are cell-tier nodes (cells is used); for zones_to_zones, origins are zone-tier nodes (zones is used). If multiple cells / zones map to the same network node, their feature values are averaged.

Destination features are looked up via od_pairs.dest_values — cell-tier dests look up in cells, while cells_to_zones and zones_to_zones dests look up in zones. Missing feature columns at a given tier silently contribute zero to that tier (the OD pairs still get the other components).

Cell-mode handling: this function operates at the network-node level (its origins are nodes). Per-cell origin features (different for two cells sharing a node) and per-cell-overhead utility contributions are NOT handled here. Compute them as a single per-cell column in user code:

cells[‘util_overhead’] = (utility.cost_coefficient * cells[‘walk_overhead_s’]
  • utility.origin_features.get(‘density’, 0) * cells[‘density’])

and pass via cell_overhead_column=’util_overhead’ to the accessibility function. (add_endpoint_utility handles origin features at the cells-per-node-averaged level only.)

Parameters:
  • route_utility (TieredODPairs) – per-OD route utility from route_utility(…).

  • pairs (TieredODPairs) – tiered destination IDs (same as used in route_utility).

  • utility (Utility) – the Utility spec.

  • cells (DataFrame | None) – per-unit DataFrames carrying the named features. Each must have node_column mapping to the network node ID for that tier.

  • zones (DataFrame | None) – per-unit DataFrames carrying the named features. Each must have node_column mapping to the network node ID for that tier.

  • node_column (str) – column name in cells/zones giving the network node. Default ‘node_id’.

Returns:

TieredODPairs of full per-OD utility (float64).

Return type:

TieredODPairs