4
\$\begingroup\$

I am attempting to write a function to calculate a user's Chinese zodiac sign based on his or her birthday for Drupal.

I originally had a function for calculating based on the Gregorian calendar (1924 = Rat, 1925 = Ox). This was easy to write, but several of my users have complained that the Chinese zodiac is actually based on the lunar calendar, and a list of relevant start and end dates can be found here.

I started writing the code as follows. The big problem is that I have an if, else if, and else per year, and at this rate I will have to manually code 100 years worth of birthdays. On the other hand, I'm unsure of how to use a loop to iterate over this, because the day the lunar year starts and ends each year is different. Any insight on how to make this more efficient would be highly appreciated.

function mymodule_calculate_chinese_zodiac($birthdate) {
  // Drupal MySQL data looks like this: 1981-07-30 00:00:00
  $year = substr($birthdate, 0, 4);
  $month = substr($birthdate, 5, 2);
  $day = substr($birthdate, 8, 2);

  if ($year == 1924) {
    if ($month > 2) {
      // Rat
      $sign = "1";
    }
    else if (($month == 2) && ($day > 4) {
      $sign = "1";
    }
    else {
      // Pig
      $sign = "12";
    }
  }
  else if ($year == 1925) {
    if ($month > 1) {
      // Ox
      $sign = "2";
    }
    else if (($month == 1) && ($day > 23)) {
      $sign = "2";
    }
    else {
      $sign = "1";
    }
  }
  else if ($year == 1926) {
    if ($month > 2) {
      // Tiger
      $sign = "3";
    }
    else if ($month == 2) && ($day > 12) {
      $sign = "3";
    }
    else {
      $sign = "2";
    }
  }
  //All years until 2020
  return $sign;
}
\$\endgroup\$
2
\$\begingroup\$

There's a few ways this could be improved:

  1. Use DateTime's rather than fiddling with individual date parts.
  2. Use an array to store the edges between each Chinese year.
  3. Search through the array until you find a match then return the index of that element mod 12. (plus 1 since you want values ranging from 1 - 12).

I'd recommend something like this:

$target = new DateTime($birthdate);

$dates = array (
    new DateTime('2024-02-05'),
    new DateTime('2025-01-24'),
    new DateTime('2026-02-13'),
    new DateTime('2027-02-02'),
    // all years until 2020
);

foreach ($array as $i => $value) {
   $diff = $target->diff($value);
   if ($diff->invert == 0) {
      return (string)(($i % 12) + 1);
   }
}

According to the documentation, as of PHP 5.2.2 you can do this:

foreach ($array as $i => $value) {
   if ($target > diff) {
      return (string)(($i % 12) + 1);
   }
}

You can also use a binary search (since this data is sorted) to speed up the search. My PHP is a bit rusty, so some one else might be able to improve this further, but I think it would look like this:

$min = 0;
$max = count($dates) - 1;
while ($max >= $min) {
  $mid = (int)(($max + $min) / 2);
  $value = $dates[$mid];
  if ($target < $value) {
    $max = $mid - 1;
  }
  else if ($target > $value) {
    $min = $mid + 1;
  }
  else {
    break;
  }
}

if ($target > $value) {
    $mid++;
}

return (string)(($mid % 12) + 1);
\$\endgroup\$
  • \$\begingroup\$ Awesome answer. I chose this one because it required by far the least amount of work to reformat the dates. \$\endgroup\$ – Patrick Kenny Jun 14 '13 at 0:44
3
\$\begingroup\$

To start, lets look for something that we can loop through. From your link to relevant dates, we can see that the associated animals loop through 12 animals, in order:

Rat
Ox
Tiger
Rabbit
Dragon
Snake
Horse
Goat
Monkey
Rooster
Dog
Pig

After [Pig], it restarts again with [Rat]. So let's put these into an associative array, with the associated element:

$assocs = array(
    "Rat"     =>  "Yang Wood", 
    "Ox"      =>  "Yin Wood", 
    "Tiger"   =>  "Yang Fire",
    "Rabbit"  =>  "Yin Fire", 
    "Dragon"  =>  "Yang Earth", 
    "Snake"   =>  "Yin Earth", 
    "Horse"   =>  "Yang Metal", 
    "Goat"    =>  "Yin Metal", 
    "Monkey"  =>  "Yang Water", 
    "Rooster" =>  "Yin Water", 
    "Dog"     =>  "Yang Wood", 
    "Pig"     =>  "Yin Wood" )
);

Now, to determine the sexagenary cycle of a gregorian year, the algorightm is as follows:

sCycle = (grego - 3) - (60 X ([(grego - 3)] / 60))

So, let's translate that to a PHP function:

function sexagenaryToGregorian($gYear){
    return ($gYear - 3) - (60 * (floor(( $gYear - 3) / 60)));
}

