University of Cambridge Staff On-costs Calculator

The ucamstaffoncosts module calculates total on-costs associated with employing staff members in the University of Cambridge. The total on-costs value calculated by this module reflects the expenditure which will result from employing a staff member on a grant.

The aim is to replicate the information available on the University HR’s website using only the publicly available rates.

Installation

Installation is best done via pip:

$ pip install git+https://github.com/uisautomation/pidash-ucamstaffoncosts

Example

Our example employee will have the following attributes:

  • They are currently at the bottom of grade 2:

    >>> from ucamstaffoncosts.salary.scales import EXAMPLE_SALARY_SCALES
    >>> initial_grade = Grade.GRADE_2
    >>> initial_point = EXAMPLE_SALARY_SCALES.starting_point_for_grade(initial_grade)
    
  • Their next employment anniversary is on the 1st June 2016:

    >>> next_anniversary_date = datetime.date(2016, 6, 1)
    
  • They are on the USS salary exchange pension scheme:

    >>> scheme = Scheme.USS_EXCHANGE
    
  • Their contract started on 1st April 2015:

    >>> start_date = datetime.date(2015, 4, 1)
    
  • Their contract ends on 30th September and so they are no-longer employed from the 1st October:

    >>> until_date = datetime.date(2020, 10, 1)
    
  • The date from which we want to calculate the commitments is the 1st February 2016:

    >>> from_date = datetime.date(2016, 2, 1)
    

We can use the employment_expenditure_and_commitments() function to calculate the total commitment for employing this staff member as of from_date:

>>> import ucamstaffoncosts
>>> expenditure, commitments, explanations = ucamstaffoncosts.employment_expenditure_and_commitments(
...     until_date, initial_grade, initial_point, scheme, start_date=start_date,
...     from_date=from_date, next_anniversary_date=next_anniversary_date,
...     scale_table=EXAMPLE_SALARY_SCALES)
>>> expenditure
14837
>>> commitments
90245

This number seems a little arbitrary so we can use the provided list of explanations to explain the calculation:

>>> from ucamstaffoncosts.util import pprinttable
>>> def print_explanations(explanations):
...     running_commitment = 0
...     for explanation in explanations:
...         print('=' * 60)
...         print('TAX YEAR: {}/{}'.format(explanation.tax_year, explanation.tax_year+1))
...         print('\nSalaries\n--------\n')
...         pprinttable(explanation.salaries)
...         print('\nCosts\n-----')
...         if explanation.cost.tax_year != explanation.tax_year:
...             print('(approximated using tax tables for {})'.format(explanation.cost.tax_year))
...         print('\n')
...         pprinttable([explanation.cost])
...         print('\nSalary for year: {}'.format(explanation.salary))
...         print('Salary earned after {}: {}'.format(from_date, explanation.salary_to_come))
...         print('Expenditure until {}: {}'.format(from_date, explanation.expenditure))
...         print('Commitment from {}: {}'.format(from_date, explanation.commitment))
...         running_commitment += explanation.commitment
...         print('Running total commitment: {}'.format(running_commitment))
...         print('\n')
>>> print_explanations(explanations) # doctest: +NORMALIZE_WHITESPACE
============================================================
TAX YEAR: 2015/2016
<BLANKLINE>
Salaries
--------
<BLANKLINE>
date       | reason          | grade         | point | base_salary | mapping_table_date
-----------+-----------------+---------------+-------+-------------+-------------------
2015-04-01 | employee start  | Grade.GRADE_2 | P3    | 14254       | 2014-08-01
2015-04-06 | end of tax year | Grade.GRADE_2 | P3    | 14254       | 2014-08-01
<BLANKLINE>
Costs
-----
(approximated using tax tables for 2018)
<BLANKLINE>
<BLANKLINE>
salary | exchange | employer_pension | employer_nic | apprenticeship_levy | total | tax_year
-------+----------+------------------+--------------+---------------------+-------+---------
195    | -16      | 51               | 0            | 0                   | 230   | 2018
<BLANKLINE>
Salary for year: 195
Salary earned after 2016-02-01: 0
Expenditure until 2016-02-01: 230
Commitment from 2016-02-01: 0
Running total commitment: 0
<BLANKLINE>
<BLANKLINE>
============================================================
TAX YEAR: 2015/2016
<BLANKLINE>
Salaries
--------
<BLANKLINE>
date       | reason                         | grade         | point | base_salary | mapping_table_date
-----------+--------------------------------+---------------+-------+-------------+-------------------
2015-04-06 | start of tax year              | Grade.GRADE_2 | P3    | 14254       | 2014-08-01
2015-08-01 | new salary table (approximate) | Grade.GRADE_2 | P3    | 14539       | 2015-08-01
2016-04-06 | end of tax year                | Grade.GRADE_2 | P3    | 14539       | 2015-08-01
<BLANKLINE>
Costs
-----
(approximated using tax tables for 2018)
<BLANKLINE>
<BLANKLINE>
salary | exchange | employer_pension | employer_nic | apprenticeship_levy | total | tax_year
-------+----------+------------------+--------------+---------------------+-------+---------
14448  | -1156    | 3756             | 672          | 66                  | 17786 | 2018
<BLANKLINE>
Salary for year: 14448
Salary earned after 2016-02-01: 2582
Expenditure until 2016-02-01: 14607
Commitment from 2016-02-01: 3179
Running total commitment: 3179
<BLANKLINE>
<BLANKLINE>
============================================================
TAX YEAR: 2016/2017
<BLANKLINE>
Salaries
--------
<BLANKLINE>
date       | reason                      | grade         | point | base_salary | mapping_table_date
-----------+-----------------------------+---------------+-------+-------------+-------------------
2016-04-06 | start of tax year           | Grade.GRADE_2 | P3    | 14539       | 2015-08-01
2016-06-01 | anniversary: point P3 to P4 | Grade.GRADE_2 | P4    | 14818       | 2015-08-01
2016-08-01 | new salary table            | Grade.GRADE_2 | P4    | 15052       | 2016-08-01
2017-04-06 | end of tax year             | Grade.GRADE_2 | P4    | 15052       | 2016-08-01
<BLANKLINE>
Costs
-----
(approximated using tax tables for 2018)
<BLANKLINE>
<BLANKLINE>
salary | exchange | employer_pension | employer_nic | apprenticeship_levy | total | tax_year
-------+----------+------------------+--------------+---------------------+-------+---------
14934  | -1195    | 3883             | 733          | 68                  | 18423 | 2018
<BLANKLINE>
Salary for year: 14934
Salary earned after 2016-02-01: 14934
Expenditure until 2016-02-01: 0
Commitment from 2016-02-01: 18423
Running total commitment: 21602
<BLANKLINE>
<BLANKLINE>
============================================================
TAX YEAR: 2017/2018
<BLANKLINE>
Salaries
--------
<BLANKLINE>
date       | reason                      | grade         | point | base_salary | mapping_table_date
-----------+-----------------------------+---------------+-------+-------------+-------------------
2017-04-06 | start of tax year           | Grade.GRADE_2 | P4    | 15052       | 2016-08-01
2017-06-01 | anniversary: point P4 to P5 | Grade.GRADE_2 | P5    | 15356       | 2016-08-01
2017-08-01 | new salary table            | Grade.GRADE_2 | P5    | 15721       | 2017-08-01
2018-04-06 | end of tax year             | Grade.GRADE_2 | P5    | 15721       | 2017-08-01
<BLANKLINE>
Costs
-----
(approximated using tax tables for 2018)
<BLANKLINE>
<BLANKLINE>
salary | exchange | employer_pension | employer_nic | apprenticeship_levy | total | tax_year
-------+----------+------------------+--------------+---------------------+-------+---------
15557  | -1245    | 4045             | 813          | 71                  | 19241 | 2018
<BLANKLINE>
Salary for year: 15557
Salary earned after 2016-02-01: 15557
Expenditure until 2016-02-01: 0
Commitment from 2016-02-01: 19241
Running total commitment: 40843
<BLANKLINE>
<BLANKLINE>
============================================================
TAX YEAR: 2018/2019
<BLANKLINE>
Salaries
--------
<BLANKLINE>
date       | reason                         | grade         | point | base_salary | mapping_table_date
-----------+--------------------------------+---------------+-------+-------------+-------------------
2018-04-06 | start of tax year              | Grade.GRADE_2 | P5    | 15721       | 2017-08-01
2018-08-01 | new salary table (approximate) | Grade.GRADE_2 | P5    | 16035       | 2018-08-01
2019-04-06 | end of tax year                | Grade.GRADE_2 | P5    | 16035       | 2018-08-01
<BLANKLINE>
Costs
-----
<BLANKLINE>
<BLANKLINE>
salary | exchange | employer_pension | employer_nic | apprenticeship_levy | total | tax_year
-------+----------+------------------+--------------+---------------------+-------+---------
15934  | -1275    | 4143             | 860          | 73                  | 19735 | 2018
<BLANKLINE>
Salary for year: 15934
Salary earned after 2016-02-01: 15934
Expenditure until 2016-02-01: 0
Commitment from 2016-02-01: 19735
Running total commitment: 60578
<BLANKLINE>
<BLANKLINE>
============================================================
TAX YEAR: 2019/2020
<BLANKLINE>
Salaries
--------
<BLANKLINE>
date       | reason                         | grade         | point | base_salary | mapping_table_date
-----------+--------------------------------+---------------+-------+-------------+-------------------
2019-04-06 | start of tax year              | Grade.GRADE_2 | P5    | 16035       | 2018-08-01
2019-08-01 | new salary table (approximate) | Grade.GRADE_2 | P5    | 16356       | 2019-08-01
2020-04-06 | end of tax year                | Grade.GRADE_2 | P5    | 16356       | 2019-08-01
<BLANKLINE>
Costs
-----
(approximated using tax tables for 2018)
<BLANKLINE>
<BLANKLINE>
salary | exchange | employer_pension | employer_nic | apprenticeship_levy | total | tax_year
-------+----------+------------------+--------------+---------------------+-------+---------
16253  | -1300    | 4226             | 901          | 74                  | 20154 | 2018
<BLANKLINE>
Salary for year: 16253
Salary earned after 2016-02-01: 16253
Expenditure until 2016-02-01: 0
Commitment from 2016-02-01: 20154
Running total commitment: 80732
<BLANKLINE>
<BLANKLINE>
============================================================
TAX YEAR: 2020/2021
<BLANKLINE>
Salaries
--------
<BLANKLINE>
date       | reason                         | grade         | point | base_salary | mapping_table_date
-----------+--------------------------------+---------------+-------+-------------+-------------------
2020-04-06 | start of tax year              | Grade.GRADE_2 | P5    | 16356       | 2019-08-01
2020-08-01 | new salary table (approximate) | Grade.GRADE_2 | P5    | 16683       | 2020-08-01
2020-10-01 | end of employment              | Grade.GRADE_2 | P5    | 16683       | 2020-08-01
<BLANKLINE>
Costs
-----
(approximated using tax tables for 2018)
<BLANKLINE>
<BLANKLINE>
salary | exchange | employer_pension | employer_nic | apprenticeship_levy | total | tax_year
-------+----------+------------------+--------------+---------------------+-------+---------
8031   | -642     | 2088             | 0            | 36                  | 9513  | 2018
<BLANKLINE>
Salary for year: 8031
Salary earned after 2016-02-01: 8031
Expenditure until 2016-02-01: 0
Commitment from 2016-02-01: 9513
Running total commitment: 90245
<BLANKLINE>
<BLANKLINE>

If initial_grade is None, the mapping table will still be used but annual increments will not be processed which means that the total expenditure and commitments will be lower:

>>> expenditure, commitments, explanations = ucamstaffoncosts.employment_expenditure_and_commitments(
...     until_date, None, initial_point, scheme, start_date=start_date,
...     from_date=from_date, next_anniversary_date=next_anniversary_date,
...     scale_table=EXAMPLE_SALARY_SCALES)
>>> expenditure
14837
>>> commitments
87258

Reference

ucamstaffoncosts.employment_expenditure_and_commitments(until_date, initial_grade, initial_point, scheme, start_date=None, from_date=None, occupancy=1, tax_year_start_month=4, tax_year_start_day=6, **kwargs)[source]

Calculate the existing expenditure and remaining commitments for an employee’s contract. Returns a pair giving the total commitment and a list of CommitmentExplanation tuples explaining the calculation.

Parameters:
  • from_date – date at which to differentiate between expenditure and commitments. If None, today’s date is used.
  • start_date – date at which employee started. Results will be returned from this date. If None, the “from date” is used.
  • occupancy (numbers.Rational) – proportion of full-time this employee works

Remaining keyword arguments are passed to costs_by_tax_year().

>>> from ucamstaffoncosts import Grade, Scheme
>>> from ucamstaffoncosts.salary.scales import EXAMPLE_SALARY_SCALES
>>> initial_grade = Grade.GRADE_2
>>> initial_point = EXAMPLE_SALARY_SCALES.starting_point_for_grade(initial_grade)
>>> scheme = Scheme.USS_EXCHANGE
>>> next_anniversary_date = datetime.date(2016, 6, 1)
>>> from_date = next_anniversary_date - datetime.timedelta(days=400)
>>> until_date = from_date + datetime.timedelta(days=1000)
>>> expenditure, commitments, _ = employment_expenditure_and_commitments(
...     until_date, initial_grade, initial_point, scheme,
...     from_date=from_date, next_anniversary_date=next_anniversary_date,
...     scale_table=EXAMPLE_SALARY_SCALES)
>>> expenditure
0
>>> commitments
50146

The occupancy parameter allows one to specify what proportion of full time this person works:

>>> import fractions
>>> _, half_time_commitments, _ = employment_expenditure_and_commitments(
...     until_date, initial_grade, initial_point, scheme,
...     occupancy=fractions.Fraction(50, 100),
...     from_date=from_date, next_anniversary_date=next_anniversary_date,
...     scale_table=EXAMPLE_SALARY_SCALES)
>>> half_time_commitments
24221

Notice that this value is not half of the full-time commitments since employer costs do not scale linearly!

The first tax year returned will contain the from_date:

>>> from_date = datetime.date(2016, 1, 1)
>>> _, _, explanations = employment_expenditure_and_commitments(
...     until_date, initial_grade, initial_point, scheme,
...     from_date=from_date, next_anniversary_date=next_anniversary_date,
...     scale_table=EXAMPLE_SALARY_SCALES)
>>> explanations[0].tax_year
2016
class ucamstaffoncosts.CommitmentExplanation[source]

An explanation of the commitment calculation for a given tax year.

tax_year

Integer giving the calendar year when the tax year started

salary

Total base salary for the tax year

salary_to_come

Salary which is yet to come. I.e. the salary which has not yet been earned as of the “from” date.

expenditure

The expenditure for this tax year. I.e. the amount of total cost of employment which has already been spent.

commitment

The commitment for this tax year. This is the total cost of employment minus the expenditure.

