education.cpd-hours
CPD logged against a requirement for a rolling or fixed period: minutes counted, shortfall and days left.
1.0.0 · published 2026-10-03 by charlie · Anterra
Pinned by 20 tests, run in TypeScript, Python and Rust.
What it does
Totals a continuing professional development log against a requirement, for the period that applies on a date, and says what is still to do.
Professional bodies measure CPD two ways, and both are supported:
For example
cpd_hours(entries ×5, required minutes 2,100, period months 12, rolling false, anchor 2025-04-01, 2026-03-15)→ period start 2025-04-01, period end 2026-03-31, logged minutes 840, entries counted 3, required minutes 2,100, shortfall minutes 1,260, met false, days remaining 16 an April-March CPD year on 15 March: the first day counts, the day before and a booked course after today do notcpd_hours(entries ×5, required minutes 800, period months 12, rolling false, anchor 2025-04-01, 2026-03-15)→ period start 2025-04-01, period end 2026-03-31, logged minutes 840, entries counted 3, required minutes 800, shortfall minutes 0, met true, days remaining 16 the requirement metcpd_hours(entries ×5, required minutes 840, period months 12, rolling false, anchor 2025-04-01, 2026-03-15)→ period start 2025-04-01, period end 2026-03-31, logged minutes 840, entries counted 3, required minutes 840, shortfall minutes 0, met true, days remaining 16 exactly met
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 cpd_hours(entries: Sequence[CpdEntry], requirement: CpdRequirement, on_date: str) -> CpdProgress
| entries | CpdEntry[] | the learning log, in any order |
| requirement | CpdRequirement | how much is needed over what period |
| on_date | date | the date to report on; entries after it are not counted yet |
| returns | CpdProgress |
The types it declares, generated into your project
@dataclass(frozen=True)
class CpdEntry:
"""One logged piece of CPD."""
date: str
#: 0 or more; 1.5 hours is 90
minutes: int
@dataclass(frozen=True)
class CpdRequirement:
"""The requirement a professional body sets."""
#: 35 hours is 2100
required_minutes: int
#: length of the period: 12 for a year, 36 for three
period_months: int
#: true: the periodMonths ending on onDate; false: fixed periods counted from anchor
rolling: bool
#: first day of any one fixed period, e.g. 2025-04-01 for an April to March CPD year; null when rolling
anchor: Optional[str]
@dataclass(frozen=True)
class CpdProgress:
"""Where the log stands against the requirement."""
period_start: str
#: last day of the period, inclusive; onDate for a rolling period
period_end: str
#: minutes dated in the period and on or before onDate
logged_minutes: int
entries_counted: int
required_minutes: int
#: still to do; 0 once met
shortfall_minutes: int
met: bool
#: days from onDate to periodEnd; 0 for a rolling period
days_remaining: int
Your code names it in one line, in the file that uses it
from fune.education.cpd_hours import cpd_hours # education.cpd-hours@^1
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
from typing import Sequence
from .dates_add_days import add_days, days_between, parse_iso_date ← from dates.add-days ^1.0.0 · built alongside by fune
from .dates_add_months import add_months ← from dates.add-months ^1.0.0 · built alongside by fune
from .education_cpd_hours_types import CpdEntry, CpdProgress, CpdRequirement
def _is_int(value: object) -> bool:
return isinstance(value, int) and not isinstance(value, bool)
def cpd_hours(entries: Sequence[CpdEntry], requirement: CpdRequirement, on_date: str) -> CpdProgress:
"""CPD minutes logged in the period that applies on on_date, against the
requirement. Fixed period boundaries are anchor + k x period_months, each
worked out from the anchor so month-end clamping never accumulates."""
on = parse_iso_date(on_date)
required = requirement.required_minutes
months = requirement.period_months
anchor = requirement.anchor
if not _is_int(required) or required < 0:
raise ValueError("requiredMinutes must be a whole number of 0 or more, received %s" % (required,))
if not _is_int(months) or months < 1:
raise ValueError("periodMonths must be a whole number of 1 or more, received %s" % (months,))
if requirement.rolling:
if anchor is not None:
raise ValueError("a rolling period takes no anchor")
period_end = on_date
period_start = add_days(add_months(on_date, -months), 1)
else:
if anchor is None:
raise ValueError("a fixed period needs an anchor date")
a = parse_iso_date(anchor)
months_apart = (on.year - a.year) * 12 + (on.month - a.month)
k = months_apart // months
while add_months(anchor, k * months) > on_date:
k -= 1
while add_months(anchor, (k + 1) * months) <= on_date:
k += 1
period_start = add_months(anchor, k * months)
period_end = add_days(add_months(anchor, (k + 1) * months), -1)
logged = 0
counted = 0
for e in entries:
parse_iso_date(e.date)
if not _is_int(e.minutes) or e.minutes < 0:
raise ValueError("minutes must be a whole number of 0 or more, received %s on %s" % (e.minutes, e.date))
if period_start <= e.date <= period_end and e.date <= on_date:
logged += e.minutes
counted += 1
shortfall = max(0, required - logged)
return CpdProgress(
period_start=period_start,
period_end=period_end,
logged_minutes=logged,
entries_counted=counted,
required_minutes=required,
shortfall_minutes=shortfall,
met=shortfall == 0,
days_remaining=days_between(on_date, period_end),
)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 education.cpd-hours
The manifest, vectors and README with only the Python implementation. Install it without the registry with fune add ./education.cpd-hours-1.0.0-python.fune, or fetch it from a terminal with fune pull education.cpd-hours@1.0.0:python.
The whole function, every language, is one file too: education.cpd-hours-1.0.0.fune, 27,361 bytes, sha256 0856b3ae6ff73b1a34e6910f31d9c3658aa73cc73e8d0b31b46eb79c1063ffa3. 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 education.cpd-hours
after — your function gets the result and the arguments, and returns the final result.
# fune: after education.cpd-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 education.cpd-hours
# fune: replace dates.add-months in education.cpd-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 education.cpd-hours --steps.
# fune: step education.cpd-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 | |
|---|---|---|---|
| an April-March CPD year on 15 March: the first day counts, the day before and a booked course after today do not | entries ×5, required minutes 2,100, period months 12, rolling false, anchor 2025-04-01, 2026-03-15 | → | period start 2025-04-01, period end 2026-03-31, logged minutes 840, entries counted 3, required minutes 2,100, shortfall minutes 1,260, met false, days remaining 16 |
| the requirement met | entries ×5, required minutes 800, period months 12, rolling false, anchor 2025-04-01, 2026-03-15 | → | period start 2025-04-01, period end 2026-03-31, logged minutes 840, entries counted 3, required minutes 800, shortfall minutes 0, met true, days remaining 16 |
| exactly met | entries ×5, required minutes 840, period months 12, rolling false, anchor 2025-04-01, 2026-03-15 | → | period start 2025-04-01, period end 2026-03-31, logged minutes 840, entries counted 3, required minutes 840, shortfall minutes 0, met true, days remaining 16 |
| the new CPD year starts from nothing | entries ×5, required minutes 2,100, period months 12, rolling false, anchor 2025-04-01, 2026-04-05 | → | period start 2026-04-01, period end 2027-03-31, logged minutes 0, entries counted 0, required minutes 2,100, shortfall minutes 2,100, met false, days remaining 360 |
| the last day of a fixed year | entries ×5, required minutes 2,100, period months 12, rolling false, anchor 2025-04-01, 2026-03-31 | → | period start 2025-04-01, period end 2026-03-31, logged minutes 930, entries counted 4, required minutes 2,100, shortfall minutes 1,170, met false, days remaining 0 |
| an anchor in the future still finds the period, counting back | entries ×5, required minutes 2,100, period months 12, rolling false, anchor 2027-04-01, 2026-03-15 | → | period start 2025-04-01, period end 2026-03-31, logged minutes 840, entries counted 3, required minutes 2,100, shortfall minutes 1,260, met false, days remaining 16 |
| a rolling year on 5 April 2026 runs from 6 April 2025 | entries ×5, required minutes 2,100, period months 12, rolling true, anchor —, 2026-04-05 | → | period start 2025-04-06, period end 2026-04-05, logged minutes 750, entries counted 3, required minutes 2,100, shortfall minutes 1,350, met false, days remaining 0 |
| a rolling three years, as for revalidation | entries ×5, required minutes 2,100, period months 36, rolling true, anchor —, 2026-03-15 | → | period start 2023-03-16, period end 2026-03-15, logged minutes 960, entries counted 4, required minutes 2,100, shortfall minutes 1,140, met false, days remaining 0 |
| a rolling year ending on 29 February starts on 1 March | entries ×3, required minutes 100, period months 12, rolling true, anchor —, 2024-02-29 | → | period start 2023-03-01, period end 2024-02-29, logged minutes 75, entries counted 2, required minutes 100, shortfall minutes 25, met false, days remaining 0 |
| monthly periods anchored on the 31st: February's starts on the 28th, March's on the 31st again | entries ×2, required minutes 120, period months 1, rolling false, anchor 2025-01-31, 2025-03-15 | → | period start 2025-02-28, period end 2025-03-30, logged minutes 0, entries counted 0, required minutes 120, shortfall minutes 120, met false, days remaining 15 |
Show the other 10 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| an empty log | , required minutes 2,100, period months 12, rolling false, anchor 2025-04-01, 2025-06-01 | → | period start 2025-04-01, period end 2026-03-31, logged minutes 0, entries counted 0, required minutes 2,100, shortfall minutes 2,100, met false, days remaining 303 |
| no requirement is always met | , required minutes 0, period months 12, rolling true, anchor —, 2025-06-01 | → | period start 2024-06-02, period end 2025-06-01, logged minutes 0, entries counted 0, required minutes 0, shortfall minutes 0, met true, days remaining 0 |
| a zero-minute entry counts as an entry and adds nothing | entries ×1, required minutes 2,100, period months 12, rolling false, anchor 2025-04-01, 2025-06-01 | → | period start 2025-04-01, period end 2026-03-31, logged minutes 0, entries counted 1, required minutes 2,100, shortfall minutes 2,100, met false, days remaining 303 |
| negative minutes are an error | entries ×1, required minutes 2,100, period months 12, rolling false, anchor 2025-04-01, 2025-06-01 | → | error: minutes must be a whole number of 0 or more, received -30 on 2025-05-01 |
| fractional minutes are an error | entries ×1, required minutes 2,100, period months 12, rolling false, anchor 2025-04-01, 2025-06-01 | → | error: minutes must be a whole number of 0 or more, received 1.5 |
| a negative requirement is an error | , required minutes -1, period months 12, rolling true, anchor —, 2025-06-01 | → | error: requiredMinutes must be a whole number of 0 or more, received -1 |
| a zero-month period is an error | , required minutes 60, period months 0, rolling true, anchor —, 2025-06-01 | → | error: periodMonths must be a whole number of 1 or more, received 0 |
| a fixed period without an anchor is an error | , required minutes 60, period months 12, rolling false, anchor —, 2025-06-01 | → | error: a fixed period needs an anchor date |
| a rolling period with an anchor is an error | , required minutes 60, period months 12, rolling true, anchor 2025-04-01, 2025-06-01 | → | error: a rolling period takes no anchor |
| an impossible entry date is an error | entries ×1, required minutes 2,100, period months 12, rolling false, anchor 2025-04-01, 2025-06-01 | → | error: "2025-02-30" is not a real calendar date |
More from the author
- **Fixed periods**: a CPD year (1 April to 31 March, 1 November to 31 October) or a three-year cycle. Give `anchor`, the first day of any one period, and `periodMonths`; the period containing `onDate` is found by counting whole periods from the anchor, forwards or backwards. - **Rolling periods**: "35 hours in the last three years", checked on any date. The period is the `periodMonths` ending on `onDate`: 12 months on 31 March 2026 runs from 1 April 2025.
## Decisions
- **Minutes, not hours.** CPD is logged in hours and half hours and quarter hours; minutes keep every sum exact. 35 hours is 2100. - **Entries after `onDate` do not count yet**, even inside a fixed period: booked courses are not CPD done. Entries before the period start do not count either. - **Period boundaries use `dates.add-months` from the anchor each time**, not step by step, so monthly periods anchored on the 31st start on the 28th (or 29th) of February and then on 31 March again, rather than drifting to the 28th for good. A rolling year ending 29 February 2024 starts on 1 March 2023. - Hours in categories (structured, verifiable, participatory), carry-over and pro-rating for part years are body-specific; filter the log per category and call once for each. - `rolling` with an `anchor`, or fixed without one, is refused rather than guessed.
Files
| Path | Bytes |
|---|---|
| README.md | 1,595 |
| impl/python.py | 2,585 |
| impl/rust.rs | 4,239 |
| impl/typescript.ts | 2,491 |
| vectors.json | 10,617 |