Functional Weave
Code in Python

fleet.drivers-hours

Check a driver's activities against GB/EU drivers' hours: breaks after 4.5h, daily, weekly and fortnightly driving.

1.0.1 (not the latest) · published 2026-10-03 by charlie · Anterra

Pinned by 26 tests, run in TypeScript, Python and Rust.

Not professional advice. This capability calculates drivers’ hours figures from published rules. It is a software component for developers, not legal advice. Rules change and every rate here has an effective date. Check that the dates cover your case. Verify results against the official sources listed in its README, and have a transport compliance specialist (DVSA rules) review how you use it, before anyone relies on the output. Provided “as is” under its licence, without warranty.

What it does

Status: needs review by a drivers' hours / transport compliance expert before it is published.

Checks a driver's recorded activities against the driving-time and break rules of Regulation (EC) No 561/2006, as it applies in Great Britain (assimilated law, formerly "retained EU law"). It returns every breach it finds, in time order, with the local date and time the limit was passed.

For example

  • check_drivers_hours(2026-09-21, 06:00, activities ×4) → compliant true, driving minutes 540, breaches a legal day: 4h30, 45 minute break, 4h30, then daily rest
  • check_drivers_hours(2026-09-21, 06:00, activities ×2) → compliant false, driving minutes 300, breaches ×1 five hours without a break passes the 4h30 limit at 10:30
  • check_drivers_hours(2026-09-21, 06:00, activities ×5) → compliant true, driving minutes 510, breaches a split break of 15 then 30 minutes is legal

The function

The same function in TypeScript, Python and Rust, pinned by the same tests. Pick your language; the choice follows you around the registry.

def check_drivers_hours(start_date: str, start_time: str, activities: Sequence[DriverActivity]) -> DriversHoursCheck
start_datedatelocal date the first activity starts
start_timestringHH:MM, 24-hour, local time the first activity starts
activitiesDriverActivity[]back to back and in order: each starts when the one before ends
returnsDriversHoursCheck

The types it declares, generated into your project

ActivityKind = Literal["driving", "work", "availability", "rest"]

@dataclass(frozen=True)
class DriverActivity:
    """One period of a driver's time, as a tachograph records it."""

    #: rest covers breaks and rest periods alike
    kind: ActivityKind
    #: length in whole minutes, at least 1
    minutes: int

@dataclass(frozen=True)
class HoursBreach:
    """One rule broken, and when."""

    #: the rule's name, e.g. break or weekly-driving
    rule: str
    #: local date and time the limit was passed, YYYY-MM-DDTHH:MM
    at: str
    #: what was recorded, in the rule's unit (minutes, or a count)
    actual: int
    #: the limit in the same unit
    limit: int

@dataclass(frozen=True)
class DriversHoursCheck:
    """The breaches found, in time order."""

    #: true when there are no breaches
    compliant: bool
    #: all driving in the activities
    driving_minutes: int
    breaches: List[HoursBreach]

Your code names it in one line, in the file that uses it

from fune.fleet.drivers_hours import check_drivers_hours  # fleet.drivers-hours@^1
impl/python.py · 194 lines · open · raw

Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.

import re
from typing import Dict, List, Sequence

from .dates_add_days import add_days  ← from dates.add-days ^1.0.0 · built alongside by fune
from .dates_day_of_week import day_of_week  ← from dates.day-of-week ^1.0.0 · built alongside by fune
from .fleet_drivers_hours_data import DRIVING_LIMITS, DRIVING_LIMITS_HISTORY  ← this capability’s own data, compiled from data/limits.json into the same file by fune build
from .fleet_drivers_hours_types import DriverActivity, DriversHoursCheck, HoursBreach

ISO_DATE = re.compile(r"[0-9]{4}-[0-9]{2}-[0-9]{2}")
HH_MM = re.compile(r"([01][0-9]|2[0-3]):[0-5][0-9]")
KINDS = ("driving", "work", "availability", "rest")
WEEK = 10080