salaries

A list of SalaryRecord tuples which explain why the employee’s salary is what it is during the tax year.

cost

A Cost tuple explaining the total cost of employment for the tax year.

class ucamstaffoncosts.Scheme[source]

Possible pension schemes an employee can be a member of.

NONE = 1

No pension scheme.

CPS_HYBRID = 2

CPS hybrid

CPS_HYBRID_EXCHANGE = 3

CPS hybrid with salary exchange

USS = 4

USS

USS_EXCHANGE = 5

USS with salary exchange

NHS = 6

NHS

MRC = 7

MRC

CPS_PRE_2013 = 8

CPS pre-2013

CPS_PRE_2013_EXCHANGE = 9

CPS pre-2013 with salary exchange

class ucamstaffoncosts.Grade[source]

Possible grades an employee can be employed at.

T_GRADE = 1

“T” grade

GRADE_1 = 2

Grade 1

GRADE_2 = 3

Grade 2

GRADE_3 = 4

Grade 3

GRADE_4 = 5

Grade 4

GRADE_5 = 6

Grade 5

GRADE_6 = 7

Grade 6

GRADE_7 = 8

Grade 7

GRADE_8 = 9

Grade 8

GRADE_9 = 10

Grade 9

GRADE_10 = 11

Grade 10

GRADE_11 = 12

Grade 11

GRADE_12_BAND_1 = 13

Grade 12 band 1

GRADE_12_BAND_2 = 14

Grade 12 band 2

GRADE_12_BAND_3 = 15

Grade 12 band 3

GRADE_12_BAND_4 = 16

Grade 12 band 4

CLINICAL_NODAL = 17

Clinical nodal points

CLINICAL_CONSULTANT = 18

Clinical consultant

CLINICAL_RA_AND_LECTURER = 19

Clinical research associate or lecturer

class ucamstaffoncosts.Cost[source]

An individual on-costs calculation for a base salary.

Note

These values are all rounded to the nearest pound and so total may not be the sum of all the other fields.

salary

Base salary for the employee.

exchange

Amount of base salary exchanged as part of a salary exchange pension. By convention, this value is negative if non-zero.

employer_pension

Employer pension contribution including any salary exchange amount.

employer_nic

Employer National Insurance contribution

apprenticeship_levy

Share of Apprenticeship Levy from this employee

total

Total on-cost of employing this employee. See note above about situations where this value may not be the sum of the others.

tax_year

Which year’s table was used to calculate these costs.

class ucamstaffoncosts.SalaryRecord[source]

A collections.namedtuple subclass which represents a salary history record.

date

datetime.date at which this new salary takes effect.

reason

Human-readable string describing the reason for this change

grade

Grade representing the initial grade of the employee

point

string giving the name of this employee’s spine point

base_salary

base salary of the employee in pounds sterling

mapping_table_date

datetime.date giving the “effective from” date for the grade and point to base salary mapping table used

Costs calculation

The costs module provides support for calculating the total cost to employ a staff member. The functionality of the module is exposed through a single function, cost(), which takes a tax year, pension scheme and base salary and returns an Cost object representing the on-costs for that employee:

>>> calculate_cost(
...     base_salary=25000, scheme=Scheme.USS, year=2018
... ) # doctest: +NORMALIZE_WHITESPACE, +ELLIPSIS
Cost(salary=25000, exchange=0, employer_pension=4500,
     employer_nic=2287, apprenticeship_levy=125, total=31912, tax_year=...)

The total attribute from the return value can be used to forecast total expenditure for an employee in a given tax year.

If year is omitted, then the latest tax year which has any calculators implemented is used. This behaviour can also be signalled by using the special value LATEST:

>>> latest_cost = calculate_cost(base_salary=25000, scheme=Scheme.USS, year=LATEST)
>>> latest_cost == calculate_cost(base_salary=25000, scheme=Scheme.USS)
True

We can get a detailed breakdown of costs for each tax year using the costs_by_tax_year() function. Our example employee will be a grade 2 whose next employment anniversary is on the 1st June 2016. The employee on on the USS salary exchange pension scheme and has a first date of non-employment of 1st October 2020:

>>> from ucamstaffoncosts import Grade
>>> from ucamstaffoncosts.salary.scales import EXAMPLE_SALARY_SCALES
>>> initial_grade = Grade.GRADE_2
>>> initial_point = EXAMPLE_SALARY_SCALES.starting_point_for_grade(initial_grade)
>>> next_anniversary_date = datetime.date(2016, 6, 1)
>>> scheme = Scheme.USS_EXCHANGE
>>> until_date = datetime.date(2020, 10, 1)

We can calculate the total cost of employment by passing these values to :

>>> costs = list(costs_by_tax_year(
...     2016, initial_grade, initial_point, scheme,
...     next_anniversary_date=next_anniversary_date,
...     until_date=until_date, scale_table=EXAMPLE_SALARY_SCALES))

We can print out details of these costs to fully explain the cost calculation:

