Skip to content

Repository files navigation

SwissEphNet

A C# port of the Astrodienst Swiss Ephemeris, the astronomical calculation library used to compute planetary positions, house cusps, eclipses and related quantities. It is a line-by-line translation of Astrodienst's C source rather than a reimplementation, so the function names, arguments and return values are the ones in Astrodienst's own programming documentation.

swe_version() reports 2.10.03. The whole 2.10.03 delta has landed: swephlib.c, the ayanamsa and pla_diam tables, the header and constants, the eight crossing functions, the ayanamsha machinery, sweph.c, swecl.c, swehouse.c and swetest.c. Parity with Astrodienst's own reference values is close but not complete; "How the numbers are verified" below says exactly how far, and docs/compliance-2.10.03.md gives the detail.

Targets netstandard2.0, net8.0 and net10.0. No dependencies.

Install

dotnet add package SwissEphSharp

The package ID is SwissEphSharp, not SwissEphNet. The namespace and every type name stay SwissEphNet, so using SwissEphNet; is still what you write. "Package name" below explains why the two differ.

Your first calculation

This computes the Sun's position on 1 January 2020 and prints its ecliptic longitude. It needs no data files: SEFLG_MOSEPH selects the built-in analytic ephemeris, which is computed rather than read from disk. It is the less accurate of the two options, by a margin measured in the next section, and it is the one to start with because it works immediately.

using System.Globalization;
using SwissEphNet;

using var swe = new SwissEph();

// Julian day for 2020-01-01 00:00 UT, Gregorian calendar.
double jd = swe.swe_julday(2020, 1, 1, 0.0, SwissEph.SE_GREG_CAL);

var xx = new double[6];
string serr = "";
int ret = swe.swe_calc_ut(jd, SwissEph.SE_SUN, SwissEph.SEFLG_MOSEPH, xx, ref serr);

if (ret < 0)
    Console.WriteLine($"error: {serr}");
else
    Console.WriteLine("Sun longitude: "
        + xx[0].ToString("F6", CultureInfo.InvariantCulture) + " degrees");

Output:

Sun longitude: 280.009518 degrees

The explicit InvariantCulture is there so the printed value has a decimal point on every machine. The library returns plain double values and never formats anything for you.

xx comes back as longitude, latitude, distance, then the three matching speeds. swe_calc_ut takes Universal Time; swe_calc takes Ephemeris Time if you already have it. A negative return value means failure and serr says why. SwissEph is IDisposable, which is why the example uses using.

For accuracy better than the analytic ephemeris gives, or for bodies it does not cover, you need Astrodienst's data files. That is the next section.

Loading ephemeris files

Point swe_set_ephe_path at a directory holding Astrodienst's .se1 files and drop SEFLG_MOSEPH from the flags:

using var swe = new SwissEph();
swe.swe_set_ephe_path("/path/to/ephe");

var xx = new double[6];
string serr = "";
int ret = swe.swe_calc_ut(jd, SwissEph.SE_SUN, SwissEph.SEFLG_SWIEPH, xx, ref serr);

Files are read straight from the filesystem, the same way the C reference does. Astrodienst publishes them in the Swiss Ephemeris repository itself, at aloistr/swisseph/ephe, with a Dropbox area and a mirror at ephe.scryr.io carrying the same data plus the larger asteroid sets. sepl_18.se1 (planets), semo_18.se1 (Moon) and seas_18.se1 (main asteroids) cover 1800 to 2399 and are enough for most work. Each file holds six centuries starting at the century in its name (NCTIES in sweph.h), so sepl_12.se1 is the previous block, 1200 to 1799. This repository vendors both blocks under external/swisseph/ephe/ if you are building from source.

Run that against the same date as the example above and the Sun comes out at 280.009507 rather than 280.009518, with ret equal to 2 (SEFLG_SWIEPH) and serr empty. The gap is about 1e-5 degrees, which is the accuracy you bought by adding the files.

That difference is also how you check your path is right. When a requested file is missing the library falls back to the analytic ephemeris, notes it in serr, and returns a number that looks perfectly reasonable. So if the value you get back is exactly the SEFLG_MOSEPH one, the files are not being found, whatever the flags say.

Comparing ret against the flag you asked for is the sturdier check, because it is a plain integer: ask for SEFLG_SWIEPH and get SEFLG_MOSEPH back and you have your answer. If you read serr instead, null-check it first. It comes back null from some successful calls and "" from others, so string.IsNullOrEmpty is the test that works for both.

A missing file is diagnosed per six-century block, so the boundaries are sharp. With only sepl_12.se1 and sepl_18.se1 present, 1200 through 2399 resolve through SEFLG_SWIEPH, while 1199 asks for sepl_06.se1 and 2400 asks for sepl_24.se1, and both quietly fall back.

When the data is not a file on disk, an embedded resource being the usual case, implement SwissEph.IEphemerisFileProvider. It is nested inside SwissEph and has one method:

class EmbeddedProvider : SwissEph.IEphemerisFileProvider
{
    public Stream Open(string path)   // return null for "not found"
        => typeof(EmbeddedProvider).Assembly
               .GetManifestResourceStream("MyApp.Ephe." + path);
}

Set it per instance with SwissEph.FileProvider, or set SwissEph.DefaultFileProvider to give every subsequently constructed instance the same one. The library takes ownership of the stream and disposes it, and it seeks while parsing, so the stream must be readable and seekable.

Returning null is part of the contract, but Open's signature is not nullable-annotated, so a project with nullable reference types enabled will report CS8603 on a body like the one above. That warning is expected; the null path is the supported way to say "not found".

Data files are decoded as UTF-8. SwissEph.DefaultEncoding overrides that globally if you have a file in another encoding.

Which API to call: prefer the 2 variants

Several functions have a newer sibling with a 2 in the name, and for new code that is generally what you want. All of them are ported here, old and new.

For fixed stars this is Astrodienst's own published advice, in the programming documentation: "For new projects, we recommend using the new functions swe_fixstar2_ut() and swe_fixstar2(). Performance will be a lot better if a great number of fixed star calculations are done." The same applies to swe_fixstar2_mag over swe_fixstar_mag, and the same document adds that if performance is a problem in an existing project, replacing the old calls with the new ones is the fix.

One porting detail worth knowing if you compare the two families field by field: on non-SEFLG_SPEED rows, swe_fixstar and swe_fixstar_ut fill xx[3..5] where the C reference leaves them at zero, while swe_fixstar2 and swe_fixstar2_ut match the C. See docs/known-issues.md, "The file-backed grid's divergence is Earth's position", for the measurement.

For houses, swe_houses_ex2 and swe_houses_armc_ex2 are new in 2.10.03 and offer two things the older entry points cannot: per-cusp and per-ascmc speed output, and an explicit serr out-parameter rather than a bare return code. Astrodienst publishes no "prefer these" recommendation for them the way it does for the fixed-star pair, so treat them as added capability rather than a replacement: use them when you want speeds or a diagnostic string, and stay on swe_houses/swe_houses_ex otherwise. Note that neither speed output has oracle coverage in this repository yet, because swe_houses_ex forwards to swe_houses_ex2 with both hardcoded NULL; see "What the oracle grids do not cover in the house code" in docs/known-issues.md.

There is no 2 variant of swe_calc, which is why the example above uses swe_calc_ut.

Threads and async

Create one SwissEph instance per thread. A single instance is not safe to share across threads: it holds the calculation state that the C library keeps in its own globals, and nothing synchronises it. Separate instances are independent and can run concurrently.

There is no async API, and nothing here blocks on I/O long enough to want one. File reads happen inside swe_calc and friends, which are synchronous by nature; IEphemerisFileProvider.Open returns a Stream directly for the same reason. Calling from an async method is fine, and wrapping a long sweep in Task.Run is the usual way to keep it off a UI thread.

Examples in this repository

Three runnable programs, all against the library as it is built here:

  • Programs/SweMini is the smallest useful example, a direct port of Astrodienst's swemini.c.
  • Programs/SweTest is the full command-line tool, a port of swetest.c, and the most complete demonstration of the API.
  • Programs/SweWin is a Windows Forms front end (Windows only).

Tests/SwissEphNet.Tests is also worth reading as a source of small, self-contained calls.

License

Swiss Ephemeris, and therefore this library, is dual-licensed. You must choose one of:

  • AGPL-3.0 (GNU Affero General Public License) - free, but with a network clause: if you run a modified or unmodified version of this library as part of a service that users interact with over a network (a web app, an API, a SaaS product, etc.), the AGPL requires you to offer those users the complete corresponding source code of your whole service, not just this library. This reaches server-side and SaaS use even when you never distribute a binary to anyone - it is triggered by operating the service, not by shipping a copy. If that obligation does not work for your project, AGPL is not the option for you.
  • Swiss Ephemeris Professional License - a commercial license purchased from Astrodienst that does not carry the AGPL's source-disclosure obligation. Contact Astrodienst directly to obtain one.

The package ships all three of these files at its own root, so the authoritative copies travel with whatever version you installed: LICENSE for the full license conditions, agpl-3.0.txt for the AGPL text, and NOTICE for attribution. To read them in the repository instead: LICENSE, agpl-3.0.txt, NOTICE. These links are absolute rather than relative because this section also renders on nuget.org's package page, where a relative link to a file in this repository does not resolve, and they name release/2.10.03 rather than main because main is still the state inherited from ygrenier/SwissEphNet, before this project's own changes: it carries no NOTICE and no agpl-3.0.txt at all, and its LICENSE is the 1997-2008 pre-relicense text rather than the dual AGPL one this library is offered under.

Project history, and who wrote what

This repository, https://github.com/Tim81/SwissEphNet, continues the C# port of Swiss Ephemeris that Yan Grenier originally wrote (2014-2019). Since 2026 it has been maintained by Timothy van der Ham, who has modernized the build and target frameworks (netstandard2.0, net8.0, net10.0) and fixed a number of bugs in the port: the fixed-star search returning the wrong star, multi-word star names being unfindable, the heliacal Moon branch never being taken, swe_set_astro_models throwing, the DIR_GLUE path separator, culture-sensitive string comparison, and a netstandard2.0 infinite recursion. See NOTICE and the package release notes for details.

Package name

This project carries three names, and meeting them separately can look like something is broken. It isn't:

  • The repository is Tim81/SwissEphNet, continuing ygrenier/SwissEphNet, and keeps that name.
  • The NuGet package ID is SwissEphSharp. The SwissEphNet ID on nuget.org already belongs to the upstream author's own release, so this project cannot publish under it.
  • The assembly is now SwissEphSharp.dll, matching the package ID, so this package and the original SwissEphNet package can be referenced together without one silently displacing the other; see the "V:2.10.3" section below for what that collision used to look like.
  • The namespace stays SwissEphNet. Every file under SwissEphNet/CPort/ is a line-by-line transliteration of the Swiss Ephemeris C source and declares that namespace; renaming it would touch every one of those frozen files for a cosmetic reason. Keeping it also means the library stays source-compatible with code written against the original namespace.

Working from this repository rather than the published package also works: build from source or reference SwissEphNet/SwissEphNet.csproj directly (see the versioning note in SwissEphNet.csproj). Migrating from the old package is mostly the one line it sounds like: replace the PackageReference for SwissEphNet with one for SwissEphSharp. using SwissEphNet; and every type name are unaffected, so source that only calls the public API needs no other change. Anything that calls Assembly.Load("SwissEphNet") by literal string, carries a binding redirect naming SwissEphNet, or otherwise hardcodes the DLL filename needs to be updated to SwissEphSharp too.

Version numbers

The package version tracks upstream, with a fourth component for releases this project makes on its own. 2.10.3 tracks Swiss Ephemeris C 2.10.03; a port-only release while upstream stays put would be 2.10.3.1, then 2.10.3.2, and the fourth component resets whenever upstream moves.

Two things about that are worth knowing rather than discovering. NuGet normalizes a trailing zero away, so 2.10.3.0 and 2.10.3 are the same package and writing the .0 achieves nothing. And four components are not valid SemVer 2.0, which has exactly three; NuGet accepts them for backwards compatibility. That is a deliberate choice, because the two obvious alternatives are worse. Build metadata (2.10.3+rev.1) is ignored by NuGet when it compares versions, so a second package differing only there cannot be published at all. A prerelease suffix (2.10.3-rev1) sorts before 2.10.3, which is backwards for a release that comes after it.

The assembly's InformationalVersion is plain SemVer, and the SDK appends the commit SHA to it as build metadata. The upstream version and tag it used to spell out are now AssemblyMetadata entries, UpstreamSwissEphemerisVersion and UpstreamSwissEphemerisTag, which is where text that does not have to parse as a version belongs.

This project is not published or endorsed by Yan Grenier or Astrodienst. See "About this repository" above and NOTICE for the credit both are owed.

Upgrading from 2.8.0.2

This section is for anyone with SwissEphNet 2.8.0.2 in a project, deciding whether to move to SwissEphSharp 2.10.3. Five questions, in the order they matter: can you still use it under your license, will your numbers change, will your code still compile, what do you gain, and can you trust the answers you get.

Can you still use it under your license?

Check this one first, because for some projects it is the answer and the rest does not matter.

The license changed, and not in a direction that suits everyone. 2.8.0.2 was GPL-2.0-or-later. This release is AGPL-3.0, or a Swiss Ephemeris Professional License bought from Astrodienst. The practical difference is the AGPL's network clause. Under GPL-2.0 you could run 2.8.0.2 inside a web service and owed nobody source, because you were not distributing anything. Under AGPL-3.0 operating the service is itself the trigger: users who interact with it over a network can require the complete corresponding source of your whole service, not just this library.

So if you are running 2.8.0.2 server-side today and cannot publish your source, upgrading is a licensing decision before it is a technical one, and the Professional License is the route that keeps it available to you. See "License" above for both options in full.

This is not a change this project chose. It follows Astrodienst's own relicensing of Swiss Ephemeris, and it applies to the C library this port tracks just as much as to the port.

Will your numbers change?

For most calls, yes. Two different things are mixed together in this list: places where Astrodienst changed the reference model between 2.08 and 2.10.03, and places where this port's own arithmetic was wrong and is fixed now. Both move your output. Only one of them is upstream's doing, and it matters which.

The largest change in the release, and a port defect rather than an upstream one: every SEFLG_SWIEPH position changes. rot_back reads a J2000 obliquity, swed.oec2000, that nothing in the port ever populated, so it came out zero on every call, and every position rotated back through it used the wrong obliquity. That covers any swe_calc/swe_calc_ut call reading the Swiss Ephemeris files rather than falling back to Moshier's analytic approximation. Fixing it moved the file-backed comparison against Astrodienst's own C from 791 of 2,024 bit-identical rows to 1,975, as grid-files.tsv stood at the time of that fix (before the crossing functions added 220 more rows to it; see "Bit-exact oracle" below for the grid's current, marked total). 2.08 and 2.10.03 compute rot_back the same way, so this was never a version-tracking gap; it was wrong the whole time.

Also a port defect, not an upstream change: swe_nod_aps and swe_nod_aps_ut returned all-zero nodes and apsides under SEFLG_SIDEREAL for every standard ayanamsha. A missing call to swi_cartpol_sp left the ecliptic cartesian coordinates zeroed before the ayanamsha was applied. Fixed for every ayanamsha except SE_SIDBIT_ECL_T0 and SE_SIDBIT_SSY_PLANE, which took a different path and were already correct.

One more port defect, present since long before 2.10.03: swe_cs2lonlatstr transposed its hemisphere letter and its degrees units digit on every call. swe_cs2lonlatstr(1234567, 'p', 'm') returned "p325'46"; the correct string, which the C has always produced, is "3p25'46".

Now the model changes, which are upstream's doing and not a bug being fixed. eclipse_how's attr[0] and attr[2] are fractions now, not percentages (swecl.c:1067-1087): an eclipse that used to report 100 reports 1. Multiply by 100 if your code expects a percentage, and recheck any threshold comparison written against a number above 1. swe_pheno and swe_pheno_ut compute Moon and Mercury-through-Neptune magnitudes with the Mallama 2018 model instead of Hilton 2005; apparent diameter and phase angle are unaffected. House cusps move at every latitude for Placidus and Gauquelin, because 2.10.03 iterates CalcH's pole-height calculation to convergence (niter_max = 100) where 2.08 always stopped after exactly two iterations; the old and new cusps only agree where two iterations happened to already converge.

A further set of smaller numeric shifts, some upstream's and some this project's, is itemized with exact C line citations under "Breaking changes" below: the swe_refrac_extended/calc_dip predicate and constant fix, the swe_lun_eclipse_when search-precision threshold, fixed stars being routed onto swe_rise_trans's slower path, and a handful of serr messages that now report a failure the port used to swallow silently. That section is the reference; this one is the summary an upgrade decision needs.

Will your code still compile?

Mostly. A handful of things need a one-line fix.

OnLoadFile is gone. It is replaced by SwissEph.FileProvider, a settable IEphemerisFileProvider with one method, Stream Open(string path), where null means "not found". If your handler just opened a real file by path, delete it: the library now reads straight off the filesystem whenever swe_set_ephe_path points at a real, populated directory, the same way the C reference does. That is new, and it closes a real defect: previously, having no OnLoadFile subscriber meant every ephemeris file silently failed to load and every calculation quietly fell back to Moshier, even with a correctly configured ephemeris path. Write an IEphemerisFileProvider only when your source is not a file on disk at all, an embedded resource being the usual case.

swe_houses, swe_houses_ex, swe_houses_armc, swe_house_pos, and swe_house_name each gained an int hsys overload alongside the existing char hsys one, matching what swephexp.h has always declared. Binary compatibility holds: anything already compiled against this library keeps working unchanged. Two source-level cases do break. Type.GetMethod("swe_house_name") and any other name-only reflection lookup now throws AmbiguousMatchException, because there are two overloads where there used to be one; pass an explicit parameter-type array. var f = swe.swe_house_name; no longer compiles under C# 10 and later: the compiler cannot pick an overload and reports CS8917; declare an explicit delegate type instead.

Dispose() now actually disposes. Before, it called swe_close() and stopped there: nothing marked the instance as disposed, so a call made after Dispose() silently reopened the ephemeris files and returned a correct answer. It now throws ObjectDisposedException instead. If your code used a SwissEph instance after disposing it, that was already a bug; it surfaces now rather than staying hidden.

PATH_SEPARATOR widened from char to char[]. The value is unchanged ({ ';' }); code that reads it as a single char needs to index [0] instead.

StringExtensions and TypeExtensions are internal now. Both were public, so using SwissEphNet; put their methods in scope on every string and every Type in your file: Contains(char), Contains(char[]), IndexOfFirstNot, two Substr overloads, GetTypeCode and GetAssembly. If you called any of them, they are gone. Substr clamps instead of throwing and has no direct replacement; the rest map onto BCL methods (string.Contains, Type.GetTypeCode(t), t.Assembly).

GetTypeCode carries a second, separate break beyond going internal: its return type changed. 2.8.0.2 shipped netstandard1.0 and net40. netstandard1.0 has no System.TypeCode at all, so TypeExtensions.GetTypeCode was conditionally compiled against a public SwissEphNet.TypeCode enum declared in that same file as a polyfill, and that enum, not System.TypeCode, was GetTypeCode's return type on the netstandard1.0 asset. Both netstandard1.0 and the polyfill enum are gone from this release, and nothing under SwissEphNet declares a TypeCode type any more. If your code named SwissEphNet.TypeCode explicitly (a field, a variable declaration, a using static), that type no longer exists and there is no drop-in replacement of the same name: Type.GetTypeCode(t) returns System.TypeCode, a different type, though one with the same enumeration values (Empty, Object, Boolean, ... String), so a straight rename is enough for most callers.

Only one of the seven actually misbehaved, and it is worth saying so plainly rather than implying the whole set was dangerous. Contains(this string, char) is the one overload the BCL lacks on netstandard2.0 and .NET Framework, so on those targets a consumer's own unrelated call bound to this method instead of the BCL's, and ((string)null).Contains('x') returned false where the same source on net8.0/net10.0 threw. It also broke the build outright for anyone who already had their own Contains(this string, char) helper, which is the ordinary way to get that method on .NET Framework: CS0121, the moment the package is referenced. Both were reproduced against the shipped assets before the change.

The other six went internal with it as a scoping decision, not because each was harmful. Narrower fixes existed: making only that overload internal, or moving the class to a namespace that using SwissEphNet; does not pull in. The broader change was taken because 2.10.3 is the first release published to nuget.org, so this is the moment the public surface is fixed, and a package's surface is easier to widen later than to narrow. If you were relying on one of the other six, that is a reasonable thing to raise: they can be made public again without disturbing the overload that caused the problem.

ArrayExtensions.GetPointer stays public, deliberately, because it is how you build the CPointer<double> that swe_houses_ex, swe_houses_ex2 and swe_cotrans take.

Your target framework may no longer be supported at all. 2.8.0.2 shipped net40 and netstandard1.0; this release ships netstandard2.0, net8.0 and net10.0. A project on net40, or on any framework that only satisfies netstandard1.0, cannot take this package. .NET Framework 4.6.1 and later resolve netstandard2.0 and can, with the accuracy caveat under "Numerical compatibility" below. Note also that netstandard2.0 is on its way out: see the deprecation entry under "Breaking changes".

One more thing worth flagging here even though it is not a source-level break like the five above: the NuGet package ID is SwissEphSharp, not SwissEphNet, while the namespace and every type name stay SwissEphNet. See "Package name" above for the full picture and what migrating from the original SwissEphNet package involves.

What you gain

Beyond fewer wrong numbers, the 2.10.3 API surface adds functionality the 2.8.0.2-era port simply did not have:

  • swe_houses_ex2 and swe_houses_armc_ex2: house calculations with per-cusp speed output and an explicit serr parameter.
  • swe_calc_pctr: planetocentric position, one body as seen from another.
  • swe_get_current_file_data: reports which ephemeris file is currently open and the time range it covers.
  • Eight crossing functions: swe_solcross/_ut, swe_mooncross/_ut, swe_mooncross_node/_ut, and swe_helio_cross/_ut. None of these existed in the 2.08-based port at all.
  • House system 'J' (Savard-A).
  • Planetary-moon and centre-of-body support (SEFLG_CENTER_BODY, SEFLG_TEST_PLMOON), reaching bodies numbered 9000 and up. This needs the ephe/sat/ data set, which this repository does not ship.
  • SEFLG_TROPICAL, SE_ECL_HYBRID, and three SE_SIDBIT_* constants (SE_SIDBIT_ECL_DATE, SE_SIDBIT_NO_PREC_OFFSET, SE_SIDBIT_PREC_ORIG).

None of this replaces existing API. It is purely additive.

With those additions the port now covers the C library's whole public surface. swephexp.h at v2.10.3bfinal declares 106 swe_* functions, and all 106 are present and public here. The count is not an estimate: it comes from parsing the header and diffing it against the committed public-API list under Tests/SwissEphNet.Tests/PublicApi/, which a test regenerates and compares on every run, so a member appearing or disappearing fails the build rather than going unnoticed. The only public swe_* member the C does not declare is swe_dotnet_version, which is this port's own and named so it cannot be mistaken for upstream.

Surface coverage is not the same as behavioural agreement, and the two are measured separately. What the port does with that surface is the subject of "Correctness oracle" below, which runs Astrodienst's own 12,757-iteration reference corpus. Read both before concluding anything: a function can be present and still disagree, and the oracle is what says whether it does.

What was actually fixed, and how we know

Model changes aside, several defects in this list are bugs a caller could hit in ordinary use, some dating back to the original 2014-2019 port.

The fixed-star cache mixed up which star it had cached. swe_fixstar, swe_fixstar_mag, swe_fixstar2, and swe_fixstar2_mag each declare their own function-local cache in the C; the port had collapsed all four into three shared fields, so calling one entry point for one star could return a different, previously-cached star's position under another entry point. Each function now keeps its own cache.

Star and heliacal lookups broke under a Turkish locale. ToLower() on "JUPITER" produces "jupıter" (dotless i) under tr-TR, so a name match against "jupiter" silently failed, and swe_vis_limit_mag's Moon special case never matched a capitalized "Moon" either. Both now lowercase ASCII-only, matching the C's own loop.

Programs/SweTest crashed on any longitude reaching 100 degrees or more. Its degree formatter was one character narrower than the C's own field width, so the routine that splices in a minus sign wrote one byte before its own buffer and threw. Fixed by restoring the leading space the C's format string has.

Thirteen call sites threw where the C, using atoi/atof, returns zero for input it cannot parse. One of them made swe_heliacal_ut throw a FormatException for any non-numeric object name. Another turned a data file's own header line into an unhandled exception, because the C uses atoi's zero return as its own signal to skip that line.

swe_house_pos threw IndexOutOfRangeException on every Gauquelin-sector (hsys = 'G') call: its internal cusp buffer was one element short of what upstream's own 2.10.03 array-size fix requires.

How this is checked, and what checking it does and does not prove. The port's output is compared field by field against Astrodienst's own C, built from the same source and run against the same ephemeris files. On Windows (MSVC), Linux (gcc) and macOS (clang, -fno-builtin), all 25,569 rows in that comparison (22,289 calls that need no ephemeris file plus 3,280 that read the shipped .se1 files) come back bit-identical, not merely close (gated in CI on all three platforms; see the table below for what has and has not been independently reproduced outside CI on this workstation); all three tracked difference lists (0 recorded exceptions each) are empty. A GET_CURRENT_FILE_DATA row records only the basename of the file it opened, deliberately, not the full path; see docs/compliance-2.10.03.md's "The last two 2.10.03-only entry points" section for why, and for the six SERR rows (five in the files grid, one in the JPL grid) that briefly existed before that scoping decision. macOS's own math builtins had to be told not to substitute for individual libm calls (-fno-builtin) before it matched. Clang's default behavior otherwise fuses adjacent libm calls (e.g. sin/cos into __sincos) in a way that does not return bit-identically to calling them separately; unlike Windows and Linux, macOS cannot be measured outside its own CI runner, so this section does not claim a current macOS number. None of that proves agreement between platforms: comparing the port's own frozen output, generated on Windows and on Linux from the same commit, found 66,342 of 3,547,367 compared fields differing at all, 5,394 of them beyond the shipped tolerance, as measured at commit 5148573. Tests/baseline/ has grown since (more rows in several areas, plus value churn in others), so this triple no longer describes the current matrix and needs a re-measure; see Tools/BaselineGen/README.md's "Platform lock" section, the source for this figure. That divergence is each platform's own math library disagreeing with itself in the last few bits, the same thing two independently built C programs would show; it is not evidence against the port.

Separately, the port's output is checked against Astrodienst's own 2.10.03 test suite (setest), 12,757 iterations across ten functional areas. 1,423 of those still fail: 664 because the answer is outside the tolerance Astrodienst's own suite allows, and 759 because a required data file (a JPL ephemeris, a per-asteroid or ephe/sat/ file, or an ephemeris era this repository does not ship, roughly years 1200 to 2399) is not present, not because the answer is wrong. Tests/conformance/value-mismatch-triage.tsv drove Astrodienst's own MSVC-built 2.10.03 C through the identical inputs for all 668 VALUE-MISMATCH rows at the time it ran, and found 4 confirmed port defects, all a missing JD-range guard on interpolated lunar perigee (docs/compliance-2.10.03.md section 3a). That guard is fixed now and the four rows are pruned, so of the 664 remaining, none is a port defect anyone has demonstrated: each reproduces the port's own output rather than the reference corpus's, i.e. drift between this build's toolchain/environment and whatever produced setest's reference values, not a wrong answer. So the port matches everything it has been checked against and is not yet at full parity with Astrodienst's own reference corpus.

Breaking changes

V:2.10.3

swe_houses, swe_houses_ex, swe_houses_armc, swe_house_pos, and swe_house_name each gained an int hsys overload alongside the existing char hsys overload, to match upstream swephexp.h, which has always declared hsys as int. Binary compatibility is preserved: existing compiled consumers (anything built against a prior version of this library) keep working unchanged, since the original char overloads are still present with the same signatures.

Source-level and reflection-based consumers can be affected:

  • Reflection by name (Type.GetMethod("swe_house_name") with no parameter-type array, or any binder that resolves by name alone; this affects Python.NET, some PowerShell cmdlet-binding paths, and dependency-injection or serializer conventions that enumerate methods by name) now throws AmbiguousMatchException for these five methods, because there are two overloads where there used to be one. Pass an explicit parameter-type array to GetMethod (or the equivalent for your binder) to select the overload you want.
  • var f = swe.swe_house_name; (or any bare method-group assignment to var for one of the five widened methods) no longer compiles under C# 10+ natural-type inference: the compiler cannot pick between the two overloads and reports CS8917. Declare an explicit delegate type instead, e.g. Func<char, string> f = swe.swe_house_name;.
  • A char above U+00FF passed to swe_houses, swe_houses_ex or swe_houses_armc now takes its low byte when resolving the house system, matching what a genuine 8-bit C char would resolve to, rather than being widened untruncated as before: measured, (char)331 (low byte 0x4B = 'K') resolved to Placidus before and resolves to Koch now. swe_house_pos changes the same way in its internal cusp computation, but its own house-system dispatch compares the raw value and is unchanged, and swe_house_name never narrows at all; its behaviour is identical before and after. This only affects callers passing a char outside the Latin-1 range, which was never a valid house-system letter either way; see docs/known-issues.md for the measured before/after.
  • swe_house_pos with hsys = 'G' (Gauquelin sectors) no longer throws IndexOutOfRangeException: an internal cusp array was undersized relative to upstream 2.10.03. If your code wraps that call in a guard specifically to catch this exception, that guard is now dead code and can be removed.
  • swe_lun_occult_when_glob and swe_lun_occult_when_loc each gained an Int32 backward overload. swephexp.h declares that parameter int32, and it is a bitfield: bit 0 selects the search direction, while SE_ECL_ONE_TRY (32768) limits the search to a single lunar cycle (swecl.c:1539 and :1593; the local variant masks it at swecl.c:2436). This port offered only a bool backward signature, which can carry 0 or 1 and nothing else, so backward & SE_ECL_ONE_TRY was always 0 and the flag could not be requested through the public API at all. The bool overload keeps its exact signature and is still there, so existing source and existing compiled binaries bind the way they always did; it just cannot reach SE_ECL_ONE_TRY, and the new Int32 overload can. The break is resolution by name: Type.GetMethod("swe_lun_occult_when_glob") with no parameter-type array, and any other binder that matches on name alone, now throws AmbiguousMatchException for these two methods, because each carries two overloads where it carried one. Pass an explicit parameter-type array to select the one you want.
  • Eclipse magnitude and obscuration are a hundred times smaller. attr[0] and attr[2] from swe_sol_eclipse_how, and from the attr array swe_sol_eclipse_where and swe_lun_occult_where fill, are now fractions rather than percentages: an eclipse that reported 100 reports 1. This is upstream's change at swecl.c:1067-1087, not this port's, and it is silent: the call still succeeds, the array is still the same length, and only the value moves. Any caller formatting these as a percentage needs to multiply by 100, and any threshold comparison against a number above 1 will now never fire. attr[1] and attr[3] onward are unaffected.
  • Planetary magnitudes changed. swe_pheno and swe_pheno_ut return a different attr[4] for the Moon and for Mercury through Neptune. Upstream replaced the Hilton 2005 model with Mallama 2018, and added a separate lunar model that switches formula past a phase angle of 147.1385465 degrees. Not an API change and not a defect fix on this side: the numbers differ because the underlying model does. Apparent diameter and phase angle for these bodies are unaffected by the magnitude-model swap. Tests/SwissEphNet.Tests/PlaDiamCoverageTest.cs is not evidence for that claim: it covers a different, unrelated change, the updated pla_diam[] table moving attr[3] for six minor bodies this bullet is not about (Chiron, Pholus, Ceres, Pallas, Juno and Vesta), and it asserts only attr[3], leaving phase angle and the rest of attr untouched.
  • swe_rise_trans_true_hor gains a horhgt == -100 sentinel. Passing exactly -100 now means "use the dip of the horizon", computed from calc_dip, instead of a literal horizon height of -100 degrees (swecl.c:4415; ported at SweCL.cs:4507). Absent from 2.08, so a caller that happened to pass -100 before now gets different rise/set times.
  • swe_lun_eclipse_when's search-precision threshold moved from 2000000 to 2100000 (swecl.c:3485, ported at SweCL.cs:3548), changing which Julian day range gets the coarser 5-day search step versus the finer 0.1-day one. The sibling function swe_sol_eclipse_when_glob deliberately keeps its own threshold at 2000000. Upstream did not move both.
  • House cusps move at every latitude for Placidus and Gauquelin. CalcH (swehouse.c) now iterates to convergence (niter_max = 100) where 2.08 always called it with iteration_count = 2 fixed. The two now agree only where two iterations happened to already converge.
  • swe_rise_trans routes fixed stars to the slow path. swecl.c:4362-4376 gates the fast algorithm on !do_fixstar; a fixed-star call now always falls through to swe_rise_trans_true_hor rather than the fast approximation, so star rise and set times change.
  • Every SEFLG_SWIEPH position changes. rot_back's J2000 obliquity was always zero in this port (it read swed.oec2000, which nothing ever populated), so every position rotated back through it used the wrong obliquity. Fixed alongside the rest of sweph.c's file layer; the file-backed oracle grid went from 791 of 2,024 bit-identical to 1,975, as grid-files.tsv stood at the time (220 crossing-function rows were added to it later; see "Bit-exact oracle" below for the grid's current, marked total). Probably the largest numeric change in this release.
  • swe_nod_aps/swe_nod_aps_ut returned all-zero nodes and apsides for every standard ayanamsha. A missing swi_cartpol_sp call on the sidereal branch (swecl.c:5587) left the ecliptic cartesian coordinates zeroed before the ayanamsha was applied. Fixed for every ayanamsha except SE_SIDBIT_ECL_T0 and SE_SIDBIT_SSY_PLANE, which were already correct.
  • serr is now populated at roughly twenty sites where it was silently empty before, a guard-inversion bug (serr != NULL transliterated from C, where it means "caller supplied a buffer", into a check for "a message is already present" against a C# ref string that always supplies one). Any caller using String.IsNullOrEmpty(serr) as a success signal will see previously-silent failures start reporting a message.
  • swe_get_ayanamsa_ex with no prior swe_set_sid_mode changes value, from 92.525 to 24.754 degrees: swi_get_ayanamsa_ex took its sid_data copy before the SE_SIDM_FAGAN_BRADLEY fallback ran, so it read pre-fallback state.
  • swe_nod_aps after swe_close, under a sidereal or geocentric mode, changes value: 344.63 degrees becomes 189.21 for the Moon's node at J2000. Two defects were cancelling each other out: free_planets replaced an object instead of zeroing it in place, and a separate != Sweph.B1950 mask (should be != 0, swecl.c:5414) made the geocentric correction unreachable, so the stale object's coincidentally-zero values had been masking the first bug.
  • swe_set_astro_models("") or (null) changes value, from AMODELS_SE_1_00 to AMODELS_SE_2_06: the version-string parser did not match strtod's "longest parseable prefix" behavior, so "2.10.03" failed to parse at all and fell through to the last branch.
  • swe_refrac_extended and calc_dip change value. swe_refrac_extended's visibility test flips from trualt > dip to inalt >= dip (upstream's own 4 Feb 2020 fix, swecl.c:3070-3113), and calc_dip corrects a constant from 273.16 to 273.15 (swecl.c:3159-3168).
  • New additive API surface, absent from 2.08 and now present: swe_houses_ex2, swe_houses_armc_ex2, swe_calc_pctr, swe_get_current_file_data, the SEFLG_TROPICAL, SEFLG_CENTER_BODY and SEFLG_TEST_PLMOON flags, SE_ECL_HYBRID, and three SE_SIDBIT_* constants (SE_SIDBIT_ECL_DATE, SE_SIDBIT_NO_PREC_OFFSET, SE_SIDBIT_PREC_ORIG).
  • SweTest CLI: options that previously threw (-ay, -sidt0, -sidsp, -sid, -j, -helflag, -amod, -tidacc) now parse instead of crashing on C pointer-arithmetic transliterated as string concatenation. -house and -utc no longer crash. dms() no longer throws ArgumentOutOfRangeException once a degree value reaches 100 or more.
  • OnLoadFile is gone; ephemeris files are read from disk by default. The event (and LoadFileEventArgs) is replaced by SwissEph.FileProvider, a settable IEphemerisFileProvider (Stream Open(string path), null meaning "not found"). A caller that never subscribed to OnLoadFile used to get every ephemeris file reported as missing and every calculation silently downgraded to Moshier, even with a real, populated ephemeris directory configured via swe_set_ephe_path; now that every target framework this library ships (netstandard2.0, net8.0, net10.0) has full filesystem access, no FileProvider set means the real filesystem is used, the same way the C reference itself behaves. Most existing OnLoadFile handlers that just opened a real file by path can be deleted outright: swe_set_ephe_path alone is now sufficient. A handler whose source genuinely is not a file (an embedded resource, for instance) should be rewritten against the new interface. SwissEph.PATH_SEPARATOR also widens from char to char[] (still { ';' }) to support this; see docs/known-issues.md's OnLoadFile entry for the full detail and the DefaultFileProvider static escape hatch for harnesses that construct many instances.
  • DIR_GLUE is / on every platform, so one diagnostic message differs by one character on Windows. SwissEph.DIR_GLUE (SwissEph.sweodef.h.cs) is '/' unconditionally, where the C picks a separator per platform (sweodef.h:304 gives "/", :319 gives "\\" under MSDOS). Both values open the file -- Windows accepts either separator -- so no numeric result is affected. What differs is text: the "SwissEph file '%s' not found in PATH '%s'" warning (sweph.c:2400) embeds the joined path, so on Windows this port reports '[ephe]/' where a Windows-built C reports '[ephe]\'. This is already visible in 11 rows of Tests/swetest/known-diff.tsv. It matters to two kinds of caller: anything that string-matches on that serr text, and any IEphemerisFileProvider that pattern-matches the separator in a path handed to Open. Asteroid file names reach a provider as "ast4/se04179.se1" rather than "ast4\se04179.se1"; split on both separators rather than on a literal backslash (Path.GetFileName does not treat \ as a separator off Windows). Keeping / everywhere is deliberate -- it is what makes a generated asteroid path usable on Linux, macOS, Android, iOS and WASM, where a backslash is an ordinary filename character -- and reverting it to a per-platform value would itself break providers written against the current behaviour. See docs/known-issues.md's "Three file-layer divergences" entry.
  • The assembly is now named SwissEphSharp, not SwissEphNet. The package ID was already SwissEphSharp; now the DLL matches it, and the namespace stays SwissEphNet, so source that only calls the public API needs no change beyond the PackageReference itself. This closes a collision: while the assembly was still named SwissEphNet, this package and the original SwissEphNet 2.8.0.2 both produced bin/SwissEphNet.dll. Referencing both in one dependency graph built cleanly (no MSB3277, no NU1605) and whichever copy's build step ran last silently overwrote the other in the output folder. A consumer with a transitive dependency on the original package, still calling the removed OnLoadFile/LoadFileEventArgs API, crashed at run time the moment the newer assembly won that silent overwrite: System.TypeLoadException: Could not load type 'SwissEphNet.LoadFileEventArgs' from assembly SwissEphNet, Version=2.10.3.0. With the rename, bin now holds both SwissEphNet.dll (2.8.0.2) and SwissEphSharp.dll (2.10.3.0), and both work: the two packages can coexist in one dependency graph instead of one silently displacing the other. Anything that calls Assembly.Load("SwissEphNet") by literal string, carries a binding redirect naming SwissEphNet, or otherwise hardcodes the DLL filename needs to be updated to SwissEphSharp; anything that only references the package and writes using SwissEphNet; does not.
  • SE_EPHE_PATH, the environment variable, is honored again, and takes priority over swe_set_ephe_path. sweph.c:1327 checks it before anything else and only reaches the argument passed to swe_set_ephe_path in an else if; that block existed in this port but was commented out (Sweph.cs:1561-1573), so setting the variable had no effect. It is restored faithfully, priority included: if SE_EPHE_PATH is set in the process environment, it wins over whatever path a caller passes to swe_set_ephe_path, matching the C exactly. This is a behavior change, not only a bug fix, and it can surprise a caller who has that variable set for an unrelated Swiss Ephemeris install on the same machine, because their explicit swe_set_ephe_path call is now silently overridden by it.
  • The default ephemeris path swe_set_ephe_path falls back to is now upstream's, not the "[ephe]" placeholder, but SwissEph.SE_EPHE_PATH itself is unchanged. Before this release, nothing in the library ever detected the "[ephe]" placeholder SwissEph.SE_EPHE_PATH held; it was meant to be recognized while OnLoadFile intercepted every file read, and with OnLoadFile gone and a null FileProvider reading the real filesystem by default, it had become a non-existent relative directory that leaked into user-facing error text. Fixing the actual default could not touch the public constant's value: SwissEph.SE_EPHE_PATH is const, which the C# compiler inlines into every caller at that caller's own compile time rather than looking it up at run time, so changing the literal would silently desync anything already compiled against "[ephe]" from what this library now does. Binary-breaking in a way a version bump does not fix. SwissEph.SE_EPHE_PATH therefore keeps its "[ephe]" value exactly as before; code that reads it directly still sees that placeholder. The real default is resolved internally instead, at every point swe_set_ephe_path and the library's own initialization previously read the constant, chosen at run time rather than compile time because this port ships one assembly for Windows, Linux and macOS rather than compiling per platform: upstream's own \sweph\ephe\ on Windows, .:/users/ephe2/:/users/ephe/ everywhere else, matching the C's own #if MSDOS branch (swephexp.h:399-408), which upstream also takes for ordinary Win32/Win64 builds, not only legacy MS-DOS ones (sweodef.h:96-98). This only affects callers who pass null or an empty string to swe_set_ephe_path, or never call it at all: a non-blank argument has always won. On Windows, the resolved value is \sweph\ephe\/ rather than the C's own \sweph\ephe\: a redundant trailing / after the literal backslash, because this port's own DIR_GLUE is always /. Cosmetic -- Windows accepts both separators in a path, so this is not a functional difference.
  • IEphemerisFileProvider.Open(string path) receives a different path as a result, for any caller that never calls swe_set_ephe_path. It used to begin with the "[ephe]" sentinel (the bullet above); it now begins with upstream's real default for the running OS. A provider that matched the old prefix by equality (path == "[ephe]/sefstars.txt", say) now gets a path starting with \sweph\ephe\ on Windows instead, the equality check fails, Open returns null, and every ephemeris file this library asks for appears missing. That is exactly what broke eight of this project's own tests the moment the default changed, across SwissEphTest.cs, SwissEphTest.Date.cs, SwissEphTest.swe_fixstar.cs and Issue18Test.cs. The strongest evidence that a real consumer's provider hits the same failure. Two fixes, either is sufficient: match on the trailing filename instead of the full path, or call swe_set_ephe_path explicitly so the prefix is one your own code chose rather than upstream's OS default. Watch for a related trap while doing either: paths are mixed-separator (e.g. Z:\some\dir/sedeltat.txt) because this port's own DIR_GLUE join is always / regardless of what separator convention the caller's configured path used, so a provider that splits the filename off by looking only for \ breaks on that join.
  • 2.10.3 is the only release that ships netstandard2.0. Not the last of several: the one. 2.8.0.2 shipped net40 and netstandard1.0 and never carried netstandard2.0 at all, and releases after this one will require net8.0 or later. So the window in which this library is reachable from .NET Framework is this single version, and anyone who needs it should pin to it deliberately rather than expect it to persist. Consumers on .NET Framework 4.6.1+ can take 2.10.3 as-is: the netstandard2.0 asset is in this release and works. The reason it goes away is measured, not a preference: netstandard2.0 is a compatibility target, not a correctness one, and bit-exactness against Astrodienst's C (see "Numerical compatibility" below) is claimed for net8.0 and later only. This is now a committed, gated instrument (scripts/verify-netstandard-compat.ps1, Tools/NetStandardCompat/, Tests/netstandard-compat/known-diff-*.tsv) rather than an ad hoc, unreproducible measurement, and it replaces an earlier note's numbers, which counted fewer differing calls and named a smaller worst-case relative error than a swept grid finds: running the same netstandard2.0 asset's swe_calc over a committed 102-call grid (34 bodies -- SE_SUN..SE_EARTH plus every fictitious-body constant swephexp.h defines, SE_CUPIDO..SE_WALDEMATH, crossed with 3 epochs, SEFLG_MOSEPH|SEFLG_SPEED), .NET Framework 4.8 and 4.6.2 both differ from .NET 10 on the identical 29 of those rows (byte- identical between the two Framework versions), not the earlier note's "21 of 111". The worst divergence by relative error is not SE_TRUE_NODE's longitude speed either: it is SE_ADMETOS (a fictitious body)'s latitude speed, 2.28e-3 relative. Read that figure with its magnitude attached, though, because on its own it invites a conclusion the numbers do not support. That latitude speed is itself about -6.1e-06 degrees per day, so a 2.28e-3 relative divergence is an absolute one of 1.4e-08 degrees per day: the relative error is large only because the quantity it divides by is nearly zero. Separating the two kinds of field makes the picture plain. Across the whole grid the positions (longitude, latitude and distance, the values a chart actually renders) agree to within 1.08e-10 relative, worst case: NSC|13|2488069.5's longitude, an absolute divergence of 3.9e-10 degrees (1.4e-06 arcseconds). That is a different row from the largest absolute position divergence in the grid, which is NSC|13|2415020.5's longitude at 2.3e-09 degrees (8.4e-06 arcseconds, 1.7e-11 relative), two separate maxima, not one figure converted two ways. Every divergence beyond the worst relative figure sits in a speed component, and the largest absolute speed divergence anywhere in the grid is 1.8e-08 (SE_ADMETOS's distance speed, in AU per day). SE_TRUE_NODE's own longitude speed still comes in close to the earlier figure (1.33e-7 relative, reproducible from this same grid), so that data point was not wrong; it was simply not the largest relative figure once fictitious bodies were swept too. net8.0 and net10.0 agree on all 102 calls, as the earlier note claimed. The causal claim held up under direct testing: Tools/NetStandardCompat/RawMathProbe compares raw Math.Sin/Math.Cos/Math.Tan/Math.Atan2 between net48 and net10.0 over 14,006 swept arguments with no SwissEphNet code involved at all, and net48's results differ from net10.0's on several thousand of them, concentrated at multiples of pi/2 (0, pi/2, pi, 3pi/2, 2pi), not spread uniformly across the sweep, and never in Atan2. That confirms the mechanism is a real .NET Framework Math.Sin/Math.Cos/Math.Tan precision difference near a quarter-turn boundary, not only "near pi" narrowly, and not this port; what this measurement does not trace is the exact chain from a few-ULP Math.Sin/Cos divergence through SE_ADMETOS's iterative Kepler solve (swi_kepler, called from swi_osc_el_plan) to a 2.28e-3 relative divergence in its derived speed, plausible as iterative amplification (a converging Newton's-method solve differentiated for speed can turn a few ULP into a much larger swing), but not instrumented step by step, since doing so would mean adding debug output to SwissEphNet/CPort/, which the transliteration freeze forbids. On .NET Framework the results are correct to well within any practical tolerance, just not the C's bits, and that gap is not one this project can close. Tests/NetStandard20Smoke.Tests is not evidence either way here: it never calls swe_calc, touches no file-loading path, and sets no culture; it is a regression pin for a net48-only string-extension recursion and nothing more.

How the numbers are verified

Three instruments sit behind the numbers this library returns, and each proves something the other two cannot. The characterization baseline proves self-consistency, that a change altered nothing it should not have. The correctness oracle proves agreement with Astrodienst's own published reference values. The bit-exact oracle proves the port and a C build of the same version return identical bits. "Numerical compatibility" below summarises what that adds up to; the rest is detail.

This whole section is reference material. Nothing in it is needed to use the library.

Numerical compatibility

This library has been validated against the Swiss Ephemeris C library.

On .NET 10 (net10.0 is what Tools/OracleDump and Tools/OracleVerify target, and the only runtime the table below was actually measured on), it produces bit-identical results for the validated test suite on every platform tested, each against a C reference built on that same platform:

Platform C reference Result
Windows x64 MSVC 19.51, /O2 /fp:precise /MD 25,569 of 25,569 rows bit-identical (gated)
Linux x64 (Ubuntu 24.04) gcc 13.3.0, -O2 25,569 of 25,569 rows bit-identical, gated at that total on every push and pull request; confirmed by PR #32's "Oracle build gates" CI run at commit f92c8ee (https://github.com/Tim81/SwissEphNet/actions/runs/30805814137, job "Gate: port matches the C reference on Linux x64 (glibc)", pass) -- this workstation has not independently re-run it outside CI -- see docs/compliance-2.10.03.md's "The last two 2.10.03-only entry points" for what was checked and when. This citation is re-anchored as the branch moves forward and commits get rewritten out from under earlier citations; verify reachability with git merge-base --is-ancestor f92c8ee HEAD before trusting an older copy of this line
macOS arm64 clang, -O2 -ffp-contract=off -fno-builtin 25,569 of 25,569 rows bit-identical, gated by macos-exactness on every push and pull request; confirmed at the current grid by the same CI run at commit f92c8ee, job "Gate: port matches the C reference on macOS arm64 (Apple libSystem)", pass -- macOS has no local reproduction path here, so this row is CI's own result, not a claim made outside it

The characterization baseline (scripts/verify-baseline.ps1) separately runs on both net8.0 and net10.0 and reports them field-identical to each other on the platform that generated it. That corroborates net8.0 from a different instrument, but it is a weaker claim than the table above: self-consistency between two TFMs of this port rather than agreement with the C reference.

"Gated" on the Windows row means oracle-dump, the .github/workflows/oracle.yml job that replays this exact 25,569-row grid, re-runs the comparison end to end on every push and pull request and fails the workflow on any mismatch. Three more jobs also run on Windows in that file but check different things than this grid: crt-parity compares MSVC C against .NET on a fixed CRT value table, c-reference-validate compares the MSVC C build against pyswisseph 2.10.03, and swetest-diff compares Programs/SweTest's printed text output against Astrodienst's own swetest.exe. swetest-diff is not itself gated on that comparison: the step carries continue-on-error by design, because it checks printed output captured from one specific MSVC build that a future toolchain bump could shift without the port changing (see that workflow's own header comment, and docs/compliance-2.10.03.md's "4. SweTest text-output comparison" for the same exemption stated in full). macos-exactness covers macOS the same way oracle-dump covers Windows, and linux-exactness covers Linux the same way: it builds Astrodienst's C with gcc on ubuntu-latest, replays both grids against it, and fails the workflow on any mismatch, on every push and pull request. header-flags-check also runs on ubuntu-latest, but it checks this workflow file's own header comment against its own continue-on-error flags rather than any of Astrodienst's C, so it does not count toward this. Before linux-exactness existed, the Linux row came from one full run of the grid, done by hand in a WSL2 Docker container; all 17,064 rows matched bit for bit, but nothing re-ran it automatically, so a regression specific to glibc would have sat unnoticed until someone measured it again. That gap is what linux-exactness closes.

The agreement is exact rather than close because the port and the C reference call the same libm on a given platform: ucrtbase.dll on Windows, glibc on Linux, Apple's libSystem on macOS. The macOS build needs -fno-builtin: without it, clang substitutes its own math builtins for some libm calls (fusing an adjacent sin/cos pair into one __sincos, for instance), and Apple's __sincos does not return bit-identically to calling the two functions separately the way the port does. With -fno-builtin, both grids are bit-identical there too. gcc on Linux x64 needs neither flag macos-exactness does: base x86-64 has no FMA3 encoding at all, so -ffp-contract=off has nothing to turn off (linux-exactness confirms this by disassembly, the same way macos-exactness does for arm64), and although gcc does substitute glibc's sincos for an adjacent sin/cos pair even without -fno-builtin, glibc's sincos returns bit-identically to calling the two functions separately, unlike Apple's, measured by building both ways and replaying both grids through each, with no difference either way.

Windows and Linux do not produce the same numbers as each other, and cannot be made to. Math.Sin and its siblings bind to whatever libm the platform provides, at run time, so this is the same divergence two identically-built C programs would show. Comparing the Windows-generated characterization baseline against Linux gives 3,547,935 numeric fields with 66,390 (1.8712%) differing and 5,394 beyond the shipped tolerance, measured against the current matrix (see Tools/BaselineGen/README.md's "Platform lock" section for the environment and for what that run did not cover). That is why the baseline is locked to the platform that generated it and the cross-platform CI job reports drift without gating on it, and it is a statement about libm rather than about this port. The baseline has not been generated on macOS, so this same field-by-field comparison has not been run there.

When the netstandard2.0 build is executed on .NET Framework 4.8, floating-point differences can occur, because that runtime's implementations of transcendental functions (for example Math.Sin and Math.Tan) are not bit-identical to those of modern .NET. No instrument in this repository measures that gap: Tests/NetStandard20Smoke.Tests is the only net48 asset, and it asserts strings and C.atof rather than floating-point distance. Measured directly for this note, the same way the bit-exact oracle compares doubles (a totalOrder ULP distance; see Tools/OracleVerify/UlpMath.cs): swe_calc for FICT_CUPIDO (ipl 40, SEFLG_MOSEPH) at J2000.0 returns a latitude 83 ULP apart and a distance 4 ULP apart between net48 and net10.0, longitude bit-identical at that one date. That is not a fixed ceiling: the same call swept over 1850-2050 at five-year steps found position differences ranging from 0 ULP up to several thousand depending on the date, and adding SEFLG_SPEED widens it further, because the speed fields are a finite difference between two nearby position evaluations and amplify whatever position-level difference already exists. The two runtimes disagree at the ULP level on FICT_CUPIDO and on transcendental math generally, reproducibly, by an amount that depends on the input; there is no single number, measured or otherwise, that bounds it across every call.

The table above is the bit-exact oracle's own result; see "Bit-exact oracle" below for the tooling behind it and what it proves that the other two verification instruments in this README cannot.

Characterization baseline

Before any change to the C-to-C# port, a frozen golden-master file records what the library currently outputs for a large matrix of calls. See Tools/BaselineGen/README.md for what it covers and scripts/verify-baseline.ps1 to check current code against it. The baseline is Windows-specific by design; see that file's "Platform lock" section. This is not in tension with "Numerical compatibility" above showing both Windows and Linux bit-identical against their own C: the baseline is a Windows-generated golden master, so comparing Linux output against it measures libm divergence between platforms (glibc versus ucrtbase.dll), not a defect in the port. Comparing each platform against its own C reference, as "Numerical compatibility" and "Bit-exact oracle" below do, is what actually tests the port. Numerical-stability findings turned up while building the baseline are in docs/known-issues.md.

The characterization baseline proves self-consistency: a change did not alter anything it wasn't supposed to. It cannot prove correctness, because it is generated from the port's own output. That is what the correctness oracle below is for.

Correctness oracle

Tests/SwissEphNet.Conformance.Tests checks the port's output against Astrodienst's own reference values, not against the port's own prior output. The reference corpus is Swiss Ephemeris 2.10.03's setest test suite (12,757 iterations, ~334K asserted values across 10 functional areas). Even though the port has now landed the whole 2.10.03 delta file by file, it is not at full parity: known-fail.tsv still lists 1,423 failing iterations (11,334 passing, 88.8%). Each porting PR should remove entries from it; any entry that reappears is a regression.

Read that 1,423 with the split under "Numerical compatibility" above, because it is not 1,423 things left to port: 759 of them are a data file this repository does not ship rather than a wrong answer, the other 664 sit outside the tolerance Astrodienst's own suite allows, and the four rows ever confirmed as port defects were fixed and pruned.

  • external/swisseph, a git submodule, sparse-checked-out, pinned to tag v2.10.3bfinal. It serves two purposes:

    1. The reference corpus for the conformance oracle: setest/t.exp (expected values) and setest/t.fix (tolerances), plus the core .se1 ephemeris files, sefstars.txt, seorbel.txt, and seleapsec.txt that the SWIEPH/analytic iterations need to run.
    2. The C source to diff the port against, file by file, as porting work from 2.08 to 2.10.03 proceeds (*.c, *.h, Makefile, LICENSE).

    Initialize it with the sparse-checkout recipe in CONTRIBUTING.md ("The upstream C is vendored at external/swisseph"), measured at ~19 MB. Sparse patterns have to be set up before the first checkout, so git submodule update --init external/swisseph on its own does not produce a sparse checkout: it lands at the same commit but pulls the full, unfiltered tree, measured at ~423.9 MB.

  • Tests/conformance/known-fail.tsv, one row per iteration currently known to fail, with a category (NOT-IMPLEMENTED, VALUE-MISMATCH, DATA-MISSING, ERROR, or UNREPRODUCIBLE) and a short reason. The conformance run fails unless the port's actual behavior matches this file exactly: any iteration failing that isn't on the list (a regression), any listed row recorded under a category the port no longer matches (category drift, still failing but not the same failure), any listed row that now passes (progress left un-pruned), and any row for an iteration no longer in the corpus (stale) are all gate failures. There is no "reports without failing" case. The file and the port's behavior must agree, in both directions, for the gate to pass.

    • NOT-IMPLEMENTED names the category for a 2.10-only API the port doesn't have; it is currently empty and its classifier unreachable, because every function the 2.10.03 API surface declares now exists on the port (the last three, swe_calc_pctr, swe_houses_ex2 and swe_houses_armc_ex2, landed with sweph.c's dispatch slice). DATA-MISSING: a required data file (a JPL DE ephemeris, ephe/sat/) isn't shipped by this repo. ERROR: the dispatch threw. VALUE-MISMATCH: the port ran and produced an answer that doesn't match the reference within t.fix tolerance. This and ERROR are the actionable categories, the actual porting work queue. UNREPRODUCIBLE: a structural C-vs-C# representational gap makes the reference call impossible to construct at all, as opposed to constructible but wrong, distinct from the other three, and excluded from the pass-rate denominator the same way they are (see ConformanceReport.SuiteSummary.PassRate's doc comment). Currently 0 across the whole corpus (suite 6 testcase 6, the one place that used to carry all of it, became reproducible once the port's five house entry points gained faithful int hsys overloads; see Suite06Houses.Dispatch's remarks on testcase 6 for the mechanics).

    See "Reporting by testcase" in CONTRIBUTING.md for how to read a run (60 testcases, split into actionable vs. parked) instead of 12,757 individual rows, and "The two gates disagree on purpose, not by accident" for why this gate failing constantly is expected and the characterization baseline above failing at all is not. They are not the same kind of red.

  • Two data sources this repo does not ship are skipped by default and reported as DATA-MISSING, not run: SEFLG_JPLEPH iterations need a multi-hundred-MB JPL DE file (opt in with SWISSEPH_CONFORMANCE_INCLUDE_JPL=1 and SWISSEPH_CONFORMANCE_JPL_FILE=<path>), and planetary-moon bodies (ipl 9000-9999) need ephe/sat/ at ~227 MB (opt in with SWISSEPH_CONFORMANCE_INCLUDE_MOONS=1 and that directory populated).

    Skipped is not untested. Both were run once, against real data, and the measurement is recorded in Tests/conformance/regenerations.log's entry of 2026-07-31: the full 12,757-iteration corpus with SWISSEPH_CONFORMANCE_INCLUDE_JPL=1 and SWISSEPH_CONFORMANCE_JPL_FILE pointed at de431.eph, the file setest's own suites 1 and 10 call swe_set_jpl_file with:

    size    2,788,676,624 bytes
    sha256  fe3d0323d26ada11f8d8228fda9ca590c7eb00cee8b22dff1839f74f5be71149
    md5     fad0f432ae18c330f9e14915fbf8960a
    

    500 of the 538 JPL rows pass outright once the data is present, along with 4 of the 18 sat rows, as measured on 2026-07-31, before 30 SEFLG_CENTER_BODY rows joined the sat category on 2026-08-01 (Tests/conformance/regenerations.log, "Reclassify 56 rows"); known-fail.tsv carries 48 sat rows today, and the 4-of-18 figure has not been re-run against that larger set. The JPL backend works; what it lacks is a way to keep proving it.

    Verify with the SHA-256, not the MD5. Upstream publishes only an MD5, and their readme.md lists fad0f432ae18c330f9e14915fbf8960a de431.eph among the md5-keys -- and MD5 has had practical chosen-prefix collisions since 2019, so it establishes that a download was not corrupted in transit, not that nobody chose its contents. The SHA-256 above was computed here from the file that produced the result, after confirming its MD5 against upstream's published one. Both are recorded so the chain is checkable in either direction: the MD5 ties this file to Astrodienst, and the SHA-256 is what anyone reproducing the run should actually match.

    Get it from NASA rather than a mirror, because de431.eph is not a Swiss Ephemeris format at all: it is JPL's own Linux binary, renamed. Upstream's readme.md links straight to it, and the sizes confirm the rename is all that happens -- lnxm13000p17000.431 is 2,788,676,624 bytes, the same count as the file measured above, so nothing is converted in between.

    de431.eph  https://ssd.jpl.nasa.gov/ftp/eph/planets/Linux/de431/lnxm13000p17000.431   2.6 GB
    de406.eph  https://ssd.jpl.nasa.gov/ftp/eph/planets/Linux/de406/lnxm3000p3000.406     190 MB
    de200.eph  https://ssd.jpl.nasa.gov/ftp/eph/planets/Linux/de200/lnxm1600p2170.200      41 MB
    

    Download, rename to the .eph name, check the SHA-256. Astrodienst also list Alois Treindl's Dropbox and https://ephe.scryr.io/jpl/ (a web space provided by Phillip McCabe) as alternatives, which are useful if NASA's FTP area is slow, but the point of taking JPL's own copy is that the mirror stops being part of the trust chain.

    Only de431.eph reproduces the numbers above. setest's suites hardcode it, so the expected values in t.exp are DE431 values, and running the corpus against DE200 or DE406 produces real differences that look exactly like port defects. The smaller files are useful for a different job: load_dpsi_deps is reached only when the opened file reports jpldenum >= 403, which DE406 clears and DE200 does not.

    Nothing in CI measures that, and the reason is size rather than doubt. de431.eph is 2.6 GB, cannot be committed, and no runner will fetch it per job, so the result above is a dated one-time measurement rather than a gate. It is reproducible by anyone holding that file: the MD5 is recorded so a different DE build, which would produce different numbers and look like defects, can be ruled out first. Treat these rows as verified-but-unwatched, not as covered.

  • The JPL backend also has a bit-exact grid, and it is the one that found the bug. Tools/OracleGrid/grid-jpl.tsv is the third oracle grid: 2,407 rows, 1,200 through swe_calc, 1,200 through swe_calc_ut, sweeping bodies, epochs and sidereal modes with SEFLG_JPLEPH set, and with SEFLG_JPLHOR and SEFLG_JPLHOR_APPROX among the flag combinations, plus 7 swe_get_current_file_data rows. It is opt-in and CI never runs it:

    pwsh scripts/run-oracle-dump.ps1 -JplFile C:/path/to/de406.eph
    pwsh scripts/verify-oracle.ps1   -Grid Jpl
    

    It runs against DE406 rather than DE431 on purpose. The conformance corpus is locked to DE431 because setest hardcodes it, but the oracle compares the port against the C over the same file on the same machine, so any DE file works and the 190 MB one is the one a contributor can actually download. load_dpsi_deps needs jpldenum >= 403, which DE406 clears and DE200 does not.

    de406.eph  https://ssd.jpl.nasa.gov/ftp/eph/planets/Linux/de406/lnxm3000p3000.406
    size       199,437,056 bytes
    sha256     b23009e208d625c5e830c4cb67e6313d7f9eadeffe17292a7471f33250c9342d
    

    Verify with that SHA-256. Astrodienst publish an MD5 for their own de406e.eph (1ef768440cc1617b6c8ad27a9a788135) which does not match NASA's DE406 (39e63b24f3540b92ec83be008f20d70e); they are different files, and only NASA's reproduces the run below.

    First run: 415 of 2,400 rows bit-identical, 1,985 differing. One cause behind all of them. swejpl.c reads the 400 six-byte constant names with an element size of 1 and checks the byte count; the port read the same 2,400 bytes but checked the character count they decoded to. DE406 carries 176 bytes above 0x7F in the unused tail of that block, so the guard fired on a perfectly good file and every SEFLG_JPLEPH call fell back to Moshier without saying so. After the fix: 2,400 of 2,400 bit-identical, 0 differing, nothing waived. That 2,400 was the grid's size at the time this fix landed; grid-jpl.tsv has since grown to the 2,407 rows described above, so "2,400 of 2,400" is this historical run's own total, not a claim about the current grid.

    It survived this long because it is data-dependent. DE431 has no high bytes in that block, and DE431 was the only DE file this repository had ever opened, including the 12,757-iteration run recorded above. Full numbers and environment are in Tests/oracle/regenerations-jpl.log; the defect is written up in docs/known-issues.md.

  • A separate workflow, not folded into ci.yml's fast job: .github/workflows/conformance.yml runs on a schedule, on demand, and on every pull request, with no paths filter. An earlier version restricted the pull_request trigger to SwissEphNet/** and the oracle's own paths, but that allowlist could never be complete (it missed global.json, Directory.Build.props, and a submodule gitlink bump to external/swisseph itself), so it was dropped to match ci.yml and baseline.yml, neither of which filters by paths either. It earns that spot on every PR on cost, not by default: dispatching all 12,757 iterations is ~2s in-process (measured, Release build, Tools/ConformanceKnownFailGen), and dotnet test Tests/SwissEphNet.Conformance.Tests end-to-end (both TFMs, including test host startup) is ~8s. The submodule checkout is the only real cost and is sparse (~19 MB, not the ~423.9 MB a full, unfiltered checkout would pull) and cached on the pinned commit SHA.

  • Regenerating Tests/conformance/known-fail.tsv is scripts/regenerate-known-fail.ps1 -PruneOnly to remove newly-passing rows (the common case after a porting PR; refuses to run if it would add or recategorize a row instead) or -Reason "..." [-PR N] for a full regenerate that can also add rows; see "Correctness oracle known-fail list" in CONTRIBUTING.md for the invariant it enforces (rows may be removed freely; adding one needs a written reason and review).

Licensing note: vendoring Swiss Ephemeris 2.10.x source is consistent with this project's own license, which is already the dual AGPL-3.0 / Swiss Ephemeris Professional text (see "License" above and LICENSE). The submodule does not change that; both sides of the port have carried the same license since before 2.10.03 work started.

Bit-exact oracle

The characterization baseline above proves self-consistency, and the correctness oracle proves agreement with Astrodienst's own published reference values within the tolerances Astrodienst itself ships. Neither can prove the strongest claim this project makes: that for a given input, the port and Astrodienst's own C compute the identical bits. That is what this third instrument is for, and it is the source of the "Numerical compatibility" table above.

  • Tools/OracleGrid holds the two input grids: grid-analytic.tsv (22,289 rows, SEFLG_MOSEPH swe_calc/swe_calc_ut, swe_houses/swe_houses_armc/swe_houses_ex/swe_houses_ex2/swe_houses_armc_ex2, swe_get_ayanamsa/_ex/_ex_ut/_ut, swe_sidtime, swe_azalt, swe_house_name and swe_nod_aps_ut, swept across every predefined sid_mode plus SE_SIDM_USER, opening no ephemeris file) and grid-files.tsv (3,280 rows, SEFLG_SWIEPH swe_calc/swe_calc_ut, the swe_fixstar family (including swe_fixstar2_mag), swe_get_planet_name, swe_houses_ex/swe_houses_ex2/swe_houses_armc_ex2, swe_nod_aps_ut, swe_calc_pctr and swe_get_current_file_data, reading the shipped .se1/sefstars.txt files).
  • Each grid is replayed by a pair of drivers built from the same inputs: Tools/CReference/sedump.c, compiled against Astrodienst's own vendored 2.10.03 C, and Tools/OracleDump, built against this port. Both write every hex-encoded field, the return code, and the serr text to a TSV.
  • Tools/OracleVerify compares the two dumps field by field. A row that is not an outright match has to be listed in Tests/oracle/known-diff.tsv or known-diff-files.tsv, under a category that still fits and at a magnitude no worse than the last time that entry was regenerated. Both lists are currently empty.
  • scripts/verify-oracle.ps1 is the gate: it also checks that the dumps on disk (gitignored, under external/.c-reference/, not committed) still reflect what they were generated from -- the two committed grids, the port's own source, and the C reference binaries, and, when a grid's known-diff list is empty, that the two dump files are byte-for-byte identical at the file level, not merely equal per OracleVerify's own field comparator.

Run scripts/run-oracle-dump.ps1 to regenerate the dumps, then scripts/verify-oracle.ps1 to check them. See docs/compliance-2.10.03.md for the current numbers on both Windows and Linux, what this instrument does and does not cover, and the same record for the other two instruments above.

Continuous Integration

This project replaced the upstream project's AppVeyor CI with GitHub Actions; see .github/workflows/.

Contributing

Before touching SwissEphNet/CPort/, Programs/SweTest/Program.cs or Programs/SweMini/Program.cs, read CONTRIBUTING.md. Those files are deliberate, line-by-line transliterations of the Swiss Ephemeris C source and must never be reformatted or restructured; that correspondence is what makes each upstream Swiss Ephemeris upgrade tractable.

References

Astrodienst's own material, which documents the API this port exposes:

NASA JPL, for the DE files the SEFLG_JPLEPH backend reads:

About

Swiss Ephemeris for .NET

Resources

Contributing

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages