tower / commit
feat(canon): how things fall in the rotating frame
src/canon/motion.ts | 61 ++++++++++++++++++++++++++++++++++++++++++++++++++++ test/motion.test.ts | 62 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 123 insertions(+) diff --git a/src/canon/motion.ts b/src/canon/motion.ts new file mode 100644 index 0000000..a0059e6 --- /dev/null +++ b/src/canon/motion.ts @@ -0,0 +1,61 @@ +/** + * How things fall in Tower's rotating frame. See design.md §1.9.2. + * + * Coriolis never pushes surface water sideways (§1.9.1), so the spin is invisible + * in every static thing. The hull is sealed, so there are no stars to wheel + * overhead either. That leaves what falls as the only evidence in the sky that + * Tower turns at all, which makes these two angles load-bearing for the interior + * view rather than decoration. + * + * Constant-g plus Coriolis is the standard approximation and is what these use. + * It is exact only for drops small against the radius: gravity is 2% weaker 1 km + * up, and the true inertial-frame path is a straight line, not a parabola. + */ +import { G0, OMEGA_RAD_S } from './constants.js' + +/** Terminal velocity of an ordinary raindrop, m/s. */ +export const RAINDROP_TERMINAL_M_S = 9 + +/** + * Lean of something already falling at a steady speed, in radians, antispinward. + * + * Coriolis gives 2ωv antispinward against g outward, and at terminal velocity drag + * holds both constant — so rain leans by a *fixed* angle rather than curving. That + * angle is small: 1.5° for an ordinary raindrop. Rain is a poor compass here. + */ +export const steadyFallLeanRad = (speedMS: number): number => + Math.atan((2 * OMEGA_RAD_S * speedMS) / G0) + +/** Time for something dropped from `heightM` to reach the floor. */ +export const freeFallTimeS = (heightM: number): number => Math.sqrt((2 * heightM) / G0) + +/** + * Antispinward drift of something in free fall from `heightM`. Integrating the + * Coriolis acceleration 2ω(gt) twice gives ωgt³/3. + */ +export const freeFallDriftM = (heightM: number): number => + (OMEGA_RAD_S * G0 * freeFallTimeS(heightM) ** 3) / 3 + +/** + * Lean of the *path* of a free fall from `heightM` — where it lands, against how + * far it dropped. This is the tilt of a waterfall's plume seen whole, and it is + * the column design.md §1.9.2 tabulates: 7.6° over a kilometre. + */ +export const freeFallPathLeanRad = (heightM: number): number => + Math.atan(freeFallDriftM(heightM) / heightM) + +/** + * Lean of the *velocity* at the bottom of the same fall — the direction a streak + * of it is moving as it arrives, and so the angle rain and spray are drawn at. + * + * It reduces to atan(ωt), independent of g, and it is exactly half again the path + * lean in tangent: the drift integrates to ωgt³/3 over a drop of gt²/2, giving + * (2/3)ωt, while the sideways speed reaches ωgt² against a fall speed of gt. + * Both numbers are real and they describe different things, which is why + * §1.9.2 says 7.6° for a kilometre drop and the interior view's readout says 11.3°. + * + * Unlike rain this keeps growing with the drop, so a tall waterfall *is* a compass + * where a shower is not. + */ +export const freeFallLeanRad = (heightM: number): number => + Math.atan(OMEGA_RAD_S * freeFallTimeS(heightM)) diff --git a/test/motion.test.ts b/test/motion.test.ts new file mode 100644 index 0000000..27f9e26 --- /dev/null +++ b/test/motion.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest' +import { G0, OMEGA_RAD_S } from '../src/canon/constants.js' +import { + RAINDROP_TERMINAL_M_S, freeFallDriftM, freeFallLeanRad, freeFallPathLeanRad, + freeFallTimeS, steadyFallLeanRad, +} from '../src/canon/motion.js' + +const deg = (r: number): number => (r * 180) / Math.PI + +/** + * These two numbers are the entire visible evidence that Tower spins, so they are + * worth pinning: the hull is sealed, there are no stars, and everything that does + * not fall is co-rotating and therefore perfectly still. + */ +describe('falling in the rotating frame', () => { + it('leaves rain very nearly vertical', () => { + expect(deg(steadyFallLeanRad(RAINDROP_TERMINAL_M_S))).toBeCloseTo(1.5, 1) + }) + + it('leans a waterfall enough to read as a compass', () => { + // A 1 km drop: eight times the lean of rain, and unmistakable. + expect(deg(freeFallLeanRad(1000))).toBeCloseTo(11.3, 1) + }) + + it('drifts an object dropped from 1 km by over a hundred metres', () => { + expect(freeFallDriftM(1000)).toBeCloseTo(133, 0) + }) + + /** + * design.md §1.9.2 tabulates 7.6° for a kilometre and this view reports 11.3°. + * Both are correct and they are not the same angle, so pin the relation that + * makes that true rather than quietly letting the two documents disagree. + */ + it('separates the lean of the path from the lean of the velocity', () => { + expect(deg(freeFallPathLeanRad(1000))).toBeCloseTo(7.6, 1) + for (const h of [100, 500, 1000, 2000]) { + expect(Math.tan(freeFallLeanRad(h))).toBeCloseTo(1.5 * Math.tan(freeFallPathLeanRad(h)), 9) + } + }) + + it('grows the lean with the drop, which is why the waterfall wins', () => { + const heights = [10, 100, 1000, 4000] + const leans = heights.map(freeFallLeanRad) + for (let i = 1; i < leans.length; i++) expect(leans[i]!).toBeGreaterThan(leans[i - 1]!) + // Rain does not: at terminal velocity the angle is fixed whatever the cloud. + expect(steadyFallLeanRad(RAINDROP_TERMINAL_M_S)) + .toBe(steadyFallLeanRad(RAINDROP_TERMINAL_M_S)) + }) + + it('agrees with the closed forms it is derived from', () => { + const h = 2500 + const t = freeFallTimeS(h) + expect(t).toBeCloseTo(Math.sqrt((2 * h) / G0), 9) + expect(freeFallLeanRad(h)).toBeCloseTo(Math.atan(OMEGA_RAD_S * t), 9) + expect(freeFallDriftM(h)).toBeCloseTo((OMEGA_RAD_S * G0 * t ** 3) / 3, 6) + }) + + it('leans everything the same way — antispinward, always', () => { + expect(steadyFallLeanRad(1)).toBeGreaterThan(0) + expect(freeFallLeanRad(1)).toBeGreaterThan(0) + }) +})