diff --git a/python/docs/source/showpieces/homotopy_basins/homotopy_basins.png b/python/docs/source/showpieces/homotopy_basins/homotopy_basins.png new file mode 100644 index 000000000..3e263cdf1 Binary files /dev/null and b/python/docs/source/showpieces/homotopy_basins/homotopy_basins.png differ diff --git a/python/docs/source/showpieces/homotopy_basins/homotopy_basins.py b/python/docs/source/showpieces/homotopy_basins/homotopy_basins.py new file mode 100644 index 000000000..b9405229c --- /dev/null +++ b/python/docs/source/showpieces/homotopy_basins/homotopy_basins.py @@ -0,0 +1,310 @@ +"""Homotopy Basins. + +A showpiece: **every pixel is a zero-dimensional solve**. Take the one-parameter family + + f(x; c) = x^d - d*x - c + +and rasterize a window of the complex c-plane. For each pixel, run the genuine total-degree +gamma-trick homotopy + + H(x, t) = gamma * t * (x^d - 1) + (1 - t) * (x^d - d*x - c) + +and track all d start roots (the roots of unity) from t=1 to t=0 with the +``DoublePrecisionTracker``. Nothing in the image is a synthetic texture -- every channel is +tracked data: + + * hue -- the phase of the *start-end correlation* ``s(c) = sum_k zeta^k x_k``: + each path contributes (its start root) * (its landing point), with + zeta = exp(2 pi i / d). The unweighted sum of landings would be + permutation-blind (here identically zero -- it is the x^(d-1) coefficient); + the start-root weights make s holomorphic in c inside each basin and make it + jump exactly where the homotopy's swept discriminant permutes which root + each path reaches; + * brightness -- the tracker's own total step count. The blazing arcs are the discriminant + of H swept through system space: the set of c for which the straight-line + homotopy passes through a singular system at some t. The adaptive stepper + piles up tiny steps exactly there; + * stripes -- level bands of log|s| (honest domain coloring; cosmetic, not singularity); + the rainbow whirlpools are the zeros of s, coiled at the roots of the arcs; + * white speckle -- pixels where a path actually failed near a singular system. + +The geometry is the gamma trick, photographed. The family's branch points form the ring +c = -(d-1) * zeta with zeta^(d-1) = 1; each grows one glowing arc, and every arc trails off to +infinity in the -gamma direction (rotating gamma literally re-aims the comet tails). + +Four frames are produced (each window contains the family's whole branch-point ring; each +degree is rendered twice from one computation, full-bleed and as an annotated map with labeled +Re(c)/Im(c) axes and every arc center c = -(d-1) zeta marked on its ring): + + * homotopy_basins_teaching.png / homotopy_basins_teaching_annotated.png + -- d = 5: four branch points, four arcs, the lesson legible. + * homotopy_basins.png / homotopy_basins_annotated.png + -- d = 9: the eight-arc comet cluster, the show-off. + +Every coefficient fed to the function tree is EXACT (fractions.Fraction; pixel coordinates are +snapped to rationals), per the library's coercion doctrine. There is no randomness anywhere -- +gamma is a fixed exact unit -- so the tracked data is fully deterministic. + +Regenerated through ``tools/refresh_doc_artifacts.py`` (see that tool). This is a raster +showpiece: PNG only (the image IS a raster of solves; there is no meaningful vector form), so it +is exempt from the tutorial figures' png+svg rule. It is NOT a doctest -- the docs page embeds +the pre-rendered image. It is also the heaviest doc artifact: the two frames together are about +17 million tracked paths (roughly 10-15 minutes on 12 cores). + +Run standalone: python homotopy_basins.py +""" + +import os +import time +from fractions import Fraction +from multiprocessing import Pool + +import numpy as np + +_OUT = os.path.dirname(os.path.abspath(__file__)) + +# pixel coordinates are snapped onto this exact grid (coefficients must be exact values) +_DENOM = 10**6 + +# supersampling: compute at SS x the output size, box-downsample for silky bands and arcs +_SS = 2 + + +# --- the engine: one total-degree homotopy per pixel -------------------------------------------- + +_worker = {} + +def _worker_init(degree, gamma_re, gamma_im): + """Per-process setup: import bertini once and remember the frame constants.""" + import bertini + import bertini.tracking # noqa: F401 (registers the tracking submodule) + bertini.recording(False) # millions of throwaway solves: do not archive them + _worker['bertini'] = bertini + _worker['degree'] = degree + _worker['gamma'] = (gamma_re, gamma_im) + _worker['starts'] = [np.exp(2j * np.pi * k / degree) for k in range(degree)] + _worker['zeta'] = np.exp(2j * np.pi * np.arange(degree) / degree) + + +def _cnode(re_exact, im_exact): + """An exact complex coefficient node re + i*im (both fractions.Fraction or int).""" + bertini = _worker['bertini'] + return bertini.coefficient(re_exact) + bertini.I * bertini.coefficient(im_exact) + + +def _track_pixel(c_re, c_im): + """All d total-degree paths for the target x^d - d*x - c at one exact pixel value c. + + Returns (start-end correlation s, total_steps, num_failures).""" + bertini = _worker['bertini'] + tracking = bertini.tracking + d = _worker['degree'] + + x = bertini.Variable('x') + t = bertini.Variable('t') + H = bertini.System() + H.add_function(_cnode(*_worker['gamma']) * t * (x**d - 1) + + (1 - t) * (x**d - d * x - _cnode(c_re, c_im))) + H.add_path_variable(t) + H.add_variable_group(bertini.VariableGroup([x])) + + tracker = bertini.DoublePrecisionTracker(H) + tracker.setup(tracking.Predictor.RK4, 1e-6, 1e5, + tracking.SteppingConfig(), tracking.NewtonConfig()) + + lands = np.zeros(d, dtype=complex) + steps = 0 + fails = 0 + end = np.zeros(1, dtype=complex) + for k, root in enumerate(_worker['starts']): + code = tracker.track_path(end, complex(1, 0), complex(0, 0), np.array([root])) + steps += tracker.num_total_steps_taken() + if str(code) != 'Success': + fails += 1 + lands[k] = end[0] + return (lands * _worker['zeta']).sum(), steps, fails + + +def _row(args): + """One raster row: track every pixel, reduce to (correlation s, steps, failures) arrays.""" + j, c_res, c_im = args + finger = np.zeros(len(c_res), dtype=complex) + steps = np.zeros(len(c_res), dtype=np.int32) + fails = np.zeros(len(c_res), dtype=np.int8) + for i, c_re in enumerate(c_res): + finger[i], steps[i], fails[i] = _track_pixel(c_re, c_im) + return j, finger, steps, fails + + +def compute(degree, gamma, center, halfwidth, w, h): + """Track the whole window (w x h pixels, all d paths each). Returns per-pixel arrays + (correlation s, steps, failures).""" + halfheight = halfwidth * h / w + xs = [Fraction(round((center[0] - halfwidth + 2 * halfwidth * i / (w - 1)) * _DENOM), _DENOM) + for i in range(w)] + ys = [Fraction(round((center[1] - halfheight + 2 * halfheight * j / (h - 1)) * _DENOM), _DENOM) + for j in range(h)] + finger = np.zeros((h, w), dtype=complex) + steps = np.zeros((h, w), dtype=np.int32) + fails = np.zeros((h, w), dtype=np.int8) + t0 = time.perf_counter() + with Pool(os.cpu_count(), initializer=_worker_init, + initargs=(degree, gamma[0], gamma[1])) as pool: + jobs = [(j, xs, ys[j]) for j in range(h)] + for n, (j, fg, st, fl) in enumerate(pool.imap_unordered(_row, jobs, chunksize=1)): + finger[j] = fg; steps[j] = st; fails[j] = fl + if n % max(1, h // 12) == 0: + print(f' row {n + 1}/{h} ({time.perf_counter() - t0:.0f}s)', flush=True) + print(f' tracked {w}x{h} pixels x {degree} paths in {time.perf_counter() - t0:.0f}s') + return finger, steps, fails + + +# --- rendering ----------------------------------------------------------------------------------- + +def _blur(channel, sigma): + """FFT gaussian blur of one 2-D channel.""" + fy = np.fft.fftfreq(channel.shape[0])[:, None] + fx = np.fft.fftfreq(channel.shape[1])[None, :] + kernel = np.exp(-2 * (np.pi * sigma) ** 2 * (fx ** 2 + fy ** 2)) + return np.real(np.fft.ifft2(np.fft.fft2(channel) * kernel)) + + +def _downsample(img, factor): + """Box-average downsample of an (h, w, 3) image by an integer factor.""" + h, w = img.shape[:2] + return img[:h - h % factor, :w - w % factor]\ + .reshape(h // factor, factor, w // factor, factor, -1).mean(axis=(1, 3)) + + +def _shade(finger, steps, fails): + """Map the tracked channels to an RGB image: hue from the phase of the start-end + correlation s, brightness from tracker effort, stripes from |s|, speckle from failures.""" + import matplotlib + matplotlib.use('Agg') + import matplotlib.colors as mcolors + + w = steps.shape[1] + hue = (np.angle(finger) / (2 * np.pi)) % 1.0 + + effort = np.log1p(steps.astype(float)) + effort = _blur(effort, sigma=max(0.7, w / 900)) # tame per-pixel step-count speckle + lo, hi = np.percentile(effort, 5), np.percentile(effort, 99.7) + stress = np.clip((effort - lo) / (hi - lo + 1e-9), 0, 1) + + mag = np.abs(finger) + band = 0.5 + 0.5 * np.cos(2 * np.pi * 2.2 * np.log(mag + 1e-12)) + hue = (hue + 0.07 * np.log(mag + 1e-12)) % 1.0 # iridescent drift across the bands + + val = np.clip(0.08 + 0.22 * band + 0.30 * stress + 0.55 * stress ** 3, 0, 1) + sat = np.clip(1.0 - 0.75 * stress ** 4, 0.35, 1.0) # the hottest arcs bleach to white + rgb = mcolors.hsv_to_rgb(np.stack([hue, sat, val], axis=-1)) + + # bloom: the stress field re-added as soft glow in the local hue (screen blend) + core = rgb * (stress ** 3)[..., None] + glow = np.stack([_blur(core[..., k], sigma=max(1.5, w / 300)) for k in range(3)], axis=-1) + img = 1 - (1 - rgb) * (1 - np.clip(1.3 * glow, 0, 1)) + img = np.clip(img + (fails > 0)[..., None] * 0.55, 0, 1) # failure speckle, white-hot + + return np.clip(_downsample(img, _SS), 0, 1) + + +def render(finger, steps, fails, out_png): + """The full-bleed frame: just the shaded image, no chrome.""" + import matplotlib.image as mimage + mimage.imsave(out_png, _shade(finger, steps, fails), origin='lower') + print(' wrote', out_png) + + +def render_annotated(finger, steps, fails, degree, extent, out_png): + """The same tracked data as a *map*: labeled Re(c)/Im(c) axes, and every arc center -- + the family's branch points c = -(d-1) zeta, zeta^(d-1) = 1 -- marked on the ring.""" + import matplotlib + matplotlib.use('Agg') + import matplotlib.pyplot as plt + + img = _shade(finger, steps, fails) + bg = '#05060a' + plt.rcParams.update({'figure.facecolor': bg, 'axes.facecolor': bg}) + fig, ax = plt.subplots(figsize=(12, 7.4)) + ax.imshow(img, origin='lower', extent=extent, interpolation='bilinear') + + radius = degree - 1 + branch = -radius * np.exp(2j * np.pi * np.arange(degree - 1) / (degree - 1)) + ax.scatter(branch.real, branch.imag, s=130, facecolors='none', + edgecolors='white', linewidths=1.6, zorder=5) + for b in branch: + if abs(b.imag) < 1e-9: # on the real axis: -8 or 8 + label = f'${round(b.real)}$' + elif abs(b.real) < 1e-9: # on the imaginary axis: -8i or 8i + label = f'${round(b.imag)}i$'.replace('1i', 'i') + else: # diagonal: exact polar form + label = f'${radius}e^{{{np.angle(b) / np.pi:+.2g}\\pi i}}$' + ax.annotate(label, (b.real, b.imag), textcoords='offset points', xytext=(9, 9), + color='white', fontsize=9) + theta = np.linspace(0, 2 * np.pi, 400) + ax.plot((degree - 1) * np.cos(theta), (degree - 1) * np.sin(theta), + color='white', lw=0.6, ls=':', alpha=0.45, + label=f'branch-point ring $|c| = {degree - 1}$') + + ax.set_xlabel(r'$\operatorname{Re}\, c$', color='#c9d3e0') + ax.set_ylabel(r'$\operatorname{Im}\, c$', color='#c9d3e0') + ax.tick_params(colors='#8896aa') + for spine in ax.spines.values(): + spine.set_color('#223') + ax.set_title(f'arc centers: the branch points $c = -{degree - 1}\\zeta$, ' + f'$\\zeta^{{{degree - 1}}} = 1$ (each arc trails toward $-\\gamma\\infty$)', + color='#e8eef7', fontsize=11) + leg = ax.legend(loc='lower right', framealpha=0.15, labelcolor='#c9d3e0', fontsize=9) + leg.get_frame().set_edgecolor('#223') + fig.savefig(out_png, dpi=110, facecolor=bg, bbox_inches='tight') + plt.close(fig) + print(' wrote', out_png) + + +# --- the two frames ------------------------------------------------------------------------------ + +def _extent(center, halfwidth, w, h): + """The imshow extent [x0, x1, y0, y1] matching compute()'s pixel grid.""" + halfheight = halfwidth * h / w + return [center[0] - halfwidth, center[0] + halfwidth, + center[1] - halfheight, center[1] + halfheight] + + +def teaching_frame(out, out_annotated): + """d = 5: four branch points (c = -4 zeta, zeta^4 = 1), four arcs -- the lesson legible. + The window contains the whole branch-point ring |c| = 4; the same tracked data is rendered + full-bleed and as an annotated map with the four arc centers marked.""" + print('teaching frame (d=5):') + center, halfwidth = (1.2, -0.2), 9.0 + w, h = 800 * _SS, 450 * _SS + finger, steps, fails = compute(degree=5, + gamma=(Fraction(-24, 25), Fraction(7, 25)), + center=center, halfwidth=halfwidth, w=w, h=h) + render(finger, steps, fails, out) + render_annotated(finger, steps, fails, 5, _extent(center, halfwidth, w, h), out_annotated) + + +def showpiece_frame(out, out_annotated): + """d = 9: the eight-arc comet cluster streaming in the -gamma direction -- the show-off. + The window contains the whole branch-point ring |c| = 8; the same tracked data is rendered + twice, full-bleed and as an annotated map with the eight arc centers marked.""" + print('showpiece frame (d=9):') + center, halfwidth = (2.5, -0.3), 16.4 + w, h = 1200 * _SS, 675 * _SS + finger, steps, fails = compute(degree=9, + gamma=(Fraction(-24, 25), Fraction(7, 25)), + center=center, halfwidth=halfwidth, w=w, h=h) + render(finger, steps, fails, out) + render_annotated(finger, steps, fails, 9, _extent(center, halfwidth, w, h), out_annotated) + + +def main(): + """Generate all four frames next to this script.""" + teaching_frame(os.path.join(_OUT, 'homotopy_basins_teaching.png'), + os.path.join(_OUT, 'homotopy_basins_teaching_annotated.png')) + showpiece_frame(os.path.join(_OUT, 'homotopy_basins.png'), + os.path.join(_OUT, 'homotopy_basins_annotated.png')) + + +if __name__ == '__main__': + main() diff --git a/python/docs/source/showpieces/homotopy_basins/homotopy_basins_annotated.png b/python/docs/source/showpieces/homotopy_basins/homotopy_basins_annotated.png new file mode 100644 index 000000000..25d0b9cb8 Binary files /dev/null and b/python/docs/source/showpieces/homotopy_basins/homotopy_basins_annotated.png differ diff --git a/python/docs/source/showpieces/homotopy_basins/homotopy_basins_teaching.png b/python/docs/source/showpieces/homotopy_basins/homotopy_basins_teaching.png new file mode 100644 index 000000000..f94a82fe8 Binary files /dev/null and b/python/docs/source/showpieces/homotopy_basins/homotopy_basins_teaching.png differ diff --git a/python/docs/source/showpieces/homotopy_basins/homotopy_basins_teaching_annotated.png b/python/docs/source/showpieces/homotopy_basins/homotopy_basins_teaching_annotated.png new file mode 100644 index 000000000..202e8eab4 Binary files /dev/null and b/python/docs/source/showpieces/homotopy_basins/homotopy_basins_teaching_annotated.png differ diff --git a/python/docs/source/showpieces/homotopy_basins/index.rst b/python/docs/source/showpieces/homotopy_basins/index.rst new file mode 100644 index 000000000..b70fb8116 --- /dev/null +++ b/python/docs/source/showpieces/homotopy_basins/index.rst @@ -0,0 +1,108 @@ +๐ŸŒ€ Homotopy Basins +****************** + +**Every pixel of this image is a zero-dimensional solve.** Rasterize a window of the complex +coefficient plane of :math:`f(x;c) = x^9 - 9x - c`; at each pixel, run the genuine total-degree +gamma-trick homotopy + +.. math:: + + H(x,t) \;=\; \gamma\, t\,(x^9 - 1) \;+\; (1-t)\,(x^9 - 9x - c) + +and track all nine start roots from :math:`t=1` to :math:`t=0`. Nothing below is a synthetic +texture โ€” every channel is tracked data. + +.. image:: homotopy_basins.png + :width: 100% + :align: center + :alt: neon comet cluster: eight glowing discriminant arcs with rainbow spiral vortices at their + curled roots, striped basins streaming to the right + +The **hue** is the phase of the *startโ€“end correlation* +:math:`s(c) = \sum_k \zeta^k x_k(c)` โ€” each path contributes (its start root) ร— (its landing +point). The naive unweighted sum of the landings would be permutation-blind (for this family it +is the :math:`x^8` coefficient: identically zero); the start-root weights break that symmetry, so +:math:`s` is holomorphic in :math:`c` inside each basin and *jumps* exactly where the homotopy's +swept discriminant permutes which root each path reaches โ€” swapping the destinations of paths +:math:`j` and :math:`k` kicks it by :math:`(\zeta^j-\zeta^k)(x_j-x_k)`. The **brightness** is the tracker's own step count โ€” the +blazing arcs are the set of :math:`c` for which the straight-line homotopy passes through a +singular system at some :math:`t`, and the adaptive stepper piles up tiny steps exactly there. +The **stripes** are level bands of :math:`\log|s|` โ€” cosmetic contours of tracked data, not +singularities โ€” and the rainbow whirlpools are the zeros of :math:`s`, where its phase winds. + +The gamma trick, photographed +============================= + +Why arcs, and why do they all stream the same way? The family's branch points โ€” the +:math:`c`-values where two roots of :math:`f` collide โ€” form the ring +:math:`c = -8\zeta,\ \zeta^8 = 1`. The homotopy drags every pixel's system along a straight line +through *system space*, and that line grazes the discriminant along one arc per branch point: + +.. math:: + + c(\mu) \;=\; -\,\frac{8\,\zeta}{(1+\mu)^{1/8}} \;-\; \mu, + \qquad \mu = \frac{\gamma\,t}{1-t} \in \gamma\cdot(0,\infty),\quad \zeta^8 = 1, + +each arc leaving its branch point and trailing to infinity in the :math:`-\gamma` direction. +Here is the same tracked data as a *map* โ€” coordinates on, all eight arc centers marked: + +.. image:: homotopy_basins_annotated.png + :width: 100% + :align: center + :alt: the degree-9 render with labeled Re(c)/Im(c) axes, the eight branch points marked on + the dotted ring |c| = 8 +That is the **gamma trick** made visible: the arcs are precisely the *bad* target systems for +this choice of :math:`\gamma`, a measure-zero set that a different :math:`\gamma` re-aims +elsewhere โ€” rotate :math:`\gamma` and the whole comet cluster swings around the ring. For a +generic pixel the paths pass *near* but never *through* the discriminant, and the closer the +brush, the harder the tracker works: the glow you see is `num_total_steps_taken` doing numerical +algebraic geometry the hard way. The rare white speckle marks pixels where a path genuinely +failed โ€” the gamma trick's measure-zero fine print, caught on camera. + +Start simple: five roots, four arcs +=================================== + +The same construction for :math:`f(x;c) = x^5 - 5x - c` keeps every feature legible: four branch +points at :math:`c = -4\zeta,\ \zeta^4=1`, one glowing arc each, five basins: + +.. image:: homotopy_basins_teaching.png + :width: 85% + :align: center + :alt: the degree-5 version: four glowing arcs with spiral vortices, five striped basins + +Crossing an arc means the straight-line homotopy passed the discriminant on one side rather than +the other, so the destinations of exactly two of the tracked paths trade places โ€” the startโ€“end correlation, +and with it the hue, jumps by the transposition's kick :math:`(\zeta^j-\zeta^k)(x_j-x_k)`. +With coordinates on, the four arc centers sit at :math:`c = \pm 4,\ \pm 4i`: + +.. image:: homotopy_basins_teaching_annotated.png + :width: 92% + :align: center + :alt: the degree-5 render with labeled Re(c)/Im(c) axes and the four branch points marked on + the dotted ring |c| = 4 Walking a small loop *around* an arc's +endpoint (the branch point itself) is precisely the monodromy loop of +:doc:`the Monodromy Loom ` โ€” the two showpieces are the same +mathematics seen from parameter space and from solution space. + +How it is built +=============== + +One exact homotopy per pixel (pixel coordinates are snapped to rationals โ€” every coefficient the +function tree sees is exact, and there is no randomness anywhere, so the tracked data is fully +deterministic): + +.. literalinclude:: homotopy_basins.py + :language: python + :start-at: def _track_pixel( + :end-at: return (lands + +The rest โ€” the startโ€“end correlation reduction, the stress normalization, the banding and bloom โ€” is +rendering; see ``homotopy_basins.py`` in full. + +.. note:: + + This is a raster showpiece: PNG only (the image *is* a raster of solves; there is no + meaningful vector form), regenerated through ``tools/refresh_doc_artifacts.py``. It is the + heaviest doc artifact โ€” about 17 million tracked paths across the two frames (roughly 10โ€“15 + minutes on 12 cores). It is not a doctest โ€” run the generator directly with + ``python homotopy_basins.py``. diff --git a/python/docs/source/showpieces/index.rst b/python/docs/source/showpieces/index.rst index d37af15c8..b99fcf0fc 100644 --- a/python/docs/source/showpieces/index.rst +++ b/python/docs/source/showpieces/index.rst @@ -17,3 +17,4 @@ figure, through ``tools/refresh_doc_artifacts.py`` so they stay current with the monodromy_loom/index flight_recorder/index + homotopy_basins/index diff --git a/tools/refresh_doc_artifacts.py b/tools/refresh_doc_artifacts.py index bd4419a39..4b5951a3e 100644 --- a/tools/refresh_doc_artifacts.py +++ b/tools/refresh_doc_artifacts.py @@ -120,6 +120,11 @@ def _plots(): SHOWPIECES / "flight_recorder" / "flight_recorder.py", ["flight_recorder.png", "flight_recorder_setup.png"], note="showpiece: raster PNG only; tight-tolerance mult-35 solve -> slow (~1 min)"), + Plot("homotopy_basins", + SHOWPIECES / "homotopy_basins" / "homotopy_basins.py", + ["homotopy_basins.png", "homotopy_basins_annotated.png", + "homotopy_basins_teaching.png", "homotopy_basins_teaching_annotated.png"], + note="showpiece: raster PNG only; ~17M tracked paths -> SLOW (~10-15 min on 12 cores)"), ]