Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
.venv/
cache/
*.db
*.sqlite
*.log
*.gpkg
*.fgb
*.hdf
projects/
dev/
winooski/
.git/
.github/
design/
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# AWS credentials — leave unset to use IAM role or ~/.aws/credentials
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_DEFAULT_REGION=us-east-1

# Required only when using NHDTiebreaker; not bundled with the package
NHD_GPKG_PATH=/path/to/NHD_H_National_GPKG.gpkg
52 changes: 52 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
name: CI

on:
push:
branches: [main]
paths:
- "crs_inference/**"
- "tests/**"
- "pyproject.toml"
- "Dockerfile"
- ".github/workflows/ci.yml"
pull_request:
branches: [main]
workflow_dispatch:

jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v3
with:
python-version: "3.12"
- run: uv sync --frozen --extra dev
- run: uv run ruff check .
- run: uv run ruff format --check .

test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v3
with:
python-version: "3.12"
- run: uv sync --frozen --extra dev
- run: uv run pytest --cov=crs_inference --cov-report=xml
- uses: codecov/codecov-action@v4
with:
files: coverage.xml
fail_ci_if_error: false

docker:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/build-push-action@v6
with:
context: .
push: false
cache-from: type=gha
cache-to: type=gha,mode=max
73 changes: 70 additions & 3 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,13 +1,80 @@
# Python-generated files
# Python
__pycache__/
*.py[oc]
*.so
*.pyd
*.egg
*.egg-info/
build/
dist/
wheels/
*.egg-info
__pypackages__/
MANIFEST

# Virtual environments
.venv
venv/
.python-version

# Environment
# Testing & coverage
.pytest_cache/
.coverage
.coverage.*
coverage.xml
htmlcov/
.hypothesis/
.tox/

# Type checkers & linters
.mypy_cache/
.ruff_cache/
.pytype/

# Jupyter
.ipynb_checkpoints/

# Environment & secrets
.env
.env.*
!.env.example

# Geospatial data (large files not for version control)
*.gpkg
*.fgb
*.hdf
*.h5
*.nc
*.tif
*.tiff
*.shp
*.shx
*.dbf
*.prj
*.cpg
*.sbn
*.sbx
*.xml
*.db
*.sqlite

# Unignore bundled data shipped with the package
!crs_inference/data/*.gpkg

# Logs & caches
*.log
cache/

# Project-specific working directories
projects/
dev/

# OS
.DS_Store
**/.DS_Store
Thumbs.db

# Editors
.vscode/
.idea/
*.swp
*.swo
20 changes: 6 additions & 14 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,21 +1,13 @@
FROM ubuntu:latest

RUN apt-get update && apt-get install -y --no-install-recommends git curl ca-certificates python3.12 python3.12-venv python3-pip
FROM python:3.12-slim

WORKDIR /process

# Install UV
ADD https://astral.sh/uv/install.sh /uv-installer.sh
RUN sh /uv-installer.sh && rm /uv-installer.sh
ENV PATH="/root/.local/bin/:$PATH"
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv

# Install python dependencies
COPY pyproject.toml uv.lock ./
RUN uv venv .venv
RUN uv sync --frozen --no-install-project
RUN uv sync --frozen --no-install-project --extra ops

# Copy project files
COPY . .
COPY crs_inference/ crs_inference/
COPY pyproject.toml README.md ./

# Install the app
RUN uv pip install -e .
RUN uv pip install --no-deps -e .
69 changes: 69 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# crs-inference

Automatically infers the Coordinate Reference System (CRS/EPSG code) for geospatial models that lack explicit CRS metadata. The primary use case is HEC-RAS riverine hydraulic models, but the core engine works with any Shapely geometry.

The library works by transforming model geometry into each plausible CRS, measuring how much of the geometry overlaps a known geographic boundary, and returning the CRS with the highest overlap.

## Installation

```bash
pip install -e .
```

For development extras:

```bash
pip install -e ".[dev]" # pytest, ruff
pip install -e ".[ops]" # matplotlib, pynhd, pystac
```

Requires **Python ≥ 3.12**.

## Quick Start

```python
from crs_inference import infer_crs, RasParser, Target

# Parse a HEC-RAS geometry file
parser = RasParser.from_file("path/to/model.g01")
parser.validate()
geometry = parser.parse() # returns a Shapely MultiLineString

# Build a target using a US county FIPS code
target = Target.from_county("50007") # Chittenden County, VT

# Infer the CRS
result = infer_crs(geometry, target)

print(result.crs) # e.g. "EPSG:5646"
print(result.confidence) # overlap fraction, e.g. 0.97
print(result.method) # "local" | "non_local" | "none"
```

You can also build a target from a bounding box or any Shapely geometry:

```python
from crs_inference import CRSDatabase, Target

db = CRSDatabase.bundled()
target = Target.from_bbox(-73.2, 44.3, -72.8, 44.6, crs="EPSG:4326", database=db)
```

For custom tiebreakers or overlap thresholds, use the engine directly:

```python
from crs_inference import CRSInferenceEngine
from crs_inference.tiebreakers import NHDTiebreaker

engine = CRSInferenceEngine(
tiebreakers=[NHDTiebreaker()],
min_overlap=0.05,
)
result = engine.infer(geometry, target)
```

## Documentation

- [How it works](docs/technical_reference.md)
- [Developer guide](docs/developers.md)
- [FAQ](docs/faq.md)
22 changes: 22 additions & 0 deletions catalog-info.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
name: crs-inference
title: crs-inference
description: Automatically infers the Coordinate Reference System (CRS/EPSG code) for geometries that lack explicit CRS metadata, with primary support for HEC-RAS hydraulic models.
tags:
- python
- hec-ras
- geospatial
- coordinate-reference-system
- flood-inundation
annotations:
github.com/project-slug: Dewberry/crs_inference
links:
- url: https://github.com/Dewberry/crs_inference
title: GitHub
spec:
type: library
lifecycle: experimental
owner: dewberry
system: flood-inundation-mapping
48 changes: 48 additions & 0 deletions crs_inference/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
"""crs_inference: automatic CRS inference for geospatial models."""

__version__ = "0.1.0"

from crs_inference.consts import MIN_OVERLAP_PCT
from crs_inference.database import CRSDatabase
from crs_inference.engine import CRSInferenceEngine
from crs_inference.errors import (
EmptyGeometryError,
HTMLDownloadError,
ModelTooLargeError,
)
from crs_inference.parsers.ras import RasParser
from crs_inference.result import InferenceResult
from crs_inference.target import Target
from crs_inference.tiebreakers import NHDTiebreaker, SmallestCodeTiebreaker


def infer_crs(
geometry,
target: Target,
*,
tiebreakers=None,
min_overlap: float | None = None,
) -> InferenceResult:
"""Infer the CRS for a geometry given a target boundary."""
kwargs = {}
if tiebreakers is not None:
kwargs["tiebreakers"] = tiebreakers
if min_overlap is not None:
kwargs["min_overlap"] = min_overlap
return CRSInferenceEngine(**kwargs).infer(geometry, target)


__all__ = [
"MIN_OVERLAP_PCT",
"CRSDatabase",
"CRSInferenceEngine",
"EmptyGeometryError",
"HTMLDownloadError",
"InferenceResult",
"ModelTooLargeError",
"NHDTiebreaker",
"RasParser",
"SmallestCodeTiebreaker",
"Target",
"infer_crs",
]
9 changes: 9 additions & 0 deletions crs_inference/consts.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,12 @@
"""Shared project constants."""

import os
from pathlib import Path

LATENT_CRS = "EPSG:4326"
MIN_OVERLAP_PCT: float = float(os.getenv("MIN_OVERLAP_PCT", 0.0011))

# Path to the NHD National GeoPackage; not bundled. Set NHD_GPKG_PATH in the environment
# or pass a path directly to NHDTiebreaker.
NHD_GPKG_PATH: Path | None = Path(p) if (p := os.getenv("NHD_GPKG_PATH")) else None
RAS_SIZE_LIMIT: int = int(os.getenv("RAS_SIZE_LIMIT", int(1e10)))
Loading
Loading