"""
Astrological house system calculations for libephemeris.

Implements 19 house systems compatible with Swiss Ephemeris:
- Placidus (P): Most common, time-based, fails at polar latitudes
- Koch (K): Birthplace system, similar to Placidus
- Porphyrius (O): Space-based trisection
- Regiomontanus (R): Medieval rational system
- Campanus (C): Prime vertical system
- Equal (A/E): Equal 30° divisions from Ascendant
- Whole Sign (W): Whole zodiac signs from Ascendant sign
- Meridian (X): Equatorial meridian divisions
- Azimuthal/Horizontal (H): Based on horizon
- Polich-Page (T): Topocentric system
- Alcabitus (B): Ancient Arabic system
- Morinus (M): Equatorial divisions
- Krusinski-Pisa (U): Modified Regiomontanus
- Gauquelin (G): Sector system
- Vehlow (V): Equal from midpoint
- APC (houses): Astronomical Planetary Cusps
- Carter Poli-Equatorial (F)
- Pulhemus (L)
- Sripati (S): Divide quadrants equally

Main Functions:
- swe_houses(): Calculate house cusps and angles (ASCMC)
- swe_houses_ex(): Extended version with sidereal support
- swe_house_pos(): Find which house a point is in
- swe_house_name(): Get house system name

Precision Notes:
Core Calculations (Phase 1 improved):
- GAST (sidereal time): ~0.001 sec = ~0.015 arcsec (Skyfield IAU SOFA)
- Obliquity: ~0.01 arcsec (Laskar 1986 + IAU 2000B nutation)
- Vertex: Exact at equator, ~0.001° elsewhere (rigorous limiting formula)
- Iterative systems (Placidus/Koch): ~0.00036 arcsec convergence (1e-7°)
- Non-iterative systems: Limited by obliquity/GAST precision (~0.01 arcsec)

Expected Accuracy by System:
- Simple (Equal, Whole Sign, Vehlow): Exact (no astronomical calculations)
- Geometric (Porphyry, Morinus, Meridian): ~0.01-0.1 arcsec
- Iterative (Placidus, Koch): ~0.001-0.01° (a few arcseconds)
- Complex (Campanus, Regiomontanus, Topocentric): ~0.01°
- Horizontal: ~0.01° (with convergence fallback to Porphyry)

Comparison with Swiss Ephemeris:
- Typical agreement: 0.001-0.1° depending on system and location
- Test suite validates against 130+ reference cases with tolerances 0.1-1.0°

Polar Latitudes:
- Placidus, Koch undefined > ~66° latitude (circumpolar ecliptic points)
- Automatic fallback to Porphyry when iteration fails
- Equal/Whole Sign work at all latitudes

Algorithm Sources:
- Placidus: Time divisions of diurnal/nocturnal arcs
- Regiomontanus: Equator trisection projected to ecliptic
- Campanus: Prime vertical trisection
- Equal: Simple 30° additions
- Algorithms from Meeus "Astronomical Algorithms", Swiss Ephemeris documentation

References:
- Meeus "Astronomical Algorithms" 2nd Ed., Ch. 13 (coordinate systems)
- Swiss Ephemeris documentation (house systems)
- Hand "Astrological Houses" (comprehensive house treatise)
- IERS Conventions 2003 (nutation models)
"""

from __future__ import annotations

import math
from typing import List, Optional, Tuple, Union, overload
from .constants import *
from .constants import (
    SEFLG_SIDEREAL,
    SEFLG_SPEED,
    SEFLG_EQUATORIAL,
    SE_SUN,
    SEFLG_JPLEPH,
    SEFLG_SWIEPH,
)
from .state import get_timescale
from .planets import swe_get_ayanamsa_ut, swe_calc_ut
from .cache import get_true_obliquity, get_cached_nutation
from .exceptions import Error, PolarCircleError, validate_coordinates


def _is_polar_circle(lat: float, eps: float) -> bool:
    """
    Check if latitude is within the polar circle for house calculations.

    At polar latitudes (approximately >66.5°), some house systems like Placidus
    and Koch cannot be calculated because the ecliptic does not properly
    intersect the horizon. This occurs when abs(lat) + eps > 90°.

    Args:
        lat: Geographic latitude in degrees
        eps: True obliquity of the ecliptic in degrees

    Returns:
        True if within polar circle (house calculation will fail)
    """
    # The polar circle is where the sun can be circumpolar
    # This happens when |lat| + obliquity > 90°
    # For typical obliquity of ~23.44°, this is lat > ~66.56°
    return abs(lat) + eps > 90.0


def get_polar_latitude_threshold(obliquity: float = 23.44) -> float:
    """
    Calculate the polar latitude threshold for a given obliquity.

    At latitudes beyond this threshold, some house systems (Placidus, Koch,
    Gauquelin) cannot be calculated because ecliptic points can be circumpolar
    (never rising or setting).

    The threshold is calculated as: 90° - obliquity

    For the current epoch (J2000), obliquity is approximately 23.44°,
    giving a threshold of approximately 66.56°.

    Args:
        obliquity: True obliquity of the ecliptic in degrees.
                   Default is 23.44° (approximate J2000 value).

    Returns:
        The polar latitude threshold in degrees. Latitudes with
        abs(lat) > threshold will trigger polar circle errors.

    Example:
        >>> get_polar_latitude_threshold()
        66.56
        >>> get_polar_latitude_threshold(23.5)
        66.5
    """
    return 90.0 - obliquity


def _get_polar_circle_info(lat: float, eps: float, house_system: str) -> dict:
    """
    Get detailed information about a polar circle condition.

    Args:
        lat: Geographic latitude in degrees
        eps: True obliquity of the ecliptic in degrees
        house_system: House system character (e.g., 'P', 'K', 'G')

    Returns:
        Dictionary with polar circle information:
        - is_polar: Whether the location is within the polar circle
        - latitude: The input latitude
        - threshold: The polar circle threshold for the given obliquity
        - obliquity: The obliquity used
        - excess: How many degrees beyond the threshold (if polar)
        - house_system: The house system character
        - hemisphere: 'N' for north, 'S' for south
    """
    threshold = get_polar_latitude_threshold(eps)
    is_polar = abs(lat) > threshold
    excess = abs(lat) - threshold if is_polar else 0.0
    hemisphere = "N" if lat >= 0 else "S"

    return {
        "is_polar": is_polar,
        "latitude": lat,
        "threshold": threshold,
        "obliquity": eps,
        "excess": excess,
        "house_system": house_system,
        "hemisphere": hemisphere,
    }


# Default threshold for extreme latitude (80°)
EXTREME_LATITUDE_THRESHOLD = 80.0


def _is_extreme_latitude(
    lat: float, threshold: float = EXTREME_LATITUDE_THRESHOLD
) -> bool:
    """
    Check if latitude is at an extreme value where house calculations may be numerically unstable.

    At latitudes beyond 80°, many house systems (especially Campanus, Regiomontanus,
    and Topocentric) may produce results with reduced accuracy or numerical instability,
    even if they don't technically fail like Placidus/Koch at polar latitudes.

    This is distinct from _is_polar_circle() which checks the mathematical limit
    where Placidus/Koch become undefined (~66.5°).

    Args:
        lat: Geographic latitude in degrees
        threshold: Latitude threshold for "extreme" (default 80°)

    Returns:
        True if latitude is at an extreme value (abs(lat) >= threshold)

    Example:
        >>> _is_extreme_latitude(85.0)
        True
        >>> _is_extreme_latitude(70.0)
        False
    """
    return abs(lat) >= threshold


def get_extreme_latitude_info(lat: float, obliquity: float = 23.44) -> dict:
    """
    Get detailed information about a location's latitude classification for house calculations.

    This function provides comprehensive information about how latitude affects
    house calculations, including whether the location is at extreme latitude,
    within the polar circle, and which house systems are affected.

    Args:
        lat: Geographic latitude in degrees
        obliquity: True obliquity of the ecliptic in degrees.
                   Default is 23.44° (approximate J2000 value).

    Returns:
        Dictionary with latitude information:
        - latitude: The input latitude
        - is_extreme: True if abs(lat) >= 80° (may have numerical instability)
        - is_polar_circle: True if abs(lat) > 90° - obliquity (Placidus/Koch/Gauquelin fail)
        - polar_threshold: The polar circle threshold latitude
        - extreme_threshold: The extreme latitude threshold (80°)
        - hemisphere: 'N' for north, 'S' for south
        - affected_systems: List of house system codes that fail at this latitude
        - unstable_systems: List of house system codes that may be numerically unstable
        - stable_systems: List of house system codes that work reliably at all latitudes

    Example:
        >>> info = get_extreme_latitude_info(85.0)
        >>> info['is_extreme']
        True
        >>> info['is_polar_circle']
        True
        >>> info['affected_systems']
        ['P', 'K', 'G']
        >>> info['stable_systems']
        ['E', 'W', 'O', 'M', 'X', 'V', 'N']
    """
    polar_threshold = get_polar_latitude_threshold(obliquity)
    is_polar = abs(lat) > polar_threshold
    is_extreme = _is_extreme_latitude(lat)
    hemisphere = "N" if lat >= 0 else "S"

    # Systems that completely fail at polar latitudes
    affected_systems = ["P", "K", "G"] if is_polar else []

    # Systems that may be numerically unstable at extreme latitudes (>80°)
    # but don't technically fail
    unstable_systems = []
    if is_extreme:
        unstable_systems = ["C", "R", "T", "B", "H", "U", "Y", "F"]

    # Systems that work reliably at all latitudes
    stable_systems = ["E", "W", "O", "M", "X", "V", "N"]

    return {
        "latitude": lat,
        "is_extreme": is_extreme,
        "is_polar_circle": is_polar,
        "polar_threshold": polar_threshold,
        "extreme_threshold": EXTREME_LATITUDE_THRESHOLD,
        "hemisphere": hemisphere,
        "affected_systems": affected_systems,
        "unstable_systems": unstable_systems,
        "stable_systems": stable_systems,
    }


def _validate_cusps(cusps: list | tuple) -> tuple[bool, str | None]:
    """
    Validate house cusps for numerical sanity.

    Checks that all cusp values are within valid range [0, 360) and are finite.
    This helps detect numerical instability at extreme latitudes.

    Args:
        cusps: List or tuple of house cusp longitudes

    Returns:
        Tuple of (is_valid, error_message or None)

    Example:
        >>> _validate_cusps([0.0, 30.0, 60.0, 90.0])
        (True, None)
        >>> _validate_cusps([float('nan'), 30.0])
        (False, 'House cusp contains NaN value')
    """
    for i, cusp in enumerate(cusps):
        # Check for NaN
        if cusp != cusp:  # NaN check (NaN != NaN is True)
            return False, f"House cusp {i + 1} contains NaN value"

        # Check for infinity
        if cusp == float("inf") or cusp == float("-inf"):
            return False, f"House cusp {i + 1} contains infinite value"

        # Check range (should be in [0, 360))
        if cusp < 0 or cusp >= 360:
            return False, f"House cusp {i + 1} out of range [0, 360): {cusp}"

    return True, None


def _raise_polar_circle_error(
    lat: float, eps: float, house_system: str, func_name: str
) -> None:
    """
    Raise a PolarCircleError with detailed information.

    Args:
        lat: Geographic latitude in degrees
        eps: True obliquity of the ecliptic in degrees
        house_system: House system character (e.g., 'P', 'K', 'G')
        func_name: Name of the function raising the error

    Raises:
        PolarCircleError: Always raised with detailed polar circle information
    """
    info = _get_polar_circle_info(lat, eps, house_system)
    threshold = info["threshold"]
    hemisphere = "Northern" if info["hemisphere"] == "N" else "Southern"

    # Map house system codes to names
    system_names = {
        "P": "Placidus",
        "K": "Koch",
        "G": "Gauquelin",
    }
    system_name = system_names.get(house_system, house_system)

    message = (
        f"{func_name}: {system_name} house system cannot be calculated at "
        f"latitude {abs(lat):.2f}°{info['hemisphere']} (within {hemisphere} polar circle). "
        f"Polar threshold for obliquity {eps:.2f}° is ±{threshold:.2f}°. "
        f"Consider using Porphyry ('O'), Equal ('E'), or Whole Sign ('W') house systems "
        f"which work at all latitudes, or use swe_houses_with_fallback() for automatic fallback."
    )

    raise PolarCircleError(
        message=message,
        latitude=lat,
        threshold=threshold,
        obliquity=eps,
        house_system=house_system,
    )


def angular_diff(a: float, b: float) -> float:
    """
    Calculate signed angular difference (a - b) handling 360° wrapping.

    Used by horizontal house system to find ecliptic longitude for given azimuth.

    Args:
        a: First angle in degrees
        b: Second angle in degrees

    Returns:
        Signed difference in range [-180, 180]
    """
    diff = (a - b) % 360.0
    if diff > 180.0:
        diff -= 360.0
    return diff


def _calc_vertex(armc_deg: float, eps: float, lat: float, mc: float) -> float:
    """
    Calculate the Vertex (intersection of Prime Vertical and Ecliptic in Western hemisphere).

    The Vertex is an auxiliary angle representing where the Prime Vertical (great circle
    through zenith perpendicular to meridian) intersects the ecliptic in the western sky.
    Often used in astrology for fateful encounters or significant relationships.

    At the equator (lat=0), the Vertex is mathematically undefined because the Prime
    Vertical coincides with the horizon plane. Following Swiss Ephemeris convention,
    we return 180.0 as a fallback value in this case.

    Args:
        armc_deg: Right Ascension of Midheaven (sidereal time) in degrees
        eps: True obliquity of ecliptic in degrees
        lat: Geographic latitude in degrees
        mc: Midheaven longitude in degrees (for hemisphere verification)

    Returns:
        Vertex longitude in degrees (western hemisphere), or 0.0 at equator

    Precision: ~0.001° for non-equatorial latitudes
    """
    eps_rad = math.radians(eps)

    # At equator (lat=0), Vertex is mathematically undefined (Prime Vertical
    # coincides with the horizon plane). Swiss Ephemeris returns 0.0 as fallback.
    if abs(lat) < 1e-10:  # Effectively zero latitude (~0.00036 arcsec)
        return 0.0

    # Standard formula for non-zero latitudes
    # Vertex is where Prime Vertical intersects ecliptic in West
    armc_rad = math.radians(armc_deg)
    lat_rad = math.radians(lat)

    num = -math.cos(armc_rad)
    den = math.sin(armc_rad) * math.cos(eps_rad) - math.sin(eps_rad) / math.tan(lat_rad)

    vtx_rad = math.atan2(num, den)
    vtx = math.degrees(vtx_rad) % 360.0

    # Ensure Vertex is in Western Hemisphere relative to MC
    # Vertex should be West of MC (i.e., behind it in diurnal motion)
    diff = (vtx - mc) % 360.0
    if diff < 180.0:
        vtx = (vtx + 180.0) % 360.0

    return vtx


def _calc_ascendant(
    armc_deg: float, eps: float, lat: float, lat_for_calc: float
) -> float:
    """
    Calculate Ascendant for given ARMC, obliquity, and latitude.

    This exactly replicates the Asc1() function in Swiss Ephemeris swehouse.c.
    Uses quadrant-based calculation with Asc2() spherical trigonometry.

    Args:
        armc_deg: Right Ascension of Midheaven in degrees (x1 in Swiss Ephemeris)
        eps: True obliquity of ecliptic in degrees
        lat: Geographic latitude (not used, kept for API compatibility)
        lat_for_calc: Latitude to use in calculation (f = pole height in Swiss Ephemeris)

    Returns:
        Ascendant longitude in degrees (0-360)
    """
    VERY_SMALL = 1e-10

    def _asc2(x: float, f: float, sine: float, cose: float) -> float:
        """
        Asc2 function from Swiss Ephemeris (swehouse.c line 2100).
        Spherical trigonometry calculation for ascendant.

        x: in range 0..90
        f: pole height (latitude for normal ascendant)
        """
        # From spherical trigonometry: cot c sin a = cot C sin B + cos a cos B
        # where B = ecliptic obliquity, a = x, C = 90° + f
        # cot(90° + f) = -tan(f)
        ass = -math.tan(math.radians(f)) * sine + cose * math.cos(math.radians(x))

        if abs(ass) < VERY_SMALL:
            ass = 0.0

        sinx = math.sin(math.radians(x))
        if abs(sinx) < VERY_SMALL:
            sinx = 0.0

        if sinx == 0:
            if ass < 0:
                ass = -VERY_SMALL
            else:
                ass = VERY_SMALL
        elif ass == 0:
            if sinx < 0:
                ass = -90.0
            else:
                ass = 90.0
        else:
            # tan c = sin x / ass
            ass = math.degrees(math.atan(sinx / ass))

        if ass < 0:
            ass = 180.0 + ass

        return ass

    # Normalize to 0-360
    x1 = armc_deg % 360.0
    f = lat_for_calc
    sine = math.sin(math.radians(eps))
    cose = math.cos(math.radians(eps))

    # Determine quadrant (1..4)
    n = int((x1 / 90.0) + 1)

    # Handle polar cases
    if abs(90.0 - f) < VERY_SMALL:  # Near north pole
        return 180.0
    if abs(90.0 + f) < VERY_SMALL:  # Near south pole
        return 0.0

    # Calculate based on quadrant
    if n == 1:
        ass = _asc2(x1, f, sine, cose)
    elif n == 2:
        ass = 180.0 - _asc2(180.0 - x1, -f, sine, cose)
    elif n == 3:
        ass = 180.0 + _asc2(x1 - 180.0, -f, sine, cose)
    else:  # n == 4
        ass = 360.0 - _asc2(360.0 - x1, f, sine, cose)

    # Normalize result
    ass = ass % 360.0

    # Rounding corrections for cardinal points
    if abs(ass - 90.0) < VERY_SMALL:
        ass = 90.0
    if abs(ass - 180.0) < VERY_SMALL:
        ass = 180.0
    if abs(ass - 270.0) < VERY_SMALL:
        ass = 270.0
    if abs(ass - 360.0) < VERY_SMALL:
        ass = 0.0

    return ass


