packages feed

moonlight-linalg-0.1.0.0: test/statics/StaticsSpec.hs

module StaticsSpec
  ( tests,
  )
where

import qualified Data.List.NonEmpty as NonEmpty
import qualified Data.Map.Strict as Map
import Moonlight.LinAlg
  ( Axis (..),
    EquationRef (..),
    EquilibriumResult (..),
    EquilibriumSolution (..),
    EquilibriumViolation (..),
    ForceNetwork,
    ForceSign (..),
    MemberRef,
    NetworkBuildError (..),
    NetworkDeclaration,
    NodeRef,
    UnknownForce (..),
    Vec3 (..),
    assembleEquilibriumEquations,
    checkEquilibrium,
    compiledEquationOrder,
    compiledFoundationOrder,
    compiledMemberOrder,
    compiledNodeOrder,
    compiledUnknownOrder,
    joint,
    load,
    member,
    mkMemberRef,
    mkSupportAxes,
    network,
    networkNodeMap,
    nodeLoad,
    nodeRef,
    support,
    supportOn,
  )
import Helpers (extractRight)
import Test.Tasty (TestTree, testGroup)
import Test.Tasty.HUnit
  ( Assertion,
    assertBool,
    assertEqual,
    assertFailure,
    testCase,
  )

tests :: TestTree
tests =
  testGroup
    "Statics"
    [ testCase "assembleEquilibriumEquations uses canonical NodeRef ordering" testCanonicalAssembly,
      testCase "assembleEquilibriumEquations emits reactions only for supported axes" testAxisSpecificSupportUnknowns,
      testCase "network declarations are order-independent" testDeclarationOrderIndependence,
      testCase "network repeated loads accumulate exactly before rounding" testRepeatedLoadsUseOneRoundedExactSum,
      testCase "network rejects conflicting node positions" testConflictingPositionsFail,
      testCase "network rejects unknown member endpoints" testUnknownEndpointFails,
      testCase "network rejects degenerate members" testDegenerateMemberFails,
      testCase "checkEquilibrium resolves a compressive vertical support" testVerticalEquilibrium,
      testCase "checkEquilibrium rejects tension-only hanging support" testCompressionOnlyViolation,
      testCase "checkEquilibrium reports residual force for unsupported load" testResidualViolation
    ]

testCanonicalAssembly :: Assertion
testCanonicalAssembly = do
  leftMember <- expectMember "c" "a"
  rightMember <- expectMember "b" "c"
  nodeA <- expectNodeRef "a"
  nodeB <- expectNodeRef "b"
  nodeC <- expectNodeRef "c"
  networkValue <-
    expectNetwork
      [ member "c" "a",
        joint "c" (Vec3 0.0 1.0 0.0),
        support "a" (Vec3 (-1.0) 0.0 0.0),
        member "b" "c",
        support "b" (Vec3 1.0 0.0 0.0)
      ]
  extractRight (assembleEquilibriumEquations networkValue) $ \compiledValue -> do
    assertEqual "node order" [nodeA, nodeB, nodeC] (compiledNodeOrder compiledValue)
    assertEqual "foundation order" [nodeA, nodeB] (compiledFoundationOrder compiledValue)
    assertEqual "member order" [leftMember, rightMember] (compiledMemberOrder compiledValue)
    assertEqual
      "equation order begins with first node"
      [ EquationRef nodeA AxisX,
        EquationRef nodeA AxisY,
        EquationRef nodeA AxisZ
      ]
      (take 3 (compiledEquationOrder compiledValue))

testAxisSpecificSupportUnknowns :: Assertion
testAxisSpecificSupportUnknowns = do
  foundationRef <- expectNodeRef "foundation"
  networkValue <-
    expectNetwork
      [ supportOn
          "foundation"
          (Vec3 0.0 0.0 0.0)
          (mkSupportAxes [AxisY])
      ]
  extractRight (assembleEquilibriumEquations networkValue) $ \compiledValue ->
    assertEqual
      "axis-specific support emits only its declared reaction unknown"
      [ReactionUnknown foundationRef AxisY]
      (compiledUnknownOrder compiledValue)

testDeclarationOrderIndependence :: Assertion
testDeclarationOrderIndependence = do
  let declarations =
        [ support "a" (Vec3 0.0 0.0 0.0),
          load "b" (Vec3 0.0 1.0 0.0) (Vec3 0.0 (-4.0) 0.0),
          load "b" (Vec3 0.0 1.0 0.0) (Vec3 0.0 (-6.0) 0.0),
          member "a" "b"
        ]
  forwardNetwork <- expectNetwork declarations
  reverseNetwork <- expectNetwork (reverse declarations)
  assertEqual "declaration order" forwardNetwork reverseNetwork

testRepeatedLoadsUseOneRoundedExactSum :: Assertion
testRepeatedLoadsUseOneRoundedExactSum = do
  pointReference <- expectNodeRef "p"
  networkValue <-
    expectNetwork
      [ load "p" (Vec3 0.0 0.0 0.0) (Vec3 1.0e16 0.0 0.0),
        load "p" (Vec3 0.0 0.0 0.0) (Vec3 1.0 0.0 0.0),
        load "p" (Vec3 0.0 0.0 0.0) (Vec3 (-1.0e16) 0.0 0.0)
      ]
  case Map.lookup pointReference (networkNodeMap networkValue) of
    Nothing -> assertFailure "expected node p"
    Just nodeValue ->
      assertEqual
        "exactly accumulated load"
        (Vec3 1.0 0.0 0.0)
        (nodeLoad nodeValue)