def _limit_on(rule: str, on_date: str) -> int:
    best = None
    earliest = None
    for row in DRIVING_LIMITS:
        if row.rule != rule:
            continue
        if earliest is None or row.valid_from < earliest:
            earliest = row.valid_from
        if on_date < row.valid_from:
            continue
        if row.valid_to is not None and on_date > row.valid_to:
            continue
        if best is None or row.valid_from > best.valid_from:
            best = row
    if best is not None:
        return best.value
    if DRIVING_LIMITS_HISTORY != "full" and earliest is not None and on_date < earliest:
        raise ValueError(
            'no drivers\' hours limit "%s" on %s: this build was installed with history=%s, '
            "so it only carries limits from %s. Reinstall with history=full for older records."
            % (rule, on_date, DRIVING_LIMITS_HISTORY, earliest)
        )
    raise ValueError('no drivers\' hours limit "%s" on %s' % (rule, on_date))


class _Pending:
    __slots__ = ("rule", "at_minute", "actual", "limit")

    def __init__(self, rule: str, at_minute: int, limit: int) -> None:
        self.rule = rule
        self.at_minute = at_minute
        self.actual = 0
        self.limit = limit


def _local_time(start_date: str, minute: int) -> str:
    day = minute // 1440
    in_day = minute - day * 1440
    return "%sT%02d:%02d" % (add_days(start_date, day), in_day // 60, in_day % 60)


def check_drivers_hours(start_date: str, start_time: str, activities: Sequence[DriverActivity]) -> DriversHoursCheck:
    """Check a run of tachograph activities against the GB/EU drivers' hours
    rules on driving time and breaks (Regulation (EC) 561/2006, Articles 6 and 7).

    Activities are back to back from start_date/start_time, in local time. A
    rest of 45 minutes, or 15 then 30, ends a driving period; a rest of 9 hours
    or more is a daily rest and ends the daily driving time. Weeks are fixed
    weeks, Monday 00:00 to Sunday 24:00, so driving across Sunday midnight is
    split.
    """
    if not isinstance(start_date, str) or not ISO_DATE.fullmatch(start_date):
        raise ValueError('startDate must be an ISO date (YYYY-MM-DD), received "%s"' % (start_date,))
    if not isinstance(start_time, str) or not HH_MM.fullmatch(start_time):
        raise ValueError('startTime must be HH:MM, 24-hour, received "%s"' % (start_time,))
    driving_period = _limit_on("driving-period", start_date)
    full_break = _limit_on("break", start_date)
    split_first = _limit_on("split-break-first", start_date)
    split_second = _limit_on("split-break-second", start_date)
    daily = _limit_on("daily-driving", start_date)
    daily_extended = _limit_on("daily-driving-extended", start_date)
    extensions_allowed = _limit_on("extended-days-per-week", start_date)
    weekly_limit = _limit_on("weekly-driving", start_date)
    fortnight_limit = _limit_on("fortnightly-driving", start_date)
    daily_rest = _limit_on("daily-rest-minimum", start_date)

    # Merge neighbours of the same kind: two rests back to back are one
    # uninterrupted rest, whatever the recording split them into.
    segments: List[List] = []
    for activity in activities:
        if activity.kind not in KINDS:
            raise ValueError('unknown activity kind "%s"' % (activity.kind,))
        minutes = activity.minutes
        if isinstance(minutes, bool) or not isinstance(minutes, int) or minutes < 1:
            raise ValueError("minutes must be a whole number of at least 1, received %s" % (minutes,))
        if segments and segments[-1][0] == activity.kind:
            segments[-1][1] += minutes
        else:
            segments.append([activity.kind, minutes])

    start_minute = int(start_time[0:2]) * 60 + int(start_time[3:5])
    week_base = (day_of_week(start_date) - 1) * 1440 + start_minute

    breaches: List[_Pending] = []
    state = {
        "accumulated": 0,
        "first_part": False,
        "break_breach": None,
        "day_driving": 0,
        "day_passed_daily": None,
        "day_week": None,
        "day_breach": None,
    }
    extensions: Dict[int, int] = {}
    weekly: Dict[int, int] = {}
    weekly_breaches: Dict[int, _Pending] = {}
    fortnight_breaches: Dict[int, _Pending] = {}

    def end_driving_period() -> None:
        if state["break_breach"] is not None:
            state["break_breach"].actual = state["accumulated"]
        state["accumulated"] = 0
        state["first_part"] = False
        state["break_breach"] = None

    def close_day() -> None:
        day_driving = state["day_driving"]
        if day_driving > daily and state["day_week"] is not None:
            used = extensions.get(state["day_week"], 0) + 1
            extensions[state["day_week"]] = used
            if used > extensions_allowed and day_driving <= daily_extended and state["day_passed_daily"] is not None:
                pending = _Pending("daily-driving", state["day_passed_daily"], daily)
                pending.actual = day_driving
                breaches.append(pending)
        if state["day_breach"] is not None:
            state["day_breach"].actual = day_driving
        state["day_driving"] = 0
        state["day_passed_daily"] = None
        state["day_week"] = None
        state["day_breach"] = None

    total_driving = 0
    offset = 0
    for kind, minutes in segments:
        if kind == "driving":
            total_driving += minutes
            start = offset
            end = offset + minutes
            while start < end:
                week = (week_base + start) // WEEK
                chunk_end = min(end, (week + 1) * WEEK - week_base)
                m = chunk_end - start
                if state["break_breach"] is None and state["accumulated"] + m > driving_period:
                    pending = _Pending("break", start + (driving_period - state["accumulated"]), driving_period)
                    state["break_breach"] = pending
                    breaches.append(pending)
                state["accumulated"] += m
                if state["day_week"] is None:
                    state["day_week"] = week
                day_driving = state["day_driving"]
                if state["day_passed_daily"] is None and day_driving + m > daily:
                    state["day_passed_daily"] = start + (daily - day_driving)
                if state["day_breach"] is None and day_driving + m > daily_extended:
                    pending = _Pending("daily-driving", start + (daily_extended - day_driving), daily_extended)
                    state["day_breach"] = pending
                    breaches.append(pending)
                state["day_driving"] = day_driving + m
                before = weekly.get(week, 0)
                if week not in weekly_breaches and before + m > weekly_limit:
                    pending = _Pending("weekly-driving", start + (weekly_limit - before), weekly_limit)
                    weekly_breaches[week] = pending
                    breaches.append(pending)
                pair_before = weekly.get(week - 1, 0) + before
                if week not in fortnight_breaches and pair_before + m > fortnight_limit:
                    pending = _Pending("fortnightly-driving", start + (fortnight_limit - pair_before), fortnight_limit)
                    fortnight_breaches[week] = pending
                    breaches.append(pending)
                weekly[week] = before + m
                start = chunk_end
        elif kind == "rest":
            if minutes >= full_break or (state["first_part"] and minutes >= split_second):
                end_driving_period()
            elif minutes >= split_first and state["accumulated"] > 0:
                state["first_part"] = True
            if minutes >= daily_rest:
                close_day()
        offset += minutes
    end_driving_period()
    close_day()
    for week, pending in weekly_breaches.items():
        pending.actual = weekly.get(week, 0)
    for week, pending in fortnight_breaches.items():
        pending.actual = weekly.get(week - 1, 0) + weekly.get(week, 0)

    # sorted() is stable, so breaches at the same minute keep the order found.
    ordered = [
        HoursBreach(rule=b.rule, at=_local_time(start_date, start_minute + b.at_minute), actual=b.actual, limit=b.limit)
        for b in sorted(breaches, key=lambda b: b.at_minute)
    ]
    return DriversHoursCheck(compliant=len(ordered) == 0, driving_minutes=total_driving, breaches=ordered)

Install

fune build

With that line in your source, in a Python project (language python in fune.project), fune build resolves it and its 2 dependencies, pins them in fune.lock, downloads only the Python package of each, and builds the code above into your project’s .fune/build, one readable file per capability with a header linking back here. Or pin a range in fune.project and build in one step:

fune add fleet.drivers-hours
Download for Python fleet.drivers-hours-1.0.1-python.fune · 31,408 bytes sha256 e1e2bce8d1c03c5a76d2f1133e0afff419b8009685caae7c0b393e2ace6b9f16

The manifest, vectors and README with only the Python implementation. Install it without the registry with fune add ./fleet.drivers-hours-1.0.1-python.fune, or fetch it from a terminal with fune pull fleet.drivers-hours@1.0.1:python.

The whole function, every language, is one file too: fleet.drivers-hours-1.0.1.fune, 52,953 bytes, sha256 382070925b09f173e31ff7c9f0e3b74a49f5ae30a02fe2b70b5db2234858199d. It installs into a project of any language.

Customise it in your app

The seams this capability offers. Put a marker directly above a function of your own and fune build wires it into the built code; the package on the registry is not changed, the built file’s header lists it under CUSTOMISED, and fune hooks lists every hook in the project. How hooks work.

before — your function gets the arguments and returns them, changed or not, or throws to refuse the call.

# fune: before fleet.drivers-hours

after — your function gets the result and the arguments, and returns the final result.

# fune: after fleet.drivers-hours

replace — inside this capability’s code only, calls to a dependency go to your function, with the same signature. Other capabilities that use it are unaffected; write in * to replace it everywhere.

# fune: replace dates.add-days in fleet.drivers-hours
# fune: replace dates.day-of-week in fleet.drivers-hours

step — your function runs at a numbered point inside the function’s body, receives the in-scope values it names as parameters, and may return replacements. List the points with fune show fleet.drivers-hours --steps.

# fune: step fleet.drivers-hours after <n|label>

Tests

A version published now needs at least 8 tests for every function, and one that expects the error for each function that throws; the registry refuses it otherwise. fune verify --all runs each case in TypeScript, Python and Rust, and a project runs them again with fune verify. This page lists the cases; it does not run them. The exact JSON is vectors.json.

CaseArgumentsExpected
a legal day: 4h30, 45 minute break, 4h30, then daily rest 2026-09-21, 06:00, activities ×4 → compliant true, driving minutes 540, breaches
five hours without a break passes the 4h30 limit at 10:30 2026-09-21, 06:00, activities ×2 → compliant false, driving minutes 300, breaches ×1
a split break of 15 then 30 minutes is legal 2026-09-21, 06:00, activities ×5 → compliant true, driving minutes 510, breaches
30 then 15 is not a split break: the second part must be at least 30 2026-09-21, 06:00, activities ×5 → compliant false, driving minutes 330, breaches ×1
an hour of other work is not a break 2026-09-21, 06:00, activities ×3 → compliant false, driving minutes 300, breaches ×1
a period of availability is not a break 2026-09-21, 06:00, activities ×3 → compliant false, driving minutes 280, breaches ×1
two rests recorded back to back are one 45 minute break 2026-09-21, 06:00, activities ×4 → compliant true, driving minutes 540, breaches
a 15 minute rest before any driving is not the first part of a split break 2026-09-21, 06:00, activities ×4 → compliant false, driving minutes 300, breaches ×1
10h30 of driving in a day passes the 10 hour ceiling at 16:30 2026-09-21, 05:00, activities ×5 → compliant false, driving minutes 630, breaches ×1
two 10 hour days in a week are allowed 2026-09-21, 06:00, activities ×11 → compliant true, driving minutes 1,200, breaches
Show the other 16 tests
CaseArgumentsExpected
a third 10 hour day in the same fixed week breaks the 9 hour limit 2026-09-21, 06:00, activities ×17 → compliant false, driving minutes 1,800, breaches ×1
six 9 hour days and 3 hours on Sunday pass 56 hours at 08:00 on Sunday 2026-09-21, 06:00, activities ×25 → compliant false, driving minutes 3,420, breaches ×1
driving across Sunday midnight is split between the two fixed weeks 2026-09-21, 06:00, activities ×25 → compliant true, driving minutes 3,480, breaches
54 hours then 36 hours is exactly 90; half an hour more passes the fortnightly limit 2026-09-21, 06:00, activities ×41 → compliant false, driving minutes 5,430, breaches ×1
breaches come back in time order 2026-09-21, 06:00, activities ×3 → compliant false, driving minutes 610, breaches ×3
no activities is compliant 2026-09-21, 06:00, → compliant true, driving minutes 0, breaches
a start time of 24:00 is refused 2026-09-21, 24:00, activities ×1 → error: startTime must be HH:MM, 24-hour
a date that is not ISO is refused 21/09/2026, 06:00, activities ×1 → error: startDate must be an ISO date (YYYY-MM-DD)
an unknown activity kind is refused 2026-09-21, 06:00, activities ×1 → error: unknown activity kind "sleeping"
a zero-length activity is refused 2026-09-21, 06:00, activities ×1 → error: minutes must be a whole number of at least 1
a fractional minute is refused 2026-09-21, 06:00, activities ×1 → error: minutes must be a whole number of at least 1
dates before Regulation 561/2006 applied (11 April 2007) are refused 2007-04-10, 06:00, activities ×1 → error: no drivers' hours limit
a start date with a trailing newline is refused 2026-09-21 , 06:00, activities ×1 → error: startDate must be an ISO date (YYYY-MM-DD), received "2026-09-21 "
a start date in Arabic-Indic digits is refused ٢٠٢٦-٠٩-٢١, 06:00, activities ×1 → error: startDate must be an ISO date (YYYY-MM-DD), received "٢٠٢٦-٠٩-٢١"
a start time with a trailing newline is refused 2026-09-21, 06:00 , activities ×1 → error: startTime must be HH:MM, 24-hour, received "06:00 "
a start time in Arabic-Indic digits is refused 2026-09-21, 0٦:0٠, activities ×1 → error: startTime must be HH:MM, 24-hour, received "0٦:0٠"

More from the author

checkDriversHours("2026-09-21", "06:00", [
  { kind: "driving", minutes: 300 }, { kind: "rest", minutes: 45 }
])
// { compliant: false, drivingMinutes: 300,
//   breaches: [{ rule: "break", at: "2026-09-21T10:30", actual: 300, limit: 270 }] }

## Input

The activities are back to back from `startDate` and `startTime`: each one starts when the previous one ends, as on a tachograph printout. `kind` is `driving`, `work` (other work), `availability` (a period of availability) or `rest` (any break or rest). Neighbouring activities of the same kind are merged, so a rest recorded as 20 minutes then 25 minutes is one uninterrupted 45-minute break.

Times are local wall-clock time with no time zone or clock change handling.

## Rules checked

| rule | limit | meaning | |------|-------|---------| | `break` | 270 minutes | Art. 7: more than 4h30 of driving without a qualifying break. `actual` is the driving in that driving period, up to the break that ended it | | `daily-driving` | 600 minutes | Art. 6(1): daily driving time over 10 hours | | `daily-driving` | 540 minutes | Art. 6(1): a third (or later) day over 9 hours in one fixed week; `at` is when that day passed 9 hours | | `weekly-driving` | 3360 minutes | Art. 6(2): over 56 hours in one fixed week | | `fortnightly-driving` | 5400 minutes | Art. 6(3): over 90 hours in two consecutive fixed weeks; `at` falls in the second week |

How the activities are read:

- **A break** is a rest of at least 45 minutes, or a rest of at least 15 minutes followed later by one of at least 30 (in that order: 30 then 15 does not count). Other work and periods of availability are not breaks. A 15-minute rest before any driving in the period is not the first part of a split break, since it is not taken during the driving period. - **A daily rest** is a rest of 9 hours or more. It ends the daily driving time (Art. 4(k)); a shorter rest, including the 3-hour first part of a split daily rest, does not. - **Weeks** are fixed weeks, 00:00 Monday to 24:00 Sunday (Art. 4(i)). Driving that crosses Sunday midnight counts in both weeks, minute by minute. A day's 10-hour extension counts in the week its first driving falls in. - **The end of the records** closes the last driving period and day, so records that stop mid-day are judged on what they show.

Whether daily and weekly *rest* was taken is a separate check: `fleet.tachograph-rest`.

## What it does not do

- GB domestic drivers' hours rules (Transport Act 1968), AETR, or mixed domestic/EU days. - Multi-manning, ferry or train crossings (Art. 9), the Art. 12 emergency departure, and the exemptions and national derogations (Art. 3 and 13). - The working-time limits (Road Transport (Working Time) Regulations 2005), including the 6-hour work break rule. - Weeks outside the records: the fortnightly check only adds up weeks it has data for, so pass the previous week's activities too if they matter.

## Data

The limits are dated rows in `data/limits.json`, effective from 11 April 2007 (Art. 29) and still in force. A date before that is an error.

## Sources

- Regulation (EC) No 561/2006, as it applies in the UK, legislation.gov.uk: Article 4 (definitions of break, rest, daily rest, week, daily driving time, driving period), Article 6 (9h/10h twice a week, 56h, 90h), Article 7 (45 minutes after 4h30; 15 then 30), Article 29 (entry into force 11 April 2007). https://www.legislation.gov.uk/eur/2006/561/contents - DVSA, "Drivers' hours: Assimilated rules": https://www.gov.uk/drivers-hours/eu-rules - DVSA, "Drivers' hours and tachographs: goods vehicles", section 1 (split break order 15 then 30, other work and availability are not breaks, fixed week): https://www.gov.uk/guidance/drivers-hours-goods-vehicles/1-eu-and-aetr-rules-on-drivers-hours

1.0.1 fixes Python accepting a trailing newline or non-ASCII digits in startDate and startTime; adds tests.

Files

PathBytes
README.md4,340
data/limits.json1,581
impl/python.py8,954
impl/rust.rs12,435
impl/typescript.ts8,414
vectors.json10,305