# ExH3o
Elixir bindings for [h3o](https://github.com/HydroniumLabs/h3o), a
Rust implementation of Uber's [H3](https://h3geo.org/docs/) geospatial
indexing system.
H3 maps geographic coordinates onto a hierarchical grid of hexagonal
cells at 16 resolutions. It's widely used for spatial indexing,
aggregation, and analysis of location data.
## Features
- Full coverage of common H3 v4 operations: cell indexing, geo
conversion, k-rings, children/parent hierarchy, neighbors, edges,
polygon fill, and compaction.
- Native-speed lookups via a thin C NIF that links against a Rust
staticlib wrapping `h3o`. No Rust toolchain is required at runtime,
only at build time.
- Bare return values, `ArgumentError` on invalid input. Matches the
convention used by the reference [erlang-h3](https://hex.pm/packages/h3)
library, so code that mixes the two doesn't change shape.
- Graceful shutdown for in-flight dirty NIFs via
`ERL_NIF_OPT_DELAY_HALT`.
## Performance vs erlang-h3
[erlang-h3](https://hex.pm/packages/h3) is the established H3 binding
for the Elixir ecosystem. ExH3o is a drop-in alternative that runs
faster than erlang-h3 on most operations in the H3 v4 API surface.
Benchmarks below were measured on an Apple M1 Pro running Elixir
1.19.5 / OTP 28, head-to-head under Benchee with 5 seconds of
measurement per scenario. Times are per-call averages. The `Speedup`
column is the ratio `erlang-h3 / ex_h3o`; values greater than 1.0
mean ex_h3o is faster and values less than 1.0 mean ex_h3o is
slower. See [Reproducing](#reproducing) below to run the same
suite on your own hardware.
### `polyfill/2`
| Polygon | Resolution | erlang-h3 | ex_h3o | Speedup |
|------------------|-----------:|----------:|---------:|--------:|
| ~1 SF block | 7 | 31.03 µs | 6.34 µs | 4.89× |
| ~1 SF block | 9 | 53.31 µs | 18.68 µs | 2.85× |
| ~1 SF block | 11 | 362.45 µs | 198.0 µs | 1.83× |
| ~1 km² urban | 7 | 21.30 µs | 8.24 µs | 2.59× |
| ~1 km² urban | 9 | 97.91 µs | 69.41 µs | 1.41× |
| ~1 km² urban | 11 | 1.92 ms | 0.87 ms | 2.21× |
| ~100 km² region | 5 | 53.91 µs | 23.41 µs | 2.30× |
| ~100 km² region | 7 | 473.80 µs | 273.6 µs | 1.73× |
| ~100 km² region | 8 | 2.54 ms | 1.24 ms | 2.05× |
### Single-cell operations
| Operation | erlang-h3 | ex_h3o | Speedup |
|------------------------|----------:|----------:|-----------:|
| `is_valid/1` (valid) | 46.95 ns | 15.64 ns | 3.00× |
| `get_base_cell/1` | 52.47 ns | 20.81 ns | 2.52× |
| `get_resolution/1` | 50.59 ns | 21.08 ns | 2.40× |
| `from_string/1` | 139.06 ns | 70.02 ns | 1.99× |
| `is_pentagon/1` | 30.47 ns | 20.75 ns | 1.47× |
| `to_geo/1` | 284 ns | 248 ns | 1.14× |
| `from_geo/2` | 320 ns | 325 ns | 0.98× |
| `to_string/1` | 119 ns | 140 ns | 0.85× |
### Grid and hierarchy
| Operation | Input | erlang-h3 | ex_h3o | Speedup |
|------------------------|--------------------|----------:|----------:|--------:|
| `k_ring/2` | k=1 (7 cells) | 230 ns | 139 ns | 1.66× |
| `k_ring/2` | k=5 (91 cells) | 1.82 µs | 1.40 µs | 1.30× |
| `k_ring/2` | k=10 (331 cells) | 6.30 µs | 5.46 µs | 1.15× |
| `k_ring/2` | k=20 (1,261 cells) | 27.76 µs | 23.92 µs | 1.16× |
| `k_ring/2` | k=50 (7,651 cells) | 162.37 µs | 163.19 µs | 0.99× |
| `k_ring_distances/2` | k=1 | 309 ns | 173 ns | 1.79× |
| `k_ring_distances/2` | k=5 | 2.08 µs | 1.89 µs | 1.10× |
| `k_ring_distances/2` | k=10 | 7.66 µs | 7.26 µs | 1.06× |
| `children/2` | +1 level (7) | 180 ns | 134 ns | 1.34× |
| `children/2` | +2 levels (49) | 449 ns | 415 ns | 1.08× |
| `children/2` | +3 levels (343) | 2.72 µs | 2.74 µs | 0.99× |
### Set operations
`compact/1` and `uncompact/2` are slower than erlang-h3 on small
inputs (roughly 0.5× and 0.75× speedup in the benchmarks below).
If your workload leans heavily on these operations, benchmark both
libraries against representative inputs before choosing.
### Reproducing
```bash
mix run bench/single_cell.exs
mix run bench/collections.exs
mix run bench/polyfill.exs
```
Each script prints a head-to-head comparison table for one
category of operations.
## Installation
Add `ex_h3o` to your `mix.exs` dependencies:
```elixir
def deps do
[
{:ex_h3o, "~> 0.1.0"}
]
end
```
Then run `mix deps.get` and `mix compile`. On supported targets the
compile step downloads a precompiled NIF from the GitHub Release
matching the package version. On other targets, or when forced via
`EX_H3O_BUILD=true`, it builds the Rust staticlib locally via
`cargo` and links it into `priv/ex_h3o_nif.so`.
### Precompiled targets
Precompiled NIFs are published to GitHub Releases for:
- macOS ARM64 (`aarch64-apple-darwin`)
- macOS x86-64 (`x86_64-apple-darwin`)
- Linux x86-64 glibc (`x86_64-unknown-linux-gnu`)
On these targets no Rust toolchain is required at install time.
### Build requirements
Source builds are used for any target outside the list above, and
can be forced on supported targets by setting `EX_H3O_BUILD=true`
before `mix compile`. Source builds require:
- Elixir 1.18+ / OTP 26+ (OTP 28 recommended)
- A C compiler (`cc`) available on `PATH`
- A Rust toolchain (`cargo`) available on `PATH`
- macOS and Linux are supported. Windows is not.
## Usage
```elixir
# Convert a {lat, lng} coordinate to an H3 cell at a given resolution
cell = ExH3o.from_geo({37.7749, -122.4194}, 9)
# => 617700169958686719
# Round-trip back to coordinates
ExH3o.to_geo(cell)
# => {37.77490199, -122.41942334}
# Grid queries
ExH3o.k_ring(cell, 2) # cells within distance 2
ExH3o.children(cell, 11) # child cells at a finer resolution
ExH3o.parent(cell, 7) # parent cell at a coarser resolution
ExH3o.is_pentagon(cell) # pentagon check
ExH3o.grid_distance(cell_a, cell_b)
# Polygon fill
polygon = [
{37.770, -122.420},
{37.770, -122.410},
{37.780, -122.410},
{37.780, -122.420},
{37.770, -122.420}
]
ExH3o.polyfill(polygon, 9)
```
Invalid input raises rather than returning an error tuple:
```elixir
ExH3o.from_geo({900.0, 0.0}, 9)
# ** (ArgumentError) argument error
ExH3o.get_resolution(0)
# ** (ArgumentError) argument error
```
If you'd rather get `{:ok, _} | {:error, _}` back, wrap the call
site in `try/rescue`.
See the module docs for the complete API:
[https://hexdocs.pm/ex_h3o](https://hexdocs.pm/ex_h3o).
## Development
```bash
# Fetch deps
mix deps.get
# Compile (builds the Rust staticlib + C NIF)
mix compile
# Run the test suite
mix test
# Formatter / linter / type checker
mix format --check-formatted
mix credo --strict
mix dialyzer
```
### Benchmarks
Comparative benchmarks against erlang-h3 live under `bench/`:
```bash
mix run bench/single_cell.exs # scalar per-call ops
mix run bench/collections.exs # k_ring, children, compact, uncompact
mix run bench/polyfill.exs # polygon fill across sizes
mix run bench/gc_deep_dive.exs # side-by-side GC pressure measurement
mix run bench/stress.exs # concurrent-load stress harness
```
## Contributing
Contributions are welcome. Please open an issue before starting any
sizable change so we can discuss direction.
Before submitting a PR:
1. Add or update tests for any behaviour change.
2. Run `mix format`, `mix credo --strict`, and `mix test` locally.
3. Keep commits focused and write a clear commit message.
Bug reports with a minimal reproduction are appreciated.
## License
Released under the MIT License. See [LICENSE](LICENSE) for details.