Calculations Reference¶
The calculations Django app provides the mathematical foundation for rota generation, deficiency tracking, leave management, and reporting. Every numeric result uses Python's Decimal type with 2-decimal-place precision to avoid floating-point errors.
Architecture Overview¶
calculations/
deficiency_calculator.py DeficiencyCalculator -- target vs actual gap
target_calculator.py TargetCalculator -- expected shift count
shift_counter.py ShiftCounter -- count worked/completed shifts
leave_calculator.py LeaveCalculator -- annual leave usage & quota
sick_leave_calculator.py SickLeaveCalculator -- sick leave fairness credit
date_utils.py Date/week/bank-holiday helpers
rounding.py Standardised rounding (ROUND_HALF_UP, 2dp)
constants.py DECIMAL_PRECISION, DAYS_PER_WEEK, etc.
validation_utils.py Input validation (dates, percentages, etc.)
cache_utils.py Caching decorators & metrics
metrics.py Prometheus metrics
helpers.py to_decimal, to_decimal_or_none
circuit_breaker.py Resilient API calls for bank holidays
All calculators are plain Python classes (no Django model state). They accept domain objects from config.models and return Decimal values.
Numeric Conventions¶
| Convention | Value |
|---|---|
| Precision | Decimal('0.01') (2 decimal places) |
| Rounding mode | ROUND_HALF_UP (0.5 rounds up) |
| Bounds | Results clamped to [0, 9999.99] unless negative allowed |
| Half-day shift | Counts as 0.5 |
| Full-day shift | Counts as 1.0 |
| Weeks per year | 52 |
| Days per week | 7 |
The shared function safe_quantize(value, quantization='0.01', min_value=..., max_value=...) in date_utils.py handles quantization, NaN/infinity guards, and clamping in one call.
TargetCalculator¶
Determines how many shifts a clinician should have worked in a given date range. The calculation differs by WorkingTermType.
Partner Targets¶
Where:
- weeks =
calculate_weeks_between(effective_start, effective_end)(fractional, not rounded down) - target_days_per_week =
SystemConfiguration.target_working_days_per_week(default: 3.50) - fte_percentage =
WorkingTerm.percentage(0-100, stored as integer) - leave_deduction = pro-rated annual leave in days, calculated per term
Multi-term handling¶
A partner may have multiple WorkingTerm records within a single reporting period (e.g. an FTE change mid-period). Each term is calculated independently:
- Determine effective date range:
max(term.start_date, period_start)tomin(target_date, term.end_date or target_date). - Skip terms entirely outside the range.
- Calculate weeks, FTE, and leave deduction for each term.
- Sum all term targets.
Leave deduction (partner)¶
For each term that has annual_leave_entitlement:
total_weeks_in_year = calculate_weeks_between(year_start, year_end)
leave_weeks_in_term = (weeks / total_weeks_in_year) x total_leave_weeks
leave_deduction = leave_weeks_in_term x target_days_per_week x fte
This pro-rates the annual leave entitlement based on the fraction of the year this term covers.
Salaried Targets¶
Salaried doctors have a fixed_working_days list (e.g. ['Monday', 'Wednesday', 'Friday']). The target is simply the count of those weekdays in the effective date range, minus pro-rated annual leave days.
WorkingTerm.percentage is never applied to salaried targets — the fixed working pattern already encodes the salaried FTE, so applying the percentage as well would double-count it. percentage is the partner FTE encoder (see issue #202).
The weekday count uses an O(1) mathematical formula in count_weekdays_in_range() rather than iterating through every date.
Leave deduction (salaried)¶
Calendar Year Target¶
calculate_calendar_year_target() computes the full-year target in one call:
Salaried:
days_worked_per_week = len(fixed_working_days) (or target_days_per_week if the list is empty)
target = (days_worked_per_week x full_weeks_in_year) - annual_leave_days
No FTE-percentage multiplication: the working pattern is the salaried FTE.
Partner:
base_target = (target_days_per_week x full_weeks_in_year) - bank_holidays - (annual_leave_weeks x target_days_per_week)
target = base_target x (fte_percentage / 100)
Note: Partners get bank holidays deducted from their base target before FTE scaling.
Other term types¶
LOCUM and FY_DOCTOR/ST_DOCTOR (resident types) return a target of 0 from calculate_target_shifts -- they do not have automatic targets. calculate_calendar_year_target differs: it runs the partner formula for any non-salaried term, including locums and residents.
Caching¶
Target calculations are cached in Django's cache backend:
| Method | Cache Key Pattern | TTL |
|---|---|---|
calculate_target_shifts |
target_shifts:{clinician_id}:{target_date}:{period_start} |
1 hour |
calculate_calendar_year_target |
year_target:{clinician_id}:{year} |
end of day |
ShiftCounter¶
Counts actual shifts for a clinician within a date range. All counts account for shift duration (full = 1.0, half = 0.5).
Methods and What They Count¶
| Method | Shifts Included | Exclusions |
|---|---|---|
count_worked_shifts |
Shifts where counts_as_worked is True (all four session types) |
Cancelled shifts (the query filters status__in=Shift.LIVE_STATUSES) |
count_completed_shifts |
COMPLETED status + past SCHEDULED shifts |
Future scheduled shifts |
count_scheduled_shifts |
SCHEDULED status, date >= today |
Past scheduled shifts (those count as completed) |
count_all_shifts |
All shifts regardless of type/status | None |
count_duty_days |
type = DUTY |
All other types |
count_toward_minimum |
Sessions that count for daily minimums | STUDY_LEAVE, CORONERS, off-sick shifts, and non-live (cancelled) shifts; the model property counts_toward_minimum additionally excludes resident (FY/ST) shifts |
count_by_type |
Single type filter | All other types |
What Counts as Worked¶
The Shift.counts_as_worked model property:
A session counts as worked however it is labelled (issue #197): all four
session types are worked sessions, including shifts flagged is_off_sick
(sickness affects minimum-staffing cover, not workload). ANNUAL_LEAVE and
NOT_WORKING are leave-request types, never shift types, so they never count.
| ShiftType | Counts as Worked | Counts Toward Minimum |
|---|---|---|
STANDARD |
Yes | Yes |
DUTY |
Yes | Yes |
STUDY_LEAVE |
Yes | No |
CORONERS |
Yes | No |
Shifts flagged is_off_sick=True count as worked but never toward the
minimum, whatever their type.
Duration Weighting¶
All methods go through _sum_shift_durations(), which returns a total quantized to 0.01.
Completed vs Scheduled Semantics¶
The distinction between count_completed_shifts and count_scheduled_shifts is based on the as_of_date parameter (defaults to today):
- Completed: status =
COMPLETED, OR status =SCHEDULEDwith date < as_of_date. Past scheduled shifts are treated as completed. - Scheduled: status =
SCHEDULEDwith date >= as_of_date. Only future scheduled shifts.
DeficiencyCalculator¶
Computes the gap between target and actual shifts. This is the core metric for fair workload distribution.
Formula¶
| Deficiency Value | Meaning |
|---|---|
| Positive (> 0) | Underworked -- clinician needs more shifts |
| Negative (< 0) | Overworked -- clinician has excess shifts |
| Zero | On target |
Methods¶
| Method | Actual Shifts Used | Purpose |
|---|---|---|
calculate_deficiency |
count_worked_shifts (past only) |
Historical deficiency |
calculate_deficiency_including_scheduled |
count_completed_shifts + count_scheduled_shifts |
Projected deficiency including future |
get_deficiency_status |
count_worked_shifts |
Returns TypedDict with status label |
DeficiencyStatus¶
class DeficiencyStatus(TypedDict):
deficiency: Decimal # target - actual
target: Decimal # expected shifts
actual: Decimal # worked shifts
status: Literal['on_track', 'under', 'over']
Status is determined by a threshold (default 1.0 shift):
|deficiency| <= threshold-->on_trackdeficiency > threshold-->underdeficiency < -threshold-->over
Period-based Calculation¶
calculate_period_deficiency(clinician, period_name) looks up the period's dates from SystemConfiguration.reporting_periods and delegates to calculate_deficiency.
Average Shift Calculations¶
| Method | Formula |
|---|---|
calculate_two_week_average |
worked_shifts(last 14 days) / 2 |
calculate_year_to_date_average |
worked_shifts(year_start to today) / weeks_elapsed |
Both use count_worked_shifts for the actual count and guard against division by zero (minimum 0.01 weeks).
Caching¶
Deficiency results are cached for 5 minutes (300 seconds) with key pattern deficiency:{clinician_id}:{target_date}:{period_start}.
Audit Logging¶
Every deficiency calculation creates an AuditLog entry recording the clinician, target, actual, deficiency, and date range.
LeaveCalculator¶
Manages annual leave entitlement, usage, and quota validation. Calculation differs between partners (measured in weeks) and salaried doctors (measured in working days).
Units by Term Type¶
| Term Type | Leave Unit | Entitlement Source |
|---|---|---|
| Partner | Weeks | WorkingTerm.annual_leave_entitlement['total'] |
| Salaried | Working days | WorkingTerm.annual_leave_entitlement['total'] |
Leave Used Calculation¶
Partner Leave (weeks)¶
days_diff = (end_date - start_date).days + 1 # inclusive
weeks = ceil(days_diff / 7) # always round UP
Partners take leave in calendar weeks. Any partial week counts as a full week.
Salaried Leave (working days)¶
Only days that match the clinician's fixed_working_days pattern count. Uses O(1) mathematical formula.
Pro-Rata Entitlement¶
For clinicians starting mid-year:
total_days = (end_date - start_date).days + 1
weeks_working = total_days / 7
pro_rata_fraction = weeks_working / 52
result = ceil(full_entitlement x pro_rata_fraction) # rounds UP to favour clinician
Shifts Affected by Leave¶
Represents the scheduling cost of a leave request.
Partner:
Salaried: Same as leave days calculation (count of fixed working days in range).
Remaining Leave¶
calculate_remaining_leave() and calculate_total_remaining_leave() both use a single-pool quota system scoped to one working term (issue #206): the quota term is resolved date-aware via WorkingTerm.get_term_for_quota (active-on-date, else latest started, never future) and approved usage is clipped to that term's window (sum_used_within_term):
quota_term = WorkingTerm.get_term_for_quota(date)
effective_entitlement = base_entitlement + leave_adjustments + manual_adjustments
remaining = effective_entitlement - sum_used_within_term(quota_term)
The extended method calculate_total_remaining_leave() returns a LeaveRemaining TypedDict:
class LeaveRemaining(TypedDict):
has_entitlement: bool
remaining: Decimal
unit: Literal['days', 'weeks']
entitlement: Decimal
adjustments: Decimal
effective_entitlement: Decimal
Adjustments come from two sources:
1. LeaveAdjustment model (tied to WorkingTerm)
2. ManualAdjustment model (legacy, tied to Clinician)
Quota Validation with Pending¶
calculate_remaining_including_pending() accounts for both approved and pending requests. Uses select_for_update() to lock rows and transaction.atomic() to prevent concurrent over-booking.
class LeaveQuotaInfo(TypedDict):
remaining: Decimal
entitlement: Decimal
used_approved: Decimal
used_pending: Decimal
unit: Literal['days', 'weeks']
Quota-Based Leave Types¶
Only ANNUAL_LEAVE counts against quota. NOT_WORKING, PLANNED_SICK, STUDY_LEAVE, and CORONERS do not.
Salaried Entitlement Calculator¶
calculations/salaried_entitlement.py (issue #201) is the single source of truth for deriving a salaried doctor's annual leave entitlement from their contracted sessions. Both the admin Leave Calculator page (/admin/annual-leave-calculator/) and the AI API endpoint (/api/v3/ai/working-terms/{id}/calculate-leave-entitlement/) call it, so the UI, the API, and any agent calculation can never drift apart. LeaveCalculator.get_leave_entitlement() remains the consumer: it reads whatever WorkingTerm.annual_leave_entitlement['total'] stores, and this calculator is what decides that value should be.
Partners are measured in weeks and are out of scope.
Calculation Method¶
FTE is measured in sessions out of 9 — a GP session is a half-day, so a 3-day week is 6 sessions. This basis is corroborated by the practice's own 6-session contract ("1.33 NHS days": only 2 x 6/9 produces it).
weekly_hours = sessions x (37.5 / 9)
annual_hours = weekly_hours x (30 / 5) # = weekly_hours x 6
monthly_hours = annual_hours / 12
monthly_days = monthly_hours / 7.5 # 7.5h full-time day
annual_days = monthly_days x 12 # = sessions x 10/3
nhs_days = 2 x (sessions / 9)
entitlement = round_half_up(
(annual_days + nhs_days) x fraction
+ bank_holiday_net_days
)
Sessions derive from the term's working pattern: each day in fixed_working_days counts as 2 sessions; a day also in fixed_half_days counts as 1.
NHS Days Are Included in the Entitlement¶
The NHS days (2 x sessions / 9) are a bookable contractual entitlement — the practice's own contract grants them in their own sentence ("You will also receive 1.33 NHS days"), and BMA/Wessex LMC guidance is explicit that these days "may be taken at any time by the salaried GP". They are therefore added into the stored entitlement: the full-year annual leave figure and the NHS-days figure are summed (full_year_entitlement_days = annual_days + nhs_days), then pro-rated and rounded once, together. The breakdown keeps the two components separate so the addition stays visible.
Because the salaried rota target subtracts the stored entitlement (calculate_calendar_year_target), NHS days booked as leave are automatically accounted for in the target.
Bank Holidays: the Sessional Entitlement¶
A salaried doctor's bank holiday entitlement is a sessional multiplier of the bank holidays in the term — a 9-session doctor does not get the full eight. The practice's formula (issue #201, comment 1462):
allowance = ((contracted weekly hours / 5)
/ (a 2-session GP's weekly hours)) # 1 day's hours
x bank holidays in the term
= (sessions / 10) x bank holidays in the term
e.g. 9 sessions with 8 bank holidays: ((37.5 / 5) / 8.333) x 8 = 7.2 days; 6 sessions: 4.8.
Then, for each bank holiday on a day the doctor is due to work (a fixed_working_days day — the practice closes), 1 day is deducted from the allowance, and the net is clamped:
The net is added to the combined (annual leave + NHS days) figure before the single rounding, so a doctor can gain leave days from bank holidays but can never lose any — a doctor whose working days dodge bank holidays (e.g. Tue/Thu) accrues the leftover allowance as extra bookable days.
- The bank holidays are the real calendar for the practice's configured UK nation, scoped to the term window — partial-year terms scale by the count actually inside the term (which may be fewer than 8).
- An open-ended term counts the bank holidays in the 12 months from its start, matching the recurring-annual reading of the full-year pro-rata.
- For a Monday–Friday doctor every bank holiday is owed back, and since the allowance is always below the count (
0.8 x sessions <= 7.2 < 8), the net is 0 — the entitlement equals the AL + NHS figure.
Rounding¶
Round half up, in the doctor's favour (0.5 and above rounds up), applied once to the combined (annual leave + NHS days + net bank holiday days) figure after pro-rating.
Pro-Rata for Partial Years¶
Partial terms are pro-rated by calendar days in term / days in year, computed per calendar-year segment so that:
- a full calendar year contributes exactly 1.0,
- leap years use 366 days,
- a term spanning 01/04/2026–31/03/2027 also totals exactly 1.0 (275/365 + 90/365).
The fraction applies to the combined entitlement (annual leave + NHS days); the bank holiday part scales by its own count of bank holidays in the term, not by the fraction. One rounding at the end.
An open-ended term (no end_date) is calculated as a full year — an indefinite contract's stored entitlement is the recurring annual figure (assumed_full_year: true flags this). This replaces the older generic LeaveCalculator.calculate_pro_rata_entitlement() weeks/52 basis for salaried entitlements (that method remains for partner-week pro-rating).
Reference Table (full year, 8 bank holidays)¶
| sessions | weekly hrs | AL days | NHS days | entitlement* | BH allowance |
|---|---|---|---|---|---|
| 9 | 37.50 | 30.00 | 2.00 | 32 | 7.20 |
| 8 | 33.33 | 26.67 | 1.78 | 28 (28.44) | 6.40 |
| 7 | 29.17 | 23.33 | 1.56 | 25 (24.89) | 5.60 |
| 6 | 25.00 | 20.00 | 1.33 | 21 (21.33) | 4.80 |
| 5 | 20.83 | 16.67 | 1.11 | 18 (17.78) | 4.00 |
| 4 | 16.67 | 13.33 | 0.89 | 14 (14.22) | 3.20 |
| 3 | 12.50 | 10.00 | 0.67 | 11 (10.67) | 2.40 |
| 2 | 8.33 | 6.67 | 0.44 | 7 (7.11) | 1.60 |
* assuming every bank holiday falls on a working day (net 0, the Monday–Friday case); the bracketed figure is the exact decimal before rounding. A doctor whose working days miss bank holidays gains — the calculator computes the real net per term against the actual calendar. Odd session counts (7/5/3) carry .89/.78/.67 fractions and round up; even counts (8/4/2) carry .44/.22/.11 fractions and round down. 9 and 6 sessions divide evenly.
Usage Insight¶
Alongside the derivation, the calculator reports the annual leave booked against the term (usage in the API response, the "Usage" section on the page): approved days (working days from approved ANNUAL_LEAVE requests clipped to the term window), pending days (REQUESTED, not yet charged), and the remaining figures against both the derived and stored entitlements. These come from LeaveCalculator.sum_used_within_term — the quota system's own accounting — so they always agree with what leave validation charges; the remaining figures are not clamped at zero (an over-allocated term reports a negative remainder).
Persistence¶
set_entitlement_on_term() is the shared write primitive (full-dict replacement with the single-pool {'total': <float>} shape). Two audited wrappers:
- Admin UI —
apply_entitlement_to_term()writes anAuditLogentry (user_id= the admin's username, previous/new entitlement) inside one transaction. - AI API —
POST .../calculate-leave-entitlement/?persist=truewraps the same primitive withlog_ai_write(full-field snapshots,user_id=ai-api:<key_id>), so the standard rollback endpoint can restore the previous entitlement.
SickLeaveCalculator¶
Provides fairness credits for partners on sick leave so they do not fall behind in deficiency calculations.
Formula¶
Only applies to clinicians with an active PARTNER working term. Salaried doctors receive 0 (their fixed schedule does not require this adjustment).
Date Range Handling¶
For each sick leave record:
effective_start = max(sick_leave.start_date, range_start)effective_end = min(sick_leave.end_date, range_end)(or today if open-ended)- Weeks calculated with
calculate_weeks_between(effective_start, effective_end, round_down=False)
Two methods are available:
- calculate_credit(clinician, year) -- full calendar year
- calculate_credit_for_date_range(clinician, start_date, end_date) -- arbitrary range
Date Utilities¶
date_utils.py provides shared date arithmetic used by all calculators.
Week Calculation¶
By default, rounds down to whole weeks (ROUND_FLOOR). The round_down=False option preserves fractional weeks.
Bank Holidays¶
Uses the govuk-bank-holidays library to fetch UK bank holidays from the government API:
- Cached in Django cache for 30 days (2,592,000 seconds)
- Thread-safe cache updates with per-nation locks
- Circuit breaker protection against API failures
- Configurable per UK nation via
SystemConfiguration.uk_nation
| Nation | govuk-bank-holidays Division |
|---|---|
| england | england-and-wales |
| wales | england-and-wales |
| scotland | scotland |
| northern_ireland | northern-ireland |
Weekday Counting¶
count_weekdays_in_range(start, end, target_weekdays) uses an O(1) formula:
Key Functions¶
| Function | Returns |
|---|---|
calculate_weeks_between(start, end) |
Decimal weeks |
count_weekdays_in_range(start, end, days) |
int count |
is_bank_holiday(date) |
bool |
get_bank_holidays_in_year(year) |
list[date] |
get_year_boundaries(date) |
(year_start, year_end) tuple |
get_full_weeks_in_year(date) |
int (floor of days / 7) |
get_days_between(start, end) |
int (inclusive) |
get_reporting_period_for_date(date) |
dict or None |
safe_quantize(value, ...) |
Clamped, quantized Decimal |
Validation and Error Handling¶
validation_utils.py provides consistent input validation:
| Function | Purpose |
|---|---|
validate_date_range(start, end) |
Ensures start <= end |
validate_percentage(value) |
Ensures 0-100 range |
safe_decimal_conversion(value) |
Converts any numeric type to Decimal with fallback |
validate_entitlement_dict(dict) |
Ensures {'total': <numeric>} structure |
validate_nation_parameter(nation) |
Validates UK nation code |
All calculators call validate_date_range before processing and raise ValueError or TypeError for invalid inputs.
Caching Strategy¶
| Calculator | Cache TTL | Cache Key |
|---|---|---|
| Target shifts | 1 hour | target_shifts:{id}:{date}:{start} |
| Calendar year target | End of day | year_target:{id}:{year} |
| Deficiency | 5 minutes | deficiency:{id}:{date}:{start} |
| Bank holidays | 30 days | bank_holidays:{nation} |
Cache hit/miss metrics are tracked by CacheMetrics (thread-safe) and can be monitored via Prometheus or logged periodically via the log_cache_stats_middleware.