def swe_houses(
    tjdut: float, lat: float, lon: float, hsys: int, iflag: int = 0
) -> tuple[tuple[float, ...], tuple[float, ...]]:
    """
    Calculate astrological house cusps and angles for a given time and location.

    Swiss Ephemeris compatible function. Computes house divisions according to
    the specified house system and returns both house cusps and major angles (ASCMC).

    Args:
        tjdut: Julian Day in Universal Time (UT1)
        lat: Geographic latitude in degrees (positive North, negative South)
        lon: Geographic longitude in degrees (positive East, negative West)
        hsys: House system identifier (e.g., ord('P') for Placidus, ord('K') for Koch)
        iflag: Calculation flags (optional, default 0). Use SEFLG_MOSEPH for extended
               date range (-3000 to +3000 CE). The ephemeris flag is propagated to
               internal calculations (e.g., Sun position for Sunshine houses).

    Returns:
        Tuple containing:
            - cusps: Tuple of 12 house cusp longitudes (houses 1-12) in degrees
            - ascmc: Tuple of 8 angles: [Asc, MC, ARMC, Vertex, EquAsc, CoAsc, CoAscKoch, PolarAsc]

    House Systems Supported:
        'P' = Placidus, 'K' = Koch, 'R' = Regiomontanus, 'C' = Campanus,
        'E'/'A' = Equal, 'W' = Whole Sign, 'O' = Porphyry, 'B' = Alcabitius,
        'T' = Topocentric, 'M' = Morinus, 'X' = Meridian, 'H' = Horizontal,
        'V' = Vehlow, 'G' = Gauquelin, 'U' = Krusinski, 'F' = Carter,
        'Y' = APC, 'N' = Natural Gradient

    Example:
        >>> cusps, ascmc = swe_houses(2451545.0, 51.5, -0.12, ord('P'))  # London, Placidus
        >>> asc, mc = ascmc[0], ascmc[1]
        >>> house_1_start = cusps[0]  # First house cusp
    """
    # Validate latitude and longitude ranges
    validate_coordinates(lat, lon, "swe_houses")

    # 1. Calculate Sidereal Time (ARMC)
    # ARMC = GMST + lon
    ts = get_timescale()
    t = ts.ut1_jd(tjdut)

    # Use Skyfield's GAST (Greenwich Apparent Sidereal Time) for house calculations
    # GAST includes nutation in right ascension, which is critical for accurate house cusps.
    # Unlike GMST (mean sidereal time), GAST accounts for the true position of the
    # equinox affected by nutation, providing ~0.015 arcsec precision in final cusps.
    # Skyfield GAST precision: ~0.001 seconds of time = ~0.015 arcsec in RA
    # Reference: Skyfield documentation, IAU SOFA standards (iau2000b nutation model)
    gast = float(t.gast)  # in hours (convert numpy.float64 to Python float)
    armc_deg = (gast * 15.0 + lon) % 360.0

    # True Obliquity of Ecliptic - uses cached nutation calculation
    # This is a hot path optimization: obliquity calculation with nutation
    # is expensive (~0.03ms) and called frequently. Caching provides ~50x speedup.
    eps = get_true_obliquity(t.tt)

    # 2. Calculate Ascendant and MC
    # MC is intersection of Meridian and Ecliptic.
    # tan(MC) = tan(ARMC) / cos(eps)
    # Quadrant check needed.

    # Determine if we need to flip MC (and thus ARMC) for specific systems
    # Regiomontanus (R), Campanus (C), Polich/Page (T) flip MC if below horizon.

    hsys_char: str
    if isinstance(hsys, int):
        hsys_char = chr(hsys)
    elif isinstance(hsys, bytes):
        hsys_char = hsys.decode("utf-8")
    else:
        hsys_char = str(hsys)

    # Determine if we need to flip MC (and thus ARMC) for specific systems
    # Regiomontanus (R), Campanus (C), Polich/Page (T) flip MC if below horizon.

    armc_active = armc_deg

    if hsys_char in ["R", "C", "T"]:
        # Check altitude of MC calculated from original ARMC
        mc_dec_rad = math.atan(
            math.sin(math.radians(armc_deg)) * math.tan(math.radians(eps))
        )
        lat_rad = math.radians(lat)
        sin_alt = math.sin(lat_rad) * math.sin(mc_dec_rad) + math.cos(
            lat_rad
        ) * math.cos(mc_dec_rad)

        if sin_alt < 0:
            armc_active = (armc_deg + 180.0) % 360.0

    # 2. Calculate Ascendant and MC

    # MC uses armc_active (flipped if needed)
    mc_rad = math.atan2(
        math.tan(math.radians(armc_active)), math.cos(math.radians(eps))
    )
    mc = math.degrees(mc_rad)
    # Adjust quadrant to match armc_active
    if mc < 0:
        mc += 360.0

    if 90.0 < armc_active <= 270.0:
        if mc < 90.0 or mc > 270.0:
            mc += 180.0
    elif armc_active > 270.0:
        if mc < 270.0:
            mc += 180.0
    elif armc_active <= 90.0:
        if mc > 90.0:
            mc += 180.0

    mc = mc % 360.0

    # Ascendant uses armc_deg (Original)
    num = math.cos(math.radians(armc_deg))
    den = -(
        math.sin(math.radians(armc_deg)) * math.cos(math.radians(eps))
        + math.tan(math.radians(lat)) * math.sin(math.radians(eps))
    )
    asc_rad = math.atan2(num, den)
    asc = math.degrees(asc_rad)
    asc = asc % 360.0

    # Ensure Ascendant is on the Eastern Horizon (Azimuth in [0, 180])
    # We check Azimuth relative to the TRUE ARMC (armc_deg)

    asc_r = math.radians(asc)
    eps_r = math.radians(eps)

    # RA
    y = math.cos(eps_r) * math.sin(asc_r)
    x = math.cos(asc_r)
    ra_r = math.atan2(y, x)
    ra = math.degrees(ra_r) % 360.0

    # Dec
    dec_r = math.asin(math.sin(eps_r) * math.sin(asc_r))

    # Hour Angle using TRUE ARMC
    h_deg = (armc_deg - ra + 360.0) % 360.0
    h_r = math.radians(h_deg)

    # Azimuth
    # tan(Az) = sin(H) / (sin(lat)cos(H) - cos(lat)tan(Dec))
    lat_r = math.radians(lat)

    num_az = math.sin(h_r)
    den_az = math.sin(lat_r) * math.cos(h_r) - math.cos(lat_r) * math.tan(dec_r)
    az_r = math.atan2(num_az, den_az)
    az = math.degrees(az_r)
    az = (az + 180.0) % 360.0

    # Check if H is West (0-180). If so, Asc is Setting (Descendant).
    # We want Rising.
    if 0.0 < h_deg < 180.0:
        asc = (asc + 180.0) % 360.0

    # Vertex uses armc_deg (Original)
    # Hemisphere check relative to TRUE ARMC (West of True ARMC)
    vertex = _calc_vertex(armc_deg, eps, lat, armc_deg)

    # Equatorial Ascendant (East Point)
    # This is the intersection of the ecliptic with the celestial equator in the east
    # It's the ecliptic longitude where RA = ARMC + 90°
    equ_asc_ra = (armc_deg + 90.0) % 360.0
    # Convert RA to ecliptic longitude
    # tan(Lon) = tan(RA) / cos(eps)
    # y = sin(RA)
    # x = cos(RA) * cos(eps)
    equ_asc_ra_r = math.radians(equ_asc_ra)
    eps_r = math.radians(eps)
    y = math.sin(equ_asc_ra_r)
    x = math.cos(equ_asc_ra_r) * math.cos(eps_r)
    equ_asc = math.degrees(math.atan2(y, x)) % 360.0

    # Co-Ascendant W. Koch (coasc1)
    # Formula from Swiss Ephemeris swehouse.c line 2036:
    # coasc1 = Asc(ARMC - 90°, latitude) + 180°
    # This is the Ascendant calculated 90° westward on the equator, then opposite point
    coasc_armc = (armc_deg - 90.0) % 360.0
    co_asc_koch = _calc_ascendant(coasc_armc, eps, lat, lat)

    # Add 180° to get opposite point (per Swiss Ephemeris formula)
    co_asc_koch = (co_asc_koch + 180.0) % 360.0

    # Co-Ascendant M. Munkasey (coasc2)
    # Formula from Swiss Ephemeris swehouse.c lines 2039-2044:
    # If lat >= 0: coasc2 = Asc(ARMC + 90°, 90° - lat)
    # If lat < 0:  coasc2 = Asc(ARMC + 90°, -90° - lat)
    # At equator (lat=0), coasc2_lat becomes 90° which is undefined.
    # Swiss Ephemeris returns 180.0 as fallback in this case.
    if abs(lat) < 1e-10:
        co_asc = 180.0
    else:
        coasc2_armc = (armc_deg + 90.0) % 360.0
        if lat >= 0:
            coasc2_lat = 90.0 - lat
        else:
            coasc2_lat = -90.0 - lat
        co_asc = _calc_ascendant(coasc2_armc, eps, coasc2_lat, coasc2_lat)

    # Polar Ascendant M. Munkasey (polasc)
    # Formula from Swiss Ephemeris swehouse.c line 2047:
    # polasc = Asc(ARMC - 90°, latitude)
    # Note: This is the same as coasc1 but WITHOUT the +180°
    polar_asc = _calc_ascendant(coasc_armc, eps, lat, lat)

    # Build ASCMC array with 8 elements (pyswisseph compatible)
    ascmc = [0.0] * 8
    ascmc[0] = asc
    ascmc[1] = mc
    ascmc[2] = armc_deg
    ascmc[3] = vertex
    ascmc[4] = equ_asc
    ascmc[5] = co_asc_koch  # Swiss Ephemeris: coasc1 (W. Koch) at index 5
    ascmc[6] = co_asc  # Swiss Ephemeris: coasc2 (M. Munkasey) at index 6
    ascmc[7] = polar_asc

    # 3. House Cusps
    # Use armc_active for house calculations
    # If MC was flipped, we might need to flip latitude for intermediate cusps (Regiomontanus, etc.)
    # Verified for Regiomontanus: using -lat with flipped MC matches SWE.

    calc_lat = lat
    if armc_active != armc_deg:
        # MC was flipped. Flip latitude for intermediate cusp calculations.
        calc_lat = -lat

    # Check for polar circle condition for Placidus/Koch/Gauquelin
    # These systems cannot be calculated when abs(lat) + eps > 90°
    # Raise detailed PolarCircleError with useful information
    if hsys_char in ["P", "K", "G"] and _is_polar_circle(lat, eps):
        _raise_polar_circle_error(lat, eps, hsys_char, "swe_houses")

    # Calculate Sun's declination for Sunshine houses ('I' or 'i')
    # This is needed before the house dispatch since only swe_houses has jd_ut
    sun_dec = 0.0
    if hsys_char in ("I", "i"):
        try:
            # Extract ephemeris flags: SEFLG_JPLEPH=1, SEFLG_SWIEPH=2
            eph_flags = iflag & (SEFLG_JPLEPH | SEFLG_SWIEPH)
            sun_pos, _ = swe_calc_ut(tjdut, SE_SUN, SEFLG_EQUATORIAL | eph_flags)
            sun_dec = sun_pos[1]  # Declination is second element in equatorial coords
        except Exception:
            # Fallback to 0 declination (same as equinox behavior)
            sun_dec = 0.0

    cusps = [0.0] * 13

    if hsys_char == "P":  # Placidus
        cusps = _houses_placidus(
            armc_active, lat, eps, asc, mc
        )  # Placidus fails anyway
    elif hsys_char == "K":  # Koch
        cusps = _houses_koch(armc_active, lat, eps, asc, mc)  # Koch fails anyway
    elif hsys_char == "R":  # Regiomontanus
        cusps = _houses_regiomontanus(armc_active, calc_lat, eps, asc, mc)
    elif hsys_char == "C":  # Campanus
        cusps = _houses_campanus(armc_active, calc_lat, eps, asc, mc)
    elif hsys_char == "E":  # Equal (Ascendant)
        cusps = _houses_equal(asc)
    elif hsys_char == "A":  # Equal (MC)
        cusps = _houses_equal(asc)  # Equal MC is same as Equal Asc in SwissEph
    elif hsys_char == "W":  # Whole Sign
        cusps = _houses_whole_sign(asc)
    elif hsys_char == "O":  # Porphyry
        cusps = _houses_porphyry(asc, mc)
    elif hsys_char == "B":  # Alcabitius
        cusps = _houses_alcabitius(armc_active, lat, eps, asc, mc)
    elif hsys_char == "T":  # Polich/Page (Topocentric)
        cusps = _houses_polich_page(armc_active, calc_lat, eps, asc, mc)
    elif hsys_char == "M":  # Morinus
        cusps = _houses_morinus(armc_active, lat, eps, asc, mc)
    elif hsys_char == "X":  # Meridian (Axial)
        cusps = _houses_meridian(armc_active, lat, eps, asc, mc)
    elif hsys_char == "V":  # Vehlow
        cusps = _houses_vehlow(asc)
    elif hsys_char == "H":  # Horizontal (Azimuthal)
        cusps = _houses_horizontal(armc_active, lat, eps, asc, mc)
    elif hsys_char == "Y":  # APC Houses
        cusps = _houses_apc(armc_active, lat, eps, asc, mc)
        # APC at polar latitudes needs cusps and MC flipped if MC is below horizon
        # (Swiss Ephemeris behavior - different from R/C/T which flip armc_active)
        mc_dec_rad = math.atan(
            math.sin(math.radians(armc_deg)) * math.tan(math.radians(eps))
        )
        lat_rad = math.radians(lat)
        sin_alt = math.sin(lat_rad) * math.sin(mc_dec_rad) + math.cos(
            lat_rad
        ) * math.cos(mc_dec_rad)
        if sin_alt < 0:
            # Flip MC in ascmc
            ascmc[1] = (ascmc[1] + 180.0) % 360.0
            # Flip all cusps by 180°
            for i in range(1, 13):
                cusps[i] = (cusps[i] + 180.0) % 360.0
    elif hsys_char == "F":  # Carter (Poli-Equatorial)
        cusps = _houses_carter(armc_active, lat, eps, asc, mc)
    elif hsys_char == "U":  # Krusinski
        cusps = _houses_krusinski(armc_active, lat, eps, asc, mc)
    elif hsys_char == "N":  # Natural Gradient
        cusps = _houses_natural_gradient(armc_active, lat, eps, asc, mc)
    elif hsys_char == "G":  # Gauquelin
        cusps = _houses_gauquelin(armc_active, lat, eps, asc, mc)
    elif hsys_char == "S":  # Sripati
        cusps = _houses_sripati(asc, mc)
    elif hsys_char == "L":  # Pullen SD (Sinusoidal Delta / Neo-Porphyry)
        cusps = _houses_pullen_sd(asc, mc)
    elif hsys_char == "Q":  # Pullen SR (Sinusoidal Ratio)
        cusps = _houses_pullen_sr(asc, mc)
    elif hsys_char == "D":  # Equal from MC
        cusps = _houses_equal_mc(asc, mc)
    elif hsys_char in ("I", "i"):  # Sunshine (Makransky)
        cusps = _houses_sunshine(armc_active, lat, eps, asc, mc, sun_dec)
    else:
        # Default to Placidus
        cusps = _houses_placidus(armc_active, lat, eps, asc, mc)

    # Return cusps array (pyswisseph compatible: no padding at index 0)
    # For Gauquelin ('G'), return 36 sectors; otherwise return 12 houses
    if hsys_char == "G":
        return tuple(cusps[1:37]), tuple(ascmc)
    return tuple(cusps[1:13]), tuple(ascmc)


def swe_houses_with_fallback(
    jd_ut: float,
    lat: float,
    lon: float,
    hsys: int,
    fallback_hsys: int = ord("O"),
    validate_cusps: bool = True,
) -> tuple[tuple[float, ...], tuple[float, ...], bool, str | None]:
    """
    Calculate house cusps with automatic fallback for polar latitudes.

    This convenience function attempts to calculate houses using the requested
    system, and automatically falls back to a polar-safe system (default: Porphyry)
    if the location is within the polar circle.

    For extreme latitudes (>80°), this function also:
    - Validates cusp values for numerical sanity (NaN, infinity, range)
    - Includes warnings about potential numerical instability
    - Falls back to a stable system if cusps are invalid

    This is useful for applications that need to handle polar latitudes gracefully
    without explicit error handling for each calculation.

    Args:
        jd_ut: Julian Day in Universal Time
        lat: Geographic latitude in degrees (positive North, negative South)
        lon: Geographic longitude in degrees (positive East, negative West)
        hsys: Primary house system identifier (e.g., ord('P') for Placidus)
        fallback_hsys: Fallback house system for polar latitudes.
                       Default: ord('O') (Porphyry).
                       Other good choices: ord('E') (Equal), ord('W') (Whole Sign)
        validate_cusps: If True (default), validate cusp values for numerical sanity
                        and fall back if invalid cusps are detected.

    Returns:
        Tuple containing:
            - cusps: Tuple of 12 house cusp longitudes (houses 1-12) in degrees
            - ascmc: Tuple of 8 angles
            - used_fallback: True if fallback system was used
            - warning_message: Informational message if fallback was used or
                               extreme latitude warning, else None

    Example:
        >>> import libephemeris as ephem
        >>> jd = 2451545.0
        >>> # This would fail with Placidus at 70°N, but uses fallback
        >>> cusps, ascmc, used_fallback, warning = ephem.swe_houses_with_fallback(
        ...     jd, 70.0, 0.0, ord('P')
        ... )
        >>> if used_fallback:
        ...     print(f"Used fallback: {warning}")
        >>> # cusps and ascmc are valid regardless of latitude
        >>>
        >>> # At extreme latitudes (>80°), you may get a warning even for non-failing systems
        >>> cusps, ascmc, used_fallback, warning = ephem.swe_houses_with_fallback(
        ...     jd, 85.0, 0.0, ord('C')  # Campanus at 85°N
        ... )
        >>> if warning:
        ...     print(f"Warning: {warning}")

    See Also:
        swe_houses: Standard function that raises PolarCircleError at polar latitudes
        get_polar_latitude_threshold: Returns the threshold for a given obliquity
        get_extreme_latitude_info: Returns detailed latitude classification information
    """
    # House system names for error messages
    system_names = {
        "P": "Placidus",
        "K": "Koch",
        "G": "Gauquelin",
        "O": "Porphyry",
        "E": "Equal",
        "W": "Whole Sign",
        "R": "Regiomontanus",
        "C": "Campanus",
        "T": "Topocentric",
        "B": "Alcabitius",
        "M": "Morinus",
        "X": "Meridian",
        "H": "Horizontal",
        "V": "Vehlow",
        "U": "Krusinski",
        "F": "Carter",
        "Y": "APC",
        "N": "Natural Gradient",
    }

    hsys_char = chr(hsys) if isinstance(hsys, int) else hsys
    fallback_char = (
        chr(fallback_hsys) if isinstance(fallback_hsys, int) else fallback_hsys
    )
    primary_name = system_names.get(hsys_char, hsys_char)
    fallback_name = system_names.get(fallback_char, fallback_char)

    # Check for extreme latitude before calculation
    is_extreme = _is_extreme_latitude(lat)

    try:
        cusps, ascmc = swe_houses(jd_ut, lat, lon, hsys)

        # Validate cusps if requested
        if validate_cusps:
            is_valid, validation_error = _validate_cusps(cusps)
            if not is_valid:
                # Invalid cusps detected - fall back to stable system
                cusps, ascmc = swe_houses(jd_ut, lat, lon, fallback_hsys)
                warning = (
                    f"{primary_name} house system produced invalid cusps at latitude "
                    f"{abs(lat):.2f}° ({validation_error}). Using {fallback_name} as fallback."
                )
                return cusps, ascmc, True, warning

        # Generate warning for extreme latitudes even if calculation succeeded
        if is_extreme:
            info = get_extreme_latitude_info(lat)
            if hsys_char in info["unstable_systems"]:
                warning = (
                    f"{primary_name} house system may have reduced accuracy at "
                    f"extreme latitude {abs(lat):.2f}°{info['hemisphere']}. "
                    f"Consider using a stable system like Porphyry, Equal, or Whole Sign."
                )
                return cusps, ascmc, False, warning

        return cusps, ascmc, False, None
    except PolarCircleError as e:
        # Use fallback house system
        cusps, ascmc = swe_houses(jd_ut, lat, lon, fallback_hsys)

        warning = (
            f"{primary_name} house system unavailable at latitude {abs(lat):.2f}° "
            f"(polar circle threshold: {e.threshold:.2f}°). "
            f"Using {fallback_name} as fallback."
        )
        return cusps, ascmc, True, warning


def swe_houses_armc_with_fallback(
    armc: float,
    lat: float,
    eps: float,
    hsys: int,
    fallback_hsys: int = ord("O"),
    validate_cusps: bool = True,
) -> tuple[tuple[float, ...], tuple[float, ...], bool, str | None]:
    """
    Calculate house cusps from ARMC with automatic fallback for polar latitudes.

    Similar to swe_houses_with_fallback, but calculates from ARMC instead of
    Julian Day. Includes the same extreme latitude handling and cusp validation.

    Args:
        armc: Right Ascension of Medium Coeli in degrees (0-360)
        lat: Geographic latitude in degrees (positive North, negative South)
        eps: True obliquity of the ecliptic in degrees
        hsys: Primary house system identifier (e.g., ord('P') for Placidus)
        fallback_hsys: Fallback house system for polar latitudes.
                       Default: ord('O') (Porphyry).
        validate_cusps: If True (default), validate cusp values for numerical sanity
                        and fall back if invalid cusps are detected.

    Returns:
        Tuple containing:
            - cusps: Tuple of 12 house cusp longitudes (houses 1-12) in degrees
            - ascmc: Tuple of 8 angles
            - used_fallback: True if fallback system was used
            - warning_message: Informational message if fallback was used or
                               extreme latitude warning, else None

    See Also:
        swe_houses_armc: Standard function that raises PolarCircleError at polar latitudes
        get_extreme_latitude_info: Returns detailed latitude classification information
    """
    # House system names for error messages
    system_names = {
        "P": "Placidus",
        "K": "Koch",
        "G": "Gauquelin",
        "O": "Porphyry",
        "E": "Equal",
        "W": "Whole Sign",
        "R": "Regiomontanus",
        "C": "Campanus",
        "T": "Topocentric",
        "B": "Alcabitius",
        "M": "Morinus",
        "X": "Meridian",
        "H": "Horizontal",
        "V": "Vehlow",
        "U": "Krusinski",
        "F": "Carter",
        "Y": "APC",
        "N": "Natural Gradient",
    }

    hsys_char = chr(hsys) if isinstance(hsys, int) else hsys
    fallback_char = (
        chr(fallback_hsys) if isinstance(fallback_hsys, int) else fallback_hsys
    )
    primary_name = system_names.get(hsys_char, hsys_char)
    fallback_name = system_names.get(fallback_char, fallback_char)

    # Check for extreme latitude before calculation
    is_extreme = _is_extreme_latitude(lat)

    try:
        cusps, ascmc = swe_houses_armc(armc, lat, eps, hsys)

        # Validate cusps if requested
        if validate_cusps:
            is_valid, validation_error = _validate_cusps(cusps)
            if not is_valid:
                # Invalid cusps detected - fall back to stable system
                cusps, ascmc = swe_houses_armc(armc, lat, eps, fallback_hsys)
                warning = (
                    f"{primary_name} house system produced invalid cusps at latitude "
                    f"{abs(lat):.2f}° ({validation_error}). Using {fallback_name} as fallback."
                )
                return cusps, ascmc, True, warning

        # Generate warning for extreme latitudes even if calculation succeeded
        if is_extreme:
            info = get_extreme_latitude_info(lat, eps)
            if hsys_char in info["unstable_systems"]:
                warning = (
                    f"{primary_name} house system may have reduced accuracy at "
                    f"extreme latitude {abs(lat):.2f}°{info['hemisphere']}. "
                    f"Consider using a stable system like Porphyry, Equal, or Whole Sign."
                )
                return cusps, ascmc, False, warning

        return cusps, ascmc, False, None
    except PolarCircleError as e:
        # Use fallback house system
        cusps, ascmc = swe_houses_armc(armc, lat, eps, fallback_hsys)

        warning = (
            f"{primary_name} house system unavailable at latitude {abs(lat):.2f}° "
            f"(polar circle threshold: {e.threshold:.2f}°). "
            f"Using {fallback_name} as fallback."
        )
        return cusps, ascmc, True, warning


