# ash_weight
A metric weight [`Ash.Type`](https://hexdocs.pm/ash/Ash.Type.html) for the
[Ash Framework](https://hexdocs.pm/ash).
Stored canonically as integer **milligrams** (`BIGINT` in Postgres), with
ergonomic conversions to grams and kilograms via `Decimal` to avoid float
drift.
```elixir
attribute :weight, AshWeight, constraints: [min: 0]
```
## Installation
```elixir
def deps do
[
{:ash_weight, "~> 0.1.0"}
]
end
```
## Usage
### Constructing weights
```elixir
AshWeight.new(250) # %AshWeight{mg: 250} (default unit is :mg)
AshWeight.new(1.5, :g) # %AshWeight{mg: 1500}
AshWeight.new(2, :kg) # %AshWeight{mg: 2_000_000}
AshWeight.from_mg(250)
AshWeight.from_g("1.5") # strings and Decimals also accepted
AshWeight.from_kg(Decimal.new("2.25"))
```
`from_g/1` and `from_kg/1` normalize through `Decimal` arithmetic, so
`AshWeight.from_g(0.1).mg == 100` exactly — no `1.1 * 1000 == 1100.0000…2`
surprises.
### Converting
```elixir
weight = AshWeight.new(1.5, :g)
AshWeight.to_mg(weight) # 1500 (integer)
AshWeight.to_g(weight) # #Decimal<1.5>
AshWeight.to_kg(weight) # #Decimal<0.0015>
```
### Displaying
`String.Chars` picks the largest unit that gives `|value| >= 1`:
```elixir
to_string(AshWeight.from_mg(250)) # "250 mg"
to_string(AshWeight.from_g(1.5)) # "1.5 g"
to_string(AshWeight.from_kg(2.25)) # "2.25 kg"
```
### Using it as an Ash attribute
```elixir
defmodule MyApp.Harvest do
use Ash.Resource,
domain: MyApp.Cultivation,
data_layer: AshPostgres.DataLayer
attributes do
uuid_primary_key :id
attribute :yield, AshWeight, public?: true, constraints: [min: 0]
end
end
MyApp.Harvest
|> Ash.Changeset.for_create(:create, %{yield: {1.5, :kg}})
|> Ash.create()
```
The Ash.Type accepts:
| Input | Example |
|---|---|
| `%AshWeight{}` | `%AshWeight{mg: 1500}` |
| Integer (mg) | `1500` |
| `{value, unit}` tuple | `{1.5, :g}`, `{2, :kg}` |
| Atom-keyed map | `%{value: 1.5, unit: :g}` |
| String-keyed map | `%{"value" => "1.5", "unit" => "g"}` |
| `nil` | `nil` |
### Constraints
```elixir
attribute :weight, AshWeight, constraints: [min: 0, max: 10_000_000]
```
| Option | Description |
|---|---|
| `:min` | Minimum weight in milligrams (inclusive). |
| `:max` | Maximum weight in milligrams (inclusive). |
### Arithmetic and comparison
```elixir
AshWeight.add(a, b)
AshWeight.subtract(a, b)
AshWeight.multiply(weight, 3) # integer or Decimal scalar
AshWeight.compare(a, b) # :lt | :eq | :gt
AshWeight.equal?(a, b)
```
## Why integer milligrams?
Same reasoning as storing money in integer cents: bigint storage is exact,
sortable in SQL without `NUMERIC`, and skips Decimal allocation on every
read. 1 mg is sufficient resolution for cultivation, dosing, and inventory
weights.
User input still goes through `Decimal`, so `1.5 g` round-trips exactly to
`1500 mg` and back.
## Limitations
**Metric only.** This package handles `:mg`, `:g`, and `:kg` — and that's
deliberate. No pounds, ounces, stones, drams, slugs, grains, hogsheads,
potatoes per square cornfield, or any of the other charming units invented
to make trade harder. If you need to ingest imperial values from a vendor
feed, convert at the edge:
```elixir
mg = round(ounces * 28_349.523125)
AshWeight.from_mg(mg)
```
The stored representation is unitless integer mg, so converting at the
boundary is the right place to do it anyway.
**No sub-mg precision.** Integer milligrams means `0.5 mg` rounds. If you
need microgram (µg) precision — e.g. cannabinoid trace measurements — this
isn't the right type. Adding `:numeric` storage and a `Decimal`-backed `mg`
field is a contained change; open an issue.
**The original input unit is not stored.** `AshWeight.new(1.5, :g)` and
`AshWeight.new(1500, :mg)` are *the same value* after construction. If you
need to remember that a user typed "1.5 g" rather than "1500 mg" (e.g. for a
printable lab certificate), track the display unit in a sibling attribute.
Unlike money — where the currency is essential, not cosmetic — the unit on
a metric weight is a presentation choice.
## License
MIT