2018-03-26

Age - hard to calculate


The concept of age seems quite simple.
Right now I'm 42 years old.
I can be more precise and define it as 42 years, some months and several days.
And at a given date I will be 43 years old, and months and date are reset to 0.
Unfortunately it's hard to calculate with these numbers.
For those who wants to go into detail I'd recommend ISO 8601 Data elements and interchange formats. Unfortunately I don't have access to this document.
Another good reason is the explanation for CPANs DateTime.pm module.
I tried a slightly different approach:
Define the age as integer and fractional part, where the integer part is the age in years as we are used to it, and the fractional part is the  number of days already passed divided by the total days in the current year. 
This sounds quite simple, but it has some complicated effects due to leap years. So I wrote a little package ( see below) to handle these effects.
The Package is called BX_YEAR_CALC and has 2 functions:
DIFFERENCE (year1 date, year2 date) returns the AGE.
ADDITION (year1 date, diff number) returns the date at a specific AGE.

Here are some examples:

SQL> alter session set nls_date_format='YYYY-MM-DD HH24:MI:SS';

SQL> select bx_year_calc.difference('2004-02-29 12:00:00', '2005-03-01 11:59:00') diff from dual;

      DIFF
----------
1.00136796

SQL> select bx_year_calc.addition ('2004-02-29 12:00:00',  1.00136796042617960426179604261796042618) d from dual;

D
-------------------
2005-03-01 11:59:00

SQL> select bx_year_calc.addition ('2005-03-01 11:59:00', -1.00136796042617960426179604261796042618) d from dual;

D
-------------------
2004-03-01 00:00:00


You can see in these examples there are situations - especially around leap years and Feb 29th - where the calculation goes quite wrong. My implementation tries to reduce this effect to a minimum.

Feel free to play with dates and ages, any comments are very welcome.


create or replace PACKAGE BX_YEAR_CALC AS 

/*  2018-03-25 - berx - initial - martin.a.berger@gmail.com
    BX_YEAR_CALC - to calculate DATES in units or YEARS
                   equal idea as  DATE1 - DATE2 returns a number,
                   where the integer part are full days and fractional part  reflects remaining hours, minutes and seconds
                   
                   As some years has 365 or 366 days, the last year can have different number of total days, 
                   so the fraction must be calculated based on this.
                   Only the number of days of the last year (based on the begin) is calculated.
                   
                   Be aware: there are situations with leap years or gregorian calendar where results are not intuitive.
                   If you find a bette rimplementation for a given situation, let's discuss

*/


/* returns the "age in years" between the 2 parameters. 
   integer is quite obvious - it's the years between the start date and the same day (if it exists) in target year.
   fraction is remaining days divided by days in the last year
*/
  FUNCTION DIFFERENCE(YEAR1 date default sysdate,
                      YEAR2 date default sysdate)
    RETURN number;

/* adds (or substracts - if number is negative) a "age in years" as defined by DIFFERENCE to a given date
   for addition, first the integer part is added and then the fraction - based on days in last year.
   for subtraction, first the fraction is removed (to be more aligned to addition) and the the integer
   */
  FUNCTION ADDITION (YEAR1 date default sysdate,
                DIFF number default 0)
    RETURN date;

/* returns the next "existing" date to a given "old-date" and a specific other year ("new year")
   this handles the situation similar to "today is 2004-02-29. what's the date a year ago?
   to avoid ORA-01840: input value not long enough for date format
   this function searches the next day "above" (with incr => default 1) or "below" (with incr => -1)
   other values for incr are not defined.
   */
    FUNCTION next_real_date (
        new_year VARCHAR2,
        old_date DATE,
        incr number default 1
    )
    return date;

END BX_YEAR_CALC;

