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
- from_date – date at which to differentiate between expenditure and commitments. If
-
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.
-
-
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
-
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 orNone
if there is none - until_date (
datetime.date
) – it notNone
, 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()
- from_date (
-
class
ucamstaffoncosts.salary.progression.
Salary
[source]¶ A
collections.namedtuple
subclass which represents a salary for an employee. Thegrade
andpoint
attributes must be set to represent a salary whereas thebase_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
andas_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))
-
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
andas_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.
-
-
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 eachSalary
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
, aTypeError
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
, aTypeError
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
, aTypeError
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 beTrue
.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]
- from_date (
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: - grade (
ucamstaffoncosts.Grade
) – grade of employee - point (str) – existing spine point for employee
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'
- grade (
-
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. IfNone
, use today’s dateThe 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
-
class