Chinese zodiac + seasons + live wildfires; overlapping concept groups

- world_zodiac.py: Chinese zodiac year/element + 2-hour animal, N/S summer (computed)
- world_fire.py: live wildfires from NASA EONET (the fire element)
- overlapping groups: a source can now belong to many concept groups at once
  (elements fire/earth/air/water/spirit, plus wealth + summer vibes) — they stack
- transform.py: member_groups (1:many) replaces 1:1 grouping

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
monsterrobotparty 2026-07-05 02:48:50 +10:00
parent 60173d44cb
commit 7f4b4bcabe
5 changed files with 171 additions and 10 deletions

View File

@ -35,7 +35,14 @@
"market": {"label": "your market", "orbit": "mercury", "members": ["crypto.vel", "market.velocity", "market.turnover"]},
"heavens": {"label": "the sky", "orbit": "neptune", "members": ["astro.moon", "astro.tension", "astro.harmony", "astro.saturn", "astro.mars"]},
"dark": {"label": "the shadow", "members": ["debt.vel", "econ.inflation", "air.pm25", "quake.event", "clock.deaths", "astro.tension", "world.poverty", "world.hunger"]},
"light": {"label": "the bright", "members": ["clock.births", "astro.moon", "astro.harmony", "almanac.fertile", "sky.day", "world.lifeexp", "world.fertility"]}
"light": {"label": "the bright", "members": ["clock.births", "astro.moon", "astro.harmony", "almanac.fertile", "sky.day", "world.lifeexp", "world.fertility"]},
"fire": {"label": "🔥 fire", "members": ["fire.count", "fire.event", "astro.mars", "sun.speed", "crypto.vel"]},
"earth": {"label": "⛰ earth", "members": ["quake.depth", "debt.total", "world.poverty", "astro.saturn"]},
"air": {"label": "💨 air", "members": ["weather.wind", "air.pm25", "planes.count", "wiki.rate"]},
"water": {"label": "💧 water", "members": ["weather.precip", "weather.humidity", "astro.moon", "season.south"]},
"spirit": {"label": "✨ spirit", "members": ["astro.harmony", "almanac.fertile", "zodiac.hour", "clock.births"]},
"wealth": {"label": "💰 wealth", "members": ["crypto.price", "market.turnover", "market.velocity", "debt.total"]},
"summer": {"label": "☀ summer", "members": ["season.north", "season.south", "weather.temp", "sky.day"]}
},
"tweaks": {
@ -118,7 +125,14 @@
"world.poverty": {"norm": {"lo": 0, "hi": 20}, "filter": {"min_cutoff": 0.05, "beta": 0.001}, "label": "extreme poverty (world %)"},
"world.hunger": {"norm": {"lo": 0, "hi": 15}, "filter": {"min_cutoff": 0.05, "beta": 0.001}, "label": "undernourishment (world %)"},
"world.fertility": {"norm": {"lo": 0, "hi": 4}, "label": "world fertility rate"},
"world.lifeexp": {"norm": {"lo": 50, "hi": 85}, "label": "world life expectancy"}
"world.lifeexp": {"norm": {"lo": 50, "hi": 85}, "label": "world life expectancy"},
"zodiac.year": {"norm": {"lo": 0, "hi": 1}, "label": "zodiac year animal"},
"zodiac.element": {"norm": {"lo": 0, "hi": 1}, "label": "zodiac year element"},
"zodiac.hour": {"norm": {"lo": 0, "hi": 1}, "filter": {"min_cutoff": 0.1, "beta": 0.001}, "label": "hour animal (2h cycle)"},
"season.north": {"norm": {"lo": 0, "hi": 1}, "filter": {"min_cutoff": 0.08, "beta": 0.001}, "label": "northern summer"},
"season.south": {"norm": {"lo": 0, "hi": 1}, "filter": {"min_cutoff": 0.08, "beta": 0.001}, "label": "southern summer"},
"fire.count": {"norm": {"lo": 0, "hi": 600}, "filter": {"min_cutoff": 0.2, "beta": 0.002}, "label": "wildfires worldwide"},
"fire.event": {"type": "event", "attack": 0.01, "decay": 0.5, "label": "new wildfire"}
},
"routes": [
@ -173,6 +187,10 @@
{"source": "econ.inflation", "dest": "master.space", "amount": 0.3, "curve": "lin", "label": "inflation erodes the space"},
{"source": "world.poverty", "dest": "drone.voices", "amount": 0.35, "curve": "lin", "label": "the world's poverty weights the drone"},
{"source": "world.hunger", "dest": "saturation", "amount": 0.25, "curve": "lin", "label": "hunger grinds under everything"},
{"source": "world.lifeexp", "dest": "pad.brightness", "amount": 0.2, "curve": "lin", "label": "longer lives, brighter pads"}
{"source": "world.lifeexp", "dest": "pad.brightness", "amount": 0.2, "curve": "lin", "label": "longer lives, brighter pads"},
{"source": "fire.count", "dest": "saturation", "amount": 0.4, "curve": "exp", "label": "the world's wildfires burn the saturation"},
{"source": "fire.event", "dest": "glitch", "amount": 0.8, "curve": "lin", "label": "each new fire flares a glitch"},
{"source": "season.north", "dest": "pad.brightness", "amount": 0.3, "curve": "scurve", "label": "northern summer brightens"},
{"source": "zodiac.hour", "dest": "wavetable.morph", "amount": 0.3, "curve": "lin", "label": "the hour animal turns the timbre"}
]
}

2
run.py
View File

@ -42,6 +42,8 @@ WORLD = [
"workers/world_debt.py",
"workers/world_econ.py",
"workers/world_bank.py",
"workers/world_zodiac.py",
"workers/world_fire.py",
]
# workers that retarget to the venue city

View File

@ -93,7 +93,7 @@ class TweakBank:
def __init__(self, cfg: dict):
self.groups: dict[str, Tweak] = {}
self.group_members: dict[str, list[str]] = {}
self.member_group: dict[str, str] = {}
self.member_groups: dict[str, list] = {} # a source can be in many groups
self.source_tweaks: dict[str, Tweak] = {}
self.controls: list[dict] = list(cfg.get("controls", []))
self.group_orbit: dict[str, str] = {} # group -> planet name
@ -108,7 +108,7 @@ class TweakBank:
if g.get("orbit"):
self.group_orbit[gname] = g["orbit"]
for m in members:
self.member_group.setdefault(m, gname)
self.member_groups.setdefault(m, []).append(gname)
for name, spec in tw.items():
if not name.startswith("@"):
self.source_tweaks[name] = Tweak(spec)
@ -141,8 +141,7 @@ class TweakBank:
out: dict[str, float] = {}
for name, val in sources.items():
v = val
g = self.member_group.get(name)
if g:
for g in self.member_groups.get(name, ()): # stack every concept-group
v = self.groups[g].apply(v)
st = self.source_tweaks.get(name)
if st:
@ -169,8 +168,8 @@ class TweakBank:
st = self.source_tweaks.get(name)
if st and st.p.get("mute", 0.0) >= 0.5:
return True
g = self.member_group.get(name)
return bool(g and self.groups[g].p.get("mute", 0.0) >= 0.5)
return any(self.groups[g].p.get("mute", 0.0) >= 0.5
for g in self.member_groups.get(name, ()))
def set_orbit_lfo(self, gname: str, v: float):
tw = self.groups.get(gname)
@ -188,7 +187,9 @@ class TweakBank:
if orbit:
self.group_orbit[name] = orbit
for m in members:
self.member_group[m] = name
gl = self.member_groups.setdefault(m, [])
if name not in gl:
gl.append(name)
def dump_groups(self) -> dict:
"""Serialize current groups for saving a template."""

70
workers/world_fire.py Normal file
View File

@ -0,0 +1,70 @@
"""
world_fire.py the world on fire. Live wildfires.
Pulls open wildfire events worldwide from NASA's EONET (Earth Observatory
Natural Event Tracker free, no key). The count is a slow burn; each newly
detected fire is a flare-up you can trigger on. The elemental fire of the set
bushfires, forest fires, the literal heat of the planet.
Emits:
/gs/fire/count number of open wildfire events worldwide
/gs/fire/event fired when a new wildfire is detected (magnitude 1)
"""
from __future__ import annotations
import argparse
import json
import time
import urllib.request
from pythonosc.udp_client import SimpleUDPClient
URL = ("https://eonet.gsfc.nasa.gov/api/v3/events"
"?category=wildfires&status=open&limit=500")
UA = "Godstrument/1.0 (live instrument)"
def fetch():
req = urllib.request.Request(URL, headers={"User-Agent": UA})
with urllib.request.urlopen(req, timeout=30) as r:
return json.load(r).get("events", [])
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--host", default="127.0.0.1")
ap.add_argument("--port", type=int, default=9000)
ap.add_argument("--poll", type=float, default=600.0)
args = ap.parse_args()
client = SimpleUDPClient(args.host, args.port)
print(f"[fire] emitting /gs/fire/count|event (NASA EONET wildfires) "
f"(re-query every {args.poll:g}s)")
seen: set[str] = set()
count = 0
first = True
last_poll = 0.0
while True:
try:
if time.time() - last_poll > args.poll or first:
events = fetch()
count = len(events)
for e in events:
eid = e.get("id")
if eid and eid not in seen:
seen.add(eid)
if not first: # no burst on the first load
client.send_message("/gs/fire/event", 1.0)
print(f" 🔥 {e.get('title', 'wildfire')}")
print(f" {count} wildfires burning worldwide")
first = False
last_poll = time.time()
client.send_message("/gs/fire/count", float(count))
except Exception as e: # noqa
print(f"[fire] warn: {e}")
time.sleep(10.0)
if __name__ == "__main__":
main()

70
workers/world_zodiac.py Normal file
View File

@ -0,0 +1,70 @@
"""
world_zodiac.py the Chinese zodiac + the turning seasons. Computed, no API.
The year's animal and element (a 12- and 60-year wheel), plus the "hour animal"
that turns every two hours a slow calendar sequencer the day walks through.
And the seasons: northern vs southern summer intensity from the day of the year,
so "all data that happens in summer" has a signal to gate against, both
hemispheres.
Emits:
/gs/zodiac/year the year's animal (0..1 over Rat..Pig)
/gs/zodiac/element the year's element (0..1 over Wood/Fire/Earth/Metal/Water)
/gs/zodiac/hour the current 2-hour animal (0..1) a 12-step day cycle
/gs/season/north northern-hemisphere summer intensity (0..1)
/gs/season/south southern-hemisphere summer intensity (0..1)
"""
from __future__ import annotations
import argparse
import datetime
import math
import time
from pythonosc.udp_client import SimpleUDPClient
ANIMALS = ["Rat", "Ox", "Tiger", "Rabbit", "Dragon", "Snake",
"Horse", "Goat", "Monkey", "Rooster", "Dog", "Pig"]
ELEMENTS = ["Wood", "Fire", "Earth", "Metal", "Water"]
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--host", default="127.0.0.1")
ap.add_argument("--port", type=int, default=9000)
ap.add_argument("--interval", type=float, default=30.0)
args = ap.parse_args()
client = SimpleUDPClient(args.host, args.port)
print("[zodiac] emitting /gs/zodiac/* and /gs/season/* (computed, no API)")
last_hour = None
while True:
try:
utc = datetime.datetime.now(datetime.timezone.utc)
y = utc.year
year_animal = (y - 4) % 12
year_element = ((y - 4) % 10) // 2
h = datetime.datetime.now().hour # local hour
hour_animal = ((h + 1) // 2) % 12
doy = utc.timetuple().tm_yday
north = 0.5 + 0.5 * math.cos(2 * math.pi * (doy - 172) / 365.25)
south = 1.0 - north
client.send_message("/gs/zodiac/year", year_animal / 11.0)
client.send_message("/gs/zodiac/element", year_element / 4.0)
client.send_message("/gs/zodiac/hour", hour_animal / 11.0)
client.send_message("/gs/season/north", north)
client.send_message("/gs/season/south", south)
if hour_animal != last_hour:
print(f" ☯ year of the {ELEMENTS[year_element]} "
f"{ANIMALS[year_animal]} · hour of the {ANIMALS[hour_animal]}")
last_hour = hour_animal
except Exception as e: # noqa
print(f"[zodiac] warn: {e}")
time.sleep(args.interval)
if __name__ == "__main__":
main()