Unit Utils

23 min read

Strongly-typed unit structs for length, mass, time, speed, and the other physical quantities a game has to track, so you can never silently add kilometres to miles.

Summary

Scylla’s unit types make a physical quantity, a length, a mass, a duration, a temperature, etc., into its own type that carries its unit along with the value, instead of a bare float that could mean anything. A LengthUnit is a length, full stop, not a number that happens to be meters until some function reads it back as feet. Reading or writing a specific unit goes through named properties (Meter, Mile, LightYear, etc.); arithmetic between two lengths returns a length; dividing one length by another returns a plain dimensionless ratio. Because the unit rides in the type, the old float distance that means meters in one file and feet in another simply cannot arise.

There are eight unit types, plus one supporting format type. Each stores a single canonical SI value (meters, kilograms, seconds, pascals, and so on) in a double field and exposes dozens of conversion properties for named alternate units. Reading any property returns the canonical value re-expressed in that unit; writing it stores the equivalent canonical value. Setters validate the incoming value through NumberUtil.EnsureFinite from Math and Number Utils (and EnsureFiniteNonNegative for DataUnit), so NaN and infinity cannot enter through the front door.

The eight types are pure value types with no allocations, no Unity dependencies, and no IDs. They cover the magnitude ranges that game code actually deals with, including extremes the player never sees (Planck length, attogram, fraction of light speed) for simulation code and astronomical work. Auto-scaling ToString() picks an appropriate unit and decimal precision for the current magnitude, so a LengthUnit formats as "1.5 km" or "4.2 light years" depending on how large the value is, without manual unit selection at the call site.

The companion TimeFormat struct supplies a token-based pattern (Y, W, D, H, m, s, f) that TimeUnit.Formatted(...) uses for structured output. The static TimeFormat.StandardClock = "HH:mm:ss" covers the typical clock case. Run length on each token sets the minimum zero-padded width, so "HH:mm:ss.fff" produces millisecond-precision strings.

Use the unit types for:

  • Any value with a physical meaning: distance, mass, time, temperature, pressure, speed.
  • File and asset sizes (bytes, kilobytes, gigabytes) where mixing SI decimal and IEC binary prefixes is a real risk.
  • Game design values that designers express in real-world units: a car’s top speed in mph or km/h, an explosion radius in meters.
  • UI display values where unit choice should auto-scale: a “1.5 km” reading on a HUD that turns into “850 m” as the target approaches.
  • Cross-system communication where the unit must be explicit: a physics integrator that consumes seconds, a network protocol that ships milliseconds.
  • Astronomical and microscopic simulation where the canonical unit alone would underflow or overflow a float.

Features

  • Eight unit types. LengthUnit, MassUnit, TimeUnit, AreaUnit, SpeedUnit, PressureUnit, TemperatureUnit, DataUnit. Each is a pure struct with a single double field.
  • Canonical SI internal representation. Every type stores its value in one canonical SI unit (meter, kilogram, second, square meter, m/s, pascal, Celsius, byte). Conversions are derived; the field never carries any other unit’s value.
  • Dozens of conversion properties per type. LengthUnit covers Planck length through gigaparsec; MassUnit covers attogram through yottaton; TimeUnit covers Planck time through millennia; SpeedUnit covers atomic-scale velocities through fractions of the speed of light. Each conversion is a double property with both a getter and setter.
  • Validated input. Setters route through NumberUtil.EnsureFinite. NaN and infinity throw ArgumentOutOfRangeException at the boundary rather than silently propagating.
  • Non-negative enforcement on DataUnit. Every DataUnit setter and the subtraction operator route through NumberUtil.EnsureFiniteNonNegative. Subtracting a larger size from a smaller one throws rather than producing nonsense.
  • Full operator overload set. +, -, unary -, *, / defined for two units and for a unit combined with a scalar. Unit-by-unit division returns a dimensionless double ratio.
  • Approximate equality. == and != use NumberUtil.Approximately on the canonical value to absorb rounding noise from chained conversions. Strict ordering operators (<, >, <=, >=) compare exactly.
  • Auto-scaling ToString(). Output unit is selected from the type’s magnitude family (km, m, mm; year, day, hour, minute, second; GB, MB, kB, byte; …) at the current magnitude. Decimal precision follows a “two decimals below 10, one decimal below 100, none above” pattern; trailing zeros are stripped.
  • TimeUnit.Formatted(TimeFormat?) for structured clock output. Token-based pattern with seven token characters (Y, W, D, H, m, s, f) and minimum-width control via repeat count. Defaults to TimeFormat.StandardClock ("HH:mm:ss").
  • Exact unit definitions. Imperial constants use the legal definitions (1 lb = 0.45359237 kg exact; 1 inch = 0.0254 m exact). Astronomical constants use IAU values for AU and parsec, the exact speed of light, and a Julian year of 365.25 days.
  • No Unity dependency. The unit types live under Scylla.Core.Util.Units and reference only System.* and Scylla.Core utilities. They work in console code, editor tools, and headless tests without a Unity context.

Unit types

The framework currently ships eight unit types plus the TimeFormat companion. The table below summarises the canonical unit, the magnitude range, and the sign policy for each.

TypeCanonical unitRangeAllows negatives
LengthUnitmeter (m)Planck length to gigaparsecYes
MassUnitkilogram (kg)attogram to yottatonYes
TimeUnitsecond (s)Planck time to millenniumYes
AreaUnitsquare meter (m2)fm2 to Gpc2Yes
SpeedUnitmeter per second (m/s)ym/s to fraction of cYes
PressureUnitpascal (Pa)yPa to TPaYes
TemperatureUnitCelsius (deg C)absolute zero upwardYes
DataUnitbytebit to quettabyteNo (>= 0)

TimeFormat is the small companion type that supplies token-based patterns for TimeUnit.Formatted(...).

The shared pattern

Every unit type follows the same shape. Once you understand one, the rest follow naturally.

  • One canonical field. Stored as a double in the canonical SI unit (meters, kilograms, seconds, …). Every conversion property translates between the named unit and this single field.
  • Property pairs for every supported unit. A property getter returns the canonical value re-expressed in the named unit; the setter validates the incoming value and writes the canonical equivalent. For example, LengthUnit.Mile reads as miles, and assigning to it stores the equivalent in meters.
  • Validated input. Setters reject NaN and infinity via NumberUtil.EnsureFinite. DataUnit additionally rejects negatives via NumberUtil.EnsureFiniteNonNegative.
  • Operator overloads. +, -, unary -, *, / are defined for two units and for a unit combined with a scalar (int or double). Dividing one unit by another of the same type returns a dimensionless double ratio.
  • Approximate equality. == and != use NumberUtil.Approximately on the canonical value to absorb rounding noise. Ordering operators (<, >, <=, >=) use exact comparison.
  • Auto-formatted output. ToString() picks an appropriate unit and decimal precision for the current magnitude. Values below 10 print with two decimals, values below 100 with one, and the rest with none; trailing zeros are stripped.

Recipes

These recipes cover every public surface in the unit system. Each one is a self-contained answer to a single problem, so jump to the one that matches what you are building. The first recipe covers construction, which the rest all assume; if you are new to the API, start there.

Create a unit value at the call site

Problem. You want to write “50 miles per hour” or “3 days” directly at the call site without first hand-converting to SI. The call site should read like the game design doc, not like a physics textbook.

Solution. Every unit type exposes a constructor that accepts the canonical SI value and a default-constructed (zero-value) parameterless form. Most calling code uses the default constructor plus a property assignment, because that pattern reads cleanly at the call site even when the canonical unit is not the desired one.

/* Default construction: zero in the canonical unit. */
var length = new LengthUnit();

/* Property assignment: clearer at the call site than passing a magic meter value.
 * A designer reading this line sees "1.5 km", not "1500". */
length.Kilometer = 1.5;

/* Constructor with canonical value, when the value is already in SI form -
 * data from a config file that stores seconds, for instance. */
var seconds = new TimeUnit(60d);   /* 60 seconds */

/* Object-initializer assignment reads even better when the unit name matters. */
var mass = new MassUnit { Pound = 150 };

/* The compiler optimizes all three forms to equivalent code. */

Convert between units

Problem. Say you’re reading a car’s top speed from a config file in kilometers per hour, but the physics integrator wants meters per second. Or a player’s run distance comes out of the physics layer in meters, and you need to display it in miles for a North American audience.