Now, we know that the Sexagenary cycle consists of 60 periods, thus, to determine what animal and element a certain year matches:

$bdayYear = 1989;
$sYear = sexagenaryToGregorian($bdayYear);
// $sYear == 6

For birth year 1989, sYear should be period 6. Now, let's generate a function that will hold the animal-element array above ($assoc) and contain two loops. The outer loop will decrement through the ceiling of the sexagenarian cycle divided by 12, and the inner loop is a foreach() over the animal-element array. This method is accurate all the way back to 4AD.

function generateAnimal($sexaCycle){
    $assocs = array(
        "Rat"     =>  "Yang Wood", 
        "Ox"      =>  "Yin Wood", 
        "Tiger"   =>  "Yang Fire",
        "Rabbit"  =>  "Yin Fire", 
        "Dragon"  =>  "Yang Earth", 
        "Snake"   =>  "Yin Earth", 
        "Horse"   =>  "Yang Metal", 
        "Goat"    =>  "Yin Metal", 
        "Monkey"  =>  "Yang Water", 
        "Rooster" =>  "Yin Water", 
        "Dog"     =>  "Yang Wood", 
        "Pig"     =>  "Yin Wood"
    );

    $i = 1;
    while(ceil($sexaCycle / 12) > 0){
        foreach($assocs as $animal => $element){
            if($i == $sexaCycle)
                return array($animal => $element);
            $i++;
        }
    }
}

Let's give it a few runs:

// Let's find the result for the year 2041
$result = generateAnimal(sexagenaryToGregorian(2041));
print_r($result); // Array ( [Rooster] => Yin Water )

// Let's find the result for the year 4
$result = generateAnimal(sexagenaryToGregorian(4));
print_r($result); // Array ( [Rat] => Yang Wood )

// Let's find the result for the year 1989
$result = generateAnimal(sexagenaryToGregorian(1989));
print_r($result); // Array ( [Snake] => Yin Earth )

Looks good to me! Now, if you want to include the dates, that's when you may want to start thinking about just using a database as a static lookup table.

\$\endgroup\$
  • 1
    \$\begingroup\$ Unfortunately, I do need the dates. But this is a really informative answer in general, so +1. \$\endgroup\$ – Patrick Kenny Jun 14 '13 at 0:45
  • \$\begingroup\$ @PatrickKenny: No problem :) thanks for the +1 \$\endgroup\$ – jsanc623 Jun 14 '13 at 13:41
  • 1
    \$\begingroup\$ There's an error in this code. The animal years are on a twelve year cycle, but the elements are only on a ten year cycle. You can't match from one to the other. You can say that in the first year of a sixty year cycle, they will always have the same pairing. But the first year won't match the thirteenth. So 2041 should be Rooster / Yin Metal (not Water). \$\endgroup\$ – Brythan Oct 31 '14 at 3:00
  • 1
    \$\begingroup\$ @Brythan good catch (and a good reason for why unit tests are important :)) \$\endgroup\$ – jsanc623 Oct 31 '14 at 3:01
2
\$\begingroup\$

My PHP is a bit rusty, but I think you could use two-dimensional arrays, maybe a structure like this:

$dates = array(  "1924" => array( "month" => 2, "day" => 4),
                 "1925" => array( "month" => 2, "day" => 23),
                 ... );

$signs = array( "1924" => array ( "beforeSign" => "2", "afterSign" => "1"),
                 ...);

if (($month == $dates[$year]["month"] && $day <  $dates[$year]["day"]) 
     || ($month < $dates[$year]["month"]))
{
  $sign = $signs["beforeSign"];
}
else
{
  $sign = $signs["afterSign"];
}

An ideal solution would not have any arrays with dates; the start and end date of the Chinese new year would be calculated on the fly. As I have not had a chance to look into how to do those calculations, I am showing a simple solution with look-up lists in arrays.

It's possible that the place you found the list of dates might also have the formulae necessary for doing on-the-fly calculations...

\$\endgroup\$
  • \$\begingroup\$ This means that they would have to maintain a large array of years, months and dates \$\endgroup\$ – jsanc623 Jun 13 '13 at 18:37
  • 2
    \$\begingroup\$ @jsanc623: Yes, your sexagenaryToGregorian is good, but you did not handle the problem of the Chinese year beginning on a date that is not January 1st, which is what the OP was original asking for. There is probably a way to calculate this so that lookup tables aren't needed at all, I just haven't had time to research that. \$\endgroup\$ – FrustratedWithFormsDesigner Jun 13 '13 at 18:43
  • \$\begingroup\$ Agreed - my code only works for numerical year and doesn't take into account months / dates. \$\endgroup\$ – jsanc623 Jun 13 '13 at 19:00

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.