rigless 0.0.5

Virtual hardware-in-the-loop runtime: run unmodified firmware on an emulated MCU with every peripheral, chip model, and unit of virtual time in Rust; a fork of QEMU is built in and executes the instructions
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
// Copyright (c) 2026 Rigless Labs.
// SPDX-License-Identifier: MIT OR Apache-2.0
//! A satellite receiver on a serial line, speaking NMEA 0183: [`Receiver`], which turns the position and
//! the velocity the world hands it into `GGA`, `RMC` and `VTG` at a rate, timed by the run's own clock.
//!
//! **The world says where the antenna is, and the receiver says so in sentences.** The board hands the
//! receiver a [`Position`] and a velocity, and every period the receiver writes one epoch: the fix
//! as `GGA`, the fix with its time, date, speed and course as `RMC`, and the speed and course again as
//! `VTG`. The port on the near end frames the bytes at whatever baud the firmware set, so the same
//! receiver answers at any baud. What the firmware writes to it is taken and ignored: a receiver that
//! needs no configuring has nothing to answer.
//!
//! **The clock is the run's.** UTC counts from a fixed epoch at the start of virtual time, so the time in
//! every sentence is the instant it was written and two runs write the same sentences.
//!
//! | Behaviour | Source | Test |
//! |---|---|---|
//! | An epoch is `GGA`, `RMC` and `VTG` in that order, each `$` to `*`, a checksum of the bytes between as two hexadecimal digits, and `CR LF` | NMEA 0183 §5.3, §6 | `each_epoch_is_three_sentences_with_their_checksums` |
//! | Latitude and longitude as degrees and minutes to five places with the hemisphere; the altitude in metres to three places; a fix of quality one on ten satellites | NMEA 0183 `GGA` | `the_position_is_written_in_degrees_and_minutes_with_its_hemisphere` |
//! | The time as `hhmmss.ss` and the date as `ddmmyy` from the epoch, 2026-01-01T00:00:00Z at zero | NMEA 0183 `RMC` | `the_time_and_date_count_from_the_epoch` |
//! | The speed over the ground in knots to two places, and in kilometres an hour in `VTG`, off the horizontal velocity; the course from north, clockwise, to a hundredth of a degree | NMEA 0183 `RMC`, `VTG` | `the_speed_and_course_come_off_the_horizontal_velocity` |
//! | An epoch every 200 ms on the receiver's own grid, written once the last has gone and stamped with the instant it was due, not the one it was asked at | | `sentences_come_at_the_rate_on_the_receivers_own_grid` |
//! | Degrees and minutes read back to the ten-millionth of a degree by the standard's own rule, the last two whole digits the minutes; the course within a tenth of a degree of the arctangent over every velocity | | `an_angle_reads_back_to_within_a_ten_millionth_of_a_degree`, `the_course_is_within_a_tenth_of_a_degree_of_the_arctangent` |
//!
//! **What the model leaves out, and why.**
//!
//! | Behaviour | Reason |
//! |---|---|
//! | Satellites in view, `GSA`, `GSV`, dilution beyond a fixed `HDOP` | The firmware reads the fix, not the sky; a fix of ten satellites at a dilution of 0.9 is what a clear sky gives |
//! | Configuration by what the firmware writes | Every firmware's driver of this protocol reads sentences as they come; a receiver that took commands would be a particular product, and this one is any |
//! | Acquisition time and a fix that degrades | A receiver here has a fix from the first instant; a plant that wants a lost fix hands a position of quality zero when the seam carries one |
//! | The geoid separation | Written as zero: the altitude is above mean sea level as the world gives it |

use std::collections::VecDeque;
use std::fmt::{Arguments, Write};

use crate::chip::uart::Peer;
use crate::device::DeviceContext;
use crate::time::VirtualNanos;

/// Five hertz.
const PERIOD: VirtualNanos = VirtualNanos(200_000_000);
const EPOCH_YEAR: u64 = 2026;
const NANOS_PER_SECOND: u64 = 1_000_000_000;
const SECONDS_PER_DAY: u64 = 86_400;
const MICRODEGREES_TENTHS: u64 = 10_000_000;
/// A metre a second is 1.943844 knots, and a hundredth of a knot is what the sentence carries.
const HUNDREDTHS_OF_A_KNOT_PER_MILLIMETRE_SECOND: (u64, u64) = (1_943_844, 10_000_000);
const HUNDREDTHS_OF_A_KILOMETRE_HOUR_PER_MILLIMETRE_SECOND: (u64, u64) = (36, 100);
const SATELLITES: u32 = 10;
const HDOP: &str = "0.90";
/// Room for an epoch, so neither buffer grows during a run.
const EPOCH_BYTES: usize = 256;
/// One radian in hundredths of a degree, as a ratio.
const CENTIDEGREES_PER_MICRORADIAN: (i64, i64) = (18_000, 3_141_593);
const SCALE: i64 = 1_000_000;

