Source code for stellium.core.builder

"""
ChartBuilder: The main API for creating charts.

This is the fluent interface that users interact with. It orchestrates all the engines
and components to build a complete chart.
"""

import datetime as dt
import warnings

import pytz
import swisseph as swe

from stellium.core.ayanamsa import ZodiacType
from stellium.core.config import CalculationConfig
from stellium.core.models import (
    CalculatedChart,
    CelestialPosition,
    ChartDateTime,
    ChartLocation,
    HouseCusps,
    MoonRange,
    UnknownTimeChart,
    longitude_to_sign_and_degree,
)
from stellium.core.native import Native, build_chart_datetime
from stellium.core.protocols import (
    AspectEngine,
    ChartAnalyzer,
    ChartComponent,
    EphemerisEngine,
    HouseSystemEngine,
    OrbEngine,
)
from stellium.data import get_notable_registry
from stellium.engines.aspects import ModernAspectEngine
from stellium.engines.ephemeris import SwissEphemerisEngine
from stellium.engines.houses import PlacidusHouses
from stellium.engines.orbs import SimpleOrbEngine
from stellium.utils.cache import Cache


[docs] class ChartBuilder: """ Fluent builder for creating astrological charts. Example:: chart = ( ChartBuilder.from_native(native) .with_ephemeris(SwissEphemeris()) .with_house_systems([PlacidusHouses(), WholeSignHouses()]) .with_aspects(ModernAspectEngine()) .with_orbs(SimpleOrbEngine()) .calculate() ) """ def __init__( self, datetime: ChartDateTime, location: ChartLocation, native: Native | None = None, ) -> None: """ Initialize builder with required data. Args: datetime: Chart datetime location: Chart location native: Optional Native object (for convenience methods) """ self._datetime = datetime self._location = location self.native = native # Store Native for reference # Default engines (can be overridden) self._ephemeris: EphemerisEngine = SwissEphemerisEngine() self._house_engines: list[HouseSystemEngine] = [PlacidusHouses()] self._aspect_engine: AspectEngine | None = None # optional self._orb_engine: OrbEngine = SimpleOrbEngine() # Configuration self._config = CalculationConfig() # Additional components self._components: list[ChartComponent] = [] # Analyzers self._analyzers: list[ChartAnalyzer] = [] # Cache management self._cache: Cache | None = None # Optional chart name (for display purposes) self._name: str | None = None # Declination aspect engine (optional) self._declination_aspect_engine = None # Unknown time flag self._time_unknown: bool = False
[docs] @classmethod def from_native(cls, native: Native) -> "ChartBuilder": """Create a new ChartBuilder from a Native object. This is the primary factory method. """ # The Native object has already done all the processing. # We just pass its clean attributes to our "pro chef" __init__. builder = cls(native.datetime, native.location, native=native) # If the Native has a name, use it if native.name: builder._name = native.name # If the Native has time_unknown flag, propagate it if native.time_unknown: builder._time_unknown = True return builder
[docs] @classmethod def from_notable(cls, name: str) -> "ChartBuilder": """ Create a ChartBuilder from the notable registry by name. This is a convenience method that looks up a famous birth or event from the curated registry and creates a chart for it. The notable's name is automatically set on the chart for display purposes. Args: name: Name of person or event (case-insensitive) Returns: ChartBuilder instance ready to build, with name pre-set Raises: ValueError: If name not found in registry Example: >>> chart = ChartBuilder.from_notable("Albert Einstein").calculate() >>> chart = ChartBuilder.from_notable("marie curie").calculate() """ registry = get_notable_registry() notable = registry.get_by_name(name) if notable is None: available = len(registry) raise ValueError( f"No notable found: '{name}'. " f"Registry contains {available} entries. " f"Use get_notable_registry().get_all() to see available notables." ) # Notable IS-A Native, so we can use from_native! builder = cls.from_native(notable) # Automatically set the notable's name on the chart builder._name = notable.name return builder
[docs] @classmethod def from_details( cls, datetime_input: str | dt.datetime | dict, location_input: str | tuple[float, float] | dict, *, name: str | None = None, time_unknown: bool = False, ) -> "ChartBuilder": """ Create a ChartBuilder from datetime and location (convenience method). This method accepts flexible datetime and location inputs, creates a Native object internally, and returns a ready-to-configure ChartBuilder. Args: datetime_input: Datetime as string, datetime object, or dict - String: "2024-11-24 14:30", "11/24/2024 2:30 PM", etc. - datetime: Any datetime object (naive will be localized to location) - dict: {"year": 2024, "month": 11, "day": 24, "hour": 14, "minute": 30} location_input: Location as string, (lat, lon) tuple, or dict - String: "Palo Alto, CA" (will be geocoded) - Tuple: (37.4419, -122.1430) - dict: {"latitude": 37.4419, "longitude": -122.1430, "name": "Palo Alto"} name: Optional name of the person or event (for display purposes) time_unknown: If True, creates an UnknownTimeChart (no houses/angles, Moon shown as range, time normalized to noon) Returns: ChartBuilder instance ready to configure Examples: >>> # Simple string inputs >>> chart = ChartBuilder.from_details( ... "1994-01-06 11:47", ... "Palo Alto, CA" ... ).calculate() >>> >>> # With a name >>> chart = ChartBuilder.from_details( ... "1994-01-06 11:47", ... "Palo Alto, CA", ... name="Kate Louie" ... ).calculate() >>> >>> # Unknown birth time >>> chart = ChartBuilder.from_details( ... "1994-01-06", ... "Palo Alto, CA", ... name="Someone", ... time_unknown=True ... ).calculate() """ # Create Native internally (it handles all the parsing) native = Native( datetime_input, location_input, name=name, time_unknown=time_unknown ) # Use from_native which stores the native reference return cls.from_native(native)
# ---- Fluent configuration methods ---
[docs] def with_ephemeris(self, engine: EphemerisEngine) -> "ChartBuilder": """Set the ephemeris engine.""" self._ephemeris = engine return self
[docs] def with_house_systems(self, engines: list[HouseSystemEngine]) -> "ChartBuilder": """ Replaces the entire list of house engines (eg - to calculate *only* Whole Sign) """ if not engines: raise ValueError("House engine list cannot be empty") self._house_engines = engines return self
[docs] def add_house_system(self, engine: HouseSystemEngine) -> "ChartBuilder": """ Adds an additional house engine to the calculation list. (e.g., to calculate Placidus *and* Whole Sign) """ self._house_engines.append(engine) return self
[docs] def with_aspects(self, engine: AspectEngine | None = None) -> "ChartBuilder": """Set the aspect calculation engine.""" self._aspect_engine = engine or ModernAspectEngine() return self
[docs] def with_orbs(self, engine: OrbEngine | None = None) -> "ChartBuilder": """Set the orb calculation engine.""" self._orb_engine = engine or SimpleOrbEngine() return self
[docs] def with_name(self, name: str) -> "ChartBuilder": """ Set the chart name (for display purposes). Args: name: Name to display on the chart (e.g., person's name, event name) Returns: Self for method chaining Example: >>> chart = (ChartBuilder.from_native(native) ... .with_name("John Doe") ... .calculate()) """ self._name = name return self
[docs] def with_config(self, config: CalculationConfig) -> "ChartBuilder": """Set the calculation configuration (which objects to find).""" self._config = config return self
[docs] def with_tnos(self) -> "ChartBuilder": """ Include Trans-Neptunian Objects in the calculation. Adds the major TNOs: - Eris (dwarf planet, discord) - Sedna (isolation, deep healing) - Makemake (resourcefulness, manifestation) - Haumea (rebirth, fertility) - Orcus (oaths, consequences) - Quaoar (creation, harmony) Note: TNOs require additional Swiss Ephemeris asteroid files (se1 files) to be present in your ephemeris data directory. Download them from: https://www.astro.com/ftp/swisseph/ephe/ Example:: chart = ChartBuilder.from_native(native).with_tnos().calculate() """ tno_names = ["Eris", "Sedna", "Makemake", "Haumea", "Orcus", "Quaoar"] for name in tno_names: if name not in self._config.include_asteroids: self._config.include_asteroids.append(name) return self
[docs] def with_uranian(self) -> "ChartBuilder": """ Include Hamburg/Uranian hypothetical planets and points in the calculation. Adds the 8 transneptunian points (TNPs) used in Uranian astrology: - Cupido (family, groups, art, community) - Hades (decay, the past, what's hidden) - Zeus (leadership, fire, directed energy) - Kronos (authority, expertise, high position) - Apollon (expansion, science, commerce, success) - Admetos (depth, stagnation, raw materials) - Vulkanus (immense power, force, intensity) - Poseidon (spirituality, enlightenment, clarity) Also adds the Aries Point (0° Aries), a fundamental reference point in Uranian astrology representing worldly manifestation and the intersection of personal and collective. These are hypothetical planets developed by Alfred Witte and the Hamburg School of Astrology. Example:: # Just Uranian planets chart = ChartBuilder.from_native(native).with_uranian().calculate() # Full Uranian setup (TNOs + TNPs) chart = ChartBuilder.from_native(native).with_tnos().with_uranian().calculate() """ uranian_names = [ "Cupido", "Hades", "Zeus", "Kronos", "Apollon", "Admetos", "Vulkanus", "Poseidon", "Aries Point", ] for name in uranian_names: if name not in self._config.include_asteroids: self._config.include_asteroids.append(name) return self
[docs] def with_sidereal(self, ayanamsa: str = "lahiri") -> "ChartBuilder": """ Use sidereal zodiac for calculations. The sidereal zodiac is based on fixed star positions, unlike the tropical zodiac which is based on the seasons. Different ayanamsa systems represent different methods of calculating the offset between tropical and sidereal. Args: ayanamsa: The ayanamsa system to use. Common options: - "lahiri" (default) - Indian government standard, most common for Vedic - "fagan_bradley" - Primary Western sidereal - "raman" - B.V. Raman's system, popular in South India - "krishnamurti" - Used in KP system - "yukteshwar" - Sri Yukteshwar's system See stellium.core.ayanamsa.list_ayanamsas() for all options Returns: Self for method chaining Example: >>> # Vedic-style chart with Lahiri ayanamsa >>> chart = (ChartBuilder.from_native(native) ... .with_sidereal("lahiri") ... .calculate()) >>> >>> # Western sidereal with Fagan-Bradley >>> chart = (ChartBuilder.from_native(native) ... .with_sidereal("fagan_bradley") ... .calculate()) """ self._config.zodiac_type = ZodiacType.SIDEREAL self._config.ayanamsa = ayanamsa return self
[docs] def with_tropical(self) -> "ChartBuilder": """ Use tropical zodiac for calculations (default). The tropical zodiac is based on the seasons, with 0° Aries aligned to the March equinox. This is the standard system used in Western astrology. This method is included for explicitness - tropical is already the default, so you only need to call this if you want to override a previous .with_sidereal() call. Returns: Self for method chaining Example: >>> # Explicit tropical (same as default) >>> chart = (ChartBuilder.from_native(native) ... .with_tropical() ... .calculate()) >>> >>> # Override previous sidereal setting >>> chart = (ChartBuilder.from_native(native) ... .with_sidereal("lahiri") ... .with_tropical() # Back to tropical ... .calculate()) """ self._config.zodiac_type = ZodiacType.TROPICAL self._config.ayanamsa = None return self
[docs] def with_heliocentric(self) -> "ChartBuilder": """ Use heliocentric (Sun-centered) coordinates. In a heliocentric chart, positions are calculated as seen from the Sun rather than Earth. This changes the chart significantly: - **Earth** appears as a planet (replacing the Sun) - **Sun** is removed (it's the center point) - **Lunar nodes and apogees** are removed (Earth-relative concepts) - **Moon** is kept (still orbits Earth, has heliocentric position) - **Houses and angles** are not calculated (Earth-horizon concepts) Heliocentric charts are used in: - Financial astrology (market timing) - Some modern experimental techniques - Scientific/astronomical contexts Returns: Self for method chaining Example: >>> chart = (ChartBuilder.from_native(native) ... .with_heliocentric() ... .calculate()) >>> earth = chart.get_object("Earth") >>> print(earth.sign_position) # Where Earth is from the Sun's view """ self._config.heliocentric = True return self
[docs] def add_component(self, component: ChartComponent) -> "ChartBuilder": """Add an additional calculation component (e.g. ArabicParts).""" self._components.append(component) return self
[docs] def add_analyzer(self, analyzer: ChartAnalyzer) -> "ChartBuilder": """ Adds a data analyzer to the calculation pipeline. (e.g., PatternDetector) """ self._analyzers.append(analyzer) return self
[docs] def with_declination_aspects( self, orb: float = 1.0, include_types: set | None = None, ) -> "ChartBuilder": """ Enable declination aspect calculation (Parallel/Contraparallel). Declination aspects are based on equatorial coordinates rather than ecliptic longitude. They use a tighter orb (default 1.0°) than longitude-based aspects. - Parallel: Two bodies at the same declination (same hemisphere). Interpreted like a conjunction. - Contraparallel: Two bodies at equal declination but opposite hemispheres. Interpreted like an opposition. Args: orb: Maximum orb in degrees (default 1.0°, range 1.0-1.5° typical) include_types: Which ObjectTypes to include. Default: PLANET, NODE. Can also include ANGLE, ASTEROID, POINT. Returns: Self for chaining Example: >>> chart = (ChartBuilder.from_native(native) ... .with_aspects() ... .with_declination_aspects(orb=1.0) ... .calculate()) >>> for asp in chart.declination_aspects: ... print(asp.description) >>> parallels = chart.get_parallels() >>> contraparallels = chart.get_contraparallels() """ from stellium.engines.aspects import DeclinationAspectEngine self._declination_aspect_engine = DeclinationAspectEngine( orb=orb, include_types=include_types ) return self
[docs] def with_unknown_time(self) -> "ChartBuilder": """ Mark this chart as having unknown birth time. When birth time is unknown: - Time is normalized to noon for planet calculations - Houses and angles will NOT be calculated - Moon will include a range showing possible positions throughout the day - The resulting chart is an UnknownTimeChart (subclass of CalculatedChart) Returns: Self for method chaining Example: >>> chart = (ChartBuilder ... .from_details("1994-01-06", "Palo Alto, CA") ... .with_unknown_time() ... .calculate()) >>> isinstance(chart, UnknownTimeChart) True >>> chart.moon_range.arc_size 13.5 # Moon travels ~13.5° that day """ self._time_unknown = True # Re-anchor the chart to local noon on the same date. We reuse the same # ChartDateTime construction as Native (build_chart_datetime) so the two # noon-normalizers can never drift apart. local_dt = self._datetime.local_datetime if local_dt is not None: noon_local = local_dt.replace( hour=12, minute=0, second=0, microsecond=0, tzinfo=None ) self._datetime = build_chart_datetime(noon_local, self._location.timezone) return self
# --- Calculation --- def _get_objects_list(self) -> list[str]: """Get list of objects to calculate based on config.""" objects = self._config.include_planets.copy() if self._config.include_nodes: objects.append("True Node") if self._config.include_chiron: objects.append("Chiron") objects.extend(self._config.include_points) objects.extend(self._config.include_asteroids) # Handle heliocentric mode: add Earth, remove Sun and Earth-relative points if self._config.heliocentric: # Remove Sun (it's the center in heliocentric) objects = [o for o in objects if o != "Sun"] # Remove lunar nodes (Earth-relative concepts) objects = [o for o in objects if o not in ("True Node", "Mean Node")] # Remove lunar apogees (Earth-relative concepts) objects = [o for o in objects if "Apogee" not in o] # Add Earth (it's now a planet in the chart) objects.append("Earth") # Ensure all names are unique return list(set(objects))
[docs] def bazi(self): """ Calculate the BaZi (Four Pillars / 八字) chart directly from the builder. Skips Western chart calculation entirely — uses the already-resolved datetime and location to compute Chinese Four Pillars. Returns: A BaZiChart with all four pillars, ready for analysis. Example:: bazi = ChartBuilder.from_details("1994-01-06 11:47", "Palo Alto, CA").bazi() print(bazi.hanzi) # Eight characters print(bazi.strength()) # Strength analysis """ from stellium.chinese.bazi.engine import BaZiEngine local_dt = self._datetime.local_datetime utc_dt = self._datetime.utc_datetime if local_dt and utc_dt: offset_seconds = ( local_dt.replace(tzinfo=None) - utc_dt.replace(tzinfo=None) ).total_seconds() offset_hours = offset_seconds / 3600.0 elif self._location and self._location.timezone: tz = pytz.timezone(self._location.timezone) offset_hours = ( tz.utcoffset(utc_dt.replace(tzinfo=None)).total_seconds() / 3600.0 ) else: offset_hours = 0.0 engine = BaZiEngine(timezone_offset_hours=offset_hours) birth_dt = local_dt or utc_dt return engine.calculate(birth_dt)
[docs] def calculate(self) -> CalculatedChart | UnknownTimeChart: """ Execute all calculations and return the final chart. Returns: CalculatedChart with all calculated data, or UnknownTimeChart if time_unknown flag is set """ # Unknown-time charts run through this exact pipeline. They differ only # in that houses/angles are skipped (they need a birth time) and a Moon # day-range is carried instead. Sharing one pipeline is deliberate: a # parallel code path silently drifts (declination aspects, analyzers, # sidereal config, and manifest all went missing that way before). is_unknown = self._time_unknown # Step 1: Calculate planetary positions objects_to_calculate = self._get_objects_list() positions = self._ephemeris.calculate_positions( self._datetime, self._location, objects_to_calculate, self._config ) # Step 2: Calculate all house systems AND angles # (Skip for heliocentric charts - houses are Earth-horizon concepts) house_systems_map: dict[str, HouseCusps] = {} calculated_angles: list[CelestialPosition] = [] house_placements_map: dict[str, dict[str, int]] = {} # Houses and angles require a known birth time; heliocentric charts # have no Earth horizon. Skip both -- everything downstream still runs. if not self._config.heliocentric and not is_unknown: for engine in self._house_engines: system_name = engine.system_name if system_name in house_systems_map: continue # Avoid duplicate calculations # Call the efficient protocol method cusps, angles = engine.calculate_house_data( self._datetime, self._location, self._config ) house_systems_map[system_name] = cusps # Angles are universal, only save them once if not calculated_angles: calculated_angles = angles # Step 3: Add angles to the main position list positions.extend(calculated_angles) # Step 4: Assign house placements for all systems for engine in self._house_engines: system_name = engine.system_name cusps = house_systems_map[system_name] # Get the {object_name: house_num} dict placements = engine.assign_houses(positions, cusps) house_placements_map[system_name] = placements # Step 5: Run additional components (Arabic parts, etc) # (Components can now see angles in the position list) component_metadata = {} _component_manifest: dict = {} for component in self._components: try: additional = component.calculate( self._datetime, self._location, positions, house_systems_map, # Pass the full map of cusps house_placements_map, ) except Exception: # Some components (e.g. accidental dignities) need houses and # cannot run on an unknown-time chart -- skip them there. On a # known-time chart a component failure is a genuine bug, so let # it propagate rather than silently dropping data. if is_unknown: continue raise positions.extend(additional) # If a component returned new CelestialPositions, add their house # placements for every system -- but only when houses were actually # calculated (never for unknown-time charts). if additional and house_systems_map: for engine in self._house_engines: system_name = engine.system_name cusps = house_systems_map[system_name] placements = engine.assign_houses(additional, cusps) house_placements_map[system_name].update(placements) # Add the metadata to the chart object if component has any if hasattr(component, "get_metadata"): metadata_key = component.metadata_name component_metadata[metadata_key] = component.get_metadata() # Record this component in the manifest manifest_entry = { "source": "positions", "object_types": None, "metadata_key": None, } if additional: manifest_entry["object_types"] = sorted( {pos.object_type.value for pos in additional} ) if hasattr(component, "get_metadata") and component.metadata_name: manifest_entry["metadata_key"] = component.metadata_name if additional: manifest_entry["source"] = "both" else: manifest_entry["source"] = "metadata" _component_manifest[component.component_name] = manifest_entry # Step 6: Calculate aspects (if engine provided) aspects = [] if self._aspect_engine: aspects = self._aspect_engine.calculate_aspects( positions, self._orb_engine, # Pass the configured orb engine ) # Step 6b: Calculate declination aspects (if engine provided) declination_aspects = [] if self._declination_aspect_engine: declination_aspects = self._declination_aspect_engine.calculate_aspects( positions ) # Compute zodiac + tag metadata now so the provisional chart (for # analyzers) and the final chart are assembled through one code path. ayanamsa_value = None if self._config.zodiac_type == ZodiacType.SIDEREAL: from stellium.core.ayanamsa import get_ayanamsa_value ayanamsa_value = get_ayanamsa_value( self._datetime.julian_day, self._config.ayanamsa, # type: ignore # Already validated in config.__post_init__ ) chart_tags: tuple[str, ...] = () if self._config.heliocentric: chart_tags = ("heliocentric",) # Unknown-time charts carry the Moon's day-range in place of houses. moon_range = self._calculate_moon_range() if is_unknown else None def _assemble(metadata: dict) -> CalculatedChart: """Build the correct chart type from the shared calculation state. One factory used for both the provisional and final charts so an unknown-time chart can never diverge from a known-time one. """ common = { "datetime": self._datetime, "location": self._location, "positions": tuple(positions), "house_systems": house_systems_map, "house_placements": house_placements_map, "aspects": tuple(aspects), "declination_aspects": tuple(declination_aspects), "metadata": metadata, "zodiac_type": self._config.zodiac_type, "ayanamsa": self._config.ayanamsa, "ayanamsa_value": ayanamsa_value, "chart_tags": chart_tags, } if is_unknown: return UnknownTimeChart(**common, moon_range=moon_range) return CalculatedChart(**common) # --- Run analyzers against a provisional chart --- # Analyzers need the full (correctly-typed) chart to work on. provisional_chart = _assemble(component_metadata) final_metadata = component_metadata.copy() # Allow external metadata injection (used by ReturnBuilder, etc.) if hasattr(self, "_extra_metadata"): final_metadata.update(self._extra_metadata) for analyzer in self._analyzers: final_metadata[analyzer.metadata_name] = analyzer.analyze(provisional_chart) _component_manifest[analyzer.analyzer_name] = { "source": "metadata", "object_types": None, "metadata_key": analyzer.metadata_name, } # Store the component manifest in metadata final_metadata["_component_manifest"] = _component_manifest # Note: Cache stats removed from metadata for performance. # get_stats() was scanning 100k+ files on every calculate() call. # Use stellium.utils.cache.get_cache_stats() directly if needed. # Add chart name to metadata if set if self._name is not None: final_metadata["name"] = self._name # Flag unknown-time charts for downstream consumers. if is_unknown: final_metadata["time_unknown"] = True # Step 7: Build final chart return _assemble(final_metadata)
def _calculate_moon_range(self) -> MoonRange: """ Calculate the Moon's position range for the day. Calculates Moon position at: - 00:00:00 (start of day in LOCAL timezone) - 12:00:00 (noon - displayed position) - 23:59:59 (end of day in LOCAL timezone) Returns: MoonRange with start, noon, and end positions """ # Use LOCAL time for day boundaries, not UTC # This ensures the moon range matches the user's actual day local_dt = self._datetime.local_datetime # Get the timezone from the location tz = pytz.timezone(self._location.timezone) # Calculate start of day in LOCAL time, then convert to UTC for JD calculation start_of_day_local = local_dt.replace(hour=0, minute=0, second=0, microsecond=0) if start_of_day_local.tzinfo is None: start_of_day_local = tz.localize(start_of_day_local) start_of_day_utc = start_of_day_local.astimezone(pytz.UTC) jd_start = swe.julday( start_of_day_utc.year, start_of_day_utc.month, start_of_day_utc.day, start_of_day_utc.hour + start_of_day_utc.minute / 60.0 + start_of_day_utc.second / 3600.0, ) # Calculate end of day in LOCAL time (23:59:59), then convert to UTC end_of_day_local = local_dt.replace( hour=23, minute=59, second=59, microsecond=0 ) if end_of_day_local.tzinfo is None: end_of_day_local = tz.localize(end_of_day_local) end_of_day_utc = end_of_day_local.astimezone(pytz.UTC) jd_end = swe.julday( end_of_day_utc.year, end_of_day_utc.month, end_of_day_utc.day, end_of_day_utc.hour + end_of_day_utc.minute / 60.0 + end_of_day_utc.second / 3600.0, ) # Get Moon position at start of day moon_start = swe.calc_ut(jd_start, swe.MOON)[0] start_longitude = moon_start[0] # Get Moon position at end of day moon_end = swe.calc_ut(jd_end, swe.MOON)[0] end_longitude = moon_end[0] # Get Moon position at noon (current calculation time) moon_noon = swe.calc_ut(self._datetime.julian_day, swe.MOON)[0] noon_longitude = moon_noon[0] # Determine signs start_sign, _ = longitude_to_sign_and_degree(start_longitude) end_sign, _ = longitude_to_sign_and_degree(end_longitude) # Check if Moon crosses sign boundary crosses_boundary = start_sign != end_sign return MoonRange( start_longitude=start_longitude, end_longitude=end_longitude, noon_longitude=noon_longitude, start_sign=start_sign, end_sign=end_sign, crosses_sign_boundary=crosses_boundary, )
[docs] def with_cache( self, cache: Cache | None = None, enabled: bool = True, cache_dir: str | None = None, max_age_seconds: int = 86400, ) -> "ChartBuilder": """ Deprecated, and a no-op. Chart calculation is not disk-cached. This never had an effect: the cache it built was stored on the builder and read by nothing (``_get_cache()`` was never called). The ephemeris engines used the *global* cache regardless, so ``with_cache(enabled=False)`` did not disable anything. There is now nothing to configure. Swiss Ephemeris positions are no longer cached to disk at all — the round-trip measured 13x slower than simply recomputing them. Geocoding *is* still cached (it is a network call); point it somewhere else with the ``STELLIUM_CACHE_DIR`` environment variable, or manage it via :class:`stellium.utils.cache.Cache`. Returns: Self for chaining """ warnings.warn( "ChartBuilder.with_cache() is deprecated and does nothing. Chart " "calculation is not disk-cached (it is faster to recompute). Geocoding " "still is — set STELLIUM_CACHE_DIR to relocate that cache.", DeprecationWarning, stacklevel=2, ) return self