Atmospheric drag and NRLMSISE-00 #11

Closed
opened 2026-09-30 11:08:03 -04:00 by connor · 6 comments
Owner

Drag behind a density model, with the models themselves split by where they live.

Harris-Priester lives here. A fixed pair of profiles with a diurnal bulge, reading no space weather at all. Cheap, smooth, and wrong in a predictable direction, which is what makes it a useful partner to a model that does read the weather: the two give two density realizations of the same trajectory.

NRLMSISE-00 is a separate crate, by the owner's decision, built and validated on its own. Both existing Rust implementations (tobari, satkit, both MIT-compatible) are f64-only, and density falls by a factor of e every 50 km, so depending on one would make a drag partial badly wrong rather than slightly wrong. The plan for that port is at ~/docs/projects/nrlmsise00/plan.md; the validation target is committed here as tests/fixtures/nrlmsise00-cases.json and regenerable by scripts/generate-msis-cases.sh. This crate will take it as an optional dependency behind a feature, as a variant of the density enum.

What is left in this issue: the DensityModel enum, the Drag dynamics with its force and its relative-wind handling, and refusing a propagation whose arc reaches past the driver record.

M1.

Drag behind a density model, with the models themselves split by where they live. **Harris-Priester lives here.** A fixed pair of profiles with a diurnal bulge, reading no space weather at all. Cheap, smooth, and wrong in a predictable direction, which is what makes it a useful partner to a model that does read the weather: the two give two density realizations of the same trajectory. **NRLMSISE-00 is a separate crate**, by the owner's decision, built and validated on its own. Both existing Rust implementations (`tobari`, `satkit`, both MIT-compatible) are `f64`-only, and density falls by a factor of e every 50 km, so depending on one would make a drag partial badly wrong rather than slightly wrong. The plan for that port is at `~/docs/projects/nrlmsise00/plan.md`; the validation target is committed here as `tests/fixtures/nrlmsise00-cases.json` and regenerable by `scripts/generate-msis-cases.sh`. This crate will take it as an optional dependency behind a feature, as a variant of the density enum. What is left in this issue: the `DensityModel` enum, the `Drag` dynamics with its force and its relative-wind handling, and refusing a propagation whose arc reaches past the driver record. M1.
connor added this to the M1 milestone 2026-09-30 11:24:57 -04:00
connor added the size/L label 2026-09-30 11:24:57 -04:00
Author
Owner

Plan for porting NRLMSISE-00, generic over the scalar

Decided after checking what exists: tobari (MIT, clean-room, validated against pymsis)
and satkit (MIT/Apache) both implement it, and both are f64-only. Depending on either
would mean a propagation with drag could be solved but not differentiated, and that is not a
small loss — density changes by a factor of e every ~50 km, so dropping its dependence on
position makes a drag partial badly wrong rather than slightly wrong.

The validation target exists already

tests/fixtures/nrlmsise00-cases.json is committed: the seventeen standard test cases, with
inputs and outputs at seventeen significant digits, regenerable by
scripts/generate-msis-cases.sh. That script fetches Brodowski's C translation of the NRL
Fortran, compiles it with a driver that prints more digits than its own test does, runs it
and throws the source away. What is committed is numbers, which are facts about the model.
The reference's digest is in the fixture header.

The cases vary one input at a time from a baseline of day 172, 29000 s UT, 400 km, 60 N,
70 W, local solar time 16 h, F10.7 and its 81-day average 150, ap 4 — reaching 0 to 1000 km
in altitude, the equator, local midnight, low and high flux, a storm, and the two cases that
use the seven-element ap history rather than the daily value.

So the port has a target before a line of it is written. Make these numbers.

Order of work, bottom up

  1. Coefficient tables, generated rather than transcribed. nrlmsise-00_data.c holds PT,
    PD (9 rows), PS, PDL (2), PTM, PDM (8), PTL (4), PMA (10), SAM and PAVGM — about 2700
    numbers. A script in the manner of scripts/generate-gravity-field.py should emit them
    as Rust statics. Do this in the same sitting as the structure, since the shapes have to
    match what reads them.
  2. The small helpers, each with its own test: spline, splint, splini, densu,
    densm, ccor, ccor2, scalh, dnet, glatf, zeta. These are where a port
    quietly goes wrong, and they are individually checkable.
  3. globe7 and glob7s, the expansions in latitude, day of year, local time and flux.
    The largest pieces and the ones the switches act on.
  4. gts7, then gtd7 and gtd7d.
  5. The seventeen cases against the fixture.
  6. A grid fixture on top: altitude by latitude by local time by flux by ap, generated by
    the same script, to catch anything the seventeen happen not to exercise.

What genericity needs

Everything the model computes with is available on T: Scalar — exp, log, sqrt,
tanh, cos, sin, powi, atan. Three rules:

  • Every magnitude comparison branches on .re(). There are many: altitude bands, the
    exponent clamps in ccor and dnet, the g0/sumex guards.
  • Coefficient tables stay f64. T op f64 already works, so lifting them would only
    cost memory. Same for the 24 switch flags.
  • Mind f64 on the left of an operator. It is not implemented; T * f64 is. This has
    bitten twice already, in third_body and in gravity.

Traps, in rough order of how expensive they are to find late

  • Use gtd7d for drag, not gtd7. They differ only in the total mass density: gtd7d
    includes anomalous oxygen and gtd7 does not. Above about 500 km that matters. The
    fixture gives all nine number densities, so both totals can be checked from it —
    gtd7d is 1.66e-24 * (4 He + 16 O + 28 N2 + 32 O2 + 40 Ar + H + 14 N + 16 anomalous O).
  • Fortran is 1-indexed. The C translation already shifted the coefficient arrays; a port
    from the Fortran would have to. Pick one source and stay with it.
  • Units. The model returns cm^-3 and g/cm^3 unless its metric switch is set. This crate
    is SI, so convert at the boundary and nowhere else. The fixture is in the model's own
    units and says so.
  • switches[9] = -1 is the ap-history mode, which is what a real propagation uses and
    what cases 15 and 16 exercise. The daily-ap mode is the simpler default and is not the
    one that matters.
  • Local solar time has to agree with the seconds of day and the longitude
    (lst = sec/3600 + lon/15). The model does not check, and gives quietly wrong answers if
    they disagree. Derive it rather than taking it as an input.
  • The drivers are already built. atmosphere::drivers gives the previous day's observed
    flux, the 81-day centered average and the seven-element ap history in the order the model
    wants. Note that the centered average costs forty days off the usable end of the record.

Checks beyond matching the reference

  • The density falls monotonically with altitude from 100 to 1000 km, and its logarithmic
    slope is the scale height, which should come out in the tens of kilometers.
  • Day is denser than night at the same altitude, and a storm denser than quiet.
  • It differentiates: the derivative with respect to altitude against central
    differences, and against -1/H from the scale height. This is the whole reason for
    porting it rather than depending on one of the existing crates, so it is the test that
    justifies the work.

Size: large. L is right.

## Plan for porting NRLMSISE-00, generic over the scalar Decided after checking what exists: `tobari` (MIT, clean-room, validated against `pymsis`) and `satkit` (MIT/Apache) both implement it, and both are `f64`-only. Depending on either would mean a propagation with drag could be solved but not differentiated, and that is not a small loss — density changes by a factor of e every ~50 km, so dropping its dependence on position makes a drag partial badly wrong rather than slightly wrong. ### The validation target exists already `tests/fixtures/nrlmsise00-cases.json` is committed: the seventeen standard test cases, with inputs and outputs at seventeen significant digits, regenerable by `scripts/generate-msis-cases.sh`. That script fetches Brodowski's C translation of the NRL Fortran, compiles it with a driver that prints more digits than its own test does, runs it and throws the source away. What is committed is numbers, which are facts about the model. The reference's digest is in the fixture header. The cases vary one input at a time from a baseline of day 172, 29000 s UT, 400 km, 60 N, 70 W, local solar time 16 h, F10.7 and its 81-day average 150, ap 4 — reaching 0 to 1000 km in altitude, the equator, local midnight, low and high flux, a storm, and the two cases that use the seven-element ap history rather than the daily value. So the port has a target before a line of it is written. **Make these numbers.** ### Order of work, bottom up 1. **Coefficient tables**, generated rather than transcribed. `nrlmsise-00_data.c` holds PT, PD (9 rows), PS, PDL (2), PTM, PDM (8), PTL (4), PMA (10), SAM and PAVGM — about 2700 numbers. A script in the manner of `scripts/generate-gravity-field.py` should emit them as Rust statics. Do this in the same sitting as the structure, since the shapes have to match what reads them. 2. **The small helpers, each with its own test**: `spline`, `splint`, `splini`, `densu`, `densm`, `ccor`, `ccor2`, `scalh`, `dnet`, `glatf`, `zeta`. These are where a port quietly goes wrong, and they are individually checkable. 3. **`globe7` and `glob7s`**, the expansions in latitude, day of year, local time and flux. The largest pieces and the ones the switches act on. 4. **`gts7`**, then **`gtd7`** and **`gtd7d`**. 5. **The seventeen cases** against the fixture. 6. **A grid fixture** on top: altitude by latitude by local time by flux by ap, generated by the same script, to catch anything the seventeen happen not to exercise. ### What genericity needs Everything the model computes with is available on `T: Scalar` — `exp`, `log`, `sqrt`, `tanh`, `cos`, `sin`, `powi`, `atan`. Three rules: - **Every magnitude comparison branches on `.re()`.** There are many: altitude bands, the exponent clamps in `ccor` and `dnet`, the `g0`/`sumex` guards. - **Coefficient tables stay `f64`.** `T op f64` already works, so lifting them would only cost memory. Same for the 24 switch flags. - **Mind `f64` on the left of an operator.** It is not implemented; `T * f64` is. This has bitten twice already, in `third_body` and in `gravity`. ### Traps, in rough order of how expensive they are to find late - **Use `gtd7d` for drag, not `gtd7`.** They differ only in the total mass density: `gtd7d` includes anomalous oxygen and `gtd7` does not. Above about 500 km that matters. The fixture gives all nine number densities, so both totals can be checked from it — `gtd7d` is `1.66e-24 * (4 He + 16 O + 28 N2 + 32 O2 + 40 Ar + H + 14 N + 16 anomalous O)`. - **Fortran is 1-indexed.** The C translation already shifted the coefficient arrays; a port from the Fortran would have to. Pick one source and stay with it. - **Units.** The model returns cm^-3 and g/cm^3 unless its metric switch is set. This crate is SI, so convert at the boundary and nowhere else. The fixture is in the model's own units and says so. - **`switches[9] = -1` is the ap-history mode**, which is what a real propagation uses and what cases 15 and 16 exercise. The daily-ap mode is the simpler default and is not the one that matters. - **Local solar time has to agree with the seconds of day and the longitude** (`lst = sec/3600 + lon/15`). The model does not check, and gives quietly wrong answers if they disagree. Derive it rather than taking it as an input. - **The drivers are already built.** `atmosphere::drivers` gives the previous day's observed flux, the 81-day centered average and the seven-element ap history in the order the model wants. Note that the centered average costs forty days off the usable end of the record. ### Checks beyond matching the reference - The density falls monotonically with altitude from 100 to 1000 km, and its logarithmic slope is the scale height, which should come out in the tens of kilometers. - Day is denser than night at the same altitude, and a storm denser than quiet. - **It differentiates**: the derivative with respect to altitude against central differences, and against `-1/H` from the scale height. This is the whole reason for porting it rather than depending on one of the existing crates, so it is the test that justifies the work. Size: large. L is right.
Author
Owner

The plan comment above is superseded: MSIS is a separate crate and the plan now lives at ~/docs/projects/nrlmsise00/plan.md, which is fuller. Left here for the history rather than deleted.

Landed so far:

  • atmosphere::drivers — the committed F10.7 and ap record, parsed on first use, with the previous day's flux, the 81-day centered average and the seven-element ap history. Ten tests. Note the centered average costs forty days off the usable end of the record.
  • atmosphere::harris_priester — the fifty-row profile, exponential in altitude, with the diurnal bulge thirty degrees east of the Sun. Nine tests, including that it differentiates and that the implied scale height comes out in the tens of kilometers.

One property worth knowing about Harris-Priester: the interpolation is exponential inside a tabulated interval and only continuous across one, so the derivative steps at each of the fifty tabulated heights. That is the published form and smoothing it would make it a different model, but it means a propagation crossing those heights meets a small corner each time. There is a test recording it. Found because an autodiff test disagreed with central differences by 4%, which turned out to be my sampling two altitudes that are exactly table nodes.

The plan comment above is superseded: MSIS is a separate crate and the plan now lives at `~/docs/projects/nrlmsise00/plan.md`, which is fuller. Left here for the history rather than deleted. Landed so far: - `atmosphere::drivers` — the committed F10.7 and ap record, parsed on first use, with the previous day's flux, the 81-day centered average and the seven-element ap history. Ten tests. Note the centered average costs forty days off the usable end of the record. - `atmosphere::harris_priester` — the fifty-row profile, exponential in altitude, with the diurnal bulge thirty degrees east of the Sun. Nine tests, including that it differentiates and that the implied scale height comes out in the tens of kilometers. One property worth knowing about Harris-Priester: the interpolation is exponential inside a tabulated interval and only *continuous* across one, so the derivative steps at each of the fifty tabulated heights. That is the published form and smoothing it would make it a different model, but it means a propagation crossing those heights meets a small corner each time. There is a test recording it. Found because an autodiff test disagreed with central differences by 4%, which turned out to be my sampling two altitudes that are exactly table nodes.
Author
Owner

Drag is in, with two density models. Ready for review; MSIS still to come as its own crate.

Dynamics::Drag { density }, with DensityModel an enum for the same reason SurfaceModel is one: the density has to come out in the propagated scalar, so the method is generic and a trait with a generic method is not object-safe. An external MSIS crate joins as a variant behind a feature.

The relative wind comes out of the frame rotation, with no wind model and no angular rate to supply: Cartesian::to_frame's R v + R_dot r is the velocity relative to the co-rotating air, the second term being the air's own motion. That is not a small correction — at the equator the air is already doing 465 m/s, six percent of orbital speed, and it enters the force quadratically. There is a test that pins it down by asking which of the two speeds explains the force: the readings differ by twelve percent, and only the relative one gives a density that is a thermosphere.

Exponential, one layer, added on the owner's suggestion for something easily differentiable. Harris-Priester's interpolation is exponential inside a tabulated interval and only continuous across one, so its slope steps fifty times between 100 and 1000 km; the single layer has no corners at all, which makes it the model to reach for when a derivative is the point. Exponential::fitted_to(altitude) takes both the density and the scale height from the Harris-Priester mean there, so no second table had to be sourced.

Fitted at an orbit's own altitude the two agree to a few tens of meters over a day against kilometers of drag; fitted elsewhere they diverge, which is the knob for running one trajectory under two density realizations.

In examples/full.rs, with each force now priced by leaving it out rather than by nested slices — which had silently stopped meaning what their labels said once drag was added. Over a day at 400 km:

gravity harmonics     1028.771 km
drag                    35.355 km
the sun                  0.035 km
the moon                 0.006 km
sunlight                 0.006 km

That is the textbook hierarchy for the altitude, and the Sun and Moon at 35 m and 6 m agree with the 45 m the two together gave in #9. Drag cost 7 steps a day out of 1420, so the Harris-Priester corners turn out not to matter.

Left: refusing an arc that reaches past the driver record, which nothing yet needs — DensityModel::needs_drivers is false for both of these and the hook is there for when a model returns true.

Drag is in, with two density models. Ready for review; MSIS still to come as its own crate. **`Dynamics::Drag { density }`**, with `DensityModel` an enum for the same reason `SurfaceModel` is one: the density has to come out in the propagated scalar, so the method is generic and a trait with a generic method is not object-safe. An external MSIS crate joins as a variant behind a feature. **The relative wind comes out of the frame rotation**, with no wind model and no angular rate to supply: `Cartesian::to_frame`'s `R v + R_dot r` *is* the velocity relative to the co-rotating air, the second term being the air's own motion. That is not a small correction — at the equator the air is already doing 465 m/s, six percent of orbital speed, and it enters the force quadratically. There is a test that pins it down by asking which of the two speeds explains the force: the readings differ by twelve percent, and only the relative one gives a density that is a thermosphere. **`Exponential`**, one layer, added on the owner's suggestion for something easily differentiable. Harris-Priester's interpolation is exponential inside a tabulated interval and only continuous across one, so its slope steps fifty times between 100 and 1000 km; the single layer has no corners at all, which makes it the model to reach for when a derivative is the point. `Exponential::fitted_to(altitude)` takes both the density and the scale height from the Harris-Priester mean there, so no second table had to be sourced. Fitted at an orbit's own altitude the two agree to a few tens of meters over a day against kilometers of drag; fitted elsewhere they diverge, which is the knob for running one trajectory under two density realizations. **In `examples/full.rs`**, with each force now priced by leaving it out rather than by nested slices — which had silently stopped meaning what their labels said once drag was added. Over a day at 400 km: ``` gravity harmonics 1028.771 km drag 35.355 km the sun 0.035 km the moon 0.006 km sunlight 0.006 km ``` That is the textbook hierarchy for the altitude, and the Sun and Moon at 35 m and 6 m agree with the 45 m the two together gave in #9. Drag cost 7 steps a day out of 1420, so the Harris-Priester corners turn out not to matter. Left: refusing an arc that reaches past the driver record, which nothing yet needs — `DensityModel::needs_drivers` is false for both of these and the hook is there for when a model returns true.
Author
Owner

