"""
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 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
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, get_default_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
# Normalize datetime to noon in local timezone
# This ensures planet positions (especially Moon) are calculated for midday
local_dt = self._datetime.local_datetime
if local_dt is not None:
# Get the date and set time to noon
tz = pytz.timezone(self._location.timezone)
noon_local = local_dt.replace(hour=12, minute=0, second=0, microsecond=0)
# Localize if naive
if noon_local.tzinfo is None:
noon_local = tz.localize(noon_local)
# Convert to UTC
noon_utc = noon_local.astimezone(pytz.UTC)
# Calculate Julian Day for noon
hour_decimal = (
noon_utc.hour + noon_utc.minute / 60.0 + noon_utc.second / 3600.0
)
julian_day_noon = swe.julday(
noon_utc.year, noon_utc.month, noon_utc.day, hour_decimal
)
# Update the datetime with noon values
self._datetime = ChartDateTime(
utc_datetime=noon_utc,
julian_day=julian_day_noon,
local_datetime=noon_local.replace(tzinfo=None), # Store naive local
)
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
"""
# Dispatch to unknown time calculation if needed
if self._time_unknown:
return self._calculate_unknown_time_chart()
# 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]] = {}
if not self._config.heliocentric:
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:
additional = component.calculate(
self._datetime,
self._location,
positions,
house_systems_map, # Pass the full map of cusps
house_placements_map,
)
positions.extend(additional)
# If component returned new CelestialPositions
# add their house placements to the placement map for all systems
if additional:
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
)
# Run analyzers
# --- Create a "provisional" chart object ---
# Analyzers need the *full chart* to work on.
provisional_chart = CalculatedChart(
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=component_metadata, # Start with 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
# Calculate ayanamsa value if sidereal
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__
)
# Build chart tags based on configuration
chart_tags: tuple[str, ...] = ()
if self._config.heliocentric:
chart_tags = ("heliocentric",)
# Step 7: Build final chart
return CalculatedChart(
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=final_metadata,
zodiac_type=self._config.zodiac_type,
ayanamsa=self._config.ayanamsa,
ayanamsa_value=ayanamsa_value,
chart_tags=chart_tags,
)
def _calculate_unknown_time_chart(self) -> UnknownTimeChart:
"""
Calculate a chart for unknown birth time.
This is a specialized calculation that:
- Calculates planetary positions for noon
- Skips house and angle calculations entirely
- Calculates Moon range (positions at 00:00, 12:00, 23:59)
- Still calculates aspects (using noon Moon)
Returns:
UnknownTimeChart with moon_range and no houses/angles
"""
# Step 1: Calculate planetary positions (at noon, already normalized)
objects_to_calculate = self._get_objects_list()
positions = self._ephemeris.calculate_positions(
self._datetime, self._location, objects_to_calculate
)
# Step 2: Calculate Moon range (need positions at start and end of day)
moon_range = self._calculate_moon_range()
# Step 3: Calculate aspects (if engine provided)
# Uses noon Moon position for aspect calculations
aspects = []
if self._aspect_engine:
aspects = self._aspect_engine.calculate_aspects(
positions,
self._orb_engine,
)
# Build metadata
final_metadata: dict = {}
# Note: Cache stats removed from metadata for performance.
# get_stats() was scanning 100k+ files on every calculate() call.
# Add chart name to metadata if set
if self._name is not None:
final_metadata["name"] = self._name
# Mark as time unknown
final_metadata["time_unknown"] = True
# Step 4: Build UnknownTimeChart (no houses, no angles)
return UnknownTimeChart(
datetime=self._datetime,
location=self._location,
positions=tuple(positions),
house_systems={}, # No houses for unknown time
house_placements={}, # No house placements
aspects=tuple(aspects),
metadata=final_metadata,
moon_range=moon_range,
)
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 = ".cache",
max_age_seconds: int = 86400,
) -> "ChartBuilder":
"""
Configure caching for this chart calculation.
Args:
cache: Custom cache instance (creates new one if None)
enabled: Whether to enable caching
cache_dir: Cache directory
max_age_seconds: Maximum cache age
Returns:
Self for chaining
Examples:
# Disable caching for this chart
chart = ChartBuilder.from_native(native).with_cache(enabled=False).calculate()
# Use custom cache directory
chart = ChartBuilder.from_native(native).with_cache(cache_dir="/tmp/my_cache").calculate()
# Use shared cache instance
my_cache = Cache(cache_dir="/shared/cache")
chart1 = ChartBuilder.from_native(n1).with_cache(cache=my_cache).calculate()
chart2 = ChartBuilder.from_native(n2).with_cache(cache=my_cache).calculate()
"""
if cache is not None:
self._cache = cache
else:
self._cache = Cache(
cache_dir=cache_dir,
max_age_seconds=max_age_seconds,
enabled=enabled,
)
return self
def _get_cache(self) -> Cache:
"""Get the cache instance for this builder."""
if self._cache is None:
return get_default_cache()
return self._cache