Skip to content

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

target = weeks x target_days_per_week x (fte_percentage / 100) - leave_deduction

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:

  1. Determine effective date range: max(term.start_date, period_start) to min(target_date, term.end_date or target_date).
  2. Skip terms entirely outside the range.
  3. Calculate weeks, FTE, and leave deduction for each term.
  4. 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

target = count_of_fixed_working_days_in_range - leave_deduction

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)

leave_days_in_term = (days_in_term / total_days_in_year) x total_leave_days

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:

return self.status in Shift.LIVE_STATUSES and self.type in Shift.WORKED_TYPES

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

if shift.duration == ShiftDuration.HALF.value:
    total += Decimal('0.5')
else:
    total += Decimal('1')

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 = SCHEDULED with date < as_of_date. Past scheduled shifts are treated as completed.
  • Scheduled: status = SCHEDULED with 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 = target_shifts - actual_worked_shifts
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_track
  • deficiency > threshold --> under
  • deficiency < -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)

working_days = count_weekdays_in_range(start_date, end_date, term.fixed_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:

shifts_affected = weeks x target_days_per_week x (fte_percentage / 100)

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:

bank_holiday_net_days = max(allowance - worked_bank_holidays, 0)

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 an AuditLog entry (user_id = the admin's username, previous/new entitlement) inside one transaction.
  • AI API — POST .../calculate-leave-entitlement/?persist=true wraps the same primitive with log_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

credit = total_sick_weeks x target_days_per_week

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

weeks = (end_date - start_date).days / 7

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:

complete_weeks = total_days // 7
count = complete_weeks x len(target_weekdays) + remainder_scan

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.