The driver coverage check is wired in, so #11 is complete apart from NRLMSISE-00 arriving as its own crate.

Drivers::covers_arc(start, end) examines the two ends, which is enough for a record that is one contiguous run of days, and does not care which end is which because a propagation may run backwards. Propagation::run calls it for any spec whose density model reads space weather, before integrating, and the error names the day the record reaches and the script that refreshes it.

Neither model here reads the weather, so the branch cannot fire yet. The logic it guards is tested directly: an arc inside the record, backwards, ending exactly on the last usable day, reaching past it, starting before it, and — the one that catches people — ending on the last measured day, which is already outside, the centered 81-day flux average costing forty days off the end.

211 unit and 6 doc tests, clippy, docs, benchmarks, all three examples and the hook clean.

The driver coverage check is wired in, so #11 is complete apart from NRLMSISE-00 arriving as its own crate. `Drivers::covers_arc(start, end)` examines the two ends, which is enough for a record that is one contiguous run of days, and does not care which end is which because a propagation may run backwards. `Propagation::run` calls it for any spec whose density model reads space weather, before integrating, and the error names the day the record reaches and the script that refreshes it. Neither model here reads the weather, so the branch cannot fire yet. The logic it guards is tested directly: an arc inside the record, backwards, ending exactly on the last usable day, reaching past it, starting before it, and — the one that catches people — ending on the last *measured* day, which is already outside, the centered 81-day flux average costing forty days off the end. 211 unit and 6 doc tests, clippy, docs, benchmarks, all three examples and the hook clean.
Author
Owner

The part of this that lives here is done, in b60dbac. Leaving open for NRLMSISE-00.

What landed:

  • Dynamics::Drag { density } and atmosphere::DensityModel as an enum, for the same
    object-safety reason as SurfaceModel: the density has to come out in whatever scalar is
    being propagated, so the method is generic and a trait with a generic method is not
    object-safe.
  • Harris-Priester, with the bulge exponent as its knob — low inclination against polar
    moves the orbit 15.0 km over a day against 59.5 km of total drag.
  • The relative wind needs no wind model. Cartesian::to_frame's R v + R_dot r is already
    the velocity relative to the co-rotating air. At the equator the air does 465 m/s, six
    percent of orbital speed, entering the force quadratically, so getting this wrong would
    be a twelve percent error in the force rather than a rounding one. The test identifies
    which speed the model used by asking which one implies a density that is a thermosphere.
  • The driver record: GFZ Potsdam's, CC BY 4.0, 25474 days from 1957-01-02 to 2026-09-30,
    generated into src/atmosphere/space_weather.rs as Rust rather than parsed at runtime.
    GFZ over Celestrak because it states a license, and because Celestrak's file runs to 2041
    on its own predictions, which are not measurements.
  • A propagation whose arc reaches past the record is refused before integrating, allowing
    for the 81-day centered flux average costing forty days off the usable end — so an arc
    ending on the last measured day is already outside.

Measured over a day at 400 km, each force priced by leaving it out: harmonics 1028.8 km,
drag 35.4 km, the Sun 0.035 km, the Moon 0.006 km, sunlight 0.006 km.

The Harris-Priester interpolation here is deliberately not the published one. The usual
presentation interpolates exponentially within each tabulated interval, which is continuous
but steps the altitude derivative at every one of the fifty tabulated heights — a median of
4%, and 29% at 120 and 130 km. That cannot be differentiated across, which matters because
the drag partial is wanted exactly where a spacecraft crosses those heights. This
implementation fits a monotone cubic to the logarithm of the density instead. The table is
still reproduced exactly and the profile still falls monotonically. Measured deviation from
the published form: 0.27% over 200-300 km, 0.25% over 300-500, 0.17% over 500-800, 0.47%
over 800-1000, and 13% over 100-130 km where the table is spaced 10 and 20 km and nothing
orbits. It is documented on the module under its own heading so nobody mistakes it for
published Harris-Priester.

Per call it is also cheaper than the published form, 16.2 ns against 19.9 ns, the cubic
beating two powf. End to end that is invisible: at degree 70 the gravity field is 6.76 us
per derivative evaluation and the density is 0.2% of it.

An Exponential single-layer model was added alongside, on the reasoning that
Harris-Priester had corners. Once it did not, that reason was gone and the model was
removed.

What is left, and why this stays open: NRLMSISE-00. That is now entirely the separate
crate's responsibility, and the validation fixture and its generator have moved out of this
repository into that project along with the plan. This crate's remaining part is small —
take it as an optional dependency behind a feature and add a DensityModel variant. The
hook for that already exists: DensityModel::needs_drivers and the arc-coverage check are
written and tested, and simply return false for every model here, because neither reads
space weather.

Note the issue body above is now stale where it says the fixture is committed here.

The part of this that lives here is done, in `b60dbac`. Leaving open for NRLMSISE-00. What landed: - `Dynamics::Drag { density }` and `atmosphere::DensityModel` as an enum, for the same object-safety reason as `SurfaceModel`: the density has to come out in whatever scalar is being propagated, so the method is generic and a trait with a generic method is not object-safe. - Harris-Priester, with the bulge exponent as its knob — low inclination against polar moves the orbit 15.0 km over a day against 59.5 km of total drag. - The relative wind needs no wind model. `Cartesian::to_frame`'s `R v + R_dot r` is already the velocity relative to the co-rotating air. At the equator the air does 465 m/s, six percent of orbital speed, entering the force quadratically, so getting this wrong would be a twelve percent error in the force rather than a rounding one. The test identifies which speed the model used by asking which one implies a density that is a thermosphere. - The driver record: GFZ Potsdam's, CC BY 4.0, 25474 days from 1957-01-02 to 2026-09-30, generated into `src/atmosphere/space_weather.rs` as Rust rather than parsed at runtime. GFZ over Celestrak because it states a license, and because Celestrak's file runs to 2041 on its own predictions, which are not measurements. - A propagation whose arc reaches past the record is refused before integrating, allowing for the 81-day centered flux average costing forty days off the usable end — so an arc ending on the last *measured* day is already outside. Measured over a day at 400 km, each force priced by leaving it out: harmonics 1028.8 km, drag 35.4 km, the Sun 0.035 km, the Moon 0.006 km, sunlight 0.006 km. **The Harris-Priester interpolation here is deliberately not the published one.** The usual presentation interpolates exponentially within each tabulated interval, which is continuous but steps the altitude derivative at every one of the fifty tabulated heights — a median of 4%, and 29% at 120 and 130 km. That cannot be differentiated across, which matters because the drag partial is wanted exactly where a spacecraft crosses those heights. This implementation fits a monotone cubic to the logarithm of the density instead. The table is still reproduced exactly and the profile still falls monotonically. Measured deviation from the published form: 0.27% over 200-300 km, 0.25% over 300-500, 0.17% over 500-800, 0.47% over 800-1000, and 13% over 100-130 km where the table is spaced 10 and 20 km and nothing orbits. It is documented on the module under its own heading so nobody mistakes it for published Harris-Priester. Per call it is also cheaper than the published form, 16.2 ns against 19.9 ns, the cubic beating two `powf`. End to end that is invisible: at degree 70 the gravity field is 6.76 us per derivative evaluation and the density is 0.2% of it. An `Exponential` single-layer model was added alongside, on the reasoning that Harris-Priester had corners. Once it did not, that reason was gone and the model was removed. **What is left, and why this stays open: NRLMSISE-00.** That is now entirely the separate crate's responsibility, and the validation fixture and its generator have moved out of this repository into that project along with the plan. This crate's remaining part is small — take it as an optional dependency behind a feature and add a `DensityModel` variant. The hook for that already exists: `DensityModel::needs_drivers` and the arc-coverage check are written and tested, and simply return false for every model here, because neither reads space weather. Note the issue body above is now stale where it says the fixture is committed here.
Author
Owner