/// A place on the Earth, as a receiver gives it.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
pub struct Position {
    /// North of the equator, in ten-millionths of a degree.
    pub latitude: i32,
    /// East of the meridian, in ten-millionths of a degree.
    pub longitude: i32,
    /// Above mean sea level, in millimetres.
    pub altitude: i32,
}

impl crate::link::Wire for Position {
    fn encode(&self, out: &mut Vec<u8>) {
        self.latitude.encode(out);
        self.longitude.encode(out);
        self.altitude.encode(out);
    }

    fn decode(bytes: &mut &[u8]) -> Result<Self, crate::link::WireError> {
        Ok(Self {
            latitude: crate::link::Wire::decode(bytes)?,
            longitude: crate::link::Wire::decode(bytes)?,
            altitude: crate::link::Wire::decode(bytes)?,
        })
    }
}

/// A receiver with a fix, reporting an epoch every period.
#[derive(Debug)]
pub struct Receiver {
    next: VirtualNanos,
    position: Position,
    velocity: [i32; 3],
    text: String,
    pending: VecDeque<u8>,
}

impl Receiver {
    /// A receiver reporting at five hertz, at a fix of nothing until the world says where it is.
    #[must_use]
    pub fn new() -> Self {
        Self {
            next: VirtualNanos(0),
            position: Position::default(),
            velocity: [0; 3],
            text: String::with_capacity(EPOCH_BYTES),
            pending: VecDeque::with_capacity(EPOCH_BYTES),
        }
    }

    /// Where the antenna is from now on.
    pub fn set_position(&mut self, position: Position) {
        self.position = position;
    }

    /// How fast the antenna moves from now on, north, east and down, in millimetres a second.
    pub fn set_velocity(&mut self, velocity: [i32; 3]) {
        self.velocity = velocity;
    }

    /// The three sentences of the epoch at `now`.
    fn write_epoch(&self, now: VirtualNanos, into: &mut String) {
        let seconds = now.0 / NANOS_PER_SECOND;
        let hundredths = (now.0 / (NANOS_PER_SECOND / 100)) % 100;
        let (year, month, day) = calendar(seconds / SECONDS_PER_DAY);
        let in_day = seconds % SECONDS_PER_DAY;
        let (hours, minutes, seconds) = (in_day / 3600, in_day % 3600 / 60, in_day % 60);
        let [north, east, _] = self.velocity;
        let ground = (u64::from(north.unsigned_abs()).pow(2)
            + u64::from(east.unsigned_abs()).pow(2))
        .isqrt();
        let knots = hundredths_of(ground, HUNDREDTHS_OF_A_KNOT_PER_MILLIMETRE_SECOND);
        let kilometres =
            hundredths_of(ground, HUNDREDTHS_OF_A_KILOMETRE_HOUR_PER_MILLIMETRE_SECOND);
        let course = course(i64::from(north), i64::from(east));
        let altitude = self.position.altitude;
        let time = |into: &mut String| {
            put(
                into,
                format_args!("{hours:02}{minutes:02}{seconds:02}.{hundredths:02}"),
            );
        };
        let fix = |into: &mut String| {
            angle(into, self.position.latitude, 2, ['N', 'S']);
            into.push(',');
            angle(into, self.position.longitude, 3, ['E', 'W']);
        };
        sentence(into, |into| {
            into.push_str("GPGGA,");
            time(into);
            into.push(',');
            fix(into);
            put(
                into,
                format_args!(
                    ",1,{SATELLITES:02},{HDOP},{}{}.{:03},M,0.0,M,,",
                    if altitude < 0 { "-" } else { "" },
                    altitude.unsigned_abs() / 1000,
                    altitude.unsigned_abs() % 1000
                ),
            );
        });
        sentence(into, |into| {
            into.push_str("GPRMC,");
            time(into);
            into.push_str(",A,");
            fix(into);
            put(
                into,
                format_args!(
                    ",{}.{:02},{}.{:02},{day:02}{month:02}{:02},,,A",
                    knots / 100,
                    knots % 100,
                    course / 100,
                    course % 100,
                    year % 100
                ),
            );
        });
        sentence(into, |into| {
            put(
                into,
                format_args!(
                    "GPVTG,{}.{:02},T,,M,{}.{:02},N,{}.{:02},K,A",
                    course / 100,
                    course % 100,
                    knots / 100,
                    knots % 100,
                    kilometres / 100,
                    kilometres % 100
                ),
            );
        });
    }
}

