Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Calculating "working time" using TimePeriod.NET's CalendarPeriodCollector gives unexpected results

Tags:

c#

datetime

I'm trying to calculate a due date for a service level agreement, and at the same time, I also need to back calculate the service level agreement in the other direction.

I've been struggling with calculations for "working time" (i.e. the time that work is possible during a set of days), and decided to use a third party library called TimePeriodLibrary.NET for the task. I need to be able to do two things:

  • Given a start DateTime and a TimeSpan, you should receive a DateTime of when a service level agreement date is due (date due).
  • Given a start DateTime and an end DateTime, you should receive a TimeSpan of how long that service level agreement should take.

All source code (test project is on GitHub). I have a ServiceLevelManager class that does all the work. It take a list of WorkDays and HolidayPeriods, in order to work out which hours are available to be worked. The CalendarPeriodCollector class is giving unexpected results. The expectations that do work in determining the due date from a timespan, do not calculate correctly when I back calculate them.

Can anyone see whether I am doing something wrong, or whether the library has a bug?

namespace ServicePlanner
{
    using System;
    using System.Collections.Generic;
    using Itenso.TimePeriod;

    public class ServicePlannerManager
    {
        public ServicePlannerManager(IEnumerable<WorkDay> workDays, IEnumerable<HolidayPeriod> holidays)
        {
            this.WorkDays = workDays;
            this.Holidays = holidays;
        }

        public IEnumerable<WorkDay> WorkDays { get; set; }

        public IEnumerable<HolidayPeriod> Holidays { get; set; }

        public TimeSpan GetRemainingWorkingTime(DateTime start, DateTime dueDate)
        {
            var filter = new CalendarPeriodCollectorFilter();
            foreach (var dayOfWeek in this.WorkDays)
            {
                filter.CollectingDayHours.Add(new DayHourRange(dayOfWeek.DayOfWeek, new Time(dayOfWeek.StartTime), new Time(dayOfWeek.EndTime)));
            }

            foreach (var holiday in this.Holidays)
            {
                filter.ExcludePeriods.Add(new TimeBlock(holiday.StartTime, holiday.EndTime));
            }

            var range = new CalendarTimeRange(start, dueDate);
            var collector = new CalendarPeriodCollector(filter, range);
            collector.CollectHours();

            var duration = collector.Periods.GetTotalDuration(new TimeZoneDurationProvider(TimeZoneInfo.FindSystemTimeZoneById("UTC")));
            return duration;
            //var rounded = Math.Round(duration.TotalMinutes, MidpointRounding.AwayFromZero);
            //return TimeSpan.FromMinutes(rounded);
        }
    }
}

The Unit tests that are failing are extracted below:

[TestFixture]
public class ServicePlannerManagerTest
{
        [Test, TestCaseSource("LocalSource")]
    public void GetRemainingWorkingTimeWithHolidayShouldOnlyEnumerateWorkingTime(DateTime startTime, TimeSpan workingHours, DateTime expectedDueDate, string expectation)
    {
        // Arrange
        var workDays = new List<WorkDay>
        { 
            new WorkDay(DayOfWeek.Monday, new DateTime(1, 1, 1, 9, 0, 0), new DateTime(1, 1, 1, 17, 0, 0)),
            new WorkDay(DayOfWeek.Tuesday, new DateTime(1, 1, 1, 9, 0, 0), new DateTime(1, 1, 1, 17, 0, 0)),
            new WorkDay(DayOfWeek.Wednesday, new DateTime(1, 1, 1, 9, 0, 0), new DateTime(1, 1, 1, 17, 0, 0)),
            new WorkDay(DayOfWeek.Thursday, new DateTime(1, 1, 1, 9, 0, 0), new DateTime(1, 1, 1, 17, 0, 0)),
            new WorkDay(DayOfWeek.Friday, new DateTime(1, 1, 1, 9, 0, 0), new DateTime(1, 1, 1, 17, 0, 0)),
        };
        var holidayPeriods = new List<HolidayPeriod>
        { 
            new HolidayPeriod(new DateTime(2015, 9, 15, 00, 0, 0), new DateTime(2015, 9, 16, 0, 0, 0))
        };
        var service = new ServicePlannerManager(workDays, holidayPeriods);

        // Act
        var result = service.GetRemainingWorkingTime(startTime, expectedDueDate);

        // Assert - 
        Assert.AreEqual(workingHours.TotalHours, result.TotalHours, expectation);
    }