def swe_houses_armc(
    armc: float, lat: float, eps: float, hsys: int
) -> tuple[tuple[float, ...], tuple[float, ...]]:
    """
    Calculate house cusps and angles from ARMC (Right Ascension of Medium Coeli).

    This function calculates house cusps directly from the ARMC value instead of
    from a Julian Day. This is useful when you have a pre-calculated ARMC or when
    working with house systems that depend only on ARMC, latitude, and obliquity.

    Swiss Ephemeris compatible function (swe_houses_armc equivalent).

    Args:
        armc: Right Ascension of Medium Coeli in degrees (0-360)
        lat: Geographic latitude in degrees (positive North, negative South)
        eps: True obliquity of the ecliptic in degrees
        hsys: House system identifier (e.g., ord('P') for Placidus, ord('K') for Koch)

    Returns:
        Tuple containing:
            - cusps: Tuple of 12 house cusp longitudes (houses 1-12) in degrees
            - ascmc: Tuple of 8 angles: [Asc, MC, ARMC, Vertex, EquAsc, CoAsc, CoAscKoch, PolarAsc]

    House Systems Supported:
        'P' = Placidus, 'K' = Koch, 'R' = Regiomontanus, 'C' = Campanus,
        'E'/'A' = Equal, 'W' = Whole Sign, 'O' = Porphyry, 'B' = Alcabitius,
        'T' = Topocentric, 'M' = Morinus, 'X' = Meridian, 'H' = Horizontal,
        'V' = Vehlow, 'G' = Gauquelin, 'U' = Krusinski, 'F' = Carter,
        'Y' = APC, 'N' = Natural Gradient

    Example:
        >>> # Calculate obliquity for J2000.0
        >>> eps = 23.4393  # approximate true obliquity
        >>> armc = 292.957  # ARMC in degrees
        >>> cusps, ascmc = swe_houses_armc(armc, 41.9, eps, ord('P'))
        >>> asc, mc = ascmc[0], ascmc[1]
    """
    # Validate latitude range (must be in [-90, 90])
    from .exceptions import validate_latitude

    validate_latitude(lat, "swe_houses_armc")

    # Normalize ARMC to 0-360
    armc_deg = armc % 360.0

    # Convert house system identifier to character
    hsys_char: str
    if isinstance(hsys, int):
        hsys_char = chr(hsys)
    elif isinstance(hsys, bytes):
        hsys_char = hsys.decode("utf-8")
    else:
        hsys_char = str(hsys)

    # Determine if we need to flip MC (and thus ARMC) for specific systems
    # Regiomontanus (R), Campanus (C), Polich/Page (T) flip MC if below horizon.
    armc_active = armc_deg

    if hsys_char in ["R", "C", "T"]:
        # Check altitude of MC calculated from original ARMC
        mc_dec_rad = math.atan(
            math.sin(math.radians(armc_deg)) * math.tan(math.radians(eps))
        )
        lat_rad = math.radians(lat)
        sin_alt = math.sin(lat_rad) * math.sin(mc_dec_rad) + math.cos(
            lat_rad
        ) * math.cos(mc_dec_rad)

        if sin_alt < 0:
            armc_active = (armc_deg + 180.0) % 360.0

    # Calculate MC from armc_active (flipped if needed)
    mc_rad = math.atan2(
        math.tan(math.radians(armc_active)), math.cos(math.radians(eps))
    )
    mc = math.degrees(mc_rad)
    # Adjust quadrant to match armc_active
    if mc < 0:
        mc += 360.0

    # Quadrant correction: MC should be in the same half of the ecliptic as ARMC
    # ARMC in (90, 270] -> MC should be in (90, 270]
    # ARMC in (270, 360] or [0, 90] -> MC should be in (270, 360] or [0, 90]
    if 90.0 < armc_active <= 270.0:
        if mc <= 90.0 or mc > 270.0:
            mc += 180.0
    elif armc_active > 270.0:
        if mc <= 270.0:
            mc += 180.0
    elif armc_active <= 90.0:
        if mc > 90.0:
            mc += 180.0

    mc = mc % 360.0

    # Ascendant uses armc_deg (Original)
    num = math.cos(math.radians(armc_deg))
    den = -(
        math.sin(math.radians(armc_deg)) * math.cos(math.radians(eps))
        + math.tan(math.radians(lat)) * math.sin(math.radians(eps))
    )
    asc_rad = math.atan2(num, den)
    asc = math.degrees(asc_rad)
    asc = asc % 360.0

    # Ensure Ascendant is on the Eastern Horizon (Azimuth in [0, 180])
    # We check Azimuth relative to the TRUE ARMC (armc_deg)
    asc_r = math.radians(asc)
    eps_r = math.radians(eps)

    # RA
    y = math.cos(eps_r) * math.sin(asc_r)
    x = math.cos(asc_r)
    ra_r = math.atan2(y, x)
    ra = math.degrees(ra_r) % 360.0

    # Dec
    dec_r = math.asin(math.sin(eps_r) * math.sin(asc_r))

    # Hour Angle using TRUE ARMC
    h_deg = (armc_deg - ra + 360.0) % 360.0
    h_r = math.radians(h_deg)

    # Azimuth
    # tan(Az) = sin(H) / (sin(lat)cos(H) - cos(lat)tan(Dec))
    lat_r = math.radians(lat)

    num_az = math.sin(h_r)
    den_az = math.sin(lat_r) * math.cos(h_r) - math.cos(lat_r) * math.tan(dec_r)
    az_r = math.atan2(num_az, den_az)
    az = math.degrees(az_r)
    az = (az + 180.0) % 360.0

    # Check if H is West (0-180). If so, Asc is Setting (Descendant).
    # We want Rising.
    if 0.0 < h_deg < 180.0:
        asc = (asc + 180.0) % 360.0

    # Ensure ASC is in [0, 360) range (handle edge case of exactly 360.0)
    if asc >= 360.0:
        asc = 0.0

    # Vertex uses armc_deg (Original)
    # Hemisphere check relative to TRUE ARMC (West of True ARMC)
    vertex = _calc_vertex(armc_deg, eps, lat, armc_deg)

    # Equatorial Ascendant (East Point)
    # This is the intersection of the ecliptic with the celestial equator in the east
    # It's the ecliptic longitude where RA = ARMC + 90°
    equ_asc_ra = (armc_deg + 90.0) % 360.0
    # Convert RA to ecliptic longitude
    equ_asc_ra_r = math.radians(equ_asc_ra)
    eps_r = math.radians(eps)
    y = math.sin(equ_asc_ra_r)
    x = math.cos(equ_asc_ra_r) * math.cos(eps_r)
    equ_asc = math.degrees(math.atan2(y, x)) % 360.0

    # Co-Ascendant W. Koch (coasc1)
    # Formula from Swiss Ephemeris: coasc1 = Asc(ARMC - 90°, latitude) + 180°
    coasc_armc = (armc_deg - 90.0) % 360.0
    co_asc_koch = _calc_ascendant(coasc_armc, eps, lat, lat)

    # Add 180° to get opposite point (per Swiss Ephemeris formula)
    co_asc_koch = (co_asc_koch + 180.0) % 360.0

    # Co-Ascendant M. Munkasey (coasc2)
    # If lat >= 0: coasc2 = Asc(ARMC + 90°, 90° - lat)
    # If lat < 0:  coasc2 = Asc(ARMC + 90°, -90° - lat)
    # At equator (lat=0), coasc2_lat becomes 90° which is undefined.
    # Swiss Ephemeris returns 180.0 as fallback in this case.
    if abs(lat) < 1e-10:
        co_asc = 180.0
    else:
        coasc2_armc = (armc_deg + 90.0) % 360.0
        if lat >= 0:
            coasc2_lat = 90.0 - lat
        else:
            coasc2_lat = -90.0 - lat
        co_asc = _calc_ascendant(coasc2_armc, eps, coasc2_lat, coasc2_lat)

    # Polar Ascendant M. Munkasey (polasc)
    # polasc = Asc(ARMC - 90°, latitude)
    # Note: This is the same as coasc1 but WITHOUT the +180°
    polar_asc = _calc_ascendant(coasc_armc, eps, lat, lat)

    # Build ASCMC array with 8 elements (pyswisseph compatible)
    ascmc = [0.0] * 8
    ascmc[0] = asc
    ascmc[1] = mc
    ascmc[2] = armc_deg
    ascmc[3] = vertex
    ascmc[4] = equ_asc
    ascmc[5] = co_asc_koch  # Swiss Ephemeris: coasc1 (W. Koch) at index 5
    ascmc[6] = co_asc  # Swiss Ephemeris: coasc2 (M. Munkasey) at index 6
    ascmc[7] = polar_asc

    # House Cusps
    # Use armc_active for house calculations
    # If MC was flipped, we might need to flip latitude for intermediate cusps
    calc_lat = lat
    if armc_active != armc_deg:
        # MC was flipped. Flip latitude for intermediate cusp calculations.
        calc_lat = -lat

    # Check for polar circle condition for Placidus/Koch/Gauquelin
    # These systems cannot be calculated when abs(lat) + eps > 90°
    # Raise detailed PolarCircleError with useful information
    if hsys_char in ["P", "K", "G"] and _is_polar_circle(lat, eps):
        _raise_polar_circle_error(lat, eps, hsys_char, "swe_houses_armc")

    cusps = [0.0] * 13

    if hsys_char == "P":  # Placidus
        cusps = _houses_placidus(armc_active, lat, eps, asc, mc)
    elif hsys_char == "K":  # Koch
        cusps = _houses_koch(armc_active, lat, eps, asc, mc)
    elif hsys_char == "R":  # Regiomontanus
        cusps = _houses_regiomontanus(armc_active, calc_lat, eps, asc, mc)
    elif hsys_char == "C":  # Campanus
        cusps = _houses_campanus(armc_active, calc_lat, eps, asc, mc)
    elif hsys_char == "E":  # Equal (Ascendant)
        cusps = _houses_equal(asc)
    elif hsys_char == "A":  # Equal (MC)
        cusps = _houses_equal(asc)  # Equal MC is same as Equal Asc in SwissEph
    elif hsys_char == "W":  # Whole Sign
        cusps = _houses_whole_sign(asc)
    elif hsys_char == "O":  # Porphyry
        cusps = _houses_porphyry(asc, mc)
    elif hsys_char == "B":  # Alcabitius
        cusps = _houses_alcabitius(armc_active, lat, eps, asc, mc)
    elif hsys_char == "T":  # Polich/Page (Topocentric)
        cusps = _houses_polich_page(armc_active, calc_lat, eps, asc, mc)
    elif hsys_char == "M":  # Morinus
        cusps = _houses_morinus(armc_active, lat, eps, asc, mc)
    elif hsys_char == "X":  # Meridian (Axial)
        cusps = _houses_meridian(armc_active, lat, eps, asc, mc)
    elif hsys_char == "V":  # Vehlow
        cusps = _houses_vehlow(asc)
    elif hsys_char == "H":  # Horizontal (Azimuthal)
        cusps = _houses_horizontal(armc_active, lat, eps, asc, mc)
    elif hsys_char == "Y":  # APC Houses
        cusps = _houses_apc(armc_active, lat, eps, asc, mc)
        # APC at polar latitudes needs MC flipped in ascmc if MC is below horizon
        # (Swiss Ephemeris behavior - different from R/C/T which flip armc_active)
        mc_dec_rad = math.atan(
            math.sin(math.radians(armc_deg)) * math.tan(math.radians(eps))
        )
        lat_rad = math.radians(lat)
        sin_alt = math.sin(lat_rad) * math.sin(mc_dec_rad) + math.cos(
            lat_rad
        ) * math.cos(mc_dec_rad)
        if sin_alt < 0:
            # Flip MC in ascmc
            ascmc[1] = (ascmc[1] + 180.0) % 360.0
            # Flip all cusps by 180°
            for i in range(1, 13):
                cusps[i] = (cusps[i] + 180.0) % 360.0
    elif hsys_char == "F":  # Carter (Poli-Equatorial)
        cusps = _houses_carter(armc_active, lat, eps, asc, mc)
    elif hsys_char == "U":  # Krusinski
        cusps = _houses_krusinski(armc_active, lat, eps, asc, mc)
    elif hsys_char == "N":  # Natural Gradient
        cusps = _houses_natural_gradient(armc_active, lat, eps, asc, mc)
    elif hsys_char == "G":  # Gauquelin
        cusps = _houses_gauquelin(armc_active, lat, eps, asc, mc)
    elif hsys_char == "S":  # Sripati
        cusps = _houses_sripati(asc, mc)
    elif hsys_char == "L":  # Pullen SD (Sinusoidal Delta / Neo-Porphyry)
        cusps = _houses_pullen_sd(asc, mc)
    elif hsys_char == "Q":  # Pullen SR (Sinusoidal Ratio)
        cusps = _houses_pullen_sr(asc, mc)
    elif hsys_char == "D":  # Equal from MC
        cusps = _houses_equal_mc(asc, mc)
    elif hsys_char in ("I", "i"):  # Sunshine (Makransky)
        # Sunshine requires Sun's declination which needs JD.
        # swe_houses_armc doesn't have JD, so fall back to Porphyry.
        # Use swe_houses() for proper Sunshine calculation.
        cusps = _houses_porphyry(asc, mc)
    else:
        # Default to Placidus
        cusps = _houses_placidus(armc_active, lat, eps, asc, mc)

    # Return cusps array (pyswisseph compatible: no padding at index 0)
    # For Gauquelin ('G'), return 36 sectors; otherwise return 12 houses
    if hsys_char == "G":
        return tuple(cusps[1:37]), tuple(ascmc)
    return tuple(cusps[1:13]), tuple(ascmc)


def swe_houses_armc_ex2(
    armc: float, lat: float, eps: float, hsys: int, flags: int = 0
) -> tuple[tuple[float, ...], tuple[float, ...], tuple[float, ...], tuple[float, ...]]:
    """
    Extended house calculation from ARMC returning cusps, angles, and their velocities.

    This function combines swe_houses_armc() with velocity calculations similar to
    swe_houses_ex2(). It calculates house cusps directly from the ARMC value and
    also returns the velocities (derivatives) of house cusps and angles.

    Velocities are only calculated when the SEFLG_SPEED flag is set in the flags
    parameter. When SEFLG_SPEED is not set, zero velocities are returned for
    efficiency. This is useful for progressed chart applications where the rate
    of change of house cusps is needed.

    Velocities are calculated using centered finite differences at ±1 minute
    intervals, with ARMC shifted by ±0.25° (corresponding to 1 minute of sidereal time).

    Note: The sidereal flag (SEFLG_SIDEREAL) has no effect since sidereal mode
    requires a Julian Day for ayanamsa calculation, which is not available when
    using ARMC directly.

    Args:
        armc: Right Ascension of Medium Coeli in degrees (0-360)
        lat: Geographic latitude in degrees (positive North, negative South)
        eps: True obliquity of the ecliptic in degrees
        hsys: House system identifier (e.g., ord('P') for Placidus, ord('K') for Koch)
        flags: Calculation flags bitmask. Use SEFLG_SPEED to compute velocities.

    Returns:
        Tuple containing:
            - cusps: Tuple of 12 house cusp longitudes in degrees
            - ascmc: Tuple of 8 angles (Asc, MC, ARMC, Vertex, EquAsc, CoAsc, CoAscKoch, PolarAsc)
            - cusps_speed: Tuple of 12 house cusp velocities in degrees/day (0.0 if SEFLG_SPEED not set)
            - ascmc_speed: Tuple of 8 angle velocities in degrees/day (0.0 if SEFLG_SPEED not set)

    Example:
        >>> eps = 23.4393  # true obliquity
        >>> armc = 292.957  # ARMC in degrees
        >>> cusps, ascmc, cusps_speed, ascmc_speed = swe_houses_armc_ex2(
        ...     armc, 41.9, eps, ord('P'), SEFLG_SPEED
        ... )
        >>> # cusps_speed[0] is the velocity of the 1st house cusp (same as ASC)
    """
    # Calculate positions at current ARMC
    cusps, ascmc = swe_houses_armc(armc, lat, eps, hsys)

    # Only calculate velocities if SEFLG_SPEED flag is set
    if flags & SEFLG_SPEED:
        # ARMC shift for 1 minute of time: 360° / (24 * 60 min) = 0.25° per minute
        # This corresponds to dt = 1/1440 days used in swe_houses_ex2
        d_armc = 360.0 / (24.0 * 60.0)  # 0.25° = 1 minute in ARMC
        dt = 1.0 / 1440.0  # 1 minute in days (for velocity calculation)

        # Calculate positions at ARMC - d_armc (1 minute earlier)
        cusps_before, ascmc_before = swe_houses_armc(armc - d_armc, lat, eps, hsys)

        # Calculate positions at ARMC + d_armc (1 minute later)
        cusps_after, ascmc_after = swe_houses_armc(armc + d_armc, lat, eps, hsys)

        # Calculate velocities using centered finite differences
        # velocity = (pos_after - pos_before) / (2 * dt)
        # Result is in degrees/day

        def angular_diff_local(pos2: float, pos1: float) -> float:
            """Calculate angular difference handling 360° wraparound."""
            diff = pos2 - pos1
            if diff > 180:
                diff -= 360
            elif diff < -180:
                diff += 360
            return diff

        # Calculate cusp velocities
        cusps_speed = tuple(
            angular_diff_local(cusps_after[i], cusps_before[i]) / (2 * dt)
            for i in range(len(cusps))
        )

        # Calculate ascmc velocities
        ascmc_speed = tuple(
            angular_diff_local(ascmc_after[i], ascmc_before[i]) / (2 * dt)
            for i in range(len(ascmc))
        )
    else:
        # Return zero velocities when SEFLG_SPEED is not set
        cusps_speed = tuple(0.0 for _ in range(len(cusps)))
        ascmc_speed = tuple(0.0 for _ in range(len(ascmc)))

    return cusps, ascmc, cusps_speed, ascmc_speed


def swe_houses_ex(
    tjdut: float, lat: float, lon: float, hsys: int, flags: int = 0
) -> tuple[tuple[float, ...], tuple[float, ...]]:
    """
    Extended house calculation with sidereal zodiac support.

    Similar to swe_houses() but applies ayanamsa correction when SEFLG_SIDEREAL
    flag is set, converting tropical positions to sidereal.

    For Ascendant-based house systems (Whole Sign, Equal, Vehlow), the cusps
    are recalculated using the sidereal Ascendant to ensure proper sign alignment.

    Args:
        tjdut: Julian Day in Universal Time (UT1)
        lat: Geographic latitude in degrees
        lon: Geographic longitude in degrees
        hsys: House system identifier (int or bytes)
        flags: Calculation flags bitmask (e.g., SEFLG_SIDEREAL, SEFLG_MOSEPH).
               Use SEFLG_MOSEPH for extended date range (-3000 to +3000 CE).
               The ephemeris flag is propagated to all internal calculations.

    Returns:
        Tuple containing:
            - cusps: Tuple of 12 house cusp longitudes in degrees
            - ascmc: Tuple of 8 angles (Asc, MC corrected if sidereal)

    Example:
        >>> from libephemeris import swe_set_sid_mode, SE_SIDM_LAHIRI
        >>> swe_set_sid_mode(SE_SIDM_LAHIRI)
        >>> cusps, ascmc = swe_houses_ex(2451545.0, 51.5, -0.12, ord('P'), SEFLG_SIDEREAL)
    """
    # Propagate flags to swe_houses() so ephemeris flags (SEFLG_MOSEPH etc.) are used
    cusps, ascmc = swe_houses(tjdut, lat, lon, hsys, flags)

    if flags & SEFLG_SIDEREAL:
        ayanamsa = swe_get_ayanamsa_ut(tjdut)

        # First compute sidereal angles
        ascmc_list = list(ascmc)
        sid_asc = (ascmc_list[0] - ayanamsa) % 360.0
        sid_mc = (ascmc_list[1] - ayanamsa) % 360.0
        ascmc_list[0] = sid_asc
        ascmc_list[1] = sid_mc
        ascmc = tuple(ascmc_list)

        # Normalize hsys to a character for comparison
        if isinstance(hsys, int):
            hsys_char = chr(hsys)
        elif isinstance(hsys, bytes):
            hsys_char = hsys.decode("utf-8")
        else:
            hsys_char = str(hsys)

        # For Ascendant-based house systems, recalculate using sidereal Ascendant
        # Whole Sign (W), Equal (A/E), Vehlow (V)
        if hsys_char == "W":
            # Whole Sign: each house is 0° of consecutive signs from Asc sign
            # cusps[0] = House 1, cusps[1] = House 2, etc.
            start = math.floor(sid_asc / 30.0) * 30.0
            cusps = tuple([(start + i * 30.0) % 360.0 for i in range(12)])
        elif hsys_char in ("A", "E"):
            # Equal: 30° divisions starting from sidereal Asc
            cusps = tuple([(sid_asc + i * 30.0) % 360.0 for i in range(12)])
        elif hsys_char == "V":
            # Vehlow Equal: sidereal Asc at middle of 1st house
            start = (sid_asc - 15.0) % 360.0
            cusps = tuple([(start + i * 30.0) % 360.0 for i in range(12)])
        elif hsys_char in ("I", "i"):
            # Sunshine (Makransky): Recalculate using sidereal Asc/MC
            # The Sun's declination and ARMC are geometric (not zodiacal),
            # so they remain unchanged. We need to recalculate the cusps
            # using the sidereal Ascendant and MC.
            try:
                # Get Sun's declination (geometric, not affected by sidereal)
                # Propagate ephemeris flags to swe_calc_ut
                eph_flags = flags & (SEFLG_JPLEPH | SEFLG_SWIEPH)
                sun_pos, _ = swe_calc_ut(tjdut, SE_SUN, SEFLG_EQUATORIAL | eph_flags)
                sun_dec = sun_pos[1]
            except Exception:
                sun_dec = 0.0
            # Get obliquity for Sunshine calculation
            ts = get_timescale()
            t = ts.ut1_jd(tjdut)
            eps = get_true_obliquity(t.tt)
            # ARMC is geometric, not zodiacal (from original ascmc)
            armc = ascmc[2]
            # Recalculate Sunshine cusps with sidereal Asc and MC
            sunshine_cusps = _houses_sunshine(armc, lat, eps, sid_asc, sid_mc, sun_dec)
            # Angular cusps (1, 4, 7, 10) are already correct with sid_asc/sid_mc
            # Intermediate cusps (2, 3, 5, 6, 8, 9, 11, 12) are calculated
            # geometrically and need to be converted to sidereal
            intermediate_houses = {2, 3, 5, 6, 8, 9, 11, 12}  # 1-indexed
            cusps = tuple(
                (sunshine_cusps[i] - ayanamsa) % 360.0
                if i in intermediate_houses
                else sunshine_cusps[i]
                for i in range(1, 13)
            )
        else:
            # For other systems, just subtract ayanamsa from tropical cusps
            cusps = tuple([(c - ayanamsa) % 360.0 for c in cusps])

    return cusps, ascmc


def swe_houses_ex2(
    tjdut: float, lat: float, lon: float, hsys: int, flags: int = 0
) -> tuple[tuple[float, ...], tuple[float, ...], tuple[float, ...], tuple[float, ...]]:
    """
    Extended house calculation returning cusps, angles, and their velocities.

    This function is an extended version of swe_houses_ex() that also returns
    the velocities (derivatives) of house cusps and angles.

    Velocities are only calculated when the SEFLG_SPEED flag is set in the flags
    parameter. When SEFLG_SPEED is not set, zero velocities are returned for
    efficiency. This is useful for progressed chart applications where the rate
    of change of house cusps is needed.

    Velocities are calculated using centered finite differences at ±1 minute
    intervals.

    Args:
        tjdut: Julian Day in Universal Time (UT1)
        lat: Geographic latitude in degrees
        lon: Geographic longitude in degrees
        hsys: House system identifier (int or bytes)
        flags: Calculation flags bitmask. Use SEFLG_SPEED to compute velocities.
               SEFLG_SIDEREAL can also be used for sidereal calculations.

    Returns:
        Tuple containing:
            - cusps: Tuple of 12 house cusp longitudes in degrees
            - ascmc: Tuple of 8 angles (Asc, MC, etc.)
            - cusps_speed: Tuple of 12 house cusp velocities in degrees/day (0.0 if SEFLG_SPEED not set)
            - ascmc_speed: Tuple of 8 angle velocities in degrees/day (0.0 if SEFLG_SPEED not set)

    Example:
        >>> cusps, ascmc, cusps_speed, ascmc_speed = swe_houses_ex2(
        ...     2451545.0, 41.9, 12.5, ord('P'), SEFLG_SPEED
        ... )
        >>> # cusps_speed[0] is the velocity of the 1st house cusp (ASC)
    """
    # Calculate positions at current time
    cusps, ascmc = swe_houses_ex(tjdut, lat, lon, hsys, flags)

    # Only calculate velocities if SEFLG_SPEED flag is set
    if flags & SEFLG_SPEED:
        # Time step for finite differences: 1 minute = 1/1440 days
        dt = 1.0 / 1440.0

        # Calculate positions at t - dt
        cusps_before, ascmc_before = swe_houses_ex(tjdut - dt, lat, lon, hsys, flags)

        # Calculate positions at t + dt
        cusps_after, ascmc_after = swe_houses_ex(tjdut + dt, lat, lon, hsys, flags)

        # Calculate velocities using centered finite differences
        # velocity = (pos_after - pos_before) / (2 * dt)
        # Convert to degrees/day: dt is in days, so result is already in deg/day

        def angular_diff(pos2: float, pos1: float) -> float:
            """Calculate angular difference handling 360° wraparound."""
            diff = pos2 - pos1
            if diff > 180:
                diff -= 360
            elif diff < -180:
                diff += 360
            return diff

        # Calculate cusp velocities
        cusps_speed = tuple(
            angular_diff(cusps_after[i], cusps_before[i]) / (2 * dt)
            for i in range(len(cusps))
        )

        # Calculate ascmc velocities
        ascmc_speed = tuple(
            angular_diff(ascmc_after[i], ascmc_before[i]) / (2 * dt)
            for i in range(len(ascmc))
        )
    else:
        # Return zero velocities when SEFLG_SPEED is not set
        cusps_speed = tuple(0.0 for _ in range(len(cusps)))
        ascmc_speed = tuple(0.0 for _ in range(len(ascmc)))

    return cusps, ascmc, cusps_speed, ascmc_speed


def _swe_houses_with_context(
    tjdut: float, lat: float, lon: float, hsys: int, ctx
) -> tuple[tuple[float, ...], tuple[float, ...]]:
    """
    Calculate houses using an explicit EphemerisContext (thread-safe).

    Thread-safe wrapper around swe_houses that uses context state.

    Args:
        tjdut: Julian Day in Universal Time
        lat: Geographic latitude in degrees
        lon: Geographic longitude in degrees
        hsys: House system identifier
        ctx: EphemerisContext instance

    Returns:
        Same as swe_houses: (cusps, ascmc)

    Thread Safety:
        This function acquires state._CONTEXT_SWAP_LOCK to ensure that the
        save-set-restore cycle is atomic across threads.
    """
    from . import state

    with state._CONTEXT_SWAP_LOCK:
        # Save current global state
        old_sid_mode = state._SIDEREAL_MODE
        old_sid_t0 = state._SIDEREAL_T0
        old_sid_ayan_t0 = state._SIDEREAL_AYAN_T0

        try:
            # Temporarily set global state from context
            state._SIDEREAL_MODE = ctx.sidereal_mode
            state._SIDEREAL_T0 = ctx.sidereal_t0
            state._SIDEREAL_AYAN_T0 = ctx.sidereal_ayan_t0

            # Use existing house calculation logic
            return swe_houses(tjdut, lat, lon, hsys)
        finally:
            # Restore global state
            state._SIDEREAL_MODE = old_sid_mode
            state._SIDEREAL_T0 = old_sid_t0
            state._SIDEREAL_AYAN_T0 = old_sid_ayan_t0


