lib/times.ex

defmodule Chi2fit.Times do

  # Copyright 2015-2021 Pieter Rijken
  #
  # Licensed under the Apache License, Version 2.0 (the "License");
  # you may not use this file except in compliance with the License.
  # You may obtain a copy of the License at
  #
  #     http://www.apache.org/licenses/LICENSE-2.0
  #
  # Unless required by applicable law or agreed to in writing, software
  # distributed under the License is distributed on an "AS IS" BASIS,
  # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  # See the License for the specific language governing permissions and
  # limitations under the License.

  @hours 24.0
  @default_workday {8.0,18.0}
  @default_epoch ~D[1970-01-01]

  @doc ~s"""
  Adjusts the times to working hours and/or work days.
  
  ## Options
  
      `workhours` - a 2-tuple containing the starting and ending hours of the work day (defaults
          to #{inspect @default_workday})
      `epoch` - the epoch to which all data elements are relative (defaults to #{@default_epoch})
      `saturday` - number of days since the epoch that corresponds to a Saturday (defaults
          to #{13 - Date.day_of_week(@default_epoch)})
      `correct` - whether to correct the times for working hours and weekdays; possible values
          `:worktime`, `:weekday`, `:"weekday+worktime"` (defaults to `false`)

  """
  @spec adjust_times(Enumerable.t, options :: Keyword.t) :: Enumerable.t
  def adjust_times(data, options) do
    {startofday,endofday} = options[:workhours] || @default_workday
    correct = options[:correct] || false
    epoch = options[:epoch] || @default_epoch
    sat = 13 - Date.day_of_week(epoch)
    saturday = options[:saturday] || sat

    data
    |> Stream.map(fn x ->
        case correct do
          :worktime -> map2workhours(x, startofday, endofday)
          :weekday -> map2weekdays(x, saturday)
          :"weekday+worktime" -> x |> map2workhours(startofday, endofday) |> map2weekdays(saturday)
          _ -> x
        end
      end)
    |> Enum.sort(&(&1>&2)) # Sort on new delivery times
  end

  @default_cutoff 0.01

  @doc """
  Returns a list of time differences (assumes an ordered list as input)
  
  ## Options
  
      `cutoff` - time differences below the cutoff are changed to the cutoff value (defaults to `#{@default_cutoff}`)
      `drop?` - whether to drop time differences below the cutoff (defaults to `false`)

  """
  @spec time_diff(data :: Enumrable.t, options :: Keyword.t) :: Enumerable.t
  def time_diff(data, options) do
    cutoff = options[:cutoff] || @default_cutoff
    drop? = options[:drop] || false

    data
    |> Stream.chunk_every(2,1,:discard)
    |> Stream.map(fn [x,y]->x-y end)
    |> Stream.transform(nil,fn x,_acc ->
          {
            cond do
              x < cutoff and drop? -> []
              x < cutoff -> [cutoff]
              true -> [x]
            end,
            nil
          }
        end)
    |> (& if is_function(data, 2), do: &1, else: Enum.into(&1, [])).()
  end

  @doc """
  Maps the time of a day into the working hour period
  
  Scales the resulting part of the day between 0..1.
  
  ## Arguments
  
      `t` - date and time of day as a float; the integer part specifies the day and the fractional part the hour of the day
      `startofday` - start of the work day in hours
      `endofday` - end of the working day in hours
  
  ## Example
  
      iex> map2workhours(43568.1, 8, 18)
      43568.0
  
      iex> map2workhours(43568.5, 8, 18)
      43568.4
  
  """
  @spec map2workhours(t :: number, startofday :: number, endofday :: number) :: number
  def map2workhours(t,startofday,endofday)
    when startofday>0 and startofday<endofday and endofday<@hours do
    frac = t - trunc(t)
    hours = endofday - startofday
    trunc(t) + min(max(0.0,frac - startofday/@hours),hours/@hours) * @hours/hours
  end

  @doc """
  Maps the date to weekdays such that weekends are eliminated; it does so with respect to a given Saturday
  
  ## Example
  
      iex> map2weekdays(43568.123,43566)
      43566.123
  
      iex> map2weekdays(43574.123,43566)
      43571.123
  
  """
  @spec map2weekdays(t :: number, sat :: pos_integer) :: number
  def map2weekdays(t, sat) when is_integer(sat) do
    offset = rem trunc(t)-sat, 7
    weeks = div trunc(t)-sat, 7
    
    part_of_day = t - trunc(t)
    sat + 5*weeks + max(0.0,offset-2.0) + part_of_day
  end

  @doc """
  Returns a `Stream` that generates a stream of dates.
  
  ## Examples
  
      iex> intervals(end: ~D[2019-06-01]) |> Enum.take(4)
      [~D[2019-06-01], ~D[2019-05-16], ~D[2019-05-01], ~D[2019-04-16]]
  
      iex> intervals(end: ~D[2019-06-01], type: :weekly) |> Enum.take(4)
      [~D[2019-06-01], ~D[2019-05-18], ~D[2019-05-04], ~D[2019-04-20]]
  
      iex> intervals(end: ~D[2019-06-01], type: :weekly, weeks: 1) |> Enum.take(4)
      [~D[2019-06-01], ~D[2019-05-25], ~D[2019-05-18], ~D[2019-05-11]]

      iex> intervals(end: ~D[2019-06-01], type: :weekly, weeks: [3,2]) |> Enum.take(4)
      [~D[2019-06-01], ~D[2019-05-11], ~D[2019-04-27], ~D[2019-04-13]]

  """
  @spec intervals(options :: Keyword.t) :: Enumerable.t
  def intervals(options \\ []) do
    type = options[:type] || :half_month
    periods = case options[:weeks] do
      nil -> [2]
      x when is_number(x) -> [x]
      list when is_list(list) -> list
    end
    last = options[:end] || Date.utc_today()

    case type do
      :half_month ->
        recent = case last do
          date = %Date{day: day} when day > 16 -> %Date{date | day: 1, month: date.month+1}
          date = %Date{day: day} when day > 1 -> %Date{date | day: 16}
          date = %Date{day: 1} -> date
        end

        recent |> Stream.iterate(fn
          previous = %Date{day: 16} -> Timex.shift previous, days: -15
          previous = %Date{day: 1} -> Timex.shift previous, days: +15, months: -1
        end)
      
      :weekly ->
        Stream.resource(
          fn -> {last,periods} end,
          fn
            {current,[p]} ->
              next = Timex.shift(current, weeks: -p)
              {[current], {next,[p]}}
            {current,[p|rest]} ->
              next = Timex.shift(current, weeks: -p)
              {[current], {next,rest}}
          end,
          fn _ -> [] end)
    end
  end

  @doc """
  Counts the number of dates (`datelist`) that is between consecutive dates in `intervals` and returns the result as a list of numbers.
  """
  @spec throughput(intervals :: Enumerable.t, datelist :: [NaiveDateTime.t]) :: [number]
  def throughput(intervals, datelist) do
    intervals
    |> Stream.chunk_every(2, 1, :discard)
    |> Stream.transform(datelist, fn
        _, [] ->
          {:halt, []}
        [d1,d2], acc ->
          {left,right} = Enum.split_with(acc, fn d -> Timex.between?(d,d2,d1) end)
          {[{d1,Enum.count(left)}],right}
      end)
    |> Enum.map(fn {_d,count} -> count end)
  end

end