nordic-skating / commit
feat(ice): zones of ice from what it will carry, and going through the thinnest
src/ice/strength.ts | 37 +++++++++++++++++++++++++++++++++++++ src/skater.ts | 23 ++++++++++++++++++++++- src/world/lake.ts | 30 +++++++++++++++++++++++------- test/lake.test.ts | 36 +++++++++++++++++++++++++++++++++++- test/skater.test.ts | 29 +++++++++++++++++++++++++++-- test/strength.test.ts | 29 +++++++++++++++++++++++++++++ 6 files changed, 173 insertions(+), 11 deletions(-) diff --git a/src/ice/strength.ts b/src/ice/strength.ts new file mode 100644 index 0000000..da800a3 --- /dev/null +++ b/src/ice/strength.ts @@ -0,0 +1,37 @@ +/** + * How much ice it takes to hold a skater up. + * + * Gold's formula, from the record of what did and did not go through the ice roads of + * Canada: a sheet `h` cm thick carries a load of `A · h²` kg, where `A` says how much + * risk is being run. L. W. Gold, "Use of ice covers for transportation", Canadian + * Geotechnical Journal 8, 170–181 (1971). + */ + +/** Gold's `A`, kg/cm²: safe, failure unlikely. */ +export const A_SAFE = 3.5 +/** The upper limit for ordinary use: beyond it the ice is being asked a lot. */ +export const A_LIMIT = 7 +/** Beyond this, failure should be expected. */ +export const A_FAILURE = 17 + +/** A skater with clothes, skates and a pack, kg. */ +export const SKATER_MASS = 80 + +/** Thickness of ice, metres, that carries `mass` kg at risk `a`. */ +export function bearingThickness(mass: number, a: number): number { + return Math.sqrt(mass / a) / 100 +} + +/** + * The three thicknesses that matter to one skater, metres. They are the zones of the + * lake, and nothing about them is chosen: they are Gold's three values of `A`. + * + * - Thinner than `BREAKING` (2.2 cm) the ice gives way. + * - Up to `CRACKING` (3.4 cm) it holds, and cracks under the skater as it does. + * - Up to `SAFE` (4.8 cm) it is sound ice run close to its limit: the ice of the film, + * at 4.1 cm, and the ice that sings best. + * - Beyond that it is safe, and sings lower the thicker it gets. + */ +export const BREAKING_THICKNESS = bearingThickness(SKATER_MASS, A_FAILURE) +export const CRACKING_THICKNESS = bearingThickness(SKATER_MASS, A_LIMIT) +export const SAFE_THICKNESS = bearingThickness(SKATER_MASS, A_SAFE) diff --git a/src/skater.ts b/src/skater.ts index 7acd544..b0bd933 100644 --- a/src/skater.ts +++ b/src/skater.ts @@ -1,3 +1,4 @@ +import { BREAKING_THICKNESS } from './ice/strength.js' import { type Lake, mulberry32 } from './world/lake.js' /** Cruising speed, m/s: about 25 km/h, an easy pace on good ice. */ @@ -26,6 +27,9 @@ export function strideSeconds(index: number): number { return MIN_STRIDE + (MAX_STRIDE - MIN_STRIDE) * mulberry32(index)() } +/** Seconds between going through the ice and skating on from the middle of the lake. */ +export const UNDER_SECONDS = 4 + export interface Skater { /** Distance skated along the lake, metres. */ s: number @@ -39,10 +43,12 @@ export interface Skater { idle: number /** Strides taken, with the fraction being how far through the current one. */ stride: number + /** Seconds left in the water after falling through the ice; 0 while skating. */ + under: number } export function createSkater(s = 0): Skater { - return { s, offset: 0, lateral: 0, heading: 0, idle: Infinity, stride: 0 } + return { s, offset: 0, lateral: 0, heading: 0, idle: Infinity, stride: 0, under: 0 } } /** Where the skater is across the lake, in the same metres as `Lake.centre`. */ @@ -56,8 +62,22 @@ export function skaterX(k: Skater, lake: Lake): number { * The skater is held as an offset from the middle of the channel rather than as a * position, which is what makes doing nothing an autopilot: an untouched offset follows * every bend. Steering moves the offset, and left alone for a while it eases back to 0. + * + * Ice too thin to hold a skater (ice/strength.ts) gives way. The skater is then in the + * water where they fell for `UNDER_SECONDS`, and skates on from the middle of the lake, + * where the ice is always sound (world/lake.ts). */ export function stepSkater(k: Skater, lake: Lake, steer: number, dt: number): void { + if (k.under > 0) { + k.under = Math.max(0, k.under - dt) + k.lateral = 0 + if (k.under === 0) { + k.offset = 0 + k.heading = Math.atan(lake.slope(k.s)) + k.idle = Infinity + } + return + } const before = k.offset if (Math.abs(steer) > 0.05) { k.idle = 0 @@ -77,4 +97,5 @@ export function stepSkater(k: Skater, lake: Lake, steer: number, dt: number): vo // The view turns into a change of direction over a moment rather than snapping to it. k.heading += (Math.atan(slope) - k.heading) * (1 - Math.exp(-dt / 0.25)) k.stride += dt / strideSeconds(Math.floor(k.stride)) + if (lake.thickness(skaterX(k, lake), k.s) < BREAKING_THICKNESS) k.under = UNDER_SECONDS } diff --git a/src/world/lake.ts b/src/world/lake.ts index 18e0058..3bdf7b9 100644 --- a/src/world/lake.ts +++ b/src/world/lake.ts @@ -35,11 +35,20 @@ export interface Lake { export const MIN_HALF_WIDTH = 12 export const MAX_HALF_WIDTH = 44 /** - * The thinnest and thickest the ice gets, metres. The ice in the film this is modelled on - * measures 4.1 cm (tools/analyse-video.mjs), and that is the middle of the range. + * The thinnest and thickest the ice gets, metres: from ice that will not hold a skater + * (ice/strength.ts) to ice half as thick again as it needs to be. */ -export const MIN_THICKNESS = 0.025 -export const MAX_THICKNESS = 0.057 +export const MIN_THICKNESS = 0.014 +export const MAX_THICKNESS = 0.07 +/** + * The ice down the middle of the lake is never thinner than this, metres, which is what + * lets the autopilot skate for ever: it is the 4.1 cm of the film this is modelled on + * (tools/analyse-video.mjs), sound ice that sings. + */ +export const MIDDLE_THICKNESS = 0.041 +/** How far either side of the middle that holds, metres, and where it has faded out. */ +const MIDDLE_HOLDS = 3 +const MIDDLE_FADES = 8 export function createLake(seed: number): Lake { const noise = createNoise2D(mulberry32(seed)) @@ -58,8 +67,15 @@ export function createLake(seed: number): Lake { const rise = 8 * (1 - Math.exp(-d / 25)) + 0.06 * d return rise * (0.55 + 0.45 * noise(s / 140, side * 37 + d / 90)) }, - // Ice thickens and thins in broad patches, longer along the lake than across it. - thickness: (x, s) => - (MIN_THICKNESS + MAX_THICKNESS) / 2 + ((MAX_THICKNESS - MIN_THICKNESS) / 2) * noise(x / 60 + 500, s / 180), + thickness: (x, s) => { + // Ice thickens and thins in broad patches, longer along the lake than across it. + const patch = (MIN_THICKNESS + MAX_THICKNESS) / 2 + ((MAX_THICKNESS - MIN_THICKNESS) / 2) * noise(x / 60 + 500, s / 180) + if (patch >= MIDDLE_THICKNESS) return patch + // Where a thin patch crosses the middle of the lake, a strip of sound ice bridges it. + const off = Math.abs(x - centre(s)) + const bridge = 1 - Math.min(1, Math.max(0, (off - MIDDLE_HOLDS) / (MIDDLE_FADES - MIDDLE_HOLDS))) + if (bridge === 1) return MIDDLE_THICKNESS + return patch + (MIDDLE_THICKNESS - patch) * bridge * bridge * (3 - 2 * bridge) + }, } } diff --git a/test/lake.test.ts b/test/lake.test.ts index dadd621..b76dc44 100644 --- a/test/lake.test.ts +++ b/test/lake.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from 'vitest' -import { MAX_HALF_WIDTH, MAX_THICKNESS, MIN_HALF_WIDTH, MIN_THICKNESS, createLake } from '../src/world/lake.js' +import { BREAKING_THICKNESS, CRACKING_THICKNESS, SAFE_THICKNESS } from '../src/ice/strength.js' +import { + MAX_HALF_WIDTH, MAX_THICKNESS, MIDDLE_THICKNESS, MIN_HALF_WIDTH, MIN_THICKNESS, createLake, +} from '../src/world/lake.js' describe('lake', () => { it('is the same lake for the same seed, and another for another', () => { @@ -50,4 +53,35 @@ describe('lake', () => { } } }) + + it('always has sound ice down its middle', () => { + const lake = createLake(1) + for (let s = 0; s < 200_000; s += 5) { + for (const off of [-2.9, 0, 2.9]) expect(lake.thickness(lake.centre(s) + off, s)).toBeGreaterThanOrEqual(MIDDLE_THICKNESS) + } + }) + + it('has every kind of ice within reach of a skater, most of it sound', () => { + const lake = createLake(1) + const zones = { breaking: 0, cracking: 0, singing: 0, safe: 0 } + let all = 0 + for (let s = 0; s < 100_000; s += 10) { + const reach = lake.halfWidth(s) - 2 + for (let off = -reach; off <= reach; off += 2) { + const h = lake.thickness(lake.centre(s) + off, s) + if (h < BREAKING_THICKNESS) zones.breaking++ + else if (h < CRACKING_THICKNESS) zones.cracking++ + else if (h < SAFE_THICKNESS) zones.singing++ + else zones.safe++ + all++ + } + } + // Ice that gives way is there to be found, and rare enough to be avoided. + expect(zones.breaking / all).toBeGreaterThan(0.005) + expect(zones.breaking / all).toBeLessThan(0.08) + expect(zones.cracking / all).toBeGreaterThan(0.1) + expect(zones.singing / all).toBeGreaterThan(0.2) + expect(zones.safe / all).toBeGreaterThan(0.2) + console.info(Object.entries(zones).map(([k, n]) => `${k} ${((100 * n) / all).toFixed(1)}%`).join(', ')) + }) }) diff --git a/test/skater.test.ts b/test/skater.test.ts index 566bb06..1d76e7d 100644 --- a/test/skater.test.ts +++ b/test/skater.test.ts @@ -1,9 +1,10 @@ import { describe, expect, it } from 'vitest' import { - IDLE_BEFORE_RETURN, LATERAL_SPEED, MAX_STRIDE, MIN_STRIDE, SHORE_MARGIN, SPEED, + IDLE_BEFORE_RETURN, LATERAL_SPEED, MAX_STRIDE, MIN_STRIDE, SHORE_MARGIN, SPEED, UNDER_SECONDS, createSkater, skaterX, stepSkater, strideSeconds, } from '../src/skater.js' -import { createLake } from '../src/world/lake.js' +import { BREAKING_THICKNESS } from '../src/ice/strength.js' +import { type Lake, createLake } from '../src/world/lake.js' const DT = 1 / 60 const lake = createLake(1) @@ -78,4 +79,28 @@ describe('skater', () => { run(k, 0, 10) expect(k.s - 1e7).toBeGreaterThan(SPEED * 10 * 0.9) }) + + it('left alone, never goes through the ice', () => { + const k = createSkater() + for (let t = 0; t < 3600; t += DT) { + stepSkater(k, lake, 0, DT) + expect(k.under).toBe(0) + } + }) + + it('goes through ice too thin to hold, waits in the water, and skates on from the middle', () => { + // A lake that is sound down the middle and gives way anywhere 5 m to the right of it. + const thin: Lake = { ...lake, thickness: (x, s) => (x - lake.centre(s) > 5 ? BREAKING_THICKNESS / 2 : 0.05) } + const k = createSkater() + while (k.under === 0) stepSkater(k, thin, 1, DT) + expect(k.offset).toBeGreaterThan(5) + const where = k.s + for (let i = 0; i < Math.round(UNDER_SECONDS / DT) - 2; i++) stepSkater(k, thin, 1, DT) + expect(k.under).toBeGreaterThan(0) + expect(k.s).toBe(where) + for (let i = 0; i < 4; i++) stepSkater(k, thin, 0, DT) + expect(k.under).toBe(0) + expect(k.offset).toBe(0) + expect(k.s).toBeGreaterThan(where) + }) }) diff --git a/test/strength.test.ts b/test/strength.test.ts new file mode 100644 index 0000000..d6b740f --- /dev/null +++ b/test/strength.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest' +import { singingFrequency } from '../src/ice/acoustics.js' +import { BREAKING_THICKNESS, CRACKING_THICKNESS, SAFE_THICKNESS, bearingThickness } from '../src/ice/strength.js' +import { MAX_THICKNESS, MIDDLE_THICKNESS, MIN_THICKNESS } from '../src/world/lake.js' + +describe('ice strength', () => { + it('needs twice the ice for four times the load', () => { + expect(bearingThickness(320, 7) / bearingThickness(80, 7)).toBeCloseTo(2, 10) + }) + + it('gives a skater zones of 2.2, 3.4 and 4.8 cm', () => { + expect(BREAKING_THICKNESS * 100).toBeCloseTo(2.2, 1) + expect(CRACKING_THICKNESS * 100).toBeCloseTo(3.4, 1) + expect(SAFE_THICKNESS * 100).toBeCloseTo(4.8, 1) + }) + + it('puts the film\'s ice, and the middle of the lake, in the zone that sings', () => { + expect(MIDDLE_THICKNESS).toBeGreaterThan(CRACKING_THICKNESS) + expect(MIDDLE_THICKNESS).toBeLessThan(SAFE_THICKNESS) + }) + + it('has every zone somewhere on the lake, and a pitch that tells them apart', () => { + expect(MIN_THICKNESS).toBeLessThan(BREAKING_THICKNESS) + expect(MAX_THICKNESS).toBeGreaterThan(SAFE_THICKNESS) + // Breaking ice sings above 1.4 kHz, safe ice below 640 Hz: more than an octave apart. + expect(singingFrequency(BREAKING_THICKNESS)).toBeGreaterThan(1390) + expect(singingFrequency(SAFE_THICKNESS)).toBeLessThan(640) + }) +})