def swe_house_name(hsys: int) -> str:
    """
    Get the name of a house system.
    """
    hsys_char: str
    if isinstance(hsys, int):
        hsys_char = chr(hsys)
    elif isinstance(hsys, bytes):
        hsys_char = hsys.decode("utf-8")
    else:
        hsys_char = str(hsys)

    names = {
        "P": "Placidus",
        "K": "Koch",
        "O": "Porphyry",
        "R": "Regiomontanus",
        "C": "Campanus",
        "E": "Equal (Asc)",
        "A": "Equal (MC)",
        "W": "Whole Sign",
        "M": "Morinus",
        "B": "Alcabitius",
        "T": "Polich/Page",
        "U": "Krusinski",
        "G": "Gauquelin",
        "V": "Vehlow",
        "X": "Meridian",
        "H": "Horizontal",
        "F": "Carter",
        "S": "Sripati",
        "L": "Pullen SD",
        "Q": "Pullen SR",
        "N": "Natural Gradient",
        "Y": "APC",
        "D": "Equal (MC)",
        "I": "Sunshine",
        "i": "Sunshine (alt)",
    }
    return names.get(hsys_char, "Unknown")


def _houses_placidus(
    armc: float, lat: float, eps: float, asc: float, mc: float
) -> List[float]:
    """
    Placidus house system (time-based divisions of diurnal/nocturnal arcs).

    Most popular house system in modern Western astrology. Divides the time a point
    takes to travel from horizon to meridian (and meridian to horizon) into thirds.

    Algorithm:
        1. Trisect semi-diurnal arc (rising to culmination) for houses 11, 12
        2. Trisect semi-nocturnal arc (setting to anti-culmination) for houses 2, 3
        3. Use iterative solution to find ecliptic longitude at each time division
        4. Calculate opposite houses by adding 180°

    Mathematical Formulas:
        Ascensional Difference: AD = arcsin(tan(φ) · tan(δ))
        Semi-diurnal Arc: SA = 90° + AD (above horizon)
        Semi-nocturnal Arc: SA = 90° - AD (below horizon)

        House 11: H₁₁ = SA/3 = (90° + AD)/3; RA₁₁ = ARMC + H₁₁
        House 12: H₁₂ = 2·SA/3 = 2·(90° + AD)/3; RA₁₂ = ARMC + H₁₂
        House 2:  H₂ = 2·(90° - AD)/3; RA₂ = ARMC + 180° - H₂
        House 3:  H₃ = (90° - AD)/3; RA₃ = ARMC + 180° - H₃

        Iterative convergence (threshold 1e-7°):
            1. tan(δ) = sin(RA) · tan(ε)
            2. AD = arcsin(tan(φ) · tan(δ))
            3. RA_new = ARMC ± H(AD)
            4. Repeat until |RA_new - RA| < 1e-7°

        RA to ecliptic: λ = atan2(sin(RA), cos(RA) · cos(ε))

    Note: Polar latitude handling
        Placidus is undefined at latitudes > ~66° where some ecliptic points never
        rise/set (circumpolar behavior). At polar latitudes, swe_houses() raises
        PolarCircleError with detailed information about the threshold and
        recommended alternatives. Use swe_houses_with_fallback() for automatic
        fallback to Porphyry. The polar threshold is approximately 90° - obliquity.
        Internal fallback to Porphyry is retained as a safety net.

    Args:
        armc: Sidereal time at Greenwich (RAMC) in degrees
        lat: Geographic latitude in degrees
        eps: True obliquity of ecliptic in degrees
        asc: Ascendant longitude in degrees (house 1)
        mc: Midheaven longitude in degrees (house 10)

    Returns:
        List of 13 house cusp longitudes (index 0 is 0.0, indices 1-12 are cusps)
    """
    # Actually, standard Placidus:
    # 11, 12 are in SE quadrant (MC to Asc).
    # 2, 3 are in NE quadrant (Asc to IC).

    cusps = [0.0] * 13
    cusps[1] = asc
    cusps[10] = mc
    cusps[7] = (asc + 180) % 360.0
    cusps[4] = (mc + 180) % 360.0

    rad_lat = math.radians(lat)
    rad_eps = math.radians(eps)

    # Helper to find cusp
    # factor: 1.0/3.0 for 11 and 3, 2.0/3.0 for 12 and 2
    # quadrant: 1 for 11/12, 2 for 2/3?
    # Actually, we solve for RA.

    def iterate_placidus(offset_deg, is_below_horizon):
        # Initial guess: RAMC + offset
        # For 11: offset = 30
        # For 12: offset = 60
        # For 2: offset = 120
        # For 3: offset = 150

        ra = (armc + offset_deg) % 360.0
        new_ra = ra  # Initialize for type safety
        prev_diff = float("inf")  # Track convergence/divergence
        converged = False

        # Convergence threshold: 1e-7° = 0.00036 arcsec (matches Swiss Ephemeris)
        CONVERGENCE_THRESHOLD = 1e-7
        # Maximum iterations - if not converged by then, likely diverging
        MAX_ITERATIONS = 50

        # Iterate to convergence (typically 15-20 iterations needed for 1e-7° threshold)
        for iteration in range(MAX_ITERATIONS):
            # Calculate declination for point at this RA on ecliptic
            # Using spherical astronomy formula: tan(dec) = sin(RA) * tan(eps)

            sin_ra = math.sin(math.radians(ra))
            tan_dec = sin_ra * math.tan(rad_eps)
            dec = math.atan(tan_dec)

            # Calculate semi-arc (or part of it)
            # tan(lat) * tan(dec)
            # Check bounds
            prod = math.tan(rad_lat) * tan_dec
            if abs(prod) > 1.0:
                # Circumpolar / fail
                return None

            # AD (Ascensional Difference) = asin(prod)
            # SA (Semi-Arc) = 90 + AD (if decl north and lat north)
            # H = RAMC - RA
            # Placidus condition:
            # H = f * SA?
            # Or sin(H) = f * sin(SA)? No.
            # Placidus: H is proportional to SA.
            # H = (offset / 90) * SA?
            # For 11 (30 deg from MC): H = SA/3.
            # For 12 (60 deg from MC): H = 2*SA/3.

            # Wait, offset 30 means 30 degrees of RA? No.
            # It implies the trisection.
            # Factor f.

            f = 1.0
            if offset_deg == 30 or offset_deg == 210:
                f = 1.0 / 3.0
            if offset_deg == 60 or offset_deg == 240:
                f = 2.0 / 3.0
            if offset_deg == 120 or offset_deg == 300:
                f = 2.0 / 3.0  # From IC?
            if offset_deg == 150 or offset_deg == 330:
                f = 1.0 / 3.0

            # If below horizon (houses 2, 3), semi-arc is nocturnal.
            # SA_noct = 180 - SA_diurnal = 90 - AD.
            # H is measured from IC (RAMC + 180).

            ad = math.asin(prod)

            if is_below_horizon:
                # Houses 2, 3
                # Measured from IC (RAMC + 180)
                # H = f * (90 - AD)
                # RA = IC - H = RAMC + 180 - H?
                # Or RA = IC + H?
                # House 2 is East of IC. RA > IC.
                # RA = RAMC + 180 + f * (90 - AD) ?
                # No, House 2 is "following" IC.
                # Let's stick to standard formula:
                # R = RAMC + 180 + f * (90 + AD)?
                # Note: AD has sign of dec.

                # Let's use the formula:
                # R = RAMC + 180 + f * (90 - AD) ?
                # If lat > 0, dec > 0, AD > 0.
                # Nocturnal arc = 180 - (90 + AD) = 90 - AD.
                # House 2 is 2/3 of way from Asc to IC? No.
                # House 2 is 1/3 of way from IC to Asc? No.
                # House 2 start is 2/3 SA_noct from IC?
                # House 3 start is 1/3 SA_noct from IC?

                # Correct mapping:
                # 11: RAMC + SA/3
                # 12: RAMC + 2*SA/3
                # 2: RAMC + 180 - 2*SA_noct/3 ? No.
                # 2 is after Asc (House 1).
                # Asc is at RAMC + 90 + AD? No.

                # Let's use the standard iterative formula directly:
                # RA_new = RAMC + const + AD? No.

                pass

            # Simplified Placidus Iteration:
            # R(n+1) = RAMC + asin( tan(lat)*tan(dec(Rn)) * factor ) + base_offset
            # Where factor depends on house.
            # House 11: factor = 1/3? No.
            # House 11 condition: sin(RA - RAMC) = tan(dec)*tan(lat)/3 ? No.
            # The condition is on time.
            # H = RA - RAMC.
            # H = SA / 3.
            # SA = 90 + AD.
            # H = 30 + AD/3 ? No.

            # Correct Placidus Formula (from Munkasey):
            # House 11: tan(H) = f * tan(H_Asc)? No.

            # Let's use the "Pole" method which is equivalent.
            # tan(Pole) = sin(HouseAngle) * tan(Lat) ? No.

            # Let's go back to basics:
            # House 11: 1/3 of semi-arc from MC.
            # H = (90 + AD) / 3.
            # RA = RAMC - H (since 11 is East of MC).
            # RA = RAMC - (90 + asin(tan(lat)tan(dec))) / 3.

            # House 12: 2/3 of semi-arc.
            # RA = RAMC - 2*(90 + asin(tan(lat)tan(dec))) / 3.

            # House 2: 2/3 of nocturnal semi-arc from IC (West of IC? No, East).
            # House 2 is below horizon, West of Asc? No, East.
            # 1 -> 2 -> 3 -> 4(IC).
            # House 2 is 1/3 of way from Asc to IC? No.
            # House 2 cusp is 2/3 of semi-arc from IC?
            # SA_noct = 90 - AD.
            # H_from_IC = 2 * SA_noct / 3.
            # RA = (RAMC + 180) - H_from_IC.
            # RA = RAMC + 180 - 2*(90 - AD)/3.

            # House 3: 1/3 of semi-arc from IC.
            # RA = RAMC + 180 - 1*(90 - AD)/3.

            # Let's verify signs.
            # 11 is SE. MC is S. 11 is East of S. RA < RAMC?
            # No, stars rise in East, RA increases Eastwards?
            # RA increases Eastwards.
            # MC is on Meridian.
            # Point East of Meridian has RA > RAMC?
            # H = LST - RA.
            # East of Meridian -> H < 0.
            # So RA > LST.
            # So House 11 (SE) has RA > RAMC?
            # No, House 11 is "before" MC in diurnal motion.
            # Sun is in 11 before it culminates.
            # So RA_Sun > RAMC? No.
            # H = LST - RA.
            # If Sun is East, H is negative (e.g. -2h).
            # RA = LST - H = LST + 2h.
            # So RA > RAMC.

            # So for House 11:
            # H_from_MC = SA / 3.
            # RA = RAMC + H_from_MC = RAMC + (90 + AD)/3.

            # House 12:
            # RA = RAMC + 2*(90 + AD)/3.

            # House 2:
            # Below horizon.
            # H_from_IC = 2 * SA_noct / 3.
            # RA = (RAMC + 180) + H_from_IC.
            # RA = RAMC + 180 + 2*(90 - AD)/3.

            # House 3:
            # RA = RAMC + 180 + 1*(90 - AD)/3.

            # Let's implement this.

            ad = math.asin(prod)
            ad_deg = math.degrees(ad)

            if offset_deg == 30:  # House 11
                h_deg = (90.0 + ad_deg) / 3.0
                new_ra = (armc - h_deg) % 360.0  # Wait, 11 is East of MC.
                # If RA > RAMC, H < 0.
                # H = RAMC - RA.
                # If H is "distance from MC", then RA = RAMC - H (if East).
                # Wait.
                # Sun rises. H = -SA. RA = RAMC + SA.
                # Sun culminates. H = 0. RA = RAMC.
                # House 11 is between Rise and Culminate.
                # So RA should be between RAMC+SA and RAMC.
                # So RA > RAMC.
                # So RA = RAMC + part_of_SA.
                new_ra = (
                    armc + h_deg
                ) % 360.0  # Wait, H is usually defined positive West.
                # If H is positive West, then East is negative H.
                # H = RAMC - RA.
                # RA = RAMC - H.
                # If we want East, H must be negative.
                # H = - (SA/3).
                # RA = RAMC - (-SA/3) = RAMC + SA/3.
                # Correct.

                # But wait, standard Placidus 11th cusp is usually *South-East*.
                # It is 30 degrees "above" Ascendant? No.
                # It is 30 degrees "before" MC?
                # House 10 starts at MC. House 11 starts at Cusp 11.
                # Order: 10, 11, 12, 1.
                # 10 is MC. 1 is Asc.
                # So 11 is between MC and Asc.
                # So RA is between RAMC and RAMC+SA.
                # So RA = RAMC + SA/3?
                # No, 10 is MC. 11 is next.
                # So 11 is "later" in RA?
                # Houses increase in counter-clockwise direction on Ecliptic.
                # 10 (MC) -> 11 -> 12 -> 1 (Asc).
                # MC RA approx 270 (if Aries rising). Asc RA approx 0.
                # So RA increases from 10 to 1.
                # So RA_11 > RA_10.
                # So RA_11 = RAMC + something.
                # Correct.

                new_ra = (armc + h_deg) % 360.0

            elif offset_deg == 60:  # House 12
                h_deg = 2.0 * (90.0 + ad_deg) / 3.0
                new_ra = (armc + h_deg) % 360.0

            elif offset_deg == 120:  # House 2
                # House 2 is after Asc (1).
                # 1 -> 2 -> 3 -> 4 (IC).
                # Asc RA approx 0. IC RA approx 90.
                # So RA increases.
                # RA_2 = RAMC + 180 - something?
                # IC is RAMC + 180.
                # House 2 is "before" IC.
                # So RA_2 < RA_IC.
                # So RA_2 = RAMC + 180 - H_from_IC.
                # H_from_IC = 2 * SA_noct / 3.
                # SA_noct = 90 - AD_deg.
                h_deg = 2.0 * (90.0 - ad_deg) / 3.0
                new_ra = (armc + 180.0 - h_deg) % 360.0

            elif offset_deg == 150:  # House 3
                h_deg = 1.0 * (90.0 - ad_deg) / 3.0
                new_ra = (armc + 180.0 - h_deg) % 360.0

            else:
                # Should never reach here - all valid offsets are handled above
                new_ra = ra  # Fallback to prevent unbound variable error

            # Update RA
            diff = abs(new_ra - ra)
            if diff > 180:
                diff = 360 - diff
            ra = new_ra

            # Check for convergence
            if diff < CONVERGENCE_THRESHOLD:
                converged = True
                break

            # Check for divergence: if diff is increasing, iteration is unstable
            # Allow some oscillation (diff can increase slightly) but detect runaway
            if iteration > 5 and diff > prev_diff * 1.5:
                # Diverging - this happens near polar latitudes
                return None

            prev_diff = diff

        # If we exhausted iterations without converging, return None for fallback
        if not converged:
            return None

        # Converged RA. Find Ecliptic Longitude.
        # tan(lon) = tan(ra) / cos(eps)
        # Use atan2 to handle quadrants correctly
        # sin(lon) ~ sin(ra)
        # cos(lon) ~ cos(ra) * cos(eps)
        y = math.sin(math.radians(ra))
        x = math.cos(math.radians(ra)) * math.cos(rad_eps)
        lon = math.degrees(math.atan2(y, x))

        return lon % 360.0

    # Calculate cusps
    c11 = iterate_placidus(30, False)
    c12 = iterate_placidus(60, False)
    c2 = iterate_placidus(120, True)
    c3 = iterate_placidus(150, True)

    if c11 is None or c12 is None or c2 is None or c3 is None:
        # Fallback to Porphyry or Equal if Placidus fails (high latitude)
        return _houses_porphyry(asc, mc)

    cusps[11] = c11
    cusps[12] = c12
    cusps[2] = c2
    cusps[3] = c3

    # Opposites
    cusps[5] = (c11 + 180) % 360.0
    cusps[6] = (c12 + 180) % 360.0
    cusps[8] = (c2 + 180) % 360.0
    cusps[9] = (c3 + 180) % 360.0

    return cusps


def _houses_koch(
    armc: float, lat: float, eps: float, asc: float, mc: float
) -> List[float]:
    """
    Koch (Birthplace/GOH) house system.

    The Koch system (Geburtsort-Häusersystem = Birthplace House System) was
    developed by Walter Koch. It divides houses based on the ascensional
    difference of the MC's declination.

    Algorithm (from Swiss Ephemeris swehouse.c):
        1. Calculate sin(A) = sin(MC) * sin(eps) / cos(lat)
        2. Calculate c = atan(tan(lat) / cos(A))
        3. Calculate ad3 = asin(sin(c) * sin(A)) / 3
        4. Calculate cusps using Asc1():
           - cusp[11] = Asc1(ARMC + 30 - 2*ad3, lat, sine, cose)
           - cusp[12] = Asc1(ARMC + 60 - ad3, lat, sine, cose)
           - cusp[2]  = Asc1(ARMC + 120 + ad3, lat, sine, cose)
           - cusp[3]  = Asc1(ARMC + 150 + 2*ad3, lat, sine, cose)

    Note: Polar latitude handling
        Koch is undefined at latitudes > ~66° where some ecliptic points never
        rise/set (circumpolar behavior). At polar latitudes, swe_houses() raises
        PolarCircleError with detailed information about the threshold and
        recommended alternatives. Use swe_houses_with_fallback() for automatic
        fallback to Porphyry. The polar threshold is approximately 90° - obliquity.
        Internal fallback to Porphyry is retained as a safety net.

    Args:
        armc: Sidereal time at Greenwich (RAMC) in degrees
        lat: Geographic latitude in degrees
        eps: True obliquity of ecliptic in degrees
        asc: Ascendant longitude in degrees
        mc: Midheaven longitude in degrees

    Returns:
        List of 13 house cusp longitudes
    """
    cusps = [0.0] * 13
    cusps[1] = asc
    cusps[10] = mc
    cusps[7] = (asc + 180) % 360.0
    cusps[4] = (mc + 180) % 360.0

    # Check for polar circle - Koch undefined at |lat| >= 90 - eps
    if abs(lat) >= 90 - eps:
        return _houses_porphyry(asc, mc)

    # Precompute trig values
    sine = math.sin(math.radians(eps))
    tanfi = math.tan(math.radians(lat))
    cosfi = math.cos(math.radians(lat))

    # sina = sin(MC) * sin(eps) / cos(lat)
    sina = math.sin(math.radians(mc)) * sine / cosfi

    # Clamp for safety
    if sina > 1.0:
        sina = 1.0
    if sina < -1.0:
        sina = -1.0

    # cosa = sqrt(1 - sina^2), always > 0
    cosa = math.sqrt(1.0 - sina * sina)

    # c = atan(tan(lat) / cos(A))
    if cosa < 1e-10:
        # Edge case: cos(A) ~ 0
        c = 90.0 if tanfi >= 0 else -90.0
    else:
        c = math.degrees(math.atan(tanfi / cosa))

    # ad3 = asin(sin(c) * sin(A)) / 3
    sinc = math.sin(math.radians(c))
    ad3_arg = sinc * sina
    if ad3_arg > 1.0:
        ad3_arg = 1.0
    if ad3_arg < -1.0:
        ad3_arg = -1.0
    ad3 = math.degrees(math.asin(ad3_arg)) / 3.0

    # Calculate cusps using Asc1
    # th = ARMC (the Right Ascension of Medium Coeli)
    th = armc

    cusps[11] = _calc_ascendant(th + 30 - 2 * ad3, eps, lat, lat)
    cusps[12] = _calc_ascendant(th + 60 - ad3, eps, lat, lat)
    cusps[2] = _calc_ascendant(th + 120 + ad3, eps, lat, lat)
    cusps[3] = _calc_ascendant(th + 150 + 2 * ad3, eps, lat, lat)

    # Opposite houses
    cusps[5] = (cusps[11] + 180) % 360.0
    cusps[6] = (cusps[12] + 180) % 360.0
    cusps[8] = (cusps[2] + 180) % 360.0
    cusps[9] = (cusps[3] + 180) % 360.0

    return cusps


def _houses_regiomontanus(
    armc: float, lat: float, eps: float, asc: float, mc: float
) -> List[float]:
    """
    Regiomontanus (Medieval rational) house system.

    Divides the celestial equator into 12 equal 30° arcs, then projects these
    divisions onto the ecliptic using great circles through the celestial poles.

    Algorithm:
        1. Divide equator into 30° segments from MC
        2. For each segment, calculate pole: tan(Pole) = tan(lat) * sin(H)
        3. Project to ecliptic using spherical trigonometry
        4. Calculate cusp longitude from pole and RAMC offset

    Mathematical Formulas:
        Hour angle offset: H = 30°, 60°, 120°, 150° for houses 11, 12, 2, 3

        Pole of projection:
            tan(P) = tan(φ) · sin(H)

        Right Ascension offset:
            R = ARMC + H - 90°

        Ecliptic longitude:
            λ = atan2(cos(R), -(sin(R)·cos(ε) + tan(P)·sin(ε)))

        Opposite houses: λ_{i+6} = (λᵢ + 180°) mod 360°

    Args:
        armc: Sidereal time at Greenwich (RAMC) in degrees
        lat: Geographic latitude in degrees
        eps: True obliquity of ecliptic in degrees
        asc: Ascendant longitude in degrees
        mc: Midheaven longitude in degrees

    Returns:
        List of 13 house cusp longitudes
    """

    cusps = [0.0] * 13
    cusps[1] = asc
    cusps[10] = mc
    cusps[7] = (asc + 180) % 360.0
    cusps[4] = (mc + 180) % 360.0

    rad_lat = math.radians(lat)
    rad_eps = math.radians(eps)

    def calc_cusp(offset_deg):
        h_rad = math.radians(offset_deg)
        tan_pole = math.tan(rad_lat) * math.sin(h_rad)

        # R = RAMC + offset - 90
        r_deg = (armc + offset_deg - 90.0) % 360.0
        r_rad = math.radians(r_deg)

        # Flip signs for East intersection (Ascendant formula)
        num = math.cos(r_rad)
        den = -(math.sin(r_rad) * math.cos(rad_eps) + tan_pole * math.sin(rad_eps))

        lon = math.degrees(math.atan2(num, den))
        return lon % 360.0

    cusps[11] = calc_cusp(30)
    cusps[12] = calc_cusp(60)
    cusps[2] = calc_cusp(120)
    cusps[3] = calc_cusp(150)

    cusps[5] = (cusps[11] + 180) % 360.0
    cusps[6] = (cusps[12] + 180) % 360.0
    cusps[8] = (cusps[2] + 180) % 360.0
    cusps[9] = (cusps[3] + 180) % 360.0

    return cusps