impl Default for Receiver {
    fn default() -> Self {
        Self::new()
    }
}

impl Peer for Receiver {
    fn receive(&mut self, _word: u16, _context: &DeviceContext<'_>) {}

    fn transmit(&mut self, now: VirtualNanos) -> Option<u16> {
        if self.pending.is_empty() && now >= self.next {
            let due = VirtualNanos(now.0 - (now.0 - self.next.0) % PERIOD.0);
            let mut text = std::mem::take(&mut self.text);
            text.clear();
            self.write_epoch(due, &mut text);
            self.pending.extend(text.bytes());
            self.text = text;
            self.next = VirtualNanos(due.0 + PERIOD.0);
        }
        self.pending.pop_front().map(u16::from)
    }
}

fn put(into: &mut String, text: Arguments<'_>) {
    into.write_fmt(text)
        .expect("a string takes what is written");
}

/// `$`, the body, `*`, the checksum of the body, and the terminator.
fn sentence(into: &mut String, body: impl FnOnce(&mut String)) {
    into.push('$');
    let start = into.len();
    body(into);
    let checksum = into.as_bytes()[start..]
        .iter()
        .fold(0, |sum, byte| sum ^ byte);
    put(into, format_args!("*{checksum:02X}\r\n"));
}

/// An angle in ten-millionths of a degree as degrees, of `digits` places, minutes and five places of a
/// minute, and its hemisphere, `hemispheres` the positive then the negative.
fn angle(into: &mut String, tenths_of_a_microdegree: i32, digits: usize, hemispheres: [char; 2]) {
    let whole = u64::from(tenths_of_a_microdegree.unsigned_abs());
    let degrees = whole / MICRODEGREES_TENTHS;
    let minutes = (whole % MICRODEGREES_TENTHS) * 60 / 100;
    let hemisphere = hemispheres[usize::from(tenths_of_a_microdegree < 0)];
    put(
        into,
        format_args!(
            "{degrees:0digits$}{:02}.{:05},{hemisphere}",
            minutes / 100_000,
            minutes % 100_000
        ),
    );
}

/// Hundredths of the unit `ratio` names, from millimetres a second.
fn hundredths_of(millimetres_a_second: u64, ratio: (u64, u64)) -> u64 {
    millimetres_a_second * ratio.0 / ratio.1
}

/// The course from north, clockwise, in hundredths of a degree.
fn course(north: i64, east: i64) -> u32 {
    if north == 0 && east == 0 {
        return 0;
    }
    let (along, across) = (north.unsigned_abs(), east.unsigned_abs());
    let from_north = if across <= along {
        arctangent(across, along)
    } else {
        9_000 - arctangent(along, across)
    };
    let course = match (north >= 0, east >= 0) {
        (true, true) => from_north,
        (false, true) => 18_000 - from_north,
        (false, false) => 18_000 + from_north,
        (true, false) => 36_000 - from_north,
    };
    course % 36_000
}

/// The arctangent of `rise` over `run`, `rise` no more than `run`, in hundredths of a degree: the
/// polynomial `x(π/4) − x(x − 1)(0.2447 + 0.0663x)`, within a tenth of a degree over the octant.
fn arctangent(rise: u64, run: u64) -> u32 {
    let x = i64::try_from(rise).expect("a speed") * SCALE / i64::try_from(run).expect("a speed");
    let quarter_pi = 785_398;
    let microradians =
        quarter_pi * x / SCALE - x * (x - SCALE) / SCALE * (244_700 + 66_300 * x / SCALE) / SCALE;
    u32::try_from(microradians * CENTIDEGREES_PER_MICRORADIAN.0 / CENTIDEGREES_PER_MICRORADIAN.1)
        .expect("an angle within the octant")
}

/// The year, the month and the day `days` after the epoch.
fn calendar(days: u64) -> (u64, u64, u64) {
    let leap = |year: u64| {
        year.is_multiple_of(4) && (!year.is_multiple_of(100) || year.is_multiple_of(400))
    };
    let (mut year, mut left) = (EPOCH_YEAR, days);
    loop {
        let in_year = if leap(year) { 366 } else { 365 };
        if left < in_year {
            break;
        }
        left -= in_year;
        year += 1;
    }
    let lengths = [
        31,
        if leap(year) { 29 } else { 28 },
        31,
        30,
        31,
        30,
        31,
        31,
        30,
        31,
        30,
        31,
    ];
    let mut month = 0;
    while left >= lengths[month] {
        left -= lengths[month];
        month += 1;
    }
    (
        year,
        u64::try_from(month).expect("twelve months") + 1,
        left + 1,
    )
}

#[cfg(test)]
mod tests {
    use proptest::prelude::*;