The NRLMSISE-00 half is done, in 74595fa, which completes this issue. The drag model
itself landed earlier in b60dbac, described in the comment above.

It lives in its own crate, as decided: nrlmsise00, now 0.2.0 on crates.io, generic
over its scalar so density can be differentiated. This crate takes it as an optional
dependency behind a msis feature and adds a DensityModel::Nrlmsise00 variant. The
feature turns on that crate's fast path, which trades the reference's exact bits for
roughly half the time: over its 2880-case grid the largest value it moves moves by 3.9e-14
of itself, against a model whose own agreement with the atmosphere is tens of percent.

src/atmosphere/msis.rs is only the join — kilometers and degrees and a Modified Julian
Day on that side, meters and radians and an Epoch on this one — with the model behind a
OnceLock so its coefficient tables are built once rather than per derivative evaluation.

Two things that changed here as a consequence

This crate's own driver record is gone. drivers.rs, the 1.4 MB generated
space_weather.rs and scripts/fetch-space-weather.py are deleted, and the arc check in
Propagation::run now asks the atmosphere crate's record instead. The two were the same
GFZ download a day apart and that one is a superset — it has the centered flux average,
the same Modified Julian Day indexing and a complete driver set per day. With the feature
off nothing reads space weather, so nothing is lost.

Conditions carries a whole Geodetic rather than a bare altitude, because this model
wants latitude and longitude and recomputing them would mean a second iteration of the
geodetic conversion. It dropped its separate body field, which Geodetic already had.

The part that was not obvious

This crate depends on ordinary-diffeq and on nrlmsise00, and each defined its own
Scalar trait — structurally near-identical, implemented for exactly the same types, and
mutually unsatisfiable, so a caller generic over one could not call code generic over the
other.

The resolution was that num-dual already carries the shared vocabulary.
DualNum<Primitive = f64> gives the arithmetic, the transcendentals and From<f64>;
DualStruct<Real = f64> gives re(); and both are implemented for plain f64 as well as
every dual type. So both crates now inherit those rather than redeclaring them —
ordinary-diffeq 0.8.0 and nrlmsise00 0.2.0 — and a type satisfying one satisfies the
other. This crate needed no source change for it: re is inherited rather than redeclared,
so every call site kept compiling, and lift stays as a provided method since num-dual
has no such name.

Worth recording for anyone tempted by the alternative: a newtype adapter, or a fourth crate
defining the shared trait. One was written before the check that num-dual already had it.

Measured, in this crate rather than quoted

per call
Harris-Priester 11.9 ns
NRLMSISE-00, fast 957 ns
Conditions::new, the geodetic conversion 66.8 ns

The whole drag path is therefore about 79 ns with the fixed profile and 1.0 us with
NRLMSISE-00, against a degree-70 gravity field at 6.76 us per derivative evaluation.

Profiling this is what produced 4ad8bc4: the geodetic conversion was 181 ns, eight times
the density model it exists to feed, because its iteration carried the latitude and so paid
an atan2 per pass that the next pass undid with a sin. Carrying the sine and cosine
instead took it to 66.8 ns with values unchanged to 1.1e-16 rad.

Validated: a 5.7x density swing at 400 km between the 2009 solar minimum and the 2014
maximum, less sensitivity at 200 km, three orders of magnitude from 200 to 800 km, and
Harris-Priester landing within a factor of a few at solar maximum, which is the activity it
is fitted for. An arc reaching past the measured record is refused before integrating.

Known limitations

  • The model is not continuous in altitude. Each species' mixed correction is dropped
    above a fitted height by a test on the altitude alone, so the density steps: eight places
    between 72.5 and 1000 km, none above 500. In the band that matters here the steps are
    5.3e-5 of the total at 300 km, 3.5e-5 at 450, 2.3e-6 at 240 — far below anything a step
    controller notices, but genuine discontinuities rather than corners, and documented on
    the variant. Smoothing them would make it a different model.
  • Harris-Priester, by contrast, is deliberately not the published interpolation here, for
    exactly the differentiability reason. See the earlier comment.
  • The issue body above is stale where it says the validation fixture is committed in this
    repository; it moved to the atmosphere crate along with its generator.
