6
\$\begingroup\$

I am working on a batch file for doing date/time maths. One challenge is to determine the (proleptic) Gregorian year from the given number of days that have elapsed since day zero (that is, 1st of January, 0 AD, 00:00:00 o'clock), particularly when regarding leap years.

As per definition, a year is considered as leap year when its number is a multiple of four, but it is not when its number is a multiple of 100, unless its number is a multiple of 400. This results in an average duration of a year of 365 97/400 = 365.2425 days.

However, simply dividing the number of days by 365.2425 and doing some rounding does not result in the correct result for the year. For example, the number of days 695421 represents the 1st of January 1904, so the expected year is 1904, but performing an integer division like floor(695421 / 365.2425) results in 1903.

Therefore I developed an iterative algorithm that loops through years, beginning at year 0, computing the number of days per year regarding leap years, subtracting that number from the given number of days and incrementing a year counter, until the remaining number of days is less than the number of days of the currently iterated year, upon which the loop is left.

Here is the code of the routine called :CALC_YEARS -- including numerous explanatory remarks (the // and /*/*/ styles are used just for nice syntax highlighting here on this site). Note that the :MAIN_LOOP section is just there for convenient execution, so it is not subject to review:

@echo off
setlocal EnableExtensions DisableDelayedExpansion

:MAIN_LOOP
echo/
set "NUMDAYS="
set /P NUMDAYS="Number of days: "
if not defined NUMDAYS goto :QUIT_LOOP
call :CALC_YEARS YEAR YEARDAYS REST %NUMDAYS%
echo Gregorian year: %YEAR% ^(consisting of %YEARDAYS% days^)
echo Remaining days: %REST%
goto :MAIN_LOOP
:QUIT_LOOP

endlocal
exit /B


:CALC_YEARS  rtn_year  rtn_year_days  rtn_rest_days  val_days
::/*
::This routine calculates the (proleptic) Gregorian year from the given number
::of days elapsed since day zero.
::Parameters:
::  rtn_year        variable that holds the returned year
::  rtn_year_days   variable that holds the number of days of the resulting year
::  rtn_rest_days   variable that holds the remaining days
::  val_days        number of days to convert to year
::*/
setlocal DisableDelayedExpansion
rem // Validate arguments and convert them to numbers:
set "DAYS=%~4"
if not defined DAYS (
    >&2 echo ERROR: too few arguments given!
    exit /B 1
)
set /A "DAYS+=0"
if %DAYS% LSS 0 (
    >&2 echo ERROR: number of days out of range!
    exit /B 1
)
if %DAYS% GEQ 3652425 (
    >&2 echo ERROR: number of days out of range!
    exit /B 1
)
rem // Estimate the year (by dividing days by 365):
setlocal EnableDelayedExpansion
set /A "YLIM=DAYS/365"
rem // Reset year counter and loop through years:
set /A "YEAR=0, YDAY=365+1"
for /L %%I in (0,1,%YLIM%) do (
    rem /* Determine number of days of the iterated year
    rem    (hence checking whether it is a leap year): */
    set /A "Y004=%%I/4*4, Y100=%%I/100*100, Y400=%%I/400*400"
    set /A "YDAY=365"
    if %%I EQU !Y400! (
        set /A "YDAY+=1"
    ) else if %%I EQU !Y004! if %%I NEQ !Y100! (
        set /A "YDAY+=1"
    )
    rem /* Check whether the given number of days is greater than the number
    rem    of days of the iterated year (thus whether enough days are left): */
    if !DAYS! GEQ !YDAY! (
        rem /* Enough days left, so subtract days of iterated year from days
        rem    and increment the year counter: */
        set /A "DAYS-=YDAY, YEAR+=1"
    ) else (
        rem // Not enough days left, hence leave loop:
        goto :STOP_YEAR
    )
)
:STOP_YEAR
rem // Return resulting values and terminate routine:
(
    endlocal
    endlocal
    set "%~1=%YEAR%"
    set "%~2=%YDAY%"
    set "%~3=%DAYS%"
)
exit /B

Besides general coding styles, I am particularly looking for ways to improve the performance of the :CALC_YEARS routine. Any suggestions concerning completely different algorithms are welcome, especially in case of non-iterative ones, as long as they are pure solutions.

As soon as I find a better approach for this myself (and I will, hopefully!), I am going to post it here as well (likely as an answer).

\$\endgroup\$
1
\$\begingroup\$

Finally I found a much better algorithm for deriving the year from the given number of days.
It is non-iterative and basically operates in three phases:

  1. The number of days is divided by 365.2425 first, ignoring the resulting fractional part, in order to estimate the year (see the question to learn where the divisor comes from). The number of days of that year are determined for later use.
  2. The estimated year is converted back to a number of days, regarding leap years.
  3. The back-converted number of days is compared with the original one; depending on the difference, the estimated year number is decremented, incremented or kept as is. The deviation between the estimated and the actual year should never be greater than one in either direction, so no iterative procedure seems to be required.

So here is the improved code (using the same coding styles as for the original script though):

@echo off
setlocal EnableExtensions DisableDelayedExpansion

:MAIN_LOOP
echo/
set "NUMDAYS="
set /P NUMDAYS="Number of days: "
if not defined NUMDAYS goto :QUIT_LOOP
call :CALC_YEARS YEAR YEARDAYS REST %NUMDAYS%
echo Gregorian year: %YEAR% ^(consisting of %YEARDAYS% days^)
echo Remaining days: %REST%
goto :MAIN_LOOP
:QUIT_LOOP

endlocal
exit /B


:CALC_YEARS  rtn_year  rtn_year_days  rtn_rest_days  val_days
::/*
::This routine calculates the (proleptic) Gregorian year from the given number
::of days elapsed since day zero.
::Parameters:
::  rtn_year        variable that holds the returned year
::  rtn_year_days   variable that holds the number of days of the resulting year
::  rtn_rest_days   variable that holds the remaining days
::  val_days        number of days to convert to year
::*/
setlocal DisableDelayedExpansion
rem // Validate arguments and convert them to numbers:
set "DAYS=%~4"
if not defined DAYS (
    >&2 echo ERROR: too few arguments given!
    exit /B 1
)
set /A "DAYS+=0"
if %DAYS% LSS 0 (
    >&2 echo ERROR: number of days out of range!
    exit /B 1
)
if %DAYS% GEQ 3652425 (
    >&2 echo ERROR: number of days out of range!
    exit /B 1
)
rem // Estimate the year (by dividing days by 365.2425):
setlocal EnableDelayedExpansion
set /A "YEAR=DAYS*400/(3652425*4/100)"
rem /* Determine number of days of the estimated year
rem    (hence checking whether it is a leap year): */
set /A "Y004=YEAR/4*4, Y100=YEAR/100*100, Y400=YEAR/400*400"
set /A "YDAY=365"
if %YEAR% EQU %Y400% (
    set /A "YDAY+=1"
) else if %YEAR% EQU %Y004% if %YEAR% NEQ %Y100% (
    set /A "YDAY+=1"
)
rem /* Convert estimated year back to number of days since day zero
rem    (the `if` overcomes an issue with negative dividends): */
if %YEAR% EQU 0 (
    set /A "BACK=0"
) else (
    set /A "BACK=1"
)
set /A "BACK+=365*YEAR+(YEAR-1)/4-(YEAR-1)/100+(YEAR-1)/400"
rem /* Check the difference between the back-converted number of days
rem    with the original number of days: */
set /A "DIFF=BACK-DAYS"
if %DIFF% LEQ -%YDAY% (
    rem /* Back-converted number of days is more than a year less than
    rem    the original number of days, so add the days of that year: */
    set /A "YEAR+=1, BACK+=YDAY, YDAY=365"
    set /A "Y004=YEAR/4*4, Y100=YEAR/100*100, Y400=YEAR/400*400"
    if !YEAR! EQU !Y400! (
        set /A "YDAY+=1"
    ) else if !YEAR! EQU !Y004! if !YEAR! NEQ !Y100! (
        set /A "YDAY+=1"
    )
) else if %DIFF% GTR 0 (
    rem /* Back-converted number of days is greater than the original
    rem    number of days, so subtract the number of days of the previous year: */
    set /A "YEAR-=1, YDAY=365"
    set /A "Y004=YEAR/4*4, Y100=YEAR/100*100, Y400=YEAR/400*400"
    if !YEAR! EQU !Y400! (
        set /A "YDAY+=1"
    ) else if !YEAR! EQU !Y004! if !YEAR! NEQ !Y100! (
        set /A "YDAY+=1"
    )
    set /A "BACK-=YDAY"
)
rem // Compute the remaining days not consumed by the year conversion:
set /A "DAYS-=BACK"
rem // Return resulting values and terminate routine:
(
    endlocal
    endlocal
    set "%~1=%YEAR%"
    set "%~2=%YDAY%"
    set "%~3=%DAYS%"
)
exit /B
\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.