packages feed

brush-strokes-0.1.0.0: src/arc-length/test/TestArcLength.hs

{-# OPTIONS_GHC -Wno-x-partial #-}

module Main where

-- base
import Control.Applicative
  ( (<|>) )
import Control.Monad
  ( guard )
import Data.Char
  ( toLower )
import Data.List
  ( intercalate, maximumBy )
import qualified Data.List.NonEmpty as NE
import Data.Maybe
  ( mapMaybe )
import Data.Ord
  ( comparing )
import GHC.Generics
  ( Generic )
import System.Environment
  ( withArgs )

-- deepseq
import Control.DeepSeq
  ( NFData )

-- parallel
import Control.Parallel.Strategies
  ( parListChunk, rdeepseq, withStrategy )

-- code-page
import System.IO.CodePage
  ( withCP65001 )

-- optparse-applicative
import qualified Options.Applicative as OptParse

-- falsify
import Test.Tasty.Falsify
import Test.Falsify.Predicate
  ( (.$) )
import qualified Test.Falsify.Generator as Gen
import qualified Test.Falsify.Predicate as Falsify.Prop
import qualified Test.Falsify.Range     as Range
import qualified Test.Tasty.Falsify     as Falsify

-- tasty
import qualified Test.Tasty as Tasty

-- brush-strokes
import Math.Bezier.ArcLength
  ( ArcLengthOptions(..), defaultArcLengthOptions
  , Integrator(..)
  , defaultRegulariser
  , ArcLengthParametrisation(..), curveArcLengthParametrisation
  )
import qualified Math.Bezier.Cubic     as Cubic     ( Bezier(..), bezier )
import qualified Math.Bezier.Quadratic as Quadratic ( Bezier(..), bezier )
import Math.Bezier.Spline
  ( Curve(..), NextPoint(..) )
import Math.Linear
  ( ℝ(..), T )
import Math.Module
  ( distance )

--------------------------------------------------------------------------------
-- Generator
--------------------------------------------------------------------------------

-- | Generate a 'Double' uniformly in @[lo, hi]@ on a grid of 10⁶ steps.
genDouble :: Double -> Double -> Gen.Gen Double
genDouble lo hi =
  fmap (\n -> lo + (hi - lo) * fromIntegral n / 1e6) $
    Gen.inRange $ Range.between (0, 1000000 :: Word)

--------------------------------------------------------------------------------
-- Polyline reference
--------------------------------------------------------------------------------

-- | Approximate the arc length of a parametric curve by a polyline with @n@
-- segments.
polylineArcLength :: Int -> (Double -> ℝ 2) -> Double
polylineArcLength n f =
  sum ( zipWith ( distance @( T (ℝ 2) ) ) pts ( tail pts ) )
  where
    pts = [ f ( fromIntegral i / fromIntegral n )
          | i <- [0 .. n]
          ]

-- | Reference arc-length implementation, using Romberg's method.
referenceArcLength :: ( Double -> ℝ 2 ) -> Double
referenceArcLength f = ( 4 * l2 - l1 ) / 3
  where
    n  = 50000 -- Number of samples used for reference implementation.
    l1 = polylineArcLength       n   f
    l2 = polylineArcLength ( 2 * n ) f

--------------------------------------------------------------------------------
-- Error computation
--------------------------------------------------------------------------------

data Result
  = Result
  { referenceResult     :: !Double
  , methodResult        :: !Double
  , methodRelativeError :: !Double
  }
  deriving stock ( Show, Generic )
  deriving anyclass NFData

-- | Compute (impl, ref, relErr) for the quadratic arc-length test.
--
-- Endpoints fixed at p0=(0,0) and p2=(1,0); control point p1=(cx,cy).
quadResult :: ArcLengthOptions -> ℝ 2 -> Result
quadResult opts p1 =
  Result
    { referenceResult     = ref
    , methodResult        = impl
    , methodRelativeError = relErr
    }
  where
    p0    = ℝ2 0 0
    p2    = ℝ2 1 0
    bez   = Quadratic.Bezier { p0, p1, p2 }
    curve = Bezier2To
      { controlPoint = p1
      , curveEnd     = NextPoint p2
      , curveData    = ()
      }
    impl   = totalArcLength
           $ curveArcLengthParametrisation @( T ( ℝ 2 ) ) opts p0 curve
    ref    = referenceArcLength $ Quadratic.bezier @( T ( ℝ 2 ) ) bez
    relErr = abs (impl - ref) / max ref 1e-15

-- | Compute (impl, ref, relErr) for the cubic arc-length test.
--
-- Fixed points p0=(0,0), p1=(1,0), p3=(1,1); free control point p2=(cx,cy).
cubicResult :: ArcLengthOptions -> ℝ 2 -> Result
cubicResult opts p2 =
  Result
    { referenceResult     = ref
    , methodResult        = impl
    , methodRelativeError = relErr
    }
  where
    p0    = ℝ2 0 0
    p1    = ℝ2 1 0
    p3    = ℝ2 1 1
    bez   = Cubic.Bezier { p0, p1, p2, p3 }
    curve = Bezier3To
      { controlPoint1 = p1
      , controlPoint2 = p2
      , curveEnd      = NextPoint p3
      , curveData     = ()
      }
    impl   = totalArcLength
           $ curveArcLengthParametrisation @( T ( ℝ 2 ) ) opts p0 curve
    ref    = referenceArcLength $ Cubic.bezier @( T ( ℝ 2 ) ) bez
    relErr = abs (impl - ref) / max ref 1e-15

--------------------------------------------------------------------------------
-- Properties
--------------------------------------------------------------------------------

-- | Relative-error tolerance for arc-length tests.
arcLengthTol :: Double
arcLengthTol = 1e-5

testOptions :: Falsify.TestOptions
testOptions = Falsify.TestOptions
  { Falsify.expectFailure      = Falsify.DontExpectFailure
  , Falsify.overrideVerbose    = Nothing
  , Falsify.overrideNumTests   = Just 400
  , Falsify.overrideMaxShrinks = Just 0 -- shrinking isn't really useful here
  , Falsify.overrideMaxRatio   = Nothing
  }

-- | Arc length of a quadratic Bézier with endpoints (0,0), (1,0) and a random
-- control point (cx, cy) in [-3, 3]².
prop_quadratic :: ArcLengthOptions -> Property ()
prop_quadratic opts = do
  cx <- Falsify.genWith (\v -> Just $ "cx = " ++ show v) $ genDouble -3 3
  cy <- Falsify.genWith (\v -> Just $ "cy = " ++ show v) $ genDouble -3 3

  let
    c = ℝ2 cx cy
    Result
      { referenceResult     = ref
      , methodResult        = impl
      , methodRelativeError = relErr
      } = quadResult opts c

  Falsify.info $
    "{ c = " ++ show c ++
    ", impl = " ++ show impl ++
    ", ref = "  ++ show ref  ++
    ", relErr = " ++ show relErr ++ " }"

  Falsify.assert $
    Falsify.Prop.relatedBy ("< tolerance", (<))
      .$ ("relErr",    relErr)
      .$ ("tolerance", arcLengthTol)

-- | Arc length of a cubic Bézier with fixed points p0=(0,0), p1=(1,0), p3=(1,1)
-- and a random free control point p2=(cx, cy) in [-3, 3]².
prop_cubic :: ArcLengthOptions -> Property ()
prop_cubic opts = do
  cx <- Falsify.genWith (\v -> Just $ "cx = " ++ show v) $ genDouble -3 3
  cy <- Falsify.genWith (\v -> Just $ "cy = " ++ show v) $ genDouble -3 3

  let
    c = ℝ2 cx cy
    Result
      { referenceResult     = ref
      , methodResult        = impl
      , methodRelativeError = relErr
      } = cubicResult opts c

  Falsify.info $
    "{ c = " ++ show c ++
    ", impl = " ++ show impl ++
    ", ref = "  ++ show ref  ++
    ", relErr = " ++ show relErr ++ " }"

  Falsify.assert $
    Falsify.Prop.relatedBy ("< tolerance", (<))
      .$ ("relErr",    relErr)
      .$ ("tolerance", arcLengthTol)

testGroup :: ArcLengthOptions -> Tasty.TestTree
testGroup opts =
  Tasty.testGroup ( integratorName opts )
    [ testPropertyWith testOptions "Quadratic Bezier" $ prop_quadratic opts
    , testPropertyWith testOptions "Cubic Bezier"     $ prop_cubic     opts
    ]

allTests :: [ Tasty.TestTree ]
allTests = map ( testGroup . snd ) integrators

--------------------------------------------------------------------------------
-- CSV output
--------------------------------------------------------------------------------

writeArcLengthData :: FilePath -> ArcLengthOptions -> Double -> IO ()
writeArcLengthData fp opts gridSize = do
  putStrLn $
    unlines
      [ "Computing arc-length data for integrator" ++ integratorName opts ++ "."
      , "Writing to: " ++ fp
      ]
  writeFile fp $ displayDataPoints dataPoints
  putStrLn $
    unlines
      [ "  Worst error, at " ++ show wp ++ ": " ++ show werr
      , "   Mean error: " ++ show avg_err
      ]

  where
    chunkSize = 2500 -- one row per spark

    dataPoints :: [ ( ℝ 2, Result ) ]
    dataPoints =
      withStrategy ( parListChunk chunkSize rdeepseq )
        [ ( p, res )
        | x <- [ -2, -2 + gridSize .. 3 ]
        , y <- [ -2, -2 + gridSize .. 3 ]
        , let p = ℝ2 x y
              res = cubicResult opts p
        ]

    getErr :: ( ℝ 2, Result ) -> Double
    getErr ( _xy, res ) = methodRelativeError res

    ( wp, werr ) = maximumBy ( comparing getErr ) dataPoints

    log_avgerr = sum (map (logBase 10 . getErr) dataPoints) / fromIntegral ( length dataPoints )
    avg_err = 10 ** log_avgerr

displayDataPoints
  :: [ ( ℝ 2, Result ) ]
  -> String
displayDataPoints pts =
  intercalate "\n" ( map displayDataPoint pts ) ++ "\n"

displayDataPoint
  :: ( ℝ 2, Result )
  -> String
displayDataPoint ( ℝ2 x y, res ) =
  intercalate ", " $ map displayDouble [ x, y, methodRelativeError res ]

displayDouble :: Double -> String
displayDouble d = show d

--------------------------------------------------------------------------------
-- Command-line interface
--------------------------------------------------------------------------------

data Command
  = RunTests
  | WriteCSV
      { csvOutputPath :: !FilePath
      , csvIntegrator :: !ArcLengthOptions
      , csvGridSize   :: !Double
      }

commandParser :: OptParse.Parser Command
commandParser =
  OptParse.subparser
    (  OptParse.command "test"
         ( OptParse.info ( pure RunTests )
             ( OptParse.progDesc "(default) Run arc-length integrator testsuite" ) )
    <> OptParse.command "csv"
         ( OptParse.info ( csvParser OptParse.<**> OptParse.helper )
             ( OptParse.progDesc
                 "Write arc-length error data to a CSV file" ) )
    )
  -- No command: run the tests.
  <|> pure RunTests
  where
    csvParser :: OptParse.Parser Command
    csvParser =
      WriteCSV
        <$> OptParse.option OptParse.str
              ( OptParse.long "output"
             <> OptParse.short 'o'
             <> OptParse.metavar "FILE"
             <> OptParse.help "Path of CSV output file" )
        <*> OptParse.argument ( OptParse.eitherReader parseIntegrator )
              ( OptParse.metavar "INTEGRATOR"
             <> OptParse.help ( "Integration method to use: "
                    ++ intercalate ", " ( map ( NE.head . fst ) integrators ) ) )
        <*> OptParse.option OptParse.auto
              ( OptParse.long "grid-size"
             <> OptParse.short 'g'
             <> OptParse.metavar "STEP"
             <> OptParse.value 5e-2
             <> OptParse.showDefault
             <> OptParse.help "Grid step size" )

optParseOpts :: OptParse.ParserInfo Command
optParseOpts = OptParse.info ( commandParser OptParse.<**> OptParse.helper )
  (  OptParse.fullDesc
  <> OptParse.header "test-arc-length – test-suite for arc-length integrators"
  )

integrators :: [ ( NE.NonEmpty String, ArcLengthOptions ) ]
integrators =
  map ( \ i -> ( integratorNames i, i ) )
    [ gravesenOpts
    , gaussLegendreOpts
    , clenshawCurtisOpts
    , tanhSinhOpts
    ]

  where
    gravesenOpts =
      defaultArcLengthOptions
        { arcLengthIntegrator = Gravesen { gravesenTol = 1e-5 }
        }
    gaussLegendreOpts =
      defaultArcLengthOptions
        { arcLengthIntegrator   = GaussLegendre
            { gaussLegendreDegree = 20
            , regulariser         = Just defaultRegulariser
            }
        , splitAtCriticalPoints = True
        }
    clenshawCurtisOpts =
      defaultArcLengthOptions
        { arcLengthIntegrator   = ClenshawCurtis
            { clenshawCurtisDegree = 20
            , regulariser          = Just defaultRegulariser
            }
        , splitAtCriticalPoints = True
        }
    tanhSinhOpts =
      defaultArcLengthOptions
        { arcLengthIntegrator  = TanhSinh
            { tanhSinhNodes    = 32
            , tanhSinhStepSize = 0.1
            }
        , splitAtCriticalPoints = True
        }

integratorName :: ArcLengthOptions -> String
integratorName = NE.head . integratorNames

integratorNames :: ArcLengthOptions -> NE.NonEmpty String
integratorNames opts =
  case arcLengthIntegrator opts of
    Gravesen {}       -> "Gravesen" NE.:| [ "AdaptiveGravesen" ]
    GaussLegendre {}  -> "Gauss–Legendre" NE.:| [ "Gauss-Legendre", "GaussLegendre", "Gauss", "Legendre" ]
    ClenshawCurtis {} -> "Clenshaw–Curtis" NE.:| [  "Clenshaw-Curtis", "ClenshawCurtis", "Clenshaw", "Curtis" ]
    TanhSinh {}       -> "TanhSinh" NE.:| [ "Tanh–Sinh", "Tanh-Sinh", "Tanh", "Sinh" ]

parseIntegrator :: String -> Either String ArcLengthOptions
parseIntegrator intStr =
  case mapMaybe parseOne integrators of
    [ opts ] -> Right opts
    [ ] ->
      Left $ "Unknown integrator " ++ show intStr
          ++ ".\n  Available integrators: "
          ++ intercalate ", " integratorNms ++ "."
    _ ->
      Left $ "Ambiguous integrator " ++ show intStr
          ++ ".\n  Available integrators: "
          ++ intercalate ", " integratorNms ++ "."
  where
    integratorNms = map ( NE.head . fst ) integrators
    parseOne :: ( NE.NonEmpty String, ArcLengthOptions ) -> Maybe ArcLengthOptions
    parseOne ( nms, integrator ) = do
      guard $ any ( ( map toLower intStr == ) . map toLower ) nms
      return integrator

--------------------------------------------------------------------------------
-- Entry point
--------------------------------------------------------------------------------

main :: IO ()
main = withCP65001 $ do
  cmd <- OptParse.execParser optParseOpts
  case cmd of
    RunTests ->
      withArgs [] $
        Tasty.defaultMain $
          Tasty.testGroup "Arc-length integrators" allTests
    WriteCSV { csvOutputPath, csvIntegrator, csvGridSize } ->
      writeArcLengthData csvOutputPath csvIntegrator csvGridSize