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 restcheck_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:30check_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_date | date | local date the first activity starts |
| start_time | string | HH:MM, 24-hour, local time the first activity starts |
| activities | DriverActivity[] | back to back and in order: each starts when the one before ends |
| returns | DriversHoursCheck |
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
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
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.
| Case | Arguments | Expected | |
|---|---|---|---|
| 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
| Case | Arguments | Expected | |
|---|---|---|---|
| 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
| Path | Bytes |
|---|---|
| README.md | 4,340 |
| data/limits.json | 1,581 |
| impl/python.py | 8,954 |
| impl/rust.rs | 12,435 |
| impl/typescript.ts | 8,414 |
| vectors.json | 10,305 |