"""
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