>>> from ucamstaffoncosts.util import pprinttable
>>> for year, cost, salaries in costs:
...     print('=' * 60)
...     print('TAX YEAR: {}/{}'.format(year, year+1))
...     print('\nSalaries\n--------\n')
...     pprinttable(salaries)
...     print('\nCosts\n-----')
...     if cost.tax_year != year:
...         print('(approximated using tax tables for {})'.format(cost.tax_year))
...     print('\n')
...     pprinttable([cost])
...     print('\n') # doctest: +NORMALIZE_WHITESPACE
============================================================
TAX YEAR: 2016/2017
<BLANKLINE>
Salaries
--------
<BLANKLINE>
date       | reason                      | grade         | point | base_salary | mapping_table_date
-----------+-----------------------------+---------------+-------+-------------+-------------------
2016-04-06 | start of tax year           | Grade.GRADE_2 | P3    | 14539       | 2015-08-01
2016-06-01 | anniversary: point P3 to P4 | Grade.GRADE_2 | P4    | 14818       | 2015-08-01
2016-08-01 | new salary table            | Grade.GRADE_2 | P4    | 15052       | 2016-08-01
2017-04-06 | end of tax year             | Grade.GRADE_2 | P4    | 15052       | 2016-08-01
<BLANKLINE>
Costs
-----
(approximated using tax tables for 2018)
<BLANKLINE>
<BLANKLINE>
salary | exchange | employer_pension | employer_nic | apprenticeship_levy | total | tax_year
-------+----------+------------------+--------------+---------------------+-------+---------
14934  | -1195    | 3883             | 733          | 68                  | 18423 | 2018
<BLANKLINE>
<BLANKLINE>
============================================================
TAX YEAR: 2017/2018
<BLANKLINE>
Salaries
--------
<BLANKLINE>
date       | reason                      | grade         | point | base_salary | mapping_table_date
-----------+-----------------------------+---------------+-------+-------------+-------------------
2017-04-06 | start of tax year           | Grade.GRADE_2 | P4    | 15052       | 2016-08-01
2017-06-01 | anniversary: point P4 to P5 | Grade.GRADE_2 | P5    | 15356       | 2016-08-01
2017-08-01 | new salary table            | Grade.GRADE_2 | P5    | 15721       | 2017-08-01
2018-04-06 | end of tax year             | Grade.GRADE_2 | P5    | 15721       | 2017-08-01
<BLANKLINE>
Costs
-----
(approximated using tax tables for 2018)
<BLANKLINE>
<BLANKLINE>
salary | exchange | employer_pension | employer_nic | apprenticeship_levy | total | tax_year
-------+----------+------------------+--------------+---------------------+-------+---------
15557  | -1245    | 4045             | 813          | 71                  | 19241 | 2018
<BLANKLINE>
<BLANKLINE>
============================================================
TAX YEAR: 2018/2019
<BLANKLINE>
Salaries
--------
<BLANKLINE>
date       | reason                         | grade         | point | base_salary | mapping_table_date
-----------+--------------------------------+---------------+-------+-------------+-------------------
2018-04-06 | start of tax year              | Grade.GRADE_2 | P5    | 15721       | 2017-08-01
2018-08-01 | new salary table (approximate) | Grade.GRADE_2 | P5    | 16035       | 2018-08-01
2019-04-06 | end of tax year                | Grade.GRADE_2 | P5    | 16035       | 2018-08-01
<BLANKLINE>
Costs
-----
<BLANKLINE>
<BLANKLINE>
salary | exchange | employer_pension | employer_nic | apprenticeship_levy | total | tax_year
-------+----------+------------------+--------------+---------------------+-------+---------
15934  | -1275    | 4143             | 860          | 73                  | 19735 | 2018
<BLANKLINE>
<BLANKLINE>
============================================================
TAX YEAR: 2019/2020
<BLANKLINE>
Salaries
--------
<BLANKLINE>
date       | reason                         | grade         | point | base_salary | mapping_table_date
-----------+--------------------------------+---------------+-------+-------------+-------------------
2019-04-06 | start of tax year              | Grade.GRADE_2 | P5    | 16035       | 2018-08-01
2019-08-01 | new salary table (approximate) | Grade.GRADE_2 | P5    | 16356       | 2019-08-01
2020-04-06 | end of tax year                | Grade.GRADE_2 | P5    | 16356       | 2019-08-01
<BLANKLINE>
Costs
-----
(approximated using tax tables for 2018)
<BLANKLINE>
<BLANKLINE>
salary | exchange | employer_pension | employer_nic | apprenticeship_levy | total | tax_year
-------+----------+------------------+--------------+---------------------+-------+---------
16253  | -1300    | 4226             | 901          | 74                  | 20154 | 2018
<BLANKLINE>
<BLANKLINE>
============================================================
TAX YEAR: 2020/2021
<BLANKLINE>
Salaries
--------
<BLANKLINE>
date       | reason                         | grade         | point | base_salary | mapping_table_date
-----------+--------------------------------+---------------+-------+-------------+-------------------
2020-04-06 | start of tax year              | Grade.GRADE_2 | P5    | 16356       | 2019-08-01
2020-08-01 | new salary table (approximate) | Grade.GRADE_2 | P5    | 16683       | 2020-08-01
2020-10-01 | end of employment              | Grade.GRADE_2 | P5    | 16683       | 2020-08-01
<BLANKLINE>
Costs
-----
(approximated using tax tables for 2018)
<BLANKLINE>
<BLANKLINE>
salary | exchange | employer_pension | employer_nic | apprenticeship_levy | total | tax_year
-------+----------+------------------+--------------+---------------------+-------+---------
8031   | -642     | 2088             | 0            | 36                  | 9513  | 2018
<BLANKLINE>
<BLANKLINE>
ucamstaffoncosts.costs.LATEST = 'LATEST'

Special value to pass to cost() to represent the latest tax year which has an implementation.

ucamstaffoncosts.costs.calculate_cost(base_salary, scheme, year='LATEST')[source]

Return a Cost instance given a tax year, pension scheme and base salary.

Parameters:
  • year (int) – tax year
  • scheme (Scheme) – pension scheme
  • base_salary (int) – base salary of employee
Raises:

NotImplementedError – if there is not an implementation for the specified tax year and pension scheme.

ucamstaffoncosts.costs.costs_by_tax_year(from_year, initial_grade, initial_point, scheme, occupancy=1, start_date=None, next_anniversary_date=None, tax_year_start_month=4, tax_year_start_day=6, until_date=None, **kwargs)[source]

Calculate total employment costs for each tax year for an employee who initially is at a particular grade and point. Keyword parameters are passed to ucamstaffoncosts.salary.progression.salary_progression().

Parameters:
  • from_year – tax year to start from
  • initial_grade – grade of employee at start of tax year
  • initial_point – salary spine point of employee at start of tax year
  • scheme – pension scheme of employee
  • start_date – date of employment start. If None, it is assumed the employee has been employed since before the start of the tax year
  • until_date – if None, employee is no longer employed on and after this date
>>> from ucamstaffoncosts import Grade
>>> from ucamstaffoncosts.salary.scales import EXAMPLE_SALARY_SCALES
>>> initial_grade = Grade.GRADE_2
>>> initial_point = EXAMPLE_SALARY_SCALES.starting_point_for_grade(initial_grade)
>>> next_anniversary_date = datetime.date(2016, 6, 1)
>>> until_date = datetime.date(2018, 5, 1)
>>> costs = list(costs_by_tax_year(
...     2016, initial_grade, initial_point, Scheme.USS_EXCHANGE,
...     next_anniversary_date=next_anniversary_date,
...     until_date=until_date, scale_table=EXAMPLE_SALARY_SCALES))

Each row returns the tax year, cost and salary record:

>>> costs[0][0]
2016
>>> costs[0][1] # doctest: +NORMALIZE_WHITESPACE
Cost(salary=14934, exchange=-1195, employer_pension=3883, employer_nic=733,
     apprenticeship_levy=68, total=18423, tax_year=2018)
>>> len(costs[0][2])
4
>>> costs[0][2][0] # doctest: +NORMALIZE_WHITESPACE
SalaryRecord(date=datetime.date(2016, 4, 6), reason='start of tax year',
             grade=<Grade.GRADE_2: 3>, point='P3', base_salary=14539,
             mapping_table_date=datetime.date(2015, 8, 1))

If until_date is before the start of from_year tax year, no results are returned >>> list(costs_by_tax_year( … 2016, initial_grade, initial_point, Scheme.USS_EXCHANGE, … next_anniversary_date=next_anniversary_date, … until_date=datetime.date(2016, 3, 1), scale_table=EXAMPLE_SALARY_SCALES)) []

Modelling salary progression

The progression module provides tool to model an employee’s salary change over time given a grade, point and date to start modelling from. (We call this the “from date” in this documentation.)

In our example, we’ll start modelling an employee’s salary from the 1st January 2016.

>>> from_date = datetime.date(2016, 1, 1)

To illustrate the use of the module, we’ll make use of the example salary scale table from the scales module documentation. When we start modelling, the employee is on the lowest point of grade 2:

>>> from ucamstaffoncosts import Grade
>>> from ucamstaffoncosts.salary.scales import EXAMPLE_SALARY_SCALES
>>> initial_grade = Grade.GRADE_2
>>> initial_point = EXAMPLE_SALARY_SCALES.starting_point_for_grade(initial_grade)

Their next employment anniversary is the 1st June 2016:

>>> next_anniversary_date = datetime.date(2016, 6, 1)

We want to show their salary progression up until the 1st January 2023:

>>> until_date = datetime.date(2023, 1, 1)

The salary_progression() function will return an iterable given these parameters which will represent the expected salary progression:

>>> progression = salary_progression(
...     from_date, initial_grade, initial_point, next_anniversary_date=next_anniversary_date,
...     until_date=until_date, scale_table=EXAMPLE_SALARY_SCALES)

The iterable returned from salary_progression() yields SalaryRecord tuples. We’ll use a format string to nicely present the progression in a table:

