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.
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.
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.
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.
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.
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.
Three runnable programs, all against the library as it is built here:
Programs/SweMiniis the smallest useful example, a direct port of Astrodienst'sswemini.c.Programs/SweTestis the full command-line tool, a port ofswetest.c, and the most complete demonstration of the API.Programs/SweWinis a Windows Forms front end (Windows only).
Tests/SwissEphNet.Tests is also worth reading as a source of small, self-contained calls.
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.
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.
This project carries three names, and meeting them separately can look like something is broken. It isn't:
- The repository is
Tim81/SwissEphNet, continuingygrenier/SwissEphNet, and keeps that name. - The NuGet package ID is
SwissEphSharp. TheSwissEphNetID 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 originalSwissEphNetpackage 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 underSwissEphNet/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.
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.
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.
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.
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.
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.
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_ex2andswe_houses_armc_ex2: house calculations with per-cusp speed output and an explicitserrparameter.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, andswe_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 theephe/sat/data set, which this repository does not ship. SEFLG_TROPICAL,SE_ECL_HYBRID, and threeSE_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.
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.
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 throwsAmbiguousMatchExceptionfor these five methods, because there are two overloads where there used to be one. Pass an explicit parameter-type array toGetMethod(or the equivalent for your binder) to select the overload you want. var f = swe.swe_house_name;(or any bare method-group assignment tovarfor one of the five widened methods) no longer compiles under C# 10+ natural-type inference: the compiler cannot pick between the two overloads and reportsCS8917. Declare an explicit delegate type instead, e.g.Func<char, string> f = swe.swe_house_name;.- A
charaboveU+00FFpassed toswe_houses,swe_houses_exorswe_houses_armcnow takes its low byte when resolving the house system, matching what a genuine 8-bit Ccharwould resolve to, rather than being widened untruncated as before: measured,(char)331(low byte0x4B='K') resolved to Placidus before and resolves to Koch now.swe_house_poschanges the same way in its internal cusp computation, but its own house-system dispatch compares the raw value and is unchanged, andswe_house_namenever narrows at all; its behaviour is identical before and after. This only affects callers passing acharoutside the Latin-1 range, which was never a valid house-system letter either way; seedocs/known-issues.mdfor the measured before/after. swe_house_poswithhsys = 'G'(Gauquelin sectors) no longer throwsIndexOutOfRangeException: 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_globandswe_lun_occult_when_loceach gained anInt32 backwardoverload.swephexp.hdeclares that parameterint32, and it is a bitfield: bit 0 selects the search direction, whileSE_ECL_ONE_TRY(32768) limits the search to a single lunar cycle (swecl.c:1539and:1593; the local variant masks it atswecl.c:2436). This port offered only abool backwardsignature, which can carry 0 or 1 and nothing else, sobackward & SE_ECL_ONE_TRYwas always 0 and the flag could not be requested through the public API at all. Thebooloverload keeps its exact signature and is still there, so existing source and existing compiled binaries bind the way they always did; it just cannot reachSE_ECL_ONE_TRY, and the newInt32overload 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 throwsAmbiguousMatchExceptionfor 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]andattr[2]fromswe_sol_eclipse_how, and from theattrarrayswe_sol_eclipse_whereandswe_lun_occult_wherefill, are now fractions rather than percentages: an eclipse that reported100reports1. This is upstream's change atswecl.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]andattr[3]onward are unaffected. - Planetary magnitudes changed.
swe_phenoandswe_pheno_utreturn a differentattr[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.csis not evidence for that claim: it covers a different, unrelated change, the updatedpla_diam[]table movingattr[3]for six minor bodies this bullet is not about (Chiron, Pholus, Ceres, Pallas, Juno and Vesta), and it asserts onlyattr[3], leaving phase angle and the rest ofattruntouched. swe_rise_trans_true_horgains ahorhgt == -100sentinel. Passing exactly-100now means "use the dip of the horizon", computed fromcalc_dip, instead of a literal horizon height of -100 degrees (swecl.c:4415; ported atSweCL.cs:4507). Absent from 2.08, so a caller that happened to pass-100before now gets different rise/set times.swe_lun_eclipse_when's search-precision threshold moved from 2000000 to 2100000 (swecl.c:3485, ported atSweCL.cs:3548), changing which Julian day range gets the coarser 5-day search step versus the finer 0.1-day one. The sibling functionswe_sol_eclipse_when_globdeliberately 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 withiteration_count = 2fixed. The two now agree only where two iterations happened to already converge. swe_rise_transroutes fixed stars to the slow path.swecl.c:4362-4376gates the fast algorithm on!do_fixstar; a fixed-star call now always falls through toswe_rise_trans_true_horrather than the fast approximation, so star rise and set times change.- Every
SEFLG_SWIEPHposition changes.rot_back's J2000 obliquity was always zero in this port (it readswed.oec2000, which nothing ever populated), so every position rotated back through it used the wrong obliquity. Fixed alongside the rest ofsweph.c's file layer; the file-backed oracle grid went from 791 of 2,024 bit-identical to 1,975, asgrid-files.tsvstood 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_utreturned all-zero nodes and apsides for every standard ayanamsha. A missingswi_cartpol_spcall on the sidereal branch (swecl.c:5587) left the ecliptic cartesian coordinates zeroed before the ayanamsha was applied. Fixed for every ayanamsha exceptSE_SIDBIT_ECL_T0andSE_SIDBIT_SSY_PLANE, which were already correct.serris now populated at roughly twenty sites where it was silently empty before, a guard-inversion bug (serr != NULLtransliterated from C, where it means "caller supplied a buffer", into a check for "a message is already present" against a C#ref stringthat always supplies one). Any caller usingString.IsNullOrEmpty(serr)as a success signal will see previously-silent failures start reporting a message.swe_get_ayanamsa_exwith no priorswe_set_sid_modechanges value, from 92.525 to 24.754 degrees:swi_get_ayanamsa_extook itssid_datacopy before theSE_SIDM_FAGAN_BRADLEYfallback ran, so it read pre-fallback state.swe_nod_apsafterswe_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_planetsreplaced an object instead of zeroing it in place, and a separate!= Sweph.B1950mask (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, fromAMODELS_SE_1_00toAMODELS_SE_2_06: the version-string parser did not matchstrtod's "longest parseable prefix" behavior, so"2.10.03"failed to parse at all and fell through to the last branch.swe_refrac_extendedandcalc_dipchange value.swe_refrac_extended's visibility test flips fromtrualt > diptoinalt >= dip(upstream's own 4 Feb 2020 fix,swecl.c:3070-3113), andcalc_dipcorrects 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, theSEFLG_TROPICAL,SEFLG_CENTER_BODYandSEFLG_TEST_PLMOONflags,SE_ECL_HYBRID, and threeSE_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.-houseand-utcno longer crash.dms()no longer throwsArgumentOutOfRangeExceptiononce a degree value reaches 100 or more. OnLoadFileis gone; ephemeris files are read from disk by default. The event (andLoadFileEventArgs) is replaced bySwissEph.FileProvider, a settableIEphemerisFileProvider(Stream Open(string path), null meaning "not found"). A caller that never subscribed toOnLoadFileused to get every ephemeris file reported as missing and every calculation silently downgraded to Moshier, even with a real, populated ephemeris directory configured viaswe_set_ephe_path; now that every target framework this library ships (netstandard2.0,net8.0,net10.0) has full filesystem access, noFileProviderset means the real filesystem is used, the same way the C reference itself behaves. Most existingOnLoadFilehandlers that just opened a real file by path can be deleted outright:swe_set_ephe_pathalone 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_SEPARATORalso widens fromchartochar[](still{ ';' }) to support this; seedocs/known-issues.md's OnLoadFile entry for the full detail and the DefaultFileProvider static escape hatch for harnesses that construct many instances.DIR_GLUEis/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:304gives"/",:319gives"\\"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 ofTests/swetest/known-diff.tsv. It matters to two kinds of caller: anything that string-matches on thatserrtext, and anyIEphemerisFileProviderthat pattern-matches the separator in a path handed toOpen. 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.GetFileNamedoes 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. Seedocs/known-issues.md's "Three file-layer divergences" entry.- The assembly is now named
SwissEphSharp, notSwissEphNet. The package ID was alreadySwissEphSharp; now the DLL matches it, and the namespace staysSwissEphNet, so source that only calls the public API needs no change beyond thePackageReferenceitself. This closes a collision: while the assembly was still namedSwissEphNet, this package and the originalSwissEphNet2.8.0.2 both producedbin/SwissEphNet.dll. Referencing both in one dependency graph built cleanly (noMSB3277, noNU1605) 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 removedOnLoadFile/LoadFileEventArgsAPI, 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,binnow holds bothSwissEphNet.dll(2.8.0.2) andSwissEphSharp.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 callsAssembly.Load("SwissEphNet")by literal string, carries a binding redirect namingSwissEphNet, or otherwise hardcodes the DLL filename needs to be updated toSwissEphSharp; anything that only references the package and writesusing SwissEphNet;does not. SE_EPHE_PATH, the environment variable, is honored again, and takes priority overswe_set_ephe_path.sweph.c:1327checks it before anything else and only reaches the argument passed toswe_set_ephe_pathin anelse 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: ifSE_EPHE_PATHis set in the process environment, it wins over whatever path a caller passes toswe_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 explicitswe_set_ephe_pathcall is now silently overridden by it.- The default ephemeris path
swe_set_ephe_pathfalls back to is now upstream's, not the"[ephe]"placeholder, butSwissEph.SE_EPHE_PATHitself is unchanged. Before this release, nothing in the library ever detected the"[ephe]"placeholderSwissEph.SE_EPHE_PATHheld; it was meant to be recognized whileOnLoadFileintercepted every file read, and withOnLoadFilegone and a nullFileProviderreading 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_PATHisconst, 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_PATHtherefore 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 pointswe_set_ephe_pathand 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 MSDOSbranch (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 passnullor an empty string toswe_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 ownDIR_GLUEis always/. Cosmetic -- Windows accepts both separators in a path, so this is not a functional difference. IEphemerisFileProvider.Open(string path)receives a differentpathas a result, for any caller that never callsswe_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 apathstarting with\sweph\ephe\on Windows instead, the equality check fails,Openreturnsnull, 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, acrossSwissEphTest.cs,SwissEphTest.Date.cs,SwissEphTest.swe_fixstar.csandIssue18Test.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 callswe_set_ephe_pathexplicitly 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 ownDIR_GLUEjoin 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 shippednet40andnetstandard1.0and never carriednetstandard2.0at all, and releases after this one will requirenet8.0or 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: thenetstandard2.0asset is in this release and works. The reason it goes away is measured, not a preference:netstandard2.0is a compatibility target, not a correctness one, and bit-exactness against Astrodienst's C (see "Numerical compatibility" below) is claimed fornet8.0and 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 samenetstandard2.0asset'sswe_calcover a committed 102-call grid (34 bodies --SE_SUN..SE_EARTHplus every fictitious-body constantswephexp.hdefines,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 notSE_TRUE_NODE's longitude speed either: it isSE_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 isNSC|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.0andnet10.0agree on all 102 calls, as the earlier note claimed. The causal claim held up under direct testing:Tools/NetStandardCompat/RawMathProbecompares rawMath.Sin/Math.Cos/Math.Tan/Math.Atan2between net48 and net10.0 over 14,006 swept arguments with noSwissEphNetcode 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 inAtan2. That confirms the mechanism is a real .NET FrameworkMath.Sin/Math.Cos/Math.Tanprecision 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-ULPMath.Sin/Cosdivergence throughSE_ADMETOS's iterative Kepler solve (swi_kepler, called fromswi_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 toSwissEphNet/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.Testsis not evidence either way here: it never callsswe_calc, touches no file-loading path, and sets no culture; it is a regression pin for anet48-only string-extension recursion and nothing more.
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.
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.
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.
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 tagv2.10.3bfinal. It serves two purposes:- The reference corpus for the conformance oracle:
setest/t.exp(expected values) andsetest/t.fix(tolerances), plus the core.se1ephemeris files,sefstars.txt,seorbel.txt, andseleapsec.txtthat the SWIEPH/analytic iterations need to run. - 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 atexternal/swisseph"), measured at ~19 MB. Sparse patterns have to be set up before the first checkout, sogit submodule update --init external/swissephon 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. - The reference corpus for the conformance oracle:
-
Tests/conformance/known-fail.tsv, one row per iteration currently known to fail, with a category (NOT-IMPLEMENTED,VALUE-MISMATCH,DATA-MISSING,ERROR, orUNREPRODUCIBLE) 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-IMPLEMENTEDnames 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_ex2andswe_houses_armc_ex2, landed withsweph.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 withint.fixtolerance. This andERRORare 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 (seeConformanceReport.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 faithfulint hsysoverloads; seeSuite06Houses.Dispatch's remarks on testcase 6 for the mechanics).
See "Reporting by testcase" in
CONTRIBUTING.mdfor 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_JPLEPHiterations need a multi-hundred-MB JPL DE file (opt in withSWISSEPH_CONFORMANCE_INCLUDE_JPL=1andSWISSEPH_CONFORMANCE_JPL_FILE=<path>), and planetary-moon bodies (ipl9000-9999) needephe/sat/at ~227 MB (opt in withSWISSEPH_CONFORMANCE_INCLUDE_MOONS=1and 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 withSWISSEPH_CONFORMANCE_INCLUDE_JPL=1andSWISSEPH_CONFORMANCE_JPL_FILEpointed atde431.eph, the filesetest's own suites 1 and 10 callswe_set_jpl_filewith:size 2,788,676,624 bytes sha256 fe3d0323d26ada11f8d8228fda9ca590c7eb00cee8b22dff1839f74f5be71149 md5 fad0f432ae18c330f9e14915fbf8960a500 of the 538 JPL rows pass outright once the data is present, along with 4 of the 18
satrows, as measured on 2026-07-31, before 30SEFLG_CENTER_BODYrows joined thesatcategory on 2026-08-01 (Tests/conformance/regenerations.log, "Reclassify 56 rows");known-fail.tsvcarries 48satrows 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.mdlistsfad0f432ae18c330f9e14915fbf8960a de431.ephamong 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.ephis not a Swiss Ephemeris format at all: it is JPL's own Linux binary, renamed. Upstream'sreadme.mdlinks straight to it, and the sizes confirm the rename is all that happens --lnxm13000p17000.431is 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 MBDownload, rename to the
.ephname, check the SHA-256. Astrodienst also list Alois Treindl's Dropbox andhttps://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.ephreproduces the numbers above.setest's suites hardcode it, so the expected values int.expare 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_depsis reached only when the opened file reportsjpldenum >= 403, which DE406 clears and DE200 does not.Nothing in CI measures that, and the reason is size rather than doubt.
de431.ephis 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.tsvis the third oracle grid: 2,407 rows, 1,200 throughswe_calc, 1,200 throughswe_calc_ut, sweeping bodies, epochs and sidereal modes withSEFLG_JPLEPHset, and withSEFLG_JPLHORandSEFLG_JPLHOR_APPROXamong the flag combinations, plus 7swe_get_current_file_datarows. 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 JplIt runs against DE406 rather than DE431 on purpose. The conformance corpus is locked to DE431 because
setesthardcodes 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_depsneedsjpldenum >= 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 b23009e208d625c5e830c4cb67e6313d7f9eadeffe17292a7471f33250c9342dVerify 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.creads 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 above0x7Fin the unused tail of that block, so the guard fired on a perfectly good file and everySEFLG_JPLEPHcall 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.tsvhas 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 indocs/known-issues.md. -
A separate workflow, not folded into
ci.yml's fast job:.github/workflows/conformance.ymlruns on a schedule, on demand, and on every pull request, with nopathsfilter. An earlier version restricted thepull_requesttrigger toSwissEphNet/**and the oracle's own paths, but that allowlist could never be complete (it missedglobal.json,Directory.Build.props, and a submodule gitlink bump toexternal/swissephitself), so it was dropped to matchci.ymlandbaseline.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), anddotnet test Tests/SwissEphNet.Conformance.Testsend-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.tsvisscripts/regenerate-known-fail.ps1 -PruneOnlyto 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" inCONTRIBUTING.mdfor 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.
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/OracleGridholds 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_nameandswe_nod_aps_ut, swept across every predefinedsid_modeplusSE_SIDM_USER, opening no ephemeris file) andgrid-files.tsv(3,280 rows,SEFLG_SWIEPH swe_calc/swe_calc_ut, theswe_fixstarfamily (includingswe_fixstar2_mag),swe_get_planet_name,swe_houses_ex/swe_houses_ex2/swe_houses_armc_ex2,swe_nod_aps_ut,swe_calc_pctrandswe_get_current_file_data, reading the shipped.se1/sefstars.txtfiles).- 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, andTools/OracleDump, built against this port. Both write every hex-encoded field, the return code, and theserrtext to a TSV. Tools/OracleVerifycompares the two dumps field by field. A row that is not an outright match has to be listed inTests/oracle/known-diff.tsvorknown-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.ps1is the gate: it also checks that the dumps on disk (gitignored, underexternal/.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 perOracleVerify'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.
This project replaced the upstream project's AppVeyor CI with GitHub Actions; see
.github/workflows/.
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.
Astrodienst's own material, which documents the API this port exposes:
- Swiss Ephemeris programming interface, the
reference for every
swe_*function here. aloistr/swisseph, the C source this port tracks. Pinned here at tagv2.10.3bfinalunderexternal/swisseph.aloistr/swisseph/ephe, the ephemeris data files, mirrored atephe.scryr.io/ephe.
NASA JPL, for the DE files the SEFLG_JPLEPH backend reads:
- Jet Propulsion Laboratory and its solar system dynamics group.
- The DE files themselves, under
ssd.jpl.nasa.gov/ftp/eph/planets/Linux/:de200/lnxm1600p2170.200,de406/lnxm3000p3000.406,de431/lnxm13000p17000.431andde441/linux_m13000p17000.441, also mirrored atephe.scryr.io/jpl.