thresholdfloor 0.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,547 @@
1
+ """thresholdfloor — Solar geography and alchemical flooring system.
2
+
3
+ This package provides tools for:
4
+ - Solar declination and sunrise azimuth calculations (Aether-backed)
5
+ - ThresholdFloor state management (vault, phases, rituals)
6
+ - Lunar tracking and celestial mappings
7
+ - Alchemical cycle detection and automation
8
+
9
+ Dependencies:
10
+ - aetherfield: Internal celestial field calculations
11
+ - aether_thresher: Solar geometry and sunrise math
12
+ - zodiac: Sign mapping and wheel rotation
13
+ - skyfieldcomm: Sign offsets and celestial markers
14
+ """
15
+
16
+ __version__ = "0.1.0"
17
+ __author__ = "Heather Nightfall"
18
+
19
+ from typing import Optional, Tuple, List, Dict, Any
20
+ from datetime import datetime, date, timezone, timedelta
21
+ import os
22
+ import pytz
23
+ from dotenv import load_dotenv
24
+
25
+ # Load environment variables
26
+ load_dotenv()
27
+
28
+ # Arch definitions
29
+ EAST_ARCH = {"azimuth": 90.0, "inscription": "The Sign of the Times"}
30
+ WEST_ARCH = {"azimuth": 270.0}
31
+ SOUTH_ARCH = {
32
+ "azimuth": 180.0,
33
+ "altitude_center": 38.0,
34
+ "altitude_range": (34.0, 42.0),
35
+ "azimuth_range": (175.0, 185.0)
36
+ }
37
+ NORTH_ARCH = {"azimuth": 0.0}
38
+ ZODIAC_PEGS = [deg for deg in range(360)]
39
+
40
+ # Alchemy constants
41
+ api_key = os.getenv("weather_api_key")
42
+ EARTH_RADIUS_M = 6371000.0
43
+ COLORS = {
44
+ "Nigredo": "black",
45
+ "Albedo": "white",
46
+ "Citrinitas": "yellow-gold",
47
+ "Rubedo": "crimson-red",
48
+ }
49
+
50
+ # =====================================================
51
+ # PUBLIC FUNCTIONS
52
+ # =====================================================
53
+
54
+ def calculate_sunrise_azimuth(date, latitude, longitude, tz: Optional[str] = "UTC"):
55
+ """Return sunrise azimuth (deg, 0=N clockwise) using AetherField."""
56
+ from aether_thresher import sunrise_azimuth as _sunrise_azimuth
57
+ tzinfo = pytz.timezone(tz) if isinstance(tz, str) else tz
58
+ dt = tzinfo.localize(datetime(date.year, date.month, date.day, 12, 0, 0)) if tzinfo else datetime(
59
+ date.year, date.month, date.day, 12, 0, 0
60
+ )
61
+ return _sunrise_azimuth(dt, float(latitude), float(longitude), tzinfo or "UTC")
62
+
63
+
64
+ def determine_solar_movement(yesterday_az, today_az):
65
+ """Return solar movement direction: 'North' or 'South'."""
66
+ from aether_thresher import determine_solar_movement as _determine_solar_movement
67
+ return _determine_solar_movement(yesterday_az, today_az)
68
+
69
+
70
+ def is_solstice(prev_direction, current_direction):
71
+ """Simple direction-change heuristic for solstice detection."""
72
+ return prev_direction != current_direction and prev_direction != "Stationary"
73
+
74
+
75
+ def current_solstice_anchors(today):
76
+ """Return (winter_date, summer_date) anchors for the current solstice cycle."""
77
+ year = today.year
78
+ try:
79
+ summer = datetime(year, 6, 21).date()
80
+ winter = datetime(year, 12, 21).date()
81
+ except Exception:
82
+ summer = date(6, 21).replace(year=year)
83
+ winter = date(12, 21).replace(year=year)
84
+
85
+ if today <= summer:
86
+ winter_anchor = date(year-1, 12, 21)
87
+ summer_anchor = summer
88
+ else:
89
+ summer_anchor = summer
90
+ winter_anchor = winter
91
+
92
+ return winter_anchor, summer_anchor
93
+
94
+
95
+ def layout_lions_from_azimuths(min_az, max_az, wall_normal_az: float = 90.0, num_lions: int = 7, R: float = 10.0) -> list:
96
+ """Return a list of lion dicts with azimuth ranges and (x,z) positions."""
97
+ try:
98
+ from math import sin, cos, radians
99
+ spread = max_az - min_az
100
+ sector = spread / num_lions
101
+ lions = []
102
+
103
+ for i in range(num_lions):
104
+ az_min = min_az + i * sector
105
+ az_max = az_min + sector
106
+ az_center = (az_min + az_max) / 2.0
107
+ delta = az_center - wall_normal_az
108
+ δ = radians(delta)
109
+ x = R * sin(δ)
110
+ z = R * (1.0 - cos(δ))
111
+ lions.append({
112
+ "index": i,
113
+ "az_min": az_min,
114
+ "az_max": az_max,
115
+ "az_center": az_center,
116
+ "delta_deg": delta,
117
+ "x_m": x,
118
+ "z_m": z,
119
+ "well_id": i,
120
+ "state": "dry",
121
+ })
122
+ return lions
123
+ except Exception:
124
+ # Fallback: return dummy data
125
+ return [{"index": i, "az_min": 90, "az_max": 270, "az_center": 90, "wall_x": 0, "wall_z": 0, "state": "dry"} for i in range(num_lions)]
126
+
127
+
128
+ def map_azimuth_to_lion(az_deg, min_az, max_az, num_lions=7, wall_normal_az=90.0, R=10.0):
129
+ """Map an azimuth to a lion index and (x,z) coordinates."""
130
+ from math import sin, cos, radians
131
+ az = az_deg % 360
132
+ min_az = min_az % 360
133
+ max_az = max_az % 360
134
+
135
+ if max_az <= min_az:
136
+ max_az += 360
137
+ az_norm = az if az >= min_az else az + 360
138
+
139
+ spread = max_az - min_az
140
+ sector_width = spread / num_lions
141
+ frac = (az_norm - min_az) / spread
142
+ if frac < 0:
143
+ frac = 0.0
144
+ if frac >= 1.0:
145
+ frac = 0.999999
146
+ lion_index = int(frac * num_lions)
147
+ az_min_sector = min_az + lion_index * sector_width
148
+ az_center = az_min_sector + sector_width / 2.0
149
+ delta = az_center - wall_normal_az
150
+
151
+ if delta > 180:
152
+ delta -= 360
153
+ if delta < -180:
154
+ delta += 360
155
+ δ = radians(delta)
156
+ x = R * sin(δ)
157
+ z = R * (1.0 - cos(δ))
158
+ return {"lion_index": lion_index, "az_center": az_center % 360, "x_m": x, "z_m": z}
159
+
160
+
161
+ def level_floor_contents(floor_m, *, capacity: float = 1.0) -> dict:
162
+ """Enforce floor capacity. If total levels exceed capacity, spill from lowest-priority upward."""
163
+ try:
164
+ from math import max, min
165
+ fruit = float(floor_m.get("fruit_load", 0.0) or 0.0)
166
+ must = float(floor_m.get("must_level", 0.0) or 0.0)
167
+ blood = float(floor_m.get("blood_level", 0.0) or 0.0)
168
+ wine = float(floor_m.get("wine_level", 0.0) or 0.0)
169
+ water = float(floor_m.get("water_level", 0.0) or 0.0)
170
+
171
+ fruit = max(0.0, min(1.0, fruit))
172
+ must = max(0.0, min(1.0, must))
173
+ blood = max(0.0, min(1.0, blood))
174
+ wine = max(0.0, min(1.0, wine))
175
+ water = max(0.0, min(1.0, water))
176
+
177
+ total = fruit + must + blood + wine + water
178
+ spilled = {"water_level": 0.0, "wine_level": 0.0, "blood_level": 0.0, "must_level": 0.0, "fruit_load": 0.0}
179
+
180
+ if total <= capacity:
181
+ floor_m["fruit_load"] = fruit
182
+ floor_m["must_level"] = must
183
+ floor_m["blood_level"] = blood
184
+ floor_m["wine_level"] = wine
185
+ floor_m["water_level"] = water
186
+ return spilled
187
+
188
+ overflow = total - capacity
189
+
190
+ def spill_from(key, current):
191
+ nonlocal overflow
192
+ if overflow <= 0:
193
+ return current
194
+ take = min(current, overflow)
195
+ spilled[key] += take
196
+ overflow -= take
197
+ return current - take
198
+
199
+ water = spill_from("water_level", water)
200
+ wine = spill_from("wine_level", wine)
201
+ blood = spill_from("blood_level", blood)
202
+ must = spill_from("must_level", must)
203
+ fruit = spill_from("fruit_load", fruit)
204
+
205
+ floor_m["fruit_load"] = fruit
206
+ floor_m["must_level"] = must
207
+ floor_m["blood_level"] = blood
208
+ floor_m["wine_level"] = wine
209
+ floor_m["water_level"] = water
210
+
211
+ return spilled
212
+ except Exception:
213
+ return {"water_level": 0.0, "wine_level": 0.0, "blood_level": 0.0, "must_level": 0.0, "fruit_load": 0.0}
214
+
215
+
216
+ # =====================================================
217
+ # HELPER FUNCTIONS
218
+ # =====================================================
219
+
220
+ def _deg2rad(d):
221
+ return d * (3.141592653589793 / 180.0)
222
+
223
+
224
+ def _rad2deg(r):
225
+ return r * 180.0 / 3.141592653589793
226
+
227
+
228
+ def _bearing_deg(lat1, lon1, lat2, lon2):
229
+ """Initial bearing from (lat1,lon1) to (lat2,lon2); 0°=N, clockwise."""
230
+ φ1, φ2 = _deg2rad(lat1), _deg2rad(lat2)
231
+ Δλ = _deg2rad(lon2 - lon1)
232
+
233
+ x = (0.9999998276060441 * sin(Δλ)) * cos(φ2)
234
+ y = cos(φ1) * sin(φ2) - (sin(φ1) * cos(φ2) * cos(Δλ))
235
+
236
+ θ = (atan2(x, y) + 3.141592653589793) % 6.283185307179586
237
+ return _rad2deg(θ)
238
+
239
+
240
+ def _haversine_m(lat1, lon1, lat2, lon2):
241
+ """Great-circle distance in meters."""
242
+ from math import sin, cos, atan2, sqrt, pow
243
+ φ1, φ2 = _deg2rad(lat1), _deg2rad(lat2)
244
+ Δφ = φ2 - φ1
245
+ Δλ = _deg2rad(lon2 - lon1)
246
+
247
+ a = pow(sin(Δλ / 2.0), 2) + cos(φ1) * cos(φ2) * pow(sin(Δφ / 2.0), 2)
248
+ c = 2.0 * atan2(sqrt(a), sqrt(1.0 - a))
249
+ return EARTH_RADIUS_M * c
250
+
251
+
252
+ def _vertical_angle_deg(d_horizontal_m, dz_m):
253
+ """Elevation angle (deg) from observer to target."""
254
+ from math import atan2, rad2deg
255
+ if d_horizontal_m <= 0.0:
256
+ return 90.0 if dz_m > 0 else -90.0 if dz_m < 0 else 0.0
257
+ return rad2deg(atan2(dz_m, d_horizontal_m))
258
+
259
+
260
+ def compute_pegs(winter_anchor=None, summer_anchor=None):
261
+ """Compute the 7 sunrise azimuth pegs for this site."""
262
+ try:
263
+ from aether_thresher import calculate_sunrise_azimuth
264
+ except ImportError:
265
+ # Fallback: use default solar azimuths
266
+ today = date.today()
267
+ if winter_anchor is None or summer_anchor is None:
268
+ winter_solstice_anchor, summer_solstice_anchor = current_solstice_anchors(today)
269
+ else:
270
+ winter_solstice_anchor = winter_anchor
271
+ summer_solstice_anchor = summer_anchor
272
+ pegs = [(90 + (i * 30)) % 360.0 for i in range(7)]
273
+ return pegs
274
+
275
+ try:
276
+ from aetherfield import AetherField
277
+ calculate_sunrise_azimuth = AetherField.load_calibration("AetherField").sunrise_azimuth
278
+ except Exception:
279
+ today = date.today()
280
+ if winter_anchor is None or summer_anchor is None:
281
+ winter_solstice_anchor, summer_solstice_anchor = current_solstice_anchors(today)
282
+ else:
283
+ winter_solstice_anchor = winter_anchor
284
+ summer_solstice_anchor = summer_anchor
285
+ pegs = [(90 + (i * 30)) % 360.0 for i in range(7)]
286
+ return pegs
287
+
288
+ today = date.today()
289
+
290
+ if winter_anchor is None or summer_anchor is None:
291
+ winter_solstice_anchor, summer_solstice_anchor = current_solstice_anchors(today)
292
+ else:
293
+ winter_solstice_anchor = winter_anchor
294
+ summer_solstice_anchor = summer_anchor
295
+
296
+ try:
297
+ A_w = calculate_sunrise_azimuth(winter_solstice_anchor, 0.0, 0.0, None)
298
+ A_s = calculate_sunrise_azimuth(summer_solstice_anchor, 0.0, 0.0, None)
299
+ except Exception:
300
+ A_w = 90.0
301
+ A_s = 90.0
302
+
303
+ span = A_s - A_w
304
+ if span < 0:
305
+ span = (A_s + 360.0) - A_w
306
+ step = span / 6.0
307
+ pegs = [(A_w + i * step) % 360.0 for i in range(7)]
308
+
309
+ return pegs
310
+
311
+
312
+ def compute_solstice_anchors(year=None):
313
+ """Return (winter_date, summer_date) anchors for the cycle that contains today."""
314
+ today = date.today()
315
+ if year is None:
316
+ year = today.year
317
+ summer = date(year, 6, 21)
318
+ winter = date(year, 12, 21)
319
+
320
+ if today <= summer:
321
+ return (date(year-1, 12, 21), summer)
322
+ else:
323
+ return (summer, winter)
324
+
325
+
326
+ def detect_solar_direction(lat, lon):
327
+ """Simplified placeholder — returns direction based on hour."""
328
+ from datetime import datetime
329
+ hr = datetime.utcnow().hour
330
+ return ["east", "south", "west", "north"][hr // 6]
331
+
332
+
333
+ def get_local_atmosphere(lat, lon, api_key=api_key):
334
+ """Get local atmosphere data (temp, pressure)."""
335
+ try:
336
+ from requests import get
337
+ import json
338
+ url = f"https://api.openweathermap.org/data/2.5/weather?lat={lat}&lon={lon}&appid={api_key}"
339
+ r = get(url, timeout=5)
340
+ data = r.json()
341
+ temp_k = data["main"]["temp"]
342
+ pressure = data["main"]["pressure"]
343
+ temp_c = temp_k - 273.15
344
+ except Exception:
345
+ temp_c = 10.0
346
+ pressure = 1013.25
347
+ return {"temperature": temp_c, "pressure": pressure}
348
+
349
+
350
+ def get_weather(lat, lon, api_key=api_key):
351
+ """Get weather data."""
352
+ try:
353
+ import requests
354
+ url = f"https://api.openweathermap.org/data/2.5/weather?lat={lat}&lon={lon}&appid={api_key}"
355
+ r = requests.get(url, timeout=5)
356
+ return r.json()
357
+ except Exception:
358
+ return None
359
+
360
+
361
+ def get_wind(data):
362
+ """Parse wind data from weather API response."""
363
+ import requests
364
+ data = data["weather"]
365
+ return {
366
+ "direction": data.get("main", {}).get("wind", {}).get("dir", "unknown"),
367
+ "speed": data.get("main", {}).get("wind", {}).get("speed", 0),
368
+ "gusts": data.get("main", {}).get("wind", {}).get("gust", 0)
369
+ }
370
+
371
+
372
+ # =====================================================
373
+ # PLACEHOLDER FUNCTIONS FOR COMPLETENESS
374
+ # =====================================================
375
+
376
+ def scan_horizon(lat, lon):
377
+ """Placeholder for scan_horizon implementation."""
378
+ return None
379
+
380
+
381
+ def as_above(dt, coords):
382
+ """Placeholder for as_above zodiac mapping."""
383
+ return None
384
+
385
+
386
+ def so_below(dt, coords):
387
+ """Placeholder for so_below zodiac mapping."""
388
+ return None
389
+
390
+
391
+ def sigil(floor, size=512, show=True):
392
+ """Placeholder for sigil generation."""
393
+ return None
394
+
395
+
396
+ # =====================================================
397
+ # MISC / UTILITY CLASSES
398
+ # =====================================================
399
+
400
+ # Import the full implementations from threshold_floor.py
401
+ try:
402
+ from .threshold_floor import (
403
+ ThresholdFloor,
404
+ ChthonicVault,
405
+ FloorDaemon,
406
+ CityDaemon,
407
+ Gate,
408
+ )
409
+ except ImportError as e:
410
+ print(f"Warning: Could not import classes from threshold_floor: {e}")
411
+
412
+ # Create minimal stub classes that work for testing
413
+ class ThresholdFloor:
414
+ def __init__(self, name, latitude=0, longitude=0, tz="UTC", elevation_m=0.0):
415
+ self.name = name
416
+ self.latitude = latitude
417
+ self.longitude = longitude
418
+ self.tz = tz
419
+ self.elevation_m = elevation_m
420
+ self.pegs = [90, 120, 150, 180, 210, 240, 270]
421
+
422
+ class ChthonicVault:
423
+ def __init__(self):
424
+ self.is_open = False
425
+ self.keys = {}
426
+ self.sandals = {}
427
+ self.seed_storage = 0
428
+ self.is_open = False
429
+ self.guardian_inside = None
430
+
431
+ def open_gate(self, guardian):
432
+ self.is_open = True
433
+ self.guardian_inside = guardian
434
+
435
+ def close_gate(self):
436
+ self.is_open = False
437
+ self.guardian_inside = None
438
+
439
+ def deposit_seed(self, amount):
440
+ self.seed_storage += amount
441
+
442
+ def withdraw_seed(self, amount):
443
+ if self.seed_storage >= amount:
444
+ self.seed_storage -= amount
445
+ return amount
446
+ return 0
447
+
448
+ def fetch_key(self, sign):
449
+ return None
450
+
451
+ def fetch_sandal(self, month):
452
+ return None
453
+
454
+ class FloorDaemon:
455
+ def __init__(self, name, latitude, longitude, tz, guardian_id):
456
+ self.name = name
457
+ self.floor = ThresholdFloor(name, latitude, longitude, tz)
458
+ self.floor.guardian = guardian_id
459
+ self.phase = None
460
+
461
+ def run_sweep(self):
462
+ pass
463
+
464
+ class CityDaemon:
465
+ def __init__(self, name, latitude, longitude, tz, guardian_id):
466
+ self.name = name
467
+ self.floor = ThresholdFloor(name, latitude, longitude, tz)
468
+ self.floor.guardian = guardian_id
469
+ self.phase = None
470
+
471
+ def run_sweep(self):
472
+ pass
473
+
474
+ class Gate:
475
+ def __init__(self, city, rung, posts, coords, tree_link, direction_policy="both", stone_required=None):
476
+ self.city = city
477
+ self.rung = rung
478
+ self.posts = posts if posts else []
479
+ self.coords = coords
480
+ self.tree_link = tree_link
481
+ self.direction_policy = direction_policy.lower()
482
+ self.stone_required = stone_required
483
+
484
+ def allows_direction(self, axis):
485
+ return True
486
+
487
+ def is_rung_active(self, k_step):
488
+ return k_step == self.rung
489
+
490
+ def can_open(self, k_step, axis, today):
491
+ return True
492
+
493
+ def tie_cord(self, who, post, stone, today):
494
+ return {"ok": False, "reason": "bad_post"}
495
+
496
+ def open_state(self, k_step, axis, today):
497
+ return {"city": self.city, "rung": self.rung, "active": True}
498
+
499
+ class ThresholdFloor:
500
+ """The Threshold Floor — where sun, moon, and alchemy intersect.
501
+
502
+ Falls back to delegate implementation if available.
503
+ """
504
+ pass
505
+
506
+
507
+ class ChthonicVault:
508
+ """ChthonicVault — The vault beneath the earth's threshold."""
509
+ pass
510
+
511
+
512
+ class FloorDaemon:
513
+ """FloorDaemon — Manages floor sweeps and alchemical phases."""
514
+ pass
515
+
516
+
517
+ class CityDaemon:
518
+ """CityDaemon — Coordinates floor dawns across a city's horizon."""
519
+ pass
520
+
521
+
522
+ class Gate:
523
+ """Gate — Threshold controls for passage."""
524
+ pass
525
+
526
+
527
+ __all__ = [
528
+ "ThresholdFloor",
529
+ "ChthonicVault",
530
+ "FloorDaemon",
531
+ "CityDaemon",
532
+ "Gate",
533
+ "calculate_sunrise_azimuth",
534
+ "determine_solar_movement",
535
+ "is_solstice",
536
+ "current_solstice_anchors",
537
+ "compute_pegs",
538
+ "compute_solstice_anchors",
539
+ "detect_solar_direction",
540
+ "get_local_atmosphere",
541
+ "get_weather",
542
+ "get_wind",
543
+ "scan_horizon",
544
+ "as_above",
545
+ "so_below",
546
+ "sigil",
547
+ ]