>>> fmt_str = ('{date!s: <12} | {reason: <35} | {grade!s: <16} | {point!s: <5} | '
...            '{base_salary: >8,d}')
>>> for row in progression:
...     print(fmt_str.format(**row._asdict()))
2016-01-01   | set salary                          | Grade.GRADE_2    | P3    |   14,539
2016-06-01   | anniversary: point P3 to P4         | Grade.GRADE_2    | P4    |   14,818
2016-08-01   | new salary table                    | Grade.GRADE_2    | P4    |   15,052
2017-06-01   | anniversary: point P4 to P5         | Grade.GRADE_2    | P5    |   15,356
2017-08-01   | new salary table                    | Grade.GRADE_2    | P5    |   15,721
2018-08-01   | new salary table (approximate)      | Grade.GRADE_2    | P5    |   16,035
2019-08-01   | new salary table (approximate)      | Grade.GRADE_2    | P5    |   16,356
2020-08-01   | new salary table (approximate)      | Grade.GRADE_2    | P5    |   16,683
2021-08-01   | new salary table (approximate)      | Grade.GRADE_2    | P5    |   17,017
2022-08-01   | new salary table (approximate)      | Grade.GRADE_2    | P5    |   17,357

The 1st August salary changes are approximated if the mapping table is not known. In this example, the actual tables for 2016 and 2017 were used but approximations were used for the remaining tables. Note that once the maximum anniversary increment point has been reached, no new increments are made on the employment anniversary.

ucamstaffoncosts.salary.progression.salary_progression(from_date, initial_grade, initial_point, initial_reason=None, next_anniversary_date=None, until_date=None, scale_table=<ucamstaffoncosts.salary.scales.SalaryScaleTable object>, approximate_negotiated_annual_change=None, negotiated_annual_change_month=None, negotiated_annual_change_day=None, elide_null_changes=True)[source]

Return an iterable of SalaryRecord tuples describing the salary progression of an employee.

Parameters:
  • from_date (datetime.date) – start returning results from this date
  • initial_grade (Grade) – initial grade for employee
  • initial_point (str) – initial spine point for employee
  • initial_reason – reason passed to set_salary()
  • next_anniversary_date (datetime.date) – next anniversary increment date or None if there is none
  • until_date (datetime.date) – it not None, stop returning results on or after this date
  • scale_table (SalaryScaleTable) – salary scale table to use
  • approximate_negotiated_annual_change – passed to salary_mapping_tables()
  • negotiated_annual_change_month – passed to salary_mapping_tables()
  • negotiated_annual_change_day – passed to salary_mapping_tables()
  • elide_null_changes – passed to fold()
class ucamstaffoncosts.salary.progression.Salary[source]

A collections.namedtuple subclass which represents a salary for an employee. The grade and point attributes must be set to represent a salary whereas the base_per_annum may be None if this object does not represent a particular per-annum base salary for that grade and point.

The base_per_annum and as_of_date attributes are optional and will default to None:

>>> from ucamstaffoncosts.salary.scales import Grade
>>> import datetime
>>> Salary(Grade.GRADE_2, 'POINT_X')
Salary(grade=<Grade.GRADE_2: 3>, point='POINT_X', base_per_annum=None, as_of_date=None)
>>> Salary(Grade.GRADE_2, 'POINT_X', 10000,
...        datetime.date(2017, 8, 1)) # doctest: +NORMALIZE_WHITESPACE
Salary(grade=<Grade.GRADE_2: 3>, point='POINT_X', base_per_annum=10000,
       as_of_date=datetime.date(2017, 8, 1))
grade

A Grade representing the grade of the employee.

point

A string giving the name of the salary spine point within the grade of the employee.

base_per_annum

If this salary has been converted to a per annum base salary, this is the per annum salary in pounds sterling. Otherwise, it is None.

as_of_date

If this salary has been converted to a per annum base salary, this is the effective date of the mapping table used.

with_base(base_per_annum, as_of_date)[source]

Return a copy of this salary with the base_per_annum and as_of_date modified.

>>> from ucamstaffoncosts.salary.scales import Grade
>>> import datetime
>>> s1 = Salary(Grade.GRADE_2, 'POINT_X')
>>> s1
Salary(grade=<Grade.GRADE_2: 3>, point='POINT_X', base_per_annum=None, as_of_date=None)
>>> s2 = s1.with_base(10000, datetime.date(2017, 8, 1))
>>> s2 # doctest: +NORMALIZE_WHITESPACE
Salary(grade=<Grade.GRADE_2: 3>, point='POINT_X', base_per_annum=10000,
       as_of_date=datetime.date(2017, 8, 1))
class ucamstaffoncosts.salary.progression.SalaryChange[source]

A collections.namedtuple subclass which represents a change of salary for an employee on a particular date.

date

A datetime.date on which the salary change happened.

update_salary

A callable which takes a Salary object and returns a (new Salary, reason) pair with the updated salary. The reason is a human-readable string describing the reason for the change.

ucamstaffoncosts.salary.progression.fold(changes, initial_salary=None, elide_null_changes=True)[source]

We will use the following list of SalaryChange tuples as examples:

>>> from ucamstaffoncosts.salary.scales import Grade
>>> changes = [
...     SalaryChange(date=datetime.date(2017, 1, 1),
...                  update_salary=lambda s: (s, 'pass through salary')),
...     SalaryChange(date=datetime.date(2017, 1, 1),
...                  update_salary=lambda _: (Salary(grade=Grade.GRADE_1, point='P1'),
...                                           'set grade and point')),
...     SalaryChange(date=datetime.date(2017, 2, 1),
...                  update_salary=lambda _: (Salary(grade=Grade.GRADE_1, point='P1'),
...                                           'null change')),
...     SalaryChange(date=datetime.date(2017, 3, 1),
...                  update_salary=lambda _: (Salary(grade=Grade.GRADE_1, point='P2'),
...                                           'point -> P2')),
...     SalaryChange(date=datetime.date(2017, 4, 1),
...                  update_salary=lambda _: (Salary(grade=Grade.GRADE_2, point='P2'),
...                                           'grade -> G2')),
... ]

If elide_null_changes is False, changes which do not change the salary are still reported:

>>> fmt_str = '{date!s: <12} | {reason: <20} | {grade!s: <16} | {point!s: <5}'
>>> for row in fold(changes, elide_null_changes=False):
...     print(fmt_str.format(**row._asdict())) # doctest: +NORMALIZE_WHITESPACE
2017-01-01   | pass through salary  | None             | None
2017-01-01   | set grade and point  | Grade.GRADE_1    | P1
2017-02-01   | null change          | Grade.GRADE_1    | P1
2017-03-01   | point -> P2          | Grade.GRADE_1    | P2
2017-04-01   | grade -> G2          | Grade.GRADE_2    | P2

The default is for elide_null_changes to be True which means that changes which do not change the salary are swallowed:

>>> for row in fold(changes):
...     print(fmt_str.format(**row._asdict())) # doctest: +NORMALIZE_WHITESPACE
2017-01-01   | set grade and point  | Grade.GRADE_1    | P1
2017-03-01   | point -> P2          | Grade.GRADE_1    | P2
2017-04-01   | grade -> G2          | Grade.GRADE_2    | P2

Note that the default grade and point are None. An initial salary can be set via initial_salary:

>>> initial_salary = Salary(grade=Grade.GRADE_2, point='P5')
>>> for row in fold(changes, initial_salary=initial_salary, elide_null_changes=False):
...     print(fmt_str.format(**row._asdict())) # doctest: +NORMALIZE_WHITESPACE
2017-01-01   | pass through salary  | Grade.GRADE_2    | P5
2017-01-01   | set grade and point  | Grade.GRADE_1    | P1
2017-02-01   | null change          | Grade.GRADE_1    | P1
2017-03-01   | point -> P2          | Grade.GRADE_1    | P2
2017-04-01   | grade -> G2          | Grade.GRADE_2    | P2
ucamstaffoncosts.salary.progression.map_grade_and_points(changes, salary_mapping_tables_kwargs={})[source]

Take an iterable yielding SalaryChange or callable objects and map the grade and point of each Salary to the base per annum salary.

Parameters:
  • changes – iterable yielding salary changes
  • salary_mapping_tables_kwargs – additional kwargs to pass to salary_mapping_tables().

To illustrate functionality, we’ll make use of the same example table as used in the ucamstaffoncosts.salary.scales documentation. We can model a Grade 2 employee starting on the lowest point in the following way:

>>> from ucamstaffoncosts import Grade
>>> from ucamstaffoncosts.salary.scales import EXAMPLE_SALARY_SCALES
>>> grade = Grade.GRADE_2
>>> point = EXAMPLE_SALARY_SCALES.starting_point_for_grade(grade)
>>> start_date = datetime.date(2017, 3, 5) # start date on contract
>>> next_anniversary_date = datetime.date(2017, 6, 1)
>>> increments = anniversary_increments(next_anniversary_date, table=EXAMPLE_SALARY_SCALES)
>>> salaries = map_grade_and_points(compose_changes(
...     set_salary(start_date, grade, point),
...     increments
... ), salary_mapping_tables_kwargs={'table': EXAMPLE_SALARY_SCALES})
>>> fmt_str = ('{date!s: <12} | {reason: <35} | {grade!s: <16} | {point!s: <5} | '
...            '{base_salary: >8,d}')
>>> for row in fold(until(datetime.date(2023, 1, 1), salaries), elide_null_changes=False):
...     print(fmt_str.format(**row._asdict()))
2017-03-05   | set salary                          | Grade.GRADE_2    | P3    |   14,767
2017-06-01   | anniversary: point P3 to P4         | Grade.GRADE_2    | P4    |   15,052
2017-08-01   | new salary table                    | Grade.GRADE_2    | P4    |   15,417
2018-06-01   | anniversary: point P4 to P5         | Grade.GRADE_2    | P5    |   15,721
2018-08-01   | new salary table (approximate)      | Grade.GRADE_2    | P5    |   16,035
2019-06-01   | anniversary: no increment           | Grade.GRADE_2    | P5    |   16,035
2019-08-01   | new salary table (approximate)      | Grade.GRADE_2    | P5    |   16,356
2020-06-01   | anniversary: no increment           | Grade.GRADE_2    | P5    |   16,356
2020-08-01   | new salary table (approximate)      | Grade.GRADE_2    | P5    |   16,683
2021-06-01   | anniversary: no increment           | Grade.GRADE_2    | P5    |   16,683
2021-08-01   | new salary table (approximate)      | Grade.GRADE_2    | P5    |   17,017
2022-06-01   | anniversary: no increment           | Grade.GRADE_2    | P5    |   17,017
2022-08-01   | new salary table (approximate)      | Grade.GRADE_2    | P5    |   17,357

Passing an empty iterable works as you’d expect:

>>> list(map_grade_and_points([]))
[]
ucamstaffoncosts.salary.progression.compose_changes(*change_iterables)[source]

Compose a several iterables yielding SalaryChange tuples into a single iterable which yields the changes in ascending date order.

ucamstaffoncosts.salary.progression.until(end_date, changes)[source]

Keep iterating over the salary changes in changes until the first date which is greater than or equal to end_date.

>>> start_date = datetime.date(2018, 6, 1)
>>> end_date = datetime.date(2022, 5, 1)
>>> increments = anniversary_increments(start_date)
>>> for change in until(end_date, increments):
...     print(change.date)
2018-06-01
2019-06-01
2020-06-01
2021-06-01

If end_date is not a datetime.date, a TypeError is raised:

>>> list(until('2022-05-01', anniversary_increments(start_date)))
Traceback (most recent call last):
    ...
TypeError: until must be passed a date object
ucamstaffoncosts.salary.progression.anniversary_increments(next_anniversary_date, table=<ucamstaffoncosts.salary.scales.SalaryScaleTable object>)[source]

Return an iterable of SalaryChange tuples representing the increments which happen on the anniversary of employment starting from next_anniversary_date.

>>> increments = anniversary_increments(datetime.date(2017, 5, 1))
>>> next(increments).date
datetime.date(2017, 5, 1)
>>> next(increments).date
datetime.date(2018, 5, 1)
>>> next(increments).date
datetime.date(2019, 5, 1)

If next_anniversary_date is not a datetime.date, a TypeError is raised:

>>> next(anniversary_increments('2017-05-01'))
Traceback (most recent call last):
    ...
TypeError: anniversary_increments must be passed a date object
ucamstaffoncosts.salary.progression.set_salary(from_date, grade, point, reason=None)[source]

Return an iterable which represents a change in grade and/or point.

>>> from ucamstaffoncosts import Grade
>>> from ucamstaffoncosts.salary.scales import EXAMPLE_SALARY_SCALES
>>> grade = Grade.GRADE_2
>>> point = EXAMPLE_SALARY_SCALES.starting_point_for_grade(grade)
>>> next(set_salary(datetime.date(2017, 1, 1), grade, point)) # doctest: +ELLIPSIS
SalaryChange(date=datetime.date(2017, 1, 1), update_salary=...)

If from_date is not a datetime.date, a TypeError is raised:

>>> set_salary('2017-01-01', grade, point)
Traceback (most recent call last):
    ...
TypeError: set_salary must be passed a date object
ucamstaffoncosts.salary.progression.merge_priority_iterables(iterables)[source]

Return a priority queue over iterables which is itself iterable. Each iterable which forms part of the queue must return objects of the form (deadline, …). As the queue is iterated over, the item with the smallest deadline from all of the iterators is selected and (deadline, index, value) is yielded where “index” is the 0-based index of the iterable which yielded the value in iterables.

If two iterables are yielding values with the same deadline at the same time, the one which was earlier in the iterables sequence is yielded first.

It is envisaged that all iterables passed to the function will themselves yield values with monotonically increasing deadline. The implementation does not attempt to enforce this.

As an example, use two lists as iterables which specify deadlines in terms of date objects:

>>> it0 = [
...     (datetime.date(2018, 3, 31), 'it0_0'),
...     (datetime.date(2018, 4, 5), 'it0_1'),
...     (datetime.date(2018, 6, 1), 'it0_2'),
...     (datetime.date(2018, 6, 1), 'it0_3')
... ]
>>> it1 = [
...     (datetime.date(2018, 1, 1), 'it1_0'),
...     (datetime.date(2018, 3, 31), 'it1_1'),
...     (datetime.date(2018, 4, 7), 'it1_2'),
...     (datetime.date(2018, 6, 2), 'it1_3')
... ]
>>> q = merge_priority_iterables([it0, it1])
>>> list(q)  # doctest: +NORMALIZE_WHITESPACE
[(datetime.date(2018, 1, 1), 1, 'it1_0'), (datetime.date(2018, 3, 31), 0, 'it0_0'),
(datetime.date(2018, 3, 31), 1, 'it1_1'), (datetime.date(2018, 4, 5), 0, 'it0_1'),
(datetime.date(2018, 4, 7), 1, 'it1_2'), (datetime.date(2018, 6, 1), 0, 'it0_2'),
(datetime.date(2018, 6, 1), 0, 'it0_3'), (datetime.date(2018, 6, 2), 1, 'it1_3')]

