Skip to content

Commit 23f8caf

Browse files
authored
Add voltage/polarity-based transition costs (#328)
* Add new type * Use new type * Add `validate_transition_cost_configs` * Update function * Assume input is validated * Validator now resolves transition costs * Update docstrings * Add tests * Add tests * Fix test * Update directives * Add test * move validation * Assume input is validated * Fix tests * Fix test * Add warning * Fix tests * Minor update
1 parent d5f5075 commit 23f8caf

14 files changed

Lines changed: 460 additions & 133 deletions

docs/source/guides/routing_layers.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,28 @@ other hand, friction values near ``-1`` can be used to represent incentives
142142
individual friction values are {math}`\gt -1` (i.e. {math}`(1 + F) > 0` is
143143
upheld), so the cost values themselves can never flip signs.
144144

145+
```{warning}
146+
Large finite friction values at an endpoint can make routing unexpectedly
147+
slow and cause cost chunks to be computed far beyond the area between the
148+
endpoints. The endpoint remains valid, but every complete route must pay its
149+
large cost. Dijkstra-based algorithms may therefore explore all cells with a
150+
lower cumulative cost before they can prove that the selected route is
151+
optimal.
152+
153+
The endpoint itself does not need to have high friction for this to occur. A
154+
high-friction region that surrounds an endpoint, or otherwise forms an
155+
unavoidable bottleneck near it, can have the same effect. This is especially
156+
noticeable with bidirectional routing because the search starting at the
157+
endpoint must cross that region before its frontier can advance.
158+
159+
Check the effective routing cost {math}`R` at and around every endpoint when
160+
using large friction multipliers. Prefer moving an endpoint to a nearby
161+
representative low-friction cell or reducing the friction where every route
162+
must enter or leave the endpoint. When ``save_routing_layer`` is enabled, the
163+
saved chunk footprint represents the union of the search area explored for
164+
the route batch, not only the final routes or their endpoint bounds.
165+
```
166+
145167
### Building friction layers
146168
Friction layers are built similarly to cost layers. A single friction
147169
layer could be defined as follows:

revrt/models/routing.py

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,9 @@
3232
type MultiplierLayerInput = list[str] | None
3333
"""One or more layer names used as spatial multipliers"""
3434

35+
type TransitionCostValue = float | dict[str, float | dict[str, float]]
36+
"""A scalar transition cost or a voltage/polarity-dependent cost"""
37+
3538

3639
class RoutingCostLayer(BaseModel, extra="forbid"):
3740
"""Config for one cost layer in a routing option
@@ -481,11 +484,13 @@ class TransitionCostRule(BaseModel, extra="forbid"):
481484
transition cost applies.
482485
"""
483486

484-
cost: float
487+
cost: TransitionCostValue
485488
"""The transition cost
486489
487490
This is the transition cost (in $) applied when a route switches
488-
between the specified options.
491+
between the specified options. It may be a scalar, a mapping from
492+
voltage to scalar cost, or a mapping from voltage and polarity to
493+
scalar cost.
489494
"""
490495

491496

@@ -496,8 +501,12 @@ class TransitionCostsConfig(BaseModel, extra="forbid"):
496501
ignored.**
497502
"""
498503

499-
default: float = 0
500-
"""Fallback cost applied when no pairwise rule is configured"""
504+
default: TransitionCostValue = 0
505+
"""Fallback cost applied when no pairwise rule is configured
506+
507+
This may be a scalar, a mapping from voltage to scalar cost, or a
508+
mapping from voltage and polarity to scalar cost.
509+
"""
501510

502511
pairwise: list[TransitionCostRule] = Field(default_factory=list)
503512
"""Explicit transition costs between routing options"""
@@ -538,8 +547,8 @@ def validate_driver_configs(drivers, routing_options):
538547
return _flatten_driver_config(validated)
539548

540549

541-
def validate_transition_cost_configs(transition_costs, routing_options):
542-
"""[NOT PUBLIC API] Normalize transition costs"""
550+
def validate_transition_cost_input(transition_costs, routing_options):
551+
"""[NOT PUBLIC API] Normalize user-supplied transition costs"""
543552
if transition_costs is None:
544553
return None
545554

@@ -630,8 +639,9 @@ def _validation_error_to_config_error(error):
630639
"RoutingOptionsMap",
631640
"TrackedLayer",
632641
"TransitionCostRule",
642+
"TransitionCostValue",
633643
"TransitionCostsConfig",
634644
"validate_driver_configs",
635645
"validate_routing_options",
636-
"validate_transition_cost_configs",
646+
"validate_transition_cost_input",
637647
]

revrt/routing/base.py

Lines changed: 5 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,6 @@
1515

1616
from revrt import simplify_using_slopes
1717
from revrt.models.cost_layers import BarrierLayer
18-
from revrt.models.routing import (
19-
validate_driver_configs,
20-
validate_routing_options,
21-
validate_transition_cost_configs,
22-
)
2318
from revrt.routing.utilities import compute_lens
2419
from revrt.utilities.parsing import (
2520
parse_comparison_values,
@@ -62,11 +57,8 @@ def __init__(
6257
:class:`~revrt.models.routing.TrackedLayer` for the
6358
canonical schema.
6459
routing_options : dict
65-
Mapping of routing-option names to dictionaries containing
66-
cost, friction, and barrier definitions. See
67-
:class:`~revrt.models.routing.RoutingOptionConfig` for the
68-
canonical schema that is serialized for the Rust routing
69-
core.
60+
Validated mapping of routing-option names to dictionaries
61+
containing cost, friction, and barrier definitions.
7062
drivers : dict, optional
7163
Optional driver-rule configuration keyed by routing option.
7264
See :class:`~revrt.models.routing.DriverConfig` and
@@ -91,11 +83,9 @@ def __init__(
9183
By default, ``"bidirectional_long_range_dijkstra"``.
9284
"""
9385
self.cost_fpath = cost_fpath
94-
self.routing_options = validate_routing_options(routing_options)
95-
self.drivers = validate_driver_configs(drivers, self.routing_options)
96-
self.transition_costs = validate_transition_cost_configs(
97-
transition_costs, self.routing_options
98-
)
86+
self.routing_options = routing_options
87+
self.drivers = drivers
88+
self.transition_costs = transition_costs
9989
self.tracked_layers = tracked_layers or []
10090
self.invalid_costs_block_routing = invalid_costs_block_routing
10191
self.algorithm = algorithm

revrt/routing/cli/base.py

Lines changed: 146 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,11 @@
1414
import geopandas as gpd
1515
import xarray as xr
1616

17-
from revrt.models.routing import validate_routing_options
17+
from revrt.models.routing import (
18+
validate_driver_configs,
19+
validate_routing_options,
20+
validate_transition_cost_input,
21+
)
1822
from revrt.routing.cli.utilities import routing_layer_mover
1923
from revrt.routing.base import RoutingScenario
2024
from revrt.routing.processing import BatchRouteProcessor
@@ -64,11 +68,12 @@ def __init__(
6468
route_points : pandas.DataFrame
6569
DataFrame defining the points to be routed. This DataFrame
6670
should contain route definitions to be transformed and
67-
passed down to the Rust routing algorithm. Route values for
68-
polarity and voltage must be provided either as one shared
69-
`polarity` / `voltage` pair that applies to every routing
70-
option, or as a full per-option set of
71-
`polarity_<option>` / `voltage_<option>` columns.
71+
passed down to the Rust routing algorithm. Provide route
72+
values for polarity and voltage when cost multipliers or
73+
transition costs depend on them. Values may be supplied as
74+
one shared ``polarity`` / ``voltage`` pair or as a full
75+
per-option set of ``polarity_<option>`` /
76+
``voltage_<option>`` columns.
7277
out_fp : path-like
7378
Path to output file where computed routes will be saved.
7479
This file will be checked for existing routes to avoid
@@ -82,15 +87,27 @@ def __init__(
8287
path to JSON/JSON5 file containing this dictionary. See the
8388
description of
8489
:func:`revrt.routing.cli.point_to_point.compute_lcp_routes`
85-
for more details.
90+
for more details. By default, ``None``.
91+
drivers : dict, optional
92+
Optional driver-rule configuration keyed by routing option.
93+
By default, ``None``.
94+
transition_costs : dict, optional
95+
Optional transition-cost configuration between routing
96+
options. A voltage/polarity-dependent cost requires matching
97+
values for every affected routing option.
98+
By default, ``None``.
8699
"""
87100
self.cost_fpath = cost_fpath
88101
self.out_fp = Path(out_fp)
89102
self.transmission_config = transmission_config
90-
self.drivers = drivers
91-
self.transition_costs = transition_costs
92103
self._input_route_points = route_points
93104
self._routing_options = RoutingOptions(routing_options)
105+
self.drivers = validate_driver_configs(
106+
drivers, self._routing_options.routing_options
107+
)
108+
self.transition_costs = validate_transition_cost_input(
109+
transition_costs, self._routing_options.routing_options
110+
)
94111

95112
@cached_property
96113
def route_points(self):
@@ -169,10 +186,18 @@ def __iter__(self):
169186
pv_by_option=pv_by_option,
170187
transmission_config=self.transmission_config,
171188
)
189+
transition_costs = _resolve_transition_costs(
190+
self.transition_costs, pv_by_option
191+
)
172192
route_definitions, route_attrs = (
173193
self._convert_to_route_definitions(routes)
174194
)
175-
yield route_options, route_definitions, route_attrs
195+
yield (
196+
route_options,
197+
transition_costs,
198+
route_definitions,
199+
route_attrs,
200+
)
176201

177202
@property
178203
def _paths_to_compute(self):
@@ -330,12 +355,17 @@ def _run_all_lcp_batches(
330355
"""Run LCP routing for all batches of routes and save results"""
331356
out_fp = Path(out_fp)
332357
save_paths = out_fp.suffix.lower() == ".gpkg"
333-
for route_options, route_definitions, route_attrs in routes_to_compute:
358+
for (
359+
route_options,
360+
transition_costs,
361+
route_definitions,
362+
route_attrs,
363+
) in routes_to_compute:
334364
scenario = RoutingScenario(
335365
cost_fpath=cost_fpath,
336366
routing_options=route_options,
337367
drivers=routes_to_compute.drivers,
338-
transition_costs=routes_to_compute.transition_costs,
368+
transition_costs=transition_costs,
339369
tracked_layers=tracked_layers,
340370
invalid_costs_block_routing=invalid_costs_block_routing,
341371
algorithm=algorithm,
@@ -427,6 +457,110 @@ def _handle_maybe_missing_column(points, option, base_col):
427457
return points
428458

429459

460+
def _resolve_transition_costs(transition_costs, pv_by_option):
461+
"""Resolve transition-cost mappings from route values"""
462+
if transition_costs is None:
463+
return None
464+
465+
resolved = {}
466+
if "default" in transition_costs:
467+
resolved["default"] = _resolve_transition_cost_value(
468+
transition_costs["default"],
469+
pv_by_option,
470+
list(pv_by_option),
471+
context="transition_costs.default",
472+
)
473+
474+
if "pairwise" in transition_costs:
475+
resolved["pairwise"] = []
476+
for rule in transition_costs["pairwise"]:
477+
options = rule["between"]
478+
resolved["pairwise"].append(
479+
{
480+
"between": options,
481+
"cost": _resolve_transition_cost_value(
482+
rule["cost"],
483+
pv_by_option,
484+
options,
485+
context=(
486+
"transition_costs.pairwise cost between "
487+
f"{options[0]!r} and {options[1]!r}"
488+
),
489+
),
490+
}
491+
)
492+
493+
return resolved
494+
495+
496+
def _resolve_transition_cost_value(value, pv_by_option, options, context):
497+
"""Resolve one scalar or voltage/polarity transition-cost value"""
498+
if not isinstance(value, dict):
499+
return value
500+
501+
option_values = {
502+
option: _normalized_transition_values(pv_by_option[option], option)
503+
for option in options
504+
}
505+
unique_values = set(option_values.values())
506+
if len(unique_values) != 1:
507+
received = ", ".join(
508+
f"{option}(polarity={polarity!r}, voltage={voltage!r})"
509+
for option, (voltage, polarity) in option_values.items()
510+
)
511+
msg = (
512+
f"{context} requires matching voltage and polarity across "
513+
f"routing options. Received: {received}"
514+
)
515+
raise revrtConfigurationError(msg)
516+
517+
voltage, polarity = next(iter(unique_values))
518+
try:
519+
resolved = value[voltage]
520+
except KeyError as error:
521+
msg = (
522+
f"{context} has no cost for voltage {voltage!r}. Available "
523+
f"voltages: {list(value)}"
524+
)
525+
raise revrtKeyError(msg) from error
526+
527+
if not isinstance(resolved, dict):
528+
return resolved
529+
530+
try:
531+
return resolved[polarity]
532+
except KeyError as error:
533+
msg = (
534+
f"{context} has no cost for voltage {voltage!r} and polarity "
535+
f"{polarity!r}. Available polarities: {list(resolved)}"
536+
)
537+
raise revrtKeyError(msg) from error
538+
539+
540+
def _normalized_transition_values(values, option):
541+
"""tuple[str, str]: Normalized values for one routing option"""
542+
polarity = values.get(_POLARITY)
543+
voltage = values.get(_VOLTAGE)
544+
unknowns = {None, "None", "unknown"}
545+
if polarity in unknowns or voltage in unknowns:
546+
msg = (
547+
"voltage/polarity-dependent transition costs require known "
548+
f"values for routing option {option!r}"
549+
)
550+
raise revrtConfigurationError(msg)
551+
552+
try:
553+
voltage = str(int(voltage))
554+
except (TypeError, ValueError, OverflowError) as error:
555+
msg = (
556+
"voltage/polarity-dependent transition costs require an integer "
557+
f"voltage for routing option {option!r}, received {voltage!r}"
558+
)
559+
raise revrtConfigurationError(msg) from error
560+
561+
return voltage, str(polarity)
562+
563+
430564
def update_multipliers(
431565
layers, polarity, voltage, routing_option, transmission_config
432566
):

revrt/routing/cli/point_to_feature.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -283,7 +283,12 @@ def compute_lcp_routes( # ruff:ignore[too-many-arguments, too-many-positional-a
283283
Optional transition-cost configuration between routing
284284
options. See
285285
:class:`~revrt.models.routing.TransitionCostsConfig` for
286-
details.
286+
details. The ``default`` and each pairwise ``cost`` value may
287+
be a scalar, a mapping from voltage to scalar cost, or a nested
288+
mapping from voltage and polarity to cost. A voltage scalar
289+
applies to every polarity at that voltage. Dependent pairwise
290+
costs require matching voltage and polarity for both options;
291+
a dependent default requires matching values for every option.
287292
tracked_layers : list, optional
288293
List of dictionaries defining route-characterization layers.
289294
These layers do not influence the routing objective and are

revrt/routing/cli/point_to_point.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,12 @@ def compute_lcp_routes( # ruff:ignore[too-many-arguments, too-many-positional-a
152152
Optional transition-cost configuration between routing
153153
options. See
154154
:class:`~revrt.models.routing.TransitionCostsConfig` for
155-
details.
155+
details. The ``default`` and each pairwise ``cost`` value may
156+
be a scalar, a mapping from voltage to scalar cost, or a nested
157+
mapping from voltage and polarity to cost. A voltage scalar
158+
applies to every polarity at that voltage. Dependent pairwise
159+
costs require matching voltage and polarity for both options;
160+
a dependent default requires matching values for every option.
156161
tracked_layers : list, optional
157162
List of dictionaries defining route-characterization layers.
158163
These layers do not influence the routing objective and are

revrt/utilities/handlers.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -992,7 +992,7 @@ def _compute_lat_lon(ny, nx, src_crs, transform, chunk_x=2048, chunk_y=2048):
992992
_proj_to_lon_lat,
993993
x_mesh_transformed,
994994
y_mesh_transformed,
995-
src_crs.to_string(),
995+
src_crs.to_wkt(),
996996
dtype="float32",
997997
new_axis=(0,), # we add a new leading axis of length 2
998998
chunks=((2,), *x_mesh_transformed.chunks), # chunk sizes for [2, y, x]

0 commit comments

Comments
 (0)