Freight routing graphs frequently need to treat the same road segment as open at 6 a.m. and closed at 2 p.m., and OSM encodes that with conditional access tags rather than a static access value. This page covers the narrow but high-value task of turning those tags into edge rules your graph can evaluate at query time, and sits underneath parsing OSM relations for advanced constraints within the broader OSM Graph Architecture & Network Modeling reference. Where the parent guide covers relation-based constraints such as turn restrictions and destination signs, conditional access lives directly on way tags and requires a different parsing strategy: a small grammar (<value> @ (<condition>)) that must be split, matched, and expanded into something a routing engine can check in microseconds per edge.
When to Use This Approach
Conditional access parsing earns its complexity when a meaningful share of your network carries time-windowed restrictions rather than blanket ones. Common tags include access:conditional, motor_vehicle:conditional, hgv:conditional, and vehicle:conditional, with values like no @ (Mo-Fr 07:00-19:00) for weekday delivery bans, or delivery @ (06:00-11:00) for loading-window exemptions.
Build a dedicated parser when:
- Your service area includes city centers with weekday HGV bans, low-traffic neighborhoods, or school-zone time windows — these are almost always encoded as
*:conditionaltags rather than as separate ways - You need the restriction state to change within a single vehicle’s shift, not just once per graph rebuild
- Your routing engine’s built-in conditional support is limited or absent — vanilla OSRM Lua profiles, for example, do not evaluate
opening_hours-style conditions out of the box - You are pre-computing time-sliced graphs (one per hour bucket) and need a cheap per-edge lookup rather than a full grammar evaluation on every query
Skip the custom parser when:
- Your engine already exposes native conditional restriction support with acceptable coverage — Valhalla’s
ConditionalSpeedLimitand date-range restriction handling cover a large fraction of real-world tag values without custom code - Conditional tags cover under roughly 1% of edges in your extract — the engineering cost of a bitmap pipeline is not justified by that small a slice
- You only need a single static snapshot (e.g., “assume peak-hour restrictions always apply”) rather than genuine time-of-day evaluation
Implementation
The grammar for *:conditional values is small: one or more <access_value> @ (<condition>) clauses separated by ;. The condition body typically contains a day range (Mo-Fr), a time range (07:00-19:00), or both. The parser below handles the common subset — day-of-week ranges combined with a single time window per clause — and stores each rule in a form that expands cleanly into a bitmap.
# requires: numpy (pip install numpy)
# Python 3.9+
import re
from dataclasses import dataclass, field
from datetime import datetime
DAY_ORDER = ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"]
DAY_INDEX = {d: i for i, d in enumerate(DAY_ORDER)}
CLAUSE_SPLIT_RE = re.compile(r"\s*;\s*")
RULE_RE = re.compile(r"^\s*(\S+)\s*@\s*\(([^)]+)\)\s*$")
DAY_RANGE_RE = re.compile(r"(Mo|Tu|We|Th|Fr|Sa|Su)(?:-(Mo|Tu|We|Th|Fr|Sa|Su))?")
TIME_RANGE_RE = re.compile(r"(\d{2}):(\d{2})-(\d{2}):(\d{2})")
@dataclass
class ConditionalRule:
access_value: str
day_ranges: list # [(start_idx, end_idx), ...] Mo=0 .. Su=6
time_ranges: list # [(start_min, end_min), ...] 0-1439, may span midnight
raw: str = field(default="")
def parse_conditional(tag_value: str) -> list[ConditionalRule]:
"""Parse an OSM *:conditional tag value into structured rules.
Example: "no @ (Mo-Fr 07:00-19:00)" -> one ConditionalRule.
Clauses that don't match the day/time grammar (e.g. pure numeric
conditions like "no @ (weight>7.5)") are skipped here and should be
handled by a separate numeric-condition evaluator.
"""
rules = []
for clause in CLAUSE_SPLIT_RE.split(tag_value.strip()):
if not clause:
continue
m = RULE_RE.match(clause)
if not m:
continue
access_value, body = m.group(1), m.group(2)
day_ranges = _expand_days(body)
time_ranges = _expand_times(body)
if not time_ranges:
continue # no time component -> not a time-conditional rule
rules.append(ConditionalRule(access_value, day_ranges, time_ranges, raw=clause))
return rules
def _expand_days(body: str) -> list[tuple[int, int]]:
ranges = [
(DAY_INDEX[start], DAY_INDEX[end or start])
for start, end in DAY_RANGE_RE.findall(body)
]
return ranges or [(0, 6)] # no day qualifier -> every day
def _expand_times(body: str) -> list[tuple[int, int]]:
ranges = []
for h1, m1, h2, m2 in TIME_RANGE_RE.findall(body):
start = int(h1) * 60 + int(m1)
end = int(h2) * 60 + int(m2)
ranges.append((start, end))
return ranges
Interval expansion turns each ConditionalRule into a (7, buckets_per_day) boolean NumPy array — the access bitmap. Overnight ranges (end < start) are split at midnight into two spans so the bucket math stays simple.
# requires: numpy (pip install numpy)
# Python 3.9+
import numpy as np
def build_access_bitmap(
rules: list[ConditionalRule],
bucket_minutes: int = 60,
) -> np.ndarray:
"""Expand parsed rules into a weekly (7, buckets_per_day) boolean bitmap.
True = access denied for that day/time bucket. Rules are applied in
order, so later clauses override earlier ones on overlapping cells —
this matches how conflicting conditional values are conventionally
resolved in OSM data consumption.
"""
buckets = 1440 // bucket_minutes
bitmap = np.zeros((7, buckets), dtype=bool)
for rule in rules:
denies = rule.access_value in ("no", "private")
days = _days_in_ranges(rule.day_ranges)
for start, end in rule.time_ranges:
for lo, hi, day_offset in _split_overnight(start, end, bucket_minutes, buckets):
for day in days:
target_day = (day + day_offset) % 7
bitmap[target_day, lo:hi] = denies
return bitmap
def _days_in_ranges(day_ranges: list[tuple[int, int]]) -> list[int]:
days = set()
for start, end in day_ranges:
if start <= end:
days.update(range(start, end + 1))
else: # wraps Su -> Mo, e.g. "Fr-Mo"
days.update(list(range(start, 7)) + list(range(0, end + 1)))
return sorted(days)
def _split_overnight(start: int, end: int, bucket_minutes: int, buckets: int):
"""Yield (bucket_lo, bucket_hi, day_offset) spans, splitting at midnight."""
if end > start:
yield start // bucket_minutes, -(-end // bucket_minutes), 0
else: # e.g. 22:00-06:00
yield start // bucket_minutes, buckets, 0
yield 0, -(-end // bucket_minutes), 1
Query-time evaluation is a single array lookup once the bitmap exists on the edge:
# Python 3.9+
def is_edge_passable(bitmap, when: datetime, bucket_minutes: int = 60) -> bool:
"""True if the edge is passable (not denied) at the given local timestamp."""
day_idx = when.weekday() # Monday=0, matches DAY_ORDER
bucket = (when.hour * 60 + when.minute) // bucket_minutes
return not bool(bitmap[day_idx, bucket])
Key Parameters and Tuning
| Parameter | Description | Recommended | Notes |
|---|---|---|---|
bucket_minutes |
Time resolution of the weekly bitmap | 15–60 | 60-minute buckets cover almost all real-world *:conditional values; drop to 15 only if you see sub-hour windows in your extract |
| Rule precedence | Order clauses are applied to the bitmap | Left-to-right, later wins | Matches conventional resolution of overlapping conditional clauses on the same tag |
| Numeric conditions | weight>7.5, maxheight>4 style predicates |
Evaluate separately from the bitmap | Attach as a static per-edge attribute checked before the bitmap lookup, since they don’t vary by day or time |
| Fallback tag | Missing *:conditional on a way |
Use the base access/hgv value |
The conditional tag only overrides the base value for the windows it defines; outside those windows, the base tag governs |
| Unmatched grammar | Clause doesn’t match RULE_RE or has no time component |
Log and skip, don’t drop the edge | Rare constructs (public holidays, PH, week-of-month) need a full opening_hours evaluator, not this regex path |
| Bitmap storage | Per-edge memory footprint | Pack as np.packbits or a single int bitmask |
A (7, 24) boolean array packs into 21 bytes raw or 3 uint64 values per edge — negligible even at tens of millions of edges |
The bucket size is the one tuning knob that actually matters in practice. Coarser buckets shrink storage and speed up evaluation, but round real time windows outward — 07:00-19:00 at 60-minute resolution is exact, while a hypothetical 07:15-18:45 would round to 07:00-19:00, slightly over-restricting the edge. For freight compliance use cases, over-restricting is the safer failure direction than under-restricting.
Integration Points
NetworkX. Attach the bitmap as an edge attribute during graph construction, then wrap it in a weight callable that returns math.inf when is_edge_passable() is False for the query’s departure time. This slots directly into the same weight callback pattern used across NetworkX shortest path algorithms for logistics — treat a denied bucket exactly like a hard turn restriction rather than a cost penalty.
OSRM. Vanilla Lua profiles don’t evaluate access:conditional natively; you have to read the raw tag inside way_function and encode the outcome as a per-speed-class override, or precompute a small number of time-sliced .osrm builds (e.g., peak vs. off-peak) and route requests to the correct build based on departure time. The bitmap from this page is exactly what you’d use to decide which pre-built graph a request belongs to.
Freight edge weights. Conditional access windows compound with the penalty logic described in configuring edge weights for freight logistics — a segment might be legally open but carry a congestion surcharge during the same hours it’s conditionally restricted for HGVs. Evaluate the access bitmap first as a hard gate, then apply cost surcharges only to edges that pass it.
Relation-based constraints. Conditional access tags live on ways, not relations, so they parse independently of the from/via/to member resolution described in the parent guide to parsing OSM relations for advanced constraints. In practice both pipelines run in the same pyosmium pass — one handler branch for relation members, one for way tag inspection — and merge into the same edge-attribute schema before graph assembly.
Validation Checklist
-
Grammar coverage audit. Run
parse_conditional()against every distinct*:conditionalvalue in your PBF extract (osmium tags-filter region.osm.pbf w/hgv:conditional w/access:conditional w/motor_vehicle:conditional) and log the fraction that return zero rules. Anything above roughly 5% unmatched warrants extending the regex or adding anopening_hoursfallback. -
Known-window smoke test. Pick a way tagged
hgv:conditional=no @ (Mo-Fr 07:00-19:00), evaluateis_edge_passable()at a known-blocked timestamp (Wednesday 14:00) and a known-open timestamp (Saturday 10:00), and assert the results differ. -
Overnight range correctness. Test a rule like
no @ (22:00-06:00)at 23:30 and at 05:30 on consecutive calendar days — both should evaluate to denied, confirming the midnight split logic works across the day boundary. -
Bitmap-to-base-tag fallback. For a way with both
hgv=yesandhgv:conditional=no @ (...), confirm the edge is passable outside the conditional window and denied inside it — the conditional tag must only override within its stated interval, never globally. -
Precedence on overlapping clauses. Construct a semicolon-separated multi-clause value with overlapping day/time ranges and assert the bitmap reflects the last clause’s
access_valueon the overlap, not the first. -
Bitmap memory budget. On your full production extract, measure total bytes consumed by packed bitmaps across all conditionally restricted edges and confirm it stays within your graph-loading memory budget — this rarely matters below a few million edges but is worth a one-time check at continent scale.
Troubleshooting: My parser silently drops valid restrictions
The most common cause is a condition body using a comma to separate day and time (Mo-Fr, 07:00-19:00) instead of a space, or a value using the full opening_hours syntax with PH or week-of-month selectors that the regex grammar in this page doesn’t target. Log every clause that fails RULE_RE or returns an empty time_ranges list, then route those specific values through a full opening_hours library instead of extending the regex indefinitely.
Troubleshooting: Restriction applies on the wrong day after parsing
This is almost always a day-index mismatch between datetime.weekday() (Monday=0) and whatever convention downstream code assumes. Confirm DAY_INDEX in the parser and the day_idx = when.weekday() call in is_edge_passable() share the same Monday=0 convention before debugging the grammar itself.
Related
- Parsing OSM relations for advanced constraints — the broader guide covering relation member resolution alongside way-level conditional tags
- OSM Graph Architecture & Network Modeling — the foundational overview of ingesting and constraining OSM-derived routing graphs
- Setting turn restrictions in GraphHopper vs OSRM — how relation-based turn constraints compile into engine-specific penalty structures, the closest analog to conditional access handling
- Configuring edge weights for freight logistics — where conditional access gates combine with cost surcharges on the same edges