Solution. Every supported unit is a read-write property on the struct. Reading converts from the canonical value to the named unit; writing converts back. The property pair is symmetric: if you assign through LengthUnit.Mile and then read LengthUnit.Kilometer, you get the correct equivalent.

var distance = new LengthUnit { Kilometer = 100 };

/* Read out as different units. Each property converts from the canonical meter value. */
double meters    = distance.Meter;        /* 100000                  */
double miles     = distance.Mile;         /* ~62.137                 */
double feet      = distance.Foot;         /* ~328084                 */
double lightSec  = distance.LightSecond;  /* ~0.000333564            */

/* Writing through any property updates the canonical value in place. */
distance.Mile = 50;                       /* canonical now ~80467 m  */
var asKm = distance.Kilometer;            /* ~80.467                 */

/* Mass conversions cover scientific and trade units side by side. */
var bagOfFlour = new MassUnit { Kilogram = 22.7 };
double lbs    = bagOfFlour.Pound;         /* ~50.04 */
double stones = bagOfFlour.Stone;         /* ~3.575 */
double carats = bagOfFlour.MetricCarat;   /* 113500 */

Add, scale, and compare unit values in gameplay code

Problem. You want to track a projectile’s cumulative travel distance across multiple frames, scale a reference radius by a difficulty multiplier, or ask “has the boss crossed the aggro threshold?” without dropping the unit tag. Anyone who has tracked health pools, time budgets, or distances in a real codebase has wanted to add and subtract them like numbers while keeping the type annotation.

Solution. Operator overloads make the unit types behave like the underlying numeric type. +, -, unary -, *, and / work for two same-type units and for a unit combined with a scalar. Dividing one unit by another of the same type returns a dimensionless double ratio. For equality checks, == and != use NumberUtil.Approximately on the canonical value, which absorbs rounding noise from chained conversions; ordering operators compare exactly.

var roomA = new AreaUnit { SquareMeter = 24 };
var roomB = new AreaUnit { SquareMeter = 36 };

/* Same-type arithmetic returns a unit value of the same type. */
var total      = roomA + roomB;                /* AreaUnit, 60 m2    */
var difference = roomB - roomA;                /* AreaUnit, 12 m2    */
var doubled    = roomA * 2;                    /* AreaUnit, 48 m2    */
var halved     = roomB / 2;                    /* AreaUnit, 18 m2    */

/* Unit-by-unit division returns a plain dimensionless ratio. */
double ratio   = roomB / roomA;                /* double, 1.5        */

/* Unary minus negates the canonical value. */
var debt = -new MassUnit { Kilogram = 5 };     /* MassUnit, -5 kg    */

/* Approximate equality for threshold checks that survive chained conversions. */
var sprint   = new LengthUnit { Mile = 1 };
var thousand = new LengthUnit { Meter = 1609.344 };

bool equal   = sprint == thousand;             /* true (Approximately) */

/* Exact ordering for aggro thresholds, timer gates, or range checks. */
var aggroRange = new LengthUnit { Meter = 15 };
var playerDist = new LengthUnit { Meter = 14.99 };

bool inRange = playerDist < aggroRange;        /* true, exact comparison */

/* When you need a bit-for-bit comparison, compare the canonical property directly. */
bool bitwiseEqual = sprint.Meter == thousand.Meter;

Display measurements on a HUD or in a log

Problem. There’s always a HUD label that wants “1.5 km” when the distance is large and “25 m” when it’s small, without you writing the branching logic by hand. And log lines that dump “1500000 mm” are worse than useless. Every unit type implements ToString() with magnitude-aware unit selection and adaptive decimal precision – the output works for log lines, HUD labels, and tooltip text without any manual unit selection.

Solution. Call ToString() directly, or let string interpolation call it for you. The auto-scaling covers the full magnitude family for each type (km, m, mm for length; year, day, hour, minute, second for time; GB, MB, kB, byte for data), and precision follows a “two decimals below 10, one decimal below 100, none above” pattern with trailing zeros stripped.

/* Length auto-scales across the entire metric and astronomical range. */
Log.Info(new LengthUnit { Meter = 0.5 }.ToString(),         LogCategory.Core); /* "50 cm"           */
Log.Info(new LengthUnit { Meter = 1500 }.ToString(),        LogCategory.Core); /* "1.5 km"          */
Log.Info(new LengthUnit { LightYear = 4.2 }.ToString(),     LogCategory.Core); /* "4.2 light years" */

/* Time auto-scales from sub-millisecond up through days and years. */
Log.Info(new TimeUnit { Second = 0.5 }.ToString(),          LogCategory.Core); /* "500 ms"          */
Log.Info(new TimeUnit { Day = 1.5 }.ToString(),             LogCategory.Core); /* "1.5 days"        */

/* Data auto-scales between SI decimal byte prefixes. */
Log.Info(new DataUnit { Byte = 250_000_000_000 }.ToString(),LogCategory.Core); /* "250 GB"          */

/* Mass auto-scales across metric prefixes. */
Log.Info(new MassUnit { Gram = 12.345 }.ToString(),         LogCategory.Core); /* "12.3 g"          */

Format a speedrun timer or countdown display

Problem. Sometimes the time display is the focal point of the screen: a speedrun timer, a boss-fight countdown, a save-file age. Those need a fixed structured format – “01:14:22” is “01:14:22” regardless of magnitude, not “1.2 hours”. Auto-scaling ToString() is wrong here; you need control over which tokens appear and how many digits each one gets.

Solution. TimeUnit.Formatted(TimeFormat?) renders structured strings using a token-based pattern. Tokens are Y (years), W (weeks), D (days), H (hours), m (minutes), s (seconds), and f (fractional seconds). Run length on each token sets the minimum zero-padded field width.

var elapsed = new TimeUnit { Second = 3725.456 };

/* Default StandardClock format: "HH:mm:ss". Good for most clock displays. */
string clock = elapsed.Formatted();
/* "01:02:05" */

/* Custom: hours, minutes, seconds with three fractional digits for a replay timer. */
string precise = elapsed.Formatted(new TimeFormat("HH:mm:ss.fff"));
/* "01:02:05.456" */

/* Compact countdown: minutes and seconds only, no hours field. */
var countdown = new TimeUnit { Second = 75 };
string mmss = countdown.Formatted(new TimeFormat("mm:ss"));
/* "01:15" */

/* Mission elapsed time for a long simulation - rolls into days and years. */
var mission = new TimeUnit { Day = 412.5 };
string elt = mission.Formatted(new TimeFormat("YY:DDD:HH:mm:ss"));
/* "01:047:12:00:00" approximately */

Work with lengths, masses, and time durations

Problem. You’re tracking a world-space distance in a mix of metric and imperial units for an audience that spans regions, computing how much weight a transport vehicle can carry across a session, or measuring elapsed time across multiple sub-systems that each report in different units.

Solution. LengthUnit, MassUnit, and TimeUnit are the most frequently reached-for types. Each covers a broad range: LengthUnit spans Planck length to gigaparsec; MassUnit spans attogram to yottaton; TimeUnit spans Planck time to millennium. A few per-type details are worth knowing before they catch you out.

/* LengthUnit covers SI metric, imperial (thou through cable), and astronomical
 * (AU, light-second through gigaparsec). IAU values for AU and parsec;
 * exact speed of light; Julian year of 365.25 days. */
var perimeter = new LengthUnit { Furlong = 8 };
double miles = perimeter.Mile;            /* 1.0 exactly (8 furlongs = 1 mile) */

/* MassUnit covers metric, imperial and US customary, engineering (slug),
 * trade (carat), and troy units for precious metals.
 * ToString() selects only from metric units; reach for imperial or troy
 * properties directly when a non-metric label is required. */
var gold = new MassUnit { TroyOunce = 32.15 };
double grams = gold.Gram;                 /* ~1000 (roughly 1 kg of gold)      */

/* TimeUnit spans Planck time through millennia with full SI sub-second
 * prefixes and human-scale units. Long-duration units use the Julian year,
 * so a TimeUnit is calendar-independent and safe for deterministic simulation. */
var half = new TimeUnit { Minute = 30 };
double fortnights = half.Fortnight;       /* ~0.00149                          */

Work with areas, speeds, and pressures

Problem. You’re computing territory control on a map in acres versus hectares, checking whether a vehicle’s speed has exceeded a Mach threshold for a sonic boom effect, or converting tire pressure from PSI to bar for a localized help screen.

Solution. AreaUnit, SpeedUnit, and PressureUnit each cover both SI and the domain-specific alternate units their quantities attract. SpeedUnit includes Mach (fixed at ISA sea-level 340.29 m/s) and fractions of light speed for simulation code; AreaUnit covers land units (acre, hectare) and astronomical areas; PressureUnit covers everything from barometric pressure to tire pressure to industrial specs.

/* AreaUnit covers SI, imperial squared lengths (in2, ft2, yd2, mi2),
 * land units (are, hectare, acre, rood), US survey (rod2, section, township),
 * nautical, and astronomical (AU2, ly2, pc2 through Gpc2). */
var lot = new AreaUnit { Acre = 2 };
double hectares = lot.Hectare;            /* ~0.809                            */

/* SpeedUnit covers SI per-second variants, transport units (km/h, mph,
 * knot, ft/s), astronomical speeds (AU/day, AU/year), Mach (ISA sea-level
 * 340.29 m/s), and fractions of light speed.
 * ToString() shifts to "c" for any speed at or above 1% of light. */
var bullet = new SpeedUnit { MeterPerSecond = 850 };
double mach = bullet.Mach;               /* ~2.499                            */

/* PressureUnit covers the SI prefix range, bar family, CGS (barye),
 * water and mercury column units, atmospheric (atm, technical atmosphere,
 * torr), imperial (psi, psf, ksi), and gravimetric. */
var tire = new PressureUnit { PoundPerSquareInch = 32 };
double bars = tire.Bar;                  /* ~2.206                            */
double atm  = tire.StandardAtmosphere;   /* ~2.177                            */

Work with temperatures and data sizes

Problem. You’re displaying an environmental temperature in both Celsius and Fahrenheit for a localized weather widget, or tracking asset bundle sizes at runtime and warning the player when a download would exceed their available storage.

Solution. TemperatureUnit handles all common temperature scales with exact conversion coefficients. DataUnit is the only type that enforces non-negative values, because a file size below zero is never meaningful. It also provides both SI decimal prefixes (Kilobyte, Megabyte, Gigabyte) and IEC binary prefixes (Kibibyte, Mebibyte, Gibibyte), which differ by about 7-8% at gigabyte scale – the distinction matters for user-facing storage displays.

/* TemperatureUnit converts to Kelvin, Fahrenheit, and Rankine using the exact
 * 273.15 K offset and 9/5 scale factor. ToString() picks sub-Kelvin SI prefixes
 * near absolute zero, Celsius for everyday magnitudes, and Kelvin prefix units
 * at extreme magnitudes. */
var oven = new TemperatureUnit { Fahrenheit = 451 };
double celsius = oven.Celsius;            /* ~232.78                           */
double kelvin  = oven.Kelvin;             /* ~505.93                           */

/* DataUnit is the only unit type that rejects negative values. Every setter
 * and the subtraction operator throw on underflow. */
var disk = new DataUnit { Gigabyte = 500 };
double mebibytes = disk.Mebibyte;         /* ~476837.158                       */
double bits      = disk.Bit;              /* 4000000000000                     */

/* IEC binary prefixes sit alongside SI decimal prefixes.
 * Memory and filesystem sizing conventionally uses IEC (Gibibyte, Mebibyte);
 * user-facing storage labels and bandwidth ratings conventionally use SI
 * (Gigabyte, Megabit). Mixing them silently produces 7-8% errors at scale. */
var ram = new DataUnit { Gibibyte = 16 };
double sizeInGB = ram.Gigabyte;           /* ~17.18                            */

Derive speed from a distance and a time

Problem. Your vehicle simulation tracks cumulative distance in LengthUnit and lap time in TimeUnit. At the end of a lap you want average speed in SpeedUnit – and from average speed and a future time budget, you want projected travel distance. There’s always a temptation to build a fully dimensional type system that expresses “length divided by time equals speed” at the type level, but that explodes into dozens of derived types and slows every call site.

Solution. The unit types are deliberately not aware of each other. Combining them goes through the canonical values or named properties. The pattern is short, explicit, and avoids the derived-unit type explosion. If you do this calculation in more than one place, wrap it in a helper.

/* Average speed from a distance and a time. */
var distance = new LengthUnit { Kilometer = 100 };
var elapsed  = new TimeUnit   { Hour = 1.25 };

/* Combine via canonical values, then wrap in the target type. */
var speed = new SpeedUnit { MeterPerSecond = distance.Meter / elapsed.Second };
double mph = speed.MilePerHour;           /* ~49.71                            */

/* Project a travel distance from speed and a time budget. */
var cruise = new SpeedUnit { KilometerPerHour = 110 };
var trip   = new TimeUnit  { Hour = 4 };

var travel = new LengthUnit { Meter = cruise.MeterPerSecond * trip.Second };
double km = travel.Kilometer;             /* 440                               */

/* Pressure-volume work or any other derived quantity: compute via canonicals,
 * wrap in the appropriate type. Scylla does not ship a compound-unit system;
 * this is the supported pattern. */

API Sketch

All eight unit structs follow the same surface. The example below shows LengthUnit; the others differ only in their canonical property name and their conversion property list.

namespace Scylla.Core.Util.Units
{
    public struct LengthUnit : IEquatable<LengthUnit>
    {
        public LengthUnit(double meters = 0d);

        public double Meter      { get; set; }    /* canonical */
        public double Kilometer  { get; set; }
        public double Mile       { get; set; }
        public double Inch       { get; set; }
        public double LightYear  { get; set; }
        public double Parsec     { get; set; }
        /* ...many more conversion properties... */

        public override string ToString();

        public static LengthUnit operator + (LengthUnit a, LengthUnit b);
        public static LengthUnit operator - (LengthUnit a, LengthUnit b);
        public static LengthUnit operator - (LengthUnit a);
        public static LengthUnit operator * (LengthUnit a, double s);
        public static LengthUnit operator * (LengthUnit a, int s);
        public static LengthUnit operator / (LengthUnit a, double s);
        public static LengthUnit operator / (LengthUnit a, int s);
        public static double     operator / (LengthUnit a, LengthUnit b);   /* dimensionless ratio */

        public static bool operator == (LengthUnit a, LengthUnit b);  /* approximate */
        public static bool operator != (LengthUnit a, LengthUnit b);
        public static bool operator <  (LengthUnit a, LengthUnit b);  /* exact */
        public static bool operator >  (LengthUnit a, LengthUnit b);
        public static bool operator <= (LengthUnit a, LengthUnit b);
        public static bool operator >= (LengthUnit a, LengthUnit b);
    }

    public struct TimeUnit : IEquatable<TimeUnit>
    {
        public TimeUnit(double seconds = 0d);

        public double Second { get; set; }   /* canonical */
        /* ...all SI sub-second prefixes plus human-scale units... */

        public string Formatted(TimeFormat? format = null);

        public override string ToString();
        /* Same operator set as LengthUnit. */
    }

    public readonly struct TimeFormat : IEquatable<TimeFormat>
    {
        public static readonly TimeFormat StandardClock = new("HH:mm:ss");

        public TimeFormat(string pattern);
        public string Pattern { get; }
        public static bool IsTokenChar(char c);
    }

    public struct DataUnit : IEquatable<DataUnit>
    {
        public DataUnit(double bytes = 0d);

        public double Byte { get; set; }   /* canonical, >= 0 */
        /* ...SI decimal byte and bit prefixes, IEC binary byte prefixes... */

        /* Same operator set, but Subtract and SetX throw ArgumentOutOfRangeException
           when the result would be negative. */
    }

    public struct MassUnit        : IEquatable<MassUnit>        { /* canonical: Kilogram        */ }
    public struct AreaUnit        : IEquatable<AreaUnit>        { /* canonical: SquareMeter     */ }
    public struct SpeedUnit       : IEquatable<SpeedUnit>       { /* canonical: MeterPerSecond  */ }
    public struct PressureUnit    : IEquatable<PressureUnit>    { /* canonical: Pascal          */ }
    public struct TemperatureUnit : IEquatable<TemperatureUnit> { /* canonical: Celsius         */ }
}

See the API reference for full signatures, remarks, and exception contracts on every member.

Conversion reference

The canonical property and a representative sample of conversions for each type. The full conversion property list per type is significantly longer; see the API reference.

TypeCanonicalSample conversions
LengthUnitMeterInch, Foot, Yard, Mile, NauticalMile, LightSecond, LightYear, Parsec
MassUnitKilogramGram, Pound, Stone, Ounce, TroyOunce, MetricCarat, MetricTon, ShortTon
TimeUnitSecondMillisecond, Minute, Hour, Day, Week, Fortnight, Year, Millennium
AreaUnitSquareMeterSquareFoot, Acre, Hectare, Are, SquareMile, SquareLightYear
SpeedUnitMeterPerSecondKilometerPerHour, MilePerHour, Knot, FootPerSecond, Mach, C
PressureUnitPascalBar, StandardAtmosphere, PoundPerSquareInch, MillimeterOfMercury, Torr
TemperatureUnitCelsiusKelvin, Fahrenheit, Rankine
DataUnitByteBit, Kilobyte, Megabyte, Gigabyte, Mebibyte, Gibibyte, Tebibyte

TimeFormat tokens

TokenMeaningNotes
YYearsJulian year (365.25 days). Run length sets minimum digit width.
WWeeks7 days.
DDays86,400 seconds.
HHoursRun length sets minimum digit width ("HH" zero-pads to 2 digits).
mMinutesRun length sets minimum digit width.
sSecondsRun length sets minimum digit width.
fFractional secondsRun length sets number of fractional digits ("fff" gives milliseconds, "ffffff" gives microseconds).

Any character that is not a token is emitted literally. IsTokenChar(c) reports whether a character is recognized as a token; use it when parsing or validating user-supplied patterns.

Best Practices

  • Prefer object-initializer construction with the unit name explicit. new MassUnit { Pound = 150 } reads better than new MassUnit(68.04) and produces identical code. Reserve the canonical-value constructor for values that are already in SI form.
  • Compare with == for value equality; reach for ordering operators only when an exact threshold matters. The approximate-equality default absorbs chained-conversion rounding noise without surprises.
  • Use ToString() for log lines and UI labels. The auto-scaling produces sensible output across many magnitudes; manual unit selection at the call site introduces the classic “the HUD says 1500000 mm” bug.
  • Reach for TimeUnit.Formatted(...) for structured clock or countdown output. The token-based pattern handles padding, fractional precision, and rollover; rolling a custom formatter on top of Second invariably ends up reimplementing the same logic.
  • Guard DataUnit subtractions that may underflow zero. The type throws on negative results by design. Either branch on a >= b before subtracting, or compute the canonical bytes manually and clamp at 0.
  • Treat TemperatureUnit arithmetic as operating on temperature differences, not absolute values. Adding two absolute temperatures is physically meaningless; subtracting them produces a meaningful difference when used carefully.
  • Combine unit types through canonical values, not through derived-unit types. Scylla does not ship a compound-unit type system. new SpeedUnit { MeterPerSecond = length.Meter / time.Second } is the supported pattern.
  • Cache TimeFormat instances when the pattern is reused often. TimeFormat is a readonly struct, so caching is trivial; reach for the static TimeFormat.StandardClock for the default clock pattern.
  • Choose Mach deliberately. The Mach property uses ISA sea-level speed of sound (340.29 m/s) and is deterministic, not altitude-dependent. For altitude- or temperature-corrected Mach values, compute the local speed of sound externally and convert via MeterPerSecond.
  • Pick IEC binary or SI decimal DataUnit properties deliberately. Memory and filesystem sizes are conventionally IEC binary (Gibibyte, Mebibyte); user-facing storage labels and bandwidths are conventionally SI decimal (Gigabyte, Megabit). Mixing them silently produces 7-8% errors at gigabyte scales.
  • Use the unit types at module boundaries. Internal hot paths can drop back to raw double for performance, but public APIs should accept and return LengthUnit, TimeUnit, and so on, so the unit annotation survives the integration.
  • Wrap frequently combined types in a helper. If you compute SpeedUnit from LengthUnit and TimeUnit in more than one place, extract a static helper. It documents the relationship, guards division by zero, and keeps callers from scattering canonical arithmetic across the codebase.

Pitfalls