Infinite iterables are also supported:

>>> import itertools
>>> it3 = map(
...     lambda o: (datetime.date(2018, 1, 1) + datetime.timedelta(days=30*o), f'it3_{o}'),
...     itertools.count()
... )
>>> q = merge_priority_iterables([it0, it1, it3])
>>> list(itertools.takewhile(
...     lambda v: v[0] < datetime.date(2018, 8, 1), q))  # doctest: +NORMALIZE_WHITESPACE
[(datetime.date(2018, 1, 1), 1, 'it1_0'), (datetime.date(2018, 1, 1), 2, 'it3_0'),
(datetime.date(2018, 1, 31), 2, 'it3_1'), (datetime.date(2018, 3, 2), 2, 'it3_2'),
(datetime.date(2018, 3, 31), 0, 'it0_0'), (datetime.date(2018, 3, 31), 1, 'it1_1'),
(datetime.date(2018, 4, 1), 2, 'it3_3'), (datetime.date(2018, 4, 5), 0, 'it0_1'),
(datetime.date(2018, 4, 7), 1, 'it1_2'), (datetime.date(2018, 5, 1), 2, 'it3_4'),
(datetime.date(2018, 5, 31), 2, 'it3_5'), (datetime.date(2018, 6, 1), 0, 'it0_2'),
(datetime.date(2018, 6, 1), 0, 'it0_3'), (datetime.date(2018, 6, 2), 1, 'it1_3'),
(datetime.date(2018, 6, 30), 2, 'it3_6'), (datetime.date(2018, 7, 30), 2, 'it3_7')]
ucamstaffoncosts.salary.progression.salary_mapping_tables(from_date, table=<ucamstaffoncosts.salary.scales.SalaryScaleTable object>, approximate_negotiated_annual_change=Fraction(51, 50), negotiated_annual_change_month=None, negotiated_annual_change_day=None)[source]

Return an iterable which yields (date, mapping, is_approximate) tuples. The date in each tuple refers to an effective-from date for a point to salary mapping and the mapping is a dict mapping point names to pounds sterling full-time equivalent salaries. The first date returned is guaranteed to be before or equal to from_date.

Parameters:
  • from_date (date.datetime) – date from which mappings are required
  • table (SalaryScaleTable) – salary scale table to use
  • approximate_negotiated_annual_change – multiplier to use for annual negotiated salary change for years when a point table is unavailable

Where present, actual mapping tables are used. For these tables, is_approximate will be False. For annual negotiated changes where the table is not available, an approximation is used: the previous year’s salaries are multiplied by approximate_negotiated_annual_change. These approximations are always made relative to the latest actual salaries available even if one is required to approximate into the past. For these tables, is_approximate will be True.

The date at which this change happens is taken from the latest actual change recorded in the salary table. It can be overridden via the negotiated_annual_change_month and negotiated_annual_change_day parameters. These parameters are 1-based day and month indices as accepted by datetime.date.

To illustrate, we will use the example table from the scales module.

>>> import pkg_resources
>>> import yaml
>>> from ucamstaffoncosts.salary.scales import SalaryScaleTable
>>> table = SalaryScaleTable(yaml.load(pkg_resources.resource_string(
...    'ucamstaffoncosts', 'data/example_salary_scales.yaml')))
>>> from_date = datetime.date(2017, 5, 1)
>>> mappings = salary_mapping_tables(from_date, table=table)

The first date returned will be before from_date since it is the effective-from date of the appropriate salary mapping table:

>>> date, mapping1, is_approximate = next(mappings)
>>> is_approximate
False
>>> date < from_date
True
>>> date
datetime.date(2016, 8, 1)
>>> _, expected_mapping = table.point_to_salary_map_for_date(date)
>>> mapping1 == expected_mapping
True

The mapping returned for the next date will also match the one from the table as the table has an exact salary map:

>>> date, mapping2, is_approximate = next(mappings)
>>> is_approximate
False
>>> date
datetime.date(2017, 8, 1)
>>> mapping1 == mapping2
False
>>> _, expected_mapping = table.point_to_salary_map_for_date(date)
>>> mapping2 == expected_mapping
True

However, the third date will be a new approximated mapping which is not present in the original table:

>>> date, mapping3, is_approximate = next(mappings)
>>> is_approximate
True
>>> date
datetime.date(2018, 8, 1)
>>> mapping2 == mapping3
False
>>> _, non_extrapolated_mapping = table.point_to_salary_map_for_date(date)
>>> mapping3 == non_extrapolated_mapping
False

This approximation should be a 2% rise over the previous value (although it is rounded to the nearest integer):

>>> mapping2 == {
...     'P1': 14304, 'P2': 14675, 'P3': 15126, 'P4': 15417, 'P5': 15721, 'P6': 16035,
...     'P7': 16341, 'P8': 16654}
True
>>> [abs((mapping3[k]/v) - 1.02) < 1e-4 for k, v in mapping2.items()]
[True, True, True, True, True, True, True, True]

Finally, the fourth date’s mapping should be an approximation which is a further 2% rise:

>>> date, mapping4, is_approximate = next(mappings)
>>> is_approximate
True
>>> date
datetime.date(2019, 8, 1)
>>> mapping3 == mapping4
False
>>> [abs((mapping4[k]/v) - 1.02) < 1e-4 for k, v in mapping3.items()]
[True, True, True, True, True, True, True, True]

Salary scales

The ucamstaffoncosts.salary.scales module provides a mapping from grade and salary scale point to an annual full-time equivalent salary in pounds sterling. Optionally, this may include a date for which this mapping is required and the salary table will return the result from the latest table which has an effective-from date before or equal to that one.

In this documentation, we shall use a subset of the full salary table. The YAML source for this table is as follows:

# This is an example salary scales table used for documentation and testing.
grades:
- name: GRADE_1
  scale:
  - {isContribution: false, name: 'X1', point: P1}
  - {isContribution: false, name: 'X2', point: P2}
  - {isContribution: false, name: 'X3', point: P3}
  - {isContribution: true, name: 'X4', point: P4}
- name: GRADE_2
  scale:
  - {isContribution: false, name: 'Y1', point: P3}
  - {isContribution: false, name: 'Y2', point: P4}
  - {isContribution: false, name: 'Y3', point: P5}
  - {isContribution: true, name: 'Y4', point: P6}
salaries:
- effectiveDate: 2016-08-01
  mapping: {P1: 13965, P2: 14327, P3: 14767,
    P4: 15052, P5: 15356, P6: 15670, P7: 15976, P8: 16289}
- effectiveDate: 2017-08-01
  mapping: {P1: 14304, P2: 14675, P3: 15126,
    P4: 15417, P5: 15721, P6: 16035, P7: 16341, P8: 16654}

The table is available via the ucamstaffoncosts.salary.scales.EXAMPLE_SALARY_SCALES constant:

>>> isinstance(EXAMPLE_SALARY_SCALES, SalaryScaleTable)
True

For real data, you would want to use the SALARY_SCALES constant which is a pre-initialised table using the most recent data available. For all calls which take a table parameter, this table is the default.

>>> isinstance(SALARY_SCALES, SalaryScaleTable)
True

There are various clinical scales which are not represented in this table. For those, you would want to use the CLINICAL_SCALES constant

>>> isinstance(CLINICAL_SCALES, SalaryScaleTable)
True

Using the table, one can find, as an example, the starting salary for grade 2 as of the 1st January 2017:

