packages feed

telescope-0.2.0: test/Test/Fits/EncodingSpec.hs

{-# LANGUAGE TypeApplications #-}

module Test.Fits.EncodingSpec where

import Data.ByteString qualified as BS
import Data.ByteString.Lazy qualified as BL
import Data.ByteString.Lazy.Char8 qualified as C8
import Data.Massiv.Array (Ix2)
import Data.Massiv.Array qualified as M
import Data.Text (pack)
import Skeletest
import Telescope.Fits.DataArray
import Telescope.Fits.Encoding
import Telescope.Fits.Encoding.Render hiding (justify, pad, spaces)
import Telescope.Fits.Header
import Telescope.Fits.Types


spec :: Spec
spec = do
  describe "decode fits" testDecodeFits
  describe "render header" testRenderHeader
  describe "render data" testRenderData
  describe "encode primary" testEncodePrimary
  describe "round trip" testRoundTrip


testDecodeFits :: Spec
testDecodeFits = do
  describe "simple2x3.fits" $ do
    it "should load metadata" $ do
      f <- decode =<< BS.readFile "samples/simple2x3.fits"
      let dat = f.primaryHDU.dataArray
          hds = f.primaryHDU.header
      dat.axes `shouldBe` Axes [3, 2]
      dat.bitpix `shouldBe` BPInt64
      lookupKeyword "CUSTOM" hds `shouldBe` Just (Integer 123456)

    it "should load data array" $ do
      f <- decode =<< BS.readFile "samples/simple2x3.fits"
      arr <- decodeDataArray @Ix2 @Int f.primaryHDU.dataArray
      M.toLists arr `shouldBe` [[0, 1, 2], [3, 4, 5]]

  describe "NSO L2 Data" $ do
    it "should load" $ do
      f <- decode =<< BS.readFile "samples/dkistL2.fits"
      length f.extensions `shouldBe` 1


-- arr <- decodeDataArray @Ix2 @Int f.primaryHDU.dataArray
-- M.toLists arr `shouldBe` [[0, 1, 2], [3, 4, 5]]

testRenderHeader :: Spec
testRenderHeader = do
  describe "renderValue" $ do
    it "int should right justify" $ do
      runValue (Integer 8) `shouldBe` justify 20 "8"

    it "float should right justify" $ do
      runValue (Float 3.2) `shouldBe` justify 20 "3.2"

    it "negative int" $ do
      runValue (Integer (-32)) `shouldBe` justify 20 "-32"

    it "negative float" $ do
      runValue (Float (-32.32)) `shouldBe` justify 20 "-32.32"

    it "float should exponent uppercase" $ do
      runValue (Float 6.0001e-16) `shouldBe` justify 20 "6.0001E-16"

    it "logic should right justify" $ do
      runValue (Logic T) `shouldBe` justify 20 "T"

    it "string" $ do
      runValue (String "Hello World") `shouldBe` "'Hello World'"

  -- TEST: does it matter if e-06 vs e-6? We output e-6.

  describe "renderKeyword" $ do
    it "should left justify" $ do
      run (renderKeyword "BITPIX") `shouldBe` "BITPIX  "

    it "should truncate" $ do
      run (renderKeyword "REALLYLONG") `shouldBe` "REALLYLO"

    it "should uppercase" $ do
      run (renderKeyword "lower") `shouldBe` "LOWER   "

  describe "renderKeywordValue" $ do
    it "should render space" $ do
      run (renderKeywordValue "SIMPLE" (Logic T)) `shouldBe` ("SIMPLE  = " <> justify 20 "T")

    it "should butt against equals" $ do
      run (renderKeywordValue "WHATEVER" (Integer 10)) `shouldBe` ("WHATEVER= " <> justify 20 "10")

    it "should string" $ do
      run (renderKeywordValue "WHATEVER" (String "dude")) `shouldBe` pad 30 "WHATEVER= 'dude'"

    it "should correctly count long values" $ do
      let render = renderKeywordValue "KEYWORD" (String "0123456789012345678901234567890123456789")
      render.length `shouldBe` length (run render)

  describe "renderComment" $ do
    it "should render comment" $ do
      run (renderComment 100 "Hello World") `shouldBe` " / Hello World"

    it "should truncate comment" $ do
      run (renderComment 10 "Hello World") `shouldBe` " / Hello W"

  describe "renderKeywordComments" $ do
    it "should render comment in line" $ do
      run (renderKeywordLine "SIMPLE" (Logic T) (Just "Comment")) `shouldBe` pad 80 ("SIMPLE  = " <> justify 20 "T" <> " / Comment")

    it "should render no comment" $ do
      run (renderKeywordLine "SIMPLE" (Logic T) Nothing) `shouldBe` pad 80 ("SIMPLE  = " <> justify 20 "T")

    it "should truncate whole line" $ do
      run (renderKeywordLine "SIMPLE" (Logic T) Nothing) `shouldBe` pad 80 ("SIMPLE  = " <> justify 20 "T")

  describe "renderKeywordLine" $ do
    it "should be 80 characters mininum" $ do
      let b = renderKeywordLine "HELLO" (Integer 1) Nothing
      b.length `shouldBe` 80

    it "should be 80 characters maximum" $ do
      let b = renderKeywordLine "HELLO" (Integer 1) (Just $ pack $ replicate 100 'a')
      b.length `shouldBe` 80

    it "should be 80 characters maximum with long strings" $ do
      let b = renderKeywordLine "HELLO" (String "this is a really long value that exceeds 20") (Just $ pack $ replicate 100 'a')
      b.length `shouldBe` 80
      length (run b) `shouldBe` 80

    it "should be padded" $ do
      run (renderKeywordLine "HELLO" (Integer 1) Nothing) `shouldBe` "HELLO   = " <> justify 20 "1" <> spaces 50

  describe "renderOtherKeywords" $ do
    it "should render comments" $ do
      let h = Header [Comment "hello world"]
      run (renderOtherKeywords h) `shouldBe` pad 80 "COMMENT hello world"

    it "should render blanks" $ do
      let h = Header [BlankLine, Keyword (KeywordRecord "WOOT" (Integer 12345) Nothing)]
      run (renderOtherKeywords h) `shouldBe` headers ["", "WOOT    = " <> justify 20 "12345"]

    it "should render blanks between" $ do
      let h = Header [Comment "comment", BlankLine, Keyword (KeywordRecord "WOOT" (Integer 12345) Nothing)]
      run (renderOtherKeywords h) `shouldBe` headers ["COMMENT comment", "", "WOOT    = " <> justify 20 "12345"]
 where
  runValue :: Value -> String
  runValue = run . renderValue


run :: BuilderBlock -> String
run = C8.unpack . runRender


headers :: [String] -> String
headers = mconcat . map (pad 80)


justify :: Int -> String -> String
justify n s = spaces (n - length s) <> s


pad :: Int -> String -> String
pad n s = s <> spaces (n - length s)


spaces :: Int -> String
spaces n = replicate n ' '


testRenderData :: Spec
testRenderData = do
  it "fill block should fill" $ do
    (fillBlock zeros "a").length `shouldBe` 2880
    (fillBlock zeros "hello world").length `shouldBe` 2880
    (fillBlock zeros "").length `shouldBe` 0

  it "should be empty" $ do
    runRender (renderData "") `shouldBe` ""

  it "should pad to nearest block" $ do
    (renderData "asdf").length `shouldBe` 2880

  it "should render some data" $ do
    runRender (renderData "12345") `shouldBe` ("12345" <> BL.replicate 2875 0)


testEncodePrimary :: Spec
testEncodePrimary = do
  describe "encoded primary hdu" $ do
    it "encodes both a header and data hdu" $ do
      FitsEncodedFix enc <- getFixture
      BS.length enc `shouldBe` hduBlockSize * 2

    it "encodes the data" $ do
      FitsEncodedFix enc <- getFixture
      BS.take 6 (BS.drop 2880 enc) `shouldBe` rawData

    it "starts with SIMPLE" $ do
      FitsEncodedFix enc <- getFixture
      BS.take 30 enc `shouldBe` "SIMPLE  =                    T"

  describe "decoded encoded primary hdu" $ do
    it "Has custom header" $ do
      FitsDecodedFix f <- getFixture
      lookupKeyword "WOOT" f.primaryHDU.header `shouldBe` Just (Integer 123)

    it "Has required headers" $ do
      FitsDecodedFix f <- getFixture
      lookupKeyword "EXTEND" f.primaryHDU.header `shouldBe` Just (Logic T)

    it "Matches data metadata" $ do
      FitsDecodedFix f <- getFixture
      f.primaryHDU.dataArray.bitpix `shouldBe` BPInt8
      f.primaryHDU.dataArray.axes `shouldBe` Axes [3, 2]

    it "Matches raw data" $ do
      FitsDecodedFix f <- getFixture
      f.primaryHDU.dataArray.rawData `shouldBe` BS.pack [0 .. 5]
 where
  rawData = BS.pack [0 .. 5]


testRoundTrip :: Spec
testRoundTrip = do
  describe "simple2x3.fits" $ do
    it "should match metadata" $ do
      Simple2x3Fix fs <- getFixture
      f2 <- decode $ encode fs
      f2.primaryHDU.dataArray.axes `shouldBe` Axes [3, 2]
      f2.primaryHDU.dataArray.bitpix `shouldBe` fs.primaryHDU.dataArray.bitpix

    it "should match raw data" $ do
      Simple2x3Fix fs <- getFixture
      f2 <- decode $ encode fs
      f2.primaryHDU.dataArray.rawData `shouldBe` fs.primaryHDU.dataArray.rawData

    it "should encode headers only once" $ do
      Simple2x3Fix fs <- getFixture
      f2 <- decode $ encode fs
      let hs = fs.primaryHDU.header
          h2 = f2.primaryHDU.header
      lookupKeyword "NAXIS" h2 `shouldBe` Just (Integer 2)

      let ks = getKeywords hs :: [KeywordRecord]
          k2 = getKeywords h2 :: [KeywordRecord]

      length (filter (matchKeyword "BITPIX") k2) `shouldBe` length (filter (matchKeyword "BITPIX") ks)

      length h2._records `shouldBe` length hs._records

    it "should keep naxes order preserved" $ do
      Simple2x3Fix fs <- getFixture
      f2 <- decode $ encode fs
      f2.primaryHDU.dataArray.axes `shouldBe` fs.primaryHDU.dataArray.axes

  describe "image fits" $ do
    it "should roundtrip image extensions" $ do
      let prim = PrimaryHDU mempty emptyDataArray
      let img = Image $ ImageHDU mempty emptyDataArray
      let out = encode $ Fits prim [img]
      fits2 <- decode out
      length fits2.extensions `shouldBe` 1
 where
  matchKeyword k (KeywordRecord k2 _ _) = k == k2


newtype Simple2x3Fix = Simple2x3Fix Fits
instance Fixture Simple2x3Fix where
  fixtureAction = do
    inp <- BS.readFile "samples/simple2x3.fits"
    fits <- decode inp
    pure $ noCleanup $ Simple2x3Fix fits


newtype FitsEncodedFix = FitsEncodedFix BS.ByteString
instance Fixture FitsEncodedFix where
  fixtureAction = do
    pure $ noCleanup $ FitsEncodedFix encoded
   where
    encoded = encode (Fits primary [])
    primary =
      let heads = Header [Keyword $ KeywordRecord "WOOT" (Integer 123) Nothing]
          dat = DataArray BPInt8 (Axes [3, 2]) $ BS.pack [0 .. 5]
       in PrimaryHDU heads dat


newtype FitsDecodedFix = FitsDecodedFix Fits
instance Fixture FitsDecodedFix where
  fixtureAction = do
    FitsEncodedFix enc <- getFixture
    f <- decode enc
    pure $ noCleanup $ FitsDecodedFix f