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.

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:

base_target = (days_worked_per_week x full_weeks_in_year) - annual_leave_days
target      = base_target x (fte_percentage / 100)

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 -- they do not have automatic targets.

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 Study leave is excluded at DB level; then filtered by model property
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 All except study leave and coroners STUDY_LEAVE, CORONERS
count_by_type Single type filter All other types

What Counts as Worked

The Shift.counts_as_worked model property:

return self.type != ShiftType.STUDY_LEAVE.value
ShiftType Counts as Worked Counts Toward Minimum
STANDARD Yes Yes
DUTY Yes Yes
STUDY_LEAVE No No
CORONERS Yes No

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:

effective_entitlement = base_entitlement + leave_adjustments + manual_adjustments
remaining             = effective_entitlement - total_used_approved

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.


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.