diff --git a/lib/plox/date_scale.ex b/lib/plox/date_scale.ex index e1334d3..5c268a4 100644 --- a/lib/plox/date_scale.ex +++ b/lib/plox/date_scale.ex @@ -4,7 +4,8 @@ defmodule Plox.DateScale do This struct implements the `Plox.Scale` protocol. - `Plox.Scale.values/2` returns a `t:Date.Range.t/0` enumerable: + `Plox.Scale.values/2` returns a `t:Date.Range.t/0` enumerable and accepts + `step` and `start` options: iex> scale = Plox.DateScale.new(Date.range(~D[2020-01-01], ~D[2020-01-10], 1)) iex> scale |> Plox.Scale.values(%{step: 2}) |> Enum.to_list() @@ -14,6 +15,10 @@ defmodule Plox.DateScale do iex> scale |> Plox.Scale.values(%{step: 3}) |> Enum.to_list() [~D[2020-01-10], ~D[2020-01-07], ~D[2020-01-04], ~D[2020-01-01]] + iex> scale = Plox.DateScale.new(Date.range(~D[2020-01-01], ~D[2020-01-10], 1)) + iex> scale |> Plox.Scale.values(%{start: ~D[2020-01-07]}) |> Enum.to_list() + [~D[2020-01-07], ~D[2020-01-08], ~D[2020-01-09], ~D[2020-01-10]] + `Plox.Scale.convert_to_range/3` returns a number in the given range: iex> scale = Plox.DateScale.new(Date.range(~D[2020-01-01], ~D[2020-01-09], 1)) @@ -67,15 +72,32 @@ defmodule Plox.DateScale do Returns a `t:Date.Range.t/0` of all `t:Date.t/0` values in the scale, stepping by the given interval. - Accepts a `step` option as a number of days. The default step is 1 day. - Raises if given `step` is not a positive integer greater than 1. + Maintains the direction of the original range (positive or negative). + + ## Options + + * `:start` - The starting date for generating values. Must be a `t:Date.t/0` + within the scale's domain. Defaults to the first date in the scale's range. + + * `:step` - The number of days between each value. Must be a positive integer. + Defaults to `1`. """ def values(scale, opts) do - case Map.fetch(opts, :step) do - :error -> scale.range - {:ok, step} when step < 1 -> raise ArgumentError, message: "Step must be a positive integer" - {:ok, step} when scale.range.step > 0 -> %{scale.range | step: step} - {:ok, step} when scale.range.step < 0 -> %{scale.range | step: -step} + first_value = Map.get(opts, :start, scale.range.first) + step = Map.get(opts, :step, 1) + + unless first_value in scale.range do + raise ArgumentError, message: "DateScale: start value must be within the range of the scale" + end + + if step < 1 do + raise ArgumentError, message: "DateScale: step must be a positive integer" + end + + if scale.range.step > 0 do + Date.range(first_value, scale.range.last, step) + else + Date.range(first_value, scale.range.last, -step) end end diff --git a/lib/plox/date_time_scale.ex b/lib/plox/date_time_scale.ex index 5ca70d4..6d7d5ee 100644 --- a/lib/plox/date_time_scale.ex +++ b/lib/plox/date_time_scale.ex @@ -4,7 +4,8 @@ defmodule Plox.DateTimeScale do This struct implements the `Plox.Scale` protocol. - `Plox.Scale.values/2` returns a list of all datetime values: + `Plox.Scale.values/2` returns a list of all datetime values and accepts + `step` and `start` options: iex> scale = Plox.DateTimeScale.new(~N[2019-01-01 00:00:00], ~N[2019-01-01 00:03:00]) iex> Plox.Scale.values(scale) @@ -14,6 +15,10 @@ defmodule Plox.DateTimeScale do iex> Plox.Scale.values(scale, %{step: {1, :day}}) [~U[2019-01-01 00:00:00Z], ~U[2019-01-02 00:00:00Z], ~U[2019-01-03 00:00:00Z]] + iex> scale = Plox.DateTimeScale.new(~N[2019-01-01 00:00:00], ~N[2019-01-01 00:03:00]) + iex> Plox.Scale.values(scale, %{start: ~N[2019-01-01 00:01:00]}) + [~N[2019-01-01 00:01:00], ~N[2019-01-01 00:02:00], ~N[2019-01-01 00:03:00]] + `Plox.Scale.convert_to_range/3` returns a number in the given range: iex> scale = Plox.DateTimeScale.new(~N[2019-01-01 00:00:00], ~N[2019-01-03 00:00:00]) @@ -64,8 +69,14 @@ defmodule Plox.DateTimeScale do Returns a list of all `DateTime` or `NaiveDateTime` values in the scale, stepping by the given interval. - Accepts a `:step` option, which can be a number of seconds, minutes, hours, - or days. The default step is 60 seconds. + ## Options + + * `:start` - The starting datetime value. Must be included in the scale. + Defaults to the first value in the scale. + + * `:step` - The step interval. Can be a non-zero integer number of seconds, + or a tuple of `{integer, :second | :minute | :hour | :day}`. + Defaults to `{60, :second}`. """ def values(%{first: %DateTime{time_zone: tz}} = scale, %{step: {step_days, :day}}) when tz != "Etc/UTC" do scale.first @@ -80,24 +91,43 @@ defmodule Plox.DateTimeScale do end def values(%{first: %date_time_module{}} = scale, opts) do + first_value = Map.get(opts, :start, scale.first) + + if date_time_module.compare(first_value, scale.first) == :lt or + date_time_module.compare(first_value, scale.last) == :gt do + raise ArgumentError, message: "DateTimeScale: start value must be within the range of the scale" + end + step_seconds = case Map.get(opts, :step, {60, :second}) do - seconds when is_integer(seconds) -> seconds - {seconds, :second} -> seconds - {minutes, :minute} -> minutes * 60 - {hours, :hour} -> hours * 3600 - {days, :day} -> days * 86_400 + seconds when is_integer(seconds) and seconds > 0 -> + seconds + + {seconds, :second} -> + seconds + + {minutes, :minute} -> + minutes * 60 + + {hours, :hour} -> + hours * 3600 + + {days, :day} -> + days * 86_400 + + _ -> + raise ArgumentError, + message: + "DateTimeScale: step must be a non-zero integer or a {integer, :second | :minute | :hour | :day} tuple" end if date_time_module == DateTime and scale.first.time_zone != "Etc/UTC" and step_seconds > 3600 do Logger.warning(fn -> - "DateTimeScale: steps greater than an hour in seconds for non UTC DateTimes are not safe to use because of DST shifts" + "DateTimeScale: steps greater than an hour for non UTC DateTimes are not safe to use because of DST shifts" end) end - first_value = Map.get(opts, :start, scale.first) - total_seconds = date_time_module.diff(scale.last, first_value) ticks = trunc(total_seconds / step_seconds) diff --git a/lib/plox/number_scale.ex b/lib/plox/number_scale.ex index 6f20202..b94b6af 100644 --- a/lib/plox/number_scale.ex +++ b/lib/plox/number_scale.ex @@ -8,7 +8,8 @@ defmodule Plox.NumberScale do This struct implements the `Plox.Scale` protocol. - `Plox.Scale.values/2` returns an enumerable of the numerical values in the scale: + `Plox.Scale.values/2` returns an enumerable of the numerical values in the scale + and accepts `start` and `ticks` options: iex> scale = Plox.NumberScale.new(0, 10) iex> Plox.Scale.values(scale) @@ -18,6 +19,10 @@ defmodule Plox.NumberScale do iex> Plox.Scale.values(scale, %{ticks: 6}) [10.0, 8.0, 6.0, 4.0, 2.0, 0.0] + iex> scale = Plox.NumberScale.new(0, 10) + iex> Plox.Scale.values(scale, %{start: 5}) + [5.0, 5.5, 6.0, 6.5, 7.0, 7.5, 8.0, 8.5, 9.0, 9.5, 10.0] + `Plox.Scale.convert_to_range/3` returns a number in the given range: iex> scale = Plox.NumberScale.new(0, 10) @@ -69,20 +74,44 @@ defmodule Plox.NumberScale do dynamically calculated based on `first`, `last`, and `ticks`. Raises if `ticks` is less than `2` (default is `11`). + + ## Options + + * `:start` - The starting value. Must be a number within the scale's domain. + Defaults to the first value in the scale. + + * `:ticks` - The number of scale values to return. Must be at least `2`. + Defaults to `11`. """ def values(scale, opts) do + first_value = + case Map.get(opts, :start) do + nil -> scale.first + value when is_number(value) -> Decimal.from_float(value / 1.0) + value -> raise ArgumentError, message: "NumberScale: start must be a number, got #{inspect(value)}" + end + + minimum = if scale.backwards?, do: scale.last, else: scale.first + maximum = if scale.backwards?, do: scale.first, else: scale.last + + if Decimal.lt?(first_value, minimum) or Decimal.gt?(first_value, maximum) do + raise ArgumentError, + message: "NumberScale: start value #{Decimal.to_float(first_value)} must be within the scale range" + end + ticks = Map.get(opts, :ticks, 11) - if ticks < 2 do - raise ArgumentError, message: "Invalid ticks count `#{ticks}`, must be at least 2" + case ticks do + n when is_integer(n) and n >= 2 -> n + _ -> raise ArgumentError, message: "NumberScale: ticks must be an integer >= 2" end - step = scale.last |> Decimal.sub(scale.first) |> Decimal.div(ticks - 1) + step = scale.last |> Decimal.sub(first_value) |> Decimal.div(ticks - 1) # we don't compute the last value because it could include rounding errors # carried through each step, instead we just append `scale.last` 0..(ticks - 2) - |> Enum.map_reduce(scale.first, fn _i, acc -> {acc, Decimal.add(acc, step)} end) + |> Enum.map_reduce(first_value, fn _i, acc -> {acc, Decimal.add(acc, step)} end) |> elem(0) |> Kernel.++([scale.last]) |> Enum.map(&Decimal.to_float/1) diff --git a/test/plox/date_scale_test.exs b/test/plox/date_scale_test.exs index 708b19b..40d4ed4 100644 --- a/test/plox/date_scale_test.exs +++ b/test/plox/date_scale_test.exs @@ -36,9 +36,9 @@ defmodule Plox.DateScaleTest do end describe "values/2" do - test "with default step (1d)" do + test "with default step (1d) and start" do scale = DateScale.new(Date.range(~D[2019-01-01], ~D[2019-01-03])) - assert %Date.Range{first: ~D[2019-01-01], last: ~D[2019-01-03], step: 1} = Scale.values(scale) + assert Scale.values(scale) == Date.range(~D[2019-01-01], ~D[2019-01-03], 1) end test "with positive custom step in days" do @@ -51,13 +51,47 @@ defmodule Plox.DateScaleTest do assert Scale.values(scale, %{step: 2}) == Date.range(~D[2019-01-10], ~D[2019-01-01], -2) end - test "raises an error with invalid step" do + test "raises an error with zero step" do scale = DateScale.new(Date.range(~D[2019-01-01], ~D[2019-01-10])) assert_raise ArgumentError, fn -> Scale.values(scale, %{step: 0}) end end + + test "raises an error with negative step" do + scale = DateScale.new(Date.range(~D[2019-01-01], ~D[2019-01-10])) + + assert_raise ArgumentError, fn -> + Scale.values(scale, %{step: -1}) + end + end + + test "with custom start" do + scale = DateScale.new(Date.range(~D[2019-01-01], ~D[2019-01-05])) + assert Scale.values(scale, %{start: ~D[2019-01-03]}) == Date.range(~D[2019-01-03], ~D[2019-01-05]) + end + + test "raises an error with start before range" do + scale = DateScale.new(Date.range(~D[2019-01-01], ~D[2019-01-05])) + + assert_raise ArgumentError, fn -> + Scale.values(scale, %{start: ~D[2018-12-31]}) + end + end + + test "raises an error with start after range" do + scale = DateScale.new(Date.range(~D[2019-01-01], ~D[2019-01-05])) + + assert_raise ArgumentError, fn -> + Scale.values(scale, %{start: ~D[2019-01-06]}) + end + end + + test "with custom step and start" do + scale = DateScale.new(Date.range(~D[2019-01-01], ~D[2019-01-10])) + assert Scale.values(scale, %{step: 3, start: ~D[2019-01-02]}) == Date.range(~D[2019-01-02], ~D[2019-01-10], 3) + end end describe "convert_to_range/3" do diff --git a/test/plox/date_time_scale_test.exs b/test/plox/date_time_scale_test.exs index 81c33f1..50d731b 100644 --- a/test/plox/date_time_scale_test.exs +++ b/test/plox/date_time_scale_test.exs @@ -45,7 +45,7 @@ defmodule Plox.DateTimeScaleTest do end describe "values/2" do - test "with default step (60s)" do + test "with default step (60s) and start" do scale = DateTimeScale.new(~N[2019-01-01 00:00:00], ~N[2019-01-01 00:02:00]) assert Scale.values(scale) == [ @@ -55,6 +55,16 @@ defmodule Plox.DateTimeScaleTest do ] end + test "with valid step in total seconds" do + scale = DateTimeScale.new(~N[2019-01-01 00:00:00], ~N[2019-01-01 00:02:00]) + + assert Scale.values(scale, %{step: 60}) == [ + ~N[2019-01-01 00:00:00], + ~N[2019-01-01 00:01:00], + ~N[2019-01-01 00:02:00] + ] + end + test "with valid step in days" do scale = DateTimeScale.new(~N[2019-01-01 00:00:00], ~N[2019-01-03 00:00:00]) @@ -94,6 +104,67 @@ defmodule Plox.DateTimeScaleTest do ~N[2019-01-01 00:00:03] ] end + + test "raises an error with zero step" do + scale = DateTimeScale.new(~N[2019-01-01 00:00:00], ~N[2019-01-01 00:02:00]) + + assert_raise ArgumentError, fn -> + Scale.values(scale, %{step: 0}) + end + end + + test "raises an error with negative step" do + scale = DateTimeScale.new(~N[2019-01-01 00:00:00], ~N[2019-01-01 00:02:00]) + + assert_raise ArgumentError, fn -> + Scale.values(scale, %{step: -60}) + end + end + + test "raises an error with invalid step" do + scale = DateTimeScale.new(~N[2019-01-01 00:00:00], ~N[2019-01-01 00:02:00]) + + assert_raise ArgumentError, fn -> + Scale.values(scale, %{step: {-500, :invalid}}) + end + end + + test "with custom start" do + scale = DateTimeScale.new(~N[2019-01-01 00:00:00], ~N[2019-01-01 00:02:00]) + + assert Scale.values(scale, %{start: ~N[2019-01-01 00:01:00]}) == [ + ~N[2019-01-01 00:01:00], + ~N[2019-01-01 00:02:00] + ] + end + + test "raises an error with start before range" do + scale = DateTimeScale.new(~N[2019-01-01 00:00:00], ~N[2019-01-01 00:02:00]) + + assert_raise ArgumentError, fn -> + Scale.values(scale, %{start: ~N[2018-12-31 23:59:59]}) + end + end + + test "raises an error with start after range" do + scale = DateTimeScale.new(~N[2019-01-01 00:00:00], ~N[2019-01-01 00:02:00]) + + assert_raise ArgumentError, fn -> + Scale.values(scale, %{start: ~N[2019-01-01 00:03:00]}) + end + end + + test "with custom step and start" do + scale = DateTimeScale.new(~N[2019-01-01 00:00:00], ~N[2019-01-01 00:10:00]) + + assert Scale.values(scale, %{step: {2, :minute}, start: ~N[2019-01-01 00:01:00]}) == [ + ~N[2019-01-01 00:01:00], + ~N[2019-01-01 00:03:00], + ~N[2019-01-01 00:05:00], + ~N[2019-01-01 00:07:00], + ~N[2019-01-01 00:09:00] + ] + end end describe "convert_to_range/3" do diff --git a/test/plox/number_scale_test.exs b/test/plox/number_scale_test.exs index 9b08f72..563ee01 100644 --- a/test/plox/number_scale_test.exs +++ b/test/plox/number_scale_test.exs @@ -29,7 +29,7 @@ defmodule Plox.NumberScaleTest do end describe "values/2" do - test "with default ticks" do + test "with default ticks (11) and start" do scale = NumberScale.new(0, 10) assert Scale.values(scale) == [0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0] end @@ -39,18 +39,52 @@ defmodule Plox.NumberScaleTest do assert Scale.values(scale, %{ticks: 5}) == [0.0, 2.5, 5.0, 7.5, 10.0] end - test "with reversed scale" do + test "with default ticks (11) and reversed scale" do scale = NumberScale.new(10, 0) assert Scale.values(scale) == [10.0, 9.0, 8.0, 7.0, 6.0, 5.0, 4.0, 3.0, 2.0, 1.0, 0.0] end - test "raises an error with invalid ticks" do + test "raises an error with ticks < 2" do scale = NumberScale.new(0, 10) assert_raise ArgumentError, fn -> Scale.values(scale, %{ticks: 1}) end end + + test "raises an error with non-integer ticks" do + scale = NumberScale.new(0, 10) + + assert_raise ArgumentError, fn -> + Scale.values(scale, %{ticks: 5.5}) + end + end + + test "with custom start" do + scale = NumberScale.new(0, 10) + assert Scale.values(scale, %{start: 5}) == [5.0, 5.5, 6.0, 6.5, 7.0, 7.5, 8.0, 8.5, 9.0, 9.5, 10.0] + end + + test "with custom start and ticks" do + scale = NumberScale.new(0, 10) + assert Scale.values(scale, %{start: 2, ticks: 5}) == [2.0, 4.0, 6.0, 8.0, 10.0] + end + + test "raises an error with start before range" do + scale = NumberScale.new(0, 10) + + assert_raise ArgumentError, fn -> + Scale.values(scale, %{start: -1}) + end + end + + test "raises an error with start after range" do + scale = NumberScale.new(0, 10) + + assert_raise ArgumentError, fn -> + Scale.values(scale, %{start: 11}) + end + end end describe "convert_to_range/3" do