Type System (SedaroTS)
Motivation
The Sedaro Platform makes modeling and simulation implementation fast and failure hard. Our Type System, SedaroTS, is critical to achieving both of these objectives.
Make Implementation Fast
The key to making implementation fast is in composable Platform Content. Our programs are rarely a greenfield implementation effort, but rather one that primarily draws from well-tested existing Content which has operational heritage. The composability of this Content along with program-specific, customer-furnished Content is core to enabling flexible modeling and simulation solutions for our customers. There are several aspects of Sedaro's approach to simulation which enable extremely high composability:
- Sedaro Query Language (SedaroQL): Query-based analytical model (i.e. State Managers) dependency definition enables maximal composability. What works in one model can easily bind to a totally different model without manual plumbing.
- Simulator Optimizations: You can imagine that a naive implementation of "throw a bunch of existing Content into a bag and run it" would yield terrible performance. The optimizations that Sedaro's Simulator does on the libraries of Content passed to it allows us humans to not need to worry about performance when we compose a simulation.
- SedaroML: Sedaro's modeling language allows for a fluid definition of what's an Agent in a simulation
Last but not least in this list is SedaroTS. In this context, SedaroTS allows all composable Content to speak the same language. In Sedaro, it's possible to compose descriptive or analytical models with different units for distance, for example, because the type system supports safe use of units via dimensional analysis. Imagine you have a thermal solver that deals in K and you want it to interoperate in simulation with heterogenous thermal subsystems with fields in units C and F - SedaroTS makes this seamless.
Make Failure Hard
The obvious reason to have a type system is, in part, to be able to statically detect type incompatibilities (i.e. type-safety). While type-safety is a goal of SedaroTS, the intention is to also enable "unit-safety". As described above, we have what we need to compose content that speaks different, compatible units within pure simulation but we are also able to extend these perks to third-parties during collaborative modeling (i.e., co-modeling) and simulation (i.e. co-simulation).
SedaroTS also allows us to escape the confines of a specific programming language and represent our data on any computer, in any language, on any planet.
Using the Bindings
SedaroTS is a Rust library with Python and WASM/JavaScript bindings. Both bindings wrap the same core, so type syntax, canonical formatting, conversion results, sampled values, and binary bytes agree across languages.
# Python distribution: sedaro-ts
from sedaro_ts.sv import ConDeps, Datum, RawType, Type, TypedDatum
// npm package: @sedaro/sedaro-ts
import {
ConDeps,
Datum,
RawType,
Type,
TypedDatum,
init,
} from "@sedaro/sedaro-ts";
init(); // optional; installs a panic hook so core failures surface as readable errors
The Four Objects
| Object | Holds | Construct with |
|---|---|---|
RawType | a type with no refinements (int, [str], (f64, bool)) | RawType("[str]"), RawType.infer(value) |
Type | a type plus refinements (units, bounds, frames, distributions) | Type("{f64 | km}"), Type.infer(value), Type("eci<km>") |
Datum | a value with no type attached | Datum("(1.0, 2.0)"), Datum.infer(value), some_type.datum(value) |
TypedDatum | a value and its Type | some_type.td(value), some_type.de_td(bytes), TypedDatum("(1.0 : km)") |
Type is the object you want in almost all cases: RawType cannot parse a
refined type at all, so RawType("{int | >0}"), RawType("eci"), and
RawType("eci<km>") are parse errors. Field and variant names are refinements
too, so RawType("(x: float, y: float)") is also a parse error, and ty.raw()
drops the names: Type("(x: float, y: float)").raw() is (float, float).
SedaroTS types target a subset of the built-in and primitive data found in supported programming languages (Python, Rust, and JavaScript). Sedaro simulations operate on the native versions of these types, maximizing scalability and allowing seamless interoperation with existing libraries and tools. The dynamic bindings provide convenience and consistency, as well as canonical cross-language interpretations.
Naming Between Bindings
Method names are usually identical (modulo snake_case/camelCase conventions) in both bindings, except where JavaScript cannot overload a language feature:
| Behavior | Python | JavaScript |
|---|---|---|
| Native value out | .py() | .object() |
| Shallow native value | .py_shallow() | .object_shallow() |
| Indexing | datum[key], td[key] | .get(key) |
| Length | len(x) | .len(), and .length on Datum |
| Equality | == | .equals(other) (=== remains identity) |
| Truthiness | bool(x) | .isTruthy() |
| Arithmetic | + - * / | .add(), .sub(), .mul(), .div() (where supported) |
| Patch / update | | | .update(other) |
| Variant predicate | .is_variant(name) | .isVariant(name) |
| Distribution seed | int | BigInt (7n) |
Errors are host-native and their text is not a contract. Python raises
Exception subclasses (bound violations raise plain Exception; some APIs
raise sedaro_ts.PublicSimulationException; a clean end of stream raises
EOFError). JavaScript throws a WASM Report object, which is not an
Error. Use String(err), not err.message. Match on outcomes, not on
rendered diagnostics.
Type Syntax
Basic Types
| Kind | Type syntax | Value text | Python value | JavaScript value | Rust type |
|---|---|---|---|---|---|
| Bool | bool | true, false | bool | boolean | bool |
| Int | int, i8–i128, u8–u64 | 123, 42i16, 7u8 | int | number | i32 (i8–i128, u8–u64) |
| Float | float, f32, f64 | 1.23, 2.0f32 | float | number | f64 (f32) |
| String | str | "foo" | str | string | String |
| ID | id, u128 | 86734u128 | base50 str | base50 string | u128 |
| FID | fid | 86734u128 | int | BigInt | u128 |
| Empty product | () | () | None | null | () |
| Never | ! | — | — | — | — |
| List | [T] | [1, 2, 3] | list | Array | Vec<T> |
| Product | (T, U), (x: T, y: U) | (1, 2.0), (1,) | tuple | Array | (T, U) |
| Slice | [T; N] | (1.0, 2.0, 3.0) | list | Array | (T, …, T) |
| Tensor | #[T; N], #[#[T; N]; M] | (1.0, 2.0, 3.0) | NumPy ndarray | nested Array | SVector<T, N>, SMatrix<T, N, M> |
| Map | {K: V} | {"a": 1, "b": 2} | dict | Map | BTreeMap<K, V> |
| Set | {T} | {1, 2}, {7,} | set/frozenset | Set | BTreeSet<T> |
| Optional | T? | Some(1), None | value or None | value or null | Option<T> |
| Sum | A + B, (ok: A + err: B) | built with variant() | the payload, untagged | the payload, untagged | Sum2<A, B>, Sum3<A, B, C>, … |
A single-element product needs a trailing comma (1,), as does a single-element set {7,}.
The Rust type column is the type a value occupies in Rust memory: tensors
use nalgebra's statically-sized matrices, and sums use
the SumN types from simvm_abi, one per arity.
Canonical spellings normalize: f64 prints as float, i32 as int, u128
as id, [T; N] as a product, and m/s/s as m/s^2. A field or variant name
that collides with a unit or keyword is quoted on output: (a: km, b: deg)
prints as (a: km, "b": deg), because b is the bit unit.
Numbers
int means i32 and float means f64. f8, f16, f128, usize, and isize are not supported.
A numeric literal carries a width. Bare integers take the narrowest of i32/i64/i128 that fits; bare floats (which require a decimal point) are f64. Any other width needs a suffix appended directly to the digits:
Datum("42i16").pretty() # '42i16'
Datum("2f32").pretty() # '2.0f32' (int-form digits with a float suffix are fine)
Datum("7u8").pretty() # '7u8'
Datum("300u8") # error: value 300 out of range for u8
Type("id").td("hhh").pretty() # '(86734u128 : id)' — id/fid values use the u128 suffix
Canonical output suffixes a number only when the bare form would re-parse to a different width, so int/i32 and f64 print bare (floats always show a fractional part) while f32, i8, i16, all unsigned widths, and id/fid always print suffixed.
There is no syntax for hexadecimal, octal, or binary literals, digit separators (1_000), exponents (1e5), or NaN/inf.
- Widths are exact.
Type("i16").check_datum(Datum("42"))fails —42is ani32. Equality is width-sensitive too:Datum("2.0") != Datum("2f32"). convertdoes not re-width.int→i16andf64→f32are no-ops that leave the original width in place; construct the datum under the target type instead.float→intdoes work and truncates toward zero.- Inference differs by host, by design. A Python
intinfers asint; every JavaScriptnumber— integral or not — infers asfloat.Datum.inferin Python also rejects integers too large fori32; declare the type (Type("i64").datum(2**40)) instead. - Wide integers lose precision in JavaScript.
i64,u64, andi128cross the WASM boundary asNumberand round above 2^53; they also rejectBigInton input, so there is no exact representation for large values.idcrosses as a base50stringandfidas aBigInt, both exact — prefer them for large magnitudes. - Python accepts
Truewherever an integer is expected and truncates floats into integer types; JavaScript rejects both.
Collections
Lists, products, slices, tensors, maps, and sets nest freely, and any of them can be optional: [ecef?]?, {str: [km]}, #[{float | >0.0}; 3].
Maps
Maps have unordered semantics with one value per key. Their canonical form sorts entries by key and collapses duplicates keeping the value supplied last, so binary output is independent of dict/Map insertion order.
ty = Type("{str: km}")
ty.ser({"b": 1.0, "a": 2.0}) == ty.ser({"a": 2.0, "b": 1.0}) # True
JavaScript accepts a Map, plus a plain record when the key type is str, and
always returns a Map. Keys must be scalars, strings, or ids; composite keys
are unsupported. An empty map needs an explicit type. Avoid mixing boolean and
numeric keys in one map: Python equates True with 1 while a JavaScript Map
does not.
Sets
A set type is {T} and a set value is {a, b, ...}. Elements are stored sorted and de-duplicated, so equality and binary output ignore both insertion order and repeats.
ty = Type("{int}")
ty.py(ty.datum({3, 1, 2})) # {1, 2, 3}
ty.py(Datum("{1, 1, 2}")) # {1, 2} — duplicates collapse silently
ty.ser({3, 1, 2}) == ty.ser({2, 1, 3}) # True
Type("{{float | >0.0}}") # a refined element type
Python accepts a set or frozenset and returns a set; JavaScript accepts and returns a Set. A list is not a set — ty.datum([1, 2, 3]) fails.
{}is the empty map. There is no empty-set literal; get one in python from a typed conversion (e.g.,Type("{str}").datum(set())) or by deserializing. In JavaScript, you can donew Type("{str}").td(new Set()).- A sum element must be parenthesized:
{(int + bool)}. A bare{A + B}in a refined type is indistinguishable from a map key. - Set
Datums support neither indexing nor membership tests nor iteration. Convert to a nativeset/Setto inspect elements. - There is no set-to-list (or list-to-set) conversion, and no cardinality/size refinement.
- Python can infer a set type from a native
set; in JavaScript, declare the type explicitly.
Sums and Named Variants
A sum is written with +, and each branch may carry a variant name:
Type("int + str").pretty() # 'int + str'
Type("(ok: int + err: str)").pretty() # '(ok: int + err: str)'
A sum must be parenthesized when its first branch is labeled, which is why
(ok: int + err: str) keeps its parentheses in canonical output. Only a leading
label is ambiguous, so int + err: str needs no parentheses. Note that (x: A + B,) is a one-field product whose field has type A + B, not a sum.
Build and inspect variants through the API:
| Operation | Python | JavaScript |
|---|---|---|
| Construct a tagged branch | ty.variant(name, value) | ty.variant(name, value) |
| Test membership | td.is_variant(name) | td.isVariant(name) |
| List variant names | ty.keys(), td.keys() | ty.keys(), td.keys() |
| Branch count | len(ty) | ty.len() |
| Branch type / value by name | ty[name], td[name] | ty.get(name), td.get(name) |
ty = Type("(ok: int + err: str)")
td = ty.variant("ok", 5)
td.is_variant("ok") # True
td.keys() # ['ok', 'err']
td["ok"].py() # 5
td["err"] # error: TypedDatum is not variant 'err'
td.py() # 5 — the payload, untagged
An optional T? is a sum with the canonical variant names none and some, so the same API works on it: Type("int?").keys() is ['none', 'some'] and Type("int?").td(5).is_variant("some") is True. Named sum variants are how SedaroTS expresses an enumeration.
.py()/.object()returns the payload only, with no tag. Useis_variantto discriminate before reading.- Building a value from a native one picks the first structurally matching branch, not the branch you meant:
Type("(a: int + b: int)").td(5)is alwaysa.variant(name, value)is the only way to pin a specific branch, and a tag does not survive a round trip through a native value. - Only
Typecarries variant names.RawTypecannot parse them, andty.raw()drops them —Type("(ok: int + err: str)").raw()isint + str. Name-basedvariant()andis_variant()need a named sum; on an unnamed sum use positional indexing.variant()applies to sum types only. - Names are not on the wire; the encoding is a branch discriminant plus payload. Renaming a variant is therefore byte-neutral, appending a branch preserves existing values, and inserting or reordering branches changes them.
- Values are only textually round-trippable for
NoneandSome(x); useser/de_tdto move a variant value around, andpretty()only for display. - Type inference never produces a sum.
Refinements
A refined type is written {T | r1, r2, ...}: the base type first, then |, then a comma-separated refinement list. The refinements are units, bounds, reference frames, the tensor marker #, and distributions.
Refinements may be written in any order; canonical output orders them #, frame, unit, lower bound, upper bound, distribution:
Type("{float | >0.0, m}").pretty() # '{float | m, >0.0}'
Type("{float | m/s}").pretty() # 'm/s' (a bare unit is a float)
Type("{int | m}").pretty() # '{int | m}'
Which refinements a container may carry depends on the refinement:
- Units and bounds belong on scalars. Refine the element, not the container:
[{int | >0}](x: {float | >0.0}, y: {float | <0.0}){str: {int | <10}}#[{float | >0.0}; 3].
A bound on a container or an optional is an error. Write {int | >0}?, not {int? | >0}.
- The tensor marker
#, reference frames, and vector distributions apply to the container itself.#[3]is{(float, float, float) | #}eci<km>is{#[km; 3] | eci}; the#andecirefine the product whilekmrefines its elements.{(float, float, float) | ~unit_vector()}is the same shape: a distribution over a whole 3-vector.
SI Units
Nine dimensions are tracked: length, mass, time, temperature, current, amount, luminous intensity, angle, and data.
| Dimension | Base unit tokens |
|---|---|
| Length | m, mi, ft, inch |
| Mass | g |
| Time | s, min, hour, day |
| Temperature | K, and the absolute C, F |
| Current | A |
| Amount | mol |
| Luminous intensity | cd |
| Angle | rad, deg, rev |
| Data | B (bytes), b (bits) |
Any unit takes the full set of SI prefixes — Q R Y Z E P T G M k h da d c m u n p f a z y r q — so km, mg, us, ndeg all parse (u is micro). An optional _ may separate prefix from unit (k_m), and is required for the two spellings that would otherwise re-lex as a keyword (a_s, p_rev).
Compound units use * for products, ^N for powers, and a repeated / for the denominator: kg*m/s^2, m/s/s (which normalizes to m/s^2), 1/m. The parenthesized form /(x*y) is not accepted. Shorthands mph, rpm, Bps, and bps are also available, and a unit whose dimensions cancel entirely prints as float.
Conversion between any two units of the same dimensions is automatic; see Unit Conversion.
Absolute (Point-Like) Units
Four units are absolute: they name a point on a scale rather than a magnitude, so converting them applies an offset as well as a scale factor. Each has a relative counterpart, reachable with the Δ prefix (U+0394), which is the identity on units that are already relative.
| Absolute | Meaning | Δ counterpart |
|---|---|---|
mjd | Modified Julian Date timestamp | Δmjd → day |
unix | Unix-epoch timestamp in seconds | Δunix → s |
C | temperature reading in Celsius | ΔC → K |
F | temperature reading in Fahrenheit | ΔF (itself) |
Type("mjd").convert(Type("unix"), 60000.0) # 1677283200.0
Type("C").convert(Type("F"), 100.0) # 212.0
Type("ΔF").convert(Type("K"), 9.0) # 5.0 — a difference, scale only
Arithmetic follows affine-space rules. TypedDatum operands are normalized into the left operand's unit first:
| Expression | Result |
|---|---|
| point − point | a difference in the left unit's Δ counterpart — (60001.0: mjd) - (60000.0: mjd) is (1.0: day) |
| point ± difference | a point — (60000.0: mjd) + (24.0: hour) is (60001.0: mjd) |
| point + point | error — subtract them, or add a relative offset |
| difference ± point | error — write the absolute operand first |
point * or / anything | error — points are not magnitudes |
Absolute units take no SI prefix (kmjd is not a unit) and cannot appear in a compound unit: mjd^2, mjd/s, and C*C are parse errors. Use the relative counterpart for rates — ΔF/s is a cooling rate.
K is a relative unit, so (10.0: C) + (300.0: K) treats the K operand as a difference and yields (310.0: C) without complaint, while Type("K").convert(Type("C"), 300.0) treats the same number as a reading and yields 26.85. Converting a relative unit to an absolute one is legal too, and reinterprets the value as an offset from the scale's origin rather than rejecting it. Be explicit about whether a quantity is a reading or a difference. Use float/f64 for absolute-unit values; integer-typed timestamps are not supported.
Bounds and Ranges
Four comparison operators are available, plus Rust-style range sugar:
| Form | Meaning |
|---|---|
>V, >=V, <V, <=V | the corresponding bound |
A..B | >=A, <B |
A..=B | >=A, <=B |
A.. | >=A |
..B / ..=B | <B / <=B |
Ranges are input sugar only: a range's start is always inclusive, and canonical output is always the comparison form. Multiple bounds intersect to the tightest interval.
Type("{float | 0.0..=5.0}").pretty() # '{float | >=0.0, <=5.0}'
Type("{int | 0..20, 10..30}").pretty() # '{int | >=10, <20}'
Type("{int | 0..10}") == Type("{int | >=0, <10}") # True
Bounds work on any orderable scalar, including strings and ids: {str | "a".."m"}, {id | >80104u128}.
- A bound literal must match the refined type's representation exactly.
{float | >0}and{f32 | >0.0}are errors; write{float | >0.0},{f32 | >0f32},{i64 | >0i64},{u8 | >0u8},{id | >0u128}. - Only
checkenforces bounds.td(),ser,de, andsampledo not, andconvertvalidates only its source. CallType.check,Type.check_datum, orTypedDatum.checkwhere it matters. - Contradictory bounds are legal types that nothing satisfies (
{int | >=20, <10}). NaNis incomparable, so every bound on aNaNfails with a "cannot compare" error rather than an out-of-range error.minis the unit minute, not a bound. There is nomin/max/==/!=refinement, and there is no subtyping:{int | >0}andintare distinct types.
Reference Frames
Frames are refinements on a 3-component value. A bare frame keyword gives unrefined float coordinates; the parameterized alias form attaches units.
| Written | Expands to | Coordinates |
|---|---|---|
eci<U> | {#[U; 3] | eci} | Cartesian x, y, z |
ecef<U> | {#[U; 3] | ecef} | Cartesian x, y, z |
gc<U> | {#[U; 3] | gc} | geocentric Cartesian |
lla<A, L> | {(A, A, L) | #, lla} | geodetic latitude, longitude, altitude (WGS84) |
Bare eci, ecef, lla, gc, body, llaDeg, gcLla, gcLlaDeg, body_eci, eci_body, body_ecef, and ecef_body also parse.
- Use
f64coordinates. A frame type withf32or integer coordinates, such aseci<{f32 | km}>, parses, builds, checks, and serializes, but every conversion fails, including a same-frame unit change, because the transforms extract an exactf643-vector and do not widen a narrower element type. - Currently, supported frames have a default unit, and bare frame refinements
are assumed to be in this unit. For example, a bare
eciis interpreted as if it waseci<km>. This support is temporary. In the future such uses will be flagged as errors in the future in favor of requiring explicit units.
See Frame Conversion for converting between frames.
Aliases
An alias is a named type template written name<arg, ...>. It expands at parse time, and the expansion is what the type is — pretty-printing, equality, and serialization all show the expansion, never the alias.
Type("eci<km>").pretty() # '{#[km; 3] | eci}'
Type("lla<deg, mi>").pretty() # '{(deg, deg, mi) | #, lla}'
Type("eci<km/s>").pretty() # '{#[km/s; 3] | eci}'
Type("eci<km>") == Type("{#[km; 3] | eci}") # True
The available aliases are the four frame templates in the table above. Each takes a scalar carrying an SI unit of the right kind — a length for eci/ecef/gc, an angle then a length for lla — with any time derivative from s^0 down to s^-32, so eci<km>, eci<km/s>, and eci<km/s^2> are all valid. An argument may specify a width (eci<{f32 | mi}>), but not bounds, a frame, or field names.
Aliases work only in Type (and in TypedDatum text), and compose with every other constructor: [eci<km>], eci<km>?, (eci<km>, lla<deg, m>), {int: eci<km>}.
There is no runtime registration API in either binding: the alias catalog is defined inside SedaroTS itself. New aliases are added by a change to the type system, so file a request rather than looking for a hook. Errors name the alias and the offending position — Type("eci<rad>") reports that parameter $0 requires a length unit.
Distributions
A distribution can be attached to a scalar as a refinement with the ~ sigil, recording where a value came from:
Type("{float | ~uniform(0.0, 1.0)}")
Type("{m | ~normal(7000.0, 5.0)}")
Type("{#[3] | ~unit_vector()}")
| Distribution | Arguments | Notes |
|---|---|---|
uniform(min, max) | both required | continuous over [min, max) |
normal(mu, sigma) | both required | Gaussian |
triangular(min, max, mode) | 0, 2, or 3 positional | defaults min=0, max=1, mode=(min+max)/2 |
poisson(lam) | 0 or 1 | default lam=1 |
binomial(n, p) | both required | n a non-negative integer, 0 <= p <= 1 |
unit_vector() | none | uniform 3D unit vector; type must be a 3-vector of float |
discrete([v, ...]) | one list | uniform over the listed values |
discrete({v: w, ...}) | one map | weighted; weights are relative, need not sum to 1 |
Arguments may be passed by keyword in any order; canonical output is always positional and fully defaulted:
Type("{float | ~triangular(mode=2.0, max=10.0, min=0.0)}").pretty()
# '{float | ~triangular(0.0, 10.0, 2.0)}'
Type("{int | ~poisson()}").pretty() # '{int | ~poisson(1.0)}'
See Sampling for drawing values.
- A distribution refinement is not a constraint.
Type("{float | ~uniform(0.0, 1.0)}").check(99.0)succeeds, and sampling ignores any bounds on the same type — a{float | >0.0, ~normal(0.0, 1.0)}draw can be negative and then fail its owncheck. - One distribution per type, and no algebra: distributions cannot be added, scaled, or composed, and a distribution refinement is only sampleable at the top-level scalar (not through a list, product, or optional).
- Use
float/f64or integer element types.f32anddiscretelists of unsuffixed literals produce draws whose width does not match the declared type.
Values
Constructing and Reading
| Operation | Python | JavaScript |
|---|---|---|
Native value → TypedDatum | ty.td(value), ty(value) | ty.td(value) |
Native value → Datum | ty.datum(value) | ty.datum(value) |
Datum → native value | ty.py(datum) | ty.object(datum) |
TypedDatum → native value | td.py() | td.object() |
TypedDatum → its parts | td.type(), td.datum() | td.type(), td.datum() |
| Infer a type from a native value | Type.infer(value) | Type.infer(value) |
Infer a Datum | Datum.infer(value) | Datum.infer(value) |
| Strip refinements | ty.raw() | ty.raw() |
| Canonical text | .pretty() | .pretty(), .toString() |
td = Type("(power: f64, position: eci<km>, angle: deg?)").td((100.5, [7000.0, 0.0, 0.0], 180.0))
td["power"].py() # 100.5
td.pretty() # '((100.5, (7000.0, 0.0, 0.0), Some(180.0)) : (power: float, position: {#[km; 3] | eci}, angle: deg?))'
TypedDatum and Type both support indexing by field name, variant name, or integer position, plus keys() and length. A tensor-refined value crosses into Python as a NumPy array and into JavaScript as a nested Array.
Checking
check validates a value against a type and its refinements.
Type("{float | m, >0.0}").check(5.0) # returns None
Type("{float | m, >0.0}").check(-5.0) # raises: Expected -5.0 > 0.0
Type("{int | <10}").td(10).check() # raises — td() itself does not check
Type.check(value) takes a native value, Type.check_datum(datum) takes a Datum, and TypedDatum.check() re-validates a typed value (useful right after de_td).
Arithmetic
Arithmetic on TypedDatums is unit-aware: the right operand is converted into the left operand's unit first.
(Type("deg").td(180.0) + Type("rad").td(3.14)).py() # 359.90874767107846
Type("mph").td(60).convert(Type("m/s")).py() # 26.822333333333333
JavaScript uses named methods (add, sub, mul, div) that consume both operands, so re-use the returned value rather than the receiver. Adding quantities whose units are not convertible is an error, as is any arithmetic on absolute units beyond the rules in Absolute Units.
Interpolation and Extrapolation
lerp evaluates two coordinate/value samples according to their declared type.
The target is not clamped to the source interval, so the same operation performs
both interpolation and extrapolation.
| Values | Python | JavaScript |
|---|---|---|
| Native | ty.lerp(coordinate0, value0, coordinate1, value1, target) | ty.lerp(coordinate0, value0, coordinate1, value1, target) |
Datum | ty.lerp_datum(coordinate0, datum0, coordinate1, datum1, target) | ty.lerpDatum(coordinate0, datum0, coordinate1, datum1, target) |
TypedDatum | value0.lerp(coordinate0, value1, coordinate1, target) | value0.lerp(coordinate0, value1, coordinate1, target) |
RawType provides the same type-owned methods, without refinement-aware angle
or quaternion behavior.
Type("float").lerp(0.0, 10.0, 2.0, 20.0, 3.0) # 25.0 — extrapolation
Type("deg").lerp(0.0, 350.0, 1.0, 10.0, 0.5) # 360.0 — shortest arc
state = Type("(temperature: float, mode: str)")
state.lerp(0.0, (0.0, "idle"), 1.0, (20.0, "heating"), 0.5)
# (10.0, "idle")
Behavior is type-directed and recursive:
| Type | Behavior |
|---|---|
f32, f64 | Linear interpolation, preserving the numeric width. |
| Ordinary signed and unsigned integers | Linear calculation through f64, then truncation toward zero and saturation to the integer's range. |
Pure plane-angle scalar (deg, rad, rev, etc.) | Shortest-arc interpolation in the declared unit. Angular rates such as rad/s remain linear. |
Four-float body_eci or body_ecef quaternion tensor | Shortest-path spherical linear interpolation (SLERP), with normalized non-endpoint results. |
| Product, tensor, equal-length list, equal-key map, or same-variant sum/optional | Positional or value-wise recursion. |
id, fid, Boolean, string, set, epoch, distribution, and other discrete values | Left-continuous hold: sample 0 is retained until coordinate1; sample 1 is used at and beyond it. |
A changed list length, map key set, or sum/optional variant makes the entire
value discrete instead of pairing unrelated elements. Quaternion inputs must be
finite and nonzero; attaching an attitude frame to any shape other than a
four-element homogeneous f32 or f64 tensor is rejected.
- Coordinates must be finite and the two source coordinates must be distinct. Exact source endpoints are returned without arithmetic, preserving values such as an unnormalized angle or an equivalent quaternion sign.
- Angle results are not normalized to a canonical period, and no result is
clamped.
lerpdoes not enforce declared bounds on its inputs or output; callcheckexplicitly where bounds must be enforced. TypedDatum.lerprequires both values to have exactly the same complete type, including units and refinements. Convert one value explicitly before calling when their types differ.
Patching and Updating
A TypedDatum can be updated from a compatible patch TypedDatum — | in Python, .update(other) in JavaScript.
typed_datum = Type("(x: float, y: float, z: float)").td((1, 2, 3))
typed_datum |= Type("(z: float,)").td((4,))
typed_datum.py() # (1.0, 2.0, 4.0)
Only products with field names can be patched sparsely. An unnamed product is replaced wholesale, and the result's Type must be unchanged:
typed_datum = Type("(float, float, float)").td((1, 2, 3))
typed_datum | Type("(float,)").td((4,)) # error
typed_datum | Type("(float, float, float)").td((1, 2, 4)) # OK
Conversion
Type.convert(target, value, deps=None) converts a native value; Type.convert_datum(target, datum, deps=None) and TypedDatum.convert(target, deps=None) convert wrapped values. deps is a ConDeps and is only needed for frame and distribution conversions.
Unit Conversion
Any two types with the same dimensions convert, including through containers, and units may be combined with a change of numeric width:
Type("deg").td(180.0).convert(Type("rad")).py() # 3.141592653589793
Type("deg").td(180.0).convert(Type("{i32 | rad}")).py() # 3
Type("deg?").td(180.0).convert(Type("rad?")).py() # 3.141592653589793
Type("[km]").convert(Type("[m]"), [1.0, 2.0]) # [1000.0, 2000.0]
Absolute units convert affinely — see Absolute Units.
Frame Conversion
ConDeps supplies the external state a frame conversion needs:
ConDeps(time=None, attitude=None, position=None, seed=None) # Python: keywords
new ConDeps(time, attitude, position, seed); // JavaScript: positional, null for absent
time is a Modified Julian Date, position is an ECI Cartesian position in kilometers, and seed is used only by sampling. A missing dependency is always an error, never a silent default. In JavaScript a ConDeps is consumed by the call it is passed to, so construct a fresh one per conversion.
Position transforms normalize coordinates to the canonical units used by the
transform before scaling the result into the target units. Cartesian frames
use kilometers; LLA uses radians, radians, and kilometers. This supports
conversions such as ecef<m> to lla<deg, mi>.
| Conversion | Supported frame pairs | Requires |
|---|---|---|
| Position | eci ↔ ecef | time |
| Position | eci ↔ lla | time |
| Position | ecef ↔ lla | nothing |
| Velocity | eci ↔ ecef | time and position |
| Unit change within one frame | any | nothing |
Velocity conversion uses the transport theorem and requires both the MJD
time and an ECI Cartesian position in kilometers.
# Position: ECI metres to ECEF kilometres
Type("eci<m>").convert(Type("ecef<km>"), (7_000_000.0, 0.0, 0.0), ConDeps(time=51544.0))
# array([-1211.66, -6894.34, -0.19])
# No dependencies needed between ECEF and LLA
Type("ecef<km>").convert(Type("lla<deg, mi>"), (6378.137, 0.0, 0.0)) # array([0., 0., 0.])
# Velocity: needs both time and an ECI position in km
deps = ConDeps(time=58660.20568634244, position=(6871.0, 0.0, 0.0))
Type("eci<km/s>").convert(Type("ecef<km/s>"), (0.0, 7.61, 0.0), deps)
# array([-1.498, 6.949, -0.00014])
Positions and velocities are told apart by their units: three length-dimensioned coordinates are a position, three length-per-time coordinates are a velocity. Coordinates without explicit units are assumed to be in the canonical units — kilometers for Cartesian frames, (radians, radians, kilometers) for LLA — so always give explicit units. Cross-frame conversions involving ECI require time, but same-frame unit conversions do not require dependencies. Mixing a position and a velocity across a conversion is an error.
Unsupported and rejected: cross-frame accelerations and higher derivatives, cross-frame LLA rates, any conversion involving gc, body, llaDeg, gcLla, gcLlaDeg, or the attitude frames, and frame-to-non-frame conversions. Pure unit scaling within a single frame does work for every accepted exponent, including LLA rates, when both sides carry explicit units.
Accuracy: ECI↔ECEF transforms use fast IAU2000A nutation with GMST and no polar motion. Expect agreement of roughly 0.1 km in position and 2e-4 km/s in velocity against a high-precision reference, not exact agreement.
Sampling
Type.sample(seed) draws from a distribution refinement and returns a native value; Type.sample_td(seed) returns a TypedDatum.
| Operation | Python | JavaScript | Returns |
|---|---|---|---|
| Sample a distribution refinement | ty.sample(seed=None) | ty.sample(seed?) | native value |
… as a TypedDatum | ty.sample_td(seed=None) | ty.sample_td(seed?) | TypedDatum |
Type("{float | ~uniform(0.0, 1.0)}").sample(0) # 0.8833108082136426
Type("{int | ~uniform(0, 10)}").sample(0) # 8 — truncated toward zero
Type("{m | ~uniform(0.0, 1.0)}").sample_td(0).pretty()
# '(0.8833108082136426 : {float | m, ~uniform(0.0, 1.0)})'
With an explicit seed the draw is deterministic and identical across both bindings and every sampling entry point; the seed is a 64-bit integer, passed as a BigInt in JavaScript. Omitting the seed draws a nondeterministic one from the host (OS entropy in Python, Math.random() in WASM). Each call is one draw — iterate over seeds to reproduce a sequence.
Serialization
SedaroTS values serialize to a compact binary format. It is frameless: a record carries no length prefix or tag, so the reader must already know the Type. Bytes are identical across bindings, and the canonical ordering of maps and sets makes them independent of host container order.
All serialization methods live on Type and RawType. Python takes and returns bytes; JavaScript takes and returns Uint8Array.
| Operation | Method | Returns |
|---|---|---|
| One native value out / in | ser(value) / de(bytes) | bytes / native value |
One Datum out / in | ser_datum(d) / de_datum(bytes) | bytes / Datum |
One TypedDatum in | de_td(bytes) | TypedDatum |
| Many values → one buffer | ser_all(iterable) | bytes, identical to concatenated ser |
| One buffer → many values | de_all(bytes) | lazy iterator |
| One value → a sink | ser_to(value, sink) | — |
| Many values → a sink | ser_all_to(iterable, sink) | — |
| One value ← a source | de_from(source) | native value |
| Many values ← a source | de_all_from(source) | lazy iterator |
Single Values
ty = Type("f64")
ty.de(ty.ser(180.0)) # 180.0
ty.de_td(ty.ser(180.0)).py() # 180.0
# The type used to decode need not be the one used to encode, as long as it is compatible
Type("deg").de_td(Type("f64").ser(180.0)).convert(Type("rad")).py() # 3.14159...
Many Values
ser_all writes records back to back and de_all reads them back lazily. The bytes are exactly the concatenation of the individual ser calls.
ty = Type("int")
values = list(range(10))
ty.ser_all(values) == b"".join(ty.ser(v) for v in values) # True
list(ty.de_all(ty.ser_all(values))) == values # True
list(ty.de_all(b"")) # [] — an empty run, not an error
const ty = new Type("i32");
[...ty.de_all(ty.ser_all([0, 1, 2, 3, 4]))]; // [0, 1, 2, 3, 4]
Streaming
ser_to / ser_all_to write to any sink exposing .write(chunk), and de_from / de_all_from read from any source exposing .read(n) — an open binary file in Python, any duck-typed object in JavaScript. A sink may return the number of bytes it accepted; anything else means it took the whole chunk. ser_all_to writes in bounded chunks, so the sequence it is given may be arbitrarily long.
with open(path, "wb") as f:
ty.ser_all_to(range(1_000_000), f) # bounded memory, never materialized
with open(path, "rb") as f:
for value in ty.de_all_from(f): # lazy
...
# de_from reads exactly one record and never over-reads, so it composes with other reads
with open(path, "rb") as f:
while True:
try:
handle(ty.de_from(f))
except EOFError: # clean end of stream
break
In JavaScript, de_from throws at a clean end of stream instead of raising EOFError, and the iterators return {done: true}.
Because the format is frameless, a self-describing file is one you frame yourself — write Type.pretty() as a header, then the records:
signature = "(time: mjd, altitude: {float | km})"
with open(path, "wb") as f:
header = signature.encode()
f.write(len(header).to_bytes(4, "big"))
f.write(header)
Type(signature).ser_all_to(frames, f)
ser_allandser_all_toiterate their argument. Passing one value that happens to be iterable silently writes several records —Type("str").ser_all("abc")writes three one-character records. Wrap single values in a list.- A wrong-but-compatible type decodes to garbage. Nothing on the wire identifies the type; ship the signature alongside the bytes.
ser_alltakes native values, notDatums. Concatenateser_datumcalls instead — the bytes match.derequires the buffer to be fully consumed; trailing bytes are an error. Usede_allorde_fromto peel one record off a longer buffer.de_all_fromreads ahead, so onlyde_fromleaves the source positioned exactly after its record.ser_all_tois not atomic: records written before a failure stay written.- A zero-width record type (such as
()) cannot form a sequence, andde_arrayis unsupported — useser_all/de_all.
Demo
Pretty Printing
Dynamic Inference
Dynamic Conversion
Dynamic Type Checking
TypedDatums
A TypedDatum is written (value : type) — for example (10 : {float | m/s/s}), ((10, false) : (i32, bool)), or (10 : i32).
Indexing Demo
TypedDatum and Type provide indexed access by numeric position, field name, or variant name.
Arithmetic Demo
TRD Object Types
Frame Conversion Demo
The interactive example below demonstrates the supported position and velocity conversions described above. Use explicit units when entering a velocity or a non-canonical position unit.