Current section

Files

Jump to
tzdata lib tzdata period_builder.ex
Raw

lib/tzdata/period_builder.ex

defmodule Tzdata.PeriodBuilder do
@moduledoc false
alias Tzdata.Util, as: TzUtil
# the first year to use when looking at rules
@min_year 1900
# the last year to use when looking at rules
@years_in_the_future_where_precompiled_periods_are_used 40
@extra_years_to_precompile 4
@max_year (:calendar.universal_time() |> elem(0) |> elem(0)) +
@years_in_the_future_where_precompiled_periods_are_used + @extra_years_to_precompile
def calc_periods(btz_data, zone_name) do
{:ok, zone} = zone(btz_data, zone_name)
calc_periods(btz_data, zone.zone_lines, :min, Map.get(hd(zone.zone_lines), :rules), "")
end
defp zone(btz_data, zone_name) do
{:ok, Map.get(btz_data.zones, zone_name)}
end
defp get_rules(btz_data, rules_name) do
{:ok, Map.get(btz_data.rules, rules_name)}
end
def calc_periods(btz_data, [zone_line_hd | zone_line_tl], from, zone_hd_rules, letter)
when zone_hd_rules == nil do
# since there are no rules, there is no standard offset
std_off = 0
utc_off = zone_line_hd.gmtoff
from_standard_time = standard_time_from_utc(from, utc_off)
from_wall_time = wall_time_from_utc(from, utc_off, std_off)
until_utc = datetime_to_utc(Map.get(zone_line_hd, :until), utc_off, std_off)
until_standard_time = standard_time_from_utc(until_utc, utc_off)
until_wall_time = wall_time_from_utc(until_utc, utc_off, std_off)
period = %{
std_off: 0,
utc_off: utc_off,
from: %{utc: from, wall: from_wall_time, standard: from_standard_time},
until: %{standard: until_standard_time, wall: until_wall_time, utc: until_utc},
zone_abbr: TzUtil.period_abbrevation(zone_line_hd.format, std_off, utc_off, letter)
}
h_calc_next_zone_line(btz_data, period, until_utc, zone_line_tl, letter)
end
def calc_periods(btz_data, [zone_line_hd | zone_line_tl], from, zone_hd_rules, letter) do
# we start out by assuming there is no offset. the rules might change this
std_off = 0
utc_off = zone_line_hd.gmtoff
from_standard_time = standard_time_from_utc(from, utc_off)
zone_line_limit = Map.get(zone_line_hd, :until)
# Get the year of the "from" time. We use the standard time with utc offset
# applied. If for instance we are ahead of UTC and the period starts at the
# start of a new year we want the new year.
from_standard_time_year =
case from do
:min ->
@min_year
_ ->
{{year, _, _}, _} = :calendar.gregorian_seconds_to_datetime(from_standard_time)
year
end
max_year_to_use =
case zone_line_limit do
{{{year, _, _}, _}, _} -> year
nil -> @max_year
end
years_to_use = from_standard_time_year..max_year_to_use |> Enum.to_list()
# get rules
{rules_type, rules_value} = zone_hd_rules
calc_rule_periods_h(
btz_data,
rules_type,
rules_value,
[zone_line_hd | zone_line_tl],
from,
utc_off,
std_off,
years_to_use,
letter
)
end
# Helper function for function calc_periods with no rules
# When the zone line tail is empty we are at the last zone line.
# As this should only be called when there are no rules, we assume that
# there the period is until :max and thus it is the last period. So we don't
# add any more periods.
def h_calc_next_zone_line(_btz_data, period, _, zone_line_tl, _) when zone_line_tl == [] do
case period do
nil -> []
_ -> [ period ]
end
end
# If there is a zone line tail, we recursively add to the list of periods with that zone line tail
def h_calc_next_zone_line(btz_data, period, until_utc, zone_line_tl, letter) do
tail = calc_periods(btz_data, zone_line_tl, until_utc, hd(zone_line_tl).rules, letter)
case period do
nil -> tail
_ -> [ period | tail ]
end
end
defp calc_rule_periods_h(
btz_data,
:amount,
rules_value,
[zone_line_hd | zone_line_tl],
from,
_,
_,
_,
letter
) do
std_off = rules_value
utc_off = Map.get(zone_line_hd, :gmtoff)
from_standard_time = standard_time_from_utc(from, utc_off)
from_wall_time = wall_time_from_utc(from, utc_off, std_off)
until_utc = datetime_to_utc(Map.get(zone_line_hd, :until), utc_off, std_off)
until_standard_time = standard_time_from_utc(until_utc, utc_off)
until_wall_time = wall_time_from_utc(until_utc, utc_off, std_off)
period = %{
std_off: rules_value,
utc_off: utc_off,
from: %{utc: from, wall: from_wall_time, standard: from_standard_time},
until: %{standard: until_standard_time, wall: until_wall_time, utc: until_utc},
zone_abbr: TzUtil.period_abbrevation(zone_line_hd.format, std_off, utc_off, letter)
}
h_calc_next_zone_line(btz_data, period, until_utc, zone_line_tl, letter)
end
defp calc_rule_periods_h(
btz_data,
:named_rules,
rules_value,
[zone_line_hd | zone_line_tl],
from,
utc_off,
std_off,
years_to_use,
letter
) do
{:ok, rules} = get_rules(btz_data, rules_value)
calc_rule_periods(
btz_data,
[zone_line_hd | zone_line_tl],
from,
utc_off,
std_off,
years_to_use,
rules,
letter
)
end
# At the last zone line, which should last until "max".
# An example of this is Asia/Tokyo where at the time this is written
# the current period starts in 1951 and is still in effect.
def calc_rule_periods(_btz_data, [zone_line], from, utc_off, std_off, [], _, letter) do
from_standard_time = standard_time_from_utc(from, utc_off)
from_wall_time = wall_time_from_utc(from, utc_off, std_off)
period = %{
std_off: std_off,
utc_off: utc_off,
from: %{utc: from, wall: from_wall_time, standard: from_standard_time},
until: %{standard: :max, wall: :max, utc: :max},
zone_abbr: TzUtil.period_abbrevation(zone_line.format, std_off, utc_off, letter)
}
[period]
end
def calc_rule_periods(
btz_data,
[zone_line | zone_line_tl],
from,
utc_off,
std_off,
[],
_,
letter
) do
until_utc = datetime_to_utc(Map.get(zone_line, :until), utc_off, std_off)
tail = calc_periods(btz_data, zone_line_tl, until_utc, Map.get(hd(zone_line_tl), :rules), letter)
if from == until_utc do # empty period may happen when 'until' of zone line coincides with end of rule
tail
else
from_standard_time = standard_time_from_utc(from, utc_off)
from_wall_time = wall_time_from_utc(from, utc_off, std_off)
until_standard_time = standard_time_from_utc(until_utc, utc_off)
until_wall_time = wall_time_from_utc(until_utc, utc_off, std_off)
period = %{
std_off: std_off,
utc_off: utc_off,
from: %{utc: from, wall: from_wall_time, standard: from_standard_time},
until: %{standard: until_standard_time, wall: until_wall_time, utc: until_utc},
zone_abbr: TzUtil.period_abbrevation(zone_line.format, std_off, utc_off, letter)
}
[ period | tail ]
end
end
def calc_rule_periods(
btz_data,
zone_lines,
from,
utc_off,
std_off,
[years_hd | years_tl],
zone_rules,
letter
) do
rules_for_year = TzUtil.rules_for_year(zone_rules, years_hd) |> sort_rules_by_time(years_hd)
# if there are no rules for the given year, continue with the remaining years
if rules_for_year == [] do
calc_rule_periods(
btz_data,
zone_lines,
from,
utc_off,
std_off,
years_tl,
zone_rules,
letter
)
else
calc_periods_for_year(
btz_data,
zone_lines,
from,
utc_off,
std_off,
[years_hd | years_tl],
zone_rules,
rules_for_year,
letter,
from
)
end
end
def calc_periods_for_year(
btz_data,
[zone_line | zone_line_tl],
from,
utc_off,
std_off,
years,
zone_rules,
rules_for_year,
letter,
lower_limit
) do
year = years |> hd
rule = rules_for_year |> hd
rules_tail = rules_for_year |> tl
upper_limit = datetime_to_utc(Map.get(zone_line, :until), utc_off, std_off)
# `from` is inferred from the end of the last period, the `upper_limit` can go back in time in
# edge cases where the `std_off` has a negative delta and the `until` of the zone line occurs
# in a time <= delta
upper_limit_before_from = is_integer(from) && is_integer(upper_limit) && from > upper_limit
upper_limit = if upper_limit_before_from, do: from, else: upper_limit
# truncate start of period to within time range of zone line
from_before_lower_limit = is_integer(lower_limit) && (from == :min || lower_limit > from)
from = if from_before_lower_limit, do: lower_limit, else: from
# derive standard and wall time for 'from'
from_standard_time = standard_time_from_utc(from, utc_off)
from_wall_time = wall_time_from_utc(from, utc_off, std_off)
until_utc = datetime_to_utc(TzUtil.time_for_rule(rule, year), utc_off, std_off)
# truncate end of period to within time range of zone line
until_before_lower_limit = is_integer(lower_limit) && is_integer(until_utc) && lower_limit > until_utc
until_utc = if until_before_lower_limit, do: lower_limit, else: until_utc
last_included_rule = is_integer(upper_limit) && is_integer(until_utc) && upper_limit <= until_utc
until_utc = if last_included_rule, do: upper_limit, else: until_utc
# derive standard and wall time for 'until'
until_standard_time = standard_time_from_utc(until_utc, utc_off)
until_wall_time = wall_time_from_utc(until_utc, utc_off, std_off)
# Some times this will calculate periods with zero length.
# Set period to nil if the length is zero (ie. "until" equals "from")
# Nil values will be filtered by another function
period =
if until_utc == from,
do: nil,
else: %{
std_off: std_off,
utc_off: utc_off,
from: %{utc: from, wall: from_wall_time, standard: from_standard_time},
until: %{standard: until_standard_time, wall: until_wall_time, utc: until_utc},
zone_abbr: TzUtil.period_abbrevation(zone_line.format, std_off, utc_off, letter)
}
no_more_rules = rules_tail == []
no_more_years = tl(years) == []
# If we've hit the upper time boundary of this zone line, we do not need to examine any more
# rules for this rule set OR there are no more years to consider for this rule set
if last_included_rule || no_more_years && no_more_rules do
h_calc_next_zone_line(btz_data, period, until_utc, zone_line_tl, letter)
else
tail = cond do
# If there are no more rules for the year, continue with the next year
no_more_rules ->
calc_rule_periods(
btz_data,
[zone_line | zone_line_tl],
until_utc,
utc_off,
rule.save,
years |> tl,
zone_rules,
rule.letter
)
# Else continue with those rules
true ->
calc_periods_for_year(
btz_data,
[zone_line | zone_line_tl],
until_utc,
utc_off,
rule.save,
years,
zone_rules,
rules_tail,
rule.letter,
lower_limit
)
end
if period == nil, do: tail, else: [ period | tail ]
end
end
# earliest rule first
# sort by month!
def sort_rules_by_time(rules, year) do
# n.b., we can have many rules per month - such as time changes for religious festivals
rules
|> Enum.map(&({&1, TzUtil.tz_day_to_date(year, &1.in, &1.on)}))
|> Enum.sort(&(elem(&1, 1) < elem(&2, 1)))
|> Enum.map(&(elem(&1, 0)))
end
@doc """
Takes a tuple of date time and modifier that can be :utc, :standard or :wall
UTC offset in seconds and standard offset in seconds.
Returns UTC time in seconds.
"""
# special case for datetime provided being nil. we assume it's for
# use with a timezone line with until being nil
def datetime_to_utc(until, _, _) when until == nil do
:max
end
def datetime_to_utc({datetime, modifier}, _, _) when modifier == :utc do
:calendar.datetime_to_gregorian_seconds(datetime)
end
def datetime_to_utc({datetime, modifier}, utc_off, _) when modifier == :standard do
:calendar.datetime_to_gregorian_seconds(datetime) - utc_off
end
def datetime_to_utc({datetime, modifier}, utc_off, std_off) when modifier == :wall do
:calendar.datetime_to_gregorian_seconds(datetime) - utc_off - std_off
end
def standard_time_from_utc(:min, _), do: :min
def standard_time_from_utc(:max, _), do: :max
def standard_time_from_utc(utc_time, utc_offset) do
utc_time + utc_offset
end
def wall_time_from_utc(:min, _, _), do: :min
def wall_time_from_utc(:max, _, _), do: :max
def wall_time_from_utc(utc_time, utc_offset, standard_offset) do
utc_time + utc_offset + standard_offset
end
end