Skip to main content

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:

  1. 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.
  2. 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.
  3. 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).

info

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​

ObjectHoldsConstruct with
RawTypea type with no refinements (int, [str], (f64, bool))RawType("[str]"), RawType.infer(value)
Typea type plus refinements (units, bounds, frames, distributions)Type("{f64 | km}"), Type.infer(value), Type("eci<km>")
Datuma value with no type attachedDatum("(1.0, 2.0)"), Datum.infer(value), some_type.datum(value)
TypedDatuma value and its Typesome_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).

note

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:

BehaviorPythonJavaScript
Native value out.py().object()
Shallow native value.py_shallow().object_shallow()
Indexingdatum[key], td[key].get(key)
Lengthlen(x).len(), and .length on Datum
Equality==.equals(other) (=== remains identity)
Truthinessbool(x).isTruthy()
Arithmetic+ - * /.add(), .sub(), .mul(), .div() (where supported)
Patch / update|.update(other)
Variant predicate.is_variant(name).isVariant(name)
Distribution seedintBigInt (7n)
note

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​

KindType syntaxValue textPython valueJavaScript valueRust type
Boolbooltrue, falseboolbooleanbool
Intint, i8–i128, u8–u64123, 42i16, 7u8intnumberi32 (i8–i128, u8–u64)
Floatfloat, f32, f641.23, 2.0f32floatnumberf64 (f32)
Stringstr"foo"strstringString
IDid, u12886734u128base50 strbase50 stringu128
FIDfid86734u128intBigIntu128
Empty product()()Nonenull()
Never!————
List[T][1, 2, 3]listArrayVec<T>
Product(T, U), (x: T, y: U)(1, 2.0), (1,)tupleArray(T, U)
Slice[T; N](1.0, 2.0, 3.0)listArray(T, …, T)
Tensor#[T; N], #[#[T; N]; M](1.0, 2.0, 3.0)NumPy ndarraynested ArraySVector<T, N>, SMatrix<T, N, M>
Map{K: V}{"a": 1, "b": 2}dictMapBTreeMap<K, V>
Set{T}{1, 2}, {7,}set/frozensetSetBTreeSet<T>
OptionalT?Some(1), Nonevalue or Nonevalue or nullOption<T>
SumA + B, (ok: A + err: B)built with variant()the payload, untaggedthe payload, untaggedSum2<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.

Notes on Numbers
  • Widths are exact. Type("i16").check_datum(Datum("42")) fails — 42 is an i32. Equality is width-sensitive too: Datum("2.0") != Datum("2f32").
  • convert does not re-width. int → i16 and f64 → f32 are no-ops that leave the original width in place; construct the datum under the target type instead. float → int does work and truncates toward zero.
  • Inference differs by host, by design. A Python int infers as int; every JavaScript number — integral or not — infers as float. Datum.infer in Python also rejects integers too large for i32; declare the type (Type("i64").datum(2**40)) instead.
  • Wide integers lose precision in JavaScript. i64, u64, and i128 cross the WASM boundary as Number and round above 2^53; they also reject BigInt on input, so there is no exact representation for large values. id crosses as a base50 string and fid as a BigInt, both exact — prefer them for large magnitudes.
  • Python accepts True wherever 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.

Notes on Sets
  • {} 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 do new 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 native set/Set to 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:

OperationPythonJavaScript
Construct a tagged branchty.variant(name, value)ty.variant(name, value)
Test membershiptd.is_variant(name)td.isVariant(name)
List variant namesty.keys(), td.keys()ty.keys(), td.keys()
Branch countlen(ty)ty.len()
Branch type / value by namety[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.

Notes on Sums
  • .py() / .object() returns the payload only, with no tag. Use is_variant to 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 always a. 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 Type carries variant names. RawType cannot parse them, and ty.raw() drops them — Type("(ok: int + err: str)").raw() is int + str. Name-based variant() and is_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 None and Some(x); use ser/de_td to move a variant value around, and pretty() 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 # and eci refine the product while km refines 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.

DimensionBase unit tokens
Lengthm, mi, ft, inch
Massg
Times, min, hour, day
TemperatureK, and the absolute C, F
CurrentA
Amountmol
Luminous intensitycd
Anglerad, deg, rev
DataB (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.

AbsoluteMeaningΔ counterpart
mjdModified Julian Date timestampΔmjd → day
unixUnix-epoch timestamp in secondsΔunix → s
Ctemperature reading in CelsiusΔC → K
Ftemperature 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:

ExpressionResult
point − pointa difference in the left unit's Δ counterpart — (60001.0: mjd) - (60000.0: mjd) is (1.0: day)
point ± differencea point — (60000.0: mjd) + (24.0: hour) is (60001.0: mjd)
point + pointerror — subtract them, or add a relative offset
difference ± pointerror — write the absolute operand first
point * or / anythingerror — 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.

note

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:

FormMeaning
>V, >=V, <V, <=Vthe 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}.

Notes on Bounds
  • 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 check enforces bounds. td(), ser, de, and sample do not, and convert validates only its source. Call Type.check, Type.check_datum, or TypedDatum.check where it matters.
  • Contradictory bounds are legal types that nothing satisfies ({int | >=20, <10}).
  • NaN is incomparable, so every bound on a NaN fails with a "cannot compare" error rather than an out-of-range error.
  • min is the unit minute, not a bound. There is no min/max/==/!= refinement, and there is no subtyping: {int | >0} and int are 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.

WrittenExpands toCoordinates
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.

Notes on Frames
  • Use f64 coordinates. A frame type with f32 or integer coordinates, such as eci<{f32 | km}>, parses, builds, checks, and serializes, but every conversion fails, including a same-frame unit change, because the transforms extract an exact f64 3-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 eci is interpreted as if it was eci<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>}.