def _houses_campanus(
    armc: float, lat: float, eps: float, asc: float, mc: float
) -> List[float]:
    """
    Campanus (Prime Vertical) house system.

    Divides the prime vertical (great circle through zenith and east/west points)
    into 12 equal 30° arcs, then projects onto the ecliptic.

    Algorithm:
        1. Divide prime vertical into 30° segments
        2. For each segment, calculate azimuth and altitude
        3. Transform to equatorial coordinates
        4. Project to ecliptic longitude

    Mathematical Formulas:
        Prime vertical offset: h_pv = 30°, 60°, 120°, 150°

        Prime vertical to hour angle transformation:
            tan(H_eff) = tan(h_pv) · cos(φ)
            (Adjust H_eff by +180° if h_pv > 90°)

        Pole calculation:
            tan(P) = tan(φ) · sin(H_eff)

        Right Ascension offset:
            R = ARMC + H_eff - 90°

        Ecliptic projection:
            λ = atan2(cos(R), -(sin(R)·cos(ε) + tan(P)·sin(ε)))

    Args:
        armc: Sidereal time at Greenwich (RAMC) in degrees
        lat: Geographic latitude in degrees
        eps: True obliquity of ecliptic in degrees
        asc: Ascendant longitude in degrees
        mc: Midheaven longitude in degrees

    Returns:
        List of 13 house cusp longitudes
    """
    # Campanus
    # House circles pass through North and South points of Horizon.
    # They divide the Prime Vertical into 30 degree segments.
    # We map the Prime Vertical division h to an Equatorial division H_eff.
    # tan(H_eff) = tan(h) * cos(lat)

    cusps = [0.0] * 13
    cusps[1] = asc
    cusps[10] = mc
    cusps[7] = (asc + 180) % 360.0
    cusps[4] = (mc + 180) % 360.0

    rad_lat = math.radians(lat)
    rad_eps = math.radians(eps)
    cos_lat = math.cos(rad_lat)

    def calc_cusp(prime_vert_offset):
        # prime_vert_offset: 30, 60, ...
        h_pv_rad = math.radians(prime_vert_offset)

        # Calculate H_eff
        # tan(H_eff) = tan(h_pv) * cos(lat)
        tan_h_eff = math.tan(h_pv_rad) * cos_lat
        h_eff = math.atan(tan_h_eff)

        # Quadrant of H_eff should match h_pv?
        # Yes, both in [0, 90] or [90, 180].
        if prime_vert_offset > 90:
            h_eff += math.pi

        # Now use Regiomontanus logic with H_eff
        # tan(Pole) = tan(lat) * sin(H_eff)
        sin_h_eff = math.sin(h_eff)
        tan_pole = math.tan(rad_lat) * sin_h_eff

        # R = RAMC + H_eff - 90
        h_eff_deg = math.degrees(h_eff)
        r_deg = (armc + h_eff_deg - 90.0) % 360.0
        r_rad = math.radians(r_deg)

        # Flip signs for East intersection
        num = math.cos(r_rad)
        den = -(math.sin(r_rad) * math.cos(rad_eps) + tan_pole * math.sin(rad_eps))

        lon = math.degrees(math.atan2(num, den))
        return lon % 360.0

    cusps[11] = calc_cusp(30)
    cusps[12] = calc_cusp(60)
    cusps[2] = calc_cusp(120)
    cusps[3] = calc_cusp(150)

    cusps[5] = (cusps[11] + 180) % 360.0
    cusps[6] = (cusps[12] + 180) % 360.0
    cusps[8] = (cusps[2] + 180) % 360.0
    cusps[9] = (cusps[3] + 180) % 360.0

    return cusps


def _houses_equal(asc: float) -> List[float]:
    """
    Equal house system (30° divisions from Ascendant).

    Simplest house system: each house is exactly 30° of ecliptic longitude.
    House 1 starts at Ascendant, each subsequent house adds 30°.

    Mathematical Formula:
        λᵢ = (λ_Asc + (i-1) × 30°) mod 360°
        where i = 1, 2, ..., 12

    Properties:
        - Works at all latitudes including polar regions
        - MC may not coincide with 10th house cusp
        - Computationally exact (no astronomical calculations)

    Args:
        asc: Ascendant longitude in degrees

    Returns:
        List of 13 house cusp longitudes
    """
    cusps = [0.0] * 13
    for i in range(1, 13):
        cusps[i] = (asc + (i - 1) * 30.0) % 360.0
    return cusps


def _houses_whole_sign(asc: float) -> List[float]:
    """
    Whole Sign house system (ancient Hellenistic method).

    Each house occupies one complete zodiac sign. House 1 starts at 0° of the
    sign containing the Ascendant. Used extensively in ancient astrology.

    Algorithm:
        1. Find zodiac sign of Ascendant (floor(asc / 30) * 30)
        2. Each house = one complete sign (30° intervals from sign 0°)

    Mathematical Formula:
        start = floor(λ_Asc / 30°) × 30°
        λᵢ = (start + (i-1) × 30°) mod 360°
        where i = 1, 2, ..., 12

    Example:
        If Asc = 15° Taurus (45°), then:
        - House 1 starts at 30° (0° Taurus)
        - House 2 starts at 60° (0° Gemini)
        - etc.

    Properties:
        - Works at all latitudes including polar regions
        - Computationally exact (no astronomical calculations)
        - House cusps always at 0° of each sign

    Args:
        asc: Ascendant longitude in degrees

    Returns:
        List of 13 house cusp longitudes
    """
    cusps = [0.0] * 13
    # Start of sign containing Asc
    start = math.floor(asc / 30.0) * 30.0
    for i in range(1, 13):
        cusps[i] = (start + (i - 1) * 30.0) % 360.0
    return cusps


def _houses_porphyry(asc: float, mc: float) -> List[float]:
    """
    Porphyry house system (space-based trisection).

    Divides each quadrant (Asc-MC, MC-Desc, Desc-IC, IC-Asc) into three equal
    30° sections along the ecliptic. Simple and well-defined at all latitudes.

    Algorithm:
        1. Calculate arc from Asc to MC, divide by 3
        2. Calculate arc from MC to Desc (MC+180), divide by 3
        3. Opposite houses are 180° from each other

    Mathematical Formulas:
        Quadrant MC→Asc (houses 11, 12):
            step = (λ_Asc - λ_MC) mod 360° / 3
            λ₁₁ = λ_MC + step
            λ₁₂ = λ_MC + 2·step

        Quadrant Asc→IC (houses 2, 3):
            step = (λ_IC - λ_Asc) mod 360° / 3
            λ₂ = λ_Asc + step
            λ₃ = λ_Asc + 2·step

        Opposite houses:
            λ₅ = (λ₁₁ + 180°) mod 360°
            λ₆ = (λ₁₂ + 180°) mod 360°
            λ₈ = (λ₂ + 180°) mod 360°
            λ₉ = (λ₃ + 180°) mod 360°

    Properties:
        - Works at all latitudes including polar regions
        - Used as fallback when Placidus/Koch fail
        - Computationally simple (no trigonometry)

    Args:
        asc: Ascendant longitude in degrees
        mc: Midheaven longitude in degrees

    Returns:
        List of 13 house cusp longitudes
    """
    cusps = [0.0] * 13
    cusps[1] = asc
    cusps[10] = mc
    cusps[7] = (asc + 180) % 360.0
    cusps[4] = (mc + 180) % 360.0

    # Trisect the ecliptic arc between angles
    # Arc 10-1
    diff = (asc - mc) % 360.0
    step = diff / 3.0
    cusps[11] = (mc + step) % 360.0
    cusps[12] = (mc + 2 * step) % 360.0

    # Arc 1-4
    ic = cusps[4]
    diff = (ic - asc) % 360.0
    step = diff / 3.0
    cusps[2] = (asc + step) % 360.0
    cusps[3] = (asc + 2 * step) % 360.0

    # Opposites
    cusps[5] = (cusps[11] + 180) % 360.0
    cusps[6] = (cusps[12] + 180) % 360.0
    cusps[8] = (cusps[2] + 180) % 360.0
    cusps[9] = (cusps[3] + 180) % 360.0

    return cusps


def _houses_sripati(asc: float, mc: float) -> List[float]:
    """
    Sripati house system (traditional Indian method).

    Creates house cusps at the midpoints of Porphyry house cusps. Each Sripati
    cusp is the midpoint between the previous and current Porphyry cusp.

    Algorithm (from Swiss Ephemeris documentation):
        1. Calculate Porphyry house cusps
        2. For each house i, the Sripati cusp is:
           H'[i] = (H[i-1] + H[i]) / 2  (midpoint on the ecliptic circle)

    Mathematical Formula:
        λ'ᵢ = λᵢ₋₁ + ((λᵢ - λᵢ₋₁) mod 360°) / 2

    This effectively shifts the house boundaries so that the "core" of each
    Porphyry house becomes the cusp, making the Porphyry cusp the center of
    each Sripati house.

    Properties:
        - Works at all latitudes (inherits Porphyry's polar stability)
        - Used in traditional Indian astrology (Jyotish)
        - Computationally simple (no trigonometry beyond Porphyry)

    Args:
        asc: Ascendant longitude in degrees
        mc: Midheaven longitude in degrees

    Returns:
        List of 13 house cusp longitudes (index 0 unused, 1-12 are house cusps)
    """
    # Get Porphyry cusps as base
    porphyry = _houses_porphyry(asc, mc)

    cusps = [0.0] * 13
    for i in range(1, 13):
        prev_i = 12 if i == 1 else i - 1
        # Calculate midpoint between previous and current Porphyry cusp
        # Handle wrap-around at 360 degrees
        diff = (porphyry[i] - porphyry[prev_i]) % 360.0
        cusps[i] = (porphyry[prev_i] + diff / 2.0) % 360.0

    return cusps


def _houses_pullen_sd(asc: float, mc: float) -> List[float]:
    """
    Pullen SD (Sinusoidal Delta) house system, also known as Neo-Porphyry.

    Invented by Walter Pullen in 1994. Like Porphyry, based on ecliptic quadrant
    divisions, but fits house widths to a sine wave pattern rather than equal
    trisection.

    Algorithm (from Swiss Ephemeris documentation):
        - Ideal house size = 30°
        - For each quadrant, compute deviation d = quadrant_size - 90°
        - Middle house of quadrant (2nd, 5th, 8th, 11th) gets d/2 added to 30°
        - Side houses each get d/4 added to 30°
        - If middle house size would be negative, set to 0

    House width pattern for quadrant:
        x+n, x, x+n  where x = 30 and n = d/4
        Middle house = x + 2n = 30 + d/2

    Properties:
        - Works at all latitudes (based on ecliptic only)
        - Houses in larger quadrants are larger
        - Middle house absorbs most of the size variation

    Args:
        asc: Ascendant longitude in degrees
        mc: Midheaven longitude in degrees

    Returns:
        List of 13 house cusp longitudes (index 0 unused, 1-12 are house cusps)
    """
    cusps = [0.0] * 13
    cusps[1] = asc
    cusps[10] = mc
    cusps[7] = (asc + 180) % 360.0
    cusps[4] = (mc + 180) % 360.0

    # Calculate quadrant sizes
    # Quadrant 1: MC to Asc (houses 11, 12)
    q1 = (asc - mc) % 360.0
    # Quadrant 2: Asc to IC (houses 2, 3)
    ic = cusps[4]
    q2 = (ic - asc) % 360.0
    # Quadrant 3: IC to Desc (houses 5, 6)
    desc = cusps[7]
    q3 = (desc - ic) % 360.0
    # Quadrant 4: Desc to MC (houses 8, 9)
    q4 = (mc - desc) % 360.0

    def calc_house_sizes(quadrant_size: float) -> tuple:
        """Calculate house sizes for a quadrant using sinusoidal delta."""
        d = quadrant_size - 90.0  # deviation from ideal 90°
        # Middle house gets d/2 added to 30°
        middle = 30.0 + d / 2.0
        # If middle would be negative, set to 0 and redistribute
        if middle < 0:
            middle = 0.0
            # Remaining space divided between side houses
            side = quadrant_size / 2.0
        else:
            # Side houses each get d/4 added to 30°
            side = 30.0 + d / 4.0
        return (side, middle, side)

    # Quadrant 1: MC to Asc (houses 11, 12, then Asc)
    sizes1 = calc_house_sizes(q1)
    cusps[11] = (mc + sizes1[0]) % 360.0
    cusps[12] = (cusps[11] + sizes1[1]) % 360.0

    # Quadrant 2: Asc to IC (houses 2, 3, then IC)
    sizes2 = calc_house_sizes(q2)
    cusps[2] = (asc + sizes2[0]) % 360.0
    cusps[3] = (cusps[2] + sizes2[1]) % 360.0

    # Quadrant 3: IC to Desc (houses 5, 6, then Desc)
    sizes3 = calc_house_sizes(q3)
    cusps[5] = (ic + sizes3[0]) % 360.0
    cusps[6] = (cusps[5] + sizes3[1]) % 360.0

    # Quadrant 4: Desc to MC (houses 8, 9, then MC)
    sizes4 = calc_house_sizes(q4)
    cusps[8] = (desc + sizes4[0]) % 360.0
    cusps[9] = (cusps[8] + sizes4[1]) % 360.0

    return cusps


def _houses_pullen_sr(asc: float, mc: float) -> List[float]:
    """
    Pullen SR (Sinusoidal Ratio) house system.

    Proposed by Walter Pullen in 2016 as an improvement over Pullen SD. Uses
    ratio multipliers instead of additive offsets, avoiding negative house
    sizes for small quadrants.

    Algorithm (from Swiss Ephemeris documentation):
        For quadrant size q:
        - Small quadrant houses: rx, x, rx  (sizes sum to q)
        - Large quadrant houses: r³x, r⁴x, r³x  (sizes sum to 180-q)

        Solving: rx + x + rx = q  →  x(2r + 1) = q
                 r³x + r⁴x + r³x = 180 - q  →  x·r³(2 + r) = 180 - q

        Dividing: r³(2 + r) / (2r + 1) = (180 - q) / q
        This is solved numerically for r.

    Properties:
        - Works at all latitudes
        - No negative house sizes even for extreme quadrants
        - More elegant mathematical formulation

    Args:
        asc: Ascendant longitude in degrees
        mc: Midheaven longitude in degrees

    Returns:
        List of 13 house cusp longitudes (index 0 unused, 1-12 are house cusps)
    """
    cusps = [0.0] * 13
    cusps[1] = asc
    cusps[10] = mc
    cusps[7] = (asc + 180) % 360.0
    cusps[4] = (mc + 180) % 360.0

    # Calculate quadrant sizes
    q1 = (asc - mc) % 360.0  # MC to Asc
    ic = cusps[4]
    q2 = (ic - asc) % 360.0  # Asc to IC
    desc = cusps[7]
    q3 = (desc - ic) % 360.0  # IC to Desc
    q4 = (mc - desc) % 360.0  # Desc to MC

    def calc_house_sizes_ratio(quadrant_size: float) -> tuple:
        """Calculate house sizes using sinusoidal ratio method."""
        q = quadrant_size
        q_opp = 180.0 - q  # opposite quadrant

        if abs(q - 90.0) < 0.0001:
            # Quadrant is exactly 90°, r = 1, equal houses of 30°
            return (30.0, 30.0, 30.0)

        # Target ratio: r³(2 + r) / (2r + 1) = q_opp / q
        target = q_opp / q if q > 0.0001 else 1000.0

        # Solve for r using Newton-Raphson
        # f(r) = r³(2 + r) / (2r + 1) - target = 0
        r = 1.0  # initial guess
        for _ in range(20):  # max iterations
            numerator = r * r * r * (2.0 + r)
            denominator = 2.0 * r + 1.0
            f = numerator / denominator - target

            # Derivative: d/dr[r³(2+r)/(2r+1)]
            # Using quotient rule
            dn = 3.0 * r * r * (2.0 + r) + r * r * r  # derivative of numerator
            dd = 2.0  # derivative of denominator
            df = (dn * denominator - numerator * dd) / (denominator * denominator)

            if abs(df) < 1e-12:
                break
            r_new = r - f / df
            if r_new < 0.01:
                r_new = 0.01  # keep r positive
            if abs(r_new - r) < 1e-10:
                break
            r = r_new

        # Calculate x from: x(2r + 1) = q
        x = q / (2.0 * r + 1.0) if (2.0 * r + 1.0) > 0.0001 else q / 3.0

        # House sizes: rx, x, rx
        side = r * x
        middle = x

        return (side, middle, side)

    # Quadrant 1: MC to Asc (houses 11, 12)
    sizes1 = calc_house_sizes_ratio(q1)
    cusps[11] = (mc + sizes1[0]) % 360.0
    cusps[12] = (cusps[11] + sizes1[1]) % 360.0

    # Quadrant 2: Asc to IC (houses 2, 3)
    sizes2 = calc_house_sizes_ratio(q2)
    cusps[2] = (asc + sizes2[0]) % 360.0
    cusps[3] = (cusps[2] + sizes2[1]) % 360.0

    # Quadrant 3: IC to Desc (houses 5, 6)
    sizes3 = calc_house_sizes_ratio(q3)
    cusps[5] = (ic + sizes3[0]) % 360.0
    cusps[6] = (cusps[5] + sizes3[1]) % 360.0

    # Quadrant 4: Desc to MC (houses 8, 9)
    sizes4 = calc_house_sizes_ratio(q4)
    cusps[8] = (desc + sizes4[0]) % 360.0
    cusps[9] = (cusps[8] + sizes4[1]) % 360.0

    return cusps


def _houses_alcabitius(
    armc: float, lat: float, eps: float, asc: float, mc: float
) -> List[float]:
    """
    Alcabitius (Alchabitius) house system (ancient Arabic method).

    Medieval Arabic system that divides the diurnal and nocturnal arcs differently
    than Placidus, using a simpler geometric approach.

    Algorithm:
        1. Calculate RA of Ascendant and MC
        2. Divide RA intervals between angles
        3. Convert RA divisions back to ecliptic longitude

    Mathematical Formulas:
        Right Ascension of Ascendant:
            RA_Asc = atan2(sin(λ_Asc)·cos(ε), cos(λ_Asc))

        Quadrant MC→Asc (houses 11, 12):
            arc = (RA_Asc - ARMC) mod 360°
            step = arc / 3
            RA₁₁ = ARMC + step
            RA₁₂ = ARMC + 2·step

        Quadrant Asc→IC (houses 2, 3):
            RA_IC = (ARMC + 180°) mod 360°
            arc = (RA_IC - RA_Asc) mod 360°
            step = arc / 3
            RA₂ = RA_Asc + step
            RA₃ = RA_Asc + 2·step

        RA to ecliptic conversion:
            λ = atan2(sin(RA), cos(RA)·cos(ε))

    Args:
        armc: Sidereal time at Greenwich (RAMC) in degrees
        lat: Geographic latitude in degrees
        eps: True obliquity of ecliptic in degrees
        asc: Ascendant longitude in degrees
        mc: Midheaven longitude in degrees

    Returns:
        List of 13 house cusp longitudes
    """
    # Alcabitius
    # Time trisection of Ascendant's diurnal arc, projected by Hour Circles.
    # RA_11 = RAMC + SA/3.
    # SA = 90 + AD_asc.

    cusps = [0.0] * 13
    cusps[1] = asc
    cusps[10] = mc
    cusps[7] = (asc + 180) % 360.0
    cusps[4] = (mc + 180) % 360.0

    rad_lat = math.radians(lat)
    rad_eps = math.radians(eps)

    # RA of Ascendant
    y = math.sin(math.radians(asc)) * math.cos(rad_eps)
    x = math.cos(math.radians(asc))
    ra_asc = math.degrees(math.atan2(y, x)) % 360.0

    # Arc from MC to Asc
    arc = (ra_asc - armc) % 360.0
    step = arc / 3.0

    def get_lon_from_ra(ra):
        y = math.sin(math.radians(ra))
        x = math.cos(math.radians(ra)) * math.cos(rad_eps)
        lon = math.degrees(math.atan2(y, x))
        return lon % 360.0

    cusps[11] = get_lon_from_ra(armc + step)
    cusps[12] = get_lon_from_ra(armc + 2 * step)

    # Sector 2: Asc to IC
    ra_ic = (armc + 180.0) % 360.0
    arc2 = (ra_ic - ra_asc) % 360.0
    step2 = arc2 / 3.0

    cusps[2] = get_lon_from_ra(ra_asc + step2)
    cusps[3] = get_lon_from_ra(ra_asc + 2 * step2)

    cusps[5] = (cusps[11] + 180) % 360.0
    cusps[6] = (cusps[12] + 180) % 360.0
    cusps[8] = (cusps[2] + 180) % 360.0
    cusps[9] = (cusps[3] + 180) % 360.0

    return cusps


def _houses_polich_page(
    armc: float, lat: float, eps: float, asc: float, mc: float
) -> List[float]:
    """
    Polich-Page (Topocentric) house system.

    Developed in 1960s to account for observer's actual position on Earth's surface
    rather than at Earth's center. Uses modified pole calculations.

    Algorithm:
        Similar to Regiomontanus but with modified pole factors that account
        for the topocentric (observer-centered) perspective.

    Mathematical Formulas:
        Pole factors:
            For houses 11, 3: factor = 1/3
            For houses 12, 2: factor = 2/3

        Pole calculation (modified from Regiomontanus):
            tan(P) = tan(φ) × factor

        Hour angle offsets:
            H = 30°, 60°, 120°, 150° for houses 11, 12, 2, 3

        Right Ascension offset:
            R = ARMC + H - 90°

        Ecliptic projection:
            λ = atan2(cos(R), -(sin(R)·cos(ε) + tan(P)·sin(ε)))

    Args:
        armc: Sidereal time at Greenwich (RAMC) in degrees
        lat: Geographic latitude in degrees
        eps: True obliquity of ecliptic in degrees
        asc: Ascendant longitude in degrees
        mc: Midheaven longitude in degrees

    Returns:
        List of 13 house cusp longitudes
    """
    # Polich/Page (Topocentric)
    # Uses Pole method with tan(Pole) = tan(lat) * factor.
    # factor = 1/3 for 11/3, 2/3 for 12/2.

    cusps = [0.0] * 13
    cusps[1] = asc
    cusps[10] = mc
    cusps[7] = (asc + 180) % 360.0
    cusps[4] = (mc + 180) % 360.0

    rad_lat = math.radians(lat)
    rad_eps = math.radians(eps)

    def calc_cusp(offset_deg, factor):
        tan_pole = math.tan(rad_lat) * factor

        # R = RAMC + offset - 90
        r_deg = (armc + offset_deg - 90.0) % 360.0
        r_rad = math.radians(r_deg)

        num = math.cos(r_rad)
        den = -(math.sin(r_rad) * math.cos(rad_eps) + tan_pole * math.sin(rad_eps))

        lon = math.degrees(math.atan2(num, den))
        return lon % 360.0

    cusps[11] = calc_cusp(30, 1.0 / 3.0)
    cusps[12] = calc_cusp(60, 2.0 / 3.0)
    cusps[2] = calc_cusp(120, 2.0 / 3.0)
    cusps[3] = calc_cusp(150, 1.0 / 3.0)

    cusps[5] = (cusps[11] + 180) % 360.0
    cusps[6] = (cusps[12] + 180) % 360.0
    cusps[8] = (cusps[2] + 180) % 360.0
    cusps[9] = (cusps[3] + 180) % 360.0

    return cusps


def _houses_morinus(
    armc: float, lat: float, eps: float, asc: float, mc: float
) -> List[float]:
    """
    Morinus house system (equatorial divisions).

    Divides the celestial equator into 12 equal 30° sections starting from 0° Aries,
    then projects to ecliptic. Independent of observer location.

    Algorithm:
        1. Divide equator into 30° RA sections from 0h RA
        2. Convert each RA to ecliptic longitude using obliquity

    Mathematical Formulas:
        For each house at RA = ARMC + (i-10)×30° where i = 10, 11, 12, 1, 2, 3:

        Projection via ecliptic poles:
            tan(λ) = tan(RA) × cos(ε)

        Using atan2 for proper quadrant:
            λ = atan2(sin(RA)·cos(ε), cos(RA))

    Properties:
        - Location-independent (ignores latitude)
        - Morinus 1st cusp differs from standard Ascendant
        - Morinus 10th cusp differs from standard MC

    Args:
        armc: Sidereal time at Greenwich (RAMC) in degrees
        lat: Geographic latitude in degrees
        eps: True obliquity of ecliptic in degrees
        asc: Ascendant longitude in degrees
        mc: Midheaven longitude in degrees

    Returns:
        List of 13 house cusp longitudes
    """
    # Morinus
    # Projects Equator points (RAMC + 30) to Ecliptic via Ecliptic Poles.
    # Lon = Lon of point on Equator.
    # tan(lon) = tan(ra) * cos(eps).

    cusps = [0.0] * 13
    # Morinus Ascendant? Standard swe_houses returns standard Asc.
    # But cusps[1] should be Morinus Ascendant (RAMC+90 projected).
    # Let's follow standard behavior: cusps array contains system cusps.

    rad_eps = math.radians(eps)

    def get_lon(ra):
        # tan(lon) = tan(ra) * cos(eps)
        y = math.sin(math.radians(ra)) * math.cos(rad_eps)
        x = math.cos(math.radians(ra))
        lon = math.degrees(math.atan2(y, x))
        return lon % 360.0

    cusps[10] = get_lon(armc)
    cusps[11] = get_lon(armc + 30)
    cusps[12] = get_lon(armc + 60)
    cusps[1] = get_lon(armc + 90)
    cusps[2] = get_lon(armc + 120)
    cusps[3] = get_lon(armc + 150)

    cusps[4] = (cusps[10] + 180) % 360.0
    cusps[5] = (cusps[11] + 180) % 360.0
    cusps[6] = (cusps[12] + 180) % 360.0
    cusps[7] = (cusps[1] + 180) % 360.0
    cusps[8] = (cusps[2] + 180) % 360.0
    cusps[9] = (cusps[3] + 180) % 360.0

    return cusps


def _houses_meridian(
    armc: float, lat: float, eps: float, asc: float, mc: float
) -> List[float]:
    """
    Meridian (Zariel/Axial Rotation) house system.

    Based on meridian passages, divides RA from MC in equal 30° intervals.
    Related to Morinus but starts from MC instead of 0° Aries.

    Algorithm:
        Projects equator points (ARMC + n×30°) to ecliptic via celestial poles.

    Mathematical Formulas:
        For each house at RA = ARMC + (i-10)×30° where i = 10, 11, 12, 1, 2, 3:

        Projection via celestial poles:
            tan(λ) = tan(RA) / cos(ε)

        Using atan2 for proper quadrant:
            λ = atan2(sin(RA), cos(RA)·cos(ε))

    Difference from Morinus:
        Morinus:  λ = atan2(sin(RA)·cos(ε), cos(RA))
        Meridian: λ = atan2(sin(RA), cos(RA)·cos(ε))

    Args:
        armc: Sidereal time at Greenwich (RAMC) in degrees
        lat: Geographic latitude in degrees
        eps: True obliquity of ecliptic in degrees
        asc: Ascendant longitude in degrees
        mc: Midheaven longitude in degrees

    Returns:
        List of 13 house cusp longitudes
    """
    # Meridian (Axial)
    # Projects Equator points to Ecliptic via Celestial Poles.
    # tan(lon) = tan(ra) / cos(eps).

    cusps = [0.0] * 13

    rad_eps = math.radians(eps)

    def get_lon_from_ra(ra):
        y = math.sin(math.radians(ra))
        x = math.cos(math.radians(ra)) * math.cos(rad_eps)
        lon = math.degrees(math.atan2(y, x))
        return lon % 360.0

    cusps[10] = get_lon_from_ra(armc)
    cusps[11] = get_lon_from_ra(armc + 30)
    cusps[12] = get_lon_from_ra(armc + 60)
    cusps[1] = get_lon_from_ra(armc + 90)
    cusps[2] = get_lon_from_ra(armc + 120)
    cusps[3] = get_lon_from_ra(armc + 150)

    cusps[4] = (cusps[10] + 180) % 360.0
    cusps[5] = (cusps[11] + 180) % 360.0
    cusps[6] = (cusps[12] + 180) % 360.0
    cusps[7] = (cusps[1] + 180) % 360.0
    cusps[8] = (cusps[2] + 180) % 360.0
    cusps[9] = (cusps[3] + 180) % 360.0

    return cusps


def _houses_vehlow(asc: float) -> List[float]:
    """
    Vehlow house system (Equal with Asc in middle of House 1).

    Variant of Equal houses where the Ascendant falls at 15° into House 1
    rather than at the cusp. Each house is still 30°.

    Mathematical Formula:
        start = (λ_Asc - 15°) mod 360°
        λᵢ = (start + (i-1) × 30°) mod 360°
        where i = 1, 2, ..., 12

    Properties:
        - Ascendant falls exactly at 15° into House 1
        - Works at all latitudes including polar regions
        - Computationally exact (no astronomical calculations)

    Args:
        asc: Ascendant longitude in degrees

    Returns:
        List of 13 house cusp longitudes
    """
    cusps = [0.0] * 13
    start = (asc - 15.0) % 360.0
    for i in range(1, 13):
        cusps[i] = (start + (i - 1) * 30.0) % 360.0
    return cusps


def _houses_carter(
    armc: float, lat: float, eps: float, asc: float, mc: float
) -> List[float]:
    """
    Carter Poli-Equatorial house system.

    Equal 30° divisions on the celestial equator starting from RA of Ascendant,
    projected to ecliptic via hour circles.

    Algorithm:
        1. Calculate RA of Ascendant
        2. Add 30° RA increments for each house
        3. Convert each RA to ecliptic longitude

    Mathematical Formulas:
        Right Ascension of Ascendant:
            RA_Asc = atan2(sin(λ_Asc)·cos(ε), cos(λ_Asc))

        For each house i (1-12):
            RA = RA_Asc + (i-1) × 30°

        RA to ecliptic conversion via celestial poles:
            λ = atan2(sin(RA), cos(RA)·cos(ε))

    Args:
        armc: Sidereal time at Greenwich (RAMC) in degrees
        lat: Geographic latitude in degrees
        eps: True obliquity of ecliptic in degrees
        asc: Ascendant longitude in degrees
        mc: Midheaven longitude in degrees

    Returns:
        List of 13 house cusp longitudes
    """

    cusps = [0.0] * 13

    rad_eps = math.radians(eps)

    # Get RA of Ascendant
    y = math.sin(math.radians(asc)) * math.cos(rad_eps)
    x = math.cos(math.radians(asc))
    ra_asc = math.degrees(math.atan2(y, x)) % 360.0

    def get_lon_from_ra(ra):
        y = math.sin(math.radians(ra))
        x = math.cos(math.radians(ra)) * math.cos(rad_eps)
        lon = math.degrees(math.atan2(y, x))
        return lon % 360.0

    # Equal 30-degree divisions from RA of Asc
    for i in range(1, 13):
        ra = (ra_asc + (i - 1) * 30.0) % 360.0
        cusps[i] = get_lon_from_ra(ra)

    return cusps


def _houses_gauquelin(
    armc: float, lat: float, eps: float, asc: float, mc: float
) -> List[float]:
    """
    Gauquelin 36-Sector house system.

    Divides celestial sphere into 36 sectors using Placidus-like divisions.
    This implementation follows Swiss Ephemeris swehouse.c algorithm.

    Algorithm (from Swiss Ephemeris):
    - Calculate ascensional difference: a = asin(tan(lat) * tan(eps))
    - For sectors 2-9 (fourth/second quarter):
        - ih2 = 10 - ih
        - fh1 = atan(sin(a * ih2 / 9) / tan(eps))
        - rectasc = (90/9) * ih2 + armc
        - Iterate to find pole height and cusp longitude
    - Sectors 20-27 are opposites of 2-9
    - For sectors 29-36 (first/third quarter):
        - ih2 = ih - 28
        - Similar calculation with rectasc = 180 - ih2 * 90/9 + armc
    - Sectors 11-18 are opposites of 29-36
    - Cardinal sectors: 1=Asc, 10=MC, 19=Desc, 28=IC

    Sector numbering (clockwise from Ascendant):
    - Sector 1: Rising (Ascendant)
    - Sector 10: Upper culmination (MC)
    - Sector 19: Setting (Descendant)
    - Sector 28: Lower culmination (IC)

    Polar Limitation:
        Fails within polar circle (|φ| >= 90° - ε).

    Args:
        armc: Sidereal time at Greenwich (RAMC) in degrees
        lat: Geographic latitude in degrees
        eps: True obliquity of ecliptic in degrees
        asc: Ascendant longitude in degrees
        mc: Midheaven longitude in degrees

    Returns:
        List of 37 sector longitudes (index 0 unused, 1-36 are sectors)
    """
    VERY_SMALL = 1e-10
    VERY_SMALL_ITER = 1e-8
    niter_max = 100

    cusps = [0.0] * 37  # Index 0 unused, 1-36 are sectors

    # Within polar circle, use Porphyry-like fallback (equal division)
    if abs(lat) >= 90.0 - eps:
        # Fallback: divide into 36 equal sectors from Asc
        for i in range(1, 37):
            cusps[i] = (asc - (i - 1) * 10.0) % 360.0
        return cusps

    # Precompute trig values
    sine = math.sin(math.radians(eps))
    cose = math.cos(math.radians(eps))
    tane = math.tan(math.radians(eps))
    tanfi = math.tan(math.radians(lat))
    th = armc

    # a = asin(tan(lat) * tan(eps))
    a_arg = tanfi * tane
    if abs(a_arg) > 1.0:
        a_arg = 1.0 if a_arg > 0 else -1.0
    a = math.degrees(math.asin(a_arg))

    # Fourth/Second quarter: sectors 2-9
    for ih in range(2, 10):
        ih2 = 10 - ih
        fh1 = math.degrees(math.atan(math.sin(math.radians(a * ih2 / 9.0)) / tane))
        rectasc = ((90.0 / 9.0) * ih2 + th) % 360.0

        # Initial cusp estimate using Asc1
        cusp_init = _calc_ascendant(rectasc, eps, lat, fh1)
        # tant = tand(asind(sine * sind(cusp_init))) - declination of cusp point
        dec_cusp = math.asin(sine * math.sin(math.radians(cusp_init)))
        tant = math.tan(dec_cusp)

        if abs(tant) < VERY_SMALL:
            cusps[ih] = rectasc
        else:
            # Pole height: f = atand(sind(asind(tanfi * tant) * ih2 / 9) / tant)
            f_arg = tanfi * tant
            if abs(f_arg) > 1.0:
                f_arg = 1.0 if f_arg > 0 else -1.0
            angle_deg = math.degrees(math.asin(f_arg))
            f = math.degrees(
                math.atan(math.sin(math.radians(angle_deg * ih2 / 9.0)) / tant)
            )
            cusps[ih] = _calc_ascendant(rectasc, eps, lat, f)

            # Iterate for convergence
            cuspsv = 0.0
            for i in range(1, niter_max + 1):
                sin_cusp = math.sin(math.radians(cusps[ih]))
                dec_cusp_new = math.asin(sine * sin_cusp)
                tant_new = math.tan(dec_cusp_new)

                if abs(tant_new) < VERY_SMALL:
                    cusps[ih] = rectasc
                    break

                f_arg = tanfi * tant_new
                if abs(f_arg) > 1.0:
                    f_arg = 1.0 if f_arg > 0 else -1.0
                angle_deg = math.degrees(math.asin(f_arg))
                f = math.degrees(
                    math.atan(math.sin(math.radians(angle_deg * ih2 / 9.0)) / tant_new)
                )
                cusps[ih] = _calc_ascendant(rectasc, eps, lat, f)

                if i > 1:
                    diff = abs(cusps[ih] - cuspsv)
                    if diff > 180:
                        diff = 360 - diff
                    if diff < VERY_SMALL_ITER:
                        break
                cuspsv = cusps[ih]

        # Opposite sector
        cusps[ih + 18] = (cusps[ih] + 180.0) % 360.0

    # First/Third quarter: sectors 29-36
    for ih in range(29, 37):
        ih2 = ih - 28
        fh1 = math.degrees(math.atan(math.sin(math.radians(a * ih2 / 9.0)) / tane))
        rectasc = (180.0 - ih2 * 90.0 / 9.0 + th) % 360.0

        cusp_init = _calc_ascendant(rectasc, eps, lat, fh1)
        # tant = tand(asind(sine * sind(cusp_init))) - declination of cusp point
        dec_cusp = math.asin(sine * math.sin(math.radians(cusp_init)))
        tant = math.tan(dec_cusp)

        if abs(tant) < VERY_SMALL:
            cusps[ih] = rectasc
        else:
            # Pole height: f = atand(sind(asind(tanfi * tant) * ih2 / 9) / tant)
            f_arg = tanfi * tant
            if abs(f_arg) > 1.0:
                f_arg = 1.0 if f_arg > 0 else -1.0
            angle_deg = math.degrees(math.asin(f_arg))
            f = math.degrees(
                math.atan(math.sin(math.radians(angle_deg * ih2 / 9.0)) / tant)
            )
            cusps[ih] = _calc_ascendant(rectasc, eps, lat, f)

            cuspsv = 0.0
            for i in range(1, niter_max + 1):
                sin_cusp = math.sin(math.radians(cusps[ih]))
                dec_cusp_new = math.asin(sine * sin_cusp)
                tant_new = math.tan(dec_cusp_new)

                if abs(tant_new) < VERY_SMALL:
                    cusps[ih] = rectasc
                    break

                f_arg = tanfi * tant_new
                if abs(f_arg) > 1.0:
                    f_arg = 1.0 if f_arg > 0 else -1.0
                angle_deg = math.degrees(math.asin(f_arg))
                f = math.degrees(
                    math.atan(math.sin(math.radians(angle_deg * ih2 / 9.0)) / tant_new)
                )
                cusps[ih] = _calc_ascendant(rectasc, eps, lat, f)

                if i > 1:
                    diff = abs(cusps[ih] - cuspsv)
                    if diff > 180:
                        diff = 360 - diff
                    if diff < VERY_SMALL_ITER:
                        break
                cuspsv = cusps[ih]

        # Opposite sector
        cusps[ih - 18] = (cusps[ih] + 180.0) % 360.0

    # Cardinal sectors
    cusps[1] = asc
    cusps[10] = mc
    cusps[19] = (asc + 180.0) % 360.0
    cusps[28] = (mc + 180.0) % 360.0

    return cusps


def _ecliptic_to_ra_simple(lon: float, eps: float) -> float:
    """Convert ecliptic longitude to right ascension."""
    rad_lon = math.radians(lon)
    rad_eps = math.radians(eps)

    y = math.sin(rad_lon) * math.cos(rad_eps)
    x = math.cos(rad_lon)
    ra = math.degrees(math.atan2(y, x))

    return ra % 360.0


def _ra_to_ecliptic_simple(ra: float, eps: float) -> float:
    """Convert right ascension to ecliptic longitude."""
    rad_ra = math.radians(ra)
    rad_eps = math.radians(eps)

    y = math.sin(rad_ra)
    x = math.cos(rad_ra) * math.cos(rad_eps)
    lon = math.degrees(math.atan2(y, x))

    return lon % 360.0


def _cotrans(x: List[float], eps: float) -> List[float]:
    """
    Coordinate transformation (rotation around x-axis by angle eps).

    Equivalent to Swiss Ephemeris swe_cotrans().
    Rotates spherical coordinates [lon, lat, r] by angle eps.

    Args:
        x: [longitude, latitude, radius] in degrees
        eps: Rotation angle in degrees

    Returns:
        Transformed [longitude, latitude, radius]
    """
    eps_rad = math.radians(eps)
    lon_rad = math.radians(x[0])
    lat_rad = math.radians(x[1])
    r = x[2]

    # Convert to Cartesian
    cos_lat = math.cos(lat_rad)
    x_cart = r * cos_lat * math.cos(lon_rad)
    y_cart = r * cos_lat * math.sin(lon_rad)
    z_cart = r * math.sin(lat_rad)

    # Rotate around x-axis by eps
    cos_eps = math.cos(eps_rad)
    sin_eps = math.sin(eps_rad)

    x_new = x_cart
    y_new = y_cart * cos_eps + z_cart * sin_eps
    z_new = -y_cart * sin_eps + z_cart * cos_eps

    # Convert back to spherical
    r_new = math.sqrt(x_new**2 + y_new**2 + z_new**2)

    if r_new > 0:
        lon_new = math.degrees(math.atan2(y_new, x_new))
        lat_new = math.degrees(math.asin(z_new / r_new))
    else:
        lon_new = 0.0
        lat_new = 0.0

    return [(lon_new % 360.0), lat_new, r_new]


def _houses_krusinski(
    armc: float, lat: float, eps: float, asc: float, mc: float
) -> List[float]:
    """
    Krusinski-Pisa house system.

    Based on great circle passing through Ascendant and Zenith.
    Divides this circle into 12 equal 30° parts, then projects onto ecliptic.

    Algorithm from Bogdan Krusinski:
    1. Transform Ascendant from ecliptic to equatorial coordinates
    2. Rotate to align with meridian
    3. Transform to horizontal coordinates
    4. Rotate to create Asc-Zenith great circle
    5. Divide circle into 30° segments
    6. Transform each cusp back to ecliptic

    Mathematical Formulas:
        Coordinate rotation (cotrans - rotation around x-axis):
            x' = x
            y' = y·cos(θ) + z·sin(θ)
            z' = -y·sin(θ) + z·cos(θ)

        Algorithm steps:
            A0. x = [λ_Asc, 0, 1]  (ecliptic coordinates)
            A1. x = cotrans(x, -ε)  (transform to equatorial)
            A2. x[0] = x[0] - (ARMC - 90°)  (rotate)
            A3. x = cotrans(x, -(90° - φ))  (transform to horizontal)
            A4. Save horizon_lon; set x[0] = 0
            A5. x = cotrans(x, -90°)  (transform to house circle)

        For each cusp i (0-5):
            B0. x_cusp = [30°·i, 0, 1]
            B1. x_cusp = cotrans(x_cusp, 90°)
            B2. x_cusp[0] += horizon_lon
            B3. x_cusp = cotrans(x_cusp, 90° - φ)
            B4. x_cusp[0] += (ARMC - 90°)
            B5. λ = atan(tan(RA)/cos(ε)) with quadrant adjustment

    Args:
        armc: Sidereal time at Greenwich (RAMC) in degrees
        lat: Geographic latitude in degrees
        eps: True obliquity of ecliptic in degrees
        asc: Ascendant longitude in degrees
        mc: Midheaven longitude in degrees

    Returns:
        List of 13 house cusp longitudes (index 0 unused, 1-12 are cusps)
    """
    cusps = [0.0] * 13

    # Within polar circle, swap AC/DC if AC is on wrong side
    acmc_diff = (asc - mc + 540.0) % 360.0 - 180.0
    if acmc_diff < 0:
        asc = (asc + 180.0) % 360.0

    # A0. Start point - ecliptic coords of ascendant
    x = [asc, 0.0, 1.0]  # lon, lat, radius

    # A1. Transform into equatorial coords
    x = _cotrans(x, -eps)

    # A2. Rotate
    x[0] = (x[0] - (armc - 90.0)) % 360.0

    # A3. Transform into horizontal coords
    x = _cotrans(x, -(90.0 - lat))

    # Save horizon longitude of Asc to restore later
    kr_horizon_lon = x[0]

    # A4. Rotate (set to zero)
    x[0] = 0.0

    # A5. Transform into house system great circle (asc-zenith)
    x = _cotrans(x, -90.0)

    # Now divide the great circle into 12 equal parts (30° each)
    for i in range(6):
        # B0. Set n-th house cusp (0°, 30°, 60°, 90°, 120°, 150°)
        x_cusp = [30.0 * i, 0.0, 1.0]

        # B1. Transform back into horizontal coords
        x_cusp = _cotrans(x_cusp, 90.0)

        # B2. Rotate back
        x_cusp[0] = (x_cusp[0] + kr_horizon_lon) % 360.0

        # B3. Transform back into equatorial coords
        x_cusp = _cotrans(x_cusp, 90.0 - lat)

        # B4. Rotate back → RA of house cusp
        x_cusp[0] = (x_cusp[0] + (armc - 90.0)) % 360.0

        # B5. Convert RA to ecliptic longitude
        # Formula: lon = atan(tan(RA) / cos(obliquity))
        ra = x_cusp[0]
        tan_ra = math.tan(math.radians(ra))
        cos_eps = math.cos(math.radians(eps))

        if abs(cos_eps) > 1e-10:
            lon = math.degrees(math.atan(tan_ra / cos_eps))
        else:
            lon = ra

        # Adjust quadrant
        if 90.0 < ra <= 270.0:
            lon = (lon + 180.0) % 360.0

        lon = lon % 360.0

        cusps[i + 1] = lon
        cusps[i + 7] = (lon + 180.0) % 360.0

    return cusps


def _houses_equal_mc(asc: float, mc: float) -> List[float]:
    """
    Equal houses from MC (code 'D').

    All houses are exactly 30° each, with the MC as the 10th house cusp.
    Unlike Equal from Ascendant, the Ascendant is NOT a house cusp in this system.

    Algorithm:
        H10 = MC
        H11 = MC + 30°
        H12 = MC + 60°
        H1 = MC + 90° (= MC - 270°)
        ... and so on

    Properties:
        - Works at all latitudes
        - MC is exactly on the 10th house cusp
        - Ascendant does NOT coincide with the 1st house cusp (stored in ascmc)

    Args:
        asc: Ascendant longitude in degrees (unused, kept for API compatibility)
        mc: Midheaven longitude in degrees

    Returns:
        List of 13 house cusp longitudes
    """
    cusps = [0.0] * 13
    # H10 = MC, then each house is 30° apart
    # H1 = MC - 270° = MC + 90°
    for i in range(1, 13):
        # House 10 is at MC, so offset = (i - 10) * 30
        cusps[i] = (mc + (i - 10) * 30.0) % 360.0
    return cusps


def _houses_sunshine(
    armc: float, lat: float, eps: float, asc: float, mc: float, sun_dec: float
) -> List[float]:
    """
    Sunshine (Makransky) house system (code 'I').

    Invented by Bob Makransky and published in 1988 in "Primary Directions".
    The diurnal and nocturnal arcs of the Sun are trisected, and great circles
    are drawn through these trisection points and the north and south points
    on the horizon. The intersection points of these great circles with the
    ecliptic are the house cusps.

    Note: Cusps 11, 12, 2, 3 are NOT in exact opposition to cusps 5, 6, 8, 9.

    This implements the Treindl algorithm from Swiss Ephemeris swehouse.c.

    Algorithm:
        1. Calculate ascensional difference from Sun's declination
        2. Compute diurnal semi-arc (DSA = 90 + AD) and nocturnal semi-arc (NSA = 90 - AD)
        3. Trisect the arcs to get offsets for each house cusp
        4. For each intermediate cusp, use spherical trigonometry to find:
           - Great-circle arc length on the Sun's diurnal/nocturnal arc
           - Zenith distance of house circle via spherical triangles
           - Pole height from zenith distance
           - Intersection with ecliptic using Asc1() formula

    Args:
        armc: Sidereal time at Greenwich (RAMC) in degrees
        lat: Geographic latitude in degrees
        eps: True obliquity of ecliptic in degrees
        asc: Ascendant longitude in degrees
        mc: Midheaven longitude in degrees
        sun_dec: Sun's declination in degrees

    Returns:
        List of 13 house cusp longitudes
    """
    VERY_SMALL = 1e-10

    cusps = [0.0] * 13
    cusps[1] = asc
    cusps[10] = mc
    cusps[4] = (mc + 180.0) % 360.0
    cusps[7] = (asc + 180.0) % 360.0

    # Calculate ascensional difference: sin(ad) = tan(dec) * tan(lat)
    arg = math.tan(math.radians(sun_dec)) * math.tan(math.radians(lat))
    if arg >= 1.0:
        ad = 90.0 - VERY_SMALL
    elif arg <= -1.0:
        ad = -90.0 + VERY_SMALL
    else:
        ad = math.degrees(math.asin(arg))

    # Semi-arcs
    nsa = 90.0 - ad  # Nocturnal semi-arc
    dsa = 90.0 + ad  # Diurnal semi-arc

    # House offsets (in right ascension along the diurnal/nocturnal arc)
    # xh[i] is the offset from the angle (IC, Asc, MC, Desc) in RA
    xh = [0.0] * 13
    xh[2] = -2.0 * nsa / 3.0
    xh[3] = -1.0 * nsa / 3.0
    xh[5] = 1.0 * nsa / 3.0
    xh[6] = 2.0 * nsa / 3.0
    xh[8] = -2.0 * dsa / 3.0
    xh[9] = -1.0 * dsa / 3.0
    xh[11] = 1.0 * dsa / 3.0
    xh[12] = 2.0 * dsa / 3.0

    # Precompute trigonometric values
    sinlat = math.sin(math.radians(lat))
    coslat = math.cos(math.radians(lat))
    cosdec = math.cos(math.radians(sun_dec))
    tandec = math.tan(math.radians(sun_dec))
    sinecl = math.sin(math.radians(eps))
    cosecl = math.cos(math.radians(eps))

    # Check if MC is under horizon
    mcdec = math.degrees(
        math.atan(math.sin(math.radians(armc)) * math.tan(math.radians(eps)))
    )
    mc_under_horizon = abs(lat - mcdec) > 90.0

    if mc_under_horizon:
        # Invert offsets if MC is under horizon
        for ih in range(2, 13):
            xh[ih] = -xh[ih]

    # Calculate intermediate cusps using Treindl algorithm
    for ih in range(1, 13):
        if (ih - 1) % 3 == 0:
            continue  # Skip 1, 4, 7, 10 (already set)

        # Great-circle length of the arc offset
        # xhs = 2 * arcsin(cos(dec) * sin(xh/2))
        xhs = 2.0 * math.degrees(
            math.asin(cosdec * math.sin(math.radians(xh[ih] / 2.0)))
        )

        # Compute triangle north pole - mp0 - hp
        # We have two sides 90-dec, base xhs, angle at pole x
        # Derive from cosine rule: cos(a) = tan(dec) * tan(xhs/2)
        cosa = tandec * math.tan(math.radians(xhs / 2.0))
        if abs(cosa) > 1.0:
            cosa = 1.0 if cosa > 0 else -1.0
        alph = math.degrees(math.acos(cosa))

        # Compute triangle south point - mp0 - hp
        # We have: side x', side b, angle alpha2 between the sides
        if ih > 7:  # Diurnal side
            alpha2 = 180.0 - alph
            b = 90.0 - lat + sun_dec
        else:  # Nocturnal side
            alpha2 = alph
            b = 90.0 - lat - sun_dec

        # Compute side c using spherical cosine rule
        cosc = math.cos(math.radians(xhs)) * math.cos(math.radians(b)) + math.sin(
            math.radians(xhs)
        ) * math.sin(math.radians(b)) * math.cos(math.radians(alpha2))
        if abs(cosc) > 1.0:
            cosc = 1.0 if cosc > 0 else -1.0
        c = math.degrees(math.acos(cosc))

        # Use sine rule to find zenith distance
        if c < 1e-6:
            zd = 0.0
        else:
            sinzd = (
                math.sin(math.radians(xhs))
                * math.sin(math.radians(alpha2))
                / math.sin(math.radians(c))
            )
            if abs(sinzd) > 1.0:
                sinzd = 1.0 if sinzd > 0 else -1.0
            zd = math.degrees(math.asin(sinzd))

        # Compute intersection of house circle with equator
        rax = math.degrees(math.atan(coslat * math.tan(math.radians(zd))))

        # Compute pole height (distance of house circle pole from equator)
        pole = math.degrees(math.asin(math.sin(math.radians(zd)) * sinlat))

        # Calculate RA intersection point
        if ih <= 6:
            pole = -pole
            a = (rax + armc + 180.0) % 360.0
        else:
            a = (armc + rax) % 360.0

        # Use Asc1() formula to find ecliptic intersection
        hc = _asc1(a, pole, sinecl, cosecl)
        cusps[ih] = hc

    # Apply 180° correction if MC was under horizon
    if mc_under_horizon:
        for ih in range(1, 13):
            if (ih - 1) % 3 == 0:
                continue
            cusps[ih] = (cusps[ih] + 180.0) % 360.0

    return cusps


def _asc1(x1: float, f: float, sine: float, cose: float) -> float:
    """
    Asc1 function from Swiss Ephemeris (swehouse.c).

    Calculates ecliptic longitude from RA intersection point and pole height.
    This is the same algorithm as _calc_ascendant but exposed as a helper.

    Args:
        x1: Right ascension in degrees
        f: Pole height (latitude parameter)
        sine: sin(obliquity)
        cose: cos(obliquity)

    Returns:
        Ecliptic longitude in degrees (0-360)
    """
    VERY_SMALL = 1e-10

    def _asc2(x: float, f: float, sine: float, cose: float) -> float:
        """Asc2 helper for quadrant calculation."""
        ass = -math.tan(math.radians(f)) * sine + cose * math.cos(math.radians(x))
        if abs(ass) < VERY_SMALL:
            ass = 0.0
        sinx = math.sin(math.radians(x))
        if abs(sinx) < VERY_SMALL:
            sinx = 0.0
        if sinx == 0:
            if ass < 0:
                ass = -VERY_SMALL
            else:
                ass = VERY_SMALL
        elif ass == 0:
            if sinx < 0:
                ass = -90.0
            else:
                ass = 90.0
        else:
            ass = math.degrees(math.atan(sinx / ass))
        if ass < 0:
            ass = 180.0 + ass
        return ass

    x1 = x1 % 360.0
    n = int((x1 / 90.0) + 1)

    if abs(90.0 - f) < VERY_SMALL:
        return 180.0
    if abs(90.0 + f) < VERY_SMALL:
        return 0.0

    if n == 1:
        ass = _asc2(x1, f, sine, cose)
    elif n == 2:
        ass = 180.0 - _asc2(180.0 - x1, -f, sine, cose)
    elif n == 3:
        ass = 180.0 + _asc2(x1 - 180.0, -f, sine, cose)
    else:
        ass = 360.0 - _asc2(360.0 - x1, f, sine, cose)

    return ass % 360.0


def _houses_horizontal(
    armc: float, lat: float, eps: float, asc: float, mc: float
) -> List[float]:
    """
    Horizontal (Azimuthal) house system.

    Algorithm from Swiss Ephemeris swehouse.c lines 1083-1155.
    Uses co-latitude transformation and Campanus-like calculation.

    Mathematical Formulas:
        Co-latitude transformation:
            φ' = 90° - φ  (for φ > 0)
            φ' = -90° - φ (for φ < 0)

        Intermediate azimuths:
            fh1 = arcsin(sin(φ')/2)
            fh2 = arcsin(√3/2 · sin(φ'))
            xh1 = arctan(√3 / cos(φ'))
            xh2 = arctan(1/(√3 · cos(φ')))

        ARMC rotation:
            θ' = ARMC + 180°

        House cusps (using modified Ascendant formula):
            cusps[11] = Asc(θ' + 90° - xh1, ε, φ, fh1)
            cusps[12] = Asc(θ' + 90° - xh2, ε, φ, fh2)
            cusps[1]  = Asc(θ' + 90°, ε, φ, φ')
            cusps[2]  = Asc(θ' + 90° + xh2, ε, φ, fh2)
            cusps[3]  = Asc(θ' + 90° + xh1, ε, φ, fh1)

    Args:
        armc: Sidereal time at Greenwich (RAMC) in degrees
        lat: Geographic latitude in degrees
        eps: True obliquity of ecliptic in degrees
        asc: Ascendant longitude in degrees
        mc: Midheaven longitude in degrees

    Returns:
        List of 13 house cusp longitudes
    """
    cusps = [0.0] * 13
    VERY_SMALL = 1e-10

    # Transform latitude to co-latitude
    if lat > 0:
        fi = 90.0 - lat
    else:
        fi = -90.0 - lat

    # Handle equator case
    if abs(abs(fi) - 90.0) < VERY_SMALL:
        if fi < 0:
            fi = -90.0 + VERY_SMALL
        else:
            fi = 90.0 - VERY_SMALL

    # Rotate ARMC by 180°
    th = (armc + 180.0) % 360.0

    # Calculate intermediate azimuths
    fh1 = math.degrees(math.asin(math.sin(math.radians(fi)) / 2.0))
    fh2 = math.degrees(math.asin(math.sqrt(3.0) / 2.0 * math.sin(math.radians(fi))))

    cosfi = math.cos(math.radians(fi))

    if abs(cosfi) == 0:
        if fi > 0:
            xh1 = xh2 = 90.0
        else:
            xh1 = xh2 = 270.0
    else:
        # tan xh1 = √3 / cos fi
        xh1 = math.degrees(math.atan(math.sqrt(3.0) / cosfi))
        # tan xh2 = 1/√3 / cos fi
        xh2 = math.degrees(math.atan(1.0 / math.sqrt(3.0) / cosfi))

    sine = math.sin(math.radians(eps))
    cose = math.cos(math.radians(eps))

    # Calculate house cusps using _calc_ascendant (Asc1)
    cusps[11] = _calc_ascendant(th + 90.0 - xh1, eps, lat, fh1)
    cusps[12] = _calc_ascendant(th + 90.0 - xh2, eps, lat, fh2)
    cusps[1] = _calc_ascendant(th + 90.0, eps, lat, fi)
    cusps[2] = _calc_ascendant(th + 90.0 + xh2, eps, lat, fh2)
    cusps[3] = _calc_ascendant(th + 90.0 + xh1, eps, lat, fh1)

    # Within polar circle handling
    if abs(fi) >= 90.0 - eps:
        acmc_diff = (asc - mc + 540.0) % 360.0 - 180.0
        if acmc_diff < 0:
            asc = (asc + 180.0) % 360.0
            mc = (mc + 180.0) % 360.0
            for i in range(1, 13):
                if i >= 4 and i < 10:
                    continue
                cusps[i] = (cusps[i] + 180.0) % 360.0

    # Add 180° to cusps 1-3 and 11-12 (per Swiss Ephemeris line 1141-1144)
    for i in range(1, 4):
        cusps[i] = (cusps[i] + 180.0) % 360.0
    for i in range(11, 13):
        cusps[i] = (cusps[i] + 180.0) % 360.0

    # Restore original latitude and ARMC (for reference)
    if fi > 0:
        fi = 90.0 - fi
    else:
        fi = -90.0 - fi
    th = (th + 180.0) % 360.0

    # Check Asc/DC orientation (per Swiss Ephemeris line 1151-1154)
    acmc_diff = (asc - mc + 540.0) % 360.0 - 180.0
    if acmc_diff < 0:
        asc = (asc + 180.0) % 360.0

    # Set MC and calculate opposite houses (cusps 4-9 are opposites of 10-3)
    cusps[10] = mc
    cusps[4] = (mc + 180.0) % 360.0
    cusps[7] = (cusps[1] + 180.0) % 360.0
    cusps[8] = (cusps[2] + 180.0) % 360.0
    cusps[9] = (cusps[3] + 180.0) % 360.0
    cusps[5] = (cusps[11] + 180.0) % 360.0
    cusps[6] = (cusps[12] + 180.0) % 360.0
    # Note: cusps[1] already set correctly, don't overwrite

    return cusps


def _houses_natural_gradient(
    armc: float, lat: float, eps: float, asc: float, mc: float
) -> List[float]:
    """
    Natural Gradient house system ('N').

    In Swiss Ephemeris, 'N' maps to "Equal houses with 0° Aries as cusp 1".
    This is effectively a Whole Sign system starting from 0° Aries.

    Mathematical Formula:
        λᵢ = (i-1) × 30°
        where i = 1, 2, ..., 12

    Result:
        House 1 = 0° Aries (0°)
        House 2 = 0° Taurus (30°)
        House 3 = 0° Gemini (60°)
        ... etc.

    Properties:
        - Independent of time and location
        - Works at all latitudes
        - Computationally exact

    Args:
        armc: Sidereal time at Greenwich (RAMC) in degrees (unused)
        lat: Geographic latitude in degrees (unused)
        eps: True obliquity of ecliptic in degrees (unused)
        asc: Ascendant longitude in degrees (unused)
        mc: Midheaven longitude in degrees (unused)

    Returns:
        List of 13 house cusp longitudes
    """
    cusps = [0.0] * 13
    for i in range(1, 13):
        cusps[i] = ((i - 1) * 30.0) % 360.0
    return cusps


def _apc_sector(n: int, ph: float, e: float, az: float) -> float:
    """
    Calculate one sector of APC (Ascendant-Parallel Circle) house system.

    Mathematical Formulas:
        Ascensional difference of Ascendant:
            kv = arctan(tan(φ)·tan(ε)·cos(ARMC) / (1 + tan(φ)·tan(ε)·sin(ARMC)))

        Declination of Ascendant:
            δ_Asc = arctan(sin(kv) / tan(φ))

        House cusp calculation (for house n):
            For n < 8 (below horizon):
                k = n - 1
                a = kv + ARMC + π/2 + k·(π/2 - kv)/3

            For n >= 8 (above horizon):
                k = n - 13
                a = kv + ARMC + π/2 + k·(π/2 + kv)/3

        Ecliptic longitude:
            λ = atan2(tan(δ_Asc)·tan(φ)·sin(ARMC) + sin(a),
                      cos(ε)·(tan(δ_Asc)·tan(φ)·cos(ARMC) + cos(a))
                      + sin(ε)·tan(φ)·sin(ARMC - a))

    Args:
        n: House number (1-12)
        ph: Geographic latitude in radians
        e: Obliquity of ecliptic in radians
        az: ARMC in radians

    Returns:
        Ecliptic longitude of house cusp in degrees
    """
    VERY_SMALL = 1e-10
    PI = math.pi

    # Calculate kv (ascensional difference of ascendant) and dasc (declination of ascendant)
    if abs(math.degrees(ph)) > 90 - VERY_SMALL:
        kv = 0.0
        dasc = 0.0
    else:
        kv = math.atan(
            math.tan(ph)
            * math.tan(e)
            * math.cos(az)
            / (1 + math.tan(ph) * math.tan(e) * math.sin(az))
        )

        if abs(math.degrees(ph)) < VERY_SMALL:
            dasc = math.radians(90.0 - VERY_SMALL)
            if ph < 0:
                dasc = -dasc
        else:
            dasc = math.atan(math.sin(kv) / math.tan(ph))

    # Determine which arc to use (below or above horizon)
    if n < 8:
        is_below_hor = True  # Houses 1-7
        k = n - 1
    else:
        is_below_hor = False  # Houses 8-12
        k = n - 13

    # Calculate right ascension of house cusp on APC circle
    if is_below_hor:
        a = kv + az + PI / 2 + k * (PI / 2 - kv) / 3
    else:
        a = kv + az + PI / 2 + k * (PI / 2 + kv) / 3

    a = a % (2 * PI)

    # Calculate ecliptic longitude
    dret = math.atan2(
        math.tan(dasc) * math.tan(ph) * math.sin(az) + math.sin(a),
        math.cos(e) * (math.tan(dasc) * math.tan(ph) * math.cos(az) + math.cos(a))
        + math.sin(e) * math.tan(ph) * math.sin(az - a),
    )

    dret = math.degrees(dret) % 360.0

    return dret


def _houses_apc(
    armc: float, lat: float, eps: float, asc: float, mc: float
) -> List[float]:
    """
    APC (Ascendant-Parallel Circle) house system.

    Based on the great circle parallel to the horizon passing through the Ascendant.
    Algorithm from Swiss Ephemeris swehouse.c lines 1806-1829.

    Mathematical Formulas:
        The APC system uses the parallel circle that passes through the Ascendant
        and is tilted at the declination of the Ascendant.

        Ascensional difference of Ascendant:
            kv = arctan(tan(φ)·tan(ε)·cos(ARMC) / (1 + tan(φ)·tan(ε)·sin(ARMC)))

        Declination of Ascendant:
            δ_Asc = arctan(sin(kv) / tan(φ))

        For each house n (1-12):
            Below horizon (n < 8): k = n - 1
                a = kv + ARMC + π/2 + k·(π/2 - kv)/3

            Above horizon (n >= 8): k = n - 13
                a = kv + ARMC + π/2 + k·(π/2 + kv)/3

            λ = atan2(tan(δ_Asc)·tan(φ)·sin(ARMC) + sin(a),
                      cos(ε)·(tan(δ_Asc)·tan(φ)·cos(ARMC) + cos(a))
                      + sin(ε)·tan(φ)·sin(ARMC - a))

    Args:
        armc: Sidereal time at Greenwich (RAMC) in degrees
        lat: Geographic latitude in degrees
        eps: True obliquity of ecliptic in degrees
        asc: Ascendant longitude in degrees
        mc: Midheaven longitude in degrees

    Returns:
        List of 13 house cusp longitudes (index 0 unused, 1-12 are cusps)
    """
    cusps = [0.0] * 13

    # Convert to radians for apc_sector
    ph_rad = math.radians(lat)
    e_rad = math.radians(eps)
    az_rad = math.radians(armc)

    # Calculate all 12 house cusps
    for i in range(1, 13):
        cusps[i] = _apc_sector(i, ph_rad, e_rad, az_rad)

    # MC from apc_sector near latitude 90 is not accurate, use calculated MC
    cusps[10] = mc
    cusps[4] = (mc + 180.0) % 360.0

    # Within polar circle, handle horizon crossing
    if abs(lat) >= 90.0 - eps:
        acmc_diff = (asc - mc + 540.0) % 360.0 - 180.0
        if acmc_diff < 0:
            # Flip all cusps by 180° (clockwise direction)
            asc = (asc + 180.0) % 360.0
            mc = (mc + 180.0) % 360.0
            for i in range(1, 13):
                cusps[i] = (cusps[i] + 180.0) % 360.0

    return cusps


@overload
def house_pos(
    armc: float,
    lat: float,
    obliquity: float,
    hsys_or_objcoord: Tuple[float, float],
    lon_or_hsys: Union[bytes, str],
    lat_body: float = ...,
) -> float: ...


@overload
def house_pos(
    armc: float,
    lat: float,
    obliquity: float,
    hsys_or_objcoord: Union[int, bytes, str],
    lon_or_hsys: float,
    lat_body: float = ...,
) -> float: ...


@overload
def house_pos(
    armc: float,
    lat: float,
    obliquity: float,
    hsys_or_objcoord: Tuple[float, float],
    lon_or_hsys: None = ...,
    lat_body: float = ...,
) -> float: ...


@overload
def house_pos(
    armc: float,
    lat: float,
    obliquity: float,
    hsys_or_objcoord: Union[int, bytes, str],
    lon_or_hsys: None = ...,
    lat_body: float = ...,
) -> float: ...


def house_pos(
    armc: float,
    lat: float,
    obliquity: float,
    hsys_or_objcoord: Union[int, bytes, str, Tuple[float, float]],
    lon_or_hsys: Optional[Union[float, bytes, str]] = None,
    lat_body: float = 0.0,
) -> float:
    """
    Determine in which house a celestial body is located.

    Returns a decimal value where the integer part is the house number (1-12)
    and the decimal part indicates the position within the house
    (0.0 = start of cusp, 0.999... = end of house, just before next cusp).

    This function supports two calling conventions:

    1. Swiss Ephemeris compatible (5 args):
       house_pos(armc, lat, obliquity, objcoord, hsys)
       where objcoord is a tuple (lon, lat_body) and hsys is bytes/str

    2. Extended signature (6 args):
       house_pos(armc, lat, obliquity, hsys, lon, lat_body)
       where hsys is int/bytes/str and lon/lat_body are separate floats

    Args:
        armc: Right Ascension of Medium Coeli (ARMC) in degrees (0-360)
        lat: Geographic latitude in degrees (positive North, negative South)
        obliquity: True obliquity of the ecliptic in degrees
        hsys_or_objcoord: Either house system (int/bytes/str) or objcoord tuple (lon, lat)
        lon_or_hsys: Either body longitude (float) or house system (bytes/str)
        lat_body: Ecliptic latitude of the body in degrees (only for 6-arg form)

    Returns:
        Decimal value where:
            - Integer part (1-12): House number
            - Decimal part (0.0-0.999...): Position within house

    Example:
        >>> # Sun at 15° Aries, Placidus houses, Rome (5-arg pyswisseph form)
        >>> pos = house_pos(292.957, 41.9, 23.4393, (15.0, 0.0), b'P')
        >>> # Or 6-arg extended form:
        >>> pos = house_pos(292.957, 41.9, 23.4393, ord('P'), 15.0, 0.0)
        >>> house = int(pos)  # House number (e.g., 10)
        >>> position = pos - house  # Position within house (e.g., 0.5 = halfway)
    """
    VERY_SMALL = 1e-10
    MILLIARCSEC = 1.0 / 3600000.0  # Add small offset to avoid edge cases

    # Declare typed local variables for proper type narrowing
    lon: float
    hsys_char: str
    hsys_int: int

    # Detect which calling convention is used
    if isinstance(hsys_or_objcoord, tuple):
        # 5-arg pyswisseph form: (armc, lat, obliquity, objcoord, hsys)
        objcoord = hsys_or_objcoord
        lon = objcoord[0]
        lat_body = objcoord[1] if len(objcoord) > 1 else 0.0
        # hsys comes from lon_or_hsys (bytes/str), default to b"P"
        if isinstance(lon_or_hsys, bytes):
            hsys_char = chr(lon_or_hsys[0])
            hsys_int = lon_or_hsys[0]
        elif isinstance(lon_or_hsys, str):
            hsys_char = lon_or_hsys[0]
            hsys_int = ord(lon_or_hsys[0])
        else:
            # Default to Placidus
            hsys_char = "P"
            hsys_int = ord("P")
    else:
        # 6-arg form: (armc, lat, obliquity, hsys, lon, lat_body)
        # hsys comes from hsys_or_objcoord (int/bytes/str)
        if isinstance(hsys_or_objcoord, bytes):
            hsys_char = chr(hsys_or_objcoord[0])
            hsys_int = hsys_or_objcoord[0]
        elif isinstance(hsys_or_objcoord, str):
            hsys_char = hsys_or_objcoord[0]
            hsys_int = ord(hsys_or_objcoord[0])
        else:
            # int case
            hsys_char = chr(hsys_or_objcoord)
            hsys_int = hsys_or_objcoord
        # lon comes from lon_or_hsys (float), default to 0.0
        lon = float(lon_or_hsys) if lon_or_hsys is not None else 0.0

    # Normalize inputs
    lon = lon % 360.0
    eps = obliquity
    geolat = lat

    # Convert ecliptic coordinates to equatorial (like Swiss Ephemeris swe_cotrans)
    # This is crucial for proper house position when body has non-zero latitude
    lon_rad = math.radians(lon)
    lat_body_rad = math.radians(lat_body)
    eps_rad = math.radians(eps)

    # Ecliptic to equatorial transformation
    # RA: tan(RA) = (sin(lon)*cos(eps) - tan(lat)*sin(eps)) / cos(lon)
    # Dec: sin(Dec) = sin(lat)*cos(eps) + cos(lat)*sin(lon)*sin(eps)
    sin_lon = math.sin(lon_rad)
    cos_lon = math.cos(lon_rad)
    sin_lat = math.sin(lat_body_rad)
    cos_lat = math.cos(lat_body_rad)
    sin_eps = math.sin(eps_rad)
    cos_eps = math.cos(eps_rad)

    # Right Ascension
    y_ra = sin_lon * cos_eps - math.tan(lat_body_rad) * sin_eps
    x_ra = cos_lon
    ra = math.degrees(math.atan2(y_ra, x_ra)) % 360.0

    # Declination
    sin_dec = sin_lat * cos_eps + cos_lat * sin_lon * sin_eps
    if sin_dec > 1.0:
        sin_dec = 1.0
    if sin_dec < -1.0:
        sin_dec = -1.0
    de = math.degrees(math.asin(sin_dec))

    # Meridian distance from ARMC
    mdd = (ra - armc) % 360.0
    if mdd >= 180:
        mdd -= 360.0
    mdn = (mdd + 180.0) % 360.0
    if mdn >= 180:
        mdn -= 360.0

    # Handle different house systems
    if hsys_char == "P" or hsys_char == "G":
        # Placidus / Gauquelin - use declination-based method
        # Check circumpolar condition
        if 90.0 - abs(de) <= abs(geolat):
            # Circumpolar - use Otto Ludwig procedure
            if de * geolat < 0:
                xp0 = (90.0 + mdn / 2.0) % 360.0
            else:
                xp0 = (270.0 + mdd / 2.0) % 360.0
        else:
            # Normal case
            sinad = math.tan(math.radians(de)) * math.tan(math.radians(geolat))
            if abs(sinad) > 1.0:
                sinad = 1.0 if sinad > 0 else -1.0
            ad = math.degrees(math.asin(sinad))
            a = sinad + math.cos(math.radians(mdd))
            is_above_hor = a >= 0

            sad = 90.0 + ad  # Semi-diurnal arc
            san = 90.0 - ad  # Semi-nocturnal arc

            if is_above_hor:
                xp0 = (mdd / sad + 3.0) * 90.0
            else:
                xp0 = (mdn / san + 1.0) * 90.0

            # Add small offset for cusp precision
            xp0 = (xp0 + MILLIARCSEC) % 360.0

        if hsys_char == "G":
            # Gauquelin sectors are clockwise
            xp0 = 360.0 - xp0
            hpos = xp0 / 10.0 + 1.0
        else:
            hpos = xp0 / 30.0 + 1.0

        return hpos

    elif hsys_char == "R":
        # Regiomontanus - uses declination
        if abs(mdd) < VERY_SMALL:
            xp0 = 270.0
        elif 180.0 - abs(mdd) < VERY_SMALL:
            xp0 = 90.0
        else:
            if 90.0 - abs(geolat) < VERY_SMALL:
                geolat = 90.0 - VERY_SMALL if geolat > 0 else -90.0 + VERY_SMALL
            if 90.0 - abs(de) < VERY_SMALL:
                de = 90.0 - VERY_SMALL if de > 0 else -90.0 + VERY_SMALL

            a = math.tan(math.radians(geolat)) * math.tan(math.radians(de)) + math.cos(
                math.radians(mdd)
            )
            xp0 = math.degrees(math.atan(-a / math.sin(math.radians(mdd))))
            if mdd < 0:
                xp0 += 180.0
            xp0 = (xp0 + MILLIARCSEC) % 360.0

        hpos = xp0 / 30.0 + 1.0
        return hpos

    elif hsys_char == "C":
        # Campanus - transform to prime vertical
        # The Campanus system divides the prime vertical into 12 equal 30° arcs.
        # For house_pos, we need to find the position of a body on the prime vertical.
        #
        # The transformation follows the Swiss Ephemeris swe_cotrans approach:
        # 1. Input: (-mdd - 90, de) where mdd = meridian distance, de = declination
        # 2. Apply SE's cotrans rotation by -geolat
        # 3. Extract the resulting longitude as the prime vertical position
        # Note: mdd = ra - armc in this function (opposite of SE's convention)
        # so we use mdd - 90 instead of -mdd - 90

        xeq0 = mdd - 90.0
        xeq1 = de

        # SE's swe_cotrans rotation formula (around x-axis with SE's sign convention):
        # x2[0] = x[0]
        # x2[1] = x[1] * cos(eps) + x[2] * sin(eps)
        # x2[2] = -x[1] * sin(eps) + x[2] * cos(eps)
        xeq0_rad = math.radians(xeq0)
        xeq1_rad = math.radians(xeq1)
        rot_angle = math.radians(-geolat)  # SE uses -geolat

        cos_eps = math.cos(rot_angle)
        sin_eps = math.sin(rot_angle)
        cos_xeq0 = math.cos(xeq0_rad)
        sin_xeq0 = math.sin(xeq0_rad)
        cos_xeq1 = math.cos(xeq1_rad)
        sin_xeq1 = math.sin(xeq1_rad)

        # Convert to Cartesian
        x0 = cos_xeq1 * cos_xeq0
        x1 = cos_xeq1 * sin_xeq0
        x2 = sin_xeq1

        # SE's rotation around x-axis
        x2_0 = x0
        x2_1 = x1 * cos_eps + x2 * sin_eps
        x2_2 = -x1 * sin_eps + x2 * cos_eps

        # Convert back to spherical (longitude only needed)
        xp0 = math.degrees(math.atan2(x2_1, x2_0))
        xp0 = (xp0 + MILLIARCSEC) % 360.0
        hpos = xp0 / 30.0 + 1.0
        return hpos

    elif hsys_char in ["E", "A", "W", "V", "D", "N"]:
        # Equal-based systems - simple longitude-based calculation
        asc = _calc_ascendant((armc + 90.0) % 360.0, eps, geolat, geolat)
        mc = _armc_to_mc(armc, eps)

        if hsys_char == "D":
            xp0 = (lon - mc - 90.0) % 360.0
        elif hsys_char == "V":
            xp0 = (lon - asc + 15.0) % 360.0
        elif hsys_char == "W":
            xp0 = (lon - asc + (asc % 30.0)) % 360.0
        elif hsys_char == "N":
            xp0 = lon
        else:
            xp0 = (lon - asc) % 360.0

        xp0 = (xp0 + MILLIARCSEC) % 360.0
        hpos = xp0 / 30.0 + 1.0
        return hpos

    elif hsys_char == "X":
        # Meridian (axial rotation)
        hpos = ((mdd - 90.0) % 360.0) / 30.0 + 1.0
        return hpos

    elif hsys_char == "M":
        # Morinus
        a = lon
        if abs(a - 90.0) > VERY_SMALL and abs(a - 270.0) > VERY_SMALL:
            tant = math.tan(math.radians(a))
            hpos_deg = math.degrees(math.atan(tant / cos_eps))
            if a > 90.0 and a <= 270.0:
                hpos_deg = (hpos_deg + 180.0) % 360.0
        else:
            hpos_deg = 90.0 if abs(a - 90.0) <= VERY_SMALL else 270.0
        hpos_deg = (hpos_deg - armc - 90.0) % 360.0
        hpos = hpos_deg / 30.0 + 1.0
        return hpos

    # Default fallback: use cusp-based method
    cusps, ascmc = swe_houses_armc(armc, lat, obliquity, hsys_int)

    # Find the house containing this longitude
    for i in range(12):
        cusp_start = cusps[i]
        cusp_end = cusps[(i + 1) % 12]

        diff_to_body = (lon - cusp_start + 360.0) % 360.0
        house_size = (cusp_end - cusp_start + 360.0) % 360.0

        if house_size < 0.0001:
            house_size = 30.0

        if diff_to_body < house_size:
            house_num = i + 1
            fraction = diff_to_body / house_size
            fraction = max(0.0, min(fraction, 0.9999999999))
            return float(house_num) + fraction

    return 1.0


def _armc_to_mc(armc: float, eps: float) -> float:
    """Convert ARMC to MC longitude."""
    mc_rad = math.atan2(math.tan(math.radians(armc)), math.cos(math.radians(eps)))
    mc = math.degrees(mc_rad)
    if mc < 0:
        mc += 360.0
    if 90.0 < armc <= 270.0:
        if mc < 90.0 or mc > 270.0:
            mc += 180.0
    elif armc > 270.0:
        if mc < 270.0:
            mc += 180.0
    elif armc <= 90.0:
        if mc > 90.0:
            mc += 180.0
    return mc % 360.0


@overload
def swe_house_pos(
    armc: float,
    lat: float,
    obliquity: float,
    hsys_or_objcoord: Tuple[float, float],
    lon_or_hsys: Union[bytes, str],
    lat_body: float = ...,
) -> float: ...


@overload
def swe_house_pos(
    armc: float,
    lat: float,
    obliquity: float,
    hsys_or_objcoord: Union[int, bytes, str],
    lon_or_hsys: float,
    lat_body: float = ...,
) -> float: ...


@overload
def swe_house_pos(
    armc: float,
    lat: float,
    obliquity: float,
    hsys_or_objcoord: Tuple[float, float],
    lon_or_hsys: None = ...,
    lat_body: float = ...,
) -> float: ...


@overload
def swe_house_pos(
    armc: float,
    lat: float,
    obliquity: float,
    hsys_or_objcoord: Union[int, bytes, str],
    lon_or_hsys: None = ...,
    lat_body: float = ...,
) -> float: ...


def swe_house_pos(
    armc: float,
    lat: float,
    obliquity: float,
    hsys_or_objcoord: Union[int, bytes, str, Tuple[float, float]],
    lon_or_hsys: Optional[Union[float, bytes, str]] = None,
    lat_body: float = 0.0,
) -> float:
    """
    Determine in which house a celestial body is located.

    This function supports two calling conventions:

    1. Swiss Ephemeris compatible (5 args):
       swe_house_pos(armc, lat, obliquity, objcoord, hsys)
       where objcoord is a tuple (lon, lat_body) and hsys is bytes/str

    2. Extended signature (6 args):
       swe_house_pos(armc, lat, obliquity, hsys, lon, lat_body)
       where hsys is int/bytes/str and lon/lat_body are separate floats

    Returns a decimal value where the integer part is the house number (1-12)
    and the decimal part indicates the position within the house.

    Args:
        armc: Right Ascension of Medium Coeli (ARMC) in degrees
        lat: Geographic latitude in degrees
        obliquity: True obliquity of the ecliptic in degrees
        hsys_or_objcoord: Either house system (int/bytes/str) or objcoord tuple
        lon_or_hsys: Either body longitude (float) or house system (bytes/str)
        lat_body: Body's ecliptic latitude in degrees (only for 6-arg form)

    Returns:
        Decimal value: integer part = house number, decimal part = position within house
    """
    # Delegate to house_pos which now supports both calling conventions
    # Use type: ignore since the overloads cover all valid combinations
    return house_pos(armc, lat, obliquity, hsys_or_objcoord, lon_or_hsys, lat_body)  # type: ignore[arg-type]


def _gauquelin_sector_from_rise_set(
    jd: float,
    planet: int,
    lat: float,
    lon: float,
    altitude: float,
    pressure: float,
    temperature: float,
    flags: int,
    method: int,
) -> float:
    """
    Calculate Gauquelin sector using actual rise/set times.

    This implements methods 2-5 which use real rise/set event times
    rather than the hour angle approximation.

    Args:
        jd: Julian Day in UT
        planet: Planet ID
        lat, lon: Observer location
        altitude, pressure, temperature: Atmospheric parameters
        flags: Calculation flags
        method: 2=disc center no refraction, 3=disc center with refraction,
                4=disc edge no refraction, 5=disc edge with refraction

    Returns:
        Sector position in range [1, 37)
    """
    from .eclipse import rise_trans
    from .constants import (
        SE_CALC_RISE,
        SE_CALC_SET,
        SE_BIT_DISC_CENTER,
        SE_BIT_NO_REFRACTION,
    )

    def fallback() -> float:
        """Fall back to method 0 (hour angle approximation)."""
        return gauquelin_sector(
            jd, planet, lat, lon, altitude, pressure, temperature, flags, 0
        )

    # Determine rise_trans flags based on method
    rsmi_flags = 0
    if method in (2, 3):  # Disc center
        rsmi_flags |= SE_BIT_DISC_CENTER
    # Methods 4, 5 use disc edge (upper limb) which is the default

    if method in (2, 4):  # No refraction
        rsmi_flags |= SE_BIT_NO_REFRACTION
    # Methods 3, 5 include refraction (default when flag not set)

    # Strategy: Find the next rise and next set after jd, then find the
    # previous rise and set by searching ~1.5 days before those.
    # Compare which previous event is more recent to determine if
    # planet is above or below horizon.

    try:
        # Find next rise after jd
        jd_next_rise, retflag = rise_trans(
            jd,
            planet,
            lat,
            lon,
            altitude,
            pressure,
            temperature,
            flags,
            SE_CALC_RISE | rsmi_flags,
        )
        if retflag == -2:
            return fallback()  # Circumpolar

        # Find next set after jd
        jd_next_set, retflag = rise_trans(
            jd,
            planet,
            lat,
            lon,
            altitude,
            pressure,
            temperature,
            flags,
            SE_CALC_SET | rsmi_flags,
        )
        if retflag == -2:
            return fallback()  # Circumpolar

        # Find previous rise before jd by searching before the next rise
        # (planets rise roughly once per day, so ~1.5 days before next rise
        # should find the previous rise)
        jd_prev_rise, retflag = rise_trans(
            jd_next_rise - 1.5,
            planet,
            lat,
            lon,
            altitude,
            pressure,
            temperature,
            flags,
            SE_CALC_RISE | rsmi_flags,
        )
        if retflag == -2:
            return fallback()
        # Verify this rise is actually before jd
        if jd_prev_rise >= jd:
            # Try searching earlier
            jd_prev_rise, retflag = rise_trans(
                jd_next_rise - 2.5,
                planet,
                lat,
                lon,
                altitude,
                pressure,
                temperature,
                flags,
                SE_CALC_RISE | rsmi_flags,
            )
            if retflag == -2 or jd_prev_rise >= jd:
                return fallback()

        # Find previous set before jd by searching before the next set
        jd_prev_set, retflag = rise_trans(
            jd_next_set - 1.5,
            planet,
            lat,
            lon,
            altitude,
            pressure,
            temperature,
            flags,
            SE_CALC_SET | rsmi_flags,
        )
        if retflag == -2:
            return fallback()
        # Verify this set is actually before jd
        if jd_prev_set >= jd:
            # Try searching earlier
            jd_prev_set, retflag = rise_trans(
                jd_next_set - 2.5,
                planet,
                lat,
                lon,
                altitude,
                pressure,
                temperature,
                flags,
                SE_CALC_SET | rsmi_flags,
            )
            if retflag == -2 or jd_prev_set >= jd:
                return fallback()

    except Exception:
        return fallback()

    # Determine if planet is above or below horizon:
    # If the most recent event before jd was a rise, planet is above horizon.
    # If the most recent event before jd was a set, planet is below horizon.
    if jd_prev_rise > jd_prev_set:
        # Planet rose more recently than it set -> above horizon (sectors 1-18)
        # Diurnal arc runs from jd_prev_rise to jd_next_set
        diurnal_arc = jd_next_set - jd_prev_rise
        if diurnal_arc <= 0:
            return fallback()

        elapsed = jd - jd_prev_rise
        fraction = elapsed / diurnal_arc
        # Clamp fraction to [0, 1] for safety
        fraction = max(0.0, min(1.0, fraction))

        # Sector 1 is at rise, sector 10 is at MC, sector 18+ is near set
        sector = 1.0 + fraction * 18.0
    else:
        # Planet set more recently than it rose -> below horizon (sectors 19-36)
        # Nocturnal arc runs from jd_prev_set to jd_next_rise
        nocturnal_arc = jd_next_rise - jd_prev_set
        if nocturnal_arc <= 0:
            return fallback()

        elapsed = jd - jd_prev_set
        fraction = elapsed / nocturnal_arc
        # Clamp fraction to [0, 1] for safety
        fraction = max(0.0, min(1.0, fraction))

        # Sector 19 is at set, sector 28 is at IC, sector 36 is near next rise
        sector = 19.0 + fraction * 18.0

    # Normalize to range [1, 37)
    if sector >= 37.0:
        sector -= 36.0
    elif sector < 1.0:
        sector += 36.0

    return sector


def gauquelin_sector(
    jd: float,
    planet: int,
    lat: float,
    lon: float,
    altitude: float = 0.0,
    pressure: float = 1013.25,
    temperature: float = 15.0,
    flags: int = 0,
    method: int = 0,
) -> float:
    """
    Calculate the Gauquelin sector (1-36) in which a planet is located.

    Gauquelin sectors are a 36-fold division of the diurnal and nocturnal arcs
    used in statistical astrology research by Michel Gauquelin. Sectors are
    numbered clockwise from the Ascendant:
    - Sector 1: Rising (Ascendant)
    - Sector 10: Upper culmination (MC)
    - Sector 19: Setting (Descendant)
    - Sector 28: Lower culmination (IC)

    Swiss Ephemeris compatible function (swe_gauquelin_sector equivalent).

    Args:
        jd: Julian Day in Universal Time (UT)
        planet: Planet/body ID (SE_SUN, SE_MOON, etc.)
        lat: Geographic latitude in degrees (positive North)
        lon: Geographic longitude in degrees (positive East)
        altitude: Geographic altitude in meters above sea level (default 0.0)
        pressure: Atmospheric pressure in mbar (default 1013.25)
        temperature: Atmospheric temperature in degrees Celsius (default 15.0)
        flags: Calculation flags (SEFLG_SWIEPH, SEFLG_TOPOCTR, etc.)
        method: Calculation method:
            - 0: with latitude
            - 1: without latitude
            - 2: from rising/setting times of disc center
            - 3: from rising/setting times of disc center, with refraction
            - 4: from rising/setting times of disc edge
            - 5: from rising/setting times of disc edge, with refraction

    Returns:
        Sector position as float in range [1, 37).
        Integer part is sector number (1-36), decimal part is position within sector.

    Note:
        Methods 2-5 use actual rise/set times calculated via rise_trans().
        These methods may be slower than methods 0-1 but provide more
        accurate sector positions based on real rise/set events.

        For circumpolar objects (that never rise or set at the given
        latitude), methods 2-5 fall back to method 0.

    Example:
        >>> sector = gauquelin_sector(2451545.0, SE_MARS, 48.85, 2.35)
        >>> print(f"Mars is in sector {int(sector)}")
    """
    # Methods 2-5: Use actual rise/set times
    if method in (2, 3, 4, 5):
        return _gauquelin_sector_from_rise_set(
            jd, planet, lat, lon, altitude, pressure, temperature, flags, method
        )

    # Methods 0-1: Use house_pos with Gauquelin house system ('G')
    # This matches Swiss Ephemeris behavior exactly
    from .planets import swe_calc_ut
    from .cache import get_true_obliquity

    ts = get_timescale()
    t = ts.ut1_jd(jd)

    # Calculate obliquity of ecliptic
    eps = get_true_obliquity(t.tt)

    # Calculate ARMC (sidereal time at location)
    gast = float(t.gast)  # in hours
    armc_deg = (gast * 15.0 + lon) % 360.0

    # Get planet position
    pos, retflag = swe_calc_ut(jd, planet, flags | SEFLG_SPEED)
    planet_lon = pos[0]
    planet_lat = pos[1]

    # For method 1 (without latitude), ignore ecliptic latitude
    if method == 1:
        planet_lat = 0.0

    # Use house_pos with 'G' (Gauquelin sectors) to get the sector position
    # house_pos returns values in [1, 37) for Gauquelin
    sector = house_pos(armc_deg, lat, eps, (planet_lon, planet_lat), "G")

    return sector


def swe_gauquelin_sector(
    tjdut: float,
    body: int,
    method: int,
    geopos: tuple[float, float, float],
    atpress: float = 0.0,
    attemp: float = 0.0,
    flags: int = 0,
) -> float:
    """
    Calculate the Gauquelin sector (1-36) in which a planet is located.

    Swiss Ephemeris compatible function signature.

    Args:
        tjdut: Julian Day number in Universal Time (UT)
        body: Planet number (SE_SUN, SE_MOON, etc.)
        method: Calculation method:
            - 0: with latitude
            - 1: without latitude
            - 2: from rising/setting times of disc center of planet
            - 3: from rising/setting times of disc center, incl. refraction
            - 4: from rising/setting times of disk edge of planet
            - 5: from rising/setting times of disk edge, incl. refraction
        geopos: Tuple of (longitude, latitude, altitude):
            - longitude: geographic longitude in degrees (eastern positive)
            - latitude: geographic latitude in degrees (northern positive)
            - altitude: geographic altitude in meters above sea level
        atpress: Atmospheric pressure in mbar (if 0, default 1013.25 is used)
        attemp: Atmospheric temperature in degrees Celsius (if 0, default 15 is used)
        flags: Bit flags for ephemeris (SEFLG_SWIEPH, etc.)

    Returns:
        Sector position as float in range [1, 37).
        Integer part is sector number (1-36), decimal part is position within sector.

    Note:
        This function matches the Swiss Ephemeris swe_gauquelin_sector() signature.
        Sectors are numbered clockwise from the Ascendant:
        - Sector 1: Rising (Ascendant)
        - Sector 10: Upper culmination (MC)
        - Sector 19: Setting (Descendant)
        - Sector 28: Lower culmination (IC)

    Example:
        >>> geopos = (2.35, 48.85, 0.0)  # Paris: (lon, lat, alt)
        >>> sector = swe_gauquelin_sector(2451545.0, SE_MARS, 0, geopos)
    """
    lon, lat, altitude = geopos
    # Use defaults if 0 is passed (matching Swiss Ephemeris behavior)
    pressure = atpress if atpress != 0.0 else 1013.25
    temperature = attemp if attemp != 0.0 else 15.0

    return gauquelin_sector(
        tjdut, body, lat, lon, altitude, pressure, temperature, flags, method
    )