    protected IEnumerable LocalSource()
    {
        yield return
            new TestCaseData(
                new DateTime(2015, 9, 14, 9, 0, 0),
                new TimeSpan(23, 0, 0),
                new DateTime(2015, 9, 17, 16, 0, 0),
                    "5. Expected 23 hours of working time to end on the 17/09/2015 16:00. Monday to Thursday evening. Just short of 3 full working days by one hour. Tuesday is holiday.");
    }
}

Output of this test is

5. Expected 23 hours of working time to end on the 17/09/2015 16:00. Monday to Thursday evening. Just short of 3 full working days by one hour. Tuesday is holiday.

Expected: 23.0d
But was:  15.999999999944444d

I want to know if I am using the collector incorrectly, or if the collector has a bug.

like image 359
Rebecca Avatar asked Sep 21 '15 16:09

Rebecca


1 Answers

This looks like a great library for solving a familiar problem.

The best thing to do is to output the periods in the period collection to help you debug the problem.

I've rewritten your test to use the base types in the examples from their documentation:

        [Test, TestCaseSource("LocalSource")]
    public void SO_GetRemainingWorkingTimeWithHolidayShouldOnlyEnumerateWorkingTime(DateTime startTime,
        TimeSpan workingHours, DateTime expectedDueDate, string expectation)
    {
        CalendarPeriodCollectorFilter filter = new CalendarPeriodCollectorFilter();
        filter.Months.Add(YearMonth.September); // only Januaries
        filter.WeekDays.Add(DayOfWeek.Monday); // 
        filter.WeekDays.Add(DayOfWeek.Tuesday); // 
        filter.WeekDays.Add(DayOfWeek.Wednesday); // 
        filter.WeekDays.Add(DayOfWeek.Thursday); // 
        filter.WeekDays.Add(DayOfWeek.Friday); // 
        filter.CollectingHours.Add(new HourRange(9, 17)); // working hours

        CalendarTimeRange testPeriod = new CalendarTimeRange(startTime, expectedDueDate);//new DateTime(2015, 9, 14, 9, 0, 0), new DateTime(2015, 9, 17, 18, 0, 0));
        Console.WriteLine("Calendar period collector of period: " + testPeriod);

        filter.ExcludePeriods.Add(new TimeBlock(new DateTime(2015, 9, 15, 00, 0, 0), new DateTime(2015, 9, 16, 0, 0, 0)));

        CalendarPeriodCollector collector = new CalendarPeriodCollector(filter, testPeriod);
        collector.CollectHours();

        foreach (ITimePeriod period in collector.Periods)
        {
            Console.WriteLine("Period: " + period); // THIS WILL HELP A LOT!
        }
        var result = collector.Periods.GetTotalDuration(new TimeZoneDurationProvider(TimeZoneInfo.FindSystemTimeZoneById("UTC")));

        Console.WriteLine(result);
            //
    }

This results in:

Calendar period collector of period: 14/09/2015 09:00:00 - 17/09/2015 15:59:59 | 3.06:59
Period: 14/09/2015 09:00:00 - 14/09/2015 16:59:59 | 0.07:59
Period: 16/09/2015 09:00:00 - 16/09/2015 16:59:59 | 0.07:59
15:59:59.9999998

So what I've noticed is that the very last period is missing.

If you change the end time of your period from 4PM to 6PM (and therefore expect an extra hour = 24) it will just about pass. (you will also need to round the result)

So it looks like the periods need to be completely covered by the total duration, partial coverage is not counted. You may be able to change the options of the library, alternatively you may be able to add each hour of the working day as separate CollectingHours (hacky)

Hope that gets you closer to the answer you need!

like image 110
Rob Bird Avatar answered Oct 19 '22 06:10

Rob Bird