The NRLMSISE-00 half is done, in `74595fa`, which completes this issue. The drag model itself landed earlier in `b60dbac`, described in the comment above. **It lives in its own crate**, as decided: `nrlmsise00`, now 0.2.0 on crates.io, generic over its scalar so density can be differentiated. This crate takes it as an optional dependency behind a `msis` feature and adds a `DensityModel::Nrlmsise00` variant. The feature turns on that crate's `fast` path, which trades the reference's exact bits for roughly half the time: over its 2880-case grid the largest value it moves moves by 3.9e-14 of itself, against a model whose own agreement with the atmosphere is tens of percent. `src/atmosphere/msis.rs` is only the join — kilometers and degrees and a Modified Julian Day on that side, meters and radians and an `Epoch` on this one — with the model behind a `OnceLock` so its coefficient tables are built once rather than per derivative evaluation. ### Two things that changed here as a consequence **This crate's own driver record is gone.** `drivers.rs`, the 1.4 MB generated `space_weather.rs` and `scripts/fetch-space-weather.py` are deleted, and the arc check in `Propagation::run` now asks the atmosphere crate's record instead. The two were the same GFZ download a day apart and that one is a superset — it has the centered flux average, the same Modified Julian Day indexing and a complete driver set per day. With the feature off nothing reads space weather, so nothing is lost. **`Conditions` carries a whole `Geodetic`** rather than a bare altitude, because this model wants latitude and longitude and recomputing them would mean a second iteration of the geodetic conversion. It dropped its separate `body` field, which `Geodetic` already had. ### The part that was not obvious This crate depends on `ordinary-diffeq` and on `nrlmsise00`, and each defined its own `Scalar` trait — structurally near-identical, implemented for exactly the same types, and mutually unsatisfiable, so a caller generic over one could not call code generic over the other. The resolution was that `num-dual` already carries the shared vocabulary. `DualNum<Primitive = f64>` gives the arithmetic, the transcendentals and `From<f64>`; `DualStruct<Real = f64>` gives `re()`; and both are implemented for plain `f64` as well as every dual type. So both crates now inherit those rather than redeclaring them — `ordinary-diffeq` 0.8.0 and `nrlmsise00` 0.2.0 — and a type satisfying one satisfies the other. This crate needed no source change for it: `re` is inherited rather than redeclared, so every call site kept compiling, and `lift` stays as a provided method since `num-dual` has no such name. Worth recording for anyone tempted by the alternative: a newtype adapter, or a fourth crate defining the shared trait. One was written before the check that `num-dual` already had it. ### Measured, in this crate rather than quoted | | per call | |---|---| | Harris-Priester | 11.9 ns | | NRLMSISE-00, `fast` | 957 ns | | `Conditions::new`, the geodetic conversion | 66.8 ns | The whole drag path is therefore about 79 ns with the fixed profile and 1.0 us with NRLMSISE-00, against a degree-70 gravity field at 6.76 us per derivative evaluation. Profiling this is what produced `4ad8bc4`: the geodetic conversion was 181 ns, eight times the density model it exists to feed, because its iteration carried the latitude and so paid an `atan2` per pass that the next pass undid with a `sin`. Carrying the sine and cosine instead took it to 66.8 ns with values unchanged to 1.1e-16 rad. Validated: a 5.7x density swing at 400 km between the 2009 solar minimum and the 2014 maximum, less sensitivity at 200 km, three orders of magnitude from 200 to 800 km, and Harris-Priester landing within a factor of a few at solar maximum, which is the activity it is fitted for. An arc reaching past the measured record is refused before integrating. ### Known limitations - **The model is not continuous in altitude.** Each species' mixed correction is dropped above a fitted height by a test on the altitude alone, so the density steps: eight places between 72.5 and 1000 km, none above 500. In the band that matters here the steps are 5.3e-5 of the total at 300 km, 3.5e-5 at 450, 2.3e-6 at 240 — far below anything a step controller notices, but genuine discontinuities rather than corners, and documented on the variant. Smoothing them would make it a different model. - Harris-Priester, by contrast, is deliberately *not* the published interpolation here, for exactly the differentiability reason. See the earlier comment. - The issue body above is stale where it says the validation fixture is committed in this repository; it moved to the atmosphere crate along with its generator.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: connor/astrokit#11