Houses
A house is a division of the sky as seen from one place at one instant. That is what separates it from a position: the planets say WHAT is happening and are the same for everybody looking up at that moment, but the houses say WHERE in a life, and they turn one degree every four minutes of clock time. The geometry underneath is old and published, some of it since the fifteenth century, and none of it needs a licence from anyone: what a commercial ephemeris sells is the positions, not the houses.
What separates one system from another is WHAT gets divided into twelve. Whole Sign divides the zodiac. Campanus divides the prime vertical, the great circle running from due east, through the zenith, to due west. Regiomontanus divides the celestial equator. Placidus does not divide a circle at all: it divides the TIME each degree takes to travel from the horizon to the meridian, which is why it has no closed formula and has to be solved by iterating.
Houses builds every one of its twenty-three systems from that geometry directly, as a great
circle cut with the ecliptic or a diurnal arc shared out, written with vectors rather than with a
closed formula copied out of a manual. It costs more code, and it buys something specific: each
system is written as what it actually is, and a definition written out can be checked against
itself, which is exactly how Koch was once caught measuring the wrong body's arc.
Computing a set of houses
use Astronomy\Houses; use Astronomy\HouseSystem; use Astronomy\Time; [$jdTT, $jdUt] = Time::fromClock(new DateTimeImmutable('1981-05-11 07:15:00', new DateTimeZone('UTC'))); $houses = Houses::calculate(HouseSystem::Placidus, $jdUt, latitude: 40.4165, geographicLongitude: -3.7026); printf("ascendant %.4f\nmidheaven %.4f\ncusp 1 %.4f\n", $houses->ascendant, $houses->midheaven, $houses->cusps[1]);
ascendant 85.9941 midheaven 332.0406 cusp 1 85.9941
calculate() takes a julian day in Universal Time, not Terrestrial Time, and that is not
interchangeable with what Ephemeris::position() wants: the houses depend on how far the Earth
has turned, and UT is what the civil clock measures. Passing $jdTT there instead is a mistake
that does not throw, and it is worth seeing how much it costs. Time::fromClock() returns both
scales for the same clock reading, about fifty seconds apart on this particular date:
$right = Houses::calculate(HouseSystem::Placidus, $jdUt, 40.4165, -3.7026); $wrong = Houses::calculate(HouseSystem::Placidus, $jdTT, 40.4165, -3.7026); printf("jdUt %.6f jdTT %.6f difference %.1f seconds\n", $jdUt, $jdTT, ($jdTT - $jdUt) * 86400); printf("ascendant with UT %.6f\n", $right->ascendant); printf("ascendant with TT %.6f\n", $wrong->ascendant); printf("difference %.1f arcseconds\n", ($wrong->ascendant - $right->ascendant) * 3600);
jdUt 2444735.802077 jdTT 2444735.802676 difference 51.7 seconds ascendant with UT 85.994148 ascendant with TT 86.198546 difference 735.8 arcseconds
Fifty-two seconds of the wrong clock move this particular ascendant by over twelve arcminutes, a fifth of a degree, on a chart that reads in degrees and minutes. How much it costs on any given chart depends on latitude and on how fast the ascendant happens to be running at that sidereal time (see Time for where that fifty-second gap, delta T, comes from).
Twenty-three systems
use Astronomy\HouseSystem; foreach (HouseSystem::cases() as $system) { printf("%-14s %s\n", $system->key(), $system->name()); }
placidus Placidus koch Koch regiomontanus Regiomontanus campanus Campanus porphyry Porphyry alcabitius Alcabitius topocentric Topocentric equal Equal houses whole-sign Whole sign vehlow Vehlow morinus Morinus meridian Meridian azimuthal Azimuthal krusinski Krusinski sripati Sripati carter Carter apc APC pullen-sd Pullen SD pullen-sr Pullen SR equal-mc Equal from the midheaven equal-aries Equal from Aries savard-a Savard-A sunshine Sunshine
Twelve of those are the classical ones; the other eleven are carried under the same letters as a
commercial engine so results can be cross-checked, and every case answers description() in one
line for what it actually divides:
foreach ([HouseSystem::Placidus, HouseSystem::WholeSign, HouseSystem::Sunshine] as $system) { printf("%s: %s\n", $system->name(), $system->description()); }
Placidus: Divides the time each degree takes to climb from the horizon to noon. The most widely used in western astrology. Whole sign: Every sign is a house. The oldest one known and the one hellenistic astrology uses. Sunshine: Divides in three the diurnal and nocturnal arcs of the Sun of that day and draws position circles through the points. From Bob Makransky, 1988.
Without a clock: Houses::fromArmc()
calculate() computes local sidereal time from the julian day and the longitude and then hands
off to a private builder; fromArmc() is that same builder with the sidereal time (the ARMC)
taken as a parameter instead. Everything a set of houses needs comes in through the ARMC, the
latitude and the obliquity, and the date enters through no other door: a house is geometry of the
turned sky and does not know what day it is.
use Astronomy\Houses; use Astronomy\HouseSystem; $armc = 123.456; $latitude = 40.4165; $obliquity = 23.4392; $worked = 0; foreach (HouseSystem::cases() as $system) { try { Houses::fromArmc($system, $armc, $latitude, $obliquity); $worked++; } catch (\RuntimeException $e) { echo $system->value, ' threw: ', $e->getMessage(), "\n"; } } echo $worked, ' of ', count(HouseSystem::cases()), " systems built with no ephemeris and no date\n";
sunshine threw: Sunshine divides the diurnal arc of the Sun: with no declination for it there are no houses to compute. 22 of 23 systems built with no ephemeris and no date
Sunshine is the exception because it shares out the Sun's own diurnal and nocturnal arcs, so it
needs the Sun's declination for that day: calculate() computes it for you with one extra
ephemeris call, and fromArmc() takes it as an optional parameter instead, which is what lets
Houses itself stay free of anything that touches an ephemeris.
The two doors sit behind the same private builder so they cannot diverge, and that is measured
rather than assumed: run against each other across twenty-three systems, six places and five
dates from 1655 to 2377, 7,943 of 8,040 cusps come back identical to the bit and the rest differ
by at most 4e-10 arcseconds, which is the unit round trip of deg2rad(rad2deg()) and not a
second computation.
Placing a body: houseOf(), houseWithLatitude(), housePosition()
houseOf() only needs a longitude, and it goes by the next cusp rather than the nearest one,
because the houses do not all measure the same: on a high-latitude chart with Placidus one house
can span eighty degrees and its neighbour ten, and "nearest cusp" would put half the chart in the
wrong house.
housePosition() is what a commercial ephemeris calls swe_house_pos, and it takes the body's
ecliptic latitude into account, which matters because a house in a quadrant system (Placidus,
Regiomontanus, Campanus...) is a division of the sky and not of the zodiac. A body sitting off the
ecliptic can be above the circle its bare longitude would place it under:
use Astronomy\Body; use Astronomy\Ephemeris; use Astronomy\Houses; use Astronomy\HouseSystem; use Astronomy\Time; [$jdTT, $jdUt] = Time::fromClock(new DateTimeImmutable('1981-05-11 07:15:00', new DateTimeZone('UTC'))); $houses = Houses::calculate(HouseSystem::Placidus, $jdUt, latitude: 40.4165, geographicLongitude: -3.7026); $saturn = Ephemeris::position(Body::Saturn, $jdTT); echo $houses->houseOf($saturn->longitude), "\n"; // by longitude alone echo $houses->houseWithLatitude($saturn->longitude, $saturn->latitude), "\n"; echo $houses->housePosition($saturn->longitude, $saturn->latitude), "\n"; echo $saturn->longitude, ' under a cusp at ', $houses->cusps[5], ', latitude ', $saturn->latitude, "\n";
4 5 5.0165278277963 183.5071189058 under a cusp at 185.01126338975, latitude 2.589119460589
This Saturn sits a degree and a half short of cusp five by longitude and over it by two and a
half degrees of latitude, so by longitude alone it is in house four and with latitude it is in
house five. housePosition() returns a number in [1, 13): the integer part is the house and
the fraction how much of it has been covered.
Not every system reads latitude the same way. The ecliptic systems (Equal, Whole Sign, Vehlow,
Porphyry, the two Pullens, Morinus) place a body by longitude alone, because the system only
exists over the ecliptic in the first place. The circle systems (Regiomontanus, Campanus,
Meridian, Carter, Azimuthal, Krusinski) put the body on the great circle that contains it and see
where that circle crosses the one being divided. And the time systems (Placidus, Koch,
Alcabitius, Topocentric, APC, Sunshine) measure what fraction of the matching arc the body has
covered. Ephemeris::position() and how longitude and latitude come out of it are covered in
Positions.
How fast a cusp moves: speeds()
A commercial ephemeris publishes cusp speeds and cites no formula for them beyond "daily
motions", so speeds() computes them as what they mean: the cusps a fraction of a second of ARMC
either side, differenced, times the rotation of the Earth.
$speeds = $houses->speeds(); printf("ascendant speed %.3f deg/day\n", $speeds->ascendant); printf("midheaven speed %.3f deg/day\n", $speeds->midheaven); printf("cusp 11 speed %.3f deg/day\n", $speeds->cusps[11]); printf("ARMC speed %.3f deg/day\n", $speeds->armc);
ascendant speed 341.830 deg/day midheaven speed 379.770 deg/day cusp 11 speed 447.958 deg/day ARMC speed 360.986 deg/day
That number is what turns "a badly noted birth time" into something concrete: an ascendant running at 283 degrees a day, Madrid's slowest, moves a degree in five minutes of clock time; one running at 624, Madrid's fastest, moves a degree in two and a half.
Differencing the cusps also turns out to be a sharper check of the cusp formulas than comparing cusp values is, and that is the reason the method earns its place independently of whatever it is checked against: two formulations can agree at every sampled point and still be different functions, and the derivative is what tells them apart. That is how a published Porphyry speed formula was caught hanging its correction on the wrong angle (its own cusp eleven is the midheaven plus a third of the quadrant, so its derivative has to move with the midheaven, and the published one moves with the ascendant instead): it agreed with its own cusps at every sampled point and disagreed by up to 2,662 degrees a day in its derivative.
The Gauquelin sectors
Placidus in ninths instead of thirds, numbered from the ascendant in the direction of diurnal motion:
$sectors = $houses->gauquelinSectors(); printf("sector 1 %.4f (= ascendant)\n", $sectors[1]); printf("sector 10 %.4f (= midheaven)\n", $sectors[10]); printf("sector 5 %.4f\n", $sectors[5]);
sector 1 85.9941 (= ascendant) sector 10 332.0406 (= midheaven) sector 5 32.1104
gauquelinSectorByRiseAndSet() gives a different definition rather than another way into the
same one: it finds where a body itself crosses the horizon and asks what fraction of that arc,
diurnal or nocturnal, has gone by, instead of dividing the semiarc of the body's zodiacal degree.
The two readings depart from each other by up to half a sector, about twenty minutes of the
Earth's turn, which is the whole reason the second one is worth having.
The four angles, and three more
printf("ascendant %.4f\n", $houses->ascendant); printf("descendant %.4f\n", $houses->descendant()); printf("midheaven %.4f\n", $houses->midheaven); printf("imum coeli %.4f\n", $houses->imumCoeli()); printf("vertex %.4f\n", $houses->vertex); printf("east point %.4f\n", $houses->eastPoint); printf("Koch co-asc %.4f\n", $houses->kochCoAscendant); printf("Munkasey co-asc %.4f\n", $houses->munkaseyCoAscendant); printf("polar ascendant %.4f\n", $houses->polarAscendant);
ascendant 85.9941 descendant 265.9941 midheaven 332.0406 imum coeli 152.0406 vertex 225.9792 east point 65.9245 Koch co-asc 50.5255 Munkasey co-asc 94.1622 polar ascendant 230.5255
The descendant and the imum coeli are never computed on their own: they are always the ascendant and the midheaven, mirrored. The vertex is different, and it does not always exist. It is where the prime vertical, the great circle through the zenith, cuts the ecliptic on the western side, and at the equator that circle passes through the celestial poles instead of cutting the ecliptic at a point:
$equator = Houses::calculate(HouseSystem::Placidus, $jdUt, 0.0, -3.7026); var_dump($equator->vertex);
NULL
Returning a number there would be worse than returning nothing: it is not an awkward edge case worth working around, it is that the definition does not reach the equator at all.
Past the polar circle
Four of the twenty-three systems divide a diurnal arc, and past the polar circle some degrees of the zodiac neither rise nor set, so there is no arc left to divide. Rather than invent one, they throw:
try { Houses::calculate(HouseSystem::Placidus, $jdUt, 78.2232, 15.6267); } catch (\RuntimeException $e) { echo $e->getMessage(), "\n"; } foreach ([HouseSystem::Placidus, HouseSystem::Koch, HouseSystem::Alcabitius, HouseSystem::Topocentric] as $system) { echo $system->value, ' fails at the poles: ', $system->failsAtThePoles() ? 'yes' : 'no', "\n"; }
The Placidus system is not defined at latitude 78.2: past the polar circle there are degrees of the zodiac that neither rise nor set, and this system divides diurnal arcs. placidus fails at the poles: yes koch fails at the poles: yes alcabitius fails at the poles: yes topocentric fails at the poles: yes
The rest keep working, because their definitions never depended on rising and setting in the first place:
foreach ([HouseSystem::Regiomontanus, HouseSystem::Campanus, HouseSystem::WholeSign] as $system) { $polarHouses = Houses::calculate($system, $jdUt, 78.2232, 15.6267); printf("%-13s ascendant %.3f\n", $system->value, $polarHouses->ascendant); }
regiomontanus ascendant 151.135 campanus ascendant 151.135 whole-sign ascendant 151.135
The trigger is arithmetic, not an observation: the altitude of a point at culmination is
90 - |latitude - declination|, and the midheaven's declination never exceeds the obliquity of
the ecliptic, so below 66.5 degrees of latitude the midheaven cannot sink and none of this ever
fires.
When it does, the ascendant is always straightened to the one that is rising rather than the one setting, in every system. Six systems go further and turn their whole wheel with it, moving the midheaven to the imum coeli and renumbering the houses clockwise, because their cusps hang from the meridian and need it the right way round to keep dividing the correct arc:
foreach (HouseSystem::cases() as $system) { if ($system->turnsWithTheMidheaven()) { echo $system->value, "\n"; } }
regiomontanus campanus topocentric apc savard-a sunshine
The rest, Porphyry among them, divide the arc running from the midheaven to the ascendant directly, and that arc is already the right way round once the ascendant alone has been straightened; turning it a second time would undo the fix rather than complete it.
The sidereal copy
sidereal() returns the same houses with every longitude minus an ayanamsa, and the geometry
that places a body with latitude stays written in tropical terms underneath: a call to
housePosition() on a sidereal Houses converts the longitude it is given back to tropical
before asking, so a planet never changes house by changing zodiac.
use Astronomy\Ayanamsa; $ayanamsa = Ayanamsa::Lahiri->value($jdTT); $sidereal = $houses->sidereal($ayanamsa); $saturnTropical = 183.5071189058; $saturnSidereal = fmod($saturnTropical - $ayanamsa + 360, 360); printf("tropical housePosition %.4f\n", $houses->housePosition($saturnTropical, 2.589119460589)); printf("sidereal housePosition %.4f\n", $sidereal->housePosition($saturnSidereal, 2.589119460589));
tropical housePosition 5.0165 sidereal housePosition 5.0165
Whole Sign and Equal from Aries are the exception: their cusps are nailed to the boundaries of the signs by definition, so shifting them by the ayanamsa would leave them sitting a few degrees into a sign rather than at its start, and they are rebuilt over the sidereal signs instead. The sidereal zodiac itself, its forty-three ayanamsas and what each one anchors to, is covered in The sidereal zodiac.
Two traps worth knowing
At the equator, the systems that divide the sky by completely different routes collapse into the same cusps. The equator, the prime vertical and every diurnal arc line up there, so six systems that reach a cusp by six unrelated constructions have nowhere left to disagree; if one of them drifts even slightly, that one is wrong. It is the strongest check this class has, because it does not compare against anything external:
$systems = [HouseSystem::Placidus, HouseSystem::Koch, HouseSystem::Regiomontanus, HouseSystem::Campanus, HouseSystem::Alcabitius, HouseSystem::Topocentric]; foreach ($systems as $system) { $houses = Houses::calculate($system, $jdUt, 0.0, -3.7026); printf("%-13s cusp 11 = %.6f\n", $system->value, $houses->cusps[11]); } $porphyry = Houses::calculate(HouseSystem::Porphyry, $jdUt, 0.0, -3.7026); printf("%-13s cusp 11 = %.6f (divides the ecliptic arc, not a circle of the sky)\n", 'porphyry', $porphyry->cusps[11]);
placidus cusp 11 = 4.395501 koch cusp 11 = 4.395501 regiomontanus cusp 11 = 4.395501 campanus cusp 11 = 4.395501 alcabitius cusp 11 = 4.395501 topocentric cusp 11 = 4.395501 porphyry cusp 11 = 3.335198 (divides the ecliptic arc, not a circle of the sky)
Porphyry is not among them and that is not a fault in the check: it divides the arc of the ecliptic between the angles rather than a circle of the sky, so there is no reason for it to land on the same number.
deg2rad(rad2deg()) is not the identity, and it is the reason calculate() and fromArmc()
do not come back bit for bit identical even though they share one builder. fromArmc() takes
the obliquity in degrees, which is what a caller has; the engine works in radians internally, and
converting one way and back is not free:
use Astronomy\Time; $survived = 0; $total = 0; $worst = 0.0; for ($year = 1600; $year <= 2400; $year += 4) { $eps = Time::trueObliquity(Time::centuries(Time::civilJulianDay($year, 6, 15))); $total++; $roundTripped = deg2rad(rad2deg($eps)); if ($roundTripped === $eps) { $survived++; } else { $worst = max($worst, abs($roundTripped - $eps)); } } printf("%d of %d survive the round trip intact, worst drift %.2e radians\n", $survived, $total, $worst);
130 of 201 survive the round trip intact, worst drift 5.55e-17 radians
Under a third of the obliquities tested come back changed at all, and the worst drift converts to about a hundredth of a nanoarcsecond, ten orders of magnitude below the 0.18 arcseconds the whole engine differs from JPL Horizons by (see Precision). It costs nothing in the sky. It is worth knowing only so that nobody goes looking for a bug where the two doors differ by 4e-10 arcseconds and finds a unit conversion instead.