"""
Helper predicates for electional astrology conditions.
These factory functions return Condition callables that can be used with
ElectionalSearch. They provide readable, reusable building blocks for
common astrological filters.
All predicates return `Callable[[CalculatedChart], bool]` (the Condition type).
Each predicate is tagged with a "speed hint" indicating how quickly the condition
changes, enabling hierarchical filtering for performance:
- SPEED_DAY: Stable conditions (phase, retrograde) - checked once at noon
- SPEED_DAY_SIGN: Sign-based conditions - checked at start+end of day
- SPEED_HOUR: Hour-level conditions (VOC, aspects)
- SPEED_MINUTE: House/angular positions (change with Earth's rotation)
Example:
>>> from stellium.electional import ElectionalSearch, is_waxing, not_voc, on_angle
>>> results = (ElectionalSearch("2025-01-01", "2025-12-31", "New York, NY")
... .where(is_waxing())
... .where(not_voc())
... .where(on_angle("Jupiter"))
... .find_moments())
"""
from __future__ import annotations
from collections.abc import Callable
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from stellium.core.models import CalculatedChart
# Type alias for conditions
Condition = Callable[["CalculatedChart"], bool]
# =============================================================================
# Speed Hint Constants
# =============================================================================
# These indicate how quickly a condition changes, enabling hierarchical filtering.
SPEED_DAY = 1 # Stable: phase, retrograde - check noon, skip day if fails
SPEED_DAY_SIGN = 2 # Sign-based: check start+end of day, skip only if BOTH fail
SPEED_HOUR = 3 # VOC, aspects - check hourly
SPEED_MINUTE = 4 # House placement - check at user's step (default for lambdas)
def _tag(condition: Condition, speed: int) -> Condition:
"""Tag a condition with its speed hint for hierarchical filtering."""
condition._speed_hint = speed # type: ignore[attr-defined]
return condition
def _tag_windows(
condition: Condition, window_generator: Callable[..., Any]
) -> Condition:
"""Tag a condition with its window generator for interval optimization.
The window_generator should be a callable that takes (start, end) and returns
a list of TimeWindow objects.
"""
condition._get_windows = window_generator # type: ignore[attr-defined]
return condition
[docs]
def get_speed_hint(condition: Any) -> int:
"""Get the speed hint for a condition, defaulting to SPEED_MINUTE."""
return getattr(condition, "_speed_hint", SPEED_MINUTE)
[docs]
def get_window_generator(condition: Any) -> Callable[..., Any] | None:
"""Get the window generator for a condition, if available."""
return getattr(condition, "_get_windows", None)
# =============================================================================
# Moon Phase Predicates
# =============================================================================
[docs]
def is_waxing() -> Condition:
"""Moon is waxing (between New and Full Moon).
Returns:
Condition that checks if Moon phase is waxing
Example:
>>> search.where(is_waxing())
"""
from stellium.electional.intervals import waxing_windows
def check(chart: CalculatedChart) -> bool:
moon = chart.get_object("Moon")
if moon is None or moon.phase is None:
return False
return moon.phase.is_waxing
condition = _tag(check, SPEED_DAY)
return _tag_windows(condition, waxing_windows)
[docs]
def is_waning() -> Condition:
"""Moon is waning (between Full and New Moon).
Returns:
Condition that checks if Moon phase is waning
"""
from stellium.electional.intervals import waning_windows
def check(chart: CalculatedChart) -> bool:
moon = chart.get_object("Moon")
if moon is None or moon.phase is None:
return False
return not moon.phase.is_waxing
condition = _tag(check, SPEED_DAY)
return _tag_windows(condition, waning_windows)
[docs]
def moon_phase(phases: list[str]) -> Condition:
"""Moon is in one of the specified phases.
Args:
phases: List of phase names, e.g., ["New Moon", "Full Moon"]
Valid phases: "New Moon", "Waxing Crescent", "First Quarter",
"Waxing Gibbous", "Full Moon", "Waning Gibbous",
"Last Quarter", "Waning Crescent"
Returns:
Condition that checks if Moon phase matches any in the list
"""
phases_lower = [p.lower() for p in phases]
def check(chart: CalculatedChart) -> bool:
moon = chart.get_object("Moon")
if moon is None or moon.phase is None:
return False
return moon.phase.phase_name.lower() in phases_lower
return _tag(check, SPEED_DAY)
# =============================================================================
# Void of Course Moon
# =============================================================================
[docs]
def is_voc(mode: str = "traditional") -> Condition:
"""Moon is void of course.
A void of course Moon has no major aspects before leaving its current sign.
Args:
mode: "traditional" (Sun-Saturn) or "modern" (includes outer planets)
Returns:
Condition that checks if Moon is void of course
"""
from functools import partial
from stellium.electional.intervals import voc_windows
def check(chart: CalculatedChart) -> bool:
voc_result = chart.voc_moon(aspects=mode)
return voc_result.is_void
condition = _tag(check, SPEED_HOUR)
return _tag_windows(condition, partial(voc_windows, mode=mode))
[docs]
def not_voc(mode: str = "traditional") -> Condition:
"""Moon is NOT void of course.
Args:
mode: "traditional" (Sun-Saturn) or "modern" (includes outer planets)
Returns:
Condition that checks Moon is NOT void of course
"""
from functools import partial
from stellium.electional.intervals import not_voc_windows
def check(chart: CalculatedChart) -> bool:
voc_result = chart.voc_moon(aspects=mode)
return not voc_result.is_void
condition = _tag(check, SPEED_HOUR)
return _tag_windows(condition, partial(not_voc_windows, mode=mode))
# =============================================================================
# Sign Predicates
# =============================================================================
[docs]
def sign_in(name: str, signs: list[str]) -> Condition:
"""Object is in one of the specified signs.
Args:
name: Object name (e.g., "Moon", "Sun", "Mars")
signs: List of sign names (e.g., ["Aries", "Leo", "Sagittarius"])
Returns:
Condition that checks if object is in any of the signs
"""
from functools import partial
from stellium.electional.intervals import moon_sign_windows
def check(chart: CalculatedChart) -> bool:
obj = chart.get_object(name)
if obj is None:
return False
return obj.sign in signs
condition = _tag(check, SPEED_DAY_SIGN)
# Only attach window generator for Moon (fast-moving)
if name.lower() == "moon":
condition = _tag_windows(condition, partial(moon_sign_windows, signs))
return condition
[docs]
def sign_not_in(name: str, signs: list[str]) -> Condition:
"""Object is NOT in any of the specified signs.
Args:
name: Object name (e.g., "Moon", "Sun", "Mars")
signs: List of sign names to exclude
Returns:
Condition that checks if object is NOT in any of the signs
"""
from functools import partial
from stellium.electional.intervals import moon_sign_not_in_windows
def check(chart: CalculatedChart) -> bool:
obj = chart.get_object(name)
if obj is None:
return False
return obj.sign not in signs
condition = _tag(check, SPEED_DAY_SIGN)
# Only attach window generator for Moon (fast-moving)
if name.lower() == "moon":
condition = _tag_windows(condition, partial(moon_sign_not_in_windows, signs))
return condition
# =============================================================================
# House Predicates
# =============================================================================
[docs]
def in_house(name: str, houses: list[int]) -> Condition:
"""Object is in one of the specified houses.
Args:
name: Object name (e.g., "Moon", "Jupiter")
houses: List of house numbers (1-12)
Returns:
Condition that checks if object is in any of the houses
"""
def check(chart: CalculatedChart) -> bool:
house = chart.get_house(name)
if house is None:
return False
return house in houses
return _tag(check, SPEED_MINUTE)
[docs]
def on_angle(name: str) -> Condition:
"""Object is angular (in houses 1, 4, 7, or 10).
Angular houses are the most powerful positions in electional astrology.
Args:
name: Object name (e.g., "Jupiter", "Venus", "Moon")
Returns:
Condition that checks if object is in an angular house
"""
return in_house(name, [1, 4, 7, 10])
[docs]
def succedent(name: str) -> Condition:
"""Object is in a succedent house (2, 5, 8, 11).
Args:
name: Object name
Returns:
Condition that checks if object is in a succedent house
"""
return in_house(name, [2, 5, 8, 11])
[docs]
def cadent(name: str) -> Condition:
"""Object is in a cadent house (3, 6, 9, 12).
Args:
name: Object name
Returns:
Condition that checks if object is in a cadent house
"""
return in_house(name, [3, 6, 9, 12])
[docs]
def not_in_house(name: str, houses: list[int]) -> Condition:
"""Object is NOT in any of the specified houses.
Args:
name: Object name
houses: List of house numbers to avoid
Returns:
Condition that checks if object is NOT in any of the houses
"""
def check(chart: CalculatedChart) -> bool:
house = chart.get_house(name)
if house is None:
return True # If no house data, consider it "not in" the bad houses
return house not in houses
return _tag(check, SPEED_MINUTE)
# =============================================================================
# Retrograde Predicates
# =============================================================================
[docs]
def is_retrograde(name: str) -> Condition:
"""Planet is retrograde.
Args:
name: Planet name (e.g., "Mercury", "Venus", "Mars")
Returns:
Condition that checks if planet is retrograde
"""
from functools import partial
from stellium.electional.intervals import retrograde_windows
def check(chart: CalculatedChart) -> bool:
obj = chart.get_object(name)
if obj is None:
return False
return obj.is_retrograde
condition = _tag(check, SPEED_DAY)
return _tag_windows(condition, partial(retrograde_windows, name))
[docs]
def not_retrograde(name: str) -> Condition:
"""Planet is NOT retrograde (direct motion).
Args:
name: Planet name (e.g., "Mercury", "Venus", "Mars")
Returns:
Condition that checks if planet is NOT retrograde
"""
from functools import partial
from stellium.electional.intervals import direct_windows
def check(chart: CalculatedChart) -> bool:
obj = chart.get_object(name)
if obj is None:
return True # If object doesn't exist, not retrograde
return not obj.is_retrograde
condition = _tag(check, SPEED_DAY)
return _tag_windows(condition, partial(direct_windows, name))
# =============================================================================
# Dignity Predicates
# =============================================================================
[docs]
def is_dignified(
name: str,
dignities: list[str] | None = None,
system: str = "traditional",
) -> Condition:
"""Planet has essential dignity.
Essential dignity means the planet is strengthened by its sign position:
- ruler: Planet rules the sign (e.g., Mars in Aries)
- exaltation: Planet is exalted (e.g., Sun in Aries)
- triplicity: Planet rules the element (depends on sect)
- bound/term: Planet rules the degree range
- decan/face: Planet rules the 10° section
Args:
name: Planet name
dignities: List of dignity types to check. If None, checks for
major dignities (ruler or exaltation)
system: "traditional" or "modern" dignity system
Returns:
Condition that checks if planet has specified dignities
"""
def check(chart: CalculatedChart) -> bool:
try:
dig_data = chart.get_planet_dignity(name, system=system)
except Exception:
return False
if dig_data is None:
return False
if dignities is None:
# Check for any major dignity
return dig_data.get("ruler", False) or dig_data.get("exaltation", False)
return any(dig_data.get(d, False) for d in dignities)
return _tag(check, SPEED_DAY_SIGN)
[docs]
def is_debilitated(
name: str,
debilities: list[str] | None = None,
system: str = "traditional",
) -> Condition:
"""Planet is debilitated (in detriment or fall).
Args:
name: Planet name
debilities: List of debility types to check ["detriment", "fall"].
If None, checks for either.
system: "traditional" or "modern" dignity system
Returns:
Condition that checks if planet is debilitated
"""
def check(chart: CalculatedChart) -> bool:
try:
dig_data = chart.get_planet_dignity(name, system=system)
except Exception:
return False
if dig_data is None:
return False
if debilities is None:
return dig_data.get("detriment", False) or dig_data.get("fall", False)
return any(dig_data.get(d, False) for d in debilities)
return _tag(check, SPEED_DAY_SIGN)
[docs]
def not_debilitated(name: str, system: str = "traditional") -> Condition:
"""Planet is NOT in detriment or fall.
Args:
name: Planet name
system: "traditional" or "modern" dignity system
Returns:
Condition that checks planet is NOT debilitated
"""
def check(chart: CalculatedChart) -> bool:
try:
dig_data = chart.get_planet_dignity(name, system=system)
except Exception:
return True # If no data, assume not debilitated
if dig_data is None:
return True
return not (dig_data.get("detriment", False) or dig_data.get("fall", False))
return _tag(check, SPEED_DAY_SIGN)
# =============================================================================
# Aspect Predicates
# =============================================================================
[docs]
def aspect_applying(
obj1: str,
obj2: str,
aspects: list[str] | None = None,
orb_max: float | None = None,
) -> Condition:
"""Applying aspect between two objects.
An applying aspect is getting tighter (objects moving toward exact aspect).
Args:
obj1: First object name
obj2: Second object name
aspects: List of aspect types (e.g., ["conjunction", "trine", "sextile"]).
If None, matches any aspect type.
orb_max: Maximum orb in degrees. If None, uses default orbs.
Returns:
Condition that checks for applying aspect between the objects
"""
aspects_lower = [a.lower() for a in aspects] if aspects else None
def check(chart: CalculatedChart) -> bool:
for asp in chart.aspects:
names = {asp.object1.name, asp.object2.name}
if obj1 not in names or obj2 not in names:
continue
# Check aspect type
if aspects_lower and asp.aspect_name.lower() not in aspects_lower:
continue
# Check orb
if orb_max is not None and asp.orb > orb_max:
continue
# Check if applying
if asp.is_applying:
return True
return False
return _tag(check, SPEED_HOUR)
[docs]
def aspect_separating(
obj1: str,
obj2: str,
aspects: list[str] | None = None,
orb_max: float | None = None,
) -> Condition:
"""Separating aspect between two objects.
A separating aspect is getting looser (objects moving away from exact aspect).
Args:
obj1: First object name
obj2: Second object name
aspects: List of aspect types. If None, matches any type.
orb_max: Maximum orb in degrees.
Returns:
Condition that checks for separating aspect between the objects
"""
aspects_lower = [a.lower() for a in aspects] if aspects else None
def check(chart: CalculatedChart) -> bool:
for asp in chart.aspects:
names = {asp.object1.name, asp.object2.name}
if obj1 not in names or obj2 not in names:
continue
if aspects_lower and asp.aspect_name.lower() not in aspects_lower:
continue
if orb_max is not None and asp.orb > orb_max:
continue
# Check if separating (not applying)
if asp.is_applying is False:
return True
return False
return _tag(check, SPEED_HOUR)
[docs]
def has_aspect(
obj1: str,
obj2: str,
aspects: list[str] | None = None,
orb_max: float | None = None,
) -> Condition:
"""Objects are in aspect (regardless of applying/separating).
Args:
obj1: First object name
obj2: Second object name
aspects: List of aspect types. If None, matches any type.
orb_max: Maximum orb in degrees.
Returns:
Condition that checks if objects are in aspect
"""
aspects_lower = [a.lower() for a in aspects] if aspects else None
def check(chart: CalculatedChart) -> bool:
for asp in chart.aspects:
names = {asp.object1.name, asp.object2.name}
if obj1 not in names or obj2 not in names:
continue
if aspects_lower and asp.aspect_name.lower() not in aspects_lower:
continue
if orb_max is not None and asp.orb > orb_max:
continue
return True
return False
return _tag(check, SPEED_HOUR)
[docs]
def no_aspect(
obj1: str,
obj2: str,
aspects: list[str] | None = None,
orb_max: float | None = None,
) -> Condition:
"""Objects are NOT in aspect.
Args:
obj1: First object name
obj2: Second object name
aspects: List of aspect types. If None, means no aspect at all.
orb_max: Maximum orb to consider.
Returns:
Condition that checks if objects are NOT in aspect
"""
has_the_aspect = has_aspect(obj1, obj2, aspects, orb_max)
def check(chart: CalculatedChart) -> bool:
return not has_the_aspect(chart)
# Inherit speed from has_aspect (SPEED_HOUR)
return _tag(check, SPEED_HOUR)
[docs]
def no_hard_aspect(
name: str,
exclude_objects: list[str] | None = None,
applying_only: bool = True,
) -> Condition:
"""Object has no hard aspects (square, opposition) from any planet.
Hard aspects from malefics (Mars, Saturn) are particularly problematic
in electional astrology.
Args:
name: Object name to check
exclude_objects: Objects to ignore (e.g., ["Mars"] if Mars is dignified)
applying_only: If True, only count applying aspects (default True)
Returns:
Condition that checks object has no hard aspects
"""
exclude = exclude_objects or []
hard_aspects = ["square", "opposition"]
def check(chart: CalculatedChart) -> bool:
for asp in chart.aspects:
# Check if this aspect involves our object
names = {asp.object1.name, asp.object2.name}
if name not in names:
continue
# Get the other object
other = (names - {name}).pop()
if other in exclude:
continue
# Check if it's a hard aspect
if asp.aspect_name.lower() not in hard_aspects:
continue
# Check if applying (if required)
if applying_only and not asp.is_applying:
continue
# Found a hard aspect
return False
return True
return _tag(check, SPEED_HOUR)
[docs]
def no_malefic_aspect(name: str, applying_only: bool = True) -> Condition:
"""Object has no hard aspects from Mars or Saturn.
Args:
name: Object name to check
applying_only: If True, only count applying aspects
Returns:
Condition that checks object has no Mars/Saturn hard aspects
"""
hard_aspects = ["conjunction", "square", "opposition"]
malefics = ["Mars", "Saturn"]
def check(chart: CalculatedChart) -> bool:
for asp in chart.aspects:
names = {asp.object1.name, asp.object2.name}
if name not in names:
continue
other = (names - {name}).pop()
if other not in malefics:
continue
if asp.aspect_name.lower() not in hard_aspects:
continue
if applying_only and not asp.is_applying:
continue
return False
return True
return _tag(check, SPEED_HOUR)
# =============================================================================
# Combust Predicate
# =============================================================================
[docs]
def is_combust(name: str, orb: float = 8.5) -> Condition:
"""Planet is combust (too close to the Sun).
A combust planet is weakened by proximity to the Sun.
Args:
name: Planet name
orb: Maximum degrees from Sun to consider combust (default 8.5)
Returns:
Condition that checks if planet is combust
"""
def check(chart: CalculatedChart) -> bool:
planet = chart.get_object(name)
sun = chart.get_object("Sun")
if planet is None or sun is None:
return False
# Calculate angular distance
diff = abs(planet.longitude - sun.longitude)
if diff > 180:
diff = 360 - diff
return diff <= orb
return _tag(check, SPEED_DAY)
[docs]
def not_combust(name: str, orb: float = 8.5) -> Condition:
"""Planet is NOT combust.
Args:
name: Planet name
orb: Minimum degrees from Sun to NOT be combust
Returns:
Condition that checks if planet is NOT combust
"""
def check(chart: CalculatedChart) -> bool:
planet = chart.get_object(name)
sun = chart.get_object("Sun")
if planet is None or sun is None:
return True
diff = abs(planet.longitude - sun.longitude)
if diff > 180:
diff = 360 - diff
return diff > orb
return _tag(check, SPEED_DAY)
# =============================================================================
# Out of Bounds
# =============================================================================
[docs]
def is_out_of_bounds(name: str) -> Condition:
"""Object is out of bounds (declination beyond ~23.4°).
Out of bounds planets are considered to operate outside normal rules.
Args:
name: Object name
Returns:
Condition that checks if object is out of bounds
"""
def check(chart: CalculatedChart) -> bool:
obj = chart.get_object(name)
if obj is None:
return False
return obj.is_out_of_bounds
return _tag(check, SPEED_DAY)
[docs]
def not_out_of_bounds(name: str) -> Condition:
"""Object is NOT out of bounds.
Args:
name: Object name
Returns:
Condition that checks if object is NOT out of bounds
"""
def check(chart: CalculatedChart) -> bool:
obj = chart.get_object(name)
if obj is None:
return True
return not obj.is_out_of_bounds
return _tag(check, SPEED_DAY)
# =============================================================================
# Aspect Exactitude Predicates
# =============================================================================
# Aspect name to angle mapping
_ASPECT_ANGLES = {
"conjunction": 0.0,
"sextile": 60.0,
"square": 90.0,
"trine": 120.0,
"opposition": 180.0,
}
[docs]
def aspect_exact_within(
obj1: str,
obj2: str,
aspect: str,
orb: float = 1.0,
) -> Condition:
"""Aspect between objects is within orb of exact.
This predicate checks if two objects are within a tight orb of an exact
aspect. Useful for finding moments near perfection of an aspect.
Args:
obj1: First object name (e.g., "Moon")
obj2: Second object name (e.g., "Jupiter")
aspect: Aspect name ("conjunction", "sextile", "square", "trine", "opposition")
orb: Maximum orb from exact in degrees (default 1°)
Returns:
Condition that checks if aspect is within orb of exact
Example:
>>> # Find moments when Moon is within 0.5° of exact trine to Jupiter
>>> search.where(aspect_exact_within("Moon", "Jupiter", "trine", orb=0.5))
"""
from functools import partial
from stellium.electional.intervals import aspect_exact_windows
if aspect.lower() not in _ASPECT_ANGLES:
raise ValueError(
f"Unknown aspect: {aspect}. Must be one of {list(_ASPECT_ANGLES.keys())}"
)
aspect_angle = _ASPECT_ANGLES[aspect.lower()]
def check(chart: CalculatedChart) -> bool:
p1 = chart.get_object(obj1)
p2 = chart.get_object(obj2)
if p1 is None or p2 is None:
return False
# Calculate actual separation
diff = abs(p2.longitude - p1.longitude)
if diff > 180:
diff = 360 - diff
# Check if within orb of the aspect angle
error = abs(diff - aspect_angle)
return error <= orb
condition = _tag(check, SPEED_HOUR)
return _tag_windows(
condition, partial(aspect_exact_windows, obj1, obj2, aspect_angle, orb=orb)
)
# =============================================================================
# Angle at Longitude Predicates
# =============================================================================
[docs]
def angle_at_degree(
target_longitude: float,
angle: str = "ASC",
orb: float = 1.0,
) -> Condition:
"""Chart angle is within orb of a specific zodiac degree.
This predicate checks if a chart angle (ASC, MC, DSC, IC) is within
the specified orb of a target longitude. Useful for finding moments
when specific degrees rise or culminate.
Args:
target_longitude: Target longitude in degrees (0-360)
angle: Which angle ("ASC", "MC", "DSC", "IC")
orb: Maximum orb in degrees (default 1°)
Returns:
Condition that checks if angle is within orb of target
Example:
>>> # Find moments when 0° Aries is rising
>>> search.where(angle_at_degree(0.0, "ASC", orb=1.0))
>>> # Find moments when 15° Leo is culminating
>>> search.where(angle_at_degree(135.0, "MC", orb=0.5))
"""
from functools import partial
from stellium.electional.intervals import angle_at_longitude_windows
target = target_longitude % 360
def check(chart: CalculatedChart) -> bool:
houses = chart.get_houses()
if houses is None:
return False
# Get angle longitude from house cusps
# ASC = House 1, MC = House 10, DSC = House 7, IC = House 4
angle_upper = angle.upper()
if angle_upper in ("ASC", "ASCENDANT"):
angle_lon = houses.get_cusp(1)
elif angle_upper in ("MC", "MIDHEAVEN"):
angle_lon = houses.get_cusp(10)
elif angle_upper in ("DSC", "DESCENDANT"):
angle_lon = houses.get_cusp(7)
elif angle_upper in ("IC", "IMUM COELI"):
angle_lon = houses.get_cusp(4)
else:
return False
# Calculate difference handling wraparound
diff = abs(angle_lon - target)
if diff > 180:
diff = 360 - diff
return diff <= orb
# Create window generator with location from search context
def make_window_gen(latitude: float, longitude: float):
return partial(
angle_at_longitude_windows, target, latitude, longitude, angle, orb=orb
)
# Tag with location-aware window generator
condition = _tag(check, SPEED_MINUTE)
condition._angle_window_params = (target, angle, orb) # Store for later
return condition
[docs]
def star_on_angle(
star_name: str,
angle: str = "ASC",
orb: float = 1.0,
) -> Condition:
"""Fixed star is conjunct a chart angle.
This is a convenience wrapper around angle_at_degree() that looks up
the star's current longitude and checks if the specified angle is
within orb of it.
Note: Fixed stars move very slowly (~50 arcseconds/year due to precession),
so for practical purposes within a year search, the star's longitude
is effectively constant.
Args:
star_name: Name of the fixed star (e.g., "Regulus", "Spica", "Algol")
angle: Which angle ("ASC", "MC", "DSC", "IC")
orb: Maximum orb in degrees (default 1°)
Returns:
Condition that checks if star is conjunct angle
Example:
>>> # Find moments when Regulus is rising
>>> search.where(star_on_angle("Regulus", "ASC", orb=1.0))
>>> # Find moments when Spica is culminating
>>> search.where(star_on_angle("Spica", "MC", orb=0.5))
"""
from stellium.core.registry import FIXED_STARS_REGISTRY
from stellium.engines.fixed_stars import SwissEphemerisFixedStarsEngine
if star_name not in FIXED_STARS_REGISTRY:
available = list(FIXED_STARS_REGISTRY.keys())
raise ValueError(
f"Unknown star: {star_name}. Available stars: {available[:10]}..."
)
def check(chart: CalculatedChart) -> bool:
# Get star position at chart time
engine = SwissEphemerisFixedStarsEngine()
stars = engine.calculate_stars(chart.datetime.julian_day, stars=[star_name])
if not stars:
return False
star = stars[0]
star_lon = star.longitude
# Get angle longitude
houses = chart.get_houses()
if houses is None:
return False
angle_upper = angle.upper()
if angle_upper in ("ASC", "ASCENDANT"):
angle_lon = houses.get_cusp(1)
elif angle_upper in ("MC", "MIDHEAVEN"):
angle_lon = houses.get_cusp(10)
elif angle_upper in ("DSC", "DESCENDANT"):
angle_lon = houses.get_cusp(7)
elif angle_upper in ("IC", "IMUM COELI"):
angle_lon = houses.get_cusp(4)
else:
return False
# Calculate difference handling wraparound
diff = abs(angle_lon - star_lon)
if diff > 180:
diff = 360 - diff
return diff <= orb
condition = _tag(check, SPEED_MINUTE)
condition._star_angle_params = (star_name, angle, orb) # Store for later
return condition
# =============================================================================
# Planetary Hour Predicates
# =============================================================================
[docs]
def in_planetary_hour(planet: str) -> Condition:
"""Check if the current time is in a planetary hour ruled by the specified planet.
Planetary hours are a traditional timing system where each hour of the day
is ruled by one of the seven classical planets in Chaldean order.
Args:
planet: Planet name ("Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn")
Returns:
Condition that checks if current time is in that planet's hour
Example:
>>> # Find Jupiter hours (good for expansion, luck, legal matters)
>>> search.where(in_planetary_hour("Jupiter"))
>>> # Find Venus hours on Friday for love matters
>>> search.where(in_planetary_hour("Venus"))
"""
from stellium.electional.planetary_hours import (
CHALDEAN_ORDER,
get_planetary_hour_at_jd,
)
if planet not in CHALDEAN_ORDER:
raise ValueError(f"Unknown planet: {planet}. Must be one of {CHALDEAN_ORDER}")
def check(chart: CalculatedChart) -> bool:
try:
hour = get_planetary_hour_at_jd(
chart.datetime.julian_day,
chart.location.latitude,
chart.location.longitude,
)
return hour.ruler == planet
except ValueError:
# Circumpolar sun or other issue
return False
# Planetary hours change roughly every hour (varies by season)
condition = _tag(check, SPEED_HOUR)
condition._planetary_hour_planet = planet # Store for window generator
return condition
__all__ = [
# Speed hint constants and utilities
"SPEED_DAY",
"SPEED_DAY_SIGN",
"SPEED_HOUR",
"SPEED_MINUTE",
"get_speed_hint",
"get_window_generator",
# Moon phase
"is_waxing",
"is_waning",
"moon_phase",
# VOC
"is_voc",
"not_voc",
# Sign
"sign_in",
"sign_not_in",
# House
"in_house",
"on_angle",
"succedent",
"cadent",
"not_in_house",
# Retrograde
"is_retrograde",
"not_retrograde",
# Dignity
"is_dignified",
"is_debilitated",
"not_debilitated",
# Aspects
"aspect_applying",
"aspect_separating",
"has_aspect",
"no_aspect",
"no_hard_aspect",
"no_malefic_aspect",
# Combust
"is_combust",
"not_combust",
# Out of bounds
"is_out_of_bounds",
"not_out_of_bounds",
# Aspect exactitude
"aspect_exact_within",
# Angle at longitude
"angle_at_degree",
"star_on_angle",
# Planetary hours
"in_planetary_hour",
]