    use super::*;

    const KANSAS: Position = Position {
        latitude: 398_283_000,
        longitude: -985_795_000,
        altitude: 500_000,
    };

    fn written(receiver: &Receiver, now: VirtualNanos) -> String {
        let mut text = String::new();
        receiver.write_epoch(now, &mut text);
        text
    }

    fn at_kansas() -> Receiver {
        let mut receiver = Receiver::new();
        receiver.set_position(KANSAS);
        receiver
    }

    fn lines(epoch: &str) -> Vec<&str> {
        epoch
            .split("\r\n")
            .filter(|line| !line.is_empty())
            .collect()
    }

    #[test]
    fn each_epoch_is_three_sentences_with_their_checksums() {
        let epoch = written(&at_kansas(), VirtualNanos(0));
        let lines = lines(&epoch);
        assert_eq!(lines.len(), 3);
        for (line, name) in lines.iter().zip(["$GPGGA", "$GPRMC", "$GPVTG"]) {
            assert!(line.starts_with(name), "{line}");
            let (body, checksum) = line
                .strip_prefix('$')
                .and_then(|rest| rest.split_once('*'))
                .expect("a checksum");
            let expected = body.bytes().fold(0, |sum, byte| sum ^ byte);
            assert_eq!(checksum, format!("{expected:02X}"), "{line}");
        }
        assert!(epoch.ends_with("\r\n"));
    }

    #[test]
    fn the_position_is_written_in_degrees_and_minutes_with_its_hemisphere() {
        let epoch = written(&at_kansas(), VirtualNanos(0));
        assert_eq!(
            lines(&epoch)[0],
            "$GPGGA,000000.00,3949.69800,N,09834.77000,W,1,10,0.90,500.000,M,0.0,M,,*75"
        );
        let mut southern = Receiver::new();
        southern.set_position(Position {
            latitude: -338_688_000,
            longitude: 1_512_093_000,
            altitude: -2_500,
        });
        assert!(
            lines(&written(&southern, VirtualNanos(0)))[0]
                .contains(",3352.12800,S,15112.55800,E,1,10,0.90,-2.500,M,")
        );
    }

    #[test]
    fn the_time_and_date_count_from_the_epoch() {
        let receiver = at_kansas();
        let rmc = |nanos| lines(&written(&receiver, VirtualNanos(nanos)))[1].to_owned();
        assert!(rmc(0).starts_with("$GPRMC,000000.00,A,"));
        assert!(rmc(0).contains(",010126,,,A*"));
        let later = 45 * SECONDS_PER_DAY * NANOS_PER_SECOND + 3_723_450_000_000;
        assert!(
            rmc(later).starts_with("$GPRMC,010203.45,A,"),
            "{}",
            rmc(later)
        );
        assert!(rmc(later).contains(",150226,,,A*"), "{}", rmc(later));
        let leap_day = (365 + 31 + 28) * SECONDS_PER_DAY * NANOS_PER_SECOND;
        assert!(
            rmc(leap_day).contains(",010327,,,A*"),
            "2027 is not a leap year, so the first of March"
        );
        let far = (365 * 2 + 31 + 28) * SECONDS_PER_DAY * NANOS_PER_SECOND;
        assert!(rmc(far).contains(",290228,,,A*"), "2028 is");
        let new_year = 365 * SECONDS_PER_DAY * NANOS_PER_SECOND;
        assert!(rmc(new_year).contains(",010127,,,A*"), "{}", rmc(new_year));
        let century = (74 * 365 + 18 + 31 + 28) * SECONDS_PER_DAY * NANOS_PER_SECOND;
        assert!(
            rmc(century).contains(",010300,,,A*"),
            "2100 is not a leap year: {}",
            rmc(century)
        );
    }

    #[test]
    fn the_speed_and_course_come_off_the_horizontal_velocity() {
        let mut receiver = at_kansas();
        for (velocity, knots, kilometres, course) in [
            ([0, 0, 0], "0.00", "0.00", 0),
            ([10_000, 0, -3_000], "19.43", "36.00", 0),
            ([0, 10_000, 0], "19.43", "36.00", 9_000),
            ([-3_000, -4_000, 0], "9.71", "18.00", 23_313),
            ([3_000, -3_000, 0], "8.24", "15.27", 31_500),
        ] {
            receiver.set_velocity(velocity);
            let epoch = written(&receiver, VirtualNanos(0));
            let rmc: Vec<&str> = lines(&epoch)[1].split(',').collect();
            let vtg: Vec<&str> = lines(&epoch)[2].split(',').collect();
            assert_eq!(rmc[7], knots, "{velocity:?}");
            assert_eq!(vtg[5], knots, "{velocity:?}");
            assert_eq!(vtg[7], kilometres, "{velocity:?}");
            let written = |field: &str| {
                let (whole, fraction) = field.split_once('.').expect("two places");
                whole.parse::<i64>().unwrap() * 100 + fraction.parse::<i64>().unwrap()
            };
            assert_eq!(rmc[8], vtg[1]);
            assert!(
                (written(rmc[8]) - course).abs() <= 10,
                "{velocity:?}: {} against {course}",
                rmc[8]
            );
        }
        assert!(course(1000, 1000).abs_diff(4500) <= 10);
        assert!(course(1000, 577).abs_diff(3000) <= 10, "thirty degrees");
        assert!(
            course(-577, 1000).abs_diff(12000) <= 10,
            "a hundred and twenty"
        );
    }

    #[test]
    fn sentences_come_at_the_rate_on_the_receivers_own_grid() {
        let mut receiver = at_kansas();
        let drain = |receiver: &mut Receiver, now| {
            std::iter::from_fn(|| {
                receiver
                    .transmit(now)
                    .map(|word| u8::try_from(word).expect("a byte"))
            })
            .take(4096)
            .collect::<Vec<u8>>()
        };
        let first = drain(&mut receiver, VirtualNanos(0));
        assert_eq!(first, written(&receiver, VirtualNanos(0)).into_bytes());
        assert!(drain(&mut receiver, VirtualNanos(199_999_999)).is_empty());
        let second = drain(&mut receiver, VirtualNanos(200_000_000));
        assert!(
            String::from_utf8(second)
                .unwrap()
                .starts_with("$GPGGA,000000.20,")
        );
        assert_eq!(
            receiver.transmit(VirtualNanos(400_000_000)),
            Some(u16::from(b'$'))
        );
        for (later, byte) in [(700_000_000, b'G'), (1_000_000_000, b'P')] {
            assert_eq!(
                receiver.transmit(VirtualNanos(later)),
                Some(u16::from(byte)),
                "the epoch in flight goes on"
            );
        }
        let rest = String::from_utf8(drain(&mut receiver, VirtualNanos(1_300_000_000))).unwrap();
        assert!(rest.starts_with("GGA,000000.40,"), "{rest}");
        assert!(
            rest.contains("$GPGGA,000001.20,")
                && !rest.contains("000001.30")
                && !rest.contains("000000.70")
                && !rest.contains("000001.00"),
            "the next epoch is written when the last has gone, once, stamped when it was due: {rest}"
        );
    }
    /// The standard's rule: the digits before the last two of the whole part are degrees, those two are
    /// minutes, and the fraction is a fraction of a minute.
    fn parsed(field: &str) -> i64 {
        let (whole, fraction) = field.split_once('.').expect("five places");
        let (degrees, minutes) = whole.split_at(whole.len() - 2);
        let fraction = fraction.parse::<i64>().expect("digits") * 10_000_000 / 100_000;
        degrees.parse::<i64>().expect("digits") * 10_000_000
            + minutes.parse::<i64>().expect("digits") * 10_000_000 / 60
            + fraction / 60
    }

    proptest! {
        #[test]
        fn an_angle_reads_back_to_within_a_ten_millionth_of_a_degree(
            tenths in -1_800_000_000_i32..=1_800_000_000,
        ) {
            let mut written = String::new();
            angle(&mut written, tenths, 3, ['E', 'W']);
            let (field, hemisphere) = written.split_once(',').expect("a hemisphere");
            let sign = if hemisphere == "W" { -1 } else { 1 };
            prop_assert!((sign * parsed(field) - i64::from(tenths)).abs() <= 2, "{written}");
        }

        #[test]
        #[expect(
            clippy::disallowed_methods,
            reason = "the oracle of a test, which nothing in a run computes"
        )]
        fn the_course_is_within_a_tenth_of_a_degree_of_the_arctangent(
            north in i32::MIN..=i32::MAX,
            east in i32::MIN..=i32::MAX,
        ) {
            let expected = f64::from(east).atan2(f64::from(north)).to_degrees().rem_euclid(360.0);
            let written = f64::from(course(i64::from(north), i64::from(east))) / 100.0;
            let apart = (written - expected).abs().min(360.0 - (written - expected).abs());
            prop_assert!(apart <= 0.1, "{north} {east}: {written} against {expected}");
        }
    }
}