note

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()}")
DistributionArgumentsNotes
uniform(min, max)both requiredcontinuous over [min, max)
normal(mu, sigma)both requiredGaussian
triangular(min, max, mode)0, 2, or 3 positionaldefaults min=0, max=1, mode=(min+max)/2
poisson(lam)0 or 1default lam=1
binomial(n, p)both requiredn a non-negative integer, 0 <= p <= 1
unit_vector()noneuniform 3D unit vector; type must be a 3-vector of float
discrete([v, ...])one listuniform over the listed values
discrete({v: w, ...})one mapweighted; 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.

Notes on Distribution
  • 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 own check.
  • 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/f64 or integer element types. f32 and discrete lists of unsuffixed literals produce draws whose width does not match the declared type.

Values​

Constructing and Reading​

OperationPythonJavaScript
Native value → TypedDatumty.td(value), ty(value)ty.td(value)
Native value → Datumty.datum(value)ty.datum(value)
Datum → native valuety.py(datum)ty.object(datum)
TypedDatum → native valuetd.py()td.object()
TypedDatum → its partstd.type(), td.datum()td.type(), td.datum()
Infer a type from a native valueType.infer(value)Type.infer(value)
Infer a DatumDatum.infer(value)Datum.infer(value)
Strip refinementsty.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.

ValuesPythonJavaScript
Nativety.lerp(coordinate0, value0, coordinate1, value1, target)ty.lerp(coordinate0, value0, coordinate1, value1, target)
Datumty.lerp_datum(coordinate0, datum0, coordinate1, datum1, target)ty.lerpDatum(coordinate0, datum0, coordinate1, datum1, target)
TypedDatumvalue0.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:

TypeBehavior
f32, f64Linear interpolation, preserving the numeric width.
Ordinary signed and unsigned integersLinear 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 tensorShortest-path spherical linear interpolation (SLERP), with normalized non-endpoint results.
Product, tensor, equal-length list, equal-key map, or same-variant sum/optionalPositional or value-wise recursion.
id, fid, Boolean, string, set, epoch, distribution, and other discrete valuesLeft-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.

Notes on Interpolation
  • 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. lerp does not enforce declared bounds on its inputs or output; call check explicitly where bounds must be enforced.
  • TypedDatum.lerp requires 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>.

ConversionSupported frame pairsRequires
Positioneci ↔ eceftime
Positioneci ↔ llatime
Positionecef ↔ llanothing
Velocityeci ↔ eceftime and position
Unit change within one frameanynothing

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.

warning

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.

OperationPythonJavaScriptReturns
Sample a distribution refinementty.sample(seed=None)ty.sample(seed?)native value
… as a TypedDatumty.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.

OperationMethodReturns
One native value out / inser(value) / de(bytes)bytes / native value
One Datum out / inser_datum(d) / de_datum(bytes)bytes / Datum
One TypedDatum inde_td(bytes)TypedDatum
Many values → one bufferser_all(iterable)bytes, identical to concatenated ser
One buffer → many valuesde_all(bytes)lazy iterator
One value → a sinkser_to(value, sink)—
Many values → a sinkser_all_to(iterable, sink)—
One value ← a sourcede_from(source)native value
Many values ← a sourcede_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)
Notes on Serialization
  • ser_all and ser_all_to iterate 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_all takes native values, not Datums. Concatenate ser_datum calls instead — the bytes match.
  • de requires the buffer to be fully consumed; trailing bytes are an error. Use de_all or de_from to peel one record off a longer buffer.
  • de_all_from reads ahead, so only de_from leaves the source positioned exactly after its record.
  • ser_all_to is not atomic: records written before a failure stay written.
  • A zero-width record type (such as ()) cannot form a sequence, and de_array is unsupported — use ser_all/de_all.

Demo​


Pretty Printing​

⚠️ Please install @sedaro/simvm_wasm

Dynamic Inference​

⚠️ Please install @sedaro/simvm_wasm

Dynamic Conversion​

⚠️ Please install @sedaro/simvm_wasm

Dynamic Type Checking​

⚠️ Please install @sedaro/simvm_wasm

TypedDatums​

A TypedDatum is written (value : type) — for example (10 : {float | m/s/s}), ((10, false) : (i32, bool)), or (10 : i32).

⚠️ Please install @sedaro/simvm_wasm

Indexing Demo​

TypedDatum and Type provide indexed access by numeric position, field name, or variant name.

⚠️ Please install @sedaro/simvm_wasm

Arithmetic Demo​

⚠️ Please install @sedaro/simvm_wasm

TRD Object Types​

⚠️ Please install @sedaro/simvm_wasm

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.

⚠️ Please install @sedaro/simvm_wasm