tower / files / test / canon.test.ts
canon.test.ts
ec7e78a8 2026-09-07 Pavel Safronov | 1 | /** |
| 2 | * Canon is derived from four free parameters (decisions.md D-007), so these tests | |
| 3 | * pin every derived figure to the number printed in design.md. If a constant is | |
| 4 | * ever retuned, the docs and these expectations must move together. | |
| 5 | */ | |
| 6 | import { afterEach, beforeEach, describe, expect, it } from 'vitest' | |
| 7 | import * as C from '../src/canon/constants.js' | |
| 8 | import { pressureAtRadius, relativeDensityAtRadius } from '../src/canon/atmosphere.js' | |
| 9 | import { | |
| 10 | landArea, landStripCount, landStripWidth, setSlatCount, slatCount, slatWidth, | |
| 11 | } from '../src/canon/slats.js' | |
| 12 | import { | |
376f2ce6 2026-09-16 Pavel Safronov | 13 | isSlat, landStripIndex, mapThetaOrigin, slatCentre, |
ec7e78a8 2026-09-07 Pavel Safronov | 14 | } from '../src/canon/geometry.js' |
| 15 | ||
| 16 | const near = (actual: number, expected: number, tolerance: number) => | |
| 17 | expect(Math.abs(actual - expected)).toBeLessThanOrEqual(tolerance) | |
| 18 | ||
| 19 | describe('§1.1 dimensions', () => { | |
| 20 | it('circumference is 314.16 km', () => near(C.CIRCUMFERENCE_M, 314_159, 5)) | |
| 21 | it('inner surface is Wisconsin total area, 169,646 km²', () => | |
| 22 | near(C.INNER_AREA_M2 / 1e6, 169_646, 5)) | |
| 23 | it('land is Wisconsin land area, 140,268 km²', () => | |
| 24 | near(C.LAND_AREA_M2 / 1e6, 140_268, 5)) | |
| 25 | }) | |
| 26 | ||
| 27 | describe('§1.2 spin', () => { | |
| 28 | it('gives exactly 1 g at the floor', () => near(C.gravityAtHeight(0), C.G0, 1e-9)) | |
| 29 | it('ω = 0.014005 rad/s', () => near(C.OMEGA_RAD_S, 0.014005, 1e-6)) | |
| 30 | it('spin period is 448.6 s', () => near(C.SPIN_PERIOD_S, 448.6, 0.1)) | |
| 31 | it('rim speed is 700.2 m/s', () => near(C.RIM_SPEED_M_S, 700.2, 0.1)) | |
| 32 | it('gravity falls linearly to zero on the axis', () => { | |
| 33 | near(C.gravityAtRadius(0), 0, 1e-12) | |
| 34 | near(C.gravityAtHeight(1000) / C.G0, 0.98, 0.001) | |
| 35 | }) | |
| 36 | }) | |
| 37 | ||
| 38 | describe('§1.4 the hull is sealed by default (D-017)', () => { | |
| 39 | afterEach(() => setSlatCount(C.SLAT_COUNT_DEFAULT)) | |
| 40 | ||
| 41 | it('defaults to no slats at all', () => { | |
| 42 | expect(C.SLAT_COUNT_DEFAULT).toBe(0) | |
| 43 | expect(slatCount()).toBe(0) | |
| 44 | }) | |
| 45 | it('makes the whole inner surface land — Wisconsin’s total area', () => | |
| 46 | near(landArea() / 1e6, 169_646, 5)) | |
| 47 | }) | |
| 48 | ||
| 49 | describe('§1.4 slats, when the author explores them', () => { | |
| 50 | beforeEach(() => setSlatCount(8)) | |
| 51 | afterEach(() => setSlatCount(C.SLAT_COUNT_DEFAULT)) | |
| 52 | ||
| 53 | it('is 8 slats, 6.80 km wide', () => { | |
| 54 | expect(slatCount()).toBe(8) | |
| 55 | near(slatWidth(), 6800, 15) | |
| 56 | }) | |
| 57 | it('land strips are 32.5 km wide', () => near(landStripWidth(), 32_470, 30)) | |
| 58 | it('slat and land widths tile the circumference', () => | |
| 59 | near(slatCount() * (slatWidth() + landStripWidth()), C.CIRCUMFERENCE_M, 1)) | |
| 60 | }) | |
| 61 | ||
| 62 | describe('§1.4 slat count is adjustable from 0 to 16', () => { | |
| 63 | afterEach(() => setSlatCount(C.SLAT_COUNT_DEFAULT)) | |
| 64 | ||
| 65 | it('keeps land at Wisconsin’s land area for every count from 1 up', () => { | |
| 66 | for (let n = 1; n <= C.SLAT_COUNT_MAX; n++) { | |
| 67 | setSlatCount(n) | |
| 68 | near(landArea() / 1e6, 140_268, 5) | |
| 69 | near(slatCount() * (slatWidth() + landStripWidth()), C.CIRCUMFERENCE_M, 1) | |
| 70 | } | |
| 71 | }) | |
| 72 | ||
| 73 | it('divides a fixed total width, so more slats means narrower ones', () => { | |
| 74 | setSlatCount(4) | |
| 75 | const wide = slatWidth() | |
| 76 | setSlatCount(16) | |
| 77 | // The total is what is fixed, so 16 narrow slats sum to the same as 4 wide ones. | |
| 78 | near(slatWidth() * 16, wide * 4, 1) | |
| 79 | near(slatWidth() * 4, wide, 1) | |
| 80 | near(slatWidth(), 3400, 10) | |
| 81 | near(landStripWidth(), 16_235, 20) | |
| 82 | }) | |
| 83 | ||
| 84 | it('makes one unbroken surface at zero, and says so about the land area', () => { | |
| 85 | setSlatCount(0) | |
| 86 | expect(slatCount()).toBe(0) | |
| 87 | expect(landStripCount()).toBe(1) | |
| 88 | expect(slatWidth()).toBe(0) | |
| 89 | near(landStripWidth(), C.CIRCUMFERENCE_M, 1) | |
| 90 | // With no slats the D-002 coincidence no longer holds — land is everything. | |
| 91 | near(landArea() / 1e6, 169_646, 5) | |
| 92 | for (const theta of [0, 1, 3, 5, 6.28]) { | |
| 93 | expect(isSlat(theta)).toBe(false) | |
| 94 | expect(landStripIndex(theta)).toBe(0) | |
| 95 | } | |
| 96 | // With no slats there is no empty theta, so the cut goes opposite the capital. | |
| 97 | expect(mapThetaOrigin()).toBe(Math.PI) | |
| 98 | }) | |
| 99 | ||
| 100 | it('clamps out-of-range and non-finite input rather than corrupting canon', () => { | |
| 101 | for (const [input, expected] of [[-5, 0], [99, 16], [3.4, 3], [NaN, 0]] as const) { | |
| 102 | setSlatCount(C.SLAT_COUNT_DEFAULT) | |
| 103 | setSlatCount(input) | |
| 104 | expect(slatCount()).toBe(expected) | |
| 105 | } | |
| 106 | }) | |
| 107 | ||
| 108 | it('cuts the map at a slat centre at every count, so no strip is split', () => { | |
| 109 | for (let n = 1; n <= C.SLAT_COUNT_MAX; n++) { | |
| 110 | setSlatCount(n) | |
| 111 | expect(isSlat(mapThetaOrigin())).toBe(true) | |
| 112 | near(mapThetaOrigin(), slatCentre(n - 1), 1e-9) | |
| 113 | } | |
| 114 | }) | |
| 115 | }) | |
| 116 | ||
| 117 | describe('§1.3 sun system', () => { | |
| 118 | it('beam radius is 1592 m', () => near(C.BEAM_RADIUS_M, 1592, 1)) | |
| 119 | it('beam carries 54.3 TW', () => near(C.BEAM_POWER_W / 1e12, 54.3, 0.2)) | |
da6bcb0e 2026-09-08 Pavel Safronov | 120 | it('full brightness is Earth clear-sky noon, 320 W/m² mean', () => |
| 121 | near(C.BRIGHTNESS_FULL_W_M2, 320, 2)) | |
| 122 | it('collector is 244 km across', () => near(C.COLLECTOR_DIAMETER_M / 1000, 244, 1)) | |
| 123 | /** | |
| 124 | * The far cap mirror (D-048). The shape of the load is the whole argument: the | |
| 125 | * survivors arrive collimated, so this is a spot problem and not a disc problem. | |
| 126 | */ | |
| 127 | it('the beam reaches the far cap as 1.7 MW/m² in a 3.2 km spot', () => { | |
| 128 | near(C.BEAM_FAR_CAP_FLUX_W_M2 / 1e6, 1.67, 0.05) | |
| 129 | // A thousandth of the cap's area — which is why 99.9% of it can be window. | |
| 130 | near(C.BEAM_AREA_M2 / (Math.PI * C.RADIUS_M ** 2), 0.001, 0.0002) | |
| 131 | }) | |
| 132 | it('an absorber there would sit at 2330 K; a 99.9% mirror at 1.2 solar constants', () => { | |
| 133 | near((C.BEAM_FAR_CAP_FLUX_W_M2 / 5.67e-8) ** 0.25, 2330, 20) | |
| 134 | near(C.FAR_CAP_MIRROR_ABSORBED_W_M2, 1665, 30) | |
| 135 | }) | |
| 136 | it('the mirror lifts utilisation from 76% to 94% by doubling the column', () => { | |
| 137 | near(C.BEAM_UTILISATION_ONE_PASS, 0.756, 0.005) | |
| 138 | near(C.BEAM_UTILISATION_TWO_PASS, 0.940, 0.005) | |
| 139 | }) | |
392bb8a7 2026-09-08 Pavel Safronov | 140 | it('night is the beam banked to a thousandth, 0.32 W/m²', () => { |
| 141 | near(C.BRIGHTNESS_NIGHT_W_M2, 0.32, 0.01) | |
| 142 | near(C.NIGHT_BEAM_FRACTION, 0.001, 1e-9) | |
| 143 | }) | |
da6bcb0e 2026-09-08 Pavel Safronov | 144 | /** |
| 145 | * The reason night has to be made this way rather than by aiming the swarm off. | |
| 146 | * §1.3 gives the glow to the beam and the air, so a fully lit beam is still a | |
| 147 | * light source however the diffusers are pointed: 779 W/m²/sr over a strip 3.65° | |
| 148 | * wide running horizon to horizon is ~0.2 sr, and with the mean cosine of a | |
| 149 | * strip through the zenith that lands near 100 W/m² on the floor — half of | |
| 150 | * temperate, which is not a night by anyone's reckoning (D-046). | |
| 151 | */ | |
| 152 | it('a lit beam alone would light the floor to about 100 W/m²', () => { | |
| 153 | const strip = (C.BEAM_ANGULAR_WIDTH_RAD * Math.PI) * (2 / Math.PI) | |
| 154 | near(C.BEAM_RADIANCE_W_M2_SR * strip, 100, 25) | |
| 155 | }) | |
ec7e78a8 2026-09-07 Pavel Safronov | 156 | it('stays inside the étendue limit — D-003 is no longer a physics break', () => { |
| 157 | expect(C.BEAM_CONCENTRATION).toBeLessThan(C.ETENDUE_LIMIT) | |
| 158 | near(C.BEAM_CONCENTRATION / C.ETENDUE_LIMIT, 0.11, 0.005) | |
| 159 | }) | |
| 160 | }) | |
| 161 | ||
| 162 | describe('§1.3.1 diffuser swarm', () => { | |
| 163 | it('is 78 billion mirrorlets', () => near(C.SWARM_COUNT / 1e9, 78, 1)) | |
| 164 | it('masses 158 tonnes in total', () => near(C.SWARM_MASS_KG / 1000, 158, 2)) | |
| 165 | it('has optical depth 1.41, leaving 24% of light for the far cap', () => { | |
| 166 | near(C.SWARM_OPTICAL_DEPTH, 1.41, 0.02) | |
| 167 | near(Math.exp(-C.SWARM_OPTICAL_DEPTH), 0.24, 0.01) | |
| 168 | }) | |
| 169 | it('aims to a 2.3 m spot on the floor — human-scale privacy', () => | |
| 170 | near(C.AIM_SPOT_AT_FLOOR_M, 2.3, 0.05)) | |
| 171 | }) | |
| 172 | ||
| 173 | describe('§1.5 atmosphere', () => { | |
| 174 | it('the axis holds 5.2 kPa — thin, but real air', () => | |
| 175 | near(pressureAtRadius(0) / 1000, 5.2, 0.1)) | |
| 176 | it('is 5.2% of floor density on the axis', () => | |
| 177 | near(relativeDensityAtRadius(0), 0.052, 0.001)) | |
| 178 | it('is full density at the floor', () => | |
| 179 | near(relativeDensityAtRadius(C.RADIUS_M), 1, 1e-12)) | |
| 180 | }) | |
| 181 |