Skip to content

Instantly share code, notes, and snippets.

@HarryR
Created November 7, 2012 22:39
Show Gist options
  • Select an option

  • Save HarryR/4035012 to your computer and use it in GitHub Desktop.

Select an option

Save HarryR/4035012 to your computer and use it in GitHub Desktop.
Simplistic telephone call rating engine
/**
* License: WTFPL - http://sam.zoy.org/wtfpl/
* Copyright: (c) 2012 Derp Ltd. <openrating@derp.ltd.uk>
* Version: 0.1
* Authors: Harry Roberts
*/
module openrating;
import std.stdio;
import std.datetime;
import std.array;
import std.math;
/**
* Billing/Rating information to be applied to a call
*/
class Billing {
/**
* ISO 4217 currency code
*/
char[3] currency;
/**
* Cost per second during peak hours
*/
real cps_peak;
/**
* Cost per second during offpeak hours
*/
real cps_offpeak;
/**
* Cost per second during the weekend
*/
real cps_weekend;
/**
* Minimum cost for the call
*/
real cost_minimum;
/**
* Setup Cost or Connection Fee
*/
real cost_setup;
/**
* Increments of time to bill for, in seconds.
* A 7 second call with 5 second increments would cost the same
* as a 10 second call.
*/
Duration increment;
/**
* Number of decimal places that all monetary values should be accurate to.
*
* Values will be rounded down to this.
*/
ushort decimal_places;
this () {
currency = "!!!";
decimal_places = 4;
}
/**
* Rounds a value to N `decimal_places`
*/
real round(real value) {
return rint(value * (10 ^ decimal_places)) / (10 ^ decimal_places);
}
invariant () {
assert( decimal_places >= 0 );
assert( false == increment.isNegative() );
}
}
/**
* Options to control the breakdown of call seconds
*/
class BreakdownOptions {
/**
* Params:
* peak_start = Start of Peak hours (starting from, inclusive)
* peak_end = End of Peak hours (up to, but not including)
* weekend_days = Days of week which are considered the Weekend
*/
this( TimeOfDay peak_start, TimeOfDay peak_end, DayOfWeek[] weekend_days ) {
m_weekend_days = weekend_days;
m_peak = Interval!TimeOfDay(peak_start, peak_end);
}
/**
* Params:
* current = Point in time
*
* Returns: true if point in time is within 'Peak hours'
*/
bool isPeak( SysTime current ) {
return m_peak.begin <= cast(TimeOfDay)current && m_peak.end > cast(TimeOfDay)current;
}
/**
* Params:
* current = Point in time
*
* Returns: whether or not the current timestamp is part of the 'weekend'
*/
bool isWeekend( SysTime current ) {
foreach( day; m_weekend_days ) {
if( day == current.dayOfWeek ) {
return true;
}
}
return false;
}
package:
SysTime nextTodSwitch( SysTime current ) {
auto one_day = dur!"days"(1);
// When today is a weekend, return 00:00:00 of the next first non-weekend day
if( isWeekend(current) ) {
do {
current += one_day;
} while( isWeekend(current) );
return current - (cast(TimeOfDay)current - TimeOfDay.min());
}
// During 'peak' time return the offpeak switch time
if( isPeak(current) ) {
return current + (m_peak.end() - cast(TimeOfDay)current);
}
auto midnight = current + (TimeOfDay.max() - cast(TimeOfDay)current) + dur!"seconds"(1);
// If tomorrow is weekend, return midnight (start of next day)
if( isWeekend(current + one_day) ) {
return midnight;
}
// Before peak time - return peak begin time
if( cast(TimeOfDay)current < m_peak.begin() ) {
return current + (m_peak.begin() - cast(TimeOfDay)current);
}
// Currently offpeak - return peak time of next day
return midnight + (m_peak.begin() - TimeOfDay.min());
}
private:
DayOfWeek[] m_weekend_days;
Interval!TimeOfDay m_peak;
unittest {
// 7am to 7pm, weekend = saturday & sunday
auto options = new BreakdownOptions(TimeOfDay(7, 0), TimeOfDay(20, 0, 0), [DayOfWeek.sat, DayOfWeek.sun]);
SysTime current;
SysTime next;
// Midnight, 8th November = offpeak
current = SysTime(DateTime(2012, 11, 8, 0, 0, 0), TimeZone.getTimeZone("UTC"));
assert( false == options.isWeekend(current) );
assert( false == options.isPeak(current) );
// 10th November 2012 = weekend
current = SysTime(DateTime(2012, 11, 10, 0, 0, 0), TimeZone.getTimeZone("UTC"));
assert( true == options.isWeekend(current) );
assert( false == options.isPeak(current) );
// 07:00:00 = peak
current = SysTime(DateTime(2012, 11, 8, 7, 0, 0), TimeZone.getTimeZone("UTC"));
assert( false == options.isWeekend(current) );
assert( true == options.isPeak(current) );
// Next TimeOfDay switch after 7am should be 8pm exactly (offpeak)
current = SysTime(DateTime(2012, 11, 8, 7, 0, 0), TimeZone.getTimeZone("UTC"));
next = options.nextTodSwitch(current);
assert( current.day == next.day );
assert( next.hour == 20 );
assert( next.minute == 0 );
assert( next.second == 0 );
assert( false == options.isPeak(next) );
assert( false == options.isWeekend(next) );
// Next TOD switch after 11pm, 9th Nov should be midnight 10th november (weekend)
current = SysTime(DateTime(2012, 11, 9, 23, 0, 0), TimeZone.getTimeZone("UTC"));
next = options.nextTodSwitch(current);
assert( options.isWeekend(next) );
assert( next.day == 10 );
assert( next.hour == 0 );
assert( next.minute == 0 );
assert( next.second == 0 );
assert( false == options.isPeak(current) );
assert( false == options.isWeekend(current) );
assert( true == options.isWeekend(next) );
}
}
/**
* Representation of how many seconds from the call fall into the different
* rating categories.
*/
class Breakdown {
this ( Duration peak, Duration offpeak, Duration weekend ) {
m_peak = peak;
m_offpeak = offpeak;
m_weekend = weekend;
}
/**
* Total number of seconds
*/
Duration total() {
return (m_peak + m_offpeak) + m_weekend;
}
/**
* Seconds occuring during 'peak' times
*/
Duration peak () { return m_peak; }
/**
* Seconds occuring during 'offpeak' times.
*/
Duration offpeak () { return m_offpeak; }
/**
* Seconds occurring during the weekend.
*/
Duration weekend () { return m_weekend; }
private:
Duration m_peak;
Duration m_offpeak;
Duration m_weekend;
invariant () {
assert( false == m_peak.isNegative() );
assert( false == m_offpeak.isNegative() );
assert( false == m_weekend.isNegative() );
}
}
SysTime roundUpTo( SysTime point, Duration increment )
in {
// Must never be over 60 seconds
assert( increment.total!"seconds" <= 60 );
// Even taking fractional seconds into account
if( increment.total!"seconds" == 60 ) {
assert( increment.fracSec.hnsecs == 0 );
}
}
body {
/* When rounding up we only take the seconds and fractional seconds past
* the minute into account.
*
* For ultimate precision we perform calculations internally with hecto-
* nanoseconds.
*
* This still allows for weird conditions, like 57 seconds being rounded up
* into the next minute.
*/
auto incr_hnsecs = increment.total!"hnsecs"();
auto point_hnsecs = (10000000 * point.second) + point.fracSec.hnsecs;
if( (point_hnsecs % incr_hnsecs) == 0 ) {
return point;
}
auto added_hnseconds = incr_hnsecs - (point_hnsecs % incr_hnsecs);
return point + dur!"hnsecs"(added_hnseconds);
}
unittest {
auto tz = TimeZone.getTimeZone("America/New_York");
// Round up whole seconds
auto timepoint = SysTime(DateTime(2012, 11, 7, 6, 59, 1), tz);
auto rounded_timepoint = roundUpTo(timepoint, dur!"seconds"(6));
assert( rounded_timepoint.second == 6 );
assert( timepoint.minute == rounded_timepoint.minute );
assert( timepoint.hour == rounded_timepoint.hour );
assert( timepoint.day == rounded_timepoint.day );
assert( timepoint.day == rounded_timepoint.day );
// Round up to the same second - should be the same
// round(1.0, 1.0) = 1.0
rounded_timepoint = roundUpTo(timepoint, dur!"seconds"(1));
assert( rounded_timepoint == timepoint );
// Timepoint which has fractional seconds should be rounded upto the next full second
// round(1.123, 1.0) = 2.0
timepoint = SysTime(DateTime(2012, 11, 7, 6, 59, 1), FracSec.from!"msecs"(123), tz);
rounded_timepoint = roundUpTo(timepoint, dur!"seconds"(1));
assert( rounded_timepoint.fracSec == FracSec.from!"msecs"(0) );
assert( rounded_timepoint.second == 2 );
assert( timepoint.minute == rounded_timepoint.minute );
assert( timepoint.hour == rounded_timepoint.hour );
// Rounding up to nearest 500 msecs, to the next second
// round(1.650, 0.5) = 2.0
timepoint = SysTime(DateTime(2012, 11, 7, 6, 59, 1), FracSec.from!"msecs"(650), tz);
rounded_timepoint = roundUpTo(timepoint, dur!"msecs"(500));
assert( rounded_timepoint.second == 2 );
assert( rounded_timepoint.fracSec == FracSec.from!"msecs"(0) );
assert( timepoint.minute == rounded_timepoint.minute );
assert( timepoint.hour == rounded_timepoint.hour );
// Rounding up to nearest 500 msecs, to the next .5 of a second
// round(1.123, 0.5) = 1.5
timepoint = SysTime(DateTime(2012, 11, 7, 6, 59, 1), FracSec.from!"msecs"(123), tz);
rounded_timepoint = roundUpTo(timepoint, dur!"msecs"(500));
auto fs_ms = rounded_timepoint.fracSec.msecs;
assert( rounded_timepoint.second == 1 );
assert( rounded_timepoint.fracSec == FracSec.from!"msecs"(500) );
assert( timepoint.minute == rounded_timepoint.minute );
assert( timepoint.hour == rounded_timepoint.hour );
// TODO: condition where it gets rounded into the next minute
// TODO: leap seconds
// TODO: leap years
// TODO: daylight savings rollback
}
/**
* Represents a billable call
*/
class Call {
/**
* A call occurs between two time points, start and end
*/
this( SysTime start, SysTime end )
in {
assert( start <= end );
}
body {
m_start = start;
m_end = end;
}
CallCost rate( Billing billing, BreakdownOptions options ) {
Duration peak = dur!"seconds"(0);
Duration offpeak = dur!"seconds"(0);
Duration weekend = dur!"seconds"(0);
// The Billing increment will round the end of the call upto N seconds.
// e.g. a 15 second call, with 6 second increment, will be rounded up
// to 18 seconds in total.
auto current = m_start;
auto effective_end = roundUpTo(m_end, billing.increment);
while( current < effective_end ) {
auto next = options.nextTodSwitch(current);
if( next > effective_end ) {
next = effective_end;
}
Duration seconds = (next - current);
if( options.isWeekend(current) ) {
weekend += seconds;
}
else if( options.isPeak(current) ) {
peak += seconds;
}
else {
offpeak += seconds;
}
current = next;
}
return new CallCost(new Breakdown(peak, offpeak, weekend), billing);
}
/**
* Duration of call, in seconds
*/
Duration duration() {
return m_end - m_start;
}
/**
* Start of Call
*/
SysTime start () { return m_start; }
/**
* End of Call
*/
SysTime end () { return m_end; }
private:
SysTime m_start;
SysTime m_end;
unittest {
auto tz = TimeZone.getTimeZone("America/New_York");
auto call = new Call(
SysTime(DateTime(2012, 11, 7, 6, 59, 30), tz),
SysTime(DateTime(2012, 11, 7, 7, 0, 30), tz));
assert( call.duration == dur!"seconds"(60) );
// TODO: validate validation during leap year
// TODO: validate duration during leap second
// TODO: validate duration during daylight savings rollback
}
}
/**
* The result of Billing applied to a Call
*/
class CallCost {
this( Breakdown breakdown, Billing billing ) {
m_breakdown = breakdown;
m_billing = billing;
m_cost_peak = billing.round(breakdown.peak.total!"msecs"() * (billing.cps_peak / 1000));
m_cost_offpeak = billing.round(breakdown.offpeak.total!"msecs"() * (billing.cps_offpeak / 1000));
m_cost_weekend = billing.round(breakdown.weekend.total!"msecs"() * (billing.cps_weekend / 1000));
m_cost_setup = billing.round(billing.cost_setup);
m_cost = m_cost_peak + m_cost_offpeak + m_cost_weekend + m_cost_setup;
if( m_cost < billing.cost_minimum ) {
m_cost = billing.cost_minimum;
}
}
/**
* Billing information used to rate the call
*/
Billing billing () { return m_billing; }
/**
* Breakdown into number of seconds in peak/offpeak/weekend
*/
Breakdown breakdown () { return m_breakdown; }
/**
* ISO 4217 currency code for costs
*/
char[3] currency () { return m_billing.currency; }
/**
* Total cost of the call
*/
real total () { return m_cost; }
/**
* Cost of 'offpeak' part of call
*/
real peak () { return m_cost_peak; }
/**
* Cost of 'peak' part of call
*/
real offpeak () { return m_cost_offpeak; }
/**
* Cost of 'weekend' part of call
*/
real weekend () { return m_cost_weekend; }
private:
real m_cost;
real m_cost_peak;
real m_cost_offpeak;
real m_cost_weekend;
real m_cost_setup;
Breakdown m_breakdown;
Billing m_billing;
unittest {
auto billing = new Billing();
billing.currency = "GBP";
billing.cps_peak = 0.5f;
billing.cps_offpeak = 0.5f;
billing.cps_weekend = 0.5f;
billing.cost_minimum = 0.5f;
billing.cost_setup = 1.0f;
billing.increment = dur!"seconds"(1);
auto tz = TimeZone.getTimeZone("America/New_York");
auto options = new BreakdownOptions(
TimeOfDay(7, 0), TimeOfDay(20, 0, 0),
[DayOfWeek.sat, DayOfWeek.sun]);
// 60 second call on boundary of offpeak->peak
auto call = new Call(
SysTime(DateTime(2012, 11, 7, 6, 59, 30), tz),
SysTime(DateTime(2012, 11, 7, 7, 0, 30), tz));
auto cost = call.rate(billing, options);
auto breakdown = cost.breakdown;
auto total_seconds = breakdown.total.seconds;
auto peak_seconds = breakdown.peak.seconds;
auto offpeak_seconds = breakdown.offpeak.seconds;
auto weekend_seconds = breakdown.weekend.seconds;
assert( (breakdown.offpeak.seconds + breakdown.peak.seconds + breakdown.weekend.seconds) == 60 );
assert( breakdown.total == dur!"seconds"(60) );
assert( breakdown.offpeak == dur!"seconds"(30) );
assert( breakdown.peak == dur!"seconds"(30) );
assert( breakdown.weekend == dur!"seconds"(0) );
// 60 second call on boundary of peak->offpeak
call = new Call(
SysTime(DateTime(2012, 11, 7, 19, 59, 30), tz),
SysTime(DateTime(2012, 11, 7, 20, 0, 30), tz));
cost = call.rate(billing, options);
breakdown = cost.breakdown;
assert( breakdown.total == dur!"seconds"(60) );
assert( breakdown.peak == dur!"seconds"(30) );
assert( breakdown.offpeak == dur!"seconds"(30) );
assert( breakdown.weekend == dur!"seconds"(0) );
// Ensure totals are being calculated correctly
assert( cost.total == 31.0f ); // 'setup cost'
assert( cost.peak == 15.0f );
assert( cost.offpeak == 15.0f );
assert( cost.weekend == 0.0f );
// 60 second call on boundary of offpeak->weekend
// 10th November 2012 is a Saturday
call = new Call(
SysTime(DateTime(2012, 11, 9, 23, 59, 30), tz),
SysTime(DateTime(2012, 11, 10, 0, 0, 30), tz));
cost = call.rate(billing, options);
breakdown = cost.breakdown;
assert( breakdown.total == dur!"seconds"(60) );
assert( breakdown.peak == dur!"seconds"(0) );
assert( breakdown.offpeak == dur!"seconds"(30) );
assert( breakdown.weekend == dur!"seconds"(30) );
// 60 second call on boundary of weekend->offpeak
// 11th November 2012 is a Sunday, 12th is Monday
call = new Call(
SysTime(DateTime(2012, 11, 11, 23, 59, 30), tz),
SysTime(DateTime(2012, 11, 12, 0, 0, 30), tz));
cost = call.rate(billing, options);
breakdown = cost.breakdown;
assert( breakdown.total == dur!"seconds"(60) );
assert( breakdown.peak == dur!"seconds"(0) );
assert( breakdown.offpeak == dur!"seconds"(30) );
assert( breakdown.weekend == dur!"seconds"(30) );
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment