1use std::collections::BTreeSet;
10
11use crate::astro::math::linear::{
12 dot4, invert_4x4_cofactor, mat4_vec4, normal_matrix_4_unweighted_row_outer,
13};
14
15use crate::constants::{C_M_S, F_L1_HZ};
16use crate::id::GnssSatelliteId;
17use crate::observables::{predict, ObservableEphemerisSource, PredictOptions};
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum VelocityObservable {
22 RangeRate,
24 Doppler,
27}
28
29#[derive(Debug, Clone, Copy, PartialEq)]
31pub struct VelocityObservation {
32 pub satellite_id: GnssSatelliteId,
34 pub value: f64,
37 pub carrier_hz: f64,
39 pub sat_clock_drift_s_s: f64,
41}
42
43#[derive(Debug, Clone, Copy, PartialEq)]
45pub struct VelocitySolveOptions {
46 pub observable: VelocityObservable,
48 pub light_time: bool,
50 pub sagnac: bool,
52}
53
54impl Default for VelocitySolveOptions {
55 fn default() -> Self {
56 Self {
57 observable: VelocityObservable::RangeRate,
58 light_time: true,
59 sagnac: true,
60 }
61 }
62}
63
64#[derive(Debug, Clone, PartialEq)]
66pub struct VelocitySolution {
67 pub velocity_m_s: [f64; 3],
69 pub speed_m_s: f64,
71 pub clock_drift_s_s: f64,
73 pub residuals_m_s: Vec<(GnssSatelliteId, f64)>,
75 pub used_sats: Vec<GnssSatelliteId>,
78}
79
80#[derive(Debug, Clone, Copy, PartialEq, Eq)]
82pub enum VelocityError {
83 NoObservations,
85 TooFewSatellites { used: usize, required: usize },
87 SingularGeometry,
89 DuplicateObservation { satellite_id: GnssSatelliteId },
91 InvalidCarrier { satellite_id: GnssSatelliteId },
93 InvalidInput {
95 field: &'static str,
96 reason: &'static str,
97 },
98 InvalidObservation { satellite_id: GnssSatelliteId },
100 InvalidReceiverState,
102}
103
104impl core::fmt::Display for VelocityError {
105 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
106 match self {
107 Self::NoObservations => write!(f, "no observations"),
108 Self::TooFewSatellites { used, required } => {
109 write!(f, "too few satellites: {used}, required {required}")
110 }
111 Self::SingularGeometry => write!(f, "singular geometry"),
112 Self::DuplicateObservation { satellite_id } => {
113 write!(f, "duplicate observation for {satellite_id}")
114 }
115 Self::InvalidCarrier { satellite_id } => {
116 write!(f, "invalid carrier for {satellite_id}")
117 }
118 Self::InvalidInput { field, reason } => {
119 write!(f, "invalid velocity input {field}: {reason}")
120 }
121 Self::InvalidObservation { satellite_id } => {
122 write!(f, "invalid observation for {satellite_id}")
123 }
124 Self::InvalidReceiverState => write!(f, "invalid receiver state"),
125 }
126 }
127}
128
129impl std::error::Error for VelocityError {}
130
131#[derive(Debug, Clone, Copy)]
132struct Row {
133 sat: GnssSatelliteId,
134 h: [f64; 4],
135 y: f64,
136}
137
138pub fn doppler_to_range_rate(doppler_hz: f64, carrier_hz: f64) -> Result<f64, VelocityError> {
140 let doppler_hz = velocity_finite(doppler_hz, "doppler_hz")?;
141 let carrier_hz = velocity_positive(carrier_hz, "carrier_hz")?;
142 velocity_finite_output(-doppler_hz * C_M_S / carrier_hz, "range_rate_m_s")
143}
144
145pub fn range_rate_to_doppler(range_rate_m_s: f64, carrier_hz: f64) -> Result<f64, VelocityError> {
147 let range_rate_m_s = velocity_finite(range_rate_m_s, "range_rate_m_s")?;
148 let carrier_hz = velocity_positive(carrier_hz, "carrier_hz")?;
149 velocity_finite_output(-range_rate_m_s * carrier_hz / C_M_S, "doppler_hz")
150}
151
152pub fn solve(
154 source: &dyn ObservableEphemerisSource,
155 observations: &[VelocityObservation],
156 receiver_ecef_m: [f64; 3],
157 t_rx_j2000_s: f64,
158 options: VelocitySolveOptions,
159) -> Result<VelocitySolution, VelocityError> {
160 if observations.is_empty() {
161 return Err(VelocityError::NoObservations);
162 }
163
164 validate_receiver_state(receiver_ecef_m, t_rx_j2000_s)?;
165 ensure_no_duplicates(observations)?;
166 validate_observations(observations)?;
167 let rows = build_rows(source, observations, receiver_ecef_m, t_rx_j2000_s, options)?;
168 if rows.len() < 4 {
169 return Err(VelocityError::TooFewSatellites {
170 used: rows.len(),
171 required: 4,
172 });
173 }
174
175 let x = solve_normal_equations(&rows)?;
176 assemble_solution(x, &rows)
177}
178
179fn validate_receiver_state(
180 receiver_ecef_m: [f64; 3],
181 t_rx_j2000_s: f64,
182) -> Result<(), VelocityError> {
183 if receiver_ecef_m.iter().all(|value| value.is_finite()) && t_rx_j2000_s.is_finite() {
184 Ok(())
185 } else {
186 Err(VelocityError::InvalidReceiverState)
187 }
188}
189
190fn ensure_no_duplicates(observations: &[VelocityObservation]) -> Result<(), VelocityError> {
191 let mut seen = BTreeSet::new();
192 for obs in observations {
193 if !seen.insert(obs.satellite_id) {
194 return Err(VelocityError::DuplicateObservation {
195 satellite_id: obs.satellite_id,
196 });
197 }
198 }
199 Ok(())
200}
201
202fn validate_observations(observations: &[VelocityObservation]) -> Result<(), VelocityError> {
203 for obs in observations {
204 if !(obs.value.is_finite() && obs.sat_clock_drift_s_s.is_finite()) {
205 return Err(VelocityError::InvalidObservation {
206 satellite_id: obs.satellite_id,
207 });
208 }
209 }
210 Ok(())
211}
212
213fn velocity_finite(x: f64, field: &'static str) -> Result<f64, VelocityError> {
214 if x.is_finite() {
215 Ok(x)
216 } else {
217 Err(VelocityError::InvalidInput {
218 field,
219 reason: "not finite",
220 })
221 }
222}
223
224fn velocity_positive(x: f64, field: &'static str) -> Result<f64, VelocityError> {
225 let x = velocity_finite(x, field)?;
226 if x > 0.0 {
227 Ok(x)
228 } else {
229 Err(VelocityError::InvalidInput {
230 field,
231 reason: "not positive",
232 })
233 }
234}
235
236fn velocity_finite_output(value: f64, field: &'static str) -> Result<f64, VelocityError> {
237 if value.is_finite() {
238 Ok(value)
239 } else {
240 Err(VelocityError::InvalidInput {
241 field,
242 reason: "out of range",
243 })
244 }
245}
246
247fn build_rows(
248 source: &dyn ObservableEphemerisSource,
249 observations: &[VelocityObservation],
250 receiver_ecef_m: [f64; 3],
251 t_rx_j2000_s: f64,
252 options: VelocitySolveOptions,
253) -> Result<Vec<Row>, VelocityError> {
254 let predict_options = PredictOptions {
255 carrier_hz: F_L1_HZ,
256 light_time: options.light_time,
257 sagnac: options.sagnac,
258 };
259 let mut rows = Vec::with_capacity(observations.len());
260
261 for obs in observations {
262 let rho_dot_m_s = match options.observable {
263 VelocityObservable::RangeRate => obs.value,
264 VelocityObservable::Doppler => {
265 if !(obs.carrier_hz.is_finite() && obs.carrier_hz > 0.0) {
266 return Err(VelocityError::InvalidCarrier {
267 satellite_id: obs.satellite_id,
268 });
269 }
270 doppler_to_range_rate(obs.value, obs.carrier_hz).map_err(|error| match error {
271 VelocityError::InvalidInput {
272 field: "carrier_hz",
273 ..
274 } => VelocityError::InvalidCarrier {
275 satellite_id: obs.satellite_id,
276 },
277 _ => VelocityError::InvalidObservation {
278 satellite_id: obs.satellite_id,
279 },
280 })?
281 }
282 };
283
284 let Ok(predicted) = predict(
285 source,
286 obs.satellite_id,
287 receiver_ecef_m,
288 t_rx_j2000_s,
289 predict_options,
290 ) else {
291 continue;
292 };
293
294 let [ex, ey, ez] = predicted.los_unit;
295 let y = rho_dot_m_s - predicted.range_rate_m_s + C_M_S * obs.sat_clock_drift_s_s;
296 if ![ex, ey, ez, predicted.range_rate_m_s, y]
297 .iter()
298 .all(|value| value.is_finite())
299 {
300 return Err(VelocityError::InvalidInput {
301 field: "velocity row",
302 reason: "out of range",
303 });
304 }
305 rows.push(Row {
306 sat: obs.satellite_id,
307 h: [-ex, -ey, -ez, 1.0],
308 y,
309 });
310 }
311
312 Ok(rows)
313}
314
315#[allow(clippy::needless_range_loop)] fn solve_normal_equations(rows: &[Row]) -> Result<[f64; 4], VelocityError> {
317 let mut aty = [0.0_f64; 4];
318
319 for row in rows {
320 for i in 0..4 {
321 aty[i] += row.h[i] * row.y;
322 }
323 }
324 let row_h: Vec<[f64; 4]> = rows.iter().map(|row| row.h).collect();
325 let ata = normal_matrix_4_unweighted_row_outer(&row_h);
326
327 let inv = invert_4x4_cofactor(&ata).ok_or(VelocityError::SingularGeometry)?;
328 let solution = mat4_vec4(&inv, &aty);
329 if solution.iter().all(|value| value.is_finite()) {
330 Ok(solution)
331 } else {
332 Err(VelocityError::InvalidInput {
333 field: "velocity solution",
334 reason: "out of range",
335 })
336 }
337}
338
339fn assemble_solution(x: [f64; 4], rows: &[Row]) -> Result<VelocitySolution, VelocityError> {
340 let velocity_m_s = [x[0], x[1], x[2]];
341 let speed_m_s = (x[0] * x[0] + x[1] * x[1] + x[2] * x[2]).sqrt();
342 let clock_drift_s_s = x[3] / C_M_S;
343 let residuals_m_s: Vec<_> = rows
344 .iter()
345 .map(|row| (row.sat, row.y - hx(&row.h, &x)))
346 .collect();
347 if !velocity_m_s.iter().all(|value| value.is_finite())
348 || !speed_m_s.is_finite()
349 || !clock_drift_s_s.is_finite()
350 || !residuals_m_s
351 .iter()
352 .all(|(_, residual)| residual.is_finite())
353 {
354 return Err(VelocityError::InvalidInput {
355 field: "velocity solution",
356 reason: "out of range",
357 });
358 }
359 let used_sats = rows.iter().map(|row| row.sat).collect();
360 Ok(VelocitySolution {
361 velocity_m_s,
362 speed_m_s,
363 clock_drift_s_s,
364 residuals_m_s,
365 used_sats,
366 })
367}
368
369fn hx(h: &[f64; 4], x: &[f64; 4]) -> f64 {
370 dot4(h, x)
371}
372
373#[cfg(all(test, sidereon_repo_tests))]
374mod tests {
375 use super::*;
376 use crate::ephemeris::Sp3;
377 use crate::observables::{
378 j2000_seconds_from_split, predict, ObservableState, ObservablesError,
379 };
380 use crate::{GnssSatelliteId, GnssSystem};
381
382 const T_RX_J2000_S: f64 = 646_272_000.0;
383 const RECEIVER: [f64; 3] = [4_500_000.0, 500_000.0, 4_500_000.0];
384 const V_TRUE: [f64; 3] = [12.0, -7.0, 3.0];
385 const DRIFT_TRUE: f64 = 1.0e-9;
386
387 fn sp3_fixture() -> Sp3 {
388 let path = concat!(
389 env!("CARGO_MANIFEST_DIR"),
390 "/tests/fixtures/sp3/GRG0MGXFIN_20201760000_01D_15M_ORB.SP3"
391 );
392 let bytes = std::fs::read(path).unwrap_or_else(|e| panic!("read SP3 fixture {path}: {e}"));
393 Sp3::parse(&bytes).expect("parse SP3 fixture")
394 }
395
396 fn visible_gps(sp3: &Sp3) -> Vec<GnssSatelliteId> {
397 let planning = PredictOptions {
398 light_time: false,
399 ..PredictOptions::default()
400 };
401 sp3.satellites()
402 .iter()
403 .copied()
404 .filter(|sat| sat.system == GnssSystem::Gps)
405 .filter(|sat| {
406 predict(sp3, *sat, RECEIVER, T_RX_J2000_S, planning)
407 .map(|obs| obs.elevation_deg >= 5.0)
408 .unwrap_or(false)
409 })
410 .collect()
411 }
412
413 fn synth_range_rate(sp3: &Sp3, sat: GnssSatelliteId, v_true: [f64; 3], drift: f64) -> f64 {
414 let obs = predict(sp3, sat, RECEIVER, T_RX_J2000_S, PredictOptions::default())
415 .expect("predict synthetic observation");
416 let e_dot_vtrue =
417 obs.los_unit[0] * v_true[0] + obs.los_unit[1] * v_true[1] + obs.los_unit[2] * v_true[2];
418 obs.range_rate_m_s - e_dot_vtrue + C_M_S * drift
419 }
420
421 fn synth_observations(sp3: &Sp3, sats: &[GnssSatelliteId]) -> Vec<VelocityObservation> {
422 sats.iter()
423 .map(|&sat| VelocityObservation {
424 satellite_id: sat,
425 value: synth_range_rate(sp3, sat, V_TRUE, DRIFT_TRUE),
426 carrier_hz: F_L1_HZ,
427 sat_clock_drift_s_s: 0.0,
428 })
429 .collect()
430 }
431
432 #[derive(Debug, Clone, Copy)]
433 struct StaticVelocitySource {
434 state: ObservableState,
435 }
436
437 impl ObservableEphemerisSource for StaticVelocitySource {
438 fn observable_state_at_j2000_s(
439 &self,
440 _sat: GnssSatelliteId,
441 _t_j2000_s: f64,
442 ) -> Result<ObservableState, ObservablesError> {
443 Ok(self.state)
444 }
445 }
446
447 fn static_velocity_source(position_ecef_m: [f64; 3]) -> StaticVelocitySource {
448 StaticVelocitySource {
449 state: ObservableState {
450 position_ecef_m,
451 clock_s: Some(0.0),
452 },
453 }
454 }
455
456 #[test]
457 fn split_epoch_constant_matches_orbis_velocity_fixture() {
458 assert_eq!(
459 j2000_seconds_from_split(2_459_024.5, 0.5).expect("valid split Julian date"),
460 T_RX_J2000_S
461 );
462 }
463
464 #[test]
465 fn range_rate_solve_has_frozen_bits_golden() {
466 let sp3 = sp3_fixture();
467 let sats = visible_gps(&sp3);
468 assert!(sats.len() >= 4);
469 let observations = synth_observations(&sp3, &sats);
470
471 let solution = solve(
472 &sp3,
473 &observations,
474 RECEIVER,
475 T_RX_J2000_S,
476 VelocitySolveOptions::default(),
477 )
478 .expect("solve velocity");
479
480 assert_eq!(
481 solution.velocity_m_s.map(f64::to_bits),
482 [0x4028000000000000, 0xc01c000000000016, 0x4007ffffffffff00]
483 );
484 assert_eq!(solution.speed_m_s.to_bits(), 0x402c6ce322982a37);
485 assert_eq!(solution.clock_drift_s_s.to_bits(), 0x3e112e0be826d2ee);
486 assert_eq!(
487 solution
488 .used_sats
489 .iter()
490 .map(ToString::to_string)
491 .collect::<Vec<_>>(),
492 ["G07", "G08", "G10", "G16", "G18", "G20", "G21", "G26", "G27"]
493 );
494 assert_eq!(
495 solution
496 .residuals_m_s
497 .iter()
498 .map(|(_, residual)| residual.to_bits())
499 .collect::<Vec<_>>(),
500 [
501 0xbd01000000000000,
502 0xbd24000000000000,
503 0x3cfc000000000000,
504 0xbd16000000000000,
505 0xbd1a800000000000,
506 0x3cf0000000000000,
507 0xbd14000000000000,
508 0x3d31800000000000,
509 0x3d18000000000000,
510 ]
511 );
512 }
513
514 #[test]
515 fn doppler_path_has_frozen_bits_with_per_sat_carriers() {
516 let sp3 = sp3_fixture();
517 let sats = visible_gps(&sp3);
518 let range_rate_observations = synth_observations(&sp3, &sats);
519 let doppler_observations: Vec<_> = range_rate_observations
520 .iter()
521 .enumerate()
522 .map(|(idx, obs)| {
523 let k = (idx % 14) as i8 - 7;
524 let carrier_hz =
525 crate::frequencies::rinex_band_frequency_hz(GnssSystem::Glonass, '1', Some(k))
526 .expect("canonical GLONASS G1 channel carrier exists");
527 VelocityObservation {
528 value: range_rate_to_doppler(obs.value, carrier_hz)
529 .expect("valid range-rate conversion"),
530 carrier_hz,
531 ..*obs
532 }
533 })
534 .collect();
535
536 let range_rate = solve(
537 &sp3,
538 &range_rate_observations,
539 RECEIVER,
540 T_RX_J2000_S,
541 VelocitySolveOptions::default(),
542 )
543 .expect("range-rate solve");
544 let doppler = solve(
545 &sp3,
546 &doppler_observations,
547 RECEIVER,
548 T_RX_J2000_S,
549 VelocitySolveOptions {
550 observable: VelocityObservable::Doppler,
551 ..VelocitySolveOptions::default()
552 },
553 )
554 .expect("doppler solve");
555
556 assert_eq!(
557 range_rate.velocity_m_s.map(f64::to_bits),
558 [0x4028000000000000, 0xc01c000000000016, 0x4007ffffffffff00]
559 );
560 assert_eq!(
561 doppler.velocity_m_s.map(f64::to_bits),
562 [0x402800000000000c, 0xc01c00000000000f, 0x4007ffffffffff60]
563 );
564 assert_eq!(doppler.speed_m_s.to_bits(), 0x402c6ce322982a44);
565 assert_eq!(doppler.clock_drift_s_s.to_bits(), 0x3e112e0be826d4b8);
566 assert_eq!(
567 doppler
568 .residuals_m_s
569 .iter()
570 .map(|(_, residual)| residual.to_bits())
571 .collect::<Vec<_>>(),
572 [
573 0x3d24c00000000000,
574 0xbd2b000000000000,
575 0xbd00000000000000,
576 0xbd00000000000000,
577 0xbd0b000000000000,
578 0x3d06000000000000,
579 0x0000000000000000,
580 0x3d40c00000000000,
581 0x3d22000000000000,
582 ]
583 );
584 }
585
586 #[test]
587 fn validates_core_error_cases() {
588 let sp3 = sp3_fixture();
589 let sats = visible_gps(&sp3);
590 let mut observations = synth_observations(&sp3, &sats);
591 let first = observations[0].satellite_id;
592
593 assert_eq!(
594 solve(
595 &sp3,
596 &[],
597 RECEIVER,
598 T_RX_J2000_S,
599 VelocitySolveOptions::default()
600 ),
601 Err(VelocityError::NoObservations)
602 );
603
604 assert_eq!(
605 solve(
606 &sp3,
607 &observations[..3],
608 RECEIVER,
609 T_RX_J2000_S,
610 VelocitySolveOptions::default()
611 ),
612 Err(VelocityError::TooFewSatellites {
613 used: 3,
614 required: 4
615 })
616 );
617
618 observations[1].satellite_id = first;
619 assert_eq!(
620 solve(
621 &sp3,
622 &observations,
623 RECEIVER,
624 T_RX_J2000_S,
625 VelocitySolveOptions::default()
626 ),
627 Err(VelocityError::DuplicateObservation {
628 satellite_id: first
629 })
630 );
631
632 let invalid_carrier = [VelocityObservation {
633 satellite_id: first,
634 value: 1.0,
635 carrier_hz: -1.0,
636 sat_clock_drift_s_s: 0.0,
637 }];
638 assert_eq!(
639 solve(
640 &sp3,
641 &invalid_carrier,
642 RECEIVER,
643 T_RX_J2000_S,
644 VelocitySolveOptions {
645 observable: VelocityObservable::Doppler,
646 ..VelocitySolveOptions::default()
647 }
648 ),
649 Err(VelocityError::InvalidCarrier {
650 satellite_id: first
651 })
652 );
653 }
654
655 #[test]
656 fn rejects_non_finite_velocity_inputs() {
657 let sp3 = sp3_fixture();
658 let sats = visible_gps(&sp3);
659 let mut observations = synth_observations(&sp3, &sats);
660 let first = observations[0].satellite_id;
661
662 observations[0].value = f64::NAN;
663 assert_eq!(
664 solve(
665 &sp3,
666 &observations,
667 RECEIVER,
668 T_RX_J2000_S,
669 VelocitySolveOptions::default()
670 ),
671 Err(VelocityError::InvalidObservation {
672 satellite_id: first
673 })
674 );
675
676 observations[0].value = 0.0;
677 observations[0].sat_clock_drift_s_s = f64::NAN;
678 assert_eq!(
679 solve(
680 &sp3,
681 &observations,
682 RECEIVER,
683 T_RX_J2000_S,
684 VelocitySolveOptions::default()
685 ),
686 Err(VelocityError::InvalidObservation {
687 satellite_id: first
688 })
689 );
690
691 observations[0].sat_clock_drift_s_s = 0.0;
692 let mut bad_receiver = RECEIVER;
693 bad_receiver[0] = f64::NAN;
694 assert_eq!(
695 solve(
696 &sp3,
697 &observations,
698 bad_receiver,
699 T_RX_J2000_S,
700 VelocitySolveOptions::default()
701 ),
702 Err(VelocityError::InvalidReceiverState)
703 );
704
705 assert_eq!(
706 solve(
707 &sp3,
708 &observations,
709 RECEIVER,
710 f64::NAN,
711 VelocitySolveOptions::default()
712 ),
713 Err(VelocityError::InvalidReceiverState)
714 );
715 }
716
717 #[test]
718 fn conversion_helpers_reject_invalid_domains() {
719 assert_eq!(
720 doppler_to_range_rate(f64::NAN, F_L1_HZ),
721 Err(VelocityError::InvalidInput {
722 field: "doppler_hz",
723 reason: "not finite"
724 })
725 );
726 assert_eq!(
727 range_rate_to_doppler(f64::INFINITY, F_L1_HZ),
728 Err(VelocityError::InvalidInput {
729 field: "range_rate_m_s",
730 reason: "not finite"
731 })
732 );
733
734 for carrier_hz in [f64::NAN, f64::INFINITY] {
735 assert_eq!(
736 doppler_to_range_rate(1.0, carrier_hz),
737 Err(VelocityError::InvalidInput {
738 field: "carrier_hz",
739 reason: "not finite"
740 })
741 );
742 assert_eq!(
743 range_rate_to_doppler(1.0, carrier_hz),
744 Err(VelocityError::InvalidInput {
745 field: "carrier_hz",
746 reason: "not finite"
747 })
748 );
749 }
750
751 for carrier_hz in [0.0, -1.0] {
752 assert_eq!(
753 doppler_to_range_rate(1.0, carrier_hz),
754 Err(VelocityError::InvalidInput {
755 field: "carrier_hz",
756 reason: "not positive"
757 })
758 );
759 assert_eq!(
760 range_rate_to_doppler(1.0, carrier_hz),
761 Err(VelocityError::InvalidInput {
762 field: "carrier_hz",
763 reason: "not positive"
764 })
765 );
766 }
767 }
768
769 #[test]
770 fn solve_rejects_non_finite_internal_rows() {
771 let source = static_velocity_source([20_200_000.0, 14_000_000.0, 21_700_000.0]);
772 let observations: Vec<_> = (1..=4)
773 .map(|prn| VelocityObservation {
774 satellite_id: GnssSatelliteId::new(GnssSystem::Gps, prn).expect("valid sat"),
775 value: 0.0,
776 carrier_hz: F_L1_HZ,
777 sat_clock_drift_s_s: f64::MAX,
778 })
779 .collect();
780
781 assert_eq!(
782 solve(
783 &source,
784 &observations,
785 [0.0, 0.0, 0.0],
786 646_272_000.0,
787 VelocitySolveOptions {
788 light_time: false,
789 sagnac: false,
790 ..VelocitySolveOptions::default()
791 }
792 ),
793 Err(VelocityError::InvalidInput {
794 field: "velocity row",
795 reason: "out of range",
796 })
797 );
798 }
799}