# calendar-utils.sh
#
# Bash utilities for managing Julian and Gregorian calendar dates
# and conversions to Julian and Absolute Days.
#
# CALFAQ version 1.1, 4 April 2008
#
# COPYRIGHT:
#   These functions are Copyright (c) 2008 by Claus Tondering
#   (claus@tondering.dk).
#
# LICENSE:
#   The code is distributed under the Boost Software License, which
#   says:
#
#     Boost Software License - Version 1.0 - August 17th, 2003
#
#     Permission is hereby granted, free of charge, to any person or
#     organization obtaining a copy of the software and accompanying
#     documentation covered by this license (the "Software") to use,
#     reproduce, display, distribute, execute, and transmit the
#     Software, and to prepare derivative works of the Software, and
#     to permit third-parties to whom the Software is furnished to do
#     so, all subject to the following:
#
#     The copyright notices in the Software and this entire
#     statement, including the above license grant, this restriction
#     and the following disclaimer, must be included in all copies of
#     the Software, in whole or in part, and all derivative works of
#     the Software, unless such copies or derivative works are solely
#     in the form of machine-executable object code generated by a
#     source language processor.
#
#     THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
#     EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
#     OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, TITLE AND
#     NON-INFRINGEMENT. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR
#     ANYONE DISTRIBUTING THE SOFTWARE BE LIABLE FOR ANY DAMAGES OR
#     OTHER LIABILITY, WHETHER IN CONTRACT, TORT OR OTHERWISE,
#     ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
#     USE OR OTHER DEALINGS IN THE SOFTWARE.
#
# DESCRIPTION:
#   These functions are an implementation in bash of the formulas
#   presented in the Calendar FAQ at
#   http://www.tondering.dk/claus/calendar.html.
#
#   The implementation follows the formulas mentioned in version 2.9
#   of the FAQ quite closely. The focus of the implementation is on
#   simplicity and clarity. For this reason, no complex data
#   structures or classes are used, nor has any attempt been made to
#   optimize the code. Also, no verification of the input parameters
#   is performed (except in the function simple_gregorian_easter).
#
#   All numbers (including Julian Day Numbers which current have
#   values of almost 2,500,000) are assumed to be representable as
#   variables of type 'int'.

CALENDAR_UTILS_VERSION="calendar-utils.sh v1.2"
[[ "$CALENDAR_UTILS_SH" = "$CALENDAR_UTILS_VERSION" ]] && return
CALENDAR_UTILS_SH="$CALENDAR_UTILS_VERSION"

source help-util.sh

calendar_help() {
  help_pager <<EOF
calendar-utils provide some basic calendaring functions.
Usage:

    source calendar-utils.sh

Functions:

date_to_jdn YYYY MM DD         -- return Julian Day Number for given DATE

day_of_week YEAR MM DD [STYLE] -- return the day of the week for the given date

days_in_month YEAR MM          -- return # of days in the month MM for YEAR

days_in_month[MM]              -- array indexed by month (1..12) to return # days

easter YEAR                    -- return date of Easter for YEAR

gregorian_easter YEAR          -- return date of Easter for YEAR in Gregorian calendar

is_leap_year YEAR              -- return whether or not YEAR is a leap year

jdn_to_date JDN                -- return date for given Julian Day Number (JDN)

julian_period YEAR             -- return Julian year for given Gregorian YEAR

style_for_year YEAR            -- compute the calendar style for the given YEAR

week_number YYYY MM DD         -- return the week number for the given date

Astronomical (obscure) Functions:

epact YEAR                     -- return the epact for the given YEAR

golden_number YEAR             -- return the golden number for YEAR

indiction YEAR                 -- return indication for YEAR

paschal_full_moon YEAR         -- return the Paschal full moon date for YEAR

solar_number YEAR              -- return the solar number for YEAR

EOF
}
help_calendar() { calendar_help ; }

source help-util.sh