CREATE OR REPLACE PACKAGE BODY bx_year_calc AS

    c_debug          NUMBER := 0;

    PROCEDURE debug (
        message VARCHAR2
    )
        AS
    BEGIN
        IF
            c_debug > 0
        THEN
            dbms_output.put_line(message);
        END IF;
    END debug;

    FUNCTION next_real_date (
        new_year   VARCHAR2,
        old_date   DATE,
        incr       NUMBER DEFAULT 1
    ) RETURN DATE AS

        year2_year    NUMBER;
        return_date   DATE; -- the highest full year related to YEAR1  which is smaller than YEAR2  
        year_diff     NUMBER;
        leap_help     BOOLEAN := false; -- do we hit a leap-year issue and need to sort out things?
        leap_count    NUMBER := 0; -- how many days to add to fix leap issue
        date_not_valid EXCEPTION;
        PRAGMA exception_init ( date_not_valid,-1839 );
    BEGIN
      if NOT abs(incr)=1 then
      raise_application_error( -20001, ' incr msut be +1 or -1 ' );
      end if;
        WHILE NOT leap_help LOOP
            BEGIN
                IF
                    leap_count = 0
                THEN
                    return_date := TO_DATE(new_year || '-' || TO_CHAR(old_date,'MM-DD HH24:MI:SS'),'YYYY-MM-DD HH24:MI:SS');

                    leap_help := true;
                ELSE
                    return_date := TO_DATE(new_year || '-' || TO_CHAR(old_date + leap_count,'MM-DD'),'YYYY-MM-DD');

                    leap_help := true; -- this happens only if NO exception is thrown
                END IF;

            EXCEPTION
                WHEN date_not_valid -- ORA-01839: date not valid for month specified
                 THEN
                    leap_count := leap_count + incr;

                    IF -- just a safety in case anything goes wrong 
                        abs(leap_count) > 33
                    THEN
                        leap_count := 1 / 0;
                    END IF;
            END;
        END LOOP;

        RETURN return_date;
    END next_real_date;

    FUNCTION difference (
        year1   DATE,
        year2   DATE DEFAULT SYSDATE
    ) RETURN NUMBER AS

        days_lastyear    NUMBER; -- how many days the last year has
        fract_lastyear   NUMBER; -- remaining part as fraction of a year
        year2_year       NUMBER;
        hi_year_lo       DATE;   -- the highest full year related to YEAR1  which is smaller than YEAR2  
        hi_year_hi       DATE;   -- the smallest full year related to YEAR1  which is higher than YEAR2  
        year_diff        NUMBER;
        leap_help        BOOLEAN := false; -- do we hit a leap-year issue and need to sort out things?
        leap_count       NUMBER := 0;      -- how many days to add to fix leap issue
        date_not_valid EXCEPTION;
        PRAGMA exception_init ( date_not_valid,-1839 );
    BEGIN
        IF
            year1 > year2
        THEN
            RETURN difference(year2,year1);
        ELSE
            year2_year := to_number(TO_CHAR(year2,'YYYY') );
            hi_year_lo := next_real_date(TO_CHAR(year2_year,'0000'),year1);
            hi_year_hi := next_real_date(TO_CHAR(year2_year + 1,'0000'),year1);
      
      -- how many days are in the last year to take care of
            days_lastyear := hi_year_hi - hi_year_lo;
            fract_lastyear := ( year2 - hi_year_lo ) / days_lastyear;

            year_diff := to_number(TO_CHAR(hi_year_lo,'YYYY') ) - to_number(TO_CHAR(year1,'YYYY') );

            RETURN year_diff + fract_lastyear;
        END IF;
    END difference;

    FUNCTION addition (
        year1   DATE DEFAULT SYSDATE,
        diff    NUMBER
    ) RETURN DATE AS

        year_year        NUMBER;
        year_diff        NUMBER;
        days_lastyear    NUMBER; -- how many days the last year has
        hi_year_lo       DATE;   -- the highest full year related to YEAR1  which is smaller than YEAR2  
        hi_year_hi       DATE;   -- the smallest full year related to YEAR1  which is higher than YEAR2  
        lo_year_lo       DATE;   -- a temp "lowest date" - only to keep the calculation somehow readable
        days_jump_back   NUMBER := 15; -- a number bigger than "365 + (15-4)" - google gregorian calendar reform 1582 & 4 October 1582 
        days_offset      NUMBER;
        leap_help        BOOLEAN := false; -- do we hit a leap-year issue and need to sort out things?
        leap_count       NUMBER := 0;      -- how many days to add to fix leap issue
        date_not_valid EXCEPTION;
        PRAGMA exception_init ( date_not_valid,-1839 );
    BEGIN
        IF
            diff < 0
        THEN
          -- first let's substract only the fraction of diff
            year_year := to_number(TO_CHAR(year1,'YYYY') );
            hi_year_lo := next_real_date(TO_CHAR(year_year - 1,'0000'),year1);
            days_lastyear := year1 - hi_year_lo;
            hi_year_hi := hi_year_lo + ( days_lastyear * ( diff - trunc(diff) ) );
          -- now the easy part - years
            RETURN next_real_date(TO_CHAR(to_number(TO_CHAR(hi_year_hi,'YYYY') ) + trunc(diff) + 1,'0000'),hi_year_hi);
          -- trunc(diff)+1 is required, as we substracted "-1" in the calculation of "hi_year_lo" above already

        ELSIF diff = 0 THEN
            RETURN year1;
        ELSE
            year_year := to_number(TO_CHAR(year1,'YYYY') ) + trunc(diff);
            hi_year_lo := next_real_date(TO_CHAR(year_year,'0000'),year1);
            hi_year_hi := next_real_date(TO_CHAR(year_year + 1,'0000'),year1);
-- 
            days_lastyear := hi_year_hi - hi_year_lo;
            RETURN hi_year_lo + ( days_lastyear * ( diff - trunc(diff) ) );
        END IF;
    END addition;

END bx_year_calc;

Keine Kommentare: