Monday, 2 November 2026

From Code to Light: A Beautiful Diwali Diya Animation in Python

 


from __future__ import annotations

import math import time from dataclasses import dataclass from typing import Optional, Tuple import numpy as np TAU = 2.0 * math.pi # ============================================================================= # 1. CONFIGURATION (everything here can also be set from the command line) # ============================================================================= @dataclass class Config: fps: float = 30.0 # target frames per second speed: float = 1.0 # global animation speed (also speeds up the intro) intro: float = 1.0 # multiplies the length of the intro stages (1-4) density: float = 1.0 # particle density multiplier glow: float = 1.0 # strength of the surrounding ambient glow accent: float = 1.0 # amount of cyan / electric-blue accents (0 = none) cols: int = 0 # canvas width in characters (0 = whole terminal) rows: int = 0 # canvas height in lines (0 = whole terminal) color_mode: str = "auto" # auto | truecolor | 256 seed: Optional[int] = None # fixed random seed for a repeatable animation duration: float = 0.0 # stop automatically after N seconds (0 = never) # Colour ramps: (heat 0..1, (r, g, b)). Edit these to re-colour the whole piece. FIRE_STOPS = [ (0.00, (35, 4, 28)), # deep plum (0.12, (125, 8, 38)), # ruby red (0.27, (220, 30, 42)), # vivid crimson (0.42, (255, 76, 35)), # coral (0.58, (255, 137, 25)), # bright orange (0.73, (255, 194, 45)), # amber (0.88, (255, 235, 105)), # golden yellow (1.00, (255, 255, 230)), # warm-white core ] ACCENT_STOPS = [ (0.00, (35, 12, 110)), # royal purple (0.24, (88, 35, 210)), # violet (0.48, (0, 115, 255)), # electric blue (0.70, (0, 220, 220)), # turquoise (0.86, (70, 255, 170)), # emerald (1.00, (235, 255, 255)), # icy highlight ] FIRE_STEPS = 48 # number of distinct fire colours ACCENT_STEPS = 20 # number of distinct accent colours # Glyphs. Index into GLYPHS is what the frame buffers store (0 = blank). GLYPHS = " .,:;!|/\\-_=+*xX#%@" LEVEL_GROUPS = [".,", ":;", "+*", "xX#", "%@"] # very low .. maximum intensity LEVEL_EDGES = np.array([0.07, 0.20, 0.36, 0.58, 0.80]) # heat thresholds between groups # ============================================================================= # 2. SMALL HELPERS # ============================================================================= def smoothstep(e0, e1, x): t = np.clip((x - e0) / (e1 - e0), 0.0, 1.0) return t * t * (3.0 - 2.0 * t) def flow(a, b, t): """Cheap smooth pseudo-noise in [-1, 1] built from travelling sine waves.""" return (np.sin(a * 1.7 + b * 1.1 + t * 1.9) + np.sin(a * 3.3 - b * 2.1 - t * 2.7 + 1.3) + np.sin(a * 5.9 + b * 3.7 + t * 3.9 + 2.1)) * (1.0 / 3.0) def build_ramp(stops, steps): pos = np.array([s[0] for s in stops], dtype=np.float64) rgb = np.array([s[1] for s in stops], dtype=np.float64) xs = np.linspace(0.0, 1.0, steps) cols = [np.interp(xs, pos, rgb[:, c]) for c in range(3)] return np.stack(cols, axis=1).round().astype(np.uint8) def rgb_to_xterm256(r: int, g: int, b: int) -> int: """Nearest xterm-256 colour (6x6x6 cube or greyscale ramp).""" levels = (0, 95, 135, 175, 215, 255) def nearest(v): return min(range(6), key=lambda i: abs(levels[i] - v)) ri, gi_, bi = nearest(r), nearest(g), nearest(b) cube = (levels[ri], levels[gi_], levels[bi]) cube_idx = 16 + 36 * ri + 6 * gi_ + bi gray_i = min(23, max(0, int(round((((r + g + b) / 3.0) - 8) / 10.0)))) gv = 8 + 10 * gray_i d_cube = sum((a - c) ** 2 for a, c in zip((r, g, b), cube)) d_gray = sum((a - gv) ** 2 for a in (r, g, b)) return 232 + gray_i if d_gray < d_cube else cube_idx FIRE_RAMP = build_ramp(FIRE_STOPS, FIRE_STEPS) ACCENT_RAMP = build_ramp(ACCENT_STOPS, ACCENT_STEPS) COLOR_TABLE = np.vstack([np.zeros((1, 3), np.uint8), FIRE_RAMP, ACCENT_RAMP]) ACCENT_BASE = 1 + FIRE_STEPS # first accent index in COLOR_TABLE def _glyph(c: str) -> int: return GLYPHS.index(c) GROUP_IDX = np.array( [[_glyph(g[i % len(g)]) for i in range(4)] for g in LEVEL_GROUPS], dtype=np.int32 ) # shape (5 intensity levels, 4 interchangeable variants) def shade(h, k, accent): """Heat (float), variant (int 0-3) and accent flag arrays -> (glyph, colour) index arrays.""" lvl = np.searchsorted(LEVEL_EDGES, h, side="right") # 0 = blank, 1..5 glyph = np.where(lvl > 0, GROUP_IDX[np.maximum(lvl - 1, 0), k], 0) hc = np.clip(h, 0.0, 1.0) fire = 1 + (hc * (FIRE_STEPS - 1) + 0.5).astype(np.int32) acc = ACCENT_BASE + (np.clip(h * 1.15, 0.0, 1.0) * (ACCENT_STEPS - 1) + 0.5).astype(np.int32) col = np.where(accent, acc, fire) return glyph.astype(np.uint8), col.astype(np.uint8) # ============================================================================= # 4. DIYA GEOMETRY # ============================================================================= class DiyaShape: """ Static geometry of the lamp, evaluated on the character grid. Coordinates are "normalised": X right, Y up, 1.0 = half the canvas height, and one terminal column counts as half a row so shapes keep their true proportions. """ Y_RIM = -0.20 # height of the rim ellipse centre R = 0.78 # half width of the bowl D = 0.46 # depth of the bowl below the rim P = 2.10 # super-ellipse power of the bowl profile RIM_B = 0.10 # half height of the rim ellipse (seen slightly from above) FOOT_TOP = -0.64 FOOT_BOT = -0.77 Y_WICK = Y_RIM + 0.02 def __init__(self, W: int, H: int, rng: np.random.Generator): self.W, self.H = W, H self.unit = max(4.0, min(H / 2.0, W / 4.0)) * 0.97 self.cx = (W - 1) / 2.0 self.cy = (H - 1) / 2.0 xs = (np.arange(W) - self.cx) * 0.5 / self.unit ys = (self.cy - np.arange(H)) / self.unit self.X, self.Y = np.meshgrid(xs, ys) # per-cell random constants (visibility, glyph variants, twinkle phases ...) self.r1 = rng.random((H, W)) self.r2 = rng.random((H, W)) self.r3 = rng.random((H, W)) self.phase = rng.uniform(0.0, TAU, (H, W)) self.rate = rng.uniform(1.5, 7.0, (H, W)) # glyph substitution speed self.rate2 = rng.uniform(0.7, 3.2, (H, W)) # twinkle speed inside, heat, oil, _ = self.evaluate(self.X, self.Y) self.bowl_in, self.oil = inside, oil self._build_edges(inside) # outline cells are drawn dark red / dark orange, the rest keeps its lighting self.bowl_heat = np.where(self.edge, 0.24 + 0.36 * heat, heat) * inside # soft radial illumination around the flame self.glow = np.exp(-((self.X / 0.80) ** 2 + ((self.Y - 0.10) / 0.85) ** 2)) # how late each cell is revealed while the diya forms (centre first, ragged edge) dist = np.hypot(self.X * 0.85, self.Y + 0.15) / 1.05 self.rv = 0.65 * np.clip(dist, 0.0, 1.3) + 0.35 * self.r1 # ---- analytic description of the lamp ------------------------------ def evaluate(self, X, Y): """Return (inside, static heat, oil mask, sampling weight) for arbitrary coordinates.""" R, yr = self.R, self.Y_RIM ax = np.abs(X) below = Y <= yr g = (ax / R) ** self.P + (np.maximum(yr - Y, 0.0) / self.D) ** 2 body = below & (g <= 1.0) ell = (ax / (0.98 * R)) ** 1.8 + ((Y - yr) / self.RIM_B) ** 2 rim = ell <= 1.0 foot_t = np.clip((self.FOOT_TOP - Y) / (self.FOOT_TOP - self.FOOT_BOT), 0.0, 1.0) foot_hw = 0.22 + 0.15 * foot_t ** 1.6 foot = (Y <= self.FOOT_TOP) & (Y >= self.FOOT_BOT) & (ax <= foot_hw) inside = body | rim | foot # lighting of the bowl body: brighter near the rim, highlights on the shoulders ybot = yr - self.D v = np.clip((Y - ybot) / self.D, 0.0, 1.0) heat = 0.40 + 0.34 * v ** 1.2 + 0.12 * v * np.exp(-(X / 0.5) ** 2) hl1 = np.exp(-(((X + 0.42 * R) / 0.22) ** 2 + ((Y - (yr - 0.14)) / 0.06) ** 2)) hl2 = np.exp(-(((X - 0.50 * R) / 0.15) ** 2 + ((Y - (yr - 0.20)) / 0.045) ** 2)) heat = heat + 0.40 * hl1 + 0.18 * hl2 # decorative band: a row of glowing pearls and a darker line above it yb = yr - 0.21 pearl = (np.abs(Y - yb) < 0.026) & (np.abs((X / 0.105) % 1.0 - 0.5) < 0.21) & (g < 0.72) heat = np.where(pearl, 0.92, heat) line = (np.abs(Y - (yb + 0.075)) < 0.011) & (g < 0.85) heat = np.where(line, heat * 0.55, heat) # foot foot_h = 0.30 + 0.28 * (1.0 - foot_t) heat = np.where(foot & ~body, foot_h, heat) # rim ellipse: bright front lip, softer back lip, glowing oil inside ring = rim & (ell > 0.60) heat = np.where(ring, np.where(below, 0.90, 0.55), heat) oil = rim & (ell <= 0.60) heat = np.where(oil, 0.80 + 0.18 * np.exp(-(X / 0.2) ** 2), heat) edge_b = np.exp(-((g - 0.93) / 0.14) ** 2) * below edge_e = np.exp(-((ell - 0.85) / 0.20) ** 2) weight = inside * (0.30 + 0.70 * np.maximum(np.maximum(edge_b, edge_e), foot * 0.6)) return inside, heat * inside, oil, weight def _build_edges(self, inside): """Outline cells and the glyph ( / \\ | - _ ) that follows the outline direction.""" pad = np.pad(inside, 1) eroded = inside & pad[:-2, 1:-1] & pad[2:, 1:-1] & pad[1:-1, :-2] & pad[1:-1, 2:] self.edge = inside & ~eroded def blur(a): p = np.pad(a, 1) return (4 * a + 2 * (p[:-2, 1:-1] + p[2:, 1:-1] + p[1:-1, :-2] + p[1:-1, 2:]) + (p[:-2, :-2] + p[:-2, 2:] + p[2:, :-2] + p[2:, 2:])) / 16.0 s = blur(blur(inside.astype(np.float64))) gy, gx = np.gradient(s) # d/drow, d/dcol th = np.arctan2(2.0 * gx, gy) % math.pi # direction of the outline in screen space glyph = np.full(inside.shape, _glyph("|"), np.uint8) top = gy > 0 # inside lies below -> upper outline horiz = (th < math.pi / 8) | (th >= 7 * math.pi / 8) slash = (th >= math.pi / 8) & (th < 3 * math.pi / 8) back = (th >= 5 * math.pi / 8) & (th < 7 * math.pi / 8) glyph[slash] = _glyph("/") glyph[back] = _glyph("\\") glyph[horiz & top] = _glyph("-") glyph[horiz & ~top] = _glyph("_") self.edge_glyph = glyph self.edge_sel = self.edge & (self.r3 < 0.88) # a few gaps keep it organic # ---- sampling / mapping -------------------------------------------- def to_cell(self, X, Y): c = np.rint(self.cx + 2.0 * self.unit * X).astype(np.int64) r = np.rint(self.cy - self.unit * Y).astype(np.int64) return r, c def sample_bowl(self, n: int, rng: np.random.Generator): """n home positions on the lamp (outline weighted) plus their static heat.""" xs, ys, hs = [], [], [] got = 0 while got < n: m = (n - got) * 4 + 64 X = rng.uniform(-self.R * 1.02, self.R * 1.02, m) Y = rng.uniform(self.FOOT_BOT, self.Y_RIM + self.RIM_B, m) _, heat, _, w = self.evaluate(X, Y) keep = rng.random(m) < w xs.append(X[keep]) ys.append(Y[keep]) hs.append(heat[keep]) got += int(keep.sum()) return np.concatenate(xs)[:n], np.concatenate(ys)[:n], np.concatenate(hs)[:n] # ============================================================================= # 5. FLAME # ============================================================================= class FireSimulation: """Procedural, continuously changing flame (shape, sway, flicker, heat).""" H0 = 0.98 # full flame height (normalised units) W0 = 0.20 # full flame half width def __init__(self, shape: DiyaShape): self.shape = shape self.y0 = shape.Y_WICK self.burst = 0.0 # set by the controller for "glowing bursts" def height(self, t, grow): f = (1.0 + 0.055 * math.sin(TAU * 1.9 * t + 0.5) + 0.040 * math.sin(TAU * 3.7 * t + 1.7) + 0.025 * math.sin(TAU * 7.3 * t + 0.2)) return self.H0 * grow * f * (1.0 + 0.10 * self.burst) def sway(self, s, t, grow): sc = np.clip(s, 0.0, 1.0) return grow * sc ** 1.5 * (0.050 * np.sin(TAU * 0.55 * t + 2.4 * sc) + 0.032 * np.sin(TAU * 1.35 * t + 4.3 * sc + 1.0) + 0.016 * np.sin(TAU * 3.10 * t + 7.0 * sc + 2.0)) def half_width(self, s, t, grow): sc = np.clip(s, 0.0, 1.0) body = np.maximum(np.sin(math.pi * sc ** 0.62), 0.0) ** 0.9 wob = 1.0 + 0.14 * flow(sc * 3.0, 0.0, t * 2.3) return np.maximum(self.W0 * grow ** 0.8 * body * wob, 0.010) @staticmethod def profile(u, s): """Flame heat for a point at |u| half-widths from the centre line, s along its height.""" sc = np.clip(s, 0.0, 1.0) body = np.exp(-(u / 0.98) ** 4) # defined flame edge halo = np.exp(-(u / 1.7) ** 2) # soft red-orange fringe core = np.exp(-(u / 0.50) ** 2) * (1.0 - 0.75 * sc) return (0.74 * body + 0.20 * halo + 0.34 * core) * (1.0 - 0.25 * sc ** 2) def place(self, s, u, t, grow): """Map flame coordinates (s along height, u across width) to normalised X, Y.""" Y = self.y0 + s * self.height(t, grow) X = self.sway(s, t, grow) + u * self.half_width(s, t, grow) return X, Y def field(self, t, grow, energy): """Flame heat (0..1) for every character cell.""" sh = self.shape X, Y = sh.X, sh.Y hf = max(self.height(t, grow), 1e-3) s = (Y - self.y0) / hf sc = np.clip(s, 0.0, 1.0) w = self.half_width(sc, t, grow) u = np.abs(X - self.sway(sc, t, grow)) / w u = u * (1.0 + 0.22 * flow(X * 6.0, Y * 6.5 - t * 2.6, t * 1.3)) heat = self.profile(u, sc) env = smoothstep(-0.03, 0.10, s) * (1.0 - smoothstep(0.93, 1.12, s)) bands = 0.88 + 0.12 * flow(X * 4.5, Y * 9.0 - t * 4.2, t * 0.7) return np.clip(heat * env * bands * energy, 0.0, 1.0) # ============================================================================= # 6. PARTICLES # ============================================================================= @dataclass class Particle: """Snapshot of one particle. ParticleSystem stores the same fields as numpy columns (struct-of-arrays) so thousands of them can be updated per frame.""" x: float = 0.0 y: float = 0.0 vx: float = 0.0 vy: float = 0.0 intensity: float = 1.0 life: float = 1.0 max_life: float = 1.0 flicker: float = 4.0 # Hz accent: bool = False char: str = " " color: Tuple[int, int, int] = (0, 0, 0) class Controls: """Everything the animation stages drive (see AnimationController.controls).""" stage = 1 gain = 0.0 # fraction of particles that are visible radius = 0.05 # size of the initial spark cloud structure = 0.0 # how far particles have moved to their place in the diya flame_grow = 0.12 # size of the flame (0..1) field_gain = 0.0 # how much of the solid, dense diya texture is revealed glow_gain = 0.0 # ambient radial glow accent_gain = 0.0 # cyan / blue accents allowed core_heat = 0.55 # temperature of the middle of the spark cloud flick_depth = 0.95 # how strongly particles blink on/off alive = False class ParticleSystem: """ Three kinds of "former" particles (bowl, flame, ambient) start as a spark cloud and drift to their place in the diya, plus a pool of free-flying sparks that rise from the flame. """ C0 = (0.0, 0.0) # centre of the first sparks (centre of the terminal) def __init__(self, shape: DiyaShape, fire: FireSimulation, cfg: Config, rng: np.random.Generator): self.shape, self.fire, self.cfg, self.rng = shape, fire, cfg, rng cells = shape.W * shape.H d = max(cfg.density, 0.05) nA, nB, nC = int(0.14 * cells * d), int(0.10 * cells * d), int(0.30 * cells * d) nS = int(0.045 * cells * d) + 24 n = nA + nB + nC self.n, self.nS = n, nS self.sl_bowl = slice(0, nA) self.sl_flame = slice(nA, nA + nB) self.sl_amb = slice(nA + nB, n) # --- common properties of the formers scale = np.array([0.75, 0.55]) self.sc_dir = np.clip(rng.standard_normal((n, 2)), -2.4, 2.4) * scale self.appear = rng.random(n) self.delay = rng.random(n) self.intensity = 0.55 + 0.45 * rng.random(n) self.flick = rng.uniform(1.5, 9.0, n) self.phase = rng.uniform(0.0, TAU, n) self.accent = np.zeros(n, bool) # --- bowl particles: sampled on the lamp self.bowl_hx, self.bowl_hy, self.bowl_heat = shape.sample_bowl(nA, rng) self.accent[self.sl_bowl] = rng.random(nA) < 0.02 * cfg.accent # --- flame particles: coordinates inside the (moving) flame s = np.empty(0) while s.size < nB: c = rng.random(nB * 3 + 32) keep = rng.random(c.size) < np.sqrt(np.maximum(np.sin(math.pi * c ** 0.62), 0.0)) s = np.concatenate([s, c[keep]]) self.fs = s[:nB] self.fu = np.clip(rng.normal(0.0, 0.55, nB), -1.5, 1.5) self.accent[self.sl_flame] = (np.abs(self.fu) > 1.0) & (rng.random(nB) < 0.35 * cfg.accent) # --- ambient particles: a halo around the flame with different behaviours r = np.abs(rng.standard_normal(nC)) * 0.44 a = rng.uniform(0.0, TAU, nC) self.amb_hx = r * np.cos(a) * 1.35 self.amb_hy = 0.06 + r * np.sin(a) * 0.95 self.beh = rng.integers(0, 4, nC) # 0 still, 1 drifts sideways, 2 rises, 3 pulses self.beh_rate = rng.uniform(0.08, 0.35, nC) self.accent[self.sl_amb] = rng.random(nC) < 0.09 * cfg.accent self._hx = np.empty(n) self._hy = np.empty(n) self._home = np.empty(n) self._hx[self.sl_bowl] = self.bowl_hx self._hy[self.sl_bowl] = self.bowl_hy # --- free-flying sparks self.s_x = np.zeros(nS) self.s_y = np.zeros(nS) self.s_vx = np.zeros(nS) self.s_vy = np.zeros(nS) self.s_life = np.zeros(nS) self.s_max = np.ones(nS) self.s_int = np.ones(nS) self.s_acc = np.zeros(nS, bool) self.s_rate = rng.uniform(2.0, 8.0, nS) self.s_ph = rng.uniform(0.0, TAU, nS) self.spark_rate = nS / 1.8 # emissions per second at full flame # ---- sparks --------------------------------------------------------- def emit_sparks(self, count: int, t: float, grow: float, strength: float = 1.0): free = np.flatnonzero(self.s_life <= 0.0)[:count] m = free.size if m == 0: return rng = self.rng s = rng.uniform(0.05, 0.70, m) u = rng.normal(0.0, 0.55, m) x, y = self.fire.place(s, u, t, grow) life = rng.uniform(0.9, 2.8, m) * (0.8 + 0.4 * strength) self.s_x[free], self.s_y[free] = x, y self.s_vx[free] = rng.normal(0.0, 0.035 * strength, m) self.s_vy[free] = rng.uniform(0.10, 0.50, m) * strength self.s_life[free] = life self.s_max[free] = life self.s_int[free] = rng.uniform(0.75, 1.0, m) self.s_acc[free] = rng.random(m) < 0.22 * self.cfg.accent def _update_sparks(self, t, dt, ctl): self.s_life -= dt np.maximum(self.s_life, -1.0, out=self.s_life) self.s_x += (self.s_vx + 0.045 * np.sin(TAU * self.s_rate * t + self.s_ph)) * dt self.s_y += self.s_vy * dt self.s_vx *= max(0.0, 1.0 - 0.25 * dt) if ctl.stage >= 4 and ctl.flame_grow > 0.3: n = self.rng.poisson(self.spark_rate * ctl.flame_grow ** 2 * dt) if n: self.emit_sparks(int(n), t, ctl.flame_grow) def snapshot(self, i: int) -> Particle: """Return spark i as a Particle record (handy for debugging / extensions).""" life = float(self.s_life[i]) h = float(self.s_int[i] * max(life, 0.0) / self.s_max[i]) ch, col = shade(np.array([h]), np.zeros(1, np.int64), np.array([bool(self.s_acc[i])])) r, g, b = COLOR_TABLE[int(col[0])].tolist() return Particle(float(self.s_x[i]), float(self.s_y[i]), float(self.s_vx[i]), float(self.s_vy[i]), float(self.s_int[i]), life, float(self.s_max[i]), float(self.s_rate[i]), bool(self.s_acc[i]), GLYPHS[int(ch[0])], (r, g, b)) # ---- per-frame state ------------------------------------------------- def render(self, t, dt, ctl: Controls): """Advance the simulation and return per-cell (heat, glyph variant, accent) layers.""" sh = self.shape H, W = sh.H, sh.W heat_buf = np.zeros((H, W)) var_buf = np.zeros((H, W), np.int64) acc_buf = np.zeros((H, W), bool) if ctl.stage < 2: return heat_buf, var_buf, acc_buf self._update_sparks(t, dt, ctl) n = self.n k = smoothstep(0.0, 1.0, np.clip((ctl.structure - 0.6 * self.delay) / 0.4, 0.0, 1.0)) ph = self.phase # cloud ("scatter") positions and heat sx = self.C0[0] + self.sc_dir[:, 0] * ctl.radius + 0.006 * np.sin(TAU * 0.7 * t + ph) sy = self.C0[1] + self.sc_dir[:, 1] * ctl.radius + 0.006 * np.cos(TAU * 0.6 * t + ph) d = np.hypot(sx - self.C0[0], (sy - self.C0[1]) * 1.3) scat = (0.10 + ctl.core_heat * np.exp(-(d / (0.22 + 0.55 * ctl.radius)) ** 2)) * self.intensity # home positions / heat hx, hy, home = self._hx, self._hy, self._home b, f, a = self.sl_bowl, self.sl_flame, self.sl_amb wave_x = 0.010 * np.sin(TAU * 0.31 * t + 3.1 * self.bowl_hy + ph[b]) hx[b] = self.bowl_hx + wave_x hy[b] = self.bowl_hy + 0.006 * np.cos(TAU * 0.27 * t + ph[b]) home[b] = self.bowl_heat * (0.80 + 0.20 * np.sin(TAU * self.flick[b] * 0.4 * t + ph[b])) fx, fy = self.fire.place(self.fs, self.fu, t, ctl.flame_grow) hx[f], hy[f] = fx, fy home[f] = self.fire.profile(np.abs(self.fu), self.fs) * (0.80 + 0.20 * self.intensity[f]) pa, beh, br = ph[a], self.beh, self.beh_rate rise_ph = (t * br * 0.5 + pa / TAU) % 1.0 drift = np.where(beh == 1, 0.07 * np.sin(TAU * br * t + pa), 0.0) rise = np.where(beh == 2, rise_ph * 0.55, 0.0) ax = self.amb_hx + 0.014 * np.sin(TAU * 0.31 * t + 3.1 * self.amb_hy + pa) + drift ay = self.amb_hy + 0.011 * np.cos(TAU * 0.27 * t + 2.3 * self.amb_hx + pa) + rise hx[a], hy[a] = ax, ay illum = np.exp(-((ax / 0.95) ** 2 + ((ay - 0.12) / 0.90) ** 2)) mod = np.ones(a.stop - a.start) mod = np.where(beh == 2, (1.0 - rise_ph) ** 0.7 * smoothstep(0.0, 0.06, rise_ph), mod) mod = np.where(beh == 3, 0.25 + 0.75 * np.maximum(0.0, np.sin(TAU * br * t + pa)) ** 3, mod) home[a] = (0.14 + 0.52 * illum) * self.intensity[a] * mod * (0.4 + 0.6 * ctl.glow_gain) # blend cloud -> home, then apply appear-over-time and on/off flickering x = sx * (1.0 - k) + hx * k y = sy * (1.0 - k) + hy * k heat = scat * (1.0 - k) + home * k vis = np.clip((ctl.gain - self.appear) * 20.0, 0.0, 1.0) raw = 0.5 + 0.5 * np.sin(TAU * self.flick * t + ph) fl = (1.0 - ctl.flick_depth) + ctl.flick_depth * smoothstep(0.15, 0.40, raw) heat = heat * vis * fl accent = self.accent & (ctl.accent_gain > 0.5) var = ((t * self.flick * 0.35 + ph).astype(np.int64)) & 3 # sparks alive = self.s_life > 0.0 shp = np.clip(self.s_life / self.s_max, 0.0, 1.0) ** 0.85 sheat = self.s_int * shp * (0.82 + 0.18 * np.sin(TAU * self.s_rate * t + self.s_ph)) * alive svar = ((t * self.s_rate * 0.5 + self.s_ph).astype(np.int64)) & 3 X = np.concatenate([x, self.s_x]) Y = np.concatenate([y, self.s_y]) Hh = np.concatenate([heat, sheat]) V = np.concatenate([var, svar]) A = np.concatenate([accent, self.s_acc]) r, c = sh.to_cell(X, Y) ok = (Hh > LEVEL_EDGES[0]) & (r >= 0) & (r < H) & (c >= 0) & (c < W) r, c, Hh, V, A = r[ok], c[ok], Hh[ok], V[ok], A[ok] order = np.argsort(Hh) # hottest particle written last wins the cell r, c = r[order], c[order] heat_buf[r, c] = Hh[order] var_buf[r, c] = V[order] acc_buf[r, c] = A[order] return heat_buf, var_buf, acc_buf # ============================================================================= # 7. ANIMATION CONTROLLER # ============================================================================= class AnimationController: """Runs the five stages and composes flame, lamp, glow and particles into one frame.""" # end of each stage in seconds at speed 1 / intro 1: # 1 darkness, 2 first sparks, 3 expansion, 4 formation, then 5 living artwork STAGE_ENDS = (1.0, 4.0, 8.5, 15.0) def __init__(self, cfg: Config, W: int, H: int): self.cfg = cfg self.rng = np.random.default_rng(cfg.seed) self.bounds = [e * max(cfg.intro, 0.05) for e in self.STAGE_ENDS] self.burst = 0.0 self.resize(W, H) def resize(self, W: int, H: int): self.W, self.H = W, H self.shape = DiyaShape(W, H, self.rng) self.fire = FireSimulation(self.shape) self.particles = ParticleSystem(self.shape, self.fire, self.cfg, self.rng) # ---- stage logic ---------------------------------------------------- def controls(self, t: float) -> Controls: b0, b1, b2, b3 = self.bounds c = Controls() if t < b0: # stage 1: darkness return c if t < b1: # stage 2: first sparks e = smoothstep(0.0, 1.0, (t - b0) / (b1 - b0)) c.stage, c.gain = 2, 0.002 + 0.028 * e c.radius, c.core_heat = 0.05 + 0.10 * e, 0.50 + 0.12 * e elif t < b2: # stage 3: particle expansion p = (t - b1) / (b2 - b1) e = smoothstep(0.0, 1.0, p) c.stage, c.gain = 3, 0.03 + 0.72 * p ** 1.5 c.radius, c.structure = 0.15 + 0.85 * e, 0.34 * e c.core_heat = 0.62 + 0.30 * e c.glow_gain = 0.30 * smoothstep(0.4, 1.0, p) c.accent_gain, c.flick_depth = p, 0.95 - 0.10 * p elif t < b3: # stage 4: diya formation p = (t - b2) / (b3 - b2) e = smoothstep(0.0, 1.0, p) c.stage, c.gain = 4, 0.75 + 0.25 * e c.radius, c.structure = 1.0, 0.34 + 0.80 * e c.flame_grow = 0.12 + 0.88 * smoothstep(0.05, 0.95, p) c.field_gain = smoothstep(0.25, 1.0, p) c.glow_gain, c.accent_gain = 0.30 + 0.70 * e, 1.0 c.core_heat, c.flick_depth = 0.95, 0.85 - 0.50 * e else: # stage 5: living artwork c.stage, c.gain, c.radius, c.structure = 5, 1.0, 1.0, 1.14 c.flame_grow, c.field_gain, c.glow_gain, c.accent_gain = 1.0, 1.0, 1.0, 1.0 c.core_heat, c.flick_depth, c.alive = 0.95, 0.35, True return c def _tick_bursts(self, t, dt, ctl): self.burst *= math.exp(-dt / 0.55) if ctl.alive and self.rng.random() < 0.30 * dt: # occasional glowing burst self.burst = 1.0 self.particles.emit_sparks(max(8, int(0.10 * self.particles.nS)), t, ctl.flame_grow, strength=1.5) self.fire.burst = self.burst # ---- composition ------------------------------------------------------ def frame(self, t: float, dt: float) -> Tuple[np.ndarray, np.ndarray]: ctl = self.controls(t) sh = self.shape if ctl.stage < 2: z = np.zeros((sh.H, sh.W), np.uint8) return z, z self._tick_bursts(t, dt, ctl) energy = 1.0 + 0.16 * self.burst flick = (0.90 + 0.05 * math.sin(TAU * 3.3 * t + 1.0) + 0.03 * math.sin(TAU * 7.9 * t) + 0.02 * math.sin(TAU * 13.1 * t + 2.0)) # solid layers: flame + lamp, revealed from the centre outwards flame = self.fire.field(t, ctl.flame_grow, energy) shimmer = 0.84 + 0.16 * np.sin(t * sh.rate + sh.phase) light = float(np.clip(flick + 0.10 * self.burst, 0.8, 1.15)) bowl = sh.bowl_heat * light * shimmer rev = np.clip((ctl.field_gain * 1.35 - sh.rv) / 0.22, 0.0, 1.0) field = np.maximum(flame, bowl) * rev field = field * (0.92 + 0.08 * np.sin(t * sh.rate * 1.3 + sh.phase * 2.0)) # ambient glow: a dithered, twinkling halo g = sh.glow * ctl.glow_gain * self.cfg.glow * (0.82 + 0.18 * flick + 0.25 * self.burst) tw = 0.5 + 0.5 * np.sin(t * sh.rate2 + sh.phase) glow_h = np.where(sh.r2 < np.clip(g * 1.25, 0.0, 1.0), g * 0.40 * (0.40 + 0.75 * tw), 0.0) field = np.maximum(field, glow_h) # digital accents: a few cyan/blue cells twinkling in the glow acc_f = (sh.r3 > 1.0 - 0.020 * self.cfg.accent) & (g > 0.10) & (tw > 0.80) & (field < 0.30) field = np.where(acc_f, 0.30 + 0.5 * tw, field) var_f = ((t * sh.rate + sh.phase * 2.0).astype(np.int64)) & 3 # particle layer on top hp, vp, ap = self.particles.render(t, dt, ctl) use_p = hp > field heat = np.where(use_p, hp, field) var = np.where(use_p, vp, var_f) acc = np.where(use_p, ap, acc_f) ch, col = shade(heat, var, acc) # outline glyphs that follow the curve of the lamp edge = sh.edge_sel & ~use_p & (rev > 0.5) & (ch != 0) ch = np.where(edge, sh.edge_glyph, ch).astype(np.uint8) return ch, col # JUPYTER NOTEBOOK RENDERER from IPython.display import display, HTML, clear_output from html import escape def frame_to_html(ch, col): rows = [] for rr in range(ch.shape[0]): pieces, run, last_color = [], [], None for cc in range(ch.shape[1]): gid = int(ch[rr, cc]) if gid == 0: char, color = " ", None else: char = GLYPHS[gid] rgb = COLOR_TABLE[int(col[rr, cc])] color = "#{:02x}{:02x}{:02x}".format(*map(int, rgb)) if color != last_color and run: txt = escape("".join(run)).replace(" ", "&nbsp;") pieces.append(txt if last_color is None else '<span style="color:{}">{}</span>'.format(last_color, txt)) run = [] run.append(char) last_color = color if run: txt = escape("".join(run)).replace(" ", "&nbsp;") pieces.append(txt if last_color is None else '<span style="color:{}">{}</span>'.format(last_color, txt)) rows.append("".join(pieces)) return "<br>".join(rows) def run_notebook(duration=25, fps=12, width=84, height=30, speed=1.0, density=0.85, glow=1.0, accent=1.0, seed=None): cfg = Config(fps=fps, speed=speed, intro=1.0, density=density, glow=glow, accent=accent, cols=width, rows=height, color_mode="truecolor", seed=seed, duration=duration) controller = AnimationController(cfg, width, height) frame_dt = 1.0 / max(1, fps) start = last = time.perf_counter() next_frame = start try: while True: now = time.perf_counter() elapsed = now - start if duration > 0 and elapsed >= duration: break dt = min(now - last, 0.1) ch, col = controller.frame(elapsed * speed, dt * speed) artwork = frame_to_html(ch, col) clear_output(wait=True) html = ('<div style="background:radial-gradient(ellipse at 50% 48%, #241044 0%, #090817 48%, #020207 100%);padding:18px 12px;border:1px solid #49306f;border-radius:16px;text-align:center;box-shadow:0 0 28px rgba(130,70,255,.22),inset 0 0 28px rgba(0,220,220,.08);">' '<div style="background:linear-gradient(90deg,#ff5d8f,#ffd166,#5ce1e6,#b58cff);-webkit-background-clip:text;color:transparent;font:bold 23px Arial;margin-bottom:12px;">HAPPY DIWALI ✨</div>' '<div style="display:inline-block;text-align:left;color:white;line-height:1.05;' 'font:13px Consolas, Courier New, monospace;white-space:nowrap;">' + artwork + '</div>' '<div style="background:linear-gradient(90deg,#ff8fab,#ffd166,#6ee7d8);-webkit-background-clip:text;color:transparent;font:bold 14px Arial;margin-top:12px;">' 'May your life shine bright! | CLCODING</div></div>') display(HTML(html)) last = now next_frame += frame_dt delay = next_frame - time.perf_counter() if delay > 0: time.sleep(delay) else: next_frame = time.perf_counter() except KeyboardInterrupt: pass finally: clear_output(wait=True) display(HTML('<h3 style="color:#e6a700">Shubh Deepavali! — CLCODING</h3>')) # Run the animation. Adjust these settings as needed. run_notebook(duration=25, fps=12, width=84, height=30)

