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 *:conditional tags 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 ConditionalSpeedLimit and 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
Conditional tag to access bitmap pipeline The tag hgv:conditional=no at Mo-Fr 07:00-19:00 is parsed into a structured rule with access value no, days Monday through Friday, and time 07:00 to 19:00. That rule is expanded into a weekly grid of 7 days by 24 hourly buckets, with the Monday-to-Friday 07:00-19:00 region shaded as blocked. A query for Wednesday 14:32 lands inside the shaded region, so the bitmap lookup returns denied for hgv traffic on that edge. hgv:conditional=no @ (Mo-Fr 07:00-19:00) parsed rule -> access: no days: Mo-Fr time: 07:00-19:00 stored as (access_value, day_ranges, time_ranges) per way interval expansion weekly access bitmap — bucket = 1 hour blocked (hgv) open 0 6 12 18 24 Mo Tu We Th Fr Sa Su query: Wed 14:32 -> bucket (row=We, col=14) bitmap[We][14] = True -> hgv edge denied

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

  1. Grammar coverage audit. Run parse_conditional() against every distinct *:conditional value 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 an opening_hours fallback.

  2. Known-window smoke test. Pick a way tagged hgv:conditional=no @ (Mo-Fr 07:00-19:00), evaluate is_edge_passable() at a known-blocked timestamp (Wednesday 14:00) and a known-open timestamp (Saturday 10:00), and assert the results differ.

  3. 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.

  4. Bitmap-to-base-tag fallback. For a way with both hgv=yes and hgv: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.

  5. 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_value on the overlap, not the first.

  6. 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.