diff --git a/Cargo.lock b/Cargo.lock index a841472..781cb90 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -15,48 +15,42 @@ dependencies = [ [[package]] name = "addr2line" -version = "0.24.2" +version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" +checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" dependencies = [ "gimli", ] [[package]] name = "adler2" -version = "2.0.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "ahash" -version = "0.8.11" +version = "0.8.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e89da841a80418a9b391ebaea17f5c112ffaaa96f621d2c285b5174da76b9011" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" dependencies = [ "cfg-if", "const-random", - "getrandom 0.2.15", + "getrandom 0.3.4", "once_cell", "version_check", - "zerocopy 0.7.35", + "zerocopy", ] [[package]] name = "aho-corasick" -version = "1.1.3" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" dependencies = [ "memchr", ] -[[package]] -name = "android-tzdata" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" - [[package]] name = "android_system_properties" version = "0.1.5" @@ -74,9 +68,15 @@ checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" [[package]] name = "anstyle" -version = "1.0.10" +version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55cc3b69f167a1ef2e161439aa98aed94e6028e5f9a59be9a6ffb47aef1651f9" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" [[package]] name = "approx" @@ -105,7 +105,7 @@ dependencies = [ "arrow-schema", "chrono", "half", - "hashbrown", + "hashbrown 0.15.5", "num", ] @@ -202,15 +202,15 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] name = "autocfg" -version = "1.4.0" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "backtrace" -version = "0.3.74" +version = "0.3.76" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d82cb332cdfaed17ae235a638438ac4d4839913cc2af585c3c6746e8f8bee1a" +checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" dependencies = [ "addr2line", "cfg-if", @@ -218,7 +218,7 @@ dependencies = [ "miniz_oxide", "object", "rustc-demangle", - "windows-targets 0.52.6", + "windows-link", ] [[package]] @@ -250,15 +250,15 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.9.0" +version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c8214115b7bf84099f1309324e63141d4c5d7cc26862f97a0a857dbefe165bd" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" [[package]] name = "bumpalo" -version = "3.17.0" +version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1628fb46dfa0b37568d12e5edd512553eccf6a22a78e8bde00bb4aed84d5bdbf" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" [[package]] name = "bytecount" @@ -268,9 +268,9 @@ checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" [[package]] name = "bytemuck" -version = "1.22.0" +version = "1.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6b1fc10dbac614ebc03540c9dbd60e83887fda27794998c6528f1782047d540" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" [[package]] name = "byteorder" @@ -280,15 +280,15 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.10.1" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" [[package]] name = "camino" -version = "1.1.9" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b96ec4966b5813e2c0507c1f86115c8c5abaadc3980879c3424042a02fd1ad3" +checksum = "e629a66d692cb9ff1a1c664e41771b3dcaf961985a9774c0eb0bd1b51cf60a48" [[package]] name = "cast" @@ -298,18 +298,19 @@ checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" [[package]] name = "cc" -version = "1.2.17" +version = "1.2.60" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fcb57c740ae1daf453ae85f16e37396f672b039e00d9d866e07ddb24e328e3a" +checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20" dependencies = [ + "find-msvc-tools", "shlex", ] [[package]] name = "cfg-if" -version = "1.0.0" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "cfg_aliases" @@ -319,11 +320,10 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] name = "chrono" -version = "0.4.40" +version = "0.4.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a7964611d71df112cb1730f2ee67324fcf4d0fc6606acbbe9bfe06df124637c" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" dependencies = [ - "android-tzdata", "iana-time-zone", "num-traits", "windows-link", @@ -358,18 +358,18 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.34" +version = "4.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e958897981290da2a852763fe9cdb89cd36977a5d729023127095fa94d95e2ff" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" dependencies = [ "clap_builder", ] [[package]] name = "clap_builder" -version = "4.5.34" +version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83b0f35019843db2160b5bb19ae09b4e6411ac33fc6a712003c33e03090e2489" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" dependencies = [ "anstyle", "clap_lex", @@ -377,15 +377,15 @@ dependencies = [ [[package]] name = "clap_lex" -version = "0.7.4" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" [[package]] name = "comfy-table" -version = "7.2.1" +version = "7.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b03b7db8e0b4b2fdad6c551e634134e99ec000e5c8c3b6856c65e8bbaded7a3b" +checksum = "958c5d6ecf1f214b4c2bbbbf6ab9523a864bd136dcf71a7e8904799acfe1ad47" dependencies = [ "unicode-segmentation", "unicode-width", @@ -393,14 +393,13 @@ dependencies = [ [[package]] name = "console" -version = "0.16.0" +version = "0.16.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e09ced7ebbccb63b4c65413d821f2e00ce54c5ca4514ddc6b3c892fdbcbc69d" +checksum = "d64e8af5551369d19cf50138de61f1c42074ab970f74e99be916646777f8fc87" dependencies = [ "encode_unicode", "libc", - "once_cell", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -418,7 +417,7 @@ version = "0.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" dependencies = [ - "getrandom 0.2.15", + "getrandom 0.2.17", "once_cell", "tiny-keccak", ] @@ -492,9 +491,9 @@ checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" [[package]] name = "crunchy" -version = "0.2.3" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43da5946c66ffcc7745f48db692ffbb10a83bfe0afd96235c5c2a4fb23994929" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" [[package]] name = "directories" @@ -514,7 +513,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -548,19 +547,25 @@ checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] name = "errno" -version = "0.3.10" +version = "0.3.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33d852cb9b869c2a9b3df2f71a3074817f01e1844f839a144f5fcef059a4eb5d" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] name = "fastrand" -version = "2.3.0" +version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" [[package]] name = "flatbuffers" @@ -579,55 +584,46 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" [[package]] -name = "foreign-types" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" -dependencies = [ - "foreign-types-shared", -] - -[[package]] -name = "foreign-types-shared" -version = "0.1.1" +name = "foldhash" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" [[package]] name = "form_urlencoded" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" dependencies = [ "percent-encoding", ] [[package]] name = "futures-channel" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", ] [[package]] name = "futures-core" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" [[package]] name = "futures-io" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" [[package]] name = "futures-macro" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", @@ -636,21 +632,21 @@ dependencies = [ [[package]] name = "futures-sink" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" [[package]] name = "futures-task" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" [[package]] name = "futures-util" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ "futures-core", "futures-io", @@ -659,48 +655,60 @@ dependencies = [ "futures-task", "memchr", "pin-project-lite", - "pin-utils", "slab", ] [[package]] name = "getrandom" -version = "0.2.15" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", "js-sys", "libc", - "wasi 0.11.0+wasi-snapshot-preview1", + "wasi", "wasm-bindgen", ] [[package]] name = "getrandom" -version = "0.3.2" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73fea8450eea4bac3940448fb7ae50d91f034f941199fcd9d909a5a07aa455f0" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", "js-sys", "libc", - "r-efi", - "wasi 0.14.2+wasi-0.2.4", + "r-efi 5.3.0", + "wasip2", "wasm-bindgen", ] +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + [[package]] name = "gimli" -version = "0.31.1" +version = "0.32.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" +checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" [[package]] name = "h2" -version = "0.4.11" +version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17da50a276f1e01e0ba6c029e47b7100754904ee8a278f886546e98575380785" +checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" dependencies = [ "atomic-waker", "bytes", @@ -717,20 +725,30 @@ dependencies = [ [[package]] name = "half" -version = "2.5.0" +version = "2.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7db2ff139bba50379da6aa0766b52fdcb62cb5b263009b09ed58ba604e14bbd1" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" dependencies = [ "cfg-if", "crunchy", "num-traits", + "zerocopy", ] [[package]] name = "hashbrown" -version = "0.15.2" +version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" [[package]] name = "heck" @@ -740,20 +758,19 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "hermit-abi" -version = "0.5.0" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fbd780fe5cc30f81464441920d82ac8740e2e46b29a6fad543ddd075229ce37e" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" [[package]] name = "hifitime" -version = "4.2.0" +version = "4.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6bcc71d459e299e045d3328cc4be250c80f2cf87a73c309d562e8afc52c88a23" +checksum = "22f1dfc1be6cd1c3a44704db0a8800aa53ebb07a9509535a0261cd387eedee7c" dependencies = [ "js-sys", "lexical-core", "num-traits", - "openssl", "serde", "serde_derive", "snafu", @@ -766,12 +783,11 @@ dependencies = [ [[package]] name = "http" -version = "1.3.1" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4a85d31aea989eead29a3aaf9e1115a180df8282431156e533de47660892565" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" dependencies = [ "bytes", - "fnv", "itoa", ] @@ -812,13 +828,14 @@ checksum = "8595e3e777338ccc8360c4eb89924f8d7e55a5ff831d057e1c65892c220da28f" [[package]] name = "hyper" -version = "1.6.0" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc2b571658e38e0c01b1fdca3bbbe93c00d3d71693ff2770043f8c29bc7d6f80" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" dependencies = [ + "atomic-waker", "bytes", "futures-channel", - "futures-util", + "futures-core", "h2", "http", "http-body", @@ -832,34 +849,36 @@ dependencies = [ [[package]] name = "hyper-rustls" -version = "0.27.7" +version = "0.27.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" dependencies = [ "http", "hyper", "hyper-util", "rustls", - "rustls-pki-types", "tokio", "tokio-rustls", "tower-service", - "webpki-roots 1.0.2", + "webpki-roots", ] [[package]] name = "hyper-util" -version = "0.1.11" +version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "497bbc33a26fdd4af9ed9c70d63f61cf56a938375fbb32df34db9b1cd6d643f2" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ + "base64", "bytes", "futures-channel", "futures-util", "http", "http-body", "hyper", + "ipnet", "libc", + "percent-encoding", "pin-project-lite", "socket2", "tokio", @@ -869,9 +888,9 @@ dependencies = [ [[package]] name = "iana-time-zone" -version = "0.1.63" +version = "0.1.65" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0c919e5debc312ad217002b8048a17b7d83f80703865bbfcfebb0458b0b27d8" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" dependencies = [ "android_system_properties", "core-foundation-sys", @@ -893,21 +912,23 @@ dependencies = [ [[package]] name = "icu_collections" -version = "1.5.0" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db2fa452206ebee18c4b5c2274dbf1de17008e874b4dc4f0aea9d01ca79e4526" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" dependencies = [ "displaydoc", + "potential_utf", + "utf8_iter", "yoke", "zerofrom", "zerovec", ] [[package]] -name = "icu_locid" -version = "1.5.0" +name = "icu_locale_core" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13acbb8371917fc971be86fc8057c41a64b521c184808a698c02acc242dbf637" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" dependencies = [ "displaydoc", "litemap", @@ -916,104 +937,72 @@ dependencies = [ "zerovec", ] -[[package]] -name = "icu_locid_transform" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01d11ac35de8e40fdeda00d9e1e9d92525f3f9d887cdd7aa81d727596788b54e" -dependencies = [ - "displaydoc", - "icu_locid", - "icu_locid_transform_data", - "icu_provider", - "tinystr", - "zerovec", -] - -[[package]] -name = "icu_locid_transform_data" -version = "1.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7515e6d781098bf9f7205ab3fc7e9709d34554ae0b21ddbcb5febfa4bc7df11d" - [[package]] name = "icu_normalizer" -version = "1.5.0" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19ce3e0da2ec68599d193c93d088142efd7f9c5d6fc9b803774855747dc6a84f" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" dependencies = [ - "displaydoc", "icu_collections", "icu_normalizer_data", "icu_properties", "icu_provider", "smallvec", - "utf16_iter", - "utf8_iter", - "write16", "zerovec", ] [[package]] name = "icu_normalizer_data" -version = "1.5.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5e8338228bdc8ab83303f16b797e177953730f601a96c25d10cb3ab0daa0cb7" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" [[package]] name = "icu_properties" -version = "1.5.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93d6020766cfc6302c15dbbc9c8778c37e62c14427cb7f6e601d849e092aeef5" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" dependencies = [ - "displaydoc", "icu_collections", - "icu_locid_transform", + "icu_locale_core", "icu_properties_data", "icu_provider", - "tinystr", + "zerotrie", "zerovec", ] [[package]] name = "icu_properties_data" -version = "1.5.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85fb8799753b75aee8d2a21d7c14d9f38921b54b3dbda10f5a3c7a7b82dba5e2" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" [[package]] name = "icu_provider" -version = "1.5.0" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ed421c8a8ef78d3e2dbc98a973be2f3770cb42b606e3ab18d6237c4dfde68d9" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" dependencies = [ "displaydoc", - "icu_locid", - "icu_provider_macros", - "stable_deref_trait", - "tinystr", + "icu_locale_core", "writeable", "yoke", "zerofrom", + "zerotrie", "zerovec", ] [[package]] -name = "icu_provider_macros" -version = "1.5.0" +name = "id-arena" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ec89e9337638ecdc08744df490b221a7399bf8d164eb52a665454e60e075ad6" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" [[package]] name = "idna" -version = "1.0.3" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "686f825264d630750a544639377bae737628043f20d38bbc029e8f29ea968a7e" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" dependencies = [ "idna_adapter", "smallvec", @@ -1022,9 +1011,9 @@ dependencies = [ [[package]] name = "idna_adapter" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "daca1df1c957320b2cf139ac61e7bd64fed304c5040df000a745aa1de3b4ef71" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" dependencies = [ "icu_normalizer", "icu_properties", @@ -1032,24 +1021,25 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.10.0" +version = "2.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe4cd85333e22411419a0bcae1297d25e58c9443848b11dc6a86fefe8c78a661" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.17.0", + "serde", + "serde_core", ] [[package]] name = "indicatif" -version = "0.18.0" +version = "0.18.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70a646d946d06bedbbc4cac4c218acf4bbf2d87757a784857025f4d447e4e1cd" +checksum = "25470f23803092da7d239834776d653104d551bc4d7eacaf31e6837854b8e9eb" dependencies = [ "console", "portable-atomic", "unit-prefix", - "web-time", ] [[package]] @@ -1060,19 +1050,29 @@ checksum = "8bb03732005da905c88227371639bf1ad885cc712789c011c31c5fb3ab3ccf02" [[package]] name = "ipnet" -version = "2.11.0" +version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "iri-string" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20" +dependencies = [ + "memchr", + "serde", +] [[package]] name = "is-terminal" -version = "0.4.16" +version = "0.4.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e04d7f318608d35d4b61ddd75cbdaee86b023ebe2bd5a66ee0915f0bf93095a9" +checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1095,31 +1095,33 @@ dependencies = [ [[package]] name = "itoa" -version = "1.0.15" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "js-sys" -version = "0.3.77" +version = "0.3.95" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" +checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca" dependencies = [ + "cfg-if", + "futures-util", "once_cell", "wasm-bindgen", ] [[package]] -name = "lazy_static" -version = "1.5.0" +name = "leb128fmt" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "lexical-core" -version = "1.0.5" +version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b765c31809609075565a70b4b71402281283aeda7ecaf4818ac14a7b2ade8958" +checksum = "7d8d125a277f807e55a77304455eb7b1cb52f2b18c143b60e766c120bd64a594" dependencies = [ "lexical-parse-float", "lexical-parse-integer", @@ -1130,94 +1132,86 @@ dependencies = [ [[package]] name = "lexical-parse-float" -version = "1.0.5" +version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de6f9cb01fb0b08060209a057c048fcbab8717b4c1ecd2eac66ebfe39a65b0f2" +checksum = "52a9f232fbd6f550bc0137dcb5f99ab674071ac2d690ac69704593cb4abbea56" dependencies = [ "lexical-parse-integer", "lexical-util", - "static_assertions", ] [[package]] name = "lexical-parse-integer" -version = "1.0.5" +version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72207aae22fc0a121ba7b6d479e42cbfea549af1479c3f3a4f12c70dd66df12e" +checksum = "9a7a039f8fb9c19c996cd7b2fcce303c1b2874fe1aca544edc85c4a5f8489b34" dependencies = [ "lexical-util", - "static_assertions", ] [[package]] name = "lexical-util" -version = "1.0.6" +version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a82e24bf537fd24c177ffbbdc6ebcc8d54732c35b50a3f28cc3f4e4c949a0b3" -dependencies = [ - "static_assertions", -] +checksum = "2604dd126bb14f13fb5d1bd6a66155079cb9fa655b37f875b3a742c705dbed17" [[package]] name = "lexical-write-float" -version = "1.0.5" +version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5afc668a27f460fb45a81a757b6bf2f43c2d7e30cb5a2dcd3abf294c78d62bd" +checksum = "50c438c87c013188d415fbabbb1dceb44249ab81664efbd31b14ae55dabb6361" dependencies = [ "lexical-util", "lexical-write-integer", - "static_assertions", ] [[package]] name = "lexical-write-integer" -version = "1.0.5" +version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "629ddff1a914a836fb245616a7888b62903aae58fa771e1d83943035efa0f978" +checksum = "409851a618475d2d5796377cad353802345cba92c867d9fbcde9cf4eac4e14df" dependencies = [ "lexical-util", - "static_assertions", ] [[package]] name = "libc" -version = "0.2.171" +version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c19937216e9d3aa9956d9bb8dfc0b0c8beb6058fc4f7a4dc4d850edf86a237d6" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] name = "libm" -version = "0.2.11" +version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8355be11b20d696c8f18f6cc018c4e372165b1fa8126cef092399c9951984ffa" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libredox" -version = "0.1.3" +version = "0.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d" +checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" dependencies = [ - "bitflags 2.9.0", "libc", ] [[package]] name = "linux-raw-sys" -version = "0.9.3" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe7db12097d22ec582439daf8618b8fdd1a7bef6270e9af3b1ebcd30893cf413" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" -version = "0.7.5" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23fb14cb19457329c82206317a5663005a4d404783dc74f4252769b0d5f42856" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" [[package]] name = "log" -version = "0.4.27" +version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" [[package]] name = "lru-slab" @@ -1227,9 +1221,9 @@ checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" [[package]] name = "matrixmultiply" -version = "0.3.9" +version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9380b911e3e96d10c1f415da0876389aaf1b56759054eeb0de7df940c456ba1a" +checksum = "a06de3016e9fae57a36fd14dba131fccf49f74b40b7fbdb472f96e361ec71a08" dependencies = [ "autocfg", "rawpointer", @@ -1237,41 +1231,35 @@ dependencies = [ [[package]] name = "memchr" -version = "2.7.4" +version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" - -[[package]] -name = "mime" -version = "0.3.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" [[package]] name = "miniz_oxide" -version = "0.8.5" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e3e04debbb59698c15bacbb6d93584a8c0ca9cc3213cb423d31f760d8843ce5" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" dependencies = [ "adler2", ] [[package]] name = "mio" -version = "1.0.3" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2886843bf800fba2e3377cff24abf6379b4c4d5c6681eaf9ea5b0d15090450bd" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", - "wasi 0.11.0+wasi-snapshot-preview1", - "windows-sys 0.52.0", + "wasi", + "windows-sys 0.61.2", ] [[package]] name = "nalgebra" -version = "0.33.2" +version = "0.33.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26aecdf64b707efd1310e3544d709c5c0ac61c13756046aaaba41be5c4f66a3b" +checksum = "9d43ddcacf343185dfd6de2ee786d9e8b1c2301622afab66b6c73baf9882abfd" dependencies = [ "approx", "matrixmultiply", @@ -1379,18 +1367,18 @@ dependencies = [ [[package]] name = "object" -version = "0.36.7" +version = "0.37.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" dependencies = [ "memchr", ] [[package]] name = "once_cell" -version = "1.21.3" +version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "oorandom" @@ -1398,54 +1386,6 @@ version = "11.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" -[[package]] -name = "openssl" -version = "0.10.73" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8505734d46c8ab1e19a1dce3aef597ad87dcb4c37e7188231769bd6bd51cebf8" -dependencies = [ - "bitflags 2.9.0", - "cfg-if", - "foreign-types", - "libc", - "once_cell", - "openssl-macros", - "openssl-sys", -] - -[[package]] -name = "openssl-macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "openssl-src" -version = "300.5.2+3.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d270b79e2926f5150189d475bc7e9d2c69f9c4697b185fa917d5a32b792d21b4" -dependencies = [ - "cc", -] - -[[package]] -name = "openssl-sys" -version = "0.9.109" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90096e2e47630d78b7d1c20952dc621f957103f8bc2c8359ec81290d75238571" -dependencies = [ - "cc", - "libc", - "openssl-src", - "pkg-config", - "vcpkg", -] - [[package]] name = "option-ext" version = "0.2.0" @@ -1463,16 +1403,18 @@ dependencies = [ [[package]] name = "ordered-float" -version = "5.0.0" +version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2c1f9f56e534ac6a9b8a4600bdf0f530fb393b5f393e7b4d03489c3cf0c3f01" +checksum = "b7d950ca161dc355eaf28f82b11345ed76c6e1f6eb1f4f4479e0323b9e2fbd0e" dependencies = [ "num-traits", + "rand 0.8.6", + "serde", ] [[package]] name = "outfit" -version = "2.1.0" +version = "3.0.0" dependencies = [ "aberth", "ahash", @@ -1489,11 +1431,12 @@ dependencies = [ "nalgebra", "nom", "once_cell", - "ordered-float 5.0.0", + "ordered-float 5.3.0", "parquet", + "photom", "proptest", - "quick-xml", - "rand", + "quick-xml 0.37.5", + "rand 0.9.4", "rand_distr", "rayon", "reqwest", @@ -1535,7 +1478,7 @@ dependencies = [ "bytes", "chrono", "half", - "hashbrown", + "hashbrown 0.15.5", "num", "num-bigint", "paste", @@ -1553,27 +1496,32 @@ checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" [[package]] name = "percent-encoding" -version = "2.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" - -[[package]] -name = "pin-project-lite" -version = "0.2.16" +version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] -name = "pin-utils" +name = "photom" version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +dependencies = [ + "ahash", + "camino", + "directories", + "hifitime", + "itertools 0.14.0", + "nom", + "ordered-float 5.3.0", + "quick-xml 0.39.2", + "serde", + "thiserror", + "ureq", +] [[package]] -name = "pkg-config" -version = "0.3.32" +name = "pin-project-lite" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "plotters" @@ -1605,9 +1553,18 @@ dependencies = [ [[package]] name = "portable-atomic" -version = "1.11.1" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] [[package]] name = "ppv-lite86" @@ -1615,7 +1572,17 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" dependencies = [ - "zerocopy 0.8.26", + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", ] [[package]] @@ -1642,25 +1609,24 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.94" +version = "1.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a31971752e70b8b2686d7e46ec17fb38dad4051d94024c88df49b667caea9c84" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" dependencies = [ "unicode-ident", ] [[package]] name = "proptest" -version = "1.7.0" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6fcdab19deb5195a31cf7726a210015ff1496ba1464fd42cb4f537b8b01b471f" +checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" dependencies = [ "bit-set", "bit-vec", - "bitflags 2.9.0", - "lazy_static", + "bitflags 2.11.1", "num-traits", - "rand", + "rand 0.9.4", "rand_chacha", "rand_xorshift", "regex-syntax", @@ -1677,9 +1643,19 @@ checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" [[package]] name = "quick-xml" -version = "0.37.4" +version = "0.37.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "quick-xml" +version = "0.39.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4ce8c88de324ff838700f36fb6ab86c96df0e3c4ab6ef3a9b2044465cce1369" +checksum = "958f21e8e7ceb5a1aa7fa87fab28e7c75976e0bfe7e23ff069e0a260f894067d" dependencies = [ "memchr", "serde", @@ -1707,14 +1683,14 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.13" +version = "0.11.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" dependencies = [ "bytes", - "getrandom 0.3.2", + "getrandom 0.3.4", "lru-slab", - "rand", + "rand 0.9.4", "ring", "rustc-hash", "rustls", @@ -1742,27 +1718,43 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.40" +version = "1.0.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" dependencies = [ "proc-macro2", ] [[package]] name = "r-efi" -version = "5.2.0" +version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74765f6d916ee2faa39bc8e68e4f3ed8949b48cccdac59983d287a7cb71ce9c5" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] name = "rand" -version = "0.9.2" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "rand_core 0.6.4", + "serde", +] + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" dependencies = [ "rand_chacha", - "rand_core", + "rand_core 0.9.5", ] [[package]] @@ -1772,16 +1764,25 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.9.5", ] [[package]] name = "rand_core" -version = "0.9.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ - "getrandom 0.3.2", + "serde", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", ] [[package]] @@ -1791,7 +1792,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a8615d50dcf34fa31f7ab52692afec947c4dd0ab803cc87cb3b0b4570ff7463" dependencies = [ "num-traits", - "rand", + "rand 0.9.4", ] [[package]] @@ -1800,7 +1801,7 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" dependencies = [ - "rand_core", + "rand_core 0.9.5", ] [[package]] @@ -1811,9 +1812,9 @@ checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3" [[package]] name = "rayon" -version = "1.11.0" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" dependencies = [ "either", "rayon-core", @@ -1831,20 +1832,20 @@ dependencies = [ [[package]] name = "redox_users" -version = "0.5.0" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd6f9d3d47bdd2ad6945c5015a226ec6155d0bcdfd8f7cd29f86b71f8de99d2b" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ - "getrandom 0.2.15", + "getrandom 0.2.17", "libredox", "thiserror", ] [[package]] name = "regex" -version = "1.11.1" +version = "1.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" dependencies = [ "aho-corasick", "memchr", @@ -1854,9 +1855,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.9" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" dependencies = [ "aho-corasick", "memchr", @@ -1865,15 +1866,15 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.5" +version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" [[package]] name = "reqwest" -version = "0.12.15" +version = "0.12.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d19c46a6fdd48bc4dab94b6103fccc55d34c67cc0ad04653aad4ea2a07cd7bbb" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ "base64", "bytes", @@ -1886,16 +1887,12 @@ dependencies = [ "hyper", "hyper-rustls", "hyper-util", - "ipnet", "js-sys", "log", - "mime", - "once_cell", "percent-encoding", "pin-project-lite", "quinn", "rustls", - "rustls-pemfile", "rustls-pki-types", "serde", "serde_json", @@ -1905,14 +1902,14 @@ dependencies = [ "tokio-rustls", "tokio-util", "tower", + "tower-http", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", "wasm-streams", "web-sys", - "webpki-roots 0.26.8", - "windows-registry", + "webpki-roots", ] [[package]] @@ -1923,7 +1920,7 @@ checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" dependencies = [ "cc", "cfg-if", - "getrandom 0.2.15", + "getrandom 0.2.17", "libc", "untrusted", "windows-sys 0.52.0", @@ -1937,15 +1934,15 @@ checksum = "082f11ffa03bbef6c2c6ea6bea1acafaade2fd9050ae0234ab44a2153742b058" [[package]] name = "rustc-demangle" -version = "0.1.24" +version = "0.1.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" +checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" [[package]] name = "rustc-hash" -version = "2.1.1" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" [[package]] name = "rustc_version" @@ -1958,22 +1955,22 @@ dependencies = [ [[package]] name = "rustix" -version = "1.0.5" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d97817398dd4bb2e6da002002db259209759911da105da92bec29ccb12cf58bf" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.11.1", "errno", "libc", "linux-raw-sys", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] name = "rustls" -version = "0.23.25" +version = "0.23.39" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "822ee9188ac4ec04a2f0531e55d035fb2de73f18b41a63c70c2712503b6fb13c" +checksum = "7c2c118cb077cca2822033836dfb1b975355dfb784b5e8da48f7b6c5db74e60e" dependencies = [ "log", "once_cell", @@ -1984,29 +1981,21 @@ dependencies = [ "zeroize", ] -[[package]] -name = "rustls-pemfile" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" -dependencies = [ - "rustls-pki-types", -] - [[package]] name = "rustls-pki-types" -version = "1.11.0" +version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "917ce264624a4b4db1c364dcc35bfca9ded014d0a958cd47ad3e960e988ea51c" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" dependencies = [ "web-time", + "zeroize", ] [[package]] name = "rustls-webpki" -version = "0.103.1" +version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fef8b8769aaccf73098557a87cd1816b4f9c7c16811c9c77142aa695c16f2c03" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ "ring", "rustls-pki-types", @@ -2015,15 +2004,15 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.20" +version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eded382c5f5f786b989652c49544c4877d9f015cc22e145a5ea8ea66c2921cd2" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" [[package]] name = "rusty-fork" -version = "0.3.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb3dcc6e454c328bb824492db107ab7c0ae8fcffe4ad210136ef014458c1bc4f" +checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" dependencies = [ "fnv", "quick-error", @@ -2033,9 +2022,9 @@ dependencies = [ [[package]] name = "ryu" -version = "1.0.20" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" [[package]] name = "safe_arch" @@ -2057,9 +2046,9 @@ dependencies = [ [[package]] name = "semver" -version = "1.0.26" +version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56e6fa9c48d24d85fb3de5ad847117517440f6beceb7798af16b4a87d616b8d0" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" [[package]] name = "seq-macro" @@ -2069,18 +2058,28 @@ checksum = "1bc711410fbe7399f390ca1c3b60ad0f53f80e95c5eb935e52268a0e2cd49acc" [[package]] name = "serde" -version = "1.0.219" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.219" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", @@ -2089,14 +2088,15 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.140" +version = "1.0.149" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" dependencies = [ "itoa", "memchr", - "ryu", "serde", + "serde_core", + "zmij", ] [[package]] @@ -2119,9 +2119,9 @@ checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" [[package]] name = "simba" -version = "0.9.0" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3a386a501cd104797982c15ae17aafe8b9261315b5d07e3ec803f2ea26be0fa" +checksum = "c99284beb21666094ba2b75bbceda012e610f5479dfcc2d6e2426f53197ffd95" dependencies = [ "approx", "num-complex", @@ -2132,24 +2132,21 @@ dependencies = [ [[package]] name = "slab" -version = "0.4.9" +version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67" -dependencies = [ - "autocfg", -] +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.14.0" +version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fcf8323ef1faaee30a44a340193b1ac6814fd9b7b4e88e9d4519a3e4abe1cfd" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" [[package]] name = "snafu" -version = "0.8.5" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "223891c85e2a29c3fe8fb900c1fae5e69c2e42415e3177752e8718475efa5019" +checksum = "d1d4bced6a69f90b2056c03dcff2c4737f98d6fb9e0853493996e1d253ca29c6" dependencies = [ "backtrace", "snafu-derive", @@ -2157,9 +2154,9 @@ dependencies = [ [[package]] name = "snafu-derive" -version = "0.8.5" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03c3c6b7927ffe7ecaa769ee0e3994da3b8cafc8f444578982c83ecb161af917" +checksum = "54254b8531cafa275c5e096f62d48c81435d1015405a91198ddb11e967301d40" dependencies = [ "heck", "proc-macro2", @@ -2175,19 +2172,19 @@ checksum = "1b6b67fb9a61334225b5b790716f609cd58395f895b3fe8b328786812a40bc3b" [[package]] name = "socket2" -version = "0.5.9" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f5fd57c80058a56cf5c777ab8a126398ece8e442983605d280a44ce79d0edef" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] name = "stable_deref_trait" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" [[package]] name = "static_assertions" @@ -2203,9 +2200,9 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "syn" -version = "2.0.100" +version = "2.0.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b09a44accad81e1ba1cd74a32461ba89dee89095ba17b32f5d03683b1b1fc2a0" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" dependencies = [ "proc-macro2", "quote", @@ -2223,9 +2220,9 @@ dependencies = [ [[package]] name = "synstructure" -version = "0.13.1" +version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8af7666ab7b6390ab78131fb5b0fce11d6b7a6951602017c35fa82800708971" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", @@ -2258,15 +2255,15 @@ dependencies = [ [[package]] name = "tempfile" -version = "3.19.1" +version = "3.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7437ac7763b9b123ccf33c338a5cc1bac6f69b45a136c19bdd8a65e3916435bf" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.2", + "getrandom 0.4.2", "once_cell", "rustix", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2280,18 +2277,18 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.12" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "2.0.12" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", @@ -2320,9 +2317,9 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.7.6" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9117f5d4db391c1cf6927e7bea3db74b9a1c1add8f7eda9ffd5364f40f57b82f" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" dependencies = [ "displaydoc", "zerovec", @@ -2340,9 +2337,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.10.0" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" dependencies = [ "tinyvec_macros", ] @@ -2355,24 +2352,23 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.44.1" +version = "1.52.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f382da615b842244d4b8738c82ed1275e6c5dd90c459a30941cd07080b06c91a" +checksum = "b67dee974fe86fd92cc45b7a95fdd2f99a36a6d7b0d431a231178d3d670bbcc6" dependencies = [ - "backtrace", "bytes", "libc", "mio", "pin-project-lite", "socket2", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] name = "tokio-rustls" -version = "0.26.2" +version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e727b36a1a0e8b74c376ac2211e40c2c8af09fb4013c60d910495810f008e9b" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" dependencies = [ "rustls", "tokio", @@ -2380,9 +2376,9 @@ dependencies = [ [[package]] name = "tokio-stream" -version = "0.1.17" +version = "0.1.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eca58d7bba4a75707817a2c44174253f9236b2d5fbd055602e9d5c07c139a047" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" dependencies = [ "futures-core", "pin-project-lite", @@ -2391,9 +2387,9 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.14" +version = "0.7.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b9590b93e6fcc1739458317cccd391ad3955e2bde8913edf6f95f9e65a8f034" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" dependencies = [ "bytes", "futures-core", @@ -2404,9 +2400,9 @@ dependencies = [ [[package]] name = "tower" -version = "0.5.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" dependencies = [ "futures-core", "futures-util", @@ -2417,6 +2413,24 @@ dependencies = [ "tower-service", ] +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "bitflags 2.11.1", + "bytes", + "futures-util", + "http", + "http-body", + "iri-string", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", +] + [[package]] name = "tower-layer" version = "0.3.3" @@ -2431,9 +2445,9 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" [[package]] name = "tracing" -version = "0.1.41" +version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "pin-project-lite", "tracing-core", @@ -2441,9 +2455,9 @@ dependencies = [ [[package]] name = "tracing-core" -version = "0.1.33" +version = "0.1.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e672c95779cf947c5311f83787af4fa8fffd12fb27e4993211a84bdfd9610f9c" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", ] @@ -2466,9 +2480,9 @@ dependencies = [ [[package]] name = "typenum" -version = "1.18.0" +version = "1.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" [[package]] name = "unarray" @@ -2478,27 +2492,33 @@ checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" [[package]] name = "unicode-ident" -version = "1.0.18" +version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "unicode-segmentation" -version = "1.12.0" +version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" [[package]] name = "unicode-width" -version = "0.2.1" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "unicode-xid" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a1a07cc7db3810833284e8d372ccdc6da29741639ecc70c9ec107df0fa6154c" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" [[package]] name = "unit-prefix" -version = "0.5.1" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "323402cff2dd658f39ca17c789b502021b3f18707c91cdf22e3838e1b4023817" +checksum = "81e544489bf3d8ef66c953931f56617f423cd4b5494be343d9b9d3dda037b9a3" [[package]] name = "untrusted" @@ -2508,26 +2528,25 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] name = "ureq" -version = "3.0.10" +version = "3.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b0351ca625c7b41a8e4f9bb6c5d9755f67f62c2187ebedecacd9974674b271d" +checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0" dependencies = [ "base64", "log", "percent-encoding", "rustls", - "rustls-pemfile", "rustls-pki-types", "ureq-proto", - "utf-8", - "webpki-roots 0.26.8", + "utf8-zero", + "webpki-roots", ] [[package]] name = "ureq-proto" -version = "0.3.5" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae239d0a3341aebc94259414d1dc67cfce87d41cbebc816772c91b77902fafa4" +checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c" dependencies = [ "base64", "http", @@ -2537,26 +2556,21 @@ dependencies = [ [[package]] name = "url" -version = "2.5.4" +version = "2.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32f8b686cadd1473f4bd0117a5d28d36b1ade384ea9b5069a1c40aefed7fda60" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" dependencies = [ "form_urlencoded", "idna", "percent-encoding", + "serde", ] [[package]] -name = "utf-8" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" - -[[package]] -name = "utf16_iter" -version = "1.0.5" +name = "utf8-zero" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" [[package]] name = "utf8_iter" @@ -2564,12 +2578,6 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" -[[package]] -name = "vcpkg" -version = "0.2.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" - [[package]] name = "version_check" version = "0.9.5" @@ -2606,63 +2614,56 @@ dependencies = [ [[package]] name = "wasi" -version = "0.11.0+wasi-snapshot-preview1" +version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] -name = "wasi" -version = "0.14.2+wasi-0.2.4" +name = "wasip2" +version = "1.0.3+wasi-0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" dependencies = [ - "wit-bindgen-rt", + "wit-bindgen 0.57.1", ] [[package]] -name = "wasm-bindgen" -version = "0.2.100" +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" dependencies = [ - "cfg-if", - "once_cell", - "rustversion", - "wasm-bindgen-macro", + "wit-bindgen 0.51.0", ] [[package]] -name = "wasm-bindgen-backend" -version = "0.2.100" +name = "wasm-bindgen" +version = "0.2.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" +checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89" dependencies = [ - "bumpalo", - "log", - "proc-macro2", - "quote", - "syn", + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-futures" -version = "0.4.50" +version = "0.4.68" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "555d470ec0bc3bb57890405e5d4322cc9ea83cebb085523ced7be4144dac1e61" +checksum = "f371d383f2fb139252e0bfac3b81b265689bf45b6874af544ffa4c975ac1ebf8" dependencies = [ - "cfg-if", "js-sys", - "once_cell", "wasm-bindgen", - "web-sys", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.100" +version = "0.2.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" +checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -2670,26 +2671,48 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.100" +version = "0.2.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" +checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904" dependencies = [ + "bumpalo", "proc-macro2", "quote", "syn", - "wasm-bindgen-backend", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.100" +version = "0.2.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" +checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129" dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + [[package]] name = "wasm-streams" version = "0.4.2" @@ -2703,11 +2726,23 @@ dependencies = [ "web-sys", ] +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.11.1", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + [[package]] name = "web-sys" -version = "0.3.77" +version = "0.3.95" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33b6dd2ef9186f1f2072e409e99cd22a975331a6b3591b12c764e0e55c60d5d2" +checksum = "4f2dfbb17949fa2088e5d39408c48368947b86f7834484e87b73de55bc14d97d" dependencies = [ "js-sys", "wasm-bindgen", @@ -2725,27 +2760,18 @@ dependencies = [ [[package]] name = "webpki-roots" -version = "0.26.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2210b291f7ea53617fbafcc4939f10914214ec15aace5ba62293a668f322c5c9" -dependencies = [ - "rustls-pki-types", -] - -[[package]] -name = "webpki-roots" -version = "1.0.2" +version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e8983c3ab33d6fb807cfcdad2491c4ea8cbc8ed839181c7dfd9c67c83e261b2" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" dependencies = [ "rustls-pki-types", ] [[package]] name = "wide" -version = "0.7.32" +version = "0.7.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41b5576b9a81633f3e8df296ce0063042a73507636cbe956c61133dd7034ab22" +checksum = "0ce5da8ecb62bcd8ec8b7ea19f69a51275e91299be594ea5cc6ef7819e16cd03" dependencies = [ "bytemuck", "safe_arch", @@ -2753,31 +2779,31 @@ dependencies = [ [[package]] name = "winapi-util" -version = "0.1.9" +version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] name = "windows-core" -version = "0.61.0" +version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4763c1de310c86d75a878046489e2e5ba02c649d185f21c67d4cf8a56d098980" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ "windows-implement", "windows-interface", "windows-link", "windows-result", - "windows-strings 0.4.0", + "windows-strings", ] [[package]] name = "windows-implement" -version = "0.60.0" +version = "0.60.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", @@ -2786,9 +2812,9 @@ dependencies = [ [[package]] name = "windows-interface" -version = "0.59.1" +version = "0.59.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", @@ -2797,44 +2823,24 @@ dependencies = [ [[package]] name = "windows-link" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" - -[[package]] -name = "windows-registry" -version = "0.4.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4286ad90ddb45071efd1a66dfa43eb02dd0dfbae1545ad6cc3c51cf34d7e8ba3" -dependencies = [ - "windows-result", - "windows-strings 0.3.1", - "windows-targets 0.53.3", -] +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" [[package]] name = "windows-result" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c64fd11a4fd95df68efcfee5f44a294fe71b8bc6a91993e2791938abcc712252" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-strings" -version = "0.3.1" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87fa48cc5d406560701792be122a10132491cff9d0aeb23583cc2dcafc847319" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" dependencies = [ "windows-link", ] [[package]] name = "windows-strings" -version = "0.4.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a2ba9642430ee452d5a7aa78d72907ebe8cfda358e8cb7918a2050581322f97" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" dependencies = [ "windows-link", ] @@ -2850,20 +2856,20 @@ dependencies = [ [[package]] name = "windows-sys" -version = "0.59.0" +version = "0.60.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" dependencies = [ - "windows-targets 0.52.6", + "windows-targets 0.53.5", ] [[package]] name = "windows-sys" -version = "0.60.2" +version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ - "windows-targets 0.53.3", + "windows-link", ] [[package]] @@ -2884,19 +2890,19 @@ dependencies = [ [[package]] name = "windows-targets" -version = "0.53.3" +version = "0.53.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d5fe6031c4041849d7c496a8ded650796e7b6ecc19df1a431c1a363342e5dc91" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" dependencies = [ "windows-link", - "windows_aarch64_gnullvm 0.53.0", - "windows_aarch64_msvc 0.53.0", - "windows_i686_gnu 0.53.0", - "windows_i686_gnullvm 0.53.0", - "windows_i686_msvc 0.53.0", - "windows_x86_64_gnu 0.53.0", - "windows_x86_64_gnullvm 0.53.0", - "windows_x86_64_msvc 0.53.0", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", ] [[package]] @@ -2907,9 +2913,9 @@ checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" [[package]] name = "windows_aarch64_gnullvm" -version = "0.53.0" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" [[package]] name = "windows_aarch64_msvc" @@ -2919,9 +2925,9 @@ checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" [[package]] name = "windows_aarch64_msvc" -version = "0.53.0" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" [[package]] name = "windows_i686_gnu" @@ -2931,9 +2937,9 @@ checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" [[package]] name = "windows_i686_gnu" -version = "0.53.0" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1dc67659d35f387f5f6c479dc4e28f1d4bb90ddd1a5d3da2e5d97b42d6272c3" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" [[package]] name = "windows_i686_gnullvm" @@ -2943,9 +2949,9 @@ checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" [[package]] name = "windows_i686_gnullvm" -version = "0.53.0" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" [[package]] name = "windows_i686_msvc" @@ -2955,9 +2961,9 @@ checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" [[package]] name = "windows_i686_msvc" -version = "0.53.0" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" [[package]] name = "windows_x86_64_gnu" @@ -2967,9 +2973,9 @@ checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" [[package]] name = "windows_x86_64_gnu" -version = "0.53.0" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" [[package]] name = "windows_x86_64_gnullvm" @@ -2979,9 +2985,9 @@ checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" [[package]] name = "windows_x86_64_gnullvm" -version = "0.53.0" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" [[package]] name = "windows_x86_64_msvc" @@ -2991,89 +2997,147 @@ checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] name = "windows_x86_64_msvc" -version = "0.53.0" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" [[package]] -name = "wit-bindgen-rt" -version = "0.39.0" +name = "wit-bindgen" +version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" dependencies = [ - "bitflags 2.9.0", + "wit-bindgen-rust-macro", ] [[package]] -name = "write16" -version = "1.0.0" +name = "wit-bindgen" +version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" [[package]] -name = "writeable" -version = "0.5.5" +name = "wit-bindgen-core" +version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] [[package]] -name = "yoke" -version = "0.7.5" +name = "wit-bindgen-rust" +version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "120e6aef9aa629e3d4f52dc8cc43a015c7724194c97dfaf45180d2daf2b77f40" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" dependencies = [ - "serde", - "stable_deref_trait", - "yoke-derive", - "zerofrom", + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", ] [[package]] -name = "yoke-derive" -version = "0.7.5" +name = "wit-bindgen-rust-macro" +version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2380878cad4ac9aac1e2435f3eb4020e8374b5f13c296cb75b4620ff8e229154" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" dependencies = [ + "anyhow", + "prettyplease", "proc-macro2", "quote", "syn", - "synstructure", + "wit-bindgen-core", + "wit-bindgen-rust", ] [[package]] -name = "zerocopy" -version = "0.7.35" +name = "wit-component" +version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ - "zerocopy-derive 0.7.35", + "anyhow", + "bitflags 2.11.1", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", ] [[package]] -name = "zerocopy" -version = "0.8.26" +name = "wit-parser" +version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1039dd0d3c310cf05de012d8a39ff557cb0d23087fd44cad61df08fc31907a2f" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" dependencies = [ - "zerocopy-derive 0.8.26", + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", ] [[package]] -name = "zerocopy-derive" -version = "0.7.35" +name = "writeable" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.26" +version = "0.8.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ecf5b4cc5364572d7f4c329661bcc82724222973f2cab6f050a4e5c22f75181" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" dependencies = [ "proc-macro2", "quote", @@ -3082,18 +3146,18 @@ dependencies = [ [[package]] name = "zerofrom" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" dependencies = [ "zerofrom-derive", ] [[package]] name = "zerofrom-derive" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", @@ -3103,15 +3167,26 @@ dependencies = [ [[package]] name = "zeroize" -version = "1.8.1" +version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] [[package]] name = "zerovec" -version = "0.10.4" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa2b893d79df23bfb12d5461018d408ea19dfafe76c2c7ef6d4eba614f8ff079" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" dependencies = [ "yoke", "zerofrom", @@ -3120,11 +3195,17 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.10.3" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6eafa6dfb17584ea3e2bd6e76e0cc15ad7af12b09abdd1ca55961bed9b1063c6" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", "syn", ] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml index a1867e8..8d94981 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,9 +1,9 @@ [package] name = "outfit" -version = "2.1.0" +version = "3.0.0" edition = "2021" license-file = "LICENSE" -rust-version = "1.82" +rust-version = "1.94.0" description = "Orbit determination toolkit in Rust. Provides astrometric parsing, observer management, and initial orbit determination (Gauss method) with JPL ephemeris support." readme = "README.md" repository = "https://github.com/FusRoman/Outfit" @@ -20,6 +20,9 @@ categories = ["science", "parsing", "data-structures"] authors = ["Roman Le Montagner "] [dependencies] + +photom = { version = "0.1.0", path = "../photom" } + aberth = { version = "0.4.1", default-features = false } ahash = { version = "0.8.11", default-features = false } arrow-array = { version = "54.3.1", default-features = false } @@ -51,21 +54,22 @@ roots = { version = "0.0.8", default-features = false } rand = { version = "0.9.2", default-features = false, features = [ "std_rng", "os_rng", + "small_rng" ] } rand_distr = { version = "0.5.1", default-features = false } -reqwest = { version = "0.12.15", default-features = false, optional = true, features = [ +reqwest = { version = "0.12.15", default-features = false, features = [ "http2", "rustls-tls", "stream", ] } -tokio = { version = "1.44.1", default-features = false, optional = true, features = [ +tokio = { version = "1.44.1", default-features = false, features = [ "fs", "rt", "rt-multi-thread", "io-util", ] } -tokio-stream = { version = "0.1.17", default-features = false, optional = true } +tokio-stream = { version = "0.1.17", default-features = false } indicatif = { version = "0.18", optional = true, default-features = false } rayon = { version = "1.11.0", optional = true, default-features = false } comfy-table = { version = "7.1.4", default-features = false } @@ -76,21 +80,12 @@ criterion = { version = "0.5.1", features = ["html_reports"] } husky-rs = "0.1.5" proptest = "1.7.0" +photom = { version = "0.1.0", path = "../photom", features = ["mpc_80_col", "ades"] } + [features] -jpl-download = ["dep:reqwest", "dep:tokio", "dep:tokio-stream"] progress = ["dep:indicatif"] parallel = ["dep:rayon"] -[[test]] -name = "reader_80col_test" -path = "tests/reader_80col_test.rs" -required-features = ["jpl-download"] - -[[test]] -name = "test_read_ades" -path = "tests/test_read_ades.rs" -required-features = ["jpl-download"] - [[test]] name = "test_large_parquet" path = "tests/trajectories_from_parquet.rs" @@ -142,7 +137,3 @@ debug = false lto = "fat" codegen-units = 1 strip = true - -[package.metadata.docs.rs] -features = ["jpl-download"] -rustdoc-args = ["--cfg", "docsrs"] diff --git a/rust-toolchain.toml b/rust-toolchain.toml index 7855e6d..0d8ed42 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,3 +1,3 @@ [toolchain] -channel = "1.88.0" +channel = "1.94.0" components = ["rustfmt", "clippy"] diff --git a/src/cache/mod.rs b/src/cache/mod.rs new file mode 100644 index 0000000..2110e15 --- /dev/null +++ b/src/cache/mod.rs @@ -0,0 +1,78 @@ +pub mod observer_centric_cache; +pub mod observer_fixed_cache; + +use hifitime::ut1::Ut1Provider; +use photom::{observation_dataset::ObsDataset, observer::dataset::ObserverId, ObsIndex}; + +use crate::{ + cache::{ + observer_centric_cache::{ + build_centric_observer_cache, CentricObserverCache, ObserverCentricCache, + ObserverGeocentricPosition, ObserverGeocentricVelocity, ObserverHeliocentricPosition, + }, + observer_fixed_cache::{ + build_fixed_observer_cache, BodyFixedObserverCache, ObserverFixedCache, + }, + }, + JPLEphem, OutfitError, +}; + +/// Precomputed observer positions for all observations in a dataset. +/// +/// Built once before any trajectory fitting. Each entry is keyed by the +/// observation's [`ObsIndex`], which is stable for the lifetime of the +/// [`ObsDataset`]. +#[derive(Debug)] +pub struct OutfitCache { + /// len == number of observations in the dataset. Indexed by [`ObsIndex`]. + observer_centric: CentricObserverCache, + /// len == number of observer in the dataset. Indexed by observer ID. + observer_fixed: BodyFixedObserverCache, +} + +impl OutfitCache { + pub fn get_centric(&self, idx: ObsIndex) -> &ObserverCentricCache { + &self.observer_centric[idx] + } + + pub fn get_fixed(&self, observer_id: ObserverId) -> Option<&ObserverFixedCache> { + self.observer_fixed.get(&observer_id) + } + + /// Build the cache for every observation in `obs_dataset`. + pub fn build( + obs_dataset: &ObsDataset, + jpl: &JPLEphem, + ut1_provider: &Ut1Provider, + ) -> Result { + let observer_iter = obs_dataset.iter_observer()?; + + let observer_fixed_cache = build_fixed_observer_cache(observer_iter)?; + + let observer_centric_cache = + build_centric_observer_cache(jpl, ut1_provider, obs_dataset, &observer_fixed_cache)?; + + Ok(Self { + observer_centric: observer_centric_cache, + observer_fixed: observer_fixed_cache, + }) + } + + pub fn get_observer_fixed_cache(&self, observer_id: ObserverId) -> Option<&ObserverFixedCache> { + self.observer_fixed.get(&observer_id) + } + + /// Accessor for the precomputed geocentric position of an observer. + pub fn get_observer_geocentric_position(&self, idx: ObsIndex) -> &ObserverGeocentricPosition { + &self.get_centric(idx).geo_position + } + + pub fn get_observer_geocentric_velocity(&self, idx: ObsIndex) -> &ObserverGeocentricVelocity { + &self.get_centric(idx).geo_velocity + } + + /// Accessor for the precomputed heliocentric position of an observer. + pub fn get_helio_position(&self, idx: ObsIndex) -> &ObserverHeliocentricPosition { + &self.get_centric(idx).helio_position + } +} diff --git a/src/cache/observer_centric_cache.rs b/src/cache/observer_centric_cache.rs new file mode 100644 index 0000000..feee960 --- /dev/null +++ b/src/cache/observer_centric_cache.rs @@ -0,0 +1,196 @@ +use hifitime::{ut1::Ut1Provider, Epoch}; +use nalgebra::Vector3; +use ordered_float::NotNan; +use photom::{observation_dataset::ObsDataset, observer::Observer, MJDTT}; + +use crate::{ + cache::observer_fixed_cache::{BodyFixedObserverCache, ObserverFixedCache}, + observer_extension::ResolvedObserver, + JPLEphem, OutfitError, +}; + +/// Geocentric position of the observer at `time` of an observation (AU, ecliptic mean J2000). +pub type ObserverGeocentricPosition = Vector3>; +/// Geocentric velocity of the observer at `time` of an observation (AU/day, ecliptic mean J2000). +pub type ObserverGeocentricVelocity = Vector3>; +/// Heliocentric position of the observer at `time` of an observation (AU, ecliptic mean J2000). +pub type ObserverHeliocentricPosition = Vector3>; + +/// Geocentric and heliocentric observer positions for a single observation epoch. +#[derive(Debug)] +pub struct ObserverCentricCache { + pub geo_position: ObserverGeocentricPosition, + pub geo_velocity: ObserverGeocentricVelocity, + pub helio_position: ObserverHeliocentricPosition, +} + +impl ObserverCentricCache { + pub fn new( + jpl: &JPLEphem, + ut1_provider: &Ut1Provider, + obs_time: MJDTT, + observer_fixed_cache: &ObserverFixedCache, + ) -> Result { + let obs_mjd = Epoch::from_mjd_in_time_scale(obs_time, hifitime::TimeScale::TT); + let (geocentric_pos, geocentric_vel) = + Observer::pvobs(&obs_mjd, ut1_provider, observer_fixed_cache)?; + + let heliocentric_pos = Observer::helio_position(jpl, &obs_mjd, &geocentric_pos)?; + + Ok(Self { + geo_position: geocentric_pos, + geo_velocity: geocentric_vel, + helio_position: heliocentric_pos, + }) + } +} + +pub type CentricObserverCache = Vec; + +pub fn build_centric_observer_cache( + jpl: &JPLEphem, + ut1_provider: &Ut1Provider, + obs_dataset: &ObsDataset, + observer_fixed_cache: &BodyFixedObserverCache, +) -> Result { + obs_dataset + .iter_observations() + .enumerate() + .map(|(idx, obs)| { + let observer_id = obs + .observer_id() + .ok_or_else(|| return OutfitError::ObserverIdIsNone(idx as u64))?; + + let fixed_cache = observer_fixed_cache + .get(&observer_id) + .ok_or_else(|| return OutfitError::ObserverIdIsNone(idx as u64))?; + + ObserverCentricCache::new(jpl, ut1_provider, obs.mjd_tt(), fixed_cache) + }) + .collect() +} + +#[cfg(test)] +mod observer_test { + + use photom::{Meters, Radians}; + + use crate::{ + cache::observer_centric_cache::ObserverCentricCache, + test_fixture::{JPL_EPHEM_HORIZON, UT1_PROVIDER}, + }; + + use super::*; + + fn to_observer( + longitude: Radians, + latitude: Radians, + height: Meters, + name: Option, + ra_accuracy: Option, + dec_accuracy: Option, + ) -> Observer { + let observer = Observer::new(longitude, latitude, height, name, ra_accuracy, dec_accuracy) + .expect("Failed to create Observer"); + observer + } + + #[test] + fn body_fixed_coord_test() { + // longitude, latitude and height of Pan-STARRS 1, Haleakala + let (lon, lat, h) = ( + 203.744090000_f64.to_radians(), + 20.707233557_f64.to_radians(), + 3067.694, + ); + let pan_starrs = to_observer(lon, lat, h, None, None, None); + assert_eq!( + pan_starrs + .earth_fixed_position() + .unwrap() + .map(|x| x.into_inner()), + Vector3::new( + -0.00003653799439776371, + -0.00001607260397528885, + 0.000014988110430544328 + ) + ); + } + + #[test] + fn pvobs_test() { + let tmjd = 57028.479297592596; + let epoch = Epoch::from_mjd_in_time_scale(tmjd, hifitime::TimeScale::TT); + // longitude, latitude and height of Pan-STARRS 1, Haleakala + let (lon, lat, h) = ( + 203.744090000_f64.to_radians(), + 20.707233557_f64.to_radians(), + 3067.694, + ); + + let pan_starrs = to_observer(lon, lat, h, Some("Pan-STARRS 1".to_string()), None, None); + + let observer_fixed_cache: ObserverFixedCache = (&pan_starrs).try_into().unwrap(); + + let (observer_position, observer_velocity) = + Observer::pvobs(&epoch, &UT1_PROVIDER, &observer_fixed_cache).unwrap(); + + assert_eq!( + observer_position.as_slice(), + [ + -2.086211182493635e-5, + 3.718476815327979e-5, + 2.4978996447997476e-7 + ] + ); + assert_eq!( + observer_velocity.as_slice(), + [ + -0.0002143246535691577, + -0.00012059801691431748, + 5.262184624215718e-5 + ] + ); + } + + #[test] + fn test_helio_pos_obs() { + let (lon, lat, h) = (203.744090000_f64, 20.707233557_f64, 3067.694_f64); + let pan_starrs = to_observer( + lon.to_radians(), + lat.to_radians(), + h, + Some("Pan-STARRS 1".to_string()), + None, + None, + ); + let observer_fixed_cache: ObserverFixedCache = (&pan_starrs).try_into().unwrap(); + + let cases = [ + ( + 57_028.479_297_592_596, + [-0.2645666171464416, 0.8689351643701766, 0.3766996211107864], + ), + ( + 57_049.245_147_592_59, + [-0.5891631852137064, 0.7238872516824697, 0.3138186516540669], + ), + ( + 57_063.977_117_592_59, + [-0.7743280306286537, 0.5612532665812755, 0.24333415479994636], + ), + ]; + + for (tmjd, expected) in cases { + let obs = ObserverCentricCache::new( + &JPL_EPHEM_HORIZON, + &UT1_PROVIDER, + tmjd, + &observer_fixed_cache, + ) + .unwrap(); + + assert_eq!(obs.helio_position.as_slice(), expected, "tmjd = {tmjd}"); + } + } +} diff --git a/src/cache/observer_fixed_cache.rs b/src/cache/observer_fixed_cache.rs new file mode 100644 index 0000000..16dc251 --- /dev/null +++ b/src/cache/observer_fixed_cache.rs @@ -0,0 +1,63 @@ +use ahash::AHashMap; +use nalgebra::Vector3; +use ordered_float::NotNan; +use photom::observer::{dataset::ObserverId, Observer}; + +use crate::{ + constants::EARTH_ROTATION, conversion::ToNotNan, observer_extension::ResolvedObserver, + OutfitError, +}; + +/// Precomputed **body-fixed** position of the observer in **AU**. +pub type ObserverFixedPosition = Vector3>; +/// Precomputed **body-fixed** velocity of the observer in **AU/day**. +pub type ObserverFixedVelocity = Vector3>; + +#[derive(Debug)] +pub struct ObserverFixedCache { + observer_fixed_positions: ObserverFixedPosition, + observer_fixed_velocities: ObserverFixedVelocity, +} + +impl ObserverFixedCache { + pub fn new(observer: &Observer) -> Result { + // Body-fixed position in AU from (ρ·cosφ, ρ·sinφ) scaled by Earth radius (AU). + let body_fixed_pos = observer.earth_fixed_position()?; + + // Body-fixed velocity from Earth rotation. + let body_fixed_vel: Vector3> = + EARTH_ROTATION.to_notnan()?.cross(&body_fixed_pos); + + Ok(Self { + observer_fixed_positions: body_fixed_pos, + observer_fixed_velocities: body_fixed_vel, + }) + } + + pub fn position(&self) -> &ObserverFixedPosition { + &self.observer_fixed_positions + } + + pub fn velocity(&self) -> &ObserverFixedVelocity { + &self.observer_fixed_velocities + } +} + +impl TryFrom<&Observer> for ObserverFixedCache { + type Error = OutfitError; + + fn try_from(resolved: &Observer) -> Result { + Self::new(resolved) + } +} + +/// Cache mapping observer IDs to their precomputed body-fixed positions and velocities. +pub type BodyFixedObserverCache = AHashMap; + +pub fn build_fixed_observer_cache<'a>( + observers: impl Iterator, +) -> Result { + observers + .map(|(id, obs)| -> Result<_, OutfitError> { Ok((id, obs.try_into()?)) }) + .collect::>() +} diff --git a/src/constants.rs b/src/constants.rs index 2b121d5..820a71d 100644 --- a/src/constants.rs +++ b/src/constants.rs @@ -15,18 +15,12 @@ //! These definitions are used by all main modules, including orbit determination, observers, //! and ephemerides. -use crate::observations::Observation; -use crate::observers::Observer; -use smallvec::SmallVec; -use std::borrow::Cow; -use std::collections::HashMap; -use std::convert::TryFrom; -use std::sync::Arc; - // ------------------------------------------------------------------------------------------------- // Physical constants and unit conversions // ------------------------------------------------------------------------------------------------- +use nalgebra::{Matrix3, Vector3}; + /// 2π, useful for trigonometric conversions pub const DPI: f64 = 2. * std::f64::consts::PI; @@ -78,189 +72,46 @@ pub const VLIGHT: f64 = 2.99792458e5; /// Speed of light in astronomical units per day pub const VLIGHT_AU: f64 = VLIGHT / AU * SECONDS_PER_DAY; -// ------------------------------------------------------------------------------------------------- -// Type aliases -// ------------------------------------------------------------------------------------------------- - -/// Angle in degrees -pub type Degree = f64; -/// Angle in arcseconds -pub type ArcSec = f64; -/// Angle in radians -pub type Radian = f64; -/// Distance in kilometers -pub type Kilometer = f64; -/// Distance in meters -pub type Meter = f64; -/// MPC code identifying an observatory (3 characters) -pub type MpcCode = String; - -/// Lookup table from MPC code to [`Observer`] metadata -pub type MpcCodeObs = HashMap>; +// Angular velocity of Earth rotation (rad/day) on the z-axis. +pub const EARTH_ROTATION: Vector3 = Vector3::new(0.0, 0.0, DPI * 1.00273790934); -/// Modified Julian Date (days) -pub type MJD = f64; - -// ------------------------------------------------------------------------------------------------- -// Identifiers and data containers -// ------------------------------------------------------------------------------------------------- +/// Hard coded rotation matrices for coordinate transformations between mean equatorial J2000 and mean ecliptic J2000 frames. +/// Can be computed using the rotpn function in the ref_system module -/// Identifier of a solar system object. +/// Rotation matrix from mean equatorial J2000 to mean ecliptic J2000. /// -/// This can be: -/// - An asteroid number (e.g. `Int(1234)`) -/// - A comet number (e.g. `"1234P"`) -/// - A provisional designation (e.g. `"K25D50B"`) -#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] -pub enum ObjectNumber { - /// Integer-based MPC designation (e.g. 1, 433…) - Int(u32), - /// String-based designation (provisional, comet, etc.) - String(String), -} - -impl std::fmt::Display for ObjectNumber { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - ObjectNumber::Int(n) => write!(f, "{n}"), - ObjectNumber::String(s) => write!(f, "{s}"), - } - } -} - -// --- Infallible conversions (enable `.into()` directly) ---------------------- - -impl From for ObjectNumber { - #[inline] - fn from(n: u32) -> Self { - ObjectNumber::Int(n) - } -} - -impl From for ObjectNumber { - #[inline] - fn from(n: u16) -> Self { - ObjectNumber::Int(n as u32) - } -} - -impl From for ObjectNumber { - #[inline] - fn from(n: u8) -> Self { - ObjectNumber::Int(n as u32) - } -} - -impl From<&u32> for ObjectNumber { - /// Convenience to allow `(&n).into()` without dereferencing at call sites. - #[inline] - fn from(n: &u32) -> Self { - ObjectNumber::Int(*n) - } -} - -impl From for ObjectNumber { - #[inline] - fn from(s: String) -> Self { - ObjectNumber::String(s) - } -} - -impl From<&String> for ObjectNumber { - /// Clones the string to build a `String`-backed identifier. - #[inline] - fn from(s: &String) -> Self { - ObjectNumber::String(s.clone()) - } -} - -impl From<&str> for ObjectNumber { - /// Note: this **does not** parse numeric strings into `Int`. Use `FromStr` if you want - /// `"1234"` to become `ObjectNumber::Int(1234)`. - #[inline] - fn from(s: &str) -> Self { - ObjectNumber::String(s.to_string()) - } -} - -impl<'a> From> for ObjectNumber { - /// Accept both borrowed and owned `Cow`. - #[inline] - fn from(c: Cow<'a, str>) -> Self { - match c { - Cow::Borrowed(s) => ObjectNumber::String(s.to_string()), - Cow::Owned(s) => ObjectNumber::String(s), - } - } -} - -// --- Fallible conversions (use `.try_into()` to be overflow-safe) ------------ - -impl TryFrom for ObjectNumber { - type Error = std::num::TryFromIntError; - - /// Convert a `usize` into `Int(u32)` if it fits. - #[inline] - fn try_from(n: usize) -> Result { - Ok(ObjectNumber::Int(u32::try_from(n)?)) - } -} - -impl TryFrom for ObjectNumber { - type Error = std::num::TryFromIntError; - - /// Convert a `u64` into `Int(u32)` if it fits. - #[inline] - fn try_from(n: u64) -> Result { - Ok(ObjectNumber::Int(u32::try_from(n)?)) - } -} - -impl TryFrom for ObjectNumber { - type Error = &'static str; - - /// Convert a non-negative `i64` into `Int(u32)` if it fits. - #[inline] - fn try_from(n: i64) -> Result { - if n < 0 { - return Err("negative value is not a valid ObjectNumber::Int"); - } - let n = u64::try_from(n).map_err(|_| "conversion failed")?; - let n = u32::try_from(n).map_err(|_| "value exceeds u32 range")?; - Ok(ObjectNumber::Int(n)) - } -} - -// --- Smart parsing from &str via `FromStr` (optional) ------------------------ - -impl std::str::FromStr for ObjectNumber { - type Err = std::num::ParseIntError; - - /// Try to parse an `ObjectNumber` from a string. - /// - /// Rules - /// ----- - /// - Pure digits that fit in `u32` → `Int(u32)`. - /// - Otherwise → `String(String)`. - /// - /// Note - /// ---- - /// If the string is *only* digits but **does not** fit in `u32`, this returns the - /// original `ParseIntError`. If you prefer to always fallback to `String` on - /// overflow, we can change the policy (but it’s usually better to fail loudly). - fn from_str(s: &str) -> Result { - match s.parse::() { - Ok(n) => Ok(ObjectNumber::Int(n)), - Err(e) => { - if s.chars().any(|c| !c.is_ascii_digit()) { - Ok(ObjectNumber::String(s.to_string())) - } else { - Err(e) - } - } - } - } -} - -/// A small, inline-optimized container for observations of a single object. -pub type Observations = SmallVec<[Observation; 6]>; +/// Rotation of $-\varepsilon$ around the X-axis, where $\varepsilon$ is the +/// obliquity of the ecliptic at J2000. +/// +/// call rotpn(RefSystem::Equm(RefEpoch::J2000), RefSystem::Eclm(RefEpoch::J2000)) for same computed result +pub const ROT_EQUMJ2000_TO_ECLMJ2000: Matrix3 = Matrix3::new( + 1.00000000000000000e0, + 0.00000000000000000e0, + 0.00000000000000000e0, + 0.00000000000000000e0, + 9.17482062069181814e-1, + -3.97777155931913706e-1, + 0.00000000000000000e0, + 3.97777155931913706e-1, + 9.17482062069181814e-1, +); + +/// Rotation matrix from mean ecliptic J2000 to mean equatorial J2000. +/// +/// Transpose (inverse) of [`ROT_EQUMJ2000_TO_ECLMJ2000`]. +/// +/// call rotpn(RefSystem::Eclm(RefEpoch::J2000), RefSystem::Equm(RefEpoch::J2000)) for same computed result +pub const ROT_ECLMJ2000_TO_EQUMJ2000: Matrix3 = Matrix3::new( + 1.00000000000000000e0, + 0.00000000000000000e0, + 0.00000000000000000e0, + 0.00000000000000000e0, + 9.17482062069181814e-1, + 3.97777155931913706e-1, + 0.00000000000000000e0, + -3.97777155931913706e-1, + 9.17482062069181814e-1, +); + +/// Modified Julian Date (Scale Ephemeris Time, ET) +pub type MJDET = f64; diff --git a/src/conversion.rs b/src/conversion.rs index 25a19c4..dc20ac5 100644 --- a/src/conversion.rs +++ b/src/conversion.rs @@ -83,9 +83,11 @@ //! - MPC/ADES ingestion modules where these utilities are typically used. use std::f64::consts::TAU; -use nalgebra::Vector3; +use nalgebra::{Matrix3, Vector3}; +use ordered_float::{FloatIsNan, NotNan}; +use photom::{coordinates::cartesian::CartesianCoord, Arcseconds, Degrees, Radians}; -use crate::constants::{ArcSec, Degree, Radian, DPI}; +use crate::constants::DPI; /// Estimate the accuracy of a numeric string based on its decimal precision. /// @@ -118,7 +120,7 @@ fn compute_accuracy(field: &str, factor: f64) -> Option { /// ---------- /// * Angle in **radians** (`Radian`). #[inline] -pub fn arcsec_to_rad(arcsec: ArcSec) -> Radian { +pub fn arcsec_to_rad(arcsec: Arcseconds) -> Radians { (arcsec / 3600.0).to_radians() } @@ -153,7 +155,7 @@ pub fn arcsec_to_rad(arcsec: ArcSec) -> Radian { /// /// # See also /// * [`parse_dec_to_deg`] – Parses declination strings into degrees. -pub fn parse_ra_to_deg(ra: &str) -> Option<(Degree, ArcSec)> { +pub fn parse_ra_to_deg(ra: &str) -> Option<(Degrees, Arcseconds)> { let parts: Vec<&str> = ra.split_whitespace().collect(); if parts.len() != 3 { return None; @@ -200,7 +202,7 @@ pub fn parse_ra_to_deg(ra: &str) -> Option<(Degree, ArcSec)> { /// /// # See also /// * [`parse_ra_to_deg`] – Parses right ascension strings into degrees. -pub fn parse_dec_to_deg(dec: &str) -> Option<(Degree, ArcSec)> { +pub fn parse_dec_to_deg(dec: &str) -> Option<(Degrees, Arcseconds)> { let parts: Vec<&str> = dec.split_whitespace().collect(); if parts.len() != 3 { return None; @@ -445,6 +447,63 @@ pub fn cartesian_to_radec(cartesian_position: Vector3) -> (f64, f64, f64) { (alpha, delta, pos_norm) } +pub trait ToNotNan { + type Output; + fn to_notnan(self) -> Result; +} + +impl ToNotNan for f64 { + type Output = NotNan; + fn to_notnan(self) -> Result { + NotNan::new(self) + } +} + +impl ToNotNan for Vector3 { + type Output = Vector3>; + fn to_notnan(self) -> Result { + Ok(Vector3::new( + self.x.to_notnan()?, + self.y.to_notnan()?, + self.z.to_notnan()?, + )) + } +} + +impl ToNotNan for Matrix3 { + type Output = Matrix3>; + fn to_notnan(self) -> Result { + Ok(Matrix3::new( + self[(0, 0)].to_notnan()?, + self[(0, 1)].to_notnan()?, + self[(0, 2)].to_notnan()?, + self[(1, 0)].to_notnan()?, + self[(1, 1)].to_notnan()?, + self[(1, 2)].to_notnan()?, + self[(2, 0)].to_notnan()?, + self[(2, 1)].to_notnan()?, + self[(2, 2)].to_notnan()?, + )) + } +} + +/// Convert a Cartesian position vector to a `CartesianCoord`. +/// +/// # Arguments +/// +/// - `vec`: A 3D vector representing the Cartesian coordinates (x, y, z). +/// +/// # Returns +/// +/// - A `CartesianCoord` struct with fields `x`, `y`, and `z` populated from the input vector. +pub fn cartesion_from_vec(vec: Vector3) -> CartesianCoord { + CartesianCoord { + x: vec[0], + y: vec[1], + z: vec[2], + } +} + #[cfg(test)] mod observations_test { use super::*; diff --git a/src/earth_orientation.rs b/src/earth_orientation.rs index cef0bbc..a674ee4 100644 --- a/src/earth_orientation.rs +++ b/src/earth_orientation.rs @@ -74,9 +74,10 @@ //! - [`crate::ref_system`] for frame transformations that use these models. //! - **Theory of Orbit Determination** by Milani & Gronchi (2010). use nalgebra::Matrix3; +use photom::{Arcseconds, Radians, MJDTT}; use crate::{ - constants::{ArcSec, Radian, RADEG, RADSEC, T2000}, + constants::{RADEG, RADSEC, T2000}, ref_system::rotmt, }; @@ -115,7 +116,7 @@ use crate::{ /// # See also /// * [`rotmt`] – constructs rotation matrices using this obliquity /// * [`rotpn`](crate::ref_system::rotpn) – applies obliquity rotation when transforming between ecliptic and equatorial frames -pub fn obleq(tjm: f64) -> Radian { +pub fn obleq(tjm: MJDTT) -> Radians { // Obliquity coefficients let ob0 = ((23.0 * 3600.0 + 26.0 * 60.0) + 21.448) * RADSEC; let ob1 = -46.815 * RADSEC; @@ -166,7 +167,7 @@ pub fn obleq(tjm: f64) -> Radian { /// * [`rnut80`] – uses these angles to build the nutation rotation matrix /// * [`rotpn`](crate::ref_system::rotpn) – applies nutation when transforming between Equt and Equm systems #[inline(always)] -pub fn nutn80(tjm: f64) -> (ArcSec, ArcSec) { +pub fn nutn80(tjm: MJDTT) -> (Arcseconds, Arcseconds) { // ---- time powers (Julian centuries from J2000) let t = (tjm - T2000) / 36525.0; let t2 = t * t; @@ -455,7 +456,7 @@ pub fn nutn80(tjm: f64) -> (ArcSec, ArcSec) { /// * [`obleq`] – computes the mean obliquity ε (radians) /// * [`rotmt`] – builds the individual axis rotation matrices /// * [`rotpn`](crate::ref_system::rotpn) – uses `rnut80` to transform between Equm and Equt systems -pub fn rnut80(tjm: f64) -> Matrix3 { +pub fn rnut80(tjm: MJDTT) -> Matrix3 { // Mean obliquity of the ecliptic at date (ε) let epsm = obleq(tjm); @@ -504,7 +505,7 @@ pub fn rnut80(tjm: f64) -> Matrix3 { /// # See also /// * [`obleq`] – Computes the mean obliquity of the ecliptic. /// * [`nutn80`] – Computes the 1980 IAU nutation model (Δψ and Δε). -pub fn equequ(tjm: f64) -> f64 { +pub fn equequ(tjm: MJDTT) -> Radians { // Compute the mean obliquity of the ecliptic (ε, in radians) let oblm = obleq(tjm); @@ -557,7 +558,7 @@ pub fn equequ(tjm: f64) -> f64 { /// # See also /// * [`rotmt`] – constructs the rotation matrices used here /// * [`rotpn`](crate::ref_system::rotpn) – uses `prec` when converting between epochs `"OFDATE"` and `"J2000"` -pub fn prec(tjm: f64) -> Matrix3 { +pub fn prec(tjm: MJDTT) -> Matrix3 { // Precession polynomial coefficients (in radians) let zed = 0.6406161 * RADEG; let zd = 0.6406161 * RADEG; diff --git a/src/env_state.rs b/src/env_state.rs deleted file mode 100644 index 67bb706..0000000 --- a/src/env_state.rs +++ /dev/null @@ -1,127 +0,0 @@ -//! # Outfit environment state -//! -//! This module defines [`crate::env_state::OutfitEnv`], the **shared environment object** used across -//! the `Outfit` library. It provides access to: -//! -//! - A persistent **HTTP client** (for downloading ephemerides, observatory lists, etc.). -//! - A **UT1 provider** from [hifitime](https://docs.rs/hifitime) to handle Earth rotation -//! parameters from JPL. -//! -//! This object is designed to be **cheaply cloneable** and passed to algorithms -//! that require access to external data sources or Earth orientation models. -//! -//! ## Overview -//! -//! The main responsibilities of `OutfitEnv` are: -//! -//! 1. Manage a global [`ureq::Agent`] HTTP client with sensible default settings. -//! 2. Download and initialize an [`hifitime::ut1::Ut1Provider`] from JPL’s `latest_eop2.long` file -//! (Earth orientation parameters) at startup. -//! 3. Provide simple utilities for performing HTTP GET requests. -//! -//! ## Structure -//! -//! ```text -//! OutfitEnv -//! ├── http_client (ureq::Agent) -//! └── ut1_provider (hifitime::Ut1Provider) -//! ``` -//! -//! ## Usage -//! -//! ```rust,ignore -//! use outfit::env_state::OutfitEnv; -//! -//! // Create a new environment (downloads UT1 data from JPL) -//! let env = OutfitEnv::new(); -//! -//! // Access the UT1 provider -//! let ut1 = &env.ut1_provider; -//! -//! // Make a GET request using the built-in HTTP client -//! let response = env.get_from_url("https://ssd.jpl.nasa.gov/api/horizons.api"); -//! println!("Response: {}", &response[..100.min(response.len())]); -//! ``` -//! -//! ## Notes -//! -//! - The [`crate::env_state::OutfitEnv`] struct is meant to be reused and shared between different -//! parts of the crate to avoid redundant downloads and HTTP session creation. -//! - The UT1 provider is initialized once at startup; if fresh data is needed, -//! the library must be restarted or the provider re-downloaded manually. -//! -//! ## See also -//! -//! - [`hifitime::ut1::Ut1Provider`] – Manages Earth orientation and UT1 corrections. -//! - [`ureq::Agent`] – Minimal HTTP client used internally. -use hifitime::ut1::Ut1Provider; -use std::convert::TryFrom; -use std::{fmt::Debug, time::Duration}; -use ureq::{ - http::{self, Uri}, - Agent, -}; - -/// This object is passed to the various functions in the library -/// to provide access to the state of the library -/// -/// # Fields -/// -/// * `http_client` - A reqwest client used to make HTTP requests -/// * `ut1_provider` - A provider used to get the current UT1 time -/// * `observatories` - A lazy map of observatories from the Minor Planet Center. -/// The key is the MPC code and the value is the observer -#[derive(Debug, Clone)] -pub struct OutfitEnv { - pub http_client: Agent, - pub ut1_provider: Ut1Provider, -} - -impl Default for OutfitEnv { - fn default() -> Self { - Self::new() - } -} - -impl OutfitEnv { - /// Create a new Outfit object - /// - /// Return - /// ------ - /// * A new Outfit object - /// - The UT1 provider is downloaded from the JPL - /// - The HTTP client is created with default settings - /// - The observatories are lazily loaded from the Minor Planet Center - pub fn new() -> Self { - let ut1_provider = OutfitEnv::initialize_ut1_provider(); - - let config = Agent::config_builder() - .timeout_global(Some(Duration::from_secs(10))) - .build(); - let agent: Agent = config.into(); - - OutfitEnv { - http_client: agent, - ut1_provider, - } - } - - fn initialize_ut1_provider() -> Ut1Provider { - Ut1Provider::download_from_jpl("latest_eop2.long") - .expect("Download of the JPL short time scale UT1 data failed") - } - - pub(crate) fn get_from_url(&self, url: U) -> String - where - Uri: TryFrom, - >::Error: Into, - { - self.http_client - .get(url) - .call() - .expect("Get request failed") - .body_mut() - .read_to_string() - .expect("Failed to read response body") - } -} diff --git a/src/error_models/data_models/cbm10.rules b/src/error_models/data_models/cbm10.rules deleted file mode 100644 index 5942367..0000000 --- a/src/error_models/data_models/cbm10.rules +++ /dev/null @@ -1,64 +0,0 @@ -! obscods catal rms(acosd) rmsd (arcsec) -699:c @ 0.93, 0.78 -608:c @ 1.26, 1.53 -644:c @ 0.47, 0.56 -106:c @ 1.02, 0.79 -D29:c @ 0.66, 0.59 -689:c @ 0.51, 0.63 -ALL:cc @ 1.02, 0.79 -ALL:cd @ 1.02, 0.79 -ALL:ce @ 0.66, 0.59 -ALL:cq @ 0.66, 0.59 -ALL:cr @ 0.66, 0.59 -ALL:ct @ 0.66, 0.59 -ALL:cu @ 0.66, 0.59 -ALL:co @ 0.99, 0.81 -ALL:cs @ 0.99, 0.81 -ALL:ca @ 1.17, 1.02 -ALL:cb @ 1.17, 1.02 -ALL:ch @ 0.90, 0.88 -ALL:ci @ 0.90, 0.88 -ALL:cj @ 0.90, 0.88 -ALL:cz @ 0.90, 0.88 -ALL:cm @ 1.11, 1.13 -ALL:cw @ 0.88, 0.71 -ALL:cf @ 1.45, 1.27 -ALL:cg @ 1.45, 1.27 -ALL:cL @ 0.50, 0.50 -704:cc @ 1.23, 1.19 -699:cc @ 0.93, 0.78 -691:cc @ 0.63, 0.68 -608:cc @ 1.26, 1.53 -703:cc @ 1.23, 1.13 -644:cc @ 0.47, 0.56 -703:ce @ 0.97, 0.91 -G96:ce @ 0.50, 0.42 -E12:ce @ 0.82, 0.85 -683:ce @ 1.21, 1.55 -699:co @ 0.84, 0.81 -644:co @ 0.36, 0.33 -691:co @ 0.50, 0.56 -704:cd @ 1.23, 1.19 -699:cd @ 0.93, 0.78 -691:cd @ 0.63, 0.68 -608:cd @ 1.26, 1.53 -703:cd @ 1.23, 1.13 -644:cd @ 0.47, 0.56 -703:cr @ 0.97, 0.91 -G96:cr @ 0.50, 0.42 -E12:cr @ 0.82, 0.85 -683:cr @ 1.21, 1.55 -699:cs @ 0.84, 0.81 -644:cs @ 0.36, 0.33 -691:cs @ 0.50, 0.56 -689:cg @ 0.51, 0.63 -645:ce @ 0.30, 0.30 -F51:cL @ 0.30, 0.30 -F51:ct @ 0.30, 0.30 -C51:cL @ 1.00, 1.00 -568:cL @ 0.30, 0.30 -568:ct @ 0.25, 0.25 -568:co @ 0.50, 0.50 -568:cs @ 0.50, 0.50 -W84:co @ 0.30, 0.30 -W84:cL @ 0.30, 0.30 \ No newline at end of file diff --git a/src/error_models/data_models/fcct14.rules b/src/error_models/data_models/fcct14.rules deleted file mode 100644 index 43369dc..0000000 --- a/src/error_models/data_models/fcct14.rules +++ /dev/null @@ -1,48 +0,0 @@ -ALL: c=cd @ 0.51, 0.40 ! CBM Generic Catalog weights -ALL: c=eqru @ 0.33, 0.30 -ALL: c=tL @ 0.25, 0.25 -ALL: c=os @ 0.50, 0.41 -ALL: c=ab @ 0.59, 0.51 -ALL: c=hijz @ 0.45, 0.44 -ALL: c=m @ 0.56, 0.57 -ALL: c=w @ 0.44, 0.36 -ALL: c=fg @ 0.73, 0.64 -ALL: c=UV @ 0.60, 0.60 -258: c=* @ 0.10, 0.10 -704: c=cd @ 0.62, 0.60 ! CBM Station-Catalog specific rules -699: c=cd @ 0.47, 0.39 -691: c=cd @ 0.32, 0.34 -608: c=cd @ 0.63, 0.77 -703: c=cd @ 0.62, 0.57 -644: c=cd @ 0.24, 0.28 -703: c=er @ 0.49, 0.46 -G96: c=erUV @ 0.25, 0.21 -E12: c=er @ 0.41, 0.43 -683: c=er @ 0.61, 0.78 -699: c=os @ 0.42, 0.41 -644: c=os @ 0.18, 0.17 -691: c=os @ 0.25, 0.28 -689: c=g @ 0.26, 0.32 -645: c=e @ 0.15, 0.15 ! New rules -F51: c=Lt @ 0.15, 0.15 -F51: c=UV @ 0.15, 0.15 -F52: c=Lt @ 0.15, 0.15 -F52: c=UV @ 0.15, 0.15 -568: c=L @ 0.15, 0.15 -568: c=t @ 0.13, 0.13 -568: c=os @ 0.25, 0.25 -568: c=UV @ 0.10, 0.10 -309: c=UV @ 0.15, 0.15 -H01: c=Lt @ 0.15, 0.15 -I41: c=UV @ 0.20, 0.20 -I41: c=N @ 0.40, 0.40 -673: c=* @ 0.30, 0.30 ! TMO -G45: c=* @ 0.50, 0.50 ! Space Survelliance Telescope -250: c=* @ 1.30, 1.30 ! Satellites: HST -249: c=* @ 60.0, 60.0 ! SOHO -C49: c=* @ 60.0, 60.0 ! STEREO-A -C50: c=* @ 60.0, 60.0 ! STEREO-B -C51: c=* @ 1.00, 1.00 ! WISE -T12: c=UV @ 0.10, 0.10 ! Tholen from UH88 with Gaia catalog -T09: c=UV @ 0.10, 0.10 ! Tholen from Subaru with Gaia catalog -T14: c=UV @ 0.10, 0.10 ! Tholen from CFHT with Gaia catalog diff --git a/src/error_models/data_models/vfcc17.rules b/src/error_models/data_models/vfcc17.rules deleted file mode 100644 index 1942c9f..0000000 --- a/src/error_models/data_models/vfcc17.rules +++ /dev/null @@ -1,100 +0,0 @@ -ALL t=PAN c=* p= > < 1890-01-01 @ 10.00, 10.00 -ALL t=PAN c=* p= > 1890-01-01 < 1950-01-01 @ 5.00, 5.00 -ALL t=PAN c=* p= > 1950-01-01 < @ 2.50, 2.50 -ALL t=cBCVn c=* p= > < @ 1.00, 1.00 ! Unknown catalog -ALL t=E c=* p= > < @ 0.20, 0.20 ! Occultations -ALL t=H c=* p= > < @ 0.40, 0.40 ! Hipparcos -ALL t=T c=* p= > < @ 0.50, 0.50 ! Transit circle -ALL t=e c=* p= > < @ 0.75, 0.75 ! Encoder -ALL t=M c=* p= > < @ 3.00, 3.00 ! Micrometer -ALL t=S c=* p= > < @ 1.50, 1.50 ! Satellite -ALL t=cC c=UVXW p= > < @ 0.60, 0.60 ! Gaia astrometric catalogs -F51 t=cC c=* p= > < @ 0.20, 0.20 -F52 t=cC c=* p= > < @ 0.20, 0.20 -G96 t=cC c=* p= > < @ 0.50, 0.50 -703 t=cC c=* p= > < 2014-01-01 @ 1.00, 1.00 -703 t=cC c=* p= > 2014-01-01 < @ 0.80, 0.80 -E12 t=cC c=* p= > < @ 0.75, 0.75 -704 t=cC c=* p= > < @ 1.00, 1.00 -691 t=cC c=* p= > < 2003-01-01 @ 0.60, 0.60 -691 t=cC c=* p= > 2003-01-01 < @ 0.50, 0.50 -291 t=cC c=* p= > < 2003-01-01 @ 0.60, 0.60 ! Updated -291 t=cC c=* p= > 2003-01-01 < @ 0.50, 0.50 ! Updated -644 t=cC c=* p= > < 2003-09-01 @ 0.60, 0.60 -644 t=cC c=* p= > 2003-09-01 < @ 0.40, 0.40 -699 t=cC c=* p= > < @ 0.80, 0.80 -G45 t=cC c=* p= > < @ 0.60, 0.60 -D29 t=cC c=* p= > < @ 0.75, 0.75 -T05 t=cC c=* p= > < @ 0.80, 0.80 -568 t=cC c=* p= > < @ 0.50, 0.50 ! Generic -568 t=cC c=t p=_ > < @ 0.20, 0.20 ! Micheli updated -568 t=cC c=q p=_ > < @ 0.20, 0.20 ! Micheli updated -568 t=cC c=UVXW p=_ > < @ 0.10, 0.10 ! Micheli updated -568 t=cC c=t p=2 > < @ 0.20, 0.20 ! Tholen -568 t=cC c=UVXW p=2 > < @ 0.10, 0.10 ! Tholen -568 t=cC c=os p=2 > < @ 0.50, 0.50 ! Tholen -568 t=cC c=UVXW p=^ > < @ 0.20, 0.20 ! Weryk new -T09 t=cC c=* p= > < @ 0.50, 0.50 ! Generic -T09 t=cC c=t p=0 > < @ 0.20, 0.20 ! Tholen -T09 t=cC c=UVX p=0 > < @ 0.10, 0.10 ! Tholen -T12 t=cC c=* p= > < 2022-10-06 @ 0.10, 0.10 ! Tholen Before 06 October 2022 -T12 t=cC c=* p= > 2022-10-06 < @ 0.50, 0.50 ! Generic New program codes, see MPEC 2022-T98 -T12 t=cC c=t p=0 > < 2022-10-06 @ 0.10, 0.10 ! Tholen Before 06 October 2022 -T12 t=cC c=t p=0 > 2022-10-06 < @ 0.20, 0.20 ! Tholen New program codes -T12 t=cC c=UVX p=0 > < 2022-10-06 @ 0.10, 0.10 ! Tholen Before 06 October 2022 -T12 t=cC c=UVX p=0 > 2022-10-06 < @ 0.10, 0.10 ! Tholen New program codes -T12 t=cC c=UVX p=1 > < 2022-10-06 @ 0.10, 0.10 ! Tholen Before 06 October 2022 -T12 t=cC c=UVX p=1 > 2022-10-06 < @ 0.20, 0.20 ! Weryk new New program code -T14 t=cC c=* p= > < @ 0.50, 0.50 ! Generic -T14 t=cC c=t p=0 > < @ 0.20, 0.20 ! Tholen -T14 t=cC c=UVX p=0 > < @ 0.10, 0.10 ! Tholen -T14 t=cC c=t p=7 > < @ 0.20, 0.20 ! Micheli updated -T14 t=cC c=UVX p=7 > < @ 0.10, 0.10 ! Micheli updated -T14 t=cC c=UVX p=3 > < @ 0.20, 0.20 ! Weryk new -H01 t=cC c=LtUVXW p= > < @ 0.30, 0.30 -673 t=cC c=* p= > < @ 0.30, 0.30 -645 t=cC c=* p= > < @ 0.30, 0.30 -689 t=cC c=* p= > < @ 0.50, 0.50 -J04 t=cC c=UVXWtLqrue p= > < @ 0.40, 0.40 ! Updated -Z84 t=cC c=UVXWtLqrue p= > < @ 0.40, 0.40 ! New -W84 t=cC c=* p= > < @ 0.50, 0.50 -950 t=cC c=UVXWtLqrue p= > < @ 0.50, 0.50 -F65 t=cC c=* p= > < @ 0.40, 0.40 -E10 t=cC c=* p= > < @ 0.40, 0.40 -W85 t=cC c=* p= > < @ 0.40, 0.40 -W86 t=cC c=* p= > < @ 0.40, 0.40 -W87 t=cC c=* p= > < @ 0.40, 0.40 -Q63 t=cC c=* p= > < @ 0.40, 0.40 -Q64 t=cC c=* p= > < @ 0.40, 0.40 -K91 t=cC c=* p= > < @ 0.40, 0.40 -K92 t=cC c=* p= > < @ 0.40, 0.40 -K93 t=cC c=* p= > < @ 0.40, 0.40 -V37 t=cC c=* p= > < @ 0.40, 0.40 -V39 t=cC c=* p= > < @ 0.40, 0.40 ! New -Z31 t=cC c=* p= > < @ 0.40, 0.40 ! New -Z24 t=cC c=* p= > < @ 0.40, 0.40 ! New -Y28 t=cC c=tUVXW p= > < 2015-01-01 @ 1.00, 1.00 ! New -Y28 t=cC c=tUVXW p= > 2015-01-01 < @ 0.30, 0.30 ! New -309 t=cC c=UVXW p=&% > < @ 0.20, 0.20 -309 t=cC c=tq p=&% > < @ 0.30, 0.30 -G83 t=cC c=UVXW p=2 > < @ 0.20, 0.20 -G83 t=cC c=tq p=2 > < @ 0.30, 0.30 -248 t=* c=* p= > < 1991-01-01 @ 0.20, 0.20 ! Hipparcos -248 t=* c=* p= > 1991-01-01 < @ 0.15, 0.15 ! Hipparcos -250 t=* c=* p= > < @ 1.30, 1.30 ! HST -249 t=* c=* p= > < @ 60.00, 60.00 ! SOHO -C49 t=* c=* p= > < @ 60.00, 60.00 ! STEREO-A -C50 t=* c=* p= > < @ 60.00, 60.00 ! STEREO-B -C51 t=* c=* p= > < @ 1.00, 1.00 ! WISE -C57 t=* c=* p= > < @ 5.00, 5.00 ! TESS -608 t=cC c=* p= > < @ 0.60, 0.60 ! New -Z18 t=cC c=tq p=1 > < @ 0.30, 0.30 ! Micheli New -Z18 t=cC c=UVXW p=1 > < @ 0.20, 0.20 ! Micheli New -T08 t=cC c=* p= > < @ 0.80, 0.80 ! New -258 t=cC c=* p= > < @ 0.50, 0.50 ! GAIA new -C65 t=cC c=UVXW p=3 > < @ 0.20, 0.20 ! Micheli new -094 t=cC c=UVXW p=9 > < @ 0.50, 0.50 ! Micheli new -181 t=cC c=UVXW p= > < 2019-01-01 @ 1.00, 1.00 ! New -181 t=cC c=UVXW p= > 2019-01-01 < @ 0.50, 0.50 ! New -M28 t=* c=* p= > < 2023-12-12 @ 3.00, 3.00 ! Bad observations from M28, biased fixed on Dec. 2023 -M28 t=* c=* p= > 2023-12-12 < @ 1.00, 1.00 ! Restore default RMS for M28 diff --git a/src/error_models/mod.rs b/src/error_models/mod.rs deleted file mode 100644 index aa00ffa..0000000 --- a/src/error_models/mod.rs +++ /dev/null @@ -1,418 +0,0 @@ -//! # Astrometric error models -//! -//! This module provides tools to **handle observation error models** used in orbit -//! determination. Error models define the astrometric biases and RMS values -//! associated with each observatory and star catalog, as recommended in the literature -//! (e.g., FCCT14, CBM10, VFCC17). -//! -//! ## Public API -//! -//! ### [`crate::error_models::ErrorModel`] -//! Enumeration of the supported astrometric error models: -//! -//! - `ErrorModel::FCCT14` – Farnocchia, Chesley, Chamberlin & Tholen (2014) -//! - `ErrorModel::CBM10` – Chesley, Baer & Monet (2010) -//! - `ErrorModel::VFCC17` – Vereš, Farnocchia, Chesley & Chamberlin (2017) -//! -//! You can create an [`crate::error_models::ErrorModel`] from a string with: -//! -//! ```rust, ignore -//! use outfit::error_models::ErrorModel; -//! let model: ErrorModel = "FCCT14".parse().unwrap(); -//! ``` -//! -//! ### [`crate::error_models::ErrorModelData`] -//! -//! ```text -//! type ErrorModelData = HashMap<(MpcCode, CatalogCode), (f32, f32)> -//! ``` -//! -//! This map associates an observatory (MPC code) and a star catalog code -//! with a pair `(bias_RMS, declination_RMS)`. -//! -//! The contents are loaded from reference files distributed with the crate. -//! -//! ### `ErrorModel::read_error_model_file` -//! -//! ```rust, ignore -//! use outfit::error_models::ErrorModel; -//! -//! let error_map = ErrorModel::FCCT14.read_error_model_file().unwrap(); -//! println!("{} entries", error_map.len()); -//! ``` -//! -//! This function reads the internal rules for the chosen model -//! and returns a [`crate::error_models::ErrorModelData`] structure ready to be queried. -//! -//! ### [`crate::error_models::get_bias_rms`] -//! -//! ```rust -//! use outfit::error_models::{ErrorModel, get_bias_rms}; -//! -//! let data = ErrorModel::FCCT14.read_error_model_file().unwrap(); -//! if let Some((bias_ra, bias_dec)) = get_bias_rms(&data, "699".to_string(), "c".to_string()) { -//! println!("Bias for MPC 699: RA = {bias_ra}, Dec = {bias_dec}"); -//! } -//! ``` -//! -//! This function looks up the `(RMS in RA, RMS in Dec)` for a given observatory -//! and star catalog code. If no exact match is found, the function falls back to -//! generic values (e.g. `ALL:c`). -//! -//! ## Typical usage -//! -//! 1. Choose an error model (e.g. `ErrorModel::FCCT14`). -//! 2. Load its table using [`read_error_model_file`](crate::error_models::ErrorModel::read_error_model_file). -//! 3. Use [`crate::error_models::get_bias_rms`] to obtain astrometric uncertainties for weighting residuals. -//! -//! ## References -//! -//! - Farnocchia, D., Chesley, S. R., Chamberlin, A. B., & Tholen, D. J. (2014) -//! - Chesley, S. R., Baer, J., & Monet, D. G. (2010) -//! - Vereš, P., Farnocchia, D., Chesley, S. R., & Chamberlin, A. B. (2017) -//! -//! These tables are essential for **realistic orbit determination** since they -//! ensure that observations are weighted according to their expected precision. -mod vfcc17; - -use std::{collections::HashMap, str::FromStr}; - -use nom::{ - branch::alt, - bytes::complete::{tag, take_until, take_while}, - character::complete::{char, multispace0}, - combinator::{map, opt}, - number::complete::float, - sequence::{preceded, separated_pair, terminated}, - IResult, Parser, -}; - -use crate::{constants::MpcCode, outfit_errors::OutfitError}; -use vfcc17::parse_vfcc17_line; - -type CatalogCode = String; -pub type ErrorModelData = HashMap<(MpcCode, CatalogCode), (f32, f32)>; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum ErrorModel { - FCCT14, - CBM10, - VFCC17, -} - -static FCCT14_RULES: &str = include_str!("data_models/fcct14.rules"); -static CBM10_RULES: &str = include_str!("data_models/cbm10.rules"); -static VFCC17_RULES: &str = include_str!("data_models/vfcc17.rules"); - -pub(in crate::error_models) type ParseResult<'a> = - IResult<&'a str, Vec<((MpcCode, CatalogCode), (f32, f32))>>; - -fn is_alphanum(c: char) -> bool { - c.is_alphanumeric() -} - -fn parse_station(input: &str) -> IResult<&str, &str> { - terminated(take_while(is_alphanum), tag(":")).parse(input) -} - -fn parse_rms_values(input: &str) -> IResult<&str, (f32, f32)> { - preceded( - multispace0, - preceded( - char('@'), - separated_pair( - preceded(multispace0, float), - char(','), - preceded(multispace0, float), - ), - ), - ) - .parse(input) -} - -fn parse_catalog_codes(input: &str) -> IResult<&str, Vec> { - preceded( - multispace0, - preceded( - // accepte soit "c=" soit rien du tout - alt((tag("c="), tag(""))), - map( - nom::bytes::complete::take_while(|c: char| c.is_alphabetic() || c == '*'), - |s: &str| s.chars().map(|c| c.to_string()).collect(), - ), - ), - ) - .parse(input) -} - -fn parse_full_line(input: &str) -> ParseResult { - let (input, remain) = opt(take_until("!")).parse(input)?; //ignore comments - let input = input.trim(); - - let input = remain.unwrap_or(input); - - map( - (parse_station, parse_catalog_codes, parse_rms_values), - |(station, catalogs, (rmsa, rmsd))| { - catalogs - .into_iter() - .map(|cat| ((station.to_string(), cat), (rmsa, rmsd))) - .collect() - }, - ) - .parse(input) -} - -fn parse_full_file(file: &str, parse_line: F) -> Result -where - F: Fn(&str) -> ParseResult, -{ - let error_map: ErrorModelData = file - .lines() - .filter(|line| !line.trim().is_empty() && !line.trim_start().starts_with('!')) - .map(|line| { - parse_line(line) - .map_err(|_e| OutfitError::NomParsingError(line.to_string())) - .map(|(_, pairs)| pairs) - }) - .collect::, OutfitError>>()? - .into_iter() - .flatten() - .collect(); - - Ok(error_map) -} - -impl ErrorModel { - /// Load the internal RMS/bias table for this astrometric error model. - /// - /// This function parses the reference file corresponding to the selected - /// [`ErrorModel`] variant (FCCT14, CBM10, or VFCC17) and returns a - /// [`ErrorModelData`] map containing the weighting coefficients - /// (RMS in right ascension and declination). - /// - /// # Returns - /// - /// A [`Result`] containing: - /// * `Ok(ErrorModelData)` – A hash map where each key is a pair `(MpcCode, CatalogCode)` - /// and the value is a tuple `(rms_ra, rms_dec)` in arcseconds. - /// * `Err(OutfitError)` – If the reference file could not be parsed. - /// - /// # Usage - /// - /// ``` - /// use outfit::error_models::ErrorModel; - /// - /// // Load FCCT14 error model data - /// let data = ErrorModel::FCCT14.read_error_model_file().unwrap(); - /// - /// println!("Number of entries: {}", data.len()); - /// - /// // Use the map later with `get_bias_rms` - /// ``` - /// - /// # See also - /// * [`get_bias_rms`] – Look up the bias and RMS values for a given station/catalog. - pub fn read_error_model_file(&self) -> Result { - let error_map: ErrorModelData = match self { - ErrorModel::FCCT14 => parse_full_file(FCCT14_RULES, parse_full_line)?, - ErrorModel::CBM10 => parse_full_file(CBM10_RULES, parse_full_line)?, - ErrorModel::VFCC17 => { - // Implement parsing logic for VFCC17 - parse_full_file(VFCC17_RULES, parse_vfcc17_line)? - } - }; - - Ok(error_map) - } -} - -impl FromStr for ErrorModel { - type Err = OutfitError; - - fn from_str(s: &str) -> Result { - match s { - "FCCT14" => Ok(ErrorModel::FCCT14), - "CBM10" => Ok(ErrorModel::CBM10), - "VFCC17" => Ok(ErrorModel::VFCC17), - _ => Err(OutfitError::InvalidErrorModel(format!( - "Invalid error model: {s}" - ))), - } - } -} - -/// Retrieve the astrometric bias and RMS values for a given observatory (MPC code) -/// and star catalog code from a preloaded [`ErrorModelData`] table. -/// -/// This function searches for a matching entry in the following priority order: -/// -/// 1. **Exact match**: `(mpc_code, catalog_code)` -/// 2. **Generic catalog fallback for the same observatory**: -/// * `(mpc_code, "e")` – generic `e` entry (elliptical) -// * `(mpc_code, "c")` – generic `c` entry (catalog-specific default) -/// 3. **Global fallback** (for any observatory): -/// * `("ALL", catalog_code)` -/// * `("ALL", "e")` -/// * `("ALL", "c")` -/// -/// The returned pair `(rms_ra, rms_dec)` corresponds to the weighting factors -/// (typically in arcseconds) used for astrometric residuals. -/// -/// # Arguments -/// -/// * `error_model` – The [`ErrorModelData`] hash map produced by -/// [`ErrorModel::read_error_model_file`](crate::error_models::ErrorModel::read_error_model_file). -/// * `mpc_code` – The Minor Planet Center observatory code. -/// * `catalog_code` – The star catalog identifier (single letter or string). -/// -/// # Returns -/// -/// * `Some((rms_ra, rms_dec))` – If a match is found in the table. -/// * `None` – If no matching entry exists (very rare). -/// -/// # Example -/// -/// ```rust, no_run -/// use outfit::error_models::{ErrorModel, get_bias_rms}; -/// -/// // Load error model data -/// let table = ErrorModel::FCCT14.read_error_model_file().unwrap(); -/// -/// // Query bias/RMS for observatory 699 (Catalina) with catalog code "c" -/// if let Some((rms_ra, rms_dec)) = get_bias_rms(&table, "699".to_string(), "c".to_string()) { -/// println!("699 / c -> RMS: RA = {rms_ra}, Dec = {rms_dec}"); -/// } -/// ``` -pub fn get_bias_rms( - error_model: &ErrorModelData, - mpc_code: MpcCode, - catalog_code: CatalogCode, -) -> Option<(f32, f32)> { - error_model - .get(&(mpc_code.clone(), catalog_code.clone())) - .cloned() - .or_else(|| { - error_model - .get(&(mpc_code.clone(), "e".to_string())) - .cloned() - }) - .or_else(|| error_model.get(&(mpc_code, "c".to_string())).cloned()) - .or_else(|| error_model.get(&("ALL".to_string(), catalog_code)).cloned()) - .or_else(|| { - error_model - .get(&("ALL".to_string(), "e".to_string())) - .cloned() - }) - .or_else(|| { - error_model - .get(&("ALL".to_string(), "c".to_string())) - .cloned() - }) -} - -impl TryFrom<&str> for ErrorModel { - type Error = OutfitError; - - fn try_from(value: &str) -> Result { - value.parse() - } -} - -#[cfg(test)] -mod test_error_model { - use super::*; - - #[test] - fn test_parse_fcct14_line() { - let line = "ALL: c=eqru @ 0.33, 0.30"; - let result = parse_full_line(line); - assert!(result.is_ok()); - let ((mpc_code, catalog_code), (rmsa, rmsd)) = &result.unwrap().1[0]; - assert_eq!(mpc_code, "ALL"); - assert_eq!(catalog_code, "e"); - assert_eq!(*rmsa, 0.33); - assert_eq!(*rmsd, 0.3); - - let line = "ALL: c=cd @ 0.51, 0.40 ! CBM Generic Catalog weights"; - let result = parse_full_line(line); - assert!(result.is_ok()); - let ((mpc_code, catalog_code), (rmsa, rmsd)) = &result.unwrap().1[0]; - assert_eq!(mpc_code, "ALL"); - assert_eq!(catalog_code, "c"); - assert_eq!(*rmsa, 0.51); - assert_eq!(*rmsd, 0.4); - - let line = "699:c @ 0.93, 0.78"; - let result = parse_full_line(line); - assert!(result.is_ok()); - let ((mpc_code, catalog_code), (rmsa, rmsd)) = &result.unwrap().1[0]; - assert_eq!(mpc_code, "699"); - assert_eq!(catalog_code, "c"); - assert_eq!(*rmsa, 0.93); - assert_eq!(*rmsd, 0.78); - } - - #[test] - fn test_read_error_model_file() { - let error_model = ErrorModel::FCCT14; - let result = error_model.read_error_model_file(); - assert!(result.is_ok()); - let data = result.unwrap(); - assert!(!data.is_empty()); - - let error_model = ErrorModel::CBM10; - let result = error_model.read_error_model_file(); - assert!(result.is_ok()); - let data = result.unwrap(); - assert!(!data.is_empty()); - - let error_model = ErrorModel::VFCC17; - let result = error_model.read_error_model_file(); - - assert!(result.is_ok()); - let data = result.unwrap(); - assert!(!data.is_empty()); - } - - #[test] - fn test_get_bias_rms() { - let error_model = ErrorModel::FCCT14.read_error_model_file().unwrap(); - let bias_rms = get_bias_rms(&error_model, "ALL".to_string(), "c".to_string()); - assert!(bias_rms.is_some()); - let (rmsa, rmsd) = bias_rms.unwrap(); - assert_eq!(rmsa, 0.51); - assert_eq!(rmsd, 0.4); - - let bias_rms = get_bias_rms(&error_model, "699".to_string(), "c".to_string()); - assert!(bias_rms.is_some()); - let (rmsa, rmsd) = bias_rms.unwrap(); - assert_eq!(rmsa, 0.47); - assert_eq!(rmsd, 0.39); - - let error_model = ErrorModel::CBM10.read_error_model_file().unwrap(); - let bias_rms = get_bias_rms(&error_model, "ALL".to_string(), "c".to_string()); - assert!(bias_rms.is_some()); - let (rmsa, rmsd) = bias_rms.unwrap(); - assert_eq!(rmsa, 0.5); - assert_eq!(rmsd, 0.5); - - let bias_rms = get_bias_rms(&error_model, "699".to_string(), "c".to_string()); - assert!(bias_rms.is_some()); - let (rmsa, rmsd) = bias_rms.unwrap(); - assert_eq!(rmsa, 0.84); - assert_eq!(rmsd, 0.81); - - let error_model = ErrorModel::VFCC17.read_error_model_file().unwrap(); - let bias_rms = get_bias_rms(&error_model, "ALL".to_string(), "U".to_string()); - assert!(bias_rms.is_some()); - let (rmsa, rmsd) = bias_rms.unwrap(); - assert_eq!(rmsa, 0.6); - assert_eq!(rmsd, 0.6); - let bias_rms = get_bias_rms(&error_model, "699".to_string(), "*".to_string()); - assert!(bias_rms.is_some()); - let (rmsa, rmsd) = bias_rms.unwrap(); - assert_eq!(rmsa, 0.8); - assert_eq!(rmsd, 0.8); - } -} diff --git a/src/error_models/vfcc17.rs b/src/error_models/vfcc17.rs deleted file mode 100644 index 3e580a9..0000000 --- a/src/error_models/vfcc17.rs +++ /dev/null @@ -1,96 +0,0 @@ -use nom::{ - bytes::complete::{tag, take_until, take_while1}, - character::complete::{char, multispace0}, - combinator::{map, opt}, - number::complete::float, - sequence::{preceded, separated_pair}, - IResult, Parser, -}; - -use crate::error_models::ParseResult; - -fn is_word_char(c: char) -> bool { - c.is_alphanumeric() || "*".contains(c) -} - -fn parse_word(input: &str) -> IResult<&str, &str> { - take_while1(is_word_char)(input) -} - -fn parse_station(input: &str) -> IResult<&str, &str> { - parse_word(input) -} - -fn parse_catalog_codes(input: &str) -> IResult<&str, Vec> { - preceded( - tag("c="), - map(take_while1(is_word_char), |s: &str| { - s.chars().map(|c| c.to_string()).collect() - }), - ) - .parse(input) -} - -fn parse_rms_values(input: &str) -> IResult<&str, (f32, f32)> { - preceded( - take_until("@"), - preceded( - tag("@"), - separated_pair( - preceded(multispace0, float), - preceded(multispace0, char(',')), - preceded(multispace0, float), - ), - ), - ) - .parse(input) -} - -pub fn parse_vfcc17_line(input: &str) -> ParseResult { - let (input, remain) = opt(take_until("!")).parse(input)?; // Ignore comments - let input = remain.unwrap_or(input).trim(); - - map( - ( - parse_station, - take_until("c="), - parse_catalog_codes, - parse_rms_values, - ), - |(station, _, catalogs, (rmsa, rmsd))| { - catalogs - .into_iter() - .map(|cat| ((station.to_string(), cat), (rmsa, rmsd))) - .collect() - }, - ) - .parse(input) -} - -#[cfg(test)] -mod test_vfcc17_parser { - use super::*; - - #[test] - fn test_vfcc17_parser() { - let input = "ALL t=cBCVn c=* p= > < @ 1.00, 1.00 ! Unknown catalog"; - let result = parse_vfcc17_line(input); - assert!(result.is_ok()); - let (_, parsed) = result.unwrap(); - assert_eq!(parsed.len(), 1); - assert_eq!(parsed[0].0 .0, "ALL"); - assert_eq!(parsed[0].0 .1, "*"); - assert_eq!(parsed[0].1 .0, 1.); - assert_eq!(parsed[0].1 .1, 1.); - - let input = "568 t=cC c=t p=_ > < @ 0.20, 0.20 ! Micheli updated "; - let result = parse_vfcc17_line(input); - assert!(result.is_ok()); - let (_, parsed) = result.unwrap(); - assert_eq!(parsed.len(), 1); - assert_eq!(parsed[0].0 .0, "568"); - assert_eq!(parsed[0].0 .1, "t"); - assert_eq!(parsed[0].1 .0, 0.2); - assert_eq!(parsed[0].1 .1, 0.2); - } -} diff --git a/src/initial_orbit_determination/gauss.rs b/src/initial_orbit_determination/gauss.rs index 178caf9..c5baa23 100644 --- a/src/initial_orbit_determination/gauss.rs +++ b/src/initial_orbit_determination/gauss.rs @@ -109,10 +109,12 @@ use std::ops::ControlFlow; use aberth::StopReason; use nalgebra::Matrix3; use nalgebra::Vector3; +use photom::Radians; +use photom::MJDTT; use rand_distr::StandardNormal; use smallvec::SmallVec; -use crate::constants::Radian; +use crate::constants::ROT_EQUMJ2000_TO_ECLMJ2000; use crate::constants::{GAUSS_GRAV, VLIGHT_AU}; use crate::initial_orbit_determination::gauss_result::GaussResult; @@ -120,7 +122,6 @@ use crate::initial_orbit_determination::IODParams; use crate::kepler::velocity_correction_with_guess; use crate::orb_elem::eccentricity_control; use crate::orbit_type::OrbitalElements; -use crate::outfit::Outfit; use crate::outfit_errors::OutfitError; use aberth::aberth; use rand::Rng; @@ -153,9 +154,9 @@ use rand::Rng; #[derive(Debug, PartialEq, Clone)] pub struct GaussObs { pub(crate) idx_obs: Vector3, - pub(crate) ra: Vector3, - pub(crate) dec: Vector3, - pub(crate) time: Vector3, + pub(crate) ra: Vector3, + pub(crate) dec: Vector3, + pub(crate) time: Vector3, pub(crate) observer_helio_position: Matrix3, } @@ -324,9 +325,9 @@ impl GaussObs { /// * [`GaussObs::prelim_orbit`] – Consumes each realization to compute a Gauss preliminary orbit. /// * [`estimate_best_orbit`](crate::observations::observations_ext::ObservationIOD::estimate_best_orbit) – High-level search loop that leverages this iterator with early pruning. pub fn realizations_iter<'a, R: Rng + 'a>( - &'a self, - errors_ra: &'a Vector3, - errors_dec: &'a Vector3, + self, + errors_ra: &Vector3, + errors_dec: &Vector3, n_realizations: usize, noise_scale: f64, rng: &'a mut R, @@ -427,7 +428,7 @@ impl GaussObs { /// * [`GaussObs::prelim_orbit`] – Compute a preliminary Gauss solution from each realization. /// * [`estimate_best_orbit`](crate::observations::observations_ext::ObservationIOD::estimate_best_orbit) – End-to-end search that consumes realizations. pub fn generate_noisy_realizations( - &self, + self, errors_ra: &Vector3, errors_dec: &Vector3, n_realizations: usize, @@ -880,7 +881,7 @@ impl GaussObs { /// /// Arguments /// --------- - /// * `state` – The outfit global state containing the rotation matrix + /// * `rot_equmj2000_to_eclmj2000` – rotation matrix from equatorial mean J2000 to ecliptic mean J2000 /// * `asteroid_position` – Cartesian heliocentric position vector of the object (in AU), in equatorial J2000 frame. /// * `asteroid_velocity` – Cartesian heliocentric velocity vector of the object (in AU/day), in equatorial J2000 frame. /// * `reference_epoch` – Epoch (in MJD TT) corresponding to the state vector, used as the reference time for the elements. @@ -908,16 +909,12 @@ impl GaussObs { /// * [`KeplerianElements`] – definition of the orbital elements struct. fn compute_orbit_from_state( &self, - state: &Outfit, &asteroid_position: &Vector3, &asteroid_velocity: &Vector3, reference_epoch: f64, ) -> Result { - // get the rotation matrix from equatorial mean J2000 to ecliptic mean J2000 - let roteqec = state.get_rot_equmj2000_to_eclmj2000(); - // Apply the transformation to position and velocity vectors - let matrix_elc_transform = roteqec.transpose(); + let matrix_elc_transform = ROT_EQUMJ2000_TO_ECLMJ2000.transpose(); let ecl_pos = matrix_elc_transform * asteroid_position; let ecl_vel = matrix_elc_transform * asteroid_velocity; @@ -1053,6 +1050,7 @@ impl GaussObs { /// /// Arguments /// ----------------- + /// * `rot_equmj2000_to_eclmj2000` – rotation matrix from equatorial mean J2000 to ecliptic mean J2000 /// * `pos_all_time`: `3×3` heliocentric positions at `t1|t2|t3` (AU). /// * `vel_t2`: heliocentric velocity at `t2` (AU/day). /// * `epoch`: reference epoch for the state (MJD TT). @@ -1068,14 +1066,13 @@ impl GaussObs { #[inline] fn build_result( &self, - state: &Outfit, pos_all_time: &Matrix3, vel_t2: &Vector3, epoch: f64, corrected: bool, ) -> Option { let r_t2: Vector3 = pos_all_time.column(1).into(); - match self.compute_orbit_from_state(state, &r_t2, vel_t2, epoch) { + match self.compute_orbit_from_state(&r_t2, vel_t2, epoch) { Ok(orbit) if corrected => Some(GaussResult::CorrectedOrbit(orbit)), Ok(orbit) => Some(GaussResult::PrelimOrbit(orbit)), Err(_) => None, @@ -1103,7 +1100,7 @@ impl GaussObs { /// /// Arguments /// ----------------- - /// * `state`: Global context (ephemerides, constants, settings). + /// * `rot_equmj2000_to_eclmj2000` – rotation matrix from equatorial mean J2000 to ecliptic mean J2000 /// * `iod_params`: Parameters controlling the IOD process, including root filtering and correction settings. /// /// Return @@ -1125,7 +1122,6 @@ impl GaussObs { /// * [`IODParams`] – Configuration parameters for the IOD process. pub fn prelim_orbit_all( &self, - state: &Outfit, iod_params: &IODParams, ) -> Result, OutfitError> { // 1) Core Gauss quantities @@ -1182,15 +1178,14 @@ impl GaussObs { iod_params.newton_eps, iod_params.newton_max_it, ) { - if let Some(res) = - self.build_result(state, &pos_cor, &v_cor, epoch_cor, true) + if let Some(res) = self.build_result(&pos_cor, &v_cor, epoch_cor, true) { if solutions.len() < iod_params.max_tested_solutions { solutions.push(res); } } } else if let Some(res) = - self.build_result(state, &pos_all, &v_pre, epoch_ref, false) + self.build_result(&pos_all, &v_pre, epoch_ref, false) { if solutions.len() < iod_params.max_tested_solutions { solutions.push(res); @@ -1224,7 +1219,7 @@ impl GaussObs { /// /// Arguments /// ----------------- - /// * `state`: The global context (ephemerides, constants, settings). + /// * `rot_equmj2000_to_eclmj2000` – rotation matrix from equatorial mean J2000 to ecliptic mean J2000 /// * `iod_params`: Parameters controlling the IOD process, including root filtering and correction settings. /// /// Return @@ -1244,12 +1239,8 @@ impl GaussObs { /// * [`prelim_orbit_all`](crate::initial_orbit_determination::gauss::GaussObs::prelim_orbit_all) – Enumerates and returns up to three acceptable solutions. /// * [`pos_and_vel_correction`](crate::initial_orbit_determination::gauss::GaussObs::pos_and_vel_correction) – Iterative velocity update (Lagrange f/g). /// * [`IODParams`] – Configuration parameters for the IOD process. - pub fn prelim_orbit( - &self, - state: &Outfit, - iod_params: &IODParams, - ) -> Result { - let all = self.prelim_orbit_all(state, iod_params)?; + pub fn prelim_orbit(&self, iod_params: &IODParams) -> Result { + let all = self.prelim_orbit_all(iod_params)?; if let Some(best_corr) = all .iter() .find(|s| matches!(s, GaussResult::CorrectedOrbit(_))) @@ -1432,14 +1423,10 @@ impl GaussObs { } #[cfg(test)] -#[cfg(feature = "jpl-download")] pub(crate) mod gauss_test { use super::*; - use crate::{ - orbit_type::{keplerian_element::KeplerianElements, orbit_type_test::approx_equal}, - unit_test_global::OUTFIT_HORIZON_TEST, - }; + use crate::orbit_type::{keplerian_element::KeplerianElements, orbit_type_test::approx_equal}; #[test] fn test_gauss_prelim() { @@ -1718,7 +1705,6 @@ pub(crate) mod gauss_test { #[test] fn test_solve_orbit() { - let env = &OUTFIT_HORIZON_TEST.0; let tol = 1e-13; let gauss = GaussObs { @@ -1747,7 +1733,7 @@ pub(crate) mod gauss_test { ), }; - let binding = gauss.prelim_orbit(env, &IODParams::default()).unwrap(); + let binding = gauss.prelim_orbit(&IODParams::default()).unwrap(); let prelim_orbit = binding.get_orbit(); // This is the expected orbit based on the Orbfit software @@ -1781,7 +1767,7 @@ pub(crate) mod gauss_test { .into(), }; - let binding = a.prelim_orbit(env, &IODParams::default()).unwrap(); + let binding = a.prelim_orbit(&IODParams::default()).unwrap(); let prelim_orbit_a = binding.get_orbit(); let expected_orbit = OrbitalElements::Keplerian(KeplerianElements { @@ -1827,7 +1813,7 @@ pub(crate) mod gauss_test { ), }; - let binding = gauss.prelim_orbit(env, &IODParams::default()).unwrap(); + let binding = gauss.prelim_orbit(&IODParams::default()).unwrap(); let prelim_orbit_b = binding.get_orbit(); // This is the expected orbit based on the Orbfit software @@ -1983,8 +1969,13 @@ pub(crate) mod gauss_test { let mut rng = StdRng::seed_from_u64(42_u64); // seed for reproducibility - let realizations = - gauss.generate_noisy_realizations(&errors_ra, &errors_dec, 5, 1.0, &mut rng); + let realizations = gauss.clone().generate_noisy_realizations( + &errors_ra, + &errors_dec, + 5, + 1.0, + &mut rng, + ); assert_eq!(realizations.len(), 6); // 1 original + 5 noisy @@ -2017,8 +2008,13 @@ pub(crate) mod gauss_test { let mut rng = StdRng::seed_from_u64(123); - let realizations = - gauss.generate_noisy_realizations(&errors_ra, &errors_dec, 3, 1.0, &mut rng); + let realizations = gauss.clone().generate_noisy_realizations( + &errors_ra, + &errors_dec, + 3, + 1.0, + &mut rng, + ); for g in realizations { assert_eq!(g.ra, gauss.ra); @@ -2041,8 +2037,13 @@ pub(crate) mod gauss_test { let mut rng = StdRng::seed_from_u64(123); - let realizations = - gauss.generate_noisy_realizations(&errors_ra, &errors_dec, 0, 1.0, &mut rng); + let realizations = gauss.clone().generate_noisy_realizations( + &errors_ra, + &errors_dec, + 0, + 1.0, + &mut rng, + ); assert_eq!(realizations.len(), 1); // Only the original observation assert_eq!(realizations[0], gauss); @@ -2064,10 +2065,20 @@ pub(crate) mod gauss_test { let mut rng_low = StdRng::seed_from_u64(42); let mut rng_high = StdRng::seed_from_u64(42); // same seed - let low_noise = - gauss.generate_noisy_realizations(&errors_ra, &errors_dec, 1, 0.1, &mut rng_low); - let high_noise = - gauss.generate_noisy_realizations(&errors_ra, &errors_dec, 1, 10.0, &mut rng_high); + let low_noise = gauss.clone().generate_noisy_realizations( + &errors_ra, + &errors_dec, + 1, + 0.1, + &mut rng_low, + ); + let high_noise = gauss.clone().generate_noisy_realizations( + &errors_ra, + &errors_dec, + 1, + 10.0, + &mut rng_high, + ); let diff_low = (low_noise[1].ra - gauss.ra).norm(); let diff_high = (high_noise[1].ra - gauss.ra).norm(); diff --git a/src/initial_orbit_determination/mod.rs b/src/initial_orbit_determination/mod.rs index d13dc26..c63f45f 100644 --- a/src/initial_orbit_determination/mod.rs +++ b/src/initial_orbit_determination/mod.rs @@ -79,6 +79,7 @@ use std::fmt; pub mod gauss; pub mod gauss_result; +pub mod triplet_generation; /// Configuration parameters controlling the behavior of /// [`estimate_best_orbit`](crate::observations::observations_ext::ObservationIOD::estimate_best_orbit). @@ -634,6 +635,11 @@ impl IODParamsBuilder { Ok(self.params) } + + /// Create an [`IODParams`] directly from the builder's internal parameters without validation. + pub fn from_params(params: IODParams) -> Self { + Self { params } + } } impl fmt::Display for IODParams { diff --git a/src/initial_orbit_determination/triplet_generation/index_generator.rs b/src/initial_orbit_determination/triplet_generation/index_generator.rs new file mode 100644 index 0000000..0e9e0fd --- /dev/null +++ b/src/initial_orbit_determination/triplet_generation/index_generator.rs @@ -0,0 +1,657 @@ +//! # IOD Triplet Index Generator (lazy, windowed by time span) +//! +//! Streams index triplets `(i, j, k)` — called **anchor**, **middle**, **last** — that +//! satisfy a time-span constraint on the outer pair: +//! +//! $$dt\_min \leq t_k - t_i \leq dt\_max \quad \text{with } i < j < k$$ +//! +//! Indices refer to a *downsampled* view of the input observations (the +//! **reduced set**). They map directly to positions in the input slice. +//! +//! ## Input contract +//! +//! The observation slice passed to [`TripletIndexGenerator::from_observations`] +//! **must already be sorted in ascending time order**. No internal sorting is +//! performed. Violating this contract produces silently incorrect triplets. +//! +//! ## Algorithm +//! +//! For each anchor `i`, a two-pointer sweep finds the valid window +//! $[lo, hi]$ for `k` such that the time-span constraint holds. +//! `j` then ranges over `(i, hi)` and `k` over `(j, hi]`. +//! This gives ~$O(n^2)$ complexity versus $O(n^3)$ brute-force. +//! +//! ## Typical usage +//! +//! ```text +//! // observations must be sorted by ascending epoch before this call. +//! let gen = TripletIndexGenerator::from_observations(&obs, dt_min, dt_max, 200, usize::MAX); +//! for (i, j, k) in gen { +//! // i, j, k index directly into the (downsampled view of the) input slice. +//! } +//! ``` +//! +//! ## Notes +//! - `dt_min`/`dt_max` must share the same time unit as the observation epochs (TT/MJD). +//! - Fewer than 3 reduced observations or `dt_min > dt_max` → empty iterator. +//! - No ordering by heuristic is imposed; layer a best-K heap on top if needed. + +use photom::observation_dataset::observation::Observation; + +// --------------------------------------------------------------------------- +// Downsampling +// --------------------------------------------------------------------------- + +/// Select `max_keep` indices from `0..n` uniformly, always including `0` and `n-1`. +/// +/// This reduces the $O(n^3)$ triplet explosion while preserving the full time span. +/// +/// Behavior +/// ----------------- +/// | Condition | Result | +/// |--------------------|-------------------------------------| +/// | `n == 0` | `[]` | +/// | `max_keep >= n` | `[0, 1, …, n-1]` (identity) | +/// | `max_keep <= 3` | `[0, n/2, n-1]` | +/// | otherwise | `max_keep` uniformly spaced indices | +/// +/// Arguments +/// ----------------- +/// * `n` – Total number of points. +/// * `max_keep` – Maximum number of indices to return. +/// +/// Return +/// ---------- +/// Indices in **strictly ascending** order. +pub(crate) fn downsample_uniform_with_edges(n: usize, max_keep: usize) -> Vec { + match n { + 0 => vec![], + _ if max_keep >= n => (0..n).collect(), + _ if max_keep <= 3 => vec![0, n / 2, n - 1], + _ => (0..max_keep) + .map(|i| i * (n - 1) / (max_keep - 1)) + .collect(), + } +} + +// --------------------------------------------------------------------------- +// Feasible window for a fixed anchor +// --------------------------------------------------------------------------- + +/// Valid range of `last` indices for a fixed anchor `i`. +/// +/// `lo` is the smallest index `k > i` with $t_k - t_i \geq dt\_min$. +/// `hi` is the largest index `k > i` with $t_k - t_i \leq dt\_max$. +/// +/// The window is **empty** when `lo > hi` or no such `k` exists. +#[derive(Debug, Clone, Copy)] +struct LastWindow { + lo: usize, + hi: usize, +} + +impl LastWindow { + fn compute(anchor: usize, epochs: &[f64], dt_min: f64, dt_max: f64) -> Self { + let n = epochs.len(); + let t0 = epochs[anchor]; + + let mut lo = anchor + 2; + while lo < n && epochs[lo] - t0 < dt_min { + lo += 1; + } + + let mut hi = lo.saturating_sub(1).max(anchor + 1); + while hi + 1 < n && epochs[hi + 1] - t0 <= dt_max { + hi += 1; + } + + Self { lo, hi } + } + + fn is_empty(&self, anchor: usize, n: usize) -> bool { + self.lo >= n || self.lo > self.hi || self.hi <= anchor + 1 + } +} + +// --------------------------------------------------------------------------- +// TripletIndexGenerator +// --------------------------------------------------------------------------- + +/// Lazy iterator over time-feasible IOD triplet indices `(anchor, middle, last)`. +/// +/// # Input contract +/// +/// The observation slice passed to [`from_observations`](Self::from_observations) +/// **must be sorted in ascending time order** before construction. +/// +/// # Index space +/// +/// Indices yielded by the iterator refer directly to positions in the +/// (downsampled) input slice — no remapping is needed. +/// +/// See the [module-level documentation](self) for the algorithm and usage. +pub struct TripletIndexGenerator { + /// Epochs of the reduced set (TT/MJD), extracted from the downsampled positions. + epochs: Vec, + + // --- iteration state --- + anchor: usize, + middle: usize, + last: usize, + window: LastWindow, + + n: usize, + dt_min: f64, + dt_max: f64, + + /// Remaining triplets allowed before the iterator stops (`usize::MAX` = no cap). + remaining: usize, +} + +impl TripletIndexGenerator { + /// Construct directly from a reduced epoch vector. + /// + /// `epochs` must be in **ascending** order. + /// + /// Arguments + /// ----------------- + /// * `epochs` – Reduced epochs in ascending order. + /// * `dt_min`, `dt_max` – Time-span bounds on `(anchor, last)`. + /// * `cap` – Maximum triplets to yield (`usize::MAX` for no limit). + pub fn new(epochs: Vec, dt_min: f64, dt_max: f64, cap: usize) -> Self { + let n = epochs.len(); + let window = if n >= 3 { + LastWindow::compute(0, &epochs, dt_min, dt_max) + } else { + LastWindow { lo: n, hi: 0 } + }; + + Self { + n, + epochs, + dt_min, + dt_max, + anchor: 0, + middle: 1, + last: window.lo.max(2), + window, + remaining: cap, + } + } + + /// Build from a time-sorted observation slice, with optional downsampling. + /// + /// The input slice **must already be sorted in ascending time order**. + /// Downsampling via [`downsample_uniform_with_edges`] is applied, always + /// preserving the first and last observations. + /// + /// Arguments + /// ----------------- + /// * `observations` – Time-sorted observation slice (ascending epoch). + /// * `dt_min`, `dt_max` – Time-span bounds on `(anchor, last)`. + /// * `max_reduced` – Downsampling cap (uniform with endpoints). + /// * `cap` – Maximum triplets to yield (`usize::MAX` for no limit). + pub fn from_observations( + observations: &[&Observation], + dt_min: f64, + dt_max: f64, + max_reduced: usize, + cap: usize, + ) -> Self { + let keep = downsample_uniform_with_edges(observations.len(), max_reduced); + let epochs: Vec = keep.iter().map(|&i| observations[i].mjd_tt()).collect(); + Self::new(epochs, dt_min, dt_max, cap) + } + + /// Reduced epochs (TT/MJD), aligned with the indices yielded by the iterator. + pub fn reduced_times(&self) -> &[f64] { + &self.epochs + } + + fn advance_anchor(&mut self) -> bool { + self.anchor += 1; + if self.anchor + 2 >= self.n { + return false; + } + self.window = LastWindow::compute(self.anchor, &self.epochs, self.dt_min, self.dt_max); + self.middle = self.anchor + 1; + self.last = self.window.lo.max(self.middle + 1); + true + } + + #[inline] + fn reset_last_for_middle(&mut self) { + self.last = self.window.lo.max(self.middle + 1); + } +} + +impl Iterator for TripletIndexGenerator { + type Item = (usize, usize, usize); + + fn next(&mut self) -> Option { + if self.remaining == 0 { + return None; + } + + loop { + if self.anchor + 2 >= self.n { + return None; + } + + if self.window.is_empty(self.anchor, self.n) { + if !self.advance_anchor() { + return None; + } + continue; + } + + if self.middle >= self.window.hi { + if !self.advance_anchor() { + return None; + } + continue; + } + + if self.last <= self.middle { + self.reset_last_for_middle(); + } + + if self.last > self.window.hi { + self.middle += 1; + self.reset_last_for_middle(); + continue; + } + + let triplet = (self.anchor, self.middle, self.last); + self.last += 1; + self.remaining -= 1; + return Some(triplet); + } + } +} + +#[cfg(test)] +mod triplet_generator_tests { + use super::*; + use proptest::prelude::*; + + // ----------------------------------------------------------------------- + // Helpers + // ----------------------------------------------------------------------- + + /// Build a generator directly from a sorted epoch slice (no observations needed). + fn gen_from_epochs(epochs: Vec, dt_min: f64, dt_max: f64) -> TripletIndexGenerator { + TripletIndexGenerator::new(epochs, dt_min, dt_max, usize::MAX) + } + + /// Collect all triplets and verify every invariant inline. + fn collect_and_validate( + epochs: &[f64], + dt_min: f64, + dt_max: f64, + ) -> Vec<(usize, usize, usize)> { + let gen = gen_from_epochs(epochs.to_vec(), dt_min, dt_max); + let mut out = Vec::new(); + for (i, j, k) in gen { + assert!(i < j, "first < middle violated: ({i},{j},{k})"); + assert!(j < k, "middle < last violated: ({i},{j},{k})"); + let span = epochs[k] - epochs[i]; + assert!( + span >= dt_min - 1e-12, + "span {span} < dt_min {dt_min}: ({i},{j},{k})" + ); + assert!( + span <= dt_max + 1e-12, + "span {span} > dt_max {dt_max}: ({i},{j},{k})" + ); + out.push((i, j, k)); + } + out + } + + /// Brute-force reference: all (i,j,k) with i Vec<(usize, usize, usize)> { + let n = epochs.len(); + let mut out = Vec::new(); + for i in 0..n { + for j in (i + 1)..n { + for k in (j + 1)..n { + let span = epochs[k] - epochs[i]; + if span >= dt_min - 1e-12 && span <= dt_max + 1e-12 { + out.push((i, j, k)); + } + } + } + } + out + } + + // ----------------------------------------------------------------------- + // downsample_uniform_with_edges_indices + // ----------------------------------------------------------------------- + + #[test] + fn downsample_empty() { + assert!(downsample_uniform_with_edges(0, 10).is_empty()); + } + + #[test] + fn downsample_no_op_when_max_ge_n() { + let result = downsample_uniform_with_edges(5, 10); + assert_eq!(result, vec![0, 1, 2, 3, 4]); + } + + #[test] + fn downsample_exact_n() { + let result = downsample_uniform_with_edges(5, 5); + assert_eq!(result, vec![0, 1, 2, 3, 4]); + } + + #[test] + fn downsample_max_keep_3() { + // Always returns [0, mid, n-1]. + let result = downsample_uniform_with_edges(9, 3); + assert_eq!(result, vec![0, 4, 8]); + } + + #[test] + fn downsample_max_keep_le_3_small_n() { + let result = downsample_uniform_with_edges(3, 2); + // Edge case: max_keep ≤ 3 branch → [0, mid, n-1] = [0, 1, 2] + assert_eq!(result[0], 0); + assert_eq!(*result.last().unwrap(), 2); + } + + #[test] + fn downsample_endpoints_always_present() { + for n in 4..=20 { + for max_keep in 3..=n { + let result = downsample_uniform_with_edges(n, max_keep); + assert_eq!( + result[0], 0, + "first endpoint missing for n={n} max={max_keep}" + ); + assert_eq!( + *result.last().unwrap(), + n - 1, + "last endpoint missing for n={n} max={max_keep}" + ); + } + } + } + + #[test] + fn downsample_length_respects_max_keep() { + for n in 4..=30 { + for max_keep in 3..n { + let result = downsample_uniform_with_edges(n, max_keep); + assert!( + result.len() <= max_keep, + "len={} > max_keep={max_keep} for n={n}", + result.len() + ); + } + } + } + + #[test] + fn downsample_strictly_increasing() { + let result = downsample_uniform_with_edges(100, 10); + for w in result.windows(2) { + assert!(w[0] < w[1], "not strictly increasing: {:?}", result); + } + } + + // ----------------------------------------------------------------------- + // TripletIndexGenerator — edge cases + // ----------------------------------------------------------------------- + + #[test] + fn generator_empty_on_fewer_than_3_obs() { + for n in 0..=2 { + let epochs: Vec = (0..n).map(|i| i as f64).collect(); + let triplets = collect_and_validate(&epochs, 0.0, 10.0); + assert!(triplets.is_empty(), "expected empty for n={n}"); + } + } + + #[test] + fn generator_empty_when_dt_min_gt_dt_max() { + let epochs = vec![0.0, 1.0, 2.0, 3.0]; + let triplets = collect_and_validate(&epochs, 5.0, 2.0); + assert!(triplets.is_empty()); + } + + #[test] + fn generator_empty_when_all_spans_below_dt_min() { + // All spans ≤ 2, dt_min = 10. + let epochs = vec![0.0, 1.0, 2.0, 3.0]; + let triplets = collect_and_validate(&epochs, 10.0, 100.0); + assert!(triplets.is_empty()); + } + + #[test] + fn generator_empty_when_all_spans_above_dt_max() { + // All spans ≥ 3, dt_max = 1. + let epochs = vec![0.0, 10.0, 20.0, 30.0]; + let triplets = collect_and_validate(&epochs, 0.0, 1.0); + assert!(triplets.is_empty()); + } + + #[test] + fn generator_single_feasible_triplet() { + // Only (0,1,2) has span 2.0 ∈ [2, 2]. + let epochs = vec![0.0, 1.0, 2.0]; + let triplets = collect_and_validate(&epochs, 2.0, 2.0); + assert_eq!(triplets, vec![(0, 1, 2)]); + } + + #[test] + fn generator_matches_brute_force_small() { + let epochs = vec![0.0, 1.0, 2.0, 3.0, 4.0]; + let dt_min = 1.5; + let dt_max = 3.5; + + let mut got = collect_and_validate(&epochs, dt_min, dt_max); + let mut expected = brute_force(&epochs, dt_min, dt_max); + + got.sort(); + expected.sort(); + assert_eq!(got, expected); + } + + #[test] + fn generator_matches_brute_force_no_constraint() { + // dt_min = 0, dt_max = ∞ → all (i = (0..6).map(|i| i as f64).collect(); + + let mut got = collect_and_validate(&epochs, 0.0, f64::MAX); + let mut expected = brute_force(&epochs, 0.0, f64::MAX); + + got.sort(); + expected.sort(); + assert_eq!(got, expected); + } + + #[test] + fn generator_matches_brute_force_equal_spacing() { + let epochs: Vec = (0..7).map(|i| i as f64 * 2.0).collect(); + let dt_min = 3.0; + let dt_max = 9.0; + + let mut got = collect_and_validate(&epochs, dt_min, dt_max); + let mut expected = brute_force(&epochs, dt_min, dt_max); + + got.sort(); + expected.sort(); + assert_eq!(got, expected); + } + + #[test] + fn generator_no_duplicates() { + let epochs: Vec = (0..8).map(|i| i as f64).collect(); + let mut triplets = collect_and_validate(&epochs, 1.0, 6.0); + triplets.sort(); + triplets.dedup(); + let all = collect_and_validate(&epochs, 1.0, 6.0); + assert_eq!(triplets.len(), all.len(), "duplicates detected"); + } + + // ----------------------------------------------------------------------- + // max_triplets_to_yield cap + // ----------------------------------------------------------------------- + + #[test] + fn generator_respects_max_triplets_cap() { + let epochs: Vec = (0..10).map(|i| i as f64).collect(); + let cap = 5; + let gen = TripletIndexGenerator::new(epochs, 1.0, 20.0, cap); + let count = gen.count(); + assert_eq!(count, cap); + } + + #[test] + fn generator_cap_zero_yields_nothing() { + let epochs = vec![0.0, 1.0, 2.0, 3.0]; + let gen = TripletIndexGenerator::new(epochs, 0.0, 10.0, 0); + assert_eq!(gen.count(), 0); + } + + // ----------------------------------------------------------------------- + // reduced_to_original mapping + // ----------------------------------------------------------------------- + + #[test] + fn reduced_times_match_input_epochs() { + let epochs: Vec = (0..5).map(|i| i as f64).collect(); + let gen = TripletIndexGenerator::new(epochs.clone(), 0.0, 10.0, usize::MAX); + assert_eq!(gen.reduced_times(), epochs.as_slice()); + } + + #[test] + fn reduced_times_aligned_with_mapping() { + let epochs = vec![0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0]; + let gen = TripletIndexGenerator::new(epochs.clone(), 0.0, 20.0, usize::MAX); + + // reduced_times length must match the number of epochs provided. + assert_eq!(gen.reduced_times().len(), epochs.len()); + } + + // ----------------------------------------------------------------------- + // EpochSortedIndices + // ----------------------------------------------------------------------- + + // (tested indirectly through from_observations; direct access is private) + + // ----------------------------------------------------------------------- + // Proptest: invariants hold for random inputs + // ----------------------------------------------------------------------- + + proptest! { + /// For random sorted epoch vectors and random dt windows, every yielded + /// triplet must satisfy i < j < k and dt_min ≤ t[k]-t[i] ≤ dt_max. + #[test] + fn prop_all_invariants_hold( + // Generate 3..=12 strictly increasing epochs starting near 0. + n in 3usize..=12, + steps in prop::collection::vec(0.1f64..5.0, 11), + dt_min in 0.0f64..10.0, + dt_range in 0.0f64..20.0, + ) { + let dt_max = dt_min + dt_range; + // Build epochs as cumulative sum of steps (strictly increasing). + let mut epochs = vec![0.0f64]; + for &s in steps.iter().take(n - 1) { + epochs.push(epochs.last().unwrap() + s); + } + + let _triplets = collect_and_validate(&epochs, dt_min, dt_max); + // collect_and_validate asserts invariants inline. + } + + /// Generator output matches brute-force for small random inputs. + #[test] + fn prop_matches_brute_force( + n in 3usize..=8, + steps in prop::collection::vec(0.5f64..3.0, 7), + dt_min in 0.0f64..5.0, + dt_range in 0.0f64..10.0, + ) { + let dt_max = dt_min + dt_range; + let mut epochs = vec![0.0f64]; + for &s in steps.iter().take(n - 1) { + epochs.push(epochs.last().unwrap() + s); + } + + let mut got = collect_and_validate(&epochs, dt_min, dt_max); + let mut expected = brute_force(&epochs, dt_min, dt_max); + got.sort(); + expected.sort(); + prop_assert_eq!(got, expected); + } + + /// No duplicate triplets regardless of input. + #[test] + fn prop_no_duplicates( + n in 3usize..=10, + steps in prop::collection::vec(0.1f64..4.0, 9), + dt_min in 0.0f64..5.0, + dt_range in 0.0f64..15.0, + ) { + let dt_max = dt_min + dt_range; + let mut epochs = vec![0.0f64]; + for &s in steps.iter().take(n - 1) { + epochs.push(epochs.last().unwrap() + s); + } + + let mut triplets = collect_and_validate(&epochs, dt_min, dt_max); + let total = triplets.len(); + triplets.sort(); + triplets.dedup(); + prop_assert_eq!(triplets.len(), total, "duplicate triplets found"); + } + + /// Cap is always respected. + #[test] + fn prop_cap_respected( + n in 3usize..=12, + steps in prop::collection::vec(0.5f64..3.0, 11), + cap in 0usize..=20, + ) { + let mut epochs = vec![0.0f64]; + for &s in steps.iter().take(n - 1) { + epochs.push(epochs.last().unwrap() + s); + } + let gen = TripletIndexGenerator::new( + epochs, 0.0, f64::MAX, cap, + ); + prop_assert!(gen.count() <= cap); + } + + /// downsample always returns endpoints and respects the length cap. + #[test] + fn prop_downsample_endpoints_and_length( + n in 1usize..=50, + max_keep in 3usize..=50, + ) { + let result = downsample_uniform_with_edges(n, max_keep); + prop_assert!(result.len() <= max_keep.min(n).max(3)); + if n >= 1 { + prop_assert_eq!(result[0], 0); + prop_assert_eq!(*result.last().unwrap(), n - 1); + } + } + + /// downsample output is strictly increasing. + #[test] + fn prop_downsample_strictly_increasing( + n in 2usize..=50, + max_keep in 3usize..=50, + ) { + let result = downsample_uniform_with_edges(n, max_keep); + for w in result.windows(2) { + prop_assert!(w[0] < w[1]); + } + } + } +} diff --git a/src/observations/triplets_iod.rs b/src/initial_orbit_determination/triplet_generation/mod.rs similarity index 54% rename from src/observations/triplets_iod.rs rename to src/initial_orbit_determination/triplet_generation/mod.rs index 8f53111..b5a4110 100644 --- a/src/observations/triplets_iod.rs +++ b/src/initial_orbit_determination/triplet_generation/mod.rs @@ -72,14 +72,18 @@ //! - [`triplet_weight`] – Spacing-based scoring rule. //! - [`GaussObs`] – Triplet container consumed by the Gauss IOD solver. //! - [`crate::observations::observations_ext::ObservationsExt::compute_triplets`] – Higher-level wrapper. + +pub mod index_generator; + use nalgebra::{Matrix3, Vector3}; +use photom::observation_dataset::observation::Observation; use std::cmp::Ordering; use std::collections::BinaryHeap; -use crate::constants::Observations; +use crate::cache::OutfitCache; use crate::initial_orbit_determination::gauss::GaussObs; -use crate::observations::triplets_generator::TripletIndexGenerator; -use crate::observations::Observation; +use crate::initial_orbit_determination::triplet_generation::index_generator::TripletIndexGenerator; +use crate::IODParams; /// Internal structure holding a weighted observation triplet during selection. /// @@ -269,62 +273,6 @@ fn s_gap(dt: f64, inv_dtw: f64) -> f64 { } } -/// Downsample observation indices while preserving endpoints and temporal coverage. -/// -/// If the input has more than `max_keep` points, this routine selects a subset of indices -/// **uniformly in time** while always including the **first** and **last** observation. -/// This reduces the `O(n³)` triplet explosion without losing global time-span information. -/// -/// Behavior -/// ----------------- -/// * If `n == 0`: returns `[]`. -/// * If `max_keep >= n`: returns all indices `0..n`. -/// * If `max_keep <= 3`: returns `[0, mid, n-1]` (with `mid = n/2`). -/// For `n < 3`, indices may repeat (callers should handle deduplication if needed). -/// * Otherwise: returns `max_keep` indices distributed uniformly between `1` and `n-2`, -/// plus the endpoints `0` and `n-1`. -/// -/// Arguments -/// ----------------- -/// * `n` – Total number of observations. -/// * `max_keep` – Maximum number of indices to return. -/// -/// Return -/// ---------- -/// * A `Vec` with the selected indices in **ascending** order. -/// -/// Remarks -/// ------------- -/// * Complexity: **O(max_keep)** after the trivial cases. -/// * The selection is **index-uniform** over the span `[1, n-2]`; if strictly -/// time-uniform selection is required, pre-sort observations by time first -/// (as done in [`generate_triplets`]). -/// -/// See also -/// ------------ -/// * [`generate_triplets`] – Uses this function before triplet enumeration. -pub(crate) fn downsample_uniform_with_edges_indices(n: usize, max_keep: usize) -> Vec { - match n { - 0 => Vec::new(), - _ if max_keep <= 3 => { - let mid = n / 2; - vec![0, mid, n - 1] - } - _ if max_keep >= n => (0..n).collect(), - _ => { - let slots = max_keep - 2; - std::iter::once(0) - .chain((0..slots).map(move |i| { - let fraction = (i + 1) as f64 / (slots + 1) as f64; - // distribute indices uniformly between 1 and n-2 - 1 + (fraction * (n - 2) as f64).floor() as usize - })) - .chain(std::iter::once(n - 1)) - .collect() - } - } -} - /// Generate and select **best-K** triplets of astrometric observations /// for Gauss Initial Orbit Determination (IOD), using a **lazy index stream** /// and a bounded **max-heap** on a spacing weight. @@ -334,269 +282,265 @@ pub(crate) fn downsample_uniform_with_edges_indices(n: usize, max_keep: usize) - /// This routine constructs good candidate triplets `(first, middle, last)` as inputs /// to the **Gauss method** while avoiding the `O(n³)` blow-up: /// -/// 1. **Sort & downsample (in `TripletIndexGenerator`)** – Observations are sorted by epoch -/// and uniformly thinned (keeping endpoints) to at most `max_obs_for_triplets`. +/// 1. **Downsample (in `TripletIndexGenerator`)** – Observations are uniformly thinned +/// (keeping endpoints) to at most `max_obs_for_triplets`. The input slice is assumed +/// to be **already sorted by ascending epoch**. /// 2. **Time-feasible enumeration (lazy)** – Indices `(i, j, k)` are streamed by /// `TripletIndexGenerator`, constrained by: /// `dt_min ≤ t[k] − t[i] ≤ dt_max` with `i < j < k`. /// 3. **Weight scoring** – Each feasible triplet receives a weight via [`triplet_weight`], /// favoring near-uniform spacing around `optimal_interval_time`. /// 4. **Best-K selection** – A bounded **max-heap** retains only the `max_triplet` -/// lowest-weight candidates (the heap’s `peek()` is the current **worst**). +/// lowest-weight candidates (the heap's `peek()` is the current **worst**). /// 5. **Materialization** – Only for the selected indices, we re-borrow `observations` -/// immutably and build [`GaussObs`] with precomputed observer heliocentric columns -/// (via [`Observation::get_observer_helio_position`]). +/// immutably and build [`GaussObs`] with precomputed observer heliocentric columns. /// -/// Design notes -/// ----------------- -/// * Enumeration and scoring happen on **reduced indices** (owned by the generator), +/// # Design notes +/// +/// - The input slice must be **sorted in ascending time order** before this call. +/// - Enumeration and scoring happen on reduced epoch indices owned by the generator, /// so there are **no overlapping borrows** of `observations`. -/// * The function avoids cloning the generator’s internal buffers (times, mapping); -/// we read them through short-lived immutable borrows between `next()` calls. -/// * The final `Vec` is **sorted by increasing weight** (best first). +/// - The final `Vec` is **sorted by increasing weight** (best first). /// -/// Arguments -/// ----------------- -/// * `observations` – Mutable set of astrometric observations; epochs are sorted **in-place**. -/// * `dt_min` – Minimum allowed time span (same units as `Observation::time`) between first and last. -/// * `dt_max` – Maximum allowed time span between first and last. -/// * `optimal_interval_time` – Target per-gap spacing used by [`triplet_weight`]. -/// * `max_obs_for_triplets` – Downsampling cap (uniform with edges). -/// * `max_triplet` – Number `K` of best triplets to return (heap capacity). +/// # Arguments /// -/// Return -/// ---------- -/// * A `Vec` of length `≤ max_triplet`, sorted by **ascending** heuristic weight. +/// - `observations` – Time-sorted observation slice (ascending epoch). +/// - `cache` – Precomputed heliocentric observer positions. +/// - `params` – IOD parameters controlling time bounds, downsampling cap, +/// optimal spacing, and the best-K limit. /// -/// Complexity -/// ----------------- -/// * Enumeration: typically ~`O(n²)` thanks to the per-anchor time window in +/// # Return +/// +/// - A `Vec` of length `≤ max_triplet`, sorted by **ascending** heuristic weight. +/// +/// # Complexity +/// +/// * Enumeration: typically ~$O(n^2)$ thanks to the per-anchor time window in /// [`TripletIndexGenerator`]. -/// * Selection: `O(n log K)` due to the bounded heap. -/// * Space: `O(1)` per yielded triplet during enumeration; only the final `K` are materialized. +/// * Selection: $O(n \log K)$ due to the bounded heap. +/// * Space: $O(1)$ per yielded triplet during enumeration; only the final `K` are materialized. +/// +/// # See also /// -/// See also -/// ------------ /// * [`TripletIndexGenerator`] – Streams time-feasible reduced indices lazily. /// * [`triplet_weight`] – Heuristic favoring evenly spaced triplets around a target gap. /// * [`GaussObs::realizations_iter`] – Lazy Monte-Carlo perturbations per triplet. -/// * [`ObservationsExt::compute_triplets`](crate::observations::observations_ext::ObservationsExt::compute_triplets) – Typical high-level wrapper. pub fn generate_triplets( - observations: &mut Observations, - dt_min: f64, - dt_max: f64, - optimal_interval_time: f64, - max_obs_for_triplets: usize, - max_triplet: u32, + observations: &[&Observation], + cache: &OutfitCache, + params: &IODParams, ) -> Vec { - if max_triplet == 0 { + if params.max_triplets == 0 || observations.len() < 3 { return Vec::new(); } - // --- Phase 1: enumerate feasible reduced indices & keep best-K by weight (no &Observation borrows). + // --- Phase 1: build the reduced-index stream and score all feasible triplets. let mut index_gen = TripletIndexGenerator::from_observations( observations, - dt_min, - dt_max, - max_obs_for_triplets, - usize::MAX, // scan all feasible triplets; the heap does best-K filtering + params.dt_min, + params.dt_max_triplet, + params.max_obs_for_triplets, + usize::MAX, ); - let k_cap = max_triplet as usize; - let mut heap: BinaryHeap = BinaryHeap::with_capacity(k_cap.saturating_add(1)); + let k_cap = params.max_triplets as usize; + let inv_dtw = params.optimal_interval_time.recip(); + let best_k = collect_best_k_triplets(&mut index_gen, k_cap, inv_dtw); - // Bounded push: maintain the K smallest weights in a BinaryHeap (max-heap). - let mut push_best_k = |cand: WeightedTriplet| { - if !cand.weight.is_finite() { - return; // guard against NaN/Inf + // --- Phase 2: materialize GaussObs for the selected indices. + // Indices in WeightedTriplet refer directly into the downsampled view of + // `observations` — no remapping is needed. + best_k + .into_iter() + .map(|wt| build_gauss_obs(cache, observations, wt)) + .collect() +} + +/// Consume the triplet stream and retain the `max_triplets` best candidates by ascending weight. +/// +/// Uses a bounded max-heap: when the heap is full, a new candidate replaces the +/// current worst only if its weight is strictly smaller. +/// +/// Returns candidates sorted by ascending weight. +fn collect_best_k_triplets( + gen: &mut TripletIndexGenerator, + max_triplets: usize, + inv_optimal_interval: f64, +) -> Vec { + let mut heap: BinaryHeap = + BinaryHeap::with_capacity(max_triplets.saturating_add(1)); + + while let Some((first, middle, last)) = gen.next() { + let times = gen.reduced_times(); + let weight = triplet_weight_with_inv( + times[first], + times[middle], + times[last], + inv_optimal_interval, + ); + + if !weight.is_finite() { + continue; } - if heap.len() < k_cap { - heap.push(cand); - } else if let Some(worst) = heap.peek() { - if cand.weight < worst.weight { - heap.pop(); - heap.push(cand); - } + + if heap.len() < max_triplets { + heap.push(WeightedTriplet { + weight, + first_idx: first, + middle_idx: middle, + last_idx: last, + }); + } else if heap.peek().map_or(false, |worst| weight < worst.weight) { + heap.pop(); + heap.push(WeightedTriplet { + weight, + first_idx: first, + middle_idx: middle, + last_idx: last, + }); } - }; - - // Consume the reduced-index stream. After each `next()`, take a short immutable - // borrow of the times to compute the weight (no overlap with the next `next()`). - - let inv_dtw = optimal_interval_time.recip(); // precompute once - while let Some((i, j, k)) = index_gen.next() { - let times = index_gen.reduced_times(); - let w = triplet_weight_with_inv(times[i], times[j], times[k], inv_dtw); - push_best_k(WeightedTriplet { - weight: w, - first_idx: i, - middle_idx: j, - last_idx: k, - }); } - // Best-K by ascending weight. - let mut best_reduced = heap.into_sorted_vec(); - best_reduced.sort_by(|a, b| a.weight.partial_cmp(&b.weight).unwrap()); // defensive - - // --- Phase 2: materialize GaussObs for the selected indices (immutable borrows now safe). - let mapping = index_gen.selected_original_indices(); + // Max-heap → ascending weight order. + let mut result = heap.into_vec(); + result.sort_unstable_by(|a, b| a.weight.partial_cmp(&b.weight).unwrap_or(Ordering::Equal)); + result +} - best_reduced - .into_iter() - .map(|wt| { - let (i, j, k) = (wt.first_idx, wt.middle_idx, wt.last_idx); - - // reduced → original indices - let oi = mapping[i]; - let oj = mapping[j]; - let ok = mapping[k]; - - // Immutable borrows of the original observations occur only here. - let o1: &Observation = &observations[oi]; - let o2: &Observation = &observations[oj]; - let o3: &Observation = &observations[ok]; - - // Observer 3×3 matrix (columns = heliocentric observer positions at each epoch). - let observer_matrix: Matrix3 = Matrix3::from_columns(&[ - o1.get_observer_helio_position(), - o2.get_observer_helio_position(), - o3.get_observer_helio_position(), - ]); - - GaussObs::with_observer_position( - Vector3::new(oi, oj, ok), - Vector3::new(o1.ra, o2.ra, o3.ra), - Vector3::new(o1.dec, o2.dec, o3.dec), - Vector3::new(o1.time, o2.time, o3.time), - observer_matrix, - ) - }) - .collect() +/// Materialize a single [`GaussObs`] from a [`WeightedTriplet`]. +/// +/// Indices in `wt` map directly into `observations` — no remapping is needed +/// since [`TripletIndexGenerator`] works on the input slice as-is. +fn build_gauss_obs( + cache: &OutfitCache, + observations: &[&Observation], + wt: WeightedTriplet, +) -> GaussObs { + let o1 = &observations[wt.first_idx]; + let o2 = &observations[wt.middle_idx]; + let o3 = &observations[wt.last_idx]; + + let observer_matrix = Matrix3::from_columns(&[ + *cache.get_helio_position(o1.index()), + *cache.get_helio_position(o2.index()), + *cache.get_helio_position(o3.index()), + ]); + + let (o1_ra, o1_dec) = (o1.equ_coord().ra, o1.equ_coord().dec); + let (o2_ra, o2_dec) = (o2.equ_coord().ra, o2.equ_coord().dec); + let (o3_ra, o3_dec) = (o3.equ_coord().ra, o3.equ_coord().dec); + + GaussObs::with_observer_position( + Vector3::new(wt.first_idx, wt.middle_idx, wt.last_idx), + Vector3::new(o1_ra, o2_ra, o3_ra), + Vector3::new(o1_dec, o2_dec, o3_dec), + Vector3::new(o1.mjd_tt(), o2.mjd_tt(), o3.mjd_tt()), + observer_matrix.map(|x| x.into_inner()), + ) } #[cfg(test)] mod triplets_iod_tests { - - #[cfg(feature = "jpl-download")] - use approx::assert_relative_eq; - use super::*; - #[cfg(feature = "jpl-download")] - pub(crate) fn assert_gauss_obs_approx_eq(a: &GaussObs, b: &GaussObs, tol: f64) { - assert_eq!(a.idx_obs, b.idx_obs); - assert_relative_eq!(a.ra, b.ra, max_relative = tol); - assert_relative_eq!(a.dec, b.dec, max_relative = tol); - assert_relative_eq!(a.time, b.time, max_relative = tol); - } - #[test] - #[cfg(feature = "jpl-download")] fn test_compute_triplets() { - use camino::Utf8Path; + use crate::cache::OutfitCache; + use crate::test_fixture::{DATASET_2015AB, JPL_EPHEM_HORIZON, UT1_PROVIDER}; + use crate::IODParams; + use photom::observer::error_model::{ModelCorrection, ObsErrorModel}; + + // The error model must be set before building the cache. + let dataset = DATASET_2015AB + .clone() + .with_error_model(ObsErrorModel::FCCT14) + .apply_batch_rms_correction(30.0); + + // Build the cache from the real 2015AB dataset. + let cache = OutfitCache::build(&dataset, &JPL_EPHEM_HORIZON, &UT1_PROVIDER).unwrap(); + + // Pick a trajectory with enough observations. + let traj = dataset + .materialize_trajectory("K09R05F") + .unwrap() + .collect_into_vec(); + + assert!( + traj.len() >= 3, + "trajectory must have at least 3 observations" + ); - use crate::{ - trajectories::trajectory_file::TrajectoryFile, unit_test_global::OUTFIT_HORIZON_TEST, - TrajectorySet, + let params = IODParams { + dt_min: 0.03, + dt_max_triplet: 150.0, + optimal_interval_time: 20.0, + max_obs_for_triplets: traj.len(), + max_triplets: 10, + ..Default::default() }; - let mut env_state = OUTFIT_HORIZON_TEST.0.clone(); - let mut traj_set = - TrajectorySet::new_from_80col(&mut env_state, Utf8Path::new("tests/data/2015AB.obs")); - - let traj_number = crate::constants::ObjectNumber::String("K09R05F".into()); - let traj_len = traj_set - .get(&traj_number) - .expect("Failed to get trajectory") - .len(); - - let traj_mut = traj_set - .get_mut(&traj_number) - .expect("Failed to get trajectory"); + let triplets = generate_triplets(&traj, &cache, ¶ms); - let triplets = generate_triplets(traj_mut, 0.03, 150.0, 20.0, traj_len, 10); + // We should get at least one triplet back. + assert!(!triplets.is_empty(), "expected at least one triplet"); - assert_eq!( + // No more than max_triplets. + assert!( + triplets.len() <= params.max_triplets as usize, + "got {} triplets, expected ≤ {}", triplets.len(), - 10, - "Expected 10 triplets, got {}", - triplets.len() + params.max_triplets ); - let expected_triplets = GaussObs { - idx_obs: [[23, 24, 33]].into(), - ra: [[1.6893715963476699, 1.689861452091063, 1.7527345385664372]].into(), - dec: [[1.082468037385525, 0.9436790189346231, 0.8273762407899986]].into(), - time: [[57028.479297592596, 57049.2318575926, 57063.97711759259]].into(), - observer_helio_position: [ - [-0.2645666171486676, 0.8689351643673471, 0.3766996211112465], - [-0.5889735526502539, 0.7240117187952059, 0.3138734206791042], - [-0.7743874438017259, 0.5612884709246775, 0.2433497107566823], - ] - .into(), - }; - - assert_gauss_obs_approx_eq(&triplets[0], &expected_triplets, 1e-12); - - let expected_triplet = GaussObs { - idx_obs: [[21, 25, 33]].into(), - ra: [[1.6894680985108947, 1.6898894500811472, 1.7527345385664372]].into(), - dec: [[1.0825984522657437, 0.9435805047946215, 0.8273762407899986]].into(), - time: [[57028.45404759259, 57049.245147592585, 57063.97711759259]].into(), - observer_helio_position: [ - [-0.26413563361674103, 0.8690466209095019, 0.3767466856686271], - [-0.5891631852172257, 0.7238872516832191, 0.3138186516545291], - [-0.7743874438017259, 0.5612884709246775, 0.2433497107566823], - ] - .into(), - }; + // Triplets must be sorted by ascending weight (best first). + // We verify this by re-computing weights and checking order. + for window in triplets.windows(2) { + let t1 = &window[0].time; + let t2 = &window[1].time; + let w1 = triplet_weight(t1[0], t1[1], t1[2], params.optimal_interval_time); + let w2 = triplet_weight(t2[0], t2[1], t2[2], params.optimal_interval_time); + assert!( + w1 <= w2 + 1e-12, + "triplets not sorted by ascending weight: w1={w1} > w2={w2}" + ); + } - assert_gauss_obs_approx_eq(&triplets[9], &expected_triplet, 1e-12); + // Each triplet's indices must be strictly increasing. + for t in &triplets { + assert!( + t.idx_obs[0] < t.idx_obs[1] && t.idx_obs[1] < t.idx_obs[2], + "triplet indices not strictly increasing: {:?}", + t.idx_obs + ); + } } mod downsampling_observations_tests { - use nalgebra::Vector3; - - use super::*; - - fn make_obs(n: usize) -> Observations { - (0..n) - .map(|i| Observation { - observer: 0, - ra: 0.0, - dec: 0.0, - error_ra: 0.0, - error_dec: 0.0, - time: i as f64, - observer_earth_position: Vector3::zeros(), - observer_helio_position: Vector3::zeros(), - }) - .collect() - } + + use crate::initial_orbit_determination::triplet_generation::index_generator::downsample_uniform_with_edges; #[test] fn returns_all_when_max_keep_ge_n() { let n = 5; - let indices = downsample_uniform_with_edges_indices(n, 5); + let indices = downsample_uniform_with_edges(n, 5); assert_eq!(indices, vec![0, 1, 2, 3, 4]); - let indices = downsample_uniform_with_edges_indices(n, 10); + let indices = downsample_uniform_with_edges(n, 10); assert_eq!(indices, vec![0, 1, 2, 3, 4]); } #[test] fn empty_input_returns_empty() { - assert!(downsample_uniform_with_edges_indices(0, 0).is_empty()); - assert!(downsample_uniform_with_edges_indices(0, 10).is_empty()); + assert!(downsample_uniform_with_edges(0, 0).is_empty()); + assert!(downsample_uniform_with_edges(0, 10).is_empty()); } #[test] fn max_keep_less_than_three_returns_first_middle_last() { let n = 10; let mid = n / 2; - for max_keep in [0, 1, 2] { - let indices = downsample_uniform_with_edges_indices(n, max_keep); + for max_keep in [1, 2, 3] { + let indices = downsample_uniform_with_edges(n, max_keep); assert_eq!(indices, vec![0, mid, n - 1]); } } @@ -604,11 +548,11 @@ mod triplets_iod_tests { #[test] fn max_keep_three_exactly_returns_first_middle_last() { let n = 10; - let indices = downsample_uniform_with_edges_indices(n, 3); + let indices = downsample_uniform_with_edges(n, 3); assert_eq!(indices, vec![0, n / 2, n - 1]); let n = 3; - let indices = downsample_uniform_with_edges_indices(n, 3); + let indices = downsample_uniform_with_edges(n, 3); assert_eq!(indices, vec![0, 1, 2]); } @@ -616,13 +560,13 @@ mod triplets_iod_tests { fn downsampling_uniformity_for_general_case() { let n = 10; let max_keep = 5; - let indices = downsample_uniform_with_edges_indices(n, max_keep); + let indices = downsample_uniform_with_edges(n, max_keep); assert_eq!(indices.len(), max_keep); assert_eq!(indices.first().unwrap(), &0); assert_eq!(indices.last().unwrap(), &(n - 1)); - // Indices doivent être strictement croissants + // Indices must be strictly increasing assert!(indices.windows(2).all(|w| w[1] > w[0])); } @@ -630,22 +574,11 @@ mod triplets_iod_tests { fn works_with_large_data() { let n = 1000; let max_keep = 100; - let indices = downsample_uniform_with_edges_indices(n, max_keep); + let indices = downsample_uniform_with_edges(n, max_keep); assert_eq!(indices.len(), max_keep); assert_eq!(indices.first().unwrap(), &0); assert_eq!(indices.last().unwrap(), &(n - 1)); } - - #[test] - fn indices_match_observations() { - let obs = make_obs(10); - let max_keep = 5; - let indices = downsample_uniform_with_edges_indices(obs.len(), max_keep); - - // Vérifie que les indices correspondent bien aux temps dans obs - let times: Vec<_> = indices.iter().map(|&i| obs[i].time).collect(); - assert!(times.windows(2).all(|w| w[1] > w[0])); - } } } diff --git a/src/jpl_ephem/download_jpl_file.rs b/src/jpl_ephem/download_jpl_file.rs index e14641e..fceac47 100644 --- a/src/jpl_ephem/download_jpl_file.rs +++ b/src/jpl_ephem/download_jpl_file.rs @@ -43,9 +43,7 @@ use camino::{Utf8Path, Utf8PathBuf}; use directories::BaseDirs; use std::{fs, str::FromStr}; -#[cfg(feature = "jpl-download")] use tokio::{fs::File, io::AsyncWriteExt}; -#[cfg(feature = "jpl-download")] use tokio_stream::StreamExt; use super::{horizon::horizon_version::JPLHorizonVersion, naif::naif_version::NaifVersion}; @@ -139,7 +137,6 @@ impl EphemFileSource { /// See also /// -------- /// * [`EphemFileSource::get_version_url`] - #[cfg(feature = "jpl-download")] fn get_baseurl(&self) -> &str { match self { EphemFileSource::JPLHorizon(_) => "https://ssd.jpl.nasa.gov/ftp/eph/planets/Linux/", @@ -157,7 +154,6 @@ impl EphemFileSource { /// -------- /// * [`JPLHorizonVersion::get_filename`] /// * [`NaifVersion::get_filename`] - #[cfg(feature = "jpl-download")] pub fn get_version_url(&self) -> String { let base_url = self.get_baseurl(); match self { @@ -210,7 +206,6 @@ impl EphemFileSource { /// See also /// -------- /// * [`EphemFileSource::get_version_url`] — Compose the URL for a versioned file. -#[cfg(feature = "jpl-download")] pub async fn download_big_file(url: &str, path: &Utf8Path) -> Result<(), OutfitError> { let mut file = File::create(path).await?; println!("Downloading {url}..."); @@ -296,7 +291,6 @@ impl EphemFilePath { if local_file.exists() { Ok(local_file) } else { - #[cfg(feature = "jpl-download")] { let url = file_source.get_version_url(); @@ -308,10 +302,6 @@ impl EphemFilePath { Ok(local_file) } - #[cfg(not(feature = "jpl-download"))] - { - Err(OutfitError::JPLFileNotFound(local_file.path().to_string())) - } } } @@ -390,20 +380,3 @@ impl TryFrom for EphemFilePath { } } } - -#[cfg(test)] -mod jpl_reader_test { - /// If `jpl-download` is **not** enabled, requesting a missing file must error. - #[test] - #[cfg(not(feature = "jpl-download"))] - fn test_no_feature_download_jpl_ephem() { - use super::*; - let file_source = "naif:DE442".try_into().unwrap(); - - let result = EphemFilePath::get_ephemeris_file(&file_source); - assert!( - result.is_err(), - "feature jpl-download is enabled, weird ..." - ); - } -} diff --git a/src/jpl_ephem/horizon/horizon_data.rs b/src/jpl_ephem/horizon/horizon_data.rs index b3bca4a..274300d 100644 --- a/src/jpl_ephem/horizon/horizon_data.rs +++ b/src/jpl_ephem/horizon/horizon_data.rs @@ -34,7 +34,7 @@ use nom::{ IResult, Parser, }; -use crate::{constants::MJD, jpl_ephem::download_jpl_file::EphemFilePath}; +use crate::{constants::MJDET, jpl_ephem::download_jpl_file::EphemFilePath}; use super::{ horizon_ids::HorizonID, horizon_records::HorizonRecord, horizon_version::JPLHorizonVersion, @@ -708,7 +708,7 @@ impl HorizonData { /// See also /// ------------ /// * [`HorizonData::get_record_horizon`] – retrieves the actual record for a body. - fn get_record_index(&self, et: MJD) -> (usize, f64) { + fn get_record_index(&self, et: MJDET) -> (usize, f64) { // ephem_start and ephem_end are in JD let (ephem_start, ephem_end, ephem_step) = ( self.header.start_period, @@ -760,7 +760,7 @@ impl HorizonData { /// See also /// ------------ /// * [`HorizonRecord::interpolate`] – evaluate Chebyshev polynomials. - fn get_record_horizon(&self, body: u8, et: MJD) -> Option<(&HorizonRecord, f64)> { + fn get_record_horizon(&self, body: u8, et: MJDET) -> Option<(&HorizonRecord, f64)> { let (nr, tau) = self.get_record_index(et); let records = &self.records[nr]; @@ -810,7 +810,7 @@ impl HorizonData { &self, target: HorizonID, center: HorizonID, - et: MJD, + et: MJDET, compute_velocity: bool, compute_acceleration: bool, ) -> InterpResult { @@ -850,17 +850,19 @@ impl HorizonData { #[cfg(test)] mod test_horizon_reader { - #[cfg(feature = "jpl-download")] use super::*; - #[cfg(feature = "jpl-download")] - use crate::unit_test_global::JPL_EPHEM_HORIZON; + use crate::test_fixture::JPL_EPHEM_HORIZON; + + fn get_horizon_data() -> HorizonData { + JPL_EPHEM_HORIZON.clone().try_into_horizon().unwrap() + } #[test] - #[cfg(feature = "jpl-download")] fn test_jpl_reader_from_horizon() { + let horizon_data = get_horizon_data(); assert_eq!( - JPL_EPHEM_HORIZON.header, + horizon_data.header, HorizonHeader { jpl_version: "DE440".to_string(), ipt: [ @@ -888,17 +890,15 @@ mod test_horizon_reader { } ); - assert_eq!(JPL_EPHEM_HORIZON.records.len(), 12556); + let horizon_data = get_horizon_data(); + assert_eq!(horizon_data.records.len(), 12556); assert_eq!( - JPL_EPHEM_HORIZON - .records - .iter() - .fold(0, |acc, x| acc + x.len()), + horizon_data.records.iter().fold(0, |acc, x| acc + x.len()), 150672 ); assert_eq!( - &JPL_EPHEM_HORIZON.records[0].get(&0).unwrap()[0], + &horizon_data.records[0].get(&0).unwrap()[0], &HorizonRecord { start_jd: 2287184.5, end_jd: 2287216.5, @@ -955,9 +955,9 @@ mod test_horizon_reader { } #[test] - #[cfg(feature = "jpl-download")] fn test_get_record_from_horizon() { - let (record, tau) = JPL_EPHEM_HORIZON + let horizon_data = get_horizon_data(); + let (record, tau) = horizon_data .get_record_horizon(4, 57028.479297592596) .unwrap(); @@ -1003,18 +1003,18 @@ mod test_horizon_reader { } #[test] - #[cfg(feature = "jpl-download")] fn test_get_record_index() { - let (index, tau) = JPL_EPHEM_HORIZON.get_record_index(57028.479297592596); + let horizon_data = get_horizon_data(); + let (index, tau) = horizon_data.get_record_index(57028.479297592596); assert_eq!(index, 5307); assert_eq!(tau, 0.6399780497686152); } #[test] - #[cfg(feature = "jpl-download")] fn test_interpolation_from_horizon() { - let (record, tau) = JPL_EPHEM_HORIZON + let horizon_data = get_horizon_data(); + let (record, tau) = horizon_data .get_record_horizon(10, 57028.479297592596) .unwrap(); @@ -1031,7 +1031,8 @@ mod test_horizon_reader { } ); - let (record, tau) = JPL_EPHEM_HORIZON + let horizon_data = get_horizon_data(); + let (record, tau) = horizon_data .get_record_horizon(10, 57_049.231_857_592_59) .unwrap(); @@ -1048,7 +1049,8 @@ mod test_horizon_reader { } ); - let (record, tau) = JPL_EPHEM_HORIZON + let horizon_data = get_horizon_data(); + let (record, tau) = horizon_data .get_record_horizon(10, 60781.51949044435) .unwrap(); let res = record.interpolate(tau, true, true, 2); @@ -1068,9 +1070,9 @@ mod test_horizon_reader { } #[test] - #[cfg(feature = "jpl-download")] fn test_target_center_interpolation() { - let interp = JPL_EPHEM_HORIZON.ephemeris( + let horizon_data = get_horizon_data(); + let interp = horizon_data.ephemeris( HorizonID::Earth, HorizonID::Sun, 60781.51949044435, @@ -1106,7 +1108,8 @@ mod test_horizon_reader { } ); - let interp = JPL_EPHEM_HORIZON.ephemeris( + let horizon_data = get_horizon_data(); + let interp = horizon_data.ephemeris( HorizonID::Mars, HorizonID::Sun, 60781.51949044435, @@ -1137,7 +1140,8 @@ mod test_horizon_reader { } ); - let interp = JPL_EPHEM_HORIZON.ephemeris( + let horizon_data = get_horizon_data(); + let interp = horizon_data.ephemeris( HorizonID::Earth, HorizonID::Sun, 52550.18467592593, diff --git a/src/jpl_ephem/mod.rs b/src/jpl_ephem/mod.rs index 31326b9..7e93071 100644 --- a/src/jpl_ephem/mod.rs +++ b/src/jpl_ephem/mod.rs @@ -168,4 +168,22 @@ impl JPLEphem { } } } + + pub fn try_into_horizon(self) -> Result { + match self { + JPLEphem::HorizonFile(horizon_data) => Ok(horizon_data), + _ => Err(OutfitError::InvalidJPLEphemFileSource( + "Expected a JPL Horizon source".to_string(), + )), + } + } + + pub fn try_into_naif(self) -> Result { + match self { + JPLEphem::NaifFile(naif_data) => Ok(naif_data), + _ => Err(OutfitError::InvalidJPLEphemFileSource( + "Expected a NAIF source".to_string(), + )), + } + } } diff --git a/src/jpl_ephem/naif/naif_data.rs b/src/jpl_ephem/naif/naif_data.rs index 4f2de84..1a7a2b0 100644 --- a/src/jpl_ephem/naif/naif_data.rs +++ b/src/jpl_ephem/naif/naif_data.rs @@ -277,23 +277,23 @@ impl NaifData { #[cfg(test)] mod test_naif_file { - #[cfg(feature = "jpl-download")] use super::*; - #[cfg(feature = "jpl-download")] use crate::jpl_ephem::naif::naif_ids::{ planet_bary::PlanetaryBary, solar_system_bary::SolarSystemBary, }; - #[cfg(feature = "jpl-download")] - use crate::unit_test_global::JPL_EPHEM_NAIF; - #[cfg(feature = "jpl-download")] + use crate::test_fixture::JPL_EPHEM_NAIF; use hifitime::Epoch; + fn get_naif_data() -> NaifData { + JPL_EPHEM_NAIF.clone().try_into_naif().unwrap() + } + #[test] - #[cfg(feature = "jpl-download")] fn test_jpl_reader_from_naif() { + let naif_data = get_naif_data(); assert_eq!( - JPL_EPHEM_NAIF.daf_header, + naif_data.daf_header, DAFHeader { idword: "DAF/SPK".to_string(), internal_filename: "NIO2SPK".to_string(), @@ -308,7 +308,7 @@ mod test_naif_file { ); assert_eq!( - JPL_EPHEM_NAIF.header, + naif_data.header, JPLEphemHeader { version: "DE440".to_string(), creation_date: "25 June 2020".to_string(), @@ -319,7 +319,7 @@ mod test_naif_file { } ); - let record_earth_sun = JPL_EPHEM_NAIF + let record_earth_sun = naif_data .get_records( NaifIds::PB(PlanetaryBary::EarthMoon), NaifIds::SSB(SolarSystemBary::SSB), @@ -412,12 +412,12 @@ mod test_naif_file { } #[test] - #[cfg(feature = "jpl-download")] fn test_get_record() { let date_str = "2024-04-10T12:30:45"; let epoch = Epoch::from_gregorian_str(date_str).unwrap(); - let record = JPL_EPHEM_NAIF + let naif_data = get_naif_data(); + let record = naif_data .get_record( NaifIds::PB(PlanetaryBary::EarthMoon), NaifIds::SSB(SolarSystemBary::SSB), @@ -480,11 +480,10 @@ mod test_naif_file { } #[test] - #[cfg(feature = "jpl-download")] fn test_jpl_ephemeris() { let epoch1 = Epoch::from_mjd_in_time_scale(57028.479297592596, hifitime::TimeScale::TT); - let interp = JPL_EPHEM_NAIF.ephemeris( + let interp = get_naif_data().ephemeris( NaifIds::PB(PlanetaryBary::EarthMoon), NaifIds::SSB(SolarSystemBary::SSB), epoch1.to_et_seconds(), @@ -512,7 +511,7 @@ mod test_naif_file { ); let epoch2 = Epoch::from_mjd_in_time_scale(57_049.231_857_592_59, hifitime::TimeScale::TT); - let interp = JPL_EPHEM_NAIF.ephemeris( + let interp = get_naif_data().ephemeris( NaifIds::PB(PlanetaryBary::EarthMoon), NaifIds::SSB(SolarSystemBary::SSB), epoch2.to_et_seconds(), diff --git a/src/lib.rs b/src/lib.rs index 3e3608c..e3205f8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -258,12 +258,6 @@ pub mod conversion; /// Earth orientation parameters and related corrections (nutation, precession). pub mod earth_orientation; -/// Environment state: ephemerides, dynamical models, configuration. -pub mod env_state; - -/// Error models used for weighting astrometric residuals. -pub mod error_models; - /// Initial Orbit Determination algorithms (Gauss method). pub mod initial_orbit_determination; @@ -276,21 +270,9 @@ pub mod kepler; /// Orbital types and conversions between them. pub mod orbit_type; -/// Observation handling (RA, DEC, times). -pub mod observations; - -/// Trajectory management and file I/O. -pub mod trajectories; - -/// Observers and observatory positions. -pub mod observers; - /// Orbital elements utilities (conversion, normalization). pub mod orb_elem; -/// Main Outfit struct: central orchestrator for orbit determination. -pub mod outfit; - /// Errors returned by Outfit operations. pub mod outfit_errors; @@ -300,40 +282,28 @@ pub mod ref_system; /// Time management and conversions (UTC, TDB, TT). pub mod time; +pub mod cache; +pub mod obs_dataset; +pub mod observation_ephemeris; +pub mod observer_extension; +pub mod trajectory; + // === Public API FACADE ===================================================== // Re-export carefully curated symbols for a simple, stable top-level API. // Users can import from `outfit::...` without diving into deep module paths. -// Core orchestrator -pub use crate::outfit::Outfit; - -// Core data types & units -pub use crate::constants::Observations; -pub use crate::constants::{ArcSec, Degree, ObjectNumber, MJD}; -pub use crate::observers::Observer; -pub use crate::trajectories::TrajectorySet; - // Orbital element representations pub use crate::orbit_type::{ cometary_element::CometaryElements, equinoctial_element::EquinoctialElements, keplerian_element::KeplerianElements, OrbitalElements, }; -// Error handling and models -pub use crate::error_models::ErrorModel; pub use crate::outfit_errors::OutfitError; // IOD (Gauss) key types pub use crate::initial_orbit_determination::gauss_result::GaussResult; pub use crate::initial_orbit_determination::IODParams; -// Frequently-used extension traits (ergonomic entry points) and key types -pub use crate::observations::display::ObservationsDisplayExt; -pub use crate::observations::observations_ext::ObservationIOD; -pub use crate::trajectories::trajectory_file::TrajectoryFile; -pub use crate::trajectories::trajectory_fit::FullOrbitResult; -pub use crate::trajectories::trajectory_fit::TrajectoryFit; - // Selected constants that are widely useful pub use crate::constants::{ AU, GAUSS_GRAV, RADEG, RADH, RADSEC, SECONDS_PER_DAY, T2000, VLIGHT_AU, @@ -352,55 +322,45 @@ pub type Result = core::result::Result; /// use outfit::prelude::*; /// ``` pub mod prelude { - pub use crate::{ - ArcSec, Degree, ErrorModel, FullOrbitResult, GaussResult, IODParams, JPLEphem, - ObjectNumber, ObservationIOD, Observer, Outfit, OutfitError, TrajectoryFile, TrajectorySet, - MJD, - }; + pub use crate::{GaussResult, IODParams, JPLEphem, OutfitError}; // Optionally include widely-used constants: pub use crate::{AU, GAUSS_GRAV, RADEG, RADH, RADSEC, SECONDS_PER_DAY, T2000, VLIGHT_AU}; } // === Tests support ========================================================== -#[cfg(all(test, feature = "jpl-download"))] -pub(crate) mod unit_test_global { +#[cfg(test)] +pub(crate) mod test_fixture { use std::sync::LazyLock; - use camino::Utf8Path; + use hifitime::ut1::Ut1Provider; + use photom::observation_dataset::ObsDataset; - use crate::{ - error_models::ErrorModel, - jpl_ephem::{horizon::horizon_data::HorizonData, naif::naif_data::NaifData}, - outfit::Outfit, - trajectories::trajectory_file::TrajectoryFile, - trajectories::TrajectorySet, - }; + use crate::{jpl_ephem::download_jpl_file::EphemFileSource, JPLEphem}; - pub(crate) static OUTFIT_NAIF_TEST: LazyLock = - LazyLock::new(|| Outfit::new("naif:DE440", ErrorModel::FCCT14).unwrap()); - - pub(crate) static OUTFIT_HORIZON_TEST: LazyLock<(Outfit, TrajectorySet)> = - LazyLock::new(|| { - let mut env = Outfit::new("horizon:DE440", ErrorModel::FCCT14).unwrap(); + pub(crate) static UT1_PROVIDER: LazyLock = LazyLock::new(|| { + Ut1Provider::download_from_jpl("latest_eop2.long") + .expect("Download of the JPL short time scale UT1 data failed") + }); - let path_file = Utf8Path::new("tests/data/2015AB.obs"); - let traj_set = TrajectorySet::new_from_80col(&mut env, path_file); - (env, traj_set) - }); + pub(crate) static JPL_EPHEM_HORIZON: LazyLock = LazyLock::new(|| { + let jpl_file: EphemFileSource = "horizon:DE440" + .try_into() + .expect("Failed to parse JPL ephemeris source"); + let jpl_ephem = + JPLEphem::new(&jpl_file).expect("Failed to load JPL ephemeris from Horizon"); + jpl_ephem + }); - pub(crate) static JPL_EPHEM_HORIZON: LazyLock<&HorizonData> = LazyLock::new(|| { - let jpl_ephem = OUTFIT_HORIZON_TEST.0.get_jpl_ephem().unwrap(); - match jpl_ephem { - crate::jpl_ephem::JPLEphem::HorizonFile(horizon_data) => horizon_data, - _ => panic!("JPL ephemeris is not a Horizon file"), - } + pub(crate) static JPL_EPHEM_NAIF: LazyLock = LazyLock::new(|| { + let jpl_file: EphemFileSource = "naif:DE440" + .try_into() + .expect("Failed to parse JPL ephemeris source"); + let jpl_ephem = JPLEphem::new(&jpl_file).expect("Failed to load JPL ephemeris from Naif"); + jpl_ephem }); - pub(crate) static JPL_EPHEM_NAIF: LazyLock<&NaifData> = LazyLock::new(|| { - let jpl_ephem = OUTFIT_NAIF_TEST.get_jpl_ephem().unwrap(); - match jpl_ephem { - crate::jpl_ephem::JPLEphem::NaifFile(naif_data) => naif_data, - _ => panic!("JPL ephemeris is not a Naif file"), - } + pub(crate) static DATASET_2015AB: LazyLock = LazyLock::new(|| { + ObsDataset::from_mpc_80_col("tests/data/2015AB.obs") + .expect("Failed to load test dataset 2015AB") }); } diff --git a/src/obs_dataset.rs b/src/obs_dataset.rs new file mode 100644 index 0000000..8892dca --- /dev/null +++ b/src/obs_dataset.rs @@ -0,0 +1,137 @@ +use ahash::AHashMap; +use hifitime::ut1::Ut1Provider; +use photom::{ + observation_dataset::{observation::Observation, ObsDataset}, + observer::error_model::{ModelCorrection, ObsErrorModel}, + TrajId, +}; +use rand::{rngs::SmallRng, SeedableRng}; + +use crate::{ + cache::OutfitCache, trajectory::TrajectoryFit, GaussResult, IODParams, JPLEphem, OutfitError, +}; + +/// Type alias for the RMS of normalized residuals from an IOD fit. +/// This is a single scalar value representing the overall fit quality of the IOD solution. +pub type IODRMS = f64; + +/// Full batch orbit determination results. +/// +/// Each entry maps an [`TrajId`] to the outcome of a full +/// Initial Orbit Determination (IOD) attempt on its set of observations. +/// +/// Internally, this is implemented as: +/// +/// ```ignore +/// HashMap, RandomState> +/// ``` +/// +/// Return semantics +/// ----------------- +/// * `Ok((GaussResult, IODRMS))` – a successful IOD with its RMS of normalized residuals. +/// * `Err(OutfitError)` – a failure isolated to that object. +pub type FullOrbitResult = AHashMap>; + +pub trait FitIOD { + fn fit_full_iod( + self, + jpl: &JPLEphem, + ut1_provider: &Ut1Provider, + params: &IODParams, + error_model: ObsErrorModel, + rng: &mut impl rand::Rng, + ) -> Result; + + fn fit_iod( + self, + traj: impl Into, + jpl: &JPLEphem, + ut1_provider: &Ut1Provider, + params: &IODParams, + error_model: ObsErrorModel, + rng: &mut impl rand::Rng, + ) -> Result<(GaussResult, IODRMS), OutfitError>; +} + +impl FitIOD for ObsDataset { + fn fit_iod( + self, + traj: impl Into, + jpl: &JPLEphem, + ut1_provider: &Ut1Provider, + params: &IODParams, + error_model: ObsErrorModel, + rng: &mut impl rand::Rng, + ) -> Result<(GaussResult, IODRMS), OutfitError> { + let corrected_dataset = self + .with_error_model(error_model) + .apply_batch_rms_correction(params.gap_max); + + let cache = OutfitCache::build(&corrected_dataset, jpl, ut1_provider)?; + + fit_single_traj(&traj.into(), &corrected_dataset, &cache, jpl, params, rng) + } + + fn fit_full_iod( + self, + jpl: &JPLEphem, + ut1_provider: &Ut1Provider, + params: &IODParams, + error_model: ObsErrorModel, + rng: &mut impl rand::Rng, + ) -> Result { + let corrected_dataset = self + .with_error_model(error_model) + .apply_model_errors() + .apply_batch_rms_correction(params.gap_max); + + let cache = OutfitCache::build(&corrected_dataset, jpl, ut1_provider)?; + + // Draw a single base seed from the caller's RNG. + // All per-trajectory RNGs are derived from this seed → deterministic + // regardless of trajectory ordering or parallelism. + let base_seed: u64 = rng.random(); + + let results: FullOrbitResult = corrected_dataset + .iter_traj_id() + .into_iter() + .flatten() + .map(|traj_id| { + // Derive a per-trajectory seed from the base seed and the trajectory ID. + // Two trajectories with different IDs always get independent sequences. + let traj_seed = base_seed ^ traj_id.stable_hash(); + let mut local_rng = SmallRng::seed_from_u64(traj_seed); + + let result = fit_single_traj( + &traj_id, + &corrected_dataset, + &cache, + jpl, + params, + &mut local_rng, + ); + (traj_id.clone(), result) + }) + .collect(); + + Ok(results) + } +} + +fn fit_single_traj( + traj: &TrajId, + corrected_dataset: &ObsDataset, + cache: &OutfitCache, + jpl: &JPLEphem, + params: &IODParams, + rng: &mut impl rand::Rng, +) -> Result<(GaussResult, IODRMS), OutfitError> { + let materialized_traj = corrected_dataset + .materialize_trajectory(traj) + .ok_or_else(|| OutfitError::TrajectoryIdNotFound(traj.clone()))?; + + let mut obs_vec_refs: Vec<&Observation> = materialized_traj.collect_into_vec(); + obs_vec_refs.sort_by(|a, b| a.mjd_tt().total_cmp(&b.mjd_tt())); + + obs_vec_refs.estimate_best_orbit(cache, jpl, params, rng) +} diff --git a/src/observation_ephemeris.rs b/src/observation_ephemeris.rs new file mode 100644 index 0000000..aa93df8 --- /dev/null +++ b/src/observation_ephemeris.rs @@ -0,0 +1,970 @@ +use std::f64::consts::PI; + +use hifitime::Epoch; +use nalgebra::Vector3; +use photom::{ + constants::DPI, coordinates::equatorial::EquCoord, + observation_dataset::observation::Observation, +}; + +use crate::{ + cache::OutfitCache, constants::ROT_EQUMJ2000_TO_ECLMJ2000, conversion::cartesion_from_vec, + EquinoctialElements, JPLEphem, OutfitError, VLIGHT_AU, +}; + +pub trait ObservationEphemeris { + /// Compute the apparent equatorial coordinates (RA, DEC) of a solar system body + /// as seen by this observation’s site at its epoch. + /// + /// Overview + /// ----------------- + /// This method determines the apparent sky position of a target body, + /// described by equinoctial orbital elements, as seen from the observing site + /// corresponding to this [`Observation`]. + /// + /// The computation steps are: + /// 1. **Orbit propagation** – Propagate the body’s state from its reference epoch to the observation epoch using a two-body model. + /// 2. **Reference frame handling** – Retrieve Earth’s barycentric position from the JPL ephemeris and transform to *ecliptic mean J2000*. + /// 3. **Observer position** – Compute the observer’s heliocentric position (Earth + site geocentric offset). + /// 4. **Light-time and aberration correction** – Form the observer–object vector and correct for aberration. + /// 5. **Conversion to equatorial coordinates** – Convert the corrected line-of-sight vector to (RA, DEC). + /// + /// Arguments + /// ----------------- + /// * `state` – Global environment providing ephemerides, UT1 provider, and frame utilities. + /// * `equinoctial_element` – Orbital elements of the target body. + /// + /// Return + /// ---------- + /// * `Result<(f64, f64), OutfitError>` – The apparent right ascension and declination `[rad]`. + /// + /// Units + /// ---------- + /// * Positions: AU + /// * Velocities: AU/day + /// * Angles: radians + /// * Time: MJD TT + /// + /// Errors + /// ---------- + /// Returns [`OutfitError`] if: + /// - Orbit propagation fails, + /// - Ephemeris data is unavailable, + /// - Reference-frame transformation fails. + /// + /// See also + /// ------------ + /// * [`EquinoctialElements::solve_two_body_problem`] – Orbit propagation. + /// * [`Observer::pvobs`] – Computes observer’s geocentric position. + /// * [`correct_aberration`] – Aberration correction. + /// * [`cartesian_to_radec`] – Convert Cartesian vectors to (RA, DEC). + fn compute_apparent_position( + &self, + cache: &OutfitCache, + jpl: &JPLEphem, + equinoctial_element: &EquinoctialElements, + ) -> Result<(f64, f64), OutfitError>; + + /// Compute the normalized squared astrometric residuals (RA, DEC) + /// between an observed position and a propagated ephemeris. + /// + /// Overview + /// ----------------- + /// This method compares the actual astrometric measurement stored in `self` + /// against the expected position of the target body propagated from + /// equinoctial elements. + /// It returns a scalar representing the sum of squared, normalized residuals + /// in RA and DEC. + /// + /// Arguments + /// ----------------- + /// * `state` – Global environment providing ephemerides and time conversions. + /// * `equinoctial_element` – Orbital elements of the target body. + /// + /// Return + /// ---------- + /// * `Result` – Dimensionless scalar value representing the weighted sum + /// of squared residuals. Equivalent to a chi² contribution for a single observation (without division by 2). + /// + /// Remarks + /// ---------- + /// * Residuals are normalized by the astrometric uncertainties `error_ra` and `error_dec`. + /// * RA residuals are multiplied by `cos(dec)` to account for projection effects. + /// * All angles are in radians. + /// + /// Errors + /// ---------- + /// Returns [`OutfitError`] if propagation or ephemeris lookup fails. + /// + /// See also + /// ------------ + /// * [`compute_apparent_position`](crate::observations::Observation::compute_apparent_position) – Used internally to obtain predicted RA/DEC. + /// * [`Observer::pvobs`] – Computes observer’s geocentric position. + /// * [`correct_aberration`] – Applies aberration correction. + /// * [`cartesian_to_radec`] – Converts 3D vectors to (RA, DEC). + /// * [`EquinoctialElements::solve_two_body_problem`] – Two-body propagation. + fn ephemeris_error( + &self, + cache: &OutfitCache, + jpl: &JPLEphem, + equinoctial_element: &EquinoctialElements, + ) -> Result; +} + +impl ObservationEphemeris for Observation { + fn compute_apparent_position( + &self, + cache: &OutfitCache, + jpl: &JPLEphem, + equinoctial_element: &EquinoctialElements, + ) -> Result<(f64, f64), OutfitError> { + // Hyperbolic/parabolic orbits (e >= 1) are not yet supported + if equinoctial_element.eccentricity() >= 1.0 { + return Err(OutfitError::InvalidOrbit( + "Eccentricity >= 1 is not yet supported".to_string(), + )); + } + + // 1. Propagate asteroid position/velocity in ecliptic J2000 + let (cart_pos_ast, cart_pos_vel, _) = equinoctial_element.solve_two_body_problem( + 0., + self.mjd_tt() - equinoctial_element.reference_epoch, + false, + )?; + + // 2. Observation time in TT + let obs_mjd = Epoch::from_mjd_in_time_scale(self.mjd_tt(), hifitime::TimeScale::TT); + + // 3. Earth's barycentric position in ecliptic J2000 + let (earth_position, _) = jpl.earth_ephemeris(&obs_mjd, false); + + let earth_pos_eclj2000 = ROT_EQUMJ2000_TO_ECLMJ2000.transpose() * earth_position; + let cart_pos_ast_eclj2000 = ROT_EQUMJ2000_TO_ECLMJ2000 * cart_pos_ast; + let cart_pos_vel_eclj2000 = ROT_EQUMJ2000_TO_ECLMJ2000 * cart_pos_vel; + + // 4. Observer heliocentric position + let geo_obs_pos = cache + .get_observer_geocentric_position(self.index()) + .map(|x| x.into_inner()); + let xobs = geo_obs_pos + earth_pos_eclj2000; + let obs_on_earth = ROT_EQUMJ2000_TO_ECLMJ2000 * xobs; + + // 5. Relative position and aberration correction + let relative_position = cart_pos_ast_eclj2000 - obs_on_earth; + let corrected_pos = correct_aberration(relative_position, cart_pos_vel_eclj2000); + let cartesion_pos = cartesion_from_vec(corrected_pos); + + // 6. Convert to equatorial coordinates + let equatorial_pos: EquCoord = cartesion_pos.into(); + + Ok((equatorial_pos.ra, equatorial_pos.dec)) + } + + fn ephemeris_error( + &self, + cache: &OutfitCache, + jpl: &JPLEphem, + equinoctial_element: &EquinoctialElements, + ) -> Result { + let (alpha, delta) = self.compute_apparent_position(cache, jpl, equinoctial_element)?; + + let (self_ra, self_ra_err, self_dec, self_dec_err) = ( + self.equ_coord().ra, + self.equ_coord().ra_error, + self.equ_coord().dec, + self.equ_coord().dec_error, + ); + + // ΔRA with wrapping to [-π, π] + let mut diff_alpha = (self_ra - alpha) % DPI; + if diff_alpha > PI { + diff_alpha -= DPI; + } + + let diff_delta = self_dec - delta; + + // Weighted RMS + let rms_ra = (self_dec.cos() * (diff_alpha / self_ra_err)).powi(2); + let rms_dec = (diff_delta / self_dec_err).powi(2); + + Ok(rms_ra + rms_dec) + } +} + +/// Apply stellar aberration correction to a relative position vector. +/// +/// This function computes the apparent position of a target object by applying +/// the first-order correction for stellar aberration due to the observer's velocity. +/// It assumes the classical limit (v ≪ c), using a linear time-delay model. +/// +/// Arguments +/// --------- +/// * `xrel`: relative position vector from observer to object \[AU\]. +/// * `vrel`: velocity of the observer relative to the barycenter \[AU/day\]. +/// +/// Returns +/// -------- +/// * Corrected position vector (same units and directionality as `xrel`), +/// shifted by the aberration effect. +/// +/// Formula +/// ------- +/// The corrected position is given by: +/// ```text +/// x_corr = xrel − (‖xrel‖ / c) · vrel +/// ``` +/// where `c` is the speed of light in AU/day (`VLIGHT_AU`). +/// +/// Remarks +/// ------- +/// * This function does **not** normalize the output. +/// * Suitable for use in astrometric modeling or when computing apparent direction +/// of celestial objects as seen from a moving observer. +pub fn correct_aberration(xrel: Vector3, vrel: Vector3) -> Vector3 { + let norm_vector = xrel.norm(); + let dt = norm_vector / VLIGHT_AU; + xrel - dt * vrel +} + +#[cfg(test)] +mod test_observations_ephemeris { + use super::*; + + mod tests_compute_apparent_position { + + use crate::test_fixture::{JPL_EPHEM_HORIZON, UT1_PROVIDER}; + + use super::*; + use approx::assert_relative_eq; + use photom::{ + observation_dataset::{observation::ObservationInput, ObsDataset}, + observer::error_model::{ModelCorrection, ObsErrorModel}, + photometry::{Filter, Photometry}, + MJDTT, + }; + + /// Helper: simple circular equinoctial elements for a 1 AU, zero inclination orbit. + fn simple_circular_elements(epoch: f64) -> EquinoctialElements { + EquinoctialElements { + reference_epoch: epoch, + semi_major_axis: 1.0, + eccentricity_sin_lon: 0.0, + eccentricity_cos_lon: 0.0, + tan_half_incl_sin_node: 0.0, + tan_half_incl_cos_node: 0.0, + mean_longitude: 0.0, + } + } + + fn obsdataset_with_observation_time(t_epoch: MJDTT) -> ObsDataset { + let observation_input = ObservationInput::new( + 0, + EquCoord { + ra: 0.0, + ra_error: 0.0, + dec: 0.0, + dec_error: 0.0, + }, + Photometry { + magnitude: 0.0, + error: 0.0, + filter: Filter::Int(0), + }, + t_epoch, + Some(photom::observer::dataset::ObserverId::MpcCode(*b"F51")), + ); + + ObsDataset::empty() + .push_observation(vec![observation_input]) + .unwrap() + .0 + .with_error_model(ObsErrorModel::FCCT14) + .apply_model_errors() + } + + #[test] + fn test_compute_apparent_position_nominal() { + let t_obs = 59000.0; // MJD + + let obs_dataset = obsdataset_with_observation_time(t_obs); + let cache = + OutfitCache::build(&obs_dataset, &JPL_EPHEM_HORIZON, &UT1_PROVIDER).unwrap(); + + let equinoctial = simple_circular_elements(t_obs); + + let (ra, dec) = obs_dataset + .get_observation(0) + .unwrap() + .compute_apparent_position(&cache, &JPL_EPHEM_HORIZON, &equinoctial) + .expect("Computation should succeed"); + + assert!(ra.is_finite()); + assert!(dec.is_finite()); + assert!((0.0..2.0 * std::f64::consts::PI).contains(&ra)); + assert!((-std::f64::consts::FRAC_PI_2..std::f64::consts::FRAC_PI_2).contains(&dec)); + } + + #[test] + fn test_compute_apparent_position_same_epoch() { + let t_epoch = 60000.0; + + let obs_dataset = obsdataset_with_observation_time(t_epoch); + let cache = + OutfitCache::build(&obs_dataset, &JPL_EPHEM_HORIZON, &UT1_PROVIDER).unwrap(); + + let equinoctial = simple_circular_elements(t_epoch); + + let obs = obs_dataset.get_observation(0).unwrap(); + + let (ra1, dec1) = obs + .compute_apparent_position(&cache, &JPL_EPHEM_HORIZON, &equinoctial) + .unwrap(); + let (ra2, dec2) = obs + .compute_apparent_position(&cache, &JPL_EPHEM_HORIZON, &equinoctial) + .unwrap(); + + // The same input should always produce the same result + assert_relative_eq!(ra1, ra2, epsilon = 1e-14); + assert_relative_eq!(dec1, dec2, epsilon = 1e-14); + } + + #[test] + fn test_apparent_position_for_distant_object() { + let t_obs = 59000.0; + + let obs_dataset = obsdataset_with_observation_time(t_obs); + let cache = + OutfitCache::build(&obs_dataset, &JPL_EPHEM_HORIZON, &UT1_PROVIDER).unwrap(); + + let mut equinoctial = simple_circular_elements(t_obs); + + // Objet far away + equinoctial.semi_major_axis = 100.0; + + let obs = obs_dataset.get_observation(0).unwrap(); + + let (ra, dec) = obs + .compute_apparent_position(&cache, &JPL_EPHEM_HORIZON, &equinoctial) + .expect("Should compute apparent position for distant object"); + + assert!(ra.is_finite()); + assert!(dec.is_finite()); + } + + #[test] + fn test_compute_apparent_position_propagation_failure() { + let obs_dataset = obsdataset_with_observation_time(0.0); + let cache = + OutfitCache::build(&obs_dataset, &JPL_EPHEM_HORIZON, &UT1_PROVIDER).unwrap(); + + // Invalid orbital elements to force failure in solve_two_body_problem + let equinoctial = EquinoctialElements { + reference_epoch: 59000.0, + semi_major_axis: -1.0, // Physically invalid + eccentricity_sin_lon: 0.0, + eccentricity_cos_lon: 0.0, + tan_half_incl_sin_node: 0.0, + tan_half_incl_cos_node: 0.0, + mean_longitude: 0.0, + }; + + let obs = obs_dataset.get_observation(0).unwrap(); + + let result = obs.compute_apparent_position(&cache, &JPL_EPHEM_HORIZON, &equinoctial); + assert!(result.is_err(), "Invalid elements should trigger an error"); + } + + mod proptests_apparent_position { + use super::*; + use crate::test_fixture::UT1_PROVIDER; + use photom::{ + observation_dataset::{observation::ObservationInput, ObsDataset}, + observer::{ + dataset::ObserverId, + error_model::{ModelCorrection, ObsErrorModel}, + Observer, + }, + photometry::{Filter, Photometry}, + }; + use proptest::prelude::*; + + fn arb_equinoctial_elements() -> impl Strategy { + ( + 58000.0..62000.0f64, + 0.5..30.0f64, + -0.5..0.5f64, + -0.5..0.5f64, + -0.5..0.5f64, + -0.5..0.5f64, + 0.0..(2.0 * std::f64::consts::PI), + ) + .prop_map(|(epoch, a, h, k, p, q, lambda)| { + EquinoctialElements { + reference_epoch: epoch, + semi_major_axis: a, + eccentricity_sin_lon: h, + eccentricity_cos_lon: k, + tan_half_incl_sin_node: p, + tan_half_incl_cos_node: q, + mean_longitude: lambda, + } + }) + } + + fn arb_observer() -> impl Strategy { + (-180.0..180.0f64, -90.0..90.0f64, 0.0..5.0f64).prop_map(|(lon, lat, elev)| { + Observer::new(lon.to_radians(), lat.to_radians(), elev, None, None, None) + .unwrap() + }) + } + + fn arb_extreme_equinoctial_elements() -> impl Strategy { + ( + 58000.0..62000.0f64, + 0.1..50.0f64, + -0.99..0.99f64, + -0.99..0.99f64, + -1.0..1.0f64, + -1.0..1.0f64, + 0.0..(2.0 * std::f64::consts::PI), + ) + .prop_map(|(epoch, a, h, k, p, q, lambda)| EquinoctialElements { + reference_epoch: epoch, + semi_major_axis: a, + eccentricity_sin_lon: h, + eccentricity_cos_lon: k, + tan_half_incl_sin_node: p, + tan_half_incl_cos_node: q, + mean_longitude: lambda, + }) + .prop_filter( + "Only bound (elliptical) orbits are supported", + |elem: &EquinoctialElements| { + let e = (elem.eccentricity_sin_lon.powi(2) + + elem.eccentricity_cos_lon.powi(2)) + .sqrt(); + e < 0.99 + }, + ) + } + + fn make_obs_dataset_and_cache( + t_obs: f64, + observer_id: ObserverId, + ) -> (ObsDataset, OutfitCache) { + let observation_input = ObservationInput::new( + 0, + EquCoord { + ra: 0.0, + ra_error: 0.0, + dec: 0.0, + dec_error: 0.0, + }, + Photometry { + magnitude: 0.0, + error: 0.0, + filter: Filter::Int(0), + }, + t_obs, + Some(observer_id), + ); + + let obs_dataset = ObsDataset::empty() + .push_observation(vec![observation_input]) + .unwrap() + .0 + .with_error_model(ObsErrorModel::FCCT14) + .apply_model_errors(); + + let cache = + OutfitCache::build(&obs_dataset, &JPL_EPHEM_HORIZON, &UT1_PROVIDER).unwrap(); + + (obs_dataset, cache) + } + + fn make_obs_dataset_and_cache_with_custom_observer( + t_obs: f64, + observer: Observer, + ) -> (ObsDataset, OutfitCache) { + let (obs_dataset_with_obs, observer_id) = + ObsDataset::empty().push_observer(observer); + + let observation_input = ObservationInput::new( + 0, + EquCoord { + ra: 0.0, + ra_error: 0.0, + dec: 0.0, + dec_error: 0.0, + }, + Photometry { + magnitude: 0.0, + error: 0.0, + filter: Filter::Int(0), + }, + t_obs, + Some(observer_id), + ); + + let obs_dataset = obs_dataset_with_obs + .push_observation(vec![observation_input]) + .unwrap() + .0 + .with_error_model(ObsErrorModel::FCCT14) + .apply_model_errors(); + + let cache = + OutfitCache::build(&obs_dataset, &JPL_EPHEM_HORIZON, &UT1_PROVIDER).unwrap(); + + (obs_dataset, cache) + } + + proptest! { + #[test] + fn proptest_ra_dec_are_finite_and_in_range( + equinoctial in arb_equinoctial_elements(), + obs_time in 58000.0f64..62000.0 + ) { + let (obs_dataset, cache) = make_obs_dataset_and_cache( + obs_time, + photom::observer::dataset::ObserverId::MpcCode(*b"F51"), + ); + + let obs = obs_dataset.get_observation(0).unwrap(); + let result = obs.compute_apparent_position(&cache, &JPL_EPHEM_HORIZON, &equinoctial); + + if let Ok((ra, dec)) = result { + prop_assert!(ra.is_finite()); + prop_assert!(dec.is_finite()); + prop_assert!((0.0..2.0 * std::f64::consts::PI).contains(&ra)); + prop_assert!((-std::f64::consts::FRAC_PI_2..std::f64::consts::FRAC_PI_2).contains(&dec)); + } + } + + #[test] + fn proptest_repeatability( + equinoctial in arb_equinoctial_elements(), + obs_time in 58000.0f64..62000.0 + ) { + let (obs_dataset, cache) = make_obs_dataset_and_cache( + obs_time, + photom::observer::dataset::ObserverId::MpcCode(*b"F51"), + ); + + let obs = obs_dataset.get_observation(0).unwrap(); + + let r1 = obs.compute_apparent_position(&cache, &JPL_EPHEM_HORIZON, &equinoctial); + let r2 = obs.compute_apparent_position(&cache, &JPL_EPHEM_HORIZON, &equinoctial); + + prop_assert_eq!(r1, r2); + } + + #[test] + fn proptest_small_time_change_has_small_effect( + equinoctial in arb_equinoctial_elements(), + obs_time in 58000.0f64..62000.0 + ) { + let (obs_dataset, cache) = make_obs_dataset_and_cache( + obs_time, + photom::observer::dataset::ObserverId::MpcCode(*b"F51"), + ); + let (obs_dataset_eps, cache_eps) = make_obs_dataset_and_cache( + obs_time + 1e-3, + photom::observer::dataset::ObserverId::MpcCode(*b"F51"), + ); + + let obs = obs_dataset.get_observation(0).unwrap(); + let obs_eps = obs_dataset_eps.get_observation(0).unwrap(); + + let r1 = obs.compute_apparent_position(&cache, &JPL_EPHEM_HORIZON, &equinoctial); + let r2 = obs_eps.compute_apparent_position(&cache_eps, &JPL_EPHEM_HORIZON, &equinoctial); + + if let (Ok((ra1, dec1)), Ok((ra2, dec2))) = (r1, r2) { + let dra = (ra1 - ra2).abs(); + let ddec = (dec1 - dec2).abs(); + + prop_assert!(dra < 1.0, "RA jump too large: {}", dra); + prop_assert!(ddec < 1.0, "DEC jump too large: {}", ddec); + } + } + + #[test] + fn proptest_ra_dec_valid_for_extreme_orbits_and_observers( + equinoctial in arb_extreme_equinoctial_elements(), + observer in arb_observer(), + obs_time in 58000.0f64..62000.0 + ) { + let (obs_dataset, cache) = + make_obs_dataset_and_cache_with_custom_observer(obs_time, observer); + + let obs = obs_dataset.get_observation(0).unwrap(); + let result = obs.compute_apparent_position(&cache, &JPL_EPHEM_HORIZON, &equinoctial); + + if let Ok((ra, dec)) = result { + prop_assert!(ra.is_finite()); + prop_assert!(dec.is_finite()); + prop_assert!((0.0..2.0 * std::f64::consts::PI).contains(&ra)); + prop_assert!((-std::f64::consts::FRAC_PI_2..std::f64::consts::FRAC_PI_2).contains(&dec)); + } + } + } + + #[test] + fn test_hyperbolic_orbit_returns_error() { + let t_obs = 59000.0; + let (obs_dataset, cache) = make_obs_dataset_and_cache( + t_obs, + photom::observer::dataset::ObserverId::MpcCode(*b"F51"), + ); + + let equinoctial = EquinoctialElements { + reference_epoch: t_obs, + semi_major_axis: 1.0, + eccentricity_sin_lon: 0.8, + eccentricity_cos_lon: 0.8, // e ≈ 1.13 > 1 + tan_half_incl_sin_node: 0.0, + tan_half_incl_cos_node: 0.0, + mean_longitude: 0.0, + }; + + let obs = obs_dataset.get_observation(0).unwrap(); + let result = + obs.compute_apparent_position(&cache, &JPL_EPHEM_HORIZON, &equinoctial); + + assert!( + result.is_err(), + "Hyperbolic or parabolic orbits should currently return an error" + ); + } + } + } + + mod tests_ephemeris_error { + use super::*; + use crate::test_fixture::{JPL_EPHEM_HORIZON, UT1_PROVIDER}; + use approx::assert_relative_eq; + use photom::{ + observation_dataset::{observation::ObservationInput, ObsDataset}, + observer::{ + error_model::{ModelCorrection, ObsErrorModel}, + mpc::MpcCode, + Observer, + }, + photometry::{Filter, Photometry}, + }; + + fn simple_equinoctial(epoch: f64) -> EquinoctialElements { + EquinoctialElements { + reference_epoch: epoch, + semi_major_axis: 1.0, + eccentricity_sin_lon: 0.0, + eccentricity_cos_lon: 0.0, + tan_half_incl_sin_node: 0.0, + tan_half_incl_cos_node: 0.0, + mean_longitude: 0.0, + } + } + + fn make_obs_dataset_and_cache_mpc( + ra: f64, + ra_error: f64, + dec: f64, + dec_error: f64, + t_obs: f64, + mpc_code: MpcCode, + apply_model_errors: bool, + ) -> (ObsDataset, OutfitCache) { + let observation_input = ObservationInput::new( + 0, + EquCoord { + ra, + ra_error, + dec, + dec_error, + }, + Photometry { + magnitude: 0.0, + error: 0.0, + filter: Filter::Int(0), + }, + t_obs, + Some(photom::observer::dataset::ObserverId::MpcCode(mpc_code)), + ); + + let obs_dataset = ObsDataset::empty() + .push_observation(vec![observation_input]) + .unwrap() + .0 + .with_error_model(ObsErrorModel::FCCT14); + + let obs_dataset = if apply_model_errors { + obs_dataset.apply_model_errors() + } else { + obs_dataset + }; + + let cache = + OutfitCache::build(&obs_dataset, &JPL_EPHEM_HORIZON, &UT1_PROVIDER).unwrap(); + (obs_dataset, cache) + } + + fn make_obs_dataset_and_cache_custom( + ra: f64, + ra_error: f64, + dec: f64, + dec_error: f64, + t_obs: f64, + observer: Observer, + ) -> (ObsDataset, OutfitCache) { + let (dataset_with_obs, observer_id) = ObsDataset::empty().push_observer(observer); + + let observation_input = ObservationInput::new( + 0, + EquCoord { + ra, + ra_error, + dec, + dec_error, + }, + Photometry { + magnitude: 0.0, + error: 0.0, + filter: Filter::Int(0), + }, + t_obs, + Some(observer_id), + ); + + let obs_dataset = dataset_with_obs + .push_observation(vec![observation_input]) + .unwrap() + .0 + .with_error_model(ObsErrorModel::FCCT14) + .apply_model_errors(); + + let cache = + OutfitCache::build(&obs_dataset, &JPL_EPHEM_HORIZON, &UT1_PROVIDER).unwrap(); + (obs_dataset, cache) + } + + #[test] + fn test_ephem_error() { + let (obs_dataset, cache) = make_obs_dataset_and_cache_mpc( + 1.7899347771316527, + 1.770_024_520_608_546E-6, + 0.778_996_538_107_973_6, + 1.259_582_891_829_317_7E-6, + 57070.262067592594, + *b"F51", + false, + ); + + let equinoctial_element = EquinoctialElements { + reference_epoch: 57_049.242_334_573_75, + semi_major_axis: 1.8017360713154256, + eccentricity_sin_lon: 0.269_373_680_909_227_2, + eccentricity_cos_lon: 8.856_415_260_013_56E-2, + tan_half_incl_sin_node: 8.089_970_166_396_302E-4, + tan_half_incl_cos_node: 0.10168201109730375, + mean_longitude: 1.6936970079414786, + }; + + let obs = obs_dataset.get_observation(0).unwrap(); + let rms_error = obs.ephemeris_error(&cache, &JPL_EPHEM_HORIZON, &equinoctial_element); + assert_eq!(rms_error.unwrap(), 75.00445641224026); + } + + #[test] + fn test_zero_error_when_positions_match() { + let t_obs = 59000.0; + let equinoctial = simple_equinoctial(t_obs); + + let (obs_dataset, cache) = + make_obs_dataset_and_cache_mpc(0.0, 1e-6, 0.0, 1e-6, t_obs, *b"F51", true); + + let obs = obs_dataset.get_observation(0).unwrap(); + let (alpha, delta) = obs + .compute_apparent_position(&cache, &JPL_EPHEM_HORIZON, &equinoctial) + .unwrap(); + + let (obs_dataset_match, cache_match) = + make_obs_dataset_and_cache_mpc(alpha, 1e-6, delta, 1e-6, t_obs, *b"F51", true); + + let obs_match = obs_dataset_match.get_observation(0).unwrap(); + let error = obs_match + .ephemeris_error(&cache_match, &JPL_EPHEM_HORIZON, &equinoctial) + .unwrap(); + + assert_relative_eq!(error, 0.0, epsilon = 1e-14); + } + + #[test] + fn test_error_increases_with_offset() { + let t_obs = 59000.0; + let equinoctial = simple_equinoctial(t_obs); + + let (obs_dataset, cache) = + make_obs_dataset_and_cache_mpc(0.0, 1e-3, 0.0, 1e-3, t_obs, *b"F51", true); + + let obs = obs_dataset.get_observation(0).unwrap(); + let (alpha, delta) = obs + .compute_apparent_position(&cache, &JPL_EPHEM_HORIZON, &equinoctial) + .unwrap(); + + let (obs_dataset_offset, cache_offset) = make_obs_dataset_and_cache_mpc( + alpha + 1e-3, + 1e-3, + delta, + 1e-3, + t_obs, + *b"F51", + true, + ); + + let obs_offset = obs_dataset_offset.get_observation(0).unwrap(); + let err = obs_offset + .ephemeris_error(&cache_offset, &JPL_EPHEM_HORIZON, &equinoctial) + .unwrap(); + + assert!(err > 0.0); + } + + #[test] + fn test_ra_wrapping_invariance() { + let t_obs = 59000.0; + let equinoctial = simple_equinoctial(t_obs); + + let (obs_dataset, cache) = + make_obs_dataset_and_cache_mpc(0.0, 1e-6, 0.0, 1e-6, t_obs, *b"F51", true); + + let obs = obs_dataset.get_observation(0).unwrap(); + let (alpha, delta) = obs + .compute_apparent_position(&cache, &JPL_EPHEM_HORIZON, &equinoctial) + .unwrap(); + + let (obs_dataset_wrapped, cache_wrapped) = make_obs_dataset_and_cache_mpc( + alpha + std::f64::consts::TAU, + 1e-6, + delta, + 1e-6, + t_obs, + *b"F51", + true, + ); + + let obs_wrapped = obs_dataset_wrapped.get_observation(0).unwrap(); + let err = obs_wrapped + .ephemeris_error(&cache_wrapped, &JPL_EPHEM_HORIZON, &equinoctial) + .unwrap(); + + assert_relative_eq!(err, 0.0, epsilon = 1e-12); + } + + #[test] + fn test_large_uncertainty_downweights_error() { + let t_obs = 59000.0; + let equinoctial = simple_equinoctial(t_obs); + + let (obs_dataset, cache) = + make_obs_dataset_and_cache_mpc(0.0, 1.0, 0.0, 1.0, t_obs, *b"F51", true); + + let obs = obs_dataset.get_observation(0).unwrap(); + let (alpha, delta) = obs + .compute_apparent_position(&cache, &JPL_EPHEM_HORIZON, &equinoctial) + .unwrap(); + + let (obs_dataset_large, cache_large) = make_obs_dataset_and_cache_mpc( + alpha + 0.1, + 10.0, + delta + 0.1, + 10.0, + t_obs, + *b"F51", + true, + ); + + let obs_large = obs_dataset_large.get_observation(0).unwrap(); + let err = obs_large + .ephemeris_error(&cache_large, &JPL_EPHEM_HORIZON, &equinoctial) + .unwrap(); + + assert!( + err < 1.0, + "Large uncertainties should reduce the error contribution" + ); + } + + mod proptests_ephemeris_error { + use super::*; + use proptest::prelude::*; + + fn arb_observer() -> impl Strategy { + (-180.0..180.0f64, -90.0..90.0f64, 0.0..5000.0f64).prop_map(|(lon, lat, elev)| { + Observer::new(lon.to_radians(), lat.to_radians(), elev, None, None, None) + .unwrap() + }) + } + + fn arb_elliptical_equinoctial() -> impl Strategy { + ( + 58000.0..62000.0f64, + 0.5..20.0f64, + -0.8..0.8f64, + -0.8..0.8f64, + -0.8..0.8f64, + -0.8..0.8f64, + 0.0..std::f64::consts::TAU, + ) + .prop_map(|(epoch, a, h, k, p, q, l)| EquinoctialElements { + reference_epoch: epoch, + semi_major_axis: a, + eccentricity_sin_lon: h, + eccentricity_cos_lon: k, + tan_half_incl_sin_node: p, + tan_half_incl_cos_node: q, + mean_longitude: l, + }) + .prop_filter("Bound orbits only", |e: &EquinoctialElements| { + e.eccentricity() < 1.0 + }) + } + + proptest! { + #[test] + fn proptest_error_is_non_negative( + equinoctial in arb_elliptical_equinoctial(), + observer in arb_observer(), + obs_time in 58000.0f64..62000.0 + ) { + let (obs_dataset, cache) = + make_obs_dataset_and_cache_custom(0.0, 1e-3, 0.0, 1e-3, obs_time, observer); + + let obs = obs_dataset.get_observation(0).unwrap(); + let result = obs.ephemeris_error(&cache, &JPL_EPHEM_HORIZON, &equinoctial); + + if let Ok(val) = result { + prop_assert!(val.is_finite()); + prop_assert!(val >= 0.0); + } + } + + #[test] + fn proptest_error_downweights_large_uncertainties( + equinoctial in arb_elliptical_equinoctial(), + observer in arb_observer(), + obs_time in 58000.0f64..62000.0 + ) { + let (obs_dataset, cache) = + make_obs_dataset_and_cache_custom(0.5, 100.0, 0.5, 100.0, obs_time, observer); + + let obs = obs_dataset.get_observation(0).unwrap(); + let result = obs.ephemeris_error(&cache, &JPL_EPHEM_HORIZON, &equinoctial); + + if let Ok(val) = result { + prop_assert!(val < 1.0); + } + } + } + } + } +} diff --git a/src/observations/display.rs b/src/observations/display.rs deleted file mode 100644 index 43a3091..0000000 --- a/src/observations/display.rs +++ /dev/null @@ -1,912 +0,0 @@ -//! # Tabular display for astrometric observations -//! -//! Pretty, zero-copy renderers to print an [`Observations`] collection -//! (a `SmallVec<[Observation; 6]>`) as a **table**. -//! -//! ## Overview -//! -//! The main entry point is the display adaptor [`ObservationsDisplay`]. It **borrows** -//! your observations and renders a formatted table when used with Rust formatting -//! (`{}` or `{:#}`), without cloning or moving data. -//! -//! Three layouts are available: -//! -//! - **Default** (compact, fixed-width): -//! `# | Site | MJD (TT) | RA[hms] ±σ["] | DEC[dms] ±σ["]` -//! - **Wide** (diagnostic, uses `comfy-table`): -//! adds `JD (TT) | RA [rad] | DEC [rad] | |r_geo| AU | |r_hel| AU` -//! - **ISO** (timestamp-centric, uses `comfy-table`): -//! replaces MJD/JD with `ISO (TT)` and `ISO (UTC)` -//! -//! ## Units & Conventions -//! -//! - **Time**: MJD/JD columns are on the **TT** scale. In ISO mode, both **TT** and **UTC** -//! timestamps are shown (UTC includes leap seconds via `hifitime`). -//! - **Angles**: RA/DEC are formatted in sexagesimal (RA in **hours**, DEC in **degrees**). -//! - **Uncertainties**: printed in **arcseconds**, converted from radians with [`RAD2ARC`]. -//! - **Positions**: vector norms (wide mode) are in **AU**, conventional **equatorial mean J2000**. -//! -//! ## Precision & Sorting -//! -//! - `with_seconds_precision(p)` — controls fractional digits for sexagesimal and ISO seconds. -//! - `with_distance_precision(p)` — controls fixed-point digits for AU distances (wide mode). -//! - `sorted()` — prints rows **sorted by epoch** (MJD TT ascending). The first column `#` -//! always shows the **original index** (pre-sort) for traceability. -//! -//! ## Observer names -//! -//! If you pass an [`Outfit`] with [`ObservationsDisplay::with_env`], site labels render -//! as `"Name (#id)"` when available; otherwise the numeric **site id** is shown. -//! -//! ## Performance -//! -//! - The adaptor never clones/moves `Observation`s. It sorts a **vector of indices** when -//! `sorted()` is used, and builds small transient strings per row (sexagesimal & ISO). -//! - **Default** layout writes fixed-width lines directly. -//! **Wide** and **ISO** layouts use [`comfy-table`] to build the table; this is still fast -//! but implies allocating the table representation before printing. -//! -//! ## Quick examples -//! -//! ```rust,ignore -//! use outfit::observations::display::ObservationsDisplayExt; -//! -//! // 1) Compact table (fixed-width), sorted by epoch -//! println!("{}", observations.show().sorted()); -//! -//! // 2) Wide table (adds JD, radians, |r| in AU), with custom precisions -//! println!("{}", observations -//! .table_wide() -//! .with_seconds_precision(4) -//! .with_distance_precision(8) -//! .sorted()); -//! -//! // 3) ISO table (ISO TT + ISO UTC), resolve site names via Outfit -//! println!("{}", observations -//! .table_iso() -//! .with_env(&env) -//! .with_seconds_precision(4) -//! .sorted()); -//! -//! // 4) Owned string (compact, unsorted) -//! let s = observations.show_string(); -//! ``` -//! -//! ## See also -//! -//! - [`Observation`] — single-observation pretty-printer and helpers. -//! - [`crate::conversion::ra_hms_prec`] / [`crate::conversion::dec_sdms_prec`] -//! — sexagesimal decomposition with carry. -//! - [`crate::time::fmt_ss`] — seconds string `"SS.sss"` with 2-digit integer part. -//! - [`crate::time::iso_tt_from_epoch`] / [`crate::time::iso_utc_from_epoch`] -//! — ISO renderers via `hifitime`. -//! -//! [`comfy-table`]: https://crates.io/crates/comfy-table -use std::fmt; - -use hifitime::{Epoch, TimeScale}; - -use crate::constants::{JDTOMJD, RAD2ARC}; -use crate::conversion::{dec_sdms_prec, ra_hms_prec}; -use crate::observations::Observation; -use crate::time::{fmt_ss, iso_tt_from_epoch, iso_utc_from_epoch}; -use crate::{Observations, Outfit}; - -use comfy_table::{presets::UTF8_FULL, Cell, CellAlignment, ContentArrangement, Row, Table}; - -/// Internal layout selector for the table renderer. -/// -/// This enum is crate-internal and selects the columns printed by -/// [`ObservationsDisplay`]. It is not part of the public API. -/// -/// Variants -/// ----------------- -/// * `Default` — Compact columns: MJD(TT), RA±σ, DEC±σ. -/// * `Wide` — Adds JD(TT), RA/DEC in radians, and AU vector norms. -/// * `Iso` — Replaces MJD/JD with `ISO (TT)` and `ISO (UTC)`. -enum TableMode { - Default, // MJD(TT) + RA/DEC ±σ - Wide, // + JD(TT), RA/DEC [rad], |r_geo|, |r_hel| - Iso, // ISO TT + ISO UTC instead of MJD/JD -} - -/// Display adaptor to render an [`Observations`] collection as a **table**. -/// -/// Render modes -/// ----------------- -/// * **Default** (via [`ObservationsDisplayExt::show`]): -/// columns `# | Site | MJD (TT) | RA[hms] ±σ["] | DEC[dms] ±σ["]`. -/// * **Wide** (via [`ObservationsDisplayExt::table_wide`]): -/// adds `JD (TT) | RA [rad] | DEC [rad] | |r_geo| AU | |r_hel| AU`. -/// * **ISO** (via [`ObservationsDisplayExt::table_iso`]): -/// replaces MJD/JD with `ISO (TT)` and `ISO (UTC)` timestamps. -/// -/// Precision -/// ----------------- -/// * `sec_prec` controls the number of fractional digits for sexagesimal seconds -/// **and** ISO seconds. -/// * `dist_prec` controls fixed-point digits for AU distances (wide mode). -/// -/// Sorting -/// ----------------- -/// * Call [`Self::sorted`] to display rows **sorted by MJD (TT)** in ascending order. -/// * The `#` column always shows the **original index** (pre-sort) for traceability. -/// * Ties (identical epochs) keep a stable order by original index. -/// -/// See also -/// ------------ -/// * [`ObservationsDisplayExt`] – Ergonomic builders for each mode. -/// * [`Observation`] – Per-row semantics used to derive the columns. -pub struct ObservationsDisplay<'a> { - /// Borrowed collection to render. No allocation or copying occurs. - obs: &'a Observations, - /// Optional Outfit environment to resolve observer names. - env: Option<&'a Outfit>, - /// Column layout selector (default / wide / iso). - mode: TableMode, - /// Fractional digits for sexagesimal and ISO seconds (default = 3). - sec_prec: usize, - /// Fixed-point digits for AU distances in wide mode (default = 6). - dist_prec: usize, - /// If `true`, rows are printed **sorted by epoch** (MJD TT) ascending. - sorted: bool, -} - -/// Pre-computed, per-row fields shared across all modes. -/// -/// Notes -/// ---------- -/// * This structure is internal to avoid recomputing formatting across modes. -/// * Optional fields are only populated in the relevant mode (e.g., `jd_tt` in **Wide**). -struct RowFields { - i: usize, - site_label: String, - // Base time & angles - mjd_tt: f64, - ra_rad: f64, - dec_rad: f64, - // Rendered sexagesimal with uncertainties - ra_str: String, - dec_str: String, - // Optional extras by mode - jd_tt: Option, - r_geo: Option, - r_hel: Option, - iso_tt: Option, - iso_utc: Option, -} - -impl<'a> ObservationsDisplay<'a> { - /// Build a new table adaptor (default: **compact** columns). - /// - /// Arguments - /// ----------------- - /// * `obs` – Borrowed observation container to render. - /// - /// Return - /// ---------- - /// * An `ObservationsDisplay` configured for the **Default** mode. - /// - /// See also - /// ------------ - /// * [`Self::wide`] – Enable the wide layout. - /// * [`Self::iso`] – Enable the ISO layout. - pub fn new(obs: &'a Observations) -> Self { - Self { - obs, - env: None, - mode: TableMode::Default, - sec_prec: 3, - dist_prec: 6, - sorted: false, - } - } - - /// Switch to **wide** mode (adds JD, radians, vector norms). - /// - /// Adds the following columns: - /// - `JD (TT)` - /// - `RA [rad]`, `DEC [rad]` - /// - `|r_geo| AU`, `|r_hel| AU` - /// - /// Arguments - /// ----------------- - /// * `yes` – If `true`, selects the **Wide** layout; otherwise resets to **Default**. - /// - /// Return - /// ---------- - /// * `Self` (builder style), allowing chained configuration. - /// - /// See also - /// ------------ - /// * [`ObservationsDisplayExt::table_wide`] - pub fn wide(mut self, yes: bool) -> Self { - self.mode = if yes { - TableMode::Wide - } else { - TableMode::Default - }; - self - } - - /// Switch to **ISO** mode (replace MJD/JD with ISO TT and ISO UTC columns). - /// - /// Replaces the time columns with: - /// - `ISO (TT)` — Gregorian breakdown on TT, - /// - `ISO (UTC)` — TT converted to UTC (leap seconds handled by `hifitime`). - /// - /// Return - /// ---------- - /// * `Self` (builder style), allowing chained configuration. - /// - /// Notes - /// ---------- - /// * ISO strings are produced via `hifitime`, using leap-second tables for UTC. - /// * This mode uses [`comfy-table`](https://docs.rs/comfy-table/latest/comfy_table/). - /// - /// See also - /// ------------ - /// * [`ObservationsDisplayExt::table_iso`] - pub fn iso(mut self) -> Self { - self.mode = TableMode::Iso; - self - } - - /// Set seconds precision for **sexagesimal** and **ISO** seconds. - /// - /// Arguments - /// ----------------- - /// * `p` – Number of fractional digits to render (typ. `0..=9`). - /// - /// Return - /// ---------- - /// * `Self` (builder style). - /// - /// Notes - /// ---------- - /// * Affects both RA/DEC seconds and ISO seconds. - pub fn with_seconds_precision(mut self, p: usize) -> Self { - self.sec_prec = p; - self - } - - /// Set decimal precision for **AU** distances (wide mode only). - /// - /// Arguments - /// ----------------- - /// * `p` – Fixed-point fractional digits for the `|r_geo|` and `|r_hel|` columns. - /// - /// Return - /// ---------- - /// * `Self` (builder style). - pub fn with_distance_precision(mut self, p: usize) -> Self { - self.dist_prec = p; - self - } - - /// Enable/disable **time-sorted** display. - /// - /// Arguments - /// ----------------- - /// * `yes` – If `true`, rows are sorted by `Observation::time` (MJD TT) ascending. - /// - /// Return - /// ---------- - /// * `Self` (builder style), allowing chained configuration. - /// - /// Notes - /// ---------- - /// * Sorting uses a **stable** index order (no reordering or cloning of observations). - /// * The `#` column prints the **original index** (pre-sort). - pub fn sorted(mut self) -> Self { - self.sorted = true; - self - } - - /// Attach an [`Outfit`] to resolve **observer names** in the `Site` column. - /// - /// If a name is available, rows show `"Name (#id)"`. Otherwise the numeric - /// site id is displayed. - /// - /// Example - /// ------- - /// ```rust,no_run - /// println!("{}", observations.table_iso().with_env(&env).sorted()); - /// ``` - /// - /// Arguments - /// ----------------- - /// * `env` – The [`Outfit`] environment to use for resolving observer names. - pub fn with_env(mut self, env: &'a Outfit) -> Self { - self.env = Some(env); - self - } - - /// Generate the label for the observer site of a given observation. - /// - /// Arguments - /// ----------------- - /// * `i` – Original index of the observation (pre-sort). - /// - /// Return - /// ----------------- - /// * A string label for the site, either `"Name (#id)"` or `"#id"`. - fn site_label(&self, i: usize) -> String { - if let Some(env) = self.env { - let site_id = self.obs[i].observer; - let site = env.get_observer_from_uint16(site_id); - if let Some(name) = site.name.as_deref() { - if !name.is_empty() { - return format!("{name} (#{site_id})"); - } - } - format!("Site ID #{site_id}") - } else { - // No environment: keep a compact ID for compatibility - format!("{}", self.obs[i].observer) - } - } - - /// Write the table header according to the selected mode. - fn write_header(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self.mode { - TableMode::Default => { - writeln!( - f, - "{:>3} {:>5} {:>14} {:>20} {:>20}", - "#", "Site", "MJD (TT)", "RA ±σ[arcsec]", "DEC ±σ[arcsec]" - ) - } - TableMode::Wide => { - writeln!( - f, - "{:>3} {:>5} {:>14} {:>14} {:>26} {:>11} {:>26} {:>11} {:>12} {:>12}", - "#", - "Site", - "MJD (TT)", - "JD (TT)", - "RA ±σ[arcsec]", - "RA [rad]", - "DEC ±σ[arcsec]", - "DEC [rad]", - "|r_geo| AU", - "|r_hel| AU" - ) - } - TableMode::Iso => { - writeln!( - f, - "{:>3} {:>5} {:>26} {:>26} {:>26} {:>26}", - "#", "Site", "ISO (TT)", "ISO (UTC)", "RA ±σ[arcsec]", "DEC ±σ[arcsec]" - ) - } - } - } - - /// Build an iterator over `(original_index, &Observation)` honoring the `sorted` flag. - /// - /// Return - /// ---------- - /// * A boxed iterator over `(index_before_sort, &Observation)`. - /// - /// Notes - /// ---------- - /// * When `sorted == true`, indices are ordered by MJD(TT) ascending, with a stable - /// tie-break on the original index. - fn row_iter(&self) -> Box + '_> { - if self.sorted { - use std::cmp::Ordering; - let mut order: Vec = (0..self.obs.len()).collect(); - order.sort_by(|&a, &b| { - let ta: f64 = self.obs[a].time; - let tb: f64 = self.obs[b].time; - match ta.partial_cmp(&tb) { - Some(ord) => ord, - None => Ordering::Equal, // NaN-safe - } - .then_with(|| a.cmp(&b)) - }); - Box::new(order.into_iter().map(|i| (i, &self.obs[i]))) - } else { - Box::new(self.obs.iter().enumerate()) - } - } - - /// Compute common formatted values once for a given row. - /// - /// Arguments - /// ----------------- - /// * `i` – Original index of the observation (pre-sort). - /// * `o` – Borrowed [`Observation`] for this row. - /// - /// Return - /// ---------- - /// * A populated [`RowFields`] struct reused by the row writer. - fn format_row_fields(&self, i: usize, o: &Observation) -> RowFields { - let sp = self.sec_prec; - - // Sexagesimal decomposition - let ra_rad: f64 = o.ra; - let dec_rad: f64 = o.dec; - let (hh, mm, ss) = ra_hms_prec(ra_rad, sp); - let (sgn, dd, dm, ds) = dec_sdms_prec(dec_rad, sp); - let ss_s = fmt_ss(ss, sp); - let ds_s = fmt_ss(ds, sp); - - // Uncertainties [arcsec] - let sra_as = o.error_ra * RAD2ARC; - let sdec_as = o.error_dec * RAD2ARC; - - // Common formatted strings - let ra_str = format!("{hh:02}h{mm:02}m{ss_s}s ± {sra_as:.3}\""); - let dec_str = format!("{sgn}{dd:02}°{dm:02}'{ds_s}\" ± {sdec_as:.3}\""); - - // Base time - let mjd_tt: f64 = o.time; - - // Mode-dependent extras (computed lazily below) - let (jd_tt, r_geo, r_hel, iso_tt, iso_utc) = match self.mode { - TableMode::Default => (None, None, None, None, None), - TableMode::Wide => { - let jd = mjd_tt + JDTOMJD; - let g = o.observer_earth_position.norm(); - let h = o.observer_helio_position.norm(); - (Some(jd), Some(g), Some(h), None, None) - } - TableMode::Iso => { - let epoch_tt = Epoch::from_mjd_in_time_scale(mjd_tt, TimeScale::TT); - let tt_str = iso_tt_from_epoch(epoch_tt, sp); - let utc_str = iso_utc_from_epoch(epoch_tt, sp); - (None, None, None, Some(tt_str), Some(utc_str)) - } - }; - - RowFields { - i, - site_label: self.site_label(i), - mjd_tt, - ra_rad, - dec_rad, - ra_str, - dec_str, - jd_tt, - r_geo, - r_hel, - iso_tt, - iso_utc, - } - } - - /// Render the WIDE table using comfy-table. - fn render_wide_comfy(&self) -> String { - let mut table = Table::new(); - table - .load_preset(UTF8_FULL) - .set_content_arrangement(ContentArrangement::Dynamic); - - // Header - table.set_header(vec![ - Cell::new("#"), - Cell::new("Site"), - Cell::new("MJD (TT)"), - Cell::new("JD (TT)"), - Cell::new("RA ±σ[arcsec]"), - Cell::new("RA [rad]"), - Cell::new("DEC ±σ[arcsec]"), - Cell::new("DEC [rad]"), - Cell::new("|r_geo| AU"), - Cell::new("|r_hel| AU"), - ]); - - // Rows - for (i, o) in self.row_iter() { - let r = self.format_row_fields(i, o); - let dp = self.dist_prec; - - table.add_row(Row::from(vec![ - Cell::new(r.i).set_alignment(CellAlignment::Right), - Cell::new(r.site_label).set_alignment(CellAlignment::Right), - Cell::new(format!("{:.6}", r.mjd_tt)).set_alignment(CellAlignment::Right), - Cell::new(format!("{:.6}", r.jd_tt.unwrap_or_default())) - .set_alignment(CellAlignment::Right), - Cell::new(r.ra_str.clone()).set_alignment(CellAlignment::Right), - Cell::new(format!("{:.7}", r.ra_rad)).set_alignment(CellAlignment::Right), - Cell::new(r.dec_str.clone()).set_alignment(CellAlignment::Right), - Cell::new(format!("{:.7}", r.dec_rad)).set_alignment(CellAlignment::Right), - Cell::new(format!("{:.*}", dp, r.r_geo.unwrap_or_default())) - .set_alignment(CellAlignment::Right), - Cell::new(format!("{:.*}", dp, r.r_hel.unwrap_or_default())) - .set_alignment(CellAlignment::Right), - ])); - } - - table.to_string() - } - - /// Render the ISO table using comfy-table. - fn render_iso_comfy(&self) -> String { - let mut table = Table::new(); - table - .load_preset(UTF8_FULL) - .set_content_arrangement(ContentArrangement::Dynamic); - - // Header - table.set_header(vec![ - Cell::new("#"), - Cell::new("Site"), - Cell::new("ISO (TT)"), - Cell::new("ISO (UTC)"), - Cell::new("RA ±σ[arcsec]"), - Cell::new("DEC ±σ[arcsec]"), - ]); - - // Rows - for (i, o) in self.row_iter() { - let r = self.format_row_fields(i, o); - table.add_row(Row::from(vec![ - Cell::new(r.i).set_alignment(CellAlignment::Right), - Cell::new(r.site_label).set_alignment(CellAlignment::Right), - Cell::new(r.iso_tt.as_deref().unwrap_or("")).set_alignment(CellAlignment::Right), - Cell::new(r.iso_utc.as_deref().unwrap_or("")).set_alignment(CellAlignment::Right), - Cell::new(r.ra_str.clone()).set_alignment(CellAlignment::Right), - Cell::new(r.dec_str.clone()).set_alignment(CellAlignment::Right), - ])); - } - - table.to_string() - } - - /// Write a single table row using pre-computed [`RowFields`]. - /// - /// Arguments - /// ----------------- - /// * `f` – Destination formatter. - /// * `r` – Pre-formatted row fields. - fn write_row(&self, f: &mut fmt::Formatter<'_>, r: &RowFields) -> fmt::Result { - match self.mode { - TableMode::Default => { - writeln!( - f, - "{i:>3} {site:>5} {mjd:>14.6} {ra:>20} {dec:>20}", - i = r.i, - site = r.site_label, - mjd = r.mjd_tt, - ra = r.ra_str, - dec = r.dec_str - ) - } - TableMode::Wide => { - let dp = self.dist_prec; - writeln!( - f, - "{i:>3} {site:>5} {mjd:>14.6} {jd:>14.6} {ra:>20} {ra_rad:>11.7} {dec:>20} {dec_rad:>11.7} {rgeo:>12.dp$} {rhel:>12.dp$}", - i = r.i, - site = r.site_label, - mjd = r.mjd_tt, - jd = r.jd_tt.unwrap_or_default(), - ra = r.ra_str, - ra_rad = r.ra_rad, - dec = r.dec_str, - dec_rad = r.dec_rad, - rgeo = r.r_geo.unwrap_or_default(), - rhel = r.r_hel.unwrap_or_default(), - dp = dp - ) - } - TableMode::Iso => { - writeln!( - f, - "{i:>3} {site:>5} {iso_tt:>26} {iso_utc:>26} {ra:>20} {dec:>20}", - i = r.i, - site = r.site_label, - iso_tt = r.iso_tt.as_deref().unwrap_or(""), - iso_utc = r.iso_utc.as_deref().unwrap_or(""), - ra = r.ra_str, - dec = r.dec_str - ) - } - } - } -} - -/// Ergonomic extension to create table adaptors from an [`Observations`] collection. -/// -/// Provided builders -/// ----------------- -/// * [`ObservationsDisplayExt::show`] – Default compact table. -/// * [`ObservationsDisplayExt::table_wide`] – Wide table with diagnostics. -/// * [`ObservationsDisplayExt::table_iso`] – ISO-centric table (TT + UTC). -/// -/// Examples -/// ---------- -/// ```rust,ignore -/// println!("{}", observations.show()); // Default -/// println!("{}", observations.table_wide()); // Wide -/// println!("{}", observations.table_iso()); // ISO -/// println!("{}", observations.show().with_seconds_precision(4)); -/// ``` -pub trait ObservationsDisplayExt { - /// Wide table (adds JD, RA/DEC in radians, vector norms in AU). - /// - /// Return - /// ---------- - /// * A configured [`ObservationsDisplay`] in **Wide** mode. - fn table_wide(&self) -> ObservationsDisplay<'_>; - - /// ISO table (replaces MJD/JD with `ISO (TT)` and `ISO (UTC)`). - /// - /// Return - /// ---------- - /// * A configured [`ObservationsDisplay`] in **ISO** mode. - fn table_iso(&self) -> ObservationsDisplay<'_>; - - /// Create a zero-allocation display adaptor (Default/compact mode). - /// - /// Examples - /// ---------- - /// ```rust,ignore - /// // Compact table - /// println!("{}", observations.show()); - /// - /// // Derive other modes from it (builder style) - /// println!("{}", observations.show().wide(true)); // Wide - /// println!("{}", observations.show().iso()); // ISO - /// ``` - fn show(&self) -> ObservationsDisplay<'_>; - - /// Convenience: return a formatted `String` in **compact** mode. - /// - /// Return - /// ---------- - /// * An owned `String` containing the compact table. - fn show_string(&self) -> String { - format!("{}", self.show()) - } -} - -impl ObservationsDisplayExt for Observations { - fn table_wide(&self) -> ObservationsDisplay<'_> { - ObservationsDisplay::new(self).wide(true) - } - fn table_iso(&self) -> ObservationsDisplay<'_> { - ObservationsDisplay::new(self).iso() - } - fn show(&self) -> ObservationsDisplay<'_> { - ObservationsDisplay::new(self) - } -} - -impl fmt::Display for ObservationsDisplay<'_> { - /// Render the table according to the selected mode. - /// - /// Notes - /// ---------- - /// * Rows are printed in the **current order** unless `sorted(true)` is set. - /// * When sorted, ordering is by MJD(TT) ascending, with ties broken by original index. - /// * Numeric fields are right-aligned; headers have fixed widths for readability. - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let n = self.obs.len(); - writeln!(f, "Observations (n={n})")?; - writeln!(f, "-------------------")?; - - match self.mode { - TableMode::Wide => { - // comfy-table rendering - let out = self.render_wide_comfy(); - f.write_str(&out)?; - } - TableMode::Iso => { - // comfy-table rendering - let out = self.render_iso_comfy(); - f.write_str(&out)?; - } - TableMode::Default => { - // Legacy compact mode: keep your existing fixed-width rendering - self.write_header(f)?; - for (i, o) in self.row_iter() { - let row = self.format_row_fields(i, o); - self.write_row(f, &row)?; - } - } - } - Ok(()) - } -} - -#[cfg(test)] -mod observation_display_tests { - use super::*; - use crate::observations::display::ObservationsDisplayExt; - use crate::Observations; - use nalgebra::Vector3; - - /// Build a minimal Observation for tests. - /// Angles are provided in degrees; uncertainties in arcseconds; time is MJD (TT). - fn make_obs( - site: u16, - ra_deg: f64, - dec_deg: f64, - err_arcsec: f64, - mjd_tt: f64, - rgeo: (f64, f64, f64), - rhel: (f64, f64, f64), - ) -> Observation { - // Convert helpers - let ra_rad = ra_deg.to_radians(); - let dec_rad = dec_deg.to_radians(); - let err_rad = (err_arcsec / 3600.0).to_radians(); - - Observation { - observer: site, - ra: ra_rad, // Radian from f64 - error_ra: err_rad, // Radian from f64 - dec: dec_rad, // Radian from f64 - error_dec: err_rad, // Radian from f64 - time: mjd_tt, // MJD from f64 - observer_earth_position: Vector3::new(rgeo.0, rgeo.1, rgeo.2), - observer_helio_position: Vector3::new(rhel.0, rhel.1, rhel.2), - } - } - - /// Build a small, heterogeneous set of observations for table tests. - fn sample_observations() -> Observations { - let mut obs: Observations = Observations::default(); - // idx = 0 (later than #1), zero vectors to simplify wide-mode distance checks - obs.push(make_obs( - 809, - 0.0, - 0.0, - 1.0, - 60000.123456, - (0.0, 0.0, 0.0), - (0.0, 0.0, 0.0), - )); - // idx = 1 (earliest), negative DEC to verify sign & DMS formatting - obs.push(make_obs( - 2, - 0.0, - -10.0, - 0.5, - 59000.0, - (0.0, 0.0, 0.0), - (0.0, 0.0, 0.0), - )); - // idx = 2 (latest), non-zero vectors to exercise wide-mode distances (1 and 2 AU) - obs.push(make_obs( - 500, - 180.0, - 20.0, - 1.2, - 60001.0, - (1.0, 0.0, 0.0), - (0.0, 2.0, 0.0), - )); - obs - } - - #[test] - fn default_headers_and_basic_format() { - let obs = sample_observations(); - let s = format!("{}", obs.show()); // Default, unsorted - - // Headers present - assert!(s.contains("MJD (TT)")); - assert!(s.contains("RA ±σ[arcsec]")); - assert!(s.contains("DEC ±σ[arcsec]")); - - // RA/DEC sexagesimal + uncertainties for idx 0 (RA=0h, DEC=+0°) - // Cells do not include 'RA=' / 'DEC=' prefixes in the table. - assert!(s.contains("00h00m00.000s ± 1.000\"")); - assert!(s.contains("+00°00'00.000\" ± 1.000\"")); - } - - #[test] - fn dec_negative_sign_is_preserved_in_table() { - let obs = sample_observations(); - let s = format!("{}", obs.show()); // Default, unsorted - // idx 1 has DEC = -10° (no 'DEC=' prefix in table cells) - assert!( - s.contains("-10°00'00.000\""), - "Negative DEC sign or DMS formatting incorrect: {s}" - ); - // And its sigma is 0.500" - assert!(s.contains("± 0.500\"")); - } - - #[test] - fn sorted_orders_by_time_and_keeps_original_index() { - let obs = sample_observations(); - let s = format!("{}", obs.show().sorted()); - - // Split lines: title, underline, header, then data rows... - let mut lines = s.lines(); - let _title = lines.next().unwrap_or_default(); - let _rule = lines.next().unwrap_or_default(); - let _hdr = lines.next().unwrap_or_default(); - - // First data row should correspond to the earliest epoch (idx = 1) - let first_row = lines.next().unwrap_or_default(); - assert!( - first_row.trim_start().starts_with("1"), - "Expected first printed row to be original index 1, got: {first_row}" - ); - // A later row should include index 0 (natural order changed by sort) - let rest = lines.collect::>().join("\n"); - assert!( - rest.contains("\n 0") || rest.starts_with(" 0"), - "Index 0 should also appear." - ); - } - - #[test] - fn wide_mode_headers_and_radians_and_distances() { - let obs = sample_observations(); - let s = format!("{}", obs.table_wide()); // Wide, unsorted - - // Headers present - assert!(s.contains("JD (TT)")); - assert!(s.contains("RA [rad]")); - assert!(s.contains("DEC [rad]")); - assert!(s.contains("|r_geo| AU")); - assert!(s.contains("|r_hel| AU")); - - // idx 1 has DEC = -10° => about -0.1745329 rad (7 decimals printed) - assert!( - s.contains("-0.1745329"), - "Expected DEC in radians around -0.1745329 rad in wide mode: {s}" - ); - - // idx 0 vectors are zeros => distances should include 0.000000 - assert!( - s.contains(" 0.000000"), - "Expected at least one zero distance in wide mode for idx 0: {s}" - ); - - // idx 2 vectors norms are 1 AU and 2 AU - assert!( - s.contains(" 1.000000") && s.contains(" 2.000000"), - "Expected distances 1.000000 and 2.000000 AU for idx 2: {s}" - ); - } - - #[test] - fn iso_mode_headers_and_suffixes() { - let obs = sample_observations(); - let s = format!("{}", obs.table_iso()); // ISO, unsorted - - // Headers present - assert!(s.contains("ISO (TT)")); - assert!(s.contains("ISO (UTC)")); - - // Body should contain ' TT' and 'Z' suffixes (at least once) - assert!(s.contains(" TT"), "TT suffix missing in ISO TT column: {s}"); - assert!(s.contains('Z'), "Z suffix missing in ISO UTC column: {s}"); - } - - #[test] - fn seconds_and_distance_precision_knobs() { - let obs = sample_observations(); - - // Seconds precision = 4: "00.0000" for RA seconds at 0h (no 'RA=' prefix in table cells) - let s_iso = format!("{}", obs.table_iso().with_seconds_precision(4)); - assert!( - s_iso.contains("00h00m00.0000s"), - "Seconds precision not applied: {s_iso}" - ); - - // Distance precision = 4: look for " 0.0000" in wide mode (idx 0 has zero vectors) - let s_wide = format!("{}", obs.table_wide().with_distance_precision(4)); - assert!( - s_wide.contains(" 0.0000"), - "Distance precision not applied (expected 4 decimals): {s_wide}" - ); - } - - #[test] - fn show_string_matches_display_default() { - let obs = sample_observations(); - let s1 = obs.show_string(); - let s2 = format!("{}", obs.show()); - assert_eq!(s1, s2, "show_string() must match Display in default mode"); - } -} diff --git a/src/observations/mod.rs b/src/observations/mod.rs deleted file mode 100644 index 10168d1..0000000 --- a/src/observations/mod.rs +++ /dev/null @@ -1,1498 +0,0 @@ -//! # Observations: ingestion, representation, and sky-projection utilities -//! -//! This module defines the core types and helpers to **ingest**, **store**, and **use** -//! optical astrometric observations for orbit determination workflows. -//! -//! ## What lives here? -//! -//! - [`Observation`](crate::observations::Observation) — a single astrometric measurement (RA/DEC at an epoch) with: -//! - the observing site identifier (`u16`), -//! - precomputed **geocentric** and **heliocentric** site positions at the epoch, -//! - astrometric uncertainties for RA/DEC. -//! -//! - Parsing & I/O: -//! - `from_80col` (private) and `extract_80col` (private) — read **80-column MPC** formatted files. -//! - [`ades_reader`](crate::trajectories::ades_reader) — ADES ingestion utilities (XML/CSV). -//! - `parquet_reader` (private) — internal helpers to read columnar batches. -//! -//! - Batch/transform helpers: -//! - [`trajectory_file`](crate::trajectories::trajectory_file) — build batches of observations (RA/DEC/time + σ) and convert to [`Observation`](crate::observations::Observation)s. -//! - [`observations_ext`](crate::observations::observations_ext) — higher-level operations on collections (triplet selection, RMS windows, metrics). -//! - [`triplets_iod`](crate::observations::triplets_iod) — construction of observation triplets for **Gauss IOD**. -//! -//! ## Units & reference frames -//! -//! - **Angles**: radians -//! - **Time**: MJD (TT scale) -//! - **Positions**: AU, **equatorial mean J2000** (J2000/ICRS-aligned) -//! -//! These conventions are enforced by [`Observation::new`](crate::observations::Observation::new), which computes and stores both -//! the **geocentric** and **heliocentric** site positions at the observation epoch using the -//! [`Outfit`](crate::outfit::Outfit) environment (UT1 provider, JPL ephemerides, site database). -//! -//! ## Typical workflow -//! -//! 1. **Ingest** observations: -//! - From MPC 80-col: \[`extract_80col`\] → `Vec` + object identifier. -//! - From ADES: via [`ades_reader`](crate::trajectories::ades_reader) into typed batches, then \[`observation_from_batch`\]. -//! -//! 2. **Precompute/Access positions** per observation: -//! - `get_observer_earth_position()` — geocentric site vector at epoch. -//! - `get_observer_helio_position()` — heliocentric site vector at epoch. -//! -//! 3. **Project to sky** (prediction / fitting): -//! - [`Observation::compute_apparent_position`](crate::observations::Observation::compute_apparent_position) — propagate an orbit (equinoctial elements), -//! apply frame transforms + aberration, and return apparent `(RA, DEC)`. -//! - [`Observation::ephemeris_error`](crate::observations::Observation::ephemeris_error) — normalized squared residual for a single observation. -//! -//! 4. **Build triplets and run IOD**: -//! - Use [`observations_ext`](crate::observations::observations_ext) / [`triplets_iod`](crate::observations::triplets_iod) to form high-quality triplets and feed -//! them to the Gauss solver (see `initial_orbit_determination::gauss`). -//! -//! ## Key types & functions -//! -//! - [`Observation`](crate::observations::Observation) — single measurement with site & precomputed positions. -//! - [`Observation::compute_apparent_position`](crate::observations::Observation::compute_apparent_position) — apparent `(RA, DEC)` from an orbit. -//! - [`Observation::ephemeris_error`](crate::observations::Observation::ephemeris_error) — per-observation χ²-like contribution. -//! -//! ## Example -//! -//! ```rust,no_run -//! use outfit::observations::Observation; -//! use outfit::outfit::Outfit; -//! use outfit::error_models::ErrorModel; -//! use outfit::constants::RADSEC; -//! -//! # use outfit::orbit_type::equinoctial_element::EquinoctialElements; -//! -//! let mut env = Outfit::new("horizon:DE440", ErrorModel::FCCT14)?; -//! -//! // Example: build one Observation manually -//! let obs = Observation::new( -//! &env, -//! 0, -//! 1.234, // RA [rad] -//! 0.5 * RADSEC, // σ_RA [rad] -//! 0.567, // DEC [rad] -//! 0.5 * RADSEC, // σ_DEC [rad] -//! 60300.0, // MJD (TT) -//! )?; -//! -//! // Predict apparent position for this observation given an orbit -//! # let eq: EquinoctialElements = unimplemented!(); -//! # let (_ra, _dec) = obs.compute_apparent_position(&env, &eq)?; -//! # Ok::<(), outfit::outfit_errors::OutfitError>(()) -//! ``` -//! -//! ## See also -//! -//! - [`initial_orbit_determination::gauss`] — Gauss IOD over observation triplets. -//! - [`observers`] — site database, Earth-fixed coordinates, and transformations. -//! - [`orbit_type::equinoctial_element::EquinoctialElements`] — propagation utilities used here. -//! - [`cartesian_to_radec`](crate::conversion::cartesian_to_radec) and [`correct_aberration`](crate::observations::correct_aberration) — sky-projection helpers. -pub mod display; -pub mod observations_ext; -pub mod triplets_generator; -pub mod triplets_iod; - -use crate::{ - constants::{Observations, Radian, DPI, JDTOMJD, MJD, RAD2ARC, VLIGHT_AU}, - conversion::{cartesian_to_radec, dec_sdms_prec, fmt_vec3_au, ra_hms_prec}, - observers::Observer, - orbit_type::equinoctial_element::EquinoctialElements, - outfit::Outfit, - outfit_errors::OutfitError, - time::{fmt_ss, iso_tt_from_epoch, iso_utc_from_epoch}, -}; -use hifitime::{Epoch, TimeScale}; -use nalgebra::Vector3; -use std::{f64::consts::PI, fmt}; - -/// Astrometric observation with site and precomputed observer positions. -/// -/// This structure represents a single optical astrometric measurement -/// (right ascension/declination at a given epoch) together with: -/// - the associated observing site identifier, -/// - the observer’s **geocentric** position vector at the epoch, and -/// - the observer’s **heliocentric** position vector at the epoch. -/// -/// Units & frames: -/// - Angles are stored in **radians**. -/// - Times are stored as **MJD (TT scale)**. -/// - Position vectors are expressed in **AU**, in the **equatorial mean J2000** frame. -/// -/// Fields -/// ----------------- -/// * `observer` – Site identifier (`u16`) referencing an [`Observer`] known by the [`Outfit`] state. -/// * `ra` – Right ascension `[rad]`. -/// * `error_ra` – Uncertainty on right ascension `[rad]`. -/// * `dec` – Declination `[rad]`. -/// * `error_dec` – Uncertainty on declination `[rad]`. -/// * `time` – Observation epoch as MJD (TT scale). -/// * `observer_earth_position` – Geocentric position of the observer at `time` (AU, equatorial mean J2000). -/// * `observer_helio_position` – Heliocentric position of the observer at `time` (AU, equatorial mean J2000). -#[derive(Debug, Clone, PartialEq, Copy)] -pub struct Observation { - pub(crate) observer: u16, - pub ra: Radian, - pub error_ra: Radian, - pub dec: Radian, - pub error_dec: Radian, - pub time: MJD, - pub(crate) observer_earth_position: Vector3, - pub(crate) observer_helio_position: Vector3, -} - -impl Observation { - /// Create a new astrometric observation and precompute observer positions. - /// - /// This constructor stores the astrometric angles and time, and computes the observer’s - /// **geocentric** and **heliocentric** position vectors at the same epoch using the - /// provided [`Outfit`] environment (UT1 provider, ephemerides, and site metadata). - /// - /// Arguments - /// ----------------- - /// * `state` – Global environment providing ephemerides, UT1 provider and site database. - /// * `observer` – Site identifier (`u16`) referencing an [`Observer`] known by `state`. - /// * `ra` – Right ascension `[rad]`. - /// * `error_ra` – Uncertainty on right ascension `[rad]`. - /// * `dec` – Declination `[rad]`. - /// * `error_dec` – Uncertainty on declination `[rad]`. - /// * `time` – Observation epoch as **MJD (TT scale)**. - /// - /// Return - /// ---------- - /// * A `Result` with the newly created [`Observation`], or an [`OutfitError`] if: - /// - the observer cannot be resolved in `state`, - /// - the UT1 provider / ephemeris computation fails. - /// - /// Remarks - /// ------------ - /// * `pvobs` computes the geocentric position (and velocity) of the observer from Earth rotation and site coordinates. - /// * `helio_position` converts the geocentric position to the heliocentric frame using the selected JPL ephemeris. - /// * Both positions are expressed in **AU**, **equatorial mean J2000**. - /// - /// See also - /// ------------ - /// * [`Observer::pvobs`] – Geocentric position/velocity of the observing site. - /// * [`Observer::helio_position`] – Heliocentric position of the observing site. - /// * [`crate::trajectories::batch_reader::ObservationBatch`] – Batch operations on observations. - pub fn new( - state: &Outfit, - observer: u16, - ra: Radian, - error_ra: Radian, - dec: Radian, - error_dec: Radian, - time: MJD, - ) -> Result { - // Observation time in TT - let obs_mjd = Epoch::from_mjd_in_time_scale(time, hifitime::TimeScale::TT); - let obs = state.get_observer_from_uint16(observer); - let (geo_obs_pos, _) = obs.pvobs(&obs_mjd, state.get_ut1_provider())?; - let helio_obs_pos = obs.helio_position(state, &obs_mjd, &geo_obs_pos)?; - - Ok(Observation { - observer, - ra, - error_ra, - dec, - error_dec, - time, - observer_earth_position: geo_obs_pos, - observer_helio_position: helio_obs_pos, - }) - } - - /// Construct an [`Observation`] from precomputed observer positions. - /// - /// This constructor is a **fast-path alternative** to [`Observation::new`]: - /// it skips all ephemeris calls by directly injecting the observer’s - /// geocentric and heliocentric positions. This is useful in ingestion - /// pipelines (e.g. Parquet readers) where positions can be cached and - /// reused for multiple observations sharing the same `(observer, time)`. - /// - /// Arguments - /// ----------------- - /// * `observer` – Packed observer identifier (`u16`). - /// * `ra`, `error_ra` – Right ascension in radians and its 1-σ uncertainty (radians). - /// * `dec`, `error_dec` – Declination in radians and its 1-σ uncertainty (radians). - /// * `time` – Observation epoch in Modified Julian Date (TT scale). - /// * `observer_earth_position` – Geocentric observer position vector (AU, equatorial mean J2000). - /// * `observer_helio_position` – Heliocentric observer position vector (AU, equatorial mean J2000). - /// - /// Return - /// ---------- - /// * A fully initialized [`Observation`] where astrometric quantities are set - /// and observer positions are trusted to be externally consistent. - /// - /// Remarks - /// ------------ - /// * Use this constructor only when you can guarantee that positions were - /// computed consistently with the same environment (`Outfit`, UT1, ephemerides). - /// * All getter methods behave identically to those of [`Observation::new`]. - /// - /// See also - /// ------------ - /// * [`Observation::new`] – Computes positions internally (slower, but self-contained). - /// * [`Observer::pvobs`] – Routine for geocentric position/velocity. - /// * [`Observer::helio_position`] – Routine for heliocentric position. - #[allow(clippy::too_many_arguments)] - pub fn with_positions( - observer: u16, - ra: Radian, - error_ra: Radian, - dec: Radian, - error_dec: Radian, - time: MJD, - observer_earth_position: Vector3, - observer_helio_position: Vector3, - ) -> Self { - Self { - observer, - ra, - error_ra, - dec, - error_dec, - time, - observer_earth_position, - observer_helio_position, - } - } - - /// Get the observer heliocentric position at the observation epoch. - /// - /// Arguments - /// ----------------- - /// * *(none)* – Accessor method. - /// - /// Return - /// ---------- - /// * A copy of the `3D` position vector (AU, equatorial mean J2000) of the observer - /// at `self.time` (MJD TT). - /// - /// See also - /// ------------ - /// * [`Observation::new`] – Computes and stores this vector at construction. - /// * [`Observer::helio_position`] – Underlying routine used to compute the value. - pub fn get_observer_helio_position(&self) -> Vector3 { - self.observer_helio_position - } - - /// Get the observer geocentric position at the observation epoch. - /// - /// Arguments - /// ----------------- - /// * *(none)* – Accessor method. - /// - /// Return - /// ---------- - /// * A copy of the `3D` position vector (AU, equatorial mean J2000) of the observer - /// relative to the Earth’s center at `self.time` (MJD TT). - /// - /// Remarks - /// ------------ - /// * This vector is computed at construction via [`Observer::pvobs`]. - /// * Units are astronomical units (AU), in the equatorial mean J2000 frame. - /// - /// See also - /// ------------ - /// * [`Observation::new`] – Computes and stores this vector at construction. - /// * [`Observer::pvobs`] – Underlying routine used to compute the value. - pub fn get_observer_earth_position(&self) -> Vector3 { - self.observer_earth_position - } - - /// Get the observer from the observation - /// - /// Arguments - /// --------- - /// * `env_state`: a mutable reference to the Outfit instance - /// - /// Return - /// ------ - /// * The observer - pub fn get_observer<'a>(&self, env_state: &'a Outfit) -> &'a Observer { - env_state.get_observer_from_uint16(self.observer) - } - - /// Compute the apparent equatorial coordinates (RA, DEC) of a solar system body - /// as seen by this observation’s site at its epoch. - /// - /// Overview - /// ----------------- - /// This method determines the apparent sky position of a target body, - /// described by equinoctial orbital elements, as seen from the observing site - /// corresponding to this [`Observation`]. - /// - /// The computation steps are: - /// 1. **Orbit propagation** – Propagate the body’s state from its reference epoch to the observation epoch using a two-body model. - /// 2. **Reference frame handling** – Retrieve Earth’s barycentric position from the JPL ephemeris and transform to *ecliptic mean J2000*. - /// 3. **Observer position** – Compute the observer’s heliocentric position (Earth + site geocentric offset). - /// 4. **Light-time and aberration correction** – Form the observer–object vector and correct for aberration. - /// 5. **Conversion to equatorial coordinates** – Convert the corrected line-of-sight vector to (RA, DEC). - /// - /// Arguments - /// ----------------- - /// * `state` – Global environment providing ephemerides, UT1 provider, and frame utilities. - /// * `equinoctial_element` – Orbital elements of the target body. - /// - /// Return - /// ---------- - /// * `Result<(f64, f64), OutfitError>` – The apparent right ascension and declination `[rad]`. - /// - /// Units - /// ---------- - /// * Positions: AU - /// * Velocities: AU/day - /// * Angles: radians - /// * Time: MJD TT - /// - /// Errors - /// ---------- - /// Returns [`OutfitError`] if: - /// - Orbit propagation fails, - /// - Ephemeris data is unavailable, - /// - Reference-frame transformation fails. - /// - /// See also - /// ------------ - /// * [`EquinoctialElements::solve_two_body_problem`] – Orbit propagation. - /// * [`Observer::pvobs`] – Computes observer’s geocentric position. - /// * [`correct_aberration`] – Aberration correction. - /// * [`cartesian_to_radec`] – Convert Cartesian vectors to (RA, DEC). - pub fn compute_apparent_position( - &self, - state: &Outfit, - equinoctial_element: &EquinoctialElements, - ) -> Result<(f64, f64), OutfitError> { - // Hyperbolic/parabolic orbits (e >= 1) are not yet supported - if equinoctial_element.eccentricity() >= 1.0 { - return Err(OutfitError::InvalidOrbit( - "Eccentricity >= 1 is not yet supported".to_string(), - )); - } - - // 1. Propagate asteroid position/velocity in ecliptic J2000 - let (cart_pos_ast, cart_pos_vel, _) = equinoctial_element.solve_two_body_problem( - 0., - self.time - equinoctial_element.reference_epoch, - false, - )?; - - // 2. Observation time in TT - let obs_mjd = Epoch::from_mjd_in_time_scale(self.time, hifitime::TimeScale::TT); - - // 3. Earth's barycentric position in ecliptic J2000 - let (earth_position, _) = state.get_jpl_ephem()?.earth_ephemeris(&obs_mjd, false); - - // 4. get rotation from equatorial mean J2000 to ecliptic mean J2000 - let matrix_elc_transform = state.get_rot_equmj2000_to_eclmj2000(); - - let earth_pos_eclj2000 = matrix_elc_transform.transpose() * earth_position; - let cart_pos_ast_eclj2000 = matrix_elc_transform * cart_pos_ast; - let cart_pos_vel_eclj2000 = matrix_elc_transform * cart_pos_vel; - - // 5. Observer heliocentric position - let geo_obs_pos = self.observer_earth_position; - let xobs = geo_obs_pos + earth_pos_eclj2000; - let obs_on_earth = matrix_elc_transform * xobs; - - // 6. Relative position and aberration correction - let relative_position = cart_pos_ast_eclj2000 - obs_on_earth; - let corrected_pos = correct_aberration(relative_position, cart_pos_vel_eclj2000); - - // 7. Convert to RA/DEC - let (alpha, delta, _) = cartesian_to_radec(corrected_pos); - Ok((alpha, delta)) - } - - /// Compute the normalized squared astrometric residuals (RA, DEC) - /// between an observed position and a propagated ephemeris. - /// - /// Overview - /// ----------------- - /// This method compares the actual astrometric measurement stored in `self` - /// against the expected position of the target body propagated from - /// equinoctial elements. - /// It returns a scalar representing the sum of squared, normalized residuals - /// in RA and DEC. - /// - /// Arguments - /// ----------------- - /// * `state` – Global environment providing ephemerides and time conversions. - /// * `equinoctial_element` – Orbital elements of the target body. - /// - /// Return - /// ---------- - /// * `Result` – Dimensionless scalar value representing the weighted sum - /// of squared residuals. Equivalent to a chi² contribution for a single observation (without division by 2). - /// - /// Remarks - /// ---------- - /// * Residuals are normalized by the astrometric uncertainties `error_ra` and `error_dec`. - /// * RA residuals are multiplied by `cos(dec)` to account for projection effects. - /// * All angles are in radians. - /// - /// Errors - /// ---------- - /// Returns [`OutfitError`] if propagation or ephemeris lookup fails. - /// - /// See also - /// ------------ - /// * [`compute_apparent_position`](crate::observations::Observation::compute_apparent_position) – Used internally to obtain predicted RA/DEC. - /// * [`Observer::pvobs`] – Computes observer’s geocentric position. - /// * [`correct_aberration`] – Applies aberration correction. - /// * [`cartesian_to_radec`] – Converts 3D vectors to (RA, DEC). - /// * [`EquinoctialElements::solve_two_body_problem`] – Two-body propagation. - pub fn ephemeris_error( - &self, - state: &Outfit, - equinoctial_element: &EquinoctialElements, - ) -> Result { - let (alpha, delta) = self.compute_apparent_position(state, equinoctial_element)?; - - // ΔRA with wrapping to [-π, π] - let mut diff_alpha = (self.ra - alpha) % DPI; - if diff_alpha > PI { - diff_alpha -= DPI; - } - - let diff_delta = self.dec - delta; - - // Weighted RMS - let rms_ra = (self.dec.cos() * (diff_alpha / self.error_ra)).powi(2); - let rms_dec = (diff_delta / self.error_dec).powi(2); - - Ok(rms_ra + rms_dec) - } -} - -/// Apply stellar aberration correction to a relative position vector. -/// -/// This function computes the apparent position of a target object by applying -/// the first-order correction for stellar aberration due to the observer's velocity. -/// It assumes the classical limit (v ≪ c), using a linear time-delay model. -/// -/// Arguments -/// --------- -/// * `xrel`: relative position vector from observer to object \[AU\]. -/// * `vrel`: velocity of the observer relative to the barycenter \[AU/day\]. -/// -/// Returns -/// -------- -/// * Corrected position vector (same units and directionality as `xrel`), -/// shifted by the aberration effect. -/// -/// Formula -/// ------- -/// The corrected position is given by: -/// ```text -/// x_corr = xrel − (‖xrel‖ / c) · vrel -/// ``` -/// where `c` is the speed of light in AU/day (`VLIGHT_AU`). -/// -/// Remarks -/// ------- -/// * This function does **not** normalize the output. -/// * Suitable for use in astrometric modeling or when computing apparent direction -/// of celestial objects as seen from a moving observer. -pub fn correct_aberration(xrel: Vector3, vrel: Vector3) -> Vector3 { - let norm_vector = xrel.norm(); - let dt = norm_vector / VLIGHT_AU; - xrel - dt * vrel -} - -impl fmt::Display for Observation { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - // Extract numeric values (adapt if Radian/MJD are newtypes). - let ra_rad: f64 = self.ra; - let dec_rad: f64 = self.dec; - let sra_as: f64 = self.error_ra * RAD2ARC; - let sdec_as: f64 = self.error_dec * RAD2ARC; - let mjd_tt: f64 = self.time; - let jd_tt = mjd_tt + JDTOMJD; - - // Formatting precisions - let sec_prec = 3; - let pos_prec = 6; - - // Sexagesimal angles with carry-safe rounding - let (rh, rm, rs) = ra_hms_prec(ra_rad, sec_prec); - let (sgn, dd, dm, ds) = dec_sdms_prec(dec_rad, sec_prec); - let rs_s = fmt_ss(rs, sec_prec); - let ds_s = fmt_ss(ds, sec_prec); - - if f.alternate() { - // Pretty multi-line variant with {:#} - let site = self.observer; - let r_geo = fmt_vec3_au(&self.observer_earth_position, pos_prec); - let r_hel = fmt_vec3_au(&self.observer_helio_position, pos_prec); - - // Build TT epoch and derive both TT ISO & UTC ISO via hifitime. - let epoch_tt = Epoch::from_mjd_in_time_scale(mjd_tt, TimeScale::TT); - let iso_tt = iso_tt_from_epoch(epoch_tt, sec_prec); - let iso_utc = iso_utc_from_epoch(epoch_tt, sec_prec); - - writeln!(f, "Astrometric observation")?; - writeln!(f, "----------------------")?; - writeln!(f, "Site ID : {site}")?; - writeln!(f, "Epoch (TT) : MJD {mjd_tt:.6}, JD {jd_tt:.6}")?; - writeln!(f, "Epoch (ISO TT) : {iso_tt}")?; - writeln!(f, "Epoch (ISO UTC): {iso_utc}")?; - writeln!( - f, - "RA / σ : {rh:02}h {rm:02}m {rs_s}s (σ = {sra_as:.3}\" )" - )?; - writeln!( - f, - "DEC / σ : {sgn}{dd:02}° {dm:02}' {ds_s}\" (σ = {sdec_as:.3}\" )" - )?; - writeln!(f, "Observer (geo) : {r_geo}")?; - writeln!(f, "Observer (hel) : {r_hel}") - } else { - // Compact single line — keep the original contract so existing tests still pass. - let site = self.observer; - let r_geo = fmt_vec3_au(&self.observer_earth_position, pos_prec); - let r_hel = fmt_vec3_au(&self.observer_helio_position, pos_prec); - - write!( - f, - "Obs(site={site}, MJD={mjd_tt:.6} TT, RA={rh:02}h{rm:02}m{rs_s}s ± {sra_as:.3}\", \ -DEC={sgn}{dd:02}°{dm:02}'{ds_s}\" ± {sdec_as:.3}\", r_geo={r_geo}, r_hel={r_hel})" - ) - } - } -} - -#[cfg(test)] -#[cfg(feature = "jpl-download")] -mod test_observations { - - use crate::unit_test_global::OUTFIT_HORIZON_TEST; - - use super::*; - - mod tests_compute_apparent_position { - - use super::*; - use crate::unit_test_global::OUTFIT_HORIZON_TEST; - use approx::assert_relative_eq; - - /// Helper: simple circular equinoctial elements for a 1 AU, zero inclination orbit. - fn simple_circular_elements(epoch: f64) -> EquinoctialElements { - EquinoctialElements { - reference_epoch: epoch, - semi_major_axis: 1.0, - eccentricity_sin_lon: 0.0, - eccentricity_cos_lon: 0.0, - tan_half_incl_sin_node: 0.0, - tan_half_incl_cos_node: 0.0, - mean_longitude: 0.0, - } - } - - #[test] - fn test_compute_apparent_position_nominal() { - let state = &mut OUTFIT_HORIZON_TEST.0.clone(); - let observer_code = state.uint16_from_mpc_code(&"F51".to_string()); - - let t_obs = 59000.0; // MJD - let equinoctial = simple_circular_elements(t_obs); - - let obs = Observation::new(state, observer_code, 0.0, 0.0, 0.0, 0.0, t_obs).unwrap(); - - let (ra, dec) = obs - .compute_apparent_position(state, &equinoctial) - .expect("Computation should succeed"); - - assert!(ra.is_finite()); - assert!(dec.is_finite()); - assert!((0.0..2.0 * std::f64::consts::PI).contains(&ra)); - assert!((-std::f64::consts::FRAC_PI_2..std::f64::consts::FRAC_PI_2).contains(&dec)); - } - - #[test] - fn test_compute_apparent_position_same_epoch() { - let state = &mut OUTFIT_HORIZON_TEST.0.clone(); - let observer_code = state.uint16_from_mpc_code(&"F51".to_string()); - - let t_epoch = 60000.0; - let equinoctial = simple_circular_elements(t_epoch); - - let obs = Observation::new(state, observer_code, 0.0, 0.0, 0.0, 0.0, t_epoch).unwrap(); - - let (ra1, dec1) = obs.compute_apparent_position(state, &equinoctial).unwrap(); - let (ra2, dec2) = obs.compute_apparent_position(state, &equinoctial).unwrap(); - - // The same input should always produce the same result - assert_relative_eq!(ra1, ra2, epsilon = 1e-14); - assert_relative_eq!(dec1, dec2, epsilon = 1e-14); - } - - #[test] - fn test_apparent_position_for_distant_object() { - let state = &mut OUTFIT_HORIZON_TEST.0.clone(); - let observer_code = state.uint16_from_mpc_code(&"F51".to_string()); - let t_obs = 59000.0; - let mut equinoctial = simple_circular_elements(t_obs); - - // Objet far away - equinoctial.semi_major_axis = 100.0; - - let obs = Observation::new(state, observer_code, 0.0, 0.0, 0.0, 0.0, t_obs).unwrap(); - - let (ra, dec) = obs - .compute_apparent_position(state, &equinoctial) - .expect("Should compute apparent position for distant object"); - - assert!(ra.is_finite()); - assert!(dec.is_finite()); - } - - #[test] - fn test_compute_apparent_position_propagation_failure() { - let state = &mut OUTFIT_HORIZON_TEST.0.clone(); - let observer_code = state.uint16_from_mpc_code(&"F51".to_string()); - - // Invalid orbital elements to force failure in solve_two_body_problem - let equinoctial = EquinoctialElements { - reference_epoch: 59000.0, - semi_major_axis: -1.0, // Physically invalid - eccentricity_sin_lon: 0.0, - eccentricity_cos_lon: 0.0, - tan_half_incl_sin_node: 0.0, - tan_half_incl_cos_node: 0.0, - mean_longitude: 0.0, - }; - - let obs = Observation::new(state, observer_code, 0.0, 0.0, 0.0, 0.0, 59000.0).unwrap(); - - let result = obs.compute_apparent_position(state, &equinoctial); - assert!(result.is_err(), "Invalid elements should trigger an error"); - } - - mod proptests_apparent_position { - use std::sync::Arc; - - use super::*; - use proptest::prelude::*; - - /// Strategy: generates random but reasonable equinoctial elements - /// for property-based tests. - fn arb_equinoctial_elements() -> impl Strategy { - ( - 58000.0..62000.0, // reference_epoch (MJD) - 0.5..30.0, // semi-major axis (AU) - -0.5..0.5, // h = e * sin(Ω+ω) - -0.5..0.5, // k = e * cos(Ω+ω) - -0.5..0.5, // p = tan(i/2)*sin Ω - -0.5..0.5, // q = tan(i/2)*cos Ω - 0.0..(2.0 * std::f64::consts::PI), // mean longitude (rad) - ) - .prop_map(|(epoch, a, h, k, p, q, lambda)| { - EquinoctialElements { - reference_epoch: epoch, - semi_major_axis: a, - eccentricity_sin_lon: h, - eccentricity_cos_lon: k, - tan_half_incl_sin_node: p, - tan_half_incl_cos_node: q, - mean_longitude: lambda, - } - }) - } - - /// Strategy: generates random observer locations on Earth. - /// - longitude in [-180, 180] degrees - /// - latitude in [-90, 90] degrees - /// - elevation from 0 to 5 km - fn arb_observer() -> impl Strategy { - (-180.0..180.0, -90.0..90.0, 0.0..5.0).prop_map(|(lon, lat, elev)| { - Observer::new(lon, lat, elev, None, None, None).unwrap() - }) - } - - /// Strategy: generates equinoctial elements with a wide range, - /// including extreme eccentricities and inclinations. - fn arb_extreme_equinoctial_elements() -> impl Strategy { - ( - 58000.0..62000.0, - 0.1..50.0, - -0.99..0.99, - -0.99..0.99, - -1.0..1.0, - -1.0..1.0, - 0.0..(2.0 * std::f64::consts::PI), - ) - .prop_map(|(epoch, a, h, k, p, q, lambda)| EquinoctialElements { - reference_epoch: epoch, - semi_major_axis: a, - eccentricity_sin_lon: h, - eccentricity_cos_lon: k, - tan_half_incl_sin_node: p, - tan_half_incl_cos_node: q, - mean_longitude: lambda, - }) - // Filter out cases where eccentricity >= 1 - .prop_filter( - "Only bound (elliptical) orbits are supported", - |elem: &EquinoctialElements| { - let e = (elem.eccentricity_sin_lon.powi(2) - + elem.eccentricity_cos_lon.powi(2)) - .sqrt(); - e < 0.99 - }, - ) - } - - proptest! { - /// Property test: RA and DEC are always finite and in the expected ranges. - #[test] - fn proptest_ra_dec_are_finite_and_in_range( - equinoctial in arb_equinoctial_elements(), - obs_time in 58000.0f64..62000.0 - ) { - let state = &mut OUTFIT_HORIZON_TEST.0.clone(); - let observer_code = state.uint16_from_mpc_code(&"F51".to_string()); - - let obs = Observation::new( - state, - observer_code, - 0.0, - 0.0, - 0.0, - 0.0, - obs_time, - ).unwrap(); - - let result = obs.compute_apparent_position(state, &equinoctial); - - if let Ok((ra, dec)) = result { - // Invariant: returned values must be finite - prop_assert!(ra.is_finite()); - prop_assert!(dec.is_finite()); - - // RA must be in [0, 2π), DEC must be in [-π/2, π/2] - prop_assert!((0.0..2.0 * std::f64::consts::PI).contains(&ra)); - prop_assert!((-std::f64::consts::FRAC_PI_2..std::f64::consts::FRAC_PI_2).contains(&dec)); - } - } - - /// Property test: Calling the function twice with the same inputs must produce exactly - /// the same output (determinism). - #[test] - fn proptest_repeatability( - equinoctial in arb_equinoctial_elements(), - obs_time in 58000.0f64..62000.0 - ) { - let state = &mut OUTFIT_HORIZON_TEST.0.clone(); - let observer_code = state.uint16_from_mpc_code(&"F51".to_string()); - - let obs = Observation::new( - state, - observer_code, - 0.0, - 0.0, - 0.0, - 0.0, - obs_time, - ).unwrap(); - - let r1 = obs.compute_apparent_position(state, &equinoctial); - let r2 = obs.compute_apparent_position(state, &equinoctial); - - // Invariant: repeated computation with the same input should be identical - prop_assert_eq!(r1, r2); - } - - /// Property test: A very small change in observation time (1e-3 days ≈ 1.4 min) - /// should not cause huge jumps in the resulting RA/DEC. - #[test] - fn proptest_small_time_change_has_small_effect( - equinoctial in arb_equinoctial_elements(), - obs_time in 58000.0f64..62000.0 - ) { - let state = &mut OUTFIT_HORIZON_TEST.0.clone(); - let observer_code = state.uint16_from_mpc_code(&"F51".to_string()); - - let obs = Observation::new( - state, - observer_code, - 0.0, - 0.0, - 0.0, - 0.0, - obs_time, - ).unwrap(); - - let obs_eps = Observation { - time: obs_time + 1e-3, // shift by 1.4 minutes - ..obs - }; - - let r1 = obs.compute_apparent_position(state, &equinoctial); - let r2 = obs_eps.compute_apparent_position(state, &equinoctial); - - if let (Ok((ra1, dec1)), Ok((ra2, dec2))) = (r1, r2) { - let dra = (ra1 - ra2).abs(); - let ddec = (dec1 - dec2).abs(); - - // Invariant: no catastrophic jumps (> 1 radian) for a small time shift - prop_assert!(dra < 1.0); - prop_assert!(ddec < 1.0); - } - } - } - - proptest! { - /// Property: RA/DEC remain finite and in valid ranges for extreme orbits and random observers. - #[test] - fn proptest_ra_dec_valid_for_extreme_orbits_and_observers( - equinoctial in arb_extreme_equinoctial_elements(), - observer in arb_observer(), - obs_time in 58000.0f64..62000.0 - ) { - let state = &mut OUTFIT_HORIZON_TEST.0.clone(); - let observer_code = state.add_observer_internal(Arc::new(observer)); - - let obs = Observation::new( - state, - observer_code, - 0.0, - 0.0, - 0.0, - 0.0, - obs_time, - ).unwrap(); - - let result = obs.compute_apparent_position(state, &equinoctial); - - if let Ok((ra, dec)) = result { - // Values must be finite - prop_assert!(ra.is_finite()); - prop_assert!(dec.is_finite()); - // Angles must be within their valid intervals - prop_assert!((0.0..2.0 * std::f64::consts::PI).contains(&ra)); - prop_assert!((-std::f64::consts::FRAC_PI_2..std::f64::consts::FRAC_PI_2).contains(&dec)); - } - } - } - - #[test] - fn test_hyperbolic_orbit_returns_error() { - let state = &mut OUTFIT_HORIZON_TEST.0.clone(); - let observer_code = state.uint16_from_mpc_code(&"F51".to_string()); - - let obs = - Observation::new(state, observer_code, 0.0, 0.0, 0.0, 0.0, 59000.0).unwrap(); - - let equinoctial = EquinoctialElements { - reference_epoch: 59000.0, - semi_major_axis: 1.0, - eccentricity_sin_lon: 0.8, - eccentricity_cos_lon: 0.8, // e ≈ 1.13 > 1 - tan_half_incl_sin_node: 0.0, - tan_half_incl_cos_node: 0.0, - mean_longitude: 0.0, - }; - - let result = obs.compute_apparent_position(state, &equinoctial); - assert!( - result.is_err(), - "Hyperbolic or parabolic orbits should currently return an error" - ); - } - } - } - - #[test] - fn test_new_observation() { - let state = &mut OUTFIT_HORIZON_TEST.0.clone(); - let observer_code = state.uint16_from_mpc_code(&"F51".to_string()); - - let observation = - Observation::new(state, observer_code, 1.0, 0.1, 2.0, 0.2, 59000.0).unwrap(); - assert_eq!( - observation, - Observation { - observer: 2, - ra: 1.0, - error_ra: 0.1, - dec: 2.0, - error_dec: 0.2, - time: 59000.0, - observer_earth_position: [ - -1.4662164592060655e-6, - 4.2560356749756634e-5, - -2.1126391698196086e-6 - ] - .into(), - observer_helio_position: [ - -0.35113019606349866, - -0.8726512942340473, - -0.37829699890414364 - ] - .into(), - } - ); - - let observation_2 = Observation::new( - state, - 2, - 343.097_375, - 2.777_777_777_777_778E-6, - -14.784833333333333, - 2.777_777_777_777_778E-5, - 59001.0, - ) - .unwrap(); - - assert_eq!( - observation_2, - Observation { - observer: 2, - ra: 343.097375, - error_ra: 2.777777777777778e-6, - dec: -14.784833333333333, - error_dec: 2.777777777777778e-5, - time: 59001.0, - observer_earth_position: [ - -2.1521316017998277e-6, - 4.2531873012231404e-5, - -2.0988352183088593e-6 - ] - .into(), - observer_helio_position: [ - -0.33522248840408125, - -0.8780465618894304, - -0.380635845615707 - ] - .into(), - } - ); - } - - mod tests_ephemeris_error { - use super::*; - use crate::unit_test_global::OUTFIT_HORIZON_TEST; - use approx::assert_relative_eq; - - fn simple_equinoctial(epoch: f64) -> EquinoctialElements { - EquinoctialElements { - reference_epoch: epoch, - semi_major_axis: 1.0, - eccentricity_sin_lon: 0.0, - eccentricity_cos_lon: 0.0, - tan_half_incl_sin_node: 0.0, - tan_half_incl_cos_node: 0.0, - mean_longitude: 0.0, - } - } - - #[test] - fn test_ephem_error() { - let state = &mut OUTFIT_HORIZON_TEST.0.clone(); - let observer_code = state.uint16_from_mpc_code(&"F51".to_string()); - - let obs = Observation::new( - state, - observer_code, - 1.7899347771316527, - 1.770_024_520_608_546E-6, - 0.778_996_538_107_973_6, - 1.259_582_891_829_317_7E-6, - 57070.262067592594, - ) - .unwrap(); - - let equinoctial_element = EquinoctialElements { - reference_epoch: 57_049.242_334_573_75, - semi_major_axis: 1.8017360713154256, - eccentricity_sin_lon: 0.269_373_680_909_227_2, - eccentricity_cos_lon: 8.856_415_260_013_56E-2, - tan_half_incl_sin_node: 8.089_970_166_396_302E-4, - tan_half_incl_cos_node: 0.10168201109730375, - mean_longitude: 1.6936970079414786, - }; - - let rms_error = obs.ephemeris_error(&OUTFIT_HORIZON_TEST.0, &equinoctial_element); - assert_eq!(rms_error.unwrap(), 75.00445641224026); - } - - /// When the observed RA/DEC exactly match the propagated RA/DEC, - /// the ephemeris_error must be zero. - #[test] - fn test_zero_error_when_positions_match() { - let state = &mut OUTFIT_HORIZON_TEST.0.clone(); - let observer_code = state.uint16_from_mpc_code(&"F51".to_string()); - - let t_obs = 59000.0; - let equinoctial = simple_equinoctial(t_obs); - - // Compute the propagated position - let obs = Observation::new(state, observer_code, 0.0, 1e-6, 0.0, 1e-6, t_obs).unwrap(); - let (alpha, delta) = obs.compute_apparent_position(state, &equinoctial).unwrap(); - - // New observation with exact same RA/DEC - let obs_match = - Observation::new(state, observer_code, alpha, 1e-6, delta, 1e-6, t_obs).unwrap(); - - let error = obs_match.ephemeris_error(state, &equinoctial).unwrap(); - assert_relative_eq!(error, 0.0, epsilon = 1e-14); - } - - /// Error grows if RA is off by a known amount - #[test] - fn test_error_increases_with_offset() { - let state = &mut OUTFIT_HORIZON_TEST.0.clone(); - let observer_code = state.uint16_from_mpc_code(&"F51".to_string()); - - let t_obs = 59000.0; - let equinoctial = simple_equinoctial(t_obs); - - let base_obs = Observation { - observer: observer_code, - ra: 0.0, - error_ra: 1e-3, - dec: 0.0, - error_dec: 1e-3, - time: t_obs, - observer_earth_position: Vector3::zeros(), - observer_helio_position: Vector3::zeros(), - }; - let (alpha, delta) = base_obs - .compute_apparent_position(state, &equinoctial) - .unwrap(); - - // Same dec, but RA offset by 1 milliradian - let obs_offset = Observation { - observer: 0, - ra: alpha + 1e-3, - error_ra: 1e-3, - dec: delta, - error_dec: 1e-3, - time: t_obs, - observer_earth_position: Vector3::zeros(), - observer_helio_position: Vector3::zeros(), - }; - - let err = obs_offset.ephemeris_error(state, &equinoctial).unwrap(); - assert!(err > 0.0); - } - - /// Check that wrapping of RA (close to 2π) does not affect the error - #[test] - fn test_ra_wrapping_invariance() { - let state = &mut OUTFIT_HORIZON_TEST.0.clone(); - let observer_code = state.uint16_from_mpc_code(&"F51".to_string()); - - let t_obs = 59000.0; - let equinoctial = simple_equinoctial(t_obs); - - let base_obs = - Observation::new(state, observer_code, 0.0, 1e-6, 0.0, 1e-6, t_obs).unwrap(); - let (alpha, delta) = base_obs - .compute_apparent_position(state, &equinoctial) - .unwrap(); - - // Same position but RA shifted by ±2π - let obs_wrapped = Observation::new( - state, - observer_code, - alpha + std::f64::consts::TAU, - 1e-6, - delta, - 1e-6, - t_obs, - ) - .unwrap(); - - let err1 = obs_wrapped.ephemeris_error(state, &equinoctial).unwrap(); - assert_relative_eq!(err1, 0.0, epsilon = 1e-12); - } - - /// When RA/DEC uncertainties are very large, error is small even with a mismatch. - #[test] - fn test_large_uncertainty_downweights_error() { - let state = &mut OUTFIT_HORIZON_TEST.0.clone(); - let observer_code = state.uint16_from_mpc_code(&"F51".to_string()); - - let t_obs = 59000.0; - let equinoctial = simple_equinoctial(t_obs); - - let base_obs = - Observation::new(state, observer_code, 0.0, 1.0, 0.0, 1.0, t_obs).unwrap(); - let (alpha, delta) = base_obs - .compute_apparent_position(state, &equinoctial) - .unwrap(); - - let obs_large_uncertainty = Observation::new( - state, - observer_code, - alpha + 0.1, - 10.0, - delta + 0.1, - 10.0, - t_obs, - ) - .unwrap(); - - let err = obs_large_uncertainty - .ephemeris_error(state, &equinoctial) - .unwrap(); - assert!( - err < 1.0, - "Large uncertainties should reduce the error contribution" - ); - } - - mod proptests_ephemeris_error { - use std::sync::Arc; - - use super::*; - use proptest::prelude::*; - - fn arb_observer() -> impl Strategy { - (-180.0..180.0, -90.0..90.0, 0.0..5.0).prop_map(|(lon, lat, elev)| { - Observer::new(lon, lat, elev, None, None, None).unwrap() - }) - } - - fn arb_elliptical_equinoctial() -> impl Strategy { - ( - 58000.0..62000.0, - 0.5..20.0, - -0.8..0.8, - -0.8..0.8, - -0.8..0.8, - -0.8..0.8, - 0.0..std::f64::consts::TAU, - ) - .prop_map(|(epoch, a, h, k, p, q, l)| EquinoctialElements { - reference_epoch: epoch, - semi_major_axis: a, - eccentricity_sin_lon: h, - eccentricity_cos_lon: k, - tan_half_incl_sin_node: p, - tan_half_incl_cos_node: q, - mean_longitude: l, - }) - .prop_filter("Bound orbits only", |e: &EquinoctialElements| { - e.eccentricity() < 1.0 - }) - } - - proptest! { - /// Property: error is always non-negative and finite for valid inputs - #[test] - fn proptest_error_is_non_negative( - equinoctial in arb_elliptical_equinoctial(), - observer in arb_observer(), - obs_time in 58000.0f64..62000.0 - ) { - let state = &mut OUTFIT_HORIZON_TEST.0.clone(); - let observer_code = state.add_observer_internal(Arc::new(observer)); - let obs = Observation::new(state, observer_code, - 0.0, - 1e-3, - 0.0, - 1e-3, - obs_time,).unwrap(); - - let result = obs.ephemeris_error(state, &equinoctial); - if let Ok(val) = result { - prop_assert!(val.is_finite()); - prop_assert!(val >= 0.0); - } - } - - /// Property: If uncertainties are huge, the error must be small - #[test] - fn proptest_error_downweights_large_uncertainties( - equinoctial in arb_elliptical_equinoctial(), - observer in arb_observer(), - obs_time in 58000.0f64..62000.0 - ) { - let state = &mut OUTFIT_HORIZON_TEST.0.clone(); - let observer_code = state.add_observer_internal(Arc::new(observer)); - let obs = Observation::new(state, observer_code, - 0.5, - 100.0, - 0.5, - 100.0, - obs_time,).unwrap(); - - let result = obs.ephemeris_error(state, &equinoctial); - if let Ok(val) = result { - prop_assert!(val < 1.0); - } - } - } - } - } - - #[cfg(test)] - mod display_obs_tests { - use super::*; - use nalgebra::Vector3; - - // --- Helpers ------------------------------------------------------------- - - /// Convert arcseconds to radians. - fn arcsec_to_rad(asx: f64) -> f64 { - asx / 206_264.806_247_096_37 - } - - /// Build an Observation with full control on fields. - /// This stays in the same module (pub(crate) fields are accessible here). - #[allow(clippy::too_many_arguments)] - fn make_obs( - site: u16, - ra_rad: f64, - dec_rad: f64, - sra_arcsec: f64, - sdec_arcsec: f64, - mjd_tt: f64, - geo: (f64, f64, f64), - hel: (f64, f64, f64), - ) -> Observation { - Observation { - observer: site, - ra: Radian::from(ra_rad), - error_ra: Radian::from(arcsec_to_rad(sra_arcsec)), - dec: Radian::from(dec_rad), - error_dec: Radian::from(arcsec_to_rad(sdec_arcsec)), - time: MJD::from(mjd_tt), - observer_earth_position: Vector3::new(geo.0, geo.1, geo.2), - observer_helio_position: Vector3::new(hel.0, hel.1, hel.2), - } - } - - // --- Tests --------------------------------------------------------------- - - #[test] - fn display_compact_basic() { - // Case: RA=0, DEC=0, uncertainties = 1.0 arcsec, simple positions. - let obs = make_obs( - 809, - 0.0, - 0.0, - 1.0, - 1.0, - 60000.123456, // MJD (TT) - (0.123456789, -1.0, 0.000042), - (0.0, 1.234567, -0.5), - ); - - let s = format!("{obs}"); - - // Site and MJD - assert!( - s.contains("site=809"), - "site field not present/incorrect: {s}" - ); - assert!( - s.contains("MJD=60000.123456"), - "MJD formatting to 6 decimals expected: {s}" - ); - - // RA/DEC with uncertainties in arcsec; 3 decimals on seconds and uncertainties. - // RA=0 -> 00h00m00.000s; DEC=+00°00'00.000" - assert!( - s.contains("RA=00h00m00.000s ± 1.000\""), - "RA sexagesimal or sigma not formatted as expected: {s}" - ); - assert!( - s.contains("DEC=+00°00'00.000\" ± 1.000\""), - "DEC sexagesimal or sigma not formatted as expected: {s}" - ); - - // JD = MJD + 2400000.5 is only printed in pretty mode, so not checked here. - - // Vectors formatted with 6 decimals and 'AU' tag - assert!( - s.contains("r_geo=[ 0.123457, -1.000000, 0.000042 ] AU"), - "Geocentric vector formatting/precision mismatch: {s}" - ); - assert!( - s.contains("r_hel=[ 0.000000, 1.234567, -0.500000 ] AU"), - "Heliocentric vector formatting/precision mismatch: {s}" - ); - } - - #[test] - fn display_pretty_multiline() { - // Non-zero RA/DEC to exercise general formatting. - // RA = 2h 30m 15s -> in radians; DEC = +12° 34' 56" - let ra_hours = 2.0 + 30.0 / 60.0 + 15.0 / 3600.0; - let ra_rad = ra_hours * std::f64::consts::PI / 12.0; - let dec_deg: f64 = 12.0 + 34.0 / 60.0 + 56.0 / 3600.0; - let dec_rad = dec_deg.to_radians(); - - let obs = make_obs( - 500, - ra_rad, - dec_rad, - 0.321, // arcsec - 0.789, // arcsec - 60200.5, - (1.0, 2.0, 3.0), - (-0.1, 0.2, -0.3), - ); - - let s = format!("{obs:#}"); - - // Header and labels should be present (human-readable multi-line) - assert!( - s.starts_with("Astrometric observation"), - "Pretty header missing: {s}" - ); - assert!( - s.contains("Site ID : 500"), - "Pretty site line missing: {s}" - ); - - // Epoch line with MJD and JD - assert!( - s.contains("Epoch (TT) : MJD 60200.500000, JD 2460201.000000"), - "Epoch line with JD=MJD+2400000.5 expected: {s}" - ); - - // RA/σ line (check fragments to avoid locale/spacing issues) - assert!(s.contains("RA / σ : "), "RA line missing: {s}"); - assert!( - s.contains("h") && s.contains("m") && s.contains("s"), - "RA HMS units missing: {s}" - ); - assert!( - s.contains("(σ = 0.321"), - "RA sigma arcsec missing/incorrect: {s}" - ); - - // DEC/σ line with sign and DMS glyphs - assert!(s.contains("DEC / σ : +"), "DEC sign missing: {s}"); - assert!( - s.contains("°") && s.contains("'") && s.contains("\""), - "DEC units missing: {s}" - ); - assert!( - s.contains("(σ = 0.789"), - "DEC sigma arcsec missing/incorrect: {s}" - ); - - // Vectors lines - assert!( - s.contains("Observer (geo) : [ 1.000000, 2.000000, 3.000000 ] AU"), - "Geo vector line mismatch: {s}" - ); - assert!( - s.contains("Observer (hel) : [ -0.100000, 0.200000, -0.300000 ] AU"), - "Hel vector line mismatch: {s}" - ); - } - - #[test] - fn ra_wraps_into_24h() { - // RA slightly negative should wrap to near 24h in display. - let tiny = 1e-6; - let obs = make_obs( - 1, - -tiny, // slightly negative angle - 0.0, - 0.1, - 0.1, - 59000.0, - (0.0, 0.0, 0.0), - (0.0, 0.0, 0.0), - ); - - let s = format!("{obs}"); - - // Expect "23h59m..." rather than negative hours - assert!( - s.contains("RA=23h59m") || s.contains("RA=24h00m"), - "RA should wrap to [0, 24h): {s}" - ); - assert!( - !s.contains("-"), - "RA string must not contain a negative sign after wrapping: {s}" - ); - } - - #[test] - fn dec_negative_sign_is_preserved() { - // DEC = -10° 00' 00" - let dec_rad = (-10.0f64).to_radians(); - let obs = make_obs( - 2, - 0.0, - dec_rad, - 0.5, - 0.5, - 59000.0, - (0.0, 0.0, 0.0), - (0.0, 0.0, 0.0), - ); - - let s = format!("{obs}"); - - assert!( - s.contains("DEC=-10°00'00.000\""), - "Negative DEC sign or DMS formatting incorrect: {s}" - ); - } - - #[test] - fn uncertainties_are_in_arcseconds() { - // Store uncertainties in radians corresponding to 2.345 arcsec. - let asx = 2.345; - let obs = make_obs( - 3, - 0.0, - 0.0, - asx, // provided in arcsec, helper converts to rad - asx, - 60001.0, - (0.0, 0.0, 0.0), - (0.0, 0.0, 0.0), - ); - - let s1 = format!("{obs}"); - let s2 = format!("{obs:#}"); - - // Both compact and pretty should surface the same arcsec value to 3 decimals. - assert!( - s1.contains("± 2.345\"") && s2.contains("(σ = 2.345"), - "Arcsecond uncertainties not printed as expected.\nCompact: {s1}\nPretty:\n{s2}" - ); - } - - #[test] - fn vector_precision_and_units() { - // Ensure 6-decimal rounding and AU suffix are stable. - let obs = make_obs( - 4, - 0.0, - 0.0, - 1.0, - 1.0, - 60010.0, - (0.9999996, -0.9999996, 0.12345649), - (1.23456749, -1.23456751, 2.00000049), - ); - - let s = format!("{obs}"); - - // Rounded at 6 decimals, with AU suffix. - assert!( - s.contains("r_geo=[ 1.000000, -1.000000, 0.123456 ] AU"), - "Geo rounding/units mismatch: {s}" - ); - assert!( - s.contains("r_hel=[ 1.234567, -1.234568, 2.000000 ] AU"), - "Hel rounding/units mismatch: {s}" - ); - } - } -} diff --git a/src/observations/observations_ext.rs b/src/observations/observations_ext.rs deleted file mode 100644 index b94f22e..0000000 --- a/src/observations/observations_ext.rs +++ /dev/null @@ -1,1245 +0,0 @@ -//! # Observation extensions for orbit determination -//! -//! This module extends the base [`Observations`] collection with methods -//! essential for **initial orbit determination (IOD)** and orbit quality -//! assessment. -//! It provides two core traits: -//! -//! ## [`ObservationsExt`] -//! -//! High-level utilities for processing and analyzing sets of [`Observation`]s: -//! -//! - **Triplet generation**: [`compute_triplets`](ObservationsExt::compute_triplets) -//! Build optimized triplets of three observations for Gauss IOD. -//! -//! - **RMS evaluation**: [`select_rms_interval`](ObservationsExt::select_rms_interval), -//! [`rms_orbit_error`](ObservationsExt::rms_orbit_error) -//! Select subsets of observations around a triplet and compute RMS residuals -//! for candidate orbits. -//! -//! - **Error handling**: [`apply_batch_rms_correction`](ObservationsExt::apply_batch_rms_correction) -//! Apply statistical corrections to observational errors based on temporal clustering. -//! -//! - **Uncertainty extraction**: [`extract_errors`](ObservationsExt::extract_errors) -//! Retrieve RA/DEC uncertainties for a given triplet. -//! -//! ## [`ObservationIOD`] -//! -//! A high-level trait encapsulating the full **initial orbit determination workflow**: -//! -//! 1. Apply uncertainty calibration with [`ObservationsExt::apply_batch_rms_correction`], -//! 2. Generate candidate triplets with [`ObservationsExt::compute_triplets`], -//! 3. Perform Monte Carlo perturbations to simulate astrometric noise, -//! 4. Run the Gauss method on each triplet, -//! 5. Select the orbit with the lowest RMS over the full arc using [`ObservationsExt::rms_orbit_error`]. -//! -//! This workflow is designed for **short arcs** (newly discovered asteroids, comets, NEOs), -//! where only a handful of observations are available. -//! -//! ## Typical usage -//! -//! ```rust, no_run -//! use rand::{rngs::StdRng, SeedableRng}; -//! use outfit::initial_orbit_determination::IODParams; -//! use outfit::constants::Observations; -//! use outfit::observations::observations_ext::ObservationIOD; -//! -//! let params = IODParams::builder() -//! .n_noise_realizations(50) -//! .noise_scale(1.0) -//! .dtmax(30.0) -//! .max_triplets(20) -//! .build().unwrap(); -//! -//! let observations: Observations = unimplemented!(); // Load observations here -//! let state = unimplemented!(); // Outfit environment -//! let error_model = unimplemented!(); // Astrometric error model -//! let mut rng = StdRng::seed_from_u64(123); -//! -//! let (best_orbit, rms) = observations.estimate_best_orbit( -//! &state, &error_model, &mut rng, ¶ms -//! ).unwrap(); -//! println!("Best preliminary orbit RMS = {rms}"); -//! ``` -//! -//! ## References -//! -//! * Danby, J.M.A. (1992). *Fundamentals of Celestial Mechanics* (2nd ed.). -//! Willmann-Bell. Classic reference for Gauss, Laplace, and related IOD methods. -//! -//! * Milani, A., & Gronchi, G. F. (2009). *Theory of Orbit Determination*. -//! Cambridge University Press. [doi:10.1017/CBO9781139175371](https://doi.org/10.1017/CBO9781139175371) -//! -//! * Carpino, M., Milani, A., & Chesley, S. R. (2003). *OrbFit: Software for Preliminary Orbit Determination*. -//! [https://adams.dm.unipi.it/orbfit/](https://adams.dm.unipi.it/orbfit/) -//! -//! ## See also -//! -//! - [`Observation`] – Representation of a single astrometric measurement. -//! - [`GaussObs`] – Structure encoding a triplet of observations for Gauss IOD. -//! - [`GaussResult`] – Output of the Gauss preliminary orbit solver. -//! - [`IODParams`] – Parameters controlling IOD (triplet constraints, noise realizations). -use itertools::Itertools; -use nalgebra::Vector3; -use std::{collections::VecDeque, ops::ControlFlow}; - -use crate::{ - constants::{Observations, Radian}, - error_models::ErrorModel, - initial_orbit_determination::{gauss::GaussObs, gauss_result::GaussResult, IODParams}, - observations::{triplets_iod::generate_triplets, Observation}, - orbit_type::equinoctial_element::EquinoctialElements, - outfit::Outfit, - outfit_errors::OutfitError, -}; - -/// Extension trait for [`Observations`] providing high-level operations -/// commonly used in orbit determination workflows. -/// -/// This trait adds methods to process and analyze a collection of -/// astrometric [`Observation`] objects, including: -/// -/// * Selection of observation triplets optimized for initial orbit determination (IOD). -/// * Selection of subsets of observations for root-mean-square (RMS) error calculation. -/// * Computation of orbit quality metrics (RMS of astrometric residuals). -/// * Statistical corrections of observational errors based on temporal clustering. -/// * Extraction of astrometric uncertainties for given observation indices. -/// -/// # Typical usage -/// -/// This trait is intended to be implemented on: -/// -/// ```rust, ignore -/// pub type Observations = SmallVec<[Observation; 6]>; -/// ``` -/// -/// It provides functionality essential for the Gauss method and related -/// algorithms used in preliminary orbit determination. -/// -/// # Provided methods -/// - [`compute_triplets`](ObservationsExt::compute_triplets): -/// Build time-filtered triplets of observations, sorted by weight. -/// - [`select_rms_interval`](ObservationsExt::select_rms_interval): -/// Given a triplet, determine which observations lie in an expanded time window. -/// - [`rms_orbit_error`](ObservationsExt::rms_orbit_error): -/// Evaluate the fit of an orbit by computing RMS residuals. -/// - [`apply_batch_rms_correction`](ObservationsExt::apply_batch_rms_correction): -/// Apply correlation-based scaling factors to astrometric errors based on temporal clustering. -/// - [`extract_errors`](ObservationsExt::extract_errors): -/// Retrieve the RA/DEC uncertainties for a given triplet of observations. -/// -/// # See also -/// * [`GaussObs`] – Data structure used to represent a triplet of observations. -/// * [`Outfit`] – Global state providing ephemerides and reference frames. -/// * [`KeplerianElements`](crate::orbit_type::keplerian_element::KeplerianElements) – Orbital elements used to propagate orbits. -/// * [`Observation`] – Individual observation data structure. -/// -/// # References -/// -/// * Danby, J.M.A. (1992). *Fundamentals of Celestial Mechanics* (2nd ed.). -/// Willmann-Bell. -/// Classic reference for preliminary orbit determination methods -/// (Gauss, Laplace, Vaisala) and iterative improvement techniques. -/// -/// * Milani, A., & Gronchi, G. F. (2009). *Theory of Orbit Determination*. -/// Cambridge University Press. -/// Comprehensive modern treatment of statistical orbit determination, -/// including least-squares methods, weighting, and correlation handling. -/// [https://doi.org/10.1017/CBO9781139175371](https://doi.org/10.1017/CBO9781139175371) -/// -/// * Carpino, M., Milani, A., & Chesley, S. R. (2003). *OrbFit: Software for Preliminary Orbit Determination*. -/// Technical documentation distributed with the OrbFit package: -/// [https://adams.dm.unipi.it/orbfit/](https://adams.dm.unipi.it/orbfit/) -/// -/// These references describe the algorithms used for: -/// - Building and filtering observation triplets (Gauss method) -/// - Propagating trial orbits and refining them via differential corrections -/// - Handling of astrometric uncertainties and RMS weighting. -/// -/// This trait is central to orbit determination pipelines and is designed -/// to work with small batches of observations (often < 100 per object). -pub trait ObservationsExt { - /// Compute **time-feasible, best-K** triplets of observations for Gauss IOD, - /// leveraging a lazy **index stream** and a bounded **max-heap** on spacing weight. - /// - /// Overview - /// ----------------- - /// This method is a convenience wrapper around [`generate_triplets`]. It operates - /// directly on `self` (the current observation set) and returns up to `max_triplet` - /// **best-scored** candidates for the Gauss preliminary solution. Internally it: - /// - /// 1) Uses a `TripletIndexGenerator` that: - /// - sorts epochs in place, - /// - downsamples to at most `max_obs_for_triplets` (uniform with edges), - /// - lazily **streams reduced indices** `(first, middle, last)` constrained by: - /// `dt_min ≤ t[last] − t[first] ≤ dt_max`. - /// 2) Scores each feasible triplet with [`triplet_weight`](crate::observations::triplets_iod::triplet_weight) against `optimal_interval_time`. - /// 3) Keeps only the **K** smallest weights in a bounded **max-heap** (best-K selection). - /// 4) Materializes the survivors as [`GaussObs`] by (re)borrowing `self` immutably. - /// - /// Compared to brute-force `O(n³)`, the time-windowed enumeration drives the effective - /// cost toward ~`O(n²)` in typical time distributions, plus `O(n log K)` for heap updates. - /// - /// Arguments - /// ----------------- - /// * `dt_min` – Minimum allowed timespan `[same units as Observation::time]` between the first and last epoch of a triplet. - /// * `dt_max` – Maximum allowed timespan between the first and last epoch of a triplet. - /// * `optimal_interval_time` – Target per-gap spacing (e.g., days) used by [`triplet_weight`](crate::observations::triplets_iod::triplet_weight). - /// * `max_obs_for_triplets` – Upper bound on observations kept after downsampling (uniform with endpoints). - /// * `max_triplet` – Number `K` of best-scoring triplets to return. - /// - /// Return - /// ---------- - /// * A `Vec` of length `≤ max_triplet`, **sorted by increasing weight** - /// (best geometric spacing first), ready to be passed to `GaussObs::prelim_orbit`. - /// - /// Remarks - /// ------------- - /// * Sorting is **in-place**; call sites should not rely on original ordering afterward. - /// * The generator avoids overlapping borrows of `self`; only the final K triplets are materialized. - /// * For robustness studies, each returned triplet can be expanded with - /// `GaussObs::realizations_iter` (lazy Monte-Carlo noise). - /// - /// Complexity - /// ----------------- - /// * Enumeration: ~`O(n²)` (per-anchor time window). - /// * Selection: `O(n log K)` (bounded max-heap). - /// * Space: `O(1)` per yielded candidate; only K triplets are allocated at the end. - /// - /// See also - /// ------------ - /// * [`generate_triplets`] – Low-level function performing the selection (index stream + heap + materialization). - /// * [`TripletIndexGenerator`](crate::observations::triplets_generator::TripletIndexGenerator) – Lazy stream of reduced indices constrained by `(dt_min, dt_max)`. - /// * [`triplet_weight`](crate::observations::triplets_iod::triplet_weight) – Spacing heuristic around `optimal_interval_time`. - /// * [`GaussObs::realizations_iter`] – On-the-fly noisy realizations for a given triplet. - fn compute_triplets( - &mut self, - dt_min: f64, - dt_max: f64, - optimal_interval_time: f64, - max_obs_for_triplets: usize, - max_triplet: u32, - ) -> Vec; - - /// Select the interval of observations for RMS calculation. - /// - /// This function selects the interval of observations for RMS calculation based on the provided triplet. - /// It computes the maximum allowed interval and finds the start and end indices of the observations - /// within that interval. - /// - /// Arguments - /// --------- - /// * `triplets`: A reference to a `GaussObs` representing the triplet of observations. - /// * `extf`: A `f64` representing the external factor for the interval calculation. - /// * `dtmax`: A `f64` representing the maximum allowed interval. - /// - /// Return - /// ------ - /// * A `Result` containing a tuple of start and end indices of the observations within the interval, - /// or an `OutfitError` if an error occurs. - fn select_rms_interval( - &self, - triplets: &GaussObs, - extf: f64, - dtmax: f64, - ) -> Result<(usize, usize), OutfitError>; - - /// Evaluate the orbit quality by computing the RMS of normalized astrometric residuals - /// over a time window centered on a Gauss triplet. - /// - /// Scientific context - /// ------------------- - /// This function measures how well a preliminary orbit reproduces the observed - /// astrometry (RA, DEC). It computes the **root-mean-square (RMS)** of the - /// normalized residuals between predicted and observed positions, aggregated over - /// a set of observations surrounding a Gauss triplet. - /// - /// Interval selection - /// ------------------- - /// The observation arc is defined by: - /// * `extf` – fractional extension factor applied around the triplet center, - /// * `dtmax` – absolute maximum time span (days) allowed for the arc. - /// - /// The effective interval is determined by - /// [`select_rms_interval`](Self::select_rms_interval), which returns the first - /// and last indices of the observations to include. - /// - /// Computation - /// ------------ - /// * Each observation contributes a squared normalized residual - /// from [`Observation::ephemeris_error`](crate::observations::Observation::ephemeris_error). - /// * The final RMS is - /// - /// ```text - /// RMS = √[ (1 / (2N)) · Σᵢ (ΔRAᵢ² + ΔDECᵢ²) ] - /// ``` - /// - /// where `N` is the number of observations in the selected interval. - /// - /// Pruning mode - /// ------------ - /// If `prune_if_rms_ge` is set: - /// * The summation stops early once the partial RMS reaches the threshold, - /// returning the pruning value directly. - /// * If `prune_if_rms_ge = ∞`, no early exit occurs (equivalent to no pruning). - /// - /// Arguments - /// ---------- - /// * `state` – Global context providing ephemerides, Earth orientation, and time conversion. - /// * `triplets` – The Gauss triplet that defined the preliminary orbit. - /// * `orbit_element` – The orbit (in equinoctial elements) to be tested against the arc. - /// * `extf` – Fractional time extension of the interval around the triplet. - /// * `dtmax` – Maximum arc duration (days). - /// * `prune_if_rms_ge` – Optional RMS cutoff for early termination (see *Pruning mode*). - /// - /// Return - /// ------- - /// * `Ok(rms)` – RMS of the normalized astrometric residuals (radians). - /// * `Err(OutfitError)` – If interval selection fails or propagation/ephemeris lookup fails. - /// - /// Units - /// ------- - /// * The returned RMS is dimensionless but expressed in **radians**. - fn rms_orbit_error( - &self, - state: &Outfit, - triplets: &GaussObs, - orbit_element: &EquinoctialElements, - extf: f64, - dtmax: f64, - prune_if_rms_ge: Option, - ) -> Result; - - /// Apply RMS correction based on temporally clustered batches of observations. - /// - /// This method adjusts the astrometric uncertainties (`error_ra`, `error_dec`) of each observation - /// based on the local density of observations in time and observer identity. Observations that are - /// close in time (within 8 hours) and come from the same observer are grouped into batches, and a - /// correction factor is applied to reflect statistical correlation or improvement due to redundancy. - /// - /// # Arguments - /// --------------- - /// * `error_model` - The error model to use when applying the batch correction. Supported values include: - /// - `"vfcc17"`: uses a reduced factor `√(n × 0.25)` if the batch has at least 5 observations, - /// - any other string: uses the standard `√n` factor. - /// * `gap_max` - The maximum time gap (in days) to consider observations as part of the same batch. - /// - /// # Behavior - /// ---------- - /// - Observations are grouped by `observer` and sorted in time. - /// - A batch is formed when consecutive observations from the same observer are spaced by less than 8 hours. - /// - Each observation in a batch of size `n` receives a correction: - /// - `√n` for standard models, - /// - `√(n × 0.25)` for `vfcc17` when `n ≥ 5`. - /// - If `n < 5` with `vfcc17`, it falls back to `√n`. - /// - Observations with fixed weights (`force_w`) are not affected (not yet implemented in this version). - /// - /// # Returns - /// ---------- - /// * `()` - This function modifies the observations in-place; it does not return a value. - /// - /// # Computation Details - /// ---------- - /// - The time comparison is based on Modified Julian Date (`MJD`), and the batch window is fixed at 8 hours (i.e., `8.0 / 24.0` days). - /// - The error fields `error_ra` and `error_dec` are both scaled by the same batch correction factor. - /// - /// # Units - /// ---------- - /// - Input and output uncertainties (`error_ra`, `error_dec`) are expressed in **radians**. - fn apply_batch_rms_correction(&mut self, error_model: &ErrorModel, gap_max: f64); - - /// Extract astrometric uncertainties (RA and DEC) for a set of three observations. - /// - /// Given a triplet of observation indices, this function retrieves the corresponding - /// astrometric errors in right ascension and declination from the observation set. - /// - /// # Arguments - /// --------------- - /// * `idx_obs` - A vector of three indices referring to the observations used in the triplet. - /// - /// # Returns - /// --------------- - /// * A tuple of two `Vector3`: - /// - The first vector contains the RA uncertainties in radians. - /// - The second vector contains the DEC uncertainties in radians. - /// - /// # Panics - /// --------------- - /// This function will panic if any index in `idx_obs` is out of bounds of the observation set. - fn extract_errors(&self, idx_obs: Vector3) -> (Vector3, Vector3); -} - -/// Trait for performing Initial Orbit Determination (IOD) on a set of astrometric observations. -/// -/// This trait encapsulates high-level algorithms to derive a **preliminary orbit** -/// from a time series of astrometric observations, using the **Gauss method**. -/// It focuses on searching for the best-fitting orbit over all valid triplets of observations. -/// -/// ## Purpose -/// -/// The main goal of this trait is to automate the process of: -/// 1. Building candidate triplets of observations (see [`compute_triplets`](ObservationsExt::compute_triplets)), -/// 2. Estimating a preliminary orbit for each triplet using the Gauss method, -/// 3. Perturbing triplets with Gaussian noise to simulate observational uncertainties, -/// 4. Selecting the orbit that minimizes the root-mean-square (RMS) of astrometric residuals -/// when compared to the entire set of observations. -/// -/// This process is the standard entry point for orbit determination workflows. -/// It is designed for **short observational arcs**, such as those of newly discovered asteroids. -/// -/// ## Typical usage -/// -/// ```rust, no_run -/// use rand::{rngs::StdRng, SeedableRng}; -/// use outfit::initial_orbit_determination::IODParams; -/// use outfit::constants::Observations; -/// use outfit::observations::observations_ext::ObservationIOD; -/// -/// let params = IODParams::builder() -/// .n_noise_realizations(100) -/// .noise_scale(1.0) -/// .dtmax(30.0) -/// .max_triplets(50) -/// .build().unwrap(); -/// -/// let observations: Observations = unimplemented!(); // Your observations here -/// let state = unimplemented!(); // Your state here -/// let error_model = unimplemented!(); // Your error model here -/// let mut rng = StdRng::seed_from_u64(42); -/// -/// let (best_orbit, rms) = observations.estimate_best_orbit( -/// &state, &error_model, &mut rng, ¶ms).unwrap(); -/// println!("Best preliminary orbit RMS = {rms}"); -/// ``` -/// -/// ## Algorithmic steps -/// -/// 1. **Batch uncertainty correction:** -/// Observations are first passed through [`ObservationsExt::apply_batch_rms_correction`]. -/// -/// 2. **Triplet generation:** -/// Valid combinations of three observations are extracted with [`ObservationsExt::compute_triplets`], -/// using the configuration parameters from [`IODParams`] (e.g., `dt_min`, `dt_max_triplet`, -/// `optimal_interval_time`, `max_obs_for_triplets`, `max_triplets`). -/// -/// 3. **Orbit estimation:** -/// For each triplet, `n_noise_realizations` noisy variants are generated (Monte Carlo) -/// and processed by the Gauss method to obtain preliminary orbital elements. -/// -/// 4. **Orbit evaluation:** -/// Each preliminary orbit is propagated and compared to the full observation arc using -/// [`ObservationsExt::rms_orbit_error`]. The orbit with the smallest RMS is returned. -/// -/// ## Performance considerations -/// -/// * Typically applied to **short arcs with tens of observations**. -/// * The number of triplets can be limited via `params.max_triplets`. -/// * The Monte Carlo loop (`params.n_noise_realizations`) dominates runtime. -/// -/// ## Returns -/// -/// * `Ok((Some(best_orbit), best_rms))` – if a valid orbit was found, -/// * `Ok((None, f64::MAX))` – if no valid orbit could be estimated, -/// * `Err(OutfitError)` – if an error occurs during propagation or fitting. -/// -/// ## Parameters -/// -/// * [`IODParams`] – Controls the noise sampling, temporal constraints on triplets, -/// and the maximum number of triplets to evaluate. -/// -/// ## See also -/// -/// * [`ObservationsExt::compute_triplets`] – Generates candidate triplets from the observation set. -/// * [`ObservationsExt::rms_orbit_error`] – Evaluates the quality of an orbit over the full arc. -/// * [`GaussResult`] – Data structure holding the result of a single Gauss method run. -/// * [`KeplerianElements`](crate::orbit_type::keplerian_element::KeplerianElements) – Orbital elements returned by successful preliminary orbit estimation. -/// * [`IODParams`] – Groups all configuration options for IOD. -pub trait ObservationIOD { - /// Estimate the best-fitting preliminary orbit from a full set of astrometric observations. - /// - /// This method searches for the best preliminary orbit by evaluating a limited number of - /// observation triplets generated from the dataset. The process includes: - /// - /// 1. **Error calibration**: - /// Observations are first preprocessed with [`ObservationsExt::apply_batch_rms_correction`] to account for - /// temporal clustering and observer-specific error models. - /// - /// 2. **Triplet generation**: - /// Candidate triplets are generated using [`ObservationsExt::compute_triplets`], which: - /// * Sorts observations by time, - /// * Optionally downsamples the dataset to at most `params.max_obs_for_triplets` points - /// (uniform in time, always keeping the first and last), - /// * Filters valid triplets according to `params.dt_min`, `params.dt_max_triplet`, - /// and `params.optimal_interval_time`. - /// - /// 3. **Monte Carlo noise sampling**: - /// For each triplet, `params.n_noise_realizations` perturbed versions are created using - /// Gaussian noise scaled by `params.noise_scale` times the nominal astrometric uncertainties. - /// - /// 4. **Orbit estimation and selection**: - /// For each (possibly perturbed) triplet, a preliminary orbit is computed with the Gauss method. - /// The resulting orbit is evaluated over the full set of observations using [`ObservationsExt::rms_orbit_error`]. - /// The orbit with the smallest RMS is returned. - /// - /// # Arguments - /// - /// * `state` – - /// Global [`Outfit`] state, providing ephemerides and time conversions. - /// * `error_model` – - /// The astrometric error model (typically per-band or per-observatory). - /// * `rng` – - /// A random number generator used to draw Gaussian perturbations. - /// * `params` – - /// Parameters controlling the initial orbit determination, including: - /// * `n_noise_realizations`: number of noisy triplet variants generated per original triplet, - /// * `noise_scale`: scaling factor for the noise, - /// * `extf`: extrapolation factor for RMS evaluation, - /// * `dtmax`: maximum time interval for RMS evaluation, - /// * `dt_min`, `dt_max_triplet`, `optimal_interval_time`: constraints on triplet spans, - /// * `max_obs_for_triplets`: maximum number of observations to keep when building triplets, - /// * `max_triplets`: maximum number of triplets to process, - /// * `gap_max`: maximum allowed time gap within a batch for RMS corrections. - /// - /// # Returns - /// - /// * `Ok((Some(best_orbit), best_rms))` – The best preliminary orbit found and its RMS. - /// * `Ok((None, f64::MAX))` – No valid orbit could be estimated. - /// * `Err(e)` – An error occurred during orbit estimation or RMS evaluation. - /// - /// # Notes - /// - /// - RMS values are computed with [`ObservationsExt::rms_orbit_error`], which accounts for - /// light-time correction and ephemeris propagation. - /// - Each triplet can produce several preliminary orbit candidates due to - /// noise realizations. - /// - The `max_obs_for_triplets` parameter is crucial for large datasets, - /// as it avoids the combinatorial explosion of triplets. - /// - /// # See also - /// - /// * [`ObservationsExt::compute_triplets`] – Selects triplets from the observation set. - /// * [`GaussObs::generate_noisy_realizations`] – Creates perturbed triplets with Gaussian noise. - /// * [`GaussObs::prelim_orbit`] – Computes a preliminary orbit from a single triplet. - /// * [`ObservationsExt::rms_orbit_error`] – Measures the goodness-of-fit of an orbit against observations. - /// * [`IODParams`] – Configuration options for the IOD process. - fn estimate_best_orbit( - &mut self, - state: &Outfit, - error_model: &ErrorModel, - rng: &mut impl rand::Rng, - params: &IODParams, - ) -> Result<(GaussResult, f64), OutfitError>; -} - -impl ObservationsExt for Observations { - fn compute_triplets( - &mut self, - dt_min: f64, - dt_max: f64, - optimal_interval_time: f64, - max_obs_for_triplets: usize, - max_triplet: u32, - ) -> Vec { - generate_triplets( - self, - dt_min, - dt_max, - optimal_interval_time, - max_obs_for_triplets, - max_triplet, - ) - } - - fn select_rms_interval( - &self, - triplets: &GaussObs, - extf: f64, - dtmax: f64, - ) -> Result<(usize, usize), OutfitError> { - let nobs = self.len(); - - let idx_obs1 = triplets.idx_obs[0]; - let obs1 = self - .get(idx_obs1) - .ok_or(OutfitError::ObservationNotFound(idx_obs1))?; - - let idx_obs3 = triplets.idx_obs[2]; - let obs3 = self - .get(idx_obs3) - .ok_or(OutfitError::ObservationNotFound(idx_obs3))?; - - let first_obs = self.first().ok_or(OutfitError::ObservationNotFound(0))?; - let last_obs = self - .last() - .ok_or(OutfitError::ObservationNotFound(nobs - 1))?; - // Step 1: Compute the maximum allowed interval - let mut dt = if extf >= 0.0 { - (obs3.time - obs1.time) * extf - } else { - 10.0 * (last_obs.time - first_obs.time) - }; - - if dtmax >= 0.0 { - dt = dt.max(dtmax); - } - - let mut i_start = 0; - - for i in (0..=idx_obs1).rev() { - if let Some(obs_i) = self.get(i) { - if obs1.time - obs_i.time > dt { - break; - } - i_start = i; - } - } - - let mut i_end = nobs - 1; - - for i in idx_obs3..nobs { - if let Some(obs_i) = self.get(i) { - if obs_i.time - obs3.time > dt { - break; - } - i_end = i; - } - } - - Ok((i_start, i_end)) - } - - fn rms_orbit_error( - &self, - state: &Outfit, - triplets: &GaussObs, - orbit_element: &EquinoctialElements, - extf: f64, - dtmax: f64, - prune_if_rms_ge: Option, - ) -> Result { - // Select the time interval [start_obs_rms, end_obs_rms] over which the RMS - // error is evaluated. The interval depends on the triplet and on external - // filtering parameters (extf, dtmax). - let (start_obs_rms, end_obs_rms) = self.select_rms_interval(triplets, extf, dtmax)?; - - // Number of observations contributing to the RMS - let n_obs = (end_obs_rms - start_obs_rms + 1) as f64; - - // Denominator of the RMS formula: here weighted by 2.0 for consistency - // with the convention used elsewhere in the code. - let denom = 2.0 * n_obs; - - // ========================================================================= - // Case 1: No pruning → behave like the "classical" RMS definition - // ========================================================================= - if prune_if_rms_ge.is_none() { - // Accumulate the squared ephemeris errors for each observation - let sum = self[start_obs_rms..=end_obs_rms] - .iter() - .map(|obs| obs.ephemeris_error(state, orbit_element)) - // try_fold propagates errors from ephemeris_error while summing - .try_fold(0.0, |acc, term| term.map(|v| acc + v))?; - - // Final RMS = sqrt( sum / denom ) - return Ok((sum / denom).sqrt()); - } - - // ========================================================================= - // Case 2: Pruning enabled → early stop if RMS exceeds a threshold - // ========================================================================= - let prune = prune_if_rms_ge.unwrap(); - - // Convert the RMS cutoff into a sum cutoff: - // RMS² = sum / denom → stop if sum ≥ (prune² * denom). - let sum_cutoff = if prune.is_finite() { - prune * prune * denom - } else { - f64::INFINITY // "no real cutoff" if prune = ∞ - }; - - // Iterate over observations and accumulate squared errors. - // We use ControlFlow to allow early exit: - // - Continue(sum): keep summing, - // - Break(value): stop early and return the pruning threshold. - let folded: ControlFlow = self[start_obs_rms..=end_obs_rms] - .iter() - .map(|obs| obs.ephemeris_error(state, orbit_element)) - .try_fold(0.0, |acc, term| match term { - Ok(v) => { - let new_sum = acc + v; - if new_sum >= sum_cutoff { - // Early exit: threshold reached, return directly - ControlFlow::Break(prune) - } else { - ControlFlow::Continue(new_sum) - } - } - // In case of error in ephemeris_error, also exit with pruning value. - Err(_) => ControlFlow::Break(prune), - }); - - // Final RMS depending on whether we exited early or not - match folded { - ControlFlow::Continue(sum) => Ok((sum / denom).sqrt()), - ControlFlow::Break(rms) => Ok(rms), - } - } - - fn apply_batch_rms_correction(&mut self, error_model: &ErrorModel, gap_max: f64) { - // Step 1: Sort in time - self.sort_by(|a, b| a.time.partial_cmp(&b.time).unwrap()); - - // Step 2: Group by observer - for (_observer_id, group) in &self.into_iter().chunk_by(|obs| obs.observer) { - // Step 3: Batch grouping using sliding window - let mut batch: VecDeque<&mut Observation> = VecDeque::new(); - let mut iter = group.peekable(); - - while let Some(obs) = iter.next() { - batch.push_back(obs); - - // Extend batch while within gap_max - while let Some(next) = iter.peek() { - let dt = next.time - - batch - .back() - .expect("in apply_batch_rms_correction: batch should not be empty") - .time; - if dt <= gap_max { - batch.push_back(iter.next().expect( - "in apply_batch_rms_correction: next in batch should not be None", - )); - } else { - break; - } - } - - // Apply correction to current batch - let n = batch.len(); - if n > 0 { - let factor = match error_model { - ErrorModel::VFCC17 if n >= 5 => (n as f64 * 0.25).sqrt(), - _ => (n as f64).sqrt(), - }; - - for obs in batch.drain(..) { - obs.error_ra *= factor; - obs.error_dec *= factor; - } - } - } - } - } - - fn extract_errors(&self, idx_obs: Vector3) -> (Vector3, Vector3) { - let (errors_ra, errors_dec): (Vec<_>, Vec<_>) = idx_obs - .into_iter() - .map(|i| { - let obs = &self[*i]; - (obs.error_ra, obs.error_dec) - }) - .unzip(); - - ( - Vector3::from_column_slice(&errors_ra), - Vector3::from_column_slice(&errors_dec), - ) - } -} - -impl ObservationIOD for Observations { - fn estimate_best_orbit( - &mut self, - state: &Outfit, - error_model: &ErrorModel, - rng: &mut impl rand::Rng, - params: &IODParams, - ) -> Result<(GaussResult, f64), OutfitError> { - // --- Stage 1: Calibrate uncertainties once for the whole batch. - // This aligns quoted per-obs errors with empirical RMS statistics. - self.apply_batch_rms_correction(error_model, params.gap_max); - - // --- Stage 2: Enumerate candidate triplets under temporal constraints. - let triplets = self.compute_triplets( - params.dt_min, - params.dt_max_triplet, - params.optimal_interval_time, - params.max_obs_for_triplets, - params.max_triplets, - ); - - if triplets.is_empty() { - let span = if self.is_empty() { - 0.0 - } else { - self.last().unwrap().time - self.first().unwrap().time - }; - return Err(OutfitError::NoFeasibleTriplets { - span, - n_obs: self.len(), - dt_min: params.dt_min, - dt_max: params.dt_max_triplet, - }); - } - - // Current best (lowest) RMS and its orbit. - // Using +∞ avoids Option branching in the hot path. - let mut best_rms = f64::INFINITY; - let mut best_orbit: Option = None; - - // Keep the last encountered error so that we can report something meaningful if *all* fail. - // We don't clone: we keep only the most recent error by moving it in. - let mut last_error: Option = None; - - // For diagnostics, count how many realizations we actually attempted. - let mut n_attempts: usize = 0; - - // --- Stage 3: Explore each triplet. - for triplet in triplets { - // Extract 1-σ astrometric uncertainties for the three obs of this triplet. - let (error_ra, error_dec) = self.extract_errors(triplet.idx_obs); - - // --- Stage 4: For each (lazy) noisy realization of this triplet... - // The iterator yields the original triplet first, then noisy copies. - for realization in triplet.realizations_iter( - &error_ra, - &error_dec, - params.n_noise_realizations, - params.noise_scale, - rng, - ) { - n_attempts += 1; - - // 4.a) Preliminary Gauss solution on the current realization. - let gauss_res = match realization.prelim_orbit(state, params) { - Ok(res) => res, - Err(e) => { - // Record the failure and continue exploring. - last_error = Some(e); - continue; - } - }; - - // 4.b) Convert to the element set required by the scorer. - let equinoctial_elements = gauss_res.get_orbit().to_equinoctial()?; - - // 4.c) Score orbit vs. full observation set (RMS residual). - let rms = match self.rms_orbit_error( - state, - &realization, - &equinoctial_elements, - params.extf, - params.dtmax, - Some(best_rms), - ) { - Ok(v) => { - if !v.is_finite() { - last_error = Some(OutfitError::NonFiniteScore(v)); - continue; - } else { - v - } - } - Err(e) => { - last_error = Some(e); - continue; - } - }; - - // 4.d) Keep the best candidate so far. - if rms < best_rms { - best_rms = rms; - best_orbit = Some(gauss_res); - } - } - } - - // --- Stage 5: If at least one candidate succeeded, return the best; otherwise, propagate an error. - if let Some(orbit) = best_orbit { - Ok((orbit, best_rms)) - } else { - // If nothing succeeded, propagate a structured error with the last underlying cause. - // Fallback to a domain-specific unit error if we never captured any (e.g., no attempts). - let root_cause = match last_error { - Some(e) => e, - None => panic!("In estimate_best_orbit: no error captured but best_orbit is None, this should not happen"), - }; - Err(OutfitError::NoViableOrbit { - cause: Box::new(root_cause), - attempts: n_attempts, - }) - } - } -} - -#[cfg(test)] -mod test_obs_ext { - - use crate::error_models::ErrorModel; - - use super::*; - - #[test] - #[cfg(feature = "jpl-download")] - fn test_select_rms_interval() { - use crate::unit_test_global::OUTFIT_HORIZON_TEST; - - let mut traj_set = OUTFIT_HORIZON_TEST.1.clone(); - - let traj_number = crate::constants::ObjectNumber::String("K09R05F".into()); - let traj_len = traj_set - .get(&traj_number) - .expect("Failed to get trajectory") - .len(); - - let traj = traj_set - .get_mut(&traj_number) - .expect("Failed to get trajectory"); - - let triplets = traj.compute_triplets(0.03, 150.0, 20.0, traj_len, 10); - let (u1, u2) = traj - .select_rms_interval(triplets.first().unwrap(), -1., 30.) - .unwrap(); - - assert_eq!(u1, 0); - assert_eq!(u2, 36); - - let (u1, u2) = traj - .select_rms_interval(triplets.first().unwrap(), 10., 30.) - .unwrap(); - - assert_eq!(u1, 14); - assert_eq!(u2, 36); - - let (u1, u2) = traj - .select_rms_interval(triplets.first().unwrap(), 0.001, 3.) - .unwrap(); - - assert_eq!(u1, 17); - assert_eq!(u2, 33); - } - - #[test] - #[cfg(feature = "jpl-download")] - fn test_rms_trajectory() { - use nalgebra::Matrix3; - - use crate::{ - orbit_type::keplerian_element::KeplerianElements, unit_test_global::OUTFIT_HORIZON_TEST, - }; - - let mut traj_set = OUTFIT_HORIZON_TEST.1.clone(); - - let traj = traj_set - .get_mut(&crate::constants::ObjectNumber::String("K09R05F".into())) - .expect("Failed to get trajectory"); - - traj.apply_batch_rms_correction(&ErrorModel::FCCT14, 8.0 / 24.0); - - let triplets = GaussObs { - idx_obs: Vector3::new(34, 35, 36), - ra: [[ - 1.789_797_623_341_267, - 1.789_865_909_348_251, - 1.7899347771316527, - ]] - .into(), - dec: [[ - 0.779_178_052_350_181, - 0.779_086_664_971_291_9, - 0.778_996_538_107_973_6, - ]] - .into(), - time: [[ - 57070.238017592594, - 57_070.250_007_592_59, - 57070.262067592594, - ]] - .into(), - observer_helio_position: Matrix3::zeros(), - }; - - let kepler = KeplerianElements { - reference_epoch: 57_049.242_334_573_75, - semi_major_axis: 1.8017360713154256, - eccentricity: 0.283_559_145_668_705_7, - inclination: 0.20267383288689386, - ascending_node_longitude: 7.955_979_023_693_781E-3, - periapsis_argument: 1.2451951387589135, - mean_anomaly: 0.44054589015887125, - }; - - let rms = traj - .rms_orbit_error( - &OUTFIT_HORIZON_TEST.0, - &triplets, - &kepler.into(), - -1.0, - 30., - None, - ) - .unwrap(); - - assert_eq!(rms, 68.88650730830162); - } - - mod test_batch_rms_correction { - use crate::constants::MJD; - use approx::assert_ulps_eq; - use smallvec::smallvec; - - use super::*; - - fn obs(observer: u16, time: MJD) -> Observation { - Observation { - observer, - ra: 1.0, - error_ra: 1e-6, - dec: 0.5, - error_dec: 2e-6, - time, - observer_earth_position: Vector3::zeros(), - observer_helio_position: Vector3::zeros(), - } - } - - #[test] - fn test_single_batch_vfcc17_large() { - let base_time = 59000.0; - let mut obs: Observations = smallvec![ - obs(1, base_time), - obs(1, base_time + 0.01), - obs(1, base_time + 0.02), - obs(1, base_time + 0.03), - obs(1, base_time + 0.04), // n = 5 - ]; - - obs.apply_batch_rms_correction(&ErrorModel::VFCC17, 8.0 / 24.0); - - let factor = (5.0_f64 * 0.25_f64).sqrt(); - for ob in &obs { - assert_ulps_eq!(ob.error_ra, 1e-6 * factor, max_ulps = 2); - assert_ulps_eq!(ob.error_dec, 2e-6 * factor, max_ulps = 2); - } - } - - #[test] - fn test_single_batch_small_n() { - let base_time = 59000.0; - let mut obs: Observations = smallvec![ - obs(2, base_time), - obs(2, base_time + 0.01), // n = 2 - ]; - - obs.apply_batch_rms_correction(&ErrorModel::FCCT14, 8.0 / 24.0); - - let factor = (2.0f64).sqrt(); - for ob in &obs { - assert_ulps_eq!(ob.error_ra, 1e-6 * factor, max_ulps = 2); - assert_ulps_eq!(ob.error_dec, 2e-6 * factor, max_ulps = 2); - } - } - - #[test] - fn test_multiple_batches_same_observer() { - let base_time = 59000.0; - let mut obs: Observations = smallvec![ - obs(3, base_time), - obs(3, base_time + 0.01), // batch 1 (n = 2) - obs(3, base_time + 1.0), // isolated, batch 2 (n = 1) - ]; - - obs.apply_batch_rms_correction(&ErrorModel::FCCT14, 8.0 / 24.0); - - let factor1 = (2.0f64).sqrt(); - let factor2 = 1.0; - - assert_ulps_eq!(obs[0].error_ra, 1e-6 * factor1, max_ulps = 2); - assert_ulps_eq!(obs[1].error_ra, 1e-6 * factor1, max_ulps = 2); - assert_ulps_eq!(obs[2].error_ra, 1e-6 * factor2, max_ulps = 2); - } - - #[test] - fn test_different_observers_are_not_grouped() { - let base_time = 59000.0; - let mut obs: Observations = smallvec![ - obs(10, base_time), - obs(11, base_time + 0.01), - obs(12, base_time + 0.02), - ]; - - obs.apply_batch_rms_correction(&ErrorModel::FCCT14, 8.0 / 24.0); - - for ob in &obs { - assert_ulps_eq!(ob.error_ra, 1e-6, max_ulps = 2); - assert_ulps_eq!(ob.error_dec, 2e-6, max_ulps = 2); - } - } - - #[test] - fn test_batch_gaps_exceed_gapmax() { - let mut obs: Observations = smallvec![ - obs(5, 59000.0), - obs(5, 59001.0), // > 8h => separate - ]; - - obs.apply_batch_rms_correction(&ErrorModel::FCCT14, 8.0 / 24.0); - - for ob in &obs { - assert_ulps_eq!(ob.error_ra, 1e-6, max_ulps = 2); - assert_ulps_eq!(ob.error_dec, 2e-6, max_ulps = 2); - } - } - - #[test] - #[cfg(feature = "jpl-download")] - fn test_batch_real_data() { - use crate::unit_test_global::OUTFIT_HORIZON_TEST; - - let mut traj_set = OUTFIT_HORIZON_TEST.1.clone(); - - let traj = traj_set - .get_mut(&crate::constants::ObjectNumber::String("K09R05F".into())) - .expect("Failed to get trajectory"); - - traj.apply_batch_rms_correction(&ErrorModel::FCCT14, 8.0 / 24.0); - - assert_ulps_eq!(traj[0].error_ra, 2.507075226057322e-6, max_ulps = 2); - assert_ulps_eq!(traj[0].error_dec, 2.036217397086327e-6, max_ulps = 2); - - assert_ulps_eq!(traj[1].error_ra, 2.5070681687218917e-6, max_ulps = 2); - assert_ulps_eq!(traj[1].error_dec, 2.036217397086327e-6, max_ulps = 2); - - assert_ulps_eq!(traj[2].error_ra, 2.507_059_507_890_695_2E-6, max_ulps = 2); - assert_ulps_eq!(traj[2].error_dec, 2.036217397086327e-6, max_ulps = 2); - } - } - - mod test_extract_errors { - use super::*; - use approx::assert_ulps_eq; - use smallvec::smallvec; - - fn make_observations() -> Observations { - smallvec![ - Observation { - observer: 0, - ra: 1.0, - dec: 0.5, - error_ra: 1e-6, - error_dec: 2e-6, - time: 59000.0, - observer_earth_position: Vector3::zeros(), - observer_helio_position: Vector3::zeros(), - }, - Observation { - observer: 0, - ra: 1.1, - dec: 0.6, - error_ra: 3e-6, - error_dec: 4e-6, - time: 59000.1, - observer_earth_position: Vector3::zeros(), - observer_helio_position: Vector3::zeros(), - }, - Observation { - observer: 0, - ra: 1.2, - dec: 0.7, - error_ra: 5e-6, - error_dec: 6e-6, - time: 59000.2, - observer_earth_position: Vector3::zeros(), - observer_helio_position: Vector3::zeros(), - }, - ] - } - - #[test] - fn test_extract_errors_basic() { - let obs = make_observations(); - let idx_obs = Vector3::new(0, 1, 2); - - let (ra_errors, dec_errors) = obs.extract_errors(idx_obs); - - assert_ulps_eq!(ra_errors[0], 1e-6, max_ulps = 2); - assert_ulps_eq!(ra_errors[1], 3e-6, max_ulps = 2); - assert_ulps_eq!(ra_errors[2], 5e-6, max_ulps = 2); - - assert_ulps_eq!(dec_errors[0], 2e-6, max_ulps = 2); - assert_ulps_eq!(dec_errors[1], 4e-6, max_ulps = 2); - assert_ulps_eq!(dec_errors[2], 6e-6, max_ulps = 2); - } - - #[test] - #[should_panic(expected = "index out of bounds")] - fn test_extract_errors_out_of_bounds() { - let obs = make_observations(); - let idx_obs = Vector3::new(0, 1, 10); // 10 is out of bounds - let _ = obs.extract_errors(idx_obs); - } - } - - #[test] - #[cfg(feature = "jpl-download")] - fn test_estimate_best_orbit() { - use approx::assert_relative_eq; - use rand::{rngs::StdRng, SeedableRng}; - - use crate::{ - orbit_type::{ - keplerian_element::KeplerianElements, orbit_type_test::approx_equal, - OrbitalElements, - }, - unit_test_global::OUTFIT_HORIZON_TEST, - }; - - let mut traj_set = OUTFIT_HORIZON_TEST.1.clone(); - - let traj_number = crate::constants::ObjectNumber::String("K09R05F".into()); - let traj_len = traj_set - .get(&traj_number) - .expect("Failed to get trajectory") - .len(); - - let traj = traj_set - .get_mut(&traj_number) - .expect("Failed to get trajectory"); - - let mut rng = StdRng::seed_from_u64(42_u64); // seed for reproducibility - - let gap_max = 8.0 / 24.0; // 8 hours in days - - let params = IODParams { - n_noise_realizations: 5, - max_obs_for_triplets: traj_len, - gap_max, - ..Default::default() - }; - - let (best_orbit, best_rms) = traj - .estimate_best_orbit( - &OUTFIT_HORIZON_TEST.0, - &ErrorModel::FCCT14, - &mut rng, - ¶ms, - ) - .unwrap(); - - let binding = best_orbit; - let orbit = binding.get_orbit(); - - let expected_orbit = OrbitalElements::Keplerian(KeplerianElements { - reference_epoch: 57049.22904488294, - semi_major_axis: 1.801748431600605, - eccentricity: 0.283572284127787, - inclination: 0.20266779609836036, - ascending_node_longitude: 0.008022659889281067, - periapsis_argument: 1.245060173584828, - mean_anomaly: 0.44047943792316746, - }); - - assert!(approx_equal(orbit, &expected_orbit, 1e-14)); - assert_relative_eq!(best_rms, 55.14810894219461, epsilon = 1e-14); - } -} diff --git a/src/observations/triplets_generator.rs b/src/observations/triplets_generator.rs deleted file mode 100644 index 3ff4cb9..0000000 --- a/src/observations/triplets_generator.rs +++ /dev/null @@ -1,323 +0,0 @@ -//! # IOD Triplet Index Generator (lazy, windowed by time span) -//! -//! Streams **reduced indices** `(first, middle, last)` of time-feasible IOD triplets, -//! after sorting and downsampling the input observations. This is intended to be the -//! *index-level* counterpart of a Gauss triplet generator, allowing clients to compute -//! their own heuristics (e.g., a spacing weight) before materializing actual `GaussObs`. -//! -//! ## What “reduced indices” means -//! The generator first downsamples the full observation set to a smaller, **time-sorted** -//! subset (the “reduced” view). Indices yielded by the iterator refer to this reduced -//! view; use `selected_original_indices()` to map **reduced → original** indices. -//! -//! ## Feasibility constraints -//! For each yielded triplet `(first, middle, last)` with `first < middle < last`, the -//! following time-span constraint holds: -//! -//! ```text -//! dt_min ≤ t[last] - t[first] ≤ dt_max -//! ``` -//! -//! where `t[*]` are the **reduced** epochs (same units as your observation times, e.g. TT/MJD). -//! -//! ## Why this generator? -//! - **Lazy**: no intermediate `Vec` of triplets; you can start consuming immediately. -//! - **Better complexity**: a per-anchor two-pointer window on `last` reduces the -//! effective cost towards ~`O(n²)` in typical time distributions (vs. `O(n³)` brute force). -//! - **No overlapping borrows**: the generator **owns** its internal buffers (times, -//! mapping), so you can iterate without holding long-lived borrows of the input. -//! -//! ## Typical flow -//! 1. Build with `TripletIndexGenerator::from_observations(...)` (sorts + downsamples). -//! 2. Iterate lazily over `(i, j, k)` **reduced** indices. -//! 3. If needed, map to originals via `gen.selected_original_indices()[i]`. -//! 4. (Optional) Compute a heuristic (e.g., spacing weight) and keep the best-K with a heap. -//! 5. Only then materialize full `GaussObs` from the original observations. -//! -//! ## Invariants per anchor -//! - `first < middle < last` always holds. -//! - For the current `first`, the **feasible window** for `last` is -//! `[last_lower_bound_reduced_idx, last_upper_bound_reduced_idx]` such that -//! `dt_min ≤ t[last] - t[first] ≤ dt_max`. -//! - The initial `middle` is `first + 1` and the initial `last` is -//! `max(last_lower_bound_reduced_idx, middle + 1)`. -//! -//! ## Complexity -//! - Time: typically ~`O(n²)` (one window sweep per anchor). -//! - Space: `O(1)` per yielded triplet (the generator holds only the reduced times and mapping). -//! -//! ## Notes & pitfalls -//! - `dt_min`/`dt_max` must be in the **same time units** as your observation epochs. -//! - If `dt_min > dt_max` or there are fewer than 3 reduced observations, the iterator is empty. -//! - The generator **does not** impose any ordering by a heuristic (e.g., “optimal interval”); -//! it only ensures time-feasibility. Best-first strategies should be layered on top. -//! -//! ## See also -//! - A Gauss triplet generator that yields `GaussObs` and can be combined with Monte-Carlo -//! perturbations (`realizations_iter`). -//! - An IOD search routine (e.g., `estimate_best_orbit`) that consumes indices for early-stop. - -use crate::observations::{triplets_iod::downsample_uniform_with_edges_indices, Observations}; - -/// Stream-only generator of **reduced indices** `(first, middle, last)` -/// for time-feasible IOD triplets. -/// -/// Arguments -/// ----------------- -/// * `dt_min` – Minimum allowed time span between **first** and **last**. -/// * `dt_max` – Maximum allowed time span between **first** and **last**. -/// * `max_triplets_to_yield` – Optional cap on the number of yielded triplets (use `usize::MAX` for no cap). -/// -/// Return -/// ---------- -/// * Implements `Iterator` where the tuple contains -/// **reduced** indices `(first, middle, last)`. -/// -/// See also -/// ------------ -/// * [`selected_original_indices`](TripletIndexGenerator::selected_original_indices) – Map reduced indices back to original ones. -/// * A `GaussObs`-level generator if you need full triplets instead of indices. -pub struct TripletIndexGenerator { - /// Map reduced index → original index (owned). - reduced_to_original_index: Vec, - /// Epochs of the reduced observations (same units as input times; owned). - reduced_epochs_tt_mjd: Vec, - - /// Current **first** (anchor) index in reduced space. - first_reduced_idx: usize, - /// Current **middle** index in reduced space. - middle_reduced_idx: usize, - /// Current **last** index in reduced space. - last_reduced_idx: usize, - - /// Lower bound (inclusive) for `last` given the current `first`. - last_lower_bound_reduced_idx: usize, - /// Upper bound (inclusive) for `last` given the current `first`. - last_upper_bound_reduced_idx: usize, - - /// Number of reduced observations. - reduced_len: usize, - - /// Time-window constraints on `(first, last)`. - dt_min: f64, - dt_max: f64, - - /// Count of triplets yielded so far (monotonic). - yielded_triplets_count: usize, - /// Hard cap on the number of triplets to yield. - max_triplets_to_yield: usize, -} - -impl TripletIndexGenerator { - /// Build a generator from a full observation set: - /// - Sorts by time (in-place), - /// - Downsamples to at most `max_obs_for_triplets`, - /// - Caches reduced epochs and the reduced→original mapping (both **owned**), - /// - Positions on the first feasible window if any. - /// - /// Arguments - /// ----------------- - /// * `observations` – The full set; will be **sorted by time in place**. - /// * `dt_min`, `dt_max` – Time-span constraints on `(first, last)`. - /// * `max_obs_for_triplets` – Downsampling cap (uniform with edges). - /// * `max_triplets_to_yield` – Upper bound on yielded triplets (`usize::MAX` for no cap). - /// - /// Return - /// ---------- - /// * A `TripletIndexGenerator` positioned at the first feasible window, - /// or “empty” if fewer than 3 reduced observations remain. - /// - /// See also - /// ------------ - /// * [`TripletIndexGenerator::selected_original_indices`] - /// * [`TripletIndexGenerator::reduced_times`] - pub fn from_observations( - observations: &mut Observations, - dt_min: f64, - dt_max: f64, - max_obs_for_triplets: usize, - max_triplets_to_yield: usize, - ) -> Self { - // 1) Sort by epoch (ascending) - observations.sort_by(|a, b| a.time.partial_cmp(&b.time).unwrap()); - - // 2) Downsample → keep indices only (no long borrows) - let reduced_to_original_index = - downsample_uniform_with_edges_indices(observations.len(), max_obs_for_triplets); - - // 3) Cache reduced epochs (owned) aligned with reduced indices - let reduced_epochs_tt_mjd: Vec = reduced_to_original_index - .iter() - .map(|&orig| observations[orig].time) - .collect(); - - let reduced_len = reduced_epochs_tt_mjd.len(); - - // Initialize; the precise window is set by `init_last_window_for_first` - let mut gen = Self { - reduced_to_original_index, - reduced_epochs_tt_mjd, - first_reduced_idx: 0, - middle_reduced_idx: 1, - last_reduced_idx: 2, - last_lower_bound_reduced_idx: 2, - last_upper_bound_reduced_idx: 1, // sentinel; will be recomputed - reduced_len, - dt_min, - dt_max, - yielded_triplets_count: 0, - max_triplets_to_yield, - }; - - if gen.reduced_len >= 3 { - gen.init_last_window_for_first(); - } - gen - } - - /// Access the reduced→original index mapping. - /// - /// Return - /// ---------- - /// * A slice of original indices; `selected_original_indices()[r]` maps reduced `r` → original. - /// - /// See also - /// ------------ - /// * [`TripletIndexGenerator::reduced_times`] - pub fn selected_original_indices(&self) -> &[usize] { - &self.reduced_to_original_index - } - - /// Access the reduced epochs (TT/MJD). - /// - /// Return - /// ---------- - /// * A slice of epochs aligned with reduced indices. - pub fn reduced_times(&self) -> &[f64] { - &self.reduced_epochs_tt_mjd - } - - /// Recompute the feasible `last` window `[lower, upper]` for the current `first`. - /// - /// Invariants - /// ----------------- - /// * `lower` is the earliest `last` with `t[last] - t[first] ≥ dt_min`. - /// * `upper` is the latest `last` with `t[last] - t[first] ≤ dt_max`. - /// * `middle = first + 1`. - /// * `last = max(lower, middle + 1)`. - fn init_last_window_for_first(&mut self) { - let first = self.first_reduced_idx; - - // Lower bound (earliest last satisfying the min span; need one middle in (first, last)) - let mut lower = first + 2; - while lower < self.reduced_len - && (self.reduced_epochs_tt_mjd[lower] - self.reduced_epochs_tt_mjd[first]) < self.dt_min - { - lower += 1; - } - - // Upper bound (latest last satisfying the max span) - let mut upper = lower.saturating_sub(1).max(first + 1); - while (upper + 1) < self.reduced_len - && (self.reduced_epochs_tt_mjd[upper + 1] - self.reduced_epochs_tt_mjd[first]) - <= self.dt_max - { - upper += 1; - } - - self.last_lower_bound_reduced_idx = lower; - self.last_upper_bound_reduced_idx = upper; - - self.middle_reduced_idx = first + 1; - self.last_reduced_idx = self - .last_lower_bound_reduced_idx - .max(self.middle_reduced_idx + 1); - } - - /// Move to the next `first` anchor and refresh its feasible `last` window. - /// - /// Return - /// ---------- - /// * `true` if there are still enough reduced observations to form a triplet; `false` otherwise. - fn advance_first_anchor(&mut self) -> bool { - self.first_reduced_idx += 1; - if self.first_reduced_idx + 2 >= self.reduced_len { - return false; - } - self.init_last_window_for_first(); - true - } - - /// Whether the current `(first, middle, last)` window is empty. - /// - /// Return - /// ---------- - /// * `true` if the time-feasible window is empty or invalid; `false` otherwise. - fn last_window_is_empty(&self) -> bool { - self.last_lower_bound_reduced_idx >= self.reduced_len - || self.last_lower_bound_reduced_idx <= self.first_reduced_idx + 1 - || self.last_upper_bound_reduced_idx <= self.first_reduced_idx + 1 - || self.last_lower_bound_reduced_idx > self.last_upper_bound_reduced_idx - } -} - -impl Iterator for TripletIndexGenerator { - type Item = (usize, usize, usize); // reduced indices (first, middle, last) - - fn next(&mut self) -> Option { - // Respect the optional global cap. - if self.yielded_triplets_count >= self.max_triplets_to_yield { - return None; - } - - while self.first_reduced_idx + 2 < self.reduced_len { - // If the current last-window is empty, move to the next anchor. - if self.last_window_is_empty() { - if !self.advance_first_anchor() { - return None; - } - continue; - } - - // If `middle` reached the upper bound, switch to the next `first`. - if self.middle_reduced_idx >= self.last_upper_bound_reduced_idx { - if !self.advance_first_anchor() { - return None; - } - continue; - } - - // Ensure `last` is within the feasible window and respects `middle < last`. - if self.last_reduced_idx < self.last_lower_bound_reduced_idx - || self.last_reduced_idx <= self.middle_reduced_idx - { - self.last_reduced_idx = self - .last_lower_bound_reduced_idx - .max(self.middle_reduced_idx + 1); - } - - // If `last` exceeded the window, advance `middle` and reset `last`. - if self.last_reduced_idx > self.last_upper_bound_reduced_idx { - self.middle_reduced_idx += 1; - self.last_reduced_idx = self - .last_lower_bound_reduced_idx - .max(self.middle_reduced_idx + 1); - continue; - } - - // We have a feasible triplet (first, middle, last). - let i = self.first_reduced_idx; - let j = self.middle_reduced_idx; - let k = self.last_reduced_idx; - - // Prepare the next candidate by advancing `last`. - self.last_reduced_idx += 1; - - self.yielded_triplets_count += 1; - return Some((i, j, k)); - } - - // No more anchors → enumeration complete. - None - } -} diff --git a/src/observer_extension.rs b/src/observer_extension.rs new file mode 100644 index 0000000..17cdaea --- /dev/null +++ b/src/observer_extension.rs @@ -0,0 +1,191 @@ +use hifitime::{ut1::Ut1Provider, Epoch}; +use nalgebra::Vector3; +use ordered_float::NotNan; +use photom::{constants::ERAU, observer::Observer}; + +use crate::{ + cache::{ + observer_centric_cache::{ + ObserverGeocentricPosition, ObserverGeocentricVelocity, ObserverHeliocentricPosition, + }, + observer_fixed_cache::{ObserverFixedCache, ObserverFixedPosition, ObserverFixedVelocity}, + }, + constants::{EARTH_ROTATION, ROT_ECLMJ2000_TO_EQUMJ2000}, + conversion::ToNotNan, + earth_orientation::equequ, + ref_system::{rotmt, rotpn, RefEpoch, RefSystem}, + time::gmst, + JPLEphem, OutfitError, +}; + +pub trait ResolvedObserver { + /// Get the fixed position of an observatory using its geographic coordinates + /// + /// Return + /// ------ + /// * observer fixed coordinates vector on the Earth (not corrected from Earth motion) + /// * units is AU + fn earth_fixed_position(&self) -> Result; + + /// Get the fixed velocity of an observatory due to Earth rotation, using its geographic coordinates + /// + /// Return + /// ------ + /// * observer fixed velocity vector due to Earth rotation, in the Earth-fixed frame (not corrected from Earth motion) + /// * units is AU/day + fn earth_fixed_velocity(&self) -> Result; + + /// Compute the observer’s geocentric position and velocity in the ecliptic J2000 frame. + /// + /// This function calculates the position and velocity of a ground-based observer relative to the Earth's + /// center of mass, accounting for Earth rotation (via GMST), nutation, and the observer’s geographic location. + /// The result is expressed in the ecliptic mean J2000 frame, suitable for use in orbital initial determination. + /// + /// Arguments + /// --------- + /// * `observer`: a reference to an [`Observer`] containing the site longitude and parallax parameters. + /// * `tmjd`: observation epoch as a [`hifitime::Epoch`] in TT. + /// * `ut1_provider`: a reference to a [`hifitime::ut1::Ut1Provider`] for accurate UT1 conversion. + /// + /// Returns + /// -------- + /// * `(dx, dv)` – Tuple of: + /// - `dx`: observer geocentric position vector in ecliptic mean J2000 frame \[AU\]. + /// - `dv`: observer velocity vector due to Earth's rotation, in the same frame \[AU/day\]. + /// + /// Remarks + /// ------- + /// * Internally, this function: + /// 1. get the body-fixed coordinates of the observer. + /// 2. get its rotational velocity: `v = ω × r`. + /// 3. Applies Earth orientation corrections using: + /// - Greenwich Mean Sidereal Time (GMST), + /// - Equation of the equinoxes, + /// - Precession and nutation transformation (`rotpn`). + /// 4. Returns position and velocity in the J2000 ecliptic frame (used in classical orbital mechanics). + /// + /// # See also + /// * [`Observer::body_fixed_coord`] – observer's base vector in Earth-fixed frame + /// * [`rotpn`] – rotation between reference frames + /// * [`gmst`], [`equequ`] – time-dependent Earth orientation + fn pvobs( + tmjd: &Epoch, + ut1_provider: &Ut1Provider, + observer_fixed_vectors: &ObserverFixedCache, + ) -> Result<(ObserverGeocentricPosition, ObserverGeocentricVelocity), OutfitError>; + + /// Compute the observer’s heliocentric position in the **equatorial mean J2000** frame. + /// + /// This method forms the full heliocentric position of the observing site by combining: + /// - the site **geocentric** position vector at `epoch`, and + /// - the Earth’s **heliocentric** position from the JPL ephemerides. + /// + /// The input geocentric vector is assumed to be expressed in the **ecliptic mean J2000** frame + /// (AU). It is rotated to **equatorial mean J2000**, then added to Earth’s heliocentric + /// position (also in equatorial mean J2000). + /// + /// Arguments + /// ----------------- + /// * `jpl` – [`JPLEphem`] providing Earth's heliocentric state. + /// * `epoch` – Observation epoch in the **TT** time scale. + /// * `observer_geocentric_position` – Geocentric site position **in ecliptic mean J2000** (AU). + /// + /// Return + /// ---------- + /// * `Result` – Observer’s **heliocentric** position at `epoch`, + /// in **AU**, expressed in **equatorial mean J2000**. + /// + /// Remarks + /// ------------- + /// * If your geocentric site vector is already in **equatorial** J2000, rotate it to + /// **ecliptic** before calling this method, or adapt the rotation accordingly. + /// * This routine is typically used internally when constructing per-observation geometry + /// (e.g., within `Observation::new`), ensuring consistent frames for Gauss IOD. + /// + /// See also + /// ------------ + /// * [`Observer::pvobs`] – Geocentric position (and velocity) of the site at `epoch`. + /// * [`Outfit::get_jpl_ephem`] – Access Earth’s heliocentric state from JPL ephemerides. + /// * [`Outfit::get_rot_eclmj2000_to_equmj2000`] – Rotation between ecliptic and equatorial J2000. + fn helio_position( + jpl: &JPLEphem, + epoch: &Epoch, + observer_geocentric_position: &ObserverGeocentricPosition, + ) -> Result; +} + +impl ResolvedObserver for Observer { + fn earth_fixed_position(&self) -> Result { + let (sin_lon, cos_lon): (NotNan, NotNan) = { + let (s, c) = self.longitude.sin_cos(); + (s.to_notnan()?, c.to_notnan()?) + }; + let erau_not_nan = ERAU.to_notnan()?; + + Ok(Vector3::new( + erau_not_nan * self.rho_cos_phi * cos_lon, + erau_not_nan * self.rho_cos_phi * sin_lon, + erau_not_nan * self.rho_sin_phi, + )) + } + + #[inline] + fn earth_fixed_velocity(&self) -> Result { + Ok(EARTH_ROTATION + .to_notnan()? + .cross(&self.earth_fixed_position()?)) + } + + fn pvobs( + tmjd: &Epoch, + ut1_provider: &Ut1Provider, + observer_fixed_vectors: &ObserverFixedCache, + ) -> Result<(ObserverGeocentricPosition, ObserverGeocentricVelocity), OutfitError> { + // Get observer position and velocity in the Earth-fixed frame + let dxbf = observer_fixed_vectors.position(); + let dvbf = observer_fixed_vectors.velocity(); + + // deviation from Orbfit, use of another conversion from MJD UTC (ET scale) to UT1 scale + // based on the hifitime crate + let mjd_ut1 = tmjd.to_ut1(ut1_provider); + let tut = mjd_ut1.to_mjd_tai_days(); + + // Compute the Greenwich sideral apparent time + let gast = gmst(tut) + equequ(tmjd.to_mjd_tt_days()); + + // Earth rotation matrix + let rot = rotmt(-gast, 2); + + // Compute the rotation matrix from equatorial mean J2000 to ecliptic mean J2000 + let rer_sys1 = RefSystem::Equt(RefEpoch::Epoch(tmjd.to_mjd_tt_days())); + let rer_sys2 = RefSystem::Eclm(RefEpoch::J2000); + let rot1 = rotpn(&rer_sys1, &rer_sys2)?; + + let rot1_mat = rot1.transpose().to_notnan()?; + let rot_mat = rot.transpose().to_notnan()?; + + let rotmat = rot1_mat * rot_mat; + + // Apply transformation to the observer position and velocity + let dx = rotmat * dxbf; + let dv = rotmat * dvbf; + + Ok((dx, dv)) + } + + fn helio_position( + jpl: &JPLEphem, + epoch: &Epoch, + observer_geocentric_position: &ObserverGeocentricPosition, + ) -> Result { + // Earth's heliocentric position + let earth_pos = jpl.earth_ephemeris(epoch, false).0.to_notnan()?; + + // Transform observer position from ecliptic to equatorial J2000 + let rot_matrix = ROT_ECLMJ2000_TO_EQUMJ2000.to_notnan()?.transpose(); + + let helio_pos = earth_pos + rot_matrix * observer_geocentric_position; + + Ok(helio_pos) + } +} diff --git a/src/observers/bimap.rs b/src/observers/bimap.rs deleted file mode 100644 index dd9ec03..0000000 --- a/src/observers/bimap.rs +++ /dev/null @@ -1,224 +0,0 @@ -use std::collections::HashMap; -use std::hash::Hash; - -#[derive(Debug, Clone)] -pub struct BiMap -where - K: Eq + Hash + Clone, - V: Eq + Hash + Clone, -{ - forward: HashMap, - reverse: HashMap, -} - -impl Default for BiMap -where - K: Eq + Hash + Clone, - V: Eq + Hash + Clone, -{ - fn default() -> Self { - Self::new() - } -} - -impl BiMap -where - K: Eq + Hash + Clone, - V: Eq + Hash + Clone, -{ - pub fn new() -> Self { - Self { - forward: HashMap::new(), - reverse: HashMap::new(), - } - } - - pub fn entry_or_insert_by_key(&mut self, key: K, value: V) -> &mut V { - self.forward.entry(key.clone()).or_insert_with(|| { - self.reverse.insert(value.clone(), key); - value - }) - } - - pub fn entry_or_insert_by_value(&mut self, value: V, key: K) -> &mut K { - self.reverse.entry(value.clone()).or_insert_with(|| { - self.forward.insert(key.clone(), value); - key - }) - } - - pub fn insert(&mut self, key: K, value: V) { - self.forward.insert(key.clone(), value.clone()); - self.reverse.insert(value, key); - } - - pub fn get_by_key(&self, key: &K) -> Option<&V> { - self.forward.get(key) - } - - pub fn get_by_value(&self, value: &V) -> Option<&K> { - self.reverse.get(value) - } - - pub fn remove_by_key(&mut self, key: &K) { - if let Some(val) = self.forward.remove(key) { - self.reverse.remove(&val); - } - } - - pub fn remove_by_value(&mut self, value: &V) { - if let Some(key) = self.reverse.remove(value) { - self.forward.remove(&key); - } - } - - pub fn len(&self) -> usize { - self.forward.len() - } - - pub fn is_empty(&self) -> bool { - self.len() == 0 - } - - // --------------------------- - // Iteration helpers (immutable) - // --------------------------- - - /// Iterate over (&K, &V) using the forward map. - /// - /// Return - /// ---------- - /// * An iterator yielding `(&K, &V)` pairs. Order is not guaranteed. - /// - /// See also - /// ------------ - /// * [`BiMap::iter_rev`] - /// * [`BiMap::keys`], [`BiMap::values`] - pub fn iter(&self) -> impl Iterator { - self.forward.iter() - } - - /// Iterate over (&V, &K) using the reverse map. - /// - /// Return - /// ---------- - /// * An iterator yielding `(&V, &K)` pairs. Order is not guaranteed. - /// - /// See also - /// ------------ - /// * [`BiMap::iter`] - pub fn iter_rev(&self) -> impl Iterator { - self.reverse.iter() - } - - /// Iterate over keys (&K). - pub fn keys(&self) -> impl Iterator { - self.forward.keys() - } - - /// Iterate over values (&V). - pub fn values(&self) -> impl Iterator { - self.forward.values() - } -} - -// --------------------------- -// IntoIterator implementations -// --------------------------- - -impl<'a, K, V> IntoIterator for &'a BiMap -where - K: Eq + Hash + Clone, - V: Eq + Hash + Clone, -{ - type Item = (&'a K, &'a V); - type IntoIter = std::collections::hash_map::Iter<'a, K, V>; - - /// Consume `&BiMap` into an iterator over `(&K, &V)` on the forward map. - fn into_iter(self) -> Self::IntoIter { - self.forward.iter() - } -} - -impl<'a, K, V> IntoIterator for &'a mut BiMap -where - K: Eq + Hash + Clone, - V: Eq + Hash + Clone, -{ - type Item = (&'a K, &'a mut V); - type IntoIter = std::collections::hash_map::IterMut<'a, K, V>; - - /// Consume `&mut BiMap` into an iterator over `(&K, &mut V)` on the forward map. - /// - /// Warning - /// ------- - /// Mutating values can desynchronize the reverse map if you change logical identity. - /// Use with care; prefer removing/re-inserting pairs instead. - fn into_iter(self) -> Self::IntoIter { - self.forward.iter_mut() - } -} - -impl IntoIterator for BiMap -where - K: Eq + Hash + Clone, - V: Eq + Hash + Clone, -{ - type Item = (K, V); - type IntoIter = std::collections::hash_map::IntoIter; - - /// Consume the bimap and iterate over owned `(K, V)` pairs using the forward map. - fn into_iter(self) -> Self::IntoIter { - self.forward.into_iter() - } -} - -#[cfg(test)] -mod bimap_iter_tests { - use super::*; - use std::collections::HashSet; - - #[test] - fn iter_and_iter_rev_cover_same_pairs() { - let mut m = BiMap::new(); - m.insert("a", 1); - m.insert("b", 2); - m.insert("c", 3); - - let fwd: HashSet<_> = m.iter().map(|(k, v)| ((*k).to_string(), *v)).collect(); - let rev: HashSet<_> = m.iter_rev().map(|(v, k)| ((*k).to_string(), *v)).collect(); - assert_eq!(fwd, rev); - } - - #[test] - fn into_iterator_by_ref_and_by_value() { - let mut m = BiMap::new(); - m.insert("x", 10); - m.insert("y", 20); - - // &BiMap - let pairs_ref: HashSet<_> = (&m) - .into_iter() - .map(|(k, v)| ((*k).to_string(), *v)) - .collect(); - assert!(pairs_ref.contains(&("x".to_string(), 10))); - assert!(pairs_ref.contains(&("y".to_string(), 20))); - - // BiMap (by value) - let pairs_val: HashSet<_> = m.into_iter().collect(); - assert!(pairs_val.contains(&("x", 10))); - assert!(pairs_val.contains(&("y", 20))); - // m is moved here; no further use - } - - #[test] - fn keys_and_values_match_len() { - let mut m = BiMap::new(); - m.insert(1, "one"); - m.insert(2, "two"); - m.insert(3, "three"); - - assert_eq!(m.len(), m.keys().count()); - assert_eq!(m.len(), m.values().count()); - } -} diff --git a/src/observers/mod.rs b/src/observers/mod.rs deleted file mode 100644 index eb0412d..0000000 --- a/src/observers/mod.rs +++ /dev/null @@ -1,1140 +0,0 @@ -//! # Observer & Site Geometry (top-level module) -//! -//! This module gathers **observer/site handling** and associated geometry utilities used in -//! orbit determination. It provides: -//! -//! - A robust [`Observer`](crate::observers::Observer) type storing **geocentric parallax coordinates** (ρ·cosφ, ρ·sinφ), -//! geodetic longitude, optional astrometric accuracies, and **precomputed body-fixed** position -//! and velocity vectors. -//! - High-level routines to compute the observer’s **geocentric PV** in the ecliptic J2000 frame -//! ([`Observer::pvobs`](crate::observers::Observer::pvobs)) and its **heliocentric equatorial position** ([`Observer::helio_position`](crate::observers::Observer::helio_position)). -//! - Helpers to convert geodetic latitude/elevation to normalized parallax coordinates -//! ([`geodetic_to_parallax`](crate::observers::geodetic_to_parallax)) and to lift optional floats into NaN-safe values -//! ([`to_opt_notnan`](crate::observers::to_opt_notnan)). -//! - A convenience function to compute **three observers’ heliocentric positions** at three epochs -//! in one call ([`helio_obs_pos`](crate::observers::helio_obs_pos)) — useful for Gauss/Vaisala IOD triplets. -//! -//! ## Frames & conventions -//! -//! - **Earth-fixed (body-fixed)**: the frame in which site coordinates and rotation rate are defined. -//! - **Ecliptic mean J2000**: default geocentric output of [`Observer::pvobs`](crate::observers::Observer::pvobs) (ICRS ecliptic plane). -//! - **Equatorial mean J2000**: default heliocentric output of [`Observer::helio_position`](crate::observers::Observer::helio_position) and -//! [`helio_obs_pos`](crate::observers::helio_obs_pos) (ICRS-aligned). -//! -//! ```text -//! Body-fixed --(Earth rotation)--> Earth-equatorial --(precession+nutation)--> Ecliptic J2000 -//! \-> Equatorial J2000 -//! ``` -//! -//! Internally, time-dependent Earth orientation uses **GMST** and the **equation of the equinoxes**, -//! and frame changes are performed via [`rotpn`](crate::ref_system::rotpn) / [`rotmt`](crate::ref_system::rotmt) utilities. -//! -//! ## Units -//! -//! - Longitudes: **degrees** (east positive). -//! - Geocentric parallax (ρ·cosφ, ρ·sinφ): **Earth radii** (dimensionless scaling of geocentric distance). -//! - Positions: **AU**. -//! - Velocities: **AU/day** (from `ω × r`, with `ω = (0, 0, 2π·1.00273790934)` rad/day). -//! - `ra_accuracy`, `dec_accuracy`: **radians**. -//! -//! ## Data flow (typical IOD usage) -//! -//! 1. Build an [`Observer`](crate::observers::Observer) from geodetic inputs (`longitude`, `latitude`, `elevation`) via -//! [`Observer::new`](crate::observers::Observer::new) (internally calls [`geodetic_to_parallax`](crate::observers::geodetic_to_parallax)) **or** from known (ρ·cosφ, ρ·sinφ) -//! via [`Observer::from_parallax`](crate::observers::Observer::from_parallax). -//! 2. Compute geocentric PV in **ecliptic J2000** with [`Observer::pvobs`](crate::observers::Observer::pvobs) (needs UT1 provider). -//! 3. Obtain Earth heliocentric state from JPL ephemerides and sum to get the observer’s -//! **heliocentric equatorial** position with [`Observer::helio_position`](crate::observers::Observer::helio_position). -//! 4. For triplets, call [`helio_obs_pos`](crate::observers::helio_obs_pos) to get the 3×3 matrix of heliocentric positions. -//! -//! ## Quick start -//! -//! ```rust,no_run -//! use hifitime::{Epoch, TimeScale}; -//! use nalgebra::{Vector3, Matrix3}; -//! use outfit::outfit::Outfit; -//! use outfit::error_models::ErrorModel; -//! use outfit::observers::{Observer, helio_obs_pos}; -//! -//! // 1) Environment (JPL ephem + UT1) and site -//! let state = Outfit::new("horizon:DE440", ErrorModel::FCCT14)?; -//! let site = Observer::new(203.74409, 20.707233557, 3067.694, Some("Pan-STARRS 1".into()), None, None)?; -//! -////! // 2) Geocentric PV (ecliptic J2000) -//! let t = Epoch::from_mjd_in_time_scale(57028.479297592596, TimeScale::TT); -//! let (_x_ecl, _v_ecl) = site.pvobs(&t, state.get_ut1_provider())?; -//! -//! // 3) Heliocentric position (equatorial J2000) -//! let r_helio_eq = site.helio_position(&state, &t, &Vector3::identity())?; -//! -//! // 4) Batch (3 observers, 3 epochs) -//! let tmjd = Vector3::new(57028.479297592596, 57049.24514759259, 57063.97711759259); -//! let R: Matrix3 = helio_obs_pos([&site, &site, &site], &tmjd, &state)?; -//! # Ok::<(), outfit::outfit_errors::OutfitError>(()) -//! ``` -//! -//! ## Design & invariants -//! -//! - [`Observer`](crate::observers::Observer) stores **precomputed body-fixed** position and velocity to avoid recomputing -//! `ω × r` and trigonometric terms at every call. This is beneficial in tight IOD loops. -//! - `NotNan` is used for fields where **NaN must be forbidden** (e.g., site geometry). -//! Use [`to_opt_notnan`](crate::observers::to_opt_notnan) for optional measurement accuracies. -//! - The geodetic-to-parallax conversion accounts for Earth oblateness via -//! [`EARTH_MAJOR_AXIS`](crate::constants::EARTH_MAJOR_AXIS) / [`EARTH_MINOR_AXIS`](crate::constants::EARTH_MINOR_AXIS). -//! -//! ## Errors -//! -//! - Constructors and helpers return [`OutfitError`](crate::outfit_errors::OutfitError) when NaNs are encountered or a frame -//! conversion fails; [`to_opt_notnan`](crate::observers::to_opt_notnan) returns `ordered_float::FloatIsNan` if given `Some(NaN)`. -//! -//! ## Testing -//! -//! The module includes unit tests for site construction, body-fixed coordinates, -//! geocentric PV against known values, and multi-epoch heliocentric positions (behind the -//! `jpl-download` feature). -//! -//! ## See also -//! ------------ -//! * [`Observer`](crate::observers::Observer) – Site container with precomputed body-fixed state. -//! * [`Observer::pvobs`](crate::observers::Observer::pvobs) – Geocentric PV in **ecliptic J2000**. -//! * [`Observer::helio_position`](crate::observers::Observer::helio_position) – Heliocentric **equatorial J2000** position. -//! * [`helio_obs_pos`](crate::observers::helio_obs_pos) – Batch heliocentric positions for triplets. -//! * [`geodetic_to_parallax`](crate::observers::geodetic_to_parallax) – Geodetic latitude/elevation → (ρ·cosφ, ρ·sinφ). -//! * [`rotpn`](crate::ref_system::rotpn), [`rotmt`](crate::ref_system::rotmt) – Reference-frame transformations. -//! * [`gmst`](crate::time::gmst), [`equequ`](crate::earth_orientation::equequ) – Earth orientation (sidereal time & equation of equinoxes). -//! * [`Outfit`](crate::outfit::Outfit) – Access to JPL ephemerides and UT1 provider. - -pub mod bimap; -pub mod observatories; - -use hifitime::ut1::Ut1Provider; -use hifitime::Epoch; -use nalgebra::{Matrix3, Vector3}; -use ordered_float::NotNan; - -use crate::constants::{Degree, Meter, EARTH_MAJOR_AXIS, EARTH_MINOR_AXIS, MJD}; -use crate::constants::{DPI, ERAU}; -use crate::earth_orientation::equequ; -use crate::outfit::Outfit; -use crate::outfit_errors::OutfitError; -use crate::ref_system::{rotmt, rotpn, RefEpoch, RefSystem}; -use crate::time::gmst; -use std::fmt; - -/// Convert an `Option` into an `Option>`, propagating `NaN` as an error. -/// -/// This helper lifts a possibly missing floating-point value into a `NotNan` container -/// while keeping the outer `Option`. If the inner value is `Some(x)` and `x.is_nan()`, -/// the function returns `Err(FloatIsNan)`. If it is `None`, the result is `Ok(None)`. -/// -/// Arguments -/// ----------------- -/// * `x`: The optional floating-point value to wrap. -/// -/// Return -/// ---------- -/// * A `Result` containing `Some(NotNan)` when `x` is finite, `Ok(None)` when `x` is `None`, -/// or an error if `x` is `NaN`. -/// -/// Errors -/// ---------- -/// * `ordered_float::FloatIsNan` if `x` is `Some(NaN)`. -/// -/// See also -/// ------------ -/// * [`ordered_float::NotNan`] – NaN-forbidding wrapper used across the crate. -#[inline] -pub fn to_opt_notnan(x: Option) -> Result>, ordered_float::FloatIsNan> { - x.map(NotNan::new).transpose() -} - -/// Observer geocentric parameters and precomputed body-fixed state. -/// -/// This struct stores: -/// - The observer's **geocentric parallax coordinates** (ρ·cosφ, ρ·sinφ), where ρ is the -/// geocentric distance in **Earth radii** and φ is the **geocentric** latitude. -/// - The **geodetic longitude** (degrees, east of Greenwich). -/// - Optional **astrometric accuracies** for right ascension and declination (radians). -/// - Precomputed **body-fixed** position and velocity vectors used in orbit determination. -/// -/// Units -/// ----- -/// * `longitude`: degrees (east positive). -/// * `rho_cos_phi`, `rho_sin_phi`: Earth radii (dimensionless scale factor ρ times trig of φ). -/// * `observer_fixed_coord`: astronomical units (AU). -/// * `observer_velocity`: AU/day (from Earth rotation cross product). -/// * `ra_accuracy`, `dec_accuracy`: radians. -/// -/// Notes -/// ----- -/// The precomputed body-fixed vectors assume a constant Earth rotation rate -/// ω = (0, 0, 2π·1.00273790934) rad/day. Position is scaled by `ERAU` (Earth radius in AU), -/// hence the resulting velocity is in AU/day. -/// -/// See also -/// ------------ -/// * [`geodetic_to_parallax`] – Converts geodetic latitude/elevation to (ρ·cosφ, ρ·sinφ). -/// * [`Observer::new`] – Construct from geodetic longitude/latitude/elevation. -/// * [`Observer::from_parallax`] – Construct directly from (ρ·cosφ, ρ·sinφ). -/// * [`crate::ref_system::rotpn`] – Reference frame rotations used elsewhere in the pipeline. -#[derive(Debug, PartialEq, Eq, Hash, Clone)] -pub struct Observer { - /// Geodetic longitude in **degrees** east of Greenwich. - pub longitude: NotNan, - - /// ρ·cosφ (geocentric latitude φ), in **Earth radii** (dimensionless scale). - pub rho_cos_phi: NotNan, - - /// ρ·sinφ (geocentric latitude φ), in **Earth radii** (dimensionless scale). - pub rho_sin_phi: NotNan, - - /// Optional human-readable site name. - pub name: Option, - - /// Right ascension measurement accuracy, in **radians** (optional). - pub ra_accuracy: Option>, - - /// Declination measurement accuracy, in **radians** (optional). - pub dec_accuracy: Option>, - - /// Precomputed **body-fixed** position of the observer in **AU**. - observer_fixed_coord: Vector3>, - - /// Precomputed **body-fixed** velocity of the observer in **AU/day**. - observer_velocity: Vector3>, -} - -impl Observer { - /// Create a new observer from geodetic coordinates. - /// - /// This constructor converts `(latitude, elevation)` into geocentric parallax - /// coordinates `(ρ·cosφ, ρ·sinφ)` using [`geodetic_to_parallax`], builds the - /// body-fixed position vector in **AU** (scaled by `ERAU`), and computes the - /// body-fixed velocity as `ω × r` with `ω = (0, 0, 2π·1.00273790934)` in rad/day, - /// yielding **AU/day**. - /// - /// Arguments - /// ----------------- - /// * `longitude`: Geodetic longitude in **degrees** (east positive). - /// * `latitude`: Geodetic latitude in **degrees**. - /// * `elevation`: Height above the reference ellipsoid in **meters**. - /// * `name`: Optional site name. - /// * `ra_accuracy`: Optional RA accuracy in **radians**. - /// * `dec_accuracy`: Optional DEC accuracy in **radians**. - /// - /// Return - /// ---------- - /// * A constructed [`Observer`] with precomputed body-fixed state. - /// - /// Errors - /// ---------- - /// * [`OutfitError`] if the inputs cannot be represented as `NotNan` (e.g., NaN encountered). - /// - /// See also - /// ------------ - /// * [`geodetic_to_parallax`] – Geodetic-to-geocentric parallax conversion. - /// * [`Observer::from_parallax`] – Build directly from (ρ·cosφ, ρ·sinφ). - /// * [`crate::ref_system::rotpn`] – Frame rotation utilities used later in the pipeline. - pub fn new( - longitude: Degree, - latitude: Degree, - elevation: Meter, - name: Option, - ra_accuracy: Option, - dec_accuracy: Option, - ) -> Result { - let (rho_cos_phi, rho_sin_phi) = geodetic_to_parallax(latitude, elevation); - - // Angular velocity of Earth rotation (rad/day) on the z-axis. - let omega: Vector3> = Vector3::new( - NotNan::new(0.0)?, - NotNan::new(0.0)?, - NotNan::new(DPI * 1.00273790934)?, - ); - - // Body-fixed position in AU from (ρ·cosφ, ρ·sinφ) scaled by Earth radius (AU). - let lon_radians = longitude.to_radians(); - let body_fixed_coord: Vector3> = Vector3::new( - NotNan::new(ERAU * rho_cos_phi * lon_radians.cos())?, - NotNan::new(ERAU * rho_cos_phi * lon_radians.sin())?, - NotNan::new(ERAU * rho_sin_phi)?, - ); - - // Body-fixed velocity from Earth rotation. - let dvbf = omega.cross(&body_fixed_coord); - - Ok(Observer { - longitude: NotNan::try_from(longitude)?, - rho_cos_phi: NotNan::try_from(rho_cos_phi)?, - rho_sin_phi: NotNan::try_from(rho_sin_phi)?, - name, - ra_accuracy: to_opt_notnan(ra_accuracy)?, - dec_accuracy: to_opt_notnan(dec_accuracy)?, - observer_fixed_coord: body_fixed_coord, - observer_velocity: dvbf, - }) - } - - /// Create a new observer from geocentric parallax coordinates. - /// - /// This constructor skips the geodetic-to-parallax conversion and directly uses - /// `(ρ·cosφ, ρ·sinφ)` to build the body-fixed position in **AU** (scaled by `ERAU`), - /// and the body-fixed velocity in **AU/day** as `ω × r` with - /// `ω = (0, 0, 2π·1.00273790934)` rad/day. - /// - /// Arguments - /// ----------------- - /// * `longitude`: Geodetic longitude in **degrees** (east positive). - /// * `rho_cos_phi`: ρ·cosφ in **Earth radii** (dimensionless). - /// * `rho_sin_phi`: ρ·sinφ in **Earth radii** (dimensionless). - /// * `name`: Optional site name. - /// * `ra_accuracy`: Optional RA accuracy in **radians**. - /// * `dec_accuracy`: Optional DEC accuracy in **radians**. - /// - /// Return - /// ---------- - /// * A constructed [`Observer`] with precomputed body-fixed state. - /// - /// Errors - /// ---------- - /// * [`OutfitError`] if inputs cannot be represented as `NotNan` (e.g., NaN encountered). - /// - /// See also - /// ------------ - /// * [`Observer::new`] – Build from geodetic latitude and elevation. - /// * [`geodetic_to_parallax`] – For the forward conversion when geodetic inputs are available. - pub fn from_parallax( - longitude: Degree, - rho_cos_phi: f64, - rho_sin_phi: f64, - name: Option, - ra_accuracy: Option, - dec_accuracy: Option, - ) -> Result { - // Angular velocity of Earth rotation (rad/day) on the z-axis. - let omega: Vector3> = Vector3::new( - NotNan::new(0.0)?, - NotNan::new(0.0)?, - NotNan::new(DPI * 1.00273790934)?, - ); - - // Body-fixed position in AU from (ρ·cosφ, ρ·sinφ) scaled by Earth radius (AU). - let lon_radians = longitude.to_radians(); - let body_fixed_coord: Vector3> = Vector3::new( - NotNan::new(ERAU * rho_cos_phi * lon_radians.cos())?, - NotNan::new(ERAU * rho_cos_phi * lon_radians.sin())?, - NotNan::new(ERAU * rho_sin_phi)?, - ); - - // Body-fixed velocity from Earth rotation. - let dvbf = omega.cross(&body_fixed_coord); - - Ok(Observer { - longitude: NotNan::try_from(longitude)?, - rho_cos_phi: NotNan::try_from(rho_cos_phi)?, - rho_sin_phi: NotNan::try_from(rho_sin_phi)?, - name, - ra_accuracy: to_opt_notnan(ra_accuracy)?, - dec_accuracy: to_opt_notnan(dec_accuracy)?, - observer_fixed_coord: body_fixed_coord, - observer_velocity: dvbf, - }) - } - - /// Get the fixed position of an observatory using its geographic coordinates - /// - /// Argument - /// -------- - /// * longitude: observer longitude in Degree - /// * latitude: observer latitude in Degree - /// * height: observer height in Degree - /// - /// Return - /// ------ - /// * observer fixed coordinates vector on the Earth (not corrected from Earth motion) - /// * units is AU - pub fn body_fixed_coord(&self) -> Vector3 { - let lon_radians = self.longitude.to_radians(); - - Vector3::new( - ERAU * self.rho_cos_phi.into_inner() * lon_radians.cos(), - ERAU * self.rho_cos_phi.into_inner() * lon_radians.sin(), - ERAU * self.rho_sin_phi.into_inner(), - ) - } - - /// Compute the observer’s geocentric position and velocity in the ecliptic J2000 frame. - /// - /// This function calculates the position and velocity of a ground-based observer relative to the Earth's - /// center of mass, accounting for Earth rotation (via GMST), nutation, and the observer’s geographic location. - /// The result is expressed in the ecliptic mean J2000 frame, suitable for use in orbital initial determination. - /// - /// Arguments - /// --------- - /// * `observer`: a reference to an [`Observer`] containing the site longitude and parallax parameters. - /// * `tmjd`: observation epoch as a [`hifitime::Epoch`] in TT. - /// * `ut1_provider`: a reference to a [`hifitime::ut1::Ut1Provider`] for accurate UT1 conversion. - /// - /// Returns - /// -------- - /// * `(dx, dv)` – Tuple of: - /// - `dx`: observer geocentric position vector in ecliptic mean J2000 frame \[AU\]. - /// - `dv`: observer velocity vector due to Earth's rotation, in the same frame \[AU/day\]. - /// - /// Remarks - /// ------- - /// * Internally, this function: - /// 1. get the body-fixed coordinates of the observer. - /// 2. get its rotational velocity: `v = ω × r`. - /// 3. Applies Earth orientation corrections using: - /// - Greenwich Mean Sidereal Time (GMST), - /// - Equation of the equinoxes, - /// - Precession and nutation transformation (`rotpn`). - /// 4. Returns position and velocity in the J2000 ecliptic frame (used in classical orbital mechanics). - /// - /// # See also - /// * [`Observer::body_fixed_coord`] – observer's base vector in Earth-fixed frame - /// * [`rotpn`] – rotation between reference frames - /// * [`gmst`], [`equequ`] – time-dependent Earth orientation - pub fn pvobs( - &self, - tmjd: &Epoch, - ut1_provider: &Ut1Provider, - ) -> Result<(Vector3, Vector3), OutfitError> { - // Get observer position and velocity in the Earth-fixed frame - let dxbf = self.observer_fixed_coord.map(|x| x.into_inner()); - let dvbf = self.observer_velocity.map(|x| x.into_inner()); - - // deviation from Orbfit, use of another conversion from MJD UTC (ET scale) to UT1 scale - // based on the hifitime crate - let mjd_ut1 = tmjd.to_ut1(ut1_provider); - let tut = mjd_ut1.to_mjd_tai_days(); - - // Compute the Greenwich sideral apparent time - let gast = gmst(tut) + equequ(tmjd.to_mjd_tt_days()); - - // Earth rotation matrix - let rot = rotmt(-gast, 2); - - // Compute the rotation matrix from equatorial mean J2000 to ecliptic mean J2000 - let rer_sys1 = RefSystem::Equt(RefEpoch::Epoch(tmjd.to_mjd_tt_days())); - let rer_sys2 = RefSystem::Eclm(RefEpoch::J2000); - let rot1 = rotpn(&rer_sys1, &rer_sys2)?; - - let rot1_mat = rot1.transpose(); - let rot_mat = rot.transpose(); - - let rotmat = rot1_mat * rot_mat; - - // Apply transformation to the observer position and velocity - let dx = rotmat * dxbf; - let dv = rotmat * dvbf; - - Ok((dx, dv)) - } - - /// Compute the observer’s heliocentric position in the **equatorial mean J2000** frame. - /// - /// This method forms the full heliocentric position of the observing site by combining: - /// - the site **geocentric** position vector at `epoch`, and - /// - the Earth’s **heliocentric** position from the JPL ephemerides. - /// - /// The input geocentric vector is assumed to be expressed in the **ecliptic mean J2000** frame - /// (AU). It is rotated to **equatorial mean J2000**, then added to Earth’s heliocentric - /// position (also in equatorial mean J2000). - /// - /// Arguments - /// ----------------- - /// * `state` – [`Outfit`] environment providing JPL ephemerides and frame rotations. - /// * `epoch` – Observation epoch in the **TT** time scale. - /// * `observer_geocentric_position` – Geocentric site position **in ecliptic mean J2000** (AU). - /// - /// Return - /// ---------- - /// * `Result, OutfitError>` – Observer’s **heliocentric** position at `epoch`, - /// in **AU**, expressed in **equatorial mean J2000**. - /// - /// Remarks - /// ------------- - /// * If your geocentric site vector is already in **equatorial** J2000, rotate it to - /// **ecliptic** before calling this method, or adapt the rotation accordingly. - /// * This routine is typically used internally when constructing per-observation geometry - /// (e.g., within `Observation::new`), ensuring consistent frames for Gauss IOD. - /// - /// See also - /// ------------ - /// * [`Observer::pvobs`] – Geocentric position (and velocity) of the site at `epoch`. - /// * [`Outfit::get_jpl_ephem`] – Access Earth’s heliocentric state from JPL ephemerides. - /// * [`Outfit::get_rot_eclmj2000_to_equmj2000`] – Rotation between ecliptic and equatorial J2000. - pub fn helio_position( - &self, - state: &Outfit, - epoch: &Epoch, - observer_geocentric_position: &Vector3, - ) -> Result, OutfitError> { - let jpl = state.get_jpl_ephem().unwrap(); - - // Earth's heliocentric position - let earth_pos = jpl.earth_ephemeris(epoch, false).0; - - // Transform observer position from ecliptic to equatorial J2000 - let rot_matrix = state.get_rot_eclmj2000_to_equmj2000().transpose(); - - Ok(earth_pos + rot_matrix * observer_geocentric_position) - } - - /// Recover geodetic latitude and ellipsoidal height (WGS-84) from parallax constants. - /// - /// Inverts the stored parallax coordinates `(ρ·cosφ, ρ·sinφ)` to the **geodetic** - /// latitude `φ` (degrees) and the ellipsoidal height `h` (meters) above the - /// WGS-84 reference ellipsoid. The computation uses **Bowring’s closed-form** - /// formula (no iteration), which is usually sufficient for double-precision - /// accuracy at the centimeter level or better. - /// - /// Units & model - /// ------------- - /// * Inputs: `ρ·cosφ` and `ρ·sinφ` are dimensionless, expressed in **Earth radii** - /// (normalized by the equatorial radius). They are scaled internally by `a` - /// (the equatorial radius) to meters. - /// * Output: latitude in **degrees**, height in **meters** (ellipsoidal height, not geoid/orthometric). - /// * Ellipsoid: WGS-84 radii from `constants.rs` (`EARTH_MAJOR_AXIS` = `a`, `EARTH_MINOR_AXIS` = `b`). - /// If you prefer exact GRS-80 reproduction, use consistent `b` there; the difference vs WGS-84 is sub-millimetric. - /// - /// Notes - /// ----- - /// * This routine **does not** compute the geodetic longitude; it only returns `(lat, h)`. - /// Your `Observer` already stores the geodetic longitude independently. - /// * Numerical robustness is good across latitudes, including near the poles. - /// * If you require bit-for-bit parity with an external reference using a different ellipsoid, - /// ensure `a`/`b` match that reference. - /// - /// Arguments - /// ----------------- - /// * None. - /// - /// Return - /// ---------- - /// * `(geodetic_latitude_deg, height_meters)` — latitude in degrees, ellipsoidal height in meters. - /// - /// See also - /// ------------ - /// * [`geodetic_to_parallax`] – Forward conversion used at construction. - /// * [`Observer::from_parallax`] – Builds an observer from `(ρ·cosφ, ρ·sinφ)`. - /// * `constants::EARTH_MAJOR_AXIS` / `constants::EARTH_MINOR_AXIS` – Ellipsoid radii used here. - pub fn geodetic_lat_height_wgs84(&self) -> (f64, f64) { - let a = EARTH_MAJOR_AXIS; - let b = EARTH_MINOR_AXIS; - let e2 = 1.0 - (b * b) / (a * a); - let ep2 = (a * a) / (b * b) - 1.0; - - let p = self.rho_cos_phi.into_inner() * a; // distance in equatorial plane [m] - let z = self.rho_sin_phi.into_inner() * a; // z [m] - - // Bowring’s formula: - let theta = (z * a).atan2(p * b); - let st = theta.sin(); - let ct = theta.cos(); - let phi = (z + ep2 * b * st.powi(3)).atan2(p - e2 * a * ct.powi(3)); - - let s = phi.sin(); - let n = a / (1.0 - e2 * s * s).sqrt(); - let h = p / phi.cos() - n; - - (phi.to_degrees(), h) - } -} - -impl fmt::Display for Observer { - /// Pretty-print an observer with optional verbose details using `{:#}` formatting. - /// - /// Default formatting (`{}`) prints a compact one-liner: - /// `Name (lon: XX.XXXXXX°, lat: YY.YYYYYY° geodetic, elev: Z.ZZ km)`. - /// - /// Alternate formatting (`{:#}`) prints a multi-line detailed block including: - /// - Parallax constants `(ρ·cosφ, ρ·sinφ)`, - /// - Geocentric latitude `φ_geo` and geocentric distance `ρ` (Earth radii), - /// - Astrometric 1-σ accuracies in arcseconds (if available). - /// - /// Arguments - /// ----------------- - /// * `self`: The observer to format. - /// - /// Return - /// ---------- - /// * A human-readable representation suitable for logs and diagnostics. - /// - /// See also - /// ------------ - /// * [`Observer::geodetic_lat_height_wgs84`] – Geodetic latitude (deg) and ellipsoidal height (m). - /// * [`geodetic_to_parallax`] – Forward conversion to `(ρ·cosφ, ρ·sinφ)`. - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - // Friendly name - let name = self.name.as_deref().unwrap_or("Unnamed"); - - // Geodetic latitude (deg) and height (m -> km) - let (lat_deg, h_m) = self.geodetic_lat_height_wgs84(); - - // Geodetic longitude in degrees - let lon_deg = self.longitude.into_inner(); - - // Geocentric latitude and ρ from parallax constants - let rc = self.rho_cos_phi.into_inner(); - let rs = self.rho_sin_phi.into_inner(); - let phi_geo_deg = rs.atan2(rc).to_degrees(); - let rho_re = rc.hypot(rs); - - // Astrometric accuracies (radians -> arcseconds) - const RAD2AS: f64 = 206_264.806_247_096_36; - let ra_sigma_as = self - .ra_accuracy - .map(|v| format!("{:.3}″", v.into_inner() * RAD2AS)) - .unwrap_or_else(|| "—".to_string()); - let dec_sigma_as = self - .dec_accuracy - .map(|v| format!("{:.3}″", v.into_inner() * RAD2AS)) - .unwrap_or_else(|| "—".to_string()); - - if f.alternate() { - // Verbose, multi-line format (triggered by "{:#}") - writeln!( - f, - "{name} (lon: {lon_deg:.6}°, lat: {lat_deg:.6}° geodetic, elev: {h_m:.2} m)" - )?; - writeln!( - f, - " parallax: ρ·cosφ={rc:.9}, ρ·sinφ={rs:+.9} | φ_geo={phi_geo_deg:+.6}° ρ={rho_re:.6} RE" - )?; - write!(f, " astrometric 1σ: RA={ra_sigma_as}, DEC={dec_sigma_as}") - } else { - // Compact, single-line format (triggered by "{}") - write!( - f, - "{name} (lon: {lon_deg:.6}°, lat: {lat_deg:.6}° geodetic, elev: {h_m:.2} m)" - ) - } - } -} - -/// Convert geodetic latitude and height into normalized parallax coordinates -/// on the Earth. -/// -/// This transformation is used to compute the observer's position on Earth -/// in a way that accounts for the Earth's oblateness. The resulting values -/// are dimensionless and are expressed in units of the Earth's equatorial -/// radius (`EARTH_MAJOR_AXIS`). -/// -/// Arguments -/// --------- -/// * `lat` - Geodetic latitude of the observer in **radians**. -/// * `height` - Observer's altitude above the reference ellipsoid in **meters**. -/// -/// Returns -/// ------- -/// A tuple `(rho_cos_phi, rho_sin_phi)`: -/// * `rho_cos_phi`: normalized distance of the observer projected on -/// the Earth's equatorial plane. -/// * `rho_sin_phi`: normalized distance of the observer projected on -/// the Earth's rotation (polar) axis. -/// -/// Details -/// ------- -/// The computation uses the reference ellipsoid defined by: -/// * `EARTH_MAJOR_AXIS`: Equatorial radius (m), -/// * `EARTH_MINOR_AXIS`: Polar radius (m). -/// -/// The formula comes from standard geodetic to geocentric conversion: -/// -/// ```text -/// u = atan( (sin φ * (b/a)) / cos φ ) -/// ρ_sinφ = (b/a) * sin u + (h/a) * sin φ -/// ρ_cosφ = cos u + (h/a) * cos φ -/// ``` -/// -/// where `a` and `b` are the Earth's semi-major and semi-minor axes, -/// and `h` is the height above the ellipsoid. -/// -/// See also -/// -------- -/// * [`Observer::body_fixed_coord`] – Uses this function to compute -/// the observer's fixed position in Earth-centered coordinates. -pub fn lat_alt_to_parallax(lat: f64, height: f64) -> (f64, f64) { - // Ratio of the Earth's minor to major axis (flattening factor) - let axis_ratio = EARTH_MINOR_AXIS / EARTH_MAJOR_AXIS; - - // Compute the auxiliary angle u (parametric latitude) - // This corrects for the Earth's oblateness. - let u = (lat.sin() * axis_ratio).atan2(lat.cos()); - - // Compute the normalized distance along the polar axis - let rho_sin_phi = axis_ratio * u.sin() + (height / EARTH_MAJOR_AXIS) * lat.sin(); - - // Compute the normalized distance along the equatorial plane - let rho_cos_phi = u.cos() + (height / EARTH_MAJOR_AXIS) * lat.cos(); - - (rho_cos_phi, rho_sin_phi) -} - -/// Convert geodetic latitude (in degrees) and height (in meters) -/// into normalized parallax coordinates. -/// -/// This is a convenience wrapper around [`lat_alt_to_parallax`] that -/// performs the degrees-to-radians conversion before calling the main -/// function. -/// -/// Arguments -/// --------- -/// * `lat` - Geodetic latitude of the observer in **degrees**. -/// * `height` - Observer's altitude above the reference ellipsoid in **meters**. -/// -/// Returns -/// ------- -/// A tuple `(rho_cos_phi, rho_sin_phi)`: -/// * `rho_cos_phi`: normalized distance of the observer projected on -/// the Earth's equatorial plane. -/// * `rho_sin_phi`: normalized distance of the observer projected on -/// the Earth's rotation (polar) axis. -/// -/// Details -/// ------- -/// This function simply converts `lat` to radians and delegates the -/// computation to [`lat_alt_to_parallax`]. -/// -/// See also -/// -------- -/// * [`lat_alt_to_parallax`] – Performs the actual computation given latitude in radians. -pub fn geodetic_to_parallax(lat: f64, height: f64) -> (f64, f64) { - // Convert latitude from degrees to radians - let latitude_rad = lat.to_radians(); - - // Call the main routine that works with radians - let (rho_cos_phi, rho_sin_phi) = lat_alt_to_parallax(latitude_rad, height); - - (rho_cos_phi, rho_sin_phi) -} - -/// Compute the heliocentric positions of three observers at their respective epochs, -/// expressed in the **equatorial mean J2000** frame (ICRS-aligned). -/// -/// Overview -/// ----------------- -/// This routine builds a `3×3` matrix of observer positions by combining: -/// - the **geocentric site position** of each observer (from [`Observer::pvobs`]), -/// - the Earth’s **heliocentric barycentric position** from JPL ephemerides, -/// - a frame transformation from **ecliptic mean J2000** (site positions) to -/// **equatorial mean J2000** (final output). -/// -/// The result is a compact representation where each column corresponds to one -/// observer/epoch pair: -/// `observers[0] ↔ mjd_tt.x`, -/// `observers[1] ↔ mjd_tt.y`, -/// `observers[2] ↔ mjd_tt.z`. -/// -/// Arguments -/// ----------------- -/// * `observers` – Array of three [`Observer`] references, each encoding the site geometry -/// (longitude, normalized geocentric radius components, etc.). -/// * `mjd_tt` – [`Vector3`] of observation epochs in Terrestrial Time (TT), one per observer. -/// * `state` – [`Outfit`] environment providing: -/// - JPL planetary ephemerides (via [`Outfit::get_jpl_ephem`]), -/// - UT1 provider for Earth rotation/orientation (via [`Outfit::get_ut1_provider`]). -/// -/// Return -/// ---------- -/// * `Result, OutfitError>` – A 3×3 matrix of observer heliocentric positions, with: -/// - **Columns** = `[r₁, r₂, r₃]`, one per observer/epoch, -/// - **Units** = astronomical units (AU), -/// - **Frame** = equatorial mean J2000 (ICRS-aligned). -/// -/// Remarks -/// ------------- -/// * For each observer/time pair: -/// 1. The site’s **geocentric position** is computed via [`Observer::pvobs`] (AU, ecliptic J2000). -/// 2. Earth’s heliocentric position is retrieved from the JPL ephemeris. -/// 3. The site position is rotated into **equatorial mean J2000** using the frame rotation. -/// 4. The Earth + rotated site vectors give the full heliocentric observer position. -/// * This function is mainly used during **Gauss IOD** preparation to populate the -/// observer position matrix stored in [`GaussObs`](crate::initial_orbit_determination::gauss::GaussObs). -/// -/// See also -/// ------------ -/// * [`Observer::pvobs`] – Geocentric observer position at a given epoch (ecliptic J2000). -/// * [`Observer::helio_position`] – Per-observer heliocentric position (equatorial J2000). -/// * [`Outfit::get_jpl_ephem`] – Access to planetary ephemerides (Earth state). -/// * [`Outfit::get_ut1_provider`] – Provides Earth orientation parameters (ΔUT1). -pub fn helio_obs_pos( - observers: [&Observer; 3], - mjd_tt: &Vector3, - state: &Outfit, -) -> Result, OutfitError> { - let epochs = [ - Epoch::from_mjd_in_time_scale(mjd_tt.x, hifitime::TimeScale::TT), - Epoch::from_mjd_in_time_scale(mjd_tt.y, hifitime::TimeScale::TT), - Epoch::from_mjd_in_time_scale(mjd_tt.z, hifitime::TimeScale::TT), - ]; - - let pvobs1 = observers[0].pvobs(&epochs[0], state.get_ut1_provider())?; - let pvobs2 = observers[1].pvobs(&epochs[1], state.get_ut1_provider())?; - let pvobs3 = observers[2].pvobs(&epochs[2], state.get_ut1_provider())?; - - let positions = [ - observers[0].helio_position(state, &epochs[0], &pvobs1.0)?, - observers[1].helio_position(state, &epochs[1], &pvobs2.0)?, - observers[2].helio_position(state, &epochs[2], &pvobs3.0)?, - ]; - - Ok(Matrix3::from_columns(&positions)) -} - -#[cfg(test)] -mod observer_test { - - use crate::{error_models::ErrorModel, outfit::Outfit}; - - use super::*; - - #[test] - fn test_observer_constructor() { - let observer = Observer::new(0.0, 0.0, 0.0, None, None, None).unwrap(); - assert_eq!(observer.longitude, 0.0); - assert_eq!(observer.rho_cos_phi, 1.0); - assert_eq!(observer.rho_sin_phi, 0.0); - - let observer = Observer::new( - 289.25058, - -30.2446, - 2647., - Some("Rubin Observatory".to_string()), - Some(0.0001), - Some(0.0001), - ) - .unwrap(); - - assert_eq!(observer.longitude, 289.25058); - assert_eq!(observer.rho_cos_phi, 0.8649760504617418); - assert_eq!(observer.rho_sin_phi, -0.5009551027512434); - } - - #[test] - fn body_fixed_coord_test() { - // longitude, latitude and height of Pan-STARRS 1, Haleakala - let (lon, lat, h) = (203.744090000, 20.707233557, 3067.694); - let pan_starrs = Observer::new(lon, lat, h, None, None, None).unwrap(); - assert_eq!( - pan_starrs.body_fixed_coord(), - Vector3::new( - -0.00003653799439776371, - -0.00001607260397528885, - 0.000014988110430544328 - ) - ); - - assert_eq!( - pan_starrs.observer_fixed_coord, - Vector3::new( - NotNan::new(-0.00003653799439776371).unwrap(), - NotNan::new(-0.00001607260397528885).unwrap(), - NotNan::new(0.000014988110430544328).unwrap() - ) - ) - } - - #[test] - fn pvobs_test() { - let state = Outfit::new("horizon:DE440", ErrorModel::FCCT14).unwrap(); - let tmjd = 57028.479297592596; - let epoch = Epoch::from_mjd_in_time_scale(tmjd, hifitime::TimeScale::TT); - // longitude, latitude and height of Pan-STARRS 1, Haleakala - let (lon, lat, h) = (203.744090000, 20.707233557, 3067.694); - let pan_starrs = - Observer::new(lon, lat, h, Some("Pan-STARRS 1".to_string()), None, None).unwrap(); - - let (observer_position, observer_velocity) = - &pan_starrs.pvobs(&epoch, state.get_ut1_provider()).unwrap(); - - assert_eq!( - observer_position.as_slice(), - [ - -2.086211182493635e-5, - 3.718476815327979e-5, - 2.4978996447997476e-7 - ] - ); - assert_eq!( - observer_velocity.as_slice(), - [ - -0.0002143246535691577, - -0.00012059801691431748, - 5.262184624215718e-5 - ] - ); - } - - #[test] - fn geodetic_to_parallax_test() { - // latitude and height of Pan-STARRS 1, Haleakala - let (pxy1, pz1) = geodetic_to_parallax(20.707233557, 3067.694); - assert_eq!(pxy1, 0.9362410003211518); - assert_eq!(pz1, 0.35154299856304305); - } - - #[test] - #[cfg(feature = "jpl-download")] - fn test_helio_pos_obs() { - use crate::unit_test_global::OUTFIT_HORIZON_TEST; - - let tmjd = Vector3::new( - 57028.479297592596, - 57_049.245_147_592_59, - 57_063.977_117_592_59, - ); - - // longitude, latitude and height of Pan-STARRS 1, Haleakala - let (lon, lat, h) = (203.744090000, 20.707233557, 3067.694); - let pan_starrs = - Observer::new(lon, lat, h, Some("Pan-STARRS 1".to_string()), None, None).unwrap(); - - // Now we need a Vector3 with three identical copies - let observers = [&pan_starrs, &pan_starrs, &pan_starrs]; - - let helio_pos = helio_obs_pos(observers, &tmjd, &OUTFIT_HORIZON_TEST.0).unwrap(); - - assert_eq!( - helio_pos.as_slice(), - [ - -0.2645666171464416, - 0.8689351643701766, - 0.3766996211107864, - -0.5891631852137064, - 0.7238872516824697, - 0.3138186516540669, - -0.7743280306286537, - 0.5612532665812755, - 0.24333415479994636 - ] - ); - } - - #[cfg(test)] - mod geodetic_inverse_tests { - use super::*; - use crate::constants::Degree; - use approx::assert_abs_diff_eq; - - /// Round-trip a single site through (lon, lat, h) -> parallax -> inverse - /// and check that we recover the original geodetic latitude & height. - /// - /// Notes - /// ----- - /// * `Observer::new` is given `h_m` in meters (as per current API usage). - /// * `geodetic_lat_height_wgs84()` returns height in **meters**; we convert to meters. - fn roundtrip_site(name: &str, lon_deg: Degree, lat_deg: Degree, h_m: f64) { - // Build observer (forward: geodetic -> parallax is done inside `Observer::new`) - let obs = Observer::new(lon_deg, lat_deg, h_m, Some(name.to_string()), None, None) - .expect("Failed to create observer"); - - // Inverse: parallax -> geodetic (WGS-84) - let (lat_rec_deg, h_rec_m) = obs.geodetic_lat_height_wgs84(); - - // Tolerances: - // - Latitude: 1e-6 deg (~3.6 mas) – tight but should pass for double precision Bowring + 0–1 Newton step - // - Height: 1e-2 m - let tol_lat_deg = 1e-6; - let tol_h_m = 1e-2; - - assert_abs_diff_eq!(lat_rec_deg, lat_deg, epsilon = tol_lat_deg); - assert_abs_diff_eq!(h_rec_m, h_m, epsilon = tol_h_m); - } - - /// See also - /// ------------ - /// * [`Observer::new`] – Forward geodetic->parallax conversion under test by round-trip. - /// * [`Observer::from_parallax`] – Alternative constructor, if you want to inject ρ·cosφ/ρ·sinφ. - /// * `geodetic_to_parallax` – The forward routine used internally by `Observer::new`. - - #[test] - fn geodetic_roundtrip_known_observatories_wgs84() { - // NOTE: - // The heights below are commonly quoted "above sea level" (orthometric). - // For pure algorithmic round-trip testing, that's acceptable because we feed - // the same height into forward and inverse. If you want strict ellipsoidal - // (h) values, substitute official WGS-84 heights here. - let sites: &[(&str, Degree, Degree, f64)] = &[ - // name, lon_deg (E+), lat_deg (N+), height_m - ("Haleakala (PS1 I41)", -156.2575, 20.7075, 3055.0), - ("Mauna Kea (CFHT)", -155.4700, 19.8261, 4205.0), - ("ESO Paranal", -70.4025, -24.6252, 2635.0), - ("Cerro Pachon (Rubin)", -70.7366, -30.2407, 2663.0), - ("La Silla", -70.7346, -29.2613, 2400.0), - ("Kitt Peak", -111.5967, 31.9583, 2096.0), - ("Roque de los Muchachos", -17.8947, 28.7606, 2396.0), - ]; - - for (name, lon, lat, h_m) in sites.iter().copied() { - roundtrip_site(name, lon, lat, h_m); - } - } - - #[test] - fn geodetic_roundtrip_extremes_equator_and_pole() { - // Equator, sea level - roundtrip_site("Equator (0°, 0 m)", 0.0, 0.0, 0.0); - - // Near-North-Pole and Near-South-Pole with modest height - roundtrip_site("Near North Pole", 0.0, 89.999, 1000.0); - roundtrip_site("Near South Pole", 0.0, -89.999, 1000.0); - } - - #[test] - fn geodetic_roundtrip_high_altitude_and_negative() { - // Very high site (simulate balloon/aircraft) - roundtrip_site("High Alt 10 m", 10.0, 45.0, 10_000.0); - - // Negative height (below ellipsoid, synthetic but tests robustness) - roundtrip_site("Below ellipsoid -50 m", -30.0, -10.0, -50.0); - } - } - - #[cfg(test)] - mod observer_display_tests { - use super::*; - - /// Convert arcseconds to radians. - #[inline] - fn arcsec_to_rad(as_val: f64) -> f64 { - // 1 arcsec = π / (180 * 3600) rad - std::f64::consts::PI / (180.0 * 3600.0) * as_val - } - - /// Helper to build an Observer with optional RA/DEC accuracies (in arcseconds). - fn make_observer_with_acc( - name: Option<&str>, - lon_deg: f64, - lat_deg: f64, - elev_m: f64, - ra_as: Option, - dec_as: Option, - ) -> Observer { - let ra_sigma = ra_as.map(arcsec_to_rad); - let dec_sigma = dec_as.map(arcsec_to_rad); - - Observer::new( - lon_deg, - lat_deg, - elev_m, // elevation in meters - name.map(|s| s.to_string()), - ra_sigma, - dec_sigma, - ) - .expect("Failed to create Observer") - } - - /// Compact formatting must be a single line with name/lon/lat/elev. - #[test] - fn display_compact_single_line() { - let obs = make_observer_with_acc(Some("TestSite"), 10.0, 0.0, 0.0, None, None); - - let s = format!("{obs}"); - // Must not contain newlines in compact form - assert!( - !s.contains('\n'), - "Compact format should be single-line, got:\n{s}" - ); - - // Must contain expected fragments (predictable numbers) - assert!( - s.contains("TestSite (lon: 10.000000°"), - "Missing name/lon fragment. Got:\n{s}" - ); - assert!( - s.contains("lat: 0.000000° geodetic"), - "Missing geodetic latitude fragment. Got:\n{s}" - ); - assert!( - s.contains("elev: 0.00 m"), - "Missing elevation fragment (m). Got:\n{s}" - ); - } - - /// Alternate formatting must be multi-line and include parallax & uncertainties. - #[test] - fn display_verbose_multiline_with_sections() { - let obs = make_observer_with_acc(Some("VerboseSite"), -70.0, -30.0, 2400.0, None, None); - - let s = format!("{obs:#}"); - - // Must contain multiple lines and the expected section headers/fragments - assert!( - s.contains('\n'), - "Verbose format should be multi-line. Got:\n{s}" - ); - assert!( - s.starts_with("VerboseSite (lon: -70.000000°"), - "First line should start with site name and lon. Got:\n{s}" - ); - assert!( - s.contains("\n parallax: ρ·cosφ="), - "Missing 'parallax:' line. Got:\n{s}" - ); - assert!( - s.contains("φ_geo=") && s.contains("ρ="), - "Missing φ_geo/ρ fragments. Got:\n{s}" - ); - assert!( - s.contains("\n astrometric 1σ: RA=—, DEC=—"), - "Missing astrometric 1σ line with em-dashes for None. Got:\n{s}" - ); - } - - /// When RA/DEC accuracies are provided, they must be printed in arcseconds with three decimals. - #[test] - fn display_verbose_shows_ra_dec_sigmas() { - // RA = 1.0″, DEC = 2.5″ (passed in arcseconds, converted to radians internally) - let obs = make_observer_with_acc(Some("AccSite"), 0.0, 0.0, 0.0, Some(1.0), Some(2.5)); - - let s = format!("{obs:#}"); - - assert!( - s.contains("astrometric 1σ: RA=1.000″, DEC=2.500″"), - "Expected RA/DEC sigma in arcseconds with 3 decimals. Got:\n{s}" - ); - } - - /// Name fallback should be "Unnamed" when not provided. - #[test] - fn display_uses_unnamed_when_missing() { - let obs = make_observer_with_acc(None, 5.0, 0.0, 0.0, None, None); - let s = format!("{obs}"); - assert!( - s.starts_with("Unnamed (lon: 5.000000°"), - "Expected 'Unnamed' fallback. Got:\n{s}" - ); - } - - /// Basic numeric sanity: geodetic height is shown in kilometers in the display. - /// For a 3055 m elevation, we expect ~3.055 km (rounded to 2 decimals). - #[test] - fn display_elevation_shown_in_km() { - let obs = make_observer_with_acc( - Some("Haleakala-ish"), - -156.2575, - 20.7075, - 3055.0, - None, - None, - ); - - let s = format!("{obs}"); - // Check the km conversion and rounding only; don't assert on latitude value here. - assert!( - s.contains("elev: 3055.00 m"), - "Expected elevation ~3055 m rounded to 2 decimals. Got:\n{s}" - ); - - // Optional: ensure lon is printed correctly - assert!( - s.contains("lon: -156.257500°"), - "Longitude formatting mismatch. Got:\n{s}" - ); - } - } -} diff --git a/src/observers/observatories.rs b/src/observers/observatories.rs deleted file mode 100644 index 636947b..0000000 --- a/src/observers/observatories.rs +++ /dev/null @@ -1,238 +0,0 @@ -use super::bimap::BiMap; -use super::Observer; -use crate::constants::{Degree, Kilometer, MpcCodeObs}; -use std::{ - fmt, - sync::{Arc, OnceLock}, -}; - -#[derive(Debug, Clone)] -pub(crate) struct Observatories { - pub(crate) mpc_code_obs: OnceLock, - obs_to_uint16: BiMap, u16>, -} - -impl Observatories { - pub(crate) fn new() -> Self { - Observatories { - mpc_code_obs: OnceLock::new(), - obs_to_uint16: BiMap::new(), - } - } - - pub(crate) fn create_observer( - &mut self, - longitude: Degree, - latitude: Degree, - elevation: Kilometer, - name: Option, - ) -> Arc { - let obs = Observer::new(longitude, latitude, elevation, name.clone(), None, None) - .expect("Failed to create observer"); - let arc_observer = Arc::new(obs); - self.obs_to_uint16 - .entry_or_insert_by_key(arc_observer.clone(), self.obs_to_uint16.len() as u16); - arc_observer - } - - pub(crate) fn add_observer(&mut self, observer: Arc) -> u16 { - let obs_idx = self.obs_to_uint16.len() as u16; - *self.obs_to_uint16.entry_or_insert_by_key(observer, obs_idx) - } - - /// Get an observer from an observer index - /// - /// Arguments - /// --------- - /// * `observer_idx`: the observer index - /// - /// Return - /// ------ - /// * The observer - pub(crate) fn get_observer_from_uint16(&self, observer_idx: u16) -> &Observer { - self.obs_to_uint16 - .get_by_value(&observer_idx) - .unwrap_or_else(|| panic!("Observer index not found: {observer_idx}")) - } - - /// Get an observer index from an observer - /// If the observer is not already in the bimap, it is added - /// - /// Arguments - /// --------- - /// * `observer`: the observer - /// - /// Return - /// ------ - /// * The observer index - pub(crate) fn uint16_from_observer(&mut self, observer: Arc) -> u16 { - let obs_idx = self.obs_to_uint16.len() as u16; - *self.obs_to_uint16.entry_or_insert_by_key(observer, obs_idx) - } -} - -impl fmt::Display for Observatories { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - if self.obs_to_uint16.is_empty() { - if self.mpc_code_obs.get().is_none() { - writeln!(f, "No observatories defined (user or MPC).\nTrying to get an observer from the MPC or insert a new one to initialize the observatory list.")?; - return Ok(()); - } else { - writeln!(f, "No user-defined observers.")?; - } - } - - writeln!(f, "User-defined observers:")?; - for obs in self.obs_to_uint16.keys() { - let (lat, height) = obs.geodetic_lat_height_wgs84(); - - writeln!( - f, - " {} (lon: {:.6}°, lat: {:.6}°, elev: {:.2} km)", - obs.name.clone().unwrap_or_else(|| "Unnamed".to_string()), - obs.longitude, - lat, - height - )?; - } - - if let Some(mpc_code_obs) = self.mpc_code_obs.get() { - writeln!(f, "MPC observers:")?; - for (code, obs) in mpc_code_obs.iter() { - let (lat, height) = obs.geodetic_lat_height_wgs84(); - - writeln!( - f, - " {} [{}] (lon: {:.6}°, lat: {:.6}°, elev: {:.2} km)", - obs.name.clone().unwrap_or_else(|| "Unnamed".to_string()), - code, - obs.longitude, - lat, - height - )?; - } - } - - Ok(()) - } -} - -#[cfg(test)] -mod observatories_test { - use super::*; - - #[test] - fn test_observatories() { - let mut observatories = Observatories::new(); - let obs = observatories.create_observer(1.0, 2.0, 3.0, Some("Test".to_string())); - assert_eq!(obs.longitude, 1.0); - assert_eq!(obs.rho_cos_phi, 0.999395371426802); - assert_eq!(obs.rho_sin_phi, 0.0346660237964843); - assert_eq!(obs.name, Some("Test".to_string())); - assert_eq!(observatories.obs_to_uint16.len(), 1); - let observer = observatories.get_observer_from_uint16(0); - assert_eq!(observer.name, Some("Test".to_string())); - - observatories.create_observer(4.0, 5.0, 6.0, Some("Test2".to_string())); - assert_eq!(observatories.obs_to_uint16.len(), 2); - let observer = observatories.get_observer_from_uint16(1); - assert_eq!(observer.name, Some("Test2".to_string())); - } - - #[cfg(test)] - mod observatories_display_tests { - use super::*; - - /// Ensure the "user-defined" section is printed and includes both user observers. - /// - /// Notes - /// ----- - /// * We don't assume any iteration order (HashMap-backed bi-map). - /// * We check for the header and the presence of each observer line fragment. - #[test] - fn display_user_defined_only() { - let mut obs = Observatories::new(); - - // Build two user-defined observers (elevation in kilometers). - obs.create_observer(10.0, 0.0, 0.0, Some("UserA".to_string())); - obs.create_observer(20.0, 45.0, 2.0, Some("UserB".to_string())); - - let s = format!("{obs}"); - - // Header must be present - assert!( - s.starts_with("User-defined observers:\n"), - "Missing 'User-defined observers:' header. Got:\n{s}" - ); - - // Each user observer should be listed with their name and longitude fragment - assert!( - s.contains("UserA (lon: 10.000000°"), - "Missing formatted line for UserA. Got:\n{s}" - ); - assert!( - s.contains("UserB (lon: 20.000000°"), - "Missing formatted line for UserB. Got:\n{s}" - ); - - // The MPC section should not appear if not initialized - assert!( - !s.contains("MPC observers:"), - "Unexpected 'MPC observers:' section when OnceLock is unset. Got:\n{s}" - ); - } - - /// If the MPC table is initialized, ensure the "MPC observers" section appears. - /// - /// Notes - /// ----- - /// * We set the OnceLock with a single entry. - /// * We only check for presence of the section and the MPC code tag. - #[test] - fn display_includes_mpc_section_when_set() { - let mut obs = Observatories::new(); - - // One user-defined observer so the first section is non-empty. - obs.create_observer(0.0, 0.0, 0.0, Some("UserOnly".to_string())); - - // Prepare an MPC observer entry. - let mpc_site = Observer::new( - -156.2575, - 20.7075, - 3.055, - Some("Haleakala".to_string()), - None, - None, - ) - .expect("Failed to create MPC observer"); - - // Build an MpcCodeObs map with a single code. - // If your `MpcCodeObs` is a type alias, this should compile as-is. - // Example: `pub type MpcCodeObs = std::collections::HashMap` (or Arc). - let mut mpc_table: MpcCodeObs = Default::default(); - // Adjust Arc vs Observer depending on your alias: - // If it is `HashMap>`, wrap with `Arc::new(mpc_site)`. - use std::sync::Arc; - mpc_table.insert("I41".to_string(), Arc::new(mpc_site)); - - // Initialize the OnceLock (only once) - obs.mpc_code_obs - .set(mpc_table) - .expect("OnceLock was already initialized"); - - let s = format!("{obs}"); - - // MPC section header must be present now - assert!( - s.contains("MPC observers:"), - "Missing 'MPC observers:' header after setting OnceLock. Got:\n{s}" - ); - - // The code tag should appear in the MPC line - assert!( - s.contains("[I41]"), - "Missing MPC code tag '[I41]' in output. Got:\n{s}" - ); - } - } -} diff --git a/src/outfit.rs b/src/outfit.rs deleted file mode 100644 index cb3aece..0000000 --- a/src/outfit.rs +++ /dev/null @@ -1,676 +0,0 @@ -//! # Outfit: environment, ephemerides, and observatory registry -//! -//! This module defines the [`Outfit`](crate::outfit::Outfit) struct, the central façade that wires together: -//! -//! 1. **Environment state** ([`OutfitEnv`](crate::env_state::OutfitEnv)) — providers and configuration (e.g., UT1). -//! 2. **JPL ephemerides access** — lazy, cached handle over a chosen source -//! ([`EphemFileSource`](crate::jpl_ephem::download_jpl_file::EphemFileSource) → [`JPLEphem`](crate::jpl_ephem::JPLEphem)). -//! 3. **Observatory registry** — MPC Observatory Codes parsed into [`Observer`](crate::observers::Observer) instances, -//! with stable integer IDs for compact indexing and storage. -//! 4. **Astrometric error models** — per-site bias/RMS lookup for RA/DEC accuracies. -//! -//! The design emphasizes *lazy initialization* and *idempotent caching*: -//! - The ephemeris file is opened on first use via [`OnceCell`](once_cell::sync::OnceCell), then reused. -//! - The MPC observatory table is fetched and parsed once, then retained. -//! -//! ## Key responsibilities -//! -//! - Single source of truth for **JPL ephemerides** (HORIZONS/NAIF) through [`get_jpl_ephem`](crate::outfit::Outfit::get_jpl_ephem) -//! - Access to **UT1 provider** for Earth-rotation dependent calculations -//! - **MPC observatory code → Observer** resolution and the inverse (**Observer → u16 index**) -//! - Enrichment of observers with **bias/RMS** angular accuracies from the configured -//! [`ErrorModel`](crate::error_models::ErrorModel) (e.g., *FCCT14*) -//! -//! ## Typical usage -//! -//! ```rust, no_run -//! use outfit::outfit::Outfit; -//! use outfit::error_models::ErrorModel; -//! -//! // Instantiate the context with a JPL source and an error model -//! let outfit = Outfit::new("horizon:DE440", ErrorModel::FCCT14).unwrap(); -//! -//! // On-demand: the ephemeris is opened only once and cached -//! let jpl = outfit.get_jpl_ephem().unwrap(); -//! -//! // Resolve an observer by MPC code -//! let haleakala = outfit.get_observer_from_mpc_code(&"F51".into()); -//! ``` -//! -//! ## Notes -//! -//! - The MPC table is pulled from: -//! `https://www.minorplanetcenter.net/iau/lists/ObsCodes.html` -//! and minimally parsed from its `
` text block.
-//! - Per-site **RA/DEC accuracies** (bias+RMS) are looked up with [`get_bias_rms`](crate::error_models::get_bias_rms),
-//!   currently assuming catalog code `"c"` unless indicated otherwise (TODO).
-//!
-//! ## See also
-//! ------------
-//! * [`JPLEphem`](crate::jpl_ephem::JPLEphem) – Ephemerides access layer.
-//! * [`Observer`](crate::observers::Observer) – Geodetic/parallax observer representation with optional RA/DEC accuracies.
-//! * [`ErrorModel`](crate::error_models::ErrorModel) / [`get_bias_rms`](crate::error_models::get_bias_rms) – Site accuracy enrichment.
-//! * [`OutfitEnv`](crate::env_state::OutfitEnv) – Providers (e.g., UT1) and environment state.
-//! * [`EphemFileSource`](crate::jpl_ephem::download_jpl_file::EphemFileSource) – Source selection for JPL files (HORIZONS/NAIF).
-//!
-//! ## Panics & errors
-//!
-//! - Functions that *must* find an MPC code will `panic!` if the code is unknown.
-//!   Prefer adding a fallible variant if you need graceful handling.
-//! - I/O and parsing failures are surfaced as [`OutfitError`](crate::outfit_errors::OutfitError) where applicable.
-
-use std::{collections::HashMap, fmt, sync::Arc};
-
-use nalgebra::Matrix3;
-use once_cell::sync::OnceCell;
-
-use crate::{
-    constants::{Degree, Kilometer, MpcCode, MpcCodeObs},
-    env_state::OutfitEnv,
-    error_models::{get_bias_rms, ErrorModel, ErrorModelData},
-    jpl_ephem::download_jpl_file::EphemFileSource,
-    observers::{observatories::Observatories, Observer},
-    outfit_errors::OutfitError,
-    ref_system::{rotpn, RefEpoch, RefSystem},
-};
-
-use crate::jpl_ephem::JPLEphem;
-
-#[derive(Debug, Clone)]
-pub struct Outfit {
-    env_state: OutfitEnv,
-    observatories: Observatories,
-    jpl_source: EphemFileSource,
-    jpl_ephem: OnceCell,
-    pub error_model: ErrorModel,
-    error_model_data: ErrorModelData,
-    rot_equmj2000_to_eclmj2000: Matrix3,
-    rot_eclmj2000_to_equmj2000: Matrix3,
-}
-
-impl Outfit {
-    /// Construct a new [`Outfit`] context.
-    ///
-    /// Initializes the environment, sets the JPL ephemerides source, and loads the configured
-    /// error model from disk. The ephemeris file itself is **not** opened yet; it is lazily
-    /// initialized the first time [`get_jpl_ephem`](crate::outfit::Outfit::get_jpl_ephem) is called.
-    ///
-    /// Arguments
-    /// -----------------
-    /// * `jpl_file`: A source descriptor resolvable into an [`EphemFileSource`]
-    ///   (e.g., `"horizon:DE440"` or a NAIF path).
-    /// * `error_model`: The site accuracy model to load (e.g., [`ErrorModel::FCCT14`]).
-    ///
-    /// Return
-    /// ----------
-    /// * A new [`Outfit`] instance or an [`OutfitError`] if the error model cannot be read.
-    ///
-    /// See also
-    /// ------------
-    /// * [`get_jpl_ephem`](crate::outfit::Outfit::get_jpl_ephem) – Lazy initialization and access to the ephemeris handle.
-    /// * [`ErrorModel::read_error_model_file`](crate::error_models::ErrorModel::read_error_model_file) – Underlying loader for the model data.
-    pub fn new(jpl_file: &str, error_model: ErrorModel) -> Result {
-        let rot1 = rotpn(
-            &RefSystem::Equm(RefEpoch::J2000),
-            &RefSystem::Eclm(RefEpoch::J2000),
-        )?;
-
-        let rot2 = rotpn(
-            &RefSystem::Eclm(RefEpoch::J2000),
-            &RefSystem::Equm(RefEpoch::J2000),
-        )?;
-
-        Ok(Outfit {
-            env_state: OutfitEnv::new(),
-            observatories: Observatories::new(),
-            jpl_source: jpl_file.try_into()?,
-            jpl_ephem: OnceCell::new(),
-            error_model,
-            error_model_data: error_model.read_error_model_file()?,
-            rot_equmj2000_to_eclmj2000: rot1,
-            rot_eclmj2000_to_equmj2000: rot2,
-        })
-    }
-
-    /// Get the rotation matrix from equatorial J2000 to ecliptic J2000.
-    /// This matrix is used to transform coordinates from the equatorial frame to the ecliptic frame.
-    pub fn get_rot_equmj2000_to_eclmj2000(&self) -> &Matrix3 {
-        &self.rot_equmj2000_to_eclmj2000
-    }
-
-    pub fn get_rot_eclmj2000_to_equmj2000(&self) -> &Matrix3 {
-        &self.rot_eclmj2000_to_equmj2000
-    }
-
-    /// Get the lazily-initialized JPL ephemerides handle.
-    ///
-    /// If this is the first call, the ephemeris is opened and cached in an internal [`OnceCell`].
-    /// Subsequent calls return the same reference.
-    ///
-    /// Arguments
-    /// -----------------
-    /// *None*
-    ///
-    /// Return
-    /// ----------
-    /// * `&JPLEphem` on success, or an [`OutfitError`] if the source cannot be opened.
-    ///
-    /// See also
-    /// ------------
-    /// * [`EphemFileSource`] – Source configuration.
-    /// * [`OnceCell::get_or_try_init`] – Lazy initialization helper.
-    pub fn get_jpl_ephem(&self) -> Result<&JPLEphem, OutfitError> {
-        self.jpl_ephem
-            .get_or_try_init(|| JPLEphem::new(&self.jpl_source))
-    }
-
-    /// Access the UT1 provider from the environment.
-    ///
-    /// This is useful for Earth-rotation dependent calculations (e.g., GMST, sidereal time).
-    ///
-    /// Arguments
-    /// -----------------
-    /// *None*
-    ///
-    /// Return
-    /// ----------
-    /// * A reference to the [`hifitime::ut1::Ut1Provider`].
-    ///
-    /// See also
-    /// ------------
-    /// * [`OutfitEnv`] – Environment state and providers.
-    pub fn get_ut1_provider(&self) -> &hifitime::ut1::Ut1Provider {
-        &self.env_state.ut1_provider
-    }
-
-    /// Get the lazily built MPC observatory map (MPC code → [`Observer`]).
-    ///
-    /// The map is fetched and parsed from the MPC HTML table on first use, then cached.
-    ///
-    /// Return
-    /// ----------
-    /// * A reference to the shared map: [`MpcCodeObs`] = `HashMap>`.
-    ///
-    /// See also
-    /// ------------
-    /// * [`init_observatories`](crate::outfit::Outfit::init_observatories) – Builder invoked on first access.
-    /// * [`get_observer_from_mpc_code`](crate::outfit::Outfit::get_observer_from_mpc_code) – Convenience accessor for one site.
-    pub(crate) fn get_observatories(&self) -> &MpcCodeObs {
-        self.observatories
-            .mpc_code_obs
-            .get_or_init(|| self.init_observatories())
-    }
-
-    /// Resolve an [`Observer`] from a given MPC observatory code.
-    ///
-    /// This accessor panics if the code is unknown. Use it when unknown codes are exceptional.
-    ///
-    /// Arguments
-    /// -----------------
-    /// * `mpc_code`: The MPC observatory code (e.g., `"F51"`).
-    ///
-    /// Return
-    /// ----------
-    /// * An `Arc` for the requested site.
-    pub fn get_observer_from_mpc_code(&self, mpc_code: &MpcCode) -> Arc {
-        self.get_observatories()
-            .get(mpc_code)
-            .unwrap_or_else(|| panic!("MPC code not found: {mpc_code}"))
-            .clone()
-    }
-
-    /// Build the MPC observatory registry by fetching and parsing the MPC list.
-    ///
-    /// For each row, the routine extracts:
-    /// - Longitude (deg), ρ·cosφ, ρ·sinφ (parallax factors),
-    /// - Human-readable name,
-    /// - Optional RA/DEC accuracies derived from the loaded [`ErrorModelData`]
-    ///   via [`get_bias_rms`] (currently using catalog code `"c"`, TODO).
-    ///
-    /// Return
-    /// ----------
-    /// * A freshly constructed [`MpcCodeObs`] map.
-    ///
-    /// See also
-    /// ------------
-    /// * [`get_observatories`](crate::outfit::Outfit::get_observatories) – Lazy wrapper that caches this map.
-    /// * [`get_bias_rms`] – Site accuracy lookup by (mpc_code, catalog_code).
-    pub(crate) fn init_observatories(&self) -> MpcCodeObs {
-        let mut observatories: MpcCodeObs = HashMap::new();
-
-        let mpc_code_response = self
-            .env_state
-            .get_from_url("https://www.minorplanetcenter.net/iau/lists/ObsCodes.html");
-
-        let mpc_code_csv = mpc_code_response
-            .trim()
-            .strip_prefix("
")
-            .and_then(|s| s.strip_suffix("
")) - .expect("Failed to strip pre tags"); - - for lines in mpc_code_csv.lines().skip(2) { - let line = lines.trim(); - - if let Some((code, remain)) = line.split_at_checked(3) { - let remain = remain.trim_end(); - - let (longitude, cos, sin, name) = parse_remain(remain, code); - - // TODO: support per-site catalog codes (not always "c") - let bias_rms = - get_bias_rms(&self.error_model_data, code.to_string(), "c".to_string()); - - let observer = Observer::from_parallax( - longitude as f64, - cos as f64, - sin as f64, - Some(name), - bias_rms.map(|(ra, _)| ra as f64), - bias_rms.map(|(_, dec)| dec as f64), - ) - .expect("Failed to create observer"); - observatories.insert(code.to_string(), Arc::new(observer)); - }; - } - observatories - } - - /// Convert an MPC code to its stable 16-bit observatory index. - /// - /// Useful for compact storage of observer references in catalogs, measurements, - /// and ephemeris products. - /// - /// Arguments - /// ----------------- - /// * `mpc_code`: The MPC observatory code. - /// - /// Return - /// ---------- - /// * The `u16` index associated with the given observer. - /// - /// See also - /// ------------ - /// * [`get_observer_from_mpc_code`](crate::outfit::Outfit::get_observer_from_mpc_code) – Resolve the observer first (panic on unknown). - /// * [`uint16_from_observer`](crate::outfit::Outfit::uint16_from_observer) – Indexing for arbitrary/new observers. - pub(crate) fn uint16_from_mpc_code(&mut self, mpc_code: &MpcCode) -> u16 { - let observer = self.get_observer_from_mpc_code(mpc_code); - self.observatories.uint16_from_observer(observer) - } - - /// Convert an [`Observer`] handle to its stable 16-bit index. - /// - /// Arguments - /// ----------------- - /// * `observer`: The observer to be indexed. - /// - /// Return - /// ---------- - /// * The `u16` index associated with this observer (inserting if new). - /// - /// See also - /// ------------ - /// * [`get_observer_from_uint16`](crate::outfit::Outfit::get_observer_from_uint16) – Recover a reference from an index. - /// * [`new_observer`](crate::outfit::Outfit::new_observer) – Create and register a new custom observer. - pub(crate) fn uint16_from_observer(&mut self, observer: Arc) -> u16 { - self.observatories.uint16_from_observer(observer) - } - - /// Recover an [`Observer`] reference from a 16-bit index. - /// - /// Arguments - /// ----------------- - /// * `observer_idx`: The previously assigned index. - /// - /// Return - /// ---------- - /// * A reference to the corresponding [`Observer`]. - /// - /// See also - /// ------------ - /// * [`uint16_from_observer`](crate::outfit::Outfit::uint16_from_observer) – Assign/lookup indices. - /// * [`get_observer_from_mpc_code`](crate::outfit::Outfit::get_observer_from_mpc_code) – Resolve by MPC code instead. - pub(crate) fn get_observer_from_uint16(&self, observer_idx: u16) -> &Observer { - self.observatories.get_observer_from_uint16(observer_idx) - } - - /// Create and register a new **custom** observer. - /// - /// This helper converts geodetic inputs to the internal parallax representation - /// (ρ·cosφ, ρ·sinφ) and stores the new [`Observer`] with an optional display name. - /// - /// Arguments - /// ----------------- - /// * `longitude`: Geodetic longitude in **degrees** (east-positive). - /// * `latitude`: Geodetic latitude in **degrees**. - /// * `elevation`: Elevation in **kilometers** above the ellipsoid/geoid (model-dependent). - /// * `name`: Optional human-readable name for the site. - /// - /// Return - /// ---------- - /// * An `Arc` handle to the newly created observer. - pub fn new_observer( - &mut self, - longitude: Degree, - latitude: Degree, - elevation: Kilometer, - name: Option, - ) -> Arc { - self.observatories - .create_observer(longitude, latitude, elevation, name) - } - - pub(crate) fn add_observer_internal(&mut self, observer: Arc) -> u16 { - self.observatories.add_observer(observer) - } - - pub fn add_observer(&mut self, observer: Arc) { - self.add_observer_internal(observer); - } - - /// Render the current observatories into a newly allocated `String`. - /// - /// This is a convenience wrapper around the `Display` implementation of - /// the internal struct \[`Observatories`\]. It materializes the formatted list (user-defined - /// observers first, then MPC sites if available) into a `String`. - /// - /// Output format - /// ------------- - /// * Longitude and latitude are shown in **degrees**. - /// * Elevation is shown in **kilometers**. - /// * User-defined observers are listed first; if initialized, the - /// **MPC observers** section follows. - /// * The relative order within each section is not guaranteed to be stable. - /// - /// Arguments - /// ----------------- - /// * None. - /// - /// Return - /// ---------- - /// * A `String` containing the formatted observatories. - /// - /// See also - /// ------------ - /// * [`Outfit::show_observatories`] – Allocation-free display adaptor. - /// * [`Observer::geodetic_lat_height_wgs84`] – Provides latitude/height used in the listing. - #[inline] - pub fn show_observatories_string(&self) -> String { - self.observatories.to_string() - } - - /// Pretty-print the current set of observatories without allocating a `String`. - /// - /// Returns a lightweight `Display` adaptor over the internal \[`Observatories`\] - /// collection. Use with `format!`, `println!`, log macros, or any consumer of - /// `fmt::Display`. User-defined observers are printed first, followed by the - /// **MPC observers** section if the MPC table has been initialized. - /// - /// Output format - /// ------------- - /// * Longitude and latitude are shown in **degrees**. - /// * Elevation is shown in **kilometers**. - /// * The relative order within each section is not guaranteed to be stable. - /// - /// Arguments - /// ----------------- - /// * None. - /// - /// Return - /// ---------- - /// * An [`ObservatoriesView`] display adaptor (zero-copy) suitable for `fmt::Display`. - /// - /// See also - /// ------------ - /// * [`Outfit::show_observatories_string`] – Eager, allocated `String`. - /// * [`Observer::geodetic_lat_height_wgs84`] – Provides latitude/height used in the listing. - #[inline] - pub fn show_observatories(&self) -> ObservatoriesView<'_> { - ObservatoriesView(&self.observatories) - } -} - -/// Lightweight, zero-allocation display adaptor for the internal private struct \[`Observatories`\]. -/// -/// This type borrows the internal \[`Observatories`\] and implements `fmt::Display`, -/// allowing you to pretty-print the full list of observers without allocating an -/// intermediate `String`. It simply delegates to the `Display` implementation -/// of \[`Observatories`\]. -/// -/// Output format -/// ------------- -/// * User-defined observers are listed first. -/// * If initialized, an **MPC observers** section follows. -/// * Longitudes/latitudes are shown in **degrees**; elevation is shown in **kilometers**. -/// * Relative order within each section is not guaranteed to be stable (hash-map backed). -/// -/// Example -/// ----------------- -/// ```rust, no_run -/// # use outfit::outfit::Outfit; -/// # use outfit::error_models::ErrorModel; -/// let outfit = Outfit::new("horizon:DE440", ErrorModel::FCCT14).unwrap(); -/// // Print to stdout without allocating a String: -/// println!("{}", outfit.show_observatories()); -/// // Or, if you need a String, use: -/// let s = outfit.show_observatories_string(); -/// assert!(s.contains("User-defined observers:")); -/// ``` -/// -/// Arguments -/// ----------------- -/// * None (constructed by [`Outfit::show_observatories`]). -/// -/// Return -/// ---------- -/// * A display adaptor suitable for `format!`, `println!`, and any `fmt::Display` consumer. -/// -/// See also -/// ------------ -/// * [`Outfit::show_observatories`] – Returns this adaptor. -/// * [`Outfit::show_observatories_string`] – Allocating `String` convenience. -/// * [`Observer::geodetic_lat_height_wgs84`] – Provides latitude/height used in the listing. -pub struct ObservatoriesView<'a>(&'a Observatories); - -impl<'a> fmt::Display for ObservatoriesView<'a> { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - // Delegate to Observatories' Display without allocating - write!(f, "{}", self.0) - } -} - -/// Parse a fixed-width float slice from an MPC observatory line. -/// -/// Arguments -/// ----------------- -/// * `s`: Full line (trailing part after the 3-char MPC code). -/// * `slice`: Byte range selecting the numeric field. -/// * `code`: MPC code (for diagnostics). -/// -/// Return -/// ---------- -/// * `Ok(f32)` parsed value, or the parsing error. -/// -/// See also -/// ------------ -/// * [`parse_remain`] – Higher-level field extraction for one line. -fn parse_f32( - s: &str, - slice: std::ops::Range, - code: &str, -) -> Result { - s.get(slice) - .unwrap_or_else(|| panic!("Failed to parse float for observer code: {code}")) - .trim() - .parse() -} - -/// Extract longitude, ρ·cosφ, ρ·sinφ, and name from a fixed-width MPC row. -/// -/// This helper returns partial values (with zeros) if any field fails to parse, -/// allowing the caller to still record the site name while signaling missing data -/// implicitly through zeros. -/// -/// Arguments -/// ----------------- -/// * `remain`: Fixed-width tail of the line (after the 3-char MPC code). -/// * `code`: MPC code (for diagnostics). -/// -/// Return -/// ---------- -/// * `(longitude_deg, rho_cos_phi, rho_sin_phi, name)` -fn parse_remain(remain: &str, code: &str) -> (f32, f32, f32, String) { - let name = remain - .get(27..) - .unwrap_or_else(|| panic!("Failed to parse name value for code: {code}")); - - let Some(longitude) = parse_f32(remain, 1..10, code).ok() else { - return (0.0, 0.0, 0.0, name.to_string()); - }; - - let Some(cos) = parse_f32(remain, 10..18, code).ok() else { - return (longitude, 0.0, 0.0, name.to_string()); - }; - - let Some(sin) = parse_f32(remain, 18..27, code).ok() else { - return (longitude, cos, 0.0, name.to_string()); - }; - (longitude, cos, sin, name.to_string()) -} - -#[cfg(test)] -mod outfit_show_observatories_tests { - use super::*; - use std::sync::Arc; - - /// Build a lightweight Outfit for display tests, then add zero or more user observers. - /// - /// Notes - /// ----- - /// * Uses the public `Outfit::new(...)` constructor to avoid touching private internals. - /// * If your `Outfit::new` signature changes, adjust here accordingly. - fn build_outfit_with_users(users: &[(&str, f64, f64, f64)]) -> Outfit { - // Pick a reasonable default error model; adjust if your API differs. - let mut outfit = Outfit::new("horizon:DE440", crate::error_models::ErrorModel::FCCT14) - .expect("Failed to construct Outfit for display tests"); - - for (name, lon_deg, lat_deg, elev_km) in users.iter().copied() { - let obs = Observer::new( - lon_deg, - lat_deg, - elev_km, - Some(name.to_string()), - None, - None, - ) - .expect("Failed to create user observer"); - // If your API differs, replace with the appropriate method to register observers: - outfit.add_observer(Arc::new(obs)); - } - outfit - } - - /// Ensure the string rendering equals the Display adaptor output when there are no observers. - #[test] - fn show_observatories_empty() { - let outfit = build_outfit_with_users(&[]); - - let s_string = outfit.show_observatories_string(); - let s_view = format!("{}", outfit.show_observatories()); - - assert_eq!( - s_string, s_view, - "String output and Display adaptor should match" - ); - assert!( - s_string.starts_with("No observatories defined (user or MPC)."), - "Missing 'User-defined observers:' header. Got:\n{s_string}" - ); - assert!( - !s_string.contains("MPC observers:"), - "Should not show 'MPC observers:' when OnceLock is unset. Got:\n{s_string}" - ); - } - - /// After adding user-defined observers, they should appear in the output. - #[test] - fn show_observatories_with_users() { - let outfit = build_outfit_with_users(&[ - ("UserA", 10.0, 0.0, 0.0), - ("UserB", 20.0, 45.0, 2.0), // 2 km elevation - ]); - - let s_string = outfit.show_observatories_string(); - let s_view = format!("{}", outfit.show_observatories()); - - assert_eq!( - s_string, s_view, - "String output and Display adaptor should match" - ); - - // Headers and user names should be present - assert!( - s_string.starts_with("User-defined observers:\n"), - "Missing 'User-defined observers:' header. Got:\n{s_string}" - ); - assert!( - s_string.contains("UserA (lon: 10.000000°"), - "Missing formatted line for UserA. Got:\n{s_string}" - ); - assert!( - s_string.contains("UserB (lon: 20.000000°"), - "Missing formatted line for UserB. Got:\n{s_string}" - ); - } - - /// If the MPC table is initialized, the MPC section should appear. - /// - /// Notes - /// ----- - /// * This test accesses the OnceLock inside `observatories` to inject a minimal MPC table. - /// * If your `MpcCodeObs` stores `Arc` instead of `Observer`, wrap with `Arc::new`. - #[test] - fn show_observatories_with_mpc_section() { - let outfit = build_outfit_with_users(&[("UserOnly", 0.0, 0.0, 0.0)]); - - // Build a minimal MPC table with one entry - let mpc_site = Observer::new( - -156.2575, - 20.7075, - 3.055, - Some("Haleakala".to_string()), - None, - None, - ) - .expect("Failed to create MPC observer"); - - // If MpcCodeObs = HashMap>, wrap with Arc::new. - // If it is HashMap, remove Arc::new below. - let mut mpc_table: crate::constants::MpcCodeObs = Default::default(); - // Uncomment ONE of the two lines below depending on your alias: - // mpc_table.insert("I41".to_string(), mpc_site); // if value is Observer - mpc_table.insert("I41".to_string(), Arc::new(mpc_site)); // if value is Arc - - // Initialize the OnceLock - outfit - .observatories - .mpc_code_obs - .set(mpc_table) - .expect("OnceLock already initialized"); - - let s_string = outfit.show_observatories_string(); - let s_view = format!("{}", outfit.show_observatories()); - - assert_eq!( - s_string, s_view, - "String output and Display adaptor should match" - ); - assert!( - s_string.contains("MPC observers:"), - "Missing 'MPC observers:' header after setting OnceLock. Got:\n{s_string}" - ); - assert!( - s_string.contains("[I41]"), - "Missing MPC code tag '[I41]' in output. Got:\n{s_string}" - ); - } -} diff --git a/src/outfit_errors.rs b/src/outfit_errors.rs index a86ae10..c78e691 100644 --- a/src/outfit_errors.rs +++ b/src/outfit_errors.rs @@ -135,8 +135,10 @@ //! * [`ParseObsError`](crate::trajectories::mpc_80col_reader::ParseObsError) – observation parsing errors. //! * [`roots::SearchError`] – wrapped in [`RootFindingError`](crate::outfit_errors::OutfitError::RootFindingError). //! * [`rand_distr::NormalError`] – wrapped in [`NoiseInjectionError`](crate::outfit_errors::OutfitError::NoiseInjectionError). - -use crate::trajectories::mpc_80col_reader::ParseObsError; +use photom::{ + observation_dataset::{ObsDatasetError, ObsId}, + TrajId, +}; use thiserror::Error; #[derive(Error, Debug)] @@ -159,7 +161,6 @@ pub enum OutfitError { #[error("Filesystem I/O error: {0}")] IoError(#[from] std::io::Error), - #[cfg(feature = "jpl-download")] #[error("HTTP request failed (reqwest): {0}")] ReqwestError(#[from] reqwest::Error), @@ -187,9 +188,6 @@ pub enum OutfitError { #[error("Parsing error (nom): {0}")] NomParsingError(String), - #[error("Parsing error in 80-column observation file: {0}")] - Parsing80ColumnFileError(ParseObsError), - #[error("Gaussian noise generation failed: {0:?}")] NoiseInjectionError(rand_distr::NormalError), @@ -253,6 +251,24 @@ pub enum OutfitError { #[error("Non-finite score encountered: {0}")] NonFiniteScore(f64), + + #[error("The provided observation {0} has no associated observer (ObserverId is None)")] + ObserverIdIsNone(ObsId), + + #[error(transparent)] + ObsDatasetError(#[from] ObsDatasetError), + + #[error("Observer dataset error: {0}")] + ObsDatasetErrorRef(String), + + #[error("Trajectory ID not found in dataset: {0}")] + TrajectoryIdNotFound(TrajId), +} + +impl From<&ObsDatasetError> for OutfitError { + fn from(e: &ObsDatasetError) -> Self { + OutfitError::ObsDatasetErrorRef(e.to_string()) + } } impl From for OutfitError { @@ -279,7 +295,6 @@ impl PartialEq for OutfitError { // Opaque external error kinds compare equal by variant only (UreqHttpError(_), UreqHttpError(_)) => true, (IoError(_), IoError(_)) => true, - #[cfg(feature = "jpl-download")] (ReqwestError(_), ReqwestError(_)) => true, (Parquet(_), Parquet(_)) => true, @@ -291,7 +306,6 @@ impl PartialEq for OutfitError { (InvalidErrorModel(a), InvalidErrorModel(b)) => a == b, (InvalidErrorModelFilePath(a), InvalidErrorModelFilePath(b)) => a == b, (NomParsingError(a), NomParsingError(b)) => a == b, - (Parsing80ColumnFileError(a), Parsing80ColumnFileError(b)) => a == b, (NoiseInjectionError(a), NoiseInjectionError(b)) => a == b, (InvalidSpkDataType(a), InvalidSpkDataType(b)) => a == b, (InvalidIODParameter(a), InvalidIODParameter(b)) => a == b, diff --git a/src/trajectories/ades_reader.rs b/src/trajectories/ades_reader.rs deleted file mode 100644 index c89c5a2..0000000 --- a/src/trajectories/ades_reader.rs +++ /dev/null @@ -1,257 +0,0 @@ -use std::str::FromStr; - -use camino::Utf8Path; -use hifitime::Epoch; -use quick_xml::de::from_str; -use serde::{Deserialize, Deserializer}; -use smallvec::SmallVec; - -use crate::{ - constants::{ArcSec, ObjectNumber}, - outfit::Outfit, - TrajectorySet, -}; - -use crate::observations::Observation; - -#[derive(Debug, Deserialize)] -struct StructuredAdes { - #[serde(rename = "obsBlock")] - obs_blocks: Vec, -} - -#[derive(Debug, Deserialize)] -struct FlatAdes { - #[serde(rename = "optical")] - opticals: Vec, -} - -#[derive(Debug, Deserialize)] -struct ObsBlock { - #[serde(rename = "obsContext")] - obs_context: ObsContext, - - #[serde(rename = "obsData")] - obs_data: ObsData, -} - -#[derive(Debug, Deserialize)] -struct ObsContext { - observatory: Observatory, -} - -#[derive(Debug, Deserialize)] -struct Observatory { - #[serde(rename = "mpcCode")] - mpc_code: String, -} - -#[derive(Debug, Deserialize)] -struct ObsData { - #[serde(rename = "optical")] - opticals: Vec, -} - -#[derive(Debug, Deserialize)] -struct OpticalObs { - #[serde(rename = "permID")] - perm_id: Option, - #[serde(rename = "provID")] - prov_id: Option, - #[serde(rename = "trkSub")] - trk_sub: Option, - - #[serde(rename = "obsTime", deserialize_with = "deserialize_mjd")] - obs_time: f64, - - ra: f64, - dec: f64, - - #[serde(rename = "precRA")] - prec_ra: Option, - #[serde(rename = "precDec")] - prec_dec: Option, - - #[serde(rename = "rmsRA")] - rms_ra: Option, - #[serde(rename = "rmsDec")] - rms_dec: Option, - - stn: String, -} - -impl OpticalObs { - /// Returns the trajectory ID for the optical observation. - /// It first checks for a `perm_id`, then a `prov_id`, and finally falls back to `trk_sub`. - /// If none of these are available, it panics with an error message. - /// The ID is parsed as a `u32` if possible, otherwise it is returned as a string. - /// - /// Return - /// ------ - /// * An `ObjectNumber` representing the trajectory ID. - /// * If the ID is a valid `u32`, it is returned as `ObjectNumber::Int(id)`. - /// * If the ID is not a valid `u32`, it is returned as `ObjectNumber::String(id)`. - /// * If no ID is found, it panics with an error message. - fn get_id(&self) -> ObjectNumber { - let id = self - .perm_id - .clone() - .or_else(|| self.prov_id.clone()) - .unwrap_or_else(|| self.trk_sub.clone().expect("No ID found")); - if let Ok(id) = id.parse::() { - ObjectNumber::Int(id) - } else { - ObjectNumber::String(id) - } - } - - fn to_observation( - &self, - state: &Outfit, - observer_idx: u16, - error_ra: Option, - error_dec: Option, - ) -> Observation { - let error_ra = self.rms_ra.unwrap_or_else(|| { - self.prec_ra - .unwrap_or_else(|| error_ra.expect("No error for RA when parsing ADES file")) - }); - - let error_dec = self.rms_dec.unwrap_or_else(|| { - self.prec_dec - .unwrap_or_else(|| error_dec.expect("No error for Dec when parsing ADES file")) - }); - Observation::new( - state, - observer_idx, - self.ra, - error_ra, - self.dec, - error_dec, - self.obs_time, - ) - .expect("Failed to create observation from ADES") - } -} - -/// Deserialize a date string in the format "YYYY-MM-DDTHH:MM:SS" into a floating-point number -/// representing the Modified Julian Date (MJD). -/// The date string is expected to be in UTC. -/// -/// Arguments -/// --------- -/// * `deserializer`: The deserializer to use for the date string. -/// -/// Return -/// ------ -/// * A `Result` containing the parsed MJD as a `f64` or an error if the parsing fails. -fn deserialize_mjd<'de, D>(deserializer: D) -> Result -where - D: Deserializer<'de>, -{ - let date_str = String::deserialize(deserializer)?; - - let time = Epoch::from_str(&date_str).map_err(serde::de::Error::custom)?; - - Ok(time.to_mjd_utc_days()) -} - -/// Parses a `FlatAdes` file and populates the given `Outfit` and `TrajectorySet`. -/// It iterates through the optical observations, extracting the observer's MPC code and -/// creating an `Observation` for each optical observation. -/// The observations are then added to the `TrajectorySet` using the trajectory ID. -/// If a new observatory is found, it is added to the `Outfit` observatory set. -/// -/// Arguments -/// --------- -/// * `outfit`: A mutable reference to the `Outfit` instance. -/// * `flat_ades`: A reference to the `FlatAdes` instance. -/// * `trajs`: A mutable reference to the `TrajectorySet` instance. -fn parse_flat_ades( - outfit: &mut Outfit, - flat_ades: &FlatAdes, - trajs: &mut TrajectorySet, - error_ra: Option, - error_dec: Option, -) { - for optical in &flat_ades.opticals { - let traj_id = optical.get_id(); - let observer = outfit.uint16_from_mpc_code(&optical.stn); - let observation = optical.to_observation(outfit, observer, error_ra, error_dec); - trajs - .entry(traj_id) - .or_insert_with(|| SmallVec::with_capacity(10)) - .push(observation); - } -} - -/// Parses a `StructuredAdes` file and populates the given `Outfit` and `TrajectorySet`. -/// It iterates through the observation blocks, extracting the observer's MPC code and -/// creating an `Observation` for each optical observation. -/// The observations are then added to the `TrajectorySet` using the trajectory ID. -/// If a new observatory is found, it is added to the `Outfit` observatory set. -/// -/// Arguments -/// --------- -/// * `outfit`: A mutable reference to the `Outfit` instance. -/// * `structured_ades`: A reference to the `StructuredAdes` instance. -/// * `trajs`: A mutable reference to the `TrajectorySet` instance. -fn parse_structured_ades( - outfit: &mut Outfit, - structured_ades: &StructuredAdes, - trajs: &mut TrajectorySet, - error_ra: Option, - error_dec: Option, -) { - for obs_block in &structured_ades.obs_blocks { - let obs_context = &obs_block.obs_context; - let mpc_code = &obs_context.observatory.mpc_code; - let observer = outfit.uint16_from_mpc_code(mpc_code); - - for optical in &obs_block.obs_data.opticals { - let observation = optical.to_observation(outfit, observer, error_ra, error_dec); - let traj_id = optical.get_id(); - trajs - .entry(traj_id) - .or_insert_with(|| SmallVec::with_capacity(10)) - .push(observation); - } - } -} - -/// Parses an ADES file and populates the given `Outfit` and `TrajectorySet`. -/// It first attempts to parse the file as a `FlatAdes`, and if that fails, it tries to parse it as a `StructuredAdes`. -/// If both parsing attempts fail, it panics with an error message. -/// If new observatory are found, they are added to the `Outfit` observatory set. -/// -/// Arguments -/// --------- -/// * `outfit`: A mutable reference to the `Outfit` instance. -/// * `ades`: A reference to the ADES file path. -/// * `trajs`: A mutable reference to the `TrajectorySet` instance. -pub(crate) fn parse_ades( - outfit: &mut Outfit, - ades: &Utf8Path, - trajs: &mut TrajectorySet, - error_ra: Option, - error_dec: Option, -) { - let xml = std::fs::read_to_string(ades) - .unwrap_or_else(|_| panic!("Failed to read ADES file: {ades}")); - - match from_str::(&xml) { - Ok(flat_ades) => { - parse_flat_ades(outfit, &flat_ades, trajs, error_ra, error_dec); - } - Err(flat_err) => match from_str::(&xml) { - Ok(structured_ades) => { - parse_structured_ades(outfit, &structured_ades, trajs, error_ra, error_dec); - } - Err(structured_err) => { - panic!( - "Failed to parse ADES file:\n- Flat error: {flat_err}\n- Structured error: {structured_err}" - ); - } - }, - } -} diff --git a/src/trajectories/batch_reader.rs b/src/trajectories/batch_reader.rs deleted file mode 100644 index 1d7021a..0000000 --- a/src/trajectories/batch_reader.rs +++ /dev/null @@ -1,432 +0,0 @@ -//! # Single-Observer Astrometric Batch Ingestion -//! -//! This module provides the [`ObservationBatch`] type, which groups multiple -//! astrometric detections from a **single observer** into a compact container. -//! Such a batch can then be expanded into concrete [`Observation`]s and stored -//! in a [`TrajectorySet`]. -//! -//! ## Overview -//! ----------------- -//! A wide-field survey typically delivers angle-only astrometry (RA/DEC, with -//! per-epoch timestamps). [`ObservationBatch`] wraps such measurements, together -//! with uniform error estimates, into a structured form ready for ingestion into -//! orbit-determination pipelines. -//! -//! To actually turn batches into stored observations, use the trait -//! [`TrajectoryFile`](crate::trajectories::trajectory_file::TrajectoryFile): -//! - [`TrajectoryFile::new_from_vec`](crate::trajectories::trajectory_file::TrajectoryFile::new_from_vec) — build a new [`TrajectorySet`] from a batch. -//! - [`TrajectoryFile::add_from_vec`](crate::trajectories::trajectory_file::TrajectoryFile::add_from_vec) — append a batch into an existing [`TrajectorySet`]. -//! -//! Both methods transparently handle the internal expansion of a batch into -//! per-sample [`Observation`]s (site position lookups, heliocentric positions, -//! RA/DEC/error propagation, etc.). -//! -//! ## Units & Conventions -//! ----------------- -//! - **Angles:** Right ascension and declination in **radians**. -//! If your upstream data are in **degrees/arcseconds**, use -//! [`ObservationBatch::from_degrees_owned`] to convert once at construction. -//! - **Uncertainties:** 1-σ errors in RA/DEC (radians). For arcsecond inputs, -//! the degree-based constructor performs the conversion for you. -//! - **Epochs:** Times in **MJD (TT)** (days). Convert UTC/TAI upstream. -//! - **Observer:** All rows in a batch must come from the **same** observer. -//! -//! ## Invariants -//! ----------------- -//! - `trajectory_id.len() == ra.len() == dec.len() == time.len()` -//! - All angles and uncertainties are in **radians**. -//! - All epochs are in **MJD (TT)**. -//! - Batch content belongs to a **single observer**. -//! -//! ## Construction Paths -//! ----------------- -//! - [`ObservationBatch::from_radians_borrowed`] — zero-copy when your pipeline already -//! provides radians and MJD (TT). -//! - [`ObservationBatch::from_degrees_owned`] — converts degrees/arcseconds → radians -//! once and stores owned buffers. -//! -//! ## Example -//! ----------------- -//! ```rust,no_run -//! use std::sync::Arc; -//! use outfit::{ -//! Outfit, Observer, TrajectorySet, TrajectoryFile, ErrorModel, -//! trajectories::batch_reader::ObservationBatch, -//! }; -//! -//! // Inputs in degrees / arcseconds (mixed objects: 0 and 1). -//! let traj_id = vec![0_u32, 0, 1]; -//! let ra_deg = vec![210.01, 210.02, 211.00]; -//! let dec_deg = vec![-5.00, -4.99, -4.00]; -//! let mjd_tt = vec![60345.12, 60345.13, 60345.20]; -//! -//! let batch = ObservationBatch::from_degrees_owned( -//! &traj_id, &ra_deg, &dec_deg, 0.5, 0.5, &mjd_tt -//! ); -//! -//! // Global environment and observer. -//! let mut outfit = Outfit::new("horizon:DE440", ErrorModel::FCCT14).unwrap(); -//! let observer = outfit.get_observer_from_mpc_code(&"I41".to_string()); -//! -//! // Build a new TrajectorySet directly from the batch. -//! let traj_set = TrajectorySet::new_from_vec(&mut outfit, &batch, observer.clone()) -//! .expect("ingestion OK"); -//! -//! // Or append to an existing set: -//! let mut other = TrajectorySet::default(); -//! other.add_from_vec(&mut outfit, &batch, observer).expect("append OK"); -//! ``` -//! -//! ## Notes -//! ----------------- -//! - Internally, ingestion is performed by a crate-private routine -//! (`observation_from_batch`) which expands the batch into per-sample -//! [`Observation`]s and caches observer positions per epoch. Users should rely -//! on the public `TrajectoryFile` methods instead. -//! - For multi-observer datasets, create one [`ObservationBatch`] per observer, -//! then ingest them separately. -//! -//! ## See also -//! ------------ -//! * [`ObservationBatch::from_radians_borrowed`] – Zero-copy construction. -//! * [`ObservationBatch::from_degrees_owned`] – Convert degrees/arcseconds once. -//! * [`TrajectoryFile::new_from_vec`](crate::trajectories::trajectory_file::TrajectoryFile::new_from_vec) – Public entry point for batch ingestion. -//! * [`TrajectoryFile::add_from_vec`](crate::trajectories::trajectory_file::TrajectoryFile::add_from_vec) – Append batch into an existing set. -use std::{borrow::Cow, sync::Arc}; - -use ahash::RandomState; -use hifitime::Epoch; -use nalgebra::Vector3; -use ordered_float::OrderedFloat; -use smallvec::SmallVec; - -use crate::{ - constants::Radian, conversion::arcsec_to_rad, observations::Observation, - trajectories::parquet_reader::FastHashMap, ArcSec, Degree, ObjectNumber, Observer, Outfit, - OutfitError, TrajectorySet, MJD, -}; - -/// Batch of observations from a single observer (angles in **radians**). -/// -/// This container groups multiple astrometric measurements sharing the same -/// observer into a single batch, ready to be expanded into -/// [`Observation`]s and stored in a -/// [`TrajectorySet`]. -/// -/// Each measurement includes: -/// - A trajectory identifier (`trajectory_id`) so that a single batch can hold -/// observations for multiple objects simultaneously. -/// - Right ascension and declination in **radians**, with uniform 1-σ uncertainties -/// (also in **radians**). -/// - Epochs in **MJD (TT)** (days). -/// -/// Fields -/// ----------------- -/// * `trajectory_id` — Integer trajectory IDs (object numbers). Length must match `ra`/`dec`/`time`. -/// * `ra` — Right ascension values (**radians**). Length must match `dec`, `time`, and `trajectory_id`. -/// * `error_ra` — 1-σ uncertainty on right ascension (**radians**) applied uniformly to the batch. -/// * `dec` — Declination values (**radians**). Length must match `ra`, `time`, and `trajectory_id`. -/// * `error_dec` — 1-σ uncertainty on declination (**radians**) applied uniformly to the batch. -/// * `time` — Observation epochs as **MJD (TT)** (days). Length must match `ra`/`dec`/`trajectory_id`. -/// -/// Invariants -/// ----------------- -/// * `trajectory_id.len() == ra.len() == dec.len() == time.len()` -/// * Angles and uncertainties are expressed in **radians**. -/// * Time scale is **TT** (use appropriate conversion if your source data are in UTC/TAI). -/// -/// Construction -/// ----------------- -/// Prefer the dedicated constructors: -/// * [`ObservationBatch::from_radians_borrowed`] — zero-copy when your inputs are already in radians. -/// * [`ObservationBatch::from_degrees_owned`] — converts degrees/arcseconds once into owned buffers. -/// -/// Example -/// ----------------- -/// ```rust, no_run -/// # use outfit::trajectories::batch_reader::ObservationBatch; -/// # let (traj_id, ra_deg, dec_deg, mjd) = (vec![0, 0, 1], vec![14.62, 14.63, 15.01], vec![9.98, 10.01, 11.02], vec![43785.35, 43785.36, 43785.40]); -/// // Inputs in degrees / arcseconds (converted once to radians internally): -/// let batch = ObservationBatch::from_degrees_owned(&traj_id, &ra_deg, &dec_deg, 0.5, 0.5, &mjd); -/// -/// // Or, if you already have radians: -/// // let batch = ObservationBatch::from_radians_borrowed(&ra_rad, &dec_rad, err_ra_rad, err_dec_rad, &mjd); -/// ``` -/// -/// See also -/// ------------ -/// * [`ObservationBatch::from_radians_borrowed`] – Borrow slices already in radians (zero-copy). -/// * [`ObservationBatch::from_degrees_owned`] – Convert degrees/arcseconds → radians once. -/// * [`conversion::arcsec_to_rad`](crate::conversion::arcsec_to_rad) – Arcseconds → radians helper. -#[derive(Debug, Clone)] -pub struct ObservationBatch<'a> { - pub trajectory_id: Cow<'a, [u32]>, - - /// Right ascension values (**radians**). Must have the same length as `dec` and `time`. - pub ra: Cow<'a, [Radian]>, - - /// 1-σ uncertainty on right ascension (**radians**), applied uniformly to the batch. - /// Note: the weighting scheme accounts for the RA geometry (e.g., cos δ factors) downstream. - pub error_ra: Radian, - - /// Declination values (**radians**). Must have the same length as `ra` and `time`. - pub dec: Cow<'a, [Radian]>, - - /// 1-σ uncertainty on declination (**radians**), applied uniformly to the batch. - pub error_dec: Radian, - - /// Observation epochs as **MJD (TT)**, in days. Must have the same length as `ra`/`dec`. - pub time: Cow<'a, [MJD]>, -} - -impl<'a> ObservationBatch<'a> { - /// Construct a batch by **borrowing** slices that are already in radians. - /// - /// The returned batch holds `Cow::Borrowed` views of the provided slices, - /// performing **no allocation** and **no unit conversion**. - /// Use this when your upstream pipeline already provides: - /// - Trajectory identifiers (`trajectory_id`) - /// - Right ascension / declination in **radians** - /// - Uncertainties in **radians** - /// - Epochs in **MJD (TT)** - /// - /// Arguments - /// ----------------- - /// * `trajectory_id` — Integer trajectory IDs; length must match all angle/time slices. - /// * `ra_rad` — Right ascension values in **radians** (borrowed). - /// * `dec_rad` — Declination values in **radians** (borrowed). - /// * `error_ra_rad` — 1-σ uncertainty on RA in **radians**, applied uniformly to the batch. - /// * `error_dec_rad` — 1-σ uncertainty on DEC in **radians**, applied uniformly to the batch. - /// * `time_mjd` — Observation epochs as **MJD (TT)** (borrowed). - /// - /// Return - /// ---------- - /// * A batch borrowing the provided slices (**zero-copy**). - /// - /// Invariants - /// ---------- - /// * `trajectory_id.len() == ra_rad.len() == dec_rad.len() == time_mjd.len()` - /// - /// Panics - /// ---------- - /// * Debug builds only: panics if the slice lengths do not match. - /// - /// Complexity - /// ---------- - /// * O(1) — no allocation, no conversion. - /// - /// See also - /// ------------ - /// * [`ObservationBatch::from_degrees_owned`] – Convert degrees/arcseconds → radians and own the buffers. - /// * [`conversion::arcsec_to_rad`](crate::conversion::arcsec_to_rad) – Arcseconds → radians helper. - pub fn from_radians_borrowed( - trajectory_id: &'a [u32], - ra_rad: &'a [Radian], - dec_rad: &'a [Radian], - error_ra_rad: Radian, - error_dec_rad: Radian, - time_mjd: &'a [MJD], - ) -> Self { - debug_assert_eq!(ra_rad.len(), dec_rad.len(), "RA/DEC length mismatch"); - debug_assert_eq!(ra_rad.len(), time_mjd.len(), "RA/time length mismatch"); - - Self { - trajectory_id: Cow::Borrowed(trajectory_id), - ra: Cow::Borrowed(ra_rad), - dec: Cow::Borrowed(dec_rad), - time: Cow::Borrowed(time_mjd), - error_ra: error_ra_rad, - error_dec: error_dec_rad, - } - } - - /// Construct a batch from **degrees** (angles) and **arcseconds** (uncertainties), - /// converting to **radians** and **owning** the resulting buffers. - /// - /// Use this when your inputs come from common astrometric formats (e.g., MPC/ADES) - /// that report RA/DEC in degrees and uncertainties in arcseconds. - /// Conversion is performed **once** at construction; downstream code operates purely in radians. - /// - /// Arguments - /// ----------------- - /// * `trajectory_id` — Integer trajectory IDs; length must match all angle/time slices. - /// * `ra_deg` — Right ascension in **degrees** (borrowed); converted to radians. - /// * `dec_deg` — Declination in **degrees** (borrowed); converted to radians. - /// * `error_ra_arcsec` — 1-σ uncertainty on RA in **arcseconds**; converted to radians. - /// * `error_dec_arcsec` — 1-σ uncertainty on DEC in **arcseconds**; converted to radians. - /// * `time_mjd` — Observation epochs as **MJD (TT)** (borrowed; cloned to owned buffer). - /// - /// Return - /// ---------- - /// * A batch **owning** converted buffers (no dangling slices). - /// - /// Invariants - /// ---------- - /// * `trajectory_id.len() == ra_deg.len() == dec_deg.len() == time_mjd.len()` - /// - /// Panics - /// ---------- - /// * Panics if the slice lengths do not match. - /// - /// Complexity - /// ---------- - /// * O(n) for the degree→radian and arcsec→radian conversions + one `to_vec()` for time. - /// - /// See also - /// ------------ - /// * [`ObservationBatch::from_radians_borrowed`] – Zero-copy constructor when inputs are already in radians. - /// * [`conversion::arcsec_to_rad`](crate::conversion::arcsec_to_rad) – Arcseconds → radians helper. - pub fn from_degrees_owned( - trajectory_id: &'a [u32], - ra_deg: &[Degree], - dec_deg: &[Degree], - error_ra_arcsec: ArcSec, - error_dec_arcsec: ArcSec, - time_mjd: &[MJD], - ) -> Self { - debug_assert_eq!(ra_deg.len(), dec_deg.len(), "RA/DEC length mismatch"); - debug_assert_eq!(ra_deg.len(), time_mjd.len(), "RA/time length mismatch"); - - let ra: Vec = ra_deg.iter().map(|&d| d.to_radians()).collect(); - let dec: Vec = dec_deg.iter().map(|&d| d.to_radians()).collect(); - let time: Vec = time_mjd.to_vec(); - - Self { - trajectory_id: Cow::Owned(trajectory_id.to_vec()), - ra: Cow::Owned(ra), - dec: Cow::Owned(dec), - time: Cow::Owned(time), - error_ra: arcsec_to_rad(error_ra_arcsec), - error_dec: arcsec_to_rad(error_dec_arcsec), - } - } -} - -/// Expand a single-observer batch into concrete [`Observation`]s and append them into a [`TrajectorySet`]. -/// -/// This routine ingests an [`ObservationBatch`] whose angles and uncertainties are in **radians** -/// and whose epochs are **MJD (TT)**, then materializes per-sample [`Observation`]s enriched with -/// site geocentric and heliocentric positions. All measurements are assumed to come from the **same -/// observer** (hence a single `observer: Arc` argument). -/// -/// For performance, it: -/// - resolves the observer into a compact `u16` **once** (hot path), -/// - pre-fetches the UT1 provider **once**, -/// - caches observer positions by epoch: **MJD(TT) → (geo_pos, helio_pos)**, -/// so repeated timestamps incur **no extra** position computation. -/// -/// Internally, epoch keys are wrapped in `OrderedFloat` to enable their use in a hash map -/// (total order on `f64` while rejecting `NaN` inputs by construction). -/// -/// Arguments -/// ----------------- -/// * `trajectories` — Target container to receive observations, bucketed by `trajectory_id`. -/// * `env_state` — Global [`Outfit`] state (ephemerides, EOP/UT1 providers, etc.). -/// * `batch` — Angles and 1-σ uncertainties in **radians**; epochs as **MJD (TT)**; includes `trajectory_id`. -/// * `observer` — The (single) observer for **all** samples in `batch`. -/// -/// Return -/// ---------- -/// * `Ok(())` if all observations were successfully appended into `trajectories`, -/// * `Err(OutfitError)` if site position or heliocentric position computations fail. -/// -/// Panics -/// ---------- -/// * **Debug builds only**: length mismatches across `ra/dec/time/trajectory_id` trigger `debug_assert!`. -/// -/// Complexity -/// ---------- -/// * Time: **O(n)**, with at most **O(u)** geocentric/heliocentric computations where `u` is the number of -/// **unique** epochs in the batch (`u ≤ n`) thanks to the epoch→position cache. -/// * Space: **O(u)** for the epoch→position cache. -/// -/// Notes -/// ---------- -/// * Input angles (RA/DEC) and uncertainties **must already be in radians**. If your source is degrees/arcsec, -/// build the batch via [`ObservationBatch::from_degrees_owned`] (conversion done once at construction). -/// * Epochs are expected as **TT**. Convert upstream if your pipeline feeds UTC/TAI. -/// * This function mutates `trajectories` and reads from `env_state`. If you need parallelization, consider -/// extracting immutable position providers beforehand, or designing providers that accept shared references. -/// -/// See also -/// ------------ -/// * [`ObservationBatch::from_radians_borrowed`] – Zero-copy batch when inputs are already radians. -/// * [`ObservationBatch::from_degrees_owned`] – Degree/arcsec → rad conversion once at construction. -/// * [`parquet_to_trajset`] – Parquet ingestion using the same unit/weighting logic. -/// * [`conversion::arcsec_to_rad`] – Arcseconds → radians helper. -pub(crate) fn observation_from_batch( - trajectories: &mut TrajectorySet, - env_state: &mut Outfit, - batch: &ObservationBatch<'_>, - observer: Arc, -) -> Result<(), OutfitError> { - // --- Fast sanity checks (debug only) --------------------------------------- - // All slices must be aligned one-to-one. These checks are enforced at construction - // time for production, but we keep them here in debug builds to catch regressions. - debug_assert_eq!(batch.ra.len(), batch.dec.len(), "RA/DEC length mismatch"); - debug_assert_eq!(batch.ra.len(), batch.time.len(), "RA/time length mismatch"); - debug_assert_eq!( - batch.ra.len(), - batch.trajectory_id.len(), - "RA/trajectory_id length mismatch" - ); - - // Resolve observer ID once (hot path avoids map lookups later). - let uint16_obs = env_state.uint16_from_observer(observer.clone()); - - // Pre-fetch UT1 provider once. - let ut1 = env_state.get_ut1_provider(); - - // Heuristic: many surveys repeat epochs per exposure → cache a fraction of N. - let n = batch.ra.len(); - let est_cache_cap = (n / 4).clamp(64, 4096); - let mut pos_cache: FastHashMap, (Vector3, Vector3)> = - FastHashMap::with_capacity_and_hasher(est_cache_cap, RandomState::default()); - - // Safe and fast: iterators avoid per-iteration bounds checks - let ra_it = batch.ra.iter().copied(); - let dec_it = batch.dec.iter().copied(); - let time_it = batch.time.iter().copied(); - let id_it = batch.trajectory_id.iter().copied(); - - for ((ra, dec), (mjd_tt, traj_id)) in ra_it.zip(dec_it).zip(time_it.zip(id_it)) { - // Use OrderedFloat to permit f64 as a key with a total order. - let key = OrderedFloat(mjd_tt); - - // Compute (or reuse) positions at this epoch. - let (geo_pos, helio_pos) = if let Some(&(geo, helio)) = pos_cache.get(&key) { - (geo, helio) - } else { - let epoch = Epoch::from_mjd_in_time_scale(mjd_tt, hifitime::TimeScale::TT); - - // Geocentric position (velocity returned but unused here). - let (geo, _vel) = observer.pvobs(&epoch, ut1)?; - - // Heliocentric position (uses geocentric position). - let helio = observer.helio_position(env_state, &epoch, &geo)?; - - pos_cache.insert(key, (geo, helio)); - (geo, helio) - }; - - // Build observation and append to the right trajectory bucket. - let obs = Observation::with_positions( - uint16_obs, - ra, // radians - batch.error_ra, // radians - dec, // radians - batch.error_dec, // radians - mjd_tt, // MJD(TT) - geo_pos, - helio_pos, - ); - - let obj = ObjectNumber::Int(traj_id); - trajectories - .entry(obj) - .or_insert_with(|| SmallVec::with_capacity(32)) - .push(obs); - } - - Ok(()) -} diff --git a/src/trajectories/mod.rs b/src/trajectories/mod.rs deleted file mode 100644 index e04349f..0000000 --- a/src/trajectories/mod.rs +++ /dev/null @@ -1,121 +0,0 @@ -//! # Trajectories: ingestion, storage, and batch IOD -//! -//! High-level facilities to **ingest**, **store**, and **process** astrometric observations -//! grouped by object. The central type is [`TrajectorySet`], a fast hash map that buckets -//! time-ordered observations per [`ObjectNumber`]. Public helpers let you build a set from -//! multiple formats (MPC 80-column, Parquet, ADES, or in-memory batches) and run a -//! **Gauss-based Initial Orbit Determination (IOD)** over all objects. -//! -//! Modules -//! ----------------- -//! * [`batch_reader`](crate::trajectories::batch_reader) – Zero-copy container and routines to expand single-observer batches -//! into concrete [`Observation`](crate::observations::Observation)s. -//! * [`mpc_80col_reader`](crate::trajectories::mpc_80col_reader) – Minimal MPC **80-column** file reader. -//! * [`parquet_reader`](crate::trajectories::parquet_reader) – Arrow/Parquet-based ingestion (`ra`, `dec`, `jd`, `trajectory_id`). -//! * [`ades_reader`](crate::trajectories::ades_reader) – ADES (MPC XML/JSON) ingestion. -//! * [`trajectory_file`](crate::trajectories::trajectory_file) – **Public** trait exposing `new_from_*` and `add_from_*` helpers -//! to construct/extend a [`TrajectorySet`] from the above sources. -//! * [`trajectory_fit`](crate::trajectories::trajectory_fit) – Batch Gauss IOD over a set (`TrajectoryFit` trait, results & stats). -//! * *(crate-private)* `progress_bar` – Optional progress UI when the `progress` feature is enabled. -//! -//! Data Model -//! ----------------- -//! * **Key:** [`ObjectNumber`] (logical object identifier). -//! * **Value:** `Observations` = `SmallVec` time-ordered per object. -//! * **Set:** [`TrajectorySet`] = `HashMap` -//! for fast hashing and predictable performance on large catalogs. -//! -//! Ingestion Sources -//! ----------------- -//! Use the [`trajectory_file::TrajectoryFile`](crate::trajectories::trajectory_file) trait (implemented for [`TrajectorySet`]): -//! * **80-col MPC** — `new_from_80col`, `add_from_80col` (fail-fast on parse errors). -//! * **Parquet** — `new_from_parquet`, `add_from_parquet` (propagate `OutfitError` on I/O/schema). -//! * **ADES** — `new_from_ades`, `add_from_ades` (error policy handled in the parser). -//! * **In-memory batch** (single observer) — `new_from_vec`, `add_from_vec` -//! using [`batch_reader::ObservationBatch`](crate::trajectories::batch_reader::ObservationBatch) (angles/σ in **radians**, epochs in **MJD (TT)**). -//! -//! Units & Time Scales -//! ----------------- -//! * Internal angles are **radians**; readers convert from **degrees/arcsec** as needed. -//! * Epochs are **MJD (TT)**; Parquet `"jd"` (assumed **TT**) is converted via -//! [`constants::JDTOMJD`](crate::constants::JDTOMJD). -//! * Single-observer batches carry uniform 1-σ uncertainties (radians) applied per component. -//! -//! Batch IOD -//! ----------------- -//! Use [`trajectory_fit::TrajectoryFit::estimate_all_orbits`](crate::trajectories::trajectory_fit::TrajectoryFit::estimate_all_orbits) to run Gauss IOD over the set. -//! Returns a map `ObjectNumber → Result<(GaussResult, rms), OutfitError>`. Errors are **per-object** -//! and do not abort other objects. A cooperative-cancel variant is also available. -//! -//! Performance Notes -//! ----------------- -//! * Ingestion paths project only required columns and cache site positions by epoch. -//! * [`TrajectorySet`] uses `ahash` for speed; no deduplication is performed on `add_*` methods. -//! * Ordering is preserved as provided by sources; sorting by time is not enforced here. -//! -//! Feature Flags -//! ----------------- -//! * `progress` — Enables a live progress bar and timing during batch IOD. See -//! [`trajectory_fit`](crate::trajectories::trajectory_fit) for details. The UI is crate-internal and optional. -//! -//! Quick-Start -//! ----------------- -//! ```rust,no_run -//! use rand::SeedableRng; -//! use std::sync::Arc; -//! use camino::Utf8Path; -//! use outfit::{Outfit, TrajectorySet}; -//! use outfit::observers::Observer; -//! use outfit::trajectories::trajectory_file::TrajectoryFile; -//! use outfit::trajectories::trajectory_fit::TrajectoryFit; -//! use outfit::initial_orbit_determination::IODParams; -//! -//! # fn run() -> Result<(), outfit::outfit_errors::OutfitError> { -//! let mut state = Outfit::new("horizon:DE440", outfit::error_models::ErrorModel::FCCT14)?; -//! let observer: Arc = state.get_observer_from_mpc_code(&"I41".into()); -//! -//! // Ingest from Parquet, then append 80-column MPC -//! let mut trajs: TrajectorySet = TrajectorySet::new_from_parquet( -//! &mut state, Utf8Path::new("obs.parquet"), observer.clone(), 0.5, 0.5, Some(8192) -//! )?; -//! trajs.add_from_80col(&mut state, Utf8Path::new("obs_80col.txt")); -//! -//! // Batch IOD -//! let mut rng = rand::rngs::StdRng::from_os_rng(); -//! let params = IODParams::builder().max_triplets(32).build()?; -//! let results = trajs.estimate_all_orbits(&state, &mut rng, ¶ms); -//! # Ok(()) } -//! ``` -//! -//! See also -//! ------------ -//! * [`trajectory_file`](crate::trajectories::trajectory_file) – Public ingestion API. -//! * [`batch_reader`](crate::trajectories::batch_reader) – Batch expansion for single-observer inputs. -//! * [`parquet_reader`](crate::trajectories::parquet_reader), [`mpc_80col_reader`](crate::trajectories::mpc_80col_reader), [`ades_reader`](crate::trajectories::ades_reader) – File readers. -//! * [`trajectory_fit`](crate::trajectories::trajectory_fit) – Batch IOD, results, and statistics. -//! * [`crate::observations::Observation`] – Atomic astrometric sample. -//! -//! --- -use std::collections::HashMap; - -use ahash::RandomState; - -use crate::{constants::Observations, ObjectNumber}; - -pub mod ades_reader; -pub mod batch_reader; -pub mod mpc_80col_reader; -pub mod parquet_reader; -pub mod trajectory_file; -pub mod trajectory_fit; - -#[cfg(feature = "progress")] -pub(crate) mod progress_bar; - -/// A full set of trajectories for multiple objects. -/// -/// The key is the [`ObjectNumber`] (identifier of an object). -/// The value is the list of [`Observation`](crate::observations::Observation) associated with this object. -/// -/// Uses [`ahash`](https://docs.rs/ahash) for fast hashing. -pub type TrajectorySet = HashMap; diff --git a/src/trajectories/mpc_80col_reader.rs b/src/trajectories/mpc_80col_reader.rs deleted file mode 100644 index b66a401..0000000 --- a/src/trajectories/mpc_80col_reader.rs +++ /dev/null @@ -1,345 +0,0 @@ -//! # MPC 80-Column Observation Reader -//! -//! Utilities to parse **MPC 80-column** astrometric observations and turn them into -//! [`Observation`] values usable by the orbit-determination pipeline. -//! -//! ## Overview -//! ----------------- -//! This module provides: -//! - A small error type [`ParseObsError`] describing MPC parsing failures. -//! - A crate-internal line parser (`from_80col`) that converts a single 80-col line -//! into an [`Observation`] with angles in **radians** and time in **MJD (TT)**. -//! - A crate-visible batch routine \[`extract_80col`\] that reads an entire file, -//! returns all parsed [`Observation`]s, and extracts the **object number** from -//! the header line. -//! -//! The implementation enforces **MPC CCD** observations (rejects non-CCD lines) and -//! converts RA/Dec strings using robust helpers (`parse_ra_to_deg`, `parse_dec_to_deg`), -//! then applies uncertainty handling that accounts for `cos δ` on the RA component. -//! -//! ## Units & Conventions -//! ----------------- -//! - **Input format:** MPC 80-column fixed-width ASCII lines. -//! - **Angles:** RA/Dec are parsed from strings into **degrees**, then converted to -//! **radians**. -//! - **Uncertainties:** parsed in input units (hour/deg for RA/Dec) and mapped to -//! **radians**; RA uncertainties are divided by `cos δ`. -//! - **Time scale:** Observing time is parsed from fractional date and converted -//! to **MJD (TT)** via [`frac_date_to_mjd`]. -//! - **Observer site:** Extracted from columns 77–80 (MPC code), mapped to the -//! compact `u16` site id via \[`Outfit::uint16_from_mpc_code`\]. -//! -//! ## File-Level Object Number -//! ----------------- -//! \[`extract_80col`\] retrieves an **object number** from the first line using the -//! conventional MPC header locations (columns `0..5` or fallback to `5..12`), trims -//! leading zeros, and returns it as an [`ObjectNumber::String`]. -//! -//! ## Error Handling -//! ----------------- -//! Parser failures are wrapped into [`OutfitError::Parsing80ColumnFileError`] with a -//! [`ParseObsError`] payload for precise diagnostics (e.g. *line too short*, -//! *invalid RA*, *invalid date*). Non-CCD lines are filtered out by default. -//! -//! ## See also -//! ------------ -//! * [`parse_ra_to_deg`] – RA string → degrees with uncertainty. -//! * [`parse_dec_to_deg`] – Dec string → degrees with uncertainty. -//! * [`frac_date_to_mjd`] – Fractional date → MJD (TT). -//! * [`Observation`] – Internal astrometric sample type. -//! * [`ObjectNumber`] – Logical object identifier. -//! * [`Outfit`] – Global state (site registry, time scales, etc.). -use std::ops::Range; - -use camino::Utf8Path; -use thiserror::Error; - -use crate::{ - constants::Observations, - conversion::{parse_dec_to_deg, parse_ra_to_deg}, - observations::Observation, - time::frac_date_to_mjd, - ObjectNumber, Outfit, OutfitError, RADH, RADSEC, -}; - -/// Line-level parsing errors for MPC 80-column observations. -/// -/// Variants -/// ----------------- -/// * `TooShortLine` – The line does not reach 80 characters. -/// * `NotCCDObs` – The line is not flagged as a CCD observation (column 15 is `'s'`). -/// * `InvalidRA` – Failed to parse RA field (`line[32..44]`); payload carries the offending slice. -/// * `InvalidDec` – Failed to parse Dec field (`line[44..56]`); payload carries the offending slice. -/// * `InvalidDate` – Failed to parse fractional date (`line[15..32]`); payload carries the offending slice. -/// -/// See also -/// ------------ -/// * [`parse_ra_to_deg`] – Robust RA parser with uncertainty extraction. -/// * [`parse_dec_to_deg`] – Robust Dec parser with uncertainty extraction. -/// * [`frac_date_to_mjd`] – Converts fractional date to MJD (TT). -#[derive(Error, Debug, PartialEq)] -pub enum ParseObsError { - #[error("The line is too short")] - TooShortLine, - #[error("The line is not a CCD observation")] - NotCCDObs, - #[error("Error parsing RA: {0}")] - InvalidRA(String), - #[error("Invalid Dec value: {0}")] - InvalidDec(String), - #[error("Invalid date: {0}")] - InvalidDate(String), -} - -/// Parse a single **MPC 80-column** line into an [`Observation`] (crate-private helper). -/// -/// This routine enforces **CCD** observations, parses RA/Dec/time fields, maps the -/// MPC site code to the compact observer id, and builds an [`Observation`] in **radians** -/// with a **MJD (TT)** epoch. RA uncertainties are divided by `cos δ` to reflect -/// the geometry on the sphere. -/// -/// Arguments -/// ----------------- -/// * `env_state` – Global state used to resolve MPC site codes and build observations. -/// * `line` – A single 80-column ASCII line. -/// -/// Return -/// ---------- -/// * A parsed [`Observation`] or an [`OutfitError::Parsing80ColumnFileError`] on failure. -/// -/// Panics -/// ---------- -/// * Never panics directly; errors are surfaced as [`OutfitError`]. Bounds use fixed slices. -/// -/// Field Layout (MPC 80-col subset used here) -/// ----------------- -/// * `15..32` – Fractional date (UTC-like string expected by `frac_date_to_mjd`). -/// * `32..44` – Right ascension string (parsed by `parse_ra_to_deg`). -/// * `44..56` – Declination string (parsed by `parse_dec_to_deg`). -/// * `77..80` – MPC site code. -/// -/// See also -/// ------------ -/// * [`parse_ra_to_deg`] – RA parsing (degrees + uncertainty). -/// * [`parse_dec_to_deg`] – Dec parsing (degrees + uncertainty). -/// * [`frac_date_to_mjd`] – Fractional date → MJD (TT). -fn from_80col(env_state: &mut Outfit, line: &str) -> Result { - if line.len() < 80 { - return Err(OutfitError::Parsing80ColumnFileError( - ParseObsError::TooShortLine, - )); - } - - if line.chars().nth(14) == Some('s') { - return Err(OutfitError::Parsing80ColumnFileError( - ParseObsError::NotCCDObs, - )); - } - - let (ra, error_ra) = parse_ra_to_deg(line[32..44].trim()).ok_or_else(|| { - OutfitError::Parsing80ColumnFileError(ParseObsError::InvalidRA( - line[32..44].trim().to_string(), - )) - })?; - - let (dec, error_dec) = parse_dec_to_deg(line[44..56].trim()).ok_or_else(|| { - OutfitError::Parsing80ColumnFileError(ParseObsError::InvalidDec( - line[44..56].trim().to_string(), - )) - })?; - - let time = frac_date_to_mjd(line[15..32].trim()).map_err(|_| { - OutfitError::Parsing80ColumnFileError(ParseObsError::InvalidDate( - line[15..32].trim().to_string(), - )) - })?; - - let observer_id = env_state.uint16_from_mpc_code(&line[77..80].trim().into()); - let observer = env_state.get_observer_from_uint16(observer_id); - - let max_rms = |observation_error: f64, observer_error: f64, factor: f64| { - f64::max(observation_error, observer_error * factor) - }; - - let dec_radians = dec.to_radians(); - let dec_rad_cos = dec_radians.cos(); - - let ra_error = max_rms( - (error_ra * RADH) / dec_rad_cos, - observer.ra_accuracy.map(|v| v.into_inner()).unwrap_or(0.0), - RADSEC / dec_rad_cos, - ); - - let dec_error = max_rms( - error_dec.to_radians(), - observer.dec_accuracy.map(|v| v.into_inner()).unwrap_or(0.0), - RADSEC, - ); - - let observation = Observation::new( - env_state, - observer_id, - ra.to_radians(), - ra_error, - dec_radians, - dec_error, - time, - )?; - Ok(observation) -} - -/// Read a full **MPC 80-column** file, returning parsed observations and the object number. -/// -/// Lines that are not CCD observations are **silently skipped**. Any other parsing error -/// triggers a panic with context (the current strategy is fail-fast for corrupted inputs). -/// The object number is extracted from the first line (`0..5`, fallback `5..12`), trimming -/// leading zeros. -/// -/// Arguments -/// ----------------- -/// * `env_state` – Global state used to resolve MPC site codes and build observations. -/// * `colfile` – Path to the MPC 80-column file. -/// -/// Return -/// ---------- -/// * A tuple `(observations, object_number)` where: -/// - `observations` is a `Vec` with angles in **radians** and epochs in **MJD (TT)**, -/// - `object_number` is the header-derived [`ObjectNumber::String`]. -/// -/// Panics -/// ---------- -/// * Panics if the file cannot be read or if a non-CCD line fails to parse for reasons -/// other than the expected `NotCCDObs` (fail-fast behavior). -/// -/// See also -/// ------------ -/// * [`Observation`] – Parsed astrometric sample. -/// * [`ObjectNumber`] – Identifier extracted from header columns. -/// * [`parse_ra_to_deg`], [`parse_dec_to_deg`], [`frac_date_to_mjd`] – Parsing helpers. -pub(crate) fn extract_80col( - env_state: &mut Outfit, - colfile: &Utf8Path, -) -> Result<(Observations, ObjectNumber), OutfitError> { - let file_content = std::fs::read_to_string(colfile) - .unwrap_or_else(|_| panic!("Could not read file {}", colfile.as_str())); - - let first_line = file_content - .lines() - .next() - .unwrap_or_else(|| panic!("Could not read first line of file {}", colfile.as_str())); - - fn get_object_number(line: &str, range: Range) -> String { - line[range].trim_start_matches('0').trim().to_string() - } - - let mut object_number = get_object_number(first_line, 0..5); - if object_number.is_empty() { - object_number = get_object_number(first_line, 5..12); - } - - Ok(( - file_content - .lines() - .filter_map(|line| match from_80col(env_state, line) { - Ok(obs) => Some(obs), - Err(OutfitError::Parsing80ColumnFileError(ParseObsError::NotCCDObs)) => None, - Err(e) => panic!("Error parsing line: {e:?}"), - }) - .collect(), - ObjectNumber::String(object_number), - )) -} - -#[cfg(test)] -#[cfg(feature = "jpl-download")] -mod mpc_80col_test { - use super::*; - - #[test] - fn test_from_80col_valid_line() { - use crate::unit_test_global::OUTFIT_HORIZON_TEST; - - let line = - " K09R05F C2009 09 15.23433 22 52 22.62 -14 47 03.2 20.8 Vr~097wG96"; - - let mut env_state = OUTFIT_HORIZON_TEST.0.clone(); - let result = from_80col(&mut env_state, line); - - assert!(result.is_ok()); - let obs = result.unwrap(); - - assert_eq!( - obs, - Observation { - observer: 0, - ra: 5.988124307160555, - error_ra: 1.2535340843609459e-6, - dec: -0.25803335512429054, - error_dec: 1.0181086985431635e-6, - time: 55089.23509601851, - observer_earth_position: [ - 3.0499942822953885e-5, - -8.594304778250371e-6, - 2.8491013919142154e-5 - ] - .into(), - observer_helio_position: [ - 0.9968138444702415, - -0.12221921296802639, - -0.05295724448160355 - ] - .into(), - } - ); - } - - #[test] - fn test_from_80col_too_short_line() { - use crate::unit_test_global::OUTFIT_HORIZON_TEST; - - let line = "short line"; - let mut env_state = OUTFIT_HORIZON_TEST.0.clone(); - let result = from_80col(&mut env_state, line); - - assert!(matches!( - result, - Err(OutfitError::Parsing80ColumnFileError( - ParseObsError::TooShortLine - )) - )); - } - - #[test] - fn test_from_80col_invalid_date() { - use crate::unit_test_global::OUTFIT_HORIZON_TEST; - - let line = - " K09R05F C20xx 09 15.23433 22 52 22.62 -14 47 03.2 20.8 Vr~097wG96"; - let mut env_state = OUTFIT_HORIZON_TEST.0.clone(); - let result = from_80col(&mut env_state, line); - - assert!(matches!( - result, - Err(OutfitError::Parsing80ColumnFileError( - ParseObsError::InvalidDate(_) - )) - )); - } - - #[test] - fn test_from_80col_invalid_ra_dec() { - use crate::unit_test_global::OUTFIT_HORIZON_TEST; - - let line = - " K09R05F C2009 09 15.23433 XX YY ZZ.ZZ -AA BB CC.C 20.8 Vr~097wG96"; - let mut env_state = OUTFIT_HORIZON_TEST.0.clone(); - let result = from_80col(&mut env_state, line); - - assert!(matches!( - result, - Err(OutfitError::Parsing80ColumnFileError( - ParseObsError::InvalidRA(_) - )) - )); - } -} diff --git a/src/trajectories/parquet_reader.rs b/src/trajectories/parquet_reader.rs deleted file mode 100644 index 4a16de9..0000000 --- a/src/trajectories/parquet_reader.rs +++ /dev/null @@ -1,362 +0,0 @@ -//! # Parquet Reader for Astrometric Observations -//! -//! High-throughput ingestion of angle-only astrometric detections from **Apache Parquet** -//! into a [`TrajectorySet`]. This module focuses on a minimal, column-projected read path, -//! converts **JD→MJD (TT)**, and constructs [`Observation`]s while caching observer positions -//! by unique epoch to avoid repeated ephemeris calls. -//! -//! ## Overview -//! ----------------- -//! The primary entry point is a crate-internal routine that reads Parquet record batches, -//! projects only the required columns, and appends parsed samples to an existing -//! [`TrajectorySet`]. It is typically called by higher-level, public ingestion helpers -//! (e.g., methods exposed by a `TrajectoryFile` trait). -//! -//! Key design points: -//! - **Projection-first**: materialize only the columns used by Outfit. -//! - **Typed downcast once per batch**: avoid per-row dynamic checks. -//! - **Fast path for non-null columns**: iterate over `&[f64]` / `&[u32]` slices. -//! - **Epoch→position cache**: compute `(geo_pos, helio_pos)` at most once per unique MJD(TT). -//! -//! ## Expected Parquet Schema -//! ----------------- -//! The input file must contain (at least) the following leaf columns: -//! - `ra: Float64` — Right ascension in **degrees**. -//! - `dec: Float64` — Declination in **degrees**. -//! - `jd: Float64` — Epoch in **Julian Date (TT)**. -//! - `trajectory_id: UInt32` — Grouping key used as [`ObjectNumber::Int`]. -//! -//! Columns are accessed by **name** at setup (to build the projection mask) and then by -//! **index** in the hot loop. If a required column is missing, a clean `io::Error` is returned. -//! -//! ## Units & Conventions -//! ----------------- -//! - **Angles:** `ra`, `dec` are stored in **degrees** on disk and converted to **radians** -//! before building [`Observation`]s. -//! - **Uncertainties:** provided as **arcseconds** at call-site, converted to **radians** once; -//! applied uniformly to all rows of the file (per-component). -//! - **Time scale:** `jd` is assumed to be **TT** on disk. It is converted to **MJD (TT)** -//! via subtraction by [`JDTOMJD`]. -//! - **Observer:** the file is read under a **single** [`Observer`]. If you ingest heterogeneous -//! observers, extend the cache key to `(observer_id, mjd_tt)` or build one cache per observer. -//! -//! ## Null Handling Policy -//! ----------------- -//! Two execution paths: -//! - **No nulls** (fast path): raw slice iteration with minimal overhead. -//! - **With nulls** (fallback): per-row checks; incomplete rows are **skipped** to preserve -//! correctness. Prefer cleaning datasets upstream for best performance. -//! -//! ## Performance Notes -//! ----------------- -//! - **Projection** avoids unnecessary I/O and deserialization. -//! - **Batch size** (`8192` by default) amortizes decompression and Arrow decoding; -//! tune between `8k` and `64k` depending on storage/CPU. -//! - **Caching** uses `FastHashMap, (Vector3, Vector3)>` -//! keyed by MJD(TT), typically yielding large savings whenever exposures share timestamps. -//! - **Zero-ephemeris constructor**: [`Observation::with_positions`] prevents recomputing -//! positions during construction. -//! -//! ## Error Handling -//! ----------------- -//! - I/O and schema issues surface as `io::Error` or `ParquetError` wrapped into [`OutfitError`]. -//! - Ephemeris/observer computations (`pvobs`, `helio_position`) may return [`OutfitError`]. -//! - Missing required columns produce an `io::ErrorKind::NotFound` with a clear message. -//! -//! > **Note** -//! > This reader is **crate-private** and is used under the hood by higher-level, -//! > public ingestion helpers (e.g., methods implementing a `TrajectoryFile` trait). -//! -//! ## See also -//! ------------ -//! * [`Observation::with_positions`] – Lagrange-friendly constructor with precomputed positions. -//! * [`Observer::pvobs`] – Geocentric site position at epoch. -//! * [`Observer::helio_position`] – Heliocentric site position at epoch. -//! * [`JDTOMJD`] – Constant used for `JD → MJD (TT)` conversion. -//! * [`ObjectNumber`] – Key type for per-object bucketing in [`TrajectorySet`]. -use arrow_array::Array; -use hifitime::Epoch; -use nalgebra::Vector3; -use ordered_float::OrderedFloat; -use parquet::errors::ParquetError; -use smallvec::SmallVec; -use std::collections::hash_map::Entry; -use std::io; -use std::sync::Arc; - -use crate::constants::ArcSec; -use crate::conversion::arcsec_to_rad; -use crate::observers::Observer; -use crate::outfit::Outfit; -use crate::outfit_errors::OutfitError; -use crate::TrajectorySet; -use crate::{ - constants::{ObjectNumber, JDTOMJD}, - observations::Observation, -}; -use arrow_array::array::{Float64Array, UInt32Array}; -use camino::Utf8Path; -use parquet::arrow::{arrow_reader::ParquetRecordBatchReaderBuilder, ProjectionMask}; - -use ahash::RandomState; -use std::collections::HashMap; - -pub type FastHashMap = HashMap; - -/// Load astrometric observations from a Parquet file into an existing [`TrajectorySet`]. -/// -/// This routine deserializes batches of observations from a Parquet file, converts -/// Julian Dates (JD) to Modified Julian Dates (MJD; TT scale), and constructs [`Observation`] -/// instances with the provided astrometric uncertainties. To avoid redundant and costly -/// ephemeris computations, it caches the observer's geocentric and heliocentric positions -/// per unique `(observer, time)` encountered during the read. -/// -/// Arguments -/// ----------------- -/// * `trajectories` – The mutable [`TrajectorySet`] to which observations are appended. -/// * `env_state` – Global environment providing ephemerides, Earth orientation data, -/// and observer definitions. -/// * `parquet` – Path to the input Parquet file containing the columns -/// `ra`, `dec`, `jd`, and `trajectory_id`. -/// The `ra` and `dec` columns have to be in degrees and of type `Float64`. -/// The `jd` column has to be in Julian Date (TT) and of type `Float64`. -/// The `trajectory_id` column has to be of type `UInt32` and is used to group -/// observations by object. -/// * `observer` – The [`Observer`] associated with all observations in this file. -/// * `error_ra` – Right ascension astrometric uncertainty (radians). -/// * `error_dec` – Declination astrometric uncertainty (radians). -/// * `batch_size` – Optional Arrow reader batch size (default: 8192 rows). -/// -/// Performance notes -/// ----------------- -/// * Projects only the required columns and accesses them by **index** to avoid -/// per-batch name lookups. -/// * Uses a per-file cache keyed by MJD (TT) to store `(geo_pos, helio_pos)` and -/// reuse them across batches and rows. -/// * Fast path when all columns are non-null: iterates over raw slices (`&[f64]`, `&[u32]`) -/// for minimal overhead. -/// * Downcasts to concrete Arrow arrays once per batch (not per row). -/// -/// Return -/// ---------- -/// * No return value. Observations are appended in-place to `trajectories`. -/// -/// See also -/// ------------ -/// * [`Observation::with_positions`] – Zero-ephemeris constructor used here. -/// * [`Observer::pvobs`] – Geocentric observer position (and velocity). -/// * [`Observer::helio_position`] – Heliocentric observer position. -pub(crate) fn parquet_to_trajset( - trajectories: &mut TrajectorySet, - env_state: &mut Outfit, - parquet: &Utf8Path, - observer: Arc, - error_ra: ArcSec, - error_dec: ArcSec, - batch_size: Option, -) -> Result<(), OutfitError> { - // Convert arcsecond uncertainties to radians once (cheap). - let error_ra_rad = arcsec_to_rad(error_ra); - let error_dec_rad = arcsec_to_rad(error_dec); - - // Resolve observer to its compact u16 key once (hot path avoids map lookups later). - let uint16_obs = env_state.uint16_from_observer(observer); - - // Open file and inspect Parquet metadata (I/O and schema discovery happen here). - let file = std::fs::File::open(parquet)?; - let builder = ParquetRecordBatchReaderBuilder::try_new(file)?; - - let parquet_metadata = builder.metadata(); - let schema_descr = parquet_metadata.file_metadata().schema_descr(); - - // Build a stable projection mask to materialize **only** the columns used by Outfit. - // We rely on the projection order to index columns directly in the hot loop. - let all_fields = schema_descr.columns(); - let column_names = ["ra", "dec", "jd", "trajectory_id"]; - let projection_indices: Vec = column_names - .iter() - .map(|name| { - all_fields - .iter() - .position(|f| f.name() == *name) - // If not found, surface a clean error instead of panicking. - .ok_or_else(|| { - io::Error::new( - io::ErrorKind::NotFound, - format!("Column '{name}' not found in schema"), - ) - }) - }) - .collect::>()?; - let mask = ProjectionMask::leaves(schema_descr, projection_indices); - - // A larger batch often amortizes decompression + Arrow deserialization cost. - // Tune with benches (8192–65536) depending on your I/O and CPU characteristics. - let batch_size = batch_size.unwrap_or(8192); - let reader = builder - .with_projection(mask) - .with_batch_size(batch_size) - .build()?; - - // Pre-fetch shared providers and observer reference to avoid repeated lookups in the hot loop. - let ut1 = env_state.get_ut1_provider(); - let obs_ref = env_state.get_observer_from_uint16(uint16_obs); - - // Cache MJD(TT) → (geo_pos, helio_pos). - // Note: we assume here the file contains a **single** observer. If you ingest multiple - // observers, extend the key to (observer_id, time), e.g. by packing into a u64 or using a tuple. - let mut pos_cache: FastHashMap, (Vector3, Vector3)> = - FastHashMap::with_capacity_and_hasher(4096, RandomState::default()); - - // Iterate over Parquet record batches - for maybe_batch in reader { - // I/O boundary; failures here usually indicate corruption or incompatible schema. - let batch = maybe_batch.map_err(ParquetError::from)?; - let len = batch.num_rows(); - - // Projected columns by index: [0]=ra, [1]=dec, [2]=jd, [3]=trajectory_id - // We downcast **once** per batch (cheap) and reuse typed views in the row loop. - let ra_arr = batch - .column(0) - .as_any() - .downcast_ref::() - .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "ra must be Float64Array"))?; - let dec_arr = batch - .column(1) - .as_any() - .downcast_ref::() - .ok_or_else(|| { - io::Error::new(io::ErrorKind::InvalidData, "dec must be Float64Array") - })?; - let jd_arr = batch - .column(2) - .as_any() - .downcast_ref::() - .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "jd must be Float64Array"))?; - let tid_arr = batch - .column(3) - .as_any() - .downcast_ref::() - .ok_or_else(|| { - io::Error::new( - io::ErrorKind::InvalidData, - "trajectory_id must be UInt32Array", - ) - })?; - - // Fast path when all projected columns have no nulls. - // This unlocks tight, bounds-checked loops on `&[T]` slices without per-row Option unwrapping. - let no_nulls = ra_arr.nulls().is_none() - && dec_arr.nulls().is_none() - && jd_arr.nulls().is_none() - && tid_arr.nulls().is_none(); - - if no_nulls { - // Raw slice views (zero allocs, no per-element downcast/boxing). - let ra_vals: &[f64] = ra_arr.values(); - let dec_vals: &[f64] = dec_arr.values(); - let jd_vals: &[f64] = jd_arr.values(); - let tid_vals: &[u32] = tid_arr.values(); - - // Hot loop: build `Observation`s with positions sourced from the per-file cache. - for i in 0..len { - // Convert degrees → radians (fast path: one multiply each). - let ra_rad = ra_vals[i].to_radians(); - let dec_rad = dec_vals[i].to_radians(); - - // Convert JD → MJD(TT). Assumes `jd` is already in TT scale in the file; - // if not, convert scales here before caching. - let mjd_time = jd_vals[i] - JDTOMJD; - // Using OrderedFloat to allow float keys in a hash map (with a total order). - let key = OrderedFloat(mjd_time); - - // Compute observer positions once per unique epoch. - // We handle errors with `?` while using the entry API (no closures returning Result). - let (geo_pos, helio_pos) = match pos_cache.entry(key) { - Entry::Occupied(e) => *e.get(), - Entry::Vacant(v) => { - let epoch = - Epoch::from_mjd_in_time_scale(mjd_time, hifitime::TimeScale::TT); - - // Geocentric position (velocity unused here, but available if needed). - let (geo, _vel) = obs_ref.pvobs(&epoch, ut1)?; - // Heliocentric position (requires geocentric as input). - let helio = obs_ref.helio_position(env_state, &epoch, &geo)?; - - v.insert((geo, helio)); - (geo, helio) - } - }; - - // Zero-ephemeris constructor: avoids recomputing positions at construction. - let obs = Observation::with_positions( - uint16_obs, - ra_rad, - error_ra_rad, - dec_rad, - error_dec_rad, - mjd_time, - geo_pos, - helio_pos, - ); - - // Group observations by `trajectory_id` (ObjectNumber::Int). - let obj = ObjectNumber::Int(tid_vals[i]); - trajectories - .entry(obj) - .or_insert_with(|| SmallVec::with_capacity(32)) - .push(obs); - } - } else { - // Safety fallback: if any column contains nulls, we check row-by-row and skip incomplete rows. - // This path is slower, but maintains correctness for sparse/missing data. - for i in 0..len { - if ra_arr.is_null(i) - || dec_arr.is_null(i) - || jd_arr.is_null(i) - || tid_arr.is_null(i) - { - continue; // Drop incomplete rows (policy: skip; alternatively, surface an error) - } - - let ra_rad: f64 = ra_arr.value(i).to_radians(); - let dec_rad = dec_arr.value(i).to_radians(); - let mjd_time = jd_arr.value(i) - JDTOMJD; - let tid = tid_arr.value(i); - let key = OrderedFloat(mjd_time); - - let (geo_pos, helio_pos) = match pos_cache.entry(key) { - Entry::Occupied(e) => *e.get(), - Entry::Vacant(v) => { - let epoch = - Epoch::from_mjd_in_time_scale(mjd_time, hifitime::TimeScale::TT); - - let (geo, _vel) = obs_ref.pvobs(&epoch, ut1)?; - let helio = obs_ref.helio_position(env_state, &epoch, &geo)?; - - v.insert((geo, helio)); - (geo, helio) - } - }; - - let obs = Observation::with_positions( - uint16_obs, - ra_rad, - error_ra_rad, - dec_rad, - error_dec_rad, - mjd_time, - geo_pos, - helio_pos, - ); - - trajectories - .entry(ObjectNumber::Int(tid)) - .or_insert_with(|| SmallVec::with_capacity(32)) - .push(obs); - } - } - } - - Ok(()) -} diff --git a/src/trajectories/progress_bar.rs b/src/trajectories/progress_bar.rs deleted file mode 100644 index 40ecba4..0000000 --- a/src/trajectories/progress_bar.rs +++ /dev/null @@ -1,115 +0,0 @@ -//! Lightweight iteration timing utilities. -//! -//! This module provides helpers to measure and report iteration times -//! in long-running loops, e.g. when combined with a progress bar -//! (see the `progress` feature). -//! -//! Components -//! ----------------- -//! * [`IterTimer`] – Tracks per-iteration durations and computes a -//! smoothed **exponential moving average** (EMA). -//! Useful to get a stable estimate of iteration time even when -//! individual steps fluctuate. -//! -//! * [`fmt_dur`] – Human-readable formatter for [`Duration`] values, -//! producing strings like `"253µs"`, `"42ms"`, or `"3.14s"` depending -//! on the scale. -//! -//! Usage -//! ----------------- -//! Typical workflow inside a loop: -//! -//! ```rust, no_run -//! use std::time::Duration; -//! use your_crate::iter_timer::{IterTimer, fmt_dur}; -//! -//! let mut timer = IterTimer::new(0.2); // smoothing factor α = 0.2 -//! -//! for i in 0..10 { -//! // ... some expensive work ... -//! -//! let dt = timer.tick(); -//! println!( -//! "iter {i} took {}, EMA = {}", -//! fmt_dur(dt), -//! fmt_dur(timer.avg()) -//! ); -//! } -//! ``` -//! -//! Design notes -//! ----------------- -//! * The EMA update rule is: -//! `ema ← α·dt + (1–α)·ema` -//! with `α ∈ (0,1]`. -//! - `α = 1.0` → no smoothing (EMA = last sample). -//! - small `α` → stronger smoothing, slower adaptation. -//! -//! * [`IterTimer::tick`] must be called at each iteration boundary. -//! The first tick initializes the average to the first duration. -//! -//! * [`IterTimer::avg`] returns the smoothed duration as a [`Duration`]. -//! -//! * This module is enabled only with the `progress` feature. -#[cfg(feature = "progress")] -use std::time::{Duration, Instant}; - -pub struct IterTimer { - last: Instant, - ema_ns: f64, - alpha: f64, - count: u64, -} - -impl IterTimer { - pub fn new(alpha: f64) -> Self { - Self { - last: Instant::now(), - ema_ns: 0.0, - alpha, - count: 0, - } - } - - #[inline] - pub fn tick(&mut self) -> Duration { - let now = Instant::now(); - let dt = now.duration_since(self.last); - self.last = now; - self.count += 1; - - let dt_ns = dt.as_nanos() as f64; - self.ema_ns = if self.count == 1 { - dt_ns - } else { - self.alpha * dt_ns + (1.0 - self.alpha) * self.ema_ns - }; - - dt - } - - #[inline] - pub fn avg(&self) -> Duration { - if self.count == 0 { - Duration::from_nanos(0) - } else { - Duration::from_nanos(self.ema_ns as u64) - } - } -} - -#[inline] -pub fn fmt_dur(d: Duration) -> String { - let us = d.as_micros(); - if us < 1_000 { - format!("{us}µs") - } else { - let ms = d.as_millis(); - if ms < 1_000 { - format!("{ms}ms") - } else { - let s = d.as_secs_f32(); - format!("{s:.2}s") - } - } -} diff --git a/src/trajectories/trajectory_file.rs b/src/trajectories/trajectory_file.rs deleted file mode 100644 index 7a104a6..0000000 --- a/src/trajectories/trajectory_file.rs +++ /dev/null @@ -1,513 +0,0 @@ -//! # Trajectory ingestion and batch Initial Orbit Determination (IOD) -//! -//! High-level utilities to **build and extend** a [`TrajectorySet`] from multiple sources -//! (MPC 80-column, Parquet, ADES, or in-memory batches) and to run a **Gauss-based IOD** -//! over all trajectories. -//! -//! ## Overview -//! ----------------- -//! This module exposes the [`TrajectoryFile`] trait implemented for [`TrajectorySet`]. -//! It provides: -//! - Constructors that **create** a new set from a given source (`new_from_*`), -//! - Appenders that **extend** an existing set (`add_from_*`), -//! - Convenience methods to ingest **in-memory batches** (single observer) via -//! [`ObservationBatch`]. -//! -//! Internally, ingestion from in-memory batches uses a crate-private routine -//! `observation_from_batch` (unit/scale/caching logic). End users should interact only -//! with the public `new_from_vec` / `add_from_vec` methods. -//! -//! ## Data model -//! ----------------- -//! - A [`TrajectorySet`] is a `HashMap` storing a -//! time-ordered list of astrometric observations per object. -//! - [`ObservationBatch`] is a thin container (borrowed/owned) for angle-only astrometry -//! from a **single observer** with uniform uncertainties; it is expanded into concrete -//! [`Observation`](crate::observations::Observation)s and grouped by `trajectory_id`. -//! - Batch IOD returns a -//! [`FullOrbitResult`](crate::trajectories::trajectory_fit::FullOrbitResult), i.e. a map -//! `ObjectNumber → Result<(Option, f64), OutfitError>`. -//! -//! ## Ingestion sources & signatures -//! ----------------- -//! **MPC 80-column** -//! - [`TrajectoryFile::new_from_80col`] → `Self` -//! Reads a file, extracts `(Observations, ObjectNumber)` and **inserts** into a new set. -//! **Panics** on extraction failure (internal `expect`). -//! - [`TrajectoryFile::add_from_80col`] → `()` -//! Reads and **inserts** into an existing set. **Panics** on extraction failure. -//! -//! **Parquet** (`"ra"`, `"dec"`, `"jd"`, `"trajectory_id"`) -//! - [`TrajectoryFile::new_from_parquet`] → `Result` -//! Creates a new set; errors are propagated. -//! - [`TrajectoryFile::add_from_parquet`] → `Result<(), OutfitError>` -//! Appends to an existing set; errors are propagated. -//! *Units on disk:* `ra/dec` in **degrees**, `jd` in **JD (TT)**. Internally converted to -//! **radians** and **MJD (TT)** (via [`JDTOMJD`](crate::constants::JDTOMJD)). Per-file -//! uncertainties are passed in **arcseconds**. -//! -//! **ADES (MPC XML/JSON)** -//! - [`TrajectoryFile::new_from_ades`] → `Self` -//! - [`TrajectoryFile::add_from_ades`] → `()` -//! Both delegate to `parse_ades` and **do not return a `Result`** (errors are handled -//! inside the parser or may panic depending on its policy). -//! -//! **In-memory batches (single observer)** -//! - [`TrajectoryFile::new_from_vec`] → `Result` -//! - [`TrajectoryFile::add_from_vec`] → `Result<(), OutfitError>` -//! Expand an [`ObservationBatch`] (RA/DEC/σ in **radians**, epochs in **MJD (TT)**) -//! into per-sample [`Observation`](crate::observations::Observation)s using the shared -//! [`Outfit`] state and **append/group** by `trajectory_id`. -//! -//! ## Units & time scales -//! ----------------- -//! - **Angles**: internal [`Observation`](crate::observations::Observation)s store RA/DEC in **radians**. -//! Parquet/80-column/ADES readers perform degree→radian conversions as needed. -//! - **Uncertainties**: expected in **arcseconds** at call-site for Parquet/ADES; for -//! in-memory batches they must already be in **radians** (uniform per batch). -//! - **Times**: internal epochs are **MJD (TT)**. Parquet `"jd"` values are assumed **TT** -//! and converted via [`JDTOMJD`](crate::constants::JDTOMJD). 80-col/ADES readers apply their respective conversions. -//! -//! ## Duplicates & ordering -//! ----------------- -//! - **No deduplication** is performed by any `add_*` method. Users must avoid re-ingesting -//! the same file/batch twice if duplicates are undesirable. -//! - Observations are stored **as provided**; ordering by time is not enforced here. -//! -//! ## Error semantics -//! ----------------- -//! - Methods returning `Result<_, OutfitError>` propagate I/O/schema/ephemeris errors. -//! - `new_from_80col` / `add_from_80col` use `expect(...)` internally and therefore may **panic** -//! on parse/read failures (fail-fast behavior). -//! - `new_from_ades` / `add_from_ades` currently **do not** return a `Result`; error handling -//! is delegated to `parse_ades` (which may log or panic depending on implementation). -//! -//! ## Batch IOD -//! ----------------- -//! Use [`crate::trajectories::trajectory_fit::TrajectoryFit::estimate_all_orbits`] to run the -//! full Gauss IOD over each `(ObjectNumber → Observations)` pair. Outcomes per object: -//! - `Ok(Some(GaussResult))` + RMS — a viable preliminary/corrected orbit, -//! - `Ok(None)` — pipeline executed but no acceptable solution kept, -//! - `Err(OutfitError)` — failure **isolated** to that object. -//! -//! ## Example -//! ----------------- -//! ```no_run -//! use std::sync::Arc; -//! use camino::Utf8Path; -//! use rand::SeedableRng; -//! use outfit::outfit::Outfit; -//! use outfit::observers::Observer; -//! use outfit::trajectories::trajectory_file::TrajectoryFile; -//! use outfit::TrajectoryFit; -//! use outfit::initial_orbit_determination::IODParams; -//! use outfit::TrajectorySet; -//! -//! # fn demo() -> Result<(), outfit::outfit_errors::OutfitError> { -//! let mut state = Outfit::new("horizon:DE440", outfit::error_models::ErrorModel::FCCT14)?; -//! let observer: Arc = state.get_observer_from_mpc_code(&"I41".into()); -//! -//! // 1) From Parquet (propagates errors) -//! let mut trajs: TrajectorySet = TrajectorySet::new_from_parquet( -//! &mut state, -//! Utf8Path::new("observations.parquet"), -//! observer.clone(), -//! 0.5, 0.5, -//! Some(8192), -//! )?; -//! -//! // 2) From MPC 80-column (may panic on parse error) -//! trajs.add_from_80col(&mut state, Utf8Path::new("obs_80col.txt")); -//! -//! // 3) Run batch IOD -//! let mut rng = rand::rngs::StdRng::from_os_rng(); -//! let params = IODParams::builder().max_triplets(32).build()?; -//! let results = trajs.estimate_all_orbits(&state, &mut rng, ¶ms); -//! # Ok(()) } -//! ``` -//! -//! ## See also -//! ------------ -//! * [`TrajectoryFile`] – Public ingestion API surface. -//! * [`ObservationBatch`] – Zero-copy batch container (single observer). -//! * [`crate::trajectories::trajectory_fit::TrajectoryFit::estimate_all_orbits`] – Batch Gauss IOD. -//! * [`Outfit`] – Ephemerides, reference frames, and observer registry. -use std::{collections::HashMap, sync::Arc}; - -use super::batch_reader::observation_from_batch; -use crate::constants::ArcSec; -use crate::observers::Observer; -use crate::outfit::Outfit; -use crate::outfit_errors::OutfitError; -use crate::trajectories::batch_reader::ObservationBatch; -use crate::TrajectorySet; -use camino::Utf8Path; - -use super::ades_reader::parse_ades; -use super::mpc_80col_reader::extract_80col; -use super::parquet_reader::parquet_to_trajset; - -/// A trait for the TrajectorySet type definition. -/// This trait provides methods to create a TrajectorySet from different sources. -/// It allows to create a TrajectorySet from an 80 column file, a parquet file, or an ADES file. -/// It also allows to add observations to an existing TrajectorySet from these sources. -/// The methods are: -/// * `from_80col`: Create a TrajectorySet from an 80 column file. -/// * `add_80col`: Add observations to a TrajectorySet from an 80 column file. -/// * `new_from_vec`: Create a TrajectorySet from a vector of observations. -/// * `add_from_vec`: Add observations to a TrajectorySet from a vector of observations. -/// * `new_from_parquet`: Create a TrajectorySet from a parquet file. -/// * `add_from_parquet`: Add observations to a TrajectorySet from a parquet file. -/// * `new_from_ades`: Create a TrajectorySet from an ADES file. -/// * `add_from_ades`: Add observations to a TrajectorySet from an ADES file. -/// -/// Note -/// ---- -/// * Warning: No check is done for duplicated observations for every add method. -/// * The user shoud be careful to not add the same observation or same file twice -pub trait TrajectoryFile { - /// Create a TrajectorySet from an 80 column file - /// The trajectory are added in place in the TrajectorySet. - /// If a trajectory id already exists, the observations are added to the existing trajectory. - /// - /// Arguments - /// --------- - /// * `env_state`: a mutable reference to the Outfit instance - /// * `colfile`: a path to an 80 column file - /// - /// Return - /// ------ - /// * a TrajectorySet containing the observations from the 80 column file - /// - /// Note - /// ---- - /// * The 80 column file must respect the MPC format. - /// * ref: - fn new_from_80col(env_state: &mut Outfit, colfile: &Utf8Path) -> Self; - - /// Add a set of trajectories from an 80 column file to a TrajectorySet - /// The trajectory are added in place in the TrajectorySet. - /// If a trajectory id already exists, the observations are added to the existing trajectory. - /// - /// Arguments - /// --------- - /// * `env_state`: a mutable reference to the Outfit instance - /// * `colfile`: a path to an 80 column file - /// - /// Note - /// ---- - /// * The 80 column file must respect the MPC format. - /// * ref: - fn add_from_80col(&mut self, env_state: &mut Outfit, colfile: &Utf8Path); - - /// Create a new [`TrajectorySet`] from a batch of observations taken by a single observer. - /// - /// This constructor consumes an [`ObservationBatch`] and groups its observations - /// into trajectories, keyed by their `trajectory_id`. - /// Each observation in the batch must have been recorded by the **same observer**, - /// but may belong to **different objects** (distinguished by `trajectory_id`). - /// - /// Arguments - /// ----------------- - /// * `env_state` — Mutable reference to the global [`Outfit`] state (used for ephemerides, UT1, etc.). - /// * `batch` — An [`ObservationBatch`] containing RA/DEC/epoch values (radians + MJD/TT) and trajectory IDs. - /// * `observer` — The observer that recorded all observations in the batch. - /// - /// Return - /// ----------------- - /// * `Ok(Self)` — A new [`TrajectorySet`] containing one or more trajectories populated from the batch. - /// * `Err(OutfitError)` — If observation construction or position computations fail. - /// - /// Invariants - /// ----------------- - /// * `batch.trajectory_id.len() == batch.ra.len() == batch.dec.len() == batch.time.len()` - /// * Angles and uncertainties in the batch must already be in **radians**. - /// - /// Example - /// ----------------- - /// ```rust, no_run - /// # use outfit::trajectories::batch_reader::ObservationBatch; - /// # use outfit::TrajectorySet; - /// # use outfit::TrajectoryFile; - /// # use outfit::{Outfit, ErrorModel}; - /// # use std::sync::Arc; - /// # let mut env = Outfit::new("horizon:DE440", ErrorModel::FCCT14).unwrap(); - /// # let observer = env.get_observer_from_mpc_code(&"I41".to_string()); - /// # let (traj_id, ra_deg, dec_deg, mjd) = (vec![0, 0, 1], vec![14.62, 14.63, 15.01], vec![9.98, 10.01, 11.02], vec![43785.35, 43785.36, 43785.40]); - /// let batch = ObservationBatch::from_degrees_owned(&traj_id, &ra_deg, &dec_deg, 0.5, 0.5, &mjd); - /// - /// // Build a trajectory set directly from the batch: - /// let ts = TrajectorySet::new_from_vec(&mut env, &batch, observer).unwrap(); - /// ``` - fn new_from_vec( - env_state: &mut Outfit, - batch: &ObservationBatch<'_>, - observer: Arc, - ) -> Result - where - Self: Sized; - - /// Add the observations from a batch to an existing [`TrajectorySet`]. - /// - /// This method inserts all observations from the provided [`ObservationBatch`] into - /// the current set, grouping them into trajectories by `trajectory_id`. - /// Each observation in the batch must have been recorded by the **same observer**, - /// but may belong to multiple distinct objects. - /// - /// Arguments - /// ----------------- - /// * `env_state` — Mutable reference to the global [`Outfit`] state (used for ephemerides, UT1, etc.). - /// * `batch` — An [`ObservationBatch`] containing RA/DEC/epoch values (radians + MJD/TT) and trajectory IDs. - /// * `observer` — The observer that recorded all observations in the batch. - /// - /// Return - /// ----------------- - /// * `Ok(())` — If all observations were successfully inserted into the `TrajectorySet`. - /// * `Err(OutfitError)` — If observation construction or position computations fail. - /// - /// Example - /// ----------------- - /// ```rust, no_run - /// use outfit::trajectories::batch_reader::ObservationBatch; - /// use outfit::TrajectorySet; - /// use outfit::TrajectoryFile; - /// use outfit::{Outfit, ErrorModel}; - /// use std::sync::Arc; - /// use ahash::RandomState; - /// use std::collections::HashMap; - /// - /// let mut env = Outfit::new("horizon:DE440", ErrorModel::FCCT14).unwrap(); - /// let observer = env.get_observer_from_mpc_code(&"I41".to_string()); - /// let (traj_id, ra_deg, dec_deg, mjd) = (vec![0, 0, 1], vec![14.62, 14.63, 15.01], vec![9.98, 10.01, 11.02], vec![43785.35, 43785.36, 43785.40]); - /// let batch = ObservationBatch::from_degrees_owned(&traj_id, &ra_deg, &dec_deg, 0.5, 0.5, &mjd); - /// - /// let mut ts = HashMap::with_hasher(RandomState::new()); - /// ts.add_from_vec(&mut env, &batch, observer).unwrap(); - /// ``` - fn add_from_vec( - &mut self, - env_state: &mut Outfit, - batch: &ObservationBatch<'_>, - observer: Arc, - ) -> Result<(), OutfitError>; - - /// Create a new [`TrajectorySet`] from a Parquet file. - /// - /// This function reads a Parquet file containing astrometric observations - /// and constructs a full [`TrajectorySet`]. Each observation is associated - /// with the provided `observer` and assigned constant uncertainties in - /// right ascension and declination. - /// - /// Arguments - /// ----------------- - /// * `env_state` – Global environment providing ephemerides, UT1 provider, and observer mapping. - /// * `parquet` – Path to the input Parquet file. - /// * `observer` – Observer metadata (shared reference, resolved once to a compact id). - /// * `error_ra` – 1-σ uncertainty in right ascension \[arcsec\], applied uniformly. - /// * `error_dec` – 1-σ uncertainty in declination \[arcsec\], applied uniformly. - /// * `batch_size` – Record batch size for Parquet reader; defaults to 2048 if `None`. - /// - /// Return - /// ---------- - /// * `Ok(TrajectorySet)` – A new set of trajectories populated from the file. - /// * `Err(OutfitError)` – If the file cannot be opened, parsed, or contains invalid data. - /// - /// Notes - /// ---------- - /// * The Parquet file must contain the following columns: `"ra"`, `"dec"`, `"jd"`, `"trajectory_id"`. - /// * The `"jd"` values are assumed to be in TT scale and are converted internally to MJD via [`JDTOMJD`](crate::constants::JDTOMJD). - /// * The `ra` and `dec` columns have to be in degrees and of type `Float64`. - /// * The `jd` column has to be in Julian Date (TT) and of type `Float64`. - /// * The `trajectory_id` column has to be of type `UInt32` and is used to group - /// observations by object. - /// - /// See also - /// ------------ - /// * [`add_from_parquet`](crate::trajectories::trajectory_file::TrajectoryFile::add_from_parquet) – Adds observations from a Parquet file to an existing set. - fn new_from_parquet( - env_state: &mut Outfit, - parquet: &Utf8Path, - mpc_code: Arc, - error_ra: ArcSec, - error_dec: ArcSec, - batch_size: Option, - ) -> Result - where - Self: Sized; - - /// Add observations from a Parquet file to an existing [`TrajectorySet`]. - /// - /// This function appends new observations (grouped by `trajectory_id`) - /// to the current set. The same `observer` and astrometric uncertainties - /// are applied to all ingested rows. - /// - /// Arguments - /// ----------------- - /// * `env_state` – Global environment providing ephemerides, UT1 provider, and observer mapping. - /// * `parquet` – Path to the input Parquet file. - /// * `observer` – Observer metadata (shared reference, resolved once to a compact id). - /// * `error_ra` – 1-σ uncertainty in right ascension \[arcsec\], applied uniformly. - /// * `error_dec` – 1-σ uncertainty in declination \[arcsec\], applied uniformly. - /// * `batch_size` – Record batch size for Parquet reader; defaults to 2048 if `None`. - /// - /// Return - /// ---------- - /// * `Ok(())` – On successful ingestion, with the internal set updated in place. - /// * `Err(OutfitError)` – If the file cannot be opened, parsed, or contains invalid data. - /// - /// Notes - /// ---------- - /// * The Parquet file must contain the following columns: `"ra"`, `"dec"`, `"jd"`, `"trajectory_id"`. - /// * The `"jd"` values are assumed to be in TT scale and are converted internally to MJD via [`JDTOMJD`](crate::constants::JDTOMJD). - /// * The `ra` and `dec` columns have to be in degrees and of type `Float64`. - /// * The `jd` column has to be in Julian Date (TT) and of type `Float64`. - /// * The `trajectory_id` column has to be of type `UInt32` and is used to group - /// observations by object. - /// - /// See also - /// ------------ - /// * [`new_from_parquet`](crate::trajectories::trajectory_file::TrajectoryFile::new_from_parquet) – Creates a brand new set from a Parquet file. - fn add_from_parquet( - &mut self, - env_state: &mut Outfit, - parquet: &Utf8Path, - observer: Arc, - error_ra: ArcSec, - error_dec: ArcSec, - batch_size: Option, - ) -> Result<(), OutfitError>; - - /// Add a set of trajectories to a TrajectorySet from an ADES file - /// - /// Arguments - /// --------- - /// * `env_state`: a mutable reference to the Outfit instance - /// * `ades`: a path to an ADES file - /// * `error_ra`: the error in right ascension (if some values are given, the error ra is supposed to be the same for all observations) - /// * `error_dec`: the error in declination (if some values are given, the error dec is supposed to be the same for all observations) - /// - /// Note - /// ---- - /// * The ADES file must respect the MPC format. - /// * ref: - fn new_from_ades( - env_state: &mut Outfit, - ades: &Utf8Path, - error_ra: Option, - error_dec: Option, - ) -> Self; - - /// Create a TrajectorySet from an ADES file - /// - /// Arguments - /// --------- - /// * `env_state`: a mutable reference to the Outfit instance - /// * `ades`: a path to an ADES file - /// * `error_ra`: the error in right ascension (if some values are given, the error ra is supposed to be the same for all observations) - /// * `error_dec`: the error in declination (if some values are given, the error dec is supposed to be the same for all observations) - /// - /// Return - /// ------ - /// * a TrajectorySet containing the observations from the ADES file - /// - /// Note - /// ---- - /// * The ADES file must respect the MPC format. - /// * ref: - fn add_from_ades( - &mut self, - env_state: &mut Outfit, - ades: &Utf8Path, - error_ra: Option, - error_dec: Option, - ); -} - -impl TrajectoryFile for TrajectorySet { - fn new_from_vec( - env_state: &mut Outfit, - batch: &ObservationBatch<'_>, - observer: Arc, - ) -> Result { - let mut traj_set: TrajectorySet = HashMap::default(); - observation_from_batch(&mut traj_set, env_state, batch, observer)?; - Ok(traj_set) - } - - fn add_from_vec( - &mut self, - env_state: &mut Outfit, - batch: &ObservationBatch<'_>, - observer: Arc, - ) -> Result<(), OutfitError> { - observation_from_batch(self, env_state, batch, observer)?; - Ok(()) - } - - fn add_from_parquet( - &mut self, - env_state: &mut Outfit, - parquet: &Utf8Path, - observer: Arc, - error_ra: ArcSec, - error_dec: ArcSec, - batch_size: Option, - ) -> Result<(), OutfitError> { - parquet_to_trajset( - self, env_state, parquet, observer, error_ra, error_dec, batch_size, - ) - } - - fn new_from_parquet( - env_state: &mut Outfit, - parquet: &Utf8Path, - observer: Arc, - error_ra: ArcSec, - error_dec: ArcSec, - batch_size: Option, - ) -> Result - where - Self: Sized, - { - let mut trajs: TrajectorySet = HashMap::default(); - parquet_to_trajset( - &mut trajs, env_state, parquet, observer, error_ra, error_dec, batch_size, - )?; - Ok(trajs) - } - - fn new_from_80col(env_state: &mut Outfit, colfile: &Utf8Path) -> Self { - let mut traj_set: TrajectorySet = HashMap::default(); - let (observations, object_number) = - extract_80col(env_state, colfile).expect("Failed to extract 80col data"); - traj_set.insert(object_number, observations); - traj_set - } - - fn add_from_80col(&mut self, env_state: &mut Outfit, colfile: &Utf8Path) { - let (observations, object_number) = - extract_80col(env_state, colfile).expect("Failed to extract 80col data"); - self.insert(object_number, observations); - } - - fn add_from_ades( - &mut self, - env_state: &mut Outfit, - ades: &Utf8Path, - error_ra: Option, - error_dec: Option, - ) { - parse_ades(env_state, ades, self, error_ra, error_dec); - } - - fn new_from_ades( - env_state: &mut Outfit, - ades: &Utf8Path, - error_ra: Option, - error_dec: Option, - ) -> Self { - let mut trajs: TrajectorySet = HashMap::default(); - parse_ades(env_state, ades, &mut trajs, error_ra, error_dec); - trajs - } -} diff --git a/src/trajectories/trajectory_fit.rs b/src/trajectories/trajectory_fit.rs deleted file mode 100644 index 15f2d89..0000000 --- a/src/trajectories/trajectory_fit.rs +++ /dev/null @@ -1,1596 +0,0 @@ -//! # Batch Gauss IOD over Trajectory Sets -//! -//! Run a full **Gauss-based Initial Orbit Determination (IOD)** over a -//! [`TrajectorySet`], collect **per-object outcomes**, and expose convenience -//! helpers to query or extract solutions and summarize observation counts. -//! -//! ## Overview -//! ----------------- -//! A [`TrajectorySet`] maps each [`ObjectNumber`] to its time-ordered -//! [`Observations`]. This module implements the [`TrajectoryFit`] trait on -//! `TrajectorySet`, providing: -//! -//! * `estimate_all_orbits` – run the Gauss IOD pipeline on **every object**, -//! * `estimate_all_orbits_with_cancel` – same, with **cooperative cancellation**, -//! * `total_observations` / `number_of_trajectories` – quick set-level metrics, -//! * `obs_count_stats` – summary statistics on observation counts, -//! * `gauss_result_for` / `take_gauss_result` – ergonomic access to results. -//! -//! All objects are processed with the same [`Outfit`] state (ephemerides, error -//! model, frames), a caller-provided RNG, and a single [`IODParams`] configuration. -//! -//! ## Result Model -//! ----------------- -//! Batch outcomes are returned as a [`FullOrbitResult`]: -//! -//! ```text -//! ObjectNumber → Result<(GaussResult, rms: f64), OutfitError> -//! ``` -//! -//! * `Ok((GaussResult, rms))` – the best preliminary/corrected orbit and its RMS -//! of normalized astrometric residuals, -//! * `Err(OutfitError)` – a failure **isolated** to that object (other objects -//! continue to be processed). -//! -//! Use [`gauss_result_for`] to **borrow** a solution and its RMS, or -//! [`take_gauss_result`] to **move** them out of the map. -//! -//! ## Execution Modes -//! ----------------- -//! ### Progress UI (feature: `progress`) -//! When compiled with the `progress` feature, `estimate_all_orbits` renders a -//! live progress bar (via `indicatif`) and reports per-iteration timing via -//! a lightweight moving average to help diagnose throughput bottlenecks. -//! -//! ### Cooperative cancellation -//! `estimate_all_orbits_with_cancel` periodically calls a user-provided -//! closure `should_cancel()` based on **wall-clock intervals** (not iteration -//! counts) to keep cancellation latency stable even if some objects are slow. -//! -//! ## Performance Notes -//! ----------------- -//! * The loop walks the underlying map once; overall time scales with the number -//! of objects × the cost of `ObservationIOD::estimate_best_orbit` (triplet -//! enumeration, scoring, optional correction). -//! * Results are accumulated in a `HashMap` that uses `ahash::RandomState`, -//! matching the default hasher used elsewhere in the crate. -//! * No mutation of the observations themselves; only per-object IOD is performed. -//! -//! ## Error Semantics -//! ----------------- -//! * Failures are **per-object**: an error for one object does **not** abort -//! the batch. -//! * The returned map contains **one entry per processed object**, -//! each entry being either `Ok((GaussResult, rms))` or `Err(OutfitError)`. -//! -//! ## Examples -//! ----------------- -//! Minimal end-to-end run (no progress UI): -//! -//! ```rust,no_run -//! use rand::SeedableRng; -//! use outfit::{Outfit, TrajectorySet}; -//! use outfit::initial_orbit_determination::IODParams; -//! use outfit::trajectories::trajectory_fit::TrajectoryFit; -//! -//! # fn demo(mut trajs: TrajectorySet) -> Result<(), outfit::outfit_errors::OutfitError> { -//! let state = Outfit::new("horizon:DE440", outfit::error_models::ErrorModel::FCCT14)?; -//! let mut rng = rand::rngs::StdRng::from_os_rng(); -//! let params = IODParams::builder().max_triplets(32).build()?; -//! -//! let results = trajs.estimate_all_orbits(&state, &mut rng, ¶ms); -//! for (obj, res) in &results { -//! match res { -//! Ok((g, rms)) => eprintln!("{obj:?}: orbit={} rms={:.4}", g, rms), -//! Err(e) => eprintln!("{obj:?}: error={e}"), -//! } -//! } -//! # Ok(()) } -//! ``` -//! -//! Cooperative cancellation (poll every ~20 ms): -//! -//! ```rust,no_run -//! use std::sync::atomic::{AtomicBool, Ordering}; -//! use outfit::trajectories::trajectory_fit::TrajectoryFit; -//! -//! # fn cancelable(mut trajs: outfit::TrajectorySet, -//! # state: &outfit::Outfit, -//! # rng: &mut impl rand::Rng, -//! # params: &outfit::IODParams, -//! # ) -> outfit::trajectories::trajectory_fit::FullOrbitResult { -//! let stop = AtomicBool::new(false); -//! // … flip `stop` from another thread / signal handler … -//! -//! trajs.estimate_all_orbits_with_cancel(state, rng, params, || stop.load(Ordering::Relaxed)) -//! # } -//! ``` -//! -//! Quick stats for logging/reporting: -//! -//! ```rust -//! use outfit::trajectories::trajectory_fit::TrajectoryFit; -//! -//! fn summarize(set: &outfit::TrajectorySet) { -//! let n_obj = set.number_of_trajectories(); -//! let n_obs = set.total_observations(); -//! if let Some(stats) = set.obs_count_stats() { -//! eprintln!("Trajectories: {n_obj}, Observations: {n_obs}"); -//! eprintln!("{:#}", stats); -//! } -//! } -//! ``` -//! -//! ## See also -//! ------------ -//! * [`TrajectoryFit`] – Trait implemented by `TrajectorySet` for batch IOD and stats. -//! * [`ObservationIOD::estimate_best_orbit`] – Per-object Gauss IOD (called internally). -//! * [`GaussResult`] – Preliminary/corrected orbit container. -//! * [`IODParams`] – Tuning for triplet generation, scoring, correction. -//! * [`gauss_result_for`] / [`take_gauss_result`] – Accessors for the `FullOrbitResult` map. -use std::{collections::HashMap, fmt}; - -use ahash::RandomState; -use rand::Rng; - -use crate::{ - constants::Observations, GaussResult, IODParams, ObjectNumber, ObservationIOD, Outfit, - OutfitError, TrajectorySet, -}; - -use std::time::{Duration, Instant}; - -#[cfg(feature = "progress")] -use super::progress_bar::IterTimer; -#[cfg(feature = "progress")] -use indicatif::{ProgressBar, ProgressStyle}; - -#[cfg(feature = "parallel")] -use rayon::prelude::*; -#[cfg(feature = "parallel")] -use std::{ - hash::{Hash, Hasher}, - mem, -}; - -/// Full batch orbit determination results. -/// -/// Each entry maps an [`ObjectNumber`] to the outcome of a full -/// Initial Orbit Determination (IOD) attempt on its set of observations. -/// -/// Internally, this is implemented as: -/// -/// ```ignore -/// HashMap, RandomState> -/// ``` -/// -/// Return semantics -/// ----------------- -/// * `Ok((GaussResult, f64))` – a successful IOD with its RMS of normalized residuals. -/// * `Err(OutfitError)` – a failure isolated to that object. -pub type FullOrbitResult = - HashMap, RandomState>; - -/// Borrow a Gauss solution (if any) and its RMS for a given key. -/// -/// Arguments -/// ----------------- -/// * `all`: The map of all IOD outcomes. -/// * `key`: The object identifier. -/// -/// Return -/// ---------- -/// * `Ok(Some((&GaussResult, f64)))` – a solution is present for the key. -/// * `Ok(None)` – key absent. -/// * `Err(&OutfitError)` – the IOD attempt failed for that key. -/// -/// See also -/// ------------ -/// * [`GaussResult`] – Gauss IOD output structure. -pub fn gauss_result_for<'a>( - all: &'a FullOrbitResult, - key: &ObjectNumber, -) -> Result, &'a OutfitError> { - match all.get(key) { - None => Ok(None), - Some(Err(e)) => Err(e), - Some(Ok((g, rms))) => Ok(Some((g, *rms))), - } -} - -/// Take ownership of the solution for `key`, removing it from the map. -/// -/// Arguments -/// ----------------- -/// * `all`: The map of all IOD outcomes (consumed entry will be removed). -/// * `key`: The object identifier to extract. -/// -/// Return -/// ---------- -/// * `Ok(Some((GaussResult, f64)))` – ownership of the solution and its RMS. -/// * `Ok(None)` – key absent. -/// * `Err(OutfitError)` – the IOD attempt failed for that key. -/// -/// See also -/// ------------ -/// * [`gauss_result_for`] – Borrowing accessor. -pub fn take_gauss_result( - all: &mut FullOrbitResult, - key: &ObjectNumber, -) -> Result, OutfitError> { - match all.remove(key) { - None => Ok(None), - Some(Err(e)) => Err(e), - Some(Ok((g, rms))) => Ok(Some((g, rms))), - } -} - -/// Summary statistics for per-trajectory observation counts. -/// -/// Each [`TrajectorySet`] entry (one object) has an associated -/// [`Observations`] container. This structure stores basic distribution -/// statistics on the **number of observations per trajectory**, as -/// returned by [`obs_count_stats`](crate::trajectories::trajectory_fit::TrajectoryFit::obs_count_stats). -/// -/// Fields -/// ----------------- -/// * `min` – smallest number of observations in any trajectory. -/// * `p25` – 25th percentile (first quartile) of observation counts. -/// * `median` – 50th percentile (second quartile). -/// * `p95` – 95th percentile, indicating the upper tail of the distribution. -/// * `max` – largest number of observations in any trajectory. -/// -/// Percentiles are computed using the *nearest-rank* method: -/// the index is `round(q × (N-1))` for quantile `q ∈ [0,1]`, clamped to valid range. -/// This convention makes results stable even for small sample sizes. -/// -/// Display -/// ----------------- -/// * `format!("{}", stats)` – compact single-line summary, e.g.: -/// ```text -/// min=2, p25=4, median=8, p95=15, max=20 -/// ``` -/// -/// * `format!("{:#}", stats)` – pretty multi-line table, e.g.: -/// ```text -/// Observation count per trajectory — summary -/// ----------------------------------------- -/// min : 2 -/// p25 : 4 -/// median : 8 -/// p95 : 15 -/// max : 20 -/// ``` -/// -/// See also -/// ------------ -/// * [`obs_count_stats`](crate::trajectories::trajectory_fit::TrajectoryFit::obs_count_stats) – Computes these statistics from a [`TrajectorySet`]. -#[derive(Debug, Clone, Copy)] -pub struct ObsCountStats { - pub min: usize, - pub p25: usize, - pub median: usize, - pub p95: usize, - pub max: usize, -} - -impl fmt::Display for ObsCountStats { - /// Compact by default; pretty multi-line when using the alternate flag (`{:#}`). - /// - /// See also - /// ------------ - /// * [`obs_count_stats`](crate::trajectories::trajectory_fit::TrajectoryFit::obs_count_stats) – Builder of these summary statistics. - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - if f.alternate() { - // Pretty, multi-line, aligned output (ASCII-only for portability). - writeln!(f, "Observation count per trajectory — summary")?; - writeln!(f, "-----------------------------------------")?; - writeln!(f, "min : {}", self.min)?; - writeln!(f, "p25 : {}", self.p25)?; - writeln!(f, "median : {}", self.median)?; - writeln!(f, "p95 : {}", self.p95)?; - write!(f, "max : {}", self.max) - } else { - // Compact single-line for logs and quick prints. - write!( - f, - "min={}, p25={}, median={}, p95={}, max={}", - self.min, self.p25, self.median, self.p95, self.max - ) - } - } -} - -// ============================================================================ -// Factorized core + progress abstraction + cancel config -// ============================================================================ - -/// Cancellation guard polled at fixed wall-clock intervals. -/// -/// A `CancelCfg` lets the main loop periodically check whether the user -/// or an external controller has requested an early stop. The loop itself -/// decides *when* to poll based on the `interval`, and *how* to react -/// based on the `should_cancel` callback. -/// -/// Arguments -/// ----------------- -/// * `interval`: Minimum wall-clock delay between two cancellation checks. -/// This prevents the loop from calling the callback at every -/// iteration, which would be too costly. -/// * `should_cancel`: User-provided closure returning `true` when cancellation -/// is requested. If `true`, the loop terminates gracefully. -/// -/// See also -/// ------------ -/// * [`estimate_all_orbits_core`] – Main iteration loop that evaluates this configuration. -struct CancelCfg { - interval: Duration, - should_cancel: F, -} - -/// Abstract interface for reporting progress to the outside world. -/// -/// The purpose of this trait is to **decouple the heavy numerical loop** -/// from any particular UI backend. The core orbit determination logic -/// always calls into a `ProgressSink`, but the actual implementation -/// depends on the build: -/// -/// * with the `progress` feature, it is backed by an [`indicatif`] progress bar, -/// * otherwise, a no-op implementation is used, so the loop compiles without UI. -/// -/// This abstraction ensures that the loop has a consistent lifecycle: -/// 1. [`start`] is called once with the total number of objects. -/// 2. [`on_iter`] is called at the beginning of each iteration (e.g. to refresh messages). -/// 3. [`inc`] is called once per completed iteration. -/// 4. [`on_interrupt`] is called right before exiting due to cancellation. -/// 5. [`finish`] is always called at the end, successful or not. -/// -/// By default, all methods are no-ops, so implementors only override the -/// subset they need. -trait ProgressSink { - /// Called once before entering the main loop, with the total number of items. - fn start(&mut self, _total: u64) {} - /// Called at the beginning of each iteration (e.g., refresh UI or logs). - fn on_iter(&mut self) {} - /// Increment the progress by one step. - fn inc(&mut self) {} - /// Called once if the loop exits because of cancellation. - fn on_interrupt(&mut self) {} - /// Called once at the very end, regardless of success or cancellation. - fn finish(&mut self) {} -} - -/// Default no-op implementation, used when the `progress` feature is disabled. -impl ProgressSink for () {} - -/// Blanket implementation so that `&mut T` also implements [`ProgressSink`]. -/// -/// This lets tests pass `&mut MockProgress` directly, while the core API -/// continues to accept progress sinks by value. -impl ProgressSink for &mut T { - #[inline] - fn start(&mut self, total: u64) { - (**self).start(total) - } - #[inline] - fn on_iter(&mut self) { - (**self).on_iter() - } - #[inline] - fn inc(&mut self) { - (**self).inc() - } - #[inline] - fn on_interrupt(&mut self) { - (**self).on_interrupt() - } - #[inline] - fn finish(&mut self) { - (**self).finish() - } -} - -/// Concrete type selected depending on the `progress` feature: -/// * [`IndicatifProgress`] when enabled, -/// * [`()`] (no-op) when disabled. -#[cfg(feature = "progress")] -type ProgressImpl = IndicatifProgress; -#[cfg(not(feature = "progress"))] -type ProgressImpl = (); - -/// Central loop that runs orbit estimation for each trajectory. -/// -/// This function is the **engine** behind the public APIs: -/// [`TrajectoryFit::estimate_all_orbits`] and -/// [`TrajectoryFit::estimate_all_orbits_with_cancel`]. -/// -/// It consumes a [`TrajectorySet`] and tries to estimate the best orbit -/// for each contained object. Along the way it reports progress, and it -/// may stop early if the provided cancellation config triggers. -/// -/// Arguments -/// ----------------- -/// * `set`: The trajectory set to process (mutable, results are inserted). -/// * `state`: Global environment providing ephemerides, constants, and frames. -/// * `rng`: Random number generator used for noisy triplet realizations. -/// * `params`: IOD parameters controlling triplet generation and scoring. -/// * `cancel`: Optional cancellation guard (poll interval + callback). -/// * `progress`: Progress reporting sink (indicatif bar or no-op). -/// -/// Return -/// ---------- -/// * A [`FullOrbitResult`], i.e. a map from object → `Ok((GaussResult, rms))` -/// or `Err(OutfitError)` depending on whether orbit estimation succeeded. -/// -/// See also -/// ------------ -/// * [`TrajectoryFit::estimate_all_orbits`] – Public API without cancellation. -/// * [`TrajectoryFit::estimate_all_orbits_with_cancel`] – Public API with cancellation. -fn estimate_all_orbits_core( - set: &mut TrajectorySet, - state: &Outfit, - rng: &mut impl Rng, - params: &IODParams, - mut cancel: Option>, - mut progress: P, -) -> FullOrbitResult -where - F: FnMut() -> bool, - P: ProgressSink, -{ - let total = set.len() as u64; - progress.start(total.max(1)); - - let mut results: FullOrbitResult = HashMap::default(); - let mut last_poll = Instant::now(); - - for (obj, observations) in set.iter_mut() { - // --- Timer-based cancellation (if configured) - if let Some(CancelCfg { - interval, - should_cancel, - }) = cancel.as_mut() - { - if last_poll.elapsed() >= *interval { - if should_cancel() { - progress.on_interrupt(); - break; - } - last_poll = Instant::now(); - } - } - - progress.on_iter(); - - // Core work - let res = observations.estimate_best_orbit(state, &state.error_model, rng, params); - results.insert(obj.clone(), res); - - progress.inc(); - } - - progress.finish(); - results -} - -// --------------------------- Progress (indicatif) ---------------------------- -#[cfg(feature = "progress")] -mod progress_impl { - use super::IterTimer; - use super::ProgressSink; - use crate::trajectories::progress_bar::fmt_dur; - - /// Progress sink backed by `indicatif`. - /// - /// See also - /// ------------ - /// * [`estimate_all_orbits_core`] – Calls into this sink at key lifecycle moments. - pub(super) struct IndicatifProgress { - pb: super::ProgressBar, - it_timer: IterTimer, - } - - impl Default for IndicatifProgress { - fn default() -> Self { - // The actual length is set in `start()`. - let pb = super::ProgressBar::new(1); - Self { - pb, - it_timer: IterTimer::new(0.2), - } - } - } - - impl ProgressSink for IndicatifProgress { - fn start(&mut self, total: u64) { - self.pb.set_length(total.max(1)); - self.pb.set_style( - super::ProgressStyle::with_template( - "{bar:40.cyan/blue} {pos}/{len} ({percent:>3}%) \ - | {per_sec} | ETA {eta_precise} | {msg}", - ) - .expect("indicatif template"), - ); - self.pb - .enable_steady_tick(super::Duration::from_millis(200)); - } - - fn on_iter(&mut self) { - let last = self.it_timer.tick(); - let avg = self.it_timer.avg(); - self.pb - .set_message(format!("last: {}, avg: {}", fmt_dur(last), fmt_dur(avg))); - } - - fn inc(&mut self) { - self.pb.inc(1); - } - - fn on_interrupt(&mut self) { - self.pb.set_message("Interrupted"); - } - - fn finish(&mut self) { - self.pb.disable_steady_tick(); - self.pb.finish_and_clear(); - } - } -} - -#[cfg(feature = "progress")] -use progress_impl::IndicatifProgress; - -// --------------------------- Parallel features ---------------------------- - -#[cfg(feature = "parallel")] -/// Generate a new 64-bit pseudo-random value using the **SplitMix64** algorithm. -/// This is a simple, fast, and reproducible way to decorrelate seeds for parallel RNGs. -/// -/// Arguments -/// ----------------- -/// * `x`: Input state (a `u64` value, typically a hash or a base seed). -/// -/// Return -/// ---------- -/// * A `u64` pseudo-random value, suitable for seeding RNGs (e.g., `StdRng`). -/// -/// See also -/// ------------ -/// * [`seed_for_object`] – Derives per-object seeds from a base seed and object hash. -#[inline] -fn splitmix64(mut x: u64) -> u64 { - // SplitMix64 constants and shifts from Steele et al. (2014). - x = x.wrapping_add(0x9E3779B97F4A7C15); - let mut z = x; - z = (z ^ (z >> 30)).wrapping_mul(0xBF58476D1CE4E5B9); - z = (z ^ (z >> 27)).wrapping_mul(0x94D049BB133111EB); - z ^ (z >> 31) -} - -#[cfg(feature = "parallel")] -/// Derive a **deterministic per-object RNG seed** from a global base seed. -/// -/// Each object is first hashed with the crate’s default hasher (`ahash`), and the -/// resulting 64-bit value is mixed with the base seed via [`splitmix64`]. -/// This ensures that: -/// - Each object gets a **stable, reproducible seed** (same input → same output). -/// - Seeds are decorrelated even across many objects. -/// - Parallel runs remain deterministic regardless of thread scheduling. -/// -/// Arguments -/// ----------------- -/// * `base`: A global base seed (drawn once per batch). -/// * `obj`: The [`ObjectNumber`] used as key to derive the per-object seed. -/// -/// Return -/// ---------- -/// * A `u64` deterministic RNG seed for the given object. -/// -/// See also -/// ------------ -/// * [`splitmix64`] – Core mixing function. -/// * [`ObjectNumber`] – Object identifier used in Outfit (MPC number or string). -#[inline] -fn seed_for_object(base: u64, obj: &ObjectNumber) -> u64 { - // Hash object key with the same family used elsewhere (ahash). - let mut h = ahash::AHasher::default(); - obj.hash(&mut h); - let obj_h = h.finish(); - - // Mix base seed and object hash through SplitMix64. - splitmix64(base ^ obj_h) -} - -// ============================================================================ -// Public trait + factorized implementation -// ============================================================================ - -pub trait TrajectoryFit { - /// Run Gauss-based Initial Orbit Determination (IOD) for **every trajectory** in the set. - /// - /// This method iterates over each `(ObjectNumber → Observations)` entry and applies the - /// full IOD pipeline: candidate triplet enumeration, preliminary Gauss solution, and - /// scoring / selection of the best orbit. It aggregates results into a [`FullOrbitResult`]. - /// - /// Mutation semantics - /// ----------------- - /// * This function requires `&mut self` and may **reorder observations in-place** (e.g., - /// by time) and/or **update batch-level calibration** data (RMS scaling of quoted errors). - /// * The underlying astrometric measurements (RA/DEC/time) remain semantically identical, - /// but their **container order** and **per-observation uncertainty metadata** may change - /// due to calibration and sorting steps used by the estimator. - /// * If you rely on a specific iteration order elsewhere, do not assume it is preserved. - /// - /// Determinism - /// ----------------- - /// * With a fixed RNG seed, the procedure is deterministic given identical inputs and params. - /// - /// Arguments - /// ----------------- - /// * `state`: Global environment providing ephemerides, constants, and reference frames. - /// * `rng`: Random number generator used for noisy triplet realizations (e.g., [`StdRng`](rand::rngs::StdRng)). - /// * `params`: IOD parameters controlling triplet generation, scoring, and correction loops. - /// - /// Return - /// ---------- - /// * A [`FullOrbitResult`] mapping each object to either: - /// * `Ok((GaussResult, f64))` – selected orbit and its RMS, - /// * `Err(OutfitError)` – diagnostic if no acceptable solution was found. - /// - /// Notes - /// ---------- - /// * Failures are isolated: one object failing does not prevent others from being processed. - /// * Runtime scales with the number of trajectories and candidate triplets per trajectory. - /// - /// See also - /// ------------ - /// * [`ObservationIOD::estimate_best_orbit`] – Per-trajectory IOD with best-orbit selection. - /// * [`TrajectoryFit::estimate_all_orbits_with_cancel`] – Same API with cooperative cancellation. - /// * [`IODParams`] – Tuning parameters for IOD batch execution. - fn estimate_all_orbits( - &mut self, - state: &Outfit, - rng: &mut impl Rng, - params: &IODParams, - ) -> FullOrbitResult; - - /// Count the total number of [`Observation`](crate::observations::Observation) entries across all trajectories. - /// - /// This method iterates once over all values in the [`TrajectorySet`], - /// summing the length of each [`Observations`] container. - /// - /// Return - /// ---------- - /// * The total number of observations across all objects. - fn total_observations(&self) -> usize; - - /// Compute distribution statistics for the number of observations per trajectory. - /// - /// Each trajectory (one object in the [`TrajectorySet`]) has an associated - /// [`Observations`] container. This function collects their sizes and computes: - /// - /// * `min` – smallest number of observations in any trajectory, - /// * `p25` – 25th percentile (first quartile), - /// * `median` – 50th percentile (second quartile), - /// * `p95` – 95th percentile (upper tail indicator), - /// * `max` – largest number of observations in any trajectory. - /// - /// Percentiles are computed using the *nearest-rank* method: - /// the index is `round(q × (N-1))` for quantile `q ∈ [0,1]`, clamped to valid range. - /// This makes results robust even for small datasets. - /// - /// Return - /// ---------- - /// * `None` if the set is empty. - /// * `Some(ObsCountStats)` containing the summary statistics otherwise. - /// - /// See also - /// ------------ - /// * [`total_observations`](crate::trajectories::trajectory_fit::TrajectoryFit::total_observations) – Sum of all observations across trajectories. - fn obs_count_stats(&self) -> Option; - - /// Return the number of distinct trajectories (objects) in the set. - /// - /// This is simply the number of keys in the underlying map. - /// - /// Return - /// ------ - /// * The number of distinct trajectories (objects) in the set. - /// - /// See also - /// ------------ - /// * [`total_observations`](crate::trajectories::trajectory_fit::TrajectoryFit::total_observations) – Sum of all observations across trajectories. - /// * [`obs_count_stats`](crate::trajectories::trajectory_fit::TrajectoryFit::obs_count_stats) – Statistics on the number of observations per trajectory. - fn number_of_trajectories(&self) -> usize; - - /// Run Gauss-based IOD for all trajectories, with **cooperative cancellation** support. - /// - /// Behaves like [`TrajectoryFit::estimate_all_orbits`], but periodically polls a user - /// callback to decide whether to stop early. Returns **partial results** if cancelled. - /// - /// Mutation semantics - /// ----------------- - /// * Same as the non-cancellable variant: the method may **reorder observations in-place** - /// and update **per-batch calibration** (e.g., RMS alignment of quoted errors). - /// - /// Cancellation model - /// ----------------- - /// * The loop polls `should_cancel` at ~20 ms wall-clock intervals. When it returns `true`, - /// the loop terminates gracefully, calls `on_interrupt()` on the progress sink (if any), - /// and returns the results accumulated so far. - /// - /// Determinism - /// ----------------- - /// * With a fixed RNG seed, behavior is deterministic except for the **cut point** at which - /// cancellation is observed (timing dependent). - /// - /// Arguments - /// ----------------- - /// * `state`: Global environment and reference frames. - /// * `rng`: Random number generator for noisy triplet realizations. - /// * `params`: IOD parameters. - /// * `should_cancel`: Closure polled periodically; return `true` to request early stop. - /// - /// Return - /// ---------- - /// * A [`FullOrbitResult`]: - /// * Complete if the loop ran to completion, - /// * Partial if cancellation was triggered mid-way. - /// - /// See also - /// ------------ - /// * [`TrajectoryFit::estimate_all_orbits`] – Non-cancellable variant. - fn estimate_all_orbits_with_cancel( - &mut self, - state: &Outfit, - rng: &mut impl Rng, - params: &IODParams, - should_cancel: F, - ) -> FullOrbitResult - where - F: FnMut() -> bool; - - /// Run Gauss-based Initial Orbit Determination (IOD) over all trajectories - /// using **parallel batches**. - /// - /// The [`TrajectorySet`] is split into chunks of size `batch_size`. Each chunk - /// is assigned to a Rayon worker thread, and objects inside a chunk are processed - /// sequentially for cache efficiency. A single `base_seed` is drawn from `rng`, - /// and a stable per-object seed is derived deterministically to guarantee - /// reproducibility regardless of parallel scheduling. - /// - /// Threading model - /// ----------------- - /// * This function uses the **global Rayon thread pool** by default. - /// * The number of worker threads is controlled by the environment variable - /// `RAYON_NUM_THREADS`. For example: - /// - /// ```bash - /// RAYON_NUM_THREADS=4 cargo run --release - /// ``` - /// - /// will cap Rayon to 4 threads across the entire program. - /// * If the variable is unset, Rayon defaults to the number of logical CPUs. - /// - /// Mutation semantics - /// ----------------- - /// * As in the sequential version, this method may **reorder observations** - /// and **update per-batch calibration** (e.g. RMS scaling of quoted errors). - /// * Each trajectory’s observation container is reinserted after processing, - /// so `self` remains valid and complete. - /// - /// Arguments - /// ----------------- - /// * `state`: Global environment (ephemerides, constants, frames). - /// * `rng`: Random number generator, used only once to draw a base seed. - /// * `params`: IOD parameters controlling triplet generation, scoring, correction. - /// * `batch_size`: Number of trajectories per parallel batch. Must be ≥ 1. - /// - /// Return - /// ---------- - /// * A [`FullOrbitResult`] mapping each object to either: - /// * `Ok((GaussResult, f64))` – best orbit and its RMS, - /// * `Err(OutfitError)` – diagnostic if no acceptable orbit was found. - /// - /// See also - /// ------------ - /// * [`TrajectoryFit::estimate_all_orbits`] – Sequential variant. - /// * [`TrajectoryFit::estimate_all_orbits_with_cancel`] – Sequential variant with cooperative cancellation. - #[cfg(feature = "parallel")] - fn estimate_all_orbits_in_batches_parallel( - &mut self, - state: &Outfit, - rng: &mut impl rand::Rng, - params: &IODParams, - ) -> FullOrbitResult; -} - -impl TrajectoryFit for TrajectorySet { - fn estimate_all_orbits( - &mut self, - state: &Outfit, - rng: &mut impl Rng, - params: &IODParams, - ) -> FullOrbitResult { - // `ProgressImpl` is `IndicatifProgress` when feature=progress, `()` otherwise. - estimate_all_orbits_core( - self, - state, - rng, - params, - None:: bool>>, - ProgressImpl::default(), - ) - } - - fn estimate_all_orbits_with_cancel( - &mut self, - state: &Outfit, - rng: &mut impl Rng, - params: &IODParams, - mut should_cancel: F, - ) -> FullOrbitResult - where - F: FnMut() -> bool, - { - let cancel = CancelCfg { - interval: Duration::from_millis(20), - should_cancel: &mut should_cancel, - }; - estimate_all_orbits_core( - self, - state, - rng, - params, - Some(cancel), - ProgressImpl::default(), - ) - } - - #[cfg(feature = "parallel")] - fn estimate_all_orbits_in_batches_parallel( - &mut self, - state: &Outfit, - rng: &mut impl rand::Rng, - params: &IODParams, - ) -> FullOrbitResult { - // Draw a single base seed once; per-object seeds derived deterministically. - let base_seed: u64 = rng.random(); - - // Take the whole map so we can own/mutate Observations per object off-thread. - let mut old: TrajectorySet = mem::take(self); - let mut entries: Vec<(ObjectNumber, Observations)> = old.drain().collect(); - - let total_items = entries.len() as u64; - - // Materialize batches **by move** (no clone of Observations). - let mut batches: Vec> = - Vec::with_capacity(entries.len().div_ceil(params.batch_size.max(1))); - while !entries.is_empty() { - let take_n = entries.len().min(params.batch_size); - batches.push(entries.drain(..take_n).collect()); - } - - // Global progress bar (thread-safe) under the `progress` feature. - #[cfg(feature = "progress")] - let pb = { - use indicatif::{ProgressBar, ProgressStyle}; - let pb = ProgressBar::new(total_items.max(1)); - pb.set_style( - ProgressStyle::with_template( - "{bar:40.cyan/blue} {pos}/{len} ({percent:>3}%) \ - | {per_sec} | ETA {eta_precise} | parallel batches", - ) - .expect("indicatif template"), - ); - pb.enable_steady_tick(std::time::Duration::from_millis(200)); - pb - }; - - // Process batches in parallel; each batch processed sequentially for locality. - #[allow(clippy::type_complexity)] - let mut per_batch: Vec< - Vec<( - ObjectNumber, - Result<(GaussResult, f64), OutfitError>, - Observations, - )>, - > = batches - .into_par_iter() - .map(|mut batch| { - let mut out: Vec<( - ObjectNumber, - Result<(GaussResult, f64), OutfitError>, - Observations, - )> = Vec::with_capacity(batch.len()); - - for (obj, mut obs) in batch.drain(..) { - use rand::SeedableRng; - - let local_seed = seed_for_object(base_seed, &obj); - let mut local_rng = rand::rngs::StdRng::seed_from_u64(local_seed); - - let res = - obs.estimate_best_orbit(state, &state.error_model, &mut local_rng, params); - - // Thread-safe progress increment. - #[cfg(feature = "progress")] - pb.inc(1); - - out.push((obj, res, obs)); - } - out - }) - .collect(); - - // Finalize progress. - #[cfg(feature = "progress")] - { - pb.disable_steady_tick(); - pb.finish_and_clear(); - } - - // Reinsert mutated observations and build results map with the same hasher. - let mut results: FullOrbitResult = HashMap::with_hasher(ahash::RandomState::new()); - for batch in per_batch.drain(..) { - for (obj, res, obs) in batch { - self.insert(obj.clone(), obs); - results.insert(obj, res); - } - } - results - } - - #[inline] - fn total_observations(&self) -> usize { - self.values().map(|obs: &Observations| obs.len()).sum() - } - - #[inline] - fn number_of_trajectories(&self) -> usize { - self.len() - } - - fn obs_count_stats(&self) -> Option { - // Collect sizes (one pass, O(N)) - let mut counts: Vec = self.values().map(|obs| obs.len()).collect(); - if counts.is_empty() { - return None; - } - - // Sort once, O(N log N). `unstable` is fine since we only need order. - counts.sort_unstable(); - - #[inline] - fn q_index(n: usize, q: f64) -> usize { - // Nearest-rank on [0, n-1] using linear index; robust for small n. - let pos = q * (n as f64 - 1.0); - let idx = pos.round() as isize; - idx.clamp(0, (n as isize) - 1) as usize - } - - let n = counts.len(); - let min = counts[0]; - let max = counts[n - 1]; - let p25 = counts[q_index(n, 0.25)]; - let median = counts[q_index(n, 0.50)]; - let p95 = counts[q_index(n, 0.95)]; - - Some(ObsCountStats { - min, - p25, - median, - p95, - max, - }) - } -} - -#[cfg(test)] -#[cfg(feature = "jpl-download")] -mod tests_estimate_all_orbits { - use crate::{ - observations::Observation, unit_test_global::OUTFIT_HORIZON_TEST, KeplerianElements, - }; - - use super::*; - use approx::assert_relative_eq; - use rand::SeedableRng; - use smallvec::SmallVec; - use std::{ - f64::consts::PI, - sync::atomic::{AtomicUsize, Ordering}, - }; - - // ------------------------------- - // Test fixtures (lightweight) - // ------------------------------- - - /// Build a tiny TrajectorySet with N empty observation lists. - /// - /// Note: This assumes `TrajectorySet` is a HashMap-like structure - /// and `ObjectNumber::Int(u64)` exists. Adjust if needed. - fn make_set(n: usize) -> TrajectorySet { - let mut set: TrajectorySet = std::collections::HashMap::with_hasher(RandomState::new()); - for i in 0..n { - // If your ObjectNumber uses a different constructor, adjust here. - let key = ObjectNumber::Int(i as u32); - // If Observations is not a Vec, adapt this to your type. - let obs: Observations = Default::default(); - set.insert(key, obs); - } - set - } - - /// Dummy `Outfit` and `IODParams` for tests that do not reach the estimator. - /// - /// We never call the estimator in cancellation-first tests, so these values - /// are placeholders to satisfy the function signatures. - fn dummy_env() -> (Outfit, IODParams) { - let env = OUTFIT_HORIZON_TEST.0.clone(); - let params = IODParams::builder() - .n_noise_realizations(10) - .noise_scale(1.0) - .max_obs_for_triplets(12) - .max_triplets(30) - .build() - .unwrap(); - (env, params) - } - - // ------------------------------- - // Unit tests: cancellation logic - // ------------------------------- - - /// Cancellation fires before the first object is processed: result should be empty. - /// - /// This test calls the factorized core with `interval = 0 ms` and a callback - /// that immediately requests cancellation. The estimator is never invoked. - #[test] - fn core_cancel_before_any_work() { - let mut set = make_set(5); - let (env, params) = dummy_env(); - let mut rng = rand::rngs::StdRng::seed_from_u64(42); - - // Cancel immediately on the very first poll. - let mut cancel_called = 0usize; - let mut should_cancel = || { - cancel_called += 1; - true - }; - - let cancel = CancelCfg { - interval: Duration::from_millis(0), - should_cancel: &mut should_cancel, - }; - - // Use no-op progress sink: works with or without the `progress` feature. - let results = estimate_all_orbits_core(&mut set, &env, &mut rng, ¶ms, Some(cancel), ()); - - assert!(results.is_empty(), "No object should have been processed"); - assert!( - cancel_called >= 1, - "Cancellation should have been polled at least once" - ); - } - - /// Cancellation after exactly one iteration: we expect exactly one entry in the map. - /// - /// IMPORTANT: This test *may* reach the estimator if the cancellation poll - /// happens after the first object. We therefore keep the set size to 1 so - /// we never process more than one. If your estimator requires real env/params, - /// mark this test as `#[ignore]` until you wire a small valid fixture. - #[test] - fn core_cancel_after_one_object() { - let mut set = make_set(2); - let (env, params) = dummy_env(); - let mut rng = rand::rngs::StdRng::seed_from_u64(123); - - let polls = AtomicUsize::new(0); - // First poll = false (let the first object run), next polls = true. - let mut should_cancel = || { - let c = polls.fetch_add(1, Ordering::Relaxed); - c >= 1 - }; - - let cancel = CancelCfg { - interval: Duration::from_millis(0), // poll at every loop entry - should_cancel: &mut should_cancel, - }; - - let results = estimate_all_orbits_core(&mut set, &env, &mut rng, ¶ms, Some(cancel), ()); - - assert_eq!( - results.len(), - 1, - "Exactly one object should have been processed before cancel" - ); - } - - // ------------------------------- - // Unit tests: progress plumbing - // ------------------------------- - - /// Mock progress sink to observe lifecycle calls. - #[derive(Default)] - struct MockProgress { - started_with: Option, - it_calls: usize, - inc_calls: usize, - interrupted: bool, - finished: bool, - } - - impl ProgressSink for MockProgress { - fn start(&mut self, total: u64) { - self.started_with = Some(total); - } - fn on_iter(&mut self) { - self.it_calls += 1; - } - fn inc(&mut self) { - self.inc_calls += 1; - } - fn on_interrupt(&mut self) { - self.interrupted = true; - } - fn finish(&mut self) { - self.finished = true; - } - } - - /// Progress sink should receive `start`, `on_interrupt`, and `finish` when cancelling before work. - #[test] - fn progress_calls_when_cancelled_immediately() { - let mut set = make_set(3); - let (env, params) = dummy_env(); - let mut rng = rand::rngs::StdRng::seed_from_u64(7); - - let mut should_cancel = || true; - let cancel = CancelCfg { - interval: Duration::from_millis(0), - should_cancel: &mut should_cancel, - }; - - let mut mock = MockProgress::default(); - let results = - estimate_all_orbits_core(&mut set, &env, &mut rng, ¶ms, Some(cancel), &mut mock); - - assert!(results.is_empty()); - assert_eq!(mock.started_with, Some(3)); - assert!(mock.interrupted, "on_interrupt() must be called"); - assert!(mock.finished, "finish() must be called"); - // No iteration advanced, so no inc() and on_iter() expected. - assert_eq!(mock.it_calls, 0); - assert_eq!(mock.inc_calls, 0); - } - - // ------------------------------- - // Integration tests - // ------------------------------- - - #[inline] - fn angle_abs_diff(a: f64, b: f64) -> f64 { - let tau = 2.0 * PI; - let mut d = (a - b) % tau; - if d > PI { - d -= tau; - } - if d < -PI { - d += tau; - } - d.abs() - } - - pub fn assert_keplerian_approx_eq( - got: &KeplerianElements, - exp: &KeplerianElements, - abs_eps: f64, - rel_eps: f64, - ) { - // Scalars (non-angular) - assert_relative_eq!( - got.reference_epoch, - exp.reference_epoch, - epsilon = abs_eps, - max_relative = rel_eps - ); - assert_relative_eq!( - got.semi_major_axis, - exp.semi_major_axis, - epsilon = abs_eps, - max_relative = rel_eps - ); - assert_relative_eq!( - got.eccentricity, - exp.eccentricity, - epsilon = abs_eps, - max_relative = rel_eps - ); - - // Angles (radians), compare with wrap-around - for (name, g, e) in [ - ("inclination", got.inclination, exp.inclination), - ( - "ascending_node_longitude", - got.ascending_node_longitude, - exp.ascending_node_longitude, - ), - ( - "periapsis_argument", - got.periapsis_argument, - exp.periapsis_argument, - ), - ("mean_anomaly", got.mean_anomaly, exp.mean_anomaly), - ] { - let diff = angle_abs_diff(g, e); - // Allow absolute OR relative tolerance (whichever is larger). - let tol = abs_eps.max(rel_eps * e.abs()); - assert!( - diff <= tol, - "Angle {name:?} differs too much: |Δ| = {diff:.6e} > tol {tol:.6e} (got={g:.15}, exp={e:.15})" - ); - } - } - - /// Estimate on an empty-observation set should return one entry per object with errors. - #[test] - fn public_no_progress_runs_all_objects() { - let mut set = OUTFIT_HORIZON_TEST.1.clone(); - - // TODO: replace with real constructors in your codebase: - let (env, params) = dummy_env(); - let mut rng = rand::rngs::StdRng::seed_from_u64(777); - - use super::TrajectoryFit; - let results = set.estimate_all_orbits(&env, &mut rng, ¶ms); - - let string_id = "K09R05F"; - let orbit = gauss_result_for(&results, &string_id.into()) - .unwrap() - .unwrap() - .0 - .as_inner() - .as_keplerian() - .unwrap(); - - let expected = KeplerianElements { - reference_epoch: 57049.25533417104, - semi_major_axis: 1.8017448718161189, - eccentricity: 0.283572382702194, - inclination: 0.2026747553253312, - ascending_node_longitude: 0.0079836299943183, - periapsis_argument: 1.245049339166438, - mean_anomaly: 0.4406946018418537, - }; - - assert_keplerian_approx_eq(orbit, &expected, 1e-6, 1e-6); - } - - /// Public cancellation API should return a partial map when cancelling quickly. - #[test] - fn public_with_cancel_returns_partial() { - let mut set = OUTFIT_HORIZON_TEST.1.clone(); - - // TODO: replace with real constructors in your codebase: - let (env, params) = dummy_env(); - let mut rng = rand::rngs::StdRng::seed_from_u64(42); - - use super::TrajectoryFit; - // Callback cancels immediately; public API polls every ~20ms. - // Depending on estimator speed, a few items may slip in before first poll. - let results = set.estimate_all_orbits_with_cancel(&env, &mut rng, ¶ms, || true); - - assert!( - results.len() <= 50, - "Result map cannot exceed the number of objects" - ); - assert!( - !results.is_empty(), - "Depending on timing, a few items may be processed before first poll" - ); - } - - // ------------------------------- - // Accessor helpers tests - // ------------------------------- - - /// `gauss_result_for` should distinguish: missing key, error entry, ok entry. - #[test] - fn gauss_accessors_err_and_missing() { - let mut all: FullOrbitResult = HashMap::with_hasher(RandomState::new()); - let k1 = ObjectNumber::Int(1); - let k2 = ObjectNumber::Int(2); - - // Insert an error entry for k1. Construct an OutfitError if you have a cheap variant. - // If construction is non-trivial, you can skip inserting and just test "missing". - all.insert( - k1.clone(), - Err(OutfitError::InvalidIODParameter("test".into())), - ); - - // Missing key: - assert!(matches!(gauss_result_for(&all, &k2), Ok(None))); - - // Error key: - match gauss_result_for(&all, &k1) { - Err(e) => { - // Just check we got *some* error reference back. - let _ = format!("{e}"); - } - other => panic!("expected Err(&OutfitError), got {other:?}"), - } - - // Take on missing: - assert!(matches!(take_gauss_result(&mut all, &k2), Ok(None))); - - // Take on error: - match take_gauss_result(&mut all, &k1) { - Err(e) => { - let _ = format!("{e}"); - } - other => panic!("expected Err(OutfitError), got {other:?}"), - } - } - - /// Stats over per-trajectory observation counts. - #[test] - fn obs_count_stats_basic() { - use std::collections::HashMap; - - // Helper: build a dummy Observation for tests only. - #[inline] - fn dummy_observation() -> Observation { - // SAFETY (tests only): - // This assumes `Observation` is plain-old-data (floats, ints) and `Copy`, - // i.e. no heap-owned fields (String, Vec, Arc, etc.) and no Drop. - // Si ce n’est pas vrai dans ton code, remplace cette fonction par - // un vrai constructeur de test qui remplit des champs plausibles. - assert_is_copy::(); - unsafe { std::mem::MaybeUninit::::zeroed().assume_init() } - } - - // Compile-time check: force `Observation: Copy` pour que le zero-init soit sûr. - #[inline(always)] - fn assert_is_copy() {} - - // Cas vide. - let set = make_set(0); - assert!(set.obs_count_stats().is_none(), "Empty set → None"); - - // Build uneven counts: 2, 4, 8, 16, 16 - let mut set: TrajectorySet = HashMap::with_hasher(RandomState::new()); - - let mut push_n = |id: u32, n: usize| { - let mut v: Observations = SmallVec::with_capacity(n); - for _ in 0..n { - v.push(dummy_observation()); - } - set.insert(ObjectNumber::Int(id), v); - }; - - push_n(1, 2); - push_n(2, 4); - push_n(3, 8); - push_n(4, 16); - push_n(5, 16); - - let stats = set.obs_count_stats().expect("non-empty"); - assert_eq!(stats.min, 2); - assert_eq!(stats.max, 16); - assert_eq!(stats.median, 8); - assert_eq!(stats.p25, 4); - assert_eq!(stats.p95, 16); - } - - #[cfg(test)] - #[cfg(feature = "parallel")] - mod tests_estimate_orbit_parallel_batches { - use super::*; - use ahash::RandomState; - use rand::SeedableRng; - - // Reuse helpers and fixtures style from your existing tests. - // If these are private in another module, duplicate minimal versions here. - - /// Build a tiny TrajectorySet with N empty observation lists. - /// - /// Note: This assumes `TrajectorySet` is a HashMap-like structure - /// and `ObjectNumber::Int(u32)` exists. Adjust if needed. - fn make_set(n: usize) -> TrajectorySet { - let mut set: TrajectorySet = std::collections::HashMap::with_hasher(RandomState::new()); - for i in 0..n { - set.insert(ObjectNumber::Int(i as u32), Default::default()); - } - set - } - - #[inline] - fn total_obs(set: &TrajectorySet) -> usize { - set.values().map(|v: &Observations| v.len()).sum() - } - - // ------------------------------- - // Unit tests: basic shape/edges - // ------------------------------- - - /// Parallel-batched IOD over an empty set should return an empty map. - #[test] - fn parallel_batches_empty_set_is_empty() { - let mut set = make_set(0); - - // Dummy env/params: estimator is never reached for empty set. - // If you need to compile without jpl, use the same `dummy_env()` strategy as your seq tests. - let env = dummy_env().0; - let params = IODParams::builder().batch_size(1024).build().unwrap(); - - let mut rng = rand::rngs::StdRng::seed_from_u64(1); - let results = set.estimate_all_orbits_in_batches_parallel(&env, &mut rng, ¶ms); - assert!(results.is_empty(), "Empty input → empty results"); - assert_eq!(set.len(), 0, "Set remains empty"); - } - - /// Batch-size boundaries (1 and very large) should produce exactly one entry per object. - /// - /// We don't assert on Ok/Err, only that every object was processed and observations were reintegrated. - #[test] - fn parallel_batches_size_edges_cover_all_objects() { - for &batch_size in &[1usize, 10_000usize] { - let mut set = make_set(7); - - // Build a dummy env that lets the code run without panicking even if estimator errs. - // The estimator may return Err for empty observations — it's fine for this test. - let env = dummy_env().0; - let params = IODParams::builder().batch_size(batch_size).build().unwrap(); - - let before_n = set.len(); - let before_tot = total_obs(&set); - - let mut rng = rand::rngs::StdRng::seed_from_u64(42); - let results = set.estimate_all_orbits_in_batches_parallel(&env, &mut rng, ¶ms); - - assert_eq!(results.len(), before_n, "Exactly one entry per object"); - assert_eq!(set.len(), before_n, "All objects reinserted in set"); - assert_eq!( - total_obs(&set), - before_tot, - "Total number of observations is preserved (reorder/calibration only)" - ); - } - } - - /// Using the same input and RNG seed must be deterministic across runs, regardless of scheduling. - /// This checks **one specific object** for identical formatted outcome (Ok/Err shape and RMS). - #[test] - fn parallel_batches_deterministic_across_runs_with_same_seed() { - // Small synthetic set; estimator likely returns Err for empty observations. - // Determinism check focuses on the *presence* and *shape* of results. - let build_set = || make_set(5); - let env = dummy_env().0; - let params = IODParams::builder().batch_size(2).build().unwrap(); - - let key = ObjectNumber::Int(2); - - let run_once = |seed: u64| { - let mut set = build_set(); - let mut rng = rand::rngs::StdRng::seed_from_u64(seed); - let results = set.estimate_all_orbits_in_batches_parallel(&env, &mut rng, ¶ms); - // Record a stable string representation for that key. - match results.get(&key) { - None => "None".to_string(), - Some(Ok((_g, rms))) => format!("Ok rms={rms:.12e}"), - Some(Err(e)) => format!("Err: {e}"), - } - }; - - let a = run_once(0xDEADBEEF); - let b = run_once(0xDEADBEEF); - assert_eq!(a, b, "Same seed/input → identical outcome formatting"); - } - - // ------------------------------- - // Integration tests with JPL env - // ------------------------------- - - mod with_ephem { - use super::*; - use approx::assert_relative_eq; - - use crate::unit_test_global::OUTFIT_HORIZON_TEST; - - /// Parallel batched results should match the known-good orbit (as in the sequential test). - #[test] - fn parallel_batches_return_orbit() { - // Use the same fixture you use elsewhere. - let mut set = OUTFIT_HORIZON_TEST.1.clone(); - let (env, params) = { - // If you have a helper `dummy_env()` in the seq tests, keep the same one: - let env = OUTFIT_HORIZON_TEST.0.clone(); - let params = IODParams::builder() - .n_noise_realizations(10) - .noise_scale(1.0) - .max_obs_for_triplets(12) - .max_triplets(30) - .batch_size(1) - .build() - .unwrap(); - (env, params) - }; - let mut rng = rand::rngs::StdRng::seed_from_u64(42); - - // Choose a batch size that is neither 1 nor huge to exercise chunking logic. - let results = set.estimate_all_orbits_in_batches_parallel(&env, &mut rng, ¶ms); - - // Same canonical object as in your sequential test: - let string_id = "K09R05F"; - let orbit = gauss_result_for(&results, &string_id.into()); - - assert!(orbit.is_ok(), "Result entry should be Ok"); - } - - /// Parallel batched determinism across different batch sizes. - /// - /// With same RNG seed + inputs, changing only `batch_size` should not affect results. - #[test] - fn parallel_batches_results_independent_of_batch_size() { - let mut set1 = OUTFIT_HORIZON_TEST.1.clone(); - let mut set2 = OUTFIT_HORIZON_TEST.1.clone(); - let (env, params) = { - let env = OUTFIT_HORIZON_TEST.0.clone(); - let params = IODParams::builder() - .n_noise_realizations(10) - .noise_scale(1.0) - .max_obs_for_triplets(12) - .max_triplets(30) - .batch_size(64) - .build() - .unwrap(); - (env, params) - }; - - let seed = 0xABCDEF0123456789; - let mut rng1 = rand::rngs::StdRng::seed_from_u64(seed); - let mut rng2 = rand::rngs::StdRng::seed_from_u64(seed); - - let res1 = set1.estimate_all_orbits_in_batches_parallel(&env, &mut rng1, ¶ms); - - let params2 = IODParams { - batch_size: 4096, - ..params.clone() - }; - - let res2 = set2.estimate_all_orbits_in_batches_parallel(&env, &mut rng2, ¶ms2); - - // Compare a known object Keplerian solution (same as above). - let key = "K09R05F".into(); - let k1 = gauss_result_for(&res1, &key) - .unwrap() - .unwrap() - .0 - .as_inner() - .as_keplerian() - .unwrap(); - let k2 = gauss_result_for(&res2, &key) - .unwrap() - .unwrap() - .0 - .as_inner() - .as_keplerian() - .unwrap(); - - // Tight numerical equality (same seed → same triplet noise → identical orbit). - assert_relative_eq!(k1.reference_epoch, k2.reference_epoch, epsilon = 0.0); - assert_relative_eq!(k1.semi_major_axis, k2.semi_major_axis, epsilon = 0.0); - assert_relative_eq!(k1.eccentricity, k2.eccentricity, epsilon = 0.0); - assert_relative_eq!(k1.inclination, k2.inclination, epsilon = 0.0); - assert_relative_eq!( - k1.ascending_node_longitude, - k2.ascending_node_longitude, - epsilon = 0.0 - ); - assert_relative_eq!(k1.periapsis_argument, k2.periapsis_argument, epsilon = 0.0); - assert_relative_eq!(k1.mean_anomaly, k2.mean_anomaly, epsilon = 0.0); - } - } - } -} diff --git a/src/trajectory.rs b/src/trajectory.rs new file mode 100644 index 0000000..d471376 --- /dev/null +++ b/src/trajectory.rs @@ -0,0 +1,816 @@ +use std::ops::ControlFlow; + +use nalgebra::Vector3; +use photom::{observation_dataset::observation::Observation, Radians}; + +use crate::{ + cache::OutfitCache, + initial_orbit_determination::{gauss::GaussObs, triplet_generation::generate_triplets}, + obs_dataset::IODRMS, + observation_ephemeris::ObservationEphemeris, + EquinoctialElements, GaussResult, IODParams, JPLEphem, OutfitError, +}; + +pub(crate) trait TrajectoryFit { + /// Extract astrometric uncertainties (RA and DEC) for a set of three observations. + /// + /// Given a triplet of observation indices, this function retrieves the corresponding + /// astrometric errors in right ascension and declination from the observation set. + /// + /// # Arguments + /// + /// - `idx_obs` - A vector of three indices referring to the observations used in the triplet. + /// + /// # Returns + /// + /// - A tuple of two `Vector3`: + /// - The first vector contains the RA uncertainties in radians. + /// - The second vector contains the DEC uncertainties in radians. + /// + /// # Panics + /// + /// This function will panic if any index in `idx_obs` is out of bounds of the observation set. + fn extract_errors(&self, idx_obs: Vector3) -> (Vector3, Vector3); + + /// Compute **time-feasible, best-K** triplets of observations for Gauss IOD, + /// leveraging a lazy **index stream** and a bounded **max-heap** on spacing weight. + /// + /// Overview + /// ----------------- + /// This method is a convenience wrapper around [`generate_triplets`]. It operates + /// directly on `self` (the current observation set) and returns up to `max_triplet` + /// **best-scored** candidates for the Gauss preliminary solution. Internally it: + /// + /// 1) Uses a `TripletIndexGenerator` that: + /// - sorts epochs in place, + /// - downsamples to at most `max_obs_for_triplets` (uniform with edges), + /// - lazily **streams reduced indices** `(first, middle, last)` constrained by: + /// `dt_min ≤ t[last] − t[first] ≤ dt_max`. + /// 2) Scores each feasible triplet with [`triplet_weight`](crate::observations::triplets_iod::triplet_weight) against `optimal_interval_time`. + /// 3) Keeps only the **K** smallest weights in a bounded **max-heap** (best-K selection). + /// 4) Materializes the survivors as [`GaussObs`] by (re)borrowing `self` immutably. + /// + /// Compared to brute-force `O(n³)`, the time-windowed enumeration drives the effective + /// cost toward ~`O(n²)` in typical time distributions, plus `O(n log K)` for heap updates. + /// + /// Arguments + /// ----------------- + /// * `dt_min` – Minimum allowed timespan `[same units as Observation::time]` between the first and last epoch of a triplet. + /// * `dt_max` – Maximum allowed timespan between the first and last epoch of a triplet. + /// * `optimal_interval_time` – Target per-gap spacing (e.g., days) used by [`triplet_weight`](crate::observations::triplets_iod::triplet_weight). + /// * `max_obs_for_triplets` – Upper bound on observations kept after downsampling (uniform with endpoints). + /// * `max_triplet` – Number `K` of best-scoring triplets to return. + /// + /// Return + /// ---------- + /// * A `Vec` of length `≤ max_triplet`, **sorted by increasing weight** + /// (best geometric spacing first), ready to be passed to `GaussObs::prelim_orbit`. + /// + /// Remarks + /// ------------- + /// * Sorting is **in-place**; call sites should not rely on original ordering afterward. + /// * The generator avoids overlapping borrows of `self`; only the final K triplets are materialized. + /// * For robustness studies, each returned triplet can be expanded with + /// `GaussObs::realizations_iter` (lazy Monte-Carlo noise). + /// + /// Complexity + /// ----------------- + /// * Enumeration: ~`O(n²)` (per-anchor time window). + /// * Selection: `O(n log K)` (bounded max-heap). + /// * Space: `O(1)` per yielded candidate; only K triplets are allocated at the end. + /// + /// See also + /// ------------ + /// * [`generate_triplets`] – Low-level function performing the selection (index stream + heap + materialization). + /// * [`TripletIndexGenerator`](crate::observations::triplets_generator::TripletIndexGenerator) – Lazy stream of reduced indices constrained by `(dt_min, dt_max)`. + /// * [`triplet_weight`](crate::observations::triplets_iod::triplet_weight) – Spacing heuristic around `optimal_interval_time`. + /// * [`GaussObs::realizations_iter`] – On-the-fly noisy realizations for a given triplet. + fn compute_triplets(&self, cache: &OutfitCache, params: &IODParams) -> Vec; + + /// Select the interval of observations for RMS calculation. + /// + /// This function selects the interval of observations for RMS calculation based on the provided triplet. + /// It computes the maximum allowed interval and finds the start and end indices of the observations + /// within that interval. + /// + /// Arguments + /// --------- + /// * `triplets`: A reference to a `GaussObs` representing the triplet of observations. + /// * `extf`: A `f64` representing the external factor for the interval calculation. + /// * `dtmax`: A `f64` representing the maximum allowed interval. + /// + /// Return + /// ------ + /// * A `Result` containing a tuple of start and end indices of the observations within the interval, + /// or an `OutfitError` if an error occurs. + fn select_rms_interval( + &self, + triplets: &GaussObs, + params: &IODParams, + ) -> Result<(usize, usize), OutfitError>; + + /// Evaluate the orbit quality by computing the RMS of normalized astrometric residuals + /// over a time window centered on a Gauss triplet. + /// + /// Scientific context + /// ------------------- + /// This function measures how well a preliminary orbit reproduces the observed + /// astrometry (RA, DEC). It computes the **root-mean-square (RMS)** of the + /// normalized residuals between predicted and observed positions, aggregated over + /// a set of observations surrounding a Gauss triplet. + /// + /// Interval selection + /// ------------------- + /// The observation arc is defined by: + /// * `extf` – fractional extension factor applied around the triplet center, + /// * `dtmax` – absolute maximum time span (days) allowed for the arc. + /// + /// The effective interval is determined by + /// [`select_rms_interval`](Self::select_rms_interval), which returns the first + /// and last indices of the observations to include. + /// + /// Computation + /// ------------ + /// * Each observation contributes a squared normalized residual + /// from [`Observation::ephemeris_error`](crate::observations::Observation::ephemeris_error). + /// * The final RMS is + /// + /// ```text + /// RMS = √[ (1 / (2N)) · Σᵢ (ΔRAᵢ² + ΔDECᵢ²) ] + /// ``` + /// + /// where `N` is the number of observations in the selected interval. + /// + /// Pruning mode + /// ------------ + /// If `prune_if_rms_ge` is set: + /// * The summation stops early once the partial RMS reaches the threshold, + /// returning the pruning value directly. + /// * If `prune_if_rms_ge = ∞`, no early exit occurs (equivalent to no pruning). + /// + /// Arguments + /// ---------- + /// * `state` – Global context providing ephemerides, Earth orientation, and time conversion. + /// * `triplets` – The Gauss triplet that defined the preliminary orbit. + /// * `orbit_element` – The orbit (in equinoctial elements) to be tested against the arc. + /// * `extf` – Fractional time extension of the interval around the triplet. + /// * `dtmax` – Maximum arc duration (days). + /// * `prune_if_rms_ge` – Optional RMS cutoff for early termination (see *Pruning mode*). + /// + /// Return + /// ------- + /// * `Ok(rms)` – RMS of the normalized astrometric residuals (radians). + /// * `Err(OutfitError)` – If interval selection fails or propagation/ephemeris lookup fails. + /// + /// Units + /// ------- + /// * The returned RMS is dimensionless but expressed in **radians**. + fn rms_orbit_error( + &self, + cache: &OutfitCache, + jpl: &JPLEphem, + triplets: &GaussObs, + orbit_element: &EquinoctialElements, + params: &IODParams, + prune_if_rms_ge: Option, + ) -> Result; + + /// Estimate the best-fitting preliminary orbit from a full set of astrometric observations. + /// + /// This method searches for the best preliminary orbit by evaluating a limited number of + /// observation triplets generated from the dataset. The process includes: + /// + /// 1. **Error calibration**: + /// Observations are first preprocessed with [`ObservationsExt::apply_batch_rms_correction`] to account for + /// temporal clustering and observer-specific error models. + /// + /// 2. **Triplet generation**: + /// Candidate triplets are generated using [`ObservationsExt::compute_triplets`], which: + /// * Sorts observations by time, + /// * Optionally downsamples the dataset to at most `params.max_obs_for_triplets` points + /// (uniform in time, always keeping the first and last), + /// * Filters valid triplets according to `params.dt_min`, `params.dt_max_triplet`, + /// and `params.optimal_interval_time`. + /// + /// 3. **Monte Carlo noise sampling**: + /// For each triplet, `params.n_noise_realizations` perturbed versions are created using + /// Gaussian noise scaled by `params.noise_scale` times the nominal astrometric uncertainties. + /// + /// 4. **Orbit estimation and selection**: + /// For each (possibly perturbed) triplet, a preliminary orbit is computed with the Gauss method. + /// The resulting orbit is evaluated over the full set of observations using [`ObservationsExt::rms_orbit_error`]. + /// The orbit with the smallest RMS is returned. + /// + /// # Arguments + /// + /// * `state` – + /// Global [`Outfit`] state, providing ephemerides and time conversions. + /// * `error_model` – + /// The astrometric error model (typically per-band or per-observatory). + /// * `rng` – + /// A random number generator used to draw Gaussian perturbations. + /// * `params` – + /// Parameters controlling the initial orbit determination, including: + /// * `n_noise_realizations`: number of noisy triplet variants generated per original triplet, + /// * `noise_scale`: scaling factor for the noise, + /// * `extf`: extrapolation factor for RMS evaluation, + /// * `dtmax`: maximum time interval for RMS evaluation, + /// * `dt_min`, `dt_max_triplet`, `optimal_interval_time`: constraints on triplet spans, + /// * `max_obs_for_triplets`: maximum number of observations to keep when building triplets, + /// * `max_triplets`: maximum number of triplets to process, + /// * `gap_max`: maximum allowed time gap within a batch for RMS corrections. + /// + /// # Returns + /// + /// * `Ok((Some(best_orbit), best_rms))` – The best preliminary orbit found and its RMS. + /// * `Ok((None, f64::MAX))` – No valid orbit could be estimated. + /// * `Err(e)` – An error occurred during orbit estimation or RMS evaluation. + /// + /// # Notes + /// + /// - RMS values are computed with [`ObservationsExt::rms_orbit_error`], which accounts for + /// light-time correction and ephemeris propagation. + /// - Each triplet can produce several preliminary orbit candidates due to + /// noise realizations. + /// - The `max_obs_for_triplets` parameter is crucial for large datasets, + /// as it avoids the combinatorial explosion of triplets. + /// + /// # See also + /// + /// * [`ObservationsExt::compute_triplets`] – Selects triplets from the observation set. + /// * [`GaussObs::generate_noisy_realizations`] – Creates perturbed triplets with Gaussian noise. + /// * [`GaussObs::prelim_orbit`] – Computes a preliminary orbit from a single triplet. + /// * [`ObservationsExt::rms_orbit_error`] – Measures the goodness-of-fit of an orbit against observations. + /// * [`IODParams`] – Configuration options for the IOD process. + fn estimate_best_orbit( + &self, + cache: &OutfitCache, + jpl: &JPLEphem, + params: &IODParams, + rng: &mut impl rand::Rng, + ) -> Result<(GaussResult, IODRMS), OutfitError>; +} + +impl TrajectoryFit for Vec<&Observation> { + fn extract_errors(&self, idx_obs: Vector3) -> (Vector3, Vector3) { + let [i, j, k] = [idx_obs[0], idx_obs[1], idx_obs[2]]; + let [c1, c2, c3] = [ + self[i].equ_coord(), + self[j].equ_coord(), + self[k].equ_coord(), + ]; + ( + Vector3::new(c1.ra_error, c2.ra_error, c3.ra_error), + Vector3::new(c1.dec_error, c2.dec_error, c3.dec_error), + ) + } + + fn compute_triplets(&self, cache: &OutfitCache, params: &IODParams) -> Vec { + generate_triplets(self, cache, params) + } + + fn select_rms_interval( + &self, + triplets: &GaussObs, + params: &IODParams, + ) -> Result<(usize, usize), OutfitError> { + let nobs = self.len(); + + let idx_obs1 = triplets.idx_obs[0]; + let obs1 = self + .get(idx_obs1) + .ok_or(OutfitError::ObservationNotFound(idx_obs1))?; + + let idx_obs3 = triplets.idx_obs[2]; + let obs3 = self + .get(idx_obs3) + .ok_or(OutfitError::ObservationNotFound(idx_obs3))?; + + let first_obs = self.first().ok_or(OutfitError::ObservationNotFound(0))?; + let last_obs = self + .last() + .ok_or(OutfitError::ObservationNotFound(nobs - 1))?; + + // Step 1: Compute the maximum allowed interval + let mut dt = if params.extf >= 0.0 { + (obs3.mjd_tt() - obs1.mjd_tt()) * params.extf + } else { + 10.0 * (last_obs.mjd_tt() - first_obs.mjd_tt()) + }; + + if params.dtmax >= 0.0 { + dt = dt.max(params.dtmax); + } + + let mut i_start = 0; + + for i in (0..=idx_obs1).rev() { + if let Some(obs_i) = self.get(i) { + if obs1.mjd_tt() - obs_i.mjd_tt() > dt { + break; + } + i_start = i; + } + } + + let mut i_end = nobs - 1; + + for i in idx_obs3..nobs { + if let Some(obs_i) = self.get(i) { + if obs_i.mjd_tt() - obs3.mjd_tt() > dt { + break; + } + i_end = i; + } + } + + Ok((i_start, i_end)) + } + + fn rms_orbit_error( + &self, + cache: &OutfitCache, + jpl: &JPLEphem, + triplets: &GaussObs, + orbit_element: &EquinoctialElements, + params: &IODParams, + prune_if_rms_ge: Option, + ) -> Result { + // Select the time interval [start_obs_rms, end_obs_rms] over which the RMS + // error is evaluated. The interval depends on the triplet and on external + // filtering parameters (extf, dtmax). + let (start_obs_rms, end_obs_rms) = self.select_rms_interval(triplets, params)?; + + // Number of observations contributing to the RMS + let n_obs = (end_obs_rms - start_obs_rms + 1) as f64; + + // Denominator of the RMS formula: here weighted by 2.0 for consistency + // with the convention used elsewhere in the code. + let denom = 2.0 * n_obs; + + // ========================================================================= + // Case 1: No pruning → behave like the "classical" RMS definition + // ========================================================================= + if prune_if_rms_ge.is_none() { + // Accumulate the squared ephemeris errors for each observation + let sum = self[start_obs_rms..=end_obs_rms] + .iter() + .map(|obs| obs.ephemeris_error(cache, jpl, orbit_element)) + // try_fold propagates errors from ephemeris_error while summing + .try_fold(0.0, |acc, term| term.map(|v| acc + v))?; + + // Final RMS = sqrt( sum / denom ) + return Ok((sum / denom).sqrt()); + } + + // ========================================================================= + // Case 2: Pruning enabled → early stop if RMS exceeds a threshold + // ========================================================================= + let prune = prune_if_rms_ge.unwrap(); + + // Convert the RMS cutoff into a sum cutoff: + // RMS² = sum / denom → stop if sum ≥ (prune² * denom). + let sum_cutoff = if prune.is_finite() { + prune * prune * denom + } else { + f64::INFINITY // "no real cutoff" if prune = ∞ + }; + + // Iterate over observations and accumulate squared errors. + // We use ControlFlow to allow early exit: + // - Continue(sum): keep summing, + // - Break(value): stop early and return the pruning threshold. + let folded: ControlFlow = self[start_obs_rms..=end_obs_rms] + .iter() + .map(|obs| obs.ephemeris_error(cache, jpl, orbit_element)) + .try_fold(0.0, |acc, term| match term { + Ok(v) => { + let new_sum = acc + v; + if new_sum >= sum_cutoff { + // Early exit: threshold reached, return directly + ControlFlow::Break(prune) + } else { + ControlFlow::Continue(new_sum) + } + } + // In case of error in ephemeris_error, also exit with pruning value. + Err(_) => ControlFlow::Break(prune), + }); + + // Final RMS depending on whether we exited early or not + match folded { + ControlFlow::Continue(sum) => Ok((sum / denom).sqrt()), + ControlFlow::Break(rms) => Ok(rms), + } + } + + fn estimate_best_orbit( + &self, + cache: &OutfitCache, + jpl: &JPLEphem, + params: &IODParams, + rng: &mut impl rand::Rng, + ) -> Result<(GaussResult, IODRMS), OutfitError> { + // Placeholder for the actual orbit estimation logic. + // This would involve: + // 1. Extracting the relevant observations for this trajectory. + // 2. Applying the Gauss method to compute the initial orbit. + // 3. Calculating the RMS of normalized residuals. + // 4. Returning either a successful result or an error. + + let triplets = self.compute_triplets(cache, params); + + if triplets.is_empty() { + let span = if self.is_empty() { + 0.0 + } else { + self.last().unwrap().mjd_tt() - self.first().unwrap().mjd_tt() + }; + return Err(OutfitError::NoFeasibleTriplets { + span, + n_obs: self.len(), + dt_min: params.dt_min, + dt_max: params.dt_max_triplet, + }); + } + + // Current best (lowest) RMS and its orbit. + // Using +∞ avoids Option branching in the hot path. + let mut best_rms = f64::INFINITY; + let mut best_orbit: Option = None; + + // Keep the last encountered error so that we can report something meaningful if *all* fail. + // We don't clone: we keep only the most recent error by moving it in. + let mut last_error: Option = None; + + // For diagnostics, count how many realizations we actually attempted. + let mut n_attempts: usize = 0; + + for triplet in triplets { + // Extract 1-σ astrometric uncertainties for the three obs of this triplet. + let (error_ra, error_dec) = self.extract_errors(triplet.idx_obs); + + // --- Stage 4: For each (lazy) noisy realization of this triplet... + // The iterator yields the original triplet first, then noisy copies. + for realization in triplet.realizations_iter( + &error_ra, + &error_dec, + params.n_noise_realizations, + params.noise_scale, + rng, + ) { + n_attempts += 1; + + // 4.a) Preliminary Gauss solution on the current realization. + let gauss_res = match realization.prelim_orbit(params) { + Ok(res) => res, + Err(e) => { + // Record the failure and continue exploring. + last_error = Some(e); + continue; + } + }; + + // 4.b) Convert to the element set required by the scorer. + let equinoctial_elements = gauss_res.get_orbit().to_equinoctial()?; + + // 4.c) Score orbit vs. full observation set (RMS residual). + let rms = match self.rms_orbit_error( + cache, + jpl, + &realization, + &equinoctial_elements, + params, + Some(best_rms), + ) { + Ok(v) => { + if !v.is_finite() { + last_error = Some(OutfitError::NonFiniteScore(v)); + continue; + } else { + v + } + } + Err(e) => { + last_error = Some(e); + continue; + } + }; + + // 4.d) Keep the best candidate so far. + if rms < best_rms { + best_rms = rms; + best_orbit = Some(gauss_res); + } + } + } + + // --- Stage 5: If at least one candidate succeeded, return the best; otherwise, propagate an error. + if let Some(orbit) = best_orbit { + Ok((orbit, best_rms)) + } else { + // If nothing succeeded, propagate a structured error with the last underlying cause. + // Fallback to a domain-specific unit error if we never captured any (e.g., no attempts). + let root_cause = match last_error { + Some(e) => e, + None => panic!("In estimate_best_orbit: no error captured but best_orbit is None, this should not happen"), + }; + Err(OutfitError::NoViableOrbit { + cause: Box::new(root_cause), + attempts: n_attempts, + }) + } + } +} + +#[cfg(test)] +mod test_obs_ext { + use nalgebra::Matrix3; + use photom::observer::error_model::{ModelCorrection, ObsErrorModel}; + + use crate::{ + initial_orbit_determination::IODParamsBuilder, + orbit_type::orbit_type_test::approx_equal, + test_fixture::{DATASET_2015AB, JPL_EPHEM_HORIZON, UT1_PROVIDER}, + KeplerianElements, OrbitalElements, + }; + + use approx::assert_relative_eq; + use rand::{rngs::StdRng, SeedableRng}; + + use super::*; + + #[test] + fn test_select_rms_interval() { + let corrected_dataset = DATASET_2015AB + .clone() + .with_error_model(ObsErrorModel::FCCT14) + .apply_model_errors(); + + let traj = corrected_dataset + .materialize_trajectory("K09R05F") + .unwrap() + .collect_into_vec(); + let traj_len = traj.len(); + + let cache = + OutfitCache::build(&corrected_dataset, &JPL_EPHEM_HORIZON, &UT1_PROVIDER).unwrap(); + + let iod_params = IODParams { + dt_min: 0.03, + dt_max_triplet: 150.0, + optimal_interval_time: 20.0, + max_obs_for_triplets: traj_len, + max_triplets: 10, + extf: -1.0, + dtmax: 30., + ..Default::default() + }; + + let triplets = traj.compute_triplets(&cache, &iod_params); + let (u1, u2) = traj + .select_rms_interval(triplets.first().unwrap(), &iod_params) + .unwrap(); + + assert_eq!(u1, 0); + assert_eq!(u2, 36); + + let new_params = IODParamsBuilder::from_params(iod_params) + .extf(10.) + .build() + .unwrap(); + + let (u1, u2) = traj + .select_rms_interval(triplets.first().unwrap(), &new_params) + .unwrap(); + + assert_eq!(u1, 14); + assert_eq!(u2, 36); + + let new_params = IODParamsBuilder::from_params(new_params) + .extf(0.001) + .dtmax(3.) + .build() + .unwrap(); + + let (u1, u2) = traj + .select_rms_interval(triplets.first().unwrap(), &new_params) + .unwrap(); + + assert_eq!(u1, 17); + assert_eq!(u2, 33); + } + + #[test] + fn test_rms_trajectory() { + let iod_params = IODParams { + extf: -1.0, + dtmax: 30., + ..Default::default() + }; + + let corrected_dataset = DATASET_2015AB + .clone() + .with_error_model(ObsErrorModel::FCCT14) + .apply_batch_rms_correction(iod_params.gap_max); + + let traj = corrected_dataset + .materialize_trajectory("K09R05F") + .unwrap() + .collect_into_vec(); + + let cache = + OutfitCache::build(&corrected_dataset, &JPL_EPHEM_HORIZON, &UT1_PROVIDER).unwrap(); + + let triplets = GaussObs { + idx_obs: Vector3::new(34, 35, 36), + ra: [[ + 1.789_797_623_341_267, + 1.789_865_909_348_251, + 1.7899347771316527, + ]] + .into(), + dec: [[ + 0.779_178_052_350_181, + 0.779_086_664_971_291_9, + 0.778_996_538_107_973_6, + ]] + .into(), + time: [[ + 57070.238017592594, + 57_070.250_007_592_59, + 57070.262067592594, + ]] + .into(), + observer_helio_position: Matrix3::zeros(), + }; + + let kepler = KeplerianElements { + reference_epoch: 57_049.242_334_573_75, + semi_major_axis: 1.8017360713154256, + eccentricity: 0.283_559_145_668_705_7, + inclination: 0.20267383288689386, + ascending_node_longitude: 7.955_979_023_693_781E-3, + periapsis_argument: 1.2451951387589135, + mean_anomaly: 0.44054589015887125, + }; + + let rms = traj + .rms_orbit_error( + &cache, + &JPL_EPHEM_HORIZON, + &triplets, + &kepler.into(), + &iod_params, + None, + ) + .unwrap(); + + assert_eq!(rms, 153.84607281520138); + } + + mod proptests_extract_errors { + use super::*; + use photom::{ + coordinates::equatorial::EquCoord, + observation_dataset::{observation::ObservationInput, ObsDataset}, + observer::dataset::ObserverId, + photometry::{Filter, Photometry}, + }; + use proptest::prelude::*; + + fn arb_equ_coord() -> impl Strategy { + ( + 0.0..(2.0 * std::f64::consts::PI), + 0.0..0.01f64, + (-std::f64::consts::FRAC_PI_2)..std::f64::consts::FRAC_PI_2, + 0.0..0.01f64, + ) + .prop_map(|(ra, ra_error, dec, dec_error)| EquCoord { + ra, + ra_error, + dec, + dec_error, + }) + } + + fn make_obs_dataset_with_coords(coords: Vec) -> ObsDataset { + let inputs: Vec = coords + .into_iter() + .enumerate() + .map(|(i, equ_coord)| { + ObservationInput::new( + i as u64, + equ_coord, + Photometry { + magnitude: 20.0, + error: 0.1, + filter: Filter::Int(0), + }, + 59000.0, + Some(ObserverId::MpcCode(*b"F51")), + ) + }) + .collect(); + + ObsDataset::default().push_observation(inputs).unwrap().0 + } + + proptest! { + /// `extract_errors` must return the `ra_error` and `dec_error` of each + /// indexed observation, in the correct vector positions. + #[test] + fn proptest_extract_errors_returns_correct_components( + coord0 in arb_equ_coord(), + coord1 in arb_equ_coord(), + coord2 in arb_equ_coord(), + ) { + let dataset = make_obs_dataset_with_coords(vec![coord0, coord1, coord2]); + let obs: Vec<&Observation> = (0..3) + .map(|i| dataset.get_observation(i).unwrap()) + .collect(); + + let idx = Vector3::new(0usize, 1, 2); + let (ra_errors, dec_errors) = obs.extract_errors(idx); + + prop_assert_eq!(ra_errors[0], coord0.ra_error); + prop_assert_eq!(ra_errors[1], coord1.ra_error); + prop_assert_eq!(ra_errors[2], coord2.ra_error); + prop_assert_eq!(dec_errors[0], coord0.dec_error); + prop_assert_eq!(dec_errors[1], coord1.dec_error); + prop_assert_eq!(dec_errors[2], coord2.dec_error); + } + + /// The index order passed to `extract_errors` must determine which + /// observation's error ends up at which vector position. + #[test] + fn proptest_extract_errors_respects_index_order( + coord0 in arb_equ_coord(), + coord1 in arb_equ_coord(), + coord2 in arb_equ_coord(), + ) { + let dataset = make_obs_dataset_with_coords(vec![coord0, coord1, coord2]); + let obs: Vec<&Observation> = (0..3) + .map(|i| dataset.get_observation(i).unwrap()) + .collect(); + + // Permuted index: (2, 0, 1) + let idx = Vector3::new(2usize, 0, 1); + let (ra_errors, dec_errors) = obs.extract_errors(idx); + + prop_assert_eq!(ra_errors[0], coord2.ra_error); + prop_assert_eq!(ra_errors[1], coord0.ra_error); + prop_assert_eq!(ra_errors[2], coord1.ra_error); + prop_assert_eq!(dec_errors[0], coord2.dec_error); + prop_assert_eq!(dec_errors[1], coord0.dec_error); + prop_assert_eq!(dec_errors[2], coord1.dec_error); + } + } + } + + #[test] + fn test_estimate_best_orbit() { + let mut rng = StdRng::seed_from_u64(42_u64); // seed for reproducibility + + let iod_params = IODParams::default(); + + let corrected_dataset = DATASET_2015AB + .clone() + .with_error_model(ObsErrorModel::FCCT14) + .apply_batch_rms_correction(iod_params.gap_max); + + let traj = corrected_dataset + .materialize_trajectory("K09R05F") + .unwrap() + .collect_into_vec(); + + let iod_params = IODParamsBuilder::from_params(iod_params) + .n_noise_realizations(5) + .max_obs_for_triplets(traj.len()) + .build() + .unwrap(); + + let cache = + OutfitCache::build(&corrected_dataset, &JPL_EPHEM_HORIZON, &UT1_PROVIDER).unwrap(); + + let (best_orbit, best_rms) = traj + .estimate_best_orbit(&cache, &JPL_EPHEM_HORIZON, &iod_params, &mut rng) + .unwrap(); + + let binding = best_orbit; + let orbit = binding.get_orbit(); + + let expected_orbit = OrbitalElements::Keplerian(KeplerianElements { + reference_epoch: 57049.22904475403, + semi_major_axis: 1.8017609974509807, + eccentricity: 0.2835733667643381, + inclination: 0.20267686119302475, + ascending_node_longitude: 0.00799201841873464, + periapsis_argument: 1.245034216916367, + mean_anomaly: 0.4405089048961484, + }); + + assert!(approx_equal(orbit, &expected_orbit, 1e-14)); + assert_relative_eq!(best_rms, 222.16583195747745, epsilon = 1e-14); + } +} diff --git a/tests/outfit_struct_test.rs b/tests/outfit_struct_test.rs deleted file mode 100644 index 2e9c42c..0000000 --- a/tests/outfit_struct_test.rs +++ /dev/null @@ -1,18 +0,0 @@ -use outfit::{error_models::ErrorModel, outfit::Outfit}; - -#[test] -fn test_outfit_observer_management() { - let mut outfit = Outfit::new("horizon:DE440", ErrorModel::FCCT14).unwrap(); - - let obs1 = outfit.new_observer(51.58206, -73.06644, 100., Some("Test Observer 1".into())); - assert_eq!(obs1.name, Some("Test Observer 1".to_string())); - assert_eq!(obs1.longitude, 51.58206); - assert_eq!(obs1.rho_cos_phi, 0.29216347396649495); - assert_eq!(obs1.rho_sin_phi, -0.9531782585730825); - - let obs2 = outfit.new_observer(52.58206, 23.4587, 1423., Some("Test Observer 2".into())); - assert_eq!(obs2.name, Some("Test Observer 2".to_string())); - assert_eq!(obs2.longitude, 52.58206); - assert_eq!(obs2.rho_cos_phi, 0.9180389162887692); - assert_eq!(obs2.rho_sin_phi, 0.39572170773696747); -} diff --git a/tests/reader_80col_test.rs b/tests/reader_80col_test.rs deleted file mode 100644 index 1582ef6..0000000 --- a/tests/reader_80col_test.rs +++ /dev/null @@ -1,57 +0,0 @@ -use camino::Utf8Path; -use outfit::constants::ObjectNumber; -use outfit::error_models::ErrorModel; -use outfit::outfit::Outfit; -use outfit::trajectories::trajectory_file::TrajectoryFile; -use outfit::TrajectorySet; - -#[test] -fn test_80col_reader() { - let mut env_state = Outfit::new("horizon:DE440", ErrorModel::FCCT14).unwrap(); - - let path_file = Utf8Path::new("tests/data/33803.obs"); - let mut traj_set = TrajectorySet::new_from_80col(&mut env_state, path_file); - - let obs_33803 = traj_set.get(&ObjectNumber::String("33803".into())).unwrap(); - assert_eq!(traj_set.len(), 1); - assert_eq!(obs_33803.len(), 129); - assert_eq!(obs_33803[0].time, 60324.52016874074); - assert_eq!(obs_33803[0].ra, 3.5491391785131814); - assert_eq!(obs_33803[0].dec, -0.15949710761897423); - assert_eq!( - obs_33803[0].get_observer(&env_state).name, - Some("Mt. Lemmon Survey".to_string()) - ); - - let path_file = Utf8Path::new("tests/data/8467.obs"); - traj_set.add_from_80col(&mut env_state, path_file); - assert_eq!(traj_set.len(), 2); - let obs_8467 = traj_set.get(&ObjectNumber::String("8467".into())).unwrap(); - assert_eq!(obs_8467.len(), 61); - assert_eq!(obs_8467[0].time, 60647.053230740734); - assert_eq!(obs_8467[0].ra, 0.10365423161131723); - assert_eq!(obs_8467[0].dec, 0.1400047372376524); - assert_eq!( - obs_8467[0].get_observer(&env_state).name, - Some("ATLAS Chile, Rio Hurtado".to_string()) - ); - - let path_file = Utf8Path::new("tests/data/K25D50B.obs"); - traj_set.add_from_80col(&mut env_state, path_file); - assert_eq!(traj_set.len(), 3); - let obs_k25 = traj_set - .get(&ObjectNumber::String("K25D50B".into())) - .unwrap(); - assert_eq!(obs_k25.len(), 20); - assert_eq!(obs_k25[0].time, 60732.28129074074); - assert_eq!(obs_k25[0].ra, 2.6992652800547146); - assert_eq!(obs_k25[0].dec, 0.5231308334332919); - assert_eq!( - obs_k25[0].get_observer(&env_state).name, - Some("Kitt Peak-Bok".to_string()) - ); - assert_eq!( - obs_k25[19].get_observer(&env_state).name, - Some("Steward Observatory, Kitt Peak-Spacewatch".to_string()) - ); -} diff --git a/tests/test_cache_consistency.rs b/tests/test_cache_consistency.rs new file mode 100644 index 0000000..c91ac24 --- /dev/null +++ b/tests/test_cache_consistency.rs @@ -0,0 +1,107 @@ +use approx::assert_abs_diff_eq; +use camino::Utf8Path; +use hifitime::ut1::Ut1Provider; +use nalgebra::Vector3; +use outfit::{ + cache::OutfitCache, jpl_ephem::download_jpl_file::EphemFileSource, IODParams, JPLEphem, +}; +use photom::{ + observation_dataset::ObsDataset, + observer::error_model::{ModelCorrection, ObsErrorModel}, +}; + +const POSITION_EPSILON: f64 = 1e-12; + +struct CacheFixture { + ut1_provider: Ut1Provider, + jpl_ephem: JPLEphem, + default_params: IODParams, +} + +impl CacheFixture { + fn new() -> Self { + let ut1_provider = Ut1Provider::download_from_jpl("latest_eop2.long") + .expect("Download of the JPL short time scale UT1 data failed"); + + let jpl_file: EphemFileSource = "horizon:DE440" + .try_into() + .expect("Failed to parse JPL ephemeris source"); + let jpl_ephem = + JPLEphem::new(&jpl_file).expect("Failed to load JPL ephemeris from Horizon"); + + Self { + ut1_provider, + jpl_ephem, + default_params: IODParams::default(), + } + } + + fn build_cache>(&self, paths: &[P]) -> (OutfitCache, ObsDataset) { + let (obs_dataset, _) = ObsDataset::from_mpc_80_col_files(paths); + + let obs_dataset = obs_dataset + .with_error_model(ObsErrorModel::FCCT14) + .apply_batch_rms_correction(self.default_params.gap_max); + + let cache = OutfitCache::build(&obs_dataset, &self.jpl_ephem, &self.ut1_provider) + .expect("Failed to build outfit cache"); + + (cache, obs_dataset) + } +} + +fn assert_helio_position(cache: &OutfitCache, obs_dataset: &ObsDataset, trajectory_id: &str) { + let traj = obs_dataset + .materialize_trajectory(trajectory_id) + .unwrap() + .collect_into_vec(); + + let checks: &[(usize, [f64; 3])] = &[ + ( + 0, + [0.996798968524259, -0.12232935537370689, -0.0530044254994447], + ), + ( + traj.len() / 2, + [-0.2588304494454786, 0.8703635675926336, 0.3773300400916685], + ), + ( + traj.len() - 1, + [-0.8383497659757538, 0.479843435538848, 0.20801659206288547], + ), + ]; + + for (idx, expected) in checks { + let obs = &traj[*idx]; + let helio_pos = cache + .get_centric(obs.index()) + .helio_position + .map(|x| x.into_inner()); + + assert_abs_diff_eq!( + helio_pos, + Vector3::from(*expected), + epsilon = POSITION_EPSILON + ); + } +} + +#[test] +fn test_cache_consistency() { + let fixture = CacheFixture::new(); + + let dataset_combinations: &[&[&str]] = &[ + &["tests/data/2015AB.obs"], + &["tests/data/8467.obs", "tests/data/2015AB.obs"], + &[ + "tests/data/2015AB.obs", + "tests/data/8467.obs", + "tests/data/33803.obs", + ], + ]; + + for paths in dataset_combinations { + let (cache, obs_dataset) = fixture.build_cache(paths); + assert_helio_position(&cache, &obs_dataset, "K09R05F"); + } +} diff --git a/tests/test_gauss_iod.rs b/tests/test_gauss_iod.rs index fbaec35..4cd9885 100644 --- a/tests/test_gauss_iod.rs +++ b/tests/test_gauss_iod.rs @@ -1,137 +1,120 @@ -#![cfg(feature = "jpl-download")] - mod common; use approx::assert_relative_eq; -use camino::Utf8Path; -use outfit::constants::ObjectNumber; -use outfit::error_models::ErrorModel; -use outfit::initial_orbit_determination::gauss_result::GaussResult; +use hifitime::ut1::Ut1Provider; use outfit::initial_orbit_determination::IODParams; -use outfit::observations::observations_ext::ObservationIOD; +use outfit::jpl_ephem::download_jpl_file::EphemFileSource; +use outfit::obs_dataset::FitIOD; use outfit::orbit_type::keplerian_element::KeplerianElements; use outfit::orbit_type::OrbitalElements; -use outfit::outfit::Outfit; -use outfit::outfit_errors::OutfitError; -use outfit::trajectories::trajectory_file::TrajectoryFile; -use outfit::TrajectorySet; +use outfit::JPLEphem; +use photom::observation_dataset::ObsDataset; +use photom::observer::error_model::ObsErrorModel; use rand::rngs::StdRng; use rand::SeedableRng; use crate::common::approx_equal; -fn run_iod( - env_state: &mut Outfit, - traj_set: &mut TrajectorySet, - traj_number: &ObjectNumber, -) -> Result<(GaussResult, f64), OutfitError> { - let obs = traj_set.get_mut(traj_number).unwrap(); - let mut rng = StdRng::seed_from_u64(42_u64); // seed for reproducibility - - let default = IODParams::builder() - .n_noise_realizations(10) - .noise_scale(1.1) - .max_obs_for_triplets(obs.len()) - .max_triplets(30) - .build()?; - - obs.estimate_best_orbit(env_state, &ErrorModel::FCCT14, &mut rng, &default) -} - #[test] - fn test_gauss_iod() { let test_max_relative = 1e-11; let test_epsilon = 1e-11; - let mut env_state = Outfit::new("horizon:DE440", ErrorModel::FCCT14).unwrap(); + let ut1_provider = Ut1Provider::download_from_jpl("latest_eop2.long") + .expect("Download of the JPL short time scale UT1 data failed"); - let path_file = Utf8Path::new("tests/data/2015AB.obs"); - let mut traj_set = TrajectorySet::new_from_80col(&mut env_state, path_file); + let jpl_file: EphemFileSource = "horizon:DE440" + .try_into() + .expect("Failed to parse JPL ephemeris source"); + let jpl_ephem = JPLEphem::new(&jpl_file).expect("Failed to load JPL ephemeris from Horizon"); - let path_file = Utf8Path::new("tests/data/8467.obs"); - traj_set.add_from_80col(&mut env_state, path_file); + let (obs_dataset, errors) = ObsDataset::from_mpc_80_col_files(&[ + "tests/data/2015AB.obs", + "tests/data/8467.obs", + "tests/data/33803.obs", + ]); - let path_file = Utf8Path::new("tests/data/33803.obs"); - traj_set.add_from_80col(&mut env_state, path_file); + if !errors.is_empty() { + panic!("Failed to load observation datasets: {:?}", errors); + } - let (best_orbit, best_rms) = run_iod( - &mut env_state, - &mut traj_set, - &ObjectNumber::String("K09R05F".into()), - ) - .unwrap(); + let default = IODParams::builder() + .n_noise_realizations(10) + .noise_scale(1.1) + .max_obs_for_triplets(130) // number of observation for the largest trajectory in the dataset + .max_triplets(30) + .build() + .unwrap(); + + let mut full_orbit = obs_dataset + .fit_full_iod( + &jpl_ephem, + &ut1_provider, + &default, + ObsErrorModel::FCCT14, + &mut StdRng::seed_from_u64(42), + ) + .unwrap(); + + let (best_orbit, best_rms) = full_orbit.remove(&"K09R05F".into()).unwrap().unwrap(); + let orbit = best_orbit.get_orbit(); let expected_orbit = OrbitalElements::Keplerian(KeplerianElements { - reference_epoch: 57049.22904452732, - semi_major_axis: 1.8017634341924542, - eccentricity: 0.28360400982137396, - inclination: 0.20267485730439427, - ascending_node_longitude: 0.00810182022710516, - periapsis_argument: 1.2445523487100616, - mean_anomaly: 0.44069989140091426, + reference_epoch: 57049.2684537375, + semi_major_axis: 1.801740835743616, + eccentricity: 0.28356259478492557, + inclination: 0.2026828189979528, + ascending_node_longitude: 0.007951791820548622, + periapsis_argument: 1.2450647642587158, + mean_anomaly: 0.4408048786626789, }); - let orbit = best_orbit.get_orbit(); - assert!(approx_equal(&expected_orbit, orbit, test_epsilon)); assert_relative_eq!( best_rms, - 47.67954270293223, + 66.97479288637471, epsilon = test_epsilon, max_relative = test_max_relative ); - let (best_orbit, best_rms) = run_iod( - &mut env_state, - &mut traj_set, - &ObjectNumber::String("8467".into()), - ) - .unwrap(); - let expected_orbit = OrbitalElements::Keplerian(KeplerianElements { - reference_epoch: 60672.24113100201, - semi_major_axis: 3.189546977249391, - eccentricity: 0.05434034666134485, - inclination: 0.18343383575588465, - ascending_node_longitude: 0.03253594968161228, - periapsis_argument: 2.0197545218038355, - mean_anomaly: 4.85070383704545, + reference_epoch: 60672.2443617134, + semi_major_axis: 3.2199380906809876, + eccentricity: 0.0624192099888107, + inclination: 0.1829771029880289, + ascending_node_longitude: 0.030775930195064964, + periapsis_argument: 1.9053705720223801, + mean_anomaly: 4.980622835177979, }); + let (best_orbit, best_rms) = full_orbit.remove(&8467_u32.into()).unwrap().unwrap(); let orbit = best_orbit.get_orbit(); assert!(approx_equal(&expected_orbit, orbit, test_epsilon)); assert_relative_eq!( best_rms, - 0.550927559734816, + 0.5739558189489471, epsilon = test_epsilon, max_relative = test_max_relative ); - let (best_orbit, best_rms) = run_iod( - &mut env_state, - &mut traj_set, - &ObjectNumber::String("33803".into()), - ) - .unwrap(); - let expected_orbit = OrbitalElements::Keplerian(KeplerianElements { - reference_epoch: 60465.26778016307, - semi_major_axis: 2.192136202201971, - eccentricity: 0.2042936374305811, - inclination: 0.1189651192106584, - ascending_node_longitude: 3.091130251223283, - periapsis_argument: 2.4714439663661487, - mean_anomaly: 4.9466622638827324, + reference_epoch: 60465.26777915681, + semi_major_axis: 2.1874983804796972, + eccentricity: 0.20256414489486008, + inclination: 0.11906245183260411, + ascending_node_longitude: 3.0918063960305293, + periapsis_argument: 2.4793248309745692, + mean_anomaly: 4.934465465531324, }); + let (best_orbit, best_rms) = full_orbit.remove(&33803_u32.into()).unwrap().unwrap(); let orbit = best_orbit.get_orbit(); assert!(approx_equal(&expected_orbit, orbit, test_epsilon)); assert_relative_eq!( best_rms, - 6.319395085728921, + 18.963755528781288, epsilon = test_epsilon, max_relative = test_max_relative ); diff --git a/tests/test_read_ades.rs b/tests/test_read_ades.rs deleted file mode 100644 index 32d9d32..0000000 --- a/tests/test_read_ades.rs +++ /dev/null @@ -1,56 +0,0 @@ -use camino::Utf8Path; -use outfit::constants::ObjectNumber; -use outfit::error_models::ErrorModel; -use outfit::outfit::Outfit; -use outfit::trajectories::trajectory_file::TrajectoryFile; -use outfit::TrajectorySet; - -#[test] -fn test_read_ades() { - let mut outfit = Outfit::new("horizon:DE440", ErrorModel::FCCT14).unwrap(); - - let mut traj_set = TrajectorySet::new_from_ades( - &mut outfit, - Utf8Path::new("tests/data/example_ades.xml"), - None, - None, - ); - assert_eq!(traj_set.len(), 4); - assert_eq!(traj_set.get(&ObjectNumber::Int(1234457)).unwrap().len(), 1); - - traj_set.add_from_ades( - &mut outfit, - Utf8Path::new("tests/data/example_ades2.xml"), - None, - None, - ); - - assert_eq!(traj_set.len(), 7); - let traj = traj_set - .get(&ObjectNumber::String("2016 RD34".into())) - .unwrap(); - assert_eq!(traj.len(), 2); - let obs = traj.first().unwrap().get_observer(&outfit); - assert_eq!( - *obs.name.as_ref().unwrap(), - "University of Hawaii 88-inch telescope, Maunakea".to_string() - ); - - traj_set.add_from_ades( - &mut outfit, - Utf8Path::new("tests/data/flat_ades.xml"), - None, - None, - ); - assert_eq!(traj_set.len(), 41); - - let traj = traj_set - .get(&ObjectNumber::String("D/1993 F2-W".into())) - .unwrap(); - - assert_eq!(traj.len(), 1); - assert_eq!( - traj.first().unwrap().get_observer(&outfit).name, - Some("La Palma".into()) - ); -}