use std::f64::consts::PI;
type Vector = [f64; 3];
fn sub(a: Vector, b: Vector) -> Vector {
[a[0] - b[0], a[1] - b[1], a[2] - b[2]]
}
fn norm(a: Vector) -> f64 {
(a[0] * a[0] + a[1] * a[1] + a[2] * a[2]).sqrt()
}
pub fn direction(inclination: f64, azimuth: f64) -> [f64; 3] {
let (i, a) = (inclination.to_radians(), azimuth.to_radians());
[i.sin() * a.cos(), i.sin() * a.sin(), i.cos()]
}
pub fn dogleg(t1: [f64; 3], t2: [f64; 3]) -> f64 {
2.0 * (norm(sub(t2, t1)) / 2.0).clamp(0.0, 1.0).asin()
}
pub fn ratio_factor(beta: f64) -> f64 {
if beta.abs() < 1e-6 {
1.0 + beta * beta / 12.0
} else {
2.0 / beta * (beta / 2.0).tan()
}
}
pub const VERTICAL: f64 = 1e-7;
const REVERSAL: f64 = 1e-9;
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Measured {
pub md: f64,
pub inclination: Option<f64>,
pub azimuth: Option<f64>,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Located {
pub md: f64,
pub north: f64,
pub east: f64,
pub tvd: f64,
}
#[derive(Debug, Clone, thiserror::Error, PartialEq)]
pub enum StationsError {
#[error("{given} station(s): a path is two stations at least")]
TooFewStations {
given: usize,
},
#[error("station {station}: its {what} is not a number ({given})")]
NotANumber {
station: usize,
what: &'static str,
given: f64,
},
#[error(
"station {station}: MD {md} does not come after MD {previous} - the stations of a survey run down the hole, in order"
)]
NotIncreasing {
station: usize,
md: f64,
previous: f64,
},
#[error("station {station}: no inclination - the direction of the hole there is not stated")]
NoInclination {
station: usize,
},
#[error(
"station {station}: no azimuth, and the hole is inclined {inclination}° there - which way it goes is not stated, and reading it as north would be a guess"
)]
NoAzimuth {
station: usize,
inclination: f64,
},
#[error(
"station {station}: an inclination is in [0°, 180°] - 0 down, 90 horizontal, 180 up - not {given}°"
)]
BadInclination {
station: usize,
given: f64,
},
#[error("station {station}: an azimuth is in [0°, 360°], not {given}°")]
BadAzimuth {
station: usize,
given: f64,
},
#[error(
"station {station}: the hole turns back on itself since the station before, a dogleg of 180° - no single arc does that, the plane it would turn in is undefined"
)]
Reversal {
station: usize,
},
}
fn direction_of(station: usize, measured: &Measured) -> Result<[f64; 3], StationsError> {
let inclination = measured
.inclination
.ok_or(StationsError::NoInclination { station })?;
if !inclination.is_finite() {
return Err(StationsError::NotANumber {
station,
what: "inclination",
given: inclination,
});
}
if !(0.0..=180.0).contains(&inclination) {
return Err(StationsError::BadInclination {
station,
given: inclination,
});
}
match measured.azimuth {
Some(azimuth) => {
if !azimuth.is_finite() {
return Err(StationsError::NotANumber {
station,
what: "azimuth",
given: azimuth,
});
}
if !(0.0..=360.0).contains(&azimuth) {
return Err(StationsError::BadAzimuth {
station,
given: azimuth,
});
}
Ok(direction(inclination, azimuth))
}
None if inclination.to_radians().sin().abs() <= VERTICAL => {
Ok([0.0, 0.0, if inclination <= 90.0 { 1.0 } else { -1.0 }])
}
None => Err(StationsError::NoAzimuth { station, inclination }),
}
}
pub fn positions(stations: &[Measured]) -> Result<Vec<Located>, StationsError> {
if stations.len() < 2 {
return Err(StationsError::TooFewStations {
given: stations.len(),
});
}
let mut directions = Vec::with_capacity(stations.len());
for (at, measured) in stations.iter().enumerate() {
let station = at + 1;
if !measured.md.is_finite() {
return Err(StationsError::NotANumber {
station,
what: "measured depth",
given: measured.md,
});
}
if at > 0 && measured.md <= stations[at - 1].md {
return Err(StationsError::NotIncreasing {
station,
md: measured.md,
previous: stations[at - 1].md,
});
}
directions.push(direction_of(station, measured)?);
}
let mut out = Vec::with_capacity(stations.len());
let mut at = [0.0f64; 3];
out.push(Located {
md: stations[0].md,
north: 0.0,
east: 0.0,
tvd: 0.0,
});
for k in 1..stations.len() {
let (t1, t2) = (directions[k - 1], directions[k]);
let beta = dogleg(t1, t2);
if PI - beta < REVERSAL {
return Err(StationsError::Reversal { station: k + 1 });
}
let scale = (stations[k].md - stations[k - 1].md) / 2.0 * ratio_factor(beta);
for axis in 0..3 {
at[axis] += scale * (t1[axis] + t2[axis]);
}
out.push(Located {
md: stations[k].md,
north: at[0],
east: at[1],
tvd: at[2],
});
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
fn near(a: f64, b: f64, tolerance: f64) -> bool {
(a - b).abs() <= tolerance
}
fn station(md: f64, inclination: f64, azimuth: f64) -> Measured {
Measured {
md,
inclination: Some(inclination),
azimuth: Some(azimuth),
}
}
#[test]
fn a_vertical_hole_is_as_deep_as_it_is_long() {
let stations: Vec<Measured> = (0..=10)
.map(|k| Measured {
md: 10.0 * k as f64,
inclination: Some(0.0),
azimuth: None,
})
.collect();
let placed = positions(&stations).unwrap();
for (one, measured) in placed.iter().zip(&stations) {
assert_eq!((one.north, one.east), (0.0, 0.0), "{one:?}");
assert!(
near(one.tvd, measured.md, 1e-12),
"TVD = MD on a vertical hole: {one:?}"
);
}
}
#[test]
fn a_ninety_degree_build_ends_one_radius_north_and_one_radius_down() {
let placed = positions(&[station(0.0, 0.0, 0.0), station(900.0, 90.0, 0.0)]).unwrap();
let end = placed[1];
assert!(near(end.north, 572.957_795_1, 1e-6), "{end:?}");
assert!(near(end.tvd, 572.957_795_1, 1e-6), "{end:?}");
assert!(near(end.east, 0.0, 1e-12), "{end:?}");
}
#[test]
fn a_constant_build_lands_on_the_circle_at_every_station() {
let r = 30.0 / 3f64.to_radians();
let stations: Vec<Measured> = (0..=30)
.map(|k| station(30.0 * k as f64, 3.0 * k as f64, 90.0))
.collect();
let placed = positions(&stations).unwrap();
for one in &placed {
let theta = (one.md / r).min(std::f64::consts::FRAC_PI_2);
assert!(near(one.east, r * (1.0 - theta.cos()), 1e-9), "{one:?}");
assert!(near(one.tvd, r * theta.sin(), 1e-9), "{one:?}");
assert!(
near(one.north, 0.0, 1e-9),
"an azimuth of 90 is east, not north: {one:?}"
);
}
let at_300 = placed[10];
assert_eq!(at_300.md, 300.0);
assert!(near(at_300.east, 76.761_789_3, 1e-6), "{at_300:?}");
assert!(near(at_300.tvd, 286.478_897_6, 1e-6), "{at_300:?}");
}
#[test]
fn azimuth_runs_clockwise_from_north() {
for (azimuth, north, east) in [
(0.0, 1.0, 0.0),
(90.0, 0.0, 1.0),
(180.0, -1.0, 0.0),
(270.0, 0.0, -1.0),
] {
let placed = positions(&[station(0.0, 90.0, azimuth), station(10.0, 90.0, azimuth)]).unwrap();
let end = placed[1];
assert!(
near(end.north, 10.0 * north, 1e-12) && near(end.east, 10.0 * east, 1e-12),
"{azimuth}: {end:?}"
);
assert!(near(end.tvd, 0.0, 1e-12), "{end:?}");
}
}
#[test]
fn what_is_not_a_survey_is_refused_by_name() {
let vertical = |md| Measured {
md,
inclination: Some(0.0),
azimuth: None,
};
assert_eq!(
positions(&[vertical(0.0)]),
Err(StationsError::TooFewStations { given: 1 })
);
assert_eq!(
positions(&[vertical(0.0), vertical(10.0), vertical(10.0)]),
Err(StationsError::NotIncreasing {
station: 3,
md: 10.0,
previous: 10.0
})
);
assert_eq!(
positions(&[
vertical(0.0),
Measured {
md: 10.0,
inclination: None,
azimuth: Some(0.0)
}
]),
Err(StationsError::NoInclination { station: 2 })
);
assert_eq!(
positions(&[
vertical(0.0),
Measured {
md: 10.0,
inclination: Some(5.0),
azimuth: None
}
]),
Err(StationsError::NoAzimuth {
station: 2,
inclination: 5.0
})
);
assert_eq!(
positions(&[vertical(0.0), station(10.0, 190.0, 0.0)]),
Err(StationsError::BadInclination {
station: 2,
given: 190.0
})
);
assert_eq!(
positions(&[vertical(0.0), station(10.0, 10.0, -5.0)]),
Err(StationsError::BadAzimuth {
station: 2,
given: -5.0
})
);
assert!(matches!(
positions(&[vertical(0.0), vertical(f64::NAN)]),
Err(StationsError::NotANumber {
station: 2,
what: "measured depth",
..
})
));
assert_eq!(
positions(&[station(0.0, 90.0, 0.0), station(10.0, 90.0, 180.0)]),
Err(StationsError::Reversal { station: 2 })
);
let up = positions(&[
station(0.0, 90.0, 0.0),
Measured {
md: 10.0,
inclination: Some(180.0),
azimuth: None,
},
])
.unwrap();
assert!(up[1].tvd < 0.0 && up[1].north > 0.0, "{:?}", up[1]);
}
}