Splitting number into bands for variable rate billing, ie "tiered pricing"

Viewed 1257

I have a problem that's simple on paper, but I'm struggling to work out in code. Before I go any further, this is not a string splitting question.

I have what is essentially a banded timesheet application. The scale is variable based on client but an example is the first three hours in a month are 80, the next three hours are 70 and remainder are 50. I currently represent this in code as:

scale = [80, 80, 80, 70, 70, 70, 50]

... But I'm open to suggestions there too.

The scale and number of steps are —and have to be— variable. Some of my billing is much more simple with some clients but I like to be able to offer this high-usage plan.

But how do I take a number of hours worked (eg 15.2) and calculate how much they should be paying? How do I split this big number into bands? As I say, it's been easy enough to work out on paper but as I get more clients and more convoluted schemes, that's getting pretty boring. Here's how I would work it out for 15.2 hours:

3 hours at 80 = 240
3 hours at 70 = 210
9.2 hours at 50 = 460
total = 910

While I'm at it, I'd appreciate comments for the proper names for what I'm trying to describe. And Oli, if you've landed back here in 2023, pick an easier billing scheme next time, mate.

4 Answers

First, I would turn this:

scale = [80, 80, 80, 70, 70, 70, 50]

Into this:

import math

scale = {(0, 3): 80, (3, 6): 70, (6, math.inf): 50}

Then the rest of my algorithm follows:

# Total hours worked
hours_worked = 15.2

# Handle the decimal (if any) to begin with... First find the "max" rate
decimal_rate = next(rate for (lower, upper), rate in scale.items()
                        if lower <= hours_worked and upper >= hours_worked)

# Then calculate the last "sliver" of pay
decimal_end = hours_worked - int(hours_worked)
end_pay = decimal_end * decimal_rate

# Use an integer for ease of calculation
hours_worked = int(hours_worked)

hours_paid_for = 0

# Beginning total pay is just the decimal "ending"
total_pay = end_pay

while hours_paid_for < hours_worked:
    # Find the rate for the current bucket of hours
    rate_filter = (rate for (lower, upper), rate in scale.items() if lower <= hours_paid_for and hours_paid_for < upper)
    current_level = next(rate_filter)

    print('Hour: {}'.format(hours_paid_for))
    print('Pay rate: ${}'.format(current_level))

    total_pay += current_level

    hours_paid_for += 1

print('Total earned: ${}'.format(total_pay))

Output follows:

Hour: 0
Pay rate: $80
Hour: 1
Pay rate: $80
Hour: 2
Pay rate: $80
Hour: 3
Pay rate: $70
Hour: 4
Pay rate: $70
Hour: 5
Pay rate: $70
Hour: 6
Pay rate: $50
Hour: 7
Pay rate: $50
Hour: 8
Pay rate: $50
Hour: 9
Pay rate: $50
Hour: 10
Pay rate: $50
Hour: 11
Pay rate: $50
Hour: 12
Pay rate: $50
Hour: 13
Pay rate: $50
Hour: 14
Pay rate: $50
Total earned: $910.0

Here's a nice-and-neat function, too:

def calculate_pay(scale, hours_worked):

    # Handle the decimal (if any) to begin with... First find the "max" rate
    decimal_rate = next(rate for (lower, upper), rate in scale.items()
                            if lower <= hours_worked and upper >= hours_worked)

    # Then calculate the last "sliver" of pay
    decimal_end = hours_worked - int(hours_worked)
    end_pay = decimal_end * decimal_rate

    # Use an integer for ease of calculation
    hours_worked = int(hours_worked)

    # Hours already paid for (int)
    hours_paid_for = 0

    # Beginning 'total pay' can be the decimal end, if any
    total_pay = end_pay

    while hours_paid_for < hours_worked:
        # Find the rate for the current bucket of hours
        rate_filter = (rate for (lower, upper), rate in scale.items()
                        if lower <= hours_paid_for and hours_paid_for < upper)
        current_level = next(rate_filter)

        total_pay += current_level

        hours_paid_for += 1

    return total_pay

If I understand the problem correctly here is my try. It's not compact, but I prefer readable. I changed the bands representation to [(hours, price), (hours, price), ... (None, Price)], see example below:

def calculate_cost(hours, bands):
    remaining_hours = hours
    current_band_idx = 0
    total_cost = 0

    while remaining_hours > 0:
        band_max_hours, band_cost = bands[current_band_idx]

        if band_max_hours is None:
            band_billable_hours = remaining_hours
        else:
            band_billable_hours = min(remaining_hours, band_max_hours)

        total_cost += band_billable_hours * band_cost
        current_band_idx += 1
        remaining_hours -= band_billable_hours

    return total_cost

And for your example:

>>> calculate_cost(15.2, [(3, 80), (3, 70), (None, 50)])
910.0

Although in my comment I suggested going for the simplest/most naive approach and just looping through - my curiosity got the better of me and so here is a "less lines" approach using recursion.

Starting with a list of "cost buckets", each comprising of a price for a set numbers of hours (e.g. [3,80] => 3 hours at 80 price units), you can break down your hours until you have filled each bucket in order (or have reached the final bucket where all remaining hours are 'spent').

# each bucket is a pair of [max hours at this rate, rate per hour]
def calc_cost(cost_buckets: list, hours_worked):
    if hours_worked <= cost_buckets[0][0] or len(cost_buckets) < 2:
        return cost_buckets[0][1] * hours_worked
    return calc_cost(cost_buckets, cost_buckets[0][0]) + calc_cost(cost_buckets[1:], hours_worked - cost_buckets[0][0])


hours = 2.5 # number of hours total worked
costs = [[1,12], [5,30]] # list of 'cost buckets'
print(calc_cost(costs, hours)) # prints '57.0'

It's an inflexible approach, but it'll do what you're after specifically.

(I should obviously point out the usual issues with recursion; in that if you're thinking of having a 100,000 work hour plan with 20,000 cost buckets - you might not want to do this.)

Here's another approach, using the rate storage scheme you used first. This approach assumes that the number of hours worked on a project is at least as long as the number of rates in s (i.e. len(s))!

# Original rate scaling method
s = [80, 80, 80, 70, 70, 70, 50]

hours_worked = 15.2

final_hours_rate = s[-1]

# Handle the "decimal end"
end_pay = (hours_worked - int(hours_worked)) * final_hours_rate

hours_worked = int(hours_worked)

n_rates = len(s)

remainder = sum(s[hour] for hour in range(n_rates)) + \
            final_hours_rate * (hours_worked - n_rates)

total_pay = end_pay + remainder

total_pay is 910.

Related