use crate::environment::EnvironmentSample;
use crate::error::{ensure_range, KernelError, NavigationError, Result};
use crate::event::{ClearanceEvent, EventList};
use crate::math;
use crate::time::{Instant, Utc};
use crate::units::{Distance, Speed};
#[derive(Debug, Clone, Copy, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(try_from = "StoredHull", into = "StoredHull"))]
pub struct Hull {
draught: Distance,
block_coefficient: f64,
}
impl Hull {
pub fn new(draught: Distance, block_coefficient: f64) -> Result<Self> {
ensure_range("draught", draught.metres(), f64::MIN_POSITIVE, f64::MAX)?;
ensure_range(
"block coefficient",
block_coefficient,
f64::MIN_POSITIVE,
1.0,
)?;
Ok(Self {
draught,
block_coefficient,
})
}
#[must_use]
pub const fn draught(&self) -> Distance {
self.draught
}
#[must_use]
pub const fn block_coefficient(&self) -> f64 {
self.block_coefficient
}
}
#[cfg(feature = "serde")]
#[derive(serde::Serialize, serde::Deserialize)]
struct StoredHull {
draught: Distance,
block_coefficient: f64,
}
#[cfg(feature = "serde")]
impl TryFrom<StoredHull> for Hull {
type Error = NavigationError;
fn try_from(stored: StoredHull) -> Result<Self> {
Self::new(stored.draught, stored.block_coefficient)
}
}
#[cfg(feature = "serde")]
impl From<Hull> for StoredHull {
fn from(hull: Hull) -> Self {
Self {
draught: hull.draught,
block_coefficient: hull.block_coefficient,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
#[non_exhaustive]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum Waterway {
OpenWater,
ConfinedChannel,
Blockage(f64),
}
#[derive(Debug, Clone, Copy, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct ClearancePolicy {
pub minimum: Distance,
pub fraction_of_draught: f64,
}
impl ClearancePolicy {
pub fn required(&self, hull: &Hull) -> Result<Distance> {
ensure_range("minimum clearance", self.minimum.metres(), 0.0, f64::MAX)?;
ensure_range(
"fraction of draught",
self.fraction_of_draught,
0.0,
f64::MAX,
)?;
let by_draught = hull.draught.metres() * self.fraction_of_draught;
Ok(Distance::from_metres(
self.minimum.metres().max(by_draught),
)?)
}
}
pub fn squat(hull: &Hull, speed: Speed, waterway: Waterway) -> Result<Distance> {
let v = math::abs(speed.knots());
let cb = hull.block_coefficient;
let metres = match waterway {
Waterway::OpenWater => cb * v * v / 100.0,
Waterway::ConfinedChannel => cb * v * v / 50.0,
Waterway::Blockage(factor) => {
ensure_range("blockage factor", factor, f64::MIN_POSITIVE, 1.0)?;
cb * power(factor, 0.81) * power(v, 2.08) / 20.0
}
};
Ok(Distance::from_metres(metres)?)
}
#[derive(Debug, Clone, Copy, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Clearance {
at: Instant<Utc>,
charted_depth: Distance,
height_of_tide: Distance,
draught: Distance,
squat: Distance,
required: Distance,
}
impl Clearance {
#[must_use]
pub const fn at(&self) -> Instant<Utc> {
self.at
}
#[must_use]
pub const fn charted_depth(&self) -> Distance {
self.charted_depth
}
#[must_use]
pub const fn height_of_tide(&self) -> Distance {
self.height_of_tide
}
#[must_use]
pub fn depth_of_water(&self) -> Distance {
self.charted_depth + self.height_of_tide
}
#[must_use]
pub const fn draught(&self) -> Distance {
self.draught
}
#[must_use]
pub const fn squat(&self) -> Distance {
self.squat
}
#[must_use]
pub fn dynamic_draught(&self) -> Distance {
self.draught + self.squat
}
#[must_use]
pub fn clearance(&self) -> Distance {
self.depth_of_water() - self.dynamic_draught()
}
#[must_use]
pub const fn required(&self) -> Distance {
self.required
}
#[must_use]
pub fn margin(&self) -> Distance {
self.clearance() - self.required
}
#[must_use]
pub fn is_sufficient(&self) -> bool {
!self.margin().is_negative()
}
}
pub fn under_keel_clearance(
hull: &Hull,
speed: Speed,
waterway: Waterway,
charted_depth: Distance,
env: &EnvironmentSample,
policy: &ClearancePolicy,
) -> Result<(Clearance, EventList<ClearanceEvent>)> {
let height_of_tide = env
.tide()
.ok_or(NavigationError::Kernel(KernelError::Missing {
what: "the height of tide",
}))?;
let clearance = Clearance {
at: env.when(),
charted_depth,
height_of_tide,
draught: hull.draught,
squat: squat(hull, speed, waterway)?,
required: policy.required(hull)?,
};
let mut events = EventList::new();
if !clearance.is_sufficient() {
events.push(ClearanceEvent::UnderKeelClearanceLow {
clearance: clearance.clearance(),
required: clearance.required,
at: clearance.at,
});
}
Ok((clearance, events))
}
pub fn height_of_tide_needed(
hull: &Hull,
speed: Speed,
waterway: Waterway,
charted_depth: Distance,
policy: &ClearancePolicy,
) -> Result<Distance> {
let needed = hull.draught + squat(hull, speed, waterway)? + policy.required(hull)?;
Ok(needed - charted_depth)
}
fn power(base: f64, exponent: f64) -> f64 {
if base <= 0.0 {
return 0.0;
}
math::exp(exponent * math::ln(base))
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::float_cmp, clippy::indexing_slicing)]
mod tests {
use super::*;
use crate::conditions::Constant;
use crate::environment::TideModel;
use crate::geodesy::{GeodeticPoint, Height};
use crate::position::Position;
use crate::tides::{TidalCycle, TideEvent};
fn metres(value: f64) -> Distance {
Distance::from_metres(value).unwrap()
}
fn knots(value: f64) -> Speed {
Speed::from_knots(value).unwrap()
}
fn noon() -> Instant<Utc> {
Instant::from_unix_seconds(1_789_000_000)
}
fn about(distance: Distance, metres: f64) -> bool {
(distance.metres() - metres).abs() < 1e-6
}
fn tanker() -> Hull {
Hull::new(metres(12.0), 0.85).unwrap()
}
fn tenth_of_draught() -> ClearancePolicy {
ClearancePolicy {
minimum: metres(1.0),
fraction_of_draught: 0.1,
}
}
fn sample(tide: Option<f64>) -> EnvironmentSample {
let here = Position::from_degrees(51.0, 3.0).unwrap();
let sample = EnvironmentSample::at(
GeodeticPoint::new(here, Height::above_ellipsoid(Distance::ZERO)),
noon(),
);
match tide {
Some(height) => sample.with_tide(metres(height)),
None => sample,
}
}
#[test]
fn squat_grows_with_the_square_of_the_speed_and_doubles_in_a_channel() {
let hull = tanker();
let open_six = squat(&hull, knots(6.0), Waterway::OpenWater).unwrap();
let open_twelve = squat(&hull, knots(12.0), Waterway::OpenWater).unwrap();
let channel_twelve = squat(&hull, knots(12.0), Waterway::ConfinedChannel).unwrap();
assert!((open_twelve.metres() - 1.224).abs() < 1e-9);
assert!((open_twelve.metres() / open_six.metres() - 4.0).abs() < 1e-9);
assert!((channel_twelve.metres() / open_twelve.metres() - 2.0).abs() < 1e-9);
assert_eq!(
squat(&hull, Speed::ZERO, Waterway::OpenWater).unwrap(),
Distance::ZERO
);
}
#[test]
fn sternway_squats_as_much_as_headway() {
let hull = tanker();
assert_eq!(
squat(&hull, knots(-8.0), Waterway::OpenWater).unwrap(),
squat(&hull, knots(8.0), Waterway::OpenWater).unwrap()
);
}
#[test]
fn the_general_formula_meets_the_two_rules_at_their_ends() {
let hull = tanker();
for v in [6.0, 10.0, 14.0] {
let open = squat(&hull, knots(v), Waterway::OpenWater).unwrap();
let at_tenth = squat(&hull, knots(v), Waterway::Blockage(0.1)).unwrap();
assert!(
(at_tenth.metres() / open.metres() - 1.0).abs() < 0.15,
"{v} kn: {} vs {}",
at_tenth.metres(),
open.metres()
);
let confined = squat(&hull, knots(v), Waterway::ConfinedChannel).unwrap();
let at_quarter = squat(&hull, knots(v), Waterway::Blockage(0.25)).unwrap();
assert!(
(at_quarter.metres() / confined.metres() - 1.0).abs() < 0.15,
"{v} kn: {} vs {}",
at_quarter.metres(),
confined.metres()
);
}
}
#[test]
fn a_blockage_factor_must_be_a_fraction_of_the_channel() {
let hull = tanker();
for factor in [0.0, -0.1, 1.5, f64::NAN, f64::INFINITY] {
assert!(
squat(&hull, knots(10.0), Waterway::Blockage(factor)).is_err(),
"{factor}"
);
}
assert!(squat(&hull, knots(10.0), Waterway::Blockage(1.0)).is_ok());
}
#[test]
fn a_hull_has_a_draught_and_a_block_coefficient_a_hull_can_have() {
assert!(Hull::new(Distance::ZERO, 0.8).is_err());
assert!(Hull::new(metres(-3.0), 0.8).is_err());
assert!(Hull::new(metres(3.0), 0.0).is_err());
assert!(Hull::new(metres(3.0), 1.2).is_err());
assert!(Hull::new(metres(3.0), f64::NAN).is_err());
let hull = Hull::new(metres(3.0), 1.0).unwrap();
assert_eq!(hull.draught(), metres(3.0));
assert_eq!(hull.block_coefficient(), 1.0);
}
#[test]
fn the_policy_keeps_the_greater_of_its_two_margins() {
let hull = tanker();
assert!(about(tenth_of_draught().required(&hull).unwrap(), 1.2));
let floor = ClearancePolicy {
minimum: metres(2.0),
fraction_of_draught: 0.1,
};
assert!(about(floor.required(&hull).unwrap(), 2.0));
for bad in [
ClearancePolicy {
minimum: metres(-1.0),
fraction_of_draught: 0.1,
},
ClearancePolicy {
minimum: metres(1.0),
fraction_of_draught: -0.1,
},
ClearancePolicy {
minimum: metres(1.0),
fraction_of_draught: f64::NAN,
},
] {
assert!(bad.required(&hull).is_err());
}
}
#[test]
fn enough_water_is_no_event_and_too_little_is_one() {
let hull = tanker();
let (open, events) = under_keel_clearance(
&hull,
knots(12.0),
Waterway::OpenWater,
metres(14.0),
&sample(Some(1.5)),
&tenth_of_draught(),
)
.unwrap();
assert_eq!(open.at(), noon());
assert!(about(open.depth_of_water(), 15.5));
assert!(about(open.dynamic_draught(), 13.224));
assert!(about(open.clearance(), 2.276));
assert!(about(open.required(), 1.2));
assert!(about(open.margin(), 1.076));
assert!(open.is_sufficient());
assert!(events.is_empty());
let (channel, events) = under_keel_clearance(
&hull,
knots(12.0),
Waterway::ConfinedChannel,
metres(14.0),
&sample(Some(1.5)),
&tenth_of_draught(),
)
.unwrap();
assert!(!channel.is_sufficient());
assert!(channel.margin().is_negative());
assert!(matches!(
events[0],
ClearanceEvent::UnderKeelClearanceLow { clearance, required, at }
if clearance == channel.clearance() && about(required, 1.2) && at == noon()
));
}
#[test]
fn aground_is_a_negative_clearance_not_an_error() {
let (stuck, events) = under_keel_clearance(
&tanker(),
knots(4.0),
Waterway::OpenWater,
metres(10.0),
&sample(Some(0.5)),
&tenth_of_draught(),
)
.unwrap();
assert!(stuck.clearance().is_negative());
assert_eq!(events.len(), 1);
}
#[test]
fn a_drying_height_is_a_negative_charted_depth() {
let dinghy = Hull::new(metres(0.3), 0.5).unwrap();
let policy = ClearancePolicy {
minimum: metres(0.2),
fraction_of_draught: 0.0,
};
let (over, _) = under_keel_clearance(
&dinghy,
knots(3.0),
Waterway::OpenWater,
metres(-1.0),
&sample(Some(3.0)),
&policy,
)
.unwrap();
assert!(about(over.depth_of_water(), 2.0));
assert!(over.is_sufficient());
}
#[test]
fn without_the_tide_resolved_there_is_no_answer() {
assert!(matches!(
under_keel_clearance(
&tanker(),
knots(12.0),
Waterway::OpenWater,
metres(14.0),
&sample(None),
&tenth_of_draught(),
)
.unwrap_err(),
NavigationError::Kernel(KernelError::Missing {
what: "the height of tide"
})
));
}
#[test]
fn the_tide_needed_makes_the_clearance_exactly_the_policy() {
let hull = tanker();
let policy = tenth_of_draught();
let needed = height_of_tide_needed(
&hull,
knots(12.0),
Waterway::ConfinedChannel,
metres(14.0),
&policy,
)
.unwrap();
assert!((needed.metres() - 1.648).abs() < 1e-9);
for (extra, enough) in [(0.001, true), (-0.001, false)] {
let (at_the_edge, events) = under_keel_clearance(
&hull,
knots(12.0),
Waterway::ConfinedChannel,
metres(14.0),
&sample(Some(needed.metres() + extra)),
&policy,
)
.unwrap();
assert!(about(at_the_edge.margin(), extra));
assert_eq!(at_the_edge.is_sufficient(), enough);
assert_eq!(events.is_empty(), enough);
}
let deep = height_of_tide_needed(
&hull,
knots(12.0),
Waterway::OpenWater,
metres(30.0),
&policy,
)
.unwrap();
assert!(deep.is_negative());
}
#[test]
fn a_tidal_cycle_and_a_constant_answer_the_tide_port() {
let here = Position::from_degrees(51.0, 3.0).unwrap();
let low = TideEvent::new(noon(), metres(0.8));
let high = TideEvent::new(
noon()
.checked_add(core::time::Duration::from_secs(6 * 3600))
.unwrap(),
metres(4.6),
);
let rise = TidalCycle::new(low, high).unwrap();
let later = noon()
.checked_add(core::time::Duration::from_secs(2 * 3600))
.unwrap();
assert_eq!(
rise.height_of_tide(here, later).unwrap(),
rise.height_at(later).unwrap()
);
assert!(rise
.height_of_tide(
here,
noon()
.checked_sub(core::time::Duration::from_secs(1))
.unwrap()
)
.is_err());
assert_eq!(
Constant(metres(2.0)).height_of_tide(here, later).unwrap(),
metres(2.0)
);
let sample = sample(None).with_tide(rise.height_at(later).unwrap());
assert_eq!(sample.tide(), Some(rise.height_at(later).unwrap()));
}
}