testConflictingPositionsFail :: Assertion
testConflictingPositionsFail =
  case
    network
      [ joint "p" (Vec3 0.0 0.0 0.0),
        load "p" (Vec3 1.0 0.0 0.0) (Vec3 0.0 1.0 0.0)
      ]
    of
    Left (ConflictingNodePosition "p" (Vec3 0.0 0.0 0.0) (Vec3 1.0 0.0 0.0)) ->
      pure ()
    Left other ->
      assertFailure ("unexpected construction error: " <> show other)
    Right _ ->
      assertFailure "expected conflicting positions to fail"

testUnknownEndpointFails :: Assertion
testUnknownEndpointFails =
  case
    network
      [ joint "a" (Vec3 0.0 0.0 0.0),
        member "a" "b"
      ]
    of
    Left (UnknownMemberEndpoint "b") ->
      pure ()
    Left other ->
      assertFailure ("unexpected construction error: " <> show other)
    Right _ ->
      assertFailure "expected unknown member endpoint to fail"

testDegenerateMemberFails :: Assertion
testDegenerateMemberFails =
  case
    network
      [ joint "a" (Vec3 0.0 0.0 0.0),
        joint "b" (Vec3 0.0 0.0 0.0),
        member "a" "b"
      ]
    of
    Left (DegenerateMember "a" "b") ->
      pure ()
    Left other ->
      assertFailure ("unexpected construction error: " <> show other)
    Right _ ->
      assertFailure "expected zero-length member to fail"

testVerticalEquilibrium :: Assertion
testVerticalEquilibrium = do
  memberRefValue <- expectMember "foundation" "load"
  foundationRef <- expectNodeRef "foundation"
  networkValue <-
    expectNetwork
      [ load "load" (Vec3 0.0 1.0 0.0) (Vec3 0.0 (-10.0) 0.0),
        supportOn "foundation" (Vec3 0.0 0.0 0.0) (mkSupportAxes [AxisY]),
        member "foundation" "load"
      ]
  extractRight (checkEquilibrium networkValue) $ \equilibriumResult ->
    case equilibriumResult of
      InEquilibrium solutionValue -> do
        assertApprox "member force" 10.0 (Map.findWithDefault 0.0 memberRefValue (equilibriumMemberForces solutionValue))
        assertVec3Approx
          "foundation reaction"
          (Vec3 0.0 10.0 0.0)
          (Map.findWithDefault (Vec3 0.0 0.0 0.0) foundationRef (equilibriumReactionForces solutionValue))
      Disequilibrium violations ->
        assertBool ("expected equilibrium, got " <> show violations) False

testCompressionOnlyViolation :: Assertion
testCompressionOnlyViolation = do
  networkValue <-
    expectNetwork
      [ support "left" (Vec3 (-1.0) 1.0 0.0),
        support "right" (Vec3 1.0 1.0 0.0),
        load "load" (Vec3 0.0 0.0 0.0) (Vec3 0.0 (-10.0) 0.0),
        member "left" "load",
        member "right" "load"
      ]
  extractRight (checkEquilibrium networkValue) $ \equilibriumResult ->
    case equilibriumResult of
      InEquilibrium solutionValue ->
        assertBool ("expected compression-only violation, got " <> show solutionValue) False
      Disequilibrium violations ->
        assertBool
          "expected at least one tension violation"
          ( any
              ((== Just Tension) . violationMemberForceSign)
              (NonEmpty.toList violations)
          )

testResidualViolation :: Assertion
testResidualViolation = do
  networkValue <-
    expectNetwork
      [ supportOn "foundation" (Vec3 0.0 0.0 0.0) (mkSupportAxes [AxisY]),
        load "load" (Vec3 1.0 0.0 0.0) (Vec3 0.0 (-10.0) 0.0),
        member "foundation" "load"
      ]
  extractRight (checkEquilibrium networkValue) $ \equilibriumResult ->
    case equilibriumResult of
      InEquilibrium solutionValue ->
        assertBool ("expected residual violation, got " <> show solutionValue) False
      Disequilibrium violations ->
        assertBool
          "expected non-zero residual"
          ( any
              ((> 1.0e-6) . violationResidualMagnitude)
              (NonEmpty.toList violations)
          )

expectNetwork :: [NetworkDeclaration] -> IO ForceNetwork
expectNetwork declarations =
  case network declarations of
    Left buildError ->
      assertFailure ("expected valid network, got " <> show buildError)
    Right networkValue ->
      pure networkValue

expectNodeRef :: String -> IO NodeRef
expectNodeRef labelValue =
  case nodeRef labelValue of
    Left buildError ->
      assertFailure ("expected valid node reference, got " <> show buildError)
    Right nodeReference ->
      pure nodeReference

expectMember :: String -> String -> IO MemberRef
expectMember leftLabel rightLabel = do
  leftReference <- expectNodeRef leftLabel
  rightReference <- expectNodeRef rightLabel
  case mkMemberRef leftReference rightReference of
    Left err -> assertFailure ("expected valid member, got " <> show err)
    Right memberRefValue -> pure memberRefValue

assertApprox :: String -> Double -> Double -> Assertion
assertApprox message expected actual =
  assertBool
    (message <> ": expected " <> show expected <> " but received " <> show actual)
    (abs (expected - actual) <= 1.0e-6)

assertVec3Approx :: String -> Vec3 -> Vec3 -> Assertion
assertVec3Approx message expected actual = do
  assertApprox (message <> " x") (vecX expected) (vecX actual)
  assertApprox (message <> " y") (vecY expected) (vecY actual)
  assertApprox (message <> " z") (vecZ expected) (vecZ actual)