Popular Posts

Categories

100 Python Programs for Beginner (119) AI (355) Android (25) AngularJS (1) API (2) Assembly Language (2) aws (31) Azure (12) BI (10) book (1) Books (359) Bootcamp (20) C (78) C# (12) C++ (83) cloud (1) Course (96) Coursera (306) Cybersecurity (37) data (10) Data Analysis (48) Data Analytics (31) data management (16) Data Science (442) Data Strucures (21) Deep Learning (227) Django (17) Downloads (3) edx (21) Engineering (15) Euron (30) Events (9) Excel (24) Finance (13) flask (4) flutter (1) FPL (17) Gadgets (1) Generative AI (79) Git (14) Google (55) Hadoop (3) HTML Quiz (1) HTML&CSS (48) IBM (43) IoT (3) IS (25) Java (99) Leet Code (4) Machine Learning (416) Meta (24) MICHIGAN (5) microsoft (13) Nvidia (8) Pandas (16) PHP (20) Projects (36) Python (1402) Python Bug (1) Python Coding Challenge (1296) Python Library (29) Python Mathematics (22) Python Mistakes (51) Python Pattern Challenge (36) python quixz (1) Python Quiz (676) Python Tips (114) Questions (3) R (72) React (7) Scripting (3) security (5) Selenium Webdriver (4) Software (21) SQL (56) Udemy (24) UX Research (1) web application (11) Web development (9) web scraping (3)

Followers

Python Coding for Kids ( Free Demo for Everyone)