packages feed

jord 1.0.0.0 → 2.0.0.0

raw patch · 64 files changed

+5907/−3808 lines, 64 filesdep +markdown-unlit

Dependencies added: markdown-unlit

Files

ChangeLog.md view
@@ -1,3 +1,26 @@+### 2.0.0.0
+
+- API change - all modules should be imported as qualified
+- API change - Position has been split between Geodetic & Geocentric
+- API change - positions conversion/transformation are provided by Positions module
+- API change - GreatCicle: interpolate -> interpolated, isInsideSurface -> enclosedBy
+- API change - Geodesic data type provides initialBearing, finalBearing, length and functions are removed
+- API change - LocalFrames -> Local
+- API change - provide separate data type for horizontal positions and positions with height
+- New API    - GreatCircle: turn & side
+- New API    - Polygon: simple, circle, arc, contains & triangulate
+- New API    - Triangle: contains, circumcentre & centroid
+- Bug Fix    - GreatCircle.intersection correctly handles minor arcs crossing the equator
+- Bug Fix    - All Geodetic positions built from a n-vector have the correct angle resolution
+- Bump stack resolver to lts-16.11
+- Examples in readme are compile to make sure they are valid
+- Use github actions for CI
+
+### 1.0.0.1
+
+- Fixed typo in doc
+- Bumped stack resolver to latest
+
 ### 1.0.0.0
 
 - New API (does not allow mixing position in different coordinate systems)
+ README.lhs view
@@ -0,0 +1,627 @@+# Jord - Geographical Position Calculations
+
+[![GitHub CI](https://github.com/ofmooseandmen/jord/workflows/CI/badge.svg)](https://github.com/ofmooseandmen/jord/actions)
+[![Hackage](https://img.shields.io/hackage/v/jord.svg)](http://hackage.haskell.org/package/jord)
+[![license](https://img.shields.io/badge/license-BSD3-lightgray.svg)](https://opensource.org/licenses/BSD-3-Clause)
+
+> __Jord__ [_Swedish_] is __Earth__ [_English_]
+
+## What is this?
+
+Jord is a [Haskell](https://www.haskell.org) library that implements various geographical position calculations using the algorithms described in [Gade, K. (2010) - A Non-singular Horizontal Position Representation](http://www.navlab.net/Publications/A_Nonsingular_Horizontal_Position_Representation.pdf),
+[Shudde, Rex H. (1986) - Some tactical algorithms for spherical geometry](https://calhoun.nps.edu/bitstream/handle/10945/29516/sometacticalalgo00shud.pdf) and [Vincenty, T. (1975) - Direct and Inverse Solutions of Geodesics on the Ellipsoid](https://www.ngs.noaa.gov/PUBS_LIB/inverse.pdf):
+
+- conversion between ECEF (earth-centred, earth-fixed), latitude/longitude and [*n*-vector](https://www.navlab.net/nvector) positions for spherical and ellipsoidal earth model,
+- conversion between latitude/longitude and *n*-vector positions,
+- local frames (body; local level, wander azimuth; north, east, down): delta between positions, target position from reference position and delta,
+- great circles: surface distance, initial & final bearing, interpolated position, great circle intersections, cross track distance, ...,
+- geodesic: surface distance, initial & final bearing and destination,
+- kinematics: position from p0, bearing and speed, closest point of approach between tracks, intercept (time, speed, minimum speed),
+- transformation between coordinate systems (both fixed and time-dependent).
+- polygon triangulation
+
+## How do I build it?
+
+If you have [Stack](https://docs.haskellstack.org/en/stable/README/), then:
+```sh
+$ stack build --test
+```
+
+If you have [Cabal](https://www.haskell.org/cabal/), then:
+```sh
+$ cabal v2-build
+$ cabal v2-test
+```
+
+## How do I use it?
+
+[See documentation on Hackage](http://hackage.haskell.org/package/jord/docs/Data-Geo-Jord.html)
+
+Import the core functional modules as qualified:
+
+```haskell
+import qualified Data.Geo.Jord.Angle as Angle
+import qualified Data.Geo.Jord.Duration as Duration
+import qualified Data.Geo.Jord.Geocentric as Geocentric
+import qualified Data.Geo.Jord.Geodetic as Geodetic
+import qualified Data.Geo.Jord.Geodesic as Geodesic
+import qualified Data.Geo.Jord.GreatCircle as GreatCircle
+import qualified Data.Geo.Jord.Kinematics as Kinematics
+import qualified Data.Geo.Jord.Length as Length
+import qualified Data.Geo.Jord.Local as Local
+import qualified Data.Geo.Jord.Polygon as Polygon
+import qualified Data.Geo.Jord.Positions as Positions
+import qualified Data.Geo.Jord.Speed as Speed
+import qualified Data.Geo.Jord.Tx as Tx
+```
+
+Note: modules can be selectively imported as non-qualified, but i.m.o. the code reads better when modules are imported qualified.
+
+Import models and transformation parameters:
+
+```haskell
+import Data.Geo.Jord.Model (Epoch(..))
+import Data.Geo.Jord.Models
+import qualified Data.Geo.Jord.Txs as Txs
+```
+
+## Solutions to the 10 examples from [NavLab](https://www.navlab.net/nvector)
+
+### Example 1: A and B to delta
+
+*Given two positions, A and B as latitudes, longitudes and depths relative to Earth, E.*
+
+*Find the exact vector between the two positions, given in meters north, east, and down, and find the direction (azimuth)
+to B, relative to north. Assume WGS-84 ellipsoid. The given depths are from the ellipsoid surface. Use position A to
+define north, east, and down directions. (Due to the curvature of Earth and different directions to the North Pole,
+the north, east, and down directions will change (relative to Earth) for different places. A must be outside the poles
+for the north and east directions to be defined.)*
+
+```haskell
+example1 :: IO()
+example1 = do
+    let pA = Geodetic.latLongHeightPos 1 2 (Length.metres 3) WGS84
+    let pB = Geodetic.latLongHeightPos 4 5 (Length.metres 6) WGS84
+
+    let ned = Local.nedBetween pA pB
+    -- Ned {north = 331.730863099km, east = 332.998501491km, down = 17.39830421km}
+
+    let slantRange = Local.slantRange ned
+    -- 470.357383823km
+
+    let bearing = Local.bearing ned
+    -- 45°6'33.346"
+
+    let elevation = Local.elevation ned
+    -- -2°7'11.381"
+
+    putStrLn ("NavLab, Example1: A and B to delta\n\
+              \  delta      = " ++ (show ned) ++ "\n\
+              \  slantRange = " ++ (show slantRange) ++ "\n\
+              \  bearing    = " ++ (show bearing) ++ "\n\
+              \  elevation  = " ++ (show elevation) ++ "\n")
+```
+
+### Example 2: B and delta to C
+
+*A radar or sonar attached to a vehicle B (Body coordinate frame) measures the distance and direction to an object C.
+We assume that the distance and two angles (typically bearing and elevation relative to B) are already combined to the
+vector p_BC_B (i.e. the vector from B to C, decomposed in B). The position of B is given as n_EB_E and z_EB, and the
+orientation (attitude) of B is given as R_NB (this rotation matrix can be found from roll/pitch/yaw by using zyx2R).*
+
+*Find the exact position of object C as n-vector and depth ( n_EC_E and z_EC ), assuming Earth ellipsoid with semi-major
+axis a and flattening f. For WGS-72, use a = 6 378 135 m and f = 1/298.26.*
+
+```haskell
+example2 :: IO()
+example2 = do
+    let frameB =
+            Local.frameB
+                (Angle.decimalDegrees 40)
+                (Angle.decimalDegrees 20)
+                (Angle.decimalDegrees 30)
+    let pB = Geodetic.nvectorHeightPos 1 2 3 (Length.metres 400) WGS72
+    let delta = Local.deltaMetres 3000 2000 100
+
+    let pC = Local.destination pB frameB delta
+    -- 53°18'46.839"N,63°29'6.179"E 406.006017m (WGS72)
+
+    putStrLn ("NavLab, Example 2: B and delta to C\n\
+              \  pC = " ++ (show pC) ++ "\n")
+```
+
+### Example 3: ECEF-vector to geodetic latitude
+
+*Position B is given as an “ECEF-vector” p_EB_E (i.e. a vector from E, the center of the Earth, to B, decomposed in E).
+Find the geodetic latitude, longitude and height (latEB, lonEB and hEB), assuming WGS-84 ellipsoid.*
+
+```haskell
+example3 :: IO()
+example3 = do
+    let ecef = Geocentric.metresPos 5733900.0 (-6371000.0) 7008100.000000001 WGS84
+
+    let geod = Positions.toGeodetic ecef
+    -- 39°22'43.495"N,48°0'46.035"W 4702.059834295km (WGS84)
+
+    putStrLn ("NavLab, 3: ECEF-vector to geodetic latitude\n\
+              \  geodetic pos = " ++ (show geod) ++ "\n")
+```
+
+### Example 4: Geodetic latitude to ECEF-vector
+
+*Geodetic latitude, longitude and height are given for position B as latEB, lonEB and hEB, find the ECEF-vector
+for this position, p_EB_E.*
+
+```haskell
+example4 :: IO()
+example4 = do
+    let geod = Geodetic.latLongHeightPos 1 2 (Length.metres 3) WGS84
+
+    let ecef = Positions.toGeocentric geod
+    -- Position {gx = 6373.290277218km, gy = 222.560200675km, gz = 110.568827182km, model = WGS84}
+
+    putStrLn ("NavLab, 4: Geodetic latitude to ECEF-vector\n\
+              \  geocentric pos = " ++ (show ecef) ++ "\n")
+```
+
+### Example 5: Surface distance
+
+*Find the surface distance sAB (i.e. great circle distance) between two positions A and B. The heights of A and B are
+ignored, i.e. if they don’t have zero height, we seek the distance between the points that are at the surface of the
+Earth, directly above/below A and B. The Euclidean distance (chord length) dAB should also be found.
+Use Earth radius 6371e3 m. Compare the results with exact calculations for the WGS-84 ellipsoid.*
+
+```haskell
+example5 :: IO()
+example5 = do
+    let pA = Geodetic.s84Pos 88 0
+    let pB = Geodetic.s84Pos 89 (-170)
+
+    let distance = GreatCircle.distance pA pB
+    -- 332.456901835km
+
+    putStrLn ("NavLab, 5: Surface distance\n\
+              \  distance = " ++ (show distance) ++ "\n")
+```
+
+### Example 6: Interpolated position
+
+*Given the position of B at time t0 and t1, n_EB_E(t0) and n_EB_E(t1).*
+
+*Find an interpolated position at time ti, n_EB_E(ti). All positions are given as n-vectors.*
+
+```haskell
+example6 :: IO()
+example6 = do
+    let pA = Geodetic.s84Pos 89 0
+    let pB = Geodetic.s84Pos 89 180
+    let f = 0.6
+
+    let interpolated = GreatCircle.interpolated pA pB f
+    -- 89°47'59.929"N,180°0'0.000"E (S84)
+
+    putStrLn ("NavLab, 6: Interpolated position\n\
+              \  interpolated = " ++ (show interpolated) ++ "\n")
+```
+
+### Example 7: Mean position
+
+*Three positions A, B, and C are given as n-vectors n_EA_E, n_EB_E, and n_EC_E. Find the mean position, M, given as
+n_EM_E. Note that the calculation is independent of the depths of the positions.*
+
+```haskell
+example7 :: IO()
+example7 = do
+    let ps =
+            [ Geodetic.s84Pos 90 0
+            , Geodetic.s84Pos 60 10
+            , Geodetic.s84Pos 50 (-20)
+            ]
+
+    let mean = GreatCircle.mean ps
+    -- Just 67°14'10.150"N,6°55'3.040"W (S84)
+
+    putStrLn ("NavLab, Example 7: Mean position\n\
+              \  mean = " ++ (show mean) ++ "\n")
+```
+
+### Example 8: A and azimuth/distance to B
+
+*We have an initial position A, direction of travel given as an azimuth (bearing) relative to north (clockwise), and finally the distance to travel along a great circle given as sAB. Use Earth radius 6371e3 m to find the destination
+point B.*
+
+*In geodesy this is known as “The first geodetic problem” or “The direct geodetic problem” for a sphere, and we see that this is similar to Example 2, but now the delta is given as an azimuth and a great circle distance.
+(“The second/inverse geodetic problem” for a sphere is already solved in Examples 1 and 5.)*
+
+```haskell
+example8 :: IO()
+example8 = do
+    let p = Geodetic.s84Pos 80 (-90)
+    let bearing = Angle.decimalDegrees 200
+    let distance = Length.metres 1000
+
+    let dest = GreatCircle.destination p bearing distance
+    -- 79°59'29.575"N,90°1'3.714"W (S84)
+
+    putStrLn ("NavLab, Example 8: A and azimuth/distance to B\n\
+              \  destination = " ++ (show dest) ++ "\n")
+```
+
+### Example 9: Intersection of two paths
+
+*Define a path from two given positions (at the surface of a spherical Earth), as the great circle that goes through the two points.*
+
+*Path A is given by A1 and A2, while path B is given by B1 and B2.*
+
+*Find the position C where the two great circles intersect.*
+
+```haskell
+example9 :: IO()
+example9 = do
+    let a1 = Geodetic.s84Pos 51.885 0.235
+    let a2 = Geodetic.s84Pos 48.269 13.093
+    let b1 = Geodetic.s84Pos 49.008 2.549
+    let b2 = Geodetic.s84Pos 56.283 11.304
+
+    let ga = GreatCircle.through a1 a2
+    let gb = GreatCircle.through b1 b2
+    let intersections = GreatCircle.intersections <$> ga <*> gb
+    -- Just (Just (50°54'6.260"N,4°29'39.052"E (S84),50°54'6.260"S,175°30'20.947"W (S84)))
+
+    let ma = GreatCircle.minorArc a1 a2
+    let mb = GreatCircle.minorArc b1 b2
+    let intersection = GreatCircle.intersection <$> ma <*> mb
+    -- Just (Just 50°54'6.260"N,4°29'39.052"E (S84))
+
+    putStrLn ("NavLab, Example 9: Intersection of two paths\n\
+              \  great circle intersections = " ++ (show intersections) ++ "\n\
+              \  minor arc intersection     = " ++ (show intersection) ++ "\n")
+```
+
+### Example 10: Cross track distance
+
+*Path A is given by the two positions A1 and A2 (similar to the previous example).
+
+*Find the cross track distance sxt between the path A (i.e. the great circle through A1 and A2) and the position B
+(i.e. the shortest distance at the surface, between the great circle and B).*
+
+```haskell
+example10 :: IO()
+example10 = do
+    let p = Geodetic.s84Pos 1 0.1
+    let gc = GreatCircle.through (Geodetic.s84Pos 0 0) (Geodetic.s84Pos 10 0)
+
+    let sxt = fmap (\g -> GreatCircle.crossTrackDistance p g) gc
+    -- Just 11.117814411km
+
+    putStrLn ("NavLab, Example 10: Cross track distance\n\
+              \  cross track distance = " ++ (show sxt) ++ "\n")
+```
+
+## Solutions to the geodesic problems (Vincenty)
+
+### Example 11: Inverse problem
+
+*Given the coordinates of the two points (Φ1, L1) and (Φ2, L2), the inverse problem finds the azimuths α1, α2 and the ellipsoidal distance s.*
+
+```haskell
+example11 :: IO()
+example11 = do
+    let pA = Geodetic.wgs84Pos 88 0
+    let pB = Geodetic.wgs84Pos 89 (-170)
+
+    let inv = Geodesic.inverse pA pB
+    let initialBearing = fmap Geodesic.initialBearing inv
+    -- Just (Just 356°40'8.701")
+
+    let finalBearing = fmap Geodesic.finalBearing inv
+    -- Just (Just 186°40'19.615")
+
+    let distance = fmap Geodesic.length inv
+    -- Just 333.947509469km
+
+    putStrLn ("Geodesic, Example 11: Inverse problem\n\
+              \    initial bearing = " ++ (show initialBearing) ++ "\n\
+              \    final bearing   = " ++ (show finalBearing) ++ "\n\
+              \    distance        = " ++ (show distance) ++ "\n")
+```
+
+### Example 12: Direct problem
+
+*Given an initial point (Φ1, L1) and initial azimuth, α1, and a distance, s, along the geodesic the problem is to find the end point (Φ2, L2) and azimuth, α2.*
+
+```haskell
+example12 :: IO()
+example12 = do
+    let p = Geodetic.wgs84Pos 80 (-90)
+    let bearing = Angle.decimalDegrees 200
+    let distance = Length.metres 1000
+
+    let dct = Geodesic.direct p bearing distance
+    let destination = fmap Geodesic.endPosition dct
+    -- Just 79°59'29.701"N,90°1'3.436"W (WGS84)
+
+    let finalBearing = fmap Geodesic.finalBearing dct
+    -- Just (Just 199°58'57.528")
+
+    putStrLn ("Geodesic, Example 12: Direct problem\n\
+              \    destination   = " ++ (show destination) ++ "\n\
+              \    final bearing = " ++ (show finalBearing) ++ "\n")
+```
+
+## Solutions to kinematics problems
+
+### Example 13: Closest point of approach
+
+*The Closest Point of Approach (CPA) refers to the positions at which two dynamically moving objects reach their
+closest possible distance.*
+
+```haskell
+example13 :: IO()
+example13 = do
+    let ownship = Kinematics.Track
+                     (Geodetic.s84Pos 20 (-60))
+                     (Angle.decimalDegrees 10)
+                     (Speed.knots 15)
+    let intruder = Kinematics.Track
+                       (Geodetic.s84Pos 34 (-50))
+                       (Angle.decimalDegrees 220)
+                       (Speed.knots 300)
+
+    let cpa = Kinematics.cpa ownship intruder
+    let timeToCpa = fmap Kinematics.timeToCpa cpa
+    -- Just 3H9M56.155S
+
+    let distanceAtCpa = fmap Kinematics.distanceAtCpa cpa
+    -- Just 124.231730834km
+
+    let cpaOwnshipPosition = fmap Kinematics.cpaOwnshipPosition cpa
+    -- Just 20°46'43.641"N,59°51'11.225"W (S84)
+
+    let cpaIntruderPosition = fmap Kinematics.cpaIntruderPosition cpa
+    -- Just 21°24'8.523"N,60°50'48.159"W (S84)
+
+    putStrLn ("Kinematics, Example 13: Closest point of approach\n\
+              \    time to CPA      = " ++ (show timeToCpa) ++ "\n\
+              \    distance at CPA  = " ++ (show distanceAtCpa) ++ "\n\
+              \    CPA ownship pos  = " ++ (show cpaOwnshipPosition) ++ "\n\
+              \    CPA intruder pos = " ++ (show cpaIntruderPosition) ++ "\n")
+```
+
+### Example 14: Speed required to intercept target
+
+*Inputs are the initial latitude and longitude of an interceptor and a target, and the target course and speed.
+Also input is the time of the desired intercept. Outputs are the speed required of the interceptor, the course
+of the interceptor, the distance travelled to intercept, and the latitude and longitude of the intercept.*
+
+```haskell
+example14 :: IO()
+example14 = do
+    let track = Kinematics.Track
+                    (Geodetic.s84Pos 34 (-50))
+                    (Angle.decimalDegrees 220)
+                    (Speed.knots 600)
+    let interceptor = Geodetic.s84Pos 20 (-60)
+    let interceptTime = Duration.seconds 2700
+
+    let intercept = Kinematics.interceptByTime track interceptor interceptTime
+    let distanceToIntercept = fmap Kinematics.distanceToIntercept intercept
+    -- Just 1015.302358852km
+
+    let interceptPosition = fmap Kinematics.interceptPosition intercept
+    -- Just 28°8'12.046"N,55°27'21.411"W (S84)
+
+    let interceptorBearing = fmap Kinematics.interceptorBearing intercept
+    -- Just 26°7'11.649"
+
+    let interceptorSpeed = fmap Kinematics.interceptorSpeed intercept
+    -- Just 1353.736478km/h
+
+    putStrLn ("Kinematics, Example 14: Speed required to intercept target\n\
+              \    distance to intercept = " ++ (show distanceToIntercept) ++ "\n\
+              \    intercept position    = " ++ (show interceptPosition) ++ "\n\
+              \    interceptor bearing   = " ++ (show interceptorBearing) ++ "\n\
+              \    interceptor speed     = " ++ (show interceptorSpeed) ++ "\n")
+```
+
+### Example 15: Time required to intercept target
+
+*Inputs are the initial latitude and longitude of an interceptor and a target, and the target course and speed. For a
+given interceptor speed, it may or may not be possible to make an intercept.*
+
+*The first algorithm is to compute the minimum interceptor speed required to achieve intercept and the time required to
+make such and intercept.*
+
+*The second algorithm queries the user to input an interceptor speed. If the speed is at least that required for intercept
+then the time required to intercept is computed.*
+
+```haskell
+example15 :: IO()
+example15 = do
+    let track = Kinematics.Track
+                    (Geodetic.s84Pos 34 (-50))
+                    (Angle.decimalDegrees 220)
+                    (Speed.knots 600)
+    let interceptor = Geodetic.s84Pos 20 (-60)
+
+    let minIntercept = Kinematics.intercept track interceptor
+    let minTimeToIntercept = fmap Kinematics.timeToIntercept minIntercept
+    -- Just 1H39M53.831S
+
+    let minDistanceToIntercept = fmap Kinematics.distanceToIntercept minIntercept
+    -- Just 162.294627463km
+
+    let minInterceptPosition = fmap Kinematics.interceptPosition minIntercept
+    -- Just 20°43'42.305"N,61°20'56.848"W (S84)
+
+    let minInterceptorBearing = fmap Kinematics.interceptorBearing minIntercept
+    -- Just 300°10'18.053"
+
+    let minInterceptorSpeed = fmap Kinematics.interceptorSpeed minIntercept
+    -- Just 97.476999km/h
+
+    putStrLn ("Kinematics, Example 15: Time required to intercept target (min)\n\
+              \    time to intercept     = " ++ (show minTimeToIntercept) ++ "\n\
+              \    distance to intercept = " ++ (show minDistanceToIntercept) ++ "\n\
+              \    intercept position    = " ++ (show minInterceptPosition) ++ "\n\
+              \    interceptor bearing   = " ++ (show minInterceptorBearing) ++ "\n\
+              \    interceptor speed     = " ++ (show minInterceptorSpeed) ++ "\n")
+
+    let interceptSpeed = Speed.knots 700
+
+    let intercept = Kinematics.interceptBySpeed track interceptor interceptSpeed
+    let timeToIntercept = fmap Kinematics.timeToIntercept intercept
+    -- Just 0H46M4.692S
+
+    let distanceToIntercept = fmap Kinematics.distanceToIntercept intercept
+    -- Just 995.596069189km
+
+    let interceptPosition = fmap Kinematics.interceptPosition intercept
+    -- Just 27°59'36.764"N,55°34'43.852"W(S84)
+
+    let interceptorBearing = fmap Kinematics.interceptorBearing intercept
+    -- Just 25°56'7.484"
+
+    putStrLn ("Kinematics, Example 15: Time required to intercept target\n\
+              \    time to intercept     = " ++ (show timeToIntercept) ++ "\n\
+              \    distance to intercept = " ++ (show distanceToIntercept) ++ "\n\
+              \    intercept position    = " ++ (show interceptPosition) ++ "\n\
+              \    interceptor bearing   = " ++ (show interceptorBearing) ++ "\n")
+```
+## Solutions to coordinates transformation problems
+
+### Example 16: Transformation between fixed models 
+
+Convert the coordinates of a geocentric position between the WGS84 model and the NAD83 model.
+
+```haskell
+example16 :: IO()
+example16 = do
+    let pWGS84 = Geocentric.metresPos 4193790.895437 454436.195118 4768166.813801 WGS84 
+
+    -- using explicit parameters:
+    let tx = Tx.params Txs.from_WGS84_to_NAD83 
+    let pNAD83 = Positions.transform' pWGS84 NAD83 tx
+    -- Position {gx = 4193.792080781km, gy = 454.433921298km, gz = 4768.16615479km, model = NAD83}
+
+    -- using the transformation graph:
+    let pNAD83' = Positions.transform pWGS84 NAD83 Txs.fixed
+    -- Just (Position {gx = 4193.792080781km, gy = 454.433921298km, gz = 4768.16615479km, model = NAD83})
+
+    putStrLn ("Coordinates transformation, Example 16: WGS84 -> NAD83\n\
+              \    WGS84 = " ++ (show pWGS84) ++ "\n\
+              \    NAD83 = " ++ (show pNAD83) ++ "\n\
+              \    NAD83 = " ++ (show pNAD83') ++ "\n")
+```
+
+### Example 17: Transformation between time dependent models 
+
+Convert the coordinates of a geocentric position between the ITRF2014, ITRF2000 and NAD83 (CORS96) models 
+
+```haskell
+example17 :: IO()
+example17 = do
+    let pITRF2014 = Geocentric.metresPos 4193790.895437 454436.195118 4768166.813801 ITRF2014 
+
+    -- using explicit parameters:
+    let tx = Tx.params Txs.from_ITRF2014_to_ITRF2000
+    let pITRF2000 = Positions.transformAt' pITRF2014 (Epoch 2014.0) ITRF2000 tx 
+    -- Position {gx = 4193.790907273km, gy = 454.436197881km, gz = 4768.166792308km, model = ITRF2000}
+
+    -- using the transformation graph (goes via ITRF2000):
+    let pNAD83 = Positions.transformAt pITRF2014 (Epoch 2014.0) NAD83_CORS96 Txs.timeDependent 
+    -- Just (Position {gx = 4193.791801305km, gy = 454.433876376km, gz = 4768.166397281km, model = NAD83_CORS96})
+
+    putStrLn ("Coordinates transformation, Example 17: ITRF2014 -> ITRF2000 -> NAD83 (CORS96)\n\
+              \    ITRF2014       = " ++ (show pITRF2014) ++ "\n\
+              \    ITRF2000       = " ++ (show pITRF2000) ++ "\n\
+              \    NAD83 (CORS96) = " ++ (show pNAD83) ++ "\n")
+```
+***
+
+## Solutions to polygon triangulation
+
+### Example 18: Triangulation of a simple polygon
+
+```haskell
+example18 :: IO()
+example18 = do
+    let p = Polygon.simple
+                [ Geodetic.s84Pos 45 45
+                , Geodetic.s84Pos (-45) 45
+                , Geodetic.s84Pos (-45) (-45)
+                , Geodetic.s84Pos 45 (-45)
+                ]
+
+    let ts = fmap Polygon.triangulate p
+    -- Right [ Triangle 45°0'0.000"N,45°0'0.000"W (S84) 45°0'0.000"N,45°0'0.000"E (S84) 45°0'0.000"S,45°0'0.000"E (S84)
+    --       , Triangle 45°0'0.000"S,45°0'0.000"E (S84) 45°0'0.000"S,45°0'0.000"W (S84) 45°0'0.000"N,45°0'0.000"W (S84)
+    --       ]
+
+    putStrLn ("Polygon triangulation, Example 18: Simple polygon\n\
+              \    triangles = " ++ (show ts) ++ "\n")   
+```
+
+### Example 19: Triangulation of a circle
+
+```haskell
+example19 :: IO()
+example19 = do
+    let p = Polygon.circle (Geodetic.s84Pos 0 0) (Length.kilometres 10) 10
+
+    let ts = fmap Polygon.triangulate p
+    -- Right [ Triangle 0°4'21.923"N,0°3'10.298"W (S84) 0°5'23.755"N,0°0'0.000"E (S84) 0°4'21.923"N,0°3'10.298"E (S84)
+    --       , Triangle 0°4'21.923"N,0°3'10.298"W (S84) 0°4'21.923"N,0°3'10.298"E (S84) 0°1'40.045"N,0°5'7.909"E (S84)
+    --       , Triangle 0°4'21.923"N,0°3'10.298"W (S84) 0°1'40.045"N,0°5'7.909"E (S84) 0°1'40.045"S,0°5'7.909"E (S84)
+    --       , Triangle 0°4'21.923"N,0°3'10.298"W (S84) 0°1'40.045"S,0°5'7.909"E (S84) 0°4'21.923"S,0°3'10.298"E (S84)
+    --       , Triangle 0°4'21.923"N,0°3'10.298"W (S84) 0°4'21.923"S,0°3'10.298"E (S84) 0°5'23.755"S,0°0'0.000"E (S84)
+    --       , Triangle 0°4'21.923"N,0°3'10.298"W (S84) 0°5'23.755"S,0°0'0.000"E (S84) 0°4'21.923"S,0°3'10.298"W (S84)
+    --       , Triangle 0°4'21.923"N,0°3'10.298"W (S84) 0°4'21.923"S,0°3'10.298"W (S84) 0°1'40.045"S,0°5'7.909"W (S84)
+    --       , Triangle 0°1'40.045"S,0°5'7.909"W (S84) 0°1'40.045"N,0°5'7.909"W (S84) 0°4'21.923"N,0°3'10.298"W (S84)
+    --       ]
+
+    putStrLn ("Polygon triangulation, Example 19: Circle\n\
+              \    triangles = " ++ (show ts) ++ "\n")   
+```
+
+### Example 20: Triangulation of an arc
+
+```haskell
+example20 :: IO()
+example20 = do
+    let p = Polygon.arc (Geodetic.s84Pos 0 0) (Length.kilometres 10) (Angle.decimalDegrees 10) (Angle.decimalDegrees  20) 5
+
+    let ts = fmap Polygon.triangulate p
+    -- Right [ Triangle 0°5'4.230"N,0°1'50.730"E (S84) 0°5'18.836"N,0°0'56.219"E (S84) 0°5'16.081"N,0°1'10.073"E (S84)
+    --       , Triangle 0°5'4.230"N,0°1'50.730"E (S84) 0°5'16.081"N,0°1'10.073"E (S84) 0°5'12.723"N,0°1'23.794"E (S84)
+    --       , Triangle 0°5'12.723"N,0°1'23.794"E (S84) 0°5'8.770"N,0°1'37.355"E (S84) 0°5'4.230"N,0°1'50.730"E (S84)
+    --       ]
+
+    putStrLn ("Polygon triangulation, Example 20: Arc\n\
+              \    triangles = " ++ (show ts) ++ "\n")   
+```
+
+```haskell
+main :: IO()
+main = do
+    example1
+    example2
+    example3
+    example4
+    example5
+    example6
+    example7
+    example8
+    example9
+    example10
+    example11
+    example12
+    example13
+    example14
+    example15
+    example16
+    example17
+    example18
+    example19
+    example20
+```
README.md view
@@ -1,6 +1,6 @@ # Jord - Geographical Position Calculations
 
-[![travis build status](https://img.shields.io/travis/ofmooseandmen/jord/master.svg?label=travis+build)](https://travis-ci.org/ofmooseandmen/jord)
+[![GitHub CI](https://github.com/ofmooseandmen/jord/workflows/CI/badge.svg)](https://github.com/ofmooseandmen/jord/actions)
 [![Hackage](https://img.shields.io/hackage/v/jord.svg)](http://hackage.haskell.org/package/jord)
 [![license](https://img.shields.io/badge/license-BSD3-lightgray.svg)](https://opensource.org/licenses/BSD-3-Clause)
 
@@ -13,30 +13,64 @@ 
 - conversion between ECEF (earth-centred, earth-fixed), latitude/longitude and [*n*-vector](https://www.navlab.net/nvector) positions for spherical and ellipsoidal earth model,
 - conversion between latitude/longitude and *n*-vector positions,
-- local, body and north, east, down Frames: delta between positions, target position from reference position and delta,
+- local frames (body; local level, wander azimuth; north, east, down): delta between positions, target position from reference position and delta,
 - great circles: surface distance, initial & final bearing, interpolated position, great circle intersections, cross track distance, ...,
 - geodesic: surface distance, initial & final bearing and destination,
 - kinematics: position from p0, bearing and speed, closest point of approach between tracks, intercept (time, speed, minimum speed),
 - transformation between coordinate systems (both fixed and time-dependent).
+- polygon triangulation
 
 ## How do I build it?
 
-If you have [Stack](https://docs.haskellstack.org/en/stable/README/),
-then:
+If you have [Stack](https://docs.haskellstack.org/en/stable/README/), then:
 ```sh
 $ stack build --test
 ```
 
+If you have [Cabal](https://www.haskell.org/cabal/), then:
+```sh
+$ cabal v2-build
+$ cabal v2-test
+```
+
 ## How do I use it?
 
 [See documentation on Hackage](http://hackage.haskell.org/package/jord/docs/Data-Geo-Jord.html)
 
+Import the core functional modules as qualified:
+
+```haskell
+import qualified Data.Geo.Jord.Angle as Angle
+import qualified Data.Geo.Jord.Duration as Duration
+import qualified Data.Geo.Jord.Geocentric as Geocentric
+import qualified Data.Geo.Jord.Geodetic as Geodetic
+import qualified Data.Geo.Jord.Geodesic as Geodesic
+import qualified Data.Geo.Jord.GreatCircle as GreatCircle
+import qualified Data.Geo.Jord.Kinematics as Kinematics
+import qualified Data.Geo.Jord.Length as Length
+import qualified Data.Geo.Jord.Local as Local
+import qualified Data.Geo.Jord.Polygon as Polygon
+import qualified Data.Geo.Jord.Positions as Positions
+import qualified Data.Geo.Jord.Speed as Speed
+import qualified Data.Geo.Jord.Tx as Tx
+```
+
+Note: modules can be selectively imported as non-qualified, but i.m.o. the code reads better when modules are imported qualified.
+
+Import models and transformation parameters:
+
+```haskell
+import Data.Geo.Jord.Model (Epoch(..))
+import Data.Geo.Jord.Models
+import qualified Data.Geo.Jord.Txs as Txs
+```
+
 ## Solutions to the 10 examples from [NavLab](https://www.navlab.net/nvector)
 
 ### Example 1: A and B to delta
 
 *Given two positions, A and B as latitudes, longitudes and depths relative to Earth, E.*
- 
+
 *Find the exact vector between the two positions, given in meters north, east, and down, and find the direction (azimuth)
 to B, relative to north. Assume WGS-84 ellipsoid. The given depths are from the ellipsoid surface. Use position A to
 define north, east, and down directions. (Due to the curvature of Earth and different directions to the North Pole,
@@ -44,40 +78,56 @@ for the north and east directions to be defined.)*
 
 ```haskell
-import Data.Geo.Jord.LocalFrames
+example1 :: IO()
+example1 = do
+    let pA = Geodetic.latLongHeightPos 1 2 (Length.metres 3) WGS84
+    let pB = Geodetic.latLongHeightPos 4 5 (Length.metres 6) WGS84
 
-posA = wgs84Pos 1 2 (metres 3)
-posB = wgs84Pos 4 5 (metres 6)
+    let ned = Local.nedBetween pA pB
+    -- Ned {north = 331.730863099km, east = 332.998501491km, down = 17.39830421km}
 
-delta = nedBetween posA posB 
--- > Ned (Vector3d {vx = 331730.234781, vy = 332997.874989, vz = 17404.271362})
-slantRange delta 
--- > 470.356717903km
-bearing delta 
--- > 45°6'33.347"
-elevation delta 
--- > -2°7'14.011"
+    let slantRange = Local.slantRange ned
+    -- 470.357383823km
+
+    let bearing = Local.bearing ned
+    -- 45°6'33.346"
+
+    let elevation = Local.elevation ned
+    -- -2°7'11.381"
+
+    putStrLn ("NavLab, Example1: A and B to delta\n\
+              \  delta      = " ++ (show ned) ++ "\n\
+              \  slantRange = " ++ (show slantRange) ++ "\n\
+              \  bearing    = " ++ (show bearing) ++ "\n\
+              \  elevation  = " ++ (show elevation) ++ "\n")
 ```
 
-### Example 2: A and B to delta
+### Example 2: B and delta to C
 
 *A radar or sonar attached to a vehicle B (Body coordinate frame) measures the distance and direction to an object C.
 We assume that the distance and two angles (typically bearing and elevation relative to B) are already combined to the
 vector p_BC_B (i.e. the vector from B to C, decomposed in B). The position of B is given as n_EB_E and z_EB, and the
 orientation (attitude) of B is given as R_NB (this rotation matrix can be found from roll/pitch/yaw by using zyx2R).*
- 
+
 *Find the exact position of object C as n-vector and depth ( n_EC_E and z_EC ), assuming Earth ellipsoid with semi-major
 axis a and flattening f. For WGS-72, use a = 6 378 135 m and f = 1/298.26.*
 
 ```haskell
-import Data.Geo.Jord.LocalFrames
+example2 :: IO()
+example2 = do
+    let frameB =
+            Local.frameB
+                (Angle.decimalDegrees 40)
+                (Angle.decimalDegrees 20)
+                (Angle.decimalDegrees 30)
+    let pB = Geodetic.nvectorHeightPos 1 2 3 (Length.metres 400) WGS72
+    let delta = Local.deltaMetres 3000 2000 100
 
-f = frameB (decimalDegrees 40) (decimalDegrees 20) (decimalDegrees 30)
-p = nvectorHeightPos 1 2 3 (metres 400) WGS72
-d = deltaMetres 3000 2000 100
+    let pC = Local.destination pB frameB delta
+    -- 53°18'46.839"N,63°29'6.179"E 406.006017m (WGS72)
 
-target p f d
--- > 53°18'46.839"N,63°29'6.179"E 406.006018m (WGS72)
+    putStrLn ("NavLab, Example 2: B and delta to C\n\
+              \  pC = " ++ (show pC) ++ "\n")
 ```
 
 ### Example 3: ECEF-vector to geodetic latitude
@@ -86,10 +136,15 @@ Find the geodetic latitude, longitude and height (latEB, lonEB and hEB), assuming WGS-84 ellipsoid.*
 
 ```haskell
-import Data.Geo.Jord.Position
+example3 :: IO()
+example3 = do
+    let ecef = Geocentric.metresPos 5733900.0 (-6371000.0) 7008100.000000001 WGS84
 
-geocentricMetresPos 5733900.0 (-6371000.0) 7008100.000000001 WGS84
--- > 39°22'43.495"N,48°0'46.035"W 4702.059834295km (WGS84)
+    let geod = Positions.toGeodetic ecef
+    -- 39°22'43.495"N,48°0'46.035"W 4702.059834295km (WGS84)
+
+    putStrLn ("NavLab, 3: ECEF-vector to geodetic latitude\n\
+              \  geodetic pos = " ++ (show geod) ++ "\n")
 ```
 
 ### Example 4: Geodetic latitude to ECEF-vector
@@ -98,10 +153,15 @@ for this position, p_EB_E.*
 
 ```haskell
-import Data.Geo.Jord.Position
+example4 :: IO()
+example4 = do
+    let geod = Geodetic.latLongHeightPos 1 2 (Length.metres 3) WGS84
 
-gcvec (wgs84Pos 1 2 (metres 3))
--- > Vector3d {vx = 6373290.277218281, vy = 222560.20067473655, vz = 110568.82718177968}
+    let ecef = Positions.toGeocentric geod
+    -- Position {gx = 6373.290277218km, gy = 222.560200675km, gz = 110.568827182km, model = WGS84}
+
+    putStrLn ("NavLab, 4: Geodetic latitude to ECEF-vector\n\
+              \  geocentric pos = " ++ (show ecef) ++ "\n")
 ```
 
 ### Example 5: Surface distance
@@ -112,42 +172,36 @@ Use Earth radius 6371e3 m. Compare the results with exact calculations for the WGS-84 ellipsoid.*
 
 ```haskell
-import Data.Geo.Jord.GreatCircle
-
-posA = s84Pos 88 0 zero
-posB = s84Pos 89 (-170) zero
-
-surfaceDistance posA posB
--- > 332.456901835km
-```
-
-*Exact solution for the WGS84 ellipsoid*
-
-```haskell
-import Data.Geo.Jord.Geodesic
+example5 :: IO()
+example5 = do
+    let pA = Geodetic.s84Pos 88 0
+    let pB = Geodetic.s84Pos 89 (-170)
 
-posA = wgs84Pos 88 0 zero
-posB = wgs84Pos 89 (-170) zero
+    let distance = GreatCircle.distance pA pB
+    -- 332.456901835km
 
-surfaceDistance posA posB
--- > Just 333.947509469km
+    putStrLn ("NavLab, 5: Surface distance\n\
+              \  distance = " ++ (show distance) ++ "\n")
 ```
 
 ### Example 6: Interpolated position
 
 *Given the position of B at time t0 and t1, n_EB_E(t0) and n_EB_E(t1).*
- 
+
 *Find an interpolated position at time ti, n_EB_E(ti). All positions are given as n-vectors.*
 
 ```haskell
-import Data.Geo.Jord.GreatCircle
+example6 :: IO()
+example6 = do
+    let pA = Geodetic.s84Pos 89 0
+    let pB = Geodetic.s84Pos 89 180
+    let f = 0.6
 
-posA = s84Pos 89 0 zero
-posB = s84Pos 89 180 zero
-f = (16 - 10) / (20 - 10) :: Double
+    let interpolated = GreatCircle.interpolated pA pB f
+    -- 89°47'59.929"N,180°0'0.000"E (S84)
 
-interpolate posA posB f
--- > 89°47'59.929"N,180°0'0.000"E 0.0m (S84)
+    putStrLn ("NavLab, 6: Interpolated position\n\
+              \  interpolated = " ++ (show interpolated) ++ "\n")
 ```
 
 ### Example 7: Mean position
@@ -156,134 +210,221 @@ n_EM_E. Note that the calculation is independent of the depths of the positions.*
 
 ```haskell
-import Data.Geo.Jord.GreatCircle
+example7 :: IO()
+example7 = do
+    let ps =
+            [ Geodetic.s84Pos 90 0
+            , Geodetic.s84Pos 60 10
+            , Geodetic.s84Pos 50 (-20)
+            ]
 
-ps = [s84Pos 90 0 zero, s84Pos 60 10 zero, s84Pos 50 (-20) zero]
+    let mean = GreatCircle.mean ps
+    -- Just 67°14'10.150"N,6°55'3.040"W (S84)
 
-mean ps
--- > Just 67°14'10.150"N,6°55'3.040"W 0.0m (S84)
+    putStrLn ("NavLab, Example 7: Mean position\n\
+              \  mean = " ++ (show mean) ++ "\n")
 ```
 
 ### Example 8: A and azimuth/distance to B
 
-*We have an initial position A, direction of travel given as an azimuth (bearing) relative to north (clockwise), and
-finally the distance to travel along a great circle given as sAB. Use Earth radius 6371e3 m to find the destination
+*We have an initial position A, direction of travel given as an azimuth (bearing) relative to north (clockwise), and finally the distance to travel along a great circle given as sAB. Use Earth radius 6371e3 m to find the destination
 point B.*
- 
-*In geodesy this is known as “The first geodetic problem” or “The direct geodetic problem” for a sphere, and we see
-that this is similar to Example 2, but now the delta is given as an azimuth and a great circle distance.
-(“The second/inverse geodetic problem” for a sphere is already solved in Examples 1 and 5.)*
 
-```haskell
-import Data.Geo.Jord.GreatCircle
-
-p = s84Pos 80 (-90) zero
-
-destination p (decimalDegrees 200) (metres 1000)
--- > 79°59'29.575"N,90°1'3.714"W 0.0m (S84)
-```
-
-*Exact solution for the WGS84 ellipsoid*
+*In geodesy this is known as “The first geodetic problem” or “The direct geodetic problem” for a sphere, and we see that this is similar to Example 2, but now the delta is given as an azimuth and a great circle distance.
+(“The second/inverse geodetic problem” for a sphere is already solved in Examples 1 and 5.)*
 
 ```haskell
-import Data.Geo.Jord.Geodesic
+example8 :: IO()
+example8 = do
+    let p = Geodetic.s84Pos 80 (-90)
+    let bearing = Angle.decimalDegrees 200
+    let distance = Length.metres 1000
 
-p = wgs84Pos 80 (-90) zero
+    let dest = GreatCircle.destination p bearing distance
+    -- 79°59'29.575"N,90°1'3.714"W (S84)
 
-destination p (decimalDegrees 200) (metres 1000)
--- > Just 79°59'29.701"N,90°1'3.436"W 0.0m (WGS84)
+    putStrLn ("NavLab, Example 8: A and azimuth/distance to B\n\
+              \  destination = " ++ (show dest) ++ "\n")
 ```
 
 ### Example 9: Intersection of two paths
 
-*Define a path from two given positions (at the surface of a spherical Earth), as the great circle that goes through
-the two points.*
- 
+*Define a path from two given positions (at the surface of a spherical Earth), as the great circle that goes through the two points.*
+
 *Path A is given by A1 and A2, while path B is given by B1 and B2.*
- 
+
 *Find the position C where the two great circles intersect.*
 
 ```haskell
-import Control.Monad (join)
-import Data.Geo.Jord.GreatCircle
+example9 :: IO()
+example9 = do
+    let a1 = Geodetic.s84Pos 51.885 0.235
+    let a2 = Geodetic.s84Pos 48.269 13.093
+    let b1 = Geodetic.s84Pos 49.008 2.549
+    let b2 = Geodetic.s84Pos 56.283 11.304
 
-a1 = s84Pos 51.885 0.235 zero
-a2 = s84Pos 48.269 13.093 zero
-b1 = s84Pos 49.008 2.549 zero
-b2 = s84Pos 56.283 11.304 zero
+    let ga = GreatCircle.through a1 a2
+    let gb = GreatCircle.through b1 b2
+    let intersections = GreatCircle.intersections <$> ga <*> gb
+    -- Just (Just (50°54'6.260"N,4°29'39.052"E (S84),50°54'6.260"S,175°30'20.947"W (S84)))
 
-ga = greatCircleThrough a1 a2
-gb = greatCircleThrough b1 b2
-join (intersections <$> ga <*> gb)
--- > Just (50°54'6.260"N,4°29'39.052"E 0.0m (S84),50°54'6.260"S,175°30'20.947"W 0.0m (S84))
+    let ma = GreatCircle.minorArc a1 a2
+    let mb = GreatCircle.minorArc b1 b2
+    let intersection = GreatCircle.intersection <$> ma <*> mb
+    -- Just (Just 50°54'6.260"N,4°29'39.052"E (S84))
 
-ma = minorArc a1 a2
-mb = minorArc b1 b2
-join (intersection <$> ma <*> mb)
--- > Just 50°54'6.260"N,4°29'39.052"E 0.0m (S84)
+    putStrLn ("NavLab, Example 9: Intersection of two paths\n\
+              \  great circle intersections = " ++ (show intersections) ++ "\n\
+              \  minor arc intersection     = " ++ (show intersection) ++ "\n")
 ```
 
 ### Example 10: Cross track distance
 
 *Path A is given by the two positions A1 and A2 (similar to the previous example).
- 
+
 *Find the cross track distance sxt between the path A (i.e. the great circle through A1 and A2) and the position B
 (i.e. the shortest distance at the surface, between the great circle and B).*
 
 ```haskell
-import Data.Geo.Jord.GreatCircle
+example10 :: IO()
+example10 = do
+    let p = Geodetic.s84Pos 1 0.1
+    let gc = GreatCircle.through (Geodetic.s84Pos 0 0) (Geodetic.s84Pos 10 0)
 
-p = s84Pos 1 0.1 zero
-gc = greatCircleThrough (s84Pos 0 0 zero) (s84Pos 10 0 zero)
+    let sxt = fmap (\g -> GreatCircle.crossTrackDistance p g) gc
+    -- Just 11.117814411km
 
-fmap (\g -> crossTrackDistance p g) gc
--- > Just 11.117814411km
+    putStrLn ("NavLab, Example 10: Cross track distance\n\
+              \  cross track distance = " ++ (show sxt) ++ "\n")
 ```
 
+## Solutions to the geodesic problems (Vincenty)
+
+### Example 11: Inverse problem
+
+*Given the coordinates of the two points (Φ1, L1) and (Φ2, L2), the inverse problem finds the azimuths α1, α2 and the ellipsoidal distance s.*
+
+```haskell
+example11 :: IO()
+example11 = do
+    let pA = Geodetic.wgs84Pos 88 0
+    let pB = Geodetic.wgs84Pos 89 (-170)
+
+    let inv = Geodesic.inverse pA pB
+    let initialBearing = fmap Geodesic.initialBearing inv
+    -- Just (Just 356°40'8.701")
+
+    let finalBearing = fmap Geodesic.finalBearing inv
+    -- Just (Just 186°40'19.615")
+
+    let distance = fmap Geodesic.length inv
+    -- Just 333.947509469km
+
+    putStrLn ("Geodesic, Example 11: Inverse problem\n\
+              \    initial bearing = " ++ (show initialBearing) ++ "\n\
+              \    final bearing   = " ++ (show finalBearing) ++ "\n\
+              \    distance        = " ++ (show distance) ++ "\n")
+```
+
+### Example 12: Direct problem
+
+*Given an initial point (Φ1, L1) and initial azimuth, α1, and a distance, s, along the geodesic the problem is to find the end point (Φ2, L2) and azimuth, α2.*
+
+```haskell
+example12 :: IO()
+example12 = do
+    let p = Geodetic.wgs84Pos 80 (-90)
+    let bearing = Angle.decimalDegrees 200
+    let distance = Length.metres 1000
+
+    let dct = Geodesic.direct p bearing distance
+    let destination = fmap Geodesic.endPosition dct
+    -- Just 79°59'29.701"N,90°1'3.436"W (WGS84)
+
+    let finalBearing = fmap Geodesic.finalBearing dct
+    -- Just (Just 199°58'57.528")
+
+    putStrLn ("Geodesic, Example 12: Direct problem\n\
+              \    destination   = " ++ (show destination) ++ "\n\
+              \    final bearing = " ++ (show finalBearing) ++ "\n")
+```
+
 ## Solutions to kinematics problems
 
-### Closest point of approach
+### Example 13: Closest point of approach
 
 *The Closest Point of Approach (CPA) refers to the positions at which two dynamically moving objects reach their
 closest possible distance.*
 
 ```haskell
-import Data.Geo.Jord.Kinematics
+example13 :: IO()
+example13 = do
+    let ownship = Kinematics.Track
+                     (Geodetic.s84Pos 20 (-60))
+                     (Angle.decimalDegrees 10)
+                     (Speed.knots 15)
+    let intruder = Kinematics.Track
+                       (Geodetic.s84Pos 34 (-50))
+                       (Angle.decimalDegrees 220)
+                       (Speed.knots 300)
 
-t1 = Track (s84Pos 20 (-60) zero) (decimalDegrees 10) (knots 15)
-t2 = Track (s84Pos 34 (-50) (metres 10000)) (decimalDegrees 220) (knots 300)
+    let cpa = Kinematics.cpa ownship intruder
+    let timeToCpa = fmap Kinematics.timeToCpa cpa
+    -- Just 3H9M56.155S
 
-cpa t1 t2
--- > Just (Cpa {
--- >     cpaTime = 3H9M56.155S,
--- >     cpaDistance = 124.231730834km,
--- >     cpaPosition1 = 20°46'43.641"N,59°51'11.225"W 0.0m (S84),
--- >     cpaPosition2 = 21°24'8.523"N,60°50'48.159"W 10000.0m (S84)})
+    let distanceAtCpa = fmap Kinematics.distanceAtCpa cpa
+    -- Just 124.231730834km
+
+    let cpaOwnshipPosition = fmap Kinematics.cpaOwnshipPosition cpa
+    -- Just 20°46'43.641"N,59°51'11.225"W (S84)
+
+    let cpaIntruderPosition = fmap Kinematics.cpaIntruderPosition cpa
+    -- Just 21°24'8.523"N,60°50'48.159"W (S84)
+
+    putStrLn ("Kinematics, Example 13: Closest point of approach\n\
+              \    time to CPA      = " ++ (show timeToCpa) ++ "\n\
+              \    distance at CPA  = " ++ (show distanceAtCpa) ++ "\n\
+              \    CPA ownship pos  = " ++ (show cpaOwnshipPosition) ++ "\n\
+              \    CPA intruder pos = " ++ (show cpaIntruderPosition) ++ "\n")
 ```
 
-### Time required to intercept target
+### Example 14: Speed required to intercept target
 
 *Inputs are the initial latitude and longitude of an interceptor and a target, and the target course and speed.
 Also input is the time of the desired intercept. Outputs are the speed required of the interceptor, the course
 of the interceptor, the distance travelled to intercept, and the latitude and longitude of the intercept.*
 
 ```haskell
-import Data.Geo.Jord.Kinematics
+example14 :: IO()
+example14 = do
+    let track = Kinematics.Track
+                    (Geodetic.s84Pos 34 (-50))
+                    (Angle.decimalDegrees 220)
+                    (Speed.knots 600)
+    let interceptor = Geodetic.s84Pos 20 (-60)
+    let interceptTime = Duration.seconds 2700
 
-t = Track (s84Pos 34 (-50) zero) (decimalDegrees 220) (knots 600)
-ip = s84Pos 20 (-60) zero
-d = seconds 2700
+    let intercept = Kinematics.interceptByTime track interceptor interceptTime
+    let distanceToIntercept = fmap Kinematics.distanceToIntercept intercept
+    -- Just 1015.302358852km
 
-interceptByTime t ip d
--- > Just (Intercept {
--- >     interceptTime = 0H45M0.000S,
--- >     interceptDistance = 1015.302358852km,
--- >     interceptPosition = 28°8'12.046"N,55°27'21.411"W 0.0m (S84),
--- >     interceptorBearing = 26°7'11.649",
--- >     interceptorSpeed = 1353.736478km/h})
+    let interceptPosition = fmap Kinematics.interceptPosition intercept
+    -- Just 28°8'12.046"N,55°27'21.411"W (S84)
+
+    let interceptorBearing = fmap Kinematics.interceptorBearing intercept
+    -- Just 26°7'11.649"
+
+    let interceptorSpeed = fmap Kinematics.interceptorSpeed intercept
+    -- Just 1353.736478km/h
+
+    putStrLn ("Kinematics, Example 14: Speed required to intercept target\n\
+              \    distance to intercept = " ++ (show distanceToIntercept) ++ "\n\
+              \    intercept position    = " ++ (show interceptPosition) ++ "\n\
+              \    interceptor bearing   = " ++ (show interceptorBearing) ++ "\n\
+              \    interceptor speed     = " ++ (show interceptorSpeed) ++ "\n")
 ```
 
-### Time required to intercept target
+### Example 15: Time required to intercept target
 
 *Inputs are the initial latitude and longitude of an interceptor and a target, and the target course and speed. For a
 given interceptor speed, it may or may not be possible to make an intercept.*
@@ -295,24 +436,192 @@ then the time required to intercept is computed.*
 
 ```haskell
-import Data.Geo.Jord.Kinematics
+example15 :: IO()
+example15 = do
+    let track = Kinematics.Track
+                    (Geodetic.s84Pos 34 (-50))
+                    (Angle.decimalDegrees 220)
+                    (Speed.knots 600)
+    let interceptor = Geodetic.s84Pos 20 (-60)
 
-t = Track (s84Pos 34 (-50) zero) (decimalDegrees 220) (knots 600)
-ip = s84Pos 20 (-60) zero
+    let minIntercept = Kinematics.intercept track interceptor
+    let minTimeToIntercept = fmap Kinematics.timeToIntercept minIntercept
+    -- Just 1H39M53.831S
 
-intercept t ip
--- > Just (Intercept {
--- >     interceptTime = 1H39M53.831S,
--- >     interceptDistance = 162.294627463km,
--- >     interceptPosition = 20°43'42.305"N,61°20'56.848"W 0.0m (S84),
--- >     interceptorBearing = 300°10'18.053",
--- >     interceptorSpeed = 97.476999km/h})
+    let minDistanceToIntercept = fmap Kinematics.distanceToIntercept minIntercept
+    -- Just 162.294627463km
 
-interceptBySpeed t ip (knots 700)
--- > Just (Intercept {
--- >     interceptTime = 0H46M4.692S,
--- >     interceptDistance = 995.596069189km,
--- >     interceptPosition = 27°59'36.764"N,55°34'43.852"W 0.0m (S84),
--- >     interceptorBearing = 25°56'7.484",
--- >     interceptorSpeed = 1296.399689km/h})
-```+    let minInterceptPosition = fmap Kinematics.interceptPosition minIntercept
+    -- Just 20°43'42.305"N,61°20'56.848"W (S84)
+
+    let minInterceptorBearing = fmap Kinematics.interceptorBearing minIntercept
+    -- Just 300°10'18.053"
+
+    let minInterceptorSpeed = fmap Kinematics.interceptorSpeed minIntercept
+    -- Just 97.476999km/h
+
+    putStrLn ("Kinematics, Example 15: Time required to intercept target (min)\n\
+              \    time to intercept     = " ++ (show minTimeToIntercept) ++ "\n\
+              \    distance to intercept = " ++ (show minDistanceToIntercept) ++ "\n\
+              \    intercept position    = " ++ (show minInterceptPosition) ++ "\n\
+              \    interceptor bearing   = " ++ (show minInterceptorBearing) ++ "\n\
+              \    interceptor speed     = " ++ (show minInterceptorSpeed) ++ "\n")
+
+    let interceptSpeed = Speed.knots 700
+
+    let intercept = Kinematics.interceptBySpeed track interceptor interceptSpeed
+    let timeToIntercept = fmap Kinematics.timeToIntercept intercept
+    -- Just 0H46M4.692S
+
+    let distanceToIntercept = fmap Kinematics.distanceToIntercept intercept
+    -- Just 995.596069189km
+
+    let interceptPosition = fmap Kinematics.interceptPosition intercept
+    -- Just 27°59'36.764"N,55°34'43.852"W(S84)
+
+    let interceptorBearing = fmap Kinematics.interceptorBearing intercept
+    -- Just 25°56'7.484"
+
+    putStrLn ("Kinematics, Example 15: Time required to intercept target\n\
+              \    time to intercept     = " ++ (show timeToIntercept) ++ "\n\
+              \    distance to intercept = " ++ (show distanceToIntercept) ++ "\n\
+              \    intercept position    = " ++ (show interceptPosition) ++ "\n\
+              \    interceptor bearing   = " ++ (show interceptorBearing) ++ "\n")
+```
+## Solutions to coordinates transformation problems
+
+### Example 16: Transformation between fixed models 
+
+Convert the coordinates of a geocentric position between the WGS84 model and the NAD83 model.
+
+```haskell
+example16 :: IO()
+example16 = do
+    let pWGS84 = Geocentric.metresPos 4193790.895437 454436.195118 4768166.813801 WGS84 
+
+    -- using explicit parameters:
+    let tx = Tx.params Txs.from_WGS84_to_NAD83 
+    let pNAD83 = Positions.transform' pWGS84 NAD83 tx
+    -- Position {gx = 4193.792080781km, gy = 454.433921298km, gz = 4768.16615479km, model = NAD83}
+
+    -- using the transformation graph:
+    let pNAD83' = Positions.transform pWGS84 NAD83 Txs.fixed
+    -- Just (Position {gx = 4193.792080781km, gy = 454.433921298km, gz = 4768.16615479km, model = NAD83})
+
+    putStrLn ("Coordinates transformation, Example 16: WGS84 -> NAD83\n\
+              \    WGS84 = " ++ (show pWGS84) ++ "\n\
+              \    NAD83 = " ++ (show pNAD83) ++ "\n\
+              \    NAD83 = " ++ (show pNAD83') ++ "\n")
+```
+
+### Example 17: Transformation between time dependent models 
+
+Convert the coordinates of a geocentric position between the ITRF2014, ITRF2000 and NAD83 (CORS96) models 
+
+```haskell
+example17 :: IO()
+example17 = do
+    let pITRF2014 = Geocentric.metresPos 4193790.895437 454436.195118 4768166.813801 ITRF2014 
+
+    -- using explicit parameters:
+    let tx = Tx.params Txs.from_ITRF2014_to_ITRF2000
+    let pITRF2000 = Positions.transformAt' pITRF2014 (Epoch 2014.0) ITRF2000 tx 
+    -- Position {gx = 4193.790907273km, gy = 454.436197881km, gz = 4768.166792308km, model = ITRF2000}
+
+    -- using the transformation graph (goes via ITRF2000):
+    let pNAD83 = Positions.transformAt pITRF2014 (Epoch 2014.0) NAD83_CORS96 Txs.timeDependent 
+    -- Just (Position {gx = 4193.791801305km, gy = 454.433876376km, gz = 4768.166397281km, model = NAD83_CORS96})
+
+    putStrLn ("Coordinates transformation, Example 17: ITRF2014 -> ITRF2000 -> NAD83 (CORS96)\n\
+              \    ITRF2014       = " ++ (show pITRF2014) ++ "\n\
+              \    ITRF2000       = " ++ (show pITRF2000) ++ "\n\
+              \    NAD83 (CORS96) = " ++ (show pNAD83) ++ "\n")
+```
+***
+
+## Solutions to polygon triangulation
+
+### Example 18: Triangulation of a simple polygon
+
+```haskell
+example18 :: IO()
+example18 = do
+    let p = Polygon.simple
+                [ Geodetic.s84Pos 45 45
+                , Geodetic.s84Pos (-45) 45
+                , Geodetic.s84Pos (-45) (-45)
+                , Geodetic.s84Pos 45 (-45)
+                ]
+
+    let ts = fmap Polygon.triangulate p
+    -- Right [ Triangle 45°0'0.000"N,45°0'0.000"W (S84) 45°0'0.000"N,45°0'0.000"E (S84) 45°0'0.000"S,45°0'0.000"E (S84)
+    --       , Triangle 45°0'0.000"S,45°0'0.000"E (S84) 45°0'0.000"S,45°0'0.000"W (S84) 45°0'0.000"N,45°0'0.000"W (S84)
+    --       ]
+
+    putStrLn ("Polygon triangulation, Example 18: Simple polygon\n\
+              \    triangles = " ++ (show ts) ++ "\n")   
+```
+
+### Example 19: Triangulation of a circle
+
+```haskell
+example19 :: IO()
+example19 = do
+    let p = Polygon.circle (Geodetic.s84Pos 0 0) (Length.kilometres 10) 10
+
+    let ts = fmap Polygon.triangulate p
+    -- Right [ Triangle 0°4'21.923"N,0°3'10.298"W (S84) 0°5'23.755"N,0°0'0.000"E (S84) 0°4'21.923"N,0°3'10.298"E (S84)
+    --       , Triangle 0°4'21.923"N,0°3'10.298"W (S84) 0°4'21.923"N,0°3'10.298"E (S84) 0°1'40.045"N,0°5'7.909"E (S84)
+    --       , Triangle 0°4'21.923"N,0°3'10.298"W (S84) 0°1'40.045"N,0°5'7.909"E (S84) 0°1'40.045"S,0°5'7.909"E (S84)
+    --       , Triangle 0°4'21.923"N,0°3'10.298"W (S84) 0°1'40.045"S,0°5'7.909"E (S84) 0°4'21.923"S,0°3'10.298"E (S84)
+    --       , Triangle 0°4'21.923"N,0°3'10.298"W (S84) 0°4'21.923"S,0°3'10.298"E (S84) 0°5'23.755"S,0°0'0.000"E (S84)
+    --       , Triangle 0°4'21.923"N,0°3'10.298"W (S84) 0°5'23.755"S,0°0'0.000"E (S84) 0°4'21.923"S,0°3'10.298"W (S84)
+    --       , Triangle 0°4'21.923"N,0°3'10.298"W (S84) 0°4'21.923"S,0°3'10.298"W (S84) 0°1'40.045"S,0°5'7.909"W (S84)
+    --       , Triangle 0°1'40.045"S,0°5'7.909"W (S84) 0°1'40.045"N,0°5'7.909"W (S84) 0°4'21.923"N,0°3'10.298"W (S84)
+    --       ]
+
+    putStrLn ("Polygon triangulation, Example 19: Circle\n\
+              \    triangles = " ++ (show ts) ++ "\n")   
+```
+
+### Example 20: Triangulation of an arc
+
+```haskell
+example20 :: IO()
+example20 = do
+    let p = Polygon.arc (Geodetic.s84Pos 0 0) (Length.kilometres 10) (Angle.decimalDegrees 10) (Angle.decimalDegrees  20) 5
+
+    let ts = fmap Polygon.triangulate p
+    -- Right [ Triangle 0°5'4.230"N,0°1'50.730"E (S84) 0°5'18.836"N,0°0'56.219"E (S84) 0°5'16.081"N,0°1'10.073"E (S84)
+    --       , Triangle 0°5'4.230"N,0°1'50.730"E (S84) 0°5'16.081"N,0°1'10.073"E (S84) 0°5'12.723"N,0°1'23.794"E (S84)
+    --       , Triangle 0°5'12.723"N,0°1'23.794"E (S84) 0°5'8.770"N,0°1'37.355"E (S84) 0°5'4.230"N,0°1'50.730"E (S84)
+    --       ]
+
+    putStrLn ("Polygon triangulation, Example 20: Arc\n\
+              \    triangles = " ++ (show ts) ++ "\n")   
+```
+
+```haskell
+main :: IO()
+main = do
+    example1
+    example2
+    example3
+    example4
+    example5
+    example6
+    example7
+    example8
+    example9
+    example10
+    example11
+    example12
+    example13
+    example14
+    example15
+    example16
+    example17
+    example18
+    example19
+    example20
+```
benchmarks/GeodesicBG.hs view
@@ -3,33 +3,39 @@     ) where
 
 import Criterion.Types
-import Data.Geo.Jord.Geodesic
-import Data.Geo.Jord.Position
+import Data.Geo.Jord.Angle (Angle)
+import qualified Data.Geo.Jord.Angle as Angle (decimalDegrees)
+import qualified Data.Geo.Jord.Geodesic as Geodesic
+import Data.Geo.Jord.Geodetic (HorizontalPosition)
+import qualified Data.Geo.Jord.Geodetic as Geodetic
+import Data.Geo.Jord.Length (Length)
+import qualified Data.Geo.Jord.Length as Length (metres)
+import Data.Geo.Jord.Models (WGS84(..))
 
 benchmark :: Benchmark
 benchmark =
     bgroup
         "Geodesic"
-        [ bench "direct" $ whnf (directGeodesic p1 b1) d1
-        , bench "inverse" $ whnf (inverseGeodesic p1) p2
-        , bench "inverse antipodal" $ whnf (inverseGeodesic p1) (antipode p1)
-        , bench "inverse near-antipodal" $ whnf (inverseGeodesic p3) p4
+        [ bench "direct" $ whnf (Geodesic.direct p1 b1) d1
+        , bench "inverse" $ whnf (Geodesic.inverse p1) p2
+        , bench "inverse antipodal" $ whnf (Geodesic.inverse p1) (Geodetic.antipode p1)
+        , bench "inverse near-antipodal" $ whnf (Geodesic.inverse p3) p4
         ]
 
-p1 :: Position WGS84
-p1 = latLongPos (-37.95103341666667) 144.42486788888888 WGS84
+p1 :: HorizontalPosition WGS84
+p1 = Geodetic.latLongPos (-37.95103341666667) 144.42486788888888 WGS84
 
-p2 :: Position WGS84
-p2 = latLongPos (-37.65282113888889) 143.92649552777777 WGS84
+p2 :: HorizontalPosition WGS84
+p2 = Geodetic.latLongPos (-37.65282113888889) 143.92649552777777 WGS84
 
 d1 :: Length
-d1 = metres 54972.271139
+d1 = Length.metres 54972.271139
 
 b1 :: Angle
-b1 = decimalDegrees 306.86815920333333
+b1 = Angle.decimalDegrees 306.86815920333333
 
-p3 :: Position WGS84
-p3 = latLongPos 0 0 WGS84
+p3 :: HorizontalPosition WGS84
+p3 = Geodetic.latLongPos 0 0 WGS84
 
-p4 :: Position WGS84
-p4 = latLongPos 0.5 179.5 WGS84+p4 :: HorizontalPosition WGS84
+p4 = Geodetic.latLongPos 0.5 179.5 WGS84
+ benchmarks/GeodeticBG.hs view
@@ -0,0 +1,23 @@+module GeodeticBG+    ( benchmark+    ) where++import Criterion.Types+import Data.Geo.Jord.Angle (Angle)+import qualified Data.Geo.Jord.Angle as Angle (decimalDegrees)+import qualified Data.Geo.Jord.Geodetic as Geodetic+import Data.Geo.Jord.Math3d (V3)++benchmark :: Benchmark+benchmark =+    bgroup+        "Geodetic"+        [ bench "nvectorFromLatLong" $ whnf Geodetic.nvectorFromLatLong ll+        , bench "nvectorToLatLong" $ whnf Geodetic.nvectorToLatLong nv+        ]++ll :: (Angle, Angle)+ll = (Angle.decimalDegrees 55.6050, Angle.decimalDegrees 13.0038)++nv :: V3+nv = Geodetic.nvectorFromLatLong ll
benchmarks/GreatCircleBG.hs view
@@ -3,34 +3,40 @@     ) where
 
 import Criterion.Types
-import Data.Geo.Jord.GreatCircle
-import Data.Geo.Jord.Position
+import Data.Geo.Jord.Angle (Angle)
+import qualified Data.Geo.Jord.Angle as Angle (decimalDegrees)
+import Data.Geo.Jord.Geodetic (HorizontalPosition)
+import qualified Data.Geo.Jord.Geodetic as Geodetic
+import qualified Data.Geo.Jord.GreatCircle as GreatCircle
+import Data.Geo.Jord.Length (Length)
+import qualified Data.Geo.Jord.Length as Length (kilometres)
+import Data.Geo.Jord.Models (S84(..))
 
 benchmark :: Benchmark
 benchmark =
     bgroup
         "Great Circle"
-        [ bench "alongTrackDistance" $ whnf (alongTrackDistance' p1 p2) a
-        , bench "angularDistance" $ whnf (angularDistance p1 p2) (Just p3)
-        , bench "crossTrackDistance" $ whnf (crossTrackDistance' p1 p2) a
-        , bench "destination" $ whnf (destination p1 a) l
-        , bench "interpolate" $ whnf (interpolate p1 p2) 0.5
-        , bench "surfaceDistance" $ whnf (surfaceDistance p1) p2
-        , bench "finalBearing" $ whnf (finalBearing p1) p2
-        , bench "initialBearing" $ whnf (initialBearing p1) p2
+        [ bench "alongTrackDistance" $ whnf (GreatCircle.alongTrackDistance' p1 p2) a
+        , bench "angularDistance" $ whnf (GreatCircle.angularDistance p1 p2) (Just p3)
+        , bench "crossTrackDistance" $ whnf (GreatCircle.crossTrackDistance' p1 p2) a
+        , bench "destination" $ whnf (GreatCircle.destination p1 a) l
+        , bench "interpolated" $ whnf (GreatCircle.interpolated p1 p2) 0.5
+        , bench "distance" $ whnf (GreatCircle.distance p1) p2
+        , bench "finalBearing" $ whnf (GreatCircle.finalBearing p1) p2
+        , bench "initialBearing" $ whnf (GreatCircle.initialBearing p1) p2
         ]
 
-p1 :: Position S84
-p1 = nvectorPos 0.5504083453140064 0.12711022980808237 0.8251627978083076 S84
+p1 :: HorizontalPosition S84
+p1 = Geodetic.nvectorPos 0.5504083453140064 0.12711022980808237 0.8251627978083076 S84
 
-p2 :: Position S84
-p2 = nvectorPos 0.484947835927087 0.1582112780286092 0.860113241343365 S84
+p2 :: HorizontalPosition S84
+p2 = Geodetic.nvectorPos 0.484947835927087 0.1582112780286092 0.860113241343365 S84
 
-p3 :: Position S84
-p3 = nvectorPos 0.5225962210695282 0.11083913756305296 0.8453448262739457 S84
+p3 :: HorizontalPosition S84
+p3 = Geodetic.nvectorPos 0.5225962210695282 0.11083913756305296 0.8453448262739457 S84
 
 a :: Angle
-a = decimalDegrees 45.0
+a = Angle.decimalDegrees 45.0
 
 l :: Length
-l = kilometres 5000
+l = Length.kilometres 5000
benchmarks/KinematicsBG.hs view
@@ -3,8 +3,14 @@     ) where
 
 import Criterion.Types
-import Data.Geo.Jord.Kinematics
-import Data.Geo.Jord.Position
+import qualified Data.Geo.Jord.Angle as Angle (decimalDegrees)
+import qualified Data.Geo.Jord.Duration as Duration (seconds)
+import Data.Geo.Jord.Geodetic (HorizontalPosition)
+import qualified Data.Geo.Jord.Geodetic as Geodetic
+import Data.Geo.Jord.Kinematics (Track(..))
+import qualified Data.Geo.Jord.Kinematics as Kinematics
+import Data.Geo.Jord.Models (S84(..))
+import qualified Data.Geo.Jord.Speed as Speed (knots)
 
 benchmark :: Benchmark
 benchmark =
@@ -12,35 +18,35 @@         "Kinematics"
         [ bgroup
               "CPA"
-              [ bench "in the past" $ whnf (cpa t1) t2
-              , bench "in the future" $ whnf (cpa t3) t4
-              , bench "same positions" $ whnf (cpa t1') t1
+              [ bench "in the past" $ whnf (Kinematics.cpa t1) t2
+              , bench "in the future" $ whnf (Kinematics.cpa t3) t4
+              , bench "same positions" $ whnf (Kinematics.cpa t1') t1
               ]
         , bgroup
               "intercept"
-              [ bench "min speed" $ whnf (intercept t5) ip1
-              , bench "by speed" $ whnf (interceptBySpeed t5 ip1) (knots 700)
-              , bench "by time" $ whnf (interceptByTime t5 ip1) (seconds 2700)
+              [ bench "min speed" $ whnf (Kinematics.intercept t5) ip1
+              , bench "by speed" $ whnf (Kinematics.interceptBySpeed t5 ip1) (Speed.knots 700)
+              , bench "by time" $ whnf (Kinematics.interceptByTime t5 ip1) (Duration.seconds 2700)
               ]
         ]
 
 t1 :: Track S84
-t1 = Track (s84Pos 20 (-60) zero) (decimalDegrees 10) (knots 15)
+t1 = Track (Geodetic.s84Pos 20 (-60)) (Angle.decimalDegrees 10) (Speed.knots 15)
 
 t1' :: Track S84
-t1' = Track (s84Pos 20 (-60) zero) (decimalDegrees 10) (knots 15)
+t1' = Track (Geodetic.s84Pos 20 (-60)) (Angle.decimalDegrees 10) (Speed.knots 15)
 
 t2 :: Track S84
-t2 = Track (s84Pos 34 (-50) zero) (decimalDegrees 220) (knots 300)
+t2 = Track (Geodetic.s84Pos 34 (-50)) (Angle.decimalDegrees 220) (Speed.knots 300)
 
 t3 :: Track S84
-t3 = Track (s84Pos 30 30 zero) (decimalDegrees 45) (knots 400)
+t3 = Track (Geodetic.s84Pos 30 30) (Angle.decimalDegrees 45) (Speed.knots 400)
 
 t4 :: Track S84
-t4 = Track (s84Pos 30.01 30 zero) (decimalDegrees 315) (knots 400)
+t4 = Track (Geodetic.s84Pos 30.01 30) (Angle.decimalDegrees 315) (Speed.knots 400)
 
 t5 :: Track S84
-t5 = Track (s84Pos 34 (-50) zero) (decimalDegrees 220) (knots 600)
+t5 = Track (Geodetic.s84Pos 34 (-50)) (Angle.decimalDegrees 220) (Speed.knots 600)
 
-ip1 :: Position S84
-ip1 = s84Pos 20 (-60) zero+ip1 :: HorizontalPosition S84
+ip1 = Geodetic.s84Pos 20 (-60)
benchmarks/Main.hs view
@@ -3,9 +3,17 @@ import Criterion.Main
 
 import qualified GeodesicBG
+import qualified GeodeticBG
 import qualified GreatCircleBG
 import qualified KinematicsBG
-import qualified PositionBG
+import qualified PositionsBG
 
 main :: IO ()
-main = defaultMain [GeodesicBG.benchmark, GreatCircleBG.benchmark, KinematicsBG.benchmark, PositionBG.benchmark]+main =
+    defaultMain
+        [ GeodesicBG.benchmark
+        , GeodeticBG.benchmark
+        , GreatCircleBG.benchmark
+        , KinematicsBG.benchmark
+        , PositionsBG.benchmark
+        ]
− benchmarks/PositionBG.hs
@@ -1,39 +0,0 @@-module PositionBG
-    ( benchmark
-    ) where
-
-import Criterion.Types
-import Data.Geo.Jord.Position
-
-benchmark :: Benchmark
-benchmark =
-    bgroup
-        "Position"
-        [ bench "nvectorFromLatLong" $ whnf nvectorFromLatLong ll
-        , bench "nvectorToLatLong" $ whnf nvectorToLatLong nv
-        , bench "nvectorFromGeocentric (ellipsoidal)" $ whnf (`nvectorFromGeocentric` e) gce
-        , bench "nvectorToGeocentric (ellipsoidal)" $ whnf (`nvectorToGeocentric` e) (nv, h)
-        , bench "nvectorFromGeocentric (spherical)" $ whnf (`nvectorFromGeocentric` s) gcs
-        , bench "nvectorToGeocentric (spherical)" $ whnf (`nvectorToGeocentric` s) (nv, h)
-        ]
-
-ll :: (Angle, Angle)
-ll = (decimalDegrees 55.6050, decimalDegrees 13.0038)
-
-nv :: Vector3d
-nv = nvectorFromLatLong ll
-
-gce :: Vector3d
-gce = nvectorToGeocentric (nv, h) s
-
-gcs :: Vector3d
-gcs = nvectorToGeocentric (nv, h) e
-
-h :: Length
-h = metres 15000
-
-s :: Ellipsoid
-s = toSphere eWGS84
-
-e :: Ellipsoid
-e = eWGS84
+ benchmarks/PositionsBG.hs view
@@ -0,0 +1,42 @@+module PositionsBG
+    ( benchmark
+    ) where
+
+import Criterion.Types
+import qualified Data.Geo.Jord.Geocentric as Geocentric
+import qualified Data.Geo.Jord.Geodetic as Geodetic
+import Data.Geo.Jord.Length (Length)
+import qualified Data.Geo.Jord.Length as Length (metres)
+import Data.Geo.Jord.Models (S84(..), WGS84(..))
+import qualified Data.Geo.Jord.Positions as Positions
+
+benchmark :: Benchmark
+benchmark =
+    bgroup
+        "Positions"
+        [ bgroup
+              "ellipsoidal"
+              [ bench "toGeodetic" $ whnf Positions.toGeodetic egeoc
+              , bench "toGeocentric" $ whnf Positions.toGeocentric egeod
+              ]
+        , bgroup
+              "spherical"
+              [ bench "toGeodetic" $ whnf Positions.toGeodetic sgeoc
+              , bench "toGeocentric" $ whnf Positions.toGeocentric sgeod
+              ]
+        ]
+
+egeoc :: Geocentric.Position WGS84
+egeoc = Geocentric.metresPos 4193790.895437 454436.195118 4768166.813801 WGS84
+
+egeod :: Geodetic.Position WGS84
+egeod = Geodetic.latLongHeightPos 45 45 h WGS84
+
+sgeoc :: Geocentric.Position S84
+sgeoc = Geocentric.metresPos 4193790.895437 454436.195118 4768166.813801 S84
+
+sgeod :: Geodetic.Position S84
+sgeod = Geodetic.latLongHeightPos 45 45 h S84
+
+h :: Length
+h = Length.metres 15000
gen/Generator.hs view
@@ -20,7 +20,7 @@     "module " ++
     module' h ++
     " where\n\n" ++
-    unlines (map (\i -> "import " ++ i) imports) ++ "\n" ++ unlines (map (\e -> genElt e ++ "\n") elts) ++ genAll elts
+    unlines (map ("import " ++) imports) ++ "\n" ++ unlines (map (\e -> genElt e ++ "\n") elts) ++ genAll elts
 
 header :: Header -> String
 header h =
@@ -36,12 +36,11 @@     \--\n" ++
     genComment (comment h) ++
     "--\n\
-    \-- This module has been generated.\n\
-    \--\n"
+    \-- This module has been generated.\n"
 
 documentation :: [String] -> String
 documentation [] = ""
-documentation (c:cs) = ("-- |" ++ c ++ "\n") ++ (genComment cs)
+documentation (c:cs) = ("-- |" ++ c ++ "\n") ++ genComment cs
 
 genComment :: [String] -> String
-genComment cs = unlines (map (\s -> "--" ++ s) cs)+genComment cs = unlines (map ("--" ++) cs)
gen/Main.hs view
@@ -1,5 +1,4 @@ import System.Environment (getArgs)
-import System.IO (readFile, writeFile)
 import Text.ParserCombinators.ReadP (ReadP, many1, readP_to_S)
 
 import qualified Ellipsoids as E
@@ -15,7 +14,7 @@         [inDir, outDir] -> do
             ellipsoidsModule <- process (inDir ++ "/ellipsoids.txt") outDir E.parser E.generator
             _ <- process (inDir ++ "/models.txt") outDir M.parser (M.generator ellipsoidsModule)
-            _ <- process (inDir ++ "/transformations.txt") outDir T.parser (T.generator)
+            _ <- process (inDir ++ "/transformations.txt") outDir T.parser T.generator
             return ()
         _ -> putStrLn ("Invalid arguments: " ++ show args)
 
gen/Transformations.hs view
@@ -72,31 +72,31 @@ 
 genTx :: Transformation -> String
 genTx t
-    | isJust (epoch t) = dynamicTx t
-    | otherwise = staticTx t
+    | isJust (epoch t) = timeDependentTx t
+    | otherwise = fixedTx t
 
-dynamicTx :: Transformation -> String
-dynamicTx t =
+timeDependentTx :: Transformation -> String
+timeDependentTx t =
     G.documentation (comment t) ++
     func t ++
-    " :: Tx TxParams15\n" ++
+    " :: Tx Params15\n" ++
     func t ++
     " =\n    Tx " ++
     idToString (from t) ++
     "\n        " ++
     idToString (to t) ++
     "\n        " ++
-    "(TxParams15" ++
+    "(Params15" ++
     "\n             " ++
     epochToString (epoch t) ++
     "\n             " ++
     tx7ToString (params t) ++ "\n             " ++ ratesToString (rates t) ++ ")"
 
-staticTx :: Transformation -> String
-staticTx t =
+fixedTx :: Transformation -> String
+fixedTx t =
     G.documentation (comment t) ++
     func t ++
-    " :: Tx TxParams7\n" ++
+    " :: Tx Params7\n" ++
     func t ++
     " =\n    Tx " ++
     idToString (from t) ++
@@ -107,11 +107,11 @@ 
 tx7ToString :: Params -> String
 tx7ToString (Params t s r) =
-    "(txParams7 " ++ dsToString t ++ " " ++ dToString s ++ " " ++ dsToString r ++ ")"
+    "(params7 " ++ dsToString t ++ " " ++ dToString s ++ " " ++ dsToString r ++ ")"
 
 ratesToString :: Params -> String
 ratesToString (Params t s r) =
-    "(txRates " ++ dsToString t ++ " " ++ dToString s ++ " " ++ dsToString r ++ ")"
+    "(rates " ++ dsToString t ++ " " ++ dToString s ++ " " ++ dsToString r ++ ")"
 
 dsToString :: [Double] -> String
 dsToString ds = "(" ++ intercalate ", " (map show ds) ++ ")"
@@ -129,25 +129,25 @@ epochToString (Just yd) = "(Epoch " ++ show yd ++ ")"
 
 genAll :: [Transformation] -> String
-genAll ts = genStaticTxs s ++ "\n" ++ genDynamicTxs d
+genAll ts = genFixedTxs s ++ "\n" ++ genTimeDependentTxs d
   where
     (d, s) = split ts
 
-genStaticTxs :: [Transformation] -> String
-genStaticTxs ts =
-    "-- | Graph of all static transformations.\n\
-   \staticTxs :: TxGraph TxParams7\n\
-   \staticTxs =\n\
-   \    txGraph\n\
+genFixedTxs :: [Transformation] -> String
+genFixedTxs ts =
+    "-- | Graph of all transformations between fixed models.\n\
+   \fixed :: Graph Params7\n\
+   \fixed =\n\
+   \    graph\n\
    \        [ " ++
     funcs ts ++ "\n        ]\n"
 
-genDynamicTxs :: [Transformation] -> String
-genDynamicTxs ts =
-    "-- | Graph of all dynamic transformations.\n\
-   \dynamicTxs :: TxGraph TxParams15\n\
-   \dynamicTxs =\n\
-   \    txGraph\n\
+genTimeDependentTxs :: [Transformation] -> String
+genTimeDependentTxs ts =
+    "-- | Graph of all transformations between time-dependent models.\n\
+   \timeDependent :: Graph Params15\n\
+   \timeDependent =\n\
+   \    graph\n\
    \        [ " ++
     funcs ts ++ "\n        ]\n"
 
jord.cabal view
@@ -1,13 +1,13 @@ cabal-version: 1.12
 
--- This file has been generated from package.yaml by hpack version 0.31.2.
+-- This file has been generated from package.yaml by hpack version 0.33.0.
 --
 -- see: https://github.com/sol/hpack
 --
--- hash: cc409401d6b89737876c63a8e5e48fe917e301badede3ca5f9a2a7406b815276
+-- hash: edf85400ffcd7e413b1007ab7623f54ed8bce9a557b28ca3701a299cf5935662
 
 name:           jord
-version:        1.0.0.0
+version:        2.0.0.0
 synopsis:       Geographical Position Calculations
 description:    Please see the README on GitHub at <https://github.com/ofmooseandmen/jord#readme>
 category:       Geography
@@ -30,30 +30,30 @@ 
 library
   exposed-modules:
-      Data.Geo.Jord
       Data.Geo.Jord.Angle
       Data.Geo.Jord.Duration
       Data.Geo.Jord.Ellipsoid
       Data.Geo.Jord.Ellipsoids
+      Data.Geo.Jord.Geocentric
+      Data.Geo.Jord.Geodetic
       Data.Geo.Jord.Geodesic
       Data.Geo.Jord.GreatCircle
       Data.Geo.Jord.Kinematics
       Data.Geo.Jord.LatLong
       Data.Geo.Jord.Length
-      Data.Geo.Jord.LocalFrames
+      Data.Geo.Jord.Local
+      Data.Geo.Jord.Math3d
       Data.Geo.Jord.Model
       Data.Geo.Jord.Models
-      Data.Geo.Jord.Position
-      Data.Geo.Jord.Quantity
+      Data.Geo.Jord.Polygon
+      Data.Geo.Jord.Positions
       Data.Geo.Jord.Rotation
       Data.Geo.Jord.Speed
-      Data.Geo.Jord.Transformation
-      Data.Geo.Jord.Txs
+      Data.Geo.Jord.Triangle
       Data.Geo.Jord.Tx
-      Data.Geo.Jord.Vector3d
+      Data.Geo.Jord.Txs
   other-modules:
       Data.Geo.Jord.Parser
-      Data.Geo.Jord.Internal
   hs-source-dirs:
       src
   ghc-options: -Wall
@@ -64,10 +64,11 @@ executable jord-benchmarks
   main-is: Main.hs
   other-modules:
+      PositionsBG
       GeodesicBG
+      GeodeticBG
       GreatCircleBG
       KinematicsBG
-      PositionBG
   hs-source-dirs:
       benchmarks
   ghc-options: -Wall
@@ -92,6 +93,19 @@       base >=4.9 && <5
   default-language: Haskell2010
 
+test-suite jord-readme
+  type: exitcode-stdio-1.0
+  main-is: README.lhs
+  other-modules:
+      Paths_jord
+  ghc-options: -Wall -pgmL markdown-unlit
+  build-depends:
+      base >=4.9 && <5
+    , jord
+    , markdown-unlit
+  default-language: Haskell2010
+  build-tool-depends: markdown-unlit:markdown-unlit
+
 test-suite jord-test
   type: exitcode-stdio-1.0
   main-is: Spec.hs
@@ -99,17 +113,19 @@       Data.Geo.Jord.AngleSpec
       Data.Geo.Jord.DurationSpec
       Data.Geo.Jord.EllipsoidSpec
+      Data.Geo.Jord.GeocentricSpec
       Data.Geo.Jord.GeodesicSpec
+      Data.Geo.Jord.GeodeticSpec
       Data.Geo.Jord.GreatCircleSpec
       Data.Geo.Jord.KinematicsSpec
       Data.Geo.Jord.LengthSpec
-      Data.Geo.Jord.LocalFramesSpec
-      Data.Geo.Jord.PositionSpec
-      Data.Geo.Jord.ReadPositionSpec
+      Data.Geo.Jord.LocalSpec
+      Data.Geo.Jord.Places
+      Data.Geo.Jord.PolygonSpec
+      Data.Geo.Jord.PositionsSpec
       Data.Geo.Jord.RotationSpec
-      Data.Geo.Jord.ShowPositionSpec
       Data.Geo.Jord.SpeedSpec
-      Data.Geo.Jord.TransformationSpec
+      Data.Geo.Jord.TriangleSpec
       Paths_jord
   hs-source-dirs:
       test
@@ -120,3 +136,4 @@     , hspec ==2.*
     , jord
   default-language: Haskell2010
+  build-tool-depends: hspec-discover:hspec-discover
− src/Data/Geo/Jord.hs
@@ -1,82 +0,0 @@--- |
--- Module:      Data.Geo.Jord
--- Copyright:   (c) 2020 Cedric Liegeois
--- License:     BSD3
--- Maintainer:  Cedric Liegeois <ofmooseandmen@yahoo.fr>
--- Stability:   experimental
--- Portability: portable
---
--- Convience module re-exporting all of Jord API while resolving function name clashes.
--- You'll probably rather want to import "Data.Geo.Jord.Position" and only the core module(s)
--- that suit your problem:
---
---    * "Data.Geo.Jord.LocalFrames"
---
---    * "Data.Geo.Jord.Geodesic"
---
---    * "Data.Geo.Jord.GreatCircle"
---
---    * "Data.Geo.Jord.Kinematics"
---
---    * "Data.Geo.Jord.Transformation"
---
-module Data.Geo.Jord
-    (
-    -- * Core modules
-      module Data.Geo.Jord.LocalFrames
-    , module Data.Geo.Jord.Geodesic
-    , module Data.Geo.Jord.GreatCircle
-    , module Data.Geo.Jord.Kinematics
-    , module Data.Geo.Jord.Position
-    , module Data.Geo.Jord.Transformation
-    -- * Aliases for name-clashing functions
-    , destinationE
-    , finalBearingE
-    , initialBearingE
-    , surfaceDistanceE
-    , destinationS
-    , finalBearingS
-    , initialBearingS
-    , surfaceDistanceS
-    ) where
-
-import Data.Geo.Jord.Geodesic hiding (destination, finalBearing, initialBearing, surfaceDistance)
-import qualified Data.Geo.Jord.Geodesic as Geodesic
-import Data.Geo.Jord.GreatCircle hiding (destination, finalBearing, initialBearing, surfaceDistance)
-import qualified Data.Geo.Jord.GreatCircle as GreatCircle
-import Data.Geo.Jord.Kinematics
-import Data.Geo.Jord.LocalFrames
-import Data.Geo.Jord.Position
-import Data.Geo.Jord.Transformation
-
--- | alias for 'Geodesic.destination'.
-destinationE :: (Ellipsoidal a) => Position a -> Angle -> Length -> Maybe (Position a)
-destinationE = Geodesic.destination
-
--- | alias for 'Geodesic.finalBearing'.
-finalBearingE :: (Ellipsoidal a) => Position a -> Position a -> Maybe Angle
-finalBearingE = Geodesic.finalBearing
-
--- | alias for 'Geodesic.initialBearing'.
-initialBearingE :: (Ellipsoidal a) => Position a -> Position a -> Maybe Angle
-initialBearingE = Geodesic.initialBearing
-
--- | alias for 'Geodesic.surfaceDistance'.
-surfaceDistanceE :: (Ellipsoidal a) => Position a -> Position a -> Maybe Length
-surfaceDistanceE = Geodesic.surfaceDistance
-
--- | alias for 'GreatCircle.destination'.
-destinationS :: (Spherical a) => Position a -> Angle -> Length -> Position a
-destinationS = GreatCircle.destination
-
--- | alias for 'GreatCircle.finalBearing'.
-finalBearingS :: (Spherical a) => Position a -> Position a -> Maybe Angle
-finalBearingS = GreatCircle.finalBearing
-
--- | alias for 'GreatCircle.initialBearing'.
-initialBearingS :: (Spherical a) => Position a -> Position a -> Maybe Angle
-initialBearingS = GreatCircle.initialBearing
-
--- | alias for 'GreatCircle.surfaceDistance'.
-surfaceDistanceS :: (Spherical a) => Position a -> Position a -> Length
-surfaceDistanceS = GreatCircle.surfaceDistance
src/Data/Geo/Jord/Angle.hs view
@@ -8,6 +8,12 @@ --
 -- Types and functions for working with angles representing latitudes, longitude and bearings.
 --
+-- In order to use this module you should start with the following imports:
+--
+-- @
+-- import Data.Geo.Jord.Angle (Angle)
+-- import qualified Data.Geo.Jord.Angle as Angle
+-- @
 module Data.Geo.Jord.Angle
     (
     -- * The 'Angle' type
@@ -19,15 +25,16 @@     -- * Calculations
     , arcLength
     , central
+    , clockwiseDifference
     , isNegative
     , isWithin
-    , negate'
+    , negate
     , normalise
     -- * Trigonometric functions
-    , asin'
-    , atan2'
-    , cos'
-    , sin'
+    , asin
+    , atan2
+    , cos
+    , sin
     -- * Accessors
     , getDegrees
     , getArcminutes
@@ -37,19 +44,25 @@     , toDecimalDegrees
     , toRadians
     -- * Read
-    , angleP
-    , readAngle
+    , angle
+    , read
+    -- * Misc
+    , add
+    , subtract
+    , zero
     ) where
 
 import Control.Applicative ((<|>))
 import Data.Fixed (mod')
+import Prelude hiding (atan2, asin, acos, cos, negate, read, sin, subtract)
+import qualified Prelude (atan2, asin, cos, sin)
 import Text.ParserCombinators.ReadP (ReadP, char, option, readP_to_S, string)
 import Text.Printf (printf)
 import Text.Read (readMaybe)
 
-import Data.Geo.Jord.Length
+import Data.Geo.Jord.Length (Length)
+import qualified Data.Geo.Jord.Length as Length (metres, toMetres)
 import Data.Geo.Jord.Parser
-import Data.Geo.Jord.Quantity
 
 -- | An angle with a resolution of a microarcsecond.
 -- When used as a latitude/longitude this roughly translate to a precision
@@ -60,9 +73,9 @@         }
     deriving (Eq)
 
--- | See 'angleP'.
+-- | See 'angle'.
 instance Read Angle where
-    readsPrec _ = readP_to_S angleP
+    readsPrec _ = readP_to_S angle
 
 -- | Show 'Angle' as degrees, minutes, seconds and milliseconds - e.g. 154°25'43.5".
 instance Show Angle where
@@ -82,14 +95,20 @@ instance Ord Angle where
     (<=) (Angle uas1) (Angle uas2) = uas1 <= uas2
 
--- | Add/Subtract 'Angle's.
-instance Quantity Angle where
-    add a1 a2 = Angle (microarcseconds a1 + microarcseconds a2)
-    sub a1 a2 = Angle (microarcseconds a1 - microarcseconds a2)
-    zero = Angle 0
+-- | Adds 2 angles.
+add :: Angle -> Angle -> Angle
+add a1 a2 = Angle (microarcseconds a1 + microarcseconds a2)
 
+-- | Subtracts 2 angles.
+subtract :: Angle -> Angle -> Angle
+subtract a1 a2 = Angle (microarcseconds a1 - microarcseconds a2)
+
+-- | 0 degrees.
+zero :: Angle
+zero = Angle 0
+
 -- | 'Angle' from given decimal degrees. Any 'Double' is accepted: it must be
--- validated by the call site when used to represent a latitude or longitude.
+-- validated by the call site when representing a latitude or longitude.
 decimalDegrees :: Double -> Angle
 decimalDegrees dec = Angle (round (dec * 3600000000.0))
 
@@ -113,15 +132,26 @@ 
 -- | @arcLength a r@ computes the 'Length' of the arc that subtends the angle @a@ for radius @r@.
 arcLength :: Angle -> Length -> Length
-arcLength a r = metres (toMetres r * toRadians a)
+arcLength a r = Length.metres (Length.toMetres r * toRadians a)
 
 -- | @central l r@ computes the central 'Angle' from the arc length @l@ and radius @r@.
 central :: Length -> Length -> Angle
-central s r = radians (toMetres s / toMetres r)
+central s r = radians (Length.toMetres s / Length.toMetres r)
 
+-- | @clockwiseDifference f s@ computes the angle between given angles, rotating clockwise.
+clockwiseDifference :: Angle -> Angle -> Angle
+clockwiseDifference f s = decimalDegrees d
+  where
+    d = cd (toDecimalDegrees f) (toDecimalDegrees s)
+
+cd :: Double -> Double -> Double
+cd d1 d2
+  | d2 < d1 = cd d1 (d2 + 360.0)
+  | otherwise = d2 - d1
+
 -- | Returns the given 'Angle' negated.
-negate' :: Angle -> Angle
-negate' (Angle millis) = Angle (-millis)
+negate :: Angle -> Angle
+negate (Angle millis) = Angle (-millis)
 
 -- | @normalise a n@ normalises @a@ to [0, @n@].
 normalise :: Angle -> Angle -> Angle
@@ -137,21 +167,21 @@ isWithin :: Angle -> Angle -> Angle -> Bool
 isWithin (Angle millis) (Angle low) (Angle high) = millis >= low && millis <= high
 
--- | @atan2' y x@ computes the 'Angle' (from the positive x-axis) of the vector from the origin to the point (x,y).
-atan2' :: Double -> Double -> Angle
-atan2' y x = radians (atan2 y x)
+-- | @atan2 y x@ computes the 'Angle' (from the positive x-axis) of the vector from the origin to the point (x,y).
+atan2 :: Double -> Double -> Angle
+atan2 y x = radians (Prelude.atan2 y x)
 
--- | @asin' a@ computes arc sine of @a@.
-asin' :: Double -> Angle
-asin' a = radians (asin a)
+-- | @asin a@ computes arc sine of @a@.
+asin :: Double -> Angle
+asin a = radians (Prelude.asin a)
 
--- | @cos' a@ returns the cosinus of @a@.
-cos' :: Angle -> Double
-cos' a = cos (toRadians a)
+-- | @cos a@ returns the cosinus of @a@.
+cos :: Angle -> Double
+cos a = Prelude.cos (toRadians a)
 
--- | @sin' a@ returns the sinus of @a@.
-sin' :: Angle -> Double
-sin' a = sin (toRadians a)
+-- | @sin a@ returns the sinus of @a@.
+sin :: Angle -> Double
+sin a = Prelude.sin (toRadians a)
 
 -- | degrees to radians.
 toRadians :: Angle -> Double
@@ -201,13 +231,12 @@ --     * minute: ', ′ or m
 --
 --     * second: ", ″, '' or s
---
-angleP :: ReadP Angle
-angleP = degsMinsSecs <|> decimal
+angle :: ReadP Angle
+angle = degsMinsSecs <|> decimal
 
--- | Reads an 'Angle' from the given string using 'angleP'.
-readAngle :: String -> Maybe Angle
-readAngle s = readMaybe s :: (Maybe Angle)
+-- | Reads an 'Angle' from the given string using 'angle'.
+read :: String -> Maybe Angle
+read s = readMaybe s :: (Maybe Angle)
 
 -- | Parses DMS.MS and returns an 'Angle'.
 degsMinsSecs :: ReadP Angle
src/Data/Geo/Jord/Duration.hs view
@@ -8,6 +8,12 @@ --
 -- Types and functions for working with (signed) durations.
 --
+-- In order to use this module you should start with the following imports:
+--
+-- @
+-- import Data.Geo.Jord.Duration (Duration)
+-- import qualified Data.Geo.Jord.Duration as Duration
+-- @
 module Data.Geo.Jord.Duration
     (
     -- * The 'Duration' type
@@ -24,16 +30,20 @@     , toMinutes
     , toSeconds
     -- * Read
-    , durationP
-    , readDuration
+    , duration
+    , read
+    -- * Misc
+    , add
+    , subtract
+    , zero
     ) where
 
+import Prelude hiding (read, subtract)
 import Text.ParserCombinators.ReadP (ReadP, char, option, readP_to_S)
 import Text.Printf (printf)
 import Text.Read (readMaybe)
 
 import Data.Geo.Jord.Parser
-import Data.Geo.Jord.Quantity
 
 -- | A duration with a resolution of 1 millisecond.
 newtype Duration =
@@ -42,9 +52,9 @@         }
     deriving (Eq)
 
--- | See 'durationP'.
+-- | See 'duration'.
 instance Read Duration where
-    readsPrec _ = readP_to_S durationP
+    readsPrec _ = readP_to_S duration
 
 -- | Show 'Duration' as @(-)nHnMn.nS@.
 instance Show Duration where
@@ -59,12 +69,18 @@ instance Ord Duration where
     (<=) (Duration d1) (Duration d2) = d1 <= d2
 
--- | Add/Subtract Durations.
-instance Quantity Duration where
-    add a b = Duration (toMilliseconds a + toMilliseconds b)
-    sub a b = Duration (toMilliseconds a - toMilliseconds b)
-    zero = Duration 0
+-- | Adds 2 durations.
+add :: Duration -> Duration -> Duration
+add a b = Duration (toMilliseconds a + toMilliseconds b)
 
+-- | Subtracts 2 durations.
+subtract :: Duration -> Duration -> Duration
+subtract a b = Duration (toMilliseconds a - toMilliseconds b)
+
+-- | 0 duration.
+zero :: Duration
+zero = Duration 0
+
 -- | 'Duration' from hours minutes and decimal seconds.
 hms :: Int -> Int -> Double -> Duration
 hms h m s = milliseconds (fromIntegral h * 3600000 + fromIntegral m * 60000 + s * 1000)
@@ -97,13 +113,13 @@ toSeconds :: Duration -> Double
 toSeconds (Duration ms) = fromIntegral ms / 1000.0 :: Double
 
--- | Reads an a 'Duration' from the given string using 'durationP'.
-readDuration :: String -> Maybe Duration
-readDuration s = readMaybe s :: (Maybe Duration)
+-- | Reads a 'Duration' from the given string using 'duration'.
+read :: String -> Maybe Duration
+read s = readMaybe s :: (Maybe Duration)
 
 -- | Parses and returns an 'Duration' formatted @(-)nHnMn.nS@.
-durationP :: ReadP Duration
-durationP = do
+duration :: ReadP Duration
+duration = do
     h <- option 0 hoursP
     m <- option 0 minutesP
     s <- option 0.0 secondsP
src/Data/Geo/Jord/Ellipsoid.hs view
@@ -9,7 +9,6 @@ -- Types and functions for working with ellipsoids (including spheres).
 --
 -- see "Data.Geo.Jord.Ellipsoids" for supported ellipsoids.
---
 module Data.Geo.Jord.Ellipsoid
     ( Ellipsoid
     , equatorialRadius
src/Data/Geo/Jord/Ellipsoids.hs view
@@ -9,7 +9,6 @@ -- Common ellipsoids of different celestial bodies. -- -- This module has been generated.--- module Data.Geo.Jord.Ellipsoids where  import Data.Geo.Jord.Ellipsoid
+ src/Data/Geo/Jord/Geocentric.hs view
@@ -0,0 +1,93 @@+-- |+-- Module:      Data.Geo.Jord.Geocentric+-- Copyright:   (c) 2020 Cedric Liegeois+-- License:     BSD3+-- Maintainer:  Cedric Liegeois <ofmooseandmen@yahoo.fr>+-- Stability:   experimental+-- Portability: portable+--+-- Geocentric coordinates of points (X, Y, and Z Cartesian coordinates) in specified models.+--+-- For the Earth the coordinate system is known as ECEF (acronym for earth-centered, earth-fixed),+-- or ECR (initialism for earth-centered rotational).+--+-- In order to use this module you should start with the following imports:+--+-- @+-- import qualified Data.Geo.Jord.Geocentric as Geocentric+-- import qualified Data.Geo.Jord.Length as Length+-- import Data.Geo.Jord.Models+-- @+--+-- see "Data.Geo.Jord.Models" for supported models.+module Data.Geo.Jord.Geocentric+    ( Position(..)+    , coords+    , metresCoords+    , metresPos+    , metresPos'+    , antipode+    , northPole+    , southPole+    ) where++import Data.Geo.Jord.Ellipsoid (polarRadius)+import Data.Geo.Jord.Length (Length)+import qualified Data.Geo.Jord.Length as Length (metres, toMetres)+import qualified Data.Geo.Jord.Math3d as Math3d+import Data.Geo.Jord.Model++-- | Geocentric coordinates (cartesian X, Y, Z) of a position in a specified 'Model'.+--+-- @gx-gy@ plane is the equatorial plane, @gx@ is on the prime meridian, and @gz@ on the polar axis.+--+-- On a spherical celestial body, an /n/-vector is equivalent to a normalised version of a+-- geocentric cartesian coordinate.+data Position a =+    Position+        { gx :: Length -- ^ x-coordinate+        , gy :: Length -- ^ y-coordinate+        , gz :: Length -- ^ z-coordinate+        , model :: a -- ^ model (e.g. WGS84)+        }+    deriving (Eq, Show)++-- | 3d vector representing the (X, Y, Z) coordinates in __metres__ of the given position.+metresCoords :: (Model a) => Position a -> Math3d.V3+metresCoords p = coords p Length.toMetres++-- | @coords p f@ returns the 3d vector representing the (X, Y, Z) coordinates in the unit+-- of @f@. For example:+--+-- >>> Geocentric.coords (Geocentric.metresPos 3194669.145061 3194669.145061 4487701.962256 WGS84) Length.toKilometres+-- V3 {vx = 3194.669145061, vy = 3194.669145061, vz = 4487.701962256}+coords :: (Model a) => Position a -> (Length -> Double) -> Math3d.V3+coords (Position x y z _) f = Math3d.vec3 (f x) (f y) (f z)++-- | Geocentric position from given (X, Y, Z) in __metres__ an given 'Model'.+metresPos :: (Model a) => Double -> Double -> Double -> a -> Position a+metresPos xm ym zm = Position (Length.metres xm) (Length.metres ym) (Length.metres zm)++-- | Geocentric position from given 3d vector (X, Y, Z) in __metres__ an given 'Model'.+metresPos' :: (Model a) => Math3d.V3 -> a -> Position a+metresPos' v = metresPos (Math3d.v3x v) (Math3d.v3y v) (Math3d.v3z v)++-- | @antipode p@ computes the antipodal position of @p@: the position which is diametrically+-- opposite to @p@.+antipode :: (Model a) => Position a -> Position a+antipode p = metresPos (Math3d.v3x avm) (Math3d.v3y avm) (Math3d.v3z avm) (model p)+  where+    c = metresCoords p+    avm = Math3d.scale c (-1.0)++-- | Surface position of the North Pole in the given model.+northPole :: (Model a) => a -> Position a+northPole m = metresPos 0 0 r m+  where+    r = Length.toMetres . polarRadius . surface $ m++-- | Surface position of the South Pole in the given model.+southPole :: (Model a) => a -> Position a+southPole m = metresPos 0 0 (-r) m+  where+    r = Length.toMetres . polarRadius . surface $ m
src/Data/Geo/Jord/Geodesic.hs view
@@ -14,68 +14,71 @@ -- In order to use this module you should start with the following imports:
 --
 -- @
---     import Data.Geo.Jord.Geodesic
---     import Data.Geo.Jord.Position
+-- import qualified Data.Geo.Jord.Angle as Angle
+-- import Data.Geo.Jord.Geodesic (Geodesic)
+-- import qualified Data.Geo.Jord.Geodesic as Geodesic
+-- import qualified Data.Geo.Jord.Geodetic as Geodetic
+-- import qualified Data.Geo.Jord.Length as Length
 -- @
 --
--- If you wish to use both this module and the "Data.Geo.Jord.GreatCircle" module you must qualify both imports.
---
 -- <http://www.ngs.noaa.gov/PUBS_LIB/inverse.pdf T Vincenty, "Direct and Inverse Solutions of Geodesics on the Ellipsoid with application of nested equations", Survey Review, vol XXIII no 176, 1975.>
---
 module Data.Geo.Jord.Geodesic
     (
     -- * The 'Geodesic' type
       Geodesic
-    , geodesicPos1
-    , geodesicPos2
-    , geodesicBearing1
-    , geodesicBearing2
-    , geodesicLength
-    -- * Calculations
-    , directGeodesic
-    , inverseGeodesic
-    , destination
-    , finalBearing
+    , startPosition
+    , endPosition
     , initialBearing
-    , surfaceDistance
+    , finalBearing
+    , length
+    -- * Calculations
+    , direct
+    , inverse
     ) where
 
-import Data.Geo.Jord.Internal
-import Data.Geo.Jord.Position
+import Prelude hiding (length)
 
+import Data.Geo.Jord.Angle (Angle)
+import qualified Data.Geo.Jord.Angle as Angle
+import Data.Geo.Jord.Ellipsoid
+import Data.Geo.Jord.Geodetic (HorizontalPosition)
+import qualified Data.Geo.Jord.Geodetic as Geodetic
+import Data.Geo.Jord.Length (Length)
+import qualified Data.Geo.Jord.Length as Length
+import Data.Geo.Jord.Model (Ellipsoidal, surface)
+
 -- | Geodesic line: shortest route between two positions on the surface of a model.
+--
+-- Bearing are in compass angles.
+-- 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.
 data Geodesic a =
     Geodesic
-        { geodesicPos1 :: Position a -- ^ geodesic start position, p1.
-        , geodesicPos2 :: Position a -- ^ geodesic end position, p2.
-        , geodesicBearing1 :: Maybe Angle -- ^ initial bearing from p1 to p2, if p1 and p2 are different.
-        , geodesicBearing2 :: Maybe Angle -- ^ final bearing from p1 to p2, if p1 and p2 are different
-        , geodesicLength :: Length -- ^ length of the geodesic: the surface distance between p1 and p2.
+        { startPosition :: HorizontalPosition a -- ^ geodesic start position.
+        , endPosition :: HorizontalPosition a -- ^ geodesic end position.
+        , initialBearing :: Maybe Angle -- ^ initial bearing from @startPosition@ to @endPosition@, if both are different.
+        , finalBearing :: Maybe Angle -- ^ final bearing from @startPosition@ to @endPosition@, if both are different.
+        , length :: Length -- ^ length of the geodesic: the surface distance between @startPosition@ to @endPosition@.
         }
     deriving (Eq, Show)
 
--- | @directGeodesic p1 b1 d@ solves the direct geodesic problem using Vicenty formula: position
+-- | @direct p1 b1 d@ solves the direct geodesic problem using Vicenty formula: position
 -- along the geodesic, reached from position @p1@ having travelled the __surface__ distance @d@ on
 -- the initial bearing (compass angle) @b1@ at __constant__ height; it also returns the final bearing
--- at the reached position.
--- The Vincenty formula for the direct problem should always converge, however this function returns
--- 'Nothing' if it would ever fail to do so (probably thus indicating a bug in the implementation).
---
--- ==== __Examples__
+-- at the reached position. For example:
 --
--- >>> import Data.Geo.Jord.Geodesic
--- >>> import Data.Geo.Jord.Position
--- >>>
--- >>> directGeodesic (northPole WGS84) zero (kilometres 20003.931458623)
--- Just (Geodesic {geodesicPos1 = 90°0'0.000"N,0°0'0.000"E 0.0m (WGS84)
---               , geodesicPos2 = 90°0'0.000"S,180°0'0.000"E 0.0m (WGS84)
---               , geodesicBearing1 = Just 0°0'0.000"
---               , geodesicBearing2 = Just 180°0'0.000"
---               , geodesicLength = 20003.931458623km})
+-- >>> Geodesic.direct (Geodetic.northPole WGS84) Angle.zero (Length.kilometres 20003.931458623)
+-- Just (Geodesic { startPosition = 90°0'0.000"N,0°0'0.000"E (WGS84)
+--                , endPosition = 90°0'0.000"S,180°0'0.000"E (WGS84)
+--                , initialBearing = Just 0°0'0.000"
+--                , finalBearing = Just 180°0'0.000"
+--                , length = 20003.931458623km})
 --
-directGeodesic :: (Ellipsoidal a) => Position a -> Angle -> Length -> Maybe (Geodesic a)
-directGeodesic p1 b1 d
-    | d == zero = Just (Geodesic p1 p1 (Just b1) (Just b1) zero)
+-- The Vincenty formula for the direct problem should always converge, however this function returns
+-- 'Nothing' if it would ever fail to do so (probably thus indicating a bug in the implementation).
+direct :: (Ellipsoidal a) => HorizontalPosition a -> Angle -> Length -> Maybe (Geodesic a)
+direct p1 b1 d
+    | d == Length.zero = Just (Geodesic p1 p1 (Just b1) (Just b1) Length.zero)
     | otherwise =
         case rec of
             Nothing -> Nothing
@@ -92,14 +95,21 @@                           (1.0 - _C) * f * sinAlpha *
                           (s + _C * sinS * (cos2S' + _C * cosS * (-1.0 + 2.0 * cos2S' * cos2S')))
                       lon2 = lon1 + _L
-                      b2 = normalise (radians (atan2 sinAlpha (-x))) (decimalDegrees 360.0)
-                      p2 = latLongHeightPos' (radians lat2) (radians lon2) (height p1) (model p1)
+                      b2 =
+                          Angle.normalise
+                              (Angle.radians (atan2 sinAlpha (-x)))
+                              (Angle.decimalDegrees 360.0)
+                      p2 =
+                          Geodetic.latLongPos'
+                              (Angle.radians lat2)
+                              (Angle.radians lon2)
+                              (Geodetic.model p1)
   where
-    lat1 = toRadians . latitude $ p1
-    lon1 = toRadians . longitude $ p1
-    ell = surface . model $ p1
+    lat1 = Angle.toRadians . Geodetic.latitude $ p1
+    lon1 = Angle.toRadians . Geodetic.longitude $ p1
+    ell = surface . Geodetic.model $ p1
     (a, b, f) = abf ell
-    br1 = toRadians b1
+    br1 = Angle.toRadians b1
     cosAlpha1 = cos br1
     sinAlpha1 = sin br1
     (tanU1, cosU1, sinU1) = reducedLat lat1 f
@@ -109,33 +119,28 @@     uSq = cosSqAlpha * (a * a - b * b) / (b * b)
     _A = 1.0 + uSq / 16384.0 * (4096.0 + uSq * (-768.0 + uSq * (320.0 - 175.0 * uSq)))
     _B = uSq / 1024.0 * (256.0 + uSq * (-128.0 + uSq * (74.0 - 47.0 * uSq)))
-    dm = toMetres d
+    dm = Length.toMetres d
     sigma = dm / (b * _A)
     rec = directRec sigma1 dm _A _B b sigma 0
 
--- | @inverseGeodesic p1 p2@ solves the inverse geodesic problem using Vicenty formula: __surface__ distance,
--- and initial/final bearing between the geodesic line between positions @p1@ and @p2@.
--- The Vincenty formula for the inverse problem can fail to converge for nearly antipodal points in which
--- case this function returns 'Nothing'.
+-- | @inverse p1 p2@ solves the inverse geodesic problem using Vicenty formula: __surface__ distance,
+-- and initial/final bearing between the geodesic line between positions @p1@ and @p2@. For example:
 --
--- ==== __Examples__
+-- >>> Geodesic.inverse (Geodetic.latLongPos 0 0 WGS84) (Geodetic.latLongPos 0.5 179.5 WGS84)
+-- Just (Geodesic { startPosition = 0°0'0.000"N,0°0'0.000"E 0.0m (WGS84)
+--                , endPosition = 0°30'0.000"N,179°30'0.000"E 0.0m (WGS84)
+--                , initialBearing = Just 25°40'18.742"
+--                , finalBearing = Just 154°19'37.507"
+--                , length = 19936.288578981km})
 --
--- >>> import Data.Geo.Jord.Geodesic
--- >>> import Data.Geo.Jord.Position
--- >>>
--- >>> inverseGeodesic (latLongPos 0 0 WGS84) (latLongPos 0.5 179.5 WGS84)
--- Just (Geodesic {geodesicPos1 = 0°0'0.000"N,0°0'0.000"E 0.0m (WGS84)
---               , geodesicPos2 = 0°30'0.000"N,179°30'0.000"E 0.0m (WGS84)
---               , geodesicBearing1 = Just 25°40'18.742"
---               , geodesicBearing2 = Just 154°19'37.507"
---               , geodesicLength = 19936.288578981km})
--- >>>
--- >>> inverseGeodesic (latLongPos 0 0 WGS84) (latLongPos 0.5 179.7 WGS84)
--- Nothing
+-- The Vincenty formula for the inverse problem can fail to converge for nearly antipodal points in which
+-- case this function returns 'Nothing'. For example:
 --
-inverseGeodesic :: (Ellipsoidal a) => Position a -> Position a -> Maybe (Geodesic a)
-inverseGeodesic p1 p2
-    | llEq p1 p2 = Just (Geodesic p1 p2 Nothing Nothing zero)
+-- >>> Geodesic.inverse (Geodetic.latLongPos 0 0 WGS84) (Geodetic.latLongPos 0.5 179.7 WGS84)
+-- Nothing
+inverse :: (Ellipsoidal a) => HorizontalPosition a -> HorizontalPosition a -> Maybe (Geodesic a)
+inverse p1 p2
+    | p1 == p2 = Just (Geodesic p1 p2 Nothing Nothing Length.zero)
     | otherwise =
         case rec of
             Nothing -> Nothing
@@ -153,7 +158,7 @@                            (cosS * (-1.0 + 2.0 * cos2S' * cos2S') -
                             _B / 6.0 * cos2S' * (-3.0 + 4.0 * sinS * sinS) *
                             (-3.0 + 4.0 * cos2S' * cos2S')))
-                      d = metres (b * _A * (s - deltaSigma))
+                      d = Length.metres (b * _A * (s - deltaSigma))
                       a1R =
                           if abs sinSqS < epsilon
                               then 0.0
@@ -162,14 +167,14 @@                           if abs sinSqS < epsilon
                               then pi
                               else atan2 (cosU1 * sinL) (-sinU1 * cosU2 + cosU1 * sinU2 * cosL)
-                      b1 = normalise (radians a1R) (decimalDegrees 360.0)
-                      b2 = normalise (radians a2R) (decimalDegrees 360.0)
+                      b1 = Angle.normalise (Angle.radians a1R) (Angle.decimalDegrees 360.0)
+                      b2 = Angle.normalise (Angle.radians a2R) (Angle.decimalDegrees 360.0)
   where
-    lat1 = toRadians . latitude $ p1
-    lon1 = toRadians . longitude $ p1
-    lat2 = toRadians . latitude $ p2
-    lon2 = toRadians . longitude $ p2
-    ell = surface . model $ p1
+    lat1 = Angle.toRadians . Geodetic.latitude $ p1
+    lon1 = Angle.toRadians . Geodetic.longitude $ p1
+    lat2 = Angle.toRadians . Geodetic.latitude $ p2
+    lon2 = Angle.toRadians . Geodetic.longitude $ p2
+    ell = surface . Geodetic.model $ p1
     (a, b, f) = abf ell
     _L = lon2 - lon1 -- difference in longitude
     (_, cosU1, sinU1) = reducedLat lat1 f
@@ -177,91 +182,6 @@     antipodal = abs _L > pi / 2.0 || abs (lat2 - lat1) > pi / 2.0
     rec = inverseRec _L cosU1 sinU1 cosU2 sinU2 _L f antipodal 0
 
--- | @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 or if 'inverseGeodesic' fails to converge.
---
--- This is equivalent to:
---
--- @
---     ('inverseGeodesic' p1 p2) >>= 'geodesicBearing2'
--- @
---
--- ==== __Examples__
---
--- >>> import Data.Geo.Jord.Geodesic
--- >>> import Data.Geo.Jord.Position
--- >>>
--- >>> p1 = latLongPos (-37.95103341666667) 144.42486788888888 WGS84
--- >>> p2 = latLongPos (-37.65282113888889) 143.92649552777777 WGS84
--- >>> initialBearing p1 p2
--- Just 307°10'25.070"
---
-finalBearing :: (Ellipsoidal a) => Position a -> Position a -> Maybe Angle
-finalBearing p1 p2 = inverseGeodesic p1 p2 >>= geodesicBearing2
-
--- | @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 or if 'inverseGeodesic' fails to converge.
---
--- @
---     ('inverseGeodesic' p1 p2) >>= 'geodesicBearing1'
--- @
---
--- ==== __Examples__
---
--- >>> import Data.Geo.Jord.Geodesic
--- >>> import Data.Geo.Jord.Position
--- >>>
--- >>> p1 = latLongPos (-37.95103341666667) 144.42486788888888 WGS84
--- >>> p2 = latLongPos (-37.65282113888889) 143.92649552777777 WGS84
--- >>> initialBearing p1 p2
--- Just 306°52'5.373"
---
-initialBearing :: (Ellipsoidal a) => Position a -> Position a -> Maybe Angle
-initialBearing p1 p2 = inverseGeodesic p1 p2 >>= geodesicBearing1
-
--- | @surfaceDistance p1 p2@ computes the surface distance on the geodesic between the
--- positions @p1@ and @p2@.
--- This function relies on 'inverseGeodesic' and can therefore fail to compute the distance
--- for nearly antipodal positions.
---
--- @
---     fmap 'geodesicLength' ('inverseGeodesic' p1 p2)
--- @
---
--- ==== __Examples__
---
--- >>> import Data.Geo.Jord.Geodesic
--- >>> import Data.Geo.Jord.Position
--- >>>
--- >>> surfaceDistance (northPole WGS84) (southPole WGS84)
--- Just 20003.931458623km
---
-surfaceDistance :: (Ellipsoidal a) => Position a -> Position a -> Maybe Length
-surfaceDistance p1 p2 = fmap geodesicLength (inverseGeodesic p1 p2)
-
--- | @destination p b d@ computes the position along the geodesic, 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.
---
--- @
---     fmap 'geodesicPos2' ('directGeodesic' p b d)
--- @
---
--- ==== __Examples__
---
--- >>> import Data.Geo.Jord.Geodesic
--- >>> import Data.Geo.Jord.Position
--- >>>
--- >>> destination (wgs84Pos 54 154 (metres 15000)) (decimalDegrees 33) (kilometres 1000)
--- Just 61°10'8.983"N,164°7'52.258"E 15.0km (WGS84)
---
-destination :: (Ellipsoidal a) => Position a -> Angle -> Length -> Maybe (Position a)
-destination p b d = fmap geodesicPos2 (directGeodesic p b d)
-
 directRec ::
        Double
     -> Double
@@ -369,4 +289,4 @@     sinU = tanU * cosU
 
 abf :: Ellipsoid -> (Double, Double, Double)
-abf e = (toMetres . equatorialRadius $ e, toMetres . polarRadius $ e, flattening e)
+abf e = (Length.toMetres . equatorialRadius $ e, Length.toMetres . polarRadius $ e, flattening e)
+ src/Data/Geo/Jord/Geodetic.hs view
@@ -0,0 +1,378 @@+-- |
+-- Module:      Data.Geo.Jord.Geodetic
+-- Copyright:   (c) 2020 Cedric Liegeois
+-- License:     BSD3
+-- Maintainer:  Cedric Liegeois <ofmooseandmen@yahoo.fr>
+-- Stability:   experimental
+-- Portability: portable
+--
+-- Geodetic coordinates of points in specified models (e.g. WGS84) and conversion functions between
+-- /n/-vectors and latitude/longitude.
+--
+-- 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>
+--
+-- See <http://clynchg3c.com/Technote/geodesy/coorddef.pdf Earth Coordinates>
+--
+-- In order to use this module you should start with the following imports:
+--
+-- @
+-- import qualified Data.Geo.Jord.Geodetic as Geodetic
+-- import qualified Data.Geo.Jord.Length as Length
+-- import Data.Geo.Jord.Models
+-- @
+module Data.Geo.Jord.Geodetic
+    (
+    -- * positions types
+      HorizontalPosition
+    , Position
+    , HasCoordinates(..)
+    , height
+    , model
+    , model'
+    -- * Smart constructors
+    , latLongPos
+    , latLongPos'
+    , latLongHeightPos
+    , latLongHeightPos'
+    , wgs84Pos
+    , wgs84Pos'
+    , s84Pos
+    , s84Pos'
+    , nvectorPos
+    , nvectorPos'
+    , nvectorHeightPos
+    , nvectorHeightPos'
+    , atHeight
+    , atSurface
+    -- * Read/Show positions
+    , readHorizontalPosition
+    , horizontalPosition
+    , readPosition
+    , position
+    -- * /n/-vector conversions
+    , nvectorFromLatLong
+    , nvectorToLatLong
+    -- * Misc.
+    , antipode
+    , antipode'
+    , northPole
+    , southPole
+    ) where
+
+import Prelude hiding (read)
+import Text.ParserCombinators.ReadP (ReadP, option, readP_to_S, skipSpaces)
+
+import Data.Geo.Jord.Angle (Angle)
+import qualified Data.Geo.Jord.Angle as Angle
+import qualified Data.Geo.Jord.LatLong as LL (isValidLatLong, isValidLong, latLongDms, showLatLong)
+import Data.Geo.Jord.Length (Length)
+import qualified Data.Geo.Jord.Length as Length (length, zero)
+import qualified Data.Geo.Jord.Math3d as Math3d (V3, scale, v3x, v3y, v3z, vec3)
+import Data.Geo.Jord.Model
+import Data.Geo.Jord.Models (S84(..), WGS84(..))
+
+-- | Horizontal coordinates: geodetic latitude, longitude & /n/-vector.
+data HCoords =
+    HCoords Angle Angle !Math3d.V3
+
+-- | Geodetic coordinates (geodetic latitude, longitude as 'Angle's) of an horizontal position
+-- in a specified 'Model'.
+--
+-- The coordinates are also given as a /n/-vector: the normal vector to the surface.
+-- /n/-vector orientation:
+--     * z-axis points to the North Pole along the body's rotation axis,
+--     * x-axis points towards the point where latitude = longitude = 0
+--
+-- Note: at the poles all longitudes are equal, therefore a position with a latitude of 90° or -90° will have
+-- its longitude forcibly set to 0°.
+--
+-- The "show" instance gives position in degrees, minutes, seconds, milliseconds ('Angle' "show" instance), and the
+-- model ('Model' "show" instance).
+--
+-- The "eq" instance returns True if and only if, both positions have the same latitude, longitude and model.
+-- Note: two positions in different models may represent the same location but are not considered equal.
+data HorizontalPosition a =
+    HorizontalPosition HCoords a
+
+-- | model of given 'HorizontalPosition' (e.g. WGS84).
+model :: (Model a) => HorizontalPosition a -> a
+model (HorizontalPosition _ m) = m
+
+-- | Geodetic coordinates (geodetic latitude, longitude as 'Angle's and height as 'Length') of a position
+-- in a specified model.
+--
+-- The "show" instance gives position in degrees, minutes, seconds, milliseconds (HorizontalPosition "show" instance),
+-- height ('Length' "show" instance) and the model ('Model' "show" instance).
+--
+-- The "eq" instance returns True if and only if, both positions have the same horizontal coordinates and height.
+--
+-- see 'HorizontalPosition'.
+data Position a =
+    Position HCoords Length a
+
+-- | height of given 'Position' above the surface of the celestial body.
+height :: (Model a) => Position a -> Length
+height (Position _ h _) = h
+
+-- | model of given 'Position' (e.g. WGS84).
+model' :: (Model a) => Position a -> a
+model' (Position _ _ m) = m
+
+-- | class for data that provide coordinates.
+class HasCoordinates a where
+    latitude :: a -> Angle -- ^ geodetic latitude
+    decimalLatitude :: a -> Double -- ^ geodetic latitude in decimal degrees
+    decimalLatitude = Angle.toDecimalDegrees . latitude
+    longitude :: a -> Angle -- ^ longitude
+    decimalLongitude :: a -> Double -- ^ longitude  in decimal degrees
+    decimalLongitude = Angle.toDecimalDegrees . longitude
+    nvector :: a -> Math3d.V3 -- ^ /n/-vector; normal vector to the surface of a celestial body.
+
+instance HasCoordinates (HorizontalPosition a) where
+    latitude (HorizontalPosition (HCoords lat _ _) _) = lat
+    longitude (HorizontalPosition (HCoords _ lon _) _) = lon
+    nvector (HorizontalPosition (HCoords _ _ nv) _) = nv
+
+instance (Model a) => Show (HorizontalPosition a) where
+    show p = LL.showLatLong (latitude p, longitude p) ++ " (" ++ (show . model $ p) ++ ")"
+
+-- model equality is ensured by @a@
+instance (Model a) => Eq (HorizontalPosition a) where
+    p1 == p2 = latitude p1 == latitude p2 && longitude p1 == longitude p2
+
+instance HasCoordinates (Position a) where
+    latitude (Position (HCoords lat _ _) _ _) = lat
+    longitude (Position (HCoords _ lon _) _ _) = lon
+    nvector (Position (HCoords _ _ nv) _ _) = nv
+
+instance (Model a) => Show (Position a) where
+    show p =
+        LL.showLatLong (latitude p, longitude p) ++
+        " " ++ (show . height $ p) ++ " (" ++ (show . model' $ p) ++ ")"
+
+-- model equality is ensured by @a@
+instance (Model a) => Eq (Position a) where
+    p1 == p2 = latitude p1 == latitude p2 && longitude p1 == longitude p2 && height p1 == height p2
+
+-- | 'HorizontalPosition' from given geodetic latitude & longitude in __decimal degrees__ in the given model.
+--
+-- Latitude & longitude values are first converted to 'Angle' to ensure a consistent resolution with the rest of the
+-- API, then wrapped to their respective range.
+latLongPos :: (Model a) => Double -> Double -> a -> HorizontalPosition a
+latLongPos lat lon = latLongPos' (Angle.decimalDegrees lat) (Angle.decimalDegrees lon)
+
+-- | 'HorizontalPosition' from given geodetic latitude & longitude in the given model.
+--
+-- Latitude & longitude values are wrapped to their respective range.
+latLongPos' :: (Model a) => Angle -> Angle -> a -> HorizontalPosition a
+latLongPos' lat lon m = HorizontalPosition (HCoords wlat wlon nv) m
+  where
+    lon' = checkPole lat lon
+    nv = nvectorFromLatLong (lat, lon')
+    (wlat, wlon) = wrap lat lon' nv m
+
+-- | 'Position' from given geodetic latitude & longitude in __decimal degrees__ and height in the given model
+--
+-- Latitude & longitude values are first converted to 'Angle' to ensure a consistent resolution with the rest of the
+-- API, then wrapped to their respective range.
+latLongHeightPos :: (Model a) => Double -> Double -> Length -> a -> Position a
+latLongHeightPos lat lon = latLongHeightPos' (Angle.decimalDegrees lat) (Angle.decimalDegrees lon)
+
+-- | 'Position' from given geodetic latitude & longitude and height in the given model.
+-- Latitude & longitude values are wrapped to their respective range.
+latLongHeightPos' :: (Model a) => Angle -> Angle -> Length -> a -> Position a
+latLongHeightPos' lat lon h m = atHeight (latLongPos' lat lon m) h
+
+-- | 'HorizontalPosition' from given geodetic latitude & longitude in __decimal degrees__ in the WGS84 datum.
+--
+-- Latitude & longitude values are first converted to 'Angle' to ensure a consistent resolution with the rest of the
+-- API, then wrapped to their respective range.
+--
+-- This is equivalent to:
+--
+-- > Geodetic.latLongPos lat lon WGS84
+wgs84Pos :: Double -> Double -> HorizontalPosition WGS84
+wgs84Pos lat lon = latLongPos lat lon WGS84
+
+-- | 'HorizontalPosition' from given geodetic latitude & longitude and height in the WGS84 datum.
+--
+-- Latitude & longitude values are wrapped to their respective range.
+--
+-- This is equivalent to:
+--
+-- > Geodetic.latLongPos' lat lon WGS84
+wgs84Pos' :: Angle -> Angle -> HorizontalPosition WGS84
+wgs84Pos' lat lon = latLongPos' lat lon WGS84
+
+-- | 'HorizontalPosition' from given latitude & longitude in __decimal degrees__ in the spherical datum derived from WGS84.
+--
+-- Latitude & longitude values are first converted to 'Angle' to ensure a consistent resolution with the rest of the
+-- API, then wrapped to their respective range.
+--
+-- This is equivalent to:
+--
+-- > Geodetic.latLongPos lat lon S84
+s84Pos :: Double -> Double -> HorizontalPosition S84
+s84Pos lat lon = latLongPos lat lon S84
+
+-- | 'Position' from given latitude & longitude in the spherical datum derived from WGS84.
+--
+-- Latitude & longitude values are wrapped to their respective range.
+--
+-- This is equivalent to:
+--
+-- > Geodetic.latLongPos' lat lon h S84
+s84Pos' :: Angle -> Angle -> HorizontalPosition S84
+s84Pos' lat lon = latLongPos' lat lon S84
+
+-- | 'Position' from given /n/-vector (x, y, z coordinates) in the given model.
+--
+-- (x, y, z) will be converted first to latitude & longitude to ensure a consistent resolution with the rest of the API.
+--
+-- This is equivalent to:
+--
+-- > Geodetic.nvectorPos' (Math3d.vec3 x y z)
+nvectorPos :: (Model a) => Double -> Double -> Double -> a -> HorizontalPosition a
+nvectorPos x y z = nvectorPos' (Math3d.vec3 x y z)
+
+-- | 'HorizontalPosition' from given /n/-vector (x, y, z coordinates) in the given model.
+--
+-- (x, y, z) will be converted first to latitude & longitude to ensure a consistent resolution with the rest of the API.
+nvectorPos' :: (Model a) => Math3d.V3 -> a -> HorizontalPosition a
+nvectorPos' v m = HorizontalPosition (HCoords lat wlon nv) m
+  where
+    (lat, lon) = nvectorToLatLong v
+    lon' = checkPole lat lon
+    nv = nvectorFromLatLong (lat, lon')
+    wlon = convertLon lon' m
+
+-- | 'Position' from given /n/-vector (x, y, z coordinates) and height in the given model.
+--
+-- (x, y, z) will be converted first to latitude & longitude to ensure a consistent resolution with the rest of the API.
+-- This is equivalent to:
+--
+-- > Geodetic.nvectorHeightPos' (Math3d.vec3 x y z) h
+nvectorHeightPos :: (Model a) => Double -> Double -> Double -> Length -> a -> Position a
+nvectorHeightPos x y z = nvectorHeightPos' (Math3d.vec3 x y z)
+
+-- | 'Position' from given /n/-vector (x, y, z coordinates) and height in the given model.
+--
+-- (x, y, z) will be converted first to latitude & longitude to ensure a consistent resolution with the rest of the API.
+nvectorHeightPos' :: (Model a) => Math3d.V3 -> Length -> a -> Position a
+nvectorHeightPos' v h m = atHeight (nvectorPos' v m) h
+
+-- | 'Position' from 'HorizontalPosition' & height.
+atHeight :: (Model a) => HorizontalPosition a -> Length -> Position a
+atHeight (HorizontalPosition c m) h = Position c h m
+
+-- | 'HorizontalPosition' from 'Position'.
+atSurface :: (Model a) => Position a -> HorizontalPosition a
+atSurface (Position c _ m) = HorizontalPosition c m
+
+-- | Reads an 'HorizontalPosition, from the given string using 'horizontalPosition', for example:
+--
+-- >>> Geodetic.readHorizontalPosition "55°36'21''N 013°00'02''E" WGS84
+-- Just 55°36'21.000"N,13°0'2.000"E (WGS84)
+readHorizontalPosition :: (Model a) => String -> a -> Maybe (HorizontalPosition a)
+readHorizontalPosition s m =
+    case map fst $ filter (null . snd) $ readP_to_S (horizontalPosition m) s of
+        [] -> Nothing
+        p:_ -> Just p
+
+-- | Reads a 'Position' from the given string using 'position', for example:
+--
+-- >>> Geodetic.readPosition "55°36'21''N 013°00'02''E 1500m" WGS84
+-- Just 55°36'21.000"N,13°0'2.000"E 1500.0m (WGS84)
+readPosition :: (Model a) => String -> a -> Maybe (Position a)
+readPosition s m =
+    case map fst $ filter (null . snd) $ readP_to_S (position m) s of
+        [] -> Nothing
+        p:_ -> Just p
+
+-- | Parses and returns a 'HorizontalPosition'.
+--
+-- Supported formats:
+--
+--     * DD(MM)(SS)[N|S]DDD(MM)(SS)[E|W] - e.g. 553621N0130002E or 0116S03649E or 47N122W
+--
+--     * 'Angle'[N|S] 'Angle'[E|W] - e.g. 55°36'21''N 13°0'02''E or 11°16'S 36°49'E or 47°N 122°W
+horizontalPosition :: (Model a) => a -> ReadP (HorizontalPosition a)
+horizontalPosition m = do
+    (lat, lon) <- LL.latLongDms m
+    return (latLongPos' lat lon m)
+
+-- | Parses and returns a 'Position': the beginning of the string is parsed by 'horizontalPosition' and additionally the
+-- string may end by a valid 'Length'.
+position :: (Model a) => a -> ReadP (Position a)
+position m = do
+    hp <- horizontalPosition m
+    skipSpaces
+    h <- option Length.zero Length.length
+    return (atHeight hp h)
+
+-- | @nvectorToLatLong nv@ returns (latitude, longitude) pair equivalent to the given /n/-vector @nv@.
+-- Latitude is always in [-90°, 90°] and longitude in [-180°, 180°].
+nvectorToLatLong :: Math3d.V3 -> (Angle, Angle)
+nvectorToLatLong v = (lat, lon)
+  where
+    x = Math3d.v3x v
+    y = Math3d.v3y v
+    z = Math3d.v3z v
+    lat = Angle.atan2 z (sqrt (x * x + y * y))
+    lon = Angle.atan2 y x
+
+-- | @nvectorFromLatLong ll@ returns /n/-vector equivalent to the given (latitude, longitude) pair @ll@.
+nvectorFromLatLong :: (Angle, Angle) -> Math3d.V3
+nvectorFromLatLong (lat, lon) = Math3d.vec3 x y z
+  where
+    cl = Angle.cos lat
+    x = cl * Angle.cos lon
+    y = cl * Angle.sin lon
+    z = Angle.sin lat
+
+-- | @antipode p@ computes the antipodal position of @p@: the position which is diametrically opposite to @p@.
+antipode :: (Model a) => HorizontalPosition a -> HorizontalPosition a
+antipode p = nvectorPos' (anti . nvector $ p) (model p)
+
+-- | @antipode p@ computes the antipodal position of @p@: the position which is diametrically opposite to @p@ at the
+-- same height.
+antipode' :: (Model a) => Position a -> Position a
+antipode' p = nvectorHeightPos' (anti . nvector $ p) (height p) (model' p)
+
+anti :: Math3d.V3 -> Math3d.V3
+anti v = Math3d.scale v (-1.0)
+
+-- | Horizontal position of the North Pole in the given model.
+northPole :: (Model a) => a -> HorizontalPosition a
+northPole = latLongPos 90 0
+
+-- | Horizontal position of the South Pole in the given model.
+southPole :: (Model a) => a -> HorizontalPosition a
+southPole = latLongPos (-90) 0
+
+wrap :: (Model a) => Angle -> Angle -> Math3d.V3 -> a -> (Angle, Angle)
+wrap lat lon nv m =
+    if LL.isValidLatLong lat lon m
+        then (lat, lon)
+        else llWrapped nv m
+
+llWrapped :: (Model a) => Math3d.V3 -> a -> (Angle, Angle)
+llWrapped nv m = (lat, lon')
+  where
+    (lat, lon) = nvectorToLatLong nv
+    lon' = convertLon lon m
+
+convertLon :: (Model a) => Angle -> a -> Angle
+convertLon lon m =
+    case (longitudeRange m) of
+        L180 -> lon
+        L360 ->
+            if LL.isValidLong lon m
+                then lon
+                else Angle.add lon (Angle.decimalDegrees 360)
+
+checkPole :: Angle -> Angle -> Angle
+checkPole lat lon
+    | lat == Angle.decimalDegrees 90 || lat == Angle.decimalDegrees (-90) = Angle.zero
+    | otherwise = lon
src/Data/Geo/Jord/GreatCircle.hs view
@@ -12,451 +12,393 @@ -- In order to use this module you should start with the following imports:
 --
 -- @
---     import Data.Geo.Jord.GreatCircle
---     import Data.Geo.Jord.Position
+-- import qualified Data.Geo.Jord.Geodetic as Geodetic
+-- import qualified Data.Geo.Jord.GreatCircle as GreatCircle
 -- @
 --
--- 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
+    , through
+    , headingOn
     -- * The 'MinorArc' type
     , MinorArc
     , minorArc
+    , minorArcStart
+    , minorArcEnd
     -- * Calculations
+    , Side(..)
     , alongTrackDistance
     , alongTrackDistance'
     , angularDistance
     , crossTrackDistance
     , crossTrackDistance'
     , destination
+    , distance
+    , enclosedBy
     , finalBearing
     , initialBearing
-    , interpolate
+    , interpolated
     , intersection
     , intersections
-    , isBetween
-    , isInsideSurface
     , mean
-    , surfaceDistance
+    , projection
+    , side
+    , turn
     ) where
 
-import Data.Fixed (Nano)
-import Data.List (subsequences)
-
-import Data.Geo.Jord.Internal
-import Data.Geo.Jord.Position
+import Data.Geo.Jord.Angle (Angle)
+import qualified Data.Geo.Jord.Angle as Angle
+import Data.Geo.Jord.Ellipsoid (equatorialRadius)
+import Data.Geo.Jord.Geodetic (HorizontalPosition)
+import qualified Data.Geo.Jord.Geodetic as Geodetic
+import Data.Geo.Jord.Length (Length)
+import qualified Data.Geo.Jord.Length as Length
+import Data.Geo.Jord.Math3d (V3)
+import qualified Data.Geo.Jord.Math3d as Math3d
+import Data.Geo.Jord.Model (Spherical, surface)
 
--- | A circle on the __surface__ of a __sphere__ which lies in a plane
+-- | 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
+    GreatCircle !V3 !a String
     deriving (Eq)
 
-instance (Model a) => Show (GreatCircle a) where
+instance (Spherical 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__
+-- | @through p1 p2@ returns the 'GreatCircle' passing by both positions @p1@ and @p2@ (in this direction). For example:
 --
--- >>> 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) }
+-- >>> let p1 = Geodetic.s84Pos 45.0 (-143.5)
+-- >>> let p2 = Geodetic.s84Pos 46.0 14.5
+-- >>> GreatCircle.through p1 p2
+-- Just Great Circle { through 45°0'0.000"N,143°30'0.000"W (S84) & 46°0'0.000"N,14°30'0.000"E (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)
+-- Returns 'Nothing' if given positions are equal or @p1@ is antipode of @p2@.
+through :: (Spherical a) => HorizontalPosition a -> HorizontalPosition a -> Maybe (GreatCircle a)
+through p1 p2 = fmap (\n -> GreatCircle n (Geodetic.model p1) dscr) (arcNormal p1 p2)
   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" }
+-- | @headingOn p b@ returns the 'GreatCircle' passing by position @p@ and heading on bearing @b@. For example:
 --
-greatCircleHeadingOn :: (Spherical a) => Position a -> Angle -> GreatCircle a
-greatCircleHeadingOn p b = GreatCircle (vsub n' e') (model p) dscr
+-- >>> let p = Geodetic.s84Pos 45.0 (-143.5)
+-- >>> let b = Angle.decimalDegrees 33.0
+-- >>> GreatCircle.headingOn p b
+-- Great Circle { by 45°0'0.000"N,143°30'0.000"W (S84) & heading on 33°0'0.000" }
+headingOn :: (Spherical a) => HorizontalPosition a -> Angle -> GreatCircle a
+headingOn p b = GreatCircle (Math3d.subtract n' e') m 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)
+    m = Geodetic.model p
+    v = Geodetic.nvector p
+    e = Math3d.cross (Geodetic.nvector . Geodetic.northPole $ m) v -- easting
+    n = Math3d.cross v e -- northing
+    e' = Math3d.scale e (Angle.cos b / Math3d.norm e)
+    n' = Math3d.scale n (Angle.sin b / Math3d.norm 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.
+-- | 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)
+    MinorArc !V3 (HorizontalPosition a) (HorizontalPosition a)
     deriving (Eq)
 
-instance (Model a) => Show (MinorArc a) where
+instance (Spherical 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__
+-- | @minorArc p1 p2@ returns the 'MinorArc' from @p1@ to @p2@.  For example:
 --
--- >>> 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) }
+-- >>> let p1 = Geodetic.s84Pos 45.0 (-143.5)
+-- >>> let p2 = Geodetic.s84Pos 46.0 14.5
+-- >>> GreatCircle.minorArc p1 p2
+-- Just Minor Arc { from: 45°0'0.000"N,143°30'0.000"W (S84), to: 46°0'0.000"N,14°30'0.000"E (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)
+-- Returns 'Nothing' if given positions are equal.
+minorArc :: (Spherical a) => HorizontalPosition a -> HorizontalPosition a -> Maybe (MinorArc a)
+minorArc p1 p2 = fmap (\n -> MinorArc n p1 p2) (arcNormal 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
+-- | @minorArcStart ma@ returns the start position of minor arc @ma@.
+minorArcStart :: (Spherical a) => MinorArc a -> HorizontalPosition a
+minorArcStart (MinorArc _ s _) = s
+
+-- | @minorArcEnd ma@ returns the end position of minor arc @ma@.
+minorArcEnd :: (Spherical a) => MinorArc a -> HorizontalPosition a
+minorArcEnd (MinorArc _ _ e) = e
+
+-- | Side of a position w.r.t. to a great circle.
+data Side
+    = LeftOf -- ^ position is left of the great circle.
+    | RightOf -- ^ position is right of the great circle.
+    | None -- ^ position is on the great circle, or the great circle is undefined.
+    deriving (Eq, Show)
+
+-- | @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. For example:
 --
-alongTrackDistance :: (Spherical a) => Position a -> MinorArc a -> Length
+-- >>> let p = Geodetic.s84Pos 53.2611 (-0.7972)
+-- >>> let mas = Geodetic.s84Pos 53.3206 (-1.7297)
+-- >>> let mae = Geodetic.s84Pos 53.1887 0.1334
+-- >>> fmap (GreatCircle.alongTrackDistance p) (GreatCircle.minorArc mas mae)
+-- Just 62.3315791km
+alongTrackDistance :: (Spherical a) => HorizontalPosition 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' 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. For example:
 --
-alongTrackDistance' :: (Spherical a) => Position a -> Position a -> Angle -> Length
+-- >>> let p = Geodetic.s84Pos 53.2611 (-0.7972)
+-- >>> let s = Geodetic.s84Pos 53.3206 (-1.7297)
+-- >>> let b = Angle.decimalDegrees 96.0017325
+-- >>> GreatCircle.alongTrackDistance' p s b
+-- 62.3315791km
+alongTrackDistance' ::
+       (Spherical a) => HorizontalPosition a -> HorizontalPosition a -> Angle -> Length
 alongTrackDistance' p s b = alongTrackDistance'' p s n
   where
-    (GreatCircle n _ _) = greatCircleHeadingOn s b
+    (GreatCircle n _ _) = headingOn 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
+-- If @n@ is 'Nothing', the angle is always in [0..180], otherwise it is in [-180, +180], signed + if @p1@ is clockwis
+-- looking along @n@, - in opposite direction.
+angularDistance ::
+       (Spherical a)
+    => HorizontalPosition a
+    -> HorizontalPosition a
+    -> Maybe (HorizontalPosition a)
+    -> Angle
+angularDistance p1 p2 n = signedAngleBetween v1 v2 vn
   where
-    v1 = nvec p1
-    v2 = nvec p2
-    vn = fmap nvec n
+    v1 = Geodetic.nvector p1
+    v2 = Geodetic.nvector p2
+    vn = fmap Geodetic.nvector 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
+-- Returns a negative 'Length' if Position is left of great circle, positive 'Length' if Position is right
+-- of great circle; the orientation of the great circle is therefore important. For example:
 --
-crossTrackDistance :: (Spherical a) => Position a -> GreatCircle a -> Length
-crossTrackDistance p (GreatCircle n _ _) = arcLength (sub a (decimalDegrees 90)) (radius p)
+-- >>> let p = Geodetic.s84Pos 53.2611 (-0.7972)
+-- >>> let gc1 = GreatCircle.through (Geodetic.s84Pos 51 0) (Geodetic.s84Pos 52 1)
+-- >>> fmap (GreatCircle.crossTrackDistance p) gc1
+-- Just -176.756870526km
+-- >>> let gc2 = GreatCircle.through (Geodetic.s84Pos 52 1) (Geodetic.s84Pos 51 0)
+-- >>> fmap (GreatCircle.crossTrackDistance p) gc2
+-- Just 176.7568725km
+-- >>> let gc3 = GreatCircle.headingOn (Geodetic.s84Pos 53.3206 (-1.7297)) (Angle.decimalDegrees 96.0)
+-- >>> GreatCircle.crossTrackDistance p gc3
+-- -305.665267m metres
+crossTrackDistance :: (Spherical a) => HorizontalPosition a -> GreatCircle a -> Length
+crossTrackDistance p (GreatCircle n _ _) =
+    Angle.arcLength (Angle.subtract a (Angle.decimalDegrees 90)) (radius p)
   where
-    a = radians (angleRadians n (nvec p))
+    a = angleBetween n (Geodetic.nvector 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@.
+-- | @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)
+-- > GreatCircle.crossTrackDistance p (GreatCircle.headingOn s b)
+crossTrackDistance' ::
+       (Spherical a) => HorizontalPosition a -> HorizontalPosition a -> Angle -> Length
+crossTrackDistance' p s b = crossTrackDistance p (headingOn 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__
+-- | @destination p b d@ computes the position along the great circle, reached from position @p@ having travelled the
+-- distance @d@ on the initial bearing (compass angle) @b@. For example:
 --
--- >>> 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)
+-- >>> let p = Geodetic.s84Pos 54 154
+-- >>> GreatCircle.destination p (Angle.decimalDegrees 33) (Length.kilometres 1000)
+-- 61°10'44.188"N,164°10'19.254"E (S84)
 --
-destination :: (Spherical a) => Position a -> Angle -> Length -> Position a
+-- Note that the bearing will normally vary before destination is reached.
+destination :: (Spherical a) => HorizontalPosition a -> Angle -> Length -> HorizontalPosition a
 destination p b d
-    | d == zero = p
-    | otherwise = nvh nvd (height p) (model p)
+    | d == Length.zero = p
+    | otherwise = Geodetic.nvectorPos' nvd m
   where
-    nv = nvec p
-    ed = vunit (vcross nvNorthPole nv) -- east direction vector at v
-    nd = vcross nv ed -- north direction vector at v
+    m = Geodetic.model p
+    nv = Geodetic.nvector p
+    ed = Math3d.unit (Math3d.cross (Geodetic.nvector . Geodetic.northPole $ m) nv) -- east direction vector at v
+    nd = Math3d.cross 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))
+    ta = Angle.central d r -- central angle
+    de = Math3d.add (Math3d.scale nd (Angle.cos b)) (Math3d.scale ed (Angle.sin b)) -- unit vector in the direction of the azimuth
+    nvd = Math3d.add (Math3d.scale nv (Angle.cos ta)) (Math3d.scale de (Angle.sin ta))
 
--- | @surfaceDistance p1 p2@ computes the surface distance on the great circle between the
--- positions @p1@ and @p2@.
---
--- ==== __Examples__
+-- | @distance p1 p2@ computes the surface distance on the great circle between the positions @p1@ and @p2@. For example:
 --
--- >>> import Data.Geo.Jord.GreatCircle
--- >>> import Data.Geo.Jord.Position
--- >>>
--- >>> surfaceDistance (northPole S84) (southPole S84)
+-- >>> GreatCircle.distance (Geodetic.northPole S84) (Geodetic.southPole S84)
 -- 20015.114352233km
--- >>>
--- >>> surfaceDistance (northPole S84) (northPole S84)
+-- >>> GreatCircle.distance (Geodetic.northPole S84) (Geodetic.northPole S84)
 -- 0.0m
+distance :: (Spherical a) => HorizontalPosition a -> HorizontalPosition a -> Length
+distance p1 p2 = Angle.arcLength a (radius p1)
+  where
+    a = angleBetween (Geodetic.nvector p1) (Geodetic.nvector p2)
+
+-- | @enclosedBy p ps@ determines whether position @p@ is enclosed by the polygon defined by horizontal positions @ps@.
+-- The polygon can be opened or closed (i.e. if @head ps /= last ps@).
 --
-surfaceDistance :: (Spherical a) => Position a -> Position a -> Length
-surfaceDistance p1 p2 = arcLength a (radius p1)
+-- 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 or if @p@ is any of the @ps@.
+--
+-- ==== __Examples__
+--
+-- >>> let malmo = Geodetic.s84Pos 55.6050 13.0038
+-- >>> let ystad = Geodetic.s84Pos 55.4295 13.82
+-- >>> let lund = Geodetic.s84Pos 55.7047 13.1910
+-- >>> let helsingborg = Geodetic.s84Pos 56.0465 12.6945
+-- >>> let kristianstad = Geodetic.s84Pos 56.0294 14.1567
+-- >>> let polygon = [malmo, ystad, kristianstad, helsingborg, lund]
+-- >>> let hoor = Geodetic.s84Pos 55.9295 13.5297
+-- >>> let hassleholm = Geodetic.s84Pos 56.1589 13.7668
+-- >>> GreatCircle.enclosedBy hoor polygon
+-- True
+-- >>> GreatCircle.enclosedBy hassleholm polygon
+-- False
+enclosedBy :: (Spherical a) => HorizontalPosition a -> [HorizontalPosition a] -> Bool
+enclosedBy p ps
+    | null ps = False
+    | p `elem` ps = False
+    | head ps == last ps = enclosedBy p (init ps)
+    | length ps < 3 = False
+    | otherwise =
+        let aSum =
+                foldl
+                    (\a v' -> Angle.add a (uncurry signedAngleBetween v' (Just v)))
+                    Angle.zero
+                    (egdes (map (Math3d.subtract v) vs))
+         in abs (Angle.toDecimalDegrees aSum) > 180.0
   where
-    a = radians (angleRadians (nvec p1) (nvec p2))
+    v = Geodetic.nvector p
+    vs = fmap Geodetic.nvector ps
 
 -- | @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__
---
+-- For example:
 --
--- >>> 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
+-- >>> let p1 = Geodetic.s84Pos 0 1
+-- >>> let p2 = Geodetic.s84Pos 0 0
+-- >>> GreatCircle.finalBearing p1 p2
 -- Just 270°0'0.000"
--- >>>
--- >>> finalBearing p1 p1
--- Nothing
 --
-finalBearing :: (Spherical a) => Position a -> Position a -> Maybe Angle
+-- Returns 'Nothing' if both positions are equals.
+finalBearing :: (Spherical a) => HorizontalPosition a -> HorizontalPosition a -> Maybe Angle
 finalBearing p1 p2
-    | llEq p1 p2 = Nothing
-    | otherwise = Just (normalise (initialBearing' p2 p1) (decimalDegrees 180))
+    | p1 == p2 = Nothing
+    | otherwise = Just (Angle.normalise (initialBearing' p2 p1) (Angle.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__
+-- For example:
 --
--- >>> 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
+-- >>> let p1 = Geodetic.s84Pos 58.643889 (-5.714722)
+-- >>> let p2 = Geodetic.s84Pos 50.066389 (-5.714722)
+-- >>> GreatCircle.initialBearing p1 p2
 -- Just 180°0'0.000"
--- >>>
--- >>> initialBearing p1 p1
--- Nothing
 --
-initialBearing :: (Spherical a) => Position a -> Position a -> Maybe Angle
+-- Returns 'Nothing' if both positions are equals.
+initialBearing :: (Spherical a) => HorizontalPosition a -> HorizontalPosition a -> Maybe Angle
 initialBearing p1 p2
-    | llEq p1 p2 = Nothing
+    | p1 == p2 = Nothing
     | otherwise = Just (initialBearing' p1 p2)
 
--- | @interpolate p0 p1 f# computes the position at fraction @f@ between the @p0@ and @p1@.
+-- | @interpolated p0 p1 f# computes the position at fraction @f@ between the @p0@ and @p1@. For example:
 --
+-- >>> let p1 = Geodetic.s84Pos 53.479444 (-2.245278)
+-- >>> let p2 = Geodetic.s84Pos 55.605833 13.035833
+-- >>> GreatCircle.interpolated p1 p2 0.5
+-- 54°47'0.805"N,5°11'41.947"E (S84)
+--
 -- Special conditions:
 --
 -- @
---     interpolate p0 p1 0.0 = p0
---     interpolate p0 p1 1.0 = p1
--- @
---
+-- interpolated p0 p1 0.0 = p0
+-- interpolated 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
+-- @
+interpolated ::
+       (Spherical a)
+    => HorizontalPosition a
+    -> HorizontalPosition a
+    -> Double
+    -> HorizontalPosition a
+interpolated 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)
+    | otherwise = Geodetic.nvectorPos' iv (Geodetic.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
+    nv0 = Geodetic.nvector p0
+    nv1 = Geodetic.nvector p1
+    iv = Math3d.unit (Math3d.add nv0 (Math3d.scale (Math3d.subtract nv1 nv0) f))
 
--- | Computes the intersection between the two given minor arcs of great circle.
---
--- see also 'intersections'
---
--- ==== __Examples__
+-- | Computes the intersection between the two given minor arcs of great circle. For example:
 --
--- >>> 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)
+-- >>> let a1s = Geodetic.s84Pos 51.885 0.235
+-- >>> let a1e = Geodetic.s84Pos 48.269 13.093
+-- >>> let a2s = Geodetic.s84Pos 49.008 2.549
+-- >>> let a2e = Geodetic.s84Pos 56.283 11.304
+-- >>> GreatCircle.intersection <$> (GreatCircle.minorArc a1s a1e) <*> (GreatCircle.minorArc a2s a2e)
+-- Just (Just 50°54'6.260"N,4°29'39.052"E (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
+-- see also 'intersections'
+intersection :: (Spherical a) => MinorArc a -> MinorArc a -> Maybe (HorizontalPosition a)
+intersection a1@(MinorArc n1 s1 e1) a2@(MinorArc n2 s2 e2) =
+    case intersections' n1 n2 (Geodetic.model s1) of
         Nothing -> Nothing
         (Just (i1, i2))
-            | isBetween i1 a1 && isBetween i1 a2 -> Just i1
-            | isBetween i2 a1 && isBetween i2 a2 -> Just i2
+            | onMinorArc pot a1 && onMinorArc pot a2 -> Just pot
             | otherwise -> Nothing
+            where mid =
+                      meanV
+                          [ Geodetic.nvector s1
+                          , Geodetic.nvector e1
+                          , Geodetic.nvector s2
+                          , Geodetic.nvector e2
+                          ]
+                  pot =
+                      if Math3d.dot (Geodetic.nvector i1) mid > 0
+                          then i1
+                          else i2
 
 -- | 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
+-- Two great circles intersect exactly twice unless there are equal (regardless of orientation), in which case 'Nothing'
+-- is returned. For example:
 --
-intersections :: (Spherical a) => GreatCircle a -> GreatCircle a -> Maybe (Position a, Position a)
+-- >>> let gc1 = GreatCircle.headingOn (Geodetic.s84Pos 51.885 0.235) (Angle.decimalDegrees 108.63)
+-- >>> let gc2 = GreatCircle.headingOn (Geodetic.s84Pos 49.008 2.549) (Angle.decimalDegrees 32.72)
+-- >>> GreatCircle.intersections gc1 gc2
+-- Just (50°54'6.201"N,4°29'39.401"E (S84),50°54'6.201"S,175°30'20.598"W (S84))
+-- >>> let is = GreatCircle.intersections gc1 gc2
+-- >>> fmap fst is == fmap (Geodetic.antipode . snd) is
+-- True
+intersections ::
+       (Spherical a)
+    => GreatCircle a
+    -> GreatCircle a
+    -> Maybe (HorizontalPosition a, HorizontalPosition 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
+-- | @mean ps@ computes the geographic mean horizontal position of @ps@, if it is defined. For example:
 --
-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.
+-- >>> let ps =
+--             [ Geodetic.s84Pos 90 0
+--             , Geodetic.s84Pos 60 10
+--             , Geodetic.s84Pos 50 (-20)
+--             ]
+-- >>> GreatCircle.mean ps
+-- Just 67°14'10.150"N,6°55'3.040"W (S84)
 --
 -- The geographic mean is not defined for antipodals positions (since they
 -- cancel each other).
@@ -464,64 +406,172 @@ -- Special conditions:
 --
 -- @
---     mean [] = Nothing
---     mean [p] = Just p
---     mean [p1, p2, p3] = Just circumcentre
---     mean [p1, .., antipode p1] = Nothing
+-- mean [] = Nothing
+-- mean [p] = Just p
+-- mean [p1, .., antipode p1] = Nothing
 -- @
-mean :: (Spherical a) => [Position a] -> Maybe (Position a)
+mean :: (Spherical a) => [HorizontalPosition a] -> Maybe (HorizontalPosition a)
 mean [] = Nothing
 mean [p] = Just p
 mean ps =
-    if null antipodals
-        then Just (nvh nv zero (model . head $ ps))
-        else Nothing
+    if any (`elem` ps) antipodes
+        then Nothing
+        else Just
+                 (Geodetic.nvectorPos'
+                      (meanV (fmap Geodetic.nvector ps))
+                      (Geodetic.model . head $ ps))
   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
+    antipodes = fmap Geodetic.antipode ps
 
+-- | @projection p ma@ computes the projection of the position @p@ on the minor arc @ma@. Returns 'Nothing' if the
+-- position @p@ is the normal of the minor arc or if the projection is not within the minor arc @ma@ (including start
+-- & end). For example:
+--
+-- >>> let p = Geodetic.s84Pos 53.2611 (-0.7972)
+-- >>> let ma = fromJust (GreatCircle.minorArc (Geodetic.s84Pos 53.3206 (-1.7297)) (Geodetic.s84Pos 53.1887 0.1334))
+-- >>> GreatCircle.projection p ma
+-- Just Geodetic.s84Pos 53.25835330666666 (-0.7977433863888889)
+projection :: (Spherical a) => HorizontalPosition a -> MinorArc a -> Maybe (HorizontalPosition a)
+projection p ma@(MinorArc na mas mae) =
+    case mnb of
+        Nothing -> Nothing
+        (Just nb) ->
+            case is of
+                Nothing -> Nothing
+                (Just (i1, i2)) ->
+                    if onMinorArc pot ma
+                        then Just pot
+                        else Nothing
+                    where mid =
+                              meanV
+                                  [ Geodetic.nvector mas
+                                  , Geodetic.nvector mae
+                                  , Geodetic.nvector nap
+                                  , Geodetic.nvector nbp
+                                  ]
+                          pot =
+                              if Math3d.dot mid (Geodetic.nvector i1) > 0
+                                  then i1
+                                  else i2
+            where nbp = Geodetic.nvectorPos' nb m -- ensure resolution of lat, lon
+                  is = intersections' (Geodetic.nvector nap) (Geodetic.nvector nbp) m
+  where
+    m = Geodetic.model p
+    nap = Geodetic.nvectorPos' na m -- ensure resolution of lat, lon
+    mnb = arcNormal nap p -- normal to great circle (na, p) - if na is p or antipode of p, then projection is not possible.
+
+-- | @side p0 p1 p2@ determines whether @p0@ is left of, right of or on the great circle passing through @p1@ and @p2@ (in this
+-- direction). For example:
+--
+-- >>> GreatCircle.side (Geodetic.s84Pos 10 10) (Geodetic.s84Pos 0 0) (Geodetic.northPole S84)
+-- RightOf
+-- >>> GreatCircle.side (Geodetic.s84Pos 10 (-10)) (Geodetic.s84Pos 0 0) (Geodetic.northPole S84)
+-- LeftOf
+-- >>> GreatCircle.side (Geodetic.s84Pos 10 0) (Geodetic.s84Pos 0 0) (Geodetic.northPole S84)
+-- None
+-- Returns 'None' if @p1@ & @p2@ do not define a great circle (see 'through') or if any of the three position are equal.
+side ::
+       (Spherical a) => HorizontalPosition a -> HorizontalPosition a -> HorizontalPosition a -> Side
+side p0 p1 p2
+    | p0 == p1 || p0 == p2 = None
+    | otherwise = maybe None toSide ms
+  where
+    ms = fmap (Math3d.dot (Geodetic.nvector p0)) (arcNormal p1 p2)
+
+-- | @turn a b c@ computes the angle turned from AB to BC; the angle is positive for left turn, negative for right turn
+-- and 0 if all 3 positions are aligned or if any 2 positions are equal. For example:
+--
+-- >>> GreatCircle.turn (Geodetic.s84Pos 0 0) (Geodetic.s84Pos 45 0) (Geodetic.s84Pos 60 (-10))
+-- 18°11'33.741"
+turn ::
+       (Spherical a)
+    => HorizontalPosition a
+    -> HorizontalPosition a
+    -> HorizontalPosition a
+    -> Angle
+turn a b c =
+    case ns of
+        (Just n1, Just n2) -> signedAngleBetween n1 n2 (Just (Geodetic.nvector b))
+        (_, _) -> Angle.zero
+  where
+    ns = (fmap Math3d.unit (arcNormal a b), fmap Math3d.unit (arcNormal b c))
+
 -- private
-alongTrackDistance'' :: (Spherical a) => Position a -> Position a -> Vector3d -> Length
-alongTrackDistance'' p s n = arcLength a (radius s)
+alongTrackDistance'' ::
+       (Spherical a) => HorizontalPosition a -> HorizontalPosition a -> V3 -> Length
+alongTrackDistance'' p s n = Angle.arcLength a (radius s)
   where
-    a = signedAngle (nvec s) (vcross (vcross n (nvec p)) n) (Just n)
+    a =
+        signedAngleBetween
+            (Geodetic.nvector s)
+            (Math3d.cross (Math3d.cross n (Geodetic.nvector p)) n)
+            (Just n)
 
 -- | [p1, p2, p3, p4] to [(p1, p2), (p2, p3), (p3, p4), (p4, p1)]
-egdes :: [Vector3d] -> [(Vector3d, Vector3d)]
+egdes :: [V3] -> [(V3, V3)]
 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' ::
+       (Spherical a) => V3 -> V3 -> a -> Maybe (HorizontalPosition a, HorizontalPosition a)
 intersections' n1 n2 s
-    | (vnorm i :: Double) == 0.0 = Nothing
+    | (Math3d.norm i :: Double) == 0.0 = Nothing
     | otherwise
-    , let ni = nvh (vunit i) zero s = Just (ni, antipode ni)
+    , let ni = Geodetic.nvectorPos' (Math3d.unit i) s = Just (ni, Geodetic.antipode ni)
   where
-    i = vcross n1 n2
+    i = Math3d.cross n1 n2
 
-initialBearing' :: Position a -> Position a -> Angle
-initialBearing' p1 p2 = normalise a (decimalDegrees 360)
+initialBearing' :: (Spherical a) => HorizontalPosition a -> HorizontalPosition a -> Angle
+initialBearing' p1 p2 = Angle.normalise a (Angle.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))
+    m = Geodetic.model p1
+    v1 = Geodetic.nvector p1
+    v2 = Geodetic.nvector p2
+    gc1 = Math3d.cross v1 v2 -- great circle through p1 & p2
+    gc2 = Math3d.cross v1 (Geodetic.nvector . Geodetic.northPole $ m) -- great circle through p1 & north pole
+    a = signedAngleBetween gc1 gc2 (Just v1)
 
+arcNormal :: (Spherical a) => HorizontalPosition a -> HorizontalPosition a -> Maybe V3
+arcNormal p1 p2
+    | p1 == p2 = Nothing
+    | Geodetic.antipode p1 == p2 = Nothing
+    | otherwise = Just (Math3d.cross (Geodetic.nvector p1) (Geodetic.nvector p2))
+
+-- | @onMinorArc p a@ determines whether position @p@ is on the minor arc @a@.
+-- Warning: this function assumes that @p@ is on great circle of the minor arc.
+-- return true if chord lengths between (p, start) & (p, end) are both less than
+-- chord length between (start, end)
+onMinorArc :: (Spherical a) => HorizontalPosition a -> MinorArc a -> Bool
+onMinorArc p (MinorArc _ s e) =
+    Math3d.squaredDistance v0 v1 <= l && Math3d.squaredDistance v0 v2 <= l
+  where
+    v0 = Geodetic.nvector p
+    v1 = Geodetic.nvector s
+    v2 = Geodetic.nvector e
+    l = Math3d.squaredDistance v1 v2
+
 -- | reference sphere radius.
-radius :: (Spherical a) => Position a -> Length
-radius = equatorialRadius . surface . model
+radius :: (Spherical a) => HorizontalPosition a -> Length
+radius = equatorialRadius . surface . Geodetic.model
 
-normal' :: (Spherical a) => Position a -> Position a -> Vector3d
-normal' p1 p2 = vcross (nvec p1) (nvec p2)
+-- | angle between 2 vectors.
+angleBetween :: V3 -> V3 -> Angle
+angleBetween v1 v2 = signedAngleBetween v1 v2 Nothing
 
-signedAngle :: Vector3d -> Vector3d -> Maybe Vector3d -> Angle
-signedAngle v1 v2 n = radians (signedAngleRadians v1 v2 n)
+-- | Signed angle between 2 vectors.
+-- If @n@ is 'Nothing', the angle is always in [0..pi], otherwise it is in [-pi, +pi],
+-- signed + if @v1@ is clockwise looking along @n@, - in opposite direction.
+signedAngleBetween :: V3 -> V3 -> Maybe V3 -> Angle
+signedAngleBetween v1 v2 n = Angle.atan2 sinO cosO
+  where
+    sign = maybe 1 (signum . Math3d.dot (Math3d.cross v1 v2)) n
+    sinO = sign * Math3d.norm (Math3d.cross v1 v2)
+    cosO = Math3d.dot v1 v2
+
+meanV :: [Math3d.V3] -> V3
+meanV vs = Math3d.unit $ foldl Math3d.add Math3d.zero vs
+
+toSide :: Double -> Side
+toSide s
+    | s < 0 = RightOf
+    | s > 0 = LeftOf
+    | otherwise = None
− src/Data/Geo/Jord/Internal.hs
@@ -1,35 +0,0 @@--- |
--- Module:      Data.Geo.Jord.Internal
--- Copyright:   (c) 2020 Cedric Liegeois
--- License:     BSD3
--- Maintainer:  Cedric Liegeois <ofmooseandmen@yahoo.fr>
--- Stability:   experimental
--- Portability: portable
---
--- internal functions.
---
-module Data.Geo.Jord.Internal
-    ( angleRadians
-    , signedAngleRadians
-    , llEq
-    ) where
-
-import Data.Geo.Jord.Position
-
--- | angle in __radians__ between 2 vectors.
-angleRadians :: Vector3d -> Vector3d -> Double
-angleRadians v1 v2 = signedAngleRadians v1 v2 Nothing
-
--- | Signed angle in __radians__ between 2 vectors.
--- If @n@ is 'Nothing', the angle is always in [0..pi], otherwise it is in [-pi, +pi],
--- signed + if @v1@ is clockwise looking along @n@, - in opposite direction.
-signedAngleRadians :: Vector3d -> Vector3d -> Maybe Vector3d -> Double
-signedAngleRadians v1 v2 n = atan2 sinO cosO
-  where
-    sign = maybe 1 (signum . vdot (vcross v1 v2)) n
-    sinO = sign * vnorm (vcross v1 v2)
-    cosO = vdot v1 v2
-
--- | both position have same latitude and longitude irrespective of model ?
-llEq :: Position a -> Position a -> Bool
-llEq p1 p2 = latitude p1 == latitude p2 && longitude p1 == longitude p2
src/Data/Geo/Jord/Kinematics.hs view
@@ -11,8 +11,8 @@ -- In order to use this module you should start with the following imports:
 --
 -- @
---     import Data.Geo.Jord.Kinematics
---     import Data.Geo.Jord.Position
+-- import qualified Data.Geo.Jord.Geodetic as Geodetic
+-- import qualified Data.Geo.Jord.Kinematics as Kinematics
 -- @
 --
 -- All functions are implemented using the vector-based approached described in
@@ -27,20 +27,21 @@     , Course
     -- * The 'Cpa' type.
     , Cpa
-    , cpaTime
-    , cpaDistance
-    , cpaPosition1
-    , cpaPosition2
+    , timeToCpa
+    , distanceAtCpa
+    , cpaOwnshipPosition
+    , cpaIntruderPosition
     -- * The 'Intercept' type.
     , Intercept
-    , interceptTime
-    , interceptDistance
+    , timeToIntercept
+    , distanceToIntercept
     , interceptPosition
     , interceptorBearing
     , interceptorSpeed
     -- * Calculations
     , course
     , positionAfter
+    , positionAfter'
     , trackPositionAfter
     , cpa
     , intercept
@@ -54,16 +55,25 @@ import Control.Applicative ((<|>))
 import Data.Maybe (fromJust, isNothing)
 
-import Data.Geo.Jord.Duration
-import Data.Geo.Jord.GreatCircle
-import Data.Geo.Jord.Internal
-import Data.Geo.Jord.Position
-import Data.Geo.Jord.Speed
+import Data.Geo.Jord.Angle (Angle)
+import qualified Data.Geo.Jord.Angle as Angle
+import Data.Geo.Jord.Duration (Duration)
+import qualified Data.Geo.Jord.Duration as Duration (seconds, toMilliseconds, toSeconds, zero)
+import Data.Geo.Jord.Ellipsoid (equatorialRadius)
+import Data.Geo.Jord.Geodetic (HorizontalPosition)
+import qualified Data.Geo.Jord.Geodetic as Geodetic
+import qualified Data.Geo.Jord.GreatCircle as GreatCircle (distance, initialBearing)
+import Data.Geo.Jord.Length (Length)
+import qualified Data.Geo.Jord.Length as Length (toMetres, zero)
+import qualified Data.Geo.Jord.Math3d as Math3d
+import Data.Geo.Jord.Model (Spherical, surface)
+import Data.Geo.Jord.Speed (Speed)
+import qualified Data.Geo.Jord.Speed as Speed (average, toMetresPerSecond)
 
--- | 'Track' represents the state of a vehicle by its current position, bearing and speed.
+-- | 'Track' represents the state of a vehicle by its current horizontal position, bearing and speed.
 data Track a =
     Track
-        { trackPosition :: Position a -- ^ position of the track.
+        { trackPosition :: HorizontalPosition a -- ^ horizontal position of the track.
         , trackBearing :: Angle -- ^ bearing of the track.
         , trackSpeed :: Speed -- ^ speed of the track.
         }
@@ -71,298 +81,258 @@ 
 -- | 'Course' represents the cardinal direction in which the vehicle is to be steered.
 newtype Course =
-    Course Vector3d
+    Course Math3d.V3
     deriving (Eq, Show)
 
 -- | Time to, and distance at, closest point of approach (CPA) as well as position of both tracks at CPA.
 data Cpa a =
     Cpa
-        { cpaTime :: Duration -- ^ time to CPA.
-        , cpaDistance :: Length -- ^ distance at CPA.
-        , cpaPosition1 :: Position a -- ^ position of track 1 at CPA.
-        , cpaPosition2 :: Position a -- ^ position of track 2 at CPA.
+        { timeToCpa :: Duration -- ^ time to CPA.
+        , distanceAtCpa :: Length -- ^ distance at CPA.
+        , cpaOwnshipPosition :: HorizontalPosition a -- ^ horizontal position of ownship CPA.
+        , cpaIntruderPosition :: HorizontalPosition a -- ^ horizontal position of intruder at CPA.
         }
     deriving (Eq, Show)
 
 -- | Time, distance and position of intercept as well as speed and initial bearing of interceptor.
 data Intercept a =
     Intercept
-        { interceptTime :: Duration -- ^ time to intercept.
-        , interceptDistance :: Length -- ^ distance at intercept.
-        , interceptPosition :: Position a -- ^ position of intercept.
+        { timeToIntercept :: Duration -- ^ time to intercept.
+        , distanceToIntercept :: Length -- ^ distance travelled to intercept.
+        , interceptPosition :: HorizontalPosition a -- ^ horizontal position of intercept.
         , interceptorBearing :: Angle -- ^ initial bearing of interceptor.
         , interceptorSpeed :: Speed -- ^ speed of interceptor.
         }
     deriving (Eq, Show)
 
 -- | @course p b@ computes the course of a vehicle currently at position @p@ and following bearing @b@.
-course :: (Spherical a) => Position a -> Angle -> Course
-course p b = Course (Vector3d (vz (head r)) (vz (r !! 1)) (vz (r !! 2)))
+course :: (Spherical a) => HorizontalPosition a -> Angle -> Course
+course p b = Course (Math3d.vec3 (Math3d.v3z (head r)) (Math3d.v3z (r !! 1)) (Math3d.v3z (r !! 2)))
   where
-    lat = latitude p
-    lon = longitude p
-    r = mdot (mdot (rz (negate' lon)) (ry lat)) (rx b)
+    lat = Geodetic.latitude p
+    lon = Geodetic.longitude p
+    r = Math3d.dotM (Math3d.dotM (rz (Angle.negate lon)) (ry lat)) (rx b)
 
--- | @positionAfter p b s d@ computes the position of a vehicle currently at position @p@
--- following bearing @b@ and travelling at speed @s@ after duration @d@ has elapsed assuming
--- the vehicle maintains a __constant__ altitude.
+-- | @positionAfter p b s d@ computes the horizontal position of a vehicle currently at position @p@ following
+-- bearing @b@ and travelling at speed @s@ after duration @d@ has elapsed. For example:
 --
--- @positionAfter p b s d@ is a shortcut for @positionAfter' ('course' p b) s d@.
+-- >>> let p = Geodetic.s84Pos 53.321 (-1.729)
+-- >>> let b = Angle.decimalDegrees 96.0217
+-- >>> let s = Speed.kilometresPerHour 124.8
+-- >>> Kinematics.positionAfter p b s (Duration.hours 1)
+-- 53°11'19.367"N,0°8'2.456"E (S84)
 --
--- ==== __Examples__
+-- This is equivalent to:
 --
--- >>> import Data.Geo.Jord.Kinematics
--- >>> import Data.Geo.Jord.Position
--- >>>
--- >>> let p = s84Pos 53.321 (-1.729) (metres 15000)
--- >>> let b = decimalDegrees 96.0217
--- >>> let s = kilometresPerHour 124.8
--- >>> positionAfter p b s (hours 1)
--- 53°11'19.368"N,0°8'2.457"E 15.0km (S84)
--- @
-positionAfter :: (Spherical a) => Position a -> Angle -> Speed -> Duration -> Position a
-positionAfter p b s d = position' p (course p b) s (toSeconds d)
+-- > Kinematics.positionAfter' p (Kinematics.course p b) s d
+positionAfter ::
+       (Spherical a) => HorizontalPosition a -> Angle -> Speed -> Duration -> HorizontalPosition a
+positionAfter p b s d = position' p (course p b) s (Duration.toSeconds d)
 
--- | @positionAfter p c s d@ computes the position of a vehicle currently at position @p@
--- on course @c@ and travelling at speed @s@ after duration @d@ has elapsed assuming
--- the vehicle maintains a __constant__ altitude.
-positionAfter' :: (Spherical a) => Position a -> Course -> Speed -> Duration -> Position a
-positionAfter' p c s d = position' p c s (toSeconds d)
+-- | @positionAfter' p c s d@ computes the horizontal position of a vehicle currently at position @p@ on course @c@ and
+-- travelling at speed @s@ after duration @d@ has elapsed.
+-- Note: course must have been calculated from position @p@.
+positionAfter' ::
+       (Spherical a) => HorizontalPosition a -> Course -> Speed -> Duration -> HorizontalPosition a
+positionAfter' p c s d = position' p c s (Duration.toSeconds d)
 
--- | @trackPositionAfter t d@ computes the position of a track @t@ after duration @d@ has elapsed
--- assuming the vehicle maintains a __constant__ altitude.
---
--- @trackPositionAfter ('Track' p b s) d@ is a equivalent to @positionAfter' p ('course' p b) s d@.
---
--- ==== __Examples__
---
--- >>> import Data.Geo.Jord.Kinematics
--- >>> import Data.Geo.Jord.Position
--- >>>
--- >>> let p = s84Pos 53.321 (-1.729) (metres 15000)
--- >>> let b = decimalDegrees 96.0217
--- >>> let s = kilometresPerHour 124.8
--- >>> trackPositionAfter (Track p b s) (hours 1)
--- 53°11'19.368"N,0°8'2.457"E 15.0km (S84)
+-- | @trackPositionAfter t d@ computes the horizontal position of a track @t@ after duration @d@ has elapsed. For example:
 --
-trackPositionAfter :: (Spherical a) => Track a -> Duration -> Position a
+-- >>> let p = Geodetic.s84Pos 53.321 (-1.729)
+-- >>> let b = Angle.decimalDegrees 96.0217
+-- >>> let s = Speed.kilometresPerHour 124.8
+-- >>> Kinematics.trackPositionAfter (Kinematics.Track p b s) (Duration.hours 1)
+-- 53°11'19.367"N,0°8'2.456"E (S84)
+trackPositionAfter :: (Spherical a) => Track a -> Duration -> HorizontalPosition a
 trackPositionAfter (Track p b s) = positionAfter' p (course p b) s
 
--- | @cpa t1 t2@ computes the closest point of approach between tracks @t1@ and @t2@ disregarding
--- their respective altitude.
--- If a closest point of approach is found, height of 'cpaPosition1' - respectively 'cpaPosition2',
--- will be the altitude of the first - respectively second, track.
---
--- ==== __Examples__
---
--- >>> import Data.Geo.Jord.Kinematics
--- >>> import Data.Geo.Jord.Position
--- >>>
--- >>> let p1 = s84Pos 20 (-60) zero
--- >>> let b1 = decimalDegrees 10
--- >>> let s1 = knots 15
--- >>> let p2 = s84Pos 34 (-50) zero
--- >>> let b2 = decimalDegrees 220
--- >>> let s2 = knots 300
--- >>> let t1 = Track p1 b1 s1
--- >>> let t2 = Track p2 b2 s2
--- >>> let c = cpa t1 t2
--- >>> fmap cpaTime c
--- Just 3H9M56.155S
--- >>> fmap cpaDistance c
--- Just 124.2317453km
+-- | @cpa ownship intruder@ computes the closest point of approach between tracks @ownship@ and @intruder@.
+-- The closest point of approach is calculated assuming both ships maintain a constant course and speed.
 --
+-- >>> let ownship = Kinematics.Track (Geodetic.s84Pos 20 (-60)) (Angle.decimalDegrees 10) (Speed.knots 15)
+-- >>> let intruder = Kinematics.Track (Geodetic.s84Pos 34 (-50)) (Angle.decimalDegrees 220) (Speed.knots 300)
+-- >>> let cpa = Kinematics.cpa ownship intruder
+-- Just (Cpa { timeToCpa = 3H9M56.155S
+--           , distanceAtCpa = 124.231730834km
+--           , cpaOwnshipPosition = 20°46'43.641"N,59°51'11.225"W (S84)
+--           , cpaIntruderPosition = 21°24'8.523"N,60°50'48.159"W (S84)})
 cpa :: (Spherical a) => Track a -> Track a -> Maybe (Cpa a)
 cpa (Track p1 b1 s1) (Track p2 b2 s2)
-    | llEq p1 p2 = Just (Cpa zero zero p1 p2)
+    | p1 == p2 = Just (Cpa Duration.zero Length.zero p1 p2)
     | t < 0 = Nothing
-    | otherwise = Just (Cpa (seconds t) d cp1 cp2)
+    | otherwise = Just (Cpa (Duration.seconds t) d cp1 cp2)
   where
     c1 = course p1 b1
     c2 = course p2 b2
-    t = timeToCpa p1 c1 s1 p2 c2 s2
+    t = timeToCpa' p1 c1 s1 p2 c2 s2
     cp1 = position' p1 c1 s1 t
     cp2 = position' p2 c2 s2 t
-    d = surfaceDistance cp1 cp2
+    d = GreatCircle.distance cp1 cp2
 
--- | @intercept t p@ computes the __minimum__ speed of interceptor at
--- position @p@ needed for an intercept with target track @t@ to take place.
--- Intercept time, position, distance and interceptor bearing are derived from
--- this minimum speed. Returns 'Nothing' if intercept cannot be achieved e.g.:
---
---     * interceptor and target are at the same position
+-- | @intercept t p@ computes the __minimum__ speed of interceptor at position @p@ needed for an intercept with target
+-- track @t@ to take place. Intercept time, position, distance and interceptor bearing are derived from this minimum
+-- speed. For example:
 --
---     * interceptor is "behind" the target
+-- >>> let t = Kinematics.Track (Geodetic.s84Pos 34 (-50)) (Angle.decimalDegrees 220) (Speed.knots 600)
+-- >>> let ip = Geodetic.s84Pos 20 (-60)
+-- >>> Kinematics.intercept t ip
+-- Just (Intercept { timeToIntercept = 1H39M53.831S
+--                 , distanceToIntercept = 162.294627463km
+--                 , interceptPosition = 20°43'42.305"N,61°20'56.848"W (S84)
+--                 , interceptorBearing = 300°10'18.053"
+--                 , interceptorSpeed = 97.476999km/h})
 --
--- If found, 'interceptPosition' is at the altitude of the track.
+-- Returns 'Nothing' if intercept cannot be achieved e.g.:
 --
--- ==== __Examples__
+--     * interceptor and target are at the same position
 --
--- >>> import Data.Geo.Jord.Kinematics
--- >>> import Data.Geo.Jord.Position
--- >>>
--- >>> let t = Track (s84Pos 34 (-50) zero) (decimalDegrees 220) (knots 600)
--- >>> let ip = s84Pos 20 (-60) zero
--- >>> let i = intercept t ip
--- >>> fmap (toKnots . interceptorSpeed) i
--- Just 52.633367756059
--- >>> fmap (toSeconds . interceptTime) i
--- Just 5993.831
+--     * interceptor is "behind" the target
 --
-intercept :: (Spherical a) => Track a -> Position a -> Maybe (Intercept a)
-intercept t p = interceptByTime t p (seconds (timeToIntercept t p))
+intercept :: (Spherical a) => Track a -> HorizontalPosition a -> Maybe (Intercept a)
+intercept t p = interceptByTime t p (Duration.seconds (timeToIntercept' t p))
 
--- | @interceptBySpeed t p s@ computes the time needed by interceptor at
--- position @p@ and travelling at speed @s@ to intercept target track @t@.
+-- | @interceptBySpeed t p s@ computes the time needed by interceptor at position @p@ and travelling at speed @s@ to
+-- intercept target track @t@.
+--
 -- Returns 'Nothing' if intercept cannot be achieved e.g.:
 --
 --     * interceptor and target are at the same position
 --
 --     * interceptor speed is below minimum speed returned by 'intercept'
---
--- If found, 'interceptPosition' is at the altitude of the track.
---
-interceptBySpeed :: (Spherical a) => Track a -> Position a -> Speed -> Maybe (Intercept a)
+interceptBySpeed :: (Spherical a) => Track a -> HorizontalPosition a -> Speed -> Maybe (Intercept a)
 interceptBySpeed t p s
     | isNothing minInt = Nothing
     | fmap interceptorSpeed minInt == Just s = minInt
-    | otherwise = interceptByTime t p (seconds (timeToInterceptSpeed t p s))
+    | otherwise = interceptByTime t p (Duration.seconds (timeToInterceptSpeed t p s))
   where
     minInt = intercept t p
 
--- | @interceptByTime t p d@ computes the speed of interceptor at
--- position @p@ needed for an intercept with target track @t@ to take place
--- after duration @d@. Returns 'Nothing' if given duration is <= 0 or
--- interceptor and target are at the same position.
---
--- If found, 'interceptPosition' is at the altitude of the track.
---
--- Note: contrary to 'intercept' and 'interceptBySpeed' this function handles
--- cases where the interceptor has to catch up the target.
---
--- ==== __Examples__
+-- | @interceptByTime t p d@ computes the speed of interceptor at position @p@ needed for an intercept with target
+-- track @t@ to take place after duration @d@.For example:
 --
--- >>> import Data.Geo.Jord.Kinematics
--- >>> import Data.Geo.Jord.Position
--- >>>
--- >>> let t = Track (s84Pos 34 (-50) zero) (decimalDegrees 220) (knots 600)
--- >>> let ip = s84Pos 20 (-60) zero
--- >>> let d = seconds 2700
--- >>> let i = interceptByTime t ip d
--- >>> fmap (toKnots . interceptorSpeed) i
--- Just 730.959238
--- >>>
--- >>> fmap interceptorBearing i
--- Just 26°7'11.649"
--- >>>
--- >>> fmap interceptPosition i
--- Just 28°8'12.047"N,55°27'21.411"W 0.0m (S84)
--- >>>
--- >>> fmap interceptDistance i
--- Just 1015.3023506km
--- >>>
--- >>> fmap (toSeconds . interceptTime) i
--- Just 2700
+-- >>> let t = Kinematics.Track (Geodetic.s84Pos 34 (-50)) (Angle.decimalDegrees 220) (Speed.knots 600)
+-- >>> let ip = Geodetic.s84Pos 20 (-60)
+-- >>> let d = Duration.seconds 2700
+-- >>> interceptByTime t ip d
+-- Just (Intercept { timeToIntercept = 0H45M0.000S
+--                 , distanceToIntercept = 1015.302358852km
+--                 , interceptPosition = 28°8'12.046"N,55°27'21.411"W (S84)
+--                 , interceptorBearing = 26°7'11.649"
+--                 , interceptorSpeed = 1353.736478km/h})
 --
-interceptByTime :: (Spherical a) => Track a -> Position a -> Duration -> Maybe (Intercept a)
+-- Returns 'Nothing' if given duration is <= 0 or interceptor and target are at the same position. Contrary to
+-- 'intercept' and 'interceptBySpeed' this function handles cases where the interceptor has to catch up the target.
+interceptByTime ::
+       (Spherical a) => Track a -> HorizontalPosition a -> Duration -> Maybe (Intercept a)
 interceptByTime t p d
-    | toMilliseconds d <= 0 = Nothing
-    | llEq (trackPosition t) p = Nothing
+    | Duration.toMilliseconds d <= 0 = Nothing
+    | trackPosition t == p = Nothing
     | isNothing ib = Nothing
     | otherwise =
-        let is = averageSpeed idist d
+        let is = Speed.average idist d
          in Just (Intercept d idist ipos (fromJust ib) is)
   where
     ipos = trackPositionAfter t d
-    idist = surfaceDistance p ipos
-    ib = initialBearing p ipos <|> initialBearing p (trackPosition t)
+    idist = GreatCircle.distance p ipos
+    ib = GreatCircle.initialBearing p ipos <|> GreatCircle.initialBearing p (trackPosition t)
 
 -- private
 -- | position from speed course and seconds.
-position' :: (Spherical a) => Position a -> Course -> Speed -> Double -> Position a
-position' p0 (Course c) s sec = nvh v1 h0 (model p0)
+position' ::
+       (Spherical a) => HorizontalPosition a -> Course -> Speed -> Double -> HorizontalPosition a
+position' p0 (Course c) s sec = Geodetic.nvectorPos' v1 (Geodetic.model p0)
   where
-    nv0 = nvec p0
-    h0 = height p0
+    nv0 = Geodetic.nvector p0
     v1 = position'' nv0 c s sec (radiusM p0)
 
 -- | position from course, speed and seconds.
-position'' :: Vector3d -> Vector3d -> Speed -> Double -> Double -> Vector3d
+position'' :: Math3d.V3 -> Math3d.V3 -> Speed -> Double -> Double -> Math3d.V3
 position'' v0 c s sec rm = v1
   where
-    a = toMetresPerSecond s / rm * sec
-    v1 = vadd (vscale v0 (cos a)) (vscale c (sin a))
+    a = Speed.toMetresPerSecond s / rm * sec
+    v1 = Math3d.add (Math3d.scale v0 (cos a)) (Math3d.scale c (sin a))
 
 -- | time to CPA.
-timeToCpa ::
-       (Spherical a) => Position a -> Course -> Speed -> Position a -> Course -> Speed -> Double
-timeToCpa p1 (Course c10) s1 p2 (Course c20) s2 = cpaNrRec v10 c10 w1 v20 c20 w2 0 0
+timeToCpa' ::
+       (Spherical a)
+    => HorizontalPosition a
+    -> Course
+    -> Speed
+    -> HorizontalPosition a
+    -> Course
+    -> Speed
+    -> Double
+timeToCpa' p1 (Course c10) s1 p2 (Course c20) s2 = cpaNrRec v10 c10 w1 v20 c20 w2 0 0
   where
-    v10 = nvec p1
+    v10 = Geodetic.nvector p1
     rm = radiusM p1
-    w1 = toMetresPerSecond s1 / rm
-    v20 = nvec p2
-    w2 = toMetresPerSecond s2 / rm
+    w1 = Speed.toMetresPerSecond s1 / rm
+    v20 = Geodetic.nvector p2
+    w2 = Speed.toMetresPerSecond s2 / rm
 
 -- | time to intercept with minimum speed
-timeToIntercept :: (Spherical a) => Track a -> Position a -> Double
-timeToIntercept (Track p2 b2 s2) p1 = intMinNrRec v10v20 v10c2 w2 (sep v10 v20 c2 s2 rm) t0 0
+timeToIntercept' :: (Spherical a) => Track a -> HorizontalPosition a -> Double
+timeToIntercept' (Track p2 b2 s2) p1 = intMinNrRec v10v20 v10c2 w2 (sep v10 v20 c2 s2 rm) t0 0
   where
-    v10 = nvec p1
-    v20 = nvec p2
+    v10 = Geodetic.nvector p1
+    v20 = Geodetic.nvector p2
     (Course c2) = course p2 b2
-    v10v20 = vdot v10 v20
-    v10c2 = vdot v10 c2
-    s2mps = toMetresPerSecond s2
+    v10v20 = Math3d.dot v10 v20
+    v10c2 = Math3d.dot v10 c2
+    s2mps = Speed.toMetresPerSecond s2
     rm = radiusM p1
     w2 = s2mps / rm
-    s0 = angleRadians v10 v20 -- initial angular distance between target and interceptor
+    s0 = angleBetweenRadians v10 v20 -- initial angular distance between target and interceptor
     t0 = rm * s0 / s2mps -- assume target is travelling towards interceptor
 
 -- | time to intercept with speed.
-timeToInterceptSpeed :: (Spherical a) => Track a -> Position a -> Speed -> Double
+timeToInterceptSpeed :: (Spherical a) => Track a -> HorizontalPosition a -> Speed -> Double
 timeToInterceptSpeed (Track p2 b2 s2) p1 s1 =
     intSpdNrRec v10v20 v10c2 w1 w2 (sep v10 v20 c2 s2 rm) t0 0
   where
-    v10 = nvec p1
-    v20 = nvec p2
+    v10 = Geodetic.nvector p1
+    v20 = Geodetic.nvector p2
     (Course c2) = course p2 b2
-    v10v20 = vdot v10 v20
-    v10c2 = vdot v10 c2
+    v10v20 = Math3d.dot v10 v20
+    v10c2 = Math3d.dot v10 c2
     rm = radiusM p1
-    w1 = toMetresPerSecond s1 / rm
-    w2 = toMetresPerSecond s2 / rm
+    w1 = Speed.toMetresPerSecond s1 / rm
+    w2 = Speed.toMetresPerSecond s2 / rm
     t0 = 0.1
 
-rx :: Angle -> [Vector3d]
-rx a = [Vector3d 1 0 0, Vector3d 0 c s, Vector3d 0 (-s) c]
+rx :: Angle -> [Math3d.V3]
+rx a = [Math3d.vec3 1 0 0, Math3d.vec3 0 c s, Math3d.vec3 0 (-s) c]
   where
-    c = cos' a
-    s = sin' a
+    c = Angle.cos a
+    s = Angle.sin a
 
-ry :: Angle -> [Vector3d]
-ry a = [Vector3d c 0 (-s), Vector3d 0 1 0, Vector3d s 0 c]
+ry :: Angle -> [Math3d.V3]
+ry a = [Math3d.vec3 c 0 (-s), Math3d.vec3 0 1 0, Math3d.vec3 s 0 c]
   where
-    c = cos' a
-    s = sin' a
+    c = Angle.cos a
+    s = Angle.sin a
 
-rz :: Angle -> [Vector3d]
-rz a = [Vector3d c s 0, Vector3d (-s) c 0, Vector3d 0 0 1]
+rz :: Angle -> [Math3d.V3]
+rz a = [Math3d.vec3 c s 0, Math3d.vec3 (-s) c 0, Math3d.vec3 0 0 1]
   where
-    c = cos' a
-    s = sin' a
+    c = Angle.cos a
+    s = Angle.sin a
 
-cpaA :: Vector3d -> Vector3d -> Double -> Vector3d -> Vector3d -> Double -> Double
-cpaA v10 c10 w1 v20 c20 w2 = negate (vdot (vscale v10 w1) c20 + vdot (vscale v20 w2) c10)
+cpaA :: Math3d.V3 -> Math3d.V3 -> Double -> Math3d.V3 -> Math3d.V3 -> Double -> Double
+cpaA v10 c10 w1 v20 c20 w2 =
+    negate (Math3d.dot (Math3d.scale v10 w1) c20 + Math3d.dot (Math3d.scale v20 w2) c10)
 
-cpaB :: Vector3d -> Vector3d -> Double -> Vector3d -> Vector3d -> Double -> Double
-cpaB v10 c10 w1 v20 c20 w2 = vdot (vscale c10 w1) v20 + vdot (vscale c20 w2) v10
+cpaB :: Math3d.V3 -> Math3d.V3 -> Double -> Math3d.V3 -> Math3d.V3 -> Double -> Double
+cpaB v10 c10 w1 v20 c20 w2 =
+    Math3d.dot (Math3d.scale c10 w1) v20 + Math3d.dot (Math3d.scale c20 w2) v10
 
-cpaC :: Vector3d -> Vector3d -> Double -> Vector3d -> Vector3d -> Double -> Double
-cpaC v10 c10 w1 v20 c20 w2 = negate (vdot (vscale v10 w1) v20 - vdot (vscale c20 w2) c10)
+cpaC :: Math3d.V3 -> Math3d.V3 -> Double -> Math3d.V3 -> Math3d.V3 -> Double -> Double
+cpaC v10 c10 w1 v20 c20 w2 =
+    negate (Math3d.dot (Math3d.scale v10 w1) v20 - Math3d.dot (Math3d.scale c20 w2) c10)
 
-cpaD :: Vector3d -> Vector3d -> Double -> Vector3d -> Vector3d -> Double -> Double
-cpaD v10 c10 w1 v20 c20 w2 = vdot (vscale c10 w1) c20 - vdot (vscale v20 w2) v10
+cpaD :: Math3d.V3 -> Math3d.V3 -> Double -> Math3d.V3 -> Math3d.V3 -> Double -> Double
+cpaD v10 c10 w1 v20 c20 w2 =
+    Math3d.dot (Math3d.scale c10 w1) c20 - Math3d.dot (Math3d.scale v20 w2) v10
 
 cpaFt :: Double -> Double -> Double -> Double -> Double -> Double -> Double -> Double -> Double
 cpaFt cw1t cw2t sw1t sw2t a b c d =
@@ -385,7 +355,7 @@     (a * w2 - b * w1) * sw1t * cw2t -
     (b * w2 - a * w1) * cw1t * sw2t
 
-cpaStep :: Vector3d -> Vector3d -> Double -> Vector3d -> Vector3d -> Double -> Double -> Double
+cpaStep :: Math3d.V3 -> Math3d.V3 -> Double -> Math3d.V3 -> Math3d.V3 -> Double -> Double -> Double
 cpaStep v10 c10 w1 v20 c20 w2 t =
     cpaFt cw1t cw2t sw1t sw2t a b c d / cpaDft w1 w2 cw1t cw2t sw1t sw2t a b c d
   where
@@ -400,7 +370,15 @@ 
 -- | Newton-Raphson for CPA time.
 cpaNrRec ::
-       Vector3d -> Vector3d -> Double -> Vector3d -> Vector3d -> Double -> Double -> Int -> Double
+       Math3d.V3
+    -> Math3d.V3
+    -> Double
+    -> Math3d.V3
+    -> Math3d.V3
+    -> Double
+    -> Double
+    -> Int
+    -> Double
 cpaNrRec v10 c10 w1 v20 c20 w2 ti i
     | i == 50 = -1.0 -- no convergence
     | abs fi < 1e-11 = ti1
@@ -448,13 +426,22 @@ 
 -- | angular separation in radians at ti between v10 and track with initial position v20,
 -- course c2 and speed s2.
-sep :: Vector3d -> Vector3d -> Vector3d -> Speed -> Double -> Double -> Double
-sep v10 v20 c2 s2 r ti = angleRadians v10 (position'' v20 c2 s2 ti r)
+sep :: Math3d.V3 -> Math3d.V3 -> Math3d.V3 -> Speed -> Double -> Double -> Double
+sep v10 v20 c2 s2 r ti = angleBetweenRadians v10 (position'' v20 c2 s2 ti r)
 
 -- | reference sphere radius.
-radius :: (Spherical a) => Position a -> Length
-radius = equatorialRadius . surface . model
+radius :: (Spherical a) => HorizontalPosition a -> Length
+radius = equatorialRadius . surface . Geodetic.model
 
 -- | reference sphere radius in metres.
-radiusM :: (Spherical a) => Position a -> Double
-radiusM = toMetres . radius
+radiusM :: (Spherical a) => HorizontalPosition a -> Double
+radiusM = Length.toMetres . radius
+
+-- angle between 2 vectors in radians - this is duplicated with GreatCircle but
+-- does not return an Angle - truncating to microarcsecond resolution can be
+-- detrimental to the convergence of Newtow-Raphson.
+angleBetweenRadians :: Math3d.V3 -> Math3d.V3 -> Double
+angleBetweenRadians v1 v2 = atan2 sinO cosO
+  where
+    sinO = Math3d.norm (Math3d.cross v1 v2)
+    cosO = Math3d.dot v1 v2
src/Data/Geo/Jord/LatLong.hs view
@@ -1,3 +1,5 @@+{-# LANGUAGE CPP #-}
+
 -- |
 -- Module:      Data.Geo.Jord.LatLong
 -- Copyright:   (c) 2020 Cedric Liegeois
@@ -6,23 +8,35 @@ -- Stability:   experimental
 -- Portability: portable
 --
--- Functions related to latitudes & longitudes.
+-- Parsers and formatter of latitudes & longitudes.
 --
 module Data.Geo.Jord.LatLong
     ( isValidLatLong
-    , latLongDmsP
-    , latLongDmsCompactP
-    , latLongDmsSymbolsP
+    , isValidLat
+    , isValidLong
+    , latLongDms
+    , latLongDmsCompact
+    , latLongDmsSymbols
     , showLatLong
     ) where
 
 import Control.Applicative ((<|>))
+#if __GLASGOW_HASKELL__ < 808
 import Control.Monad.Fail (MonadFail)
+#endif
 import Data.Char ()
 import Data.Maybe ()
 import Text.ParserCombinators.ReadP (ReadP, char, option, pfail)
 
-import Data.Geo.Jord.Angle
+import Data.Geo.Jord.Angle (Angle)
+import qualified Data.Geo.Jord.Angle as Angle
+    ( angle
+    , decimalDegrees
+    , dms
+    , isNegative
+    , isWithin
+    , negate
+    )
 import Data.Geo.Jord.Model
 import Data.Geo.Jord.Parser
 
@@ -31,6 +45,20 @@ isValidLatLong :: (Model a) => Angle -> Angle -> a -> Bool
 isValidLatLong lat lon m = isValidLat lat && isValidLong lon m
 
+-- | @isValidLat lat@ determines whether the given latitude is valid - i.e. in range [-90°, 90°].
+isValidLat :: Angle -> Bool
+isValidLat lat = Angle.isWithin lat (Angle.decimalDegrees (-90)) (Angle.decimalDegrees 90)
+
+-- | @isValidLong lon m@ determines whether the given longitude is valid for model @m@.
+--
+-- * If longitude range is L180: in range [-180°, 180°]
+-- * If longitude range is L360: in range [0°, 360°]
+isValidLong :: (Model a) => Angle -> a -> Bool
+isValidLong lon m =
+    case longitudeRange m of
+        L180 -> Angle.isWithin lon (Angle.decimalDegrees (-180)) (Angle.decimalDegrees 180)
+        L360 -> Angle.isWithin lon (Angle.decimalDegrees 0) (Angle.decimalDegrees 360)
+
 -- | latitude and longitude reader.
 -- Formats:
 --
@@ -38,12 +66,12 @@ --
 --     * 'Angle'[N|S] 'Angle'[E|W] - e.g. 55°36'21''N 13°0'02''E or 11°16'S 36°49'E or 47°N 122°W
 --
-latLongDmsP :: (Model a) => a -> ReadP (Angle, Angle)
-latLongDmsP m = latLongDmsCompactP m <|> latLongDmsSymbolsP m
+latLongDms :: (Model a) => a -> ReadP (Angle, Angle)
+latLongDms m = latLongDmsCompact m <|> latLongDmsSymbols m
 
 -- | reads latitude and longitude in DD(D)MMSS.
-latLongDmsCompactP :: (Model a) => a -> ReadP (Angle, Angle)
-latLongDmsCompactP m = do
+latLongDmsCompact :: (Model a) => a -> ReadP (Angle, Angle)
+latLongDmsCompact m = do
     lat <- blat
     lon <- blon
     if isValidLatLong lat lon m
@@ -70,11 +98,11 @@         then dmsF d' m' s'
         else dmsF (-d') m' s'
 
--- | reads N or S char.
+-- | reads N or S char.
 hemisphere :: ReadP Char
 hemisphere = char 'N' <|> char 'S'
 
--- | reads E or W char.
+-- | reads E or W char.
 meridian :: ReadP Char
 meridian = char 'E' <|> char 'W'
 
@@ -92,8 +120,8 @@     return (m', 0.0)
 
 -- | reads (latitude, longitude) from a human friendly text - see 'Angle'.
-latLongDmsSymbolsP :: (Model a) => a -> ReadP (Angle, Angle)
-latLongDmsSymbolsP m = do
+latLongDmsSymbols :: (Model a) => a -> ReadP (Angle, Angle)
+latLongDmsSymbols m = do
     lat <- hlat
     _ <- char ' ' <|> char ','
     lon <- hlon
@@ -104,35 +132,35 @@ -- | reads a latitude, 'Angle'N|S expected.
 hlat :: ReadP Angle
 hlat = do
-    lat <- angleP
+    lat <- Angle.angle
     h <- hemisphere
     if h == 'N'
         then return lat
-        else return (negate' lat)
+        else return (Angle.negate lat)
 
 -- | reads a longitude, 'Angle'E|W expected.
 hlon :: ReadP Angle
 hlon = do
-    lon <- angleP
+    lon <- Angle.angle
     m' <- meridian
     if m' == 'E'
         then return lon
-        else return (negate' lon)
+        else return (Angle.negate lon)
 
 -- | Show a (latitude, longitude) pair as DMS - e.g. 55°36'21''N,13°0'2''E.
 showLatLong :: (Angle, Angle) -> String
 showLatLong (lat, lon) = showLat lat ++ "," ++ showLon lon
 
--- | Latitude to string.
+-- | Latitude to string.
 showLat :: Angle -> String
 showLat lat
-    | isNegative lat = show (negate' lat) ++ "S"
+    | Angle.isNegative lat = show (Angle.negate lat) ++ "S"
     | otherwise = show lat ++ "N"
 
--- | Longitude to string.
+-- | Longitude to string.
 showLon :: Angle -> String
 showLon lon
-    | isNegative lon = show (negate' lon) ++ "W"
+    | Angle.isNegative lon = show (Angle.negate lon) ++ "W"
     | otherwise = show lon ++ "E"
 
 dmsF :: (MonadFail m) => Int -> Int -> Double -> m Angle
@@ -141,13 +169,4 @@         Left err -> fail err
         Right a -> return a
   where
-    e = dms degs mins secs
-
-isValidLat :: Angle -> Bool
-isValidLat a = isWithin a (decimalDegrees (-90)) (decimalDegrees 90)
-
-isValidLong :: (Model a) => Angle -> a -> Bool
-isValidLong a m =
-    case longitudeRange m of
-        L180 -> isWithin a (decimalDegrees (-180)) (decimalDegrees 180)
-        L360 -> isWithin a (decimalDegrees 0) (decimalDegrees 360)
+    e = Angle.dms degs mins secs
src/Data/Geo/Jord/Length.hs view
@@ -1,124 +1,141 @@--- |
--- Module:      Data.Geo.Jord.Length
--- Copyright:   (c) 2020 Cedric Liegeois
--- License:     BSD3
--- Maintainer:  Cedric Liegeois <ofmooseandmen@yahoo.fr>
--- Stability:   experimental
--- Portability: portable
---
--- Types and functions for working with (signed) lengths in metres, kilometres, nautical miles or feet.
---
-module Data.Geo.Jord.Length
-    (
-    -- * The 'Length' type
-      Length
-    -- * Smart constructors
-    , feet
-    , kilometres
-    , metres
-    , nauticalMiles
-    -- * Read
-    , lengthP
-    , readLength
-    -- * Conversions
-    , toFeet
-    , toKilometres
-    , toMetres
-    , toMillimetres
-    , toNauticalMiles
-    ) where
-
-import Control.Applicative ((<|>))
-import Text.ParserCombinators.ReadP (ReadP, pfail, readP_to_S, skipSpaces, string)
-import Text.Read (readMaybe)
-
-import Data.Geo.Jord.Parser
-import Data.Geo.Jord.Quantity
-
--- | A length with a resolution of 1 micrometre.
-newtype Length =
-    Length
-        { micrometre :: Int
-        }
-    deriving (Eq)
-
--- | See 'lengthP'.
-instance Read Length where
-    readsPrec _ = readP_to_S lengthP
-
--- | Length is shown in metres when absolute value is <= 10 km and in kilometres otherwise.
-instance Show Length where
-    show l
-        | abs' l <= (kilometres 10) = show (toMetres l) ++ "m"
-        | otherwise = show (toKilometres l) ++ "km"
-
-instance Ord Length where
-    (<=) (Length l1) (Length l2) = l1 <= l2
-
--- | Add/Subtract 'Length's.
-instance Quantity Length where
-    add a b = Length (micrometre a + micrometre b)
-    sub a b = Length (micrometre a - micrometre b)
-    zero = Length 0
-
--- | 'Length' from given amount of feet.
-feet :: Double -> Length
-feet ft = Length (round (ft * 0.3048 * m2um))
-
--- | 'Length' from given amount of kilometres.
-kilometres :: Double -> Length
-kilometres km = Length (round (km * 1000.0 * m2um))
-
--- | 'Length' from given amount of metres.
-metres :: Double -> Length
-metres m = Length (round (m * m2um))
-
--- | 'Length' from given amount of nautical miles.
-nauticalMiles :: Double -> Length
-nauticalMiles nm = Length (round (nm * 1852.0 * m2um))
-
--- | Reads an a 'Length' from the given string using 'lengthP'.
-readLength :: String -> Maybe Length
-readLength s = readMaybe s :: (Maybe Length)
-
--- | @toFeet l@ converts @l@ to feet.
-toFeet :: Length -> Double
-toFeet (Length l) = fromIntegral l / (0.3048 * m2um)
-
--- | @toKilometres l@ converts @l@ to kilometres.
-toKilometres :: Length -> Double
-toKilometres (Length l) = fromIntegral l / (1000.0 * m2um)
-
--- | @toMetres l@ converts @l@ to metres.
-toMetres :: Length -> Double
-toMetres (Length l) = fromIntegral l / m2um
-
--- | @toMillimetres l@ converts @l@ to millimetres.
-toMillimetres :: Length -> Double
-toMillimetres (Length l) = fromIntegral l / 1000.0
-
--- | @toNauticalMiles l@ converts @l@ to nautical miles.
-toNauticalMiles :: Length -> Double
-toNauticalMiles (Length l) = fromIntegral l / (1852.0 * m2um)
-
--- | Parses and returns a 'Length' formatted as (-)float[m|km|nm|ft].
--- e.g. 3000m, 2.5km, -154nm or 10000ft.
---
-lengthP :: ReadP Length
-lengthP = do
-    v <- number
-    skipSpaces
-    u <- string "m" <|> string "km" <|> string "nm" <|> string "ft"
-    case u of
-        "m" -> return (metres v)
-        "km" -> return (kilometres v)
-        "nm" -> return (nauticalMiles v)
-        "ft" -> return (feet v)
-        _ -> pfail
-
--- | metre to micrometre.
-m2um :: Double
-m2um = 1000.0 * 1000.0
-
-abs' :: Length -> Length
-abs' (Length um) = Length (abs um)
+-- |+-- Module:      Data.Geo.Jord.Length+-- Copyright:   (c) 2020 Cedric Liegeois+-- License:     BSD3+-- Maintainer:  Cedric Liegeois <ofmooseandmen@yahoo.fr>+-- Stability:   experimental+-- Portability: portable+--+-- Types and functions for working with (signed) lengths in metres, kilometres, nautical miles or feet.+--+-- In order to use this module you should start with the following imports:+--+-- @+-- import Data.Geo.Jord.Length (Length)+-- import qualified Data.Geo.Jord.Length as Length+-- @+--+module Data.Geo.Jord.Length+    (+    -- * The 'Length' type+      Length+    -- * Smart constructors+    , feet+    , kilometres+    , metres+    , nauticalMiles+    -- * Read+    , length+    , read+    -- * Conversions+    , toFeet+    , toKilometres+    , toMetres+    , toMillimetres+    , toNauticalMiles+    -- * Misc+    , add+    , subtract+    , zero+    ) where++import Control.Applicative ((<|>))+import Prelude hiding (length, read, subtract)+import Text.ParserCombinators.ReadP (ReadP, pfail, readP_to_S, skipSpaces, string)+import Text.Read (readMaybe)++import Data.Geo.Jord.Parser++-- | A length with a resolution of 1 micrometre.+newtype Length =+    Length+        { micrometre :: Int+        }+    deriving (Eq)++-- | See 'length'.+instance Read Length where+    readsPrec _ = readP_to_S length++-- | Length is shown in metres when absolute value is <= 10 km and in kilometres otherwise.+instance Show Length where+    show l+        | abs' l <= kilometres 10 = show (toMetres l) ++ "m"+        | otherwise = show (toKilometres l) ++ "km"++instance Ord Length where+    (<=) (Length l1) (Length l2) = l1 <= l2++-- | Adds 2 lengths.+add :: Length -> Length -> Length+add a b = Length (micrometre a + micrometre b)++-- | Subtracts 2 lengths.+subtract :: Length -> Length -> Length+subtract a b = Length (micrometre a - micrometre b)++-- | 0 length.+zero :: Length+zero = Length 0++-- | 'Length' from given amount of feet.+feet :: Double -> Length+feet ft = Length (round (ft * 0.3048 * m2um))++-- | 'Length' from given amount of kilometres.+kilometres :: Double -> Length+kilometres km = Length (round (km * 1000.0 * m2um))++-- | 'Length' from given amount of metres.+metres :: Double -> Length+metres m = Length (round (m * m2um))++-- | 'Length' from given amount of nautical miles.+nauticalMiles :: Double -> Length+nauticalMiles nm = Length (round (nm * 1852.0 * m2um))++-- | Reads a 'Length' from the given string using 'length'.+read :: String -> Maybe Length+read s = readMaybe s :: (Maybe Length)++-- | @toFeet l@ converts @l@ to feet.+toFeet :: Length -> Double+toFeet (Length l) = fromIntegral l / (0.3048 * m2um)++-- | @toKilometres l@ converts @l@ to kilometres.+toKilometres :: Length -> Double+toKilometres (Length l) = fromIntegral l / (1000.0 * m2um)++-- | @toMetres l@ converts @l@ to metres.+toMetres :: Length -> Double+toMetres (Length l) = fromIntegral l / m2um++-- | @toMillimetres l@ converts @l@ to millimetres.+toMillimetres :: Length -> Double+toMillimetres (Length l) = fromIntegral l / 1000.0++-- | @toNauticalMiles l@ converts @l@ to nautical miles.+toNauticalMiles :: Length -> Double+toNauticalMiles (Length l) = fromIntegral l / (1852.0 * m2um)++-- | Parses and returns a 'Length' formatted as (-)float[m|km|nm|ft].+-- e.g. 3000m, 2.5km, -154nm or 10000ft.+--+length :: ReadP Length+length = do+    v <- number+    skipSpaces+    u <- string "m" <|> string "km" <|> string "nm" <|> string "ft"+    case u of+        "m" -> return (metres v)+        "km" -> return (kilometres v)+        "nm" -> return (nauticalMiles v)+        "ft" -> return (feet v)+        _ -> pfail++-- | metre to micrometre.+m2um :: Double+m2um = 1000.0 * 1000.0++abs' :: Length -> Length+abs' (Length um) = Length (abs um)
+ src/Data/Geo/Jord/Local.hs view
@@ -0,0 +1,319 @@+-- |
+-- Module:      Data.Geo.Jord.Local
+-- Copyright:   (c) 2020 Cedric Liegeois
+-- License:     BSD3
+-- Maintainer:  Cedric Liegeois <ofmooseandmen@yahoo.fr>
+-- Stability:   experimental
+-- Portability: portable
+--
+-- Type and functions for working with delta vectors in different local reference frames: all frames are location dependent.
+--
+-- In order to use this module you should start with the following imports:
+--
+-- @
+-- import qualified Data.Geo.Jord.Geodetic as Geodetic
+-- import qualified Data.Geo.Jord.Local as Local
+-- @
+--
+-- All functions are implemented using the vector-based approached described in
+-- <http://www.navlab.net/Publications/A_Nonsingular_Horizontal_Position_Representation.pdf Gade, K. (2010). A Non-singular Horizontal Position Representation>
+--
+-- Notes:
+--
+--     * The term Earth is used to be consistent with the paper. However any celestial body reference frame can be used.
+--
+--     * Though the API accept spherical models, doing so defeats the purpose of this module
+--       which is to find exact solutions. Prefer using ellipsoidal models.
+module Data.Geo.Jord.Local
+    (
+    -- * Local Reference frame
+      Frame(..)
+    -- * Body frame
+    , FrameB
+    , yaw
+    , pitch
+    , roll
+    , bOrigin
+    , frameB
+    -- * Local level/wander azimuth frame
+    , FrameL
+    , wanderAzimuth
+    , lOrigin
+    , frameL
+    -- * North-East-Down frame
+    , FrameN
+    , nOrigin
+    , frameN
+    -- * Deltas
+    , Delta(..)
+    , deltaMetres
+    -- * Delta in the north, east, down frame
+    , Ned(..)
+    , nedMetres
+    , bearing
+    , elevation
+    , slantRange
+    -- * Calculations
+    , deltaBetween
+    , nedBetween
+    , destination
+    , destinationN
+    ) where
+
+import Data.Geo.Jord.Angle (Angle)
+import qualified Data.Geo.Jord.Angle as Angle
+import qualified Data.Geo.Jord.Geocentric as Geocentric
+import qualified Data.Geo.Jord.Geodetic as Geodetic
+import Data.Geo.Jord.Length (Length)
+import qualified Data.Geo.Jord.Length as Length (metres, toMetres)
+import qualified Data.Geo.Jord.Math3d as Math3d
+import Data.Geo.Jord.Model (Model)
+import Data.Geo.Jord.Positions
+import Data.Geo.Jord.Rotation
+
+-- | class for local reference frames: a reference frame which is location dependant.
+--
+-- Supported frames:
+--
+--     * 'FrameB': 'rEF' returns R_EB
+--
+--     * 'FrameL': 'rEF' returns R_EL
+--
+--     * 'FrameN': 'rEF' returns R_EN
+class Frame a where
+    rEF :: a -> [Math3d.V3] -- ^ rotation matrix to transform vectors decomposed in frame @a@ to vectors decomposed Earth-Fixed frame.
+
+-- | Body frame (typically of a vehicle).
+--
+--     * Position: The origin is in the vehicle’s reference point.
+--
+--     * Orientation: The x-axis points forward, the y-axis to the right (starboard) and the z-axis in the vehicle’s down direction.
+--
+--     * Comments: The frame is fixed to the vehicle.
+data FrameB a =
+    FrameB
+        { yaw :: Angle -- ^ body yaw angle (vertical axis).
+        , pitch :: Angle -- ^ body pitch angle (transverse axis).
+        , roll :: Angle -- ^ body roll angle (longitudinal axis).
+        , bOrigin :: Geodetic.Position a -- ^ frame origin.
+        , bNorth :: Math3d.V3 -- ^ position of the north pole as /n/-vector.
+        }
+    deriving (Eq, Show)
+
+-- | 'FrameB' from given yaw, pitch, roll, position (origin).
+frameB :: (Model a) => Angle -> Angle -> Angle -> Geodetic.Position a -> FrameB a
+frameB y p r o = FrameB y p r o (northPole o)
+
+-- | R_EB: frame B to Earth
+instance Frame (FrameB a) where
+    rEF (FrameB y p r o np) = rm
+      where
+        rNB = zyx2r y p r
+        n = FrameN o np
+        rEN = rEF n
+        rm = Math3d.dotM rEN rNB -- closest frames cancel: N
+
+-- | Local level, Wander azimuth frame.
+--
+--     * Position: The origin is directly beneath or above the vehicle (B), at Earth’s surface (surface
+-- of ellipsoid model).
+--
+--     * Orientation: The z-axis is pointing down. Initially, the x-axis points towards north, and the
+-- y-axis points towards east, but as the vehicle moves they are not rotating about the z-axis
+-- (their angular velocity relative to the Earth has zero component along the z-axis).
+-- (Note: Any initial horizontal direction of the x- and y-axes is valid for L, but if the
+-- initial position is outside the poles, north and east are usually chosen for convenience.)
+--
+--     * Comments: The L-frame is equal to the N-frame except for the rotation about the z-axis,
+-- which is always zero for this frame (relative to Earth). Hence, at a given time, the only
+-- difference between the frames is an angle between the x-axis of L and the north direction;
+-- this angle is called the wander azimuth angle. The L-frame is well suited for general
+-- calculations, as it is non-singular.
+data FrameL a =
+    FrameL
+        { wanderAzimuth :: Angle -- ^ wander azimuth: angle between x-axis of the frame L and the north direction.
+        , lOrigin :: Geodetic.Position a -- ^ frame origin.
+        }
+    deriving (Eq, Show)
+
+-- | R_EL: frame L to Earth
+instance Frame (FrameL m) where
+    rEF (FrameL w o) = rm
+      where
+        lat = Geodetic.latitude o
+        lon = Geodetic.longitude o
+        r = xyz2r lon (Angle.negate lat) w
+        rEe' = [Math3d.vec3 0 0 (-1), Math3d.vec3 0 1 0, Math3d.vec3 1 0 0]
+        rm = Math3d.dotM rEe' r
+
+-- | 'FrameL' from given wander azimuth, position (origin).
+frameL :: (Model a) => Angle -> Geodetic.Position a -> FrameL a
+frameL = FrameL
+
+-- | North-East-Down (local level) frame.
+--
+--     * Position: The origin is directly beneath or above the vehicle (B), at Earth’s surface (surface
+-- of ellipsoid model).
+--
+--     * Orientation: The x-axis points towards north, the y-axis points towards east (both are
+-- horizontal), and the z-axis is pointing down.
+--
+--     * Comments: When moving relative to the Earth, the frame rotates about its z-axis to allow the
+-- x-axis to always point towards north. When getting close to the poles this rotation rate
+-- will increase, being infinite at the poles. The poles are thus singularities and the direction of
+-- the x- and y-axes are not defined here. Hence, this coordinate frame is not suitable for
+-- general calculations.
+data FrameN a =
+    FrameN
+        { nOrigin :: Geodetic.Position a -- ^ frame origin.
+        , nNorth :: Math3d.V3 -- ^ position of the north pole as /n/-vector.
+        }
+    deriving (Eq, Show)
+
+-- | R_EN: frame N to Earth
+instance Frame (FrameN a) where
+    rEF (FrameN o np) = Math3d.transposeM rm
+      where
+        vo = Geodetic.nvector o
+        rd = Math3d.scale vo (-1.0) -- down (pointing opposite to n-vector)
+        re = Math3d.unit (Math3d.cross np vo) -- east (pointing perpendicular to the plane)
+        rn = Math3d.cross re rd -- north (by right hand rule)
+        rm = [rn, re, rd]
+
+-- | 'FrameN' from given position (origin).
+frameN :: (Model a) => Geodetic.Position a -> FrameN a
+frameN p = FrameN p (northPole p)
+
+-- | delta between position in one of the reference frames.
+data Delta =
+    Delta
+        { dx :: Length -- ^ x component.
+        , dy :: Length -- ^ y component.
+        , dz :: Length -- ^ z component.
+        }
+    deriving (Eq, Show)
+
+-- | 'Delta' from given x, y and z length in __metres__.
+deltaMetres :: Double -> Double -> Double -> Delta
+deltaMetres x y z = Delta (Length.metres x) (Length.metres y) (Length.metres z)
+
+-- | North, east and down delta (thus in frame 'FrameN').
+data Ned =
+    Ned
+        { north :: Length -- ^ North component.
+        , east :: Length -- ^ East component.
+        , down :: Length -- ^ Down component.
+        }
+    deriving (Eq, Show)
+
+-- | 'Ned' from given north, east and down in __metres__.
+nedMetres :: Double -> Double -> Double -> Ned
+nedMetres n e d = Ned (Length.metres n) (Length.metres e) (Length.metres d)
+
+-- | @bearing v@ computes the bearing in compass angle of the NED vector @v@ from north.
+--
+-- Compass angles are clockwise angles from true north: 0 = north, 90 = east, 180 = south, 270 = west.
+bearing :: Ned -> Angle
+bearing (Ned n e _) =
+    let a = Angle.atan2 (Length.toMetres e) (Length.toMetres n)
+     in Angle.normalise a (Angle.decimalDegrees 360.0)
+
+-- | @elevation v@ computes the elevation of the NED vector @v@ from horizontal (ie tangent to ellipsoid surface).
+elevation :: Ned -> Angle
+elevation n = Angle.negate (Angle.asin (Math3d.v3z v / Math3d.norm v))
+  where
+    v = nedV3 n
+
+-- | @slantRange v@ computes the distance from origin in the local system of the NED vector @v@.
+slantRange :: Ned -> Length
+slantRange = Length.metres . Math3d.norm . nedV3
+
+-- | @deltaBetween p1 p2 f@ computes the exact 'Delta' between the two
+-- positions @p1@ and @p2@ in local frame @f@. For example:
+--
+-- >>> let p1 = Geodetic.latLongHeightPos 1 2 (Length.metres (-3)) WGS84
+-- >>> let p2 = Geodetic.latLongHeightPos 4 5 (Length.metres (-6)) WGS84
+-- >>> let w = Angle.decimalDegrees 5 -- wander azimuth
+-- >>> Local.deltaBetween p1 p2 (Local.frameL w)
+-- Delta {dx = 359.490578214km, dy = 302.818522536km, dz = 17.404271362km}
+deltaBetween ::
+       (Frame a, Model b)
+    => Geodetic.Position b
+    -> Geodetic.Position b
+    -> (Geodetic.Position b -> a)
+    -> Delta
+deltaBetween p1 p2 f = deltaMetres (Math3d.v3x d) (Math3d.v3y d) (Math3d.v3z d)
+  where
+    g1 = Geocentric.metresCoords . toGeocentric $ p1
+    g2 = Geocentric.metresCoords . toGeocentric $ p2
+    de = Math3d.subtract g2 g1
+    -- rotation matrix to go from Earth Frame to Frame at p1
+    rm = Math3d.transposeM (rEF (f p1))
+    d = Math3d.multM de rm
+
+-- | @nedBetween p1 p2@ computes the exact 'Ned' vector between the two
+-- positions @p1@ and @p2@, in north, east, and down. For example:
+--
+-- >>> let p1 = Geodetic.latLongHeightPos 1 2 (Length.metres (-3)) WGS84
+-- >>> let p2 = Geodetic.latLongHeightPos 4 5 (Length.metres (-6)) WGS84
+-- >>> Local.nedBetween p1 p2
+-- Ned {north = 331.730234781km, east = 332.997874989km, down = 17.404271362km}
+--
+-- Resulting 'Ned' delta is relative to @p1@: Due to the curvature of Earth and
+-- different directions to the North Pole, the north, east, and down directions
+-- will change (relative to Earth) for different places.
+--
+-- Position @p1@ must be outside the poles for the north and east directions to be defined.
+--
+-- This is equivalent to:
+--
+-- > Local.deltaBetween p1 p2 Local.frameN
+nedBetween :: (Model a) => Geodetic.Position a -> Geodetic.Position a -> Ned
+nedBetween p1 p2 = Ned n e d
+  where
+    (Delta n e d) = deltaBetween p1 p2 frameN
+
+-- | @destination p0 f d@ computes the destination position from position @p0@ and delta @d@ in local frame @f@. For
+-- example:
+--
+-- >>> let p0 = Geodetic.latLongHeightPos 49.66618 3.45063 Length.zero WGS84
+-- >>> let y = Angle.decimalDegrees 10 -- yaw
+-- >>> let r = Angle.decimalDegrees 20 -- roll
+-- >>> let p = Angle.decimalDegrees 30 -- pitch
+-- >>> let d = Local.deltaMetres 3000 2000 100
+-- >>> Local.destination p0 (Local.frameB y r p) d
+-- 49°41'30.485"N,3°28'52.561"E 6.007735m (WGS84)
+destination ::
+       (Frame a, Model b)
+    => Geodetic.Position b
+    -> (Geodetic.Position b -> a)
+    -> Delta
+    -> Geodetic.Position b
+destination p0 f d = toGeodetic gt
+  where
+    g0 = Geocentric.metresCoords . toGeocentric $ p0
+    rm = rEF (f p0)
+    c = Math3d.multM (deltaV3 d) rm
+    v = Math3d.add g0 c
+    gt = Geocentric.metresPos' v (Geodetic.model' p0)
+
+-- | @destinationN p0 d@ computes the destination position from position @p0@ and north, east, down @d@. For example:
+--
+-- >>> let p0 = Geodetic.latLongHeightPos 49.66618 3.45063 Length.zero WGS84
+-- >>> Local.destinationN p0 (Local.nedMetres 100 200 300)
+-- 49°40'1.484"N,3°27'12.242"E -299.996086m (WGS84)
+-- This is equivalent to:
+--
+-- > Local.destination p0 Local.frameN
+destinationN :: (Model a) => Geodetic.Position a -> Ned -> Geodetic.Position a
+destinationN p0 (Ned n e d) = destination p0 frameN (Delta n e d)
+
+nedV3 :: Ned -> Math3d.V3
+nedV3 (Ned n e d) = Math3d.vec3 (Length.toMetres n) (Length.toMetres e) (Length.toMetres d)
+
+deltaV3 :: Delta -> Math3d.V3
+deltaV3 (Delta x' y' z') =
+    Math3d.vec3 (Length.toMetres x') (Length.toMetres y') (Length.toMetres z')
+
+northPole :: (Model a) => Geodetic.Position a -> Math3d.V3
+northPole = Geodetic.nvector . Geodetic.northPole . Geodetic.model'
− src/Data/Geo/Jord/LocalFrames.hs
@@ -1,356 +0,0 @@--- |
--- Module:      Data.Geo.Jord.LocalFrames
--- Copyright:   (c) 2020 Cedric Liegeois
--- License:     BSD3
--- Maintainer:  Cedric Liegeois <ofmooseandmen@yahoo.fr>
--- Stability:   experimental
--- Portability: portable
---
--- Type and functions for working with delta vectors in different local reference frames: all frames are location dependent.
---
--- In order to use this module you should start with the following imports:
---
--- @
---     import Data.Geo.Jord.LocalFrames
---     import Data.Geo.Jord.Position
--- @
---
--- All functions are implemented using the vector-based approached described in
--- <http://www.navlab.net/Publications/A_Nonsingular_Horizontal_Position_Representation.pdf Gade, K. (2010). A Non-singular Horizontal Position Representation>
---
--- Notes:
---
---     * The term Earth is used to be consistent with the paper. However any celestial body reference frame can be used.
---
---     * Though the API accept spherical models, doing so defeats the purpose of this module
---       which is to find exact solutions. Prefer using ellipsoidal models.
---
-module Data.Geo.Jord.LocalFrames
-    (
-    -- * Local Reference frame
-      LocalFrame(..)
-    -- * Body frame
-    , FrameB
-    , yaw
-    , pitch
-    , roll
-    , bOrigin
-    , frameB
-    -- * Local level/wander azimuth frame
-    , FrameL
-    , wanderAzimuth
-    , lOrigin
-    , frameL
-    -- * North-East-Down frame
-    , FrameN
-    , nOrigin
-    , frameN
-    -- * Deltas
-    , Delta
-    , delta
-    , deltaMetres
-    , dx
-    , dy
-    , dz
-    -- * Delta in the north, east, down frame
-    , Ned
-    , ned
-    , nedMetres
-    , north
-    , east
-    , down
-    , bearing
-    , elevation
-    , slantRange
-    -- * Calculations
-    , deltaBetween
-    , nedBetween
-    , target
-    , targetN
-    -- * re-exported for convenience
-    , module Data.Geo.Jord.Rotation
-    ) where
-
-import Data.Geo.Jord.Position
-import Data.Geo.Jord.Rotation
-
--- | class for local reference frames: a reference frame which is location dependant.
---
--- Supported frames:
---
---     * 'FrameB': 'rEF' returns R_EB
---
---     * 'FrameL': 'rEF' returns R_EL
---
---     * 'FrameN': 'rEF' returns R_EN
---
-class LocalFrame a where
-    rEF :: a -> [Vector3d] -- ^ rotation matrix to transform vectors decomposed in frame @a@ to vectors decomposed Earth-Fixed frame.
-
--- | Body frame (typically of a vehicle).
---
---     * Position: The origin is in the vehicle’s reference point.
---
---     * Orientation: The x-axis points forward, the y-axis to the right (starboard) and the z-axis
--- in the vehicle’s down direction.
---
---      * Comments: The frame is fixed to the vehicle.
---
-data FrameB a =
-    FrameB
-        { yaw :: Angle -- ^ body yaw angle (vertical axis).
-        , pitch :: Angle -- ^ body pitch angle (transverse axis).
-        , roll :: Angle -- ^ body roll angle (longitudinal axis).
-        , bOrigin :: Position a -- ^ frame origin.
-        }
-    deriving (Eq, Show)
-
--- | 'FrameB' from given yaw, pitch, roll, position (origin).
-frameB :: (Model a) => Angle -> Angle -> Angle -> Position a -> FrameB a
-frameB = FrameB
-
--- | R_EB: frame B to Earth
-instance LocalFrame (FrameB a) where
-    rEF (FrameB y p r o) = rm
-      where
-        rNB = zyx2r y p r
-        n = FrameN o
-        rEN = rEF n
-        rm = mdot rEN rNB -- closest frames cancel: N
-
--- | Local level, Wander azimuth frame.
---
---     * Position: The origin is directly beneath or above the vehicle (B), at Earth’s surface (surface
--- of ellipsoid model).
---
---     * Orientation: The z-axis is pointing down. Initially, the x-axis points towards north, and the
--- y-axis points towards east, but as the vehicle moves they are not rotating about the z-axis
--- (their angular velocity relative to the Earth has zero component along the z-axis).
--- (Note: Any initial horizontal direction of the x- and y-axes is valid for L, but if the
--- initial position is outside the poles, north and east are usually chosen for convenience.)
---
---     * Comments: The L-frame is equal to the N-frame except for the rotation about the z-axis,
--- which is always zero for this frame (relative to Earth). Hence, at a given time, the only
--- difference between the frames is an angle between the x-axis of L and the north direction;
--- this angle is called the wander azimuth angle. The L-frame is well suited for general
--- calculations, as it is non-singular.
---
-data FrameL a =
-    FrameL
-        { wanderAzimuth :: Angle -- ^ wander azimuth: angle between x-axis of the frame L and the north direction.
-        , lOrigin :: Position a -- ^ frame origin.
-        }
-    deriving (Eq, Show)
-
--- | R_EL: frame L to Earth
-instance LocalFrame (FrameL m) where
-    rEF (FrameL w o) = rm
-      where
-        lat = latitude o
-        lon = longitude o
-        r = xyz2r lon (negate' lat) w
-        rEe' = [Vector3d 0 0 (-1), Vector3d 0 1 0, Vector3d 1 0 0]
-        rm = mdot rEe' r
-
--- | 'FrameL' from given wander azimuth, position (origin).
-frameL :: (Model a) => Angle -> Position a -> FrameL a
-frameL = FrameL
-
--- | North-East-Down (local level) frame.
---
---     * Position: The origin is directly beneath or above the vehicle (B), at Earth’s surface (surface
--- of ellipsoid model).
---
---     * Orientation: The x-axis points towards north, the y-axis points towards east (both are
--- horizontal), and the z-axis is pointing down.
---
---     * Comments: When moving relative to the Earth, the frame rotates about its z-axis to allow the
--- x-axis to always point towards north. When getting close to the poles this rotation rate
--- will increase, being infinite at the poles. The poles are thus singularities and the direction of
--- the x- and y-axes are not defined here. Hence, this coordinate frame is not suitable for
--- general calculations.
---
-newtype FrameN a =
-    FrameN
-        { nOrigin :: Position a -- ^ frame origin.
-        }
-    deriving (Eq, Show)
-
--- | R_EN: frame N to Earth
-instance LocalFrame (FrameN a) where
-    rEF (FrameN o) = transpose rm
-      where
-        vo = nvec o
-        np = nvNorthPole
-        rd = vscale vo (-1) -- down (pointing opposite to n-vector)
-        re = vunit (vcross np vo) -- east (pointing perpendicular to the plane)
-        rn = vcross re rd -- north (by right hand rule)
-        rm = [rn, re, rd]
-
--- | 'FrameN' from given position (origin).
-frameN :: (Model a) => Position a -> FrameN a
-frameN = FrameN
-
--- | delta between position in one of the reference frames.
-newtype Delta =
-    Delta Vector3d
-    deriving (Eq, Show)
-
--- | 'Delta' from given x, y and z length.
-delta :: Length -> Length -> Length -> Delta
-delta x y z = Delta (Vector3d (toMetres x) (toMetres y) (toMetres z))
-
--- | 'Delta' from given x, y and z length in __metres__.
-deltaMetres :: Double -> Double -> Double -> Delta
-deltaMetres x y z = delta (metres x) (metres y) (metres z)
-
--- | x component of given 'Delta'.
-dx :: Delta -> Length
-dx (Delta v) = metres (vx v)
-
--- | y component of given 'Delta'.
-dy :: Delta -> Length
-dy (Delta v) = metres (vy v)
-
--- | z component of given 'Delta'.
-dz :: Delta -> Length
-dz (Delta v) = metres (vz v)
-
--- | North, east and down delta (thus in frame 'FrameN').
-newtype Ned =
-    Ned Vector3d
-    deriving (Eq, Show)
-
--- | 'Ned' from given north, east and down.
-ned :: Length -> Length -> Length -> Ned
-ned n e d = Ned (Vector3d (toMetres n) (toMetres e) (toMetres d))
-
--- | 'Ned' from given north, east and down in __metres__.
-nedMetres :: Double -> Double -> Double -> Ned
-nedMetres n e d = ned (metres n) (metres e) (metres d)
-
--- | North component of given 'Ned'.
-north :: Ned -> Length
-north (Ned v) = metres (vx v)
-
--- | East component of given 'Ned'.
-east :: Ned -> Length
-east (Ned v) = metres (vy v)
-
--- | Down component of given 'Ned'.
-down :: Ned -> Length
-down (Ned v) = metres (vz v)
-
--- | @bearing v@ computes the bearing in compass angle of the NED vector @v@ from north.
---
--- Compass angles are clockwise angles from true north: 0 = north, 90 = east, 180 = south, 270 = west.
---
-bearing :: Ned -> Angle
-bearing v =
-    let a = atan2' (toMetres (east v)) (toMetres (north v))
-     in normalise a (decimalDegrees 360.0)
-
--- | @elevation v@ computes the elevation of the NED vector @v@ from horizontal (ie tangent to ellipsoid surface).
-elevation :: Ned -> Angle
-elevation (Ned v) = negate' (asin' (vz v / vnorm v))
-
--- | @slantRange v@ computes the distance from origin in the local system of the NED vector @v@.
-slantRange :: Ned -> Length
-slantRange (Ned v) = metres (vnorm v)
-
--- | @deltaBetween p1 p2 f@ computes the exact 'Delta' between the two
--- positions @p1@ and @p2@ in local frame @f@.
---
--- ==== __Examples__
---
--- >>> import Data.Geo.Jord.LocalFrames
--- >>> import Data.Geo.Jord.Position
--- >>>
--- >>> let p1 = wgs84Pos 1 2 (metres (-3))
--- >>> let p2 = wgs84Pos 4 5 (metres (-6))
--- >>> let w = decimalDegrees 5 -- wander azimuth
--- >>> deltaBetween p1 p2 (frameL w)
--- Delta (Vector3d {vx = 359490.5782, vy = 302818.5225, vz = 17404.2714})
---
-deltaBetween :: (LocalFrame a, Model b) => Position b -> Position b -> (Position b -> a) -> Delta
-deltaBetween p1 p2 f = deltaMetres (vx d) (vy d) (vz d)
-  where
-    g1 = gcvec p1
-    g2 = gcvec p2
-    de = vsub g2 g1
-    -- rotation matrix to go from Earth Frame to Frame at p1
-    rm = transpose (rEF (f p1))
-    d = vmultm de rm
-
--- | @nedBetween p1 p2@ computes the exact 'Ned' vector between the two
--- positions @p1@ and @p2@, in north, east, and down.
---
--- Resulting 'Ned' delta is relative to @p1@: Due to the curvature of Earth and
--- different directions to the North Pole, the north, east, and down directions
--- will change (relative to Earth) for different places.
---
--- Position @p1@ must be outside the poles for the north and east directions to be defined.
---
--- This is equivalent to:
---
--- @
---     'deltaBetween' p1 p2 'frameN'
--- @
---
--- ==== __Examples__
---
--- >>> import Data.Geo.Jord.LocalFrames
--- >>> import Data.Geo.Jord.Position
--- >>>
--- >>> let p1 = wgs84Pos 1 2 (metres (-3))
--- >>> let p2 = wgs84Pos 4 5 (metres (-6))
--- >>> nedBetween p1 p2
--- Ned (Vector3d {vx = 331730.2348, vy = 332997.875, vz = 17404.2714})
---
-nedBetween :: (Model a) => Position a -> Position a -> Ned
-nedBetween p1 p2 = nedMetres (vx d) (vy d) (vz d)
-  where
-    (Delta d) = deltaBetween p1 p2 frameN
-
--- | @target p0 f d@ computes the target position from position @p0@ and delta @d@ in local frame @f@.
---
--- ==== __Examples__
---
--- >>> import Data.Geo.Jord.LocalFrames
--- >>> import Data.Geo.Jord.Position
--- >>>
--- >>> let p0 = wgs84Pos 49.66618 3.45063 zero
--- >>> let y = decimalDegrees 10 -- yaw
--- >>> let r = decimalDegrees 20 -- roll
--- >>> let p = decimalDegrees 30 -- pitch
--- >>> let d = deltaMetres 3000 2000 100
--- >>> target p0 (frameB y r p) d
--- 49°41'30.486"N,3°28'52.561"E 6.0077m (WGS84)
---
-target :: (LocalFrame a, Model b) => Position b -> (Position b -> a) -> Delta -> Position b
-target p0 f (Delta d) = geocentricMetresPos x y z (model p0)
-  where
-    g0 = gcvec p0
-    rm = rEF (f p0)
-    c = vmultm d rm
-    (Vector3d x y z) = vadd g0 c
-
--- | @targetN p0 d@ computes the target position from position @p0@ and north, east, down @d@.
---
--- This is equivalent to:
---
--- @
---     'target' p0 'frameN' ('Delta' d)
--- @
---
--- ==== __Examples__
---
--- >>> import Data.Geo.Jord.LocalFrames
--- >>> import Data.Geo.Jord.Position
--- >>>
--- >>> let p0 = wgs84Pos 49.66618 3.45063 zero
--- >>> targetN p0 (nedMeters 100 200 300)
--- 49°40'1.485"N,3°27'12.242"E -299.9961m (WGS84)
---
-targetN :: (Model a) => Position a -> Ned -> Position a
-targetN p0 (Ned d) = target p0 frameN (Delta d)
+ src/Data/Geo/Jord/Math3d.hs view
@@ -0,0 +1,125 @@+-- |+-- Module:      Data.Geo.Jord.Math3d+-- Copyright:   (c) 2020 Cedric Liegeois+-- License:     BSD3+-- Maintainer:  Cedric Liegeois <ofmooseandmen@yahoo.fr>+-- Stability:   experimental+-- Portability: portable+--+-- 3-element vector and associated math functions.+module Data.Geo.Jord.Math3d+    ( V3+    , v3x+    , v3y+    , v3z+    , vec3+    , add+    , subtract+    , squaredDistance+    , dot+    , norm+    , cross+    , scale+    , unit+    , zero+    , transposeM+    , dotM+    , multM+    ) where++import Prelude hiding (subtract)++-- | 3-element vector.+data V3 =+    V3+        { v3x :: Double -- ^ x-coordinate+        , v3y :: Double -- ^ y-coordinate+        , v3z :: Double -- ^ z-coordinate+        }+    deriving (Eq, Show)++-- | Vector 3d from given coordinates.+-- 0.0 is added to each component to avoid @-0.0@.+vec3 :: Double -> Double -> Double -> V3+vec3 x y z = V3 (x + 0.0) (y + 0.0) (z + 0.0)++-- | Adds 2 vectors.+add :: V3 -> V3 -> V3+add (V3 x1 y1 z1) (V3 x2 y2 z2) = vec3 (x1 + x2) (y1 + y2) (z1 + z2)++-- | Subtracts 2 vectors.+subtract :: V3 -> V3 -> V3+subtract (V3 x1 y1 z1) (V3 x2 y2 z2) = vec3 (x1 - x2) (y1 - y2) (z1 - z2)++-- | Computes the cross product of 2 vectors: the vector perpendicular to given vectors.+cross :: V3 -> V3 -> V3+cross (V3 x1 y1 z1) (V3 x2 y2 z2) = vec3 x y z+  where+    x = y1 * z2 - z1 * y2+    y = z1 * x2 - x1 * z2+    z = x1 * y2 - y1 * x2++-- | Computes the square of the straight line distance (or geometrical distance)+-- between 2 vectors.+squaredDistance :: V3 -> V3 -> Double+squaredDistance (V3 x1 y1 z1) (V3 x2 y2 z2) = dx * dx + dy * dy + dz * dz+  where+    dx = x1 - x2+    dy = y1 - y2+    dz = z1 - z2++-- | Computes the dot product of 2 vectors.+dot :: V3 -> V3 -> Double+dot (V3 x1 y1 z1) (V3 x2 y2 z2) = x1 * x2 + y1 * y2 + z1 * z2++-- | Computes the norm of a vector.+norm :: V3 -> Double+norm (V3 x y z) = sqrt (x * x + y * y + z * z)++-- | Multiplies vector by __3x3__ matrix (rows).+multM :: V3 -> [V3] -> V3+multM v rm+    | length rm /= 3 = error ("Invalid matrix" ++ show rm)+    | otherwise = vec3 x y z+  where+    [x, y, z] = map (dot v) rm++-- | @scale v s@ multiplies each component of @v@ by @s@.+scale :: V3 -> Double -> V3+scale (V3 x y z) s = vec3 (x * s) (y * s) (z * s)++-- | Normalises a vector. The 'norm' of the produced vector is @1@.+unit :: V3 -> V3+unit v+    | s == 1.0 = v+    | otherwise = scale v s+  where+    s = 1.0 / norm v++-- | vector of norm 0.+zero :: V3+zero = V3 0.0 0.0 0.0++-- | transpose __square (3x3)__ matrix of 'V3'.+transposeM :: [V3] -> [V3]+transposeM m = fmap ds2v (transpose' xs)+  where+    xs = fmap v2ds m++-- | transpose matrix.+transpose' :: [[Double]] -> [[Double]]+transpose' ([]:_) = []+transpose' x = map head x : transpose' (map tail x)++-- | multiplies 2 __3x3__ matrices.+dotM :: [V3] -> [V3] -> [V3]+dotM a b = fmap ds2v [[dot ar bc | bc <- transposeM b] | ar <- a]++-- | 'V3' to list of doubles.+v2ds :: V3 -> [Double]+v2ds (V3 x y z) = [x, y, z]++-- | list of doubles to 'V3'.+ds2v :: [Double] -> V3+ds2v [x, y, z] = vec3 x y z+ds2v xs = error ("Invalid list: " ++ show xs)
src/Data/Geo/Jord/Model.hs view
@@ -9,7 +9,6 @@ -- Definition of celestial body models.
 --
 -- see "Data.Geo.Jord.Models" for supported models.
---
 module Data.Geo.Jord.Model
     ( LongitudeRange(..)
     , ModelId(..)
@@ -29,7 +28,7 @@ 
 -- | Epoch (decimal years) such as 2018.60: the 219th day of the year or August 7, 2018
 -- in the Gregorian calendar.
-data Epoch =
+newtype Epoch =
     Epoch Double
     deriving (Eq, Show)
 
src/Data/Geo/Jord/Models.hs view
@@ -9,7 +9,6 @@ -- Common ellipsoidal and spherical models. -- -- This module has been generated.--- module Data.Geo.Jord.Models where  import Data.Geo.Jord.Ellipsoids
src/Data/Geo/Jord/Parser.hs view
@@ -6,8 +6,7 @@ -- Stability:   experimental
 -- Portability: portable
 --
--- internal 'ReadP' parsers used by "Jord".
---
+-- internal 'ReadP' parsers.
 module Data.Geo.Jord.Parser
     ( digits
     , double
+ src/Data/Geo/Jord/Polygon.hs view
@@ -0,0 +1,258 @@+-- |
+-- Module:      Data.Geo.Jord.Polygon
+-- Copyright:   (c) 2020 Cedric Liegeois
+-- License:     BSD3
+-- Maintainer:  Cedric Liegeois <ofmooseandmen@yahoo.fr>
+-- Stability:   experimental
+-- Portability: portable
+--
+-- Types and functions for working with polygons at the surface of a __spherical__ celestial body.
+--
+-- In order to use this module you should start with the following imports:
+--
+-- @
+-- import qualified Data.Geo.Jord.Geodetic as Geodetic
+-- import qualified Data.Geo.Jord.Polygon as Polygon
+-- @
+module Data.Geo.Jord.Polygon
+    (
+    -- * The 'Polygon' type
+      Polygon
+    , vertices
+    , edges
+    , concave
+    -- * smart constructors
+    , Error(..)
+    , simple
+    , circle
+    , arc
+    -- * calculations
+    , contains
+    , triangulate
+    ) where
+
+import Data.List (find)
+import Data.Maybe (isJust, mapMaybe)
+
+import Data.Geo.Jord.Angle (Angle)
+import qualified Data.Geo.Jord.Angle as Angle
+import Data.Geo.Jord.Ellipsoid (meanRadius)
+import Data.Geo.Jord.Geodetic (HorizontalPosition)
+import qualified Data.Geo.Jord.Geodetic as Geodetic
+import Data.Geo.Jord.GreatCircle (MinorArc)
+import qualified Data.Geo.Jord.GreatCircle as GreatCircle
+import Data.Geo.Jord.Length (Length)
+import qualified Data.Geo.Jord.Length as Length
+import qualified Data.Geo.Jord.Math3d as Math3d
+import Data.Geo.Jord.Model (Spherical, surface)
+import Data.Geo.Jord.Triangle (Triangle)
+import qualified Data.Geo.Jord.Triangle as Triangle
+
+-- | A polygon whose vertices are horizontal geodetic positions.
+data Polygon a =
+    Polygon
+        { vertices :: [HorizontalPosition a] -- ^ vertices of the polygon in __clockwise__ order.
+        , edges :: [MinorArc a] -- ^ edges of the polyon in __clockwise__ order.
+        , concave :: Bool -- ^ whether the polygon is concave.
+        }
+    deriving (Eq, Show)
+
+-- | Error returned when attempting to create a polygon from invalid data. 
+data Error
+    = NotEnoughVertices -- ^ less than 3 vertices were supplied.
+    | InvalidEdge -- ^ 2 consecutives vertices are antipodal or equal.
+    | InvalidRadius -- ^ radius of circle or arc is <= 0.
+    | EmptyArcRange -- ^ arc start angle == end angle.
+    | SeflIntersectingEdge -- ^ 2 edges of the polygon intersect.
+    deriving (Eq, Show)
+
+-- | Simple polygon (outer ring only and not self-intersecting) from given vertices. Returns an error ('Left') if:
+--
+--     * less than 3 vertices are given.
+--     * the given vertices defines self-intersecting edges.
+--     * the given vertices contains duplicated positions or antipodal positions.
+simple :: (Spherical a) => [HorizontalPosition a] -> Either Error (Polygon a)
+simple vs
+    | null vs = Left NotEnoughVertices
+    | head vs == last vs = simple (init vs)
+    | length vs < 3 = Left NotEnoughVertices
+    | otherwise = simple' vs
+
+-- | Circle from given centre and radius. The resulting polygon contains @nb@ vertices equally distant from one
+-- another. Returns an error ('Left') if:
+--
+--     * given radius is 0
+--     * given number of positions is less than 3
+circle :: (Spherical a) => HorizontalPosition a -> Length -> Int -> Either Error (Polygon a)
+circle c r nb
+    | r <= Length.zero = Left InvalidRadius
+    | nb < 3 = Left NotEnoughVertices
+    | otherwise = Right (discretiseArc c r as)
+  where
+    n = fromIntegral nb :: Double
+    as = take nb (iterate (\x -> x + pi * 2.0 / n) 0.0)
+
+-- | Arc from given centre, radius and given start/end angles. The resulting polygon contains @nb@ vertices equally
+-- distant from one another. Returns an error ('Left') if:
+--
+--     * given radius is 0
+--     * given number of positions is less than 3
+--     * difference between start and end angle is 0
+arc :: (Spherical a)
+    => HorizontalPosition a
+    -> Length
+    -> Angle
+    -> Angle
+    -> Int
+    -> Either Error (Polygon a)
+arc c r sa ea nb
+    | r <= Length.zero = Left InvalidRadius
+    | nb < 3 = Left NotEnoughVertices
+    | range == Angle.zero = Left EmptyArcRange
+    | otherwise = Right (discretiseArc c r as)
+  where
+    range = Angle.clockwiseDifference sa ea
+    n = fromIntegral nb :: Double
+    inc = Angle.toRadians range / (n - 1.0)
+    r0 = Angle.toRadians sa
+    as = take nb (iterate (+ inc) r0)
+
+-- | @contains poly p@ returns 'True' if position @p@ is enclosed by the vertices of polygon
+-- @poly@ - see 'GreatCircle.enclosedBy'.
+contains :: (Spherical a) => Polygon a -> HorizontalPosition a -> Bool
+contains poly p = GreatCircle.enclosedBy p (vertices poly)
+
+-- | Triangulates the given polygon using the ear clipping method.
+--
+-- May return an empty list if the algorithm fails to find an ear (which probably indicates a bug in the implementation).
+triangulate :: (Spherical a) => Polygon a -> [Triangle a]
+triangulate p
+    | length vs == 3 = [triangle vs]
+    | otherwise = earClipping vs []
+  where
+    vs = vertices p
+
+-- private
+triangle :: (Spherical a) => [HorizontalPosition a] -> Triangle a
+triangle vs = Triangle.unsafeMake (head vs) (vs !! 1) (vs !! 2)
+
+earClipping :: (Spherical a) => [HorizontalPosition a] -> [Triangle a] -> [Triangle a]
+earClipping vs ts
+    | length vs == 3 = reverse (triangle vs : ts)
+    | otherwise =
+        case findEar vs of
+            Nothing -> []
+            (Just (p, e, n)) -> earClipping vs' ts'
+                where ts' = Triangle.unsafeMake p e n : ts
+                      vs' = filter (/= e) vs
+
+findEar ::
+       (Spherical a)
+    => [HorizontalPosition a]
+    -> Maybe (HorizontalPosition a, HorizontalPosition a, HorizontalPosition a)
+findEar ps = find (`isEar` rs) convex
+  where
+    rs = reflices ps
+    t3 = tuple3 ps
+    convex = filter (\(_, v, _) -> v `notElem` rs) t3
+
+-- | a convex vertex @c@ is an ear if triangle (prev, c, next) contains no reflex.
+isEar ::
+       (Spherical a)
+    => (HorizontalPosition a, HorizontalPosition a, HorizontalPosition a)
+    -> [HorizontalPosition a]
+    -> Bool
+isEar (p, c, n) = all (\r -> not (GreatCircle.enclosedBy r vs))
+  where
+    vs = [p, c, n]
+
+-- | A reflex is a vertex where the polygon is concave.
+-- a vertex is a reflex if previous vertex is left (assuming a clockwise polygon), otherwise it is a convex vertex
+reflices :: (Spherical a) => [HorizontalPosition a] -> [HorizontalPosition a]
+reflices ps = fmap (\(_, c, _) -> c) rs
+  where
+    t3 = tuple3 ps
+    rs = filter (\(p, c, n) -> GreatCircle.side p c n == GreatCircle.LeftOf) t3
+
+-- | [mAB, mBC, mCD, mDE, mEA]
+-- no intersections:
+-- mAB vs [mCD, mDE]
+-- mBC vs [mDE, mEA]
+-- mCD vs [mEA]
+selfIntersects :: (Spherical a) => [MinorArc a] -> Bool
+selfIntersects ps
+    | length ps < 4 = False
+    | otherwise = any intersects pairs
+  where
+    (_, pairs) = makePairs' (ps, [])
+
+intersects :: (Spherical a) => (MinorArc a, [MinorArc a]) -> Bool
+intersects (ma, mas) = any (isJust . GreatCircle.intersection ma) mas
+
+makePairs' ::
+       (Spherical a)
+    => ([MinorArc a], [(MinorArc a, [MinorArc a])])
+    -> ([MinorArc a], [(MinorArc a, [MinorArc a])])
+makePairs' (xs, ps)
+    | length xs < 3 = (xs, ps)
+    | otherwise = makePairs' (nxs, np : ps)
+  where
+    nxs = tail xs
+    -- if ps is empty (first call), drop last minor arc as it connect to first minor arc
+    versus =
+        if null ps
+            then init . tail . tail $ xs
+            else tail . tail $ xs
+    np = (head xs, versus)
+
+simple' :: (Spherical a) => [HorizontalPosition a] -> Either Error (Polygon a)
+simple' vs
+    | length es /= length vs = Left InvalidEdge
+    | si = Left SeflIntersectingEdge
+    | otherwise = Right (Polygon os es isConcave)
+  where
+    zs = tuple3 vs
+    clockwise = sum (fmap (\(a, b, c) -> Angle.toRadians (GreatCircle.turn a b c)) zs) < 0.0
+    os =
+        if clockwise
+            then vs
+            else reverse vs
+    es = mkEdges os
+    zzs = tuple3 os
+    isConcave =
+        length vs > 3 && any (\(a, b, c) -> GreatCircle.side a b c == GreatCircle.LeftOf) zzs
+    si = isConcave && selfIntersects es
+
+tuple3 ::
+       (Spherical a)
+    => [HorizontalPosition a]
+    -> [(HorizontalPosition a, HorizontalPosition a, HorizontalPosition a)]
+tuple3 ps = zip3 l1 l2 l3
+  where
+    l1 = last ps : init ps
+    l2 = ps
+    l3 = tail ps ++ [head ps]
+
+mkEdges :: (Spherical a) => [HorizontalPosition a] -> [MinorArc a]
+mkEdges ps = mapMaybe (uncurry GreatCircle.minorArc) (zip ps (tail ps ++ [head ps]))
+
+discretiseArc :: (Spherical a) => HorizontalPosition a -> Length -> [Double] -> Polygon a
+discretiseArc c r as = Polygon ps (mkEdges ps) False
+  where
+    m = Geodetic.model c
+    lat = Geodetic.latitude c
+    lon = Geodetic.longitude c
+    erm = Length.toMetres . meanRadius . surface $ m
+    rm = erm * sin (Length.toMetres r / erm)
+    z = sqrt (erm * erm - rm * rm)
+    rya = pi / 2.0 - Angle.toRadians lat
+    cy = cos rya
+    sy = sin rya
+    ry = [Math3d.vec3 cy 0 sy, Math3d.vec3 0 1 0, Math3d.vec3 (-sy) 0 cy]
+    rza = Angle.toRadians lon
+    cz = cos rza
+    sz = sin rza
+    rz = [Math3d.vec3 cz (-sz) 0, Math3d.vec3 sz cz 0, Math3d.vec3 0 0 1]
+    anp = fmap (\a -> Math3d.vec3 ((-rm) * cos a) (rm * sin a) z) as -- arc at north pole
+    rot = fmap (\v -> Math3d.multM (Math3d.multM v ry) rz) anp -- rotate each point to arc centre
+    ps = fmap (`Geodetic.nvectorPos'` m) rot
− src/Data/Geo/Jord/Position.hs
@@ -1,539 +0,0 @@--- |
--- Module:      Data.Geo.Jord.Position
--- Copyright:   (c) 2020 Cedric Liegeois
--- License:     BSD3
--- Maintainer:  Cedric Liegeois <ofmooseandmen@yahoo.fr>
--- Stability:   experimental
--- Portability: portable
---
--- Position of points in specified models (e.g. WGS84) and conversion functions between
--- coordinate system (geodetic to/from geocentric).
---
--- 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>
---
--- See <http://clynchg3c.com/Technote/geodesy/coorddef.pdf Earth Coordinates>
---
-module Data.Geo.Jord.Position
-    (
-    -- * The 'Position' type
-      Position
-    , latitude
-    , longitude
-    , height
-    , nvec
-    , gcvec
-    , model
-    -- * /n/-vector
-    , NVector
-    , nx
-    , ny
-    , nz
-    , nvector
-    -- * Geocentric coordinates
-    , Geocentric
-    , gx
-    , gy
-    , gz
-    , geocentric
-    -- * Smart constructors
-    , latLongPos
-    , latLongHeightPos
-    , latLongPos'
-    , latLongHeightPos'
-    , wgs84Pos
-    , wgs84Pos'
-    , s84Pos
-    , s84Pos'
-    , nvectorPos
-    , nvectorHeightPos
-    , geocentricPos
-    , geocentricMetresPos
-    , nvh
-    -- * Read/Show points
-    , readPosition
-    , positionP
-    -- * Vector3d conversions
-    , nvectorFromLatLong
-    , nvectorToLatLong
-    , nvectorFromGeocentric
-    , nvectorToGeocentric
-    -- * Misc.
-    , antipode
-    , latLong
-    , latLong'
-    , northPole
-    , southPole
-    , nvNorthPole
-    , nvSouthPole
-    -- * re-exported for convenience
-    , module Data.Geo.Jord.Angle
-    , module Data.Geo.Jord.Ellipsoid
-    , module Data.Geo.Jord.Ellipsoids
-    , module Data.Geo.Jord.LatLong
-    , module Data.Geo.Jord.Length
-    , module Data.Geo.Jord.Model
-    , module Data.Geo.Jord.Models
-    , module Data.Geo.Jord.Quantity
-    , module Data.Geo.Jord.Vector3d
-    ) where
-
-import Text.ParserCombinators.ReadP (ReadP, option, readP_to_S, skipSpaces)
-
-import Data.Geo.Jord.Angle
-import Data.Geo.Jord.Ellipsoid
-import Data.Geo.Jord.Ellipsoids
-import Data.Geo.Jord.LatLong
-import Data.Geo.Jord.Length
-import Data.Geo.Jord.Model
-import Data.Geo.Jord.Models
-import Data.Geo.Jord.Quantity
-import Data.Geo.Jord.Vector3d
-
--- | Coordinates of a position in a specified 'Model'.
--- A position provides both geodetic latitude & longitude, height and
--- geocentric coordinates. The horizontal position
--- (i.e. coordinates at the surface of the celestial body) is also provided
--- as /n/-vector.
---
--- The "show" instance gives position in degrees, minutes, seconds,
--- milliseconds ('Angle' "show" instance), height ('Length' "show" instance)
--- and the model ('Model' "show" instance).
---
--- The "eq" instance returns True if and only if, both positions have the same
--- horizontal position, height and model.
-data Position a =
-    Position
-        { latitude :: Angle -- ^ geodetic latitude
-        , longitude :: Angle -- ^ geodetic longitude
-        , height :: Length -- ^ height above the surface of the celestial body
-        , nvec :: !Vector3d -- ^ /n/-vector representing the horizontal coordinates of the position
-        , gcvec :: Vector3d -- ^ vector representing the geocentric coordinates of the position (metres)
-        , model :: !a -- ^ model (e.g. WGS84)
-        }
-
-instance (Model a) => Show (Position a) where
-    show p = showLatLong (latitude p, longitude p) ++ " " ++ (show . height $ p) ++ " (" ++ (show . model $ p) ++ ")"
-
-instance (Model a) => Eq (Position a)
-    -- model equality is ensure by @a@
-                                       where
-    p1 == p2 = latitude p1 == latitude p2 && longitude p1 == longitude p2 && height p1 == height p2
-
--- | normal vector to the surface of a celestial body.
---
--- Orientation: z-axis points to the North Pole along the body's rotation axis,
--- x-axis points towards the point where latitude = longitude = 0.
-data NVector =
-    NVector Double Double Double
-
-instance Show NVector where
-    show (NVector x y z) = "n-vector {" ++ show x ++ ", " ++ show y ++ ", " ++ show z ++ "}"
-
--- | x-component of the given /n/-vector.
-nx :: NVector -> Double
-nx (NVector x _ _) = x
-
--- | y-component of the given /n/-vector.
-ny :: NVector -> Double
-ny (NVector _ y _) = y
-
--- | z-component of the given /n/-vector.
-nz :: NVector -> Double
-nz (NVector _ _ z) = z
-
--- | Geocentric (cartesian) coordinates in the fixed-body coordinates system.
---
--- @x-y@ plane is the equatorial plane, @x@ is on the prime meridian, and @z@ on the polar axis.
---
--- On a spherical celestial body, an /n/-vector is equivalent to a normalised version of an
--- geocentric cartesian coordinate.
---
--- Note: For Earth, this is known as the Earth-Centred Earth Fixed coordinates system (ECEF).
---
-data Geocentric =
-    Geocentric Length Length Length
-
-instance Show Geocentric where
-    show (Geocentric x y z) = "geocentric {" ++ show x ++ ", " ++ show y ++ ", " ++ show z ++ "}"
-
--- | x-coordinate of the given 'Geocentric' coordinates.
-gx :: Geocentric -> Length
-gx (Geocentric x _ _) = x
-
--- | y-coordinate of the given 'Geocentric' coordinates.
-gy :: Geocentric -> Length
-gy (Geocentric _ y _) = y
-
--- | z-coordinate of the given 'Geocentric' coordinates.
-gz :: Geocentric -> Length
-gz (Geocentric _ _ z) = z
-
--- | @antipode p@ computes the antipodal position of @p@: the position which is diametrically
--- opposite to @p@.
-antipode :: (Model a) => Position a -> Position a
-antipode p = nvh nv h (model p)
-  where
-    h = height p
-    nv = vscale (nvec p) (-1.0)
-
--- | @nvector p@ returns the horizontal position of @p@ as a /n/-vector.
---
--- ==== __Examples__
---
--- >>> import Data.Geo.Jord.Position
--- >>>
--- >>> nvector (northPole S84)
--- n-vector {0.0, 0.0, 1.0}
---
--- >>> nvector (wgs84Pos 54 154 (metres 1000))
--- n-vector {-0.5282978852629286, 0.2576680951131586, 0.8090169943749475}
---
-nvector :: (Model a) => Position a -> NVector
-nvector p = NVector x y z
-  where
-    (Vector3d x y z) = nvec p
-
--- | @geocentric p@ returns the 'Geocentric' coordinates of position @p@.
---
--- ==== __Examples__
---
--- >>> import Data.Geo.Jord.Position
--- >>>
--- >>> geocentric (wgs84Pos 54 154 (metres 1000))
--- geocentric {-3377.4908375km, 1647.312349km, 5137.5528484km}
---
-geocentric :: (Model a) => Position a -> Geocentric
-geocentric p = Geocentric (metres x) (metres y) (metres z)
-  where
-    (Vector3d x y z) = gcvec p
-
--- | Horizontal position of the North Pole in the given model.
-northPole :: (Model a) => a -> Position a
-northPole = nvh nvNorthPole zero
-
--- | Horizontal position of the South Pole in the given model.
-southPole :: (Model a) => a -> Position a
-southPole = nvh nvSouthPole zero
-
--- | Horizontal position of the North Pole (/n/-vector).
-nvNorthPole :: Vector3d
-nvNorthPole = Vector3d 0.0 0.0 1.0
-
--- | Horizontal position of the South Pole (/n/-vector).
-nvSouthPole :: Vector3d
-nvSouthPole = Vector3d 0.0 0.0 (-1.0)
-
--- | Reads a 'Position' from the given string using 'positionP'.
-readPosition :: (Model a) => String -> a -> Maybe (Position a)
-readPosition s m =
-    case map fst $ filter (null . snd) $ readP_to_S (positionP m) s of
-        [] -> Nothing
-        p:_ -> Just p
-
--- | Parses and returns a 'Position'.
---
--- Supported formats:
---
---     * DD(MM)(SS)[N|S]DDD(MM)(SS)[E|W] - e.g. 553621N0130002E or 0116S03649E or 47N122W
---
---     * 'Angle'[N|S] 'Angle'[E|W] - e.g. 55°36'21''N 13°0'02''E or 11°16'S 36°49'E or 47°N 122°W
---
--- Additionally the string may end by a valid 'Length'.
---
--- ==== __Examples__
---
--- >>> import Data.Geo.Jord.Position
--- >>>
--- >>> readPosition "55°36'21''N 013°00'02''E" WGS84
--- Just 55°36'21.000"N,13°0'2.000"E 0.0m (WGS84)
--- >>>
--- >>> readPosition "55°36'21''N 013°00'02''E 1500m" WGS84
--- Just 55°36'21.000"N,13°0'2.000"E 1500.0m (WGS84)
---
-positionP :: (Model a) => a -> ReadP (Position a)
-positionP m = do
-    (lat, lon) <- latLongDmsP m
-    skipSpaces
-    h <- option zero lengthP
-    return (latLongHeightPos' lat lon h m)
-
--- | Ground 'Position' from given geodetic latitude & longitude in __decimal degrees__ in
--- the given model.
---
--- Latitude & longitude values are first converted to 'Angle' to ensure a consistent resolution
--- with the rest of the API, then wrapped to their respective range.
---
--- This is equivalent to:
---
--- @
---     'latLongHeightPos' lat lon zero model
--- @
---
-latLongPos :: (Model a) => Double -> Double -> a -> Position a
-latLongPos lat lon = latLongHeightPos lat lon zero
-
--- | 'Position' from given geodetic latitude & longitude in __decimal degrees__ and height
--- in the given model
---
--- Latitude & longitude values are first converted to 'Angle' to ensure a consistent resolution
--- with the rest of the API, then wrapped to their respective range.
---
-latLongHeightPos :: (Model a) => Double -> Double -> Length -> a -> Position a
-latLongHeightPos lat lon = latLongHeightPos' (decimalDegrees lat) (decimalDegrees lon)
-
--- | Ground 'Position' from given geodetic latitude & longitude in
--- the given model.
--- Latitude & longitude values are wrapped to their respective range.
---
--- This is equivalent to:
---
--- @
---     'latLongHeightPos'' lat lon zero model
--- @
---
-latLongPos' :: (Model a) => Angle -> Angle -> a -> Position a
-latLongPos' lat lon = latLongHeightPos' lat lon zero
-
--- | 'Position' from given geodetic latitude & longitude and height in the given model.
--- Latitude & longitude values are wrapped to their respective range.
-latLongHeightPos' :: (Model a) => Angle -> Angle -> Length -> a -> Position a
-latLongHeightPos' lat lon h m = Position lat' lon' h nv g m
-  where
-    nv = nvectorFromLatLong (lat, lon)
-    g = nvectorToGeocentric (nv, h) (surface m)
-    (lat', lon') = wrap lat lon nv m
-
--- | 'Position' from given geodetic latitude & longitude in __decimal degrees__ and height in
--- the WGS84 datum.
---
--- Latitude & longitude values are first converted to 'Angle' to ensure a consistent resolution
--- with the rest of the API, then wrapped to their respective range.
---
--- This is equivalent to:
---
--- @
---     'latLongHeightPos' lat lon h 'WGS84'
--- @
---
-wgs84Pos :: Double -> Double -> Length -> Position WGS84
-wgs84Pos lat lon h = latLongHeightPos lat lon h WGS84
-
--- | 'Position' from given geodetic latitude & longitude and height in the WGS84 datum.
--- Latitude & longitude values are wrapped to their respective range.
---
--- This is equivalent to:
---
--- @
---     'latLongHeightPos'' lat lon h 'WGS84'
--- @
---
-wgs84Pos' :: Angle -> Angle -> Length -> Position WGS84
-wgs84Pos' lat lon h = latLongHeightPos' lat lon h WGS84
-
--- | 'Position' from given latitude & longitude in __decimal degrees__ and height in the
--- spherical datum derived from WGS84.
---
--- Latitude & longitude values are first converted to 'Angle' to ensure a consistent resolution
--- with the rest of the API, then wrapped to their respective range.
---
--- This is equivalent to:
---
--- @
---     'latLongHeightPos' lat lon h 'S84'
--- @
---
-s84Pos :: Double -> Double -> Length -> Position S84
-s84Pos lat lon h = latLongHeightPos lat lon h S84
-
--- | 'Position' from given latitude & longitude and height in the spherical datum derived
--- from WGS84. Latitude & longitude values are wrapped to their respective range.
---
--- This is equivalent to:
---
--- @
---     'latLongHeightPos'' lat lon h 'S84'
--- @
---
-s84Pos' :: Angle -> Angle -> Length -> Position S84
-s84Pos' lat lon h = latLongHeightPos' lat lon h S84
-
--- | 'Position' from given geocentric coordinates x, y and z in the given model.
-geocentricPos :: (Model a) => Length -> Length -> Length -> a -> Position a
-geocentricPos x y z = geocentricMetresPos' (toMetres x) (toMetres y) (toMetres z)
-
--- | 'Position' from given geocentric coordinates x, y and z in __metres__ in the given model.
---
--- x, y, z lengths are first converted to 'Length' to ensure a consistent resolution with the rest of the API.
-geocentricMetresPos :: (Model a) => Double -> Double -> Double -> a -> Position a
-geocentricMetresPos x y z = geocentricMetresPos' (toMetres . metres $ x) (toMetres . metres $ y) (toMetres . metres $ z)
-
-geocentricMetresPos' :: (Model a) => Double -> Double -> Double -> a -> Position a
-geocentricMetresPos' x y z m = Position lat lon h nv ev m
-  where
-    ev = Vector3d x y z
-    (nv, h) = nvectorFromGeocentric ev (surface m)
-    (lat, lon) = nvectorToLatLong nv
-
--- | 'Position' from given /n/-vector x, y, z coordinates in the given model.
--- Vector (x, y, z) will be normalised to a unit vector to get a valid /n/-vector.
---
--- This is equivalent to:
---
--- @
---     'nvectorHeightPos' lat lon zero model
--- @
---
-nvectorPos :: (Model a) => Double -> Double -> Double -> a -> Position a
-nvectorPos x y z = nvectorHeightPos x y z zero
-
--- | 'Position' from given /n/-vector x, y, z coordinates and height in the given model.
--- Vector (x, y, z) will be normalised to a unit vector to get a valid /n/-vector.
-nvectorHeightPos :: (Model a) => Double -> Double -> Double -> Length -> a -> Position a
-nvectorHeightPos x y z = nvh (vunit (Vector3d x y z))
-
--- | (latitude, longitude) pair in __decimal degrees__ from given position.
-latLong :: (Model a) => Position a -> (Double, Double)
-latLong p = (toDecimalDegrees . latitude $ p, toDecimalDegrees . longitude $ p)
-
--- | (latitude, longitude) pair from given position.
-latLong' :: (Model a) => Position a -> (Angle, Angle)
-latLong' p = (latitude p, longitude p)
- -- given 'Vector3d' is a /n/-vector.
-
--- | position from /n/-vector, height and model; this method is to be used only if
-nvh :: (Model a) => Vector3d -> Length -> a -> Position a
-nvh nv h m = Position lat lon h nv g m
-  where
-    (lat, lon) = llWrapped nv (longitudeRange m)
-    g = nvectorToGeocentric (nv, h) (surface m)
-
--- | @nvectorToLatLong nv@ returns (latitude, longitude) pair equivalent to the given /n/-vector @nv@.
---
--- You should prefer using:
---
--- @
---     'latLong' ('nvectorPos' x y z model)
--- @
---
--- Latitude is always in [-90°, 90°] and longitude in [-180°, 180°].
-nvectorToLatLong :: Vector3d -> (Angle, Angle)
-nvectorToLatLong nv = (lat, lon)
-  where
-    lat = atan2' (vz nv) (sqrt (vx nv * vx nv + vy nv * vy nv))
-    lon = atan2' (vy nv) (vx nv)
-
--- | @nvectorFromLatLong ll@ returns /n/-vector equivalent to the given (latitude, longitude) pair @ll@.
---
--- You should prefer using:
---
--- @
---     'nvector' ('latLongPos' lat lon model)
--- @
-nvectorFromLatLong :: (Angle, Angle) -> Vector3d
-nvectorFromLatLong (lat, lon) = Vector3d x y z
-  where
-    cl = cos' lat
-    x = cl * cos' lon
-    y = cl * sin' lon
-    z = sin' lat
-
--- | @nvectorToGeocentric (nv, h) e@ returns the geocentric coordinates equivalent to the given
--- /n/-vector @nv@ and height @h@ using the ellispoid @e@.
---
--- You should prefer using:
---
--- @
---     'geocentric' ('nvectorHeightPos' x y z h model)
--- @
---
-nvectorToGeocentric :: (Vector3d, Length) -> Ellipsoid -> Vector3d
-nvectorToGeocentric (nv, h) e
-    | isSphere e = nvectorToGeocentricS (nv, h) (equatorialRadius e)
-    | otherwise = nvectorToGeocentricE (nv, h) e
-
-nvectorToGeocentricS :: (Vector3d, Length) -> Length -> Vector3d
-nvectorToGeocentricS (nv, h) r = vscale nv (toMetres n)
-  where
-    n = add h r
-
-nvectorToGeocentricE :: (Vector3d, Length) -> Ellipsoid -> Vector3d
-nvectorToGeocentricE (nv, h) e = Vector3d gx' gy' gz'
-  where
-    a = toMetres . equatorialRadius $ e
-    b = toMetres . polarRadius $ e
-    nx' = vx nv
-    ny' = vy nv
-    nz' = vz nv
-    m = (a * a) / (b * b)
-    n = b / sqrt ((nx' * nx' * m) + (ny' * ny' * m) + (nz' * nz'))
-    h' = toMetres h
-    gx' = n * m * nx' + h' * nx'
-    gy' = n * m * ny' + h' * ny'
-    gz' = n * nz' + h' * nz'
-
--- | @nvectorFromGeocentric g e@ returns the /n/-vector equivalent to the geocentric
--- coordinates @g@ using the ellispoid @e@.
---
--- You should prefer using:
---
--- @
---     'nvector' ('geocentricMetresPos' x y z model)
--- @
---
-nvectorFromGeocentric :: Vector3d -> Ellipsoid -> (Vector3d, Length)
-nvectorFromGeocentric g e
-    | isSphere e = nvectorFromGeocentricS g (equatorialRadius e)
-    | otherwise = nvectorFromGeocentricE g e
-
-nvectorFromGeocentricS :: Vector3d -> Length -> (Vector3d, Length)
-nvectorFromGeocentricS g r = (vunit g, h)
-  where
-    h = sub (metres (vnorm g)) r
-
-nvectorFromGeocentricE :: Vector3d -> Ellipsoid -> (Vector3d, Length)
-nvectorFromGeocentricE g e = (nvecEllipsoidal d e2 k px py pz, metres h)
-  where
-    e' = eccentricity e
-    e2 = e' * e'
-    e4 = e2 * e2
-    a = toMetres . equatorialRadius $ e
-    a2 = a * a
-    px = vx g
-    py = vy g
-    pz = vz g
-    p = (px * px + py * py) / a2
-    q = ((1 - e2) / a2) * (pz * pz)
-    r = (p + q - e4) / 6.0
-    s = (e4 * p * q) / (4.0 * r * r * r)
-    t = (1.0 + s + sqrt (s * (2.0 + s))) ** (1 / 3)
-    u = r * (1.0 + t + 1.0 / t)
-    v = sqrt (u * u + q * e4)
-    w = e2 * (u + v - q) / (2.0 * v)
-    k = sqrt (u + v + w * w) - w
-    d = k * sqrt (px * px + py * py) / (k + e2)
-    h = ((k + e2 - 1.0) / k) * sqrt (d * d + pz * pz)
-
-nvecEllipsoidal :: Double -> Double -> Double -> Double -> Double -> Double -> Vector3d
-nvecEllipsoidal d e2 k px py pz = Vector3d nx' ny' nz'
-  where
-    s = 1.0 / sqrt (d * d + pz * pz)
-    a = k / (k + e2)
-    nx' = s * a * px
-    ny' = s * a * py
-    nz' = s * pz
-
-wrap :: (Model a) => Angle -> Angle -> Vector3d -> a -> (Angle, Angle)
-wrap lat lon nv m =
-    if isValidLatLong lat lon m
-        then (lat, lon)
-        else llWrapped nv (longitudeRange m)
-
-llWrapped :: Vector3d -> LongitudeRange -> (Angle, Angle)
-llWrapped nv lr = (lat, lon')
-  where
-    (lat, lon) = nvectorToLatLong nv
-    lon' =
-        case lr of
-            L180 -> lon
-            L360 -> add lon (decimalDegrees 180)
+ src/Data/Geo/Jord/Positions.hs view
@@ -0,0 +1,213 @@+-- |
+-- Module:      Data.Geo.Jord.Positions
+-- Copyright:   (c) 2020 Cedric Liegeois
+-- License:     BSD3
+-- Maintainer:  Cedric Liegeois <ofmooseandmen@yahoo.fr>
+-- Stability:   experimental
+-- Portability: portable
+--
+-- Functions to convert position between geodetic and geocentric and to transform position coordinates between ellipsoidal models.
+--
+-- @
+-- import qualified Data.Geo.Jord.Geocentric as Geocentric
+-- import qualified Data.Geo.Jord.Geodetic as Geodetic
+-- import Data.Geo.Jord.Models
+-- import qualified Data.Geo.Jord.Positions as Positions
+-- import qualified Data.Geo.Jord.Transformations as Transformations
+-- @
+module Data.Geo.Jord.Positions
+    (
+    -- Geodetic <=> Geocentric
+      toGeodetic
+    , toGeocentric
+    -- Coordinates transformation between ellipsoidal models
+    , transform
+    , transform'
+    , transformAt
+    , transformAt'
+    ) where
+
+import Data.Geo.Jord.Ellipsoid (Ellipsoid, eccentricity, equatorialRadius, isSphere, polarRadius)
+import qualified Data.Geo.Jord.Geocentric as Geocentric
+import qualified Data.Geo.Jord.Geodetic as Geodetic
+import Data.Geo.Jord.Length (Length)
+import qualified Data.Geo.Jord.Length as Length
+import qualified Data.Geo.Jord.Math3d as Math3d
+import Data.Geo.Jord.Model
+import Data.Geo.Jord.Tx (Graph, Params, Params15, Params7)
+import qualified Data.Geo.Jord.Tx as Tx
+
+-- | @toGeodetic p@ converts the geodetic coordinates of position @p@ to geocentric coordinates.
+toGeodetic :: (Model m) => Geocentric.Position m -> Geodetic.Position m
+toGeodetic p = Geodetic.atHeight (Geodetic.nvectorPos' nv (Geocentric.model p)) h
+  where
+    (nv, h) = nvectorFromGeocentric (Geocentric.metresCoords p) (surface . Geocentric.model $ p)
+
+-- | @toGeocentric p@ converts the geocentric coordinates of position @p@ to geodetic coordinates.
+toGeocentric :: (Model m) => Geodetic.Position m -> Geocentric.Position m
+toGeocentric p =
+    Geocentric.metresPos (Math3d.v3x c) (Math3d.v3y c) (Math3d.v3z c) (Geodetic.model' p)
+  where
+    c = nvectorToGeocentric (Geodetic.nvector p, Geodetic.height p) (surface . Geodetic.model' $ p)
+
+-- | @transform p1 m2 g@ transforms the coordinates of the position @p1@ from its coordinate system into the coordinate
+-- system defined by the model @m2@ using the graph @g@ to find the sequence of transformation parameters. Returns
+-- 'Nothing' if the given graph does not contain a transformation from @m1@ to @m2@. For example:
+--
+-- >>> let pWGS84 = Positions.toGeocentric (Geodetic.latLongHeightPos 48.6921 6.1844 (Length.metres 188) WGS84)
+-- >>> Positions.transform pWGS84 NAD83 Txs.fixed
+-- Just (Position {gx = 4193.792080781km, gy = 454.433921298km, gz = 4768.166154789km, model = NAD83})
+transform ::
+       (Ellipsoidal a, Ellipsoidal b)
+    => Geocentric.Position a
+    -> b
+    -> Graph Params7
+    -> Maybe (Geocentric.Position b)
+transform p1 m2 g = transformGraph p1 m2 g id
+
+-- | @transform' p1 m2 tx@ transforms the coordinates of the position @p1@ from its coordinate system into the coordinate
+-- system defined by the model @m2@ using the 7-parameters transformation @tx@. For example:
+--
+-- >>> let tx = Tx.params7 (995.6, -1910.3, -521.5) (-0.62) (25.915, 9.426, 11.599) -- WGS84 -> NAD83
+-- >>> let pWGS84 = Positions.toGeocentric (Geodetic.latLongHeightPos 48.6921 6.1844 (Length.metres 188) WGS84)
+-- >>> Positions.transform' pWGS84 NAD83 tx
+-- Position {gx = 4193.792080781km, gy = 454.433921298km, gz = 4768.166154789km, model = NAD83}
+transform' ::
+       (Ellipsoidal a, Ellipsoidal b)
+    => Geocentric.Position a
+    -> b
+    -> Params7
+    -> Geocentric.Position b
+transform' = transformOne
+
+-- | @transformAt p1 e m2 g@ transforms the coordinates of the position @p1@ observed at epoch @e@ from its coordinate
+-- system into the coordinate system defined by the model @m2@ using the graph @g@ to find the sequence of transformation
+-- parameters. Returns 'Nothing' if the given graph does not contain a transformation from @m1@ to @m2@. For example:
+--
+-- >>> let pITRF2014 = Positions.toGeocentric (Geodetic.latLongHeightPos 48.6921 6.1844 (Length.metres 188) ITRF2014)
+-- >>> Positions.transformAt pITRF2014 (Epoch 2019.0) NAD83_CORS96 Txs.timeDependent -- through ITRF2000
+-- Just (Position {gx = 4193.791716941km, gy = 454.433860294km, gz = 4768.166466192km, model = NAD83_CORS96})
+transformAt ::
+       (EllipsoidalT0 a, EllipsoidalT0 b)
+    => Geocentric.Position a
+    -> Epoch
+    -> b
+    -> Graph Params15
+    -> Maybe (Geocentric.Position b)
+transformAt p1 e m2 g = transformGraph p1 m2 g (Tx.paramsAt e)
+
+-- | @transformAt' p1 e m2 tx@ transforms the coordinates of the position @p1@ observed at epoch @e@ from its coordinate
+-- system into the coordinate system defined by the model @m2@ using the 15-parameters transformation @tx@. For example:
+--
+-- >>> let tx7 = Tx.params7 (53.7, 51.2, -55.1) 1.2 (0.891, 5.39, -8.712)
+-- >>> let txR = Tx.rates (0.1, 0.1, -1.9) 0.11 (0.81, 0.49, -0.792)
+-- >>> let tx = Tx.Params15 (Epoch 2000.0) tx7 txR -- ITRF2014 -> ETRF2000
+-- >>> let pITRF2014 = Positions.toGeocentric (Geodetic.latLongHeightPos 48.6921 6.1844 (Length.metres 188) ITRF2014)
+-- >>> Positions.transformAt' pITRF2014 (Epoch 2019.0) ETRF2000 tx
+-- Position {gx = 4193.791357037km, gy = 454.435390265km, gz = 4768.166475162km, model = ETRF2000}
+transformAt' ::
+       (EllipsoidalT0 a, EllipsoidalT0 b)
+    => Geocentric.Position a
+    -> Epoch
+    -> b
+    -> Params15
+    -> Geocentric.Position b
+transformAt' p1 e m2 ps = transformOne p1 m2 (Tx.paramsAt e ps)
+
+-- | @nvectorToGeocentric (nv, h) e@ returns the geocentric coordinates equivalent to the given
+-- /n/-vector @nv@ and height @h@ using the ellispoid @e@.
+nvectorToGeocentric :: (Math3d.V3, Length) -> Ellipsoid -> Math3d.V3
+nvectorToGeocentric (nv, h) e
+    | isSphere e = nvectorToGeocentricS (nv, h) (equatorialRadius e)
+    | otherwise = nvectorToGeocentricE (nv, h) e
+
+nvectorToGeocentricS :: (Math3d.V3, Length) -> Length -> Math3d.V3
+nvectorToGeocentricS (nv, h) r = Math3d.scale nv (Length.toMetres n)
+  where
+    n = Length.add h r
+
+nvectorToGeocentricE :: (Math3d.V3, Length) -> Ellipsoid -> Math3d.V3
+nvectorToGeocentricE (nv, h) e = Math3d.vec3 cx cy cz
+  where
+    nx = Math3d.v3x nv
+    ny = Math3d.v3y nv
+    nz = Math3d.v3z nv
+    a = Length.toMetres . equatorialRadius $ e
+    b = Length.toMetres . polarRadius $ e
+    m = (a * a) / (b * b)
+    n = b / sqrt ((nx * nx * m) + (ny * ny * m) + (nz * nz))
+    h' = Length.toMetres h
+    cx = n * m * nx + h' * nx
+    cy = n * m * ny + h' * ny
+    cz = n * nz + h' * nz
+
+-- | @nvectorFromGeocentric g e@ returns the /n/-vector equivalent to the geocentric
+-- coordinates @g@ using the ellispoid @e@.
+nvectorFromGeocentric :: Math3d.V3 -> Ellipsoid -> (Math3d.V3, Length)
+nvectorFromGeocentric g e
+    | isSphere e = nvectorFromGeocentricS g (equatorialRadius e)
+    | otherwise = nvectorFromGeocentricE g e
+
+nvectorFromGeocentricS :: Math3d.V3 -> Length -> (Math3d.V3, Length)
+nvectorFromGeocentricS g r = (Math3d.unit g, h)
+  where
+    h = Length.subtract (Length.metres (Math3d.norm g)) r
+
+nvectorFromGeocentricE :: Math3d.V3 -> Ellipsoid -> (Math3d.V3, Length)
+nvectorFromGeocentricE pv e = (nvecEllipsoidal d e2 k px py pz, Length.metres h)
+  where
+    px = Math3d.v3x pv
+    py = Math3d.v3y pv
+    pz = Math3d.v3z pv
+    e' = eccentricity e
+    e2 = e' * e'
+    e4 = e2 * e2
+    a = Length.toMetres . equatorialRadius $ e
+    a2 = a * a
+    p = (px * px + py * py) / a2
+    q = ((1 - e2) / a2) * (pz * pz)
+    r = (p + q - e4) / 6.0
+    s = (e4 * p * q) / (4.0 * r * r * r)
+    t = (1.0 + s + sqrt (s * (2.0 + s))) ** (1 / 3)
+    u = r * (1.0 + t + 1.0 / t)
+    v = sqrt (u * u + q * e4)
+    w = e2 * (u + v - q) / (2.0 * v)
+    k = sqrt (u + v + w * w) - w
+    d = k * sqrt (px * px + py * py) / (k + e2)
+    h = ((k + e2 - 1.0) / k) * sqrt (d * d + pz * pz)
+
+nvecEllipsoidal :: Double -> Double -> Double -> Double -> Double -> Double -> Math3d.V3
+nvecEllipsoidal d e2 k px py pz = Math3d.vec3 nx ny nz
+  where
+    s = 1.0 / sqrt (d * d + pz * pz)
+    a = k / (k + e2)
+    nx = s * a * px
+    ny = s * a * py
+    nz = s * pz
+
+transformGraph ::
+       (Ellipsoidal a, Ellipsoidal b, Params p)
+    => Geocentric.Position a
+    -> b
+    -> Graph p
+    -> (p -> Params7)
+    -> Maybe (Geocentric.Position b)
+transformGraph p1 m2 g f =
+    case ps of
+        [] -> Nothing
+        _ -> Just (Geocentric.metresPos' v2 m2)
+  where
+    mi1 = modelId . Geocentric.model $ p1
+    mi2 = modelId m2
+    ps = Tx.paramsBetween mi1 mi2 g
+    v2 = foldl (\gc p -> Tx.apply gc (f p)) (Geocentric.metresCoords p1) ps
+
+transformOne ::
+       (Ellipsoidal a, Ellipsoidal b)
+    => Geocentric.Position a
+    -> b
+    -> Params7
+    -> Geocentric.Position b
+transformOne p1 m2 ps = Geocentric.metresPos' v2 m2
+  where
+    v2 = Tx.apply (Geocentric.metresCoords p1) ps
− src/Data/Geo/Jord/Quantity.hs
@@ -1,20 +0,0 @@--- |
--- Module:      Data.Geo.Jord.Quantity
--- Copyright:   (c) 2020 Cedric Liegeois
--- License:     BSD3
--- Maintainer:  Cedric Liegeois <ofmooseandmen@yahoo.fr>
--- Stability:   experimental
--- Portability: portable
---
--- Classes for working with quantities.
---
-module Data.Geo.Jord.Quantity
-    ( Quantity(..)
-    ) where
-
--- | Something that can be added or subtracted.
-class (Eq a, Ord a) =>
-      Quantity a
-    where
-    add, sub :: a -> a -> a
-    zero :: a
src/Data/Geo/Jord/Rotation.hs view
@@ -6,21 +6,29 @@ -- Stability:   experimental
 -- Portability: portable
 --
--- Rotation matrices from/to 3 angles about new axes
+-- Rotation matrices from/to 3 angles about new axes.
 --
+-- In order to use this module you should start with the following imports:
+--
+-- @
+-- import qualified Data.Geo.Jord.Angle as Angle
+-- import Data.Geo.Jord.Math3d (V3(..))
+-- import qualified Data.Geo.Jord.Rotation as Rotation
+-- @
+--
 -- All functions are implemented using the vector-based approached described in
 -- <http://www.navlab.net/Publications/A_Nonsingular_Horizontal_Position_Representation.pdf Gade, K. (2010). A Non-singular Horizontal Position Representation>
---
-module Data.Geo.Jord.Rotation
-    ( r2xyz
-    , r2zyx
-    , xyz2r
-    , zyx2r
-    ) where
-
-import Data.Geo.Jord.Angle
-import Data.Geo.Jord.Vector3d
-
+module Data.Geo.Jord.Rotation+    ( r2xyz+    , r2zyx+    , xyz2r+    , zyx2r+    ) where++import Data.Geo.Jord.Angle (Angle)+import qualified Data.Geo.Jord.Angle as Angle (atan2, cos, negate, sin)+import qualified Data.Geo.Jord.Math3d as Math3d (V3, transposeM, v3x, v3y, v3z, vec3)+ -- | Angles about new axes in the xyz-order from a rotation matrix.
 --
 -- The produced list contains 3 'Angle's of rotation about new axes.
@@ -35,23 +43,23 @@ -- T now coincides with the orientation of B.
 -- The signs of the angles are given by the directions of the axes and the
 -- right hand rule.
-r2xyz :: [Vector3d] -> [Angle]
-r2xyz [v0, v1, v2] = [x, y, z]
-  where
-    v00 = vx v0
-    v01 = vy v0
-    v12 = vz v1
-    v22 = vz v2
-    z = atan2' (-v01) v00
-    x = atan2' (-v12) v22
-    sy = vz v0
+r2xyz :: [Math3d.V3] -> [Angle]+r2xyz [v0, v1, v2] = [x, y, z]+  where+    v00 = Math3d.v3x v0+    v01 = Math3d.v3y v0+    v12 = Math3d.v3z v1+    v22 = Math3d.v3z v2+    z = Angle.atan2 (-v01) v00+    x = Angle.atan2 (-v12) v22+    sy = Math3d.v3z v0     -- cos y is based on as many elements as possible, to average out
     -- numerical errors. It is selected as the positive square root since
     -- y: [-pi/2 pi/2]
-    cy = sqrt ((v00 * v00 + v01 * v01 + v12 * v12 + v22 * v22) / 2.0)
-    y = atan2' sy cy
-r2xyz m = error ("Invalid rotation matrix " ++ show m)
-
+    cy = sqrt ((v00 * v00 + v01 * v01 + v12 * v12 + v22 * v22) / 2.0)+    y = Angle.atan2 sy cy+r2xyz m = error ("Invalid rotation matrix " ++ show m)+ -- | Angles about new axes in the xyz-order from a rotation matrix.
 --
 -- The produced list contains 3 'Angle's of rotation about new axes.
@@ -67,11 +75,11 @@ -- right hand rule.
 -- Note that if A is a north-east-down frame and B is a body frame, we
 -- have that z=yaw, y=pitch and x=roll.
-r2zyx :: [Vector3d] -> [Angle]
-r2zyx m = [z, y, x]
-  where
-    [x, y, z] = fmap negate' (r2xyz (transpose m))
-
+r2zyx :: [Math3d.V3] -> [Angle]+r2zyx m = [z, y, x]+  where+    [x, y, z] = fmap Angle.negate (r2xyz (Math3d.transposeM m))+ -- | Rotation matrix (direction cosine matrix) from 3 angles about new axes in the xyz-order.
 --
 -- The produced (no unit) rotation matrix is such
@@ -90,19 +98,19 @@ -- T now coincides with the orientation of B.
 -- The signs of the angles are given by the directions of the axes and the
 -- right hand rule.
-xyz2r :: Angle -> Angle -> Angle -> [Vector3d]
-xyz2r x y z = [v1, v2, v3]
-  where
-    cx = cos' x
-    sx = sin' x
-    cy = cos' y
-    sy = sin' y
-    cz = cos' z
-    sz = sin' z
-    v1 = Vector3d (cy * cz) ((-cy) * sz) sy
-    v2 = Vector3d (sy * sx * cz + cx * sz) ((-sy) * sx * sz + cx * cz) ((-cy) * sx)
-    v3 = Vector3d ((-sy) * cx * cz + sx * sz) (sy * cx * sz + sx * cz) (cy * cx)
-
+xyz2r :: Angle -> Angle -> Angle -> [Math3d.V3]+xyz2r x y z = [v1, v2, v3]+  where+    cx = Angle.cos x+    sx = Angle.sin x+    cy = Angle.cos y+    sy = Angle.sin y+    cz = Angle.cos z+    sz = Angle.sin z+    v1 = Math3d.vec3 (cy * cz) ((-cy) * sz) sy+    v2 = Math3d.vec3 (sy * sx * cz + cx * sz) ((-sy) * sx * sz + cx * cz) ((-cy) * sx)+    v3 = Math3d.vec3 ((-sy) * cx * cz + sx * sz) (sy * cx * sz + sx * cz) (cy * cx)+ -- | rotation matrix (direction cosine matrix) from 3 angles about new axes in the zyx-order.
 --
 -- The produced (no unit) rotation matrix is such
@@ -124,15 +132,15 @@ --
 -- Note that if A is a north-east-down frame and B is a body frame, we
 -- have that z=yaw, y=pitch and x=roll.
-zyx2r :: Angle -> Angle -> Angle -> [Vector3d]
-zyx2r z y x = [v1, v2, v3]
-  where
-    cx = cos' x
-    sx = sin' x
-    cy = cos' y
-    sy = sin' y
-    cz = cos' z
-    sz = sin' z
-    v1 = Vector3d (cz * cy) ((-sz) * cx + cz * sy * sx) (sz * sx + cz * sy * cx)
-    v2 = Vector3d (sz * cy) (cz * cx + sz * sy * sx) ((-cz) * sx + sz * sy * cx)
-    v3 = Vector3d (-sy) (cy * sx) (cy * cx)+zyx2r :: Angle -> Angle -> Angle -> [Math3d.V3]+zyx2r z y x = [v1, v2, v3]+  where+    cx = Angle.cos x+    sx = Angle.sin x+    cy = Angle.cos y+    sy = Angle.sin y+    cz = Angle.cos z+    sz = Angle.sin z+    v1 = Math3d.vec3 (cz * cy) ((-sz) * cx + cz * sy * sx) (sz * sx + cz * sy * cx)+    v2 = Math3d.vec3 (sz * cy) (cz * cx + sz * sy * sx) ((-cz) * sx + sz * sy * cx)+    v3 = Math3d.vec3 (-sy) (cy * sx) (cy * cx)
src/Data/Geo/Jord/Speed.hs view
@@ -8,36 +8,49 @@ --
 -- Types and functions for working with speed in metres per second, kilometres per hour, miles per hour, knots or feet per second.
 --
+-- In order to use this module you should start with the following imports:
+--
+-- @
+-- import Data.Geo.Jord.Speed (Speed)
+-- import qualified Data.Geo.Jord.Speed as Speed
+-- @
+--
 module Data.Geo.Jord.Speed
     (
     -- * The 'Speed' type
       Speed
     -- * Smart constructors
-    , averageSpeed
+    , average
     , metresPerSecond
     , kilometresPerHour
     , milesPerHour
     , knots
     , feetPerSecond
     -- * Read
-    , speedP
-    , readSpeed
+    , speed
+    , read
     -- * Conversions
     , toMetresPerSecond
     , toKilometresPerHour
     , toMilesPerHour
     , toKnots
     , toFeetPerSecond
+    -- * Misc
+    , add
+    , subtract
+    , zero
     ) where
 
 import Control.Applicative ((<|>))
+import Prelude hiding (read, subtract)
 import Text.ParserCombinators.ReadP (ReadP, pfail, readP_to_S, skipSpaces, string)
 import Text.Read (readMaybe)
 
-import Data.Geo.Jord.Duration
-import Data.Geo.Jord.Length
+import Data.Geo.Jord.Duration (Duration)
+import qualified Data.Geo.Jord.Duration as Duration (toHours)
+import Data.Geo.Jord.Length (Length)
+import qualified Data.Geo.Jord.Length as Length (toMillimetres)
 import Data.Geo.Jord.Parser
-import Data.Geo.Jord.Quantity
 
 -- | A speed with a resolution of 1 millimetre per hour.
 newtype Speed =
@@ -46,9 +59,9 @@         }
     deriving (Eq)
 
--- | See 'speedP'.
+-- | See 'speed'.
 instance Read Speed where
-    readsPrec _ = readP_to_S speedP
+    readsPrec _ = readP_to_S speed
 
 -- | Speed is shown in kilometres per hour.
 instance Show Speed where
@@ -57,18 +70,24 @@ instance Ord Speed where
     (<=) (Speed s1) (Speed s2) = s1 <= s2
 
--- | Add/Subtract Speed.
-instance Quantity Speed where
-    add a b = Speed (mmPerHour a + mmPerHour b)
-    sub a b = Speed (mmPerHour a - mmPerHour b)
-    zero = Speed 0
+-- | Adds 2 speeds.
+add :: Speed -> Speed -> Speed
+add a b = Speed (mmPerHour a + mmPerHour b)
 
+-- | Subtracts 2 speeds.
+subtract :: Speed -> Speed -> Speed
+subtract a b = Speed (mmPerHour a - mmPerHour b)
+
+-- | 0 speed.
+zero :: Speed
+zero = Speed 0
+
 -- | 'Speed' from covered distance and duration.
-averageSpeed :: Length -> Duration -> Speed
-averageSpeed d t = Speed (round (mm / h))
+average :: Length -> Duration -> Speed
+average d t = Speed (round (mm / h))
   where
-    mm = toMillimetres d
-    h = toHours t
+    mm = Length.toMillimetres d
+    h = Duration.toHours t
 
 -- | 'Speed' from given amount of metres per second.
 metresPerSecond :: Double -> Speed
@@ -90,9 +109,9 @@ feetPerSecond :: Double -> Speed
 feetPerSecond fps = Speed (round (fps * 1097280.0))
 
--- | Reads an a 'Speed' from the given string using 'speedP'.
-readSpeed :: String -> Maybe Speed
-readSpeed s = readMaybe s :: (Maybe Speed)
+-- | Reads a 'Speed' from the given string using 'speed'.
+read :: String -> Maybe Speed
+read s = readMaybe s :: (Maybe Speed)
 
 -- | @toMetresPerSecond s@ converts @s@ to metres per second.
 toMetresPerSecond :: Speed -> Double
@@ -116,8 +135,8 @@ 
 -- | Parses and returns a 'Speed' formatted as (-)float[m/s|km/h|mph|kt].
 -- e.g. 300m/s, 250km/h, -154mph, 400kt or 100ft/s.
-speedP :: ReadP Speed
-speedP = do
+speed :: ReadP Speed
+speed = do
     s <- number
     skipSpaces
     u <- string "m/s" <|> string "km/h" <|> string "mph" <|> string "kt" <|> string "ft/s"
− src/Data/Geo/Jord/Transformation.hs
@@ -1,136 +0,0 @@--- |
--- Module:      Data.Geo.Jord.Transformation
--- Copyright:   (c) 2020 Cedric Liegeois
--- License:     BSD3
--- Maintainer:  Cedric Liegeois <ofmooseandmen@yahoo.fr>
--- Stability:   experimental
--- Portability: portable
---
--- Coordinates transformation between ellipsoidal models.
---
--- In order to use this module you should start with the following imports:
---
--- @
---     import Data.Geo.Jord.Position
---     import Data.Geo.Jord.Transformation
--- @
---
---
-module Data.Geo.Jord.Transformation
-    ( transformCoords
-    , transformCoords'
-    , transformCoordsAt
-    , transformCoordsAt'
-    -- * re-exported for convenience
-    , module Data.Geo.Jord.Tx
-    , module Data.Geo.Jord.Txs
-    ) where
-
-import Data.Geo.Jord.Position
-import Data.Geo.Jord.Tx
-import Data.Geo.Jord.Txs
-
--- | @transformCoords p1 m2 g@ transforms the coordinates of the position @p1@ from its coordinate
--- system into the coordinate system defined by the model @m2@ using the graph @g@ to find the
--- sequence of transformation parameters. Returns 'Nothing' if the given graph does not contain a
--- transformation from @m1@ to @m2@ - see 'txParamsBetween'.
---
--- ==== __Examples__
---
--- >>> import Data.Geo.Jord.Position
--- >>> import Data.Geo.Jord.Transformation
--- >>>
--- >>> let pWGS84 = wgs84Pos 48.6921 6.1844 (metres 188)
--- >>> transformCoords pWGS84 NAD83 staticTxs
--- Just 48°41'31.523"N,6°11'3.723"E 188.1212m (NAD83)
---
-transformCoords ::
-       (Ellipsoidal a, Ellipsoidal b) => Position a -> b -> TxGraph TxParams7 -> Maybe (Position b)
-transformCoords p1 m2 g = transformCoordsF p1 m2 g id
-
--- | @transformCoords' p1 m2 tx@ transforms the coordinates of the position @p1@ from its coordinate system
--- into the coordinate system defined by the model @m2@ using the 7-parameters transformation @tx@.
---
--- Notes: this function does not checks whether both models are equals. It should be used when the
--- 7-parameter transformation is known. Most of the time prefer using 'transformCoords'.
---
--- ==== __Examples__
---
--- >>> import Data.Geo.Jord.Position
--- >>> import Data.Geo.Jord.Transformation
--- >>>
--- >>> let tx = txParams7 (995.6, -1910.3, -521.5) (-0.62) (25.915, 9.426, 11.599) -- WGS84 -> NAD83
--- >>> let pWGS84 = wgs84Pos 48.6921 6.1844 (metres 188)
--- >>> transformCoords' pWGS84 NAD83 tx
--- 48°41'31.523"N,6°11'3.723"E 188.1212m (NAD83)
---
-transformCoords' :: (Ellipsoidal a, Ellipsoidal b) => Position a -> b -> TxParams7 -> Position b
-transformCoords' = transformPosCoords
-
--- | @transformCoordsAt p1 e m2 g@ transforms the coordinates of the position @p1@ observed at epoch @e@
--- from its coordinate system into the coordinate system defined by the model @m2@ using the graph @g@ to
--- find the sequence of transformation parameters. Returns 'Nothing' if the given graph does not contain a
--- transformation from @m1@ to @m2@ - see 'txParamsBetween'.
---
--- ==== __Examples__
---
--- >>> import Data.Geo.Jord.Position
--- >>> import Data.Geo.Jord.Transformation
--- >>>
--- >>> let pITRF2014 = latLongHeightPos 48.6921 6.1844 (metres 188) ITRF2014
--- >>> transformCoordsAt pITRF2014 (Epoch 2019.0) NAD83_CORS96 dynamicTxs -- through ITRF2000
--- Just 48°41'31.538"N,6°11'3.722"E 188.112035m (NAD83_CORS96)
---
-transformCoordsAt ::
-       (EllipsoidalT0 a, EllipsoidalT0 b)
-    => Position a
-    -> Epoch
-    -> b
-    -> TxGraph TxParams15
-    -> Maybe (Position b)
-transformCoordsAt p1 e m2 g = transformCoordsF p1 m2 g (txParamsAt e)
-
--- | @transformCoordsAt' p1 e m2 tx@ transforms the coordinates of the position @p1@ observed at epoch @e@
--- from its coordinate system into the coordinate system defined by the model @m2@ using
--- the 15-parameters transformation @tx@.
---
--- Notes: this function does not checks whether both models are equals. It should be used when the
--- 15-parameter transformation is known. Most of the time prefer using 'transformCoords'.
---
--- ==== __Examples__
---
--- >>> import Data.Geo.Jord.Position
--- >>> import Data.Geo.Jord.Transformation
--- >>>
--- >>> let tx7 = txParams7 (53.7, 51.2, -55.1) 1.2 (0.891, 5.39, -8.712)
--- >>> let txR = txRates (0.1, 0.1, -1.9) 0.11 (0.81, 0.49, -0.792)
--- >>> let tx = TxParams15 (Epoch 2000.0) tx7 txR -- ITRF2014 -> ETRF2000
--- >>> let pITRF2014 = latLongHeightPos 48.6921 6.1844 (metres 188) ITRF2014
--- >>> transformCoordsAt' pITRF2014 (Epoch 2019.0) ETRF2000 tx
--- 48°41'31.561"N,6°11'3.865"E 188.0178m (ETRF2000)
---
-transformCoordsAt' ::
-       (EllipsoidalT0 a, EllipsoidalT0 b) => Position a -> Epoch -> b -> TxParams15 -> Position b
-transformCoordsAt' p1 e m2 ps = transformPosCoords p1 m2 (txParamsAt e ps)
-
-transformCoordsF ::
-       (Ellipsoidal a, Ellipsoidal b, TxParams p)
-    => Position a
-    -> b
-    -> TxGraph p
-    -> (p -> TxParams7)
-    -> Maybe (Position b)
-transformCoordsF p1 m2 g f =
-    case ps of
-        [] -> Nothing
-        _ -> Just (geocentricMetresPos v2x v2y v2z m2)
-  where
-    mi1 = modelId . model $ p1
-    mi2 = modelId m2
-    ps = txParamsBetween mi1 mi2 g
-    (Vector3d v2x v2y v2z) = foldl (\gc p -> transformGeoc gc (f p)) (gcvec p1) ps
-
-transformPosCoords :: (Model a, Model b) => Position a -> b -> TxParams7 -> Position b
-transformPosCoords p1 m2 ps = geocentricMetresPos v2x v2y v2z m2
-  where
-    (Vector3d v2x v2y v2z) = transformGeoc (gcvec p1) ps
+ src/Data/Geo/Jord/Triangle.hs view
@@ -0,0 +1,107 @@+-- |
+-- Module:      Data.Geo.Jord.Triangle
+-- Copyright:   (c) 2020 Cedric Liegeois
+-- License:     BSD3
+-- Maintainer:  Cedric Liegeois <ofmooseandmen@yahoo.fr>
+-- Stability:   experimental
+-- Portability: portable
+--
+-- Types and functions for working with triangles at the surface of a __spherical__ celestial body.
+--
+-- In order to use this module you should start with the following imports:
+--
+-- @
+-- import qualified Data.Geo.Jord.Geodetic as Geodetic
+-- import qualified Data.Geo.Jord.Triangle as Triangle
+-- @
+module Data.Geo.Jord.Triangle
+    ( Triangle
+    , vertex0
+    , vertex1
+    , vertex2
+    , make
+    , unsafeMake
+    , centroid
+    , circumcentre
+    , contains
+    ) where
+
+import Control.Monad (join)
+import Data.Maybe (fromJust)
+
+import Data.Geo.Jord.Geodetic (HorizontalPosition)
+import qualified Data.Geo.Jord.Geodetic as Geodetic
+import qualified Data.Geo.Jord.GreatCircle as GreatCircle
+import qualified Data.Geo.Jord.Math3d as Math3d
+import Data.Geo.Jord.Model (Spherical)
+
+-- | A triangle whose vertices are horizontal geodetic positions.
+data Triangle a =
+    Triangle (HorizontalPosition a) (HorizontalPosition a) (HorizontalPosition a)
+    deriving (Eq, Show)
+
+-- | First vertex of given triangle.
+vertex0 :: (Spherical a) => Triangle a -> HorizontalPosition a
+vertex0 (Triangle v _ _) = v
+
+-- | Second vertex of given triangle.
+vertex1 :: (Spherical a) => Triangle a -> HorizontalPosition a
+vertex1 (Triangle _ v _) = v
+
+-- | Third vertex of given triangle.
+vertex2 :: (Spherical a) => Triangle a -> HorizontalPosition a
+vertex2 (Triangle _ _ v) = v
+
+-- | Triangle from given vertices. Returns 'Nothing' if some vertices are equal or some are antipodes of others.
+make ::
+       (Spherical a)
+    => HorizontalPosition a
+    -> HorizontalPosition a
+    -> HorizontalPosition a
+    -> Maybe (Triangle a)
+make v0 v1 v2
+    | v0 == v1 || v1 == v2 || v2 == v0 = Nothing
+    | a0 == v1 || a0 == v2 || a1 == v2 = Nothing
+    | otherwise = Just (Triangle v0 v1 v2)
+  where
+    a0 = Geodetic.antipode v0
+    a1 = Geodetic.antipode v1
+
+-- | Triangle from given vertices. This is unsafe, if any vertices are equal or some are antipodes of others,
+-- the resulting triangle is actually undefined.
+unsafeMake ::
+       (Spherical a)
+    => HorizontalPosition a
+    -> HorizontalPosition a
+    -> HorizontalPosition a
+    -> Triangle a
+unsafeMake = Triangle
+
+-- | @contains t p@ returns 'True' if position @p@ is enclosed by the vertices of triangle
+-- @t@ - see 'GreatCircle.enclosedBy'.
+contains :: (Spherical a) => Triangle a -> HorizontalPosition a -> Bool
+contains (Triangle v0 v1 v2) p = GreatCircle.enclosedBy p [v0, v1, v2]
+
+-- | Computes the centroid of the given triangle: the position which is the intersection of the three medians of
+-- the triangle (each median connecting a vertex with the midpoint of the opposite side).
+--
+-- The centroid is always within the triangle.
+centroid :: (Spherical a) => Triangle a -> HorizontalPosition a
+centroid (Triangle v0 v1 v2) = fromJust c -- this is safe unless triangle was created using unsafeMake.
+  where
+    m1 = GreatCircle.mean [v1, v2] >>= GreatCircle.minorArc v0
+    m2 = GreatCircle.mean [v2, v0] >>= GreatCircle.minorArc v1
+    c = join (GreatCircle.intersection <$> m1 <*> m2)
+
+-- | The circumcentre of the triangle: the position which is equidistant from all three vertices.
+--
+-- The circumscribed circle or circumcircle of a triangle is a circle which passes through all
+-- the vertices of the triangle; The circumcentre is not necessarily inside the triangle.
+--
+-- Thanks to STRIPACK: http://orion.math.iastate.edu/burkardt/f_src/stripack/stripack.f90
+circumcentre :: (Spherical a) => Triangle a -> HorizontalPosition a
+circumcentre (Triangle v0 v1 v2) = Geodetic.nvectorPos' cu (Geodetic.model v0)
+  where
+    e0 = Math3d.subtract (Geodetic.nvector v1) (Geodetic.nvector v0)
+    e1 = Math3d.subtract (Geodetic.nvector v2) (Geodetic.nvector v0)
+    cu = Math3d.cross e0 e1
src/Data/Geo/Jord/Tx.hs view
@@ -1,236 +1,238 @@--- |
--- Module:      Data.Geo.Jord.Transformation
--- Copyright:   (c) 2020 Cedric Liegeois
--- License:     BSD3
--- Maintainer:  Cedric Liegeois <ofmooseandmen@yahoo.fr>
--- Stability:   experimental
--- Portability: portable
---
--- Coordinates transformation parameters.
---
-module Data.Geo.Jord.Tx
-    (
-    -- * transformation parameters.
-      Tx(..)
-    , inverseTx
-    , TxParams(..)
-    , TxParams7
-    , TxRates
-    , TxParams15(..)
-    , txParams7
-    , txRates
-    , txParamsAt
-    -- * transformation graph.
-    , TxGraph
-    , txGraph
-    , txParamsBetween
-    -- * geocentric coordinate transformation
-    , transformGeoc
-    ) where
-
-import Data.List (find, foldl', sortOn)
-import Data.Maybe (mapMaybe)
-
-import Data.Geo.Jord.Model
-import Data.Geo.Jord.Vector3d
-
--- | Coordinate transformation between 2 models (A & B).
-data Tx a =
-    Tx
-        { modelA :: ModelId -- ^  model A.
-        , modelB :: ModelId -- ^ model B.
-        , txParams :: a -- ^ transformation parameters - i.e. 'modelA'-> 'modelB'
-        }
-
--- | inverse transformation.
-inverseTx :: (TxParams a) => Tx a -> Tx a
-inverseTx t = Tx (modelB t) (modelA t) (inverseTxParams (txParams t))
-
--- | class for transformation parameters.
-class TxParams a where
-    idTxParams :: a -- ^ identity transformation parameters, i.e. @p T idTxParams = p@.
-    inverseTxParams :: a -> a -- ^ inverse transformation parameters.
-
--- | 7-parameter transformation (Helmert); use 'txParams7' to construct.
-data TxParams7 =
-    TxParams7 !Vector3d !Double !Vector3d
-    deriving (Show)
-
-instance TxParams TxParams7 where
-    idTxParams = TxParams7 (Vector3d 0 0 0) 0 (Vector3d 0 0 0)
-    inverseTxParams (TxParams7 c s r) = TxParams7 (vscale c (-1.0)) (-s) (vscale r (-1.0))
-
-instance TxParams TxParams15 where
-    idTxParams = TxParams15 (Epoch 0) idTxParams (TxRates (Vector3d 0 0 0) 0 (Vector3d 0 0 0))
-    inverseTxParams (TxParams15 e p (TxRates c s r)) =
-        TxParams15 e (inverseTxParams p) (TxRates (vscale c (-1.0)) (-s) (vscale r (-1.0)))
-
--- | Transformation rates for the 15-parameter transformation (Helmert); use 'txRates' to construct.
-data TxRates =
-    TxRates !Vector3d !Double !Vector3d
-    deriving (Show)
-
--- | Epoch and 14-parameter transformation (Helmert).
-data TxParams15 =
-    TxParams15 Epoch TxParams7 TxRates
-    deriving (Show)
-
--- | 7-parameter transformation (Helmert) from given translation vector, scale factor and rotation matrix.
-txParams7 ::
-       (Double, Double, Double) -- ^ translation vector containing the three translations along the coordinate axes: tx, ty, tz in __millimetres__
-    -> Double -- ^ scale factor (unitless) expressed in __parts per billion__
-    -> (Double, Double, Double) -- ^ rotation matrix (orthogonal) consisting of the three axes rx, ry, rz in __milliarcseconds__
-    -> TxParams7
-txParams7 c s r = TxParams7 (mmToMetres c) (s / 1e9) (masToRadians r)
-
--- | rates of the 15-parameter translation (Helmert) from given translation rates, scale factor rate and rotation rates.
-txRates ::
-       (Double, Double, Double) -- ^ translation rate in __millimetres per year__.
-    -> Double -- ^ scale factor rate in __part per billion per year__.
-    -> (Double, Double, Double) -- ^ rotation rate in __milliarcseconds per year__.
-    -> TxRates
-txRates c s r = TxRates (mmToMetres c) (s / 1e9) (masToRadians r)
-
-mmToMetres :: (Double, Double, Double) -> Vector3d
-mmToMetres (cx, cy, cz) = vscale (Vector3d cx cy cz) (1.0 / 1000.0)
-
-masToRadians :: (Double, Double, Double) -> Vector3d
-masToRadians (rx, ry, rz) = vscale (Vector3d rx ry rz) (pi / (3600.0 * 1000.0 * 180.0))
-
--- | @txParamsAt e tx15@ returns the 7-parameter transformation corresponding to the
--- 15-parameter transformation @tx15@ at epoch @e@.
-txParamsAt :: Epoch -> TxParams15 -> TxParams7
-txParamsAt (Epoch e) (TxParams15 (Epoch pe) (TxParams7 c s r) (TxRates rc rs rr)) =
-    TxParams7 c' s' r'
-  where
-    de = e - pe
-    c' = vadd c (vscale rc de)
-    s' = s + de * rs
-    r' = vadd r (vscale rr de)
-
--- | node to adjacent nodes.
-data Connection =
-    Connection
-        { node :: !ModelId
-        , adjacents :: ![ModelId]
-        }
-
--- | graph edge: from model, tx params, to model.
-data Edge a =
-    Edge ModelId a ModelId
-
--- path of visited models.
-type Path = [ModelId]
-
--- queued, visited.
-data State =
-    State [ModelId] [Path]
-
--- | Transformation graph: vertices are 'ModelId' and edges are transformation parameters.
-data TxGraph a =
-    TxGraph ![Connection] ![Edge a]
-
--- | @txGraph ts@ returns a transformation graph containing all given direct and inverse
--- (i.e. for each 'Tx': 'txParams' & 'inverseTxParams') transformations.
-txGraph :: (TxParams a) => [Tx a] -> TxGraph a
-txGraph = foldl' addTx emptyGraph
-
--- | @txParamsBetween m0 m1 g@ computes the ordered list of transformation parameters to be
--- successively applied when transforming the coordinates of a position in model @m0@ to model @m1@.
--- The returned list is empty, if either model is not in the graph (i.e. not a vertex)  or if no
--- such transformation exists (i.e. model @m1@ cannot be reached from model @m0@).
-txParamsBetween :: (TxParams a) => ModelId -> ModelId -> TxGraph a -> [a]
-txParamsBetween m0 m1 g
-    | m0 == m1 = [idTxParams]
-    | null ms = []
-    | otherwise = findParams ms g
-  where
-    ms = dijkstra (State [m0] []) m1 g
-
--- | empty graph.
-emptyGraph :: TxGraph a
-emptyGraph = TxGraph [] []
-
--- | add 'Tx' to graph.
-addTx :: (TxParams a) => TxGraph a -> Tx a -> TxGraph a
-addTx (TxGraph cs es) t = TxGraph cs' es'
-  where
-    ma = modelA t
-    mb = modelB t
-    cs1 = addConnection cs ma mb
-    cs' = addConnection cs1 mb ma
-    txp = txParams t
-    es' = Edge ma txp mb : Edge mb (inverseTxParams txp) ma : es
-
--- | add connection to graph.
-addConnection :: [Connection] -> ModelId -> ModelId -> [Connection]
-addConnection cs m1 m2
-    | null filtered = Connection m1 [m2] : cs
-    | otherwise =
-        map
-            (\c' ->
-                 if node c' == m1
-                     then updated
-                     else c')
-            cs
-  where
-    filtered = filter (\c -> node c == m1) cs
-    cur = head filtered
-    updated = cur {adjacents = m2 : adjacents cur}
-
--- | successors of given model in given graph.
-successors :: ModelId -> TxGraph a -> [ModelId]
-successors m (TxGraph cs _) = concatMap adjacents (filter (\c -> node c == m) cs)
-
--- | visit every given model from given model.
-visit :: ModelId -> [ModelId] -> State -> State
-visit f ms (State q0 v0) = State q1 v1
-  where
-    toVisit = filter (`notElem` concat v0) ms -- filter models already visited
-    fs = filter (\v -> head v == f) v0 -- all paths starting at f
-    q1 = q0 ++ toVisit
-    updatedPaths = concatMap (\x -> map (: x) toVisit) fs
-    v1 = updatedPaths ++ filter (\v -> head v /= f) v0
-
-shortest :: ModelId -> ModelId -> [Path] -> [ModelId]
-shortest c m ps = reverse (m : s)
-  where
-    fs = filter (\v -> head v == c) ps -- all paths starting at c
-    s = head (sortOn length fs)
-
--- | dijkstra.
-dijkstra :: State -> ModelId -> TxGraph a -> [ModelId]
-dijkstra (State [] _) _ _ = []
-dijkstra (State [c] []) t g = dijkstra (State [c] [[c]]) t g
-dijkstra (State (c:r) v) t g
-    | t `elem` succs = shortest c t v
-    | otherwise = dijkstra s'' t g
-  where
-    s' = State r v
-    succs = successors c g
-    s'' = visit c succs s'
-
--- | find tx params between given models: [A, B, C] => params (A, B), params (B, C).
-findParams :: [ModelId] -> TxGraph a -> [a]
-findParams ms (TxGraph _ es)
-    | length ps == length r = r
-    | otherwise = []
-  where
-    ps = zip ms (tail ms)
-    r = mapMaybe (`findParam` es) ps
-
--- | find tx params between (A, B).
-findParam :: (ModelId, ModelId) -> [Edge a] -> Maybe a
-findParam p es = fmap (\(Edge _ pa _) -> pa) (find (edgeEq p) es)
-
--- | edge eq given pair?
-edgeEq :: (ModelId, ModelId) -> Edge a -> Bool
-edgeEq (m1, m2) (Edge m1' _ m2') = m1 == m1' && m2 == m2'
-
--- | @transformGeoc gc tx7@ returns the geocentric coordinates resulting from applying the 7-parameter
--- transformation @tx7@ to the geocentric coordinates represented by vector @gc@.
-transformGeoc :: Vector3d -> TxParams7 -> Vector3d
-transformGeoc gc (TxParams7 c s r) = vadd c (vscale (vmultm gc (rotation r)) (1.0 + s))
-
-rotation :: Vector3d -> [Vector3d]
-rotation (Vector3d x y z) = [Vector3d 1.0 (-z) y, Vector3d z 1.0 (-x), Vector3d (-y) x 1.0]
+-- |+-- Module:      Data.Geo.Jord.Tx+-- Copyright:   (c) 2020 Cedric Liegeois+-- License:     BSD3+-- Maintainer:  Cedric Liegeois <ofmooseandmen@yahoo.fr>+-- Stability:   experimental+-- Portability: portable+--+-- Coordinates transformation parameters.+module Data.Geo.Jord.Tx+    (+    -- * transformation parameters.+      Tx(..)+    , inverse+    , Params(..)+    , Params7+    , Rates+    , Params15(..)+    , params7+    , rates+    , paramsAt+    -- * transformation graph.+    , Graph+    , graph+    , paramsBetween+    -- * geocentric coordinate transformation+    , apply+    ) where++import Data.List (find, foldl', sortOn)+import Data.Maybe (mapMaybe)++import qualified Data.Geo.Jord.Math3d as Math3d+import Data.Geo.Jord.Model (Epoch(..), ModelId)++-- | Coordinate transformation between 2 models (A & B).+data Tx a =+    Tx+        { modelA :: ModelId -- ^  model A.+        , modelB :: ModelId -- ^ model B.+        , params :: a -- ^ transformation parameters - i.e. 'modelA'-> 'modelB'+        }++-- | inverse transformation.+inverse :: (Params a) => Tx a -> Tx a+inverse t = Tx (modelB t) (modelA t) (inverseParams (params t))++-- | class for transformation parameters.+class Params a where+    idParams :: a -- ^ identity transformation parameters, i.e. @p T idParams = p@.+    inverseParams :: a -> a -- ^ inverse transformation parameters.++-- | 7-parameter transformation (Helmert); use 'params7' to construct.+data Params7 =+    Params7 !Math3d.V3 !Double !Math3d.V3+    deriving (Show)++instance Params Params7 where+    idParams = Params7 Math3d.zero 0 Math3d.zero+    inverseParams (Params7 c s r) = Params7 (Math3d.scale c (-1.0)) (-s) (Math3d.scale r (-1.0))++instance Params Params15 where+    idParams = Params15 (Epoch 0) idParams (Rates Math3d.zero 0 Math3d.zero)+    inverseParams (Params15 e p (Rates c s r)) =+        Params15 e (inverseParams p) (Rates (Math3d.scale c (-1.0)) (-s) (Math3d.scale r (-1.0)))++-- | Transformation rates for the 15-parameter transformation (Helmert); use 'rates' to construct.+data Rates =+    Rates !Math3d.V3 !Double !Math3d.V3+    deriving (Show)++-- | Epoch and 14-parameter transformation (Helmert).+data Params15 =+    Params15 Epoch Params7 Rates+    deriving (Show)++-- | 7-parameter transformation (Helmert) from given translation vector, scale factor and rotation matrix.+params7 ::+       (Double, Double, Double) -- ^ translation vector containing the three translations along the coordinate axes: tx, ty, tz in __millimetres__+    -> Double -- ^ scale factor (unitless) expressed in __parts per billion__+    -> (Double, Double, Double) -- ^ rotation matrix (orthogonal) consisting of the three axes rx, ry, rz in __milliarcseconds__+    -> Params7+params7 c s r = Params7 (mmToMetres c) (s / 1e9) (masToRadians r)++-- | rates of the 15-parameter translation (Helmert) from given translation rates, scale factor rate and rotation rates.+rates ::+       (Double, Double, Double) -- ^ translation rate in __millimetres per year__.+    -> Double -- ^ scale factor rate in __part per billion per year__.+    -> (Double, Double, Double) -- ^ rotation rate in __milliarcseconds per year__.+    -> Rates+rates c s r = Rates (mmToMetres c) (s / 1e9) (masToRadians r)++mmToMetres :: (Double, Double, Double) -> Math3d.V3+mmToMetres (cx, cy, cz) = Math3d.scale (Math3d.vec3 cx cy cz) (1.0 / 1000.0)++masToRadians :: (Double, Double, Double) -> Math3d.V3+masToRadians (rx, ry, rz) = Math3d.scale (Math3d.vec3 rx ry rz) (pi / (3600.0 * 1000.0 * 180.0))++-- | @paramsAt e tx15@ returns the 7-parameter transformation corresponding to the+-- 15-parameter transformation @tx15@ at epoch @e@.+paramsAt :: Epoch -> Params15 -> Params7+paramsAt (Epoch e) (Params15 (Epoch pe) (Params7 c s r) (Rates rc rs rr)) = Params7 c' s' r'+  where+    de = e - pe+    c' = Math3d.add c (Math3d.scale rc de)+    s' = s + de * rs+    r' = Math3d.add r (Math3d.scale rr de)++-- | node to adjacent nodes.+data Connection =+    Connection+        { node :: !ModelId+        , adjacents :: ![ModelId]+        }++-- | graph edge: from model, tx params, to model.+data Edge a =+    Edge ModelId a ModelId++-- path of visited models.+type Path = [ModelId]++-- queued, visited.+data State =+    State [ModelId] [Path]++-- | Transformation graph: vertices are 'ModelId' and edges are transformation parameters.+data Graph a =+    Graph ![Connection] ![Edge a]++-- | @graph ts@ returns a transformation graph containing all given direct and inverse+-- (i.e. for each 'Tx': 'params' & 'inverseParams') transformations.+graph :: (Params a) => [Tx a] -> Graph a+graph = foldl' addTx emptyGraph++-- | @paramsBetween m0 m1 g@ computes the ordered list of transformation parameters to be+-- successively applied when transforming the coordinates of a position in model @m0@ to model @m1@.+-- The returned list is empty, if either model is not in the graph (i.e. not a vertex)  or if no+-- such transformation exists (i.e. model @m1@ cannot be reached from model @m0@).+paramsBetween :: (Params a) => ModelId -> ModelId -> Graph a -> [a]+paramsBetween m0 m1 g+    | m0 == m1 = [idParams]+    | null ms = []+    | otherwise = findParams ms g+  where+    ms = dijkstra (State [m0] []) m1 g++-- | empty graph.+emptyGraph :: Graph a+emptyGraph = Graph [] []++-- | add 'Tx' to graph.+addTx :: (Params a) => Graph a -> Tx a -> Graph a+addTx (Graph cs es) t = Graph cs' es'+  where+    ma = modelA t+    mb = modelB t+    cs1 = addConnection cs ma mb+    cs' = addConnection cs1 mb ma+    txp = params t+    es' = Edge ma txp mb : Edge mb (inverseParams txp) ma : es++-- | add connection to graph.+addConnection :: [Connection] -> ModelId -> ModelId -> [Connection]+addConnection cs m1 m2+    | null filtered = Connection m1 [m2] : cs+    | otherwise =+        map+            (\c' ->+                 if node c' == m1+                     then updated+                     else c')+            cs+  where+    filtered = filter (\c -> node c == m1) cs+    cur = head filtered+    updated = cur {adjacents = m2 : adjacents cur}++-- | successors of given model in given graph.+successors :: ModelId -> Graph a -> [ModelId]+successors m (Graph cs _) = concatMap adjacents (filter (\c -> node c == m) cs)++-- | visit every given model from given model.+visit :: ModelId -> [ModelId] -> State -> State+visit f ms (State q0 v0) = State q1 v1+  where+    toVisit = filter (`notElem` concat v0) ms -- filter models already visited+    fs = filter (\v -> head v == f) v0 -- all paths starting at f+    q1 = q0 ++ toVisit+    updatedPaths = concatMap (\x -> map (: x) toVisit) fs+    v1 = updatedPaths ++ filter (\v -> head v /= f) v0++shortest :: ModelId -> ModelId -> [Path] -> [ModelId]+shortest c m ps = reverse (m : s)+  where+    fs = filter (\v -> head v == c) ps -- all paths starting at c+    s = head (sortOn length fs)++-- | dijkstra.+dijkstra :: State -> ModelId -> Graph a -> [ModelId]+dijkstra (State [] _) _ _ = []+dijkstra (State [c] []) t g = dijkstra (State [c] [[c]]) t g+dijkstra (State (c:r) v) t g+    | t `elem` succs = shortest c t v+    | otherwise = dijkstra s'' t g+  where+    s' = State r v+    succs = successors c g+    s'' = visit c succs s'++-- | find tx params between given models: [A, B, C] => params (A, B), params (B, C).+findParams :: [ModelId] -> Graph a -> [a]+findParams ms (Graph _ es)+    | length ps == length r = r+    | otherwise = []+  where+    ps = zip ms (tail ms)+    r = mapMaybe (`findParam` es) ps++-- | find tx params between (A, B).+findParam :: (ModelId, ModelId) -> [Edge a] -> Maybe a+findParam p es = fmap (\(Edge _ pa _) -> pa) (find (edgeEq p) es)++-- | edge eq given pair?+edgeEq :: (ModelId, ModelId) -> Edge a -> Bool+edgeEq (m1, m2) (Edge m1' _ m2') = m1 == m1' && m2 == m2'++-- | @apply gc tx7@ returns the geocentric coordinates resulting from applying the 7-parameter+-- transformation @tx7@ to the geocentric coordinates represented by vector @gc@.+apply :: Math3d.V3 -> Params7 -> Math3d.V3+apply gc (Params7 c s r) = Math3d.add c (Math3d.scale (Math3d.multM gc (rotation r)) (1.0 + s))++rotation :: Math3d.V3 -> [Math3d.V3]+rotation v = [Math3d.vec3 1.0 (-z) y, Math3d.vec3 z 1.0 (-x), Math3d.vec3 (-y) x 1.0]+  where+    x = Math3d.v3x v+    y = Math3d.v3y v+    z = Math3d.v3z v
src/Data/Geo/Jord/Txs.hs view
@@ -19,136 +19,135 @@ --    * rotations in milliarcseconds, rates in milliarcseconds per year -- -- This module has been generated.--- module Data.Geo.Jord.Txs where  import Data.Geo.Jord.Model import Data.Geo.Jord.Tx  -- | WGS84 to NAD83 transformation parameters.-from_WGS84_to_NAD83 :: Tx TxParams7+from_WGS84_to_NAD83 :: Tx Params7 from_WGS84_to_NAD83 =     Tx (ModelId "WGS84")         (ModelId "NAD83")-        (txParams7 (995.6, -1910.3, -521.5) (-0.62) (25.915, 9.426, 11.599))+        (params7 (995.6, -1910.3, -521.5) (-0.62) (25.915, 9.426, 11.599))  -- | WGS84 to ED50 transformation parameters.-from_WGS84_to_ED50 :: Tx TxParams7+from_WGS84_to_ED50 :: Tx Params7 from_WGS84_to_ED50 =     Tx (ModelId "WGS84")         (ModelId "ED50")-        (txParams7 (89500.0, 93800.0, 123100.0) (-1200.0) (0.0, 0.0, 156.0))+        (params7 (89500.0, 93800.0, 123100.0) (-1200.0) (0.0, 0.0, 156.0))  -- | WGS84 to ETRS89 transformation parameters.-from_WGS84_to_ETRS89 :: Tx TxParams7+from_WGS84_to_ETRS89 :: Tx Params7 from_WGS84_to_ETRS89 =     Tx (ModelId "WGS84")         (ModelId "ETRS89")-        (txParams7 (0.0, 0.0, 0.0) 0.0 (0.0, 0.0, 0.0))+        (params7 (0.0, 0.0, 0.0) 0.0 (0.0, 0.0, 0.0))  -- | WGS84 to Irl1975 transformation parameters.-from_WGS84_to_Irl1975 :: Tx TxParams7+from_WGS84_to_Irl1975 :: Tx Params7 from_WGS84_to_Irl1975 =     Tx (ModelId "WGS84")         (ModelId "Irl1975")-        (txParams7 (-48253.0, 13059.6, -56455.7) (-815.0) (104.2, 214.0, 631.0))+        (params7 (-48253.0, 13059.6, -56455.7) (-815.0) (104.2, 214.0, 631.0))  -- |WGS84 to NAD27 transformation parameters.-from_WGS84_to_NAD27 :: Tx TxParams7+from_WGS84_to_NAD27 :: Tx Params7 from_WGS84_to_NAD27 =     Tx (ModelId "WGS84")         (ModelId "NAD27")-        (txParams7 (8000.0, -160000.0, -176000.0) 0.0 (0.0, 0.0, 0.0))+        (params7 (8000.0, -160000.0, -176000.0) 0.0 (0.0, 0.0, 0.0))  -- |WGS84 to NTF transformation parameters.-from_WGS84_to_NTF :: Tx TxParams7+from_WGS84_to_NTF :: Tx Params7 from_WGS84_to_NTF =     Tx (ModelId "WGS84")         (ModelId "NTF")-        (txParams7 (168000.0, 60000.0, -320000.0) 0.0 (0.0, 0.0, 0.0))+        (params7 (168000.0, 60000.0, -320000.0) 0.0 (0.0, 0.0, 0.0))  -- |WGS84 to OSGB36 transformation parameters.-from_WGS84_to_OSGB36 :: Tx TxParams7+from_WGS84_to_OSGB36 :: Tx Params7 from_WGS84_to_OSGB36 =     Tx (ModelId "WGS84")         (ModelId "OSGB36")-        (txParams7 (-44644.8, 12515.7, -54206.0) 20489.4 (-150.2, -247.0, -842.1))+        (params7 (-44644.8, 12515.7, -54206.0) 20489.4 (-150.2, -247.0, -842.1))  -- |WGS84 to Potsdam transformation parameters.-from_WGS84_to_Potsdam :: Tx TxParams7+from_WGS84_to_Potsdam :: Tx Params7 from_WGS84_to_Potsdam =     Tx (ModelId "WGS84")         (ModelId "Potsdam")-        (txParams7 (-582000.0, -105000.0, -414000.0) (-8300.0) (1040.0, 350.0, -3080.0))+        (params7 (-582000.0, -105000.0, -414000.0) (-8300.0) (1040.0, 350.0, -3080.0))  -- |WGS84 to TokyoJapan transformation parameters.-from_WGS84_to_TokyoJapan :: Tx TxParams7+from_WGS84_to_TokyoJapan :: Tx Params7 from_WGS84_to_TokyoJapan =     Tx (ModelId "WGS84")         (ModelId "TokyoJapan")-        (txParams7 (148000.0, -507000.0, -685000.0) 0.0 (0.0, 0.0, 0.0))+        (params7 (148000.0, -507000.0, -685000.0) 0.0 (0.0, 0.0, 0.0))  -- |WGS84 to WGS72 transformation parameters.-from_WGS84_to_WGS72 :: Tx TxParams7+from_WGS84_to_WGS72 :: Tx Params7 from_WGS84_to_WGS72 =     Tx (ModelId "WGS84")         (ModelId "WGS72")-        (txParams7 (0.0, 0.0, -4500.0) (-220.0) (0.0, 0.0, 554.0))+        (params7 (0.0, 0.0, -4500.0) (-220.0) (0.0, 0.0, 554.0))  -- | ITRF2014 to ITRF2008 transformation parameters.-from_ITRF2014_to_ITRF2008 :: Tx TxParams15+from_ITRF2014_to_ITRF2008 :: Tx Params15 from_ITRF2014_to_ITRF2008 =     Tx (ModelId "ITRF2014")         (ModelId "ITRF2008")-        (TxParams15+        (Params15              (Epoch 2010.0)-             (txParams7 (1.6, 1.9, 2.4) (-2.0e-2) (0.0, 0.0, 0.0))-             (txRates (0.0, 0.0, -0.1) 3.0e-2 (0.0, 0.0, 0.0)))+             (params7 (1.6, 1.9, 2.4) (-2.0e-2) (0.0, 0.0, 0.0))+             (rates (0.0, 0.0, -0.1) 3.0e-2 (0.0, 0.0, 0.0)))  -- | ITRF2014 to ITRF2005 transformation parameters.-from_ITRF2014_to_ITRF2005 :: Tx TxParams15+from_ITRF2014_to_ITRF2005 :: Tx Params15 from_ITRF2014_to_ITRF2005 =     Tx (ModelId "ITRF2014")         (ModelId "ITRF2005")-        (TxParams15+        (Params15              (Epoch 2010.0)-             (txParams7 (2.6, 1.0, -2.3) 0.92 (0.0, 0.0, 0.0))-             (txRates (0.3, 0.0, -0.1) 3.0e-2 (0.0, 0.0, 0.0)))+             (params7 (2.6, 1.0, -2.3) 0.92 (0.0, 0.0, 0.0))+             (rates (0.3, 0.0, -0.1) 3.0e-2 (0.0, 0.0, 0.0)))  -- | ITRF2014 to ITRF2000 transformation parameters.-from_ITRF2014_to_ITRF2000 :: Tx TxParams15+from_ITRF2014_to_ITRF2000 :: Tx Params15 from_ITRF2014_to_ITRF2000 =     Tx (ModelId "ITRF2014")         (ModelId "ITRF2000")-        (TxParams15+        (Params15              (Epoch 2010.0)-             (txParams7 (0.7, 1.2, -26.1) 2.12 (0.0, 0.0, 0.0))-             (txRates (0.1, 0.1, -1.9) 0.11 (0.0, 0.0, 0.0)))+             (params7 (0.7, 1.2, -26.1) 2.12 (0.0, 0.0, 0.0))+             (rates (0.1, 0.1, -1.9) 0.11 (0.0, 0.0, 0.0)))  -- | ITRF2014 to ETRF2000 transformation parameters.-from_ITRF2014_to_ETRF2000 :: Tx TxParams15+from_ITRF2014_to_ETRF2000 :: Tx Params15 from_ITRF2014_to_ETRF2000 =     Tx (ModelId "ITRF2014")         (ModelId "ETRF2000")-        (TxParams15+        (Params15              (Epoch 2000.0)-             (txParams7 (53.7, 51.2, -55.1) 1.02 (0.891, 5.39, -8.712))-             (txRates (0.1, 0.1, -1.9) 0.11 (8.1e-2, 0.49, -0.792)))+             (params7 (53.7, 51.2, -55.1) 1.02 (0.891, 5.39, -8.712))+             (rates (0.1, 0.1, -1.9) 0.11 (8.1e-2, 0.49, -0.792)))  -- | ITRF2000 to NAD83 (CORS96) transformation parameters.-from_ITRF2000_to_NAD83_CORS96 :: Tx TxParams15+from_ITRF2000_to_NAD83_CORS96 :: Tx Params15 from_ITRF2000_to_NAD83_CORS96 =     Tx (ModelId "ITRF2000")         (ModelId "NAD83_CORS96")-        (TxParams15+        (Params15              (Epoch 1997.0)-             (txParams7 (995.6, -1901.3, -521.5) 0.62 (25.915, 9.426, 11.599))-             (txRates (0.7, -0.7, 0.5) (-0.18) (6.7e-2, -0.757, -5.1e-2)))+             (params7 (995.6, -1901.3, -521.5) 0.62 (25.915, 9.426, 11.599))+             (rates (0.7, -0.7, 0.5) (-0.18) (6.7e-2, -0.757, -5.1e-2))) --- | Graph of all static transformations.-staticTxs :: TxGraph TxParams7-staticTxs =-    txGraph+-- | Graph of all transformations between fixed models.+fixed :: Graph Params7+fixed =+    graph         [ from_WGS84_to_NAD83         , from_WGS84_to_ED50         , from_WGS84_to_ETRS89@@ -161,10 +160,10 @@         , from_WGS84_to_WGS72         ] --- | Graph of all dynamic transformations.-dynamicTxs :: TxGraph TxParams15-dynamicTxs =-    txGraph+-- | Graph of all transformations between time-dependent models.+timeDependent :: Graph Params15+timeDependent =+    graph         [ from_ITRF2014_to_ITRF2008         , from_ITRF2014_to_ITRF2005         , from_ITRF2014_to_ITRF2000
− src/Data/Geo/Jord/Vector3d.hs
@@ -1,121 +0,0 @@--- |
--- Module:      Data.Geo.Jord.Vector3d
--- Copyright:   (c) 2020 Cedric Liegeois
--- License:     BSD3
--- Maintainer:  Cedric Liegeois <ofmooseandmen@yahoo.fr>
--- Stability:   experimental
--- Portability: portable
---
--- 3-element vector.
---
-module Data.Geo.Jord.Vector3d
-    ( Vector3d(..)
-    , vadd
-    , vsub
-    , vdot
-    , vnorm
-    , vcross
-    , vmultm
-    , vscale
-    , vunit
-    , vzero
-    , transpose
-    , mdot
-    ) where
-
--- | 3-element vector.
-data Vector3d =
-    Vector3d
-        { vx :: Double
-        , vy :: Double
-        , vz :: Double
-        }
-    deriving (Eq, Show)
-
--- | Adds 2 vectors.
-vadd :: Vector3d -> Vector3d -> Vector3d
-vadd v1 v2 = Vector3d x y z
-  where
-    x = vx v1 + vx v2
-    y = vy v1 + vy v2
-    z = vz v1 + vz v2
-
--- | Subtracts 2 vectors.
-vsub :: Vector3d -> Vector3d -> Vector3d
-vsub v1 v2 = Vector3d x y z
-  where
-    x = vx v1 - vx v2
-    y = vy v1 - vy v2
-    z = vz v1 - vz v2
-
--- | Computes the cross product of 2 vectors: the vector perpendicular to given vectors.
-vcross :: Vector3d -> Vector3d -> Vector3d
-vcross v1 v2 = Vector3d x y z
-  where
-    x = vy v1 * vz v2 - vz v1 * vy v2
-    y = vz v1 * vx v2 - vx v1 * vz v2
-    z = vx v1 * vy v2 - vy v1 * vx v2
-
--- | Computes the dot product of 2 vectors.
-vdot :: Vector3d -> Vector3d -> Double
-vdot v1 v2 = vx v1 * vx v2 + vy v1 * vy v2 + vz v1 * vz v2
-
--- | Computes the norm of a vector.
-vnorm :: Vector3d -> Double
-vnorm v = sqrt (x * x + y * y + z * z)
-  where
-    x = vx v
-    y = vy v
-    z = vz v
-
--- | @vmultm v rm@ multiplies vector @v@ by __3x3__ matrix @m@ (rows).
-vmultm :: Vector3d -> [Vector3d] -> Vector3d
-vmultm v rm
-    | length rm /= 3 = error ("Invalid matrix" ++ show rm)
-    | otherwise = Vector3d x y z
-  where
-    [x, y, z] = map (vdot v) rm
-
--- | @vscale v s@ multiplies each component of @v@ by @s@.
-vscale :: Vector3d -> Double -> Vector3d
-vscale v s = Vector3d x y z
-  where
-    x = vx v * s
-    y = vy v * s
-    z = vz v * s
-
--- | Normalises a vector. The 'vnorm' of the produced vector is @1@.
-vunit :: Vector3d -> Vector3d
-vunit v
-    | s == 1.0 = v
-    | otherwise = vscale v s
-  where
-    s = 1.0 / vnorm v
-
--- | vector of vnorm 0.
-vzero :: Vector3d
-vzero = Vector3d 0 0 0
-
--- | transpose __square (3x3)__ matrix of 'Vector3d'.
-transpose :: [Vector3d] -> [Vector3d]
-transpose m = fmap ds2v (transpose' xs)
-  where
-    xs = fmap v2ds m
-
--- | transpose matrix.
-transpose' :: [[Double]] -> [[Double]]
-transpose' ([]:_) = []
-transpose' x = map head x : transpose' (map tail x)
-
--- | multiplies 2 __3x3__ matrices.
-mdot :: [Vector3d] -> [Vector3d] -> [Vector3d]
-mdot a b = fmap ds2v [[vdot ar bc | bc <- transpose b] | ar <- a]
-
--- | 'Vector3d' to list of doubles.
-v2ds :: Vector3d -> [Double]
-v2ds (Vector3d x' y' z') = [x', y', z']
-
--- | list of doubles to 'Vector3d'.
-ds2v :: [Double] -> Vector3d
-ds2v [x', y', z'] = Vector3d x' y' z'
-ds2v xs = error ("Invalid list: " ++ show xs)
test/Data/Geo/Jord/AngleSpec.hs view
@@ -6,93 +6,116 @@ 
 import Test.Hspec
 
-import Data.Geo.Jord.Angle
-import Data.Geo.Jord.Length
-import Data.Geo.Jord.Quantity
+import qualified Data.Geo.Jord.Angle as Angle
+import qualified Data.Geo.Jord.Length as Length
 
 spec :: Spec
 spec = do
     describe "Reading valid angles" $ do
-        it "reads 55°36'21\"" $ readAngle "55°36'21\"" `shouldBe` Just (decimalDegrees 55.6058333333)
-        it "reads 55°36'21''" $ readAngle "55°36'21''" `shouldBe` Just (decimalDegrees 55.6058333333)
-        it "reads 55d36m21.0s" $ readAngle "55d36m21.0s" `shouldBe` Just (decimalDegrees 55.6058333333)
-        it "reads 55.6058333°" $ readAngle "55.6058333°" `shouldBe` Just (decimalDegrees 55.6058333)
-        it "reads 55.6058333333°" $ readAngle "55.6058333333°" `shouldBe` Just (decimalDegrees 55.6058333333)
+        it "reads 55°36'21\"" $
+            Angle.read "55°36'21\"" `shouldBe` Just (Angle.decimalDegrees 55.6058333333)
+        it "reads 55°36'21''" $
+            Angle.read "55°36'21''" `shouldBe` Just (Angle.decimalDegrees 55.6058333333)
+        it "reads 55d36m21.0s" $
+            Angle.read "55d36m21.0s" `shouldBe` Just (Angle.decimalDegrees 55.6058333333)
+        it "reads 55.6058333°" $
+            Angle.read "55.6058333°" `shouldBe` Just (Angle.decimalDegrees 55.6058333)
+        it "reads 55.6058333333°" $
+            Angle.read "55.6058333333°" `shouldBe` Just (Angle.decimalDegrees 55.6058333333)
         it "reads -55.6058333333°" $
-            readAngle "-55.6058333333°" `shouldBe` Just (decimalDegrees (-55.6058333333))
+            Angle.read "-55.6058333333°" `shouldBe` Just (Angle.decimalDegrees (-55.6058333333))
         it "reads 96°01′18″" $ do
             hSetEncoding stdin utf8
             hSetEncoding stdout utf8
             hSetEncoding stderr utf8
-            readAngle "96°01′18″" `shouldBe` Just (decimalDegrees 96.02166666666)
+            Angle.read "96°01′18″" `shouldBe` Just (Angle.decimalDegrees 96.02166666666)
     describe "Adding/Subtracting angles" $ do
         it "adds angles" $
-            add (decimalDegrees 55.6058333) (decimalDegrees 5.0) `shouldBe`
-            decimalDegrees 60.6058333
+            Angle.add (Angle.decimalDegrees 55.6058333) (Angle.decimalDegrees 5.0) `shouldBe`
+            Angle.decimalDegrees 60.6058333
         it "subtracts angles" $
-            sub (decimalDegrees 5.0) (decimalDegrees 55.6058333) `shouldBe`
-            decimalDegrees (-50.6058333)
+            Angle.subtract (Angle.decimalDegrees 5.0) (Angle.decimalDegrees 55.6058333) `shouldBe`
+            Angle.decimalDegrees (-50.6058333)
     describe "Angle normalisation" $ do
         it "370 degrees normalised to [0..360] = 10" $
-            normalise (decimalDegrees 370) (decimalDegrees 360) `shouldBe` decimalDegrees 10
+            Angle.normalise (Angle.decimalDegrees 370) (Angle.decimalDegrees 360) `shouldBe`
+            Angle.decimalDegrees 10
         it "350 degrees normalised to [0..360] = 350" $
-            normalise (decimalDegrees 350) (decimalDegrees 360) `shouldBe` decimalDegrees 350
+            Angle.normalise (Angle.decimalDegrees 350) (Angle.decimalDegrees 360) `shouldBe`
+            Angle.decimalDegrees 350
     describe "Angle equality" $ do
-        it "considers 59.9999999999° == 60.0°" $ decimalDegrees 59.9999999999 `shouldBe` decimalDegrees 60
+        it "considers 59.9999999999° == 60.0°" $
+            Angle.decimalDegrees 59.9999999999 `shouldBe` Angle.decimalDegrees 60
         it "considers 59.999999998° /= 60.0°" $
-            decimalDegrees 59.999999998 `shouldNotBe` decimalDegrees 60
+            Angle.decimalDegrees 59.999999998 `shouldNotBe` Angle.decimalDegrees 60
     describe "Showing angles" $ do
         it "shows 59.99999999999999 as 60°0'0.000\"" $
-            show (decimalDegrees 59.99999999999999) `shouldBe` "60°0'0.000\""
+            show (Angle.decimalDegrees 59.99999999999999) `shouldBe` "60°0'0.000\""
         it "shows 154.915 as 154°54'54.000\"" $
-            show (decimalDegrees 154.915) `shouldBe` "154°54'54.000\""
+            show (Angle.decimalDegrees 154.915) `shouldBe` "154°54'54.000\""
         it "shows -154.915 as -154°54'54.000\"" $
-            show (decimalDegrees (-154.915)) `shouldBe` "-154°54'54.000\""
-        it "show 0.5245 as 0°31'28.800\"" $ show (decimalDegrees 0.5245) `shouldBe` "0°31'28.200\""
+            show (Angle.decimalDegrees (-154.915)) `shouldBe` "-154°54'54.000\""
+        it "show 0.5245 as 0°31'28.800\"" $
+            show (Angle.decimalDegrees 0.5245) `shouldBe` "0°31'28.200\""
         it "show -0.5245 as -0°31'28.800\"" $
-            show (decimalDegrees (-0.5245)) `shouldBe` "-0°31'28.200\""
+            show (Angle.decimalDegrees (-0.5245)) `shouldBe` "-0°31'28.200\""
     describe "Angle from decimal degrees" $ do
         it "returns 1 arcmillisecond when called with 1 / 3600000" $ do
-            let actual = decimalDegrees (1 / 3600000)
-            getDegrees actual `shouldBe` 0
-            getArcminutes actual `shouldBe` 0
-            getArcseconds actual `shouldBe` 0
-            getArcmilliseconds actual `shouldBe` 1
+            let actual = Angle.decimalDegrees (1 / 3600000)
+            Angle.getDegrees actual `shouldBe` 0
+            Angle.getArcminutes actual `shouldBe` 0
+            Angle.getArcseconds actual `shouldBe` 0
+            Angle.getArcmilliseconds actual `shouldBe` 1
         it "returns 1 arcsecond when called with 1000 / 3600000" $ do
-            let actual = decimalDegrees (1000 / 3600000)
-            getDegrees actual `shouldBe` 0
-            getArcminutes actual `shouldBe` 0
-            getArcseconds actual `shouldBe` 1
-            getArcmilliseconds actual `shouldBe` 0
+            let actual = Angle.decimalDegrees (1000 / 3600000)
+            Angle.getDegrees actual `shouldBe` 0
+            Angle.getArcminutes actual `shouldBe` 0
+            Angle.getArcseconds actual `shouldBe` 1
+            Angle.getArcmilliseconds actual `shouldBe` 0
         it "returns 1 arcminute when called with 60000 / 3600000" $ do
-            let actual = decimalDegrees (60000 / 3600000)
-            getDegrees actual `shouldBe` 0
-            getArcminutes actual `shouldBe` 1
-            getArcseconds actual `shouldBe` 0
-            getArcmilliseconds actual `shouldBe` 0
+            let actual = Angle.decimalDegrees (60000 / 3600000)
+            Angle.getDegrees actual `shouldBe` 0
+            Angle.getArcminutes actual `shouldBe` 1
+            Angle.getArcseconds actual `shouldBe` 0
+            Angle.getArcmilliseconds actual `shouldBe` 0
         it "returns 1 degree when called with 1" $ do
-            let actual = decimalDegrees 1
-            getDegrees actual `shouldBe` 1
-            getArcminutes actual `shouldBe` 0
-            getArcseconds actual `shouldBe` 0
-            getArcmilliseconds actual `shouldBe` 0
+            let actual = Angle.decimalDegrees 1
+            Angle.getDegrees actual `shouldBe` 1
+            Angle.getArcminutes actual `shouldBe` 0
+            Angle.getArcseconds actual `shouldBe` 0
+            Angle.getArcmilliseconds actual `shouldBe` 0
         it "accepts positve values" $ do
-            let actual = decimalDegrees 154.9150300
-            getDegrees actual `shouldBe` 154
-            getArcminutes actual `shouldBe` 54
-            getArcseconds actual `shouldBe` 54
-            getArcmilliseconds actual `shouldBe` 108
+            let actual = Angle.decimalDegrees 154.9150300
+            Angle.getDegrees actual `shouldBe` 154
+            Angle.getArcminutes actual `shouldBe` 54
+            Angle.getArcseconds actual `shouldBe` 54
+            Angle.getArcmilliseconds actual `shouldBe` 108
         it "accepts negative values" $ do
-            let actual = decimalDegrees (-154.915)
-            getDegrees actual `shouldBe` (-154)
-            getArcminutes actual `shouldBe` 54
-            getArcseconds actual `shouldBe` 54
-            getArcmilliseconds actual `shouldBe` 0
+            let actual = Angle.decimalDegrees (-154.915)
+            Angle.getDegrees actual `shouldBe` (-154)
+            Angle.getArcminutes actual `shouldBe` 54
+            Angle.getArcseconds actual `shouldBe` 54
+            Angle.getArcmilliseconds actual `shouldBe` 0
     describe "Arc length" $ do
         it "computes the length of an arc with a central angle of 1 microarcsecond" $
-            arcLength (decimalDegrees (1.0 / 3600000000.0)) (kilometres 10000) `shouldBe` metres 4.8e-5
-        it
-            "computes arc length with central angle of 0.6 microarcsecond" $
-            arcLength (decimalDegrees (0.6 / 3600000000.0)) (kilometres 10000) `shouldBe` metres 4.8e-5
+            Angle.arcLength (Angle.decimalDegrees (1.0 / 3600000000.0)) (Length.kilometres 10000) `shouldBe`
+            Length.metres 4.8e-5
+        it "computes arc length with central angle of 0.6 microarcsecond" $
+            Angle.arcLength (Angle.decimalDegrees (0.6 / 3600000000.0)) (Length.kilometres 10000) `shouldBe`
+            Length.metres 4.8e-5
         it "computes arc length with central angle of 0.4 microarcsecond as 0" $
-            arcLength (decimalDegrees (0.4 / 3600000000.0)) (kilometres 1) `shouldBe` metres 0
+            Angle.arcLength (Angle.decimalDegrees (0.4 / 3600000000.0)) (Length.kilometres 1) `shouldBe`
+            Length.zero
+    describe "clockwiseDifference" $ do
+        it "returns 0 if both angles are equal" $ do
+            Angle.clockwiseDifference (Angle.decimalDegrees 154) (Angle.decimalDegrees 154) `shouldBe`
+                Angle.zero
+        it "return the difference between the 2 angles clockwise" $ do
+            Angle.clockwiseDifference Angle.zero (Angle.decimalDegrees 10) `shouldBe`
+                Angle.decimalDegrees 10
+            Angle.clockwiseDifference Angle.zero (Angle.decimalDegrees (-10)) `shouldBe`
+                Angle.decimalDegrees 350
+            Angle.clockwiseDifference (Angle.decimalDegrees 350) (Angle.decimalDegrees 10) `shouldBe`
+                Angle.decimalDegrees 20
+            Angle.clockwiseDifference (Angle.decimalDegrees 350) (Angle.decimalDegrees 370) `shouldBe`
+                Angle.decimalDegrees 20
test/Data/Geo/Jord/DurationSpec.hs view
@@ -1,28 +1,32 @@-module Data.Geo.Jord.DurationSpec
-    ( spec
-    ) where
-
-import Test.Hspec
-
-import Data.Geo.Jord.Duration
-import Data.Geo.Jord.Quantity
-
-spec :: Spec
-spec = do
-    describe "Reading valid durations" $ do
-        it "reads 1H45M36.5S" $ readDuration "1H45M36.5S" `shouldBe` Just (hms 1 45 36.5)
-        it "reads 45M" $ readDuration "45M" `shouldBe` Just (minutes 45)
-        it "reads 36S" $ readDuration "36S" `shouldBe` Just (seconds 36)
-        it "reads 36.6S" $ readDuration "36.6S" `shouldBe` Just (milliseconds 36600)
-        it "reads 1H-30M" $ readDuration "1H-30M" `shouldBe` Just (hours 0.5)
-        it "reads 0H8M5.953S" $ readDuration "0H8M5.953S" `shouldBe` Just (seconds 485.953)
-    describe "Reading invalid duration" $ it "fails to read 5" $ readDuration "5" `shouldBe` Nothing
-    describe "Showing duration" $
-        it "shows duration" $ show (hms 1 45 36.5) `shouldBe` "1H45M36.500S"
-    describe "Converting duration" $ do
-        it "converts hours to seconds" $ toSeconds (hours 1) `shouldBe` 3600.0
-        it "converts minutes to hours" $ toHours (minutes 30) `shouldBe` 0.5
-        it "converts duration to milliseconds" $ toMilliseconds (hms 1 54 3.154) `shouldBe` 6843154
-    describe "Adding/Subtracting duration" $ do
-        it "adds duration" $ add (minutes 45) (seconds 36) `shouldBe` hms 0 45 36
-        it "subtracts duration" $ sub (hours 1) (minutes 60) `shouldBe` zero
+module Data.Geo.Jord.DurationSpec+    ( spec+    ) where++import Test.Hspec++import qualified Data.Geo.Jord.Duration as Duration++spec :: Spec+spec = do+    describe "Reading valid durations" $ do+        it "reads 1H45M36.5S" $ Duration.read "1H45M36.5S" `shouldBe` Just (Duration.hms 1 45 36.5)+        it "reads 45M" $ Duration.read "45M" `shouldBe` Just (Duration.minutes 45)+        it "reads 36S" $ Duration.read "36S" `shouldBe` Just (Duration.seconds 36)+        it "reads 36.6S" $ Duration.read "36.6S" `shouldBe` Just (Duration.milliseconds 36600)+        it "reads 1H-30M" $ Duration.read "1H-30M" `shouldBe` Just (Duration.hours 0.5)+        it "reads 0H8M5.953S" $+            Duration.read "0H8M5.953S" `shouldBe` Just (Duration.seconds 485.953)+    describe "Reading invalid duration" $+        it "fails to read 5" $ Duration.read "5" `shouldBe` Nothing+    describe "Showing duration" $+        it "shows duration" $ show (Duration.hms 1 45 36.5) `shouldBe` "1H45M36.500S"+    describe "Converting duration" $ do+        it "converts hours to seconds" $ Duration.toSeconds (Duration.hours 1) `shouldBe` 3600.0+        it "converts minutes to hours" $ Duration.toHours (Duration.minutes 30) `shouldBe` 0.5+        it "converts duration to milliseconds" $+            Duration.toMilliseconds (Duration.hms 1 54 3.154) `shouldBe` 6843154+    describe "Adding/Subtracting duration" $ do+        it "adds duration" $+            Duration.add (Duration.minutes 45) (Duration.seconds 36) `shouldBe` Duration.hms 0 45 36+        it "subtracts duration" $+            Duration.subtract (Duration.hours 1) (Duration.minutes 60) `shouldBe` Duration.zero
+ test/Data/Geo/Jord/GeocentricSpec.hs view
@@ -0,0 +1,34 @@+module Data.Geo.Jord.GeocentricSpec+    ( spec+    ) where++import Test.Hspec++import qualified Data.Geo.Jord.Ellipsoid as Ellipsoid+import Data.Geo.Jord.Ellipsoids+import qualified Data.Geo.Jord.Geocentric as Geocentric+import qualified Data.Geo.Jord.Length as Length+import Data.Geo.Jord.Models++spec :: Spec+spec = do+    describe "antipode" $ do+        it "returns the antipodal position" $ do+            Geocentric.antipode+                (Geocentric.metresPos (-4069916.936) 1985031.122443 4497955.010584 WGS84) `shouldBe`+                Geocentric.metresPos 4069916.936 (-1985031.122443) (-4497955.010584) WGS84+        it "returns the south pole when called with the north pole" $ do+            Geocentric.antipode (Geocentric.northPole WGS84) `shouldBe` Geocentric.southPole WGS84+        it "returns the north pole when called with the south pole" $ do+            Geocentric.antipode (Geocentric.southPole WGS84) `shouldBe` Geocentric.northPole WGS84+    describe "poles" $ do+        it "returns (0, 0, polarRadius) for the north pole" $ do+            Geocentric.northPole WGS84 `shouldBe`+                Geocentric.metresPos 0 0 (Length.toMetres . Ellipsoid.polarRadius $ eWGS84) WGS84+        it "returns (0, 0, -polarRadius) for the south pole" $ do+            Geocentric.southPole WGS84 `shouldBe`+                Geocentric.metresPos+                    0+                    0+                    (negate . Length.toMetres . Ellipsoid.polarRadius $ eWGS84)+                    WGS84
test/Data/Geo/Jord/GeodesicSpec.hs view
@@ -4,98 +4,165 @@ 
 import Test.Hspec
 
-import Data.Geo.Jord.Geodesic
-import Data.Geo.Jord.Position
+import Data.Geo.Jord.Angle (Angle)
+import qualified Data.Geo.Jord.Angle as Angle
+import qualified Data.Geo.Jord.Geodesic as Geodesic
+import Data.Geo.Jord.Geodetic (HorizontalPosition)
+import qualified Data.Geo.Jord.Geodetic as Geodetic
+import Data.Geo.Jord.Length (Length)
+import qualified Data.Geo.Jord.Length as Length
+import Data.Geo.Jord.Model (Ellipsoidal)
+import Data.Geo.Jord.Models (WGS84(..))
 
--- | See Geodesy Test Harness - latlon-ellipsoidal-vincenty  by Chris Veness - TODO link
+-- | See Geodesy Test Harness - latlon-ellipsoidal-vincenty  by Chris Veness.
+-- https://github.com/chrisveness/geodesy/blob/master/test/latlon-ellipsoidal-vincenty-tests.js.
 spec :: Spec
 spec = do
     describe "Geodesic for (near) antipodal positions" $ do
         it "handles near-antipodal positions" $
-            surfaceDistance (latLongPos 0 0 WGS84) (latLongPos 0.5 179.5 WGS84) `shouldBe`
-            Just (kilometres 19936.288578981)
+            distance (Geodetic.latLongPos 0 0 WGS84) (Geodetic.latLongPos 0.5 179.5 WGS84) `shouldBe`
+            Just (Length.kilometres 19936.288578981)
         it "returns Nothing if vincenty fails to converge - inverseGeodesic" $
-            inverseGeodesic (latLongPos 0 0 WGS84) (latLongPos 0.5 179.7 WGS84) `shouldBe` Nothing
-        it "returns Nothing if vincenty fails to converge - surfaceDistance" $
-            surfaceDistance (latLongPos 0 0 WGS84) (latLongPos 0.5 179.7 WGS84) `shouldBe` Nothing
+            Geodesic.inverse (Geodetic.latLongPos 0 0 WGS84) (Geodetic.latLongPos 0.5 179.7 WGS84) `shouldBe`
+            Nothing
+        it "returns Nothing if vincenty fails to converge - distance" $
+            distance (Geodetic.latLongPos 0 0 WGS84) (Geodetic.latLongPos 0.5 179.7 WGS84) `shouldBe`
+            Nothing
         it "returns Nothing if vincenty fails to converge - initialBearing" $
-            initialBearing (latLongPos 0 0 WGS84) (latLongPos 0.5 179.7 WGS84) `shouldBe` Nothing
+            initialBearing (Geodetic.latLongPos 0 0 WGS84) (Geodetic.latLongPos 0.5 179.7 WGS84) `shouldBe`
+            Nothing
         it "returns Nothing if vincenty fails to converge - finalBearing" $
-            finalBearing (latLongPos 0 0 WGS84) (latLongPos 0.5 179.7 WGS84) `shouldBe` Nothing
-        it "handle antipodal positions - surfaceDistance at equator" $
-            surfaceDistance (latLongPos 0 0 WGS84) (latLongPos 0 180 WGS84) `shouldBe` Just (kilometres 20003.931458623)
+            finalBearing (Geodetic.latLongPos 0 0 WGS84) (Geodetic.latLongPos 0.5 179.7 WGS84) `shouldBe`
+            Nothing
+        it "handle antipodal positions - distance at equator" $
+            distance (Geodetic.latLongPos 0 0 WGS84) (Geodetic.latLongPos 0 180 WGS84) `shouldBe`
+            Just (Length.kilometres 20003.931458623)
         it "handle antipodal positions - initialBearing at equator" $
-            initialBearing (latLongPos 0 0 WGS84) (latLongPos 0 180 WGS84) `shouldBe` Just zero
-        it "handle antipodal positions - surfaceDistance between poles" $
-            surfaceDistance (northPole WGS84) (southPole WGS84) `shouldBe` Just (kilometres 20003.931458623)
+            initialBearing (Geodetic.latLongPos 0 0 WGS84) (Geodetic.latLongPos 0 180 WGS84) `shouldBe`
+            Just Angle.zero
+        it "handle antipodal positions - distance between poles" $
+            distance (Geodetic.northPole WGS84) (Geodetic.southPole WGS84) `shouldBe`
+            Just (Length.kilometres 20003.931458623)
         it "handle antipodal positions - initialBearing between poles" $
-            initialBearing (northPole WGS84) (southPole WGS84) `shouldBe` Just zero
+            initialBearing (Geodetic.northPole WGS84) (Geodetic.southPole WGS84) `shouldBe`
+            Just Angle.zero
     describe "Geodesic for coincident positions" $ do
-        let p = wgs84Pos 48 6 zero
+        let p = Geodetic.wgs84Pos 48 6
         it "returns a distance of 0 and no bearing" $ do
-            let i = inverseGeodesic p p
-            let ib = i >>= geodesicBearing1
-            let fb = i >>= geodesicBearing2
-            fmap geodesicLength i `shouldBe` Just zero
+            let i = Geodesic.inverse p p
+            let ib = i >>= Geodesic.initialBearing
+            let fb = i >>= Geodesic.initialBearing
+            fmap Geodesic.length i `shouldBe` Just Length.zero
             ib `shouldBe` Nothing
             fb `shouldBe` Nothing
-            surfaceDistance p p `shouldBe` Just zero
-            initialBearing p p `shouldBe` Nothing
-            finalBearing p p `shouldBe` Nothing
-        it "returns the given position when distance is 0" $ destination p (decimalDegrees 54) zero `shouldBe` Just p
+        it "returns the given position when distance is 0" $
+            destination p (Angle.decimalDegrees 54) Length.zero `shouldBe` Just p
     describe "Geodesic for selected positions" $ do
-        let flindersPeak = latLongPos (-37.95103341666667) 144.42486788888888 WGS84
-        let buninyong = latLongPos (-37.65282113888889) 143.92649552777777 WGS84
-        let le = latLongPos 50.06632 (-5.71475) WGS84
-        let jog = latLongPos 58.64402 (-3.07009) WGS84
+        let flindersPeak = Geodetic.latLongPos (-37.95103341666667) 144.42486788888888 WGS84
+        let buninyong = Geodetic.latLongPos (-37.65282113888889) 143.92649552777777 WGS84
+        let le = Geodetic.latLongPos 50.06632 (-5.71475) WGS84
+        let jog = Geodetic.latLongPos 58.64402 (-3.07009) WGS84
         it "computes the surface distance - inverse" $ do
-            surfaceDistance flindersPeak buninyong `shouldBe` Just (metres 54972.271139)
-            surfaceDistance le jog `shouldBe` Just (kilometres 969.954166314)
+            distance flindersPeak buninyong `shouldBe` Just (Length.metres 54972.271139)
+            distance le jog `shouldBe` Just (Length.kilometres 969.954166314)
         it "computes the initial bearing - inverse" $ do
-            initialBearing flindersPeak buninyong `shouldBe` Just (decimalDegrees 306.86815920333333)
-            initialBearing le jog `shouldBe` Just (decimalDegrees 9.14187748888889)
+            initialBearing flindersPeak buninyong `shouldBe`
+                Just (Angle.decimalDegrees 306.86815920333333)
+            initialBearing le jog `shouldBe` Just (Angle.decimalDegrees 9.14187748888889)
         it "computes the final bearing - inverse" $ do
-            finalBearing flindersPeak buninyong `shouldBe` Just (decimalDegrees 307.17363062944446)
-            finalBearing le jog `shouldBe` Just (decimalDegrees 11.297220414166667)
+            finalBearing flindersPeak buninyong `shouldBe`
+                Just (Angle.decimalDegrees 307.17363062944446)
+            finalBearing le jog `shouldBe` Just (Angle.decimalDegrees 11.297220414166667)
         it "compute the destination - direct" $ do
-            destination flindersPeak (decimalDegrees 306.86815920333333) (metres 54972.271139) `shouldBe` Just buninyong
-            destination le (decimalDegrees 9.14187748888889) (kilometres 969.954166314) `shouldBe` Just jog
+            destination
+                flindersPeak
+                (Angle.decimalDegrees 306.86815920333333)
+                (Length.metres 54972.271139) `shouldBe`
+                Just buninyong
+            destination le (Angle.decimalDegrees 9.14187748888889) (Length.kilometres 969.954166314) `shouldBe`
+                Just jog
         it "computes the final bearing - direct" $ do
             let fb1 =
-                    directGeodesic flindersPeak (decimalDegrees 306.86815920333333) (metres 54972.271139) >>=
-                    geodesicBearing2
-            fb1 `shouldBe` Just (decimalDegrees 307.17363062944446)
+                    Geodesic.direct
+                        flindersPeak
+                        (Angle.decimalDegrees 306.86815920333333)
+                        (Length.metres 54972.271139) >>=
+                    Geodesic.finalBearing
+            fb1 `shouldBe` Just (Angle.decimalDegrees 307.17363062944446)
             let fb2 =
-                    directGeodesic le (decimalDegrees 9.14187748888889) (kilometres 969.954166314) >>= geodesicBearing2
-            fb2 `shouldBe` Just (decimalDegrees 11.297220414166667)
+                    Geodesic.direct
+                        le
+                        (Angle.decimalDegrees 9.14187748888889)
+                        (Length.kilometres 969.954166314) >>=
+                    Geodesic.finalBearing
+            fb2 `shouldBe` Just (Angle.decimalDegrees 11.297220414166667)
     describe "Surface distance for anti-meridian positions" $
         it "handles positions crossing antimeridian" $
-        surfaceDistance (latLongPos 30 120 WGS84) (latLongPos 30 (-120) WGS84) `shouldBe`
-        Just (kilometres 10825.924088908)
+        distance (Geodetic.latLongPos 30 120 WGS84) (Geodetic.latLongPos 30 (-120) WGS84) `shouldBe`
+        Just (Length.kilometres 10825.924088908)
     describe "Geodesic for quadrants" $
         it "returns the same surface distance in all quadrants" $ do
             let actuals =
-                    [ surfaceDistance (latLongPos 30 30 WGS84) (latLongPos 60 60 WGS84)
-                    , surfaceDistance (latLongPos 60 60 WGS84) (latLongPos 30 30 WGS84)
-                    , surfaceDistance (latLongPos 30 60 WGS84) (latLongPos 60 30 WGS84)
-                    , surfaceDistance (latLongPos 60 30 WGS84) (latLongPos 30 60 WGS84)
-                    , surfaceDistance (latLongPos 30 (-30) WGS84) (latLongPos 60 (-60) WGS84)
-                    , surfaceDistance (latLongPos 60 (-60) WGS84) (latLongPos 30 (-30) WGS84)
-                    , surfaceDistance (latLongPos 30 (-60) WGS84) (latLongPos 60 (-30) WGS84)
-                    , surfaceDistance (latLongPos 60 (-30) WGS84) (latLongPos 30 (-60) WGS84)
-                    , surfaceDistance (latLongPos (-30) (-30) WGS84) (latLongPos (-60) (-60) WGS84)
-                    , surfaceDistance (latLongPos (-60) (-60) WGS84) (latLongPos (-30) (-30) WGS84)
-                    , surfaceDistance (latLongPos (-30) (-60) WGS84) (latLongPos (-60) (-30) WGS84)
-                    , surfaceDistance (latLongPos (-60) (-30) WGS84) (latLongPos (-30) (-60) WGS84)
-                    , surfaceDistance (latLongPos (-30) 30 WGS84) (latLongPos (-60) 60 WGS84)
-                    , surfaceDistance (latLongPos (-60) 60 WGS84) (latLongPos (-30) 30 WGS84)
-                    , surfaceDistance (latLongPos (-30) 60 WGS84) (latLongPos (-60) 30 WGS84)
-                    , surfaceDistance (latLongPos (-60) 30 WGS84) (latLongPos (-30) 60 WGS84)
+                    [ distance (Geodetic.latLongPos 30 30 WGS84) (Geodetic.latLongPos 60 60 WGS84)
+                    , distance (Geodetic.latLongPos 60 60 WGS84) (Geodetic.latLongPos 30 30 WGS84)
+                    , distance (Geodetic.latLongPos 30 60 WGS84) (Geodetic.latLongPos 60 30 WGS84)
+                    , distance (Geodetic.latLongPos 60 30 WGS84) (Geodetic.latLongPos 30 60 WGS84)
+                    , distance
+                          (Geodetic.latLongPos 30 (-30) WGS84)
+                          (Geodetic.latLongPos 60 (-60) WGS84)
+                    , distance
+                          (Geodetic.latLongPos 60 (-60) WGS84)
+                          (Geodetic.latLongPos 30 (-30) WGS84)
+                    , distance
+                          (Geodetic.latLongPos 30 (-60) WGS84)
+                          (Geodetic.latLongPos 60 (-30) WGS84)
+                    , distance
+                          (Geodetic.latLongPos 60 (-30) WGS84)
+                          (Geodetic.latLongPos 30 (-60) WGS84)
+                    , distance
+                          (Geodetic.latLongPos (-30) (-30) WGS84)
+                          (Geodetic.latLongPos (-60) (-60) WGS84)
+                    , distance
+                          (Geodetic.latLongPos (-60) (-60) WGS84)
+                          (Geodetic.latLongPos (-30) (-30) WGS84)
+                    , distance
+                          (Geodetic.latLongPos (-30) (-60) WGS84)
+                          (Geodetic.latLongPos (-60) (-30) WGS84)
+                    , distance
+                          (Geodetic.latLongPos (-60) (-30) WGS84)
+                          (Geodetic.latLongPos (-30) (-60) WGS84)
+                    , distance
+                          (Geodetic.latLongPos (-30) 30 WGS84)
+                          (Geodetic.latLongPos (-60) 60 WGS84)
+                    , distance
+                          (Geodetic.latLongPos (-60) 60 WGS84)
+                          (Geodetic.latLongPos (-30) 30 WGS84)
+                    , distance
+                          (Geodetic.latLongPos (-30) 60 WGS84)
+                          (Geodetic.latLongPos (-60) 30 WGS84)
+                    , distance
+                          (Geodetic.latLongPos (-60) 30 WGS84)
+                          (Geodetic.latLongPos (-30) 60 WGS84)
                     ]
-            let expecteds = replicate (length actuals) (Just (kilometres 4015.703020938))
+            let expecteds = replicate (length actuals) (Just (Length.kilometres 4015.703020938))
             actuals `shouldBe` expecteds
     describe "Inverse geodesic non-convergence" $ do
         it "returns Nothing for antipodal λ > π" $
-            surfaceDistance (latLongPos 0 0 WGS84) (latLongPos 0.5 179.7 WGS84) `shouldBe` Nothing
+            distance (Geodetic.latLongPos 0 0 WGS84) (Geodetic.latLongPos 0.5 179.7 WGS84) `shouldBe`
+            Nothing
         it "returns Nothing for antipodal convergence" $
-            surfaceDistance (latLongPos 5 0 WGS84) (latLongPos (-5.1) 179.4 WGS84) `shouldBe` Nothing+            distance (Geodetic.latLongPos 5 0 WGS84) (Geodetic.latLongPos (-5.1) 179.4 WGS84) `shouldBe`
+            Nothing
+
+finalBearing :: (Ellipsoidal a) => HorizontalPosition a -> HorizontalPosition a -> Maybe Angle
+finalBearing p1 p2 = Geodesic.inverse p1 p2 >>= Geodesic.finalBearing
+
+initialBearing :: (Ellipsoidal a) => HorizontalPosition a -> HorizontalPosition a -> Maybe Angle
+initialBearing p1 p2 = Geodesic.inverse p1 p2 >>= Geodesic.initialBearing
+
+distance :: (Ellipsoidal a) => HorizontalPosition a -> HorizontalPosition a -> Maybe Length
+distance p1 p2 = fmap Geodesic.length (Geodesic.inverse p1 p2)
+
+destination ::
+       (Ellipsoidal a) => HorizontalPosition a -> Angle -> Length -> Maybe (HorizontalPosition a)
+destination p b d = fmap Geodesic.endPosition (Geodesic.direct p b d)
+ test/Data/Geo/Jord/GeodeticSpec.hs view
@@ -0,0 +1,160 @@+module Data.Geo.Jord.GeodeticSpec
+    ( spec
+    ) where
+
+import Data.Maybe (mapMaybe)
+
+import Test.Hspec
+
+import qualified Data.Geo.Jord.Angle as Angle
+import qualified Data.Geo.Jord.Geodetic as Geodetic
+import qualified Data.Geo.Jord.Length as Length
+import Data.Geo.Jord.Models (GRS80(..), Mars2000(..), S84(..), WGS84(..))
+
+spec :: Spec
+spec = do
+    describe "antipode" $ do
+        it "returns the antipodal position" $ do
+            Geodetic.antipode (Geodetic.wgs84Pos 45 154) `shouldBe` Geodetic.wgs84Pos (-45) (-26)
+            Geodetic.antipode (Geodetic.s84Pos 45 154) `shouldBe` Geodetic.s84Pos (-45) (-26)
+            Geodetic.antipode' (Geodetic.latLongHeightPos 45 154 (Length.metres 15000) WGS84) `shouldBe`
+                Geodetic.latLongHeightPos (-45) (-26) (Length.metres 15000) WGS84
+            Geodetic.antipode' (Geodetic.latLongHeightPos 45 154 (Length.metres 15000) S84) `shouldBe`
+                Geodetic.latLongHeightPos (-45) (-26) (Length.metres 15000) S84
+        it "returns the south pole when called with the north pole" $ do
+            Geodetic.antipode (Geodetic.northPole WGS84) `shouldBe` Geodetic.southPole WGS84
+        it "returns the north pole when called with the south pole" $ do
+            Geodetic.antipode (Geodetic.southPole WGS84) `shouldBe` Geodetic.northPole WGS84
+    describe "poles" $ do
+        it "returns 90°, 0° for the north pole" $ do
+            Geodetic.latitude (Geodetic.northPole WGS84) `shouldBe` Angle.decimalDegrees 90
+            Geodetic.longitude (Geodetic.northPole WGS84) `shouldBe` Angle.zero
+        it "returns -90°, 0° for the south pole" $ do
+            Geodetic.latitude (Geodetic.southPole WGS84) `shouldBe` Angle.decimalDegrees (-90)
+            Geodetic.longitude (Geodetic.southPole WGS84) `shouldBe` Angle.zero
+        it "always returns a longitude of 0° at the north pole" $ do
+            let longs = (take 37 (iterate (\x -> x + 10 :: Double) (-180.0)))
+            fmap (\long -> Geodetic.wgs84Pos 90 long) longs `shouldBe`
+                (replicate 37 (Geodetic.northPole WGS84))
+        it "always returns a longitude of 0° at the south pole" $ do
+            let longs = (take 37 (iterate (\x -> x + 10 :: Double) (-180.0)))
+            fmap (\long -> Geodetic.wgs84Pos (-90) long) longs `shouldBe`
+                (replicate 37 (Geodetic.southPole WGS84))
+    describe "wrapping latitude/longitude" $ do
+        it "wraps a Earth position to [-90°, 90°] and [-180°, 180°]" $ do
+            let p1 = Geodetic.s84Pos 91 54
+            Geodetic.latitude p1 `shouldBe` Angle.decimalDegrees 89
+            Geodetic.longitude p1 `shouldBe` Angle.decimalDegrees (-126)
+            let p2 = Geodetic.s84Pos 91 (-150)
+            Geodetic.latitude p2 `shouldBe` Angle.decimalDegrees 89
+            Geodetic.longitude p2 `shouldBe` Angle.decimalDegrees 30
+        it "wraps a Mars position longitude to [0°, 360°]" $ do
+            let p = Geodetic.latLongPos 89 (-150) Mars2000
+            Geodetic.latitude p `shouldBe` Angle.decimalDegrees 89
+            Geodetic.longitude p `shouldBe` Angle.decimalDegrees 210
+        it "wraps a Mars position to [-90°, 90°] and [0°, 360°]" $ do
+            let p = Geodetic.latLongPos 91 (-150) Mars2000
+            Geodetic.latitude p `shouldBe` Angle.decimalDegrees 89
+            Geodetic.longitude p `shouldBe` Angle.decimalDegrees 30
+    describe "wrapping n-vector" $ do
+        it "wraps a n-vector Mars position to [-90°, 90°] and [0°, 360°]" $ do
+            let p = Geodetic.nvectorPos (-0.8660254037844387) (-0.49999999999999994) 0 Mars2000
+            Geodetic.latitude p `shouldBe` Angle.zero
+            Geodetic.longitude p `shouldBe` Angle.decimalDegrees 210
+    describe "Reading valid DMS text" $ do
+        it "reads WGS84 horizontal positions" $ do
+            let texts =
+                    [ "553621N0130002E"
+                    , "5536N01300E"
+                    , "55N013E"
+                    , "011659S0364900E"
+                    , "0116S03649E"
+                    , "01S036E"
+                    , "473622N1221955W"
+                    , "4736N12219W"
+                    , "47N122W"
+                    , "544807S0681811W"
+                    , "5448S06818W"
+                    , "54S068W"
+                    , "55°36'21''N 013°00'02''E"
+                    , "1°16'S,36°49'E"
+                    , "47°N 122°W"
+                    ]
+            let positions =
+                    [ Geodetic.wgs84Pos 55.60583333333334 13.000555555555556
+                    , Geodetic.wgs84Pos 55.6 13.0
+                    , Geodetic.wgs84Pos 55.0 13.0
+                    , Geodetic.wgs84Pos (-1.2830555555555556) 36.81666666666667
+                    , Geodetic.wgs84Pos (-1.2666666666666666) 36.81666666666667
+                    , Geodetic.wgs84Pos (-1.0) 36.0
+                    , Geodetic.wgs84Pos 47.60611111111111 (-122.33194444444445)
+                    , Geodetic.wgs84Pos 47.6 (-122.31666666666666)
+                    , Geodetic.wgs84Pos 47.0 (-122.0)
+                    , Geodetic.wgs84Pos (-54.801944444444445) (-68.30305555555556)
+                    , Geodetic.wgs84Pos (-54.8) (-68.3)
+                    , Geodetic.wgs84Pos (-54.0) (-68.0)
+                    , Geodetic.wgs84Pos 55.60583333333334 13.000555555555556
+                    , Geodetic.wgs84Pos (-1.2666666666666666) 36.81666666666667
+                    , Geodetic.wgs84Pos 47.0 (-122.0)
+                    ]
+            mapMaybe (`Geodetic.readHorizontalPosition` WGS84) texts `shouldBe` positions
+        it "reads positions around the WGS84 ellipsoid" $ do
+            let texts = ["55°36'21''N 013°00'02''E 5m", "55°36'21''N 013°00'02''E -5m"]
+            let positions =
+                    [ Geodetic.latLongHeightPos
+                          55.60583333333334
+                          13.000555555555556
+                          (Length.metres 5)
+                          WGS84
+                    , Geodetic.latLongHeightPos
+                          55.60583333333334
+                          13.000555555555556
+                          (Length.metres (-5))
+                          WGS84
+                    ]
+            mapMaybe (`Geodetic.readPosition` WGS84) texts `shouldBe` positions
+        it "reads positions around the S84 sphere" $ do
+            let texts = ["55°36'21''N 013°00'02''E 5m", "55°36'21''N 013°00'02''E -5m"]
+            let positions =
+                    [ Geodetic.latLongHeightPos
+                          55.60583333333334
+                          13.000555555555556
+                          (Length.metres 5)
+                          S84
+                    , Geodetic.latLongHeightPos
+                          55.60583333333334
+                          13.000555555555556
+                          (Length.metres (-5))
+                          S84
+                    ]
+            mapMaybe (`Geodetic.readPosition` S84) texts `shouldBe` positions
+        it "reads Mars horizontal positions" $ do
+            let texts = ["54S360E", "55°36'21''N 341°34'02''E"]
+            let positions =
+                    [ Geodetic.latLongPos (-54.0) 360 Mars2000
+                    , Geodetic.latLongPos 55.60583333333334 341.5672222222222 Mars2000
+                    ]
+            mapMaybe (`Geodetic.readHorizontalPosition` Mars2000) texts `shouldBe` positions
+    describe "Attempting to read invalid DMS text" $ do
+        it "fails to read syntactically invalid positions" $ do
+            let texts = ["553621K0130002E", "011659S0364900Z", "4736221221955W", "54480S0681811W"]
+            mapMaybe (`Geodetic.readHorizontalPosition` WGS84) texts `shouldBe` []
+        it "fails to read invalid WGS84 surface positions" $ do
+            let texts = ["914807S0681811W", "804807S1811811W"]
+            mapMaybe (`Geodetic.readHorizontalPosition` WGS84) texts `shouldBe` []
+        it "fails to read invalid Mars surface positions" $ do
+            let texts = ["914807S0681811E", "5448S06818W"]
+            mapMaybe (`Geodetic.readHorizontalPosition` Mars2000) texts `shouldBe` []
+    describe "Showing positions" $ do
+        it "shows the N/E position formatted in DMS with symbols" $
+            show (Geodetic.latLongHeightPos 55.6058333333 13.00055556 (Length.metres 5) WGS84) `shouldBe`
+            "55°36'21.000\"N,13°0'2.000\"E 5.0m (WGS84)"
+        it "shows the S/E position formatted in DMS with symbols" $
+            show (Geodetic.latLongPos (-1.28305556) 36.81666 GRS80) `shouldBe`
+            "1°16'59.000\"S,36°48'59.976\"E (GRS80)"
+        it "shows the N/W position formatted in DMS with symbols" $
+            show (Geodetic.latLongPos 47.60611 (-122.33194) S84) `shouldBe`
+            "47°36'21.996\"N,122°19'54.984\"W (S84)"
+        it "shows the S/W position formatted in DMS with symbols" $
+            show (Geodetic.latLongPos (-54.80194) (-68.30305) S84) `shouldBe`
+            "54°48'6.984\"S,68°18'10.980\"W (S84)"
test/Data/Geo/Jord/GreatCircleSpec.hs view
@@ -8,251 +8,454 @@ 
 import Test.Hspec
 
-import Data.Geo.Jord.GreatCircle
-import Data.Geo.Jord.Position
+import qualified Data.Geo.Jord.Angle as Angle
+import Data.Geo.Jord.Geodetic as Geodetic (HorizontalPosition)
+import qualified Data.Geo.Jord.Geodetic as Geodetic
+import qualified Data.Geo.Jord.GreatCircle as GreatCircle
+import qualified Data.Geo.Jord.Length as Length
+import qualified Data.Geo.Jord.Math3d as Math3d (cross)
+import Data.Geo.Jord.Models (S84(..))
+import Data.Geo.Jord.Places
 
 spec :: Spec
 spec = do
     describe "alongTrackDistance" $ do
         it "returns a positive length when position is ahead start of great arc" $ do
-            let p = s84Pos 53.2611 (-0.7972) zero
-            let g = minorArc (s84Pos 53.3206 (-1.7297) zero) (s84Pos 53.1887 0.1334 zero)
-            fmap (alongTrackDistance p) g `shouldBe` Just (kilometres 62.3315791)
+            let p = Geodetic.s84Pos 53.2611 (-0.7972)
+            let g =
+                    GreatCircle.minorArc
+                        (Geodetic.s84Pos 53.3206 (-1.7297))
+                        (Geodetic.s84Pos 53.1887 0.1334)
+            fmap (GreatCircle.alongTrackDistance p) g `shouldBe` Just (Length.kilometres 62.3315791)
         it "returns a negative length when position is ahead start of great arc" $ do
-            let p = s84Pos 53.3206 (-1.7297) zero
-            let g = minorArc (s84Pos 53.2611 (-0.7972) zero) (s84Pos 53.1887 0.1334 zero)
-            fmap (alongTrackDistance p) g `shouldBe` Just (kilometres (-62.329309979))
+            let p = Geodetic.s84Pos 53.3206 (-1.7297)
+            let g =
+                    GreatCircle.minorArc
+                        (Geodetic.s84Pos 53.2611 (-0.7972))
+                        (Geodetic.s84Pos 53.1887 0.1334)
+            fmap (GreatCircle.alongTrackDistance p) g `shouldBe`
+                Just (Length.kilometres (-62.329309979))
         it "returns 0 when position is start of great arc" $ do
-            let p = s84Pos 53.2611 (-0.7972) zero
-            let g = minorArc p (s84Pos 53.1887 0.1334 zero)
-            fmap (alongTrackDistance p) g `shouldBe` Just zero
+            let p = Geodetic.s84Pos 53.2611 (-0.7972)
+            let g = GreatCircle.minorArc p (Geodetic.s84Pos 53.1887 0.1334)
+            fmap (GreatCircle.alongTrackDistance p) g `shouldBe` Just Length.zero
     describe "crossTrackDistance" $ do
         it "returns a negative length when position is left of great circle (bearing)" $ do
-            let p = s84Pos 53.2611 (-0.7972) zero
-            let gc = greatCircleHeadingOn (s84Pos 53.3206 (-1.7297) zero) (decimalDegrees 96.0)
-            crossTrackDistance p gc `shouldBe` metres (-305.665267)
+            let p = Geodetic.s84Pos 53.2611 (-0.7972)
+            let gc =
+                    GreatCircle.headingOn
+                        (Geodetic.s84Pos 53.3206 (-1.7297))
+                        (Angle.decimalDegrees 96.0)
+            GreatCircle.crossTrackDistance p gc `shouldBe` Length.metres (-305.665267)
         it "returns a negative length when position is left of great circle" $ do
-            let p = s84Pos 53.2611 (-0.7972) zero
-            let gc = greatCircleThrough (s84Pos 53.3206 (-1.7297) zero) (s84Pos 53.1887 0.1334 zero)
-            fmap (crossTrackDistance p) gc `shouldBe` Just (metres (-307.549992))
+            let p = Geodetic.s84Pos 53.2611 (-0.7972)
+            let gc =
+                    GreatCircle.through
+                        (Geodetic.s84Pos 53.3206 (-1.7297))
+                        (Geodetic.s84Pos 53.1887 0.1334)
+            fmap (GreatCircle.crossTrackDistance p) gc `shouldBe` Just (Length.metres (-307.549992))
         it "returns a positve length when position is right of great circle (bearing)" $ do
-            let p = s84Pos 53.261111 (-1.797222) zero
-            let gc = greatCircleHeadingOn (s84Pos 53.320556 (-1.729722) zero) (decimalDegrees 96.02166667)
-            crossTrackDistance p gc `shouldBe` metres 7042.396068
+            let p = Geodetic.s84Pos 53.261111 (-1.797222)
+            let gc =
+                    GreatCircle.headingOn
+                        (Geodetic.s84Pos 53.320556 (-1.729722))
+                        (Angle.decimalDegrees 96.02166667)
+            GreatCircle.crossTrackDistance p gc `shouldBe` Length.metres 7042.396068
         it "returns a positive length when position is left of great circle" $ do
-            let p = antipode (s84Pos 53.2611 (-0.7972) zero)
-            let gc = greatCircleThrough (s84Pos 53.3206 (-1.7297) zero) (s84Pos 53.1887 0.1334 zero)
-            fmap (crossTrackDistance p) gc `shouldBe` Just (metres 307.549992)
+            let p = Geodetic.antipode (Geodetic.s84Pos 53.2611 (-0.7972))
+            let gc =
+                    GreatCircle.through
+                        (Geodetic.s84Pos 53.3206 (-1.7297))
+                        (Geodetic.s84Pos 53.1887 0.1334)
+            fmap (GreatCircle.crossTrackDistance p) gc `shouldBe` Just (Length.metres 307.549992)
+        it "return zero when position is on the great circle" $ do
+            let gc1 = Geodetic.s84Pos 53.3206 (-1.7297)
+            let gc2 = Geodetic.s84Pos 53.1887 (0.1334)
+            let gc = fromJust $ GreatCircle.through gc1 gc2
+            let ps =
+                    fmap
+                        (\f -> GreatCircle.interpolated gc1 gc2 f)
+                        (take 11 (iterate (\x -> x + 0.1 :: Double) 0.0))
+            fmap (\p -> GreatCircle.crossTrackDistance p gc) ps `shouldBe`
+                (replicate 11 Length.zero)
     describe "destination" $ do
         it "return the given position if distance is 0 meter" $ do
-            let p0 = s84Pos 53.320556 (-1.729722) zero
-            destination p0 (decimalDegrees 96.0217) zero `shouldBe` p0
+            let p0 = Geodetic.s84Pos 53.320556 (-1.729722)
+            GreatCircle.destination p0 (Angle.decimalDegrees 96.0217) Length.zero `shouldBe` p0
         it "return the position along the great circle at distance and bearing" $ do
-            let p0 = s84Pos 53.320556 (-1.729722) (metres 15000.0)
-            let p1 = s84Pos 53.18826954833333 0.13327449055555557 (metres 15000.0)
-            destination p0 (decimalDegrees 96.0217) (metres 124800) `shouldBe` p1
-    describe "surfaceDistance" $ do
+            let p0 = Geodetic.s84Pos 53.320556 (-1.729722)
+            let p1 = Geodetic.s84Pos 53.18826954833333 0.13327449055555557
+            GreatCircle.destination p0 (Angle.decimalDegrees 96.0217) (Length.metres 124800) `shouldBe`
+                p1
+    describe "distance" $ do
         it "returns 0 if both points are equal" $ do
-            let p = s84Pos 50.066389 (-5.714722) (metres 15000.0)
-            surfaceDistance p p `shouldBe` zero
+            let p = Geodetic.s84Pos 50.066389 (-5.714722)
+            GreatCircle.distance p p `shouldBe` Length.zero
         it "returns the distance between 2 points" $ do
-            let p1 = s84Pos 50.066389 (-5.714722) zero
-            let p2 = s84Pos 58.643889 (-3.07) zero
-            surfaceDistance p1 p2 `shouldBe` metres 968854.878007
+            let p1 = Geodetic.s84Pos 50.066389 (-5.714722)
+            let p2 = Geodetic.s84Pos 58.643889 (-3.07)
+            GreatCircle.distance p1 p2 `shouldBe` Length.metres 968854.878007
         it "handles singularity at the pole" $
-            surfaceDistance (northPole S84) (southPole S84) `shouldBe` kilometres 20015.114352233
+            GreatCircle.distance (Geodetic.northPole S84) (Geodetic.southPole S84) `shouldBe`
+            Length.kilometres 20015.114352233
         it "handles the discontinuity at the Date Line" $ do
-            let p1 = s84Pos 50.066389 (-179.999722) zero
-            let p2 = s84Pos 50.066389 179.999722 zero
-            surfaceDistance p1 p2 `shouldBe` metres 39.685092
+            let p1 = Geodetic.s84Pos 50.066389 (-179.999722)
+            let p2 = Geodetic.s84Pos 50.066389 179.999722
+            GreatCircle.distance p1 p2 `shouldBe` Length.metres 39.685092
     describe "greatCircle through position" $
         it "fails if both positions are equal" $
-        greatCircleThrough (s84Pos 3 154 zero) (s84Pos 3 154 zero) `shouldBe` Nothing
+        GreatCircle.through (Geodetic.s84Pos 3 154) (Geodetic.s84Pos 3 154) `shouldBe` Nothing
     describe "finalBearing" $ do
-        it "returns the Nothing if both positions are the same (ignoring height)" $ do
-            let p = s84Pos 50.066389 (-5.714722) zero
-            finalBearing p p `shouldBe` Nothing
-            finalBearing p (s84Pos 50.066389 (-5.714722) (metres 10)) `shouldBe` Nothing
+        it "returns the Nothing if both positions are the same" $ do
+            let p = Geodetic.s84Pos 50.066389 (-5.714722)
+            GreatCircle.finalBearing p p `shouldBe` Nothing
+            GreatCircle.finalBearing p (Geodetic.s84Pos 50.066389 (-5.714722)) `shouldBe` Nothing
         it "returns 0° if both positions have the same longitude (going north)" $ do
-            let p1 = s84Pos 50.066389 (-5.714722) (metres 12000)
-            let p2 = s84Pos 58.643889 (-5.714722) (metres 5000)
-            finalBearing p1 p2 `shouldBe` Just (decimalDegrees 0)
+            let p1 = Geodetic.s84Pos 50.066389 (-5.714722)
+            let p2 = Geodetic.s84Pos 58.643889 (-5.714722)
+            GreatCircle.finalBearing p1 p2 `shouldBe` Just (Angle.zero)
         it "returns 180° if both positions have the same longitude (going south)" $ do
-            let p1 = s84Pos 58.643889 (-5.714722) (metres 5000)
-            let p2 = s84Pos 50.066389 (-5.714722) (metres 12000)
-            finalBearing p1 p2 `shouldBe` Just (decimalDegrees 180)
+            let p1 = Geodetic.s84Pos 58.643889 (-5.714722)
+            let p2 = Geodetic.s84Pos 50.066389 (-5.714722)
+            GreatCircle.finalBearing p1 p2 `shouldBe` Just (Angle.decimalDegrees 180)
         it "returns 90° at the equator going east" $ do
-            let p1 = s84Pos 0 0 (metres 12000)
-            let p2 = s84Pos 0 1 (metres 5000)
-            finalBearing p1 p2 `shouldBe` Just (decimalDegrees 90)
+            let p1 = Geodetic.s84Pos 0 0
+            let p2 = Geodetic.s84Pos 0 1
+            GreatCircle.finalBearing p1 p2 `shouldBe` Just (Angle.decimalDegrees 90)
         it "returns 270° at the equator going west" $ do
-            let p1 = s84Pos 0 1 (metres 12000)
-            let p2 = s84Pos 0 0 (metres 5000)
-            finalBearing p1 p2 `shouldBe` Just (decimalDegrees 270)
+            let p1 = Geodetic.s84Pos 0 1
+            let p2 = Geodetic.s84Pos 0 0
+            GreatCircle.finalBearing p1 p2 `shouldBe` Just (Angle.decimalDegrees 270)
         it "returns the final bearing in compass angle" $ do
-            let p1 = s84Pos 50.066389 (-5.714722) zero
-            let p2 = s84Pos 58.643889 (-3.07) zero
-            finalBearing p1 p2 `shouldBe` Just (decimalDegrees 11.27520031611111)
+            let p1 = Geodetic.s84Pos 50.066389 (-5.714722)
+            let p2 = Geodetic.s84Pos 58.643889 (-3.07)
+            GreatCircle.finalBearing p1 p2 `shouldBe` Just (Angle.decimalDegrees 11.27520031611111)
         it "returns the final bearing in compass angle" $ do
-            let p1 = s84Pos 58.643889 (-3.07) zero
-            let p2 = s84Pos 50.066389 (-5.714722) zero
-            finalBearing p1 p2 `shouldBe` Just (decimalDegrees 189.1198173275)
+            let p1 = Geodetic.s84Pos 58.643889 (-3.07)
+            let p2 = Geodetic.s84Pos 50.066389 (-5.714722)
+            GreatCircle.finalBearing p1 p2 `shouldBe` Just (Angle.decimalDegrees 189.1198173275)
         it "returns the final bearing in compass angle" $ do
-            let p1 = s84Pos (-53.994722) (-25.9875) zero
-            let p2 = s84Pos 54 154 zero
-            finalBearing p1 p2 `shouldBe` Just (decimalDegrees 125.68508662305555)
+            let p1 = Geodetic.s84Pos (-53.994722) (-25.9875)
+            let p2 = Geodetic.s84Pos 54 154
+            GreatCircle.finalBearing p1 p2 `shouldBe` Just (Angle.decimalDegrees 125.68508662305555)
     describe "initialBearing" $ do
-        it "returns Nothing if both positions are the same (ignoring height)" $ do
-            let p = s84Pos 50.066389 (-179.999722) zero
-            initialBearing p p `shouldBe` Nothing
-            initialBearing p (s84Pos 50.066389 (-179.999722) (metres 100)) `shouldBe` Nothing
+        it "returns Nothing if both positions are the same" $ do
+            let p = Geodetic.s84Pos 50.066389 (-179.999722)
+            GreatCircle.initialBearing p p `shouldBe` Nothing
+            GreatCircle.initialBearing p (Geodetic.s84Pos 50.066389 (-179.999722)) `shouldBe`
+                Nothing
         it "returns 0° if both positions have the same longitude (going north)" $ do
-            let p1 = s84Pos 50.066389 (-5.714722) (metres 12000)
-            let p2 = s84Pos 58.643889 (-5.714722) (metres 12000)
-            initialBearing p1 p2 `shouldBe` Just (decimalDegrees 0)
+            let p1 = Geodetic.s84Pos 50.066389 (-5.714722)
+            let p2 = Geodetic.s84Pos 58.643889 (-5.714722)
+            GreatCircle.initialBearing p1 p2 `shouldBe` Just (Angle.zero)
         it "returns 180° if both positions have the same longitude (going south)" $ do
-            let p1 = s84Pos 58.643889 (-5.714722) (metres 12000)
-            let p2 = s84Pos 50.066389 (-5.714722) (metres 12000)
-            initialBearing p1 p2 `shouldBe` Just (decimalDegrees 180)
+            let p1 = Geodetic.s84Pos 58.643889 (-5.714722)
+            let p2 = Geodetic.s84Pos 50.066389 (-5.714722)
+            GreatCircle.initialBearing p1 p2 `shouldBe` Just (Angle.decimalDegrees 180)
         it "returns 90° at the equator going east" $ do
-            let p1 = s84Pos 0 0 (metres 12000)
-            let p2 = s84Pos 0 1 (metres 5000)
-            initialBearing p1 p2 `shouldBe` Just (decimalDegrees 90)
+            let p1 = Geodetic.s84Pos 0 0
+            let p2 = Geodetic.s84Pos 0 1
+            GreatCircle.initialBearing p1 p2 `shouldBe` Just (Angle.decimalDegrees 90)
         it "returns 270° at the equator going west" $ do
-            let p1 = s84Pos 0 1 (metres 12000)
-            let p2 = s84Pos 0 0 (metres 5000)
-            initialBearing p1 p2 `shouldBe` Just (decimalDegrees 270)
+            let p1 = Geodetic.s84Pos 0 1
+            let p2 = Geodetic.s84Pos 0 0
+            GreatCircle.initialBearing p1 p2 `shouldBe` Just (Angle.decimalDegrees 270)
         it "returns 0° at the prime meridian going north" $ do
-            let p1 = s84Pos 50 0 zero
-            let p2 = s84Pos 58 0 zero
-            initialBearing p1 p2 `shouldBe` Just (decimalDegrees 0)
+            let p1 = Geodetic.s84Pos 50 0
+            let p2 = Geodetic.s84Pos 58 0
+            GreatCircle.initialBearing p1 p2 `shouldBe` Just (Angle.zero)
         it "returns 180° at the prime meridian going south" $ do
-            let p1 = s84Pos 58 0 zero
-            let p2 = s84Pos 50 0 zero
-            initialBearing p1 p2 `shouldBe` Just (decimalDegrees 180)
+            let p1 = Geodetic.s84Pos 58 0
+            let p2 = Geodetic.s84Pos 50 0
+            GreatCircle.initialBearing p1 p2 `shouldBe` Just (Angle.decimalDegrees 180)
         it "returns 0° at the date line going north" $ do
-            let p1 = s84Pos 50 180 zero
-            let p2 = s84Pos 58 180 zero
-            initialBearing p1 p2 `shouldBe` Just (decimalDegrees 0)
+            let p1 = Geodetic.s84Pos 50 180
+            let p2 = Geodetic.s84Pos 58 180
+            GreatCircle.initialBearing p1 p2 `shouldBe` Just (Angle.zero)
         it "returns 180° at the date line going south" $ do
-            let p1 = s84Pos 58 180 zero
-            let p2 = s84Pos 50 180 zero
-            initialBearing p1 p2 `shouldBe` Just (decimalDegrees 180)
+            let p1 = Geodetic.s84Pos 58 180
+            let p2 = Geodetic.s84Pos 50 180
+            GreatCircle.initialBearing p1 p2 `shouldBe` Just (Angle.decimalDegrees 180)
         it "returns 0° going from the south pole to the north pole" $ do
-            let p1 = southPole S84
-            let p2 = northPole S84
-            initialBearing p1 p2 `shouldBe` Just (decimalDegrees 0)
+            let p1 = Geodetic.southPole S84
+            let p2 = Geodetic.northPole S84
+            GreatCircle.initialBearing p1 p2 `shouldBe` Just (Angle.zero)
         it "returns 0° going from the north pole to the south pole" $ do
-            let p1 = northPole S84
-            let p2 = southPole S84
-            initialBearing p1 p2 `shouldBe` Just (decimalDegrees 0)
-        it "returns 0° going from the south pole to anywhere on the date line" $ do
-            let p1 = southPole S84
-            let p2 = s84Pos 50 180 zero
-            initialBearing p1 p2 `shouldBe` Just (decimalDegrees 0)
+            let p1 = Geodetic.northPole S84
+            let p2 = Geodetic.southPole S84
+            GreatCircle.initialBearing p1 p2 `shouldBe` Just (Angle.zero)
+        it "returns 180° going from the south pole to anywhere on the date line" $ do
+            let p1 = Geodetic.southPole S84
+            let p2 = Geodetic.s84Pos 50 180
+            GreatCircle.initialBearing p1 p2 `shouldBe` Just (Angle.decimalDegrees 180)
         it "returns the initial bearing in compass angle" $ do
-            let p1 = s84Pos 50.066389 (-5.714722) (metres 12000)
-            let p2 = s84Pos 58.643889 (-3.07) (metres 5000)
-            initialBearing p1 p2 `shouldBe` Just (decimalDegrees 9.1198173275)
+            let p1 = Geodetic.s84Pos 50.066389 (-5.714722)
+            let p2 = Geodetic.s84Pos 58.643889 (-3.07)
+            GreatCircle.initialBearing p1 p2 `shouldBe` Just (Angle.decimalDegrees 9.1198173275)
         it "returns the initial bearing in compass angle" $ do
-            let p1 = s84Pos 58.643889 (-3.07) (metres 12000)
-            let p2 = s84Pos 50.066389 (-5.714722) (metres 5000)
-            initialBearing p1 p2 `shouldBe` Just (decimalDegrees 191.27520031611112)
-    describe "interpolate" $ do
-        let p1 = s84Pos 44 44 zero
-        let p2 = s84Pos 46 46 zero
+            let p1 = Geodetic.s84Pos 58.643889 (-3.07)
+            let p2 = Geodetic.s84Pos 50.066389 (-5.714722)
+            GreatCircle.initialBearing p1 p2 `shouldBe`
+                Just (Angle.decimalDegrees 191.27520031611112)
+    describe "interpolated" $ do
+        let p1 = Geodetic.s84Pos 44 44
+        let p2 = Geodetic.s84Pos 46 46
         it "fails if f < 0.0" $
-            evaluate (interpolate p1 p2 (-0.5)) `shouldThrow` errorCall "fraction must be in range [0..1], was -0.5"
+            evaluate (GreatCircle.interpolated p1 p2 (-0.5)) `shouldThrow`
+            errorCall "fraction must be in range [0..1], was -0.5"
         it "fails if f > 1.0" $
-            evaluate (interpolate p1 p2 1.1) `shouldThrow` errorCall "fraction must be in range [0..1], was 1.1"
-        it "returns p0 if f == 0" $ interpolate p1 p2 0.0 `shouldBe` p1
-        it "returns p1 if f == 1" $ interpolate p1 p2 1.0 `shouldBe` p2
+            evaluate (GreatCircle.interpolated p1 p2 1.1) `shouldThrow`
+            errorCall "fraction must be in range [0..1], was 1.1"
+        it "returns p0 if f == 0" $ GreatCircle.interpolated p1 p2 0.0 `shouldBe` p1
+        it "returns p1 if f == 1" $ GreatCircle.interpolated p1 p2 1.0 `shouldBe` p2
         it "returns the interpolated position" $ do
-            let p3 = s84Pos 53.479444 (-2.245278) (metres 10000)
-            let p4 = s84Pos 55.605833 13.035833 (metres 20000)
-            interpolate p3 p4 0.5 `shouldBe` s84Pos 54.78355703138889 5.194985318055555 (metres 15000)
-    describe "isInsideSurface" $ do
-        let p1 = s84Pos 45 1 zero
-        let p2 = s84Pos 45 2 zero
-        let p3 = s84Pos 46 1 zero
-        let p4 = s84Pos 46 2 zero
-        let p5 = s84Pos 45.1 1.1 zero
-        it "return False if polygon is empty" $ isInsideSurface p1 [] `shouldBe` False
-        it "return False if polygon does not define at least a triangle" $ isInsideSurface p1 [p1, p2] `shouldBe` False
+            let p3 = Geodetic.s84Pos 53.479444 (-2.245278)
+            let p4 = Geodetic.s84Pos 55.605833 13.035833
+            GreatCircle.interpolated p3 p4 0.5 `shouldBe`
+                Geodetic.s84Pos 54.78355703138889 5.194985318055555
+    describe "enclosedBy" $ do
+        let p1 = Geodetic.s84Pos 45 1
+        let p2 = Geodetic.s84Pos 45 2
+        let p3 = Geodetic.s84Pos 46 1
+        let p4 = Geodetic.s84Pos 46 2
+        let p5 = Geodetic.s84Pos 45.1 1.1
+        it "return False if polygon is empty" $ GreatCircle.enclosedBy p1 [] `shouldBe` False
+        it "return False if polygon does not define at least a triangle" $
+            GreatCircle.enclosedBy p1 [p1, p2] `shouldBe` False
         it "returns True if position is inside polygon" $ do
             let polygon = [p1, p2, p4, p3]
-            isInsideSurface p5 polygon `shouldBe` True
+            GreatCircle.enclosedBy p5 polygon `shouldBe` True
         it "returns False if position is inside polygon" $ do
             let polygon = [p1, p2, p4, p3]
-            let p = antipode p5
-            isInsideSurface p polygon `shouldBe` False
+            let p = Geodetic.antipode p5
+            GreatCircle.enclosedBy p polygon `shouldBe` False
         it "returns False if position is a vertex of the polygon" $ do
-            let polygon = [p1, p2, p4, p3]
-            isInsideSurface p1 polygon `shouldBe` False
+            let convex = [p1, p2, p4, p3]
+            fmap (\p -> GreatCircle.enclosedBy p convex) convex `shouldBe` replicate 4 False
+            let concave = [malmo, ystad, kristianstad, helsingborg, lund]
+            fmap (\p -> GreatCircle.enclosedBy p concave) concave `shouldBe` replicate 5 False
         it "handles closed polygons" $ do
             let polygon = [p1, p2, p4, p3, p1]
-            isInsideSurface p5 polygon `shouldBe` True
+            GreatCircle.enclosedBy p5 polygon `shouldBe` True
         it "handles concave polygons" $ do
-            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 `shouldBe` True
-            isInsideSurface hassleholm polygon `shouldBe` False
+            GreatCircle.enclosedBy hoor polygon `shouldBe` True
+            GreatCircle.enclosedBy hassleholm polygon `shouldBe` False
+        it "considers a point on an edge to be in one polygon only" $ do
+            let i = GreatCircle.interpolated helsingborg lund 0.5
+            let poly1 = [malmo, kristianstad, helsingborg, lund]
+            let poly2 = [helsingborg, lund, copenhagen]
+            GreatCircle.enclosedBy i poly1 `shouldBe` True
+            GreatCircle.enclosedBy i poly2 `shouldBe` False
     describe "intersection" $ do
         it "returns nothing if both great arc are equals" $ do
-            let a = minorArc (s84Pos 51.885 0.235 zero) (s84Pos 52.885 1.235 zero)
-            join (intersection <$> a <*> a) `shouldBe` Nothing
+            let a =
+                    GreatCircle.minorArc
+                        (Geodetic.s84Pos 51.885 0.235)
+                        (Geodetic.s84Pos 52.885 1.235)
+            join (GreatCircle.intersection <$> a <*> a) `shouldBe` Nothing
         it "returns nothing if both great arc are equals (opposite orientation)" $ do
-            let a1 = minorArc (s84Pos 51.885 0.235 zero) (s84Pos 52.885 1.235 zero)
-            let a2 = minorArc (s84Pos 52.885 1.235 zero) (s84Pos 51.885 0.235 zero)
-            join (intersection <$> a1 <*> a2) `shouldBe` Nothing
+            let a1 =
+                    GreatCircle.minorArc
+                        (Geodetic.s84Pos 51.885 0.235)
+                        (Geodetic.s84Pos 52.885 1.235)
+            let a2 =
+                    GreatCircle.minorArc
+                        (Geodetic.s84Pos 52.885 1.235)
+                        (Geodetic.s84Pos 51.885 0.235)
+            join (GreatCircle.intersection <$> a1 <*> a2) `shouldBe` Nothing
         it "returns nothing if great circle intersection is outside either great arc" $ do
-            let a1 = minorArc (s84Pos 0 0 zero) (s84Pos 0 10 zero)
-            let a2 = minorArc (s84Pos (-5) 5 zero) (s84Pos (-1) 5 zero)
-            join (intersection <$> a1 <*> a2) `shouldBe` Nothing
+            let a1 = GreatCircle.minorArc (Geodetic.s84Pos 0 0) (Geodetic.s84Pos 0 10)
+            let a2 = GreatCircle.minorArc (Geodetic.s84Pos (-5) 5) (Geodetic.s84Pos (-1) 5)
+            join (GreatCircle.intersection <$> a1 <*> a2) `shouldBe` Nothing
         it "returns nothing if great circle intersection is outside both great arcs" $ do
-            let a1 = minorArc (s84Pos 0 (-10) zero) (s84Pos 0 (-1) zero)
-            let a2 = minorArc (s84Pos (-5) 5 zero) (s84Pos (-1) 5 zero)
-            join (intersection <$> a1 <*> a2) `shouldBe` Nothing
+            let a1 = GreatCircle.minorArc (Geodetic.s84Pos 0 (-10)) (Geodetic.s84Pos 0 (-1))
+            let a2 = GreatCircle.minorArc (Geodetic.s84Pos (-5) 5) (Geodetic.s84Pos (-1) 5)
+            join (GreatCircle.intersection <$> a1 <*> a2) `shouldBe` Nothing
         it "returns the point where the two great arcs intersect" $ do
-            let a1 = minorArc (s84Pos 51.885 0.235 zero) (s84Pos 48.269 13.093 zero)
-            let a2 = minorArc (s84Pos 49.008 2.549 zero) (s84Pos 56.283 11.304 zero)
-            join (intersection <$> a1 <*> a2) `shouldBe` Just (s84Pos 50.901738961111114 4.49418117 zero)
+            let a1 =
+                    GreatCircle.minorArc
+                        (Geodetic.s84Pos 51.885 0.235)
+                        (Geodetic.s84Pos 48.269 13.093)
+            let a2 =
+                    GreatCircle.minorArc
+                        (Geodetic.s84Pos 49.008 2.549)
+                        (Geodetic.s84Pos 56.283 11.304)
+            join (GreatCircle.intersection <$> a1 <*> a2) `shouldBe`
+                Just (Geodetic.s84Pos 50.901738961111114 4.49418117)
+        it "handles a minor arc across the equator" $ do
+            let a1 = GreatCircle.minorArc (Geodetic.s84Pos 54 154) (Geodetic.s84Pos (-54) 154)
+            let a2 = GreatCircle.minorArc (Geodetic.s84Pos 53 153) (Geodetic.s84Pos 53 155)
+            join (GreatCircle.intersection <$> a1 <*> a2) `shouldBe`
+                Just (Geodetic.s84Pos 53.00419442027778 154)
+        it "returns the common start position between the 2 minor arcs" $ do
+            let a1 =
+                    GreatCircle.minorArc
+                        (Geodetic.s84Pos (-41.52) 141)
+                        (Geodetic.s84Pos (-65.444811) 111.616598)
+            let a2 =
+                    GreatCircle.minorArc
+                        (Geodetic.s84Pos (-42.35) 141)
+                        (Geodetic.s84Pos (-39.883333) 141)
+            join (GreatCircle.intersection <$> a1 <*> a2) `shouldBe`
+                Just (Geodetic.s84Pos (-41.52) 141.0)
+        it "returns the common end position between the 2 minor arcs" $ do
+            let a1 =
+                    GreatCircle.minorArc
+                        (Geodetic.s84Pos (-65.444811) 111.616598)
+                        (Geodetic.s84Pos (-41.52) 141)
+            let a2 =
+                    GreatCircle.minorArc
+                        (Geodetic.s84Pos (-39.883333) 141)
+                        (Geodetic.s84Pos (-41.52) 141)
+            join (GreatCircle.intersection <$> a1 <*> a2) `shouldBe`
+                Just (Geodetic.s84Pos (-41.52) 141.0)
+        it "handles an intersection exactly on one of the minor arcs" $ do
+            let a1 = GreatCircle.minorArc (Geodetic.s84Pos 0 (-10)) (Geodetic.s84Pos 0 10)
+            let a2 = GreatCircle.minorArc (Geodetic.s84Pos (-10) 0) (Geodetic.s84Pos 10 0)
+            join (GreatCircle.intersection <$> a1 <*> a2) `shouldBe` Just (Geodetic.s84Pos 0 0)
     describe "intersections" $ do
         it "returns nothing if both great circle are equals" $ do
-            let gc = greatCircleHeadingOn (s84Pos 51.885 0.235 zero) (decimalDegrees 108.63)
-            intersections gc gc `shouldBe` Nothing
+            let gc =
+                    GreatCircle.headingOn
+                        (Geodetic.s84Pos 51.885 0.235)
+                        (Angle.decimalDegrees 108.63)
+            GreatCircle.intersections gc gc `shouldBe` Nothing
         it "returns nothing if both great circle are equals (opposite orientation)" $ do
-            let gc1 = greatCircleThrough (s84Pos 51.885 0.235 zero) (s84Pos 52.885 1.235 zero)
-            let gc2 = greatCircleThrough (s84Pos 52.885 1.235 zero) (s84Pos 51.885 0.235 zero)
-            join (intersections <$> gc1 <*> gc2) `shouldBe` Nothing
+            let gc1 =
+                    GreatCircle.through
+                        (Geodetic.s84Pos 51.885 0.235)
+                        (Geodetic.s84Pos 52.885 1.235)
+            let gc2 =
+                    GreatCircle.through
+                        (Geodetic.s84Pos 52.885 1.235)
+                        (Geodetic.s84Pos 51.885 0.235)
+            join (GreatCircle.intersections <$> gc1 <*> gc2) `shouldBe` Nothing
         it "returns the two positions where the two great circles intersects" $ do
-            let gc1 = greatCircleHeadingOn (s84Pos 51.885 0.235 zero) (decimalDegrees 108.63)
-            let gc2 = greatCircleHeadingOn (s84Pos 49.008 2.549 zero) (decimalDegrees 32.72)
-            let (i1, i2) = fromJust (intersections gc1 gc2)
-            i1 `shouldBe` s84Pos 50.90172260888889 4.494278278888889 zero
-            i2 `shouldBe` antipode i1
+            let gc1 =
+                    GreatCircle.headingOn
+                        (Geodetic.s84Pos 51.885 0.235)
+                        (Angle.decimalDegrees 108.63)
+            let gc2 =
+                    GreatCircle.headingOn
+                        (Geodetic.s84Pos 49.008 2.549)
+                        (Angle.decimalDegrees 32.72)
+            let (i1, i2) = fromJust (GreatCircle.intersections gc1 gc2)
+            i1 `shouldBe` Geodetic.s84Pos 50.90172260888889 4.494278278888889
+            i2 `shouldBe` Geodetic.antipode i1
     describe "mean" $ do
-        it "returns Nothing if no position is given" $ (mean [] :: (Maybe (Position S84))) `shouldBe` Nothing
+        it "returns Nothing if no position is given" $
+            (GreatCircle.mean [] :: (Maybe (HorizontalPosition S84))) `shouldBe` Nothing
         it "returns the unique given position" $ do
-            let p = s84Pos 50.066389 (-5.714722) zero
-            mean [p] `shouldBe` Just p
+            let p = Geodetic.s84Pos 50.066389 (-5.714722)
+            GreatCircle.mean [p] `shouldBe` Just p
         it "returns the geographical mean" $ do
-            let p1 = s84Pos 50.066389 (-5.714722) (metres 15000.0)
-            let p2 = s84Pos 58.643889 (-3.07) (metres 25000.0)
-            let e = s84Pos 54.3622869375 (-4.530672405) zero
-            mean [p1, p2] `shouldBe` Just e
+            let p1 = Geodetic.s84Pos 50.066389 (-5.714722)
+            let p2 = Geodetic.s84Pos 58.643889 (-3.07)
+            let e = Geodetic.s84Pos 54.3622869375 (-4.530672405)
+            GreatCircle.mean [p1, p2] `shouldBe` Just e
         it "returns Nothing if list contains antipodal positions" $ do
             let points =
-                    [ s84Pos 45 1 zero
-                    , s84Pos 45 2 zero
-                    , s84Pos 46 2 zero
-                    , s84Pos 46 1 zero
-                    , antipode (s84Pos 45 2 zero)
+                    [ Geodetic.s84Pos 45 1
+                    , Geodetic.s84Pos 45 2
+                    , Geodetic.s84Pos 46 2
+                    , Geodetic.s84Pos 46 1
+                    , Geodetic.antipode (Geodetic.s84Pos 45 2)
                     ]
-            mean points `shouldBe` Nothing
+            GreatCircle.mean points `shouldBe` Nothing
+    describe "projection" $ do
+        it "returns Nothing if position is the normal to minor arc (1/2)" $ do
+            let s = Geodetic.s84Pos 3 (-10)
+            let e = (Geodetic.s84Pos 4 10)
+            let ma = fromJust (GreatCircle.minorArc s e)
+            let p =
+                    Geodetic.nvectorPos'
+                        (Math3d.cross (Geodetic.nvector s) (Geodetic.nvector e))
+                        S84
+            GreatCircle.projection p ma `shouldBe` Nothing
+        it "returns Nothing if position is the normal to minor arc (2/2)" $ do
+            let ma =
+                    fromJust (GreatCircle.minorArc (Geodetic.s84Pos 0 (-10)) (Geodetic.s84Pos 0 10))
+            let p = Geodetic.northPole S84
+            GreatCircle.projection p ma `shouldBe` Nothing
+        it "returns Nothing if position is the antipode of the normal to minor arc (1/2)" $ do
+            let s = Geodetic.s84Pos 3 (-10)
+            let e = (Geodetic.s84Pos 4 10)
+            let ma = fromJust (GreatCircle.minorArc s e)
+            let p =
+                    Geodetic.antipode
+                        (Geodetic.nvectorPos'
+                             (Math3d.cross (Geodetic.nvector s) (Geodetic.nvector e))
+                             S84)
+            GreatCircle.projection p ma `shouldBe` Nothing
+        it "returns Nothing if position is the antipode of the normal to minor arc (2/2)" $ do
+            let ma =
+                    fromJust (GreatCircle.minorArc (Geodetic.s84Pos 0 (-10)) (Geodetic.s84Pos 0 10))
+            let p = Geodetic.southPole S84
+            GreatCircle.projection p ma `shouldBe` Nothing
+        it "returns Nothing if projection is outside minor arc" $ do
+            let ma = fromJust (GreatCircle.minorArc (Geodetic.s84Pos 54 15) (Geodetic.s84Pos 54 20))
+            let p = Geodetic.s84Pos 54 10
+            GreatCircle.projection p ma `shouldBe` Nothing
+        it "returns the projection if within the minor arc" $ do
+            let s = Geodetic.s84Pos 53.3206 (-1.7297)
+            let e = Geodetic.s84Pos 53.1887 0.1334
+            let ma = fromJust (GreatCircle.minorArc s e)
+            let p = Geodetic.s84Pos 53.2611 (-0.7972)
+            let proj = GreatCircle.projection p ma
+            proj `shouldBe` Just (Geodetic.s84Pos 53.25835330666666 (-0.7977433863888889))
+            -- absolute cross track distance from p to great circle should be distance between projection and p
+            let stx = (GreatCircle.crossTrackDistance p (fromJust (GreatCircle.through s e)))
+            abs (Length.toMetres stx) `shouldBe`
+                Length.toMetres (GreatCircle.distance (fromJust proj) p)
+        it "handles p exactly being the start of the minor arc (1/2)" $ do
+            let s = Geodetic.s84Pos 54 15
+            let ma = fromJust (GreatCircle.minorArc s (Geodetic.s84Pos 54 20))
+            GreatCircle.projection s ma `shouldBe` (Just s)
+        it "handles p exactly being the start of the minor arc (2/2)" $ do
+            let s = Geodetic.s84Pos 13.733333587646484 100.5
+            let ma = fromJust (GreatCircle.minorArc s (Geodetic.s84Pos 12.0 100.58499908447266))
+            GreatCircle.projection s ma `shouldBe` (Just s)
+        it "handles p exactly being the end of the minor arc (1/2)" $ do
+            let e = Geodetic.s84Pos 54 20
+            let ma = fromJust (GreatCircle.minorArc (Geodetic.s84Pos 54 15) e)
+            GreatCircle.projection e ma `shouldBe` (Just e)
+        it "handles p exactly being the end of the minor arc (2/2)" $ do
+            let e = Geodetic.s84Pos 12.0 100.58499908447266
+            let ma = fromJust (GreatCircle.minorArc (Geodetic.s84Pos 13.733333587646484 100) e)
+            GreatCircle.projection e ma `shouldBe` (Just e)
+    describe "side" $ do
+        it "retuns None if p1 is antipode of p2" $ do
+            GreatCircle.side ystad helsingborg (Geodetic.antipode helsingborg) `shouldBe`
+                GreatCircle.None
+        it "returns None if (p1, p2) are equal" $ do
+            GreatCircle.side ystad helsingborg helsingborg `shouldBe` GreatCircle.None
+        it "returns None if p0 is on the great circle" $ do
+            GreatCircle.side (Geodetic.s84Pos 0 0) (Geodetic.s84Pos 45 0) (Geodetic.northPole S84) `shouldBe`
+                GreatCircle.None
+            GreatCircle.side helsingborg helsingborg kristianstad `shouldBe` GreatCircle.None
+            GreatCircle.side kristianstad helsingborg kristianstad `shouldBe` GreatCircle.None
+        it "return LeftOf or RightOf" $ do
+            GreatCircle.side ystad helsingborg kristianstad `shouldBe` GreatCircle.RightOf
+            GreatCircle.side ystad kristianstad helsingborg `shouldBe` GreatCircle.LeftOf
+            GreatCircle.side malmo lund helsingborg `shouldBe` GreatCircle.LeftOf
+            GreatCircle.side malmo helsingborg lund `shouldBe` GreatCircle.RightOf
+    describe "turn" $ do
+        it "returns a negative angle when turning left" $ do
+            GreatCircle.turn (Geodetic.s84Pos 0 0) (Geodetic.s84Pos 45 0) (Geodetic.s84Pos 60 (-10)) `shouldBe`
+                Angle.decimalDegrees 18.192705871944444
+        it "returns a positive angle when turning right" $ do
+            GreatCircle.turn (Geodetic.s84Pos 0 0) (Geodetic.s84Pos 45 0) (Geodetic.s84Pos 60 10) `shouldBe`
+                Angle.decimalDegrees (-18.192705871944444)
+        it "returns 0 when a, b & c are aligned" $ do
+            GreatCircle.turn (Geodetic.s84Pos 0 0) (Geodetic.s84Pos 45 0) (Geodetic.northPole S84) `shouldBe`
+                Angle.zero
+        it "returns 0 is any 2 of the 3 positions are equal" $ do
+            let a = Geodetic.s84Pos 45 63
+            let b = Geodetic.s84Pos (-54) (-89)
+            GreatCircle.turn a a a `shouldBe` Angle.zero
+            GreatCircle.turn a a b `shouldBe` Angle.zero
+            GreatCircle.turn a b b `shouldBe` Angle.zero
+            GreatCircle.turn b b b `shouldBe` Angle.zero
+            GreatCircle.turn b b a `shouldBe` Angle.zero
+            GreatCircle.turn b a a `shouldBe` Angle.zero
test/Data/Geo/Jord/KinematicsSpec.hs view
@@ -6,206 +6,259 @@ 
 import Test.Hspec
 
-import Data.Geo.Jord.GreatCircle
-import Data.Geo.Jord.Kinematics
-import Data.Geo.Jord.Position
+import qualified Data.Geo.Jord.Angle as Angle
+import qualified Data.Geo.Jord.Duration as Duration
+import qualified Data.Geo.Jord.Geodetic as Geodetic
+import qualified Data.Geo.Jord.GreatCircle as GreatCircle
+import Data.Geo.Jord.Kinematics (Track(..))
+import qualified Data.Geo.Jord.Kinematics as Kinematics
+import qualified Data.Geo.Jord.Length as Length
+import Data.Geo.Jord.Models (S84(..))
+import qualified Data.Geo.Jord.Speed as Speed
 
 spec :: Spec
 spec =
     describe "kinematics" $ do
         describe "trackPositionAfter" $ do
             it "computes position at t from p0, bearing and speed" $ do
-                let p0 = s84Pos 53.320556 (-1.729722) (metres 15000)
-                let p1 = s84Pos 53.18826954833333 0.13327449083333334 (metres 15000)
-                let t = Track p0 (decimalDegrees 96.0217) (kilometresPerHour 124.8)
-                trackPositionAfter t (hours 1) `shouldBe` p1
+                let p0 = Geodetic.s84Pos 53.320556 (-1.729722)
+                let p1 = Geodetic.s84Pos 53.18826954833333 0.13327449083333334
+                let t = Track p0 (Angle.decimalDegrees 96.0217) (Speed.kilometresPerHour 124.8)
+                Kinematics.trackPositionAfter t (Duration.hours 1) `shouldBe` p1
             it "handles crossing the date line" $
                 -- distance at equator between 2 positions separated by 180 degrees assuming a spherical
                 -- earth (from WGS84) = 20015.114352233km
                 -- track at 0N1E travelling at 20015.114352233km/h and 90 degrees reaches 0N179W after 1 hour.
              do
-                let p0 = s84Pos 0 1 zero
-                let t = Track p0 (decimalDegrees 90) (kilometresPerHour 20015.114352233)
-                let p1 = trackPositionAfter t (hours 1)
-                surfaceDistance p1 (s84Pos 0 (-179) zero) < metres 0.001 `shouldBe` True
+                let p0 = Geodetic.s84Pos 0 1
+                let t = Track p0 (Angle.decimalDegrees 90) (Speed.kilometresPerHour 20015.114352233)
+                let p1 = Kinematics.trackPositionAfter t (Duration.hours 1)
+                GreatCircle.distance p1 (Geodetic.s84Pos 0 (-179)) <
+                    Length.metres 0.001 `shouldBe` True
             it "handles poles" $
                 -- distance between poles assuming a spherical earth (from WGS84) = 20015.114352233km
                 -- track at north pole travelling at 20015.114352233km/h and true north reaches the
                 -- south pole after 1 hour.
              do
-                let t = Track (northPole S84) zero (kilometresPerHour 20015.114352233)
-                let p1 = trackPositionAfter t (hours 1)
-                surfaceDistance p1 (southPole S84) < metres 0.001 `shouldBe` True
+                let t =
+                        Track
+                            (Geodetic.northPole S84)
+                            Angle.zero
+                            (Speed.kilometresPerHour 20015.114352233)
+                let p1 = Kinematics.trackPositionAfter t (Duration.hours 1)
+                GreatCircle.distance p1 (Geodetic.southPole S84) <
+                    Length.metres 0.001 `shouldBe` True
             it "return p0 if speed is 0" $ do
-                let p0 = s84Pos 53.320556 (-1.729722) (metres 15000)
-                let t = Track p0 (decimalDegrees 96.0217) zero
-                trackPositionAfter t (hours 1) `shouldBe` p0
+                let p0 = Geodetic.s84Pos 53.320556 (-1.729722)
+                let t = Track p0 (Angle.decimalDegrees 96.0217) Speed.zero
+                Kinematics.trackPositionAfter t (Duration.hours 1) `shouldBe` p0
             it "return p0 if duration is 0" $ do
-                let p0 = s84Pos 53.320556 (-1.729722) (metres 15000)
-                let t = Track p0 (decimalDegrees 96.0217) (kilometresPerHour 124.8)
-                trackPositionAfter t zero `shouldBe` p0
+                let p0 = Geodetic.s84Pos 53.320556 (-1.729722)
+                let t = Track p0 (Angle.decimalDegrees 96.0217) (Speed.kilometresPerHour 124.8)
+                Kinematics.trackPositionAfter t Duration.zero `shouldBe` p0
         describe "cpa" $ do
             it "returns nothing for trailing tracks at same speed" $ do
-                let p1 = s84Pos 20 30 zero
-                let px = destination p1 (decimalDegrees 20) (kilometres 1)
-                let p2 = interpolate p1 px 0.25
-                let b1 = fromJust (initialBearing p1 px)
-                let b2 = fromJust (initialBearing p2 px)
-                let t1 = Track p1 b1 (knots 400)
-                let t2 = Track p2 b2 (knots 400)
-                cpa t1 t2 `shouldBe` Nothing
+                let p1 = Geodetic.s84Pos 20 30
+                let px = GreatCircle.destination p1 (Angle.decimalDegrees 20) (Length.kilometres 1)
+                let p2 = GreatCircle.interpolated p1 px 0.25
+                let b1 = fromJust (GreatCircle.initialBearing p1 px)
+                let b2 = fromJust (GreatCircle.initialBearing p2 px)
+                let ownship = Track p1 b1 (Speed.knots 400)
+                let intruder = Track p2 b2 (Speed.knots 400)
+                Kinematics.cpa ownship intruder `shouldBe` Nothing
             it "returns nothing for trailing tracks with track ahead escaping" $ do
-                let p1 = s84Pos 20 30 zero
-                let px = destination p1 (decimalDegrees 20) (kilometres 1)
-                let p2 = interpolate p1 px 0.25
-                let b1 = fromJust (initialBearing p1 px)
-                let b2 = fromJust (initialBearing p2 px)
-                let t1 = Track p1 b1 (knots 400)
-                let t2 = Track p2 b2 (knots 401)
-                cpa t1 t2 `shouldBe` Nothing
+                let p1 = Geodetic.s84Pos 20 30
+                let px = GreatCircle.destination p1 (Angle.decimalDegrees 20) (Length.kilometres 1)
+                let p2 = GreatCircle.interpolated p1 px 0.25
+                let b1 = fromJust (GreatCircle.initialBearing p1 px)
+                let b2 = fromJust (GreatCircle.initialBearing p2 px)
+                let ownship = Track p1 b1 (Speed.knots 400)
+                let intruder = Track p2 b2 (Speed.knots 401)
+                Kinematics.cpa ownship intruder `shouldBe` Nothing
             it "handles trailing tracks with track behind catching up" $ do
-                let p1 = s84Pos 20 30 zero
-                let px = destination p1 (decimalDegrees 20) (kilometres 1)
-                let p2 = interpolate p1 px 0.25
-                let b1 = fromJust (initialBearing p1 px)
-                let b2 = fromJust (initialBearing p2 px)
-                let t1 = Track p1 b1 (knots 401)
-                let t2 = Track p2 b2 (knots 400)
-                let c = cpa t1 t2
-                fmap cpaTime c `shouldBe` Just (seconds 485.953)
-                fmap cpaDistance c `shouldBe` Just (metres 4.293e-3) -- close to 0
+                let p1 = Geodetic.s84Pos 20 30
+                let px = GreatCircle.destination p1 (Angle.decimalDegrees 20) (Length.kilometres 1)
+                let p2 = GreatCircle.interpolated p1 px 0.25
+                let b1 = fromJust (GreatCircle.initialBearing p1 px)
+                let b2 = fromJust (GreatCircle.initialBearing p2 px)
+                let ownship = Track p1 b1 (Speed.knots 402)
+                let intruder = Track p2 b2 (Speed.knots 400)
+                let cpa = Kinematics.cpa ownship intruder
+                fmap Kinematics.timeToCpa cpa `shouldBe` Just (Duration.seconds 242.981)
+                fmap Kinematics.distanceAtCpa cpa `shouldBe` Just (Length.metres 5.25e-4) -- close to 0
             it "handles heading tracks" $ do
-                let p1 = s84Pos 20 30 zero
-                let p2 = s84Pos 21 31 zero
-                let b1 = fromJust (initialBearing p1 p2)
-                let b2 = fromJust (initialBearing p2 p1)
-                let t1 = Track p1 b1 (knots 400)
-                let t2 = Track p2 b2 (knots 400)
-                let c = cpa t1 t2
+                let p1 = Geodetic.s84Pos 20 30
+                let p2 = Geodetic.s84Pos 21 31
+                let b1 = fromJust (GreatCircle.initialBearing p1 p2)
+                let b2 = fromJust (GreatCircle.initialBearing p2 p1)
+                let ownship = Track p1 b1 (Speed.knots 400)
+                let intruder = Track p2 b2 (Speed.knots 400)
+                let cpa = Kinematics.cpa ownship intruder
                 -- distance between p1 and p2 = 152.354309 km
                 -- speed = 740.8 km/h
                 -- time = 152.354309 / 740.8 / 2
-                fmap cpaTime c `shouldBe` Just (milliseconds 370191)
-                fmap cpaDistance c `shouldBe` Just zero
+                fmap Kinematics.timeToCpa cpa `shouldBe` Just (Duration.milliseconds 370191)
+                fmap Kinematics.distanceAtCpa cpa `shouldBe` Just Length.zero
             it "handles tracks at the same position" $ do
-                let p = s84Pos 20 30 zero
-                let t1 = Track p (decimalDegrees 45) (knots 300)
-                let t2 = Track p (decimalDegrees 135) (knots 500)
-                let c = cpa t1 t2
-                fmap cpaTime c `shouldBe` Just zero
-                fmap cpaDistance c `shouldBe` Just zero
+                let p = Geodetic.s84Pos 20 30
+                let ownship = Track p (Angle.decimalDegrees 45) (Speed.knots 300)
+                let intruder = Track p (Angle.decimalDegrees 135) (Speed.knots 500)
+                let cpa = Kinematics.cpa ownship intruder
+                fmap Kinematics.timeToCpa cpa `shouldBe` Just Duration.zero
+                fmap Kinematics.distanceAtCpa cpa `shouldBe` Just Length.zero
             it "computes time to CPA, positions and distance at CPA" $ do
-                let p1 = s84Pos 20 (-60) zero
-                let b1 = decimalDegrees 10
-                let s1 = knots 15
-                let p2 = s84Pos 34 (-50) (metres 10000)
-                let b2 = decimalDegrees 220
-                let s2 = knots 300
-                let t1 = Track p1 b1 s1
-                let t2 = Track p2 b2 s2
-                let c = cpa t1 t2
-                fmap cpaTime c `shouldBe` Just (milliseconds 11396155)
-                fmap cpaDistance c `shouldBe` Just (kilometres 124.231730834)
-                fmap cpaPosition1 c `shouldBe` Just (s84Pos 20.778789303333333 (-59.85311827861111) zero)
-                fmap cpaPosition2 c `shouldBe` Just (s84Pos 21.402367759166665 (-60.846710862222224) (metres 10000))
+                let p1 = Geodetic.s84Pos 20 (-60)
+                let b1 = Angle.decimalDegrees 10
+                let s1 = Speed.knots 15
+                let p2 = Geodetic.s84Pos 34 (-50)
+                let b2 = Angle.decimalDegrees 220
+                let s2 = Speed.knots 300
+                let ownship = Track p1 b1 s1
+                let intruder = Track p2 b2 s2
+                let cpa = Kinematics.cpa ownship intruder
+                fmap Kinematics.timeToCpa cpa `shouldBe` Just (Duration.milliseconds 11396155)
+                fmap Kinematics.distanceAtCpa cpa `shouldBe` Just (Length.kilometres 124.231730834)
+                fmap Kinematics.cpaOwnshipPosition cpa `shouldBe`
+                    Just (Geodetic.s84Pos 20.778789303333333 (-59.85311827861111))
+                fmap Kinematics.cpaIntruderPosition cpa `shouldBe`
+                    Just (Geodetic.s84Pos 21.402367759166665 (-60.846710862222224))
             it "returns Nothing if time to CPA is in the past" $ do
-                let t1 = Track (s84Pos 30 30 zero) (decimalDegrees 45) (knots 400)
-                let t2 = Track (s84Pos 30.01 30 zero) (decimalDegrees 315) (knots 400)
-                cpa t1 t2 `shouldBe` Nothing
+                let ownship =
+                        Track (Geodetic.s84Pos 30 30) (Angle.decimalDegrees 45) (Speed.knots 400)
+                let intruder =
+                        Track
+                            (Geodetic.s84Pos 30.01 30)
+                            (Angle.decimalDegrees 315)
+                            (Speed.knots 400)
+                Kinematics.cpa ownship intruder `shouldBe` Nothing
         describe "intercept" $ do
             it "returns Nothing if target and interceptor are at the same position" $
-                intercept (Track (s84Pos 30 30 zero) (decimalDegrees 45) (knots 400)) (s84Pos 30 30 zero) `shouldBe`
+                Kinematics.intercept
+                    (Track (Geodetic.s84Pos 30 30) (Angle.decimalDegrees 45) (Speed.knots 400))
+                    (Geodetic.s84Pos 30 30) `shouldBe`
                 Nothing
             it "returns Nothing if interceptor is behind target" $ do
-                let t = Track (s84Pos 45 67 zero) (decimalDegrees 54) (knots 400)
-                let ip = s84Pos 44 66 zero
-                intercept t ip `shouldBe` Nothing
+                let t = Track (Geodetic.s84Pos 45 67) (Angle.decimalDegrees 54) (Speed.knots 400)
+                let ip = Geodetic.s84Pos 44 66
+                Kinematics.intercept t ip `shouldBe` Nothing
             it "handles interceptor on the great circle of target and in front" $ do
-                let tp = s84Pos 20 30 zero
-                let b = decimalDegrees 12
-                let t = Track tp b (knots 400)
-                let ip = trackPositionAfter t (minutes 1)
-                let i = intercept t ip
-                fmap interceptorSpeed i `shouldBe` Just zero
-                fmap interceptDistance i `shouldBe` Just zero
-                fmap interceptPosition i `shouldBe` Just ip
-                fmap interceptTime i `shouldBe` Just (minutes 1)
+                let tp = Geodetic.s84Pos 20 30
+                let b = Angle.decimalDegrees 12
+                let t = Track tp b (Speed.knots 400)
+                let ip = Kinematics.trackPositionAfter t (Duration.minutes 1)
+                let i = Kinematics.intercept t ip
+                fmap Kinematics.interceptorSpeed i `shouldBe` Just Speed.zero
+                fmap Kinematics.distanceToIntercept i `shouldBe` Just Length.zero
+                fmap Kinematics.interceptPosition i `shouldBe` Just ip
+                fmap Kinematics.timeToIntercept i `shouldBe` Just (Duration.minutes 1)
             it "returns the minimum speed required for intercept to take place" $ do
-                let t = Track (s84Pos 34 (-50) zero) (decimalDegrees 220) (knots 600)
-                let ip = s84Pos 20 (-60) zero
-                let i = intercept t ip
-                fmap interceptorSpeed i `shouldBe` Just (knots 52.63336879049676)
-                fmap interceptTime i `shouldBe` Just (seconds 5993.831)
-                let interceptor = Track ip (fromJust (fmap interceptorBearing i)) (fromJust (fmap interceptorSpeed i))
-                let ep = trackPositionAfter interceptor (fromJust (fmap interceptTime i))
-                let ap = fmap interceptPosition i
-                let d = fmap (surfaceDistance ep) ap
-                fmap (<= metres 0.001) d `shouldBe` Just True
+                let t =
+                        Track
+                            (Geodetic.s84Pos 34 (-50))
+                            (Angle.decimalDegrees 220)
+                            (Speed.knots 600)
+                let ip = Geodetic.s84Pos 20 (-60)
+                let i = Kinematics.intercept t ip
+                fmap Kinematics.interceptorSpeed i `shouldBe` Just (Speed.knots 52.63336879049676)
+                fmap Kinematics.timeToIntercept i `shouldBe` Just (Duration.seconds 5993.831)
+                let interceptor =
+                        Track
+                            ip
+                            (Kinematics.interceptorBearing (fromJust i))
+                            (Kinematics.interceptorSpeed (fromJust i))
+                let ep =
+                        Kinematics.trackPositionAfter
+                            interceptor
+                            (Kinematics.timeToIntercept (fromJust i))
+                let ap = fmap Kinematics.interceptPosition i
+                let d = fmap (GreatCircle.distance ep) ap
+                fmap (<= Length.metres 0.001) d `shouldBe` Just True
         describe "interceptBySpeed" $ do
             it "returns Nothing if target and interceptor are at the same position" $
-                interceptBySpeed
-                    (Track (s84Pos 30 30 zero) (decimalDegrees 45) (knots 400))
-                    (s84Pos 30 30 zero)
-                    (knots 400) `shouldBe`
+                Kinematics.interceptBySpeed
+                    (Track (Geodetic.s84Pos 30 30) (Angle.decimalDegrees 45) (Speed.knots 400))
+                    (Geodetic.s84Pos 30 30)
+                    (Speed.knots 400) `shouldBe`
                 Nothing
             it "returns Nothing if interceptor speed is below minimum speed" $ do
-                let t = Track (s84Pos 34 (-50) zero) (decimalDegrees 220) (knots 600)
-                let ip = s84Pos 20 (-60) zero
-                interceptBySpeed t ip (knots 50) `shouldBe` Nothing
-            it "returns the speed needed for intercept to take place" $ do
-                let t = Track (s84Pos 34 (-50) zero) (decimalDegrees 220) (knots 600)
-                let ip = s84Pos 20 (-60) zero
-                let i = interceptBySpeed t ip (knots 700)
-                fmap interceptTime i `shouldBe` Just (seconds 2764.692)
-                fmap interceptorBearing i `shouldBe` Just (decimalDegrees 25.93541248472222)
-                fmap interceptDistance i `shouldBe` Just (kilometres 995.596069189)
+                let t =
+                        Track
+                            (Geodetic.s84Pos 34 (-50))
+                            (Angle.decimalDegrees 220)
+                            (Speed.knots 600)
+                let ip = Geodetic.s84Pos 20 (-60)
+                Kinematics.interceptBySpeed t ip (Speed.knots 50) `shouldBe` Nothing
+            it "returns the time needed for intercept to take place" $ do
+                let t =
+                        Track
+                            (Geodetic.s84Pos 34 (-50))
+                            (Angle.decimalDegrees 220)
+                            (Speed.knots 600)
+                let ip = Geodetic.s84Pos 20 (-60)
+                let i = Kinematics.interceptBySpeed t ip (Speed.knots 700)
+                fmap Kinematics.timeToIntercept i `shouldBe` Just (Duration.seconds 2764.692)
+                fmap Kinematics.interceptorBearing i `shouldBe`
+                    Just (Angle.decimalDegrees 25.935412485277777)
+                fmap Kinematics.distanceToIntercept i `shouldBe`
+                    Just (Length.kilometres 995.596069189)
             it "returns the same as intercept when called with minimum speed" $ do
-                let t = Track (s84Pos 45 50 zero) (decimalDegrees 54) (knots 500)
-                let ip = s84Pos 70 30 zero
-                let mi = intercept t ip
-                let i = interceptBySpeed t ip (fromJust (fmap interceptorSpeed mi))
-                fmap interceptTime i `shouldBe` fmap interceptTime mi
+                let t = Track (Geodetic.s84Pos 45 50) (Angle.decimalDegrees 54) (Speed.knots 500)
+                let ip = Geodetic.s84Pos 70 30
+                let mi = Kinematics.intercept t ip
+                let i = Kinematics.interceptBySpeed t ip (Kinematics.interceptorSpeed (fromJust mi))
+                fmap Kinematics.timeToIntercept i `shouldBe` fmap Kinematics.timeToIntercept mi
         describe "interceptByTime" $ do
-            it "returns Nothing if duration is zero" $
-                interceptByTime (Track (s84Pos 30 30 zero) (decimalDegrees 45) (knots 400)) (s84Pos 34 (-50) zero) zero `shouldBe`
+            it "returns Nothing if duration is 0" $
+                Kinematics.interceptByTime
+                    (Track (Geodetic.s84Pos 30 30) (Angle.decimalDegrees 45) (Speed.knots 400))
+                    (Geodetic.s84Pos 34 (-50))
+                    Duration.zero `shouldBe`
                 Nothing
             it "returns Nothing if duration is negative" $
-                interceptByTime
-                    (Track (s84Pos 30 30 zero) (decimalDegrees 45) (knots 400))
-                    (s84Pos 34 (-50) zero)
-                    (seconds (-1)) `shouldBe`
+                Kinematics.interceptByTime
+                    (Track (Geodetic.s84Pos 30 30) (Angle.decimalDegrees 45) (Speed.knots 400))
+                    (Geodetic.s84Pos 34 (-50))
+                    (Duration.seconds (-1)) `shouldBe`
                 Nothing
             it "returns Nothing if target and interceptor are at the same position" $
-                interceptByTime
-                    (Track (s84Pos 30 30 zero) (decimalDegrees 45) (knots 400))
-                    (s84Pos 30 30 zero)
-                    (seconds 10) `shouldBe`
+                Kinematics.interceptByTime
+                    (Track (Geodetic.s84Pos 30 30) (Angle.decimalDegrees 45) (Speed.knots 400))
+                    (Geodetic.s84Pos 30 30)
+                    (Duration.seconds 10) `shouldBe`
                 Nothing
             it "returns the speed needed for intercept to take place" $ do
-                let t = Track (s84Pos 34 (-50) zero) (decimalDegrees 220) (knots 600)
-                let ip = s84Pos 20 (-60) zero
-                let d = seconds 2700
-                let i = interceptByTime t ip d
-                fmap interceptorSpeed i `shouldBe` Just (knots 730.9592213822895)
-                fmap interceptorBearing i `shouldBe` Just (decimalDegrees 26.119902564166665)
-                fmap interceptPosition i `shouldBe` Just (s84Pos 28.136679674444444 (-55.455947612222225) zero)
-                fmap interceptDistance i `shouldBe` Just (kilometres 1015.302358852)
-                fmap interceptTime i `shouldBe` Just (seconds 2700)
+                let t =
+                        Track
+                            (Geodetic.s84Pos 34 (-50))
+                            (Angle.decimalDegrees 220)
+                            (Speed.knots 600)
+                let ip = Geodetic.s84Pos 20 (-60)
+                let d = Duration.seconds 2700
+                let i = Kinematics.interceptByTime t ip d
+                fmap Kinematics.interceptorSpeed i `shouldBe` Just (Speed.knots 730.9592213822895)
+                fmap Kinematics.interceptorBearing i `shouldBe`
+                    Just (Angle.decimalDegrees 26.11990256388889)
+                fmap Kinematics.interceptPosition i `shouldBe`
+                    Just (Geodetic.s84Pos 28.136679674444444 (-55.455947612222225))
+                fmap Kinematics.distanceToIntercept i `shouldBe`
+                    Just (Length.kilometres 1015.302358852)
+                fmap Kinematics.timeToIntercept i `shouldBe` Just (Duration.seconds 2700)
             it "handles the poles" $
                 -- distance between poles assuming a spherical earth (WGS84) = 20015.114352200002km
                 -- target at north pole travelling at 500km/h and true north can be intercepted from
-                -- the south pole by an interceptor travelling at ~ 19515.114352200002km/h and 0 degrees.
+                -- the south pole by an interceptor travelling at ~ 19515.114352200002km/h and 180 degrees.
              do
-                let t = Track (northPole S84) zero (kilometresPerHour 500)
-                let ip = southPole S84
-                let i = interceptByTime t ip (seconds 3600)
-                fmap interceptorSpeed i `shouldBe` Just (kilometresPerHour 19515.114352)
-                fmap interceptorBearing i `shouldBe` Just (decimalDegrees 0)
+                let t = Track (Geodetic.northPole S84) Angle.zero (Speed.kilometresPerHour 500)
+                let ip = Geodetic.southPole S84
+                let i = Kinematics.interceptByTime t ip (Duration.seconds 3600)
+                fmap Kinematics.interceptorSpeed i `shouldBe`
+                    Just (Speed.kilometresPerHour 19515.114352)
+                fmap Kinematics.interceptorBearing i `shouldBe` Just (Angle.decimalDegrees 180)
             it "handles the interceptor being at the intercept position at t" $ do
-                let tp = s84Pos 34 (-50) zero
-                let t = Track tp (decimalDegrees 220) (knots 600)
-                let d = seconds 3600
-                let ip = trackPositionAfter t d
-                let i = interceptByTime t ip d
-                fmap interceptorSpeed i `shouldBe` Just zero
-                fmap interceptorBearing i `shouldBe` initialBearing ip tp+                let tp = Geodetic.s84Pos 34 (-50)
+                let t = Track tp (Angle.decimalDegrees 220) (Speed.knots 600)
+                let d = Duration.seconds 3600
+                let ip = Kinematics.trackPositionAfter t d
+                let i = Kinematics.interceptByTime t ip d
+                fmap Kinematics.interceptorSpeed i `shouldBe` Just Speed.zero
+                fmap Kinematics.interceptorBearing i `shouldBe` GreatCircle.initialBearing ip tp
test/Data/Geo/Jord/LengthSpec.hs view
@@ -1,42 +1,50 @@-module Data.Geo.Jord.LengthSpec
-    ( spec
-    ) where
-
-import Test.Hspec
-
-import Data.Geo.Jord.Length
-import Data.Geo.Jord.Quantity
-
-spec :: Spec
-spec = do
-    describe "Reading valid lengths" $ do
-        it "reads -15.2m" $ readLength "-15.2m" `shouldBe` Just (metres (-15.2))
-        it "reads 154km" $ readLength "154km" `shouldBe` Just (kilometres 154)
-        it "reads 1000nm" $ readLength "1000nm" `shouldBe` Just (nauticalMiles 1000)
-        it "reads 25000ft" $ readLength "25000ft" `shouldBe` Just (feet 25000)
-    describe "Reading invalid lengths" $ do
-        it "fails to read 5" $ readLength "5" `shouldBe` Nothing
-        it "fails to read 5nmi" $ readLength "5nmi" `shouldBe` Nothing
-    describe "Showing lengths" $ do
-        it "shows length in metres when <= 10000 m" $ show (metres 5) `shouldBe` "5.0m"
-        it "shows length in kilometres when > 10000 m" $
-            show (kilometres 1000) `shouldBe` "1000.0km"
-    describe "Converting lengths" $ do
-        it "converts metres to kilometres" $ toKilometres (metres 1000) `shouldBe` 1.0
-        it "converts metres to nautical miles" $
-            toNauticalMiles (metres 1000) `shouldBe` 0.5399568034557235
-        it "converts kilometres to nautical miles" $
-            toNauticalMiles (kilometres 1000) `shouldBe` 539.9568034557235
-        it "converts nautical miles to metres" $ toMetres (nauticalMiles 10.5) `shouldBe` 19446
-        it "converts nautical miles to kilometres" $
-            toKilometres (nauticalMiles 10.5) `shouldBe` 19.446
-        it "converts feet to metres" $ toMetres (feet 25000) `shouldBe` 7620
-        it "converts metres to feet" $ toFeet (metres 7620) `shouldBe` 25000
-    describe "Resolution" $ do
-        it "handles 1 kilometre" $ toKilometres (kilometres 1) `shouldBe` 1
-        it "handles 1 metre" $ toMetres (metres 1) `shouldBe` 1
-        it "handles 1 nautical mile" $ toNauticalMiles (nauticalMiles 1) `shouldBe` 1
-        it "handles 1 foot" $ toFeet (feet 1) `shouldBe` 1
-    describe "Adding/Subtracting lengths" $ do
-        it "adds lengths" $ add (kilometres 1000) (metres 1000) `shouldBe` metres 1001000
-        it "subtracts lengths" $ sub (metres 1000) (nauticalMiles 10.5) `shouldBe` metres (-18446)
+module Data.Geo.Jord.LengthSpec+    ( spec+    ) where++import Test.Hspec++import qualified Data.Geo.Jord.Length as Length++spec :: Spec+spec = do+    describe "Reading valid lengths" $ do+        it "reads -15.2m" $ Length.read "-15.2m" `shouldBe` Just (Length.metres (-15.2))+        it "reads 154km" $ Length.read "154km" `shouldBe` Just (Length.kilometres 154)+        it "reads 1000nm" $ Length.read "1000nm" `shouldBe` Just (Length.nauticalMiles 1000)+        it "reads 25000ft" $ Length.read "25000ft" `shouldBe` Just (Length.feet 25000)+    describe "Reading invalid lengths" $ do+        it "fails to read 5" $ Length.read "5" `shouldBe` Nothing+        it "fails to read 5nmi" $ Length.read "5nmi" `shouldBe` Nothing+    describe "Showing lengths" $ do+        it "shows length in Length.metres when <= 10000 m" $+            show (Length.metres 5) `shouldBe` "5.0m"+        it "shows length in Length.kilometres when > 10000 m" $+            show (Length.kilometres 1000) `shouldBe` "1000.0km"+    describe "Converting lengths" $ do+        it "converts Length.metres to Length.kilometres" $+            Length.toKilometres (Length.metres 1000) `shouldBe` 1.0+        it "converts Length.metres to nautical miles" $+            Length.toNauticalMiles (Length.metres 1000) `shouldBe` 0.5399568034557235+        it "converts Length.kilometres to nautical miles" $+            Length.toNauticalMiles (Length.kilometres 1000) `shouldBe` 539.9568034557235+        it "converts nautical miles to Length.metres" $+            Length.toMetres (Length.nauticalMiles 10.5) `shouldBe` 19446+        it "converts nautical miles to Length.kilometres" $+            Length.toKilometres (Length.nauticalMiles 10.5) `shouldBe` 19.446+        it "converts Length.feet to Length.metres" $+            Length.toMetres (Length.feet 25000) `shouldBe` 7620+        it "converts Length.metres to Length.feet" $+            Length.toFeet (Length.metres 7620) `shouldBe` 25000+    describe "Resolution" $ do+        it "handles 1 kilometre" $ Length.toKilometres (Length.kilometres 1) `shouldBe` 1+        it "handles 1 metre" $ Length.toMetres (Length.metres 1) `shouldBe` 1+        it "handles 1 nautical mile" $ Length.toNauticalMiles (Length.nauticalMiles 1) `shouldBe` 1+        it "handles 1 foot" $ Length.toFeet (Length.feet 1) `shouldBe` 1+    describe "Adding/Subtracting lengths" $ do+        it "adds lengths" $+            Length.add (Length.kilometres 1000) (Length.metres 1000) `shouldBe`+            Length.metres 1001000+        it "subtracts lengths" $+            Length.subtract (Length.metres 1000) (Length.nauticalMiles 10.5) `shouldBe`+            Length.metres (-18446)
− test/Data/Geo/Jord/LocalFramesSpec.hs
@@ -1,78 +0,0 @@-module Data.Geo.Jord.LocalFramesSpec
-    ( spec
-    ) where
-
-import Test.Hspec
-
-import Data.Geo.Jord.LocalFrames
-import Data.Geo.Jord.Position
-
-spec :: Spec
-spec = do
-    describe "Ellipsoidal earth model" $ do
-        describe "target" $ do
-            it "return the given position if NED norm = 0" $ do
-                let p0 = wgs84Pos 53.320556 (-1.729722) zero
-                let d = ned zero zero zero
-                targetN p0 d `shouldBe` p0
-            it "computes the target position from p0 and NED" $ do
-                let p0 = wgs84Pos 49.66618 3.45063 zero
-                let d = nedMetres (-86126) (-78900) 1069
-                targetN p0 d `shouldBe` wgs84Pos 48.886668961666665 2.3747212533333335 (metres 0.198937)
-            it "computes the target position from p0 and vector in Frame B" $ do
-                let p0 = wgs84Pos 49.66618 3.45063 zero
-                let y = decimalDegrees 10 -- yaw
-                let r = decimalDegrees 20 -- roll
-                let p = decimalDegrees 30 -- pitch
-                let d = deltaMetres 3000 2000 100
-                target p0 (frameB y r p) d `shouldBe` wgs84Pos 49.69180157805555 3.4812670616666668 (metres 6.007736)
-        describe "nedBetween" $ do
-            it "computes NED between surface positions" $ do
-                let p1 = wgs84Pos 49.66618 3.45063 zero
-                let p2 = wgs84Pos 48.88667 2.37472 zero
-                let d = nedBetween p1 p2
-                d `shouldBe` nedMetres (-86125.880548) (-78900.087817) 1069.19844
-            it "computes NED between positions" $ do
-                let p1 = wgs84Pos 49.66618 3.45063 (metres 12000)
-                let p2 = wgs84Pos 48.88667 2.37472 (metres 15000)
-                let d = nedBetween p1 p2
-                d `shouldBe` nedMetres (-86328.623924) (-79085.290891) (-1928.287848)
-        describe "deltaBetween" $
-            it "computes delta between positions in frame L" $ do
-                let p1 = wgs84Pos 1 2 (metres (-3))
-                let p2 = wgs84Pos 4 5 (metres (-6))
-                let w = decimalDegrees 5 -- wander azimuth
-                let d = deltaBetween p1 p2 (frameL w)
-                d `shouldBe` deltaMetres 359490.578214 302818.522536 17404.271362
-        describe "deltaBetween and target consistency" $
-            it "computes targetN p1 (nedBetween p1 p2) = p2" $ do
-                let p1 = wgs84Pos 49.66618 3.45063 zero
-                let p2 = wgs84Pos 48.88667 2.37472 zero
-                targetN p1 (nedBetween p1 p2) `shouldBe` p2
-        describe "rotation matrix to/from earth-fixed frame" $ do
-            it "computes rEN (frame N to earth-fixed frame)" $ do
-                let p = wgs84Pos 49.66618 3.45063 zero
-                let f = frameN p
-                rEF f `shouldBe`
-                    [ Vector3d (-0.7609044147683025) (-6.018845511258954e-2) (-0.646066421861767)
-                    , Vector3d (-4.5880841733693466e-2) 0.9981870315082038 (-3.895636649699221e-2)
-                    , Vector3d 0.6472398473115779 0.0 (-0.7622864160222752)
-                    ]
-            it "computes rEB (frame B to earth-fixed frame)" $ do
-                let p = wgs84Pos 49.66618 3.45063 zero
-                let f = frameB (decimalDegrees 10) (decimalDegrees 20) (decimalDegrees 30) p
-                rEF f `shouldBe`
-                    [ Vector3d (-0.49300713580470057) (-0.37038991706707025) (-0.7872453705044535)
-                    , Vector3d 0.1337450488624887 0.8618333991596926 (-0.4892396692804258)
-                    , Vector3d 0.8596837941652826 (-0.3464888186188679) (-0.375352198104241)
-                    ]
-    describe "North, East, Down delta" $ do
-        describe "slantRange" $
-            it "computes the slant range of a NED vector" $
-            slantRange (nedMetres (-86126) (-78900) 1069) `shouldBe` metres 116807.707952
-        describe "bearing" $
-            it "computes the bearing of a NED vector" $
-            bearing (nedMetres (-86126) (-78900) 1069) `shouldBe` decimalDegrees 222.49278897666667
-        describe "elevation" $
-            it "computes the elevation of a NED vector from horizontal" $
-            elevation (nedMetres (-86126) (-78900) 1069) `shouldBe` decimalDegrees (-0.5243664513888889)
+ test/Data/Geo/Jord/LocalSpec.hs view
@@ -0,0 +1,101 @@+module Data.Geo.Jord.LocalSpec
+    ( spec
+    ) where
+
+import Test.Hspec
+
+import qualified Data.Geo.Jord.Angle as Angle
+import qualified Data.Geo.Jord.Geodetic as Geodetic
+import qualified Data.Geo.Jord.Length as Length
+import Data.Geo.Jord.Local (Ned(..))
+import qualified Data.Geo.Jord.Local as Local
+import qualified Data.Geo.Jord.Math3d as Math3d
+import Data.Geo.Jord.Models (WGS84(..))
+
+spec :: Spec
+spec = do
+    describe "Ellipsoidal earth model" $ do
+        describe "destination" $ do
+            it "return the given position if NED norm = 0" $ do
+                let p0 = Geodetic.latLongHeightPos 53.320556 (-1.729722) Length.zero WGS84
+                let d = Ned Length.zero Length.zero Length.zero
+                Local.destinationN p0 d `shouldBe` p0
+            it "computes the destination position from p0 and NED" $ do
+                let p0 = Geodetic.latLongHeightPos 49.66618 3.45063 Length.zero WGS84
+                let d = Local.nedMetres (-86126) (-78900) 1069
+                Local.destinationN p0 d `shouldBe`
+                    Geodetic.latLongHeightPos
+                        48.886668961666665
+                        2.3747212533333335
+                        (Length.metres 0.198937)
+                        WGS84
+            it "computes the destination position from p0 and vector in Frame B" $ do
+                let p0 = Geodetic.latLongHeightPos 49.66618 3.45063 Length.zero WGS84
+                let y = Angle.decimalDegrees 10 -- yaw
+                let r = Angle.decimalDegrees 20 -- roll
+                let p = Angle.decimalDegrees 30 -- pitch
+                let d = Local.deltaMetres 3000 2000 100
+                Local.destination p0 (Local.frameB y r p) d `shouldBe`
+                    Geodetic.latLongHeightPos
+                        49.69180157805555
+                        3.4812670616666668
+                        (Length.metres 6.007735)
+                        WGS84
+        describe "nedBetween" $ do
+            it "computes NED between surface positions" $ do
+                let p1 = Geodetic.latLongHeightPos 49.66618 3.45063 Length.zero WGS84
+                let p2 = Geodetic.latLongHeightPos 48.88667 2.37472 Length.zero WGS84
+                let d = Local.nedBetween p1 p2
+                d `shouldBe` Local.nedMetres (-86125.880549) (-78900.087818) 1069.19844
+            it "computes NED between positions" $ do
+                let p1 = Geodetic.latLongHeightPos 49.66618 3.45063 (Length.metres 12000) WGS84
+                let p2 = Geodetic.latLongHeightPos 48.88667 2.37472 (Length.metres 15000) WGS84
+                let d = Local.nedBetween p1 p2
+                d `shouldBe` Local.nedMetres (-86328.623924) (-79085.290891) (-1928.287847)
+        describe "deltaBetween" $
+            it "computes delta between positions in frame L" $ do
+                let p1 = Geodetic.latLongHeightPos 1 2 (Length.metres (-3)) WGS84
+                let p2 = Geodetic.latLongHeightPos 4 5 (Length.metres (-6)) WGS84
+                let w = Angle.decimalDegrees 5 -- wander azimuth
+                let d = Local.deltaBetween p1 p2 (Local.frameL w)
+                d `shouldBe` Local.deltaMetres 359490.578214 302818.522536 17404.271362
+        describe "deltaBetween and destination consistency" $
+            it "computes targetN p1 (nedBetween p1 p2) = p2" $ do
+                let p1 = Geodetic.latLongHeightPos 49.66618 3.45063 Length.zero WGS84
+                let p2 = Geodetic.latLongHeightPos 48.88667 2.37472 Length.zero WGS84
+                Local.destinationN p1 (Local.nedBetween p1 p2) `shouldBe` p2
+        describe "rotation matrix to/from earth-fixed frame" $ do
+            it "computes rEN (frame N to earth-fixed frame)" $ do
+                let p = Geodetic.latLongHeightPos 0.0 0.0 Length.zero WGS84
+                let f = Local.frameN p
+                Local.rEF f `shouldBe`
+                    [Math3d.vec3 0.0 0.0 (-1.0), Math3d.vec3 0.0 1.0 0.0, Math3d.vec3 1.0 0.0 0.0]
+            it "computes rEB (frame B to earth-fixed frame)" $ do
+                let p = Geodetic.latLongHeightPos 49.66618 3.45063 Length.zero WGS84
+                let f =
+                        Local.frameB
+                            (Angle.decimalDegrees 10)
+                            (Angle.decimalDegrees 20)
+                            (Angle.decimalDegrees 30)
+                            p
+                Local.rEF f `shouldBe`
+                    [ Math3d.vec3
+                          (-0.49300713580470057)
+                          (-0.37038991706707025)
+                          (-0.7872453705044535)
+                    , Math3d.vec3 0.1337450488624887 0.8618333991596926 (-0.4892396692804258)
+                    , Math3d.vec3 0.8596837941652826 (-0.3464888186188679) (-0.375352198104241)
+                    ]
+    describe "North, East, Down delta" $ do
+        describe "slantRange" $
+            it "computes the slant range of a NED vector" $
+            Local.slantRange (Local.nedMetres (-86126) (-78900) 1069) `shouldBe`
+            Length.metres 116807.707952
+        describe "bearing" $
+            it "computes the bearing of a NED vector" $
+            Local.bearing (Local.nedMetres (-86126) (-78900) 1069) `shouldBe`
+            Angle.decimalDegrees 222.49278897666667
+        describe "elevation" $
+            it "computes the elevation of a NED vector from horizontal" $
+            Local.elevation (Local.nedMetres (-86126) (-78900) 1069) `shouldBe`
+            Angle.decimalDegrees (-0.5243664513888889)
+ test/Data/Geo/Jord/Places.hs view
@@ -0,0 +1,61 @@+module Data.Geo.Jord.Places where
+
+import Data.Geo.Jord.Geodetic (HorizontalPosition, s84Pos)
+import Data.Geo.Jord.Models (S84)
+
+antananrivo :: HorizontalPosition S84
+antananrivo = s84Pos (-18.8792) 47.5079
+
+bangui :: HorizontalPosition S84
+bangui = s84Pos 4.3947 18.5582
+
+copenhagen :: HorizontalPosition S84
+copenhagen = s84Pos 55.6761 12.5683
+
+dar_es_salaam :: HorizontalPosition S84
+dar_es_salaam = s84Pos (-6.7924) 39.2083
+
+djibouti :: HorizontalPosition S84
+djibouti = s84Pos 11.8251 42.5903
+
+goteborg :: HorizontalPosition S84
+goteborg = s84Pos 57.7 11.966667
+
+harare :: HorizontalPosition S84
+harare = s84Pos (-17.8252) 31.0335
+
+hassleholm :: HorizontalPosition S84
+hassleholm = s84Pos 56.1589 13.7668
+
+helsingborg :: HorizontalPosition S84
+helsingborg = s84Pos 56.0465 12.6945
+
+hoor :: HorizontalPosition S84
+hoor = s84Pos 55.9349 13.5396
+
+horby :: HorizontalPosition S84
+horby = s84Pos 55.8576 13.6642
+
+juba :: HorizontalPosition S84
+juba = s84Pos 4.8594 31.5713
+
+kinshasa :: HorizontalPosition S84
+kinshasa = s84Pos (-4.4419) 15.2663
+
+kristianstad :: HorizontalPosition S84
+kristianstad = s84Pos 56.0294 14.1567
+
+lund :: HorizontalPosition S84
+lund = s84Pos 55.7047 13.1910
+
+malmo :: HorizontalPosition S84
+malmo = s84Pos 55.6050 13.0038
+
+narobi :: HorizontalPosition S84
+narobi = s84Pos (-1.2921) 36.8219
+
+stockholm :: HorizontalPosition S84
+stockholm = s84Pos 59.35 18.066667
+
+ystad :: HorizontalPosition S84
+ystad = s84Pos 55.4295 13.82
+ test/Data/Geo/Jord/PolygonSpec.hs view
@@ -0,0 +1,224 @@+module Data.Geo.Jord.PolygonSpec
+    ( spec
+    ) where
+
+import Data.Either (fromRight, isRight)
+import Data.Maybe (mapMaybe)
+
+import Test.Hspec
+
+import qualified Data.Geo.Jord.Angle as Angle
+import Data.Geo.Jord.Geodetic (HorizontalPosition)
+import qualified Data.Geo.Jord.Geodetic as Geodetic
+import qualified Data.Geo.Jord.GreatCircle as GreatCircle
+import Data.Geo.Jord.Length (Length)
+import qualified Data.Geo.Jord.Length as Length
+import Data.Geo.Jord.Model (Spherical)
+import Data.Geo.Jord.Places
+import Data.Geo.Jord.Polygon (Error(..), Polygon)
+import qualified Data.Geo.Jord.Polygon as Polygon
+import Data.Geo.Jord.Triangle (Triangle)
+import qualified Data.Geo.Jord.Triangle as Triangle
+
+spec :: Spec
+spec = do
+    describe "simple" $ do
+        it "returns an error if less than 3 vertices" $ do
+            let ps = [Geodetic.s84Pos (-2) (-2), Geodetic.s84Pos 2 2]
+            Polygon.simple ps `shouldBe` Left NotEnoughVertices
+        it "returns an error if edges self-intersects" $ do
+            let ps =
+                    [ Geodetic.s84Pos (-2) (-2)
+                    , Geodetic.s84Pos 2 (-2)
+                    , Geodetic.s84Pos 3 0
+                    , Geodetic.s84Pos (-2) 2
+                    , Geodetic.s84Pos 2 2
+                    ]
+            Polygon.simple ps `shouldBe` Left SeflIntersectingEdge
+        it "returns an error if edges self-intersects (quad)" $ do
+            let ps =
+                    [ Geodetic.s84Pos (-2) (-2)
+                    , Geodetic.s84Pos 2 (-2)
+                    , Geodetic.s84Pos (-2) 2
+                    , Geodetic.s84Pos 2 2
+                    ]
+            Polygon.simple ps `shouldBe` Left SeflIntersectingEdge
+        it "returns a concave polygon (4 vertices in clockwise order)" $ do
+            let p = Polygon.simple [ystad, hoor, helsingborg, kristianstad]
+            fmap Polygon.concave p `shouldBe` Right True
+        it "returns a concave polygon (4 vertices in counterclockwise order)" $ do
+            let p = Polygon.simple [ystad, kristianstad, helsingborg, hoor]
+            fmap Polygon.concave p `shouldBe` Right True
+        it "returns a concave polygon (5 vertices in clockwise order)" $ do
+            let p = Polygon.simple [ystad, malmo, lund, helsingborg, kristianstad]
+            fmap Polygon.concave p `shouldBe` Right True
+        it "returns a concave polygon (5 vertices in counterclockwise order)" $ do
+            let p = Polygon.simple [ystad, lund, kristianstad, helsingborg, malmo]
+            fmap Polygon.concave p `shouldBe` Right True
+        it "returns a concave polygon (7 vertices in clockwise order)" $ do
+            let p =
+                    Polygon.simple
+                        [bangui, juba, djibouti, antananrivo, dar_es_salaam, kinshasa, narobi]
+            fmap Polygon.concave p `shouldBe` Right True
+        it "returns a convex polygon (4 vertices clockwise order)" $ do
+            let p = Polygon.simple [ystad, malmo, helsingborg, kristianstad]
+            fmap Polygon.concave p `shouldBe` Right False
+        it "returns a convex polygon (4 vertices counterclockwise order)" $ do
+            let p = Polygon.simple [ystad, kristianstad, helsingborg, malmo]
+            fmap Polygon.concave p `shouldBe` Right False
+        it "returns a convex polygon (6 vertices in clockwise order)" $ do
+            let p = Polygon.simple [bangui, juba, narobi, dar_es_salaam, harare, kinshasa]
+            fmap Polygon.concave p `shouldBe` Right False
+        it "returns a convex polygon (triangle)" $ do
+            let p = Polygon.simple [ystad, malmo, helsingborg]
+            fmap Polygon.concave p `shouldBe` Right False
+    describe "circle" $ do
+        it "returns a error if radius <= 0" $ do
+            Polygon.circle malmo Length.zero 10 `shouldBe` Left InvalidRadius
+            Polygon.circle malmo (Length.metres (-1)) 10 `shouldBe` Left InvalidRadius
+        it "returns a error if radius <= 0" $ do
+            Polygon.circle malmo (Length.metres 1000) 2 `shouldBe` Left NotEnoughVertices
+        it "returns a convex polygon" $ do
+            let c = Geodetic.s84Pos 55.6050 13.0038
+            let r = Length.metres 2000.0
+            let nb = 10
+            let n = fromIntegral nb :: Double
+            let eBrngs = take nb (iterate (\x -> x + 360.0 / n) 0.0)
+            let ep = Polygon.circle c r nb
+            assertPoly ep c r nb eBrngs
+    describe "arc" $ do
+        it "returns an error if radius <= 0" $ do
+            Polygon.arc malmo Length.zero Angle.zero Angle.zero 10 `shouldBe` Left InvalidRadius
+            Polygon.arc malmo (Length.metres (-1)) Angle.zero Angle.zero 10 `shouldBe`
+                Left InvalidRadius
+        it "returns an error if radius <= 0" $ do
+            Polygon.arc malmo (Length.metres 1000) Angle.zero Angle.zero 2 `shouldBe`
+                Left NotEnoughVertices
+        it "returns an error if start = end angle" $ do
+            Polygon.arc
+                malmo
+                (Length.metres 1000)
+                (Angle.decimalDegrees 154)
+                (Angle.decimalDegrees 154)
+                4 `shouldBe`
+                Left EmptyArcRange
+        it "returns a convex polygon (start < end)" $ do
+            let c = Geodetic.s84Pos 55.6050 13.0038
+            let r = Length.metres 2000.0
+            let nb = 10
+            let sa = Angle.decimalDegrees 36
+            let ea = Angle.decimalDegrees 289
+            let ep = Polygon.arc c r sa ea nb
+            let n = fromIntegral nb :: Double
+            let eBrngs = take nb (iterate (\x -> x + (289.0 - 36.0) / (n - 1.0)) 36.0)
+            assertPoly ep c r nb eBrngs
+        it "returns a convex polygon (start > end)" $ do
+            let c = Geodetic.s84Pos 55.6050 13.0038
+            let r = Length.metres 2000.0
+            let nb = 10
+            let sa = Angle.decimalDegrees 180
+            let ea = Angle.decimalDegrees 90
+            let ep = Polygon.arc c r sa ea nb
+            let n = fromIntegral nb :: Double
+            let eBrngs =
+                    take
+                        nb
+                        (fmap
+                             (\x ->
+                                  if x >= 360.0
+                                      then x - 360.0
+                                      else x)
+                             (iterate (\x -> x + 270.0 / (n - 1.0)) 180.0))
+            assertPoly ep c r nb eBrngs
+    describe "triangulate" $ do
+        it "triangluates a concave clockwise quad (1/2)" $ do
+            let p = simple [kristianstad, ystad, helsingborg, horby]
+            let ts = Polygon.triangulate p
+            ts `shouldBe` [triangle horby kristianstad ystad, triangle ystad helsingborg horby]
+        it "triangluates a concave clockwise quad (2/2)" $ do
+            let p = simple [ystad, helsingborg, kristianstad, horby]
+            let ts = Polygon.triangulate p
+            ts `shouldBe`
+                [triangle horby ystad helsingborg, triangle helsingborg kristianstad horby]
+        it "triangluates a concave clockwise pentagon" $ do
+            let p = simple [ystad, malmo, lund, helsingborg, kristianstad]
+            let ts = Polygon.triangulate p
+            ts `shouldBe`
+                [ triangle kristianstad ystad malmo
+                , triangle kristianstad malmo lund
+                , triangle lund helsingborg kristianstad
+                ]
+        it "triangluates a concave clockwise polygon with 7 vertices" $ do
+            let p = simple [bangui, juba, djibouti, antananrivo, dar_es_salaam, kinshasa, narobi]
+            let ts = Polygon.triangulate p
+            ts `shouldBe`
+                [ triangle narobi bangui juba
+                , triangle narobi juba djibouti
+                , triangle narobi djibouti antananrivo
+                , triangle narobi antananrivo dar_es_salaam
+                , triangle dar_es_salaam kinshasa narobi
+                ]
+        it "triangluates a concave counterclockwise pentagon" $ do
+            let p = simple [ystad, lund, kristianstad, helsingborg, malmo]
+            let ts = Polygon.triangulate p
+            ts `shouldBe`
+                [ triangle helsingborg kristianstad lund
+                , triangle malmo helsingborg lund
+                , triangle malmo lund ystad
+                ]
+        it "triangluates a convex clockwise quad" $ do
+            let p = simple [ystad, malmo, helsingborg, kristianstad]
+            let ts = Polygon.triangulate p
+            ts `shouldBe`
+                [triangle kristianstad ystad malmo, triangle malmo helsingborg kristianstad]
+        it "triangulates a convex polygon with 6 vertices" $ do
+            let p = simple [bangui, juba, narobi, dar_es_salaam, harare, kinshasa]
+            let ts = Polygon.triangulate p
+            ts `shouldBe`
+                [ triangle kinshasa bangui juba
+                , triangle kinshasa juba narobi
+                , triangle kinshasa narobi dar_es_salaam
+                , triangle dar_es_salaam harare kinshasa
+                ]
+        it "triangluates a convex counterclockwise quad" $ do
+            let p = simple [ystad, kristianstad, helsingborg, malmo]
+            let ts = Polygon.triangulate p
+            ts `shouldBe`
+                [triangle ystad malmo helsingborg, triangle helsingborg kristianstad ystad]
+        it "triangluates a triangle" $ do
+            let p = simple [ystad, malmo, helsingborg]
+            let ts = Polygon.triangulate p
+            ts `shouldBe` [triangle ystad malmo helsingborg]
+
+assertPoly ::
+       (Spherical a)
+    => Either Error (Polygon a)
+    -> HorizontalPosition a
+    -> Length
+    -> Int
+    -> [Double]
+    -> IO ()
+assertPoly ep c r nb eBrngs = do
+    isRight ep `shouldBe` True
+    let p = fromRight (error "should be right") ep
+    Polygon.concave p `shouldBe` False
+    let vs = Polygon.vertices p
+    length vs `shouldBe` nb
+    let aDists = fmap (GreatCircle.distance c) vs
+    let eDists = replicate nb r
+    let distErrs = zipWith (\l1 l2 -> abs (Length.toMetres (Length.subtract l1 l2))) aDists eDists
+    all (< 0.01) distErrs `shouldBe` True
+    let aBrngs = mapMaybe (GreatCircle.initialBearing c) vs
+    let brngErrs = zipWith (\a1 a2 -> abs (Angle.toDecimalDegrees a1 - a2)) aBrngs eBrngs
+    all (< 0.01) brngErrs `shouldBe` True
+
+simple :: (Spherical a) => [HorizontalPosition a] -> Polygon a
+simple vs = fromRight (error "should be right") (Polygon.simple vs)
+
+triangle ::
+       (Spherical a)
+    => HorizontalPosition a
+    -> HorizontalPosition a
+    -> HorizontalPosition a
+    -> Triangle a
+triangle = Triangle.unsafeMake
− test/Data/Geo/Jord/PositionSpec.hs
@@ -1,71 +0,0 @@-module Data.Geo.Jord.PositionSpec
-    ( spec
-    ) where
-
-import Test.Hspec
-
-import Data.Geo.Jord.Position
-
-spec :: Spec
-spec = do
-    describe "antipode" $ do
-        it "returns the antipodal position" $ do
-            antipode (wgs84Pos 45 154 (metres 15000)) `shouldBe` wgs84Pos (-45) (-26) (metres 15000)
-            antipode (s84Pos 45 154 (metres 15000)) `shouldBe` s84Pos (-45) (-26) (metres 15000)
-        it "returns the south pole when called with the north pole" $ do
-            latitude (antipode (northPole WGS84)) `shouldBe` latitude (southPole WGS84)
-            latitude (antipode (northPole S84)) `shouldBe` latitude (southPole S84)
-        it "returns the north pole when called with the south pole" $ do
-            latitude (antipode (southPole WGS84)) `shouldBe` latitude (northPole WGS84)
-            latitude (antipode (southPole S84)) `shouldBe` latitude (northPole S84)
-    describe "wrapping latitude/longitude" $ do
-        it "wraps a Earth position to [-90°, 90°] and [-180°, 180°]" $
-            latLong (s84Pos 91 54 zero) `shouldBe` (89, -126)
-        it "wraps a Mars position to [-90°, 90°] and [0°, 360°]" $
-            latLong (latLongPos 91 (-150) Mars2000) `shouldBe` (89, 210)
-    describe "Geodetic <=> Geocentric (Ellipsoidal)" $ do
-        it "n-vector <=> Geocentric" $ do
-            let p = nvectorPos 0.5 0.5 0.7071 WGS84
-            let g = geocentricMetresPos 3194434.410968 3194434.410968 4487326.819509 WGS84
-            p `shouldBe` g
-        it "latitude, longitude and height <=> Geocentric" $ do
-            let refLlh =
-                    [ latLongHeightPos 0 0 zero WGS84
-                    , latLongHeightPos 90 0 zero WGS84
-                    , latLongHeightPos (-90) 0 zero WGS84
-                    , latLongHeightPos 45.0 45.0 (metres 500) WGS84
-                    , latLongHeightPos (-45) (-45) (metres 500) WGS84
-                    ]
-            let refGeocentrics =
-                    [ geocentricMetresPos 6378137 0 0 WGS84
-                    , geocentricMetresPos 0 0 6356752.314245 WGS84
-                    , geocentricMetresPos 0 0 (-6356752.314245) WGS84
-                    , geocentricMetresPos 3194669.145061 3194669.145061 4487701.962256 WGS84
-                    , geocentricMetresPos
-                          3194669.145061
-                          (-3194669.145061)
-                          (-4487701.962256)
-                          WGS84
-                    ]
-            refLlh `shouldBe` refGeocentrics
-    describe "Geodetic <=> Geocentric (Spherical)" $ do
-        it "n-vector <=> Geocentric" $ do
-            let p = nvectorPos 0.5 0.5 0.7071 S84
-            let g = geocentricMetresPos 3185519.660311 3185519.660311 4504961.903612 S84
-            p `shouldBe` g
-        it "latitude, longitude and height <=> Geocentric" $ do
-            let refLlh =
-                    [ latLongHeightPos 0 0 zero S84
-                    , latLongHeightPos 90 0 zero S84
-                    , latLongHeightPos (-90) 0 zero S84
-                    , latLongHeightPos 45.0 45.0 (metres 500) S84
-                    , latLongHeightPos (-45) (-45) (metres 500) S84
-                    ]
-            let refGeocentrics =
-                    [ geocentricMetresPos 6371008.771415 0 0 S84
-                    , geocentricMetresPos 0 0 6371008.771415 S84
-                    , geocentricMetresPos 0 0 (-6371008.771415) S84
-                    , geocentricMetresPos 3185754.385708 3185754.385708 4505337.058657 S84
-                    , geocentricMetresPos 3185754.385708 (-3185754.385708) (-4505337.058657) S84
-                    ]
-            refLlh `shouldBe` refGeocentrics
+ test/Data/Geo/Jord/PositionsSpec.hs view
@@ -0,0 +1,126 @@+module Data.Geo.Jord.PositionsSpec
+    ( spec
+    ) where
+
+import Test.Hspec
+
+import Data.Maybe (fromJust)
+
+import qualified Data.Geo.Jord.Geocentric as Geocentric
+import qualified Data.Geo.Jord.Geodetic as Geodetic
+import qualified Data.Geo.Jord.Length as Length
+import Data.Geo.Jord.Model (Epoch(..))
+import Data.Geo.Jord.Models
+import qualified Data.Geo.Jord.Positions as Positions
+import qualified Data.Geo.Jord.Tx as Tx
+import qualified Data.Geo.Jord.Txs as Txs
+
+spec :: Spec
+spec = do
+    describe "Geodetic <=> Geocentric (Ellipsoidal)" $ do
+        it "n-vector <=> Geocentric" $ do
+            let p = Geodetic.nvectorHeightPos 0.5 0.5 0.7071067811865475 Length.zero WGS84
+            let g = Geocentric.metresPos 3194419.145061 3194419.145061 4487348.408866 WGS84
+            Positions.toGeocentric p `shouldBe` g
+            Positions.toGeodetic g `shouldBe` p
+        it "latitude, longitude and height <=> Geocentric" $ do
+            let refLlh =
+                    [ Geodetic.latLongHeightPos 0 0 Length.zero WGS84
+                    , Geodetic.latLongHeightPos 90 0 Length.zero WGS84
+                    , Geodetic.latLongHeightPos (-90) 0 Length.zero WGS84
+                    , Geodetic.latLongHeightPos 45.0 45.0 (Length.metres 500) WGS84
+                    , Geodetic.latLongHeightPos (-45) (-45) (Length.metres 500) WGS84
+                    ]
+            let refGeocentrics =
+                    [ Geocentric.metresPos 6378137 0 0 WGS84
+                    , Geocentric.metresPos 0 0 6356752.314245 WGS84
+                    , Geocentric.metresPos 0 0 (-6356752.314245) WGS84
+                    , Geocentric.metresPos 3194669.145061 3194669.145061 4487701.962256 WGS84
+                    , Geocentric.metresPos 3194669.145061 (-3194669.145061) (-4487701.962256) WGS84
+                    ]
+            fmap Positions.toGeocentric refLlh `shouldBe` refGeocentrics
+            fmap Positions.toGeodetic refGeocentrics `shouldBe` refLlh
+    describe "Geodetic <=> Geocentric (Spherical)" $ do
+        it "n-vector <=> Geocentric" $ do
+            let p = Geodetic.nvectorHeightPos 0.5 0.5 0.7071 Length.zero S84
+            let g = Geocentric.metresPos 3185519.660307 3185519.660307 4504961.903617 S84
+            Positions.toGeocentric p `shouldBe` g
+            Positions.toGeodetic g `shouldBe` p
+        it "latitude, longitude and height <=> Geocentric" $ do
+            let refLlh =
+                    [ Geodetic.latLongHeightPos 0 0 Length.zero S84
+                    , Geodetic.latLongHeightPos 90 0 Length.zero S84
+                    , Geodetic.latLongHeightPos (-90) 0 Length.zero S84
+                    , Geodetic.latLongHeightPos 45.0 45.0 (Length.metres 500) S84
+                    , Geodetic.latLongHeightPos (-45) (-45) (Length.metres 500) S84
+                    ]
+            let refGeocentrics =
+                    [ Geocentric.metresPos 6371008.771415 0 0 S84
+                    , Geocentric.metresPos 0 0 6371008.771415 S84
+                    , Geocentric.metresPos 0 0 (-6371008.771415) S84
+                    , Geocentric.metresPos 3185754.385708 3185754.385708 4505337.058657 S84
+                    , Geocentric.metresPos 3185754.385708 (-3185754.385708) (-4505337.058657) S84
+                    ]
+            fmap Positions.toGeocentric refLlh `shouldBe` refGeocentrics
+            fmap Positions.toGeodetic refGeocentrics `shouldBe` refLlh
+    describe "coordinates transformation - fixed" $ do
+        it "returns the initial coordinates if all parameters are 0" $ do
+            let tx7 = Tx.params7 (0, 0, 0) 0 (0, 0, 0)
+            let pWGS84 = Geocentric.metresPos 4193790.895437 454436.195118 4768166.813801 WGS84
+            Positions.transform' pWGS84 WGS84 tx7 `shouldBe` pWGS84
+        it "uses the 7-parameter transformation" $ do
+            let pWGS84 = Geocentric.metresPos 4193790.895437 454436.195118 4768166.813801 WGS84
+            let tx = Tx.params Txs.from_WGS84_to_NAD83
+            let pNAD83 = Positions.transform' pWGS84 NAD83 tx
+            pNAD83 `shouldBe` Geocentric.metresPos 4193792.080781 454433.921298 4768166.15479 NAD83
+        it "returns the initial coordinates when doing round-trip (direct -> inverse)" $ do
+            let tx = Tx.params Txs.from_WGS84_to_ETRS89
+            let itx = Tx.inverseParams tx
+            let pWGS84 = Geocentric.metresPos 3194419.145061 3194419.145061 4487348.408866 WGS84
+            Positions.transform' (Positions.transform' pWGS84 NAD83 tx) WGS84 itx `shouldBe` pWGS84
+    describe "coordinates transformation - time dependent" $ do
+        it "returns the initial coordinates if all parameters are 0" $ do
+            let tx15 =
+                    Tx.Params15
+                        (Epoch 2010)
+                        (Tx.params7 (0, 0, 0) 0 (0, 0, 0))
+                        (Tx.rates (0, 0, 0) 0 (0, 0, 0))
+            let pWGS84 =
+                    Geocentric.metresPos 4193790.895437 454436.195118 4768166.813801 WGS84_G1762
+            Positions.transformAt' pWGS84 (Epoch 2010.0) WGS84_G1762 tx15 `shouldBe` pWGS84
+        it "uses the 15-parameter transformation and position epoch" $ do
+            let pITRF2014 = Geocentric.metresPos 4027894.006 307045.600 4919474.910 ITRF2014
+            let tx = Tx.params Txs.from_ITRF2014_to_ETRF2000
+            let pETRF2000 = Positions.transformAt' pITRF2014 (Epoch 2012.0) ETRF2000 tx
+            pETRF2000 `shouldBe`
+                Geocentric.metresPos 4027894.366234 307045.252967 4919474.626307 ETRF2000
+        it "returns the initial coordinates when doing round-trip (direct -> inverse)" $ do
+            let tx = Tx.params Txs.from_ITRF2014_to_ETRF2000
+            let itx = Tx.inverseParams tx
+            let pITRF2014 = Geocentric.metresPos 4027894.006 307045.600 4919474.910 ITRF2014
+            let e = Epoch 2019.0
+            Positions.transformAt' (Positions.transformAt' pITRF2014 e ETRF2000 tx) e ITRF2014 itx `shouldBe`
+                pITRF2014
+        it "returns the initial coordinates when using chained conversion round-trip" $ do
+            let pNAD83 =
+                    Positions.toGeocentric (Geodetic.latLongHeightPos 0 0 Length.zero NAD83_CORS96)
+            -- goes via ITRF2000
+            let pITRF2014 =
+                    fromJust
+                        (Positions.transformAt pNAD83 (Epoch 2014.0) ITRF2014 Txs.timeDependent)
+            Positions.transformAt pITRF2014 (Epoch 2014.0) NAD83_CORS96 Txs.timeDependent `shouldBe`
+                Just pNAD83
+        it "converts between ITRF2000 & ETRF2000" $ do
+            let pITRF2000 = Geocentric.metresPos 4027894.006 307045.600 4919474.910 ITRF2000
+            let pETRF2000 =
+                    fromJust
+                        (Positions.transformAt pITRF2000 (Epoch 2012) ETRF2000 Txs.timeDependent)
+            pETRF2000 `shouldBe`
+                Geocentric.metresPos 4027894.355909 307045.250849 4919474.644695 ETRF2000
+        it "converts between ITRF2014 & ETRF2000" $ do
+            let pITRF2014 = Geocentric.metresPos 4027894.006 307045.600 4919474.910 ITRF2014
+            let pETRF2000 =
+                    fromJust
+                        (Positions.transformAt pITRF2014 (Epoch 2012) ETRF2000 Txs.timeDependent)
+            pETRF2000 `shouldBe`
+                Geocentric.metresPos 4027894.366234 307045.252967 4919474.626307 ETRF2000
− test/Data/Geo/Jord/ReadPositionSpec.hs
@@ -1,77 +0,0 @@-module Data.Geo.Jord.ReadPositionSpec
-    ( spec
-    ) where
-
-import Data.Maybe (mapMaybe)
-
-import Test.Hspec
-
-import Data.Geo.Jord.Position
-
-spec :: Spec
-spec = do
-    describe "Reading valid DMS text" $ do
-        it "reads WGS84 surface positions" $ do
-            let texts =
-                    [ "553621N0130002E"
-                    , "5536N01300E"
-                    , "55N013E"
-                    , "011659S0364900E"
-                    , "0116S03649E"
-                    , "01S036E"
-                    , "473622N1221955W"
-                    , "4736N12219W"
-                    , "47N122W"
-                    , "544807S0681811W"
-                    , "5448S06818W"
-                    , "54S068W"
-                    , "55°36'21''N 013°00'02''E"
-                    , "1°16'S,36°49'E"
-                    , "47°N 122°W"
-                    ]
-            let positions =
-                    [ wgs84Pos 55.60583333333334 13.000555555555556 zero
-                    , wgs84Pos 55.6 13.0 zero
-                    , wgs84Pos 55.0 13.0 zero
-                    , wgs84Pos (-1.2830555555555556) 36.81666666666667 zero
-                    , wgs84Pos (-1.2666666666666666) 36.81666666666667 zero
-                    , wgs84Pos (-1.0) 36.0 zero
-                    , wgs84Pos 47.60611111111111 (-122.33194444444445) zero
-                    , wgs84Pos 47.6 (-122.31666666666666) zero
-                    , wgs84Pos 47.0 (-122.0) zero
-                    , wgs84Pos (-54.801944444444445) (-68.30305555555556) zero
-                    , wgs84Pos (-54.8) (-68.3) zero
-                    , wgs84Pos (-54.0) (-68.0) zero
-                    , wgs84Pos 55.60583333333334 13.000555555555556 zero
-                    , wgs84Pos (-1.2666666666666666) 36.81666666666667 zero
-                    , wgs84Pos 47.0 (-122.0) zero
-                    ]
-            mapMaybe (`readPosition` WGS84) texts `shouldBe` positions
-        it "reads positions around the WGS84 ellipsoid" $ do
-            let texts = ["55°36'21''N 013°00'02''E 5m", "55°36'21''N 013°00'02''E -5m"]
-            let positions =
-                    [ wgs84Pos 55.60583333333334 13.000555555555556 (metres 5)
-                    , wgs84Pos 55.60583333333334 13.000555555555556 (metres (-5))
-                    ]
-            mapMaybe (`readPosition` WGS84) texts `shouldBe` positions
-        it "reads positions around the S84 sphere" $ do
-            let texts = ["55°36'21''N 013°00'02''E 5m", "55°36'21''N 013°00'02''E -5m"]
-            let positions =
-                    [ s84Pos 55.60583333333334 13.000555555555556 (metres 5)
-                    , s84Pos 55.60583333333334 13.000555555555556 (metres (-5))
-                    ]
-            mapMaybe (`readPosition` S84) texts `shouldBe` positions
-        it "reads Mars surface positions" $ do
-            let texts = ["54S360E", "55°36'21''N 341°34'02''E"]
-            let positions = [latLongPos (-54.0) 360 Mars2000, latLongPos 55.60583333333334 341.5672222222222 Mars2000]
-            mapMaybe (`readPosition` Mars2000) texts `shouldBe` positions
-    describe "Attempting to read invalid DMS text" $ do
-        it "fails to read syntactically invalid positions" $ do
-            let texts = ["553621K0130002E", "011659S0364900Z", "4736221221955W", "54480S0681811W"]
-            mapMaybe (`readPosition` WGS84) texts `shouldBe` []
-        it "fails to read invalid WGS84 surface positions" $ do
-            let texts = ["914807S0681811W", "804807S1811811W"]
-            mapMaybe (`readPosition` WGS84) texts `shouldBe` []
-        it "fails to read invalid Mars surface positions" $ do
-            let texts = ["914807S0681811E", "5448S06818W"]
-            mapMaybe (`readPosition` Mars2000) texts `shouldBe` []
test/Data/Geo/Jord/RotationSpec.hs view
@@ -1,52 +1,52 @@-module Data.Geo.Jord.RotationSpec
-    ( spec
-    ) where
-
-import Test.Hspec
-
-import Data.Geo.Jord.Angle
-import Data.Geo.Jord.Rotation
-import Data.Geo.Jord.Vector3d
-
-spec :: Spec
-spec = do
-    describe "r2xyz" $
-        it "computes the 3 angles about new axes in the xyz-order from rotation matrix" $ do
-            let xyz = [decimalDegrees 45, decimalDegrees 45, decimalDegrees 5]
-            let rm =
-                    [ Vector3d 0.7044160264027587 (-6.162841671621935e-2) 0.7071067811865475
-                    , Vector3d 0.559725765762092 0.6608381550289296 (-0.5)
-                    , Vector3d (-0.43646893232965345) 0.7479938977765876 0.5000000000000001
-                    ]
-            r2xyz rm `shouldBe` xyz
-    describe "r2xyz" $
-        it "computes the 3 angles about new axes in the zyx-order from rotation matrix" $ do
-            let zyx = [decimalDegrees 10, decimalDegrees 20, decimalDegrees 30]
-            let rm =
-                    [ Vector3d 0.9254165783983234 1.802831123629725e-2 0.37852230636979245
-                    , Vector3d 0.16317591116653482 0.8825641192593856 (-0.44096961052988237)
-                    , Vector3d (-0.3420201433256687) 0.46984631039295416 0.8137976813493738
-                    ]
-            r2zyx rm `shouldBe` zyx
-    describe "xyz2r" $
-        it "computes the rotation matrix from 3 angles about new axes in the xyz-order" $ do
-            let x = decimalDegrees 45
-            let y = decimalDegrees 45
-            let z = decimalDegrees 5
-            let rm =
-                    [ Vector3d 0.7044160264027587 (-6.162841671621935e-2) 0.7071067811865475
-                    , Vector3d 0.559725765762092 0.6608381550289296 (-0.5)
-                    , Vector3d (-0.43646893232965345) 0.7479938977765876 0.5000000000000001
-                    ]
-            xyz2r x y z `shouldBe` rm
-    describe "zyx2r" $
-        it "computes the rotation matrix from 3 angles about new axes in the zyx-order" $ do
-            let x = decimalDegrees 10
-            let y = decimalDegrees 20
-            let z = decimalDegrees 30
-            let rm =
-                    [ Vector3d 0.9254165783983234 1.802831123629725e-2 0.37852230636979245
-                    , Vector3d 0.16317591116653482 0.8825641192593856 (-0.44096961052988237)
-                    , Vector3d (-0.3420201433256687) 0.46984631039295416 0.8137976813493738
-                    ]
-            zyx2r x y z `shouldBe` rm
+module Data.Geo.Jord.RotationSpec+    ( spec+    ) where++import Test.Hspec++import qualified Data.Geo.Jord.Angle as Angle+import qualified Data.Geo.Jord.Math3d as Math3d+import qualified Data.Geo.Jord.Rotation as Rotation++spec :: Spec+spec = do+    describe "r2xyz" $+        it "computes the 3 angles about new axes in the xyz-order from rotation matrix" $ do+            let xyz = [Angle.decimalDegrees 45, Angle.decimalDegrees 45, Angle.decimalDegrees 5]+            let rm =+                    [ Math3d.vec3 0.7044160264027587 (-6.162841671621935e-2) 0.7071067811865475+                    , Math3d.vec3 0.559725765762092 0.6608381550289296 (-0.5)+                    , Math3d.vec3 (-0.43646893232965345) 0.7479938977765876 0.5000000000000001+                    ]+            Rotation.r2xyz rm `shouldBe` xyz+    describe "r2xyz" $+        it "computes the 3 angles about new axes in the zyx-order from rotation matrix" $ do+            let zyx = [Angle.decimalDegrees 10, Angle.decimalDegrees 20, Angle.decimalDegrees 30]+            let rm =+                    [ Math3d.vec3 0.9254165783983234 1.802831123629725e-2 0.37852230636979245+                    , Math3d.vec3 0.16317591116653482 0.8825641192593856 (-0.44096961052988237)+                    , Math3d.vec3 (-0.3420201433256687) 0.46984631039295416 0.8137976813493738+                    ]+            Rotation.r2zyx rm `shouldBe` zyx+    describe "xyz2r" $+        it "computes the rotation matrix from 3 angles about new axes in the xyz-order" $ do+            let x = Angle.decimalDegrees 45+            let y = Angle.decimalDegrees 45+            let z = Angle.decimalDegrees 5+            let rm =+                    [ Math3d.vec3 0.7044160264027587 (-6.162841671621935e-2) 0.7071067811865475+                    , Math3d.vec3 0.559725765762092 0.6608381550289296 (-0.5)+                    , Math3d.vec3 (-0.43646893232965345) 0.7479938977765876 0.5000000000000001+                    ]+            Rotation.xyz2r x y z `shouldBe` rm+    describe "zyx2r" $+        it "computes the rotation matrix from 3 angles about new axes in the zyx-order" $ do+            let x = Angle.decimalDegrees 10+            let y = Angle.decimalDegrees 20+            let z = Angle.decimalDegrees 30+            let rm =+                    [ Math3d.vec3 0.9254165783983234 1.802831123629725e-2 0.37852230636979245+                    , Math3d.vec3 0.16317591116653482 0.8825641192593856 (-0.44096961052988237)+                    , Math3d.vec3 (-0.3420201433256687) 0.46984631039295416 0.8137976813493738+                    ]+            Rotation.zyx2r x y z `shouldBe` rm
− test/Data/Geo/Jord/ShowPositionSpec.hs
@@ -1,23 +0,0 @@-module Data.Geo.Jord.ShowPositionSpec
-    ( spec
-    ) where
-
-import Test.Hspec
-
-import Data.Geo.Jord.Position
-
-spec :: Spec
-spec =
-    describe "Showing positions" $ do
-        it "shows the N/E position formatted in DMS with symbols" $
-            show (wgs84Pos 55.6058333333 13.00055556 (metres 5)) `shouldBe`
-            "55°36'21.000\"N,13°0'2.000\"E 5.0m (WGS84)"
-        it "shows the S/E position formatted in DMS with symbols" $
-            show (latLongPos (-1.28305556) 36.81666 GRS80) `shouldBe`
-            "1°16'59.000\"S,36°48'59.976\"E 0.0m (GRS80)"
-        it "shows the N/W position formatted in DMS with symbols" $
-            show (latLongPos 47.60611 (-122.33194) S84) `shouldBe`
-            "47°36'21.996\"N,122°19'54.984\"W 0.0m (S84)"
-        it "shows the S/W position formatted in DMS with symbols" $
-            show (latLongPos (-54.80194) (-68.30305) S84) `shouldBe`
-            "54°48'6.984\"S,68°18'10.980\"W 0.0m (S84)"
test/Data/Geo/Jord/SpeedSpec.hs view
@@ -1,43 +1,44 @@-module Data.Geo.Jord.SpeedSpec
-    ( spec
-    ) where
-
-import Test.Hspec
-
-import Data.Geo.Jord.Quantity
-import Data.Geo.Jord.Speed
-
-spec :: Spec
-spec = do
-    describe "Reading valid speeds" $ do
-        it "reads -15.2m/s" $ readSpeed "-15.2m/s" `shouldBe` Just (metresPerSecond (-15.2))
-        it "reads 154km/h" $ readSpeed "154km/h" `shouldBe` Just (kilometresPerHour 154)
-        it "reads 200mph" $ readSpeed "200mph" `shouldBe` Just (milesPerHour 200)
-        it "reads 400kt" $ readSpeed "400kt" `shouldBe` Just (knots 400)
-        it "reads 1ft/s" $ readSpeed "1ft/s" `shouldBe` Just (feetPerSecond 1)
-    describe "Reading invalid speeds" $ do
-        it "fails to read 5" $ readSpeed "5" `shouldBe` Nothing
-        it "fails to read 5mps" $ readSpeed "5mps" `shouldBe` Nothing
-    describe "Showing speeds" $
-        it "shows speed in kilometres per hour" $
-        show (kilometresPerHour 154) `shouldBe` "154.0km/h"
-    describe "Converting speeds" $ do
-        it "converts metres per seconds to kilometres per hour" $
-            toKilometresPerHour (metresPerSecond 100) `shouldBe` 360.0
-        it "converts metres per seconds to miles per hour" $
-            toMilesPerHour (metresPerSecond 100) `shouldBe` 223.69362920544023
-        it "converts kilometres per hour to knots" $
-            toKnots (kilometresPerHour 1000) `shouldBe` 539.9568034557235
-        it "converts feet per second to kilometres per hour" $
-            toKilometresPerHour (feetPerSecond 1) `shouldBe` 1.09728
-    describe "Resolution" $ do
-        it "handles 1 km/h" $ toKilometresPerHour (kilometresPerHour 1) `shouldBe` 1
-        it "handles 1 m/s" $ toMetresPerSecond (metresPerSecond 1) `shouldBe` 1
-        it "handles 1 mph" $ toMilesPerHour (milesPerHour 1) `shouldBe` 1
-        it "handles 1 knot" $ toKnots (knots 1) `shouldBe` 1
-        it "handles 1 fp/s" $ toFeetPerSecond (feetPerSecond 1) `shouldBe` 1
-    describe "Adding/Subtracting speeds" $ do
-        it "adds speeds" $
-            add (kilometresPerHour 1000) (metresPerSecond 1000) `shouldBe` kilometresPerHour 4600
-        it "subtracts speeds" $
-            sub (metresPerSecond 1000) (knots 10.5) `shouldBe` kilometresPerHour 3580.554
+module Data.Geo.Jord.SpeedSpec+    ( spec+    ) where++import Test.Hspec++import qualified Data.Geo.Jord.Speed as Speed++spec :: Spec+spec = do+    describe "Reading valid speeds" $ do+        it "reads -15.2m/s" $ Speed.read "-15.2m/s" `shouldBe` Just (Speed.metresPerSecond (-15.2))+        it "reads 154km/h" $ Speed.read "154km/h" `shouldBe` Just (Speed.kilometresPerHour 154)+        it "reads 200mph" $ Speed.read "200mph" `shouldBe` Just (Speed.milesPerHour 200)+        it "reads 400kt" $ Speed.read "400kt" `shouldBe` Just (Speed.knots 400)+        it "reads 1ft/s" $ Speed.read "1ft/s" `shouldBe` Just (Speed.feetPerSecond 1)+    describe "Reading invalid speeds" $ do+        it "fails to read 5" $ Speed.read "5" `shouldBe` Nothing+        it "fails to read 5mps" $ Speed.read "5mps" `shouldBe` Nothing+    describe "Showing speeds" $+        it "shows speed in kilometres per hour" $+        show (Speed.kilometresPerHour 154) `shouldBe` "154.0km/h"+    describe "Converting speeds" $ do+        it "converts metres per seconds to kilometres per hour" $+            Speed.toKilometresPerHour (Speed.metresPerSecond 100) `shouldBe` 360.0+        it "converts metres per seconds to miles per hour" $+            Speed.toMilesPerHour (Speed.metresPerSecond 100) `shouldBe` 223.69362920544023+        it "converts kilometres per hour to Speed.knots" $+            Speed.toKnots (Speed.kilometresPerHour 1000) `shouldBe` 539.9568034557235+        it "converts feet per second to kilometres per hour" $+            Speed.toKilometresPerHour (Speed.feetPerSecond 1) `shouldBe` 1.09728+    describe "Resolution" $ do+        it "handles 1 km/h" $ Speed.toKilometresPerHour (Speed.kilometresPerHour 1) `shouldBe` 1+        it "handles 1 m/s" $ Speed.toMetresPerSecond (Speed.metresPerSecond 1) `shouldBe` 1+        it "handles 1 mph" $ Speed.toMilesPerHour (Speed.milesPerHour 1) `shouldBe` 1+        it "handles 1 knot" $ Speed.toKnots (Speed.knots 1) `shouldBe` 1+        it "handles 1 fp/s" $ Speed.toFeetPerSecond (Speed.feetPerSecond 1) `shouldBe` 1+    describe "Adding/Subtracting speeds" $ do+        it "adds speeds" $+            Speed.add (Speed.kilometresPerHour 1000) (Speed.metresPerSecond 1000) `shouldBe`+            Speed.kilometresPerHour 4600+        it "subtracts speeds" $+            Speed.subtract (Speed.metresPerSecond 1000) (Speed.knots 10.5) `shouldBe`+            Speed.kilometresPerHour 3580.554
− test/Data/Geo/Jord/TransformationSpec.hs
@@ -1,52 +0,0 @@-module Data.Geo.Jord.TransformationSpec
-    ( spec
-    ) where
-
-import Test.Hspec
-
-import Data.Geo.Jord.Position
-import Data.Geo.Jord.Transformation
-
-spec :: Spec
-spec = do
-    describe "coordinates static transformation" $ do
-        it "returns the initial coordinates if all parameters are 0" $ do
-            let tx7 = txParams7 (0, 0, 0) 0 (0, 0, 0)
-            let pWGS84 = wgs84Pos 48.6921 6.1844 (metres 188)
-            transformCoords' pWGS84 WGS84 tx7 `shouldBe` pWGS84
-        it "uses the 7-parameter transformation" $ do
-            let pWGS84 = wgs84Pos 48.6921 6.1844 (metres 188)
-            let tx = txParams from_WGS84_to_NAD83
-            let pNAD83 = transformCoords' pWGS84 NAD83 tx
-            pNAD83 `shouldBe`
-                latLongHeightPos
-                    48.69208978369768
-                    6.184367561060834
-                    (metres 188.12122946884483)
-                    NAD83
-        it "returns the initial coordinates when doing round-trip (direct -> inverse)" $ do
-            let tx = txParams from_WGS84_to_NAD83
-            let itx = inverseTxParams tx
-            let pWGS84 = wgs84Pos 48.6921 6.1844 (metres 188)
-            transformCoords' (transformCoords' pWGS84 NAD83 tx) WGS84 itx `shouldBe` pWGS84
-    describe "coordinates dynamic transformation" $ do
-        it "returns the initial coordinates if all parameters are 0" $ do
-            let tx15 =
-                    TxParams15
-                        (Epoch 2010)
-                        (txParams7 (0, 0, 0) 0 (0, 0, 0))
-                        (txRates (0, 0, 0) 0 (0, 0, 0))
-            let pWGS84 = latLongHeightPos 48.6921 6.1844 (metres 188) WGS84_G1762
-            transformCoordsAt' pWGS84 (Epoch 2010.0) WGS84_G1762 tx15 `shouldBe` pWGS84
-        it "uses the 15-parameter transformation and position epoch" $ do
-            let pITRF2014 = geocentricMetresPos 4027894.006 307045.600 4919474.910 ITRF2014
-            let tx = txParams from_ITRF2014_to_ETRF2000
-            let pETRF2000 = transformCoordsAt' pITRF2014 (Epoch 2012.0) ETRF2000 tx
-            pETRF2000 `shouldBe` geocentricMetresPos 4027894.366234 307045.252967 4919474.626307 ETRF2000
-        it "returns the initial coordinates when doing round-trip (direct -> inverse)" $ do
-            let tx = txParams from_ITRF2014_to_ETRF2000
-            let itx = inverseTxParams tx
-            let pITRF2014 = geocentricMetresPos 4027894.006 307045.600 4919474.910 ITRF2014
-            let e = Epoch 2019.0
-            transformCoordsAt' (transformCoordsAt' pITRF2014 e ETRF2000 tx) e ITRF2014 itx `shouldBe`
-                pITRF2014
+ test/Data/Geo/Jord/TriangleSpec.hs view
@@ -0,0 +1,44 @@+module Data.Geo.Jord.TriangleSpec
+    ( spec
+    ) where
+
+import Test.Hspec
+
+import qualified Data.Geo.Jord.Geodetic as Geodetic
+import qualified Data.Geo.Jord.GreatCircle as GreatCircle
+import Data.Geo.Jord.Places
+import qualified Data.Geo.Jord.Triangle as Triangle
+
+spec :: Spec
+spec = do
+    describe "make" $ do
+        it "returns Nothing if any 2 positions are equal" $ do
+            let v0 = Geodetic.s84Pos 0 0
+            Triangle.make v0 v0 (Geodetic.s84Pos 10 0) `shouldBe` Nothing
+            Triangle.make v0 (Geodetic.s84Pos 10 0) v0 `shouldBe` Nothing
+            Triangle.make (Geodetic.s84Pos 10 0) v0 v0 `shouldBe` Nothing
+        it "returns Nothing if any position is the antipode of another one" $ do
+            let v0 = Geodetic.s84Pos 0 0
+            Triangle.make v0 (Geodetic.antipode v0) (Geodetic.s84Pos 10 0) `shouldBe` Nothing
+            Triangle.make v0 (Geodetic.s84Pos 10 0) (Geodetic.antipode v0) `shouldBe` Nothing
+            Triangle.make (Geodetic.s84Pos 10 0) v0 (Geodetic.antipode v0) `shouldBe` Nothing
+    describe "centroid" $ do
+        it "returns the intersection of the medians" $ do
+            let t =
+                    Triangle.unsafeMake
+                        (Geodetic.s84Pos 0 (-10))
+                        (Geodetic.s84Pos 10 0)
+                        (Geodetic.s84Pos 0 10)
+            let c = Triangle.centroid t
+            c `shouldBe` Geodetic.s84Pos 3.3637274116666664 0
+            Triangle.contains t c `shouldBe` True
+    describe "circumcentre" $ do
+        it "returns the position equidistant from all vertices" $ do
+            let t = Triangle.unsafeMake malmo stockholm goteborg
+            let c = Triangle.circumcentre t
+            let d1 = GreatCircle.distance c malmo
+            let d2 = GreatCircle.distance c stockholm
+            let d3 = GreatCircle.distance c goteborg
+            d1 `shouldBe` d2
+            d1 `shouldBe` d3
+            d2 `shouldBe` d3