diff --git a/docs/research/gravity-well-law-r3.md b/docs/research/gravity-well-law-r3.md new file mode 100644 index 000000000..84a7feed4 --- /dev/null +++ b/docs/research/gravity-well-law-r3.md @@ -0,0 +1,118 @@ +# The well law and the generator, co-designed; graded as nested basins + +Imagination pass `imagination-ag3-well-law` (r3), campaign aetheria-gravity, session self-2026-10-15-ag3, 2026-10-16, read at master b71dfc21. It revises `gravity-well-law.md` (r2) for question `aetheria-gravity:question:tail-taming-law-r3`, under rulings `tame-tails-parent-only-rails`, `wells-read-as-nested-basins` and the lean `tail-taming-lean-conserving-compact`. + +The operator, verbatim: +- "'reach is narrower than the generator's window' tells me that this exploration has not been taking full advantage of its permission to also treat zone generation (or at least the variables governing its behavior) as part of the solution space." +- "Hypotheses regarding the aesthetic quality of the proposed solution can be somewhat graded by running the minimap shader on the resulting heightfield: it presents both potential contour lines and shades steep slopes (which should be avoided by overencumbered freighters) with scary orange plan contours" +- (ruling `wells-read-as-nested-basins`) "What we'll want to see is clear cusps between bodies and defined wells ... Fields should flow into each other with enough curvature to create saddles to ride along, and parents should encompass their children." + +New scripts, all in `gravity-well-law/`: +- `replace.py`: the generator rules, applied as a re-placement of each generated zone. It keeps the same tree and masses and folds whatever does not fit into its parent. +- `probe3.py`: gained `place=`, `cusp=` and `spacing=` arguments and a stricter split counter (same reach and same fade start). +- `probe4.py`: the basin metrics and the minimap port. +- `well_law.py`: gained a stable series branch for τ > 1 (§3). + +Outputs are `probe3-r3-output.txt`, `probe4-output.txt` and `minimap/C*-*.png`. + +## 1. The two pulls the law and the generator must balance + +- **Rail honesty** wants the parent to own the child's orbit: no sibling's term may reach it. In the band law a body's term reaches Λ = λ·D. So siblings must stand at least a reach apart, D_(i+1) ≥ D_i/(1 − λ), and a child's whole family must end before its parent's fade starts (B_F ≤ ½Λ_F). Split invariance is exact under the same condition. +- **A visible cusp** wants the child to keep its own basin against the parent's slope. With the Plummer core the child's steepest pull is 0.5·c_c/ε_c, so a saddle exists on the child-parent line only if D ≥ 2·ε_c·c_p/c_c. + - For a planetoid moon of a median planet that is about 53 u. Today's generator puts moons from 2·PlanetSafetyRadius (about 18 u) upward, so 805 of 10,127 planetoid cusps vanish today (C0 below). + - The saddle must also lie inside the child's reach: r_saddle ≈ D·c_c/(c_c + c_p), 0.3-0.4 D for this mass range. So λ ≥ about 0.4. +- **The window between them.** For a child of parent P: [max(1.5·Roche_P, 3·ε_c·c_p/c_c), ½·λ·D_P/β], and the upper end is D_P/6 for λ = ½ and β = 1.5. + - It is about [45-80, 102] u for moons of a median planet at 610 u. + - For the root's children it is [120, 0.75·Z]. +- **What it costs.** The window is narrow, so the honest generator holds fewer bodies than today's. That count is the price, measured below. + +## 2. Candidates + +Each candidate varies one thing from the one before. The phases are the same in every candidate (a hash of zone and node), and so are the zones. "Generator" means `replace.py` applied to today's generated zones. + +| # | Law | Generator rule | Varies from previous | +|---|---|---|---| +| C0 | today (harmonic core, log tails), K 4.098 | today | control | +| C1 | band law, reach ½·D, K 2.049 | today | the law | +| C2 | band law | strict: Roche margin 1.5, window from the law's reach, siblings a full reach apart | the generator | +| C3 | band law | C2 plus the cusp bound D ≥ 3·ε_c·c_p/c_c | the cusp bound | +| C4 | band law | C3 with siblings half a reach apart, D_(i+1) ≥ D_i/(1 − λ/2) | the spacing | + +**Hypotheses, written before the first render run and recorded unchanged:** +- **H0** (C0): zone-wide funnel contours round the sun; small bodies are blobs at the minimap's contour spacing; orange only at cores; low nesting. +- **H1** (C1): the funnel contours go and each family gets its own contour set. Blob share falls because the depth range shrinks. Steep share rises (K halved doubles the drawn slope). Dead ground stays near 0, because a parent's body slope persists under its children. +- **H2** (C2): about half the bodies remain. Each stands alone with plain parent slope between (the "self-contained spikes" risk). Nesting rises. +- **H3** (C3): moon cusps never vanish, and blob share falls further. +- **H4** (C4): body count recovers toward today. Rails loosen to about ±10%, and blob and nesting stay near C3. + +## 3. Results + +Basin metrics are over 60 zones (every 25th, all Z) at the minimap texel (15.625 u). Cusps are over every pair in all 1500 zones. Rails come from `probe3-r3-output.txt` (6 draws). + +- **Nodes kept:** C2 13,447, C3 11,260, C4 20,768 (of 31,640). +- **Cusp vanished:** count / pairs for planets and planetoids. +- **Prominence:** cusp prominence over c_child, p50, planets and planetoids. +- **Blob:** bodies without their own closed contour at the minimap spacing DR/20. +- **Nested:** the child's basin spills into its parent's. +- **Saddle:** p50 of the smaller Hessian eigenvalue at spill saddles, 1/s². +- **Dead:** share of the disc with |F| < 0.05 u/s², a 10 u drift over a 20 s coast (about a hull length; 1% of a packed Djinni's 4 u/s², B25). +- **Steep:** share with the port's danger blend > 0.5 (freighter no-go). +- **Ratio:** rail honesty, p10-p90 across classes. +- **Split:** families whose field outside B is unchanged by merging them. + +| # | Nodes | Wells/zone p50 | Cusp vanished (planet; planetoid) | Prominence/c (planet; planetoid) | Blob | Nested | Saddle | Dead p50/p90 | Steep p50/p90 | Lanes open | Ratio p10-p90 | Split exact | Far field | +|---|---|---|---|---|---|---|---|---|---|---|---|---|---| +| C0 | 31,640 | 17 | 358/6879; 805/10127 | 0.94; 0.73 | 0.876 | 0.459 | 0.022 | 0.000/0.001 | 0.026/0.146 | 0.97 | 0.95-2.47 | n/a | 5.54x | +| C1 | 31,640 | 17 | 261/6879; 499/10127 | 0.91; 0.57 | 0.585 | 0.542 | 0.013 | 0.001/0.005 | 0.029/0.125 | 1.00 | 0.83-1.24 | 1765/6976 | 1.00x | +| C2 | 13,447 | 8 | 203/3724; 164/1633 | 0.27; 0.16 | 0.729 | 0.528 | 0.017 | 0.001/0.003 | 0.016/0.078 | 1.00 | 0.99-1.07 | 3198/3199 | 1.00x | +| C3 | 11,260 | 7 | 30/2766; 3/890 | 0.34; 0.27 | 0.488 | 0.588 | 0.019 | 0.001/0.003 | 0.017/0.084 | 1.00 | 0.99-1.03 | 2308/2309 | 1.00x | +| C4 | 20,768 | 12 | 47/6177; 4/4894 | 0.54; 0.36 | 0.561 | 0.542 | 0.020 | 0.001/0.003 | 0.023/0.109 | 1.00 | 0.90-1.10 | 4107/4108 | 1.00x | + +Every candidate keeps the r2 guarantees where the generator places it: far field exactly c(M_zone), zone depth 0.99x of one body of its mass (p90 1.29-1.34), planets round a median sun 99-100% in 12-18 u/s, and 0 children under half their parent's pull. That last one was 234 in C0 and C1: the Roche margin removes them. The one family per placed run that is not exactly split-invariant is a bodyless node's child; I did not chase it. + +**Renders** (`minimap/C-.png`). The zones: small (Z 1000, seed 1), binary (Z 2125, seed 10), rosette (Z 4182, seed 104), moons (Z 6846, seed 253, 25 moons today). Per render (steep, blob, nested) is in `probe4-output.txt`. + +| Hyp. | Held / failed | Why | +|---|---|---| +| H0 | held | C0-moons shows 17 concentric zone contours round the sun with planets as orange specks inside them. Blob 34/34 there and 0.876 overall; nested 0.459. | +| H1 | held, but steep did not rise | The funnel contours are gone and every planet has its own ring set, with a cusp against the sun (C1-moons). Blob falls to 0.585. Steep *falls* slightly (p90 0.146 to 0.125): the funnel's summed slope near the sun outweighs the halved K. | +| H2 | half held | 42% of nodes remain. Nesting does not rise (0.528). Blob *rises* to 0.729: the strict window squeezes moons close to their parents, where the parent's slope eats their cusp (planetoid prominence 0.16). No dead plateaus appear (dead 0.001), so the "self-contained spikes" failure does not show as flat ground. It shows as small cusps. | +| H3 | held | Planetoid cusps vanish for 3/890 (C0 805/10127). Blob 0.488 is the best of the five, nested 0.588 the best. The price: 36% of nodes kept. | +| H4 | held | 66% of nodes kept and wells 12 per zone (C0 17). Cusps survive (planetoid 4/4894). Blob 0.561, nested 0.542, saddle curvature 0.020, close to today's 0.022. Rails 0.90-1.10. | + +**What no candidate fixes.** Blob stays at 0.49 or above in every candidate, because the minimap spaces its 21 contours linearly over the deepest body's depth (`IsolinesUI.shader` line 178, `_DepthRange` = the deepest planet, ZoneRenderer.cs 622-626). With c ∝ m^0.2 a planetoid's own well is a few percent of a sun's depth, below one contour step under any law. Fixing it is a presentation choice (log-spaced or per-family contours) for grid-noise-presentation, not a law or generator one. The "rosette" render keeps 11/11 blobs in C2-C4 because the root rosette's members are deep and close; rosette geometry is `rosette-generation-r3`'s. + +**Precision finding.** The band closed form cancels catastrophically when the softening is comparable to the band (τ = (a²+ε²)/(b²−a²) > 1). The float32 error is 6.9e-3 at τ = 3 and 12 at τ = 10 (`check-output.txt` §5). Today's generator produces such terms: children inside their parent's Roche limit, and binaries shrunk inside their members' cores. `well_law.band_potential` now sums the band as a series in (1−t)/(1+τ) when τ > 1 (float32 error 1e-7). Forces were never affected. Potentials, and so the r2 depth column, were, slightly; the r2 rows rerun here (w1 and w3 on today's generator) match r2 to two digits. The r2 split claim "2382/2427" used a counter that did not check the fade start; the corrected count for w3 is 2329/2427 in-window, 3425/6976 overall. + +## 4. Recommendation and what the generator must do + +**Recommended: C4.** Band law with reach ½·D, plus the generator rules below. It is the only candidate that holds all three of: +- the nested-basins bar near today's saddle curvature, with cusps nearly always present and 40% fewer blobs; +- rails within ±10% (today 0.95-2.47); +- two thirds of today's bodies. + +It is exact where it matters: far field, merged families, and no child in a core. + +**Alternative: C3.** It wins on every basin number and has exact rails, at 36% of the bodies. If the operator values honest rails and clean basins over body count, C3 is the choice; the rules differ by one constant (spacing). + +**Generator rules (children-within-reach's scope).** Each rule reads the law's reach (WellTree.Reach / Support), never a respelling. +1. **Lower bound** D ≥ max(1.5·Roche_parent, 3·ε_child·c_parent/c_child), so there is no child in a core and every child keeps a cusp. Replaces PlanetSafetyRadius at ZoneGenerator.cs 407, 587, 632, 648. +2. **Upper bound:** the child's family ends before its parent's fade: 1.5·D_C ≤ ½·λ·D_P and D_C·(1+λ) ≤ ½·λ·D_P, i.e. D_C ≤ D_P/6 at λ = ½. A root's children keep 0.75·Z (line 406). Replaces ChildDistanceMaximum's neighbour rule (650-651) and the linear distance map (631-632). +3. **Spacing:** D_(i+1) ≥ D_i/(1 − λ/2). Replaces distMulMin/distMulMax (425-426, 440-441, 455-456, 497-498, 615) as the spacing owner. They may remain as random spread above the minimum. +4. **Capacity:** a requested count (452, 494, 422, 437) is a request. Children that do not fit are folded into the parent's body (family mass, so the field at range is unchanged). +5. **Pairs:** a binary whose members cannot sit outside each other's Roche limit inside its node's reach is made one body (486). Circumbinary children sit beyond (1 + 4)·D_ring·1.5. +6. **Rosettes** keep their own follow-up (twisted geometry). The law's ring group must then accept members on two radii. + +**Dead or derived generator variables:** +- `PlanetSafetyRadius` (Settings.cs 113, Settings.asset 135-138): dead. +- `ChildDistanceMinimum` and `ChildDistanceMaximum`: derived from the law's reach, Roche and cusp bounds. +- `distMul*` as spacing: dead. +- `p0ChildDist` and `p1ChildDist` (573-574): derived (members' windows, 0.2 × the nearest-partner chord). +- Unchanged: counts as requests, `massMul*`, `jupiterJump`, `massFraction`, `MassFloor` and the final mass renormalisation (mass split); the m^p exponent and the WellStrength and Roche curves. Conservation removes the reason to retune p. + +## Confidence + +- Basin metrics: medium. They are measured at the minimap texel (15.6 u), so moons under two texels are basins of one or two cells, and "nested" is limited by that resolution, not by the law. +- Cusps (analytic line samples, all zones) and rails: high. +- The port: the frag logic of IsolinesUI.shader and the material's values. The background is a stand-in colour and the window is the whole zone at the minimap's texel. It is a port, not the shader; Self renders the chosen candidate in Unity. +- Hypotheses H0-H4 were written before the first render run. diff --git a/docs/research/gravity-well-law/check-output.txt b/docs/research/gravity-well-law/check-output.txt index b291ede0d..ac0bee977 100644 --- a/docs/research/gravity-well-law/check-output.txt +++ b/docs/research/gravity-well-law/check-output.txt @@ -15,3 +15,6 @@ median sun: c = 304.9, sqrt(c) = 17.46 u/s, eps = Roche = 79.8 float32 eps 79.8 band [1000.0, 1500.0]: max abs err 7.28e-06 (unit strength; depth 2.77) float32 eps 17.0 band [40.0, 60.0]: max abs err 1.45e-05 (unit strength; depth 1.15) float32 eps 79.8 band [12000.0, 25000.0]: max abs err 9.06e-07 (unit strength; depth 5.49) +tau 3.00: closed form float32 err 6.9e-03; series float32 err 3.1e-07; F = dphi/dr 3.5e-08 +tau 10.00: closed form float32 err 1.2e+01; series float32 err 1.1e-07; F = dphi/dr 3.8e-08 +tau 9.92: closed form float32 err 1.2e+01; series float32 err 1.1e-07; F = dphi/dr 2.1e-08 diff --git a/docs/research/gravity-well-law/check_well_law.py b/docs/research/gravity-well-law/check_well_law.py index c0a23e6d0..067153aa7 100644 --- a/docs/research/gravity-well-law/check_well_law.py +++ b/docs/research/gravity-well-law/check_well_law.py @@ -67,3 +67,24 @@ for eps, a, b in [(79.8, 1000.0, 1500.0), (17.0, 40.0, 60.0), (79.8, 12000.0, 25 r64 = np.linspace(0, b, 100001) ref = band_potential(eps, a, b, r64) print(f'float32 eps {eps} band [{a}, {b}]: max abs err {np.max(np.abs(band32(eps, a, b, r64) - ref)):.2e} (unit strength; depth {np.abs(ref).max():.2f})') + +# 5. large tau (softening comparable to the band): the closed form cancels in float32; the series branch does not +def series32(eps, a, b, r): + f32 = np.float32 + eps, a, b, r = f32(eps), f32(a), f32(b), r.astype(f32) + s = r * r; span = b * b - a * a; tau = (a * a + eps * eps) / span; w = f32(1) / (f32(1) + tau) + def bandv(t): + Y = (f32(1) - t).astype(f32); acc = np.zeros_like(t) + for k in range(40): + inner = np.zeros_like(t) + for j in range(4, len(SC)): inner = (inner + f32(SC[j]) * Y ** (j + k + 1) / f32(j + k + 1)).astype(f32) + acc = (acc + w ** (k + 1) * inner).astype(f32) + return f32(-.5) * acc + t = np.clip((s - a * a) / span, 0, 1).astype(f32) + inside = f32(.5) * np.log((s + eps * eps) / (a * a + eps * eps)).astype(f32) + bandv(np.zeros(1, f32))[0] + return np.where(s <= a * a, inside, np.where(s >= b * b, f32(0), bandv(t))) +for eps, a, b in [(1658.0, 1000.0, 1500.0), (3391.0, 1000.0, 1500.0), (59.0, 11.0, 22.0)]: + r64 = np.linspace(0, b, 20001); ref = band_potential(eps, a, b, r64) + tau = (a * a + eps * eps) / (b * b - a * a) + print(f'tau {tau:6.2f}: closed form float32 err {np.max(np.abs(band32(eps, a, b, r64) - ref)):.1e}; series float32 err ' + f'{np.max(np.abs(series32(eps, a, b, r64) - ref)):.1e}; F = dphi/dr {np.max(np.abs(np.gradient(ref, r64) - band_pull(eps, a, b, r64))[3:-3]) / band_pull(eps, a, b, r64).max():.1e}') diff --git a/docs/research/gravity-well-law/minimap/C0-binary.png b/docs/research/gravity-well-law/minimap/C0-binary.png new file mode 100644 index 000000000..68f76956a --- /dev/null +++ b/docs/research/gravity-well-law/minimap/C0-binary.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1a2045bcbfe8058455648ceb244b0a8d728a1946f4880b1caf65601f754f0d7d +size 70734 diff --git a/docs/research/gravity-well-law/minimap/C0-moons.png b/docs/research/gravity-well-law/minimap/C0-moons.png new file mode 100644 index 000000000..71f4a8fff --- /dev/null +++ b/docs/research/gravity-well-law/minimap/C0-moons.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:58e5a3826dec35b6bb7eaa332189d4d887b65bd176c750d0f45bf2cd7f26a420 +size 226274 diff --git a/docs/research/gravity-well-law/minimap/C0-rosette.png b/docs/research/gravity-well-law/minimap/C0-rosette.png new file mode 100644 index 000000000..50a1c3e4f --- /dev/null +++ b/docs/research/gravity-well-law/minimap/C0-rosette.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:62447181a04a8ccb131c4daa541cc9be7163024c291d73904be36d1395a1eb38 +size 184767 diff --git a/docs/research/gravity-well-law/minimap/C0-small.png b/docs/research/gravity-well-law/minimap/C0-small.png new file mode 100644 index 000000000..9b78ef66c --- /dev/null +++ b/docs/research/gravity-well-law/minimap/C0-small.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5cc13b949660c8ce4be221928c0457681169cb32109a826de4e62c70fd3cbe78 +size 22540 diff --git a/docs/research/gravity-well-law/minimap/C1-binary.png b/docs/research/gravity-well-law/minimap/C1-binary.png new file mode 100644 index 000000000..266618e63 --- /dev/null +++ b/docs/research/gravity-well-law/minimap/C1-binary.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:65c938198c7eebaf818be0a79ea39cd2d44c60dfa7ad4d8bfef9ab35161c1451 +size 67057 diff --git a/docs/research/gravity-well-law/minimap/C1-moons.png b/docs/research/gravity-well-law/minimap/C1-moons.png new file mode 100644 index 000000000..31348955e --- /dev/null +++ b/docs/research/gravity-well-law/minimap/C1-moons.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:845682f7ec32cae664b3037396da6c8ea05ed437e6700474e9c9aea10fab3352 +size 175113 diff --git a/docs/research/gravity-well-law/minimap/C1-rosette.png b/docs/research/gravity-well-law/minimap/C1-rosette.png new file mode 100644 index 000000000..8411e8b17 --- /dev/null +++ b/docs/research/gravity-well-law/minimap/C1-rosette.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5b3ec90c3aa07893b73968cddf7d82bf5ffd7d0b896609f3c6f6b276e63fb5f0 +size 163592 diff --git a/docs/research/gravity-well-law/minimap/C1-small.png b/docs/research/gravity-well-law/minimap/C1-small.png new file mode 100644 index 000000000..829944e59 --- /dev/null +++ b/docs/research/gravity-well-law/minimap/C1-small.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0a3e24ce777124b8ea72c3d114c9c8ec316814338a6e4fa0997a379716b03181 +size 18258 diff --git a/docs/research/gravity-well-law/minimap/C2-binary.png b/docs/research/gravity-well-law/minimap/C2-binary.png new file mode 100644 index 000000000..dbcd30060 --- /dev/null +++ b/docs/research/gravity-well-law/minimap/C2-binary.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d2084873b711595092d4ad47f8321920343419902ed9f5d8317e729705306e05 +size 40543 diff --git a/docs/research/gravity-well-law/minimap/C2-moons.png b/docs/research/gravity-well-law/minimap/C2-moons.png new file mode 100644 index 000000000..1a027c3c2 --- /dev/null +++ b/docs/research/gravity-well-law/minimap/C2-moons.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:aa5d9e6dc3c88e45e96b98d45132315b372bf597b06702e45b314d66cb6e5192 +size 118382 diff --git a/docs/research/gravity-well-law/minimap/C2-rosette.png b/docs/research/gravity-well-law/minimap/C2-rosette.png new file mode 100644 index 000000000..c90145fe5 --- /dev/null +++ b/docs/research/gravity-well-law/minimap/C2-rosette.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:095669ab9f95772f0c8d4a64e879818465501ca5c1bb95f8b3919654afeb1676 +size 147553 diff --git a/docs/research/gravity-well-law/minimap/C2-small.png b/docs/research/gravity-well-law/minimap/C2-small.png new file mode 100644 index 000000000..1a285f370 --- /dev/null +++ b/docs/research/gravity-well-law/minimap/C2-small.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3b2b287d39e685521e1b73831718e5ca5f72774929ab5913ac57ed2e7cae72b4 +size 16589 diff --git a/docs/research/gravity-well-law/minimap/C3-binary.png b/docs/research/gravity-well-law/minimap/C3-binary.png new file mode 100644 index 000000000..819d5d82a --- /dev/null +++ b/docs/research/gravity-well-law/minimap/C3-binary.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:dd9f5cc980da4fffdd86947189869671fcc5468ced19d18fceb01bd9cff0b79f +size 39792 diff --git a/docs/research/gravity-well-law/minimap/C3-moons.png b/docs/research/gravity-well-law/minimap/C3-moons.png new file mode 100644 index 000000000..792eedfc7 --- /dev/null +++ b/docs/research/gravity-well-law/minimap/C3-moons.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:faf8afd95764c5819005ae5b0a7053f2b8c56176e499fcb7414921d75fec56a9 +size 118370 diff --git a/docs/research/gravity-well-law/minimap/C3-rosette.png b/docs/research/gravity-well-law/minimap/C3-rosette.png new file mode 100644 index 000000000..cbd57cbe5 --- /dev/null +++ b/docs/research/gravity-well-law/minimap/C3-rosette.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:82b72d5d6e4d3c8861e4c67495be34501e691c664edc4ef040001b840f58c53f +size 147653 diff --git a/docs/research/gravity-well-law/minimap/C3-small.png b/docs/research/gravity-well-law/minimap/C3-small.png new file mode 100644 index 000000000..5a42fc873 --- /dev/null +++ b/docs/research/gravity-well-law/minimap/C3-small.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5910d07eb71b91b7e6934fd6ffbac1822363d1078c639fd87cad818300d21c06 +size 16771 diff --git a/docs/research/gravity-well-law/minimap/C4-binary.png b/docs/research/gravity-well-law/minimap/C4-binary.png new file mode 100644 index 000000000..4dfff6fa9 --- /dev/null +++ b/docs/research/gravity-well-law/minimap/C4-binary.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0454c91f4b3b8ed312896d810aa6d2cf0c01475824ae49663d6f913bf9b0a6fb +size 46749 diff --git a/docs/research/gravity-well-law/minimap/C4-moons.png b/docs/research/gravity-well-law/minimap/C4-moons.png new file mode 100644 index 000000000..ccac7c7e0 --- /dev/null +++ b/docs/research/gravity-well-law/minimap/C4-moons.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:057b0a2112068a4faad21fe3740d56e1160ceed624598fe498c9729871190f4c +size 142566 diff --git a/docs/research/gravity-well-law/minimap/C4-rosette.png b/docs/research/gravity-well-law/minimap/C4-rosette.png new file mode 100644 index 000000000..cbd57cbe5 --- /dev/null +++ b/docs/research/gravity-well-law/minimap/C4-rosette.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:82b72d5d6e4d3c8861e4c67495be34501e691c664edc4ef040001b840f58c53f +size 147653 diff --git a/docs/research/gravity-well-law/minimap/C4-small.png b/docs/research/gravity-well-law/minimap/C4-small.png new file mode 100644 index 000000000..faa2b493c --- /dev/null +++ b/docs/research/gravity-well-law/minimap/C4-small.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a38a73ca22bd5b10b4831383c5b7dac44ae4e7f97ae805b65907fe452ee57372 +size 17346 diff --git a/docs/research/gravity-well-law/probe3-r3-output.txt b/docs/research/gravity-well-law/probe3-r3-output.txt new file mode 100644 index 000000000..43f27727d --- /dev/null +++ b/docs/research/gravity-well-law/probe3-r3-output.txt @@ -0,0 +1,94 @@ +# python probe3.py bodies.csv 6 [place=... cusp=... spacing=...], one process per run, Starfire 2026-10-16 (stable band potential) +== [7] (w1) conserving compact, Lam = 0.5*D (856s, 6 draws) + Sun ratio p10/50/90/99 [0.86, 1.01, 1.24, 1.55] parent-only v p10/50/90 [21.99, 23.6, 24.62] n 2211 + GasGiant ratio p10/50/90/99 [0.83, 1.0, 1.24, 1.63] parent-only v p10/50/90 [17.58, 22.08, 24.48] n 3566 + Planet ratio p10/50/90/99 [0.87, 0.99, 1.14, 1.45] parent-only v p10/50/90 [14.9, 16.9, 23.2] n 7445 + Planetoid ratio p10/50/90/99 [0.89, 0.99, 1.12, 1.42] parent-only v p10/50/90 [11.01, 13.75, 18.14] n 10200 + Belt ratio p10/50/90/99 [0.87, 0.99, 1.15, 1.45] parent-only v p10/50/90 [11.05, 14.24, 19.45] n 3134 + planets round a median sun: v p10/50/90 [16.07, 17.17, 17.78]; share in 12-18 0.99; n 1116 + Sun-class children parent-only v p10/50/90 [21.99, 23.6, 24.62] + children with no parent pull: {}; parent pull < 0.5 c/D: {'Planetoid': 214, 'Belt': 11, 'Planet': 9} (total 234) + mid-zone inward pull by Z: 1000: 0.55, 2125: 0.39, 4182: 0.24, 6846: 0.17, 10000: 0.13 + zone far-field c / c(M_zone) p10/50/90: at 2Z [1.0, 1.0, 1.0], at 10Z [1.0, 1.0, 1.0] + split invariance (family merged into one body), max |da|/|a| p50/p90/p99: at 1.05 B_f [0.52, 1.63, 5.34], at 2Z [0.0, 0.0, 0.0]; n 6976; families whose reach the merge leaves unchanged 1765/6976, of those inside the ruled half-tidal window 1165/2427 + whole-zone depth over one body of the zone mass, p10/50/90: [0.99, 1.0, 1.32] + wells (bodies) per zone p50/p99: [17.0, 58.0] + containment violations (term outside an ancestor support): 0 of 70890 + terms per zone p50/p99: [30.0, 114.0] + sim sample (uniform in the zone disc): terms in support p50/p99/mean [4.0, 14.0]/4.92; tree tests p50/p99 [10.0, 34.0] + shader texel (8192 u window): terms in support p50/p99 [3.0, 13.0], mean per texel p50/p99 over windows [2.88, 12.3]; terms meeting the window p50/p99 [21.0, 76.0] +== [9] (w3) conserving compact, Lam = 2*d_Hill (629s, 6 draws) + Sun ratio p10/50/90/99 [0.84, 1.09, 1.32, 1.62] parent-only v p10/50/90 [21.99, 23.6, 24.62] n 2211 + GasGiant ratio p10/50/90/99 [0.77, 1.0, 1.29, 1.67] parent-only v p10/50/90 [17.58, 22.08, 24.48] n 3566 + Planet ratio p10/50/90/99 [0.81, 0.97, 1.17, 1.5] parent-only v p10/50/90 [14.9, 16.9, 23.2] n 7445 + Planetoid ratio p10/50/90/99 [0.84, 0.97, 1.15, 1.45] parent-only v p10/50/90 [11.01, 13.75, 18.14] n 10200 + Belt ratio p10/50/90/99 [0.83, 0.98, 1.19, 1.48] parent-only v p10/50/90 [11.05, 14.24, 19.45] n 3134 + planets round a median sun: v p10/50/90 [16.07, 17.17, 17.78]; share in 12-18 0.99; n 1116 + Sun-class children parent-only v p10/50/90 [21.99, 23.6, 24.62] + children with no parent pull: {}; parent pull < 0.5 c/D: {'Planetoid': 214, 'Belt': 11, 'Planet': 9} (total 234) + mid-zone inward pull by Z: 1000: 0.60, 2125: 0.42, 4182: 0.27, 6846: 0.19, 10000: 0.14 + zone far-field c / c(M_zone) p10/50/90: at 2Z [1.0, 1.0, 1.0], at 10Z [1.0, 1.0, 1.0] + split invariance (family merged into one body), max |da|/|a| p50/p90/p99: at 1.05 B_f [0.0, 0.91, 3.16], at 2Z [0.0, 0.0, 0.0]; n 6976; families whose reach the merge leaves unchanged 3425/6976, of those inside the ruled half-tidal window 2329/2427 + whole-zone depth over one body of the zone mass, p10/50/90: [0.99, 1.01, 1.34] + wells (bodies) per zone p50/p99: [17.0, 58.0] + containment violations (term outside an ancestor support): 0 of 70890 + terms per zone p50/p99: [30.0, 114.0] + sim sample (uniform in the zone disc): terms in support p50/p99/mean [4.0, 15.0]/5.62; tree tests p50/p99 [10.0, 34.0] + shader texel (8192 u window): terms in support p50/p99 [3.0, 14.0], mean per texel p50/p99 over windows [3.69, 12.61]; terms meeting the window p50/p99 [21.0, 76.0] +## placement lam=0.5 cusp=0.0 spacing=1.0: nodes 31640 -> 13447; folded 18193 (14992 massive) into their parents; grandchildren inside the half-tidal window 4278/4278 +== [7] (w1) conserving compact, Lam = 0.5*D (300s, 6 draws) + Sun ratio p10/50/90/99 [1.0, 1.0, 1.0, 1.26] parent-only v p10/50/90 [20.21, 23.53, 24.6] n 2203 + GasGiant ratio p10/50/90/99 [1.0, 1.0, 1.07, 1.36] parent-only v p10/50/90 [16.24, 19.97, 23.41] n 2895 + Planet ratio p10/50/90/99 [1.0, 1.0, 1.01, 1.23] parent-only v p10/50/90 [13.52, 16.04, 18.48] n 3725 + Planetoid ratio p10/50/90/99 [0.99, 1.0, 1.01, 1.2] parent-only v p10/50/90 [10.48, 12.71, 15.34] n 1633 + Belt ratio p10/50/90/99 [0.99, 1.0, 1.01, 1.25] parent-only v p10/50/90 [9.97, 13.71, 16.83] n 408 + planets round a median sun: v p10/50/90 [14.42, 16.66, 17.66]; share in 12-18 1.00; n 102 + Sun-class children parent-only v p10/50/90 [20.21, 23.53, 24.6] + children with no parent pull: {}; parent pull < 0.5 c/D: {} (total 0) + mid-zone inward pull by Z: 1000: 0.51, 2125: 0.37, 4182: 0.22, 6846: 0.15, 10000: 0.11 + zone far-field c / c(M_zone) p10/50/90: at 2Z [1.0, 1.0, 1.0], at 10Z [1.0, 1.0, 1.0] + split invariance (family merged into one body), max |da|/|a| p50/p90/p99: at 1.05 B_f [0.0, 0.0, 0.0], at 2Z [0.0, 0.0, 0.0]; n 3199; families whose reach the merge leaves unchanged 3198/3199, of those inside the ruled half-tidal window 3198/3199 + whole-zone depth over one body of the zone mass, p10/50/90: [0.99, 0.99, 1.29] + wells (bodies) per zone p50/p99: [8.0, 24.0] + containment violations (term outside an ancestor support): 0 of 24097 + terms per zone p50/p99: [14.0, 45.0] + sim sample (uniform in the zone disc): terms in support p50/p99/mean [3.0, 12.0]/3.98; tree tests p50/p99 [4.0, 22.0] + shader texel (8192 u window): terms in support p50/p99 [3.0, 11.0], mean per texel p50/p99 over windows [2.38, 10.96]; terms meeting the window p50/p99 [10.0, 36.0] +## placement lam=0.5 cusp=1.5 spacing=1.0: nodes 31640 -> 11260; folded 20380 (17097 massive) into their parents; grandchildren inside the half-tidal window 3002/3002 +== [7] (w1) conserving compact, Lam = 0.5*D (400s, 6 draws) + Sun ratio p10/50/90/99 [1.0, 1.0, 1.0, 1.24] parent-only v p10/50/90 [21.7, 23.55, 24.6] n 2177 + GasGiant ratio p10/50/90/99 [1.0, 1.0, 1.03, 1.34] parent-only v p10/50/90 [17.34, 20.43, 23.51] n 2489 + Planet ratio p10/50/90/99 [1.0, 1.0, 1.0, 1.12] parent-only v p10/50/90 [14.74, 16.38, 18.64] n 2767 + Planetoid ratio p10/50/90/99 [0.99, 1.0, 1.01, 1.02] parent-only v p10/50/90 [11.23, 13.81, 15.8] n 890 + Belt ratio p10/50/90/99 [0.99, 1.0, 1.01, 1.16] parent-only v p10/50/90 [10.19, 13.22, 16.57] n 352 + planets round a median sun: v p10/50/90 [15.96, 16.79, 17.75]; share in 12-18 1.00; n 57 + Sun-class children parent-only v p10/50/90 [21.7, 23.55, 24.6] + children with no parent pull: {}; parent pull < 0.5 c/D: {} (total 0) + mid-zone inward pull by Z: 1000: 0.53, 2125: 0.36, 4182: 0.23, 6846: 0.15, 10000: 0.12 + zone far-field c / c(M_zone) p10/50/90: at 2Z [1.0, 1.0, 1.0], at 10Z [1.0, 1.0, 1.0] + split invariance (family merged into one body), max |da|/|a| p50/p90/p99: at 1.05 B_f [0.0, 0.0, 0.0], at 2Z [0.0, 0.0, 0.0]; n 2309; families whose reach the merge leaves unchanged 2308/2309, of those inside the ruled half-tidal window 2308/2309 + whole-zone depth over one body of the zone mass, p10/50/90: [0.99, 0.99, 1.32] + wells (bodies) per zone p50/p99: [7.0, 18.0] + containment violations (term outside an ancestor support): 0 of 17929 + terms per zone p50/p99: [11.0, 36.0] + sim sample (uniform in the zone disc): terms in support p50/p99/mean [3.0, 12.0]/3.97; tree tests p50/p99 [4.0, 18.0] + shader texel (8192 u window): terms in support p50/p99 [3.0, 11.0], mean per texel p50/p99 over windows [2.38, 10.73]; terms meeting the window p50/p99 [9.0, 28.0] +## placement lam=0.5 cusp=1.5 spacing=0.5: nodes 31640 -> 20768; folded 10872 (9026 massive) into their parents; grandchildren inside the half-tidal window 7699/7703 +== [7] (w1) conserving compact, Lam = 0.5*D (559s, 6 draws) + Sun ratio p10/50/90/99 [0.92, 1.0, 1.07, 1.24] parent-only v p10/50/90 [21.9, 23.57, 24.61] n 2201 + GasGiant ratio p10/50/90/99 [0.9, 1.0, 1.1, 1.38] parent-only v p10/50/90 [17.75, 21.98, 24.35] n 3400 + Planet ratio p10/50/90/99 [0.92, 1.0, 1.02, 1.14] parent-only v p10/50/90 [15.03, 17.03, 22.76] n 6180 + Planetoid ratio p10/50/90/99 [0.93, 1.0, 1.01, 1.08] parent-only v p10/50/90 [11.7, 14.29, 20.02] n 4894 + Belt ratio p10/50/90/99 [0.96, 1.0, 1.01, 1.08] parent-only v p10/50/90 [10.24, 13.81, 18.52] n 1476 + planets round a median sun: v p10/50/90 [16.01, 16.91, 17.69]; share in 12-18 0.99; n 219 + Sun-class children parent-only v p10/50/90 [21.9, 23.57, 24.61] + children with no parent pull: {}; parent pull < 0.5 c/D: {} (total 0) + mid-zone inward pull by Z: 1000: 0.52, 2125: 0.37, 4182: 0.23, 6846: 0.16, 10000: 0.12 + zone far-field c / c(M_zone) p10/50/90: at 2Z [1.0, 1.0, 1.0], at 10Z [1.0, 1.0, 1.0] + split invariance (family merged into one body), max |da|/|a| p50/p90/p99: at 1.05 B_f [0.0, 0.0, 0.0], at 2Z [0.0, 0.0, 0.0]; n 4108; families whose reach the merge leaves unchanged 4107/4108, of those inside the ruled half-tidal window 4103/4104 + whole-zone depth over one body of the zone mass, p10/50/90: [0.99, 0.99, 1.34] + wells (bodies) per zone p50/p99: [12.0, 28.0] + containment violations (term outside an ancestor support): 0 of 34357 + terms per zone p50/p99: [19.0, 46.0] + sim sample (uniform in the zone disc): terms in support p50/p99/mean [3.0, 12.0]/4.07; tree tests p50/p99 [7.0, 25.0] + shader texel (8192 u window): terms in support p50/p99 [3.0, 11.0], mean per texel p50/p99 over windows [2.42, 10.88]; terms meeting the window p50/p99 [13.0, 35.0] diff --git a/docs/research/gravity-well-law/probe3.py b/docs/research/gravity-well-law/probe3.py index e2c9af58e..2ae810a94 100644 --- a/docs/research/gravity-well-law/probe3.py +++ b/docs/research/gravity-well-law/probe3.py @@ -3,7 +3,8 @@ # paths), plus: split invariance (one family merged into one body of its mass), whole-zone merge depth, and # evaluation cost (terms per sample for the sim, per texel for a 2048^2 / 4 u Surface Displacement window). # Rows t1-t3 are the first draft (windows tapered from the centre), kept because they show what the band form buys. -# Usage: python probe3.py bodies.csv [draws] [law-index,...] +# Usage: python probe3.py bodies.csv [draws] [law-index,...] [place=LAM] [cusp=K] [spacing=S] +# place=LAM re-places every zone by replace.py's generator rules (same tree and masses) before measuring. import csv, sys, os, collections, time import numpy as np HERE = os.path.dirname(os.path.abspath(__file__)) @@ -176,12 +177,30 @@ LAWS = [ ('(w1) conserving compact, Lam = 0.5*D', 'band', HALF), ('(w2) conserving compact, Lam = 1*D', 'band', lambda i, D, CF, cp: 1.0 * D), ('(w3) conserving compact, Lam = 2*d_Hill', 'band', TIDAL), + ('(w4) conserving compact, Lam = D/3', 'band', lambda i, D, CF, cp: D / 3), ] pick = [int(x) for x in sys.argv[3].split(',')] if len(sys.argv) > 3 else range(len(LAWS)) sunm = np.sort([float(r['mass']) for r in rows if r['type'] == 'Sun' and r['empty'] == '0']) SUN_LO, SUN_HI = sunm[int(.4 * len(sunm))], sunm[int(.6 * len(sunm))] pre = {k: zone_struct(z) for k, z in zones.items()} +PLACE = next((float(a.split('=')[1]) for a in sys.argv[4:] if a.startswith('place=')), None) +CUSPK = next((float(a.split('=')[1]) for a in sys.argv[4:] if a.startswith('cusp=')), 0.0) +SPACING = next((float(a.split('=')[1]) for a in sys.argv[4:] if a.startswith('spacing=')), 1.0) +if PLACE is not None: + import replace as RP + agg = collections.Counter(); inwin = [0, 0]; before = sum(len(v[0]) for v in pre.values()) + for k, z in zones.items(): + par, D, m, body, cls = pre[k] + par2, D2, m2, b2, c2, st = RP.replace_zone(par, D, m, body, cls, float(z[0]['zoneRadius']), PLACE, CUSPK, SPACING) + pre[k] = (par2, D2, m2, b2, c2); st.pop('idx'); agg.update(st) + cb = np.where(b2, strength(np.maximum(m2, 1e-12)), 0.0) + for j in range(len(par2)): + p = par2[j] + if p >= 0 and b2[p] and par2[p] >= 0 and b2[par2[p]]: + inwin[1] += 1; inwin[0] += int(D2[j] <= 0.5 * D2[p] * np.sqrt(cb[p] / (2 * cb[par2[p]]))) + print(f'## placement lam={PLACE} cusp={CUSPK} spacing={SPACING}: nodes {before} -> {agg["kept"]}; folded {agg["folded"]} ({agg["folded_massive"]} massive) into their parents; ' + f'grandchildren inside the half-tidal window {inwin[0]}/{inwin[1]}') TH8 = np.arange(8) * np.pi / 4; RING8 = np.stack([np.cos(TH8), np.sin(TH8)], 1) class ZoneLaw: @@ -278,7 +297,9 @@ for li in pick: a1 = zn.field(pos, X); a2 = z2.field(pos2, X) split_err[tag].append(np.max(np.linalg.norm(a1 - a2, axis=1) / np.maximum(np.linalg.norm(a2, axis=1), 1e-12))) if kind in ('band', 'tapered'): - same = int(zn.Lam[f] == z2.Lam[remap[f]]) + # same terms: same reach and same fade start (max(B_f, GAMMA Lam_f) against the merged body's GAMMA Lam_f) + fs = [t[3] for t in zn.T if t[0] == f and t[1] > 0 and t[4] == zn.Lam[f]] + same = int(zn.Lam[f] == z2.Lam[remap[f]] and (kind != 'band' or (fs and fs[0] == WL.GAMMA * zn.Lam[f]))) split_exact[1] += 1; split_exact[0] += same # families the ruled generator window would hold: every descendant within half its parent's tidal # radius (B25: d_H = D sqrt(c / 2 c_parent); a parent with no body parent has no window and is skipped) diff --git a/docs/research/gravity-well-law/probe4-output.txt b/docs/research/gravity-well-law/probe4-output.txt new file mode 100644 index 000000000..0b9043062 --- /dev/null +++ b/docs/research/gravity-well-law/probe4-output.txt @@ -0,0 +1,61 @@ +# python probe4.py bodies.csv minimap 25, one process per candidate, Starfire 2026-10-16 +render zones: {'small': (('0', '1'), {'Z': 1000.0, 'moons': 3, 'rosette': False, 'binary': False, 'n': 15}), 'binary': (('0.25', '10'), {'Z': 2125.0, 'moons': 8, 'rosette': False, 'binary': True, 'n': 24}), 'rosette': (('0.5', '104'), {'Z': 4181.9805, 'moons': 27, 'rosette': True, 'binary': False, 'n': 41}), 'moons': (('0.75', '253'), {'Z': 6845.6714, 'moons': 25, 'rosette': False, 'binary': True, 'n': 40})} +== C0 today law, today generator + cusp Sun : vanished 17/2050; prominence/c p10/50 [0.715, 1.55] + cusp GasGiant : vanished 163/3305; prominence/c p10/50 [0.254, 1.209] + cusp Planet : vanished 358/6879; prominence/c p10/50 [0.274, 0.943] + cusp Planetoid: vanished 805/10127; prominence/c p10/50 [0.13, 0.733] + dead ground (|F| < 0.05) share of disc p50/p90 [0.0, 0.001]; steep share p50/p90 [0.026, 0.146]; lanes open in 0.97 of zones + blob share 0.876; nested share 0.459; saddle min |eigenvalue| p50 [0.022] 1/s^2 (n 854) + render small ('0', '1'): steep 0.141, dead 0.000, lanes True, blob 10/11, nested 5/11, bodies 12, depth range 441.7 + render binary ('0.25', '10'): steep 0.071, dead 0.000, lanes True, blob 18/18, nested 6/14, bodies 19, depth range 786.2 + render rosette ('0.5', '104'): steep 0.216, dead 0.000, lanes True, blob 35/35, nested 5/27, bodies 35, depth range 2080.2 + render moons ('0.75', '253'): steep 0.036, dead 0.000, lanes True, blob 34/34, nested 15/29, bodies 35, depth range 2381.6 +render zones: {'small': (('0', '1'), {'Z': 1000.0, 'moons': 3, 'rosette': False, 'binary': False, 'n': 15}), 'binary': (('0.25', '10'), {'Z': 2125.0, 'moons': 8, 'rosette': False, 'binary': True, 'n': 24}), 'rosette': (('0.5', '104'), {'Z': 4181.9805, 'moons': 27, 'rosette': True, 'binary': False, 'n': 41}), 'moons': (('0.75', '253'), {'Z': 6845.6714, 'moons': 25, 'rosette': False, 'binary': True, 'n': 40})} +== C1 band law (reach D/2), today generator + cusp Sun : vanished 5/2050; prominence/c p10/50 [0.853, 1.969] + cusp GasGiant : vanished 99/3305; prominence/c p10/50 [0.245, 1.344] + cusp Planet : vanished 261/6879; prominence/c p10/50 [0.214, 0.913] + cusp Planetoid: vanished 499/10127; prominence/c p10/50 [0.139, 0.571] + dead ground (|F| < 0.05) share of disc p50/p90 [0.001, 0.005]; steep share p50/p90 [0.029, 0.125]; lanes open in 1.00 of zones + blob share 0.585; nested share 0.542; saddle min |eigenvalue| p50 [0.013] 1/s^2 (n 895) + render small ('0', '1'): steep 0.122, dead 0.000, lanes True, blob 7/11, nested 8/11, bodies 12, depth range 356.9 + render binary ('0.25', '10'): steep 0.091, dead 0.001, lanes True, blob 14/18, nested 6/14, bodies 19, depth range 591.6 + render rosette ('0.5', '104'): steep 0.152, dead 0.000, lanes True, blob 35/35, nested 10/27, bodies 35, depth range 1935.9 + render moons ('0.75', '253'): steep 0.019, dead 0.002, lanes True, blob 27/34, nested 19/29, bodies 35, depth range 1170.6 +render zones: {'small': (('0', '1'), {'Z': 1000.0, 'moons': 3, 'rosette': False, 'binary': False, 'n': 15}), 'binary': (('0.25', '10'), {'Z': 2125.0, 'moons': 8, 'rosette': False, 'binary': True, 'n': 24}), 'rosette': (('0.5', '104'), {'Z': 4181.9805, 'moons': 27, 'rosette': True, 'binary': False, 'n': 41}), 'moons': (('0.75', '253'), {'Z': 6845.6714, 'moons': 25, 'rosette': False, 'binary': True, 'n': 40})} +== C2 band law + strict generator + cusp Sun : vanished 25/2168; prominence/c p10/50 [0.32, 1.28] + cusp GasGiant : vanished 170/2887; prominence/c p10/50 [0.056, 0.406] + cusp Planet : vanished 203/3724; prominence/c p10/50 [0.034, 0.269] + cusp Planetoid: vanished 164/1633; prominence/c p10/50 [0.027, 0.16] + dead ground (|F| < 0.05) share of disc p50/p90 [0.001, 0.003]; steep share p50/p90 [0.016, 0.078]; lanes open in 1.00 of zones + blob share 0.729; nested share 0.528; saddle min |eigenvalue| p50 [0.017] 1/s^2 (n 386) + render small ('0', '1'): steep 0.083, dead 0.000, lanes True, blob 6/6, nested 3/6, bodies 7, depth range 357.6 + render binary ('0.25', '10'): steep 0.044, dead 0.000, lanes True, blob 4/6, nested 2/6, bodies 7, depth range 581.5 + render rosette ('0.5', '104'): steep 0.101, dead 0.000, lanes True, blob 12/12, nested 2/4, bodies 12, depth range 1715.4 + render moons ('0.75', '253'): steep 0.008, dead 0.001, lanes True, blob 6/9, nested 5/9, bodies 10, depth range 1004.0 +render zones: {'small': (('0', '1'), {'Z': 1000.0, 'moons': 3, 'rosette': False, 'binary': False, 'n': 15}), 'binary': (('0.25', '10'), {'Z': 2125.0, 'moons': 8, 'rosette': False, 'binary': True, 'n': 24}), 'rosette': (('0.5', '104'), {'Z': 4181.9805, 'moons': 27, 'rosette': True, 'binary': False, 'n': 41}), 'moons': (('0.75', '253'), {'Z': 6845.6714, 'moons': 25, 'rosette': False, 'binary': True, 'n': 40})} +== C3 band law + strict generator + cusp bound + cusp Sun : vanished 16/2141; prominence/c p10/50 [0.299, 1.422] + cusp GasGiant : vanished 79/2481; prominence/c p10/50 [0.158, 0.578] + cusp Planet : vanished 30/2766; prominence/c p10/50 [0.134, 0.341] + cusp Planetoid: vanished 3/890; prominence/c p10/50 [0.109, 0.272] + dead ground (|F| < 0.05) share of disc p50/p90 [0.001, 0.003]; steep share p50/p90 [0.017, 0.084]; lanes open in 1.00 of zones + blob share 0.488; nested share 0.588; saddle min |eigenvalue| p50 [0.019] 1/s^2 (n 375) + render small ('0', '1'): steep 0.087, dead 0.000, lanes True, blob 2/4, nested 3/4, bodies 5, depth range 357.6 + render binary ('0.25', '10'): steep 0.042, dead 0.000, lanes True, blob 4/5, nested 1/5, bodies 6, depth range 582.3 + render rosette ('0.5', '104'): steep 0.101, dead 0.000, lanes True, blob 11/11, nested 1/3, bodies 11, depth range 1723.0 + render moons ('0.75', '253'): steep 0.008, dead 0.001, lanes True, blob 5/8, nested 5/8, bodies 9, depth range 1004.0 +render zones: {'small': (('0', '1'), {'Z': 1000.0, 'moons': 3, 'rosette': False, 'binary': False, 'n': 15}), 'binary': (('0.25', '10'), {'Z': 2125.0, 'moons': 8, 'rosette': False, 'binary': True, 'n': 24}), 'rosette': (('0.5', '104'), {'Z': 4181.9805, 'moons': 27, 'rosette': True, 'binary': False, 'n': 41}), 'moons': (('0.75', '253'), {'Z': 6845.6714, 'moons': 25, 'rosette': False, 'binary': True, 'n': 40})} +== C4 band law + loose spacing + cusp bound + cusp Sun : vanished 12/2155; prominence/c p10/50 [0.623, 1.751] + cusp GasGiant : vanished 75/3387; prominence/c p10/50 [0.229, 1.003] + cusp Planet : vanished 47/6177; prominence/c p10/50 [0.18, 0.54] + cusp Planetoid: vanished 4/4894; prominence/c p10/50 [0.125, 0.356] + dead ground (|F| < 0.05) share of disc p50/p90 [0.001, 0.003]; steep share p50/p90 [0.023, 0.109]; lanes open in 1.00 of zones + blob share 0.561; nested share 0.542; saddle min |eigenvalue| p50 [0.02] 1/s^2 (n 708) + render small ('0', '1'): steep 0.109, dead 0.000, lanes True, blob 4/8, nested 6/8, bodies 9, depth range 357.0 + render binary ('0.25', '10'): steep 0.061, dead 0.000, lanes True, blob 6/11, nested 5/11, bodies 12, depth range 580.5 + render rosette ('0.5', '104'): steep 0.101, dead 0.000, lanes True, blob 11/11, nested 1/3, bodies 11, depth range 1723.0 + render moons ('0.75', '253'): steep 0.011, dead 0.002, lanes True, blob 11/17, nested 9/17, bodies 18, depth range 1000.4 diff --git a/docs/research/gravity-well-law/probe4.py b/docs/research/gravity-well-law/probe4.py new file mode 100644 index 000000000..f678e7971 --- /dev/null +++ b/docs/research/gravity-well-law/probe4.py @@ -0,0 +1,307 @@ +# Basin readability probe and minimap renders (aetheria-gravity, imagination-ag3-well-law, r3 pass, 2026-10-15). +# For ruling wells-read-as-nested-basins. Per candidate (law + generator rule), over the same zones and phases: +# cusp every body-parent/child pair: the potential along the segment child -> parent has an interior saddle +# (a local maximum after the child's own minimum); prominence = (saddle - child minimum) / c_child. +# grid on a sample of zones, at the minimap's texel (15.625 u: ARPG.unity camera "Map Gravity" orthographic +# size 1000 at scene line 7812 over the 128^2 MinimapGravity.renderTexture, lines 15-16): +# dead ground share of the zone disc where |F| < 0.05 u/s^2 (a 10 u drift over a 20 s coast, about a +# hull length; 1% of a packed Djinni's 4 u/s^2, map B25); +# steep share where the port's danger blend > 0.5 (the orange "freighter no-go"); +# lanes open the non-steep part of the disc is one region (islands under 0.5% of the disc ignored); +# basins steepest descent on the drawn height (8 neighbours): a body's basin is the one holding its +# cell; blob = its basin is shared with another body or its prominence (spill height - basin +# minimum) is under the minimap contour spacing DR/20; nested = it spills into its parent's +# basin; saddle curvature = Hessian eigenvalues of phi at the spill point. +# renders a numpy port of Assets/Shaders/IsolinesUI.shader (constants cited where used) on four zones. +# Usage: python probe4.py bodies.csv outdir [candidate-index,...] [sample stride, default 25 = 60 zones] +import os, sys, math, zlib, struct, collections, hashlib +import numpy as np +HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, HERE) +DATA, OUT = sys.argv[1], sys.argv[2] +ONLY = [int(x) for x in sys.argv[3].split(',')] if len(sys.argv) > 3 else None +STRIDE = int(sys.argv[4]) if len(sys.argv) > 4 else 25 +os.makedirs(OUT, exist_ok=True) +sys.argv = ['probe3.py', DATA, '1', '0'] +src = open(os.path.join(HERE, 'probe3.py'), encoding='utf-8').read() +g = {'__file__': os.path.join(HERE, 'probe3.py'), '__name__': 'probe3_defs'} +exec(src[:src.index('for li in pick:')], g) # definitions, data and zones only; the probe loop does not run +import replace as RP, well_law as WL +zones, pre, ZoneLaw, strength, core_radius, family = g['zones'], g['pre'], g['ZoneLaw'], g['strength'], g['core_radius'], g['family'] +HALF = g['HALF'] + +TEXEL = 2000.0 / 128 # minimap texel in u (see header) +K_TODAY, K_BAND = 4.0983607, 2.0491803 # Settings.asset 125 today; halved for the Plummer peak (gravity-well-law.md) +FLAT = 0.05 + +CANDIDATES = [ + # name, law kind, reach, K, placement (lam, cusp, spacing) or None + ('C0 today law, today generator', 'today', None, K_TODAY, None), + ('C1 band law (reach D/2), today generator', 'band', HALF, K_BAND, None), + ('C2 band law + strict generator', 'band', HALF, K_BAND, (0.5, 0.0, 1.0)), + ('C3 band law + strict generator + cusp bound', 'band', HALF, K_BAND, (0.5, 1.5, 1.0)), + ('C4 band law + loose spacing + cusp bound', 'band', HALF, K_BAND, (0.5, 1.5, 0.5)), +] + +def phases(key, ids): + """One phase per original node, the same in every candidate (hash of zone key and node index).""" + return np.array([int(hashlib.md5(f'{key}-{i}'.encode()).hexdigest()[:8], 16) / 2 ** 32 * 2 * np.pi for i in ids]) + +def place_fixed(par, D, depth, th): + pos = np.zeros((len(par), 2)) + for k in np.argsort(depth, kind='stable'): + if par[k] >= 0: pos[k] = pos[par[k]] + D[k] * np.array([np.cos(th[k]), np.sin(th[k])]) + return pos + +def zone_for(cand, key, Z): + par, D, m, body, cls = pre[key] + ids = np.arange(len(par)) + if cand[4] is not None: + lam, cusp, spacing = cand[4] + par, D, m, body, cls, st = RP.replace_zone(par, D, m, body, cls, Z, lam, cusp, spacing) + ids = st['idx'] + zn = ZoneLaw(cand[1], cand[2], par, D, m, body, Z) + fam, depth = family(par) + pos = place_fixed(par, D, depth, phases(key, ids)) + return zn, par, D, m, body, cls, pos + +def reference(zn, Z): + if zn.kind in ('band', 'tapered'): + own, c, e, a, b = g['_arr'](zn.T) + return float((c * WL.band_potential(e, a, b, np.full_like(c, Z))).sum()) + return float(np.where(zn.c > 0, zn.law['P'](zn.c, zn.e, np.full_like(zn.c, Z), zn.s), 0).sum()) + +def heights(zn, pos, X, Z, K): + out = np.empty(len(X)) + for i in range(0, len(X), 20000): + out[i:i + 20000] = zn.potential(pos, X[i:i + 20000]) + return (out - reference(zn, Z)) / K + +def forces(zn, pos, X): + out = np.empty((len(X), 2)) + for i in range(0, len(X), 20000): + out[i:i + 20000] = zn.field(pos, X[i:i + 20000]) + return out + +def q(v, ps=(10, 50, 90)): + v = np.sort(np.asarray(v, float)) + return [round(float(v[min(len(v) - 1, int(p / 100 * len(v)))]), 3) for p in ps] if len(v) else [] + +def smoothstep(a, b, x): + t = np.clip((x - a) / (b - a), 0, 1) + return t * t * (3 - 2 * t) + +# ---- the minimap port: Assets/Shaders/IsolinesUI.shader frag (lines 151-207) with Minimap Gravity Lines.mat values ---- +MAT = dict(Color=np.array([0, .4, .6, 1]), AngleColor=np.array([1.4980392, 0, 1.2705883, .5019608]), + DangerColor=np.array([2.670157, .22367807, 0, 1]), LineWidth=.15, LineFade=5., AngleWidth=.05, AngleFade=1.15, + DangerSteepness=1.5, Scale=10.) # Assets/Materials/UI/Minimap Gravity Lines.mat, m_Floats and m_Colors +def minimap(h, depth_range): + """h: drawn height grid (GetHeight, <= 0 in wells), rows = +y. Returns rgba per the shader's col.""" + height = h # shader 161: height = -tex, tex = -GetHeight + me = height + e = np.roll(height, -1, axis=1); n = np.roll(height, -1, axis=0) + plan = np.stack([e - me, n - me], -1) # calcGrad, shader 144-149 (forward, one texel) + slope = np.linalg.norm(plan, axis=-1) + 1e-12 # shader 171 + start, dr = 0.0, depth_range # ZoneRenderer.cs 625-626: _StartDepth 0, _DepthRange = max planet depth + blend = smoothstep(start, start + dr / 32, -height) # shader 165 + danger = smoothstep(0, MAT['DangerSteepness'], (slope / MAT['Scale']) ** 2) # shader 173 + linecol = (MAT['Color'][None, None] * (1 - danger[..., None]) + MAT['DangerColor'][None, None] * danger[..., None]) * blend[..., None] + col = np.zeros(h.shape + (4,)) + spacing = dr / 20 # shader 178 + for ih in range(1, 22): # shader 179-182 + iso = np.abs(height + start + ih * spacing) + col += (1 - smoothstep(MAT['LineWidth'], MAT['LineWidth'] * MAT['LineFade'], iso / slope))[..., None] * linecol + angle = np.arctan2(plan[..., 1], plan[..., 0]) / np.pi + 1 # shader 184 + angcol = (blend * slope / MAT['Scale'])[..., None] * (MAT['AngleColor'][None, None] * (1 - danger[..., None]) + MAT['DangerColor'][None, None] * danger[..., None]) + for ia in np.arange(0.5, 13, 1.0): # shader 195-201 + iso = np.abs(angle - ia / 6.0) + l = 1 - smoothstep(MAT['AngleWidth'], MAT['AngleWidth'] * MAT['AngleFade'], iso) + l = l * smoothstep(-start - dr, -start, height) + col += l[..., None] * angcol + return col, danger + +def write_png(path, rgb): + hgt, wid, _ = rgb.shape + raw = b''.join(b'\x00' + rgb[y].tobytes() for y in range(hgt - 1, -1, -1)) # rows = +y, PNG top row = max y + def chunk(t, d): return struct.pack('>I', len(d)) + t + d + struct.pack('>I', zlib.crc32(t + d) & 0xffffffff) + with open(path, 'wb') as f: + f.write(b'\x89PNG\r\n\x1a\n' + chunk(b'IHDR', struct.pack('>IIBBBBB', wid, hgt, 8, 2, 0, 0, 0)) + + chunk(b'IDAT', zlib.compress(raw, 9)) + chunk(b'IEND', b'')) + +def composite(col): + bg = np.array([.02, .03, .05]) # stand-in for the Minimap Background material + a = np.clip(col[..., 3:4], 0, 1) + rgb = np.clip(col[..., :3], 0, 1) * a + bg * (1 - a) + return (rgb * 255).astype(np.uint8) + +# ---- grid analysis ---- +def grid_for(Z): + nx = int(math.ceil(2.1 * Z / TEXEL)) + xs = (np.arange(nx) - (nx - 1) / 2) * TEXEL + X = np.stack(np.meshgrid(xs, xs), -1).reshape(-1, 2) # rows = +y + return xs, X, nx + +def labels_by_descent(h): + ny, nx = h.shape + idx = np.arange(ny * nx).reshape(ny, nx) + best = idx.copy(); bh = h.copy() + pad = np.pad(h, 1, constant_values=np.inf); pidx = np.pad(idx, 1, constant_values=-1) + for dy in (-1, 0, 1): + for dx in (-1, 0, 1): + if dy == 0 and dx == 0: continue + nh = pad[1 + dy:1 + dy + ny, 1 + dx:1 + dx + nx]; ni = pidx[1 + dy:1 + dy + ny, 1 + dx:1 + dx + nx] + better = nh < bh + bh = np.where(better, nh, bh); best = np.where(better, ni, best) + lab = best.ravel() + for _ in range(64): + nxt = lab[lab] + if np.array_equal(nxt, lab): break + lab = nxt + return lab.reshape(ny, nx) + +def spills(h, lab): + """For every basin: (spill height, spill-into basin, spill cell pair) over 4-neighbour boundaries.""" + best = {} + for ax in (0, 1): + a = lab; b = np.roll(lab, -1, axis=ax); ha = h; hb = np.roll(h, -1, axis=ax) + sl = (slice(None, -1), slice(None)) if ax == 0 else (slice(None), slice(None, -1)) + a, b, ha, hb = a[sl], b[sl], ha[sl], hb[sl] + mask = a != b + pas = np.maximum(ha, hb)[mask]; la = a[mask]; lb = b[mask] + ys, xs = np.nonzero(mask) + order = np.argsort(pas) + for o in order: + for (u, v) in ((la[o], lb[o]), (lb[o], la[o])): + if u not in best: + y, x = ys[o], xs[o] + best[u] = (pas[o], v, (y, x, ax)) + return best + +def components(mask): + """4-connected components by min-label propagation with pointer jumping (labels are cell indices).""" + ny, nx = mask.shape + big = ny * nx + lab = np.where(mask, np.arange(big).reshape(ny, nx), big).ravel() + flat_mask = mask.ravel() + while True: + old = lab.copy() + L = lab.reshape(ny, nx) + for ax in (0, 1): + for sh in (1, -1): + r = np.roll(L, sh, axis=ax) + L = np.where(mask, np.minimum(L, r), big) + lab = L.ravel() + ext = np.append(lab, big) + for _ in range(20): + lab = np.where(flat_mask, np.minimum(lab, ext[lab]), big); ext = np.append(lab, big) + if np.array_equal(lab, old): break + return lab.reshape(ny, nx) + +def analyse(cand, key, z): + Z = float(z[0]['zoneRadius']) + zn, par, D, m, body, cls, pos = zone_for(cand, key, Z) + xs, X, nx = grid_for(Z) + h = heights(zn, pos, X, Z, cand[3]).reshape(nx, nx) + F = forces(zn, pos, X) + disc = (np.linalg.norm(X, axis=1) <= Z).reshape(nx, nx) + bodies = np.where(body)[0] + cell = lambda p: (int(round(p[1] / TEXEL + (nx - 1) / 2)), int(round(p[0] / TEXEL + (nx - 1) / 2))) + dr = max(1.0, max(-heights(zn, pos, pos[bodies], Z, cand[3]).min(), 0.0)) + col, danger = minimap(h, dr) + steep = danger > 0.5 + dead = (np.linalg.norm(F, axis=1) < FLAT).reshape(nx, nx) + free = disc & ~steep + comp = components(free) + sizes = collections.Counter(comp[free].tolist()) + big = [s for s in sizes.values() if s >= 0.005 * disc.sum()] + lab = labels_by_descent(h) + sp = spills(h, lab) + out = dict(dead=dead[disc].mean(), steep=steep[disc].mean(), lanes=len(big) <= 1, blob=0, nb=0, nested=0, nn=0, curv=[], dr=dr) + bl = {j: lab[cell(pos[j])] if 0 <= cell(pos[j])[0] < nx and 0 <= cell(pos[j])[1] < nx else None for j in bodies} + share = collections.Counter(v for v in bl.values() if v is not None) + for j in bodies: + if par[j] < 0 or bl[j] is None: continue + out['nb'] += 1 + b = bl[j] + prom = (sp[b][0] - h.ravel()[b]) if b in sp else 0.0 + if share[b] > 1 or prom < dr / 20: out['blob'] += 1 + p = par[j] + if body[p] and bl.get(p) is not None and b in sp: + out['nn'] += 1; out['nested'] += int(sp[b][1] == bl[p]) + if b in sp and share[b] == 1: + y, x, ax = sp[b][2] + pt = np.array([xs[x] + (TEXEL / 2 if ax == 1 else 0), xs[y] + (TEXEL / 2 if ax == 0 else 0)]) + e = 1.0 + Fx1, Fx0 = zn.field(pos, (pt + [e, 0])[None])[0], zn.field(pos, (pt - [e, 0])[None])[0] + Fy1, Fy0 = zn.field(pos, (pt + [0, e])[None])[0], zn.field(pos, (pt - [0, e])[None])[0] + H = -np.array([(Fx1 - Fx0) / (2 * e), (Fy1 - Fy0) / (2 * e)]) # Hessian of phi = -dF/dx + ev = np.linalg.eigvalsh((H + H.T) / 2) + out['curv'].append(float(np.min(np.abs(ev)))) + return out, h, col, zn, pos, body, par, cls + +def cusp_pairs(cand, key, z): + Z = float(z[0]['zoneRadius']) + zn, par, D, m, body, cls, pos = zone_for(cand, key, Z) + res = [] + t = np.concatenate([np.geomspace(1e-4, 0.05, 150), np.linspace(0.05, 0.98, 250)[1:]]) + for k in range(len(par)): + p = par[k] + if p < 0 or not body[p] or not body[k]: continue + X = pos[k][None] + t[:, None] * (pos[p] - pos[k])[None] + X = np.concatenate([pos[k][None], X]) + phi = zn.potential(pos, X) + ck = strength(m[k]) + i0 = int(np.argmin(phi[:60])) # the child's own minimum, near the child + rest = phi[i0:] + interior = [i for i in range(1, len(rest) - 1) if rest[i] >= rest[i - 1] and rest[i] > rest[i + 1]] + if interior: + s_ = interior[0] + res.append((cls[k], True, (rest[s_] - rest[:s_ + 1].min()) / ck)) + else: + res.append((cls[k], False, 0.0)) + return res + +if __name__ == '__main__': + keys = sorted(zones.keys()) + # the four render zones, picked from today's generator (same zones in every candidate) + def stats_of(key): + par, D, m, body, cls = pre[key]; fam, depth = family(par) + bodyless = [i for i in range(len(par)) if not body[i] and cls[i] != 'Belt'] + return dict(Z=float(zones[key][0]['zoneRadius']), moons=int(((depth >= 2) & body).sum()), + rosette=any(par[i] < 0 for i in bodyless), binary=any(par[i] >= 0 for i in bodyless), n=len(par)) + S = {k: stats_of(k) for k in keys} + pick = { + 'small': next(k for k in keys if S[k]['Z'] == 1000 and S[k]['n'] >= 10 and not S[k]['rosette']), + 'binary': next(k for k in keys if S[k]['binary'] and S[k]['Z'] == 2125), + 'rosette': next(k for k in keys if S[k]['rosette'] and abs(S[k]['Z'] - 4182) < 1), + 'moons': max((k for k in keys if abs(S[k]['Z'] - 6846) < 1 and not S[k]['rosette']), key=lambda k: S[k]['moons']), + } + print('render zones:', {n: (k, S[k]) for n, k in pick.items()}) + sample = [k for i, k in enumerate(keys) if i % STRIDE == 0] # every STRIDE-th zone (60 at 25), every Z in proportion + for ci, cand in enumerate(CANDIDATES): + if ONLY is not None and ci not in ONLY: continue + cusp = collections.defaultdict(list); vanished = collections.Counter(); total = collections.Counter() + for key in keys: + for c, ok, pr in cusp_pairs(cand, key, zones[key]): + total[c] += 1 + if ok: cusp[c].append(pr) + else: vanished[c] += 1 + agg = collections.defaultdict(list) + for key in sample: + o = analyse(cand, key, zones[key])[0] + for kk in ('dead', 'steep', 'lanes'): agg[kk].append(o[kk]) + agg['blob'].append((o['blob'], o['nb'])); agg['nested'].append((o['nested'], o['nn'])); agg['curv'] += o['curv'] + blob = sum(a for a, b in agg['blob']) / max(1, sum(b for a, b in agg['blob'])) + nest = sum(a for a, b in agg['nested']) / max(1, sum(b for a, b in agg['nested'])) + print(f'== {cand[0]}') + for c in ('Sun', 'GasGiant', 'Planet', 'Planetoid'): + print(f' cusp {c:9s}: vanished {vanished[c]}/{total[c]}; prominence/c p10/50 {q(cusp[c], (10, 50))}') + print(f' dead ground (|F| < {FLAT}) share of disc p50/p90 {q(agg["dead"], (50, 90))}; steep share p50/p90 {q(agg["steep"], (50, 90))}; ' + f'lanes open in {np.mean(agg["lanes"]):.2f} of zones') + print(f' blob share {blob:.3f}; nested share {nest:.3f}; saddle min |eigenvalue| p50 {q(agg["curv"], (50,))} 1/s^2 (n {len(agg["curv"])})') + for name, key in pick.items(): + o, h, col, zn, pos, body, par, cls = analyse(cand, key, zones[key]) + write_png(os.path.join(OUT, f'C{ci}-{name}.png'), composite(col)) + print(f' render {name} {key}: steep {o["steep"]:.3f}, dead {o["dead"]:.3f}, lanes {o["lanes"]}, blob {o["blob"]}/{o["nb"]}, ' + f'nested {o["nested"]}/{o["nn"]}, bodies {int(body.sum())}, depth range {o["dr"]:.1f}') + sys.stdout.flush() diff --git a/docs/research/gravity-well-law/replace.py b/docs/research/gravity-well-law/replace.py new file mode 100644 index 000000000..f89fc6c0e --- /dev/null +++ b/docs/research/gravity-well-law/replace.py @@ -0,0 +1,106 @@ +# Generator rules co-designed with the band law (aetheria-gravity, imagination-ag3-well-law, r3 pass, 2026-10-15). +# Re-places the bodies of one generated zone (same tree, same masses) so every family sits where the law is exact +# and rails are honest. The rules, each read from the law (well_law.py constants), none respelled: +# reach Lam_C = lam * D_C for a child of a body (the law's nominal reach); ring members RING_REACH * D. +# lower bound a child sits at least ROCHE_MARGIN x its parent's Roche limit out (the 234 in-core children go). +# upper bound a child's whole family ends before its parent's fade begins: BETA D_C <= GAMMA lam D_F and +# D_C (1 + lam) <= GAMMA lam D_F (B_F <= GAMMA Lam_F, the band law's split-invariance condition); +# a root's children: 0.75 Z (today's). +# spacing siblings at least a reach apart: D_(i+1) >= D_i / (1 - lam), so no sibling's term ever reaches +# another sibling's rail (phase-free: |D_i - D_j| >= max(Lam_i, Lam_j)). spacing=s < 1 loosens it to +# D_i / (1 - s lam), letting reaches overlap neighbouring rails. +# capacity children are laid inside-out at max(lower bound, previous x 1/(1-lam)); while the last overflows the +# window the lightest family is folded into the parent's body (family mass, so the far field is unchanged). +# placement kept children keep their order and are stretched geometrically so the outermost sits at hi. +# cusp (opt) cusp=k: a child also sits where its Plummer peak pull 0.5 c_c/eps_c is k x its parent's pull c_p/D, +# i.e. D >= 2 k eps_c c_p / c_c, so its own basin keeps a saddle against the parent (wells-read-as-nested-basins). +# bodyless a ring keeps its radius when it is a root (rosette geometry is rosette-generation-r3's), else shrinks +# to fit its node's reach; members' children sit within 0.2 x the nearest-partner chord; outer +# ("captured") children sit beyond the ring group's support (1 + RING_REACH) x D_ring. A pair whose +# members cannot sit outside each other's Roche limit inside its node's reach becomes one body. +import math +import numpy as np +import well_law as WL + +ROCHE_MARGIN = 1.5 # v >= 0.83 sqrt(c) at the innermost child under the Plummer core +ROCHE = lambda m: 7.32 * np.maximum(m, 1e-12) ** 0.25 # PlanetSettings.RocheLimit (Settings.asset 121-124) + +def replace_zone(par, D, m, body, cls, Z, lam, cusp=0.0, spacing=1.0): + CUSP = cusp + n = len(par) + kids = [[j for j in range(n) if par[j] == i] for i in range(n)] + depth = np.zeros(n, int) + for j in range(n): + a = par[j] + while a >= 0: depth[j] += 1; a = par[a] + def subtree(i): + out = [i] + for j in kids[i]: out += subtree(j) + return out + mb = np.where(body, m, 0.0).astype(float) + newD = D.astype(float).copy(); keep = np.ones(n, bool); m2 = m.astype(float).copy(); body2 = body.copy() + stats = {'folded': 0, 'folded_massive': 0, 'kept': 0} + q = 1 / (1 - spacing * lam) + def fam_mass(i): return sum(mb[j] for j in subtree(i) if keep[j]) + def fold(i, into): + for j in subtree(i): + if keep[j]: + keep[j] = False; stats['folded'] += 1; stats['folded_massive'] += int(mb[j] > 0) + m2[into] += mb[j]; mb[into] += mb[j] + cfun = lambda mm: 45.08 * np.maximum(mm, 1e-12) ** 0.2 # PlanetSettings.WellStrength (Settings.asset 117-120) + def place(ch, lo, hi, into): + """Greedy inside-out: each child at max(its lower bound, previous x q); fold the lightest family until the + last fits under hi; then stretch geometrically so the outermost sits at hi (ratios only grow).""" + hi = hi * (1 - 1e-6) + ch = sorted([j for j in ch if keep[j]], key=lambda j: D[j]) + def lo_of(j): + if not CUSP or mb[j] <= 0 or not body2[into] or mb[into] <= 0: return lo + # the child's Plummer peak pull 0.5 c_c / eps_c must beat CUSP x the parent's pull c_p / D at the child + return max(lo, 2 * CUSP * ROCHE(m2[j]) * cfun(m2[into]) / cfun(m2[j])) + while ch: + pos = []; prev = 0.0 + for j in ch: + prev = max(lo_of(j), prev * q) if pos else lo_of(j); pos.append(prev) + if pos[-1] <= hi: break + light = min(ch, key=lambda j: fam_mass(j)); fold(light, into) + ch = [j for j in ch if keep[j]] + if not ch: return + if len(ch) == 1: + newD[ch[0]] = max(pos[0], math.sqrt(lo * hi)) if pos[0] <= hi else pos[0] + return + g = (hi / pos[-1]) ** (1 / (len(ch) - 1)) + for k, j in enumerate(ch): newD[j] = pos[k] * g ** k + for i in sorted(range(n), key=lambda i: depth[i]): + if not keep[i] or not kids[i]: continue + root = par[i] < 0 + # B_F <= GAMMA Lam_F: the family's own support ends before its fade starts, so a merged body has the same terms + def hi_of(Dn): return 0.75 * Z if root else min(WL.GAMMA * lam * Dn / WL.BETA, WL.GAMMA * lam * Dn / (1 + lam)) + if body2[i]: + place(kids[i], ROCHE_MARGIN * ROCHE(m2[i]), hi_of(newD[i]), i) + continue + massive = [j for j in kids[i] if keep[j] and mb[j] > 0] + if not massive: + continue + dmin = min(D[j] for j in massive) + ring = [j for j in massive if abs(D[j] - dmin) <= 0.1] + if len(ring) < 2: ring = [min(massive, key=lambda j: D[j])] + heavy = max(ring, key=lambda j: mb[j]) + Dr = D[ring[0]] + if not root: + Dr = min(Dr, WL.GAMMA * lam * newD[i] / (WL.BETA ** 2 * (1 + WL.RING_REACH))) + if Dr < ROCHE_MARGIN * ROCHE(m2[heavy]): + # the pair cannot fit its node's reach with its members outside each other's Roche limit: it is one body + for j in list(kids[i]): + if keep[j]: fold(j, i) + body2[i] = True; stats['binaries_merged'] = stats.get('binaries_merged', 0) + 1 + continue + for j in ring: newD[j] = Dr + chord = 2 * Dr * math.sin(math.pi / len(ring)) if len(ring) >= 2 else Dr + for j in ring: + place(kids[j], ROCHE_MARGIN * ROCHE(m2[j]), 0.2 * chord, j) + outer = [j for j in kids[i] if keep[j] and j not in ring] + place(outer, (1 + WL.RING_REACH) * Dr * WL.BETA, hi_of(newD[i]), heavy) + idx = np.where(keep)[0]; remap = {j: k for k, j in enumerate(idx)} + par2 = np.array([remap[par[j]] if par[j] >= 0 else -1 for j in idx]) + stats['kept'] = int(keep.sum()); stats['idx'] = idx + return par2, newD[idx], m2[idx], body2[idx], [cls[j] for j in idx], stats diff --git a/docs/research/gravity-well-law/well_law.py b/docs/research/gravity-well-law/well_law.py index 286a71f81..704b3f836 100644 --- a/docs/research/gravity-well-law/well_law.py +++ b/docs/research/gravity-well-law/well_law.py @@ -7,7 +7,8 @@ # S(t) = t^k sum_{j= b^2; on the band 1/2 [R ln((t+tau)/(1+tau)) - (I(1) - I(t))], tau = (a^2+eps^2)/(b^2-a^2), # where (1 - S(t)) = (t + tau) Q(t) + R and I is the antiderivative of Q; inside, 1/2 ln((s+eps^2)/(a^2+eps^2)) plus -# the band's value at t = 0. a = b = inf is the global term 1/2 ln(r^2 + eps^2). +# the band's value at t = 0. a = b = inf is the global term 1/2 ln(r^2 + eps^2). For tau > 1 (softening comparable to +# the band) the band is summed as a series in (1 - t)/(1 + tau) instead (band_potential), which stays exact in float32. # The potential is a function of r^2 (C-infinity at the centre and wherever B is constant) and C^k on the circles # r = a and r = b (the k-th derivative of the pull jumps there). # @@ -86,9 +87,22 @@ def band_potential(eps, a, b, r): q, R = _band_coeffs(tau) def band(t): return 0.5 * (R * np.log((t + tau) / (1 + tau)) - (_qint(q, np.ones_like(t)) - _qint(q, t))) + def band_series(t): + # tau > 1: the closed form cancels catastrophically (float32 error 6e-3 at tau 3, 13 at tau 10). With y = 1 - t, + # 1 - S(1 - y) = S(y) (the smoothstep is symmetric) and 1/(t + tau) = sum_k y^k / (1 + tau)^(k+1), ratio <= 1/2: + # band = -1/2 sum_k (1+tau)^-(k+1) sum_j S_j Y^(j+k+1) / (j+k+1), Y = 1 - t; 40 terms reach 1e-12 at tau = 1. + Y = 1 - t; acc = np.zeros_like(t); w = 1 / (1 + tau) + for k in range(40): + inner = np.zeros_like(t) + for j in range(4, len(SC)): + inner = inner + SC[j] * Y ** (j + k + 1) / (j + k + 1) + acc = acc + w ** (k + 1) * inner + return -0.5 * acc + def band_any(t): + return np.where(tau <= 1, band(t), band_series(t)) t = np.clip((s - A2) / span, 0.0, 1.0) - inside = 0.5 * np.log((s + eps * eps) / (A2 + eps * eps)) + band(np.zeros_like(t)) - val = np.where(s <= A2, inside, np.where(s >= B2, 0.0, band(t))) + inside = 0.5 * np.log((s + eps * eps) / (A2 + eps * eps)) + band_any(np.zeros_like(t)) + val = np.where(s <= A2, inside, np.where(s >= B2, 0.0, band_any(t))) return np.where(inf, 0.5 * np.log(s + eps * eps), val) # ---- the first draft: tapered window from the centre (see the header) ----