>>> effective_from_date, mapping = EXAMPLE_SALARY_SCALES.point_to_salary_map_for_date(
...     datetime.date(2017, 1, 1))
>>> effective_from_date
datetime.date(2016, 8, 1)
>>> mapping[EXAMPLE_SALARY_SCALES.scale_for_grade(Grade.GRADE_2)[0].point]
14767

Or, what their salary will be after their second anniversary:

>>> grade = Grade.GRADE_2
>>> point = EXAMPLE_SALARY_SCALES.scale_for_grade(Grade.GRADE_2)[0].point
>>> point = EXAMPLE_SALARY_SCALES.increment(grade, point) # first anniversary
>>> point = EXAMPLE_SALARY_SCALES.increment(grade, point) # second anniversary
>>> date = datetime.date(2019, 1, 1) # note: two years after start date
>>> effective_from_date, mapping = EXAMPLE_SALARY_SCALES.point_to_salary_map_for_date(date)
>>> effective_from_date
datetime.date(2017, 8, 1)
>>> second_anniversary_salary = mapping[point]
>>> second_anniversary_salary
15721

Note that the salary table is from the 2017 negotiated increment. No attempt is made to predict negotiated increments into the future from this table.

On the next anniversary, the employee does not proceed to the next spine point since the employee is now at the top non-contribution points for the grade. Hence, the salary will be the same:

>>> point = EXAMPLE_SALARY_SCALES.increment(grade, point) # third anniversary
>>> date = datetime.date(2019, 1, 1) # note: three years after start date
>>> effective_from_date, mapping = EXAMPLE_SALARY_SCALES.point_to_salary_map_for_date(date)
>>> effective_from_date
datetime.date(2017, 8, 1)
>>> mapping[point] == second_anniversary_salary
True
class ucamstaffoncosts.salary.scales.SalaryScaleTable(data)[source]

An encapsulation of the salary scale table available on the HR website.

Parameters:data (dict) – a data table whose format is epitomised by the example used in this documentation.
class ScaleRow[source]

A collections.namedtuple subclass which represents a row from the salary scale for a particular grade.

name

The name of this point on the salary scale

point

The name of this point on the spine. This is the name used in the table returned by point_to_salary_map_for_date().

is_contribution

Boolean indicating if this is a contribution point and not subject to annual increments.

increment(grade, point)[source]

Return the salary scale point which is the next annual increment from the specified grade and point. If there is no further increment, return the value of point. Note that point is the spine point, name as used by the table returned by point_to_salary_map_for_date().

Parameters:

Let’s look at the scale for grade 1:

>>> EXAMPLE_SALARY_SCALES.scale_for_grade(Grade.GRADE_1) # doctest: +NORMALIZE_WHITESPACE
[ScaleRow(name='X1', point='P1', is_contribution=False),
ScaleRow(name='X2', point='P2', is_contribution=False),
ScaleRow(name='X3', point='P3', is_contribution=False),
ScaleRow(name='X4', point='P4', is_contribution=True)]

Normal increments will return the next point:

>>> EXAMPLE_SALARY_SCALES.increment(Grade.GRADE_1, 'P1')
'P2'

Increments do not move to contribution points:

>>> EXAMPLE_SALARY_SCALES.increment(Grade.GRADE_1, 'P3')
'P3'

Contribution points also do not increment:

>>> EXAMPLE_SALARY_SCALES.increment(Grade.GRADE_1, 'P4')
'P4'

Note that the spine point must be in the salary progression for the grade otherwise an exception is raised:

>>> EXAMPLE_SALARY_SCALES.increment(Grade.GRADE_1, 'P5')
Traceback (most recent call last):
    ...
ValueError: point "P5" is not part of grade "Grade.GRADE_1"

If the grade is None, the “increment” will always return the input grade.

>>> EXAMPLE_SALARY_SCALES.increment(None, 'P5')
'P5'
scale_for_grade(grade)[source]

Return the salary scale for a given grade. The scale is ordered in increasing order of spine point so that they represent the progression through spine points from annual increments.

Parameters:grade (ucamstaffoncosts.Grade) – grade whose scale should be returned
>>> EXAMPLE_SALARY_SCALES.scale_for_grade(Grade.GRADE_1) # doctest: +NORMALIZE_WHITESPACE
[ScaleRow(name='X1', point='P1', is_contribution=False),
ScaleRow(name='X2', point='P2', is_contribution=False),
ScaleRow(name='X3', point='P3', is_contribution=False),
ScaleRow(name='X4', point='P4', is_contribution=True)]

Note that the grade must be a Grade:

>>> EXAMPLE_SALARY_SCALES.scale_for_grade('GRADE_1') # doctest: +ELLIPSIS
Traceback (most recent call last):
    ...
KeyError: ...
starting_point_for_grade(grade)[source]

Convenience wrapper around scale_for_grade() which returns the name of the first point on the scale for a given grade.

>>> EXAMPLE_SALARY_SCALES.starting_point_for_grade(Grade.GRADE_2)
'P3'
point_to_salary_map_effective_dates()[source]

Return a list of effective-from dates for each point to salary mapping we know about. These are ordered in descending order of dates.

>>> EXAMPLE_SALARY_SCALES.point_to_salary_map_effective_dates()
[datetime.date(2017, 8, 1), datetime.date(2016, 8, 1)]
point_to_salary_map_for_date(date=None)[source]

Return a salary table mapping for a given date. The mapping table is a dictionary which maps point names to full-time equivalent salaries. The return value is an (effective date, table) pair giving the table and the date from which it became effective.

Parameters:date (datetime.date) – date for which mapping is required. If None, use today’s date

The exact mapping is selected based upon the date:

>>> date, mapping = EXAMPLE_SALARY_SCALES.point_to_salary_map_for_date(
...     datetime.date(2017, 5, 1))
>>> mapping == {
... 'P1': 13965, 'P2': 14327, 'P3': 14767, 'P4': 15052, 'P5': 15356, 'P6': 15670,
... 'P7': 15976, 'P8': 16289}
True
>>> date == datetime.date(2016, 8, 1)
True
>>> date, mapping = EXAMPLE_SALARY_SCALES.point_to_salary_map_for_date(
...     datetime.date(2018, 5, 1))
>>> mapping == {
... 'P1': 14304, 'P2': 14675, 'P3': 15126, 'P4': 15417, 'P5': 15721, 'P6': 16035,
... 'P7': 16341, 'P8': 16654}
True
>>> date == datetime.date(2017, 8, 1)
True

If no date is specified, today’s date is used as a default:

>>> d1, m1 = EXAMPLE_SALARY_SCALES.point_to_salary_map_for_date()
>>> d2, m2 = EXAMPLE_SALARY_SCALES.point_to_salary_map_for_date(
...     datetime.datetime.now().date())
>>> d1 == d2
True
>>> m1 == m2
True

If a date matches the effective from date, it returns the corresponding mapping:

>>> date, mapping = EXAMPLE_SALARY_SCALES.point_to_salary_map_for_date(
...     datetime.date(2016, 8, 1))
>>> mapping == {
... 'P1': 13965, 'P2': 14327, 'P3': 14767, 'P4': 15052, 'P5': 15356, 'P6': 15670,
... 'P7': 15976, 'P8': 16289}
True
>>> date == datetime.date(2016, 8, 1)
True

If a date is specified which is too far in the future, a ValueError is raised:

>>> EXAMPLE_SALARY_SCALES.point_to_salary_map_for_date(datetime.date(2016, 7, 31))
Traceback (most recent call last):
    ...
ValueError: date is too far in the past