NO_STYLE_DATES=0
JULIAN_DATES=1
GREGORIAN_DATES=2

# This application assumes that the changeover from the Julian calendar to the
# Gregorian calendar occurred in October of 1582, according to the scheme
# instituted by Pope Gregory XIII. Specifically, for dates on or before 4 October
# 1582, the Julian calendar is used; for dates on or after 15 October 1582, the
# Gregorian calendar is used. Thus, there is a ten-day gap in calendar dates, but
# no discontinuity in Julian dates or days of the week: 4 October 1582 (Julian)
# is a Thursday, which begins at JD 2299159.5; and 15 October 1582 (Gregorian) is
# a Friday, which begins at JD 2299160.5. The omission of ten days of calendar
# dates was necessitated by the astronomical error built up by the Julian
# calendar over its many centuries of use, due to its too-frequent leap years. 

GREGORIAN_START_DATE='1582-10-4'

GREGORIAN_START_YEAR=1582
GREGORIAN_START_MONTH=10
GREGORIAN_START_DAY=4

GREGORIAN_START_JDAY=2299150     # Julian Day Number for GREGORIAN_START_DATE

# style_for_year YEAR STYLE
#
# Return the given style, or the default style depending on the calendar year

style_for_year() {
  help_args_func calendar_help $# || return
  local year=$(( 10#$1 ))
  local style=${2:-${NO_STYLE_DATES}}
  if (( style == NO_STYLE_DATES )) ; then
    style=$(( year < GREGORIAN_START_YEAR ? JULIAN_DATES : GREGORIAN_DATES ))
  fi
  echo $style
}

# style_for_date YYYY MM DD [style]

style_for_date() {
  help_args_func calendar_help $# 3 || return
  local -i year month day
  year=$(( 10#$1 ))  month=$(( 10#$2 ))  day=$(( 10#$3 ))  style=${4:-$NO_STYLE_DATES}
  if (( style == NO_STYLE_DATES )) ; then
    if   ((  year < GREGORIAN_START_YEAR ))  ; then style=$JULIAN_DATES 
    elif ((  year > GREGORIAN_START_YEAR ))  ; then style=$GREGORIAN_DATES
    elif (( month < GREGORIAN_START_MONTH )) ; then style=$JULIAN_DATES
    elif (( month > GREGORIAN_START_MONTH )) ; then style=$GREGORIAN_DATES
    elif ((   day < GREGORIAN_START_DAY ))   ; then style=$JULIAN_DATES
    else                                            style=$GREGORIAN_DATES
    fi
  fi
  echo $style
}


# is_leap_year YEAR [STYLE]
#
# Returns 0 (true) if YEAR is a leap year, 1 (false) otherwise.
# If STYLE not given, JULIAN_DATES is assumed if the year is
# less than 1582, and GREGORIAN_DATES is assumed otherwise.

is_leap_year() {
  help_args_func calendar_help $# 1 || return
  local year="$1"
  local style=`style_for_year $year $2`
  if (( style == JULIAN_DATES )) ; then
    return $(( year % 4 == 0 )) 
  else # GREGORIAN_DATES
    return $(( ( year % 4 == 0 && year % 100 != 0 ) || year % 400 == 0 )) 
  fi
  return 1
}

# days_in_month YEAR MONTH [STYLE]
#
# Calculates the number of days in a month.
#
#     Year (must be >0)
#     Month (1..12)
#
# Outputs: 
#
#     The number of days in the month (28..31)

days_in_month=( - 31 28 31 30 31 30 31 31 30 31 30 31 )

days_in_month() {
  help_args_func calendar_help $# 2 || return
  local year=$(( 10#$1 ))
  local month=$(( 10#$2 ))
  local style=`style_for_date $year $month 1 $3`
  if (( month == 2 )) && is_leap $year $style ; then
    echo 29
  else
    echo ${days_in_month[$month]}
  fi
}

# solar_number YEAR
#
# Calculates the Solar Number of a given year.
#
#     Year (must be >0)
#
# Output:
#
#     Solar Number (1..28)
#
# Reference: Section 2.4 of version 2.9 of the FAQ.

solar_number() {
  help_args_func calendar_help $# || return
  local year="$1"
  echo $(( ( year + 8 ) % 28 + 1 ))
}

# day_of_week YEAR MONTH DAY [STYLE]
#
# Calculates the weekday for a given date.
#
#     Year (must be >0)
#     Month (1..12)
#     Day (1..31)
#     Calendar style (JULIAN or GREGORIAN)
#
# Output:
#
#     0 for Sunday, 1 for Monday, 2 for Tuesday, etc.

day_of_week() {
  help_args_func calendar_help $# 3 || return
  local year=$(( 10#$1 ))
  local month=$(( 10#$2 ))
  local day=$(( 10#$3 ))
  local style=`style_for_date "$@" `  # YYYY MM DD STYLE

  local a=$(( ( 14 - month ) / 12 ))
  local y=$(( year - a ))
  local m=$(( month + 12*a - 2 ))
  if (( style == JULIAN_DATES )) ; then
    echo $(( ( 5 + day + y + y/4 + (31*m)/12 ) % 7 )) 
  else # GREGORIAN_DATES
    echo $(( ( day + y + y/4 - y/100 + y/400 + (31*m)/12) % 7 ))
  fi
}

# golden_number YEAR
#
# Calculates the Golden Number of a given year.
#
#     Year (must be >0)
#
# Output:
#
#     Golden Number (1..19)

golden_number() {
  help_args_func calendar_help $# || return
  local year=$(( 10#$1 ))
  echo $(( year % 19 + 1 ))
}

# epact: YEAR [STYLE]
#
# Calculates the Epact of a given year.
#
#     Year (must be >0)
#     Calendar style (JULIAN or GREGORIAN)
#
# Output:
#
#     Epact (1..30)

epact() {
  help_args_func calendar_help $# || return
  local year=$(( 10#$1 ))
  local style=`style_for_year $year $2`
  if (( style == JULIAN_DATES )); then
    local je=$(( (11 * (`golden_number year` - 1)) % 30 ))
    echo $(( je == 0 ? 30 : je )) 
  else # GREGORIAN_DATES
    local century=$(( year / 100 + 1 ))
    local solar_eq=$(( (3*century)/4 ))
    local lunar_eq=$(( (8*century + 5)/25 ))
    local greg_epact=$(( `epact year $JULIAN_DATES` - solar_eq + lunar_eq + 8 ))
    while (( greg_epact > 30 )) ; do (( greg_epact -= 30 )) ; done
    while (( greg_epact < 1  )) ; do (( greg_epact += 30 )) ; done
    echo $greg_epact 
  fi
}

# paschal_full_moon YEAR STYLE
#
# Calculates the date of the Paschal full moon.
#
#     Year (must be >0)
#     Calendar style (JULIAN or GREGORIAN)
#
# Output:
#
#     Year
#     Month of Paschal full moon (3..4)
#     Day of Pascal full moon (1..31)

paschal_jul_months=( 4  3  4  4  3  4  3  4  4  3  4  4  3  4  4  3  4  3  4 )
paschal_jul_days=(   5 25 13  2 22 10 30 18  7 27 15  4 24 12  1 21  9 29 17 )

paschal_full_moon() {
  help_args_func calendar_help $# || return
  local year=$(( 10#1 ))
  local style=`style_for_year $year $2`

  if (( style == JULIAN_DATES )); then
    local gn=`golden_number $year`
    local month=$(( paschal_jul_months[gn] ))
    local day=$(( paschal_jul_days[gn] ))
  else # GREGORIAN_DATES
    local gepact=`epact $year $GREGORIAN_DATES`
    if (( gepact <= 12 )); then
      month=4 day=$(( 13 - gepact ))
    elif (( gepact <= 23 )); then
      month=3 day=$(( 44 - gepact ))
    elif (( gepact == 24 )) ; then
      month=4 day=18
    elif (( gepact == 25 )) ; then
      month=4 day=$(( `golden_number year` > 11 ? 17 : 18 ))
    else
      month=4 day=$(( 43 - gepact ))
    fi
  fi
  echo $year $month $day
}

# easter YEAR [STYLE]
#
# Calculates the date of Easter Sunday.
#
#     Year (must be >0)
#     Calendar style (JULIAN or GREGORIAN)
#
# Output parameters:
#
#     year
#     month of Easter Sunday (3..4)
#     day of Easter Sunday (1..31)
#

easter() {
  help_args_func calendar_help $# || return
  local year=$(( 10#$1 ))
  local style=`style_for_year $year $2`
  local G I J C H L
  (( G = year % 19 ))
  if (( style == JULIAN_DATES )); then
    (( I = (19*G + 15) % 30 ))
    (( J = (year + year/4 + I) % 7 ))
  else # GREGORIAN_DATES
    (( C = year/100 ))
    (( H = (C - C/4 - (8*C+13)/25 + 19*G + 15) % 30 ))
    (( I = H - (H/28)*(1 - (29/(H + 1))*((21 - G)/11)) ))
    (( J = (year + year/4 + I + 2 - C + C/4) % 7 ))
  fi
  (( L = I - J ))
  (( month = 3 + (L + 40)/44 ))
  (( day = L + 28 - 31*(month/4) ))
  echo $year $month $day
}

# gregorian_easter:
#
# Calculates the date of Easter Sunday in the Gregorian calendar.
#
#     Year (must be in the range 1900..2099)
#
# Output: date of Easter Sunday (year month day)
#
#     month of Easter Sunday (3..4)
#     day of Easter Sunday (1..31)
#
# If the year is outside the legal range, month and day are zero.

gregorian_easter() {
  help_args_func calendar_help $# || return
  local year=$(( 10#$1 ))
  local H I J L
  if (( year < 1900 || year > 2099 )) ; then
    month=0 day=0
  else
    (( H = (24 + 19*(year % 19)) % 30 ))
    (( I = H - H/28 ))
    (( J = (year + year/4 + I - 13) % 7 ))
    (( L = I - J ))
    (( month = 3 + (L + 40)/44 ))
    (( day = L + 28 - 31*(month/4) ))
  fi
  echo $year $month $day
}

# indictio: YEAR
#
# Calculates the Indiction of a given year.
#
#     Year (must be >0)
#
# Output: 
#
#     Indiction (1..15)

indiction() {
  help_args_func calendar_help $# || return
  echo $(( ( 10#$1 + 2 ) % 15 + 1 ))
}

# julian_period ABSYEAR
#
# Calculates the year in the Julian Period corresponding to a given
# year.
#
# Input parameter:
#
#    Year (must be in the range -4712..3267). The year 1 BC must be
#        given as 0, the year 2 BC must be given as -1, etc.
#
# Returns:
#
#    The corresponding year in the Julian period

JULIAN_YEARS_OFFSET=4713

julian_period() {
  help_args_func calendar_help $# || return
  local year=$(( 10#$1 ))
  echo $(( year + 4713 ))
}

# date_to_jdn YEAR MONTH DAY
#
# Calculates the Julian Day Number for a given date.
#
# Input parameters:
#
#     Year (must be > -4800). The year 1 BC must be given as 0, the
#         year 2 BC must be given as -1, etc.
#     Month (1..12)
#     Day (1..31)
#     Calendar style (JULIAN or GREGORIAN)
#
# Returns:
#
#     Julian Day Number

date_to_jdn() {
  help_args_func calendar_help $# 3 || return
  local year=$((  10#$1 ))
  local month=$(( 10#$2 ))
  local day=$((   10#$3 ))
  local style=`style_for_date "$@"`

  local a y m
  (( a = (14 - month)/12 ))
  (( y = year + 4800 - a ))
  (( m = month + 12*a - 3 ))
  if (( style == JULIAN_DATES )); then
    echo $(( day + (153*m+ + 2)/5 + y*365 + y/4 - 32083 ))
  else # GREGORIAN_DATES
    echo $(( day + (153*m + 2)/5 + y*365 + y/4 - y/100 + y/400 - 32045 ))
  fi
}

# GREGORIAN_START_JDAY=`date_to_jdn ${GREGORIAN_START_DATE//-/ }`

# jdn_to_date JDAY STYLE
#
# Calculates the date for a given Julian Day Number.
#
#     Julian Day Number
#     Calendar style (JULIAN or GREGORIAN)
#
#     if style is omitted, the Gregorian style is used if the JDAY
#     is larger or equal to GREGORIAN_START_JDAY
#
# Output
#
#     Address of year. The year 1 BC will be stored as 0, the year
#         2 BC will be stored as -1, etc.
#     Address of month (1..12)
#     Address of day (1..31)

jdn_to_date() {
  help_args_func calendar_help $# || return
  local jday=$(( 10#$1 ))
  local style=${2:-$NO_STYLE_DATES}    # default to gregorian calendar

  local b c d e m

  if (( style == NO_STYLE_DATES )) ; then
    style=$(( jday >= GREGORIAN_START_JDAY ? GREGORIAN_DATES : JULIAN_DATES ))
  fi
  if (( style == JULIAN_DATES )) ; then
    (( b = 0 ))
    (( c = jday + 32082 ))
  else  # GREGORIAN_DATES
    (( a = jday + 32044     ))
    (( b = (4*a + 3)/146097 ))
    (( c = a - (b*146097)/4 ))
  fi
  ((     d = (4*c + 3)/1461          ))
  ((     e = c - (1461*d)/4          ))
  ((     m = (5*e + 2)/153           ))
  ((   day = e - (153*m + 2)/5 + 1   ))
  (( month = m + 3 - 12*(m/10)       ))
  ((  year = b*100 + d - 4800 + m/10 ))
  echo $year $month $day
}

# week_number YEAR MONTH DAY
#
# Calculates the ISO 8601 week number (and corresponding year) for a given
# Gregorian date.
#
#     Year (must be >0)
#     Month (1..12)
#     Day
#
# Output:
#
#     week number (1..53) (also in 'week_num')
#     week year           (also in 'week_year')

week_number() {
  help_args_func calendar_help $# 3 || return
  local year=$((  10#$1 ))
  local month=$(( 10#$2 ))
  local day=$((   10#$3 ))

  local a b c s e f g d n

  if (( month <= 2 )) ; then
    (( a = year -1 ))
    (( b = a/4 - a/100 + a/400 ))
    (( c = (a-1)/4 - (a-1)/100 + (a-1)/400 ))
    (( s = b - c ))
    (( e = 0 ))
    (( f = day - 1 + 31*(month - 1) ))
  else
    (( a = year ))
    (( b = a/4 - a/100 + a/400 ))
    (( c = (a-1)/4 - (a-1)/100 + (a-1)/400 ))
    (( s = b - c ))
    (( e = s + 1 ))
    (( f = day + (153*(month-3)+2)/5 + 58 + s ))
  fi
  (( g = (a + b) % 7 ))
  (( d = (f + g - e) % 7 ))
  (( n = f + 3 - d ))
  if (( n < 0 )) ; then
    (( week_num = 53 - (g - s)/5 ))
    (( week_year = year - 1 ))
  elif (( n > 364 + s )) ; then
    (( week_num = 1 ))
    (( week_year = year + 1 ))
  else
    (( week_num = n/7 + 1  ))
    (( week_year = year ))
  fi
  echo $week_num $week_year
}

# vim: syntax=sh : ai : sw=2
