jord-1.0.0.0: src/Data/Geo/Jord/GreatCircle.hs
-- |
-- Module: Data.Geo.Jord.GreatCircle
-- Copyright: (c) 2020 Cedric Liegeois
-- License: BSD3
-- Maintainer: Cedric Liegeois <ofmooseandmen@yahoo.fr>
-- Stability: experimental
-- Portability: portable
--
-- Geographical Position calculations on great circles, i.e. using a __sphere__ to represent
-- the celestial body that positions refer to.
--
-- In order to use this module you should start with the following imports:
--
-- @
-- import Data.Geo.Jord.GreatCircle
-- import Data.Geo.Jord.Position
-- @
--
-- If you wish to use both this module and the "Data.Geo.Jord.Geodesic" module you must qualify both imports.
--
-- All functions are implemented using the vector-based approached described in
-- <http://www.navlab.net/Publications/A_Nonsingular_Horizontal_Point_Representation.pdf Gade, K. (2010). A Non-singular Horizontal Position Representation>
--
module Data.Geo.Jord.GreatCircle
(
-- * The 'GreatCircle' type
GreatCircle
, greatCircleThrough
, greatCircleHeadingOn
-- * The 'MinorArc' type
, MinorArc
, minorArc
-- * Calculations
, alongTrackDistance
, alongTrackDistance'
, angularDistance
, crossTrackDistance
, crossTrackDistance'
, destination
, finalBearing
, initialBearing
, interpolate
, intersection
, intersections
, isBetween
, isInsideSurface
, mean
, surfaceDistance
) where
import Data.Fixed (Nano)
import Data.List (subsequences)
import Data.Geo.Jord.Internal
import Data.Geo.Jord.Position
-- | A circle on the __surface__ of a __sphere__ which lies in a plane
-- passing through the sphere centre. Every two distinct and non-antipodal points
-- define a unique Great Circle.
--
-- It is internally represented as its normal vector - i.e. the normal vector
-- to the plane containing the great circle.
--
data GreatCircle a =
GreatCircle !Vector3d !a String
deriving (Eq)
instance (Model a) => Show (GreatCircle a) where
show (GreatCircle _ _ s) = s
-- | @greatCircleThrough p1 p2@ returns the 'GreatCircle' passing by both positions @p1@ and @p2@.
-- If positions are antipodal, any great circle passing through those positions will be returned.
-- Returns 'Nothing' if given positions are equal.
--
-- ==== __Examples__
--
-- >>> import Data.Geo.Jord.GreatCircle
-- >>> import Data.Geo.Jord.Position
-- >>>
-- >>> let p1 = latLongHeightPos 45.0 (-143.5) (metres 1500) S84
-- >>> let p2 = latLongHeightPos 46.0 14.5 (metres 3000) S84
-- >>> greatCircleThrough p1 p2 -- heights are ignored, great circle is always at surface.
-- Just Great Circle { through 45°0'0.000"N,143°30'0.000"W 1500.0m (S84) & 46°0'0.000"N,14°30'0.000"E 3000.0m (S84) }
--
greatCircleThrough :: (Spherical a) => Position a -> Position a -> Maybe (GreatCircle a)
greatCircleThrough p1 p2
| llEq p1 p2 = Nothing
| otherwise = Just (GreatCircle (normal' p1 p2) (model p1) dscr)
where
dscr = "Great Circle { through " ++ show p1 ++ " & " ++ show p2 ++ " }"
-- | @greatCircleHeadingOn p b@ returns the 'GreatCircle' passing by position @p@ and
-- heading on bearing @b@.
--
-- ==== __Examples__
--
-- >>> import Data.Geo.Jord.GreatCircle
-- >>> import Data.Geo.Jord.Position
-- >>>
-- >>> let p = latLongPos 45.0 (-143.5) S84
-- >>> let b = decimalDegrees 33.0
-- >>> greatCircleHeadingOn p b
-- Great Circle { by 45°0'0.000"N,143°30'0.000"W 0.0m (S84) & heading on 33°0'0.000" }
--
greatCircleHeadingOn :: (Spherical a) => Position a -> Angle -> GreatCircle a
greatCircleHeadingOn p b = GreatCircle (vsub n' e') (model p) dscr
where
v = nvec p
e = vcross nvNorthPole v -- easting
n = vcross v e -- northing
e' = vscale e (cos' b / vnorm e)
n' = vscale n (sin' b / vnorm n)
dscr = "Great Circle { by " ++ show p ++ " & heading on " ++ show b ++ " }"
-- | Oriented minor arc of a great circle between two positions: shortest path between
-- positions on a great circle.
data MinorArc a =
MinorArc !Vector3d (Position a) (Position a)
deriving (Eq)
instance (Model a) => Show (MinorArc a) where
show (MinorArc _ s e) = "Minor Arc { from: " ++ show s ++ ", to: " ++ show e ++ " }"
-- | @minorArc p1 p2@ returns the 'MinorArc' from @p1@ to @p2@.
-- Returns 'Nothing' if given positions are equal.
--
-- ==== __Examples__
--
-- >>> import Data.Geo.Jord.GreatCircle
-- >>> import Data.Geo.Jord.Position
-- >>>
-- >>> let p1 = latLongHeightPos 45.0 (-143.5) (metres 1500) S84
-- >>> let p2 = latLongHeightPos 46.0 14.5 (metres 3000) S84
-- Just Minor Arc { from: 45°0'0.000"N,143°30'0.000"W 1500.0m (S84), to: 46°0'0.000"N,14°30'0.000"E 3000.0m (S84) }
--
minorArc :: (Spherical a) => Position a -> Position a -> Maybe (MinorArc a)
minorArc p1 p2
| llEq p1 p2 = Nothing
| otherwise = Just (MinorArc (normal' p1 p2) p1 p2)
-- | @alongTrackDistance p a@ computes how far Position @p@ is along a path described
-- by the minor arc @a@: if a perpendicular is drawn from @p@ to the path, the
-- along-track distance is the signed distance from the start point to where the
-- perpendicular crosses the path.
--
-- ==== __Examples__
--
-- >>> import Data.Geo.Jord.GreatCircle
-- >>> import Data.Geo.Jord.Position
-- >>>
-- >>> let p = s84Pos 53.2611 (-0.7972) zero
-- >>> let g = minorArcBetween (s84Pos 53.3206 (-1.7297) zero) (s84Pos 53.1887 0.1334 zero)
-- >>> fmap (alongTrackDistance p) a
-- Right 62.3315757km
--
alongTrackDistance :: (Spherical a) => Position a -> MinorArc a -> Length
alongTrackDistance p (MinorArc n s _) = alongTrackDistance'' p s n
-- | @alongTrackDistance' p s b@ computes how far Position @p@ is along a path starting
-- at @s@ and heading on bearing @b@: if a perpendicular is drawn from @p@ to the path, the
-- along-track distance is the signed distance from the start point to where the
-- perpendicular crosses the path.
--
-- ==== __Examples__
--
-- >>> import Data.Geo.Jord.GreatCircle
-- >>> import Data.Geo.Jord.Position
-- >>>
-- >>> let p = s84Pos 53.2611 (-0.7972) zero
-- >>> let s = s84Pos 53.3206 (-1.7297) zero
-- >>> let b = decimalDegrees 96.0017325
-- >>> alongTrackDistance' p s b
-- 62.3315757km
--
alongTrackDistance' :: (Spherical a) => Position a -> Position a -> Angle -> Length
alongTrackDistance' p s b = alongTrackDistance'' p s n
where
(GreatCircle n _ _) = greatCircleHeadingOn s b
-- | @angularDistance p1 p2 n@ computes the angle between the horizontal Points @p1@ and @p2@.
-- If @n@ is 'Nothing', the angle is always in [0..180], otherwise it is in [-180, +180],
-- signed + if @p1@ is clockwise looking along @n@, - in opposite direction.
angularDistance :: (Spherical a) => Position a -> Position a -> Maybe (Position a) -> Angle
angularDistance p1 p2 n = signedAngle v1 v2 vn
where
v1 = nvec p1
v2 = nvec p2
vn = fmap nvec n
-- | @crossTrackDistance p gc@ computes the signed distance from horizontal Position @p@ to great circle @gc@.
-- Returns a negative 'Length' if Position if left of great circle,
-- positive 'Length' if Position if right of great circle; the orientation of the
-- great circle is therefore important:
--
-- ==== __Examples__
--
-- >>> import Data.Geo.Jord.GreatCircle
-- >>> import Data.Geo.Jord.Position
-- >>>
-- >>> let gc1 = greatCircleThrough (s84Pos 51 0 zero) (s84Pos 52 1 zero)
-- >>> fmap (crossTrackDistance p) gc1
-- Right -176.7568725km
-- >>>
-- >>> let gc2 = greatCircleThrough (s84Pos 52 1 zero) (s84Pos 51 0 zero)
-- >>> fmap (crossTrackDistance p) gc2
-- Right 176.7568725km
-- >>>
-- >>> let p = s84Pos 53.2611 (-0.7972) zero
-- >>> let gc = greatCircleHeadingOn (s84Pos 53.3206 (-1.7297) zero) (decimalDegrees 96.0)
-- >>> crossTrackDistance p gc
-- -305.6629 metres
--
crossTrackDistance :: (Spherical a) => Position a -> GreatCircle a -> Length
crossTrackDistance p (GreatCircle n _ _) = arcLength (sub a (decimalDegrees 90)) (radius p)
where
a = radians (angleRadians n (nvec p))
-- | @crossTrackDistance' p s b@ computes the signed distance from horizontal Position @p@ to the
-- great circle passing by @s@ and heading on bearing @b@.
--
-- This is equivalent to:
--
-- @
-- 'crossTrackDistance' p ('greatCircleHeadingOn' s b)
-- @
--
crossTrackDistance' :: (Spherical a) => Position a -> Position a -> Angle -> Length
crossTrackDistance' p s b = crossTrackDistance p (greatCircleHeadingOn s b)
-- | @destination p b d@ computes the position along the great circle, reached from
-- position @p@ having travelled the __surface__ distance @d@ on the initial bearing (compass angle) @b@
-- at __constant__ height.
-- Note that the bearing will normally vary before destination is reached.
--
-- ==== __Examples__
--
-- >>> import Data.Geo.Jord.GreatCircle
-- >>> import Data.Geo.Jord.Position
-- >>>
-- >>> destination (s84Pos 54 154 (metres 15000)) (decimalDegrees 33) (kilometres 1000)
-- 61°10'44.188"N,164°10'19.254"E 15.0km (S84)
--
destination :: (Spherical a) => Position a -> Angle -> Length -> Position a
destination p b d
| d == zero = p
| otherwise = nvh nvd (height p) (model p)
where
nv = nvec p
ed = vunit (vcross nvNorthPole nv) -- east direction vector at v
nd = vcross nv ed -- north direction vector at v
r = radius p
ta = central d r -- central angle
de = vadd (vscale nd (cos' b)) (vscale ed (sin' b)) -- vunit vector in the direction of the azimuth
nvd = vadd (vscale nv (cos' ta)) (vscale de (sin' ta))
-- | @surfaceDistance p1 p2@ computes the surface distance on the great circle between the
-- positions @p1@ and @p2@.
--
-- ==== __Examples__
--
-- >>> import Data.Geo.Jord.GreatCircle
-- >>> import Data.Geo.Jord.Position
-- >>>
-- >>> surfaceDistance (northPole S84) (southPole S84)
-- 20015.114352233km
-- >>>
-- >>> surfaceDistance (northPole S84) (northPole S84)
-- 0.0m
--
surfaceDistance :: (Spherical a) => Position a -> Position a -> Length
surfaceDistance p1 p2 = arcLength a (radius p1)
where
a = radians (angleRadians (nvec p1) (nvec p2))
-- | @finalBearing p1 p2@ computes the final bearing arriving at @p2@ from @p1@ in compass angle.
-- Compass angles are clockwise angles from true north: 0° = north, 90° = east, 180° = south, 270° = west.
-- The final bearing will differ from the initial bearing by varying degrees according to distance and latitude.
-- Returns 'Nothing' if both positions are equals.
--
-- ==== __Examples__
--
--
-- >>> import Data.Geo.Jord.GreatCircle
-- >>> import Data.Geo.Jord.Position
-- >>>
-- >>> let p1 = s84Pos 0 1 (metres 12000)
-- >>> let p2 = s84Pos 0 0 (metres 5000)
-- >>> finalBearing p1 p2
-- Just 270°0'0.000"
-- >>>
-- >>> finalBearing p1 p1
-- Nothing
--
finalBearing :: (Spherical a) => Position a -> Position a -> Maybe Angle
finalBearing p1 p2
| llEq p1 p2 = Nothing
| otherwise = Just (normalise (initialBearing' p2 p1) (decimalDegrees 180))
-- | @initialBearing p1 p2@ computes the initial bearing from @p1@ to @p2@ in compass angle.
-- Compass angles are clockwise angles from true north: 0° = north, 90° = east, 180° = south, 270° = west.
-- Returns 'Nothing' if both positions are equals.
--
-- ==== __Examples__
--
-- >>> import Data.Geo.Jord.GreatCircle
-- >>> import Data.Geo.Jord.Position
-- >>>
-- >>> let p1 = s84Pos 58.643889 (-5.714722) (metres 12000)
-- >>> let p2 = s84Pos 50.066389 (-5.714722) (metres 12000)
-- >>> initialBearing p1 p2
-- Just 180°0'0.000"
-- >>>
-- >>> initialBearing p1 p1
-- Nothing
--
initialBearing :: (Spherical a) => Position a -> Position a -> Maybe Angle
initialBearing p1 p2
| llEq p1 p2 = Nothing
| otherwise = Just (initialBearing' p1 p2)
-- | @interpolate p0 p1 f# computes the position at fraction @f@ between the @p0@ and @p1@.
--
-- Special conditions:
--
-- @
-- interpolate p0 p1 0.0 = p0
-- interpolate p0 p1 1.0 = p1
-- @
--
-- 'error's if @f < 0 || f > 1@
--
-- ==== __Examples__
--
-- >>> import Data.Geo.Jord.GreatCircle
-- >>> import Data.Geo.Jord.Position
-- >>>
-- >>> let p1 = s84Pos 53.479444 (-2.245278) (metres 10000)
-- >>> let p2 = s84Pos 55.605833 13.035833 (metres 20000)
-- >>> interpolate p1 p2 0.5
-- 54°47'0.805"N,5°11'41.947"E 15.0km (S84)
--
interpolate :: (Spherical a) => Position a -> Position a -> Double -> Position a
interpolate p0 p1 f
| f < 0 || f > 1 = error ("fraction must be in range [0..1], was " ++ show f)
| f == 0 = p0
| f == 1 = p1
| otherwise = nvh iv ih (model p0)
where
nv0 = nvec p0
h0 = height p0
nv1 = nvec p1
h1 = height p1
iv = vunit (vadd nv0 (vscale (vsub nv1 nv0) f))
ih = lrph h0 h1 f
-- | Computes the intersection between the two given minor arcs of great circle.
--
-- see also 'intersections'
--
-- ==== __Examples__
--
-- >>> import Data.Geo.Jord.GreatCircle
-- >>> import Data.Geo.Jord.Position
-- >>>
-- >>> let a1 = minorArcBetween (s84Pos 51.885 0.235 zero) (s84Pos 48.269 13.093 zero)
-- >>> let a2 = minorArcBetween (s84Pos 49.008 2.549 zero) (s84Pos 56.283 11.304 zero)
-- >>> join (intersection <$> a1 <*> a2)
-- Just 50°54'6.260"N,4°29'39.052"E 0.0m (S84)
--
intersection :: (Spherical a) => MinorArc a -> MinorArc a -> Maybe (Position a)
intersection a1@(MinorArc n1 s1 _) a2@(MinorArc n2 _ _) =
case intersections' n1 n2 (model s1) of
Nothing -> Nothing
(Just (i1, i2))
| isBetween i1 a1 && isBetween i1 a2 -> Just i1
| isBetween i2 a1 && isBetween i2 a2 -> Just i2
| otherwise -> Nothing
-- | Computes the intersections between the two given 'GreatCircle's.
-- Two great circles intersect exactly twice unless there are equal (regardless of orientation),
-- in which case 'Nothing' is returned.
--
-- ==== __Examples__
--
-- >>> import Data.Geo.Jord.GreatCircle
-- >>> import Data.Geo.Jord.Position
-- >>>
-- >>> let gc1 = greatCircleHeadingOn (s84Pos 51.885 0.235 zero) (decimalDegrees 108.63)
-- >>> let gc2 = greatCircleHeadingOn (s84Pos 49.008 2.549 zero) (decimalDegrees 32.72)
-- >>> intersections gc1 gc2
-- Just (50°54'6.201"N,4°29'39.402"E 0.0m (S84),50°54'6.201"S,175°30'20.598"W 0.0m (S84))
-- >>> let i = intersections gc1 gc2
-- fmap fst i == fmap (antipode . snd) i
-- >>> True
--
intersections :: (Spherical a) => GreatCircle a -> GreatCircle a -> Maybe (Position a, Position a)
intersections (GreatCircle n1 m _) (GreatCircle n2 _ _) = intersections' n1 n2 m
-- | @isBetween p a@ determines whether position @p@ is within the minor arc
-- of great circle @a@.
--
-- If @p@ is not on the arc, returns whether @p@ is within the area bound
-- by perpendiculars to the arc at each point (in the same hemisphere).
--
isBetween :: (Spherical a) => Position a -> MinorArc a -> Bool
isBetween p (MinorArc _ s e) = between && hemisphere
where
v0 = nvec p
v1 = nvec s
v2 = nvec e
v10 = vsub v0 v1
v12 = vsub v2 v1
v20 = vsub v0 v2
v21 = vsub v1 v2
e1 = vdot v10 v12 -- p is on e side of s
e2 = vdot v20 v21 -- p is on s side of e
between = e1 >= 0 && e2 >= 0
hemisphere = vdot v0 v1 >= 0 && vdot v0 v2 >= 0
-- | @isInsideSurface p ps@ determines whether position @p@ is inside the __surface__ polygon defined by
-- positions @ps@ (i.e. ignoring the height of the positions).
-- The polygon can be opened or closed (i.e. if @head ps /= last ps@).
--
-- Uses the angle summation test: on a sphere, due to spherical excess, enclosed point angles
-- will sum to less than 360°, and exterior point angles will be small but non-zero.
--
-- Always returns 'False' if @ps@ does not at least defines a triangle.
--
-- ==== __Examples__
--
-- >>> import Data.Geo.Jord.GreatCircle
-- >>> import Data.Geo.Jord.Position
-- >>>
-- >>> let malmo = s84Pos 55.6050 13.0038 zero
-- >>> let ystad = s84Pos 55.4295 13.82 zero
-- >>> let lund = s84Pos 55.7047 13.1910 zero
-- >>> let helsingborg = s84Pos 56.0465 12.6945 zero
-- >>> let kristianstad = s84Pos 56.0294 14.1567 zero
-- >>> let polygon = [malmo, ystad, kristianstad, helsingborg, lund]
-- >>> let hoor = s84Pos 55.9295 13.5297 zero
-- >>> let hassleholm = s84Pos 56.1589 13.7668 zero
-- >>> isInsideSurface hoor polygon
-- True
-- >>> isInsideSurface hassleholm polygon
-- False
--
isInsideSurface :: (Spherical a) => Position a -> [Position a] -> Bool
isInsideSurface p ps
| null ps = False
| llEq (head ps) (last ps) = isInsideSurface p (init ps)
| length ps < 3 = False
| otherwise =
let aSum = foldl (\a v' -> add a (uncurry signedAngle v' (Just v))) (decimalDegrees 0) (egdes (map (vsub v) vs))
in abs (toDecimalDegrees aSum) > 180.0
where
v = nvec p
vs = fmap nvec ps
-- | @mean ps@ computes the geographic mean surface position of @ps@, if it is defined.
--
-- The geographic mean is not defined for antipodals positions (since they
-- cancel each other).
--
-- Special conditions:
--
-- @
-- mean [] = Nothing
-- mean [p] = Just p
-- mean [p1, p2, p3] = Just circumcentre
-- mean [p1, .., antipode p1] = Nothing
-- @
mean :: (Spherical a) => [Position a] -> Maybe (Position a)
mean [] = Nothing
mean [p] = Just p
mean ps =
if null antipodals
then Just (nvh nv zero (model . head $ ps))
else Nothing
where
vs = fmap nvec ps
ts = filter (\l -> length l == 2) (subsequences vs)
antipodals = filter (\t -> (realToFrac (vnorm (vadd (head t) (last t)) :: Double) :: Nano) == 0) ts
nv = vunit $ foldl vadd vzero vs
-- private
alongTrackDistance'' :: (Spherical a) => Position a -> Position a -> Vector3d -> Length
alongTrackDistance'' p s n = arcLength a (radius s)
where
a = signedAngle (nvec s) (vcross (vcross n (nvec p)) n) (Just n)
-- | [p1, p2, p3, p4] to [(p1, p2), (p2, p3), (p3, p4), (p4, p1)]
egdes :: [Vector3d] -> [(Vector3d, Vector3d)]
egdes ps = zip ps (tail ps ++ [head ps])
lrph :: Length -> Length -> Double -> Length
lrph h0 h1 f = metres h
where
h0' = toMetres h0
h1' = toMetres h1
h = h0' + (h1' - h0') * f
intersections' :: (Spherical a) => Vector3d -> Vector3d -> a -> Maybe (Position a, Position a)
intersections' n1 n2 s
| (vnorm i :: Double) == 0.0 = Nothing
| otherwise
, let ni = nvh (vunit i) zero s = Just (ni, antipode ni)
where
i = vcross n1 n2
initialBearing' :: Position a -> Position a -> Angle
initialBearing' p1 p2 = normalise a (decimalDegrees 360)
where
v1 = nvec p1
v2 = nvec p2
gc1 = vcross v1 v2 -- great circle through p1 & p2
gc2 = vcross v1 nvNorthPole -- great circle through p1 & north pole
a = radians (signedAngleRadians gc1 gc2 (Just v1))
-- | reference sphere radius.
radius :: (Spherical a) => Position a -> Length
radius = equatorialRadius . surface . model
normal' :: (Spherical a) => Position a -> Position a -> Vector3d
normal' p1 p2 = vcross (nvec p1) (nvec p2)
signedAngle :: Vector3d -> Vector3d -> Maybe Vector3d -> Angle
signedAngle v1 v2 n = radians (signedAngleRadians v1 v2 n)