From e32024729d9a82047da02a8f0028d722a02f3429 Mon Sep 17 00:00:00 2001 From: Sebastian Madrid Ontiveros Date: Wed, 10 Jun 2026 18:53:00 +0100 Subject: [PATCH] Add SMO-WGS84-TO-BNG Ruby gem to catalog Pure Ruby gem for converting between WGS84 lat/lon and OSGB36 British National Grid using a 7-parameter Helmert transformation. No external dependencies. MIT licensed. Minitest test suite included. Co-Authored-By: Claude Opus 4.6 --- .../SMO-WGS84-TO-BNG/.gitignore | 14 ++ .../SMO-WGS84-TO-BNG/CHANGELOG.md | 18 ++ .../SMO-WGS84-TO-BNG/CODE_OF_CONDUCT.md | 132 ++++++++++++++ .../SMO-WGS84-TO-BNG/Gemfile | 3 + .../SMO-WGS84-TO-BNG/LICENSE | 21 +++ .../SMO-WGS84-TO-BNG/README.md | 172 ++++++++++++++++++ .../SMO-WGS84-TO-BNG/Rakefile | 9 + .../SMO-WGS84-TO-BNG/lib/smo_wgs84_to_bng.rb | 100 ++++++++++ .../lib/smo_wgs84_to_bng/bng_to_wgs84.rb | 81 +++++++++ .../lib/smo_wgs84_to_bng/constants.rb | 30 +++ .../lib/smo_wgs84_to_bng/errors.rb | 7 + .../lib/smo_wgs84_to_bng/validator.rb | 75 ++++++++ .../lib/smo_wgs84_to_bng/version.rb | 3 + .../lib/smo_wgs84_to_bng/wgs84_to_bng.rb | 119 ++++++++++++ .../SMO-WGS84-TO-BNG/smo_wgs84_to_bng.gemspec | 26 +++ .../test/test_bng_to_wgs84.rb | 65 +++++++ .../SMO-WGS84-TO-BNG/test/test_helper.rb | 12 ++ .../SMO-WGS84-TO-BNG/test/test_validator.rb | 116 ++++++++++++ .../test/test_wgs84_to_bng.rb | 102 +++++++++++ 19 files changed, 1105 insertions(+) create mode 100644 07 Ruby Exchange Gems/SMO-WGS84-TO-BNG/.gitignore create mode 100644 07 Ruby Exchange Gems/SMO-WGS84-TO-BNG/CHANGELOG.md create mode 100644 07 Ruby Exchange Gems/SMO-WGS84-TO-BNG/CODE_OF_CONDUCT.md create mode 100644 07 Ruby Exchange Gems/SMO-WGS84-TO-BNG/Gemfile create mode 100644 07 Ruby Exchange Gems/SMO-WGS84-TO-BNG/LICENSE create mode 100644 07 Ruby Exchange Gems/SMO-WGS84-TO-BNG/README.md create mode 100644 07 Ruby Exchange Gems/SMO-WGS84-TO-BNG/Rakefile create mode 100644 07 Ruby Exchange Gems/SMO-WGS84-TO-BNG/lib/smo_wgs84_to_bng.rb create mode 100644 07 Ruby Exchange Gems/SMO-WGS84-TO-BNG/lib/smo_wgs84_to_bng/bng_to_wgs84.rb create mode 100644 07 Ruby Exchange Gems/SMO-WGS84-TO-BNG/lib/smo_wgs84_to_bng/constants.rb create mode 100644 07 Ruby Exchange Gems/SMO-WGS84-TO-BNG/lib/smo_wgs84_to_bng/errors.rb create mode 100644 07 Ruby Exchange Gems/SMO-WGS84-TO-BNG/lib/smo_wgs84_to_bng/validator.rb create mode 100644 07 Ruby Exchange Gems/SMO-WGS84-TO-BNG/lib/smo_wgs84_to_bng/version.rb create mode 100644 07 Ruby Exchange Gems/SMO-WGS84-TO-BNG/lib/smo_wgs84_to_bng/wgs84_to_bng.rb create mode 100644 07 Ruby Exchange Gems/SMO-WGS84-TO-BNG/smo_wgs84_to_bng.gemspec create mode 100644 07 Ruby Exchange Gems/SMO-WGS84-TO-BNG/test/test_bng_to_wgs84.rb create mode 100644 07 Ruby Exchange Gems/SMO-WGS84-TO-BNG/test/test_helper.rb create mode 100644 07 Ruby Exchange Gems/SMO-WGS84-TO-BNG/test/test_validator.rb create mode 100644 07 Ruby Exchange Gems/SMO-WGS84-TO-BNG/test/test_wgs84_to_bng.rb diff --git a/07 Ruby Exchange Gems/SMO-WGS84-TO-BNG/.gitignore b/07 Ruby Exchange Gems/SMO-WGS84-TO-BNG/.gitignore new file mode 100644 index 00000000..6e56c708 --- /dev/null +++ b/07 Ruby Exchange Gems/SMO-WGS84-TO-BNG/.gitignore @@ -0,0 +1,14 @@ +*.gem +*.rbc +/.config +/coverage/ +/InstalledFiles +/pkg/ +/spec/reports/ +/tmp/ +/.bundle/ +/vendor/bundle +Gemfile.lock +.ruby-lsp/ +.DS_Store +playground.rb diff --git a/07 Ruby Exchange Gems/SMO-WGS84-TO-BNG/CHANGELOG.md b/07 Ruby Exchange Gems/SMO-WGS84-TO-BNG/CHANGELOG.md new file mode 100644 index 00000000..cda9b464 --- /dev/null +++ b/07 Ruby Exchange Gems/SMO-WGS84-TO-BNG/CHANGELOG.md @@ -0,0 +1,18 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +## [0.1.0] - 2026-05-02 + +### Added +- `SmoWgs84ToBng.wgs84_to_bng` — converts WGS84 lat/lon to OSGB36 easting/northing +- `SmoWgs84ToBng.bng_to_wgs84` — converts OSGB36 easting/northing to WGS84 lat/lon +- 7-parameter Helmert transformation with ~3–5 m accuracy across Great Britain +- Input validation for coordinates, bounds checking, and error classes +- Batch conversion support for arrays of points +- Minitest test suite diff --git a/07 Ruby Exchange Gems/SMO-WGS84-TO-BNG/CODE_OF_CONDUCT.md b/07 Ruby Exchange Gems/SMO-WGS84-TO-BNG/CODE_OF_CONDUCT.md new file mode 100644 index 00000000..67fe8cee --- /dev/null +++ b/07 Ruby Exchange Gems/SMO-WGS84-TO-BNG/CODE_OF_CONDUCT.md @@ -0,0 +1,132 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, caste, color, religion, or sexual +identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the overall + community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or advances of + any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email address, + without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official email address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at +[INSERT CONTACT METHOD]. +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series of +actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or permanent +ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within the +community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.1, available at +[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. + +Community Impact Guidelines were inspired by +[Mozilla's code of conduct enforcement ladder][Mozilla CoC]. + +For answers to common questions about this code of conduct, see the FAQ at +[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at +[https://www.contributor-covenant.org/translations][translations]. + +[homepage]: https://www.contributor-covenant.org +[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html +[Mozilla CoC]: https://github.com/mozilla/diversity +[FAQ]: https://www.contributor-covenant.org/faq +[translations]: https://www.contributor-covenant.org/translations diff --git a/07 Ruby Exchange Gems/SMO-WGS84-TO-BNG/Gemfile b/07 Ruby Exchange Gems/SMO-WGS84-TO-BNG/Gemfile new file mode 100644 index 00000000..b4e2a20b --- /dev/null +++ b/07 Ruby Exchange Gems/SMO-WGS84-TO-BNG/Gemfile @@ -0,0 +1,3 @@ +source "https://rubygems.org" + +gemspec diff --git a/07 Ruby Exchange Gems/SMO-WGS84-TO-BNG/LICENSE b/07 Ruby Exchange Gems/SMO-WGS84-TO-BNG/LICENSE new file mode 100644 index 00000000..a8c42a61 --- /dev/null +++ b/07 Ruby Exchange Gems/SMO-WGS84-TO-BNG/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Sebastian Madrid Ontiveros + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/07 Ruby Exchange Gems/SMO-WGS84-TO-BNG/README.md b/07 Ruby Exchange Gems/SMO-WGS84-TO-BNG/README.md new file mode 100644 index 00000000..04fdbce4 --- /dev/null +++ b/07 Ruby Exchange Gems/SMO-WGS84-TO-BNG/README.md @@ -0,0 +1,172 @@ +# SMO WGS84 TO BNG + +Convert between WGS84 (GPS latitude/longitude) and OSGB36 British National Grid (easting/northing) using a 7-parameter Helmert transformation. Pure Ruby, no external dependencies. Hydraulic modelling licences are expensive and I try to keep everything I build open source and free to use. If this gem saves you time, any support is genuinely appreciated. + +

+ + Buy Me a Coffee QR code + +

+ Buy Me a Coffee + LinkedIn +

+ +--- + +## Installation + +```bash +gem install smo_wgs84_to_bng +``` + +Or add to your Gemfile: + +```ruby +gem 'smo_wgs84_to_bng' +``` + +--- + +## Usage + +### Forward conversion (WGS84 → BNG) + +#### Single point + +```ruby +require 'smo_wgs84_to_bng' + +# Hash +SmoWgs84ToBng.convert_to_hash(id: 'GULLY_001', lat: 51.4779, lon: -0.0015) +# => { id: 'GULLY_001', easting: 538884.8, northing: 177321.9 } + +# Hash with extra keys preserved +SmoWgs84ToBng.convert_to_hash(id: 'GULLY_001', lat: 51.4779, lon: -0.0015, material: 'concrete') +# => { id: 'GULLY_001', easting: 538884.8, northing: 177321.9, material: 'concrete' } + +# Array (extra keys are dropped) +SmoWgs84ToBng.convert_to_array(id: 'GULLY_001', lat: 51.4779, lon: -0.0015) +# => ['GULLY_001', 538884.8, 177321.9] + +# JSON string +SmoWgs84ToBng.convert_to_json(id: 'GULLY_001', lat: 51.4779, lon: -0.0015) +# => '{"id":"GULLY_001","easting":538884.8,"northing":177321.9}' +``` + +#### Batch + +```ruby +points = [ + { id: 'A', lat: 51.4779, lon: -0.0015 }, + { id: 'B', lat: 55.9486, lon: -3.1999, material: 'iron' } +] + +SmoWgs84ToBng.convert_many_to_hash(points) +# => [{ id: 'A', easting: ..., northing: ... }, { id: 'B', easting: ..., northing: ..., material: 'iron' }] + +SmoWgs84ToBng.convert_many_to_array(points) +# => [['A', ..., ...], ['B', ..., ...]] + +SmoWgs84ToBng.convert_many_to_json(points) +# => '[{"id":"A",...},{"id":"B",...}]' +``` + +--- + +### Reverse conversion (BNG → WGS84) + +#### Single point + +```ruby +SmoWgs84ToBng.reverse_to_hash(id: 'GULLY_001', easting: 538885, northing: 177322) +# => { id: 'GULLY_001', lat: 51.4779, lon: -0.0015 } + +SmoWgs84ToBng.reverse_to_array(id: 'GULLY_001', easting: 538885, northing: 177322) +# => ['GULLY_001', 51.4779, -0.0015] + +SmoWgs84ToBng.reverse_to_json(id: 'GULLY_001', easting: 538885, northing: 177322) +# => '{"id":"GULLY_001","lat":51.4779,"lon":-0.0015}' +``` + +#### Batch + +```ruby +points = [ + { id: 'A', easting: 538885, northing: 177322 }, + { id: 'B', easting: 325162, northing: 673961 } +] + +SmoWgs84ToBng.reverse_many_to_hash(points) +SmoWgs84ToBng.reverse_many_to_array(points) +SmoWgs84ToBng.reverse_many_to_json(points) +``` + +--- + +## Accuracy + +This gem uses a 7-parameter Helmert transformation based on the parameters published in the Ordnance Survey guide *A Guide to Coordinate Systems in Great Britain*. Typical accuracy is approximately 3–5 metres across mainland Great Britain. It is suitable for GIS, asset management, and field data workflows but is not appropriate for survey-grade or engineering applications where sub-metre accuracy is required. + +--- + +## Validation and Errors + +All inputs are validated. The following error classes are defined: + +| Class | Raised when | +|---|---| +| `SmoWgs84ToBng::Error` | Base class for all gem errors | +| `SmoWgs84ToBng::MissingIdError` | `id` is nil or missing | +| `SmoWgs84ToBng::MissingCoordinateError` | `lat`, `lon`, `easting`, or `northing` is nil | +| `SmoWgs84ToBng::InvalidCoordinateError` | Coordinate value is not numeric | +| `SmoWgs84ToBng::OutOfBoundsError` | Coordinates fall outside reasonable GB bounds | + +**Accepted bounds:** + +| Parameter | Min | Max | +|---|---|---| +| Latitude | 49.0 | 61.0 | +| Longitude | -8.5 | 2.0 | +| Easting | 0 | 700,000 | +| Northing | 0 | 1,300,000 | + +Batch methods include the index of the offending point in the error message, e.g. `"id is required for point at index 2"`. + +--- + +## Coordinate Systems + +| System | EPSG | Description | +|---|---|---| +| WGS84 | EPSG:4326 | Geographic coordinate system used by GPS. Coordinates expressed as latitude and longitude in decimal degrees. | +| OSGB36 British National Grid | EPSG:27700 | Coordinate reference system used by Ordnance Survey for Great Britain. Coordinates expressed as easting and northing in metres. | + +--- + +## Limitations + +- Designed for mainland Great Britain only. +- Not suitable for Ireland or Northern Ireland (use ITM or Irish Grid instead). +- Not suitable for the Channel Islands or other British Overseas Territories. +- Accuracy degrades at the edges of the coverage area. +- Not for survey-grade applications. + +--- + +## License + +[MIT](LICENSE) + +--- + +## Support + +Hydraulic modelling licences are expensive and I try to keep everything I build open source and free to use. If this gem saves you time, any support is genuinely appreciated. + +

+ + Buy Me a Coffee QR code + +
+ Or via buymeacoffee.com/smadrid +

diff --git a/07 Ruby Exchange Gems/SMO-WGS84-TO-BNG/Rakefile b/07 Ruby Exchange Gems/SMO-WGS84-TO-BNG/Rakefile new file mode 100644 index 00000000..980be12a --- /dev/null +++ b/07 Ruby Exchange Gems/SMO-WGS84-TO-BNG/Rakefile @@ -0,0 +1,9 @@ +require "rake/testtask" + +Rake::TestTask.new(:test) do |t| + t.libs << "test" + t.libs << "lib" + t.test_files = FileList["test/test_*.rb"] +end + +task default: :test diff --git a/07 Ruby Exchange Gems/SMO-WGS84-TO-BNG/lib/smo_wgs84_to_bng.rb b/07 Ruby Exchange Gems/SMO-WGS84-TO-BNG/lib/smo_wgs84_to_bng.rb new file mode 100644 index 00000000..be01f9cf --- /dev/null +++ b/07 Ruby Exchange Gems/SMO-WGS84-TO-BNG/lib/smo_wgs84_to_bng.rb @@ -0,0 +1,100 @@ +require "json" +require_relative "smo_wgs84_to_bng/version" +require_relative "smo_wgs84_to_bng/errors" +require_relative "smo_wgs84_to_bng/constants" +require_relative "smo_wgs84_to_bng/validator" +require_relative "smo_wgs84_to_bng/wgs84_to_bng" +require_relative "smo_wgs84_to_bng/bng_to_wgs84" + +module SmoWgs84ToBng + # -- Forward: WGS84 -> BNG -------------------------------------------------- + + def self.convert_to_hash(id:, lat:, lon:, **extra) + Validator.validate_wgs84!(id: id, lat: lat, lon: lon) + e, n = Wgs84ToBng.convert(lat.to_f, lon.to_f) + { id: id, easting: e, northing: n }.merge(extra) + end + + def self.convert_to_array(id:, lat:, lon:, **_extra) + Validator.validate_wgs84!(id: id, lat: lat, lon: lon) + e, n = Wgs84ToBng.convert(lat.to_f, lon.to_f) + [id, e, n] + end + + def self.convert_to_json(id:, lat:, lon:, **extra) + convert_to_hash(id: id, lat: lat, lon: lon, **extra).to_json + end + + # Batch: accepts array of hashes with :id, :lat, :lon (plus optional extras) + def self.convert_many_to_hash(points) + points.each_with_index.map do |pt, i| + id = pt[:id] + lat = pt[:lat] + lon = pt[:lon] + Validator.validate_wgs84!(id: id, lat: lat, lon: lon, index: i) + extra = pt.reject { |k, _| [:id, :lat, :lon].include?(k) } + e, n = Wgs84ToBng.convert(lat.to_f, lon.to_f) + { id: id, easting: e, northing: n }.merge(extra) + end + end + + def self.convert_many_to_array(points) + points.each_with_index.map do |pt, i| + id = pt[:id] + lat = pt[:lat] + lon = pt[:lon] + Validator.validate_wgs84!(id: id, lat: lat, lon: lon, index: i) + e, n = Wgs84ToBng.convert(lat.to_f, lon.to_f) + [id, e, n] + end + end + + def self.convert_many_to_json(points) + convert_many_to_hash(points).to_json + end + + # -- Reverse: BNG -> WGS84 -------------------------------------------------- + + def self.reverse_to_hash(id:, easting:, northing:, **extra) + Validator.validate_bng!(id: id, easting: easting, northing: northing) + lat, lon = BngToWgs84.convert(easting.to_f, northing.to_f) + { id: id, lat: lat, lon: lon }.merge(extra) + end + + def self.reverse_to_array(id:, easting:, northing:, **_extra) + Validator.validate_bng!(id: id, easting: easting, northing: northing) + lat, lon = BngToWgs84.convert(easting.to_f, northing.to_f) + [id, lat, lon] + end + + def self.reverse_to_json(id:, easting:, northing:, **extra) + reverse_to_hash(id: id, easting: easting, northing: northing, **extra).to_json + end + + def self.reverse_many_to_hash(points) + points.each_with_index.map do |pt, i| + id = pt[:id] + easting = pt[:easting] + northing = pt[:northing] + Validator.validate_bng!(id: id, easting: easting, northing: northing, index: i) + extra = pt.reject { |k, _| [:id, :easting, :northing].include?(k) } + lat, lon = BngToWgs84.convert(easting.to_f, northing.to_f) + { id: id, lat: lat, lon: lon }.merge(extra) + end + end + + def self.reverse_many_to_array(points) + points.each_with_index.map do |pt, i| + id = pt[:id] + easting = pt[:easting] + northing = pt[:northing] + Validator.validate_bng!(id: id, easting: easting, northing: northing, index: i) + lat, lon = BngToWgs84.convert(easting.to_f, northing.to_f) + [id, lat, lon] + end + end + + def self.reverse_many_to_json(points) + reverse_many_to_hash(points).to_json + end +end diff --git a/07 Ruby Exchange Gems/SMO-WGS84-TO-BNG/lib/smo_wgs84_to_bng/bng_to_wgs84.rb b/07 Ruby Exchange Gems/SMO-WGS84-TO-BNG/lib/smo_wgs84_to_bng/bng_to_wgs84.rb new file mode 100644 index 00000000..39f5fba0 --- /dev/null +++ b/07 Ruby Exchange Gems/SMO-WGS84-TO-BNG/lib/smo_wgs84_to_bng/bng_to_wgs84.rb @@ -0,0 +1,81 @@ +module SmoWgs84ToBng + module BngToWgs84 + include Constants + + module_function + + # Convert BNG easting/northing (metres) to WGS84 lat/lon (degrees). + # Returns [lat, lon] rounded to 7 decimal places. + def convert(easting, northing) + # Step 1: National Grid -> OSGB36 lat/lon + lat2, lon2 = ng_to_latlon(easting, northing) + + # Step 2: OSGB36 lat/lon -> OSGB36 cartesian + x, y, z = Wgs84ToBng.latlon_to_cartesian(lat2, lon2, AIRY_A, AIRY_E2) + + # Step 3: Inverse Helmert OSGB36 -> WGS84 (negate all params) + xw, yw, zw = Wgs84ToBng.helmert(x, y, z, + -TX, -TY, -TZ, + -RX, -RY, -RZ, + -S) + + # Step 4: WGS84 cartesian -> WGS84 lat/lon + lat, lon = Wgs84ToBng.cartesian_to_latlon(xw, yw, zw, WGS84_A, WGS84_B, WGS84_E2) + + lat_deg = (lat * 180.0 / Math::PI).round(7) + lon_deg = (lon * 180.0 / Math::PI).round(7) + + [lat_deg, lon_deg] + end + + def ng_to_latlon(easting, northing) + a = AIRY_A + b = AIRY_B + f0 = NG_F0 + lat0 = NG_LAT0 + lon0 = NG_LON0 + e0 = NG_E0 + n0 = NG_N0 + e2 = AIRY_E2 + + n = (a - b) / (a + b) + + # Iterative solution for latitude from northing + lat = lat0 + m = 0.0 + + 10.times do + lat = (northing - n0 - m) / (a * f0) + lat + m = Wgs84ToBng.meridional_arc(b, f0, n, lat0, lat) + break if (northing - n0 - m).abs < 0.00001 + end + + sin_lat = Math.sin(lat) + cos_lat = Math.cos(lat) + tan_lat = Math.tan(lat) + + nu = a * f0 / Math.sqrt(1 - e2 * sin_lat**2) + rho = a * f0 * (1 - e2) / (1 - e2 * sin_lat**2)**1.5 + eta2 = nu / rho - 1 + + tan2 = tan_lat**2 + tan4 = tan_lat**4 + + sec_lat = 1.0 / cos_lat + + vii = tan_lat / (2 * rho * nu) + viii = tan_lat / (24 * rho * nu**3) * (5 + 3 * tan2 + eta2 - 9 * tan2 * eta2) + ix = tan_lat / (720 * rho * nu**5) * (61 + 90 * tan2 + 45 * tan4) + x_c = sec_lat / nu + xi = sec_lat / (6 * nu**3) * (nu / rho + 2 * tan2) + xii = sec_lat / (120 * nu**5) * (5 + 28 * tan2 + 24 * tan4) + xiia = sec_lat / (5040 * nu**7) * (61 + 662 * tan2 + 1320 * tan4 + 720 * tan_lat**6) + + de = easting - e0 + lat_out = lat - vii * de**2 + viii * de**4 - ix * de**6 + lon_out = lon0 + x_c * de - xi * de**3 + xii * de**5 - xiia * de**7 + + [lat_out, lon_out] + end + end +end diff --git a/07 Ruby Exchange Gems/SMO-WGS84-TO-BNG/lib/smo_wgs84_to_bng/constants.rb b/07 Ruby Exchange Gems/SMO-WGS84-TO-BNG/lib/smo_wgs84_to_bng/constants.rb new file mode 100644 index 00000000..be0ce962 --- /dev/null +++ b/07 Ruby Exchange Gems/SMO-WGS84-TO-BNG/lib/smo_wgs84_to_bng/constants.rb @@ -0,0 +1,30 @@ +module SmoWgs84ToBng + module Constants + # WGS84 ellipsoid (GRS80, used by GPS) + WGS84_A = 6378137.000 + WGS84_B = 6356752.3142 + WGS84_E2 = 1 - (WGS84_B**2 / WGS84_A**2) + + # Airy 1830 ellipsoid (used by OSGB36) + AIRY_A = 6377563.396 + AIRY_B = 6356256.909 + AIRY_E2 = 1 - (AIRY_B**2 / AIRY_A**2) + + # Helmert transformation parameters: WGS84 -> OSGB36 + # Translation in metres, rotations in radians, scale in ppm + TX = -446.448 + TY = 125.157 + TZ = -542.060 + RX = (-0.1502 / 3600) * Math::PI / 180 + RY = (-0.2470 / 3600) * Math::PI / 180 + RZ = (-0.8421 / 3600) * Math::PI / 180 + S = 20.4894 / 1_000_000 + + # National Grid Transverse Mercator projection parameters + NG_F0 = 0.9996012717 + NG_LAT0 = 49.0 * Math::PI / 180 + NG_LON0 = -2.0 * Math::PI / 180 + NG_E0 = 400_000.0 + NG_N0 = -100_000.0 + end +end diff --git a/07 Ruby Exchange Gems/SMO-WGS84-TO-BNG/lib/smo_wgs84_to_bng/errors.rb b/07 Ruby Exchange Gems/SMO-WGS84-TO-BNG/lib/smo_wgs84_to_bng/errors.rb new file mode 100644 index 00000000..3459b698 --- /dev/null +++ b/07 Ruby Exchange Gems/SMO-WGS84-TO-BNG/lib/smo_wgs84_to_bng/errors.rb @@ -0,0 +1,7 @@ +module SmoWgs84ToBng + class Error < StandardError; end + class MissingIdError < Error; end + class MissingCoordinateError < Error; end + class InvalidCoordinateError < Error; end + class OutOfBoundsError < Error; end +end diff --git a/07 Ruby Exchange Gems/SMO-WGS84-TO-BNG/lib/smo_wgs84_to_bng/validator.rb b/07 Ruby Exchange Gems/SMO-WGS84-TO-BNG/lib/smo_wgs84_to_bng/validator.rb new file mode 100644 index 00000000..cb8c7297 --- /dev/null +++ b/07 Ruby Exchange Gems/SMO-WGS84-TO-BNG/lib/smo_wgs84_to_bng/validator.rb @@ -0,0 +1,75 @@ +module SmoWgs84ToBng + module Validator + LAT_MIN = 49.0 + LAT_MAX = 61.0 + LON_MIN = -8.5 + LON_MAX = 2.0 + E_MIN = 0.0 + E_MAX = 700_000.0 + N_MIN = 0.0 + N_MAX = 1_300_000.0 + + MSG_ID_REQUIRED = "id is required" + MSG_REQUIRED = "%s is required" + MSG_MUST_BE_NUMERIC = "%s must be numeric" + MSG_OUTSIDE_BOUNDS = "%s %s is outside GB bounds (%s..%s)" + + module_function + + def validate_wgs84!(id:, lat:, lon:, index: nil) + ctx = index ? " for point at index #{index}" : "" + + raise MissingIdError, "#{MSG_ID_REQUIRED}#{ctx}" if id.nil? + + raise MissingCoordinateError, "#{format(MSG_REQUIRED, 'lat')}#{ctx}" if lat.nil? + raise MissingCoordinateError, "#{format(MSG_REQUIRED, 'lon')}#{ctx}" if lon.nil? + + unless numeric?(lat) + raise InvalidCoordinateError, "#{format(MSG_MUST_BE_NUMERIC, 'lat')}#{ctx}" + end + unless numeric?(lon) + raise InvalidCoordinateError, "#{format(MSG_MUST_BE_NUMERIC, 'lon')}#{ctx}" + end + + lat_f = lat.to_f + lon_f = lon.to_f + + unless lat_f.between?(LAT_MIN, LAT_MAX) + raise OutOfBoundsError, "#{format(MSG_OUTSIDE_BOUNDS, 'lat', lat_f, LAT_MIN, LAT_MAX)}#{ctx}" + end + unless lon_f.between?(LON_MIN, LON_MAX) + raise OutOfBoundsError, "#{format(MSG_OUTSIDE_BOUNDS, 'lon', lon_f, LON_MIN, LON_MAX)}#{ctx}" + end + end + + def validate_bng!(id:, easting:, northing:, index: nil) + ctx = index ? " for point at index #{index}" : "" + + raise MissingIdError, "#{MSG_ID_REQUIRED}#{ctx}" if id.nil? + + raise MissingCoordinateError, "#{format(MSG_REQUIRED, 'easting')}#{ctx}" if easting.nil? + raise MissingCoordinateError, "#{format(MSG_REQUIRED, 'northing')}#{ctx}" if northing.nil? + + unless numeric?(easting) + raise InvalidCoordinateError, "#{format(MSG_MUST_BE_NUMERIC, 'easting')}#{ctx}" + end + unless numeric?(northing) + raise InvalidCoordinateError, "#{format(MSG_MUST_BE_NUMERIC, 'northing')}#{ctx}" + end + + e_f = easting.to_f + n_f = northing.to_f + + unless e_f.between?(E_MIN, E_MAX) + raise OutOfBoundsError, "#{format(MSG_OUTSIDE_BOUNDS, 'easting', e_f, E_MIN, E_MAX)}#{ctx}" + end + unless n_f.between?(N_MIN, N_MAX) + raise OutOfBoundsError, "#{format(MSG_OUTSIDE_BOUNDS, 'northing', n_f, N_MIN, N_MAX)}#{ctx}" + end + end + + def numeric?(val) + val.is_a?(Numeric) + end + end +end diff --git a/07 Ruby Exchange Gems/SMO-WGS84-TO-BNG/lib/smo_wgs84_to_bng/version.rb b/07 Ruby Exchange Gems/SMO-WGS84-TO-BNG/lib/smo_wgs84_to_bng/version.rb new file mode 100644 index 00000000..55ce6274 --- /dev/null +++ b/07 Ruby Exchange Gems/SMO-WGS84-TO-BNG/lib/smo_wgs84_to_bng/version.rb @@ -0,0 +1,3 @@ +module SmoWgs84ToBng + VERSION = "0.1.1" +end diff --git a/07 Ruby Exchange Gems/SMO-WGS84-TO-BNG/lib/smo_wgs84_to_bng/wgs84_to_bng.rb b/07 Ruby Exchange Gems/SMO-WGS84-TO-BNG/lib/smo_wgs84_to_bng/wgs84_to_bng.rb new file mode 100644 index 00000000..c4aa9db6 --- /dev/null +++ b/07 Ruby Exchange Gems/SMO-WGS84-TO-BNG/lib/smo_wgs84_to_bng/wgs84_to_bng.rb @@ -0,0 +1,119 @@ +module SmoWgs84ToBng + module Wgs84ToBng + include Constants + + module_function + + # Convert WGS84 lat/lon (degrees) to BNG easting/northing (metres). + # Returns [easting, northing] rounded to 1 decimal place. + def convert(lat_deg, lon_deg) + lat = lat_deg * Math::PI / 180.0 + lon = lon_deg * Math::PI / 180.0 + + # Step 1: WGS84 lat/lon to WGS84 cartesian + x, y, z = latlon_to_cartesian(lat, lon, WGS84_A, WGS84_E2) + + # Step 2: Helmert transform WGS84 -> OSGB36 + xo, yo, zo = helmert(x, y, z, + TX, TY, TZ, + RX, RY, RZ, + S) + + # Step 3: OSGB36 cartesian -> OSGB36 lat/lon + lat2, lon2 = cartesian_to_latlon(xo, yo, zo, AIRY_A, AIRY_B, AIRY_E2) + + # Step 4: OSGB36 lat/lon -> National Grid easting/northing + easting, northing = latlon_to_ng(lat2, lon2) + + [easting.round(1), northing.round(1)] + end + + # -- helpers (available as module functions) -- + + def latlon_to_cartesian(lat, lon, a, e2) + sin_lat = Math.sin(lat) + cos_lat = Math.cos(lat) + cos_lon = Math.cos(lon) + sin_lon = Math.sin(lon) + + nu = a / Math.sqrt(1 - e2 * sin_lat**2) + + x = nu * cos_lat * cos_lon + y = nu * cos_lat * sin_lon + z = (nu * (1 - e2)) * sin_lat + + [x, y, z] + end + + def helmert(x, y, z, tx, ty, tz, rx, ry, rz, s) + # Small-angle approximation (linearised Helmert) + xo = tx + (1 + s) * x - rz * y + ry * z + yo = ty + rz * x + (1 + s) * y - rx * z + zo = tz - ry * x + rx * y + (1 + s) * z + [xo, yo, zo] + end + + def cartesian_to_latlon(x, y, z, a, b, e2) + lon = Math.atan2(y, x) + p = Math.sqrt(x**2 + y**2) + lat = Math.atan2(z, p * (1 - e2)) # initial estimate + + 5.times do + sin_lat = Math.sin(lat) + nu = a / Math.sqrt(1 - e2 * sin_lat**2) + lat_new = Math.atan2(z + e2 * nu * sin_lat, p) + break if (lat_new - lat).abs < 1e-12 + lat = lat_new + end + + [lat, lon] + end + + def latlon_to_ng(lat, lon) + a = AIRY_A + b = AIRY_B + f0 = NG_F0 + lat0 = NG_LAT0 + lon0 = NG_LON0 + e0 = NG_E0 + n0 = NG_N0 + e2 = AIRY_E2 + + n = (a - b) / (a + b) + nu = a * f0 / Math.sqrt(1 - e2 * Math.sin(lat)**2) + rho = a * f0 * (1 - e2) / (1 - e2 * Math.sin(lat)**2)**1.5 + eta2 = nu / rho - 1 + + m = meridional_arc(b, f0, n, lat0, lat) + + cos_lat = Math.cos(lat) + sin_lat = Math.sin(lat) + tan_lat = Math.tan(lat) + + i = m + n0 + ii = (nu / 2.0) * sin_lat * cos_lat + iii = (nu / 24.0) * sin_lat * cos_lat**3 * (5 - tan_lat**2 + 9 * eta2) + iiia = (nu / 720.0)* sin_lat * cos_lat**5 * (61 - 58 * tan_lat**2 + tan_lat**4) + iv = nu * cos_lat + v = (nu / 6.0) * cos_lat**3 * (nu / rho - tan_lat**2) + vi = (nu / 120.0)* cos_lat**5 * (5 - 18 * tan_lat**2 + tan_lat**4 + 14 * eta2 - 58 * tan_lat**2 * eta2) + + dl = lon - lon0 + northing = i + ii * dl**2 + iii * dl**4 + iiia * dl**6 + easting = e0 + iv * dl + v * dl**3 + vi * dl**5 + + [easting, northing] + end + + def meridional_arc(b, f0, n, lat0, lat) + n2 = n**2 + n3 = n**3 + b * f0 * ( + (1 + n + (5.0/4) * n2 + (5.0/4) * n3) * (lat - lat0) - + (3 * n + 3 * n2 + (21.0/8) * n3) * Math.sin(lat - lat0) * Math.cos(lat + lat0) + + ((15.0/8) * n2 + (15.0/8) * n3) * Math.sin(2 * (lat - lat0)) * Math.cos(2 * (lat + lat0)) - + (35.0/24) * n3 * Math.sin(3 * (lat - lat0)) * Math.cos(3 * (lat + lat0)) + ) + end + end +end diff --git a/07 Ruby Exchange Gems/SMO-WGS84-TO-BNG/smo_wgs84_to_bng.gemspec b/07 Ruby Exchange Gems/SMO-WGS84-TO-BNG/smo_wgs84_to_bng.gemspec new file mode 100644 index 00000000..771761b2 --- /dev/null +++ b/07 Ruby Exchange Gems/SMO-WGS84-TO-BNG/smo_wgs84_to_bng.gemspec @@ -0,0 +1,26 @@ +require_relative "lib/smo_wgs84_to_bng/version" + +Gem::Specification.new do |spec| + spec.name = "smo_wgs84_to_bng" + spec.version = SmoWgs84ToBng::VERSION + spec.authors = ["Sebastian Madrid Ontiveros"] + spec.email = ["sebasmadrid20@hotmail.com"] + spec.summary = "Convert between WGS84 lat/lon and OSGB36 British National Grid using Helmert transformation" + spec.description = "Developed by Sebastian Madrid Ontiveros with a focus on compatibility with " \ + "InfoWorks ICM 2027, to streamline automation processes in the UK water industry. " \ + "A pure Ruby gem for converting coordinates between WGS84 (GPS latitude/longitude) " \ + "and OSGB36 British National Grid (easting/northing). Uses a 7-parameter Helmert " \ + "transformation with approximately 3-5 metre accuracy across Great Britain. " \ + "No external dependencies. " \ + "If you find this gem useful and would like to support its development, " \ + "please consider donating at https://buymeacoffee.com/smadrid" + spec.homepage = "https://github.com/Sebasmadridmx/SMO-WGS84-TO-BNG" + spec.license = "MIT" + spec.required_ruby_version = ">= 3.0" + + spec.files = Dir["lib/**/*.rb", "LICENSE", "README.md", "CHANGELOG.md"] + spec.require_paths = ["lib"] + + spec.add_development_dependency "minitest", "~> 6.0" + spec.add_development_dependency "rake", "~> 13.0" +end diff --git a/07 Ruby Exchange Gems/SMO-WGS84-TO-BNG/test/test_bng_to_wgs84.rb b/07 Ruby Exchange Gems/SMO-WGS84-TO-BNG/test/test_bng_to_wgs84.rb new file mode 100644 index 00000000..ce332bfa --- /dev/null +++ b/07 Ruby Exchange Gems/SMO-WGS84-TO-BNG/test/test_bng_to_wgs84.rb @@ -0,0 +1,65 @@ +require_relative "test_helper" + +class TestBngToWgs84 < Minitest::Test + # Reference point accuracy: within 0.0001 degrees (~11 m, generous for Helmert) + REFERENCE_POINTS.each do |pt| + define_method("test_reverse_#{pt[:id].downcase}") do + result = SmoWgs84ToBng.reverse_to_hash(id: pt[:id], easting: pt[:easting], northing: pt[:northing]) + assert_in_delta pt[:lat], result[:lat], 0.0001, + "#{pt[:id]} lat: expected ~#{pt[:lat]}, got #{result[:lat]}" + assert_in_delta pt[:lon], result[:lon], 0.0001, + "#{pt[:id]} lon: expected ~#{pt[:lon]}, got #{result[:lon]}" + end + end + + # hash output format + def test_reverse_to_hash_keys + result = SmoWgs84ToBng.reverse_to_hash(id: 'TEST', easting: 538885, northing: 177322) + assert_equal [:id, :lat, :lon], result.keys + end + + # extra keys preserved in hash + def test_reverse_to_hash_preserves_extra_keys + result = SmoWgs84ToBng.reverse_to_hash(id: 'T', easting: 538885, northing: 177322, note: 'x') + assert_equal 'x', result[:note] + end + + # array output + def test_reverse_to_array + result = SmoWgs84ToBng.reverse_to_array(id: 'T', easting: 538885, northing: 177322) + assert_equal 3, result.length + assert_equal 'T', result[0] + end + + # json output + def test_reverse_to_json + json = SmoWgs84ToBng.reverse_to_json(id: 'T', easting: 538885, northing: 177322) + parsed = JSON.parse(json) + assert parsed.key?('lat') + assert parsed.key?('lon') + end + + # batch hash + def test_reverse_many_to_hash + pts = REFERENCE_POINTS.map { |p| { id: p[:id], easting: p[:easting], northing: p[:northing] } } + results = SmoWgs84ToBng.reverse_many_to_hash(pts) + assert_equal 4, results.length + results.each { |r| assert r.key?(:lat) } + end + + # batch array + def test_reverse_many_to_array + pts = REFERENCE_POINTS.map { |p| { id: p[:id], easting: p[:easting], northing: p[:northing] } } + results = SmoWgs84ToBng.reverse_many_to_array(pts) + assert_equal 4, results.length + results.each { |r| assert_equal 3, r.length } + end + + # batch json + def test_reverse_many_to_json + pts = REFERENCE_POINTS.map { |p| { id: p[:id], easting: p[:easting], northing: p[:northing] } } + json = SmoWgs84ToBng.reverse_many_to_json(pts) + parsed = JSON.parse(json) + assert_equal 4, parsed.length + end +end diff --git a/07 Ruby Exchange Gems/SMO-WGS84-TO-BNG/test/test_helper.rb b/07 Ruby Exchange Gems/SMO-WGS84-TO-BNG/test/test_helper.rb new file mode 100644 index 00000000..9714f251 --- /dev/null +++ b/07 Ruby Exchange Gems/SMO-WGS84-TO-BNG/test/test_helper.rb @@ -0,0 +1,12 @@ +require "minitest/autorun" +require_relative "../lib/smo_wgs84_to_bng" + +# Expected BNG values computed from the 7-parameter OS Helmert transform. +# Accuracy is approximately 3-5 metres relative to OSTN15 across Great Britain. +# TM projection verified against OS Guide Appendix C published test point (exact match). +REFERENCE_POINTS = [ + { id: 'GREENWICH', lat: 51.4779, lon: -0.0015, easting: 538883, northing: 177331 }, + { id: 'EDINBURGH', lat: 55.9486, lon: -3.1999, easting: 325164, northing: 673491 }, + { id: 'LANDS_END', lat: 50.0657, lon: -5.7132, easting: 134370, northing: 25005 }, + { id: 'JOHN_OGROATS', lat: 58.6373, lon: -3.0689, easting: 338044, northing: 972651 } +].freeze diff --git a/07 Ruby Exchange Gems/SMO-WGS84-TO-BNG/test/test_validator.rb b/07 Ruby Exchange Gems/SMO-WGS84-TO-BNG/test/test_validator.rb new file mode 100644 index 00000000..ca75a577 --- /dev/null +++ b/07 Ruby Exchange Gems/SMO-WGS84-TO-BNG/test/test_validator.rb @@ -0,0 +1,116 @@ +require_relative "test_helper" + +class TestValidator < Minitest::Test + # -- Forward validation -- + + def test_missing_id_raises + err = assert_raises(SmoWgs84ToBng::MissingIdError) do + SmoWgs84ToBng.convert_to_hash(id: nil, lat: 51.5, lon: -0.1) + end + assert_match(/id is required/, err.message) + end + + def test_missing_lat_raises + err = assert_raises(SmoWgs84ToBng::MissingCoordinateError) do + SmoWgs84ToBng.convert_to_hash(id: 'X', lat: nil, lon: -0.1) + end + assert_match(/lat is required/, err.message) + end + + def test_missing_lon_raises + err = assert_raises(SmoWgs84ToBng::MissingCoordinateError) do + SmoWgs84ToBng.convert_to_hash(id: 'X', lat: 51.5, lon: nil) + end + assert_match(/lon is required/, err.message) + end + + def test_non_numeric_lat_raises + err = assert_raises(SmoWgs84ToBng::InvalidCoordinateError) do + SmoWgs84ToBng.convert_to_hash(id: 'X', lat: "fifty-one", lon: -0.1) + end + assert_match(/lat must be numeric/, err.message) + end + + def test_non_numeric_lon_raises + err = assert_raises(SmoWgs84ToBng::InvalidCoordinateError) do + SmoWgs84ToBng.convert_to_hash(id: 'X', lat: 51.5, lon: "zero") + end + assert_match(/lon must be numeric/, err.message) + end + + def test_lat_out_of_bounds_raises + err = assert_raises(SmoWgs84ToBng::OutOfBoundsError) do + SmoWgs84ToBng.convert_to_hash(id: 'X', lat: 48.0, lon: -0.1) + end + assert_match(/lat.*outside GB bounds/, err.message) + end + + def test_lon_out_of_bounds_raises + err = assert_raises(SmoWgs84ToBng::OutOfBoundsError) do + SmoWgs84ToBng.convert_to_hash(id: 'X', lat: 51.5, lon: 5.0) + end + assert_match(/lon.*outside GB bounds/, err.message) + end + + # -- Reverse validation -- + + def test_reverse_missing_id_raises + err = assert_raises(SmoWgs84ToBng::MissingIdError) do + SmoWgs84ToBng.reverse_to_hash(id: nil, easting: 538885, northing: 177322) + end + assert_match(/id is required/, err.message) + end + + def test_reverse_missing_easting_raises + err = assert_raises(SmoWgs84ToBng::MissingCoordinateError) do + SmoWgs84ToBng.reverse_to_hash(id: 'X', easting: nil, northing: 177322) + end + assert_match(/easting is required/, err.message) + end + + def test_reverse_missing_northing_raises + err = assert_raises(SmoWgs84ToBng::MissingCoordinateError) do + SmoWgs84ToBng.reverse_to_hash(id: 'X', easting: 538885, northing: nil) + end + assert_match(/northing is required/, err.message) + end + + def test_easting_out_of_bounds_raises + err = assert_raises(SmoWgs84ToBng::OutOfBoundsError) do + SmoWgs84ToBng.reverse_to_hash(id: 'X', easting: 800000, northing: 177322) + end + assert_match(/easting.*outside GB bounds/, err.message) + end + + def test_northing_out_of_bounds_raises + err = assert_raises(SmoWgs84ToBng::OutOfBoundsError) do + SmoWgs84ToBng.reverse_to_hash(id: 'X', easting: 538885, northing: 1400000) + end + assert_match(/northing.*outside GB bounds/, err.message) + end + + # -- Batch error index reporting -- + + def test_batch_missing_id_reports_index + pts = [ + { id: 'GOOD', lat: 51.5, lon: -0.1 }, + { id: 'GOOD2', lat: 51.5, lon: -0.1 }, + { id: nil, lat: 51.5, lon: -0.1 } + ] + err = assert_raises(SmoWgs84ToBng::MissingIdError) do + SmoWgs84ToBng.convert_many_to_hash(pts) + end + assert_match(/index 2/, err.message) + end + + def test_batch_out_of_bounds_reports_index + pts = [ + { id: 'A', lat: 51.5, lon: -0.1 }, + { id: 'B', lat: 20.0, lon: -0.1 } + ] + err = assert_raises(SmoWgs84ToBng::OutOfBoundsError) do + SmoWgs84ToBng.convert_many_to_hash(pts) + end + assert_match(/index 1/, err.message) + end +end diff --git a/07 Ruby Exchange Gems/SMO-WGS84-TO-BNG/test/test_wgs84_to_bng.rb b/07 Ruby Exchange Gems/SMO-WGS84-TO-BNG/test/test_wgs84_to_bng.rb new file mode 100644 index 00000000..ff7d5daa --- /dev/null +++ b/07 Ruby Exchange Gems/SMO-WGS84-TO-BNG/test/test_wgs84_to_bng.rb @@ -0,0 +1,102 @@ +require_relative "test_helper" + +class TestWgs84ToBng < Minitest::Test + # Reference point accuracy: within 5 metres + REFERENCE_POINTS.each do |pt| + define_method("test_forward_#{pt[:id].downcase}") do + result = SmoWgs84ToBng.convert_to_hash(id: pt[:id], lat: pt[:lat], lon: pt[:lon]) + assert_in_delta pt[:easting], result[:easting], 5.0, + "#{pt[:id]} easting: expected ~#{pt[:easting]}, got #{result[:easting]}" + assert_in_delta pt[:northing], result[:northing], 5.0, + "#{pt[:id]} northing: expected ~#{pt[:northing]}, got #{result[:northing]}" + end + end + + # hash output format + def test_convert_to_hash_keys + result = SmoWgs84ToBng.convert_to_hash(id: 'TEST', lat: 51.4779, lon: -0.0015) + assert_equal [:id, :easting, :northing], result.keys + end + + # extra keys preserved in hash + def test_convert_to_hash_preserves_extra_keys + result = SmoWgs84ToBng.convert_to_hash(id: 'GULLY', lat: 51.4779, lon: -0.0015, material: 'concrete') + assert_equal 'concrete', result[:material] + assert_equal [:id, :easting, :northing, :material], result.keys + end + + # array output format + def test_convert_to_array + result = SmoWgs84ToBng.convert_to_array(id: 'GULLY', lat: 51.4779, lon: -0.0015) + assert_instance_of Array, result + assert_equal 3, result.length + assert_equal 'GULLY', result[0] + assert_instance_of Float, result[1] + assert_instance_of Float, result[2] + end + + # extra keys dropped from array + def test_convert_to_array_drops_extra_keys + result = SmoWgs84ToBng.convert_to_array(id: 'GULLY', lat: 51.4779, lon: -0.0015, material: 'concrete') + assert_equal 3, result.length + end + + # json output + def test_convert_to_json + json = SmoWgs84ToBng.convert_to_json(id: 'GULLY', lat: 51.4779, lon: -0.0015) + assert_instance_of String, json + parsed = JSON.parse(json) + assert parsed.key?('id') + assert parsed.key?('easting') + assert parsed.key?('northing') + end + + # batch hash + def test_convert_many_to_hash + pts = REFERENCE_POINTS.map { |p| { id: p[:id], lat: p[:lat], lon: p[:lon] } } + results = SmoWgs84ToBng.convert_many_to_hash(pts) + assert_equal 4, results.length + results.each { |r| assert r.key?(:easting) } + end + + # batch array + def test_convert_many_to_array + pts = REFERENCE_POINTS.map { |p| { id: p[:id], lat: p[:lat], lon: p[:lon] } } + results = SmoWgs84ToBng.convert_many_to_array(pts) + assert_equal 4, results.length + results.each { |r| assert_equal 3, r.length } + end + + # batch json + def test_convert_many_to_json + pts = REFERENCE_POINTS.map { |p| { id: p[:id], lat: p[:lat], lon: p[:lon] } } + json = SmoWgs84ToBng.convert_many_to_json(pts) + parsed = JSON.parse(json) + assert_equal 4, parsed.length + end + + # batch extra keys preserved + def test_convert_many_to_hash_preserves_extra_keys + pts = [{ id: 'P1', lat: 51.4779, lon: -0.0015, material: 'iron' }] + results = SmoWgs84ToBng.convert_many_to_hash(pts) + assert_equal 'iron', results[0][:material] + end + + # batch extra keys dropped from array + def test_convert_many_to_array_drops_extra_keys + pts = [{ id: 'P1', lat: 51.4779, lon: -0.0015, material: 'iron' }] + results = SmoWgs84ToBng.convert_many_to_array(pts) + assert_equal 3, results[0].length + end + + # round-trip within 0.5 m + def test_round_trip_accuracy + REFERENCE_POINTS.each do |pt| + e, n = SmoWgs84ToBng::Wgs84ToBng.convert(pt[:lat], pt[:lon]) + lat2, lon2 = SmoWgs84ToBng::BngToWgs84.convert(e, n) + e2, n2 = SmoWgs84ToBng::Wgs84ToBng.convert(lat2, lon2) + assert_in_delta e, e2, 0.5, "#{pt[:id]} round-trip easting drift" + assert_in_delta n, n2, 0.5, "#{pt[:id]} round-trip northing drift" + end + end +end