diff --git a/.github/agents/copilot-instructions.md b/.github/agents/copilot-instructions.md index 343a70d..b434432 100644 --- a/.github/agents/copilot-instructions.md +++ b/.github/agents/copilot-instructions.md @@ -1,4 +1,4 @@ -# tp-lib Development Guidelines +# tp-lib Development Guidelines Auto-generated from all feature plans. Last updated: 2026-01-09 @@ -7,6 +7,8 @@ Auto-generated from all feature plans. Last updated: 2026-01-09 - File-based (GeoJSON network, CSV train path, CSV GNSS positions) — no database (003-path-review-webapp) - Rust 1.91.1+ (workspace edition 2021) + `geo` 0.28, `rstar` 0.12, `geojson` 0.24, `csv` 1.x, `serde`/`serde_json`, `chrono` (DateTime), `petgraph`, `proj4rs` 0.1.9; webapp: `axum`, `tokio`, Leaflet (static) (004-train-detections) - File-based I/O (CSV / GeoJSON); no DB. R-tree (`rstar`) in-memory spatial index reused for coordinate resolution. (004-train-detections) +- Rust 1.75 (tp-net crate) + C# 12 / .NET 8 (TpLib managed) + `csbindgen` (FFI stub generation), `serde_json` (FFI marshalling), `tp-lib-core` (core algorithms); C# side: `System.Text.Json` (deserialization), xUnit (testing) (005-dotnet-bindings) +- N/A — stateless function calls only (005-dotnet-bindings) - Rust 1.75+ (edition 2021) (002-train-path-calculation) @@ -27,7 +29,7 @@ After **any** change to a `.rs` file in this workspace, always run these checks 1. `cargo fmt --check` — verify all Rust source is formatted with rustfmt. Fix automatically with `cargo fmt` if there are diffs. -2. `cargo clippy --all-targets --all-features -- -D warnings` — zero-warning policy. +2. `cargo clippy --workspace --all-targets --all-features -- -D warnings` — zero-warning policy. 3. `cargo test --workspace` — full test suite must stay green. For changes to `tp-py/` (Python bindings), also run: @@ -41,10 +43,10 @@ For Python source files (`.py`) changed under `tp-py/`: Rust 1.75+ (edition 2021): Follow standard conventions ## Recent Changes +- 005-dotnet-bindings: Added Rust 1.75 (tp-net crate) + C# 12 / .NET 8 (TpLib managed) + `csbindgen` (FFI stub generation), `serde_json` (FFI marshalling), `tp-lib-core` (core algorithms); C# side: `System.Text.Json` (deserialization), xUnit (testing) - 004-train-detections: Added Rust 1.91.1+ (workspace edition 2021) + `geo` 0.28, `rstar` 0.12, `geojson` 0.24, `csv` 1.x, `serde`/`serde_json`, `chrono` (DateTime), `petgraph`, `proj4rs` 0.1.9; webapp: `axum`, `tokio`, Leaflet (static) - 003-path-review-webapp: Added Rust 2021 edition, latest stable (1.80+) -- 002-train-path-calculation: Added Rust 1.75+ (edition 2021) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 670c1db..e3be886 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,8 @@ on: branches: [ main, develop ] pull_request: branches: [ main ] + release: + types: [ published ] env: CARGO_TERM_COLOR: always @@ -44,6 +46,7 @@ jobs: bench: name: Benchmarks runs-on: ubuntu-latest + if: github.event_name == 'release' steps: - uses: actions/checkout@v4 @@ -84,6 +87,29 @@ jobs: - name: Run Python tests run: .venv/bin/pytest tp-py/python/tests/ + dotnet: + name: .NET Tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '8.0.x' + + - name: Build native library + run: cargo build -p tp-lib-net + + - name: Run cargo tests (tp-net) + run: cargo test -p tp-lib-net + + - name: Run .NET tests + run: dotnet test tp-net/csharp/Tests/TpLib.Tests.csproj -c Debug --verbosity minimal + lint: name: Linting runs-on: ubuntu-latest diff --git a/.github/workflows/publish-nuget.yml b/.github/workflows/publish-nuget.yml new file mode 100644 index 0000000..d9a6410 --- /dev/null +++ b/.github/workflows/publish-nuget.yml @@ -0,0 +1,122 @@ +name: Publish to NuGet + +on: + release: + types: [published] + workflow_dispatch: + +env: + CARGO_TERM_COLOR: always + +permissions: + contents: read + +jobs: + build-native: + name: Build native (${{ matrix.rid }}) + runs-on: ${{ matrix.os }} + if: startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch' + strategy: + fail-fast: false + matrix: + include: + - rid: win-x64 + os: windows-latest + target: x86_64-pc-windows-msvc + artifact: tp_lib_net.dll + - rid: linux-x64 + os: ubuntu-latest + target: x86_64-unknown-linux-gnu + artifact: libtp_lib_net.so + - rid: osx-x64 + os: macos-13 + target: x86_64-apple-darwin + artifact: libtp_lib_net.dylib + - rid: osx-arm64 + os: macos-latest + target: aarch64-apple-darwin + artifact: libtp_lib_net.dylib + steps: + - uses: actions/checkout@v4 + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + + - name: Build native library + run: cargo build --release -p tp-lib-net --target ${{ matrix.target }} + + - name: Stage artifact + shell: bash + run: | + mkdir -p staging + cp target/${{ matrix.target }}/release/${{ matrix.artifact }} staging/ + + - name: Upload native artifact + uses: actions/upload-artifact@v4 + with: + name: native-${{ matrix.rid }} + path: staging/${{ matrix.artifact }} + + pack-and-publish: + name: Pack and publish to NuGet + needs: build-native + runs-on: ubuntu-latest + if: startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch' + permissions: + contents: read + id-token: write # required for GitHub OIDC token issuance (trusted publishing) + steps: + - uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '8.0.x' + + - name: Download all native artifacts + uses: actions/download-artifact@v4 + with: + path: native-artifacts + + - name: Arrange runtimes + run: | + mkdir -p tp-net/csharp/runtimes/win-x64/native + mkdir -p tp-net/csharp/runtimes/linux-x64/native + mkdir -p tp-net/csharp/runtimes/osx-x64/native + mkdir -p tp-net/csharp/runtimes/osx-arm64/native + cp native-artifacts/native-win-x64/tp_lib_net.dll tp-net/csharp/runtimes/win-x64/native/ + cp native-artifacts/native-linux-x64/libtp_lib_net.so tp-net/csharp/runtimes/linux-x64/native/ + cp native-artifacts/native-osx-x64/libtp_lib_net.dylib tp-net/csharp/runtimes/osx-x64/native/ + cp native-artifacts/native-osx-arm64/libtp_lib_net.dylib tp-net/csharp/runtimes/osx-arm64/native/ + + - name: Pack + run: | + if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then + PACKAGE_VERSION="${GITHUB_REF_NAME#v}" + dotnet pack tp-net/csharp/TpLib.csproj -c Release -o nupkg -p:PackageVersion="${PACKAGE_VERSION}" + else + dotnet pack tp-net/csharp/TpLib.csproj -c Release -o nupkg + fi + + - name: Upload nupkg + uses: actions/upload-artifact@v4 + with: + name: nupkg + path: nupkg/*.nupkg + + - name: NuGet login (OIDC trusted publishing) + if: startsWith(github.ref, 'refs/tags/v') + uses: NuGet/login@v1 + id: nuget-login + with: + user: ${{ secrets.NUGET_USER }} + + - name: Publish to NuGet + if: startsWith(github.ref, 'refs/tags/v') + run: | + dotnet nuget push "nupkg/*.nupkg" \ + --api-key "${{ steps.nuget-login.outputs.NUGET_API_KEY }}" \ + --source https://api.nuget.org/v3/index.json \ + --skip-duplicate diff --git a/.gitignore b/.gitignore index 5b71af9..d092a8d 100644 --- a/.gitignore +++ b/.gitignore @@ -61,6 +61,65 @@ htmlcov/ # Logs *.log +# .NET +[Bb]in/ +[Oo]bj/ +*.user +*.suo +*.userosscache +*.sln.docstates +*.userprefs +.vs/ +*.ncrunch* +*.[Rr]e[Ss]harper +*.DotSettings.user +[Dd]ebug/ +[Rr]elease/ +x64/ +x86/ +[Ww][Ii][Nn]32/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ +[Ll]ogs/ +*.VisualState.xml +TestResult.xml +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* +*.ilk +*.meta +*.obj +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*_wpftmp.csproj +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc +*.nupkg +*.snupkg +*.coverage +*.coveragexml +_NCrunch_* +MightyCruise_Crash.txt +.localhistory/ +*.vs/ # OS Thumbs.db diff --git a/Cargo.toml b/Cargo.toml index 65ddf4c..dcced7f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,7 @@ members = [ "tp-cli", "tp-py", "tp-webapp", + "tp-net", ] resolver = "2" diff --git a/README.md b/README.md index 5db2ccf..f2603c6 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,7 @@ [![codecov](https://codecov.io/gh/matdata-eu/tp-lib/branch/main/graph/badge.svg)](https://codecov.io/gh/matdata-eu/tp-lib) [![crates.io](https://img.shields.io/crates/v/tp-lib-core.svg)](https://crates.io/crates/tp-lib-core) [![PyPI](https://img.shields.io/pypi/v/tp-lib.svg)](https://pypi.org/project/tp-lib/) +[![NuGet](https://img.shields.io/nuget/v/TpLib.svg)](https://www.nuget.org/packages/TpLib/) [![Documentation](https://img.shields.io/badge/docs-github.io-blue)](https://matdata-eu.github.io/tp-lib/) [![License](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](LICENSE) @@ -155,7 +156,8 @@ tp-lib/ # Rust workspace root │ └── benches/ # Performance benchmarks ├── tp-cli/ # Command-line interface ├── tp-webapp/ # Interactive path review web server (axum + Leaflet.js) -└── tp-py/ # Python bindings (PyO3) +├── tp-py/ # Python bindings (PyO3) +└── tp-net/ # .NET bindings (csbindgen + System.Text.Json) ``` ## Quick Start @@ -431,6 +433,7 @@ The documentation is automatically built and deployed on every push to `main`. I - **tp-core**: Core library API with examples - **tp-cli**: Command-line interface documentation - **tp-py**: Python bindings API reference +- **tp-net**: .NET bindings API reference **Build locally:** @@ -499,7 +502,7 @@ This project follows the TP-Lib Constitution v1.1.0 principles: - ✅ **VII. CRS Explicit**: All coordinates include CRS specification - ✅ **VIII. Error Handling**: Typed errors with thiserror, fail-fast validation - ✅ **IX. Data Provenance**: Preserve original GNSS data, audit logging -- ✅ **X. Integration Flexibility**: Rust API + CLI + Python bindings +- ✅ **X. Integration Flexibility**: Rust API + CLI + Python bindings + .NET bindings ## Contributing diff --git a/docs/OpenRail-onboarding.md b/docs/OpenRail-onboarding.md new file mode 100644 index 0000000..fe7dfe5 --- /dev/null +++ b/docs/OpenRail-onboarding.md @@ -0,0 +1,122 @@ +# Questionnaire for projects intending to join the OpenRail Association + +_As a project intending to join the OpenRail Association, please fill out this questionnaire and send it to the Technical Committee. See the [incubation process](../../incubation-process.md) for details about criteria and requirements for new projects and how it works to get new projects into the association._ + +_Copy this template and fill in your answers to the questions in the sections below._ + +## What is the project's name? + +TP-lib + +## Describe the project. What does the project do, why is it valuable, where does it come from? + +TP-Lib is a Rust library for post-processing GNSS positions recorded by measurement trains onto an unambiguous location in a railway network — a problem commonly referred to as map matching. Starting from raw GNSS coordinates and a topological network description (GeoJSON), it identifies which track segment each position falls on and calculates the most probable continuous path the train took through the network, using a Hidden Markov Model with Viterbi decoding (following the Newson & Krumm 2009 method). The projected result is a sequence of linear references (netelement + offset) on the railway network. + +The library originates from Infrabel, the Belgian railway infrastructure manager, where measurement trains continuously collect GNSS data and the resulting positions must be referenced to specific track segments for infrastructure monitoring purposes. Infrabel open-sourced the library to allow other infrastructure managers facing the same problem to build on a shared, well-tested foundation rather than each implementing their own solution independently. + +TP-Lib is valuable because: +- It is specifically designed for railway topology (directed, navigable networks), where general-purpose map-matching libraries fall short. +- It provides a full pipeline: GNSS input → candidate selection → probabilistic HMM path calculation → network projection → CSV/GeoJSON output. +- It ships a browser-based interactive path review and editing tool (`tp-webapp`) so operators can correct the calculated path before final projection. +- It exposes a CLI (`tp-cli`) for operational use, a Python API (`tp-lib` on PyPI) for integration in data science workflows and a .NET API (`TpLib` on NuGet, install via `dotnet add package TpLib`). +- It is thoroughly tested with 460 tests (unit, integration, contract, CLI, and doctests). + +## Who are the maintainers of the project (these will be the primary contacts for the OpenRail Association)? + +Infrabel: + +- mathias@matdata.eu (ad interim) + +## Which organizations are sponsoring/contributing to the project? + +Infrabel + +## Where is the code hosted? + +https://github.com/Matdata-eu/tp-lib + +## Which exact repositories do you intend to transfer to the GitHub organization of the OpenRail Association? + +`Matdata-eu/tp-lib` — the single repository containing the entire workspace (core library, CLI, Python bindings, .NET bindings and web application). + +## What is the project's main license? + +Apache License 2.0 + +## What other licenses does the project use, e.g., for included 3rd-party code? + +All third-party dependencies use permissive licenses compatible with Apache 2.0: + +- **MIT OR Apache-2.0**: geo-rs, rstar, geojson, petgraph, serde, chrono, thiserror, csv (Rust crates) +- **MIT**: polars, proj4rs, tracing, PyO3 (Rust/Python crates) +- **Apache-2.0**: Apache Arrow (Rust) +- **BSD-2-Clause**: Leaflet.js (JavaScript, bundled in the web application) + +No copyleft (GPL/LGPL) dependencies are used. + +## Are any trademarks associated with the project? + +No + +## Does the project have a web site? Where is it? Are you ok with moving it to be hosted by the OpenRail Association? + +The project has a documentation site hosted on GitHub Pages at https://matdata-eu.github.io/tp-lib/ specifically about the Rust. We are willing to move it to hosting provided by the OpenRail Association. + +## What are the communication channels the project uses (such as mailing lists, Slack, IRC, etc.)? + +Communication currently happens through GitHub: Issues for bug reports and feature requests, and Pull Requests for code contributions. There are no mailing lists, Slack workspaces, or other channels at this time. + +## What is the project's leadership team and decision-making process? + +See the [governance file](./GOVERNANCE.MD) + +## How is it decided if and when a pull request is merged? + +See the [governance file](./GOVERNANCE.MD) + +## How can someone become a committer or a maintainer to/of the project? + +See the [governance file](./GOVERNANCE.MD) + +## How is development of the project planned and organized? Is this transparent to the public? + +See the [governance file](./GOVERNANCE.MD) + +## What is the project's roadmap? + +After the project refactoring and extensive testing, no further development is planned. + +## What other organizations in the world should be interested in this project? + +All organisations that use measurement trains and process the measurements. But the project can also be used as a guideline for organisations that want to post processes location measurements for other domains that include topology and navigability. + +## Why would this project be a good candidate for inclusion in the OpenRail Association? + +Because all infra managers do this and should better do it together. + +## Are there competing products or projects? If there are, please explain how the proposed projects differentiates. + +Not that we know of. + +## What standards does the project implement or rely on? How are they related to other existing standards? + +Topology and navigability is also defined by several other railway related standards. But they are very loosely related. + +## What is the tech stack of the project? Name the major programming languages and frameworks which are used. + +- **Primary language**: Rust (1.91.1+), used for all performance-critical computation, the CLI, and the web server. +- **Key Rust libraries**: geo-rs and proj4rs (geospatial calculations), rstar (R-tree spatial indexing), petgraph (graph algorithms for Viterbi decoding), axum (embedded web server for path review), Apache Arrow / Polars (data processing), rust-embed (bundling web assets into the binary). +- **Python bindings**: PyO3 + Maturin expose the core library as a native Python extension (`tp-lib` on PyPI), requiring Python 3.12+. +- **.NET bindings**: csbindgen + System.Text.Json, published as the `TpLib` NuGet package (net8.0). +- **Frontend (embedded)**: Leaflet.js (JavaScript) for the browser-based interactive path review map; the web assets are compiled into the binary at build time with no separate Node.js build step. +- **Packaging**: Cargo for Rust, Maturin/pip for Python, Docker for containerised deployment. + +## What is the project's plan for growing in maturity if accepted within the OpenRail Association? + +Find other openrail members with similar software and if they are willing, combine their logic into this project and collaborate to both advance. + +## Concluding statements + +By sending this questionnaire you confirm that the project will adhere to the [code of conduct](CODE_OF_CONDUCT.md) of the OpenRail Association. + +By sending this questionnaire you confirm that the project intends to be incubated in the OpenRail Association and plans to meet the maturity criteria set out by the OpenRail Association for incubated projects. diff --git a/specs/005-dotnet-bindings/checklists/requirements.md b/specs/005-dotnet-bindings/checklists/requirements.md new file mode 100644 index 0000000..336ae71 --- /dev/null +++ b/specs/005-dotnet-bindings/checklists/requirements.md @@ -0,0 +1,36 @@ +# Specification Quality Checklist: C#/.NET Bindings (tp-net) + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-05-13 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + +## Notes + +- Spec is complete and ready for `/speckit.plan` +- FR-009 references native binaries per platform — framed as distribution requirement (what), not implementation (how) +- Assumptions section documents the FFI mechanism as a technical decision deferred to planning diff --git a/specs/005-dotnet-bindings/contracts/api.md b/specs/005-dotnet-bindings/contracts/api.md new file mode 100644 index 0000000..f144f0b --- /dev/null +++ b/specs/005-dotnet-bindings/contracts/api.md @@ -0,0 +1,624 @@ +# API Contracts: TpLib C# Public API + +**Phase**: Phase 1 — Design & Contracts +**Date**: 2026-05-13 +**Feature**: `005-dotnet-bindings` + +This document specifies the **public C# API surface** for the `TpLib` NuGet package. These contracts define the boundary that downstream .NET consumers depend on and must remain stable across minor versions. + +--- + +## Namespace: `TpLib` + +--- + +### Class: `TpLib.Projection` + +Static utility class for GNSS projection operations. + +```csharp +namespace TpLib; + +/// +/// Projects GNSS positions onto a railway network. +/// +public static class Projection +{ + /// + /// Projects a sequence of GNSS positions onto the railway network using simple + /// nearest-segment matching (feature 001 — topology-free). + /// Each GNSS point is matched independently to its geometrically nearest netelement; + /// network relations are not used. Use this for quick analysis, data quality checks, + /// or when netrelations are unavailable. + /// For topology-aware path reconstruction, see . + /// + /// Railway network as a (structured segments or GeoJSON). + /// GNSS positions as a (structured records, GeoJSON, or CSV). + /// Projection configuration. Uses defaults when null. + /// List of projected positions, one per valid GNSS point. + /// Network or GNSS data is malformed. + /// Projection failed (e.g. empty network). + public static IReadOnlyList ProjectGnss( + NetworkInput network, + GnssInput gnss, + ProjectionConfig? config = null); + + /// Convenience overload: accepts the railway network as a GeoJSON string directly. + public static IReadOnlyList ProjectGnss( + string networkGeoJson, + GnssInput gnss, + ProjectionConfig? config = null) + => ProjectGnss(NetworkInput.FromGeoJson(networkGeoJson), gnss, config); + + /// Convenience overload: accepts both network and GNSS as GeoJSON strings directly. + public static IReadOnlyList ProjectGnss( + string networkGeoJson, + string gnssGeoJson, + ProjectionConfig? config = null) + => ProjectGnss(NetworkInput.FromGeoJson(networkGeoJson), GnssInput.FromGeoJson(gnssGeoJson), config); + + /// + /// Projects a sequence of GNSS positions onto a pre-calculated railway path. + /// Use this when you already have a (from a prior call to + /// or loaded from a saved/reviewed file) + /// and want to project GNSS positions along that fixed path. + /// + /// The resulting field is populated for + /// every position (normalised 0–1 offset along the matched segment). + /// + /// Common workflows: + /// + /// Save the after , + /// let a user review it in the webapp, then call this method to re-project using the reviewed path. + /// Re-use a path calculated for one GNSS recording to project another recording + /// on the same route. + /// + /// + /// For topology-free nearest-segment projection see .
+ /// For calculating a new path and projecting in one step see + /// (returns + /// ).
+ ///
+ /// Railway network containing all netelements referenced by . + /// GNSS positions to project. + /// Pre-calculated train path. All netelement IDs must exist in . + /// Path configuration. Uses defaults when null. + /// One per GNSS input point, ordered as the input. + /// is populated for all returned positions. + /// Network or GNSS data is malformed. + /// A netelement in does not exist + /// in , or projection onto the path fails. + public static IReadOnlyList ProjectOntoPath( + NetworkInput network, + GnssInput gnss, + TrainPath path, + PathConfig? config = null); + + /// Convenience overload: accepts the railway network as a GeoJSON string directly. + public static IReadOnlyList ProjectOntoPath( + string networkGeoJson, + GnssInput gnss, + TrainPath path, + PathConfig? config = null) + => ProjectOntoPath(NetworkInput.FromGeoJson(networkGeoJson), gnss, path, config); +} +``` + +**Preconditions**: +- `network` is non-null; its content is non-empty and valid for the declared format. +- `gnss` is non-null; its content is non-empty and valid for the declared format. +- `config.MaxSearchRadiusMeters > 0` when config is non-null. + +**Postconditions**: +- Returns a list of `ProjectedPosition` objects, ≥0 elements. +- Each returned position has `MeasureMeters >= 0` and `ProjectionDistanceMeters >= 0`. + +--- + +### Class: `TpLib.PathCalculation` + +Static utility class for train path calculation. + +```csharp +namespace TpLib; + +/// +/// Calculates train paths from projected GNSS positions. +/// +public static class PathCalculation +{ + /// + /// Calculates the most probable train path through the railway network using a + /// Hidden Markov Model (Viterbi algorithm) over network topology (feature 002). + /// The returned are projected along + /// the reconstructed path — more accurate than simple nearest-segment projection. + /// Check to determine which mode was used: + /// (normal) or + /// (topology unavailable; + /// falls back to per-point nearest-segment matching, equivalent to + /// ). + /// + /// Railway network as a (structured segments or GeoJSON). + /// GNSS positions as a (structured records, GeoJSON, or CSV). + /// Path calculation configuration. Uses defaults when null. + /// Optional prepared detections to anchor the HMM calculation. + /// Obtain via . + /// When provided, resolved anchors constrain the Viterbi path. + /// PathResult with the calculated path and diagnostics. + /// Network or GNSS data is malformed. + /// Path calculation encountered an unrecoverable error. + public static PathResult CalculateTrainPath( + NetworkInput network, + GnssInput gnss, + PathConfig? config = null, + PreparedDetections? detections = null); + + /// Convenience overload: accepts the railway network as a GeoJSON string directly. + public static PathResult CalculateTrainPath( + string networkGeoJson, + GnssInput gnss, + PathConfig? config = null, + PreparedDetections? detections = null) + => CalculateTrainPath(NetworkInput.FromGeoJson(networkGeoJson), gnss, config, detections); + + /// Convenience overload: accepts both network and GNSS as GeoJSON strings directly. + public static PathResult CalculateTrainPath( + string networkGeoJson, + string gnssGeoJson, + PathConfig? config = null, + PreparedDetections? detections = null) + => CalculateTrainPath(NetworkInput.FromGeoJson(networkGeoJson), GnssInput.FromGeoJson(gnssGeoJson), config, detections); +} +``` + +**Preconditions**: +- `network` is non-null; its content is non-empty and valid for the declared format. +- `gnss` is non-null; its content is non-empty and valid for the declared format. + +**Postconditions**: +- Always returns a `PathResult` (never null); `PathResult.Path` may be null if no path was found. +- `PathResult.ProjectedPositions` is never null. + +--- + +### Class: `TpLib.DetectionPreparation` + +Static utility class for detection record preparation. + +```csharp +namespace TpLib; + +/// +/// Loads, validates, time-filters, and spatially resolves train detection records +/// against the railway network. +/// +public static class DetectionPreparation +{ + /// + /// Prepares detection records for use as anchors in + /// . + /// Detection timestamps are filtered to the GNSS time window, and each detection + /// is resolved to its nearest netelement (punctual) or span (linear). + /// The resulting should be passed to + /// via the detections parameter + /// so that resolved anchors constrain the HMM path calculation. + /// + /// Railway network as a (structured segments or GeoJSON). + /// GNSS positions as a ; used to establish the time window for filtering. + /// Detection records to prepare. Order is preserved. + /// Maximum distance (m) for resolving coordinate-only detections. Default: 2.5. + /// PreparedDetections with all records annotated with status, ready to anchor path calculation. + /// Any required parameter is null. + /// Preparation failed (e.g. incompatible network). + public static PreparedDetections PrepareDetections( + NetworkInput network, + GnssInput gnss, + IEnumerable detections, + double cutoffDistanceMeters = 2.5); + + /// Convenience overload: accepts the railway network as a GeoJSON string directly. + public static PreparedDetections PrepareDetections( + string networkGeoJson, + GnssInput gnss, + IEnumerable detections, + double cutoffDistanceMeters = 2.5) + => PrepareDetections(NetworkInput.FromGeoJson(networkGeoJson), gnss, detections, cutoffDistanceMeters); +} +``` + +**Preconditions**: +- `network` is non-null; its content is non-empty and valid for the declared format. +- `gnss` is non-null; used to establish the time window for filtering. +- `detections` is non-null (may be empty; returns empty `PreparedDetections`). +- `cutoffDistanceMeters > 0`. + +**Postconditions**: +- `result.Records.Count == detections.Count()`. +- Each record has a non-null `Status` (one of `Applied`, `Resolved`, `Discarded`). + +--- + +## Input Format Types + +```csharp +namespace TpLib; + +/// +/// A railway track segment (netelement): a LineString geometry with an identifier. +/// Coordinates are in GeoJSON order — (Longitude, Latitude) — matching the WGS-84 +/// convention used by the tp-lib Rust core. +/// Corresponds to features with "type": "netelement" in a tp-lib GeoJSON network file. +/// +/// Unique identifier for the segment (matches the id GeoJSON property). +/// Ordered sequence of (Longitude, Latitude) pairs forming the track centerline. Minimum 2 points. +/// Coordinate reference system (default: "EPSG:4326"). +public sealed record NetworkSegment( + string Id, + IReadOnlyList<(double Longitude, double Latitude)> Coordinates, + string Crs = "EPSG:4326"); + +/// +/// A topological connection between two track segments (netrelation), describing whether +/// trains may travel from one segment to another and at which endpoints they connect. +/// Corresponds to features with "type": "netrelation" in a tp-lib GeoJSON network file. +/// +/// Unique identifier for this relation. +/// ID of the first connected track segment. +/// ID of the second connected track segment. +/// Endpoint of segment A used by this connection: 0 = start, 1 = end. +/// Endpoint of segment B used by this connection: 0 = start, 1 = end. +/// Allowed train travel directions across this connection. +public sealed record NetworkRelation( + string Id, + string NetelementAId, + string NetelementBId, + int PositionOnA, + int PositionOnB, + Navigability Navigability); + +/// +/// Allowed train travel directions across a . +/// Maps to the navigability string property in the tp-lib GeoJSON format. +/// +public enum Navigability +{ + /// Trains may travel in both directions between the two segments. + Both, + /// Trains may travel from segment A to segment B only. + Forward, + /// Trains may travel from segment B to segment A only. + Backward, + /// No train movement is permitted (administrative connection only). + None, +} + +/// +/// Wraps railway network input data for tp-lib processing. +/// Callers choose the most convenient entry point; internal serialization is +/// handled by the library and is not visible to consumers. +/// +public sealed class NetworkInput +{ + /// + /// Creates a from in-memory collections of typed track + /// segments and their topological relations. + /// This is the preferred entry point when network data originates from a relational + /// database with separate netelements and netrelations tables — + /// no serialization is required on the caller's side. + /// + /// Non-empty sequence of railway track segments. + /// + /// Sequence of connections between track segments. + /// May be empty for topology-free operations (e.g., ), + /// but is required for path calculation (). + /// + public static NetworkInput FromRecords( + IEnumerable netelements, + IEnumerable netrelations); + + /// + /// Creates a from a GeoJSON FeatureCollection string. + /// The collection must contain both netelement (LineString) and + /// netrelation (Point) features, as produced by tp-lib's export tools. + /// Use this when network data is stored as a GeoJSON text/jsonb column or loaded from a file. + /// Prefer when data is available as structured rows. + /// + /// Non-null, non-empty GeoJSON FeatureCollection string. + public static NetworkInput FromGeoJson(string geoJson); +} +``` + +```csharp +namespace TpLib; + +/// +/// A GNSS point: geographic position with timestamp. +/// +/// WGS-84 latitude in decimal degrees. +/// WGS-84 longitude in decimal degrees. +/// Observation time — must include UTC offset. +public sealed record GnssRecord( + double Latitude, + double Longitude, + DateTimeOffset Timestamp); + +/// +/// Wraps GNSS input data for tp-lib processing. +/// Callers choose the most convenient entry point; internal serialization is +/// handled by the library and is not visible to consumers. +/// +public sealed class GnssInput +{ + /// + /// Creates a from an in-memory collection of typed records. + /// This is the preferred entry point when GNSS data originates from a relational + /// database or any in-process data structure — no serialization is required on the + /// caller's side. + /// + /// Non-empty sequence of GNSS points. + /// Coordinate reference system (default: "EPSG:4326"). + public static GnssInput FromRecords(IEnumerable records, string crs = "EPSG:4326"); + + /// + /// Creates a from a GeoJSON FeatureCollection string. + /// Each Feature must be a Point geometry with a timestamp property. + /// Prefer when data is already available in memory. + /// + /// Non-null, non-empty GeoJSON FeatureCollection string. + /// Coordinate reference system (default: "EPSG:4326"). + public static GnssInput FromGeoJson(string geoJson, string crs = "EPSG:4326"); + + /// + /// Creates a from a CSV string with a header row. + /// Useful when reading from a CSV file or stream. + /// Prefer when data is already available in memory. + /// + /// Non-null, non-empty CSV string with a header row. + /// Coordinate reference system (default: "EPSG:4326"). + /// Latitude column name (default: "latitude"). + /// Longitude column name (default: "longitude"). + /// Timestamp column name (default: "timestamp"). Values must be ISO-8601. + public static GnssInput FromCsv( + string csv, + string crs = "EPSG:4326", + string latitudeColumn = "latitude", + string longitudeColumn = "longitude", + string timestampColumn = "timestamp"); +} +``` + +**CSV format requirements**: +- Header row required; column order is irrelevant — columns are identified by name. +- Latitude and longitude: decimal degrees (e.g. `50.8503`, `4.3517`). +- Timestamp: ISO-8601 string with timezone offset (e.g. `2026-03-13T17:15:00+01:00` or `2026-03-13T16:15:00Z`). +- Additional columns are preserved as metadata. + +--- + +## Configuration Records + +```csharp +namespace TpLib; + +/// Configures GNSS projection behavior. +public sealed record ProjectionConfig +{ + public double MaxSearchRadiusMeters { get; init; } = 1000.0; + public double ProjectionDistanceWarningThreshold { get; init; } = 50.0; + public bool SuppressWarnings { get; init; } = false; +} + +/// Configures train path calculation. +public sealed record PathConfig +{ + /// Emission probability distance scale (m). Default: 10.0 + public double DistanceScale { get; init; } = 10.0; + /// Emission probability heading scale (degrees). Default: 2.0 + public double HeadingScale { get; init; } = 2.0; + /// Maximum candidate distance from GNSS position (m). Default: 500.0 + public double CutoffDistanceMeters { get; init; } = 500.0; + /// Maximum heading difference for candidates (degrees). Default: 10.0 + public double HeadingCutoffDegrees { get; init; } = 10.0; + /// Minimum probability threshold for candidates (0–1). Default: 0.02 + public double ProbabilityThreshold { get; init; } = 0.02; + /// Resampling distance between GNSS positions (m). Null disables resampling. + public double? ResamplingDistanceMeters { get; init; } = null; + /// Maximum candidate netelements per GNSS position. Default: 3 + public int MaxCandidates { get; init; } = 3; + /// When true, skip projecting positions onto the path; ProjectedPositions will be empty. Default: false + public bool PathOnly { get; init; } = false; + /// Transition probability scale β in meters (Newson & Krumm). Default: 50.0 + public double Beta { get; init; } = 50.0; + /// Distance threshold for edge-zone handling (m). Default: 50.0 + public double EdgeZoneDistanceMeters { get; init; } = 50.0; + /// Turn-angle scale (degrees). Default: 30.0 + public double TurnScaleDegrees { get; init; } = 30.0; + /// Max distance for resolving coordinate-only detections (m). Default: 2.5 + public double DetectionCutoffDistanceMeters { get; init; } = 2.5; +} +``` + +--- + +## Result Types + +```csharp +namespace TpLib; + +public sealed record ProjectedPosition( + string NetelementId, + double MeasureMeters, + double ProjectionDistanceMeters, + /// Projected X coordinate in the output CRS. + double ProjectedX, + /// Projected Y coordinate in the output CRS. + double ProjectedY, + string Crs, + double OriginalLatitude, + double OriginalLongitude, + DateTimeOffset Timestamp, + /// + /// Normalised position along the matched segment (0–1, from start to end). + /// Populated when projecting onto a pre-calculated path (); + /// null for simple nearest-segment projection (). + /// + double? Intrinsic = null); + +public sealed record TrainPath( + IReadOnlyList Segments, + double OverallProbability, + DateTimeOffset? CalculatedAt); + +public sealed record AssociatedNetElement( + string NetelementId, + double Probability, + double StartIntrinsic, + double EndIntrinsic, + int GnssStartIndex, + int GnssEndIndex, + /// + /// Whether this segment was placed by the algorithm or manually added/adjusted + /// by a user in the webapp review interface. Defaults to ; + /// backward-compatible with older saved path files. + /// + PathOrigin Origin = PathOrigin.Algorithm); + +public sealed record PathResult( + TrainPath? Path, + PathCalculationMode Mode, + IReadOnlyList ProjectedPositions, + IReadOnlyList Warnings, + /// + /// Per-detection provenance after path calculation. Contains the final status + /// of every detection record that was passed in via PreparedDetections. + /// + IReadOnlyList DetectionProvenance) +{ + public bool HasPath => Path is not null; +} + +public sealed record PreparedDetections( + IReadOnlyList Records, + /// Non-fatal warnings emitted during detection preparation. + IReadOnlyList Warnings); +``` + +--- + +## Detection Input Types + +```csharp +namespace TpLib; + +public sealed record DetectionRecord( + string SourceFile, + ulong SourceRow, + DetectionKind Kind, + DetectionTimestamp Timestamp, + string? Id, + string? Source, + IReadOnlyDictionary Metadata, + DetectionStatus? Status = null); // null on input; populated after PrepareDetections + +public enum DetectionKind { Punctual, Linear } + +public abstract record DetectionTimestamp +{ + public sealed record Single(DateTimeOffset Timestamp) : DetectionTimestamp; + public sealed record Range(DateTimeOffset From, DateTimeOffset To) : DetectionTimestamp; +} +``` + +--- + +## Status & Reason Types + +```csharp +namespace TpLib; + +public abstract record DetectionStatus +{ + public sealed record Applied(string NetelementId, double Intrinsic) : DetectionStatus; + public sealed record Resolved(string NetelementId, double DistanceMeters) : DetectionStatus; + public sealed record Discarded(DiscardReason Reason) : DetectionStatus; +} + +public abstract record DiscardReason +{ + public sealed record OutOfTimeRange( + DateTimeOffset GnssFirst, + DateTimeOffset GnssLast) : DiscardReason; + + public sealed record OutOfReach( + double NearestDistanceMeters, + double CutoffMeters) : DiscardReason; + + public sealed record UnknownNetelement(string NetelementId) : DiscardReason; + public sealed record IntrinsicOutOfRange(double Value) : DiscardReason; + public sealed record DuplicateOfPriorDetection(int KeptIndex) : DiscardReason; +} + +public enum PathCalculationMode { TopologyBased, FallbackIndependent } + +/// +/// Indicates whether a path segment was placed by the path calculation algorithm +/// or manually added/adjusted by a user in the webapp review interface. +/// +public enum PathOrigin +{ + /// Segment selected by the Viterbi/HMM algorithm (default; backward-compatible with older path files). + Algorithm, + /// Segment manually added or adjusted by a user in the webapp. + Manual, +} +``` + +--- + +## Exception Hierarchy + +```csharp +namespace TpLib; + +/// Base for all tp-lib exceptions. +public class TpLibException : Exception +{ + public TpLibException(string message) : base(message) { } + public TpLibException(string message, Exception inner) : base(message, inner) { } +} + +/// File or stream read error. +public class TpLibIoException : TpLibException { ... } + +/// GeoJSON or CSV parse error. +public class TpLibParseException : TpLibException { ... } + +/// Invalid parameter value. +public class TpLibConfigurationException : TpLibException { ... } + +/// GNSS projection failure. +public class TpLibProjectionException : TpLibException { ... } + +/// No projection found within the search radius. +public class NoMatchWithinRadiusException : TpLibProjectionException { ... } + +/// Train path calculation failure. +public class TpLibPathException : TpLibException { ... } + +/// No navigable path exists in the network. +public class NoNavigablePathException : TpLibPathException { ... } + +/// Detection preparation failure. +public class TpLibDetectionException : TpLibException { ... } +``` + +--- + +## Stability Guarantees + +| API element | Stability | +|---|---| +| All `public` types/members above | **Stable** — breaking changes require major version bump | +| `NativeMethods.g.cs` (generated, `internal`) | **Internal** — not part of public contract | +| JSON interchange format between FFI layers | **Internal** — may change between any versions | +| Error message strings | **Unstable** — use exception types for branching, not message text | diff --git a/specs/005-dotnet-bindings/data-model.md b/specs/005-dotnet-bindings/data-model.md new file mode 100644 index 0000000..ca63a93 --- /dev/null +++ b/specs/005-dotnet-bindings/data-model.md @@ -0,0 +1,283 @@ +# Data Model: C#/.NET Bindings (tp-net) + +**Phase**: Phase 1 — Design & Contracts +**Date**: 2026-05-13 +**Feature**: `005-dotnet-bindings` + +--- + +## Overview + +The tp-net data model mirrors tp-py's public surface, mapping Rust core types to idiomatic C# records and classes. All types live in the `TpLib` namespace. Serialization across the native FFI boundary uses JSON (`System.Text.Json`); public C# types are the consumer-facing API. + +--- + +## Entity Map + +### Input Entities + +#### `ProjectionConfig` +Configuration for GNSS projection. + +| C# Property | Rust source | Type | Description | +|---|---|---|---| +| `MaxSearchRadiusMeters` | `max_search_radius_meters` | `double` | Maximum search radius for nearest-segment lookup (m). Default: 1000.0 | +| `ProjectionDistanceWarningThreshold` | `projection_distance_warning_threshold` | `double` | Warning threshold for large projection distances (m). Default: 50.0 | +| `SuppressWarnings` | `suppress_warnings` | `bool` | Suppress warning messages. Default: false | + +Validation: +- `MaxSearchRadiusMeters > 0` +- `ProjectionDistanceWarningThreshold >= 0` + +--- + +#### `PathConfig` +Configuration for train path calculation. + +| C# Property | Rust source | Type | Description | +|---|---|---|---| +| `DistanceScale` | `distance_scale` | `double` | Emission probability distance scale (m). Default: 10.0 | +| `HeadingScale` | `heading_scale` | `double` | Emission probability heading scale (degrees). Default: 2.0 | +| `CutoffDistanceMeters` | `cutoff_distance` | `double` | Maximum candidate distance from GNSS position (m). Default: 500.0 | +| `HeadingCutoffDegrees` | `heading_cutoff` | `double` | Maximum heading difference for candidates (degrees). Default: 10.0 | +| `ProbabilityThreshold` | `probability_threshold` | `double` | Minimum probability threshold for candidates (0–1). Default: 0.02 | +| `ResamplingDistanceMeters` | `resampling_distance` | `double?` | GNSS resampling distance (m). Null = disabled | +| `MaxCandidates` | `max_candidates` | `int` | Maximum candidate netelements per GNSS position. Default: 3 | +| `PathOnly` | `path_only` | `bool` | Skip projecting positions onto path; `ProjectedPositions` will be empty. Default: false | +| `Beta` | `beta` | `double` | Transition probability scale β in meters (Newson & Krumm). Default: 50.0 | +| `EdgeZoneDistanceMeters` | `edge_zone_distance` | `double` | Distance threshold for edge-zone handling (m). Default: 50.0 | +| `TurnScaleDegrees` | `turn_scale` | `double` | Turn-angle scale (degrees). Default: 30.0 | +| `DetectionCutoffDistanceMeters` | `detection_cutoff_distance` | `double` | Max distance for resolving coordinate-only detections (m). Default: 2.5 | + +Validation: +- All numeric fields ≥ 0 +- `ProbabilityThreshold` ∈ [0.0, 1.0] + +--- + +#### `DetectionRecord` *(input to `PrepareDetections`)* +A single train detection event (punctual or linear sensor). + +| C# Property | Rust source | Type | Description | +|---|---|---|---| +| `Id` | `id` | `string?` | Optional detection identifier | +| `Source` | `source` | `string?` | Optional source system identifier | +| `SourceFile` | `source_file` | `string` | Source file path | +| `SourceRow` | `source_row` | `ulong` | Row index in source file | +| `Kind` | `kind` | `DetectionKind` | `Punctual` or `Linear` | +| `Timestamp` | `timestamp` | `DetectionTimestamp` | Single instant or time range (see below) | +| `NetelementId` | *(via status)* | `string?` | Pre-assigned netelement reference (optional) | +| `Metadata` | `metadata` | `IReadOnlyDictionary` | Arbitrary key-value pairs from source file | + +--- + +#### `DetectionTimestamp` *(discriminated union)* + +| Variant | C# representation | Fields | +|---|---|---| +| `Single` | `DetectionTimestamp.Single` | `Timestamp: DateTimeOffset` | +| `Range` | `DetectionTimestamp.Range` | `From: DateTimeOffset`, `To: DateTimeOffset` | + +Implementation: abstract base class with two sealed subclasses. Both carry timezone-aware `DateTimeOffset` (maps from Rust's `DateTime`). + +--- + +#### `NetworkSegment` *(netelement — track geometry)* +A single railway track segment, wrapping the Rust `Netelement` struct. +Corresponds to GeoJSON features with `"type": "netelement"`. + +| C# Property | Rust source | Type | Description | +|---|---|---|---| +| `Id` | `id` | `string` | Unique netelement identifier | +| `Coordinates` | `geometry` (`LineString`) | `IReadOnlyList<(double Longitude, double Latitude)>` | Ordered list of coordinate pairs (longitude first, GeoJSON convention). Minimum 2 points. | +| `Crs` | `crs` | `string` | Coordinate reference system. Default: `"EPSG:4326"` | + +Validation: +- `Id` must be non-empty. +- `Coordinates` must have at least 2 points. + +--- + +#### `NetworkRelation` *(netrelation — topology)* +A directed connection between two track segments, wrapping the Rust `NetRelation` struct. +Corresponds to GeoJSON features with `"type": "netrelation"`. + +| C# Property | Rust source | GeoJSON field | Type | Description | +|---|---|---|---|---| +| `Id` | `id` | `id` | `string` | Unique netrelation identifier | +| `NetelementAId` | `from_netelement_id` | `netelementA` | `string` | ID of the first connected track segment | +| `NetelementBId` | `to_netelement_id` | `netelementB` | `string` | ID of the second connected track segment | +| `PositionOnA` | `position_on_a` | `positionOnA` | `int` | Endpoint of segment A used by this connection: `0` = start, `1` = end | +| `PositionOnB` | `position_on_b` | `positionOnB` | `int` | Endpoint of segment B used by this connection: `0` = start, `1` = end | +| `Navigability` | `navigable_forward`/`navigable_backward` | `navigability` | `Navigability` | Allowed travel directions | + +**`Navigability` enum**: + +| Value | GeoJSON string | Description | +|---|---|---| +| `Both` | `"both"` | Trains may travel in both directions | +| `Forward` | `"AB"` | Trains may travel from A to B only | +| `Backward` | `"BA"` | Trains may travel from B to A only | +| `None` | `"none"` | No train movement permitted | + +Validation: +- `Id`, `NetelementAId`, `NetelementBId` must be non-empty. +- `PositionOnA` and `PositionOnB` must each be `0` or `1`. + +--- + +`NetworkInput` is the wrapper that carries the chosen entry path (`FromRecords` or `FromGeoJson`). When `FromGeoJson` is called, tp-net passes the raw string to the Rust core unchanged. When `FromRecords` is called, tp-net serializes both collections into an equivalent GeoJSON FeatureCollection internally before crossing the FFI boundary — the two datasets are merged into a single FeatureCollection with mixed feature types, matching the format the Rust core expects. + +--- + +### Output Entities + +#### `ProjectedPosition` +A single GNSS position projected onto the railway network. + +| C# Property | Rust source | Type | Description | +|---|---|---|---| +| `NetelementId` | `netelement_id` | `string` | Track segment ID | +| `MeasureMeters` | `measure_meters` | `double` | Distance along netelement from start (m) | +| `ProjectionDistanceMeters` | `projection_distance_meters` | `double` | Perpendicular projection distance (m) | +| `ProjectedX` | `projected_coords.x()` | `double` | Projected X coordinate in the output CRS | +| `ProjectedY` | `projected_coords.y()` | `double` | Projected Y coordinate in the output CRS | +| `Crs` | `crs` | `string` | Coordinate reference system | +| `OriginalLatitude` | `original.latitude` | `double` | Original WGS84 latitude | +| `OriginalLongitude` | `original.longitude` | `double` | Original WGS84 longitude | +| `Timestamp` | `original.timestamp` | `DateTimeOffset` | Observation time with timezone | +| `Intrinsic` | `intrinsic` | `double?` | Normalised position along the matched segment (0–1, from start to end). Populated when projecting onto a pre-calculated path (`ProjectOntoPath`); null for simple nearest-segment projection (`ProjectGnss`) | + +--- + +#### `TrainPath` +Reconstructed path of a train across the railway network. + +| C# Property | Rust source | Type | Description | +|---|---|---|---| +| `Segments` | `segments` | `IReadOnlyList` | Ordered traversal segments | +| `OverallProbability` | `overall_probability` | `double` | Path quality score (0–1) | +| `CalculatedAt` | `calculated_at` | `DateTimeOffset?` | Calculation timestamp | + +--- + +#### `AssociatedNetElement` +A single network element in a train path. + +| C# Property | Rust source | Type | Description | +|---|---|---|---| +| `NetelementId` | `netelement_id` | `string` | Track segment ID | +| `Probability` | `probability` | `double` | Segment confidence score (0–1) | +| `StartIntrinsic` | `start_intrinsic` | `double` | Entry point (0–1) | +| `EndIntrinsic` | `end_intrinsic` | `double` | Exit point (0–1) | +| `GnssStartIndex` | `gnss_start_index` | `int` | First GNSS position index | +| `GnssEndIndex` | `gnss_end_index` | `int` | Last GNSS position index | +| `Origin` | `origin` | `PathOrigin` | Whether segment was placed by the algorithm or manually added/adjusted by a user in the webapp (see `PathOrigin`). Default: `Algorithm` | + +--- + +#### `PathResult` +Full result from `CalculateTrainPath`, including optional path and diagnostics. + +| C# Property | Rust source | Type | Description | +|---|---|---|---| +| `Path` | `path` | `TrainPath?` | Calculated path; null if calculation failed | +| `Mode` | `mode` | `PathCalculationMode` | `TopologyBased` or `FallbackIndependent` | +| `ProjectedPositions` | `projected_positions` | `IReadOnlyList` | All GNSS positions projected along the reconstructed path; empty when `PathConfig.PathOnly = true` | +| `Warnings` | `warnings` | `IReadOnlyList` | Alerts emitted during calculation | +| `DetectionProvenance` | `detection_provenance` | `IReadOnlyList` | Final provenance of every detection passed via `PreparedDetections`; empty when no detections were supplied | +| `HasPath` | computed | `bool` | `Path != null` | + +--- + +#### `PreparedDetections` +Result from `PrepareDetections`, containing enriched detection records. + +| C# Property | Rust source | Type | Description | +|---|---|---|---| +| `Records` | `records` | `IReadOnlyList` | All records with status applied | +| `Warnings` | `warnings` | `IReadOnlyList` | Non-fatal warnings emitted during preparation | + +--- + +### Status & Reason Enumerations + +#### `DetectionKind` +``` +Punctual — single-point sensor event +Linear — span/range sensor event +``` + +#### `DetectionStatus` *(discriminated union on `DetectionRecord.Status`)* + +| Variant | Extra fields | Description | +|---|---|---| +| `Applied` | `NetelementId: string`, `Intrinsic: double` | Detection directly matched to a network element | +| `Resolved` | `NetelementId: string`, `DistanceMeters: double` | Detection matched by proximity | +| `Discarded` | `Reason: DiscardReason` | Detection could not be matched | + +#### `DiscardReason` *(discriminated union)* + +| Variant | Extra fields | Description | +|---|---|---| +| `OutOfTimeRange` | `GnssFirst: DateTimeOffset`, `GnssLast: DateTimeOffset` | Timestamp outside GNSS coverage window | +| `OutOfReach` | `NearestDistanceMeters: double`, `CutoffMeters: double` | No network element within search radius | +| `UnknownNetelement` | `NetelementId: string` | Referenced element not in network | +| `IntrinsicOutOfRange` | `Value: double` | Computed intrinsic outside [0, 1] | +| `DuplicateOfPriorDetection` | `KeptIndex: int` | Duplicate of an earlier record | + +#### `PathCalculationMode` +``` +TopologyBased — network graph used for HMM/Viterbi matching +FallbackIndependent — topology unavailable; segments matched independently +``` + +#### `PathOrigin` +``` +Algorithm — segment selected by the Viterbi/HMM algorithm (default; backward-compatible with older saved path files) +Manual — segment manually added or adjusted by a user in the webapp review interface +``` + +--- + +## Exception Hierarchy + +All exceptions derive from `TpLibException` (base). + +``` +TpLibException +├── TpLibIoException — file/stream read errors +├── TpLibParseException — GeoJSON or CSV parse errors +├── TpLibConfigurationException — invalid parameter values +├── TpLibProjectionException — projection failures +│ └── NoMatchWithinRadiusException +├── TpLibPathException — path calculation failures +│ └── NoNavigablePathException +└── TpLibDetectionException — detection preparation failures +``` + +Maps from Rust's `ProjectionError` variants (see tp-py `convert_error` for reference). + +--- + +## Type Lifecycle & Memory Management + +- All public C# types are **managed** (GC-owned); no `IDisposable` required by consumers. +- The native library is loaded once via `NativeLibrary.SetDllImportResolver` on first use (static initializer in `TpLibNative`). +- `ByteBuffer` allocations returned from native code are freed by the C# wrapper immediately after deserialization (via `tp_lib_net_free_buffer` FFI call). + +--- + +## FFI Type Mapping Summary + +| Rust core type | FFI boundary | C# public type | +|---|---|---| +| `ProjectionConfig` | `#[repr(C)]` struct | `ProjectionConfig` record | +| `PathConfig` | `#[repr(C)]` struct | `PathConfig` record | +| `Vec` | JSON `ByteBuffer` | `IReadOnlyList` | +| `PathResult` | JSON `ByteBuffer` | `PathResult` | +| `TrainPath` (input to `project_onto_path`) | JSON `ByteBuffer` | `TrainPath` | +| `Vec` (input) | JSON `ByteBuffer` | `IEnumerable` | +| `PreparedDetections` (output) | JSON `ByteBuffer` | `PreparedDetections` | +| `ProjectionError` | i32 error code + message buffer | `TpLibException` subclass | +| `DetectionError` | i32 error code + message buffer | `TpLibDetectionException` | diff --git a/specs/005-dotnet-bindings/plan.md b/specs/005-dotnet-bindings/plan.md new file mode 100644 index 0000000..10079a2 --- /dev/null +++ b/specs/005-dotnet-bindings/plan.md @@ -0,0 +1,83 @@ +# Implementation Plan: C#/.NET Bindings (tp-net) + +**Branch**: `005-dotnet-bindings` | **Date**: 2026-05-13 | **Spec**: [spec.md](spec.md) +**Input**: Feature specification from `specs/005-dotnet-bindings/spec.md` + +## Summary + +Add C#/.NET bindings for tp-lib-core to enable .NET 8+ consumers to perform GNSS projection, train path calculation, and detection preparation via a `TpLib` NuGet package. The native layer is a new `tp-net` Rust crate (cdylib) that exposes `extern "C"` functions; `csbindgen` generates P/Invoke stubs at build time; a hand-written C# wrapper layer provides idiomatic types and exception handling. JSON serialization bridges complex types across the FFI boundary. Pre-built native binaries for `win-x64`, `linux-x64`, `osx-x64`, and `osx-arm64` are packaged via GitHub Actions matrix build. + +## Technical Context + +**Language/Version**: Rust 1.75 (tp-net crate) + C# 12 / .NET 8 (TpLib managed) +**Primary Dependencies**: `csbindgen` (FFI stub generation), `serde_json` (FFI marshalling), `tp-lib-core` (core algorithms); C# side: `System.Text.Json` (deserialization), xUnit (testing) +**Storage**: N/A — stateless function calls only +**Testing**: `cargo test` (Rust unit tests in tp-net), `dotnet test` (xUnit in TpLib.Tests), integration tests using `test-data/` GeoJSON/CSV fixtures +**Target Platform**: .NET 8+ on `win-x64`, `linux-x64`, `osx-x64`, `osx-arm64` +**Project Type**: Single Rust crate + single .NET SDK project +**Performance Goals**: Match tp-py throughput; projection of 1k GNSS points < 500ms; path calc of 1k points < 2s +**Constraints**: Net8.0 minimum TFM; no managed unsafe code exposed to consumers; all FFI unsafe code isolated in `NativeMethods.g.cs` and `TpLibNative.cs` (internal) +**Scale/Scope**: Same workload scale as tp-py (batch processing, not streaming) + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-checked after Phase 1 design.* + +| Principle | Check | Notes | +|---|---|---| +| I — Library-First | ✅ PASS | tp-net is a library crate (cdylib); no application code | +| II — CLI Mandatory | ⚠️ N/A (justified) | Bindings are a language-bridge crate; CLI is `tp-cli` and remains mandatory for other features. No CLI is added here because the .NET package IS a consumer-facing library, not a tool. See Complexity Tracking. | +| III — High Performance | ✅ PASS | FFI overhead is minimal (JSON serialization at I/O boundary only, not hot path) | +| IV — TDD | ✅ GATE MUST PASS | All C# code follows Red→Green→Refactor. xUnit tests written before implementation. | +| V — Full Test Coverage | ✅ GATE MUST PASS | 100% line coverage on `TpLib.cs` and `Exceptions.cs` via xUnit; Rust FFI functions covered by cargo test | +| VI — Timezone Awareness | ✅ PASS | All timestamps use `DateTimeOffset`; UTC offsets preserved through FFI via ISO8601 strings | +| VII — CRS Awareness | ✅ PASS | `ProjectedPosition.Crs` field carries the CRS string from core; no CRS assumptions in wrapper layer | +| XI — Modern Module Org | ✅ PASS | `tp-net/src/lib.rs` (not `tp-net/src/mod.rs`); submodules use `tp-net/src/ffi.rs`, `tp-net/src/marshal.rs` pattern | + +## Project Structure + +### Documentation (this feature) + +```text +specs/005-dotnet-bindings/ +├── plan.md # This file +├── research.md # Phase 0 output +├── data-model.md # Phase 1 output +├── quickstart.md # Phase 1 output +├── contracts/ +│ └── api.md # Phase 1 output +└── tasks.md # Phase 2 output (speckit.tasks) +``` + +### Source Code (repository root) + +```text +tp-net/ +├── Cargo.toml # crate-type = ["cdylib","rlib"]; deps: tp-lib-core, csbindgen (build), serde_json, serde +├── build.rs # csbindgen::Builder::new().input_extern_file("src/lib.rs").csharp_dll_name("tp_lib_net").generate_to_file("csharp/NativeMethods.g.cs") +├── src/ +│ ├── lib.rs # FFI entry points (#[no_mangle] pub extern "C" fn ...) +│ ├── ffi.rs # #[repr(C)] structs (ProjectionConfigFfi, PathConfigFfi) + ByteBuffer +│ └── marshal.rs # Rust-side JSON serialization helpers for complex types +└── csharp/ + ├── TpLib.csproj # net8.0; NuGet packaging props + ├── TpLib.cs # Public API: Projection, PathCalculation, DetectionPreparation static classes + ├── Models.cs # Public record types: ProjectedPosition, TrainPath, etc. + ├── Enums.cs # Enums + discriminated union base types + ├── Exceptions.cs # TpLibException hierarchy + ├── NativeMethods.g.cs # (generated by csbindgen at build time — do not edit) + ├── TpLibNative.cs # Internal: NativeLibrary resolver + ByteBuffer free helper + └── Tests/ + ├── TpLib.Tests.csproj # xUnit 2, net8.0 + ├── ProjectionTests.cs + ├── PathCalculationTests.cs + └── DetectionPreparationTests.cs +``` + +**Structure Decision**: Option 1 (single Rust crate + single .NET SDK project), mirroring `tp-py/` layout. `tp-net/` is added as a workspace member in `Cargo.toml`. + +## Complexity Tracking + +| Violation | Why Needed | Simpler Alternative Rejected Because | +|-----------|------------|-------------------------------------| +| CLI not added (Principle II) | tp-net is a language-binding crate, not a feature with user-facing operations. The .NET package IS the consumer interface — adding a CLI would duplicate tp-cli for no benefit. | A CLI wrapping NuGet consumption would be redundant with the existing `tp-cli` binary and would not demonstrate any new capability. | diff --git a/specs/005-dotnet-bindings/quickstart.md b/specs/005-dotnet-bindings/quickstart.md new file mode 100644 index 0000000..083347e --- /dev/null +++ b/specs/005-dotnet-bindings/quickstart.md @@ -0,0 +1,549 @@ +# Quickstart: TpLib C#/.NET Bindings + +**Feature**: `005-dotnet-bindings` +**Target audience**: .NET developers consuming tp-lib from C# + +--- + +## Prerequisites + +- .NET 8 SDK or later +- A supported platform: Windows x64, Linux x64, macOS x64, or macOS arm64 +- Railway network data available in one of two forms: + - a GeoJSON string (file or database text/jsonb column — see `test-data/sample_network.geojson` for format), + - or structured rows from a relational `network_segments` table. Pass either form via `NetworkInput.FromGeoJson()` or `NetworkInput.FromRecords()` respectively — or use the raw-string convenience overloads for quick scripting. +- GNSS positions in one of three forms: + - **GeoJSON string**: `GnssInput.FromGeoJson(string)` + - **CSV string** with `latitude`, `longitude`, and `timestamp` columns: `GnssInput.FromCsv(string)` + - **Typed objects** mapped from database rows: `GnssInput.FromRecords(IEnumerable)` — no serialization required on the caller's side + +--- + +## 1. Add the NuGet Package + +```bash +dotnet add package TpLib +``` + +Or in your `.csproj`: + +```xml + +``` + +The package includes pre-built native binaries for all supported platforms. No additional setup is required — the correct binary is loaded automatically at runtime. + +--- + +## 2. Project GNSS Positions (Simple Projection) + +`Projection.ProjectGnss()` performs **simple, topology-free projection** (feature 001): each GNSS position is matched independently to its geometrically nearest netelement using an R-tree spatial index. Network relations are not used and do not need to be present in the network input. + +Use this when: +- You want a quick per-point nearest-segment match without full path reconstruction. +- You are doing data quality analysis or debugging GNSS accuracy. +- Netrelations are not available. + +For topology-aware path reconstruction that leverages network connectivity, see [Section 3: Calculate a Train Path](#3-calculate-a-train-path). + +```csharp +using TpLib; + +// Load your network and GNSS data +string networkGeoJson = File.ReadAllText("sample_network.geojson"); +string gnssGeoJson = File.ReadAllText("sample_gnss.geojson"); + +// Project GNSS onto the network with default settings +IReadOnlyList positions = Projection.ProjectGnss( + networkGeoJson, + gnssGeoJson); + +foreach (var pos in positions) +{ + Console.WriteLine($"[{pos.Timestamp:O}] {pos.NetelementId} @ {pos.MeasureMeters:F1}m " + + $"(dist={pos.ProjectionDistanceMeters:F2}m)"); +} +``` + +Custom configuration: + +```csharp +var config = new ProjectionConfig +{ + MaxSearchRadiusMeters = 500.0, + ProjectionDistanceWarningThreshold = 30.0, + SuppressWarnings = true +}; + +IReadOnlyList positions = Projection.ProjectGnss( + networkGeoJson, + gnssGeoJson, + config); +``` + +--- + +## 3. Calculate a Train Path + +`PathCalculation.CalculateTrainPath()` uses a **Hidden Markov Model (Viterbi algorithm)** over network topology to find the most probable train path (feature 002). The returned `ProjectedPositions` are the GNSS points projected *along the reconstructed path*, which is more accurate than simple nearest-segment projection. + +Check `result.Mode` to know which algorithm was used: +- `PathCalculationMode.TopologyBased` — the HMM used network topology successfully (normal case). +- `PathCalculationMode.FallbackIndependent` — topology was insufficient; the library fell back to per-point nearest-segment matching (equivalent to `Projection.ProjectGnss()`). Check `result.Warnings` for details. + +If detection anchors are available, prepare them first (see [Section 4](#4-prepare-detection-records)) and pass the `PreparedDetections` result to this call so anchors constrain the HMM path. + +```csharp +using TpLib; + +string networkGeoJson = File.ReadAllText("sample_network.geojson"); +string gnssGeoJson = File.ReadAllText("sample_gnss.geojson"); + +PathResult result = PathCalculation.CalculateTrainPath(networkGeoJson, gnssGeoJson); + +if (result.HasPath) +{ + TrainPath path = result.Path!; + Console.WriteLine($"Path probability: {path.OverallProbability:P1}"); + Console.WriteLine($"Segments: {path.Segments.Count}"); + + foreach (var seg in path.Segments) + { + Console.WriteLine($" {seg.NetelementId}: {seg.StartIntrinsic:F3} → {seg.EndIntrinsic:F3}"); + } +} +else +{ + Console.WriteLine("No path found."); + foreach (var warning in result.Warnings) + Console.WriteLine($" Warning: {warning}"); +} +``` + +Custom path configuration: + +```csharp +var pathConfig = new PathConfig +{ + CutoffDistanceMeters = 500.0, + ProbabilityThreshold = 0.05, + ResamplingDistanceMeters = 10.0 +}; + +PathResult result = PathCalculation.CalculateTrainPath( + networkGeoJson, + gnssGeoJson, + pathConfig); +``` + +--- + +## 4. Project Onto a Pre-Calculated Path + +`Projection.ProjectOntoPath()` projects GNSS positions along a **pre-calculated** `TrainPath`. This is the equivalent of the tp-cli `--train-path` flag: + +```bash +# Calculate path and project in one step (Section 3) +tp-cli --gnss positions.csv --network network.geojson --output result.csv + +# Project onto a previously saved/reviewed path (this section) +tp-cli --gnss positions.csv --network network.geojson --train-path path.csv --output result.csv +``` + +Use this workflow when you: +- Have a path that was reviewed and edited in the webapp and want to re-project GNSS positions onto it. +- Want to re-use a path calculated from one GNSS recording to project another recording on the same route. + +Unlike `ProjectGnss`, **every returned `ProjectedPosition` has `Intrinsic` populated** (normalised 0–1 offset along the matched segment). + +```csharp +using TpLib; + +string networkGeoJson = File.ReadAllText("sample_network.geojson"); +string gnssGeoJson = File.ReadAllText("sample_gnss.geojson"); + +// --- Option A: path comes from a prior CalculateTrainPath call --- +PathResult calc = PathCalculation.CalculateTrainPath(networkGeoJson, gnssGeoJson); +TrainPath reviewedPath = calc.Path ?? throw new InvalidOperationException("No path found"); + +// Optionally: persist reviewedPath to JSON, send to webapp for review, reload later. +// string json = JsonSerializer.Serialize(reviewedPath); +// reviewedPath = JsonSerializer.Deserialize(json)!; + +// --- Option B: path loaded from a previously saved file --- +// TrainPath reviewedPath = JsonSerializer.Deserialize( +// File.ReadAllText("reviewed_path.json"))!; + +// Project GNSS positions onto the reviewed path. +IReadOnlyList positions = Projection.ProjectOntoPath( + networkGeoJson, + GnssInput.FromGeoJson(gnssGeoJson), + reviewedPath); + +foreach (var pos in positions) +{ + // Intrinsic is always non-null when using ProjectOntoPath + Console.WriteLine( + $"{pos.Timestamp:u} {pos.NetelementId} " + + $"measure={pos.MeasureMeters:F1}m intrinsic={pos.Intrinsic:F4} " + + $"dist={pos.ProjectionDistanceMeters:F1}m"); +} +``` + +**Key differences from `CalculateTrainPath`:** + +| | `CalculateTrainPath` | `ProjectOntoPath` | +|---|---|---| +| Input path | Calculated internally | Provided by caller | +| `Intrinsic` | Populated | Populated | +| `PathResult.Mode` | Returned | N/A (no topology step) | +| Detections | Supported | Not applicable | +| Use case | First run, unknown path | Re-projection on known path | + +**Preconditions:** +- All `NetelementId` values in `path.Segments` must exist in the provided network. +- `TpLibPathException` is thrown when any referenced netelement is missing. + +--- + +## 5. Prepare Detection Records + +Detection records (axle counters, loop sensors, etc.) serve as **spatial anchors** that constrain the HMM path calculation. The correct workflow is: + +1. **Prepare detections first** — time-filter, validate, and resolve detection events against the network. +2. **Pass the result to `CalculateTrainPath`** — resolved anchors constrain the Viterbi path. + +```csharp +using TpLib; + +string networkGeoJson = File.ReadAllText("sample_network.geojson"); +string gnssGeoJson = File.ReadAllText("sample_gnss.geojson"); + +// Step 1: Build detection records (e.g. from CSV or DB) +var detections = new List +{ + new DetectionRecord( + SourceFile: "sensors.csv", + SourceRow: 0, + Kind: DetectionKind.Punctual, + Timestamp: new DetectionTimestamp.Single( + DateTimeOffset.Parse("2026-03-13T17:15:00+01:00")), + Id: "D001", + Source: "axle-counter-A", + Metadata: new Dictionary { ["confidence"] = "0.95" }), + + new DetectionRecord( + SourceFile: "sensors.csv", + SourceRow: 1, + Kind: DetectionKind.Linear, + Timestamp: new DetectionTimestamp.Range( + From: DateTimeOffset.Parse("2026-03-13T17:15:30+01:00"), + To: DateTimeOffset.Parse("2026-03-13T17:15:45+01:00")), + Id: "D002", + Source: "loop-sensor-B", + Metadata: ImmutableDictionary.Empty), +}; + +// Step 2: Prepare detections — time-filter and resolve to network elements. +// Uses the GNSS window to discard out-of-range events. +PreparedDetections prepared = DetectionPreparation.PrepareDetections( + networkGeoJson, + GnssInput.FromGeoJson(gnssGeoJson), + detections); + +foreach (var warning in prepared.Warnings) + Console.WriteLine($"Detection warning: {warning}"); + +// Step 3: Calculate path anchored by the prepared detections. +PathResult pathResult = PathCalculation.CalculateTrainPath( + networkGeoJson, + gnssGeoJson, + config: null, + detections: prepared); + +// Step 4: Inspect detection provenance from the path result. +foreach (var record in pathResult.DetectionProvenance) +{ + switch (record.Status) + { + case DetectionStatus.Applied applied: + Console.WriteLine($"{record.Id}: Applied to {applied.NetelementId}"); + break; + case DetectionStatus.Resolved resolved: + Console.WriteLine($"{record.Id}: Resolved to {resolved.NetelementId} ({resolved.DistanceMeters:F1}m away)"); + break; + case DetectionStatus.Discarded { Reason: DiscardReason.OutOfTimeRange otr }: + Console.WriteLine($"{record.Id}: Discarded — outside GNSS window [{otr.GnssFirst:t} – {otr.GnssLast:t}]"); + break; + case DetectionStatus.Discarded { Reason: DiscardReason.OutOfReach oor }: + Console.WriteLine($"{record.Id}: Discarded — nearest element {oor.NearestDistanceMeters:F0}m (cutoff {oor.CutoffMeters:F0}m)"); + break; + case DetectionStatus.Discarded discarded: + Console.WriteLine($"{record.Id}: Discarded — {discarded.Reason.GetType().Name}"); + break; + } +} +``` + +--- + +## 6. Error Handling + +All TpLib operations throw typed exceptions. Catch the base `TpLibException` or specific subclasses: + +```csharp +using TpLib; + +try +{ + var positions = Projection.ProjectGnss(networkGeoJson, gnssGeoJson); +} +catch (TpLibParseException ex) +{ + Console.Error.WriteLine($"Failed to parse input: {ex.Message}"); +} +catch (NoMatchWithinRadiusException ex) +{ + // All GNSS points fell outside the search radius + Console.Error.WriteLine($"Projection failed: {ex.Message}"); +} +catch (TpLibException ex) +{ + // Catch-all for other tp-lib errors + Console.Error.WriteLine($"tp-lib error: {ex.Message}"); +} +``` + +--- + +## 7. Timezone Awareness + +All timestamps in TpLib use `DateTimeOffset`, preserving the original UTC offset. +When parsing timestamps from CSV/text, always specify the timezone: + +```csharp +// Good — timezone preserved +var ts = DateTimeOffset.Parse("2026-03-13T17:15:00+01:00"); + +// Avoid — loses timezone info +var ts = DateTime.Parse("2026-03-13T17:15:00"); +``` + +GNSS timestamps returned in `ProjectedPosition.Timestamp` carry the original UTC offset from the GeoJSON source. + +--- + +## 8. Full Pipeline Example + +This example shows two common workflows: + +**Workflow A — Simple projection only** (no topology, no detections): fast nearest-segment match, good for diagnostics. + +**Workflow B — Topology-aware path with detections** (recommended for production): prepare detection anchors first, then calculate the path with those anchors. + +```csharp +using TpLib; + +// Load data +string networkGeoJson = File.ReadAllText("network.geojson"); +string gnssGeoJson = File.ReadAllText("gnss_log.geojson"); + +// ── Workflow A: Simple projection (topology-free) ── +IReadOnlyList projected = Projection.ProjectGnss( + networkGeoJson, gnssGeoJson, + new ProjectionConfig { ProjectionDistanceWarningThreshold = 30.0 }); + +Console.WriteLine($"Simple projection: {projected.Count} positions."); + +// ── Workflow B: Topology-aware path calculation with detection anchors ── + +// Step 1: Prepare detections (must happen before CalculateTrainPath) +var detections = ParseDetections("detections.csv"); // your CSV parser +PreparedDetections prepared = DetectionPreparation.PrepareDetections( + networkGeoJson, GnssInput.FromGeoJson(gnssGeoJson), detections); + +Console.WriteLine($"Detection preparation: {prepared.Records.Count} records, {prepared.Warnings.Count} warnings."); + +// Step 2: Calculate path anchored by the prepared detections +PathResult pathResult = PathCalculation.CalculateTrainPath( + networkGeoJson, gnssGeoJson, + config: null, + detections: prepared); + +Console.WriteLine(pathResult.HasPath + ? $"Path ({pathResult.Mode}): {pathResult.Path!.Segments.Count} segments, p={pathResult.Path.OverallProbability:P1}" + : $"No path found (mode={pathResult.Mode})."); + +// Step 3: Inspect detection provenance +int applied = pathResult.DetectionProvenance.Count(r => r.Status is DetectionStatus.Applied); +int resolved = pathResult.DetectionProvenance.Count(r => r.Status is DetectionStatus.Resolved); +int discarded = pathResult.DetectionProvenance.Count(r => r.Status is DetectionStatus.Discarded); + +Console.WriteLine($"Detections: {applied} applied, {resolved} resolved, {discarded} discarded."); +``` + +--- + +## 9. Database-Backed Service Integration + +When tp-lib is used inside a service that stores railway data in PostgreSQL (or any relational database), all inputs are available as in-memory data — no files or string serialization steps are required on the caller's side. + +Use `GnssInput.FromRecords()` to map database rows directly to typed `GnssRecord` objects. The library handles everything across the FFI boundary transparently. + +### Prerequisites + +- Network topology available in one of two forms: + - **GeoJSON text/jsonb column**: wrap with `NetworkInput.FromGeoJson(fetchedString)`. + - **Structured tables**: a `netelements` table (`id`, `crs`, `coordinates`) and a `netrelations` table (`id`, `netelement_a_id`, `netelement_b_id`, `position_on_a`, `position_on_b`, `navigability`). Map rows to `NetworkSegment` + `NetworkRelation` and wrap with `NetworkInput.FromRecords(netelements, netrelations)`. +- GNSS readings stored as structured rows: `latitude DOUBLE PRECISION`, `longitude DOUBLE PRECISION`, `timestamp TIMESTAMPTZ`. +- Detection events stored as structured rows (or a joined view). + +The examples below use [Dapper](https://github.com/DapperLib/Dapper) for brevity; any ADO.NET-compatible library works. + +### Fetching and projecting + +```csharp +using TpLib; +using Dapper; +using Npgsql; + +await using var connection = new NpgsqlConnection(connectionString); + +// Option A: network stored as a GeoJSON text/jsonb column +string rawGeoJson = await connection.QuerySingleAsync( + "SELECT geojson FROM railway_networks WHERE id = @networkId", + new { networkId }); +NetworkInput networkInput = NetworkInput.FromGeoJson(rawGeoJson); + +// Option B: network stored as structured rows — two tables (netelements + netrelations) +var elementRows = await connection.QueryAsync( + "SELECT id, crs, coordinates FROM netelements WHERE network_id = @networkId", + new { networkId }); +var relationRows = await connection.QueryAsync( + """SELECT id, netelement_a_id, netelement_b_id, + position_on_a, position_on_b, navigability + FROM netrelations WHERE network_id = @networkId""", + new { networkId }); +NetworkInput networkInput = NetworkInput.FromRecords( + netelements: elementRows.Select(r => new NetworkSegment( + Id: r.id, + Coordinates: ParseCoordinates(r.coordinates), // your geometry deserializer + Crs: r.crs)), + netrelations: relationRows.Select(r => new NetworkRelation( + Id: r.id, + NetelementAId: r.netelement_a_id, + NetelementBId: r.netelement_b_id, + PositionOnA: r.position_on_a, + PositionOnB: r.position_on_b, + Navigability: Enum.Parse(r.navigability, ignoreCase: true)))); + +// 2. Fetch GNSS rows and map directly to typed records — no serialization required +var gnssRows = await connection.QueryAsync( + """ + SELECT latitude, longitude, timestamp + FROM gnss_logs + WHERE task_id = @taskId + ORDER BY timestamp + """, + new { taskId }); + +GnssInput gnssInput = GnssInput.FromRecords( + gnssRows.Select(r => new GnssRecord(r.latitude, r.longitude, r.timestamp))); + +// 3. Project GNSS onto the network +IReadOnlyList positions = Projection.ProjectGnss(networkInput, gnssInput); +``` + +### Full pipeline + +```csharp +// 5. Fetch detection records and map to tp-lib types +var detectionRows = await connection.QueryAsync( + """ + SELECT id, source, source_file, source_row, kind, + timestamp_from, timestamp_to + FROM detections + WHERE task_id = @taskId + """, + new { taskId }); + +var detections = detectionRows.Select(r => new DetectionRecord +{ + Id = r.id?.ToString(), + Source = r.source, + SourceFile = r.source_file, + SourceRow = (ulong)r.source_row, + Kind = Enum.Parse(r.kind, ignoreCase: true), + Timestamp = r.timestamp_to is null + ? new DetectionTimestamp.Single(r.timestamp_from) + : new DetectionTimestamp.Range(r.timestamp_from, r.timestamp_to), + Metadata = new Dictionary() +}).ToList(); + +// 6. Prepare detections first — anchors constrain the HMM path calculation. +PreparedDetections prepared = DetectionPreparation.PrepareDetections( + networkInput, gnssInput, detections); + +// 7. Calculate path with detection anchors +PathResult pathResult = PathCalculation.CalculateTrainPath( + networkInput, gnssInput, config: null, detections: prepared); + +if (!pathResult.HasPath) +{ + logger.LogWarning("No path found for task {TaskId}", taskId); + return; +} + +// 8. Persist results +await WriteResultsAsync(connection, taskId, pathResult, prepared); +``` + +### Key points + +| Concern | Approach | +|---|---| +| Network from GeoJSON column | `NetworkInput.FromGeoJson(fetchedString)` — no conversion needed | +| Network from structured tables | Map rows to `NetworkSegment` + `NetworkRelation` → `NetworkInput.FromRecords(netelements, netrelations)` — no serialization | +| GNSS data from DB | Map rows to `GnssRecord` → `GnssInput.FromRecords()` — no serialization | +| GNSS data from CSV file | `GnssInput.FromCsv(File.ReadAllText(...))` | +| GNSS data as GeoJSON | `GnssInput.FromGeoJson(string)` or the `string gnssGeoJson` convenience overload | +| Timestamps | Use `DateTimeOffset` (Dapper maps `TIMESTAMPTZ` automatically) — timezone offset is required | +| No temp files | The entire pipeline runs in-memory | + +--- + +## Supported Platforms + +| RID | OS | Architecture | +|---|---|---| +| `win-x64` | Windows 10/11, Windows Server 2019+ | x86-64 | +| `linux-x64` | Ubuntu 20.04+, Debian 11+, RHEL 8+ | x86-64 | +| `osx-x64` | macOS 12+ | Intel | +| `osx-arm64` | macOS 12+ | Apple Silicon | + +--- + +## Building from Source + +To build the native library and C# wrapper from source: + +```bash +# Clone the repository +git clone https://github.com/infrabel/tp-lib.git +cd tp-lib + +# Build the Rust native library for your platform +cargo build --release --package tp-net + +# Build and test the C# project +cd tp-net/csharp +dotnet build +dotnet test +``` + +To build the NuGet package locally: + +```bash +cd tp-net/csharp +dotnet pack -c Release +``` diff --git a/specs/005-dotnet-bindings/research.md b/specs/005-dotnet-bindings/research.md new file mode 100644 index 0000000..63ed811 --- /dev/null +++ b/specs/005-dotnet-bindings/research.md @@ -0,0 +1,147 @@ +# Research: C#/.NET Bindings (tp-net) + +**Phase**: Phase 0 — Outline & Research +**Date**: 2026-05-13 +**Feature**: `005-dotnet-bindings` + +--- + +## Decision 1: Rust → .NET FFI Mechanism + +**Decision**: Use **csbindgen** (Cysharp) for FFI layer generation, with a hand-written C# wrapper layer on top. + +**Rationale**: +- Production-proven: actively used by Cysharp in NativeCompressions, and by other projects wrapping Bullet Physics, Quiche, and SQLite. +- Aligns with the existing project philosophy: "Rust-first, minimal ceremony" (same as pyo3 for Python). +- Simpler build pipeline — a single `build.rs` invocation generates `NativeMethods.g.cs`; no extra UDL step. +- The public API types (`ProjectedPosition`, `TrainPath`, etc.) are I/O-boundary types; they can be efficiently represented as flat `#[repr(C)]` structs or serialized over a `ByteBuffer` pattern, keeping the FFI surface thin. +- Lower adoption risk than the community-maintained `uniffi-bindgen-cs` backend. + +**How it works**: +1. `tp-net/` Rust crate exposes `extern "C"` functions with `#[no_mangle]`, using `#[repr(C)]` structs for simple types and a `ByteBuffer`/JSON-over-bytes pattern for `Vec` and complex types. +2. `csbindgen` runs in `build.rs` and emits `NativeMethods.g.cs` (unsafe P/Invoke stubs). +3. A thin, hand-written `TpLib.cs` (public managed API) wraps the stubs and provides: safe types, XML docs, exception mapping, and `IDisposable` lifecycle management. + +**Alternatives considered**: + +| Alternative | Why rejected | +|---|---| +| **uniffi + uniffi-bindgen-cs** (NordSecurity) | Community-maintained C# backend (v0.29.4); versioning tightly coupled to uniffi-rs, less proven in production. Would provide better automatic type marshalling but +2-3 weeks learning curve. | +| **Manual cbindgen + P/Invoke** | Higher maintenance burden (dual codebases that can drift). Acceptable for small surfaces; tp-net's surface is large enough that automation is worthwhile. | +| **dotnet-bindgen** | Unmaintained as of 2023; not viable. | + +--- + +## Decision 2: Complex Type Marshalling Strategy + +**Decision**: Use a **two-layer marshalling** approach. +- **Layer 1 (FFI)**: Flat `#[repr(C)]` structs for scalar-only types (e.g., `ProjectionConfig`). For collection types (`Vec`, `Vec`) and types with associated-data enums (discard reasons), serialize to **JSON bytes** across the FFI boundary using `serde_json`. +- **Layer 2 (C# wrapper)**: Deserialize JSON into idiomatic C# records/classes using `System.Text.Json`. + +**Rationale**: +- JSON-over-FFI is a well-established pattern for complex types (used in several Rust→.NET integrations). The overhead is negligible for tp-lib's batch workloads (not hot-loop). +- Avoids the complexity of manually bridging `geo::Point`, `chrono::DateTime`, `HashMap`, and enum variants with embedded payloads. +- The Python bindings already convert these types to Python-native dicts/lists at the FFI boundary; JSON is the equivalent idiom for .NET. + +**Alternatives considered**: +- **FlatBuffers/Protocol Buffers**: More efficient but adds two schema files and code-gen steps. Not justified for this batch-oriented API. +- **Pure `#[repr(C)]` structs everywhere**: Would require duplicating every Rust type as a C-compatible struct (no `Vec`, no `Option`, no `String`), leading to manual memory management on the C# side. Higher risk of memory leaks and unsafety. + +--- + +## Decision 3: NuGet Packaging Layout + +**Decision**: Publish a **single `TpLib` NuGet package** with RID-specific native binaries embedded in `runtimes/{rid}/native/`. The managed C# assembly goes in `lib/net8.0/`. + +**Package layout**: +``` +TpLib.{version}.nupkg +├── lib/ +│ └── net8.0/ +│ └── TpLib.dll # Managed API + P/Invoke stubs +├── runtimes/ +│ ├── win-x64/native/ tp_lib_net.dll +│ ├── linux-x64/native/ libtp_lib_net.so +│ ├── osx-x64/native/ libtp_lib_net.dylib +│ └── osx-arm64/native/ libtp_lib_net.dylib +└── TpLib.nuspec +``` + +**Rationale**: +- This is the standard Microsoft-documented layout for native NuGet packages (used by SQLitePCLRaw, SkiaSharp, etc.). +- At runtime, the .NET host resolves the correct native binary via the RID graph automatically — zero consumer configuration required. +- `NativeLibrary.SetDllImportResolver` in the C# assembly ensures the correct `runtimes/{rid}/native/` path is used regardless of working directory. + +**Alternatives considered**: +- **Separate `TpLib.runtime.{rid}` packages**: Standard pattern for large binaries. For tp-lib, the native binary is small; a single package is simpler for consumers. +- **Embedded resources + runtime extraction**: More portable but adds startup latency and temp-file management. Not needed since NuGet RID support is universal. + +--- + +## Decision 4: CI/CD Build Pipeline + +**Decision**: Extend the existing **GitHub Actions** workflow (modeled after `publish-pypi.yml`) with a matrix build across 4 RIDs, then a NuGet pack+publish step. + +**Build targets**: +| RID | Cargo target | Runner | +|---|---|---| +| `win-x64` | `x86_64-pc-windows-msvc` | `windows-latest` | +| `linux-x64` | `x86_64-unknown-linux-gnu` | `ubuntu-latest` | +| `osx-x64` | `x86_64-apple-darwin` | `macos-latest` | +| `osx-arm64` | `aarch64-apple-darwin` | `macos-latest` (cross-compile) | + +**Rationale**: Follows the same pattern already established for Python wheels. Leverages existing Rust toolchain installation steps. + +--- + +## Decision 5: .NET Target Framework and Package Naming + +**Decision**: Target **net8.0** as minimum TFM. Package name: **`TpLib`** (NuGet) / assembly name: `TpLib`. + +**Rationale**: +- FR-011 mandates .NET 8+; net8.0 is the current LTS release (supported until Nov 2026) and the most widely deployed modern .NET version. +- `TpLib` matches the existing naming convention (the Python package is `tp-lib-py`; the .NET equivalent follows the namespace convention `TpLib`). + +**Alternatives considered**: +- `net6.0`: LTS but end-of-life May 2024. Not worth supporting. +- `netstandard2.0`: Would maximize compatibility but lacks modern C# features (records, nullable reference types, `NativeLibrary`). Excluded per spec requirement for .NET 8+. + +--- + +## Decision 6: C# Project Structure + +**Decision**: Create `tp-net/` at workspace root as a **single Rust crate** (cdylib + rlib) plus a **single .NET SDK project** (`tp-net/csharp/TpLib.csproj`) inside it. The Rust crate builds the native library; the .NET project wraps it. + +**Source layout**: +``` +tp-net/ +├── Cargo.toml # cdylib + rlib +├── build.rs # csbindgen invocation +├── src/ +│ └── lib.rs # FFI functions (extern "C") +└── csharp/ + ├── TpLib.csproj # net8.0 SDK project + ├── TpLib.cs # Public managed API + ├── NativeMethods.g.cs # (generated by csbindgen) + ├── Exceptions.cs # Typed exception hierarchy + └── Tests/ + ├── TpLib.Tests.csproj + └── ProjectionTests.cs +``` + +**Rationale**: Mirrors `tp-py/` structure (Rust src + language-specific wrapper in subfolder). Keeps the workspace consistent. + +--- + +## Resolved NEEDS CLARIFICATION + +All items marked "NEEDS CLARIFICATION" in the Technical Context are resolved: + +| Unknown | Resolution | +|---|---| +| FFI mechanism | csbindgen (see Decision 1) | +| Complex type marshalling | JSON-over-FFI + System.Text.Json (see Decision 2) | +| NuGet packaging | Single package with RID runtimes/ layout (see Decision 3) | +| CI/CD pipeline | GitHub Actions matrix (see Decision 4) | +| Target framework | net8.0 minimum (see Decision 5) | +| Project structure | tp-net/ with csharp/ subdirectory (see Decision 6) | diff --git a/specs/005-dotnet-bindings/spec.md b/specs/005-dotnet-bindings/spec.md new file mode 100644 index 0000000..addac39 --- /dev/null +++ b/specs/005-dotnet-bindings/spec.md @@ -0,0 +1,143 @@ +# Feature Specification: C#/.NET Bindings (tp-net) + +**Feature Branch**: `005-dotnet-bindings` +**Created**: 2026-05-13 +**Status**: Draft +**Input**: User description: "we need to allow integration of this library into c#.net applications. So similar to the tp-py project, we also need a tp-net project that exposes all the functions and parameters in a similar way." + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - GNSS Projection in C# Application (Priority: P1) + +A C# developer working on a railway positioning system wants to project raw GNSS coordinates onto the railway network. They add the tp-net package to their project, load a railway network from a GeoJSON file, load GNSS readings from a CSV file, and call the projection function to get a list of projected positions — each tied to a specific track segment with an intrinsic offset. + +**Why this priority**: GNSS projection is the foundational capability of the library. Delivering it first gives C# teams immediate value and represents the smallest viable slice of the overall feature. + +**Independent Test**: Can be fully tested by loading `sample_gnss.geojson` and `sample_network.geojson` from the test-data directory, calling the projection function from a C# test project, and verifying that the returned positions contain valid netelement IDs and intrinsic offsets. + +**Acceptance Scenarios**: + +1. **Given** a loaded railway network and a set of GNSS readings, **When** a C# developer calls the projection function with a valid configuration, **Then** a list of projected positions is returned with one entry per GNSS reading, each containing a netelement ID and an intrinsic offset. +2. **Given** a GNSS reading that falls outside the configured search radius, **When** the projection function is called, **Then** a descriptive exception is thrown indicating no match was found within the allowed distance. +3. **Given** a malformed GeoJSON file, **When** the network is loaded, **Then** an exception with a clear I/O or parse error message is raised. + +--- + +### User Story 2 - Train Path Calculation in C# Application (Priority: P2) + +A C# developer needs to reconstruct the path a train followed across the railway network from a sequence of projected GNSS positions. They call the path calculation function with a loaded network, projected positions, and a configuration object, and receive a structured path result describing the ordered sequence of network elements the train traversed. + +**Why this priority**: Train path calculation builds directly on GNSS projection and represents the second major capability. It is independently testable once projection works. + +**Independent Test**: Can be fully tested by taking projected positions from User Story 1 and passing them to the path calculation function in a C# test project, verifying that the returned path contains an ordered sequence of network elements matching the known route. + +**Acceptance Scenarios**: + +1. **Given** a valid set of projected positions and a loaded railway network, **When** the path calculation function is called, **Then** a train path result is returned containing an ordered list of traversed network elements with entry/exit offsets. +2. **Given** projected positions spanning a network gap that cannot be bridged, **When** path calculation is called, **Then** an exception is raised indicating that no navigable path was found. +3. **Given** a configuration object specifying a particular calculation mode, **When** path calculation is called, **Then** the result respects the specified mode behavior. + +--- + +### User Story 3 - Train Detection Preparation in C# Application (Priority: P3) + +A C# developer wants to correlate train detection events (punctual or linear sensor activations) against a known train path and GNSS projection. They call the detection preparation function with detection records, the projected positions, and the railway network, and receive a set of prepared detections where each detection is either applied to a network element, resolved by proximity, or discarded with a stated reason. + +**Why this priority**: Detection preparation depends on the outputs of Stories 1 and 2 and represents the third major capability. Useful independently once the earlier stories are in place. + +**Independent Test**: Can be fully tested using sample detection files from the test-data directory, verifying that each detection record in the output has a status (applied, resolved, or discarded) and that discarded records include a reason. + +**Acceptance Scenarios**: + +1. **Given** a set of detection records and a matching GNSS projection, **When** the detection preparation function is called, **Then** each detection in the result has a status of applied, resolved, or discarded. +2. **Given** a detection record that falls outside the time range of the GNSS data, **When** preparation is called, **Then** the detection is marked as discarded with the reason `out_of_time_range`. +3. **Given** a detection with an unknown netelement ID, **When** preparation is called, **Then** the detection is marked as discarded with the reason `unknown_netelement`. + +--- + +### User Story 5 - Database-Backed Service Processes Tasks Without Disk I/O (Priority: P2) + +A .NET background service polls a PostgreSQL database for pending train path tasks. For each task, it fetches the relevant railway network, GNSS readings, and detection records from the database as in-memory objects (strings, collections), passes them directly to the tp-net API, and writes the resulting projected positions and path back to the database — without creating any temporary files on disk. + +**Why this priority**: This is the primary production deployment pattern for tp-net. The library must work correctly when all input data originates from a database, not from files. Validating this path early avoids late-stage integration surprises. + +**Independent Test**: Can be tested by constructing `NetworkInput.FromRecords(IEnumerable)`, `GnssInput.FromRecords(IEnumerable)`, and `IEnumerable` in-memory (no File I/O), calling the projection and path calculation functions, and asserting that results are returned correctly and no temporary files are created in any temp directory. + +**Acceptance Scenarios**: + +1. **Given** a .NET service that fetches GeoJSON strings and detection rows from a PostgreSQL database, **When** it calls the tp-net projection and path calculation functions with those strings and collections, **Then** valid results are returned without any disk I/O occurring in the tp-net library. +2. **Given** a railway network stored in the database as a GeoJSON text column, **When** the calling service wraps the fetched string with `NetworkInput.FromGeoJson()` and passes it to any tp-net function, **Then** the function processes it identically to a GeoJSON string loaded from a file. **Given** a railway network stored as structured rows in a `network_segments` table, **When** the calling service maps those rows to `NetworkSegment` objects and passes them via `NetworkInput.FromRecords()`, **Then** the network is processed correctly with no GeoJSON serialization required on the caller's side. +3. **Given** GNSS readings stored in the database as structured rows (latitude, longitude, timestamp), **When** the calling service maps those rows to `GnssRecord` objects and passes them via `GnssInput.FromRecords()`, **Then** projected positions are returned correctly with no serialization required on the caller's side. +4. **Given** detection events stored in the database as structured rows, **When** the calling service maps those rows to `DetectionRecord` objects and passes them as `IEnumerable`, **Then** prepared detections are returned correctly with no serialization required on the caller's side. + +--- + +### User Story 4 - NuGet Package Distribution (Priority: P4) + +A C# developer discovers tp-net on NuGet, adds it to their project with a single package reference, and can immediately start calling the projection, path calculation, and detection APIs without any manual build or native dependency steps. + +**Why this priority**: Discoverability and ease of installation are prerequisites for adoption. However, the core API can be developed and tested before publishing; therefore this is lower priority. + +**Independent Test**: Can be fully tested by creating a blank .NET class library project, adding the NuGet package reference, and confirming that all three core functions are callable without additional setup. + +**Acceptance Scenarios**: + +1. **Given** a C# project targeting a supported .NET version, **When** the tp-net NuGet package is added, **Then** all public API types and functions are available without additional native DLL installation. +2. **Given** a developer on Windows, macOS, or Linux, **When** they install the package, **Then** the correct native binaries for their platform are loaded automatically. + +--- + +### Edge Cases + +- What happens when an empty GNSS dataset is passed to the projection function? +- How does the library behave when the railway network GeoJSON contains elements with missing or null geometry? +- What happens if the caller passes a `null` configuration object? +- How are detections handled when the detection timestamp exactly equals the boundary of the GNSS time range? +- What happens when the path calculation receives fewer than two projected positions? + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001**: The library MUST expose a GNSS projection function that accepts a railway network, a collection of GNSS readings, and a configuration object, and returns a collection of projected positions. +- **FR-002**: The library MUST expose a train path calculation function that accepts projected positions, a railway network, and a configuration object, and returns a structured path result. +- **FR-003**: The library MUST expose a train detection preparation function that accepts detection records, projected positions, and a railway network, and returns a collection of prepared detections with status and reason information. +- **FR-004**: The library MUST provide C#-idiomatic types for all inputs and outputs: `ProjectionConfig`, `ProjectedPosition`, `PathConfig`, `TrainPath`, `AssociatedNetElement`, `PreparedDetections`, and all status/reason enumerations. +- **FR-005**: The library MUST support loading a railway network from a GeoJSON **string (in-memory content), file path, or stream**. +- **FR-006**: The library MUST support loading GNSS readings from an **in-memory collection of structured records, a GeoJSON string, a CSV file path, or a stream**. +- **FR-007**: All error conditions MUST be surfaced as typed C# exceptions with descriptive messages matching the specificity of the equivalent Python bindings. +- **FR-008**: The library MUST be distributed as a NuGet package named `TpLib` (or equivalent naming convention consistent with the project). +- **FR-009**: The NuGet package MUST include pre-built native binaries for Windows (x64), Linux (x64), and macOS (x64/arm64), eliminating the need for consumers to install a Rust toolchain. +- **FR-010**: All public API members MUST have XML documentation comments sufficient for IntelliSense support in Visual Studio and Rider. +- **FR-011**: The library MUST support .NET 8 or later as the minimum target framework. +- **FR-012**: The library MUST NOT create any temporary files on disk during normal operation. All input and output data MUST be transferable as in-memory objects (strings, collections, structs) without requiring file system access. + +### Key Entities + +- **ProjectionConfig**: Configuration for GNSS projection; includes maximum search radius in meters and optional CRS override. +- **ProjectedPosition**: A single GNSS reading projected onto the railway network; contains netelement ID, intrinsic offset, timestamp, and source coordinate. +- **PathConfig**: Configuration for train path calculation; includes calculation mode and tolerance parameters. +- **TrainPath**: The reconstructed path of a train; contains an ordered list of `AssociatedNetElement` entries. +- **AssociatedNetElement**: A single network element in a train path; contains element ID, entry and exit intrinsic offsets, and direction. +- **DetectionRecord**: An input detection event (punctual or linear); contains kind, timestamp or time range, and optional netelement reference. +- **PreparedDetection**: A detection record enriched with a processing status (applied, resolved, or discarded) and a discard reason when applicable. + +## Assumptions + +- The tp-net package will be implemented as a Rust-based native library with a C# wrapper layer, following the same architecture as tp-py (pyo3 → Rust core). The exact FFI mechanism (e.g., CsBindgen, uniffi, or manual P/Invoke) is a technical decision outside this specification. +- API surface parity with tp-py is the baseline; tp-net does not need to add capabilities beyond what tp-py currently exposes. +- The package targets developers building server-side or desktop .NET applications; a MAUI/mobile scenario is out of scope for the initial release. +- XML documentation is sufficient; a separate documentation website is not required for the initial release. +- **GNSS data format in database-backed workflows**: GNSS readings stored in a PostgreSQL database are typically held as structured rows (latitude, longitude, timestamp, accuracy). The tp-net API accepts GNSS input via the `GnssInput` wrapper type. The preferred entry point for in-memory or database-sourced data is `GnssInput.FromRecords(IEnumerable)` — callers map query results directly to typed `GnssRecord` objects with no serialization step. `GnssInput.FromGeoJson()` and `GnssInput.FromCsv()` remain available for file-based or streaming workflows. All internal serialization across the FFI boundary is handled by the library. +- **Network input from database**: Railway network data may be stored in two ways: as a GeoJSON text/jsonb column (wrap with `NetworkInput.FromGeoJson()`), or as structured rows in a relational `network_segments` table (map rows to `NetworkSegment` objects and wrap with `NetworkInput.FromRecords()`). Both paths require no intermediate file or manual serialization on the caller's side. +- **Detection records from database**: Detection records fetched from a database can be mapped directly to `DetectionRecord` objects and passed as an `IEnumerable` — no intermediate file is required. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: All three core capabilities (GNSS projection, path calculation, detection preparation) available in tp-py are available in tp-net with equivalent parameters and return values. +- **SC-002**: A C# developer with no prior tp-lib experience can write a working integration that projects GNSS data in under 30 minutes, using only the package documentation and IntelliSense. +- **SC-003**: The NuGet package installs and runs without errors on Windows x64, Linux x64, and macOS (at least one architecture) without requiring any manual native dependency steps. +- **SC-004**: Every error condition that produces a typed exception in tp-py produces a corresponding typed exception in tp-net with a message of equivalent clarity. +- **SC-005**: 100% of public API types and functions have IntelliSense-visible XML documentation. diff --git a/specs/005-dotnet-bindings/tasks.md b/specs/005-dotnet-bindings/tasks.md new file mode 100644 index 0000000..d970a8c --- /dev/null +++ b/specs/005-dotnet-bindings/tasks.md @@ -0,0 +1,269 @@ +# Tasks: C#/.NET Bindings (tp-net) + +**Feature**: `005-dotnet-bindings` +**Input**: `specs/005-dotnet-bindings/` +**Prerequisites**: plan.md ✓, spec.md ✓, research.md ✓, data-model.md ✓, contracts/api.md ✓, quickstart.md ✓ + +--- + +## Format: `[ID] [P?] [Story] Description with file path` + +- **[P]**: Can run in parallel (different files, no dependencies on incomplete tasks) +- **[Story]**: User story label — US1, US2, US3, US5 (setup/foundational/polish phases carry no label) + +--- + +## Phase 1: Setup (Scaffolding) + +**Purpose**: Register `tp-net` in the Cargo workspace and create all project files as stubs. +No logic — empty/placeholder content only. After this phase `cargo check --workspace` and +`dotnet build tp-net/csharp/TpLib.csproj` must both succeed without errors. + +- [X] T001 Add `tp-net` to `members` in root `Cargo.toml` workspace +- [X] T002 [P] Create `tp-net/Cargo.toml` — `crate-type = ["cdylib","rlib"]`; deps: `tp-lib-core` (path), `serde`, `serde_json`; build-deps: `csbindgen` +- [X] T003 [P] Create stub `tp-net/src/lib.rs`, `tp-net/src/ffi.rs`, `tp-net/src/marshal.rs` (empty modules with `mod` declarations) +- [X] T004 [P] Create `tp-net/build.rs` — csbindgen invocation that generates `tp-net/csharp/NativeMethods.g.cs` from `src/lib.rs` +- [X] T005 [P] Create `tp-net/csharp/TpLib.csproj` — `net8.0`, `TpLib`, allow unsafe blocks, NuGet packaging metadata placeholders (``, ``, `TpLib`) +- [X] T006 [P] Create `tp-net/csharp/Tests/TpLib.Tests.csproj` — `net8.0`, xUnit 2 + `Microsoft.NET.Test.Sdk`, project reference to `TpLib.csproj` +- [X] T007 [P] Create stub C# source files: `tp-net/csharp/Exceptions.cs`, `tp-net/csharp/Enums.cs`, `tp-net/csharp/Models.cs`, `tp-net/csharp/TpLib.cs`, `tp-net/csharp/TpLibNative.cs` — each with `namespace TpLib;` and empty type stubs only + +**Checkpoint**: `cargo check --workspace` passes with `tp-net` visible; `dotnet build tp-net/csharp/TpLib.csproj` compiles the stubs. + +--- + +## Phase 2: Foundational (FFI Infrastructure + Core Types) + +**Purpose**: Implement the complete FFI layer and all public C# types that every user story depends on. +No user story can begin until this phase is complete. + +**⚠️ CRITICAL**: Complete before any user story implementation. + +### FFI Layer (Rust) + +- [X] T008 Implement `ByteBuffer` struct (`ptr: *mut u8`, `len: i32`, `cap: i32`) and `#[no_mangle] extern "C" fn tp_net_free_byte_buffer(buf: ByteBuffer)` in `tp-net/src/ffi.rs`; implement `#[repr(C)] ProjectionConfigFfi` and `#[repr(C)] PathConfigFfi` flat structs mirroring all scalar fields from data-model.md +- [X] T009 [P] Implement JSON serialization helpers in `tp-net/src/marshal.rs` — `fn to_json_bytes(val: &T) -> ByteBuffer` (allocates heap bytes, caller must free via `tp_net_free_byte_buffer`); `fn from_json_bytes<'a, T: Deserialize<'a>>(ptr: *const u8, len: i32) -> Result` (reads C# buffer without taking ownership); use `serde_json::to_vec` / `serde_json::from_slice` + +### Exception Hierarchy (C#) + +- [X] T010 [P] Implement `TpLibException` (base, `public class`), `TpLibParseException`, `TpLibProjectionException`, `TpLibPathException`, `TpLibDetectionException` (all sealed, each with `(string message)` and `(string message, Exception inner)` constructors) in `tp-net/csharp/Exceptions.cs` + +### Enumerations (C#) + +- [X] T011 [P] Implement all enums in `tp-net/csharp/Enums.cs`: `DetectionKind` (Punctual, Linear), `Navigability` (Both, Forward, Backward, None), `PathCalculationMode` (TopologyBased, FallbackIndependent), `PathOrigin` (Algorithm, UserAdded, UserEdited), `DetectionStatus` (Applied, Resolved, Discarded) + +### Public Record Types (C#) + +- [X] T012 [P] Implement output record types in `tp-net/csharp/Models.cs`: `ProjectedPosition` (all 10 properties from data-model.md; `Intrinsic` is `double?`), `AssociatedNetElement` (7 properties), `TrainPath` (Segments, OverallProbability, CalculatedAt), `PathResult` (Path, Mode, ProjectedPositions, Warnings, DetectionProvenance, HasPath computed), `PreparedDetections` (Records, Warnings) +- [X] T013 [P] Implement `DetectionTimestamp` (abstract sealed base with `Single` and `Range` sealed subclasses) and `DetectionRecord` (all fields from data-model.md; `Metadata` as `IReadOnlyDictionary`) in `tp-net/csharp/Models.cs` +- [X] T014 [P] Implement `NetworkSegment` sealed record (`Id`, `Coordinates` as `IReadOnlyList<(double Longitude, double Latitude)>`, `Crs = "EPSG:4326"`) and `NetworkRelation` sealed record (all 6 fields) in `tp-net/csharp/Models.cs` +- [X] T015 [P] Implement `GnssRecord` record (`Latitude`, `Longitude`, `Timestamp` as `DateTimeOffset`) in `tp-net/csharp/Models.cs` + +### Input Wrapper Types (C#) + +- [X] T016 Implement `NetworkInput` sealed class in `tp-net/csharp/Models.cs` — private constructor holding internal JSON string; `public static NetworkInput FromGeoJson(string geoJson)` (validates non-null/empty, stores as-is); `public static NetworkInput FromRecords(IEnumerable segments, IEnumerable relations)` (serializes both to a merged GeoJSON FeatureCollection internally using `System.Text.Json`; mixed feature types matching tp-lib format); `internal string AsJson()` accessor +- [X] T017 Implement `GnssInput` sealed class in `tp-net/csharp/Models.cs` — private constructor holding internal JSON string; `public static GnssInput FromGeoJson(string geoJson)`; `public static GnssInput FromCsv(string csv)` (wraps CSV string directly — Rust core parses it); `public static GnssInput FromRecords(IEnumerable records)` (serializes to GeoJSON FeatureCollection of Points with `latitude`, `longitude`, `timestamp` properties); `internal string AsJson()` accessor + +### Native Library Resolver (C#) + +- [X] T018 Implement `TpLibNative` internal static class in `tp-net/csharp/TpLibNative.cs` — registers `NativeLibrary.SetDllImportResolver` in a static constructor that resolves `tp_lib_net` (Windows: `.dll`, Linux: `lib*.so`, macOS: `lib*.dylib`) from the `runtimes/{rid}/native/` path relative to the managed assembly; add `internal static void FreeByteBuffer(ByteBufferNative buf)` P/Invoke stub calling `tp_net_free_byte_buffer`; expose `internal const string LibName = "tp_lib_net"` used by all P/Invoke `[DllImport]` attributes in `NativeMethods.g.cs` + +**Checkpoint**: `cargo build -p tp-net` produces a native library; `dotnet build tp-net/csharp/TpLib.csproj` compiles all foundational types without errors. + +--- + +## Phase 3: User Story 1 — GNSS Projection in C# (Priority: P1) 🎯 MVP + +**Goal**: A C# consumer calls `Projection.ProjectGnss(networkGeoJson, gnssGeoJson)` and receives a +typed `IReadOnlyList`. Also `Projection.ProjectOntoPath(network, gnss, path)` for +re-projection onto a pre-calculated path with `Intrinsic` populated on every position. + +**Independent Test**: With `test-data/sample_network.geojson` and `test-data/sample_gnss.geojson`: +run `Projection.ProjectGnss(networkGeoJson, gnssGeoJson)` → list is non-empty, every element has +`NetelementId != null`, `MeasureMeters >= 0`, `ProjectionDistanceMeters >= 0`, `Intrinsic == null`. +For `ProjectOntoPath`: same data, every element has `Intrinsic` in `[0, 1]`. + +### Implementation + +- [X] T019 [US1] Implement `extern "C" fn tp_net_project_gnss(network_json_ptr, network_json_len, gnss_json_ptr, gnss_json_len, config: ProjectionConfigFfi, out_len: *mut i32) -> *mut u8` in `tp-net/src/lib.rs` — deserialize inputs via `marshal.rs`, call `tp_lib_core` projection, serialize `Vec` to JSON bytes via `marshal::to_json_bytes`, return pointer; errors return null with `out_len = -1` +- [X] T020 [US1] Implement `extern "C" fn tp_net_project_onto_path(network_json_ptr, network_json_len, gnss_json_ptr, gnss_json_len, path_json_ptr, path_json_len, config: PathConfigFfi, out_len: *mut i32) -> *mut u8` in `tp-net/src/lib.rs` — same pattern; pass pre-calculated `TrainPath` deserialized from JSON +- [X] T021 [US1] Implement `public static class Projection` in `tp-net/csharp/TpLib.cs` — `ProjectGnss(NetworkInput, GnssInput, ProjectionConfig?)` calls the P/Invoke stub from `NativeMethods.g.cs`, reads returned bytes, deserializes via `System.Text.Json` to `List`, throws `TpLibProjectionException` on null return, frees native buffer via `TpLibNative.FreeByteBuffer`; add all convenience overloads per `contracts/api.md` (`string, string, …`) +- [X] T022 [US1] Implement `ProjectOntoPath(NetworkInput, GnssInput, TrainPath, PathConfig?)` and its convenience overloads in `tp-net/csharp/TpLib.cs` — serialize `TrainPath` to JSON for the FFI call; deserialize result to `IReadOnlyList` + +### Tests + +- [X] T023 [US1] Write `ProjectionTests` in `tp-net/csharp/Tests/ProjectionTests.cs` — cover: `ProjectGnss` with `test-data/sample_network.geojson` + `test-data/sample_gnss.geojson` returns non-empty list with valid fields; custom `ProjectionConfig` respected; `ProjectGnss` with `string, string` convenience overload works; `ProjectOntoPath` with pre-calculated path returns positions with `Intrinsic` in `[0,1]`; null network throws `ArgumentNullException`; malformed GeoJSON throws `TpLibParseException` + +**Checkpoint**: `dotnet test tp-net/csharp/Tests/` — `ProjectionTests` all green. MVP deliverable functional. + +--- + +## Phase 4: User Story 2 — Train Path Calculation in C# (Priority: P2) + +**Goal**: A C# consumer calls `PathCalculation.CalculateTrainPath(networkGeoJson, gnssGeoJson)` and +receives a `PathResult` with a `TrainPath` (when found), projected positions, and diagnostics. +`result.HasPath`, `result.Mode`, and `result.Warnings` are all accessible. + +**Independent Test**: With `test-data/sample_network.geojson` and `test-data/sample_gnss.geojson`: +`result.HasPath == true`, `result.Path!.Segments.Count > 0`, +`result.Path!.OverallProbability` is in `[0, 1]`, `result.ProjectedPositions` is non-empty. + +### Implementation + +- [X] T024 [US2] Implement `extern "C" fn tp_net_calculate_train_path(network_json_ptr, network_json_len, gnss_json_ptr, gnss_json_len, config: PathConfigFfi, detections_json_ptr, detections_json_len, out_len: *mut i32) -> *mut u8` in `tp-net/src/lib.rs` — pass `detections_json_ptr` as optional (null pointer = no detections); deserialize optional `PreparedDetections`; serialize `PathResult` to JSON bytes +- [X] T025 [US2] Implement `public static class PathCalculation` in `tp-net/csharp/TpLib.cs` — `CalculateTrainPath(NetworkInput, GnssInput, PathConfig?, PreparedDetections?)` P/Invoke call, deserialize `PathResult`, throw `TpLibPathException` on error, free native buffer; add all convenience overloads per `contracts/api.md` (`string, string, …`); serialize optional `PreparedDetections` to JSON before FFI call (null if not provided) + +### Tests + +- [X] T026 [US2] Write `PathCalculationTests` in `tp-net/csharp/Tests/PathCalculationTests.cs` — cover: `CalculateTrainPath` with `test-data/sample_network.geojson` + `test-data/sample_gnss.geojson` returns `HasPath == true` with non-empty segments; `result.Mode` is `TopologyBased` or `FallbackIndependent` (not null); `result.ProjectedPositions` non-empty when `PathConfig.PathOnly == false`; `result.ProjectedPositions` empty when `PathConfig.PathOnly == true`; `string, string` convenience overload works; custom `PathConfig` respected; malformed GeoJSON throws `TpLibParseException`; `ProjectOntoPath` round-trip: calculate path → re-project → positions have `Intrinsic` populated + +**Checkpoint**: `dotnet test tp-net/csharp/Tests/` — `PathCalculationTests` all green. + +--- + +## Phase 5: User Story 5 — Database-Backed Service Without Disk I/O (Priority: P2) + +**Goal**: A C# backend service that reads network and GNSS data from a database (as typed record +objects) calls `NetworkInput.FromRecords()` and `GnssInput.FromRecords()` with no temporary files +written to disk, and the projection/path calculation results are identical to the file-based path. +FR-012 compliance: no disk I/O anywhere in the code path. + +**Independent Test**: Using `test-data/sample_network.geojson` and `test-data/sample_gnss.geojson` +as source: parse them manually in C# test code into `IEnumerable` + +`IEnumerable` and `IEnumerable`; call +`Projection.ProjectGnss(NetworkInput.FromRecords(…), GnssInput.FromRecords(…))` and compare the +result to the file-based call — projected netelement IDs and measures must match. + +### Tests + +- [X] T027 [US5] Write `InMemoryInputTests` in `tp-net/csharp/Tests/InMemoryInputTests.cs` — cover: `NetworkInput.FromRecords(segments, relations)` + `GnssInput.FromRecords(records)` round-trip via `ProjectGnss` produces identical `NetelementId` and `MeasureMeters` values as the GeoJSON file path; `GnssInput.FromCsv(csvString)` matches `GnssInput.FromGeoJson(geoJsonString)` on the same positions; `CalculateTrainPath` via `FromRecords` path returns `HasPath == true`; `DetectionPreparation.PrepareDetections` via `FromRecords` path succeeds with empty detection list; zero files on disk created during any call (verify using temp-directory isolation) +- [X] T028 [US5] Write `DetectionRecordSerializationTests` in `tp-net/csharp/Tests/InMemoryInputTests.cs` — cover: `DetectionTimestamp.Single` round-trips through `System.Text.Json` with timezone preserved; `DetectionTimestamp.Range` (From, To) round-trips; `DetectionRecord.Metadata` dictionary survives serialization; `DetectionKind.Punctual` and `DetectionKind.Linear` serialize/deserialize correctly + +**Checkpoint**: `dotnet test tp-net/csharp/Tests/` — `InMemoryInputTests` all green. FR-012 compliance confirmed. + +--- + +## Phase 6: User Story 3 — Train Detection Preparation in C# (Priority: P3) + +**Goal**: A C# consumer calls `DetectionPreparation.PrepareDetections(network, gnss, detections)` and +receives a `PreparedDetections` where every input record has a non-null `Status` +(Applied / Resolved / Discarded). The result can be passed directly to `CalculateTrainPath`. + +**Independent Test**: Load `test-data/sample_network.geojson` and one of the sample detection files. +Call `PrepareDetections` → `result.Records.Count` equals the number of input detections; no exception +thrown; passing the result to `CalculateTrainPath` succeeds and `result.DetectionProvenance` is +non-empty. + +### Implementation + +- [X] T029 [US3] Implement `extern "C" fn tp_net_prepare_detections(network_json_ptr, network_json_len, gnss_json_ptr, gnss_json_len, detections_json_ptr, detections_json_len, cutoff_distance_meters: f64, out_len: *mut i32) -> *mut u8` in `tp-net/src/lib.rs` — deserialize `Vec` from JSON; call `tp_lib_core` detection preparation; serialize `PreparedDetections` to JSON bytes +- [X] T030 [US3] Implement `public static class DetectionPreparation` in `tp-net/csharp/TpLib.cs` — `PrepareDetections(NetworkInput, GnssInput, IEnumerable, double cutoffDistanceMeters = 2.5)` P/Invoke call, serialize detections list to JSON, deserialize `PreparedDetections`, throw `TpLibDetectionException` on error; add `string networkGeoJson` convenience overload per `contracts/api.md` + +### Tests + +- [X] T031 [P] [US3] Write `DetectionPreparationTests` in `tp-net/csharp/Tests/DetectionPreparationTests.cs` — cover: `PrepareDetections` with `test-data/sample_network.geojson` + `test-data/sample_gnss.geojson` + empty detections list returns `PreparedDetections` with empty `Records`; with `test-data/sample_detections_punctual.csv` rows parsed to `DetectionRecord` list → `result.Records.Count` equals input count, each record has non-null status; out-of-window detections produce `DetectionStatus.Discarded`; `PreparedDetections` passed to `CalculateTrainPath` succeeds and `PathResult.DetectionProvenance` is non-empty; `string` network convenience overload works +- [X] T032 [P] [US3] Write end-to-end detection-anchored path test in `tp-net/csharp/Tests/DetectionPreparationTests.cs` — cover: prepare punctual detections → calculate path with detections → verify `result.DetectionProvenance` length matches input detection count; detections with `DetectionStatus.Applied` have non-null netelement IDs + +**Checkpoint**: `dotnet test tp-net/csharp/Tests/` — `DetectionPreparationTests` all green. + +--- + +## Phase 7: User Story 4 — NuGet Package Distribution (Priority: P4) + +**Goal**: A .NET developer runs `dotnet add package TpLib` and can call `Projection.ProjectGnss(…)` +without installing Rust toolchain, copying native binaries, or setting `LD_LIBRARY_PATH`. +The correct native binary is resolved automatically via the NuGet RID graph. + +**Independent Test**: Build the NuGet package locally; `dotnet pack`; install into a fresh test +project with `dotnet add package TpLib --source ./nupkg`; call `Projection.ProjectGnss(…)` — +succeeds without any native-library configuration. Repeat on each supported platform. + +### NuGet Packaging Configuration + +- [X] T033 Configure NuGet package layout in `tp-net/csharp/TpLib.csproj` — set packaging props: `TpLib`, `true`, `true`; add `` items that copy the locally-built native library (`tp_lib_net.dll` / `.so` / `.dylib`) into `runtimes/{rid}/native/` inside the package; add `` section in `.nuspec` stub or equivalent MSBuild targets to embed all 4 RID variants when building on CI +- [X] T034 [P] Create `tp-net/csharp/TpLib.targets` MSBuild targets file — on `dotnet restore`, copy `runtimes/{rid}/native/tp_lib_net{ext}` to the output directory so `dotnet run` works without installing the package from NuGet; referenced via `` + +### CI/CD Pipeline + +- [X] T035 Create `.github/workflows/publish-nuget.yml` — matrix build with 4 jobs (win-x64 / linux-x64 / osx-x64 / osx-arm64) mirroring `publish-pypi.yml`; each job: checkout + Rust toolchain setup + `cargo build --release -p tp-net --target {cargo_target}` + upload native artifact; final job: download all 4 artifacts + `dotnet pack` + `dotnet nuget push` to NuGet.org using `NUGET_API_KEY` secret +- [X] T036 [P] Add `tp-net` to existing `.github/workflows/ci.yml` — add `cargo test -p tp-net` step; add `dotnet test tp-net/csharp/Tests/TpLib.Tests.csproj` step (linux only; native library built from workspace); gate both steps on the `tp-net` path filter + +**Checkpoint**: `dotnet pack tp-net/csharp/TpLib.csproj` produces a `.nupkg` with `lib/net8.0/TpLib.dll`; a `dotnet add package TpLib --source ./nupkg` + simple console app calling `Projection.ProjectGnss` runs without errors locally. + +--- + +## Phase 8: Polish & Cross-Cutting Concerns + +**Purpose**: Documentation, XML doc comments, and README updates that affect multiple user stories +and must be done after the implementation is stable. + +- [X] T037 Add XML documentation comments to all public API surface in `tp-net/csharp/TpLib.cs`, `tp-net/csharp/Models.cs`, `tp-net/csharp/Enums.cs`, `tp-net/csharp/Exceptions.cs` — verify `true` is set in `TpLib.csproj` so no CS1591 warnings remain +- [X] T038 [P] Write `tp-net/README.md` — cover: prerequisites (.NET 8 SDK), `dotnet add package TpLib`, quick example (ProjectGnss + CalculateTrainPath), supported platforms table (win-x64, linux-x64, osx-x64, osx-arm64), link to quickstart.md and contracts/api.md +- [X] T039 Update root `README.md` — add NuGet badge (`[![NuGet](https://img.shields.io/nuget/v/TpLib.svg)](https://www.nuget.org/packages/TpLib/)`); add `.NET` to the "It exposes…" sentence in the features list; add `tp-net/` entry to the Project Structure tree +- [X] T040 Update `docs/OpenRail-onboarding.md` — update the tech stack section to include `.NET bindings: csbindgen + System.Text.Json, published as TpLib NuGet package (net8.0)`; update the "It exposes…" sentence from `a .NET API (...)` to include the actual package name and NuGet install command + +**Checkpoint**: `dotnet build tp-net/csharp/TpLib.csproj /warnaserror:CS1591` succeeds; `README.md` NuGet badge renders; onboarding doc reflects the .NET binding. + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Setup (Phase 1)**: No dependencies — can start immediately +- **Foundational (Phase 2)**: Depends on Phase 1 completion — **BLOCKS all user stories** +- **US1 (Phase 3)**: Depends on Phase 2 completion — MVP increment; no dependency on US2/US3/US5 +- **US2 (Phase 4)**: Depends on Phase 2 completion — no dependency on US1 (path calc is independent) +- **US5 (Phase 5)**: Depends on Phase 3 + Phase 4 completion — validates the full in-memory path end-to-end +- **US3 (Phase 6)**: Depends on Phase 2 completion — no dependency on US1/US2/US5 +- **US4 (Phase 7)**: Depends on Phase 3 + Phase 4 + Phase 6 completion — packages the completed API +- **Polish (Phase 8)**: Depends on all user story phases completing + +### User Story Dependencies + +- **US1 (P1)**: Can start after Phase 2 — no dependency on other stories +- **US2 (P2)**: Can start after Phase 2 — no dependency on other stories +- **US5 (P2)**: Requires US1 + US2 to be complete (validates both code paths) +- **US3 (P3)**: Can start after Phase 2 — no dependency on US1/US2 +- **US4 (P4)**: Requires US1 + US2 + US3 to be complete (packages everything) + +### Within Each User Story + +- Rust FFI function (`tp-net/src/lib.rs`) before C# wrapper class +- C# wrapper before tests +- Tests written after implementation (this feature does not use TDD) + +### Parallel Opportunities + +- All `[P]` tasks within Phase 1 can run in parallel (different files) +- All `[P]` tasks within Phase 2 can run in parallel (different files; Rust FFI is independent of C# types) +- US1 and US2 can be worked in parallel once Phase 2 is complete (entirely separate static classes) +- US3 and US2/US1 can be worked in parallel (no dependency between detection and projection) +- Within US1: T019 and T020 can run in parallel (two separate FFI functions) + +--- + +## Summary + +| Phase | Tasks | User Story | Priority | +|---|---|---|---| +| Phase 1 — Setup | T001–T007 | — | — | +| Phase 2 — Foundational | T008–T018 | — | — | +| Phase 3 — GNSS Projection | T019–T023 | US1 | P1 🎯 MVP | +| Phase 4 — Train Path Calculation | T024–T026 | US2 | P2 | +| Phase 5 — Database-Backed Service | T027–T028 | US5 | P2 | +| Phase 6 — Detection Preparation | T029–T032 | US3 | P3 | +| Phase 7 — NuGet Packaging | T033–T036 | US4 | P4 | +| Phase 8 — Polish | T037–T040 | — | — | + +**Total**: 40 tasks across 8 phases. + +**Parallel opportunities**: 19 tasks marked `[P]`. + +**Independent test criteria**: +- US1: `Projection.ProjectGnss` with sample GeoJSON files → non-empty typed list ✓ +- US2: `PathCalculation.CalculateTrainPath` → `HasPath == true`, `Segments.Count > 0` ✓ +- US5: `FromRecords` path produces identical results to `FromGeoJson` path ✓ +- US3: `DetectionPreparation.PrepareDetections` → `Records.Count == input count` ✓ +- US4: `dotnet add package TpLib` → `ProjectGnss` works without native setup ✓ + +**Suggested MVP scope**: Phase 1 + Phase 2 + Phase 3 (T001–T023) diff --git a/tp-core/src/io.rs b/tp-core/src/io.rs index ca4ac11..e9391ce 100644 --- a/tp-core/src/io.rs +++ b/tp-core/src/io.rs @@ -4,8 +4,10 @@ pub mod arrow; pub mod csv; pub mod geojson; -pub use csv::{parse_gnss_csv, parse_trainpath_csv, write_csv, write_trainpath_csv}; +pub use csv::{ + parse_gnss_csv, parse_gnss_csv_str, parse_trainpath_csv, write_csv, write_trainpath_csv, +}; pub use geojson::{ - parse_gnss_geojson, parse_netrelations_geojson, parse_network_geojson, parse_trainpath_geojson, - write_geojson, write_trainpath_geojson, + parse_gnss_geojson, parse_gnss_geojson_str, parse_netrelations_geojson, parse_network_geojson, + parse_network_geojson_str, parse_trainpath_geojson, write_geojson, write_trainpath_geojson, }; diff --git a/tp-core/src/io/csv.rs b/tp-core/src/io/csv.rs index 60a66dc..fdec3bf 100644 --- a/tp-core/src/io/csv.rs +++ b/tp-core/src/io/csv.rs @@ -69,6 +69,41 @@ pub fn parse_gnss_csv( )) })?; + gnss_positions_from_df(df, crs, lat_col, lon_col, time_col) +} + +/// In-memory variant of [`parse_gnss_csv`] that accepts the full CSV text +/// directly. No disk I/O is performed; required by the .NET bindings for +/// database-backed callers (FR-012). +pub fn parse_gnss_csv_str( + csv_text: &str, + crs: &str, + lat_col: &str, + lon_col: &str, + time_col: &str, +) -> Result, ProjectionError> { + let cursor = std::io::Cursor::new(csv_text.as_bytes().to_vec()); + let df = CsvReadOptions::default() + .with_has_header(true) + .into_reader_with_file_handle(cursor) + .finish() + .map_err(|e| { + ProjectionError::IoError(std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!("Failed to parse CSV: {}", e), + )) + })?; + gnss_positions_from_df(df, crs, lat_col, lon_col, time_col) +} + +/// Shared body: convert a polars DataFrame to a sequence of GnssPosition rows. +fn gnss_positions_from_df( + df: DataFrame, + crs: &str, + lat_col: &str, + lon_col: &str, + time_col: &str, +) -> Result, ProjectionError> { // Handle empty CSV (only headers) - polars can't infer types from empty data if df.height() == 0 { return Ok(Vec::new()); diff --git a/tp-core/src/io/csv/detections.rs b/tp-core/src/io/csv/detections.rs index 47f1e16..e434c5f 100644 --- a/tp-core/src/io/csv/detections.rs +++ b/tp-core/src/io/csv/detections.rs @@ -38,12 +38,21 @@ const LINEAR_RESERVED: &[&str] = &[ /// Load detections from a CSV file. pub fn load(path: &Path, expected_kind: DetectionKind) -> Result, DetectionError> { let source_file = path.display().to_string(); + let text = std::fs::read_to_string(path)?; + load_str(&text, &source_file, expected_kind) +} +/// In-memory variant of [`load`] that accepts the full CSV text. Required by +/// the .NET bindings (FR-012, no temp files). +pub fn load_str( + text: &str, + source_file: &str, + expected_kind: DetectionKind, +) -> Result, DetectionError> { let mut rdr = csv::ReaderBuilder::new() .has_headers(true) .flexible(false) - .from_path(path) - .map_err(|e| DetectionError::InvalidSchema(format!("failed to open CSV: {e}")))?; + .from_reader(text.as_bytes()); let headers: Vec = rdr .headers() @@ -53,8 +62,8 @@ pub fn load(path: &Path, expected_kind: DetectionKind) -> Result, .collect(); match expected_kind { - DetectionKind::Punctual => parse_punctual(&mut rdr, &headers, &source_file), - DetectionKind::Linear => parse_linear(&mut rdr, &headers, &source_file), + DetectionKind::Punctual => parse_punctual(&mut rdr, &headers, source_file), + DetectionKind::Linear => parse_linear(&mut rdr, &headers, source_file), } } diff --git a/tp-core/src/io/csv/tests.rs b/tp-core/src/io/csv/tests.rs index bcbe70b..8d44f8a 100644 --- a/tp-core/src/io/csv/tests.rs +++ b/tp-core/src/io/csv/tests.rs @@ -768,3 +768,137 @@ fn test_parse_trainpath_csv_invalid_gnss_index() { // Testing that parsing doesn't crash let _ = result; } + +// Tests for parse_gnss_csv_str (in-memory / .NET bindings variant) + +#[test] +fn test_parse_gnss_csv_str_basic() { + let csv = "latitude,longitude,timestamp\n50.8503,4.3517,2024-01-15T10:30:00Z\n50.8504,4.3518,2024-01-15T10:30:01Z"; + + let result = parse_gnss_csv_str(csv, "EPSG:4326", "latitude", "longitude", "timestamp"); + + assert!(result.is_ok()); + let positions = result.unwrap(); + assert_eq!(positions.len(), 2); + assert_eq!(positions[0].latitude, 50.8503); + assert_eq!(positions[0].longitude, 4.3517); + assert_eq!(positions[1].latitude, 50.8504); +} + +#[test] +fn test_parse_gnss_csv_str_empty() { + let csv = "latitude,longitude,timestamp"; + + let result = parse_gnss_csv_str(csv, "EPSG:4326", "latitude", "longitude", "timestamp"); + + assert!(result.is_ok()); + assert_eq!(result.unwrap().len(), 0); +} + +#[test] +fn test_parse_gnss_csv_str_missing_latitude_column() { + let csv = "longitude,timestamp\n4.3517,2024-01-15T10:30:00Z"; + + let result = parse_gnss_csv_str(csv, "EPSG:4326", "latitude", "longitude", "timestamp"); + + assert!(result.is_err()); + if let Err(ProjectionError::InvalidCoordinate(msg)) = result { + assert!(msg.contains("Latitude column")); + } else { + panic!("Expected InvalidCoordinate error"); + } +} + +#[test] +fn test_parse_gnss_csv_str_missing_longitude_column() { + let csv = "latitude,timestamp\n50.8503,2024-01-15T10:30:00Z"; + + let result = parse_gnss_csv_str(csv, "EPSG:4326", "latitude", "longitude", "timestamp"); + + assert!(result.is_err()); + if let Err(ProjectionError::InvalidCoordinate(msg)) = result { + assert!(msg.contains("Longitude column")); + } else { + panic!("Expected InvalidCoordinate error"); + } +} + +#[test] +fn test_parse_gnss_csv_str_missing_timestamp_column() { + let csv = "latitude,longitude\n50.8503,4.3517"; + + let result = parse_gnss_csv_str(csv, "EPSG:4326", "latitude", "longitude", "timestamp"); + + assert!(result.is_err()); + if let Err(ProjectionError::InvalidTimestamp(msg)) = result { + assert!(msg.contains("Timestamp column")); + } else { + panic!("Expected InvalidTimestamp error"); + } +} + +#[test] +fn test_parse_gnss_csv_str_with_heading_and_distance() { + let csv = "latitude,longitude,timestamp,heading,distance\n50.8503,4.3517,2024-01-15T10:30:00Z,45.0,100.5"; + + let result = parse_gnss_csv_str(csv, "EPSG:4326", "latitude", "longitude", "timestamp"); + + assert!(result.is_ok()); + let positions = result.unwrap(); + assert_eq!(positions.len(), 1); + assert_eq!(positions[0].heading, Some(45.0)); + assert_eq!(positions[0].distance, Some(100.5)); +} + +#[test] +fn test_parse_gnss_csv_str_preserves_metadata() { + let csv = "latitude,longitude,timestamp,speed,train_id\n50.8503,4.3517,2024-01-15T10:30:00Z,80.5,T123"; + + let result = parse_gnss_csv_str(csv, "EPSG:4326", "latitude", "longitude", "timestamp"); + + assert!(result.is_ok()); + let positions = result.unwrap(); + assert_eq!(positions.len(), 1); + assert!(positions[0].metadata.contains_key("speed")); + assert!(positions[0].metadata.contains_key("train_id")); +} + +#[test] +fn test_parse_gnss_csv_str_invalid_heading_range() { + let csv = "latitude,longitude,timestamp,heading\n50.8503,4.3517,2024-01-15T10:30:00Z,400.0"; + + let result = parse_gnss_csv_str(csv, "EPSG:4326", "latitude", "longitude", "timestamp"); + + assert!(result.is_err()); + if let Err(ProjectionError::InvalidGeometry(msg)) = result { + assert!(msg.contains("Heading must be in [0, 360]")); + } else { + panic!("Expected InvalidGeometry error for invalid heading"); + } +} + +#[test] +fn test_parse_gnss_csv_str_negative_distance() { + let csv = "latitude,longitude,timestamp,distance\n50.8503,4.3517,2024-01-15T10:30:00Z,-10.0"; + + let result = parse_gnss_csv_str(csv, "EPSG:4326", "latitude", "longitude", "timestamp"); + + assert!(result.is_err()); + if let Err(ProjectionError::InvalidGeometry(msg)) = result { + assert!(msg.contains("Distance must be >= 0")); + } else { + panic!("Expected InvalidGeometry error for negative distance"); + } +} + +#[test] +fn test_parse_gnss_csv_str_custom_column_names() { + let csv = "lat,lon,time\n50.8503,4.3517,2024-01-15T10:30:00Z"; + + let result = parse_gnss_csv_str(csv, "EPSG:4326", "lat", "lon", "time"); + + assert!(result.is_ok()); + let positions = result.unwrap(); + assert_eq!(positions.len(), 1); + assert_eq!(positions[0].latitude, 50.8503); +} diff --git a/tp-core/src/io/geojson.rs b/tp-core/src/io/geojson.rs index 281c058..9ed2905 100644 --- a/tp-core/src/io/geojson.rs +++ b/tp-core/src/io/geojson.rs @@ -66,9 +66,16 @@ fn parse_crs_from_feature_collection(feature_collection: &geojson::FeatureCollec pub fn parse_network_geojson( path: &str, ) -> Result<(Vec, Vec), ProjectionError> { - // Read file let geojson_str = fs::read_to_string(path)?; + parse_network_geojson_str(&geojson_str) +} +/// In-memory variant of [`parse_network_geojson`] that accepts the GeoJSON +/// FeatureCollection text directly. No disk I/O is performed; required by the +/// .NET bindings for database-backed callers (FR-012). +pub fn parse_network_geojson_str( + geojson_str: &str, +) -> Result<(Vec, Vec), ProjectionError> { // Parse GeoJSON let geojson = geojson_str .parse::() @@ -159,9 +166,17 @@ pub fn parse_network_geojson( /// } /// ``` pub fn parse_gnss_geojson(path: &str, crs: &str) -> Result, ProjectionError> { - // Read file let geojson_str = fs::read_to_string(path)?; + parse_gnss_geojson_str(&geojson_str, crs) +} +/// In-memory variant of [`parse_gnss_geojson`] that accepts the GeoJSON +/// FeatureCollection text directly. No disk I/O is performed; required by the +/// .NET bindings for database-backed callers (FR-012). +pub fn parse_gnss_geojson_str( + geojson_str: &str, + crs: &str, +) -> Result, ProjectionError> { // Parse GeoJSON let geojson = geojson_str .parse::() diff --git a/tp-core/src/io/geojson/detections.rs b/tp-core/src/io/geojson/detections.rs index 8467842..b874969 100644 --- a/tp-core/src/io/geojson/detections.rs +++ b/tp-core/src/io/geojson/detections.rs @@ -41,10 +41,19 @@ const LINEAR_RESERVED: &[&str] = &[ pub fn load(path: &Path, expected_kind: DetectionKind) -> Result, DetectionError> { let source_file = path.display().to_string(); let raw = std::fs::read_to_string(path)?; + load_str(&raw, &source_file, expected_kind) +} + +/// In-memory variant of [`load`] that accepts the full GeoJSON text. Required +/// by the .NET bindings (FR-012, no temp files). +pub fn load_str( + raw: &str, + source_file: &str, + expected_kind: DetectionKind, +) -> Result, DetectionError> { let gj: GeoJson = raw.parse().map_err(|e: geojson::Error| { DetectionError::InvalidSchema(format!("invalid GeoJSON: {e}")) })?; - let fc = match gj { GeoJson::FeatureCollection(fc) => fc, _ => { @@ -61,7 +70,7 @@ pub fn load(path: &Path, expected_kind: DetectionKind) -> Result, DetectionError::InvalidSchema(format!("feature[{idx}]: missing 'properties'")) })?; - let kind_str = require_str(&props, "kind", &source_file, source_row)?; + let kind_str = require_str(&props, "kind", source_file, source_row)?; let actual_kind = match kind_str.as_str() { "punctual" => DetectionKind::Punctual, "linear" => DetectionKind::Linear, @@ -79,9 +88,9 @@ pub fn load(path: &Path, expected_kind: DetectionKind) -> Result, let detection = match expected_kind { DetectionKind::Punctual => { - parse_punctual(&props, feature.geometry.as_ref(), &source_file, source_row)? + parse_punctual(&props, feature.geometry.as_ref(), source_file, source_row)? } - DetectionKind::Linear => parse_linear(&props, &source_file, source_row)?, + DetectionKind::Linear => parse_linear(&props, source_file, source_row)?, }; out.push(detection); } diff --git a/tp-core/src/lib.rs b/tp-core/src/lib.rs index 249bc1b..40c62bf 100644 --- a/tp-core/src/lib.rs +++ b/tp-core/src/lib.rs @@ -54,7 +54,8 @@ pub use detections::{ }; pub use errors::ProjectionError; pub use io::{ - parse_gnss_csv, parse_gnss_geojson, parse_netrelations_geojson, parse_network_geojson, + parse_gnss_csv, parse_gnss_csv_str, parse_gnss_geojson, parse_gnss_geojson_str, + parse_netrelations_geojson, parse_network_geojson, parse_network_geojson_str, parse_trainpath_csv, parse_trainpath_geojson, write_csv, write_geojson, write_trainpath_csv, write_trainpath_geojson, }; diff --git a/tp-lib.sln b/tp-lib.sln new file mode 100644 index 0000000..b8c971e --- /dev/null +++ b/tp-lib.sln @@ -0,0 +1,42 @@ +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.5.2.0 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tp-net", "tp-net", "{9D3E6958-A343-E8A0-39BA-585D2A92CA46}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "csharp", "csharp", "{E1D09E30-F88F-0C53-CAA2-20F8ECD70B1F}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TpLib", "tp-net\csharp\TpLib.csproj", "{6CC3FEBB-BA1F-4B8C-0993-9443C279A878}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{E68C0691-C6FF-1DAB-50E0-2665E4B23868}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TpLib.Tests", "tp-net\csharp\Tests\TpLib.Tests.csproj", "{1CD61DFB-0EB4-BCF1-4139-55AA758524BD}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {6CC3FEBB-BA1F-4B8C-0993-9443C279A878}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6CC3FEBB-BA1F-4B8C-0993-9443C279A878}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6CC3FEBB-BA1F-4B8C-0993-9443C279A878}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6CC3FEBB-BA1F-4B8C-0993-9443C279A878}.Release|Any CPU.Build.0 = Release|Any CPU + {1CD61DFB-0EB4-BCF1-4139-55AA758524BD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1CD61DFB-0EB4-BCF1-4139-55AA758524BD}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1CD61DFB-0EB4-BCF1-4139-55AA758524BD}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1CD61DFB-0EB4-BCF1-4139-55AA758524BD}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {E1D09E30-F88F-0C53-CAA2-20F8ECD70B1F} = {9D3E6958-A343-E8A0-39BA-585D2A92CA46} + {6CC3FEBB-BA1F-4B8C-0993-9443C279A878} = {E1D09E30-F88F-0C53-CAA2-20F8ECD70B1F} + {E68C0691-C6FF-1DAB-50E0-2665E4B23868} = {E1D09E30-F88F-0C53-CAA2-20F8ECD70B1F} + {1CD61DFB-0EB4-BCF1-4139-55AA758524BD} = {E68C0691-C6FF-1DAB-50E0-2665E4B23868} + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {0CD07019-A10A-4E06-A6C0-27D09D9563BE} + EndGlobalSection +EndGlobal diff --git a/tp-net/Cargo.toml b/tp-net/Cargo.toml new file mode 100644 index 0000000..c9f9eff --- /dev/null +++ b/tp-net/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "tp-lib-net" +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true +repository.workspace = true +description = "C#/.NET bindings for tp-lib-core GNSS projection library" +readme = "../README.md" +keywords = ["gnss", "gps", "railway", "dotnet", "csharp"] +categories = ["api-bindings"] + +[lib] +name = "tp_lib_net" +crate-type = ["cdylib", "rlib"] + +[dependencies] +tp-lib-core = { version = "0.0.1", path = "../tp-core" } +serde.workspace = true +serde_json.workspace = true +chrono.workspace = true + +[build-dependencies] +csbindgen = "1.9" diff --git a/tp-net/README.md b/tp-net/README.md new file mode 100644 index 0000000..cedc63c --- /dev/null +++ b/tp-net/README.md @@ -0,0 +1,61 @@ +# TpLib — .NET bindings for tp-lib + +C#/.NET bindings for `tp-lib`, providing GNSS projection onto railway networks +and train-path calculation from a managed API. + +## Prerequisites + +- .NET 8 SDK (or newer) +- One of the supported platforms (see table below) + +## Install + +```sh +dotnet add package TpLib +``` + +The package ships pre-built native binaries for every supported platform; the +correct one is selected automatically at runtime. + +## Quick example + +```csharp +using TpLib; + +var networkGeoJson = File.ReadAllText("network.geojson"); +var gnssGeoJson = File.ReadAllText("gnss.geojson"); + +// 1. Project GNSS points onto the closest network elements. +var projections = Projection.ProjectGnss(networkGeoJson, gnssGeoJson); +Console.WriteLine($"Projected {projections.Count} points"); + +// 2. Calculate the most likely train path. +var result = PathCalculation.CalculateTrainPath(networkGeoJson, gnssGeoJson); +if (result.HasPath) +{ + Console.WriteLine($"Path probability: {result.Path!.OverallProbability:F3}"); + foreach (var segment in result.Path.Segments) + { + Console.WriteLine($" {segment.NetelementId} p={segment.Probability:F3}"); + } +} +``` + +## Supported platforms + +| RID | OS / Architecture | Native library | +|-------------|------------------------------|------------------------| +| `win-x64` | Windows 10+ on x86_64 | `tp_lib_net.dll` | +| `linux-x64` | Linux glibc on x86_64 | `libtp_lib_net.so` | +| `osx-x64` | macOS on Intel | `libtp_lib_net.dylib` | +| `osx-arm64` | macOS on Apple Silicon | `libtp_lib_net.dylib` | + +## See also + +- [quickstart.md](../../specs/005-dotnet-bindings/quickstart.md) — end-to-end walkthrough +- [contracts/api.md](../../specs/005-dotnet-bindings/contracts/api.md) — full API reference +- [tp-lib root README](../../README.md) + +## License + +Apache-2.0 diff --git a/tp-net/build.rs b/tp-net/build.rs new file mode 100644 index 0000000..16a237e --- /dev/null +++ b/tp-net/build.rs @@ -0,0 +1,16 @@ +fn main() { + // csbindgen scans lib.rs at build time and emits C# P/Invoke declarations. + // Stub for Phase 1 — generation enabled in Phase 2 once FFI fns exist. + println!("cargo:rerun-if-changed=src/lib.rs"); + println!("cargo:rerun-if-changed=src/ffi.rs"); + + csbindgen::Builder::default() + .input_extern_file("src/lib.rs") + .input_extern_file("src/ffi.rs") + .csharp_dll_name("tp_lib_net") + .csharp_namespace("TpLib") + .csharp_class_name("NativeMethods") + .csharp_class_accessibility("internal") + .generate_csharp_file("csharp/NativeMethods.g.cs") + .expect("failed to generate csharp/NativeMethods.g.cs with csbindgen"); +} diff --git a/tp-net/csharp/Enums.cs b/tp-net/csharp/Enums.cs new file mode 100644 index 0000000..f41c048 --- /dev/null +++ b/tp-net/csharp/Enums.cs @@ -0,0 +1,27 @@ +namespace TpLib; + +public enum DetectionKind +{ + Punctual, + Linear, +} + +public enum Navigability +{ + Both, + Forward, + Backward, + None, +} + +public enum PathCalculationMode +{ + TopologyBased, + FallbackIndependent, +} + +public enum PathOrigin +{ + Algorithm, + Manual, +} diff --git a/tp-net/csharp/Exceptions.cs b/tp-net/csharp/Exceptions.cs new file mode 100644 index 0000000..1bc2917 --- /dev/null +++ b/tp-net/csharp/Exceptions.cs @@ -0,0 +1,56 @@ +namespace TpLib; + +/// Base exception type for all tp-lib failures. +public class TpLibException : Exception +{ + public TpLibException(string message) : base(message) { } + public TpLibException(string message, Exception inner) : base(message, inner) { } +} + +public sealed class TpLibIoException : TpLibException +{ + public TpLibIoException(string message) : base(message) { } + public TpLibIoException(string message, Exception inner) : base(message, inner) { } +} + +public sealed class TpLibParseException : TpLibException +{ + public TpLibParseException(string message) : base(message) { } + public TpLibParseException(string message, Exception inner) : base(message, inner) { } +} + +public sealed class TpLibConfigurationException : TpLibException +{ + public TpLibConfigurationException(string message) : base(message) { } + public TpLibConfigurationException(string message, Exception inner) : base(message, inner) { } +} + +public class TpLibProjectionException : TpLibException +{ + public TpLibProjectionException(string message) : base(message) { } + public TpLibProjectionException(string message, Exception inner) : base(message, inner) { } +} + +public sealed class NoMatchWithinRadiusException : TpLibProjectionException +{ + public NoMatchWithinRadiusException(string message) : base(message) { } + public NoMatchWithinRadiusException(string message, Exception inner) : base(message, inner) { } +} + +public class TpLibPathException : TpLibException +{ + public TpLibPathException(string message) : base(message) { } + public TpLibPathException(string message, Exception inner) : base(message, inner) { } +} + +public sealed class NoNavigablePathException : TpLibPathException +{ + public NoNavigablePathException(string message) : base(message) { } + public NoNavigablePathException(string message, Exception inner) : base(message, inner) { } +} + +public sealed class TpLibDetectionException : TpLibException +{ + public TpLibDetectionException(string message) : base(message) { } + public TpLibDetectionException(string message, Exception inner) : base(message, inner) { } +} diff --git a/tp-net/csharp/Models.cs b/tp-net/csharp/Models.cs new file mode 100644 index 0000000..666810d --- /dev/null +++ b/tp-net/csharp/Models.cs @@ -0,0 +1,511 @@ +using System.Globalization; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace TpLib; + +// --------------------------------------------------------------------------- +// Configuration records (mirror Rust ProjectionConfig / PathConfig defaults) +// --------------------------------------------------------------------------- + +public sealed record ProjectionConfig +{ + public double MaxSearchRadiusMeters { get; init; } = 1000.0; + public double ProjectionDistanceWarningThreshold { get; init; } = 50.0; + public bool SuppressWarnings { get; init; } = false; +} + +public sealed record PathConfig +{ + public double DistanceScale { get; init; } = 10.0; + public double HeadingScale { get; init; } = 2.0; + public double CutoffDistanceMeters { get; init; } = 500.0; + public double HeadingCutoffDegrees { get; init; } = 10.0; + public double ProbabilityThreshold { get; init; } = 0.02; + public double? ResamplingDistanceMeters { get; init; } + public int MaxCandidates { get; init; } = 3; + public bool PathOnly { get; init; } = false; + public double Beta { get; init; } = 50.0; + public double EdgeZoneDistanceMeters { get; init; } = 50.0; + public double TurnScaleDegrees { get; init; } = 30.0; + public double DetectionCutoffDistanceMeters { get; init; } = 2.5; +} + +// --------------------------------------------------------------------------- +// Output records (deserialized from FFI JSON — property names match Rust +// snake_case via [JsonPropertyName]). +// --------------------------------------------------------------------------- + +public sealed record GnssOriginal( + [property: JsonPropertyName("latitude")] double Latitude, + [property: JsonPropertyName("longitude")] double Longitude, + [property: JsonPropertyName("timestamp")] DateTimeOffset Timestamp, + [property: JsonPropertyName("crs")] string Crs, + [property: JsonPropertyName("heading")] double? Heading = null, + [property: JsonPropertyName("distance")] double? Distance = null); + +public sealed record ProjectedCoords( + [property: JsonPropertyName("x")] double X, + [property: JsonPropertyName("y")] double Y); + +public sealed record ProjectedPosition( + [property: JsonPropertyName("netelement_id")] string NetelementId, + [property: JsonPropertyName("measure_meters")] double MeasureMeters, + [property: JsonPropertyName("projection_distance_meters")] double ProjectionDistanceMeters, + [property: JsonPropertyName("projected_coords")] ProjectedCoords ProjectedCoords, + [property: JsonPropertyName("crs")] string Crs, + [property: JsonPropertyName("original")] GnssOriginal Original, + [property: JsonPropertyName("intrinsic")] double? Intrinsic = null) +{ + public double ProjectedX => ProjectedCoords.X; + public double ProjectedY => ProjectedCoords.Y; + public double OriginalLatitude => Original.Latitude; + public double OriginalLongitude => Original.Longitude; + public DateTimeOffset Timestamp => Original.Timestamp; +} + +public sealed record AssociatedNetElement( + [property: JsonPropertyName("netelement_id")] string NetelementId, + [property: JsonPropertyName("probability")] double Probability, + [property: JsonPropertyName("start_intrinsic")] double StartIntrinsic, + [property: JsonPropertyName("end_intrinsic")] double EndIntrinsic, + [property: JsonPropertyName("gnss_start_index")] int GnssStartIndex, + [property: JsonPropertyName("gnss_end_index")] int GnssEndIndex, + [property: JsonPropertyName("origin")] PathOrigin Origin = PathOrigin.Algorithm); + +public sealed record TrainPath( + [property: JsonPropertyName("segments")] IReadOnlyList Segments, + [property: JsonPropertyName("overall_probability")] double OverallProbability, + [property: JsonPropertyName("calculated_at")] DateTimeOffset? CalculatedAt = null); + +public sealed record PathResult( + [property: JsonPropertyName("path")] TrainPath? Path, + [property: JsonPropertyName("mode")] PathCalculationMode Mode, + [property: JsonPropertyName("projected_positions")] IReadOnlyList ProjectedPositions, + [property: JsonPropertyName("warnings")] IReadOnlyList Warnings, + [property: JsonPropertyName("detection_provenance")] IReadOnlyList DetectionProvenance) +{ + public bool HasPath => Path is not null; +} + +// --------------------------------------------------------------------------- +// Detection records (Rust models::detection_record). +// --------------------------------------------------------------------------- + +public abstract record DetectionTimestamp +{ + public sealed record Single( + [property: JsonPropertyName("timestamp")] DateTimeOffset Timestamp) : DetectionTimestamp; + + public sealed record Range( + [property: JsonPropertyName("t_from")] DateTimeOffset From, + [property: JsonPropertyName("t_to")] DateTimeOffset To) : DetectionTimestamp; +} + +internal sealed class DetectionTimestampJsonConverter : JsonConverter +{ + public override DetectionTimestamp Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + using var doc = JsonDocument.ParseValue(ref reader); + var root = doc.RootElement; + if (root.TryGetProperty("timestamp", out var ts)) + { + return new DetectionTimestamp.Single(ts.GetDateTimeOffset()); + } + if (root.TryGetProperty("t_from", out var from) && root.TryGetProperty("t_to", out var to)) + { + return new DetectionTimestamp.Range(from.GetDateTimeOffset(), to.GetDateTimeOffset()); + } + throw new JsonException("Unrecognized DetectionTimestamp shape"); + } + + public override void Write(Utf8JsonWriter writer, DetectionTimestamp value, JsonSerializerOptions options) + { + switch (value) + { + case DetectionTimestamp.Single s: + writer.WriteStartObject(); + writer.WriteString("timestamp", s.Timestamp); + writer.WriteEndObject(); + break; + case DetectionTimestamp.Range r: + writer.WriteStartObject(); + writer.WriteString("t_from", r.From); + writer.WriteString("t_to", r.To); + writer.WriteEndObject(); + break; + default: + throw new JsonException(); + } + } +} + +/// +/// Disposition of an ingested detection (output, populated by ). +/// Mirrors Rust's DetectionStatus tagged enum (tag = "status"). +/// +public abstract record DetectionStatus +{ + /// Detection was applied as a Viterbi anchor. + public sealed record Applied( + [property: JsonPropertyName("netelement_id")] string NetelementId, + [property: JsonPropertyName("intrinsic")] double Intrinsic) : DetectionStatus; + + /// Coordinate-only detection successfully resolved within the cutoff. + public sealed record Resolved( + [property: JsonPropertyName("netelement_id")] string NetelementId, + [property: JsonPropertyName("distance_m")] double DistanceMeters) : DetectionStatus; + + /// Detection was discarded; see . + public sealed record Discarded( + [property: JsonPropertyName("reason")] DiscardReason Reason) : DetectionStatus; +} + +/// +/// Reason a detection was discarded. Mirrors Rust's DiscardReason tagged enum (tag = "kind"). +/// +public abstract record DiscardReason +{ + public sealed record OutOfTimeRange( + [property: JsonPropertyName("gnss_first")] DateTimeOffset GnssFirst, + [property: JsonPropertyName("gnss_last")] DateTimeOffset GnssLast) : DiscardReason; + + public sealed record OutOfReach( + [property: JsonPropertyName("nearest_distance_m")] double NearestDistanceMeters, + [property: JsonPropertyName("cutoff_m")] double CutoffMeters) : DiscardReason; + + public sealed record UnknownNetelement( + [property: JsonPropertyName("netelement_id")] string NetelementId) : DiscardReason; + + public sealed record IntrinsicOutOfRange( + [property: JsonPropertyName("value")] double Value) : DiscardReason; + + public sealed record DuplicateOfPriorDetection( + [property: JsonPropertyName("kept_index")] int KeptIndex) : DiscardReason; +} + +internal sealed class DetectionStatusJsonConverter : JsonConverter +{ + public override DetectionStatus Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + using var doc = JsonDocument.ParseValue(ref reader); + var root = doc.RootElement; + var tag = root.GetProperty("status").GetString(); + return tag switch + { + "applied" => new DetectionStatus.Applied( + root.GetProperty("netelement_id").GetString()!, + root.GetProperty("intrinsic").GetDouble()), + "resolved" => new DetectionStatus.Resolved( + root.GetProperty("netelement_id").GetString()!, + root.GetProperty("distance_m").GetDouble()), + "discarded" => new DetectionStatus.Discarded( + JsonSerializer.Deserialize(root.GetProperty("reason").GetRawText(), options)!), + _ => throw new JsonException($"Unknown DetectionStatus tag '{tag}'"), + }; + } + + public override void Write(Utf8JsonWriter writer, DetectionStatus value, JsonSerializerOptions options) + { + writer.WriteStartObject(); + switch (value) + { + case DetectionStatus.Applied a: + writer.WriteString("status", "applied"); + writer.WriteString("netelement_id", a.NetelementId); + writer.WriteNumber("intrinsic", a.Intrinsic); + break; + case DetectionStatus.Resolved r: + writer.WriteString("status", "resolved"); + writer.WriteString("netelement_id", r.NetelementId); + writer.WriteNumber("distance_m", r.DistanceMeters); + break; + case DetectionStatus.Discarded d: + writer.WriteString("status", "discarded"); + writer.WritePropertyName("reason"); + JsonSerializer.Serialize(writer, d.Reason, options); + break; + default: + throw new JsonException($"Unsupported DetectionStatus variant: {value.GetType()}"); + } + writer.WriteEndObject(); + } +} + +internal sealed class DiscardReasonJsonConverter : JsonConverter +{ + public override DiscardReason Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + using var doc = JsonDocument.ParseValue(ref reader); + var root = doc.RootElement; + var tag = root.GetProperty("kind").GetString(); + return tag switch + { + "out_of_time_range" => new DiscardReason.OutOfTimeRange( + root.GetProperty("gnss_first").GetDateTimeOffset(), + root.GetProperty("gnss_last").GetDateTimeOffset()), + "out_of_reach" => new DiscardReason.OutOfReach( + root.GetProperty("nearest_distance_m").GetDouble(), + root.GetProperty("cutoff_m").GetDouble()), + "unknown_netelement" => new DiscardReason.UnknownNetelement( + root.GetProperty("netelement_id").GetString()!), + "intrinsic_out_of_range" => new DiscardReason.IntrinsicOutOfRange( + root.GetProperty("value").GetDouble()), + "duplicate_of_prior_detection" => new DiscardReason.DuplicateOfPriorDetection( + root.GetProperty("kept_index").GetInt32()), + _ => throw new JsonException($"Unknown DiscardReason kind '{tag}'"), + }; + } + + public override void Write(Utf8JsonWriter writer, DiscardReason value, JsonSerializerOptions options) + { + writer.WriteStartObject(); + switch (value) + { + case DiscardReason.OutOfTimeRange r: + writer.WriteString("kind", "out_of_time_range"); + writer.WriteString("gnss_first", r.GnssFirst); + writer.WriteString("gnss_last", r.GnssLast); + break; + case DiscardReason.OutOfReach r: + writer.WriteString("kind", "out_of_reach"); + writer.WriteNumber("nearest_distance_m", r.NearestDistanceMeters); + writer.WriteNumber("cutoff_m", r.CutoffMeters); + break; + case DiscardReason.UnknownNetelement r: + writer.WriteString("kind", "unknown_netelement"); + writer.WriteString("netelement_id", r.NetelementId); + break; + case DiscardReason.IntrinsicOutOfRange r: + writer.WriteString("kind", "intrinsic_out_of_range"); + writer.WriteNumber("value", r.Value); + break; + case DiscardReason.DuplicateOfPriorDetection r: + writer.WriteString("kind", "duplicate_of_prior_detection"); + writer.WriteNumber("kept_index", r.KeptIndex); + break; + default: + throw new JsonException($"Unsupported DiscardReason variant: {value.GetType()}"); + } + writer.WriteEndObject(); + } +} + +/// +/// A single detection event passed to , +/// and also returned in / +/// with populated. +/// +/// On input, supply either topological position ( [+ ]) +/// or geographic position (, ) — never both. +/// is ignored on input and populated on output. +/// +public sealed record DetectionRecord( + [property: JsonPropertyName("source_file")] string SourceFile, + [property: JsonPropertyName("source_row")] ulong SourceRow, + [property: JsonPropertyName("kind")] DetectionKind Kind, + [property: JsonPropertyName("timestamp")] DetectionTimestamp Timestamp, + [property: JsonPropertyName("id")] string? Id = null, + [property: JsonPropertyName("source")] string? Source = null, + [property: JsonPropertyName("metadata")] IReadOnlyDictionary? Metadata = null, + [property: JsonPropertyName("status")] DetectionStatus? Status = null, + [property: JsonPropertyName("netelement_id")] string? NetelementId = null, + [property: JsonPropertyName("intrinsic")] double? Intrinsic = null, + [property: JsonPropertyName("start_intrinsic")] double? StartIntrinsic = null, + [property: JsonPropertyName("end_intrinsic")] double? EndIntrinsic = null, + [property: JsonPropertyName("latitude")] double? Latitude = null, + [property: JsonPropertyName("longitude")] double? Longitude = null, + [property: JsonPropertyName("crs")] string? Crs = null); + +public sealed record PreparedDetections( + [property: JsonPropertyName("records")] IReadOnlyList Records, + [property: JsonPropertyName("warnings")] IReadOnlyList Warnings, + [property: JsonPropertyName("anchors")] JsonElement Anchors); + +// --------------------------------------------------------------------------- +// Input record types (consumer-facing). +// --------------------------------------------------------------------------- + +public sealed record NetworkSegment( + string Id, + IReadOnlyList<(double Longitude, double Latitude)> Coordinates, + string Crs = "EPSG:4326"); + +public sealed record NetworkRelation( + string Id, + string NetelementAId, + string NetelementBId, + int PositionOnA, + int PositionOnB, + Navigability Navigability); + +public sealed record GnssRecord( + double Latitude, + double Longitude, + DateTimeOffset Timestamp); + +// --------------------------------------------------------------------------- +// Input wrappers — carry raw JSON/CSV ready for the Rust core. +// --------------------------------------------------------------------------- + +public sealed class NetworkInput +{ + private readonly string _json; + private NetworkInput(string json) { _json = json; } + + public static NetworkInput FromGeoJson(string geoJson) + { + ArgumentException.ThrowIfNullOrEmpty(geoJson); + return new NetworkInput(geoJson); + } + + public static NetworkInput FromRecords( + IEnumerable segments, + IEnumerable relations) + { + ArgumentNullException.ThrowIfNull(segments); + ArgumentNullException.ThrowIfNull(relations); + + using var stream = new MemoryStream(); + using var w = new Utf8JsonWriter(stream); + w.WriteStartObject(); + w.WriteString("type", "FeatureCollection"); + w.WriteStartArray("features"); + + foreach (var seg in segments) + { + w.WriteStartObject(); + w.WriteString("type", "Feature"); + w.WriteStartObject("properties"); + w.WriteString("id", seg.Id); + w.WriteString("type", "netelement"); + w.WriteString("crs", seg.Crs); + w.WriteEndObject(); + w.WriteStartObject("geometry"); + w.WriteString("type", "LineString"); + w.WriteStartArray("coordinates"); + foreach (var (lon, lat) in seg.Coordinates) + { + w.WriteStartArray(); + w.WriteNumberValue(lon); + w.WriteNumberValue(lat); + w.WriteEndArray(); + } + w.WriteEndArray(); + w.WriteEndObject(); + w.WriteEndObject(); + } + + foreach (var rel in relations) + { + w.WriteStartObject(); + w.WriteString("type", "Feature"); + w.WriteStartObject("properties"); + w.WriteString("id", rel.Id); + w.WriteString("type", "netrelation"); + w.WriteString("netelementA", rel.NetelementAId); + w.WriteString("netelementB", rel.NetelementBId); + w.WriteNumber("positionOnA", rel.PositionOnA); + w.WriteNumber("positionOnB", rel.PositionOnB); + w.WriteString("navigability", rel.Navigability switch + { + Navigability.Both => "both", + Navigability.Forward => "AB", + Navigability.Backward => "BA", + Navigability.None => "none", + _ => "both", + }); + w.WriteEndObject(); + w.WriteNull("geometry"); + w.WriteEndObject(); + } + + w.WriteEndArray(); + w.WriteEndObject(); + w.Flush(); + return new NetworkInput(Encoding.UTF8.GetString(stream.ToArray())); + } + + internal string AsJson() => _json; +} + +public sealed class GnssInput +{ + private readonly string _payload; + private readonly bool _isCsv; + + private GnssInput(string payload, bool isCsv) { _payload = payload; _isCsv = isCsv; } + + public static GnssInput FromGeoJson(string geoJson) + { + ArgumentException.ThrowIfNullOrEmpty(geoJson); + return new GnssInput(geoJson, isCsv: false); + } + + public static GnssInput FromCsv(string csv) + { + ArgumentException.ThrowIfNullOrEmpty(csv); + return new GnssInput(csv, isCsv: true); + } + + public static GnssInput FromRecords(IEnumerable records) + { + ArgumentNullException.ThrowIfNull(records); + + using var stream = new MemoryStream(); + using var w = new Utf8JsonWriter(stream); + w.WriteStartObject(); + w.WriteString("type", "FeatureCollection"); + w.WriteStartArray("features"); + foreach (var r in records) + { + w.WriteStartObject(); + w.WriteString("type", "Feature"); + w.WriteStartObject("properties"); + w.WriteNumber("latitude", r.Latitude); + w.WriteNumber("longitude", r.Longitude); + w.WriteString("timestamp", r.Timestamp.ToString("o", CultureInfo.InvariantCulture)); + w.WriteEndObject(); + w.WriteStartObject("geometry"); + w.WriteString("type", "Point"); + w.WriteStartArray("coordinates"); + w.WriteNumberValue(r.Longitude); + w.WriteNumberValue(r.Latitude); + w.WriteEndArray(); + w.WriteEndObject(); + w.WriteEndObject(); + } + w.WriteEndArray(); + w.WriteEndObject(); + w.Flush(); + return new GnssInput(Encoding.UTF8.GetString(stream.ToArray()), isCsv: false); + } + + internal string AsJson() => _payload; + internal bool IsCsv => _isCsv; +} + +// --------------------------------------------------------------------------- +// Shared JSON options used by the C# wrappers when (de)serializing FFI payloads. +// --------------------------------------------------------------------------- + +internal static class TpLibJson +{ + internal static readonly JsonSerializerOptions Options = BuildOptions(); + + private static JsonSerializerOptions BuildOptions() + { + var o = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, + PropertyNameCaseInsensitive = false, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + }; + o.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.SnakeCaseLower)); + o.Converters.Add(new DetectionTimestampJsonConverter()); + o.Converters.Add(new DetectionStatusJsonConverter()); + o.Converters.Add(new DiscardReasonJsonConverter()); + return o; + } +} diff --git a/tp-net/csharp/NativeMethods.g.cs b/tp-net/csharp/NativeMethods.g.cs new file mode 100644 index 0000000..da2be09 --- /dev/null +++ b/tp-net/csharp/NativeMethods.g.cs @@ -0,0 +1,132 @@ +// +// This code is generated by csbindgen. +// DON'T CHANGE THIS DIRECTLY. +// +#pragma warning disable CS8500 +#pragma warning disable CS8981 +using System; +using System.Runtime.InteropServices; + + +namespace TpLib +{ + internal static unsafe partial class NativeMethods + { + const string __DllName = "tp_lib_net"; + + + + + + /// + /// Project GNSS positions onto the nearest network segments. + /// + /// # Safety + /// All pointers must reference valid UTF-8 byte slices of the indicated length. + /// + [DllImport(__DllName, EntryPoint = "tp_net_project_gnss", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + internal static extern ByteBuffer tp_net_project_gnss(byte* network_ptr, int network_len, byte* gnss_ptr, int gnss_len, ProjectionConfigFfi config); + + /// + /// Project GNSS positions onto a previously computed train path. + /// + /// # Safety + /// All pointers must reference valid UTF-8 byte slices of the indicated length. + /// + [DllImport(__DllName, EntryPoint = "tp_net_project_onto_path", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + internal static extern ByteBuffer tp_net_project_onto_path(byte* network_ptr, int network_len, byte* gnss_ptr, int gnss_len, byte* train_path_ptr, int train_path_len, PathConfigFfi config); + + /// + /// Calculate a train path from GNSS positions and a railway network. + /// + /// `prepared_detections_ptr` may be null (`prepared_detections_len == 0`). + /// When provided, the JSON must include an `anchors` array of [`ResolvedAnchor`]. + /// + /// # Safety + /// All non-null pointers must reference valid UTF-8 byte slices of the + /// indicated length. + /// + [DllImport(__DllName, EntryPoint = "tp_net_calculate_train_path", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + internal static extern ByteBuffer tp_net_calculate_train_path(byte* network_ptr, int network_len, byte* gnss_ptr, int gnss_len, byte* prepared_detections_ptr, int prepared_detections_len, PathConfigFfi config); + + /// + /// Validate, time-filter and resolve detections into [`ResolvedAnchor`]s for + /// path calculation. + /// + /// `kind_is_linear == 0` ⇒ `Punctual`; non-zero ⇒ `Linear`. + /// + /// # Safety + /// All pointers must reference valid UTF-8 byte slices of the indicated length. + /// + [DllImport(__DllName, EntryPoint = "tp_net_prepare_detections", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + internal static extern ByteBuffer tp_net_prepare_detections(byte* network_ptr, int network_len, byte* gnss_ptr, int gnss_len, byte* detections_geojson_ptr, int detections_geojson_len, byte kind_is_linear, double cutoff_distance_meters); + + /// + /// Free a [`ByteBuffer`] previously returned by any `tp_net_*` function. + /// + /// # Safety + /// The buffer must have been produced by this library and not yet freed. + /// + [DllImport(__DllName, EntryPoint = "tp_net_free_byte_buffer", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + internal static extern void tp_net_free_byte_buffer(ByteBuffer buf); + + + } + + /// + /// Heap-allocated byte buffer owned by the Rust side; freed by the caller via + /// [`tp_net_free_byte_buffer`]. + /// + /// `len == cap` after allocation. A null `ptr` paired with `len == -1` + /// signals an FFI error (the C# layer raises a generic exception). + /// + [StructLayout(LayoutKind.Sequential)] + internal unsafe partial struct ByteBuffer + { + public byte* ptr; + public int len; + public int cap; + } + + /// + /// Flat mirror of `tp_lib_core::ProjectionConfig` for `#[repr(C)]` transport. + /// + /// `max_search_radius_meters` is reserved for the public contract but is not + /// currently consumed by `tp-lib-core`. + /// + [StructLayout(LayoutKind.Sequential)] + internal unsafe partial struct ProjectionConfigFfi + { + public double max_search_radius_meters; + public double projection_distance_warning_threshold; + public byte suppress_warnings; + } + + /// + /// Flat mirror of `tp_lib_core::PathConfig` for `#[repr(C)]` transport. + /// + /// `anchors` are not transmitted via this struct; they arrive on the + /// `PreparedDetections` JSON payload of `tp_net_calculate_train_path`. + /// + [StructLayout(LayoutKind.Sequential)] + internal unsafe partial struct PathConfigFfi + { + public double distance_scale; + public double heading_scale; + public double cutoff_distance; + public double heading_cutoff; + public double probability_threshold; + public double resampling_distance; + public byte has_resampling_distance; + public ulong max_candidates; + public byte path_only; + public byte debug_mode; + public double beta; + public double edge_zone_distance; + public double turn_scale; + public double detection_cutoff_distance; + } + + + +} diff --git a/tp-net/csharp/NuGet.config b/tp-net/csharp/NuGet.config new file mode 100644 index 0000000..e219de2 --- /dev/null +++ b/tp-net/csharp/NuGet.config @@ -0,0 +1,7 @@ + + + + + + + diff --git a/tp-net/csharp/Tests/DetectionPreparationTests.cs b/tp-net/csharp/Tests/DetectionPreparationTests.cs new file mode 100644 index 0000000..1f47cbd --- /dev/null +++ b/tp-net/csharp/Tests/DetectionPreparationTests.cs @@ -0,0 +1,177 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.Json; +using TpLib; +using Xunit; + +namespace TpLib.Tests; + +public class DetectionPreparationTests +{ + private static (NetworkInput network, GnssInput gnss) LoadFixtures() + { + var network = NetworkInput.FromGeoJson(TestData.Read("sample_network.geojson")); + var gnss = GnssInput.FromGeoJson(TestData.Read("sample_gnss.geojson")); + return (network, gnss); + } + + [Fact] + public void PrepareDetections_Punctual_TypedRecords_ReturnsRecords() + { + var (network, gnss) = LoadFixtures(); + + var detections = new List + { + new( + SourceFile: "test", + SourceRow: 0, + Kind: DetectionKind.Punctual, + Timestamp: new DetectionTimestamp.Single( + DateTimeOffset.Parse("2024-01-15T10:30:05+01:00")), + Id: "D001", + NetelementId: "NE001"), + }; + + var prepared = DetectionPreparation.PrepareDetections(network, gnss, detections); + + Assert.NotNull(prepared); + Assert.NotNull(prepared.Records); + Assert.Single(prepared.Records); + } + + [Fact] + public void PrepareDetections_Linear_TypedRecords_ReturnsRecords() + { + var (network, gnss) = LoadFixtures(); + + var detections = new List + { + new( + SourceFile: "test", + SourceRow: 0, + Kind: DetectionKind.Linear, + Timestamp: new DetectionTimestamp.Range( + From: DateTimeOffset.Parse("2024-01-15T10:30:00+01:00"), + To: DateTimeOffset.Parse("2024-01-15T10:30:10+01:00")), + Id: "L001", + NetelementId: "NE001", + StartIntrinsic: 0.0, + EndIntrinsic: 1.0), + }; + + var prepared = DetectionPreparation.PrepareDetections(network, gnss, detections); + + Assert.NotNull(prepared); + Assert.Single(prepared.Records); + } + + [Fact] + public void PrepareDetections_MixedKinds_AreCombined() + { + var (network, gnss) = LoadFixtures(); + + var detections = new List + { + new( + SourceFile: "test", + SourceRow: 0, + Kind: DetectionKind.Punctual, + Timestamp: new DetectionTimestamp.Single( + DateTimeOffset.Parse("2024-01-15T10:30:02+01:00")), + NetelementId: "NE001"), + new( + SourceFile: "test", + SourceRow: 1, + Kind: DetectionKind.Linear, + Timestamp: new DetectionTimestamp.Range( + From: DateTimeOffset.Parse("2024-01-15T10:30:04+01:00"), + To: DateTimeOffset.Parse("2024-01-15T10:30:08+01:00")), + NetelementId: "NE002", + StartIntrinsic: 0.0, + EndIntrinsic: 1.0), + }; + + var prepared = DetectionPreparation.PrepareDetections(network, gnss, detections); + + Assert.Equal(2, prepared.Records.Count); + } + + [Fact] + public void PrepareDetections_EmptySequence_ReturnsEmpty() + { + var (network, gnss) = LoadFixtures(); + + var prepared = DetectionPreparation.PrepareDetections( + network, gnss, Enumerable.Empty()); + + Assert.NotNull(prepared); + Assert.Empty(prepared.Records); + Assert.Empty(prepared.Warnings); + } + + [Fact] + public void PrepareDetections_NullDetections_Throws() + { + var (network, gnss) = LoadFixtures(); + + Assert.Throws( + () => DetectionPreparation.PrepareDetections( + network, gnss, (IEnumerable)null!)); + } + + [Fact] + public void DetectionStatus_Applied_RoundTrips() + { + var options = TpLibJson.Options; + DetectionStatus status = new DetectionStatus.Applied("ne-1", 0.5); + var json = JsonSerializer.Serialize(status, options); + var roundTripped = JsonSerializer.Deserialize(json, options); + var applied = Assert.IsType(roundTripped); + Assert.Equal("ne-1", applied.NetelementId); + Assert.Equal(0.5, applied.Intrinsic); + } + + [Fact] + public void DetectionStatus_Resolved_RoundTrips() + { + var options = TpLibJson.Options; + DetectionStatus status = new DetectionStatus.Resolved("ne-2", 1.25); + var json = JsonSerializer.Serialize(status, options); + var roundTripped = JsonSerializer.Deserialize(json, options); + var resolved = Assert.IsType(roundTripped); + Assert.Equal("ne-2", resolved.NetelementId); + Assert.Equal(1.25, resolved.DistanceMeters); + } + + [Fact] + public void DetectionStatus_Discarded_OutOfTimeRange_RoundTrips() + { + var options = TpLibJson.Options; + var first = DateTimeOffset.Parse("2026-03-13T17:00:00+01:00"); + var last = DateTimeOffset.Parse("2026-03-13T18:00:00+01:00"); + DetectionStatus status = new DetectionStatus.Discarded( + new DiscardReason.OutOfTimeRange(first, last)); + var json = JsonSerializer.Serialize(status, options); + var roundTripped = JsonSerializer.Deserialize(json, options); + var discarded = Assert.IsType(roundTripped); + var reason = Assert.IsType(discarded.Reason); + Assert.Equal(first, reason.GnssFirst); + Assert.Equal(last, reason.GnssLast); + } + + [Fact] + public void DetectionStatus_Discarded_OutOfReach_RoundTrips() + { + var options = TpLibJson.Options; + DetectionStatus status = new DetectionStatus.Discarded( + new DiscardReason.OutOfReach(12.5, 2.5)); + var json = JsonSerializer.Serialize(status, options); + var roundTripped = JsonSerializer.Deserialize(json, options); + var discarded = Assert.IsType(roundTripped); + var reason = Assert.IsType(discarded.Reason); + Assert.Equal(12.5, reason.NearestDistanceMeters); + Assert.Equal(2.5, reason.CutoffMeters); + } +} diff --git a/tp-net/csharp/Tests/InMemoryInputTests.cs b/tp-net/csharp/Tests/InMemoryInputTests.cs new file mode 100644 index 0000000..4b58926 --- /dev/null +++ b/tp-net/csharp/Tests/InMemoryInputTests.cs @@ -0,0 +1,55 @@ +using System.Collections.Generic; +using System.Linq; +using TpLib; +using Xunit; + +namespace TpLib.Tests; + +public class InMemoryInputTests +{ + [Fact] + public void NetworkInput_FromRecords_Equivalent_To_GeoJson() + { + var segments = new List + { + new("A", new (double, double)[] + { + (4.4351, 50.8505), + (4.4361, 50.8510), + (4.4371, 50.8515), + }), + new("B", new (double, double)[] + { + (4.4371, 50.8515), + (4.4381, 50.8520), + }), + }; + var relations = new List + { + new("R1", "A", "B", 1, 0, Navigability.Both), + }; + + var network = NetworkInput.FromRecords(segments, relations); + var gnss = GnssInput.FromGeoJson(TestData.Read("sample_gnss.geojson")); + + // Should at least construct and serialize without throwing. + Assert.False(string.IsNullOrEmpty(network.AsJson())); + } + + [Fact] + public void GnssInput_FromRecords_Builds_Valid_GeoJson() + { + var records = new List + { + new(50.8505, 4.4351, new System.DateTimeOffset(2024, 1, 1, 8, 0, 0, System.TimeSpan.Zero)), + new(50.8510, 4.4361, new System.DateTimeOffset(2024, 1, 1, 8, 0, 1, System.TimeSpan.Zero)), + new(50.8515, 4.4371, new System.DateTimeOffset(2024, 1, 1, 8, 0, 2, System.TimeSpan.Zero)), + }; + + var gnss = GnssInput.FromRecords(records); + var json = gnss.AsJson(); + + Assert.Contains("FeatureCollection", json); + Assert.Contains("Point", json); + } +} diff --git a/tp-net/csharp/Tests/PathCalculationTests.cs b/tp-net/csharp/Tests/PathCalculationTests.cs new file mode 100644 index 0000000..5b1a12d --- /dev/null +++ b/tp-net/csharp/Tests/PathCalculationTests.cs @@ -0,0 +1,56 @@ +using System.Linq; +using TpLib; +using Xunit; + +namespace TpLib.Tests; + +public class PathCalculationTests +{ + [Fact] + public void CalculateTrainPath_SampleData_ReturnsPath() + { + var network = NetworkInput.FromGeoJson(TestData.Read("sample_network.geojson")); + var gnss = GnssInput.FromGeoJson(TestData.Read("sample_gnss.geojson")); + + var result = PathCalculation.CalculateTrainPath(network, gnss); + + Assert.NotNull(result); + if (result.HasPath) + { + Assert.InRange(result.Path!.OverallProbability, 0.0, 1.0); + } + } + + [Fact] + public void CalculateTrainPath_ModeIsTopologyOrFallback() + { + var network = NetworkInput.FromGeoJson(TestData.Read("sample_network.geojson")); + var gnss = GnssInput.FromGeoJson(TestData.Read("sample_gnss.geojson")); + + var result = PathCalculation.CalculateTrainPath(network, gnss); + + Assert.True(result.Mode == PathCalculationMode.TopologyBased + || result.Mode == PathCalculationMode.FallbackIndependent); + } + + [Fact] + public void CalculateTrainPath_PathOnlyTrue_EmptyProjections() + { + var network = NetworkInput.FromGeoJson(TestData.Read("sample_network.geojson")); + var gnss = GnssInput.FromGeoJson(TestData.Read("sample_gnss.geojson")); + var config = new PathConfig { PathOnly = true }; + + var result = PathCalculation.CalculateTrainPath(network, gnss, config); + + Assert.Empty(result.ProjectedPositions); + } + + [Fact] + public void CalculateTrainPath_MalformedGeoJson_ThrowsParse() + { + var network = NetworkInput.FromGeoJson("{ broken"); + var gnss = GnssInput.FromGeoJson(TestData.Read("sample_gnss.geojson")); + + Assert.Throws(() => PathCalculation.CalculateTrainPath(network, gnss)); + } +} diff --git a/tp-net/csharp/Tests/ProjectionTests.cs b/tp-net/csharp/Tests/ProjectionTests.cs new file mode 100644 index 0000000..516585b --- /dev/null +++ b/tp-net/csharp/Tests/ProjectionTests.cs @@ -0,0 +1,106 @@ +using System; +using System.Linq; +using TpLib; +using Xunit; + +namespace TpLib.Tests; + +public class ProjectionTests +{ + [Fact] + public void ProjectGnss_SampleData_ReturnsValidPositions() + { + var network = NetworkInput.FromGeoJson(TestData.Read("sample_network.geojson")); + var gnss = GnssInput.FromGeoJson(TestData.Read("sample_gnss.geojson")); + + var result = Projection.ProjectGnss(network, gnss); + + Assert.NotEmpty(result); + Assert.All(result, p => + { + Assert.False(string.IsNullOrEmpty(p.NetelementId)); + Assert.True(p.MeasureMeters >= 0); + Assert.True(p.ProjectionDistanceMeters >= 0); + Assert.Null(p.Intrinsic); + }); + } + + [Fact] + public void ProjectGnss_StringOverload_Works() + { + var net = TestData.Read("sample_network.geojson"); + var gnss = TestData.Read("sample_gnss.geojson"); + + var result = Projection.ProjectGnss( + NetworkInput.FromGeoJson(net), + GnssInput.FromGeoJson(gnss)); + + Assert.NotEmpty(result); + } + + [Fact] + public void ProjectGnss_CsvInput_Works() + { + var network = NetworkInput.FromGeoJson(TestData.Read("sample_network.geojson")); + var csv = """ + latitude,longitude,timestamp + 50.8503,4.3517,2024-01-15T10:30:00+01:00 + 50.8505,4.3520,2024-01-15T10:30:05+01:00 + 50.8508,4.3523,2024-01-15T10:30:10+01:00 + """; + var gnss = GnssInput.FromCsv(csv); + + var result = Projection.ProjectGnss(network, gnss); + + Assert.Equal(3, result.Count); + } + + [Fact] + public void ProjectGnss_CustomConfig_Respected() + { + var network = NetworkInput.FromGeoJson(TestData.Read("sample_network.geojson")); + var gnss = GnssInput.FromGeoJson(TestData.Read("sample_gnss.geojson")); + var config = new ProjectionConfig { MaxSearchRadiusMeters = 2000.0, SuppressWarnings = true }; + + var result = Projection.ProjectGnss(network, gnss, config); + + Assert.NotEmpty(result); + } + + [Fact] + public void ProjectGnss_NullNetwork_Throws() + { + var gnss = GnssInput.FromGeoJson(TestData.Read("sample_gnss.geojson")); + Assert.Throws(() => Projection.ProjectGnss((NetworkInput)null!, gnss)); + } + + [Fact] + public void ProjectGnss_MalformedGeoJson_ThrowsParse() + { + var network = NetworkInput.FromGeoJson("{ not valid json"); + var gnss = GnssInput.FromGeoJson(TestData.Read("sample_gnss.geojson")); + Assert.Throws(() => Projection.ProjectGnss(network, gnss)); + } + + [Fact] + public void ProjectOntoPath_RoundTrip_PopulatesIntrinsic() + { + var network = NetworkInput.FromGeoJson(TestData.Read("sample_network.geojson")); + var gnss = GnssInput.FromGeoJson(TestData.Read("sample_gnss.geojson")); + + var pathResult = PathCalculation.CalculateTrainPath(network, gnss); + if (!pathResult.HasPath) + { + return; // sample data may not yield a path in all runs + } + + var projected = Projection.ProjectOntoPath(network, gnss, pathResult.Path!); + Assert.All(projected, p => + { + if (p.Intrinsic is not null) + { + Assert.InRange(p.Intrinsic!.Value, 0.0, 1.0); + } + }); + } +} diff --git a/tp-net/csharp/Tests/SerializationTests.cs b/tp-net/csharp/Tests/SerializationTests.cs new file mode 100644 index 0000000..b521c31 --- /dev/null +++ b/tp-net/csharp/Tests/SerializationTests.cs @@ -0,0 +1,58 @@ +using System.Text.Json; +using TpLib; +using Xunit; + +namespace TpLib.Tests; + +public class SerializationTests +{ + [Fact] + public void Enums_SerializeAs_SnakeCaseLower() + { + var json = JsonSerializer.Serialize(PathCalculationMode.TopologyBased, TpLibJson.Options); + Assert.Equal("\"topology_based\"", json); + + var nav = JsonSerializer.Serialize(Navigability.Forward, TpLibJson.Options); + Assert.Equal("\"forward\"", nav); + + var kind = JsonSerializer.Serialize(DetectionKind.Linear, TpLibJson.Options); + Assert.Equal("\"linear\"", kind); + } + + [Fact] + public void PathConfig_SerializesUsing_SnakeCase() + { + var cfg = new PathConfig { CutoffDistanceMeters = 600.0, PathOnly = true }; + var json = JsonSerializer.Serialize(cfg, TpLibJson.Options); + + Assert.Contains("cutoff_distance_meters", json); + Assert.Contains("path_only", json); + Assert.DoesNotContain("CutoffDistanceMeters", json); + } + + [Fact] + public void DetectionTimestamp_Single_RoundTrips() + { + var ts = new System.DateTimeOffset(2024, 6, 1, 12, 0, 0, System.TimeSpan.Zero); + DetectionTimestamp value = new DetectionTimestamp.Single(ts); + var json = JsonSerializer.Serialize(value, TpLibJson.Options); + var back = JsonSerializer.Deserialize(json, TpLibJson.Options); + + var single = Assert.IsType(back); + Assert.Equal(ts, single.Timestamp); + } + + [Fact] + public void DetectionTimestamp_Range_RoundTrips() + { + var from = new System.DateTimeOffset(2024, 6, 1, 12, 0, 0, System.TimeSpan.Zero); + var to = new System.DateTimeOffset(2024, 6, 1, 12, 0, 30, System.TimeSpan.Zero); + DetectionTimestamp value = new DetectionTimestamp.Range(from, to); + var json = JsonSerializer.Serialize(value, TpLibJson.Options); + var back = JsonSerializer.Deserialize(json, TpLibJson.Options); + + var range = Assert.IsType(back); + Assert.Equal(from, range.From); + Assert.Equal(to, range.To); + } +} diff --git a/tp-net/csharp/Tests/TestData.cs b/tp-net/csharp/Tests/TestData.cs new file mode 100644 index 0000000..c206c07 --- /dev/null +++ b/tp-net/csharp/Tests/TestData.cs @@ -0,0 +1,30 @@ +using System; +using System.IO; + +namespace TpLib.Tests; + +internal static class TestData +{ + private static readonly Lazy _root = new(FindRoot); + + public static string Root => _root.Value; + + public static string Path(string relative) => System.IO.Path.Combine(Root, relative); + + public static string Read(string relative) => File.ReadAllText(Path(relative)); + + private static string FindRoot() + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir is not null) + { + var candidate = System.IO.Path.Combine(dir.FullName, "test-data"); + if (Directory.Exists(candidate)) + { + return candidate; + } + dir = dir.Parent; + } + throw new DirectoryNotFoundException("Could not locate test-data directory"); + } +} diff --git a/tp-net/csharp/Tests/TpLib.Tests.csproj b/tp-net/csharp/Tests/TpLib.Tests.csproj new file mode 100644 index 0000000..8263319 --- /dev/null +++ b/tp-net/csharp/Tests/TpLib.Tests.csproj @@ -0,0 +1,22 @@ + + + + net8.0 + enable + enable + latest + false + true + + + + + + + + + + + + + diff --git a/tp-net/csharp/TpLib.cs b/tp-net/csharp/TpLib.cs new file mode 100644 index 0000000..718d62f --- /dev/null +++ b/tp-net/csharp/TpLib.cs @@ -0,0 +1,402 @@ +using System.IO; +using System.Linq; +using System.Text; +using System.Text.Json; + +namespace TpLib; + +/// +/// Project GNSS positions onto a railway network or a previously computed path. +/// +public static class Projection +{ + public static IReadOnlyList ProjectGnss( + NetworkInput network, + GnssInput gnss, + ProjectionConfig? config = null) + { + ArgumentNullException.ThrowIfNull(network); + ArgumentNullException.ThrowIfNull(gnss); + config ??= new ProjectionConfig(); + TpLibNative.EnsureInitialized(); + var netBytes = Encoding.UTF8.GetBytes(network.AsJson()); + var gnssBytes = Encoding.UTF8.GetBytes(gnss.AsJson()); + + unsafe + { + fixed (byte* netPtr = netBytes) + fixed (byte* gnssPtr = gnssBytes) + { + var cfg = new ProjectionConfigFfi + { + max_search_radius_meters = config.MaxSearchRadiusMeters, + projection_distance_warning_threshold = config.ProjectionDistanceWarningThreshold, + suppress_warnings = (byte)(config.SuppressWarnings ? 1 : 0), + }; + var buf = NativeMethods.tp_net_project_gnss(netPtr, netBytes.Length, gnssPtr, gnssBytes.Length, cfg); + return TpLibFfi.DeserializeOrThrow>( + buf, + "ProjectGnss failed (FFI returned an error sentinel).", + ex => ex is null + ? new TpLibProjectionException("ProjectGnss failed (FFI returned an error sentinel).") + : new TpLibProjectionException("ProjectGnss failed (FFI returned an error sentinel).", ex)); + } + } + } + + public static IReadOnlyList ProjectOntoPath( + NetworkInput network, + GnssInput gnss, + TrainPath path, + PathConfig? config = null) + { + ArgumentNullException.ThrowIfNull(network); + ArgumentNullException.ThrowIfNull(gnss); + ArgumentNullException.ThrowIfNull(path); + config ??= new PathConfig(); + TpLibNative.EnsureInitialized(); + + var netBytes = Encoding.UTF8.GetBytes(network.AsJson()); + var gnssBytes = Encoding.UTF8.GetBytes(gnss.AsJson()); + var pathBytes = JsonSerializer.SerializeToUtf8Bytes(path, TpLibJson.Options); + + unsafe + { + fixed (byte* netPtr = netBytes) + fixed (byte* gnssPtr = gnssBytes) + fixed (byte* pathPtr = pathBytes) + { + var cfg = TpLibFfi.ToFfi(config); + var buf = NativeMethods.tp_net_project_onto_path( + netPtr, netBytes.Length, + gnssPtr, gnssBytes.Length, + pathPtr, pathBytes.Length, + cfg); + return TpLibFfi.DeserializeOrThrow>( + buf, + "ProjectOntoPath failed (FFI returned an error sentinel).", + ex => ex is null + ? new TpLibProjectionException("ProjectOntoPath failed (FFI returned an error sentinel).") + : new TpLibProjectionException("ProjectOntoPath failed (FFI returned an error sentinel).", ex)); + } + } + } + + public static IReadOnlyList ProjectGnss( + string networkGeoJson, GnssInput gnss, ProjectionConfig? config = null) + => ProjectGnss(NetworkInput.FromGeoJson(networkGeoJson), gnss, config); + + public static IReadOnlyList ProjectGnss( + string networkGeoJson, string gnssGeoJson, ProjectionConfig? config = null) + => ProjectGnss(NetworkInput.FromGeoJson(networkGeoJson), GnssInput.FromGeoJson(gnssGeoJson), config); + + public static IReadOnlyList ProjectOntoPath( + string networkGeoJson, GnssInput gnss, TrainPath path, PathConfig? config = null) + => ProjectOntoPath(NetworkInput.FromGeoJson(networkGeoJson), gnss, path, config); + + public static IReadOnlyList ProjectOntoPath( + string networkGeoJson, string gnssGeoJson, TrainPath path, PathConfig? config = null) + => ProjectOntoPath(NetworkInput.FromGeoJson(networkGeoJson), GnssInput.FromGeoJson(gnssGeoJson), path, config); +} + +/// +/// Train path calculation entry points. +/// +public static class PathCalculation +{ + public static PathResult CalculateTrainPath( + NetworkInput network, + GnssInput gnss, + PathConfig? config = null, + PreparedDetections? detections = null) + { + ArgumentNullException.ThrowIfNull(network); + ArgumentNullException.ThrowIfNull(gnss); + config ??= new PathConfig(); + TpLibNative.EnsureInitialized(); + + var netBytes = Encoding.UTF8.GetBytes(network.AsJson()); + var gnssBytes = Encoding.UTF8.GetBytes(gnss.AsJson()); + var detBytes = detections is null + ? Array.Empty() + : JsonSerializer.SerializeToUtf8Bytes(detections, TpLibJson.Options); + + unsafe + { + fixed (byte* netPtr = netBytes) + fixed (byte* gnssPtr = gnssBytes) + fixed (byte* detPtr = detBytes) + { + var cfg = TpLibFfi.ToFfi(config); + byte* detArg = detBytes.Length == 0 ? null : detPtr; + var buf = NativeMethods.tp_net_calculate_train_path( + netPtr, netBytes.Length, + gnssPtr, gnssBytes.Length, + detArg, detBytes.Length, + cfg); + return TpLibFfi.DeserializeOrThrow( + buf, + "CalculateTrainPath failed (FFI returned an error sentinel).", + ex => ex is null + ? new TpLibPathException("CalculateTrainPath failed (FFI returned an error sentinel).") + : new TpLibPathException("CalculateTrainPath failed (FFI returned an error sentinel).", ex)); + } + } + } + + public static PathResult CalculateTrainPath( + string networkGeoJson, GnssInput gnss, PathConfig? config = null, PreparedDetections? detections = null) + => CalculateTrainPath(NetworkInput.FromGeoJson(networkGeoJson), gnss, config, detections); + + public static PathResult CalculateTrainPath( + string networkGeoJson, string gnssGeoJson, PathConfig? config = null, PreparedDetections? detections = null) + => CalculateTrainPath(NetworkInput.FromGeoJson(networkGeoJson), GnssInput.FromGeoJson(gnssGeoJson), config, detections); +} + +/// +/// Detection preparation: validates and resolves user-supplied detections into anchors. +/// +public static class DetectionPreparation +{ + /// + /// Validate, time-filter and resolve detections against the network. + /// + /// Network input (GeoJSON or constructed from records). + /// GNSS input used to derive the time window. + /// Detection events. Each record's selects the + /// punctual/linear schema and must be paired with the required positional fields (see + /// ). + /// Max projection distance for coordinate-only punctual detections. + public static PreparedDetections PrepareDetections( + NetworkInput network, + GnssInput gnss, + IEnumerable detections, + double cutoffDistanceMeters = 2.5) + { + ArgumentNullException.ThrowIfNull(network); + ArgumentNullException.ThrowIfNull(gnss); + ArgumentNullException.ThrowIfNull(detections); + TpLibNative.EnsureInitialized(); + + var records = detections as IReadOnlyList ?? detections.ToList(); + + var allRecords = new List(); + var allWarnings = new List(); + using var anchorsStream = new MemoryStream(); + using (var aw = new Utf8JsonWriter(anchorsStream)) + { + aw.WriteStartArray(); + foreach (var kind in new[] { DetectionKind.Punctual, DetectionKind.Linear }) + { + var subset = records.Where(r => r.Kind == kind).ToList(); + if (subset.Count == 0) + { + continue; + } + var partial = PrepareDetectionsForKind(network, gnss, subset, kind, cutoffDistanceMeters); + allRecords.AddRange(partial.Records); + allWarnings.AddRange(partial.Warnings); + if (partial.Anchors.ValueKind == JsonValueKind.Array) + { + foreach (var anchor in partial.Anchors.EnumerateArray()) + { + anchor.WriteTo(aw); + } + } + } + aw.WriteEndArray(); + aw.Flush(); + } + + using var anchorsDoc = JsonDocument.Parse(anchorsStream.ToArray()); + return new PreparedDetections(allRecords, allWarnings, anchorsDoc.RootElement.Clone()); + } + + /// Convenience overload accepting the raw network GeoJSON string. + public static PreparedDetections PrepareDetections( + string networkGeoJson, + GnssInput gnss, + IEnumerable detections, + double cutoffDistanceMeters = 2.5) + => PrepareDetections(NetworkInput.FromGeoJson(networkGeoJson), gnss, detections, cutoffDistanceMeters); + + private static PreparedDetections PrepareDetectionsForKind( + NetworkInput network, + GnssInput gnss, + IReadOnlyList records, + DetectionKind kind, + double cutoffDistanceMeters) + { + var detectionsGeoJson = BuildDetectionsGeoJson(records, kind); + + var netBytes = Encoding.UTF8.GetBytes(network.AsJson()); + var gnssBytes = Encoding.UTF8.GetBytes(gnss.AsJson()); + var detBytes = Encoding.UTF8.GetBytes(detectionsGeoJson); + + unsafe + { + fixed (byte* netPtr = netBytes) + fixed (byte* gnssPtr = gnssBytes) + fixed (byte* detPtr = detBytes) + { + var buf = NativeMethods.tp_net_prepare_detections( + netPtr, netBytes.Length, + gnssPtr, gnssBytes.Length, + detPtr, detBytes.Length, + (byte)(kind == DetectionKind.Linear ? 1 : 0), + cutoffDistanceMeters); + return TpLibFfi.DeserializeOrThrow( + buf, + "PrepareDetections failed (FFI returned an error sentinel).", + ex => ex is null + ? new TpLibDetectionException("PrepareDetections failed (FFI returned an error sentinel).") + : new TpLibDetectionException("PrepareDetections failed (FFI returned an error sentinel).", ex)); + } + } + } + + private static string BuildDetectionsGeoJson(IReadOnlyList records, DetectionKind kind) + { + using var stream = new MemoryStream(); + using (var w = new Utf8JsonWriter(stream)) + { + w.WriteStartObject(); + w.WriteString("type", "FeatureCollection"); + w.WriteStartArray("features"); + foreach (var rec in records) + { + w.WriteStartObject(); + w.WriteString("type", "Feature"); + w.WriteStartObject("properties"); + w.WriteString("kind", kind == DetectionKind.Linear ? "linear" : "punctual"); + + switch (rec.Timestamp) + { + case DetectionTimestamp.Single single when kind == DetectionKind.Punctual: + w.WriteString("timestamp", single.Timestamp); + break; + case DetectionTimestamp.Range range when kind == DetectionKind.Linear: + w.WriteString("t_from", range.From); + w.WriteString("t_to", range.To); + break; + default: + throw new TpLibDetectionException( + $"Detection (row {rec.SourceRow}, kind {kind}) has incompatible Timestamp type."); + } + + if (rec.Id is not null) w.WriteString("id", rec.Id); + if (rec.Source is not null) w.WriteString("source", rec.Source); + + if (kind == DetectionKind.Punctual) + { + if (rec.NetelementId is not null) + { + w.WriteString("netelement_id", rec.NetelementId); + if (rec.Intrinsic.HasValue) w.WriteNumber("intrinsic", rec.Intrinsic.Value); + } + if (rec.Crs is not null) w.WriteString("crs", rec.Crs); + } + else + { + if (rec.NetelementId is null) + { + throw new TpLibDetectionException( + $"Linear detection (row {rec.SourceRow}) requires NetelementId."); + } + w.WriteString("netelement_id", rec.NetelementId); + if (rec.StartIntrinsic.HasValue) w.WriteNumber("start_intrinsic", rec.StartIntrinsic.Value); + if (rec.EndIntrinsic.HasValue) w.WriteNumber("end_intrinsic", rec.EndIntrinsic.Value); + } + + if (rec.Metadata is not null) + { + foreach (var (k, v) in rec.Metadata) + { + // Reserved property names handled above; user metadata uses other keys. + w.WriteString(k, v); + } + } + w.WriteEndObject(); // properties + + if (kind == DetectionKind.Punctual && rec.Latitude.HasValue && rec.Longitude.HasValue) + { + w.WriteStartObject("geometry"); + w.WriteString("type", "Point"); + w.WriteStartArray("coordinates"); + w.WriteNumberValue(rec.Longitude.Value); + w.WriteNumberValue(rec.Latitude.Value); + w.WriteEndArray(); + w.WriteEndObject(); + } + else + { + w.WriteNull("geometry"); + } + w.WriteEndObject(); // feature + } + w.WriteEndArray(); + w.WriteEndObject(); + } + return Encoding.UTF8.GetString(stream.ToArray()); + } +} + +internal static class TpLibFfi +{ + internal static PathConfigFfi ToFfi(PathConfig c) => new() + { + distance_scale = c.DistanceScale, + heading_scale = c.HeadingScale, + cutoff_distance = c.CutoffDistanceMeters, + heading_cutoff = c.HeadingCutoffDegrees, + probability_threshold = c.ProbabilityThreshold, + resampling_distance = c.ResamplingDistanceMeters ?? 0.0, + has_resampling_distance = (byte)(c.ResamplingDistanceMeters.HasValue ? 1 : 0), + max_candidates = (ulong)c.MaxCandidates, + path_only = (byte)(c.PathOnly ? 1 : 0), + debug_mode = 0, + beta = c.Beta, + edge_zone_distance = c.EdgeZoneDistanceMeters, + turn_scale = c.TurnScaleDegrees, + detection_cutoff_distance = c.DetectionCutoffDistanceMeters, + }; + + internal static unsafe T DeserializeOrThrow( + ByteBuffer buf, + string errorMessage, + Func errorFactory) + { + try + { + if (buf.ptr == null || buf.len < 0) + { + throw errorFactory(null); + } + if (buf.len == 0) + { + throw errorFactory(new InvalidOperationException("FFI returned an empty buffer.")); + } + var span = new ReadOnlySpan(buf.ptr, buf.len); + try + { + var value = JsonSerializer.Deserialize(span, TpLibJson.Options); + if (value is null) + { + throw errorFactory(new InvalidOperationException("FFI payload deserialized to null.")); + } + return value; + } + catch (JsonException jex) + { + throw errorFactory(jex); + } + } + finally + { + if (buf.ptr != null) + { + TpLibNative.FreeByteBuffer(buf); + } + } + } +} diff --git a/tp-net/csharp/TpLib.csproj b/tp-net/csharp/TpLib.csproj new file mode 100644 index 0000000..313d915 --- /dev/null +++ b/tp-net/csharp/TpLib.csproj @@ -0,0 +1,67 @@ + + + + net8.0 + TpLib + TpLib + enable + enable + latest + true + true + true + $(NoWarn);CS1591 + + + TpLib + 0.0.1 + Mathias Vanden Auweele + C#/.NET bindings for tp-lib — GNSS projection onto railway networks. + Apache-2.0 + https://github.com/Matdata-eu/tp-lib + https://github.com/Matdata-eu/tp-lib + false + true + + + + + + + + + + + + + + + + + + + + + + + + release + debug + + + + + <_DevNativeWindows Include="..\..\target\$(CargoProfile)\tp_lib_net.dll" Condition="Exists('..\..\target\$(CargoProfile)\tp_lib_net.dll')" /> + <_DevNativeLinux Include="..\..\target\$(CargoProfile)\libtp_lib_net.so" Condition="Exists('..\..\target\$(CargoProfile)\libtp_lib_net.so')" /> + <_DevNativeMac Include="..\..\target\$(CargoProfile)\libtp_lib_net.dylib" Condition="Exists('..\..\target\$(CargoProfile)\libtp_lib_net.dylib')" /> + + + + + + diff --git a/tp-net/csharp/TpLib.targets b/tp-net/csharp/TpLib.targets new file mode 100644 index 0000000..e070afe --- /dev/null +++ b/tp-net/csharp/TpLib.targets @@ -0,0 +1,19 @@ + + + + $(MSBuildThisFileDirectory)..\ + + + + + %(Filename)%(Extension) + PreserveNewest + false + + + diff --git a/tp-net/csharp/TpLibNative.cs b/tp-net/csharp/TpLibNative.cs new file mode 100644 index 0000000..ab50123 --- /dev/null +++ b/tp-net/csharp/TpLibNative.cs @@ -0,0 +1,83 @@ +using System.Reflection; +using System.Runtime.InteropServices; + +namespace TpLib; + +internal static class TpLibNative +{ + internal const string LibName = "tp_lib_net"; + + static TpLibNative() + { + NativeLibrary.SetDllImportResolver(typeof(TpLibNative).Assembly, Resolve); + } + + /// Ensures the static constructor (and resolver registration) has run. + internal static void EnsureInitialized() + { + // no-op; touching the type triggers the static ctor. + } + + internal static unsafe void FreeByteBuffer(ByteBuffer buf) + { + NativeMethods.tp_net_free_byte_buffer(buf); + } + + private static IntPtr Resolve(string libraryName, Assembly assembly, DllImportSearchPath? searchPath) + { + if (libraryName != LibName) + { + return IntPtr.Zero; + } + + var rid = GetRuntimeIdentifier(); + var fileName = GetNativeFileName(libraryName); + var asmDir = Path.GetDirectoryName(assembly.Location) ?? AppContext.BaseDirectory; + + // Candidate paths (in order): + // 1) runtimes/{rid}/native/{file} (NuGet layout) + // 2) {asmDir}/{file} (loose dev/test layout) + // 3) {AppContext.BaseDirectory}/{file} + var candidates = new[] + { + Path.Combine(asmDir, "runtimes", rid, "native", fileName), + Path.Combine(asmDir, fileName), + Path.Combine(AppContext.BaseDirectory, fileName), + }; + + foreach (var path in candidates) + { + if (File.Exists(path) && NativeLibrary.TryLoad(path, out var handle)) + { + return handle; + } + } + + // Fall back to default OS search. + return NativeLibrary.TryLoad(fileName, assembly, searchPath, out var fallback) + ? fallback + : IntPtr.Zero; + } + + private static string GetRuntimeIdentifier() + { + var arch = RuntimeInformation.ProcessArchitecture switch + { + Architecture.X64 => "x64", + Architecture.Arm64 => "arm64", + Architecture.X86 => "x86", + _ => "x64", + }; + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) return $"win-{arch}"; + if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) return $"linux-{arch}"; + if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) return $"osx-{arch}"; + return $"unknown-{arch}"; + } + + private static string GetNativeFileName(string baseName) + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) return $"{baseName}.dll"; + if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) return $"lib{baseName}.dylib"; + return $"lib{baseName}.so"; + } +} diff --git a/tp-net/src/ffi.rs b/tp-net/src/ffi.rs new file mode 100644 index 0000000..c4a6336 --- /dev/null +++ b/tp-net/src/ffi.rs @@ -0,0 +1,115 @@ +//! FFI primitives: heap byte buffers and flat `#[repr(C)]` config structs. +//! +//! All data crossing the boundary is either a raw byte slice (JSON text) or a +//! flat config struct. Strings/collections are never passed in `#[repr(C)]`. + +/// Heap-allocated byte buffer owned by the Rust side; freed by the caller via +/// [`tp_net_free_byte_buffer`]. +/// +/// `len == cap` after allocation. A null `ptr` paired with `len == -1` +/// signals an FFI error (the C# layer raises a generic exception). +#[repr(C)] +pub struct ByteBuffer { + pub ptr: *mut u8, + pub len: i32, + pub cap: i32, +} + +impl ByteBuffer { + pub(crate) fn from_vec(mut v: Vec) -> Self { + v.shrink_to_fit(); + let len = v.len() as i32; + let cap = v.capacity() as i32; + let ptr = v.as_mut_ptr(); + std::mem::forget(v); + Self { ptr, len, cap } + } + + pub(crate) fn null_error() -> Self { + Self { + ptr: std::ptr::null_mut(), + len: -1, + cap: 0, + } + } +} + +/// Free a [`ByteBuffer`] previously returned by any `tp_net_*` function. +/// +/// # Safety +/// The buffer must have been produced by this library and not yet freed. +#[no_mangle] +pub unsafe extern "C" fn tp_net_free_byte_buffer(buf: ByteBuffer) { + if !buf.ptr.is_null() && buf.cap > 0 { + let _ = Vec::from_raw_parts(buf.ptr, buf.len.max(0) as usize, buf.cap as usize); + } +} + +/// Flat mirror of `tp_lib_core::ProjectionConfig` for `#[repr(C)]` transport. +/// +/// `max_search_radius_meters` is reserved for the public contract but is not +/// currently consumed by `tp-lib-core`. +#[repr(C)] +#[derive(Debug, Clone, Copy)] +pub struct ProjectionConfigFfi { + pub max_search_radius_meters: f64, + pub projection_distance_warning_threshold: f64, + pub suppress_warnings: u8, +} + +impl From for tp_lib_core::ProjectionConfig { + fn from(c: ProjectionConfigFfi) -> Self { + tp_lib_core::ProjectionConfig { + projection_distance_warning_threshold: c.projection_distance_warning_threshold, + suppress_warnings: c.suppress_warnings != 0, + } + } +} + +/// Flat mirror of `tp_lib_core::PathConfig` for `#[repr(C)]` transport. +/// +/// `anchors` are not transmitted via this struct; they arrive on the +/// `PreparedDetections` JSON payload of `tp_net_calculate_train_path`. +#[repr(C)] +#[derive(Debug, Clone, Copy)] +pub struct PathConfigFfi { + pub distance_scale: f64, + pub heading_scale: f64, + pub cutoff_distance: f64, + pub heading_cutoff: f64, + pub probability_threshold: f64, + pub resampling_distance: f64, + pub has_resampling_distance: u8, + pub max_candidates: u64, + pub path_only: u8, + pub debug_mode: u8, + pub beta: f64, + pub edge_zone_distance: f64, + pub turn_scale: f64, + pub detection_cutoff_distance: f64, +} + +impl From for tp_lib_core::PathConfig { + fn from(c: PathConfigFfi) -> Self { + tp_lib_core::PathConfig { + distance_scale: c.distance_scale, + heading_scale: c.heading_scale, + cutoff_distance: c.cutoff_distance, + heading_cutoff: c.heading_cutoff, + probability_threshold: c.probability_threshold, + resampling_distance: if c.has_resampling_distance != 0 { + Some(c.resampling_distance) + } else { + None + }, + max_candidates: c.max_candidates as usize, + path_only: c.path_only != 0, + debug_mode: c.debug_mode != 0, + beta: c.beta, + edge_zone_distance: c.edge_zone_distance, + turn_scale: c.turn_scale, + detection_cutoff_distance: c.detection_cutoff_distance, + ..Default::default() + } + } +} diff --git a/tp-net/src/lib.rs b/tp-net/src/lib.rs new file mode 100644 index 0000000..e903123 --- /dev/null +++ b/tp-net/src/lib.rs @@ -0,0 +1,223 @@ +//! C#/.NET bindings for tp-lib-core. +//! +//! All public FFI symbols are declared with `extern "C"` and `#[no_mangle]`. +//! Data crosses the boundary as JSON in heap-allocated byte buffers; configs +//! are flat `#[repr(C)]` structs. + +pub mod ffi; +pub mod marshal; + +use ffi::{ByteBuffer, PathConfigFfi, ProjectionConfigFfi}; +use marshal::{from_json_bytes, to_json_bytes}; +use serde::Deserialize; +use tp_lib_core::{ + calculate_train_path, parse_gnss_csv_str, parse_gnss_geojson_str, parse_network_geojson_str, + prepare_detections_from_loaded, project_gnss, project_onto_path, DetectionKind, PathConfig, + RailwayNetwork, ResolvedAnchor, TrainPath, +}; + +const WGS84: &str = "EPSG:4326"; +const CSV_LAT_COL: &str = "latitude"; +const CSV_LON_COL: &str = "longitude"; +const CSV_TIME_COL: &str = "timestamp"; + +/// Partial mirror of `tp_lib_core::PreparedDetections` used only to recover the +/// `anchors` for path calculation. Remaining fields (records, warnings) are +/// ignored on input. +#[derive(Deserialize)] +struct PreparedDetectionsInput { + #[serde(default)] + anchors: Vec, +} + +unsafe fn load_network( + ptr: *const u8, + len: i32, +) -> Option<( + RailwayNetwork, + Vec, + Vec, +)> { + if ptr.is_null() || len < 0 { + return None; + } + let bytes = std::slice::from_raw_parts(ptr, len as usize); + let text = std::str::from_utf8(bytes).ok()?; + let (netelements, netrelations) = parse_network_geojson_str(text).ok()?; + let net_clone = netelements.clone(); + let network = RailwayNetwork::new(netelements).ok()?; + Some((network, netrelations, net_clone)) +} + +unsafe fn load_gnss(ptr: *const u8, len: i32) -> Option> { + if ptr.is_null() || len < 0 { + return None; + } + let bytes = std::slice::from_raw_parts(ptr, len as usize); + let text = std::str::from_utf8(bytes).ok()?; + if text.trim_start().starts_with('{') { + parse_gnss_geojson_str(text, WGS84).ok() + } else { + parse_gnss_csv_str(text, WGS84, CSV_LAT_COL, CSV_LON_COL, CSV_TIME_COL).ok() + } +} + +/// Project GNSS positions onto the nearest network segments. +/// +/// # Safety +/// All pointers must reference valid UTF-8 byte slices of the indicated length. +#[no_mangle] +pub unsafe extern "C" fn tp_net_project_gnss( + network_ptr: *const u8, + network_len: i32, + gnss_ptr: *const u8, + gnss_len: i32, + config: ProjectionConfigFfi, +) -> ByteBuffer { + let Some((network, _, _)) = load_network(network_ptr, network_len) else { + return ByteBuffer::null_error(); + }; + let Some(gnss) = load_gnss(gnss_ptr, gnss_len) else { + return ByteBuffer::null_error(); + }; + let core_config: tp_lib_core::ProjectionConfig = config.into(); + match project_gnss(&gnss, &network, &core_config) { + Ok(projected) => to_json_bytes(&projected), + Err(_) => ByteBuffer::null_error(), + } +} + +/// Project GNSS positions onto a previously computed train path. +/// +/// # Safety +/// All pointers must reference valid UTF-8 byte slices of the indicated length. +#[no_mangle] +pub unsafe extern "C" fn tp_net_project_onto_path( + network_ptr: *const u8, + network_len: i32, + gnss_ptr: *const u8, + gnss_len: i32, + train_path_ptr: *const u8, + train_path_len: i32, + config: PathConfigFfi, +) -> ByteBuffer { + let Some((_, _, netelements)) = load_network(network_ptr, network_len) else { + return ByteBuffer::null_error(); + }; + let Some(gnss) = load_gnss(gnss_ptr, gnss_len) else { + return ByteBuffer::null_error(); + }; + let Ok(train_path) = from_json_bytes::(train_path_ptr, train_path_len) else { + return ByteBuffer::null_error(); + }; + let core_config: PathConfig = config.into(); + match project_onto_path(&gnss, &train_path, &netelements, &core_config) { + Ok(projected) => to_json_bytes(&projected), + Err(_) => ByteBuffer::null_error(), + } +} + +/// Calculate a train path from GNSS positions and a railway network. +/// +/// `prepared_detections_ptr` may be null (`prepared_detections_len == 0`). +/// When provided, the JSON must include an `anchors` array of [`ResolvedAnchor`]. +/// +/// # Safety +/// All non-null pointers must reference valid UTF-8 byte slices of the +/// indicated length. +#[no_mangle] +pub unsafe extern "C" fn tp_net_calculate_train_path( + network_ptr: *const u8, + network_len: i32, + gnss_ptr: *const u8, + gnss_len: i32, + prepared_detections_ptr: *const u8, + prepared_detections_len: i32, + config: PathConfigFfi, +) -> ByteBuffer { + let Some((_, netrelations, netelements)) = load_network(network_ptr, network_len) else { + return ByteBuffer::null_error(); + }; + let Some(gnss) = load_gnss(gnss_ptr, gnss_len) else { + return ByteBuffer::null_error(); + }; + let mut core_config: PathConfig = config.into(); + if !prepared_detections_ptr.is_null() && prepared_detections_len > 0 { + match from_json_bytes::( + prepared_detections_ptr, + prepared_detections_len, + ) { + Ok(pd) => core_config.anchors = pd.anchors, + Err(_) => return ByteBuffer::null_error(), + } + } + match calculate_train_path(&gnss, &netelements, &netrelations, &core_config) { + Ok(result) => to_json_bytes(&result), + Err(_) => ByteBuffer::null_error(), + } +} + +/// Validate, time-filter and resolve detections into [`ResolvedAnchor`]s for +/// path calculation. +/// +/// `kind_is_linear == 0` ⇒ `Punctual`; non-zero ⇒ `Linear`. +/// +/// # Safety +/// All pointers must reference valid UTF-8 byte slices of the indicated length. +#[no_mangle] +pub unsafe extern "C" fn tp_net_prepare_detections( + network_ptr: *const u8, + network_len: i32, + gnss_ptr: *const u8, + gnss_len: i32, + detections_geojson_ptr: *const u8, + detections_geojson_len: i32, + kind_is_linear: u8, + cutoff_distance_meters: f64, +) -> ByteBuffer { + let Some((_, _, netelements)) = load_network(network_ptr, network_len) else { + return ByteBuffer::null_error(); + }; + let Some(gnss) = load_gnss(gnss_ptr, gnss_len) else { + return ByteBuffer::null_error(); + }; + if detections_geojson_ptr.is_null() || detections_geojson_len < 0 { + return ByteBuffer::null_error(); + } + let det_bytes = + std::slice::from_raw_parts(detections_geojson_ptr, detections_geojson_len as usize); + let Ok(det_text) = std::str::from_utf8(det_bytes) else { + return ByteBuffer::null_error(); + }; + let kind = if kind_is_linear != 0 { + DetectionKind::Linear + } else { + DetectionKind::Punctual + }; + let detections = + match tp_lib_core::io::geojson::detections::load_str(det_text, "", kind) { + Ok(d) => d, + Err(_) => return ByteBuffer::null_error(), + }; + let prepared = match prepare_detections_from_loaded( + detections, + &gnss, + &netelements, + cutoff_distance_meters, + ) { + Ok(p) => p, + Err(_) => return ByteBuffer::null_error(), + }; + #[derive(serde::Serialize)] + struct PreparedDetectionsDto<'a> { + anchors: &'a [ResolvedAnchor], + records: &'a [tp_lib_core::DetectionRecord], + warnings: &'a [String], + } + let dto = PreparedDetectionsDto { + anchors: &prepared.anchors, + records: &prepared.records, + warnings: &prepared.warnings, + }; + to_json_bytes(&dto) +} diff --git a/tp-net/src/marshal.rs b/tp-net/src/marshal.rs new file mode 100644 index 0000000..ce39410 --- /dev/null +++ b/tp-net/src/marshal.rs @@ -0,0 +1,30 @@ +//! JSON (de)serialization helpers for crossing the FFI boundary. + +use crate::ffi::ByteBuffer; +use serde::{Deserialize, Serialize}; + +/// Serialize a value to JSON and return an owned [`ByteBuffer`]. Serialization +/// failures return an error-sentinel buffer (null ptr, len == -1). +pub(crate) fn to_json_bytes(val: &T) -> ByteBuffer { + match serde_json::to_vec(val) { + Ok(v) => ByteBuffer::from_vec(v), + Err(_) => ByteBuffer::null_error(), + } +} + +/// Borrow a JSON byte slice from a raw pointer and deserialize into `T`. +/// +/// # Safety +/// `ptr` must be non-null and reference at least `len` valid bytes. +pub(crate) unsafe fn from_json_bytes<'a, T: Deserialize<'a>>( + ptr: *const u8, + len: i32, +) -> Result { + if ptr.is_null() || len < 0 { + return Err(::custom( + "null pointer or negative length", + )); + } + let slice = std::slice::from_raw_parts(ptr, len as usize); + serde_json::from_slice(slice) +}