Skip to main content

gridish/
osbg.rs

1use std::{fmt::Display, str::FromStr};
2
3use geo_types::{LineString, Point, Polygon};
4
5use crate::{
6    ParseError,
7    constants::*,
8    error::OutOfBoundsError,
9    grid::{GRID, coords_to_grid, grid_to_coords},
10    grid_reference::GridReference,
11    resolution::Resolution,
12};
13
14// The 500km grid's offset from the true origin.
15const OFFSET_EAST: u32 = _500KM * 2;
16const OFFSET_NORTH: u32 = _500KM;
17
18// The bounds for eastings and northings
19const BOUNDS_EAST: u32 = (_500KM * 5) - OFFSET_EAST;
20const BOUNDS_NORTH: u32 = (_500KM * 5) - OFFSET_NORTH;
21
22/// Type representing a valid British National Grid Reference.
23/// Can be instantiated either by parsing from a string or through
24/// a valid set of eastings and northings as coordinates.
25///
26/// Provides functionality to convert between strings and coordinates,
27/// as well as re-mapping to a new precision.
28// Is primarily a wrapper over Point, but with additional logic to
29// handle 500Km squares and their false origin.
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct OSGB {
32    point: crate::grid_reference::GridReference,
33    square_500k_east: u32,
34    square_500k_north: u32,
35}
36
37impl OSGB {
38    /// Creates a new grid reference from the given coordinates
39    /// and precision.
40    ///
41    /// # Errors
42    /// Returns an error if the given coordinates are out of bounds.
43    ///
44    /// # Example
45    /// ```
46    /// use gridish::{OSGB, Resolution};
47    ///
48    /// let gridref = OSGB::new(
49    ///     389_200,
50    ///     243_700,
51    ///     Resolution::_100m
52    /// ).unwrap();
53    ///
54    /// assert_eq!(gridref.to_string(), "SO892437".to_string());
55    /// ```
56    pub fn new(
57        eastings: u32,
58        northings: u32,
59        resolution: Resolution,
60    ) -> Result<Self, OutOfBoundsError> {
61        match (eastings >= BOUNDS_EAST, northings >= BOUNDS_NORTH) {
62            (true, true) => Err(OutOfBoundsError::Both),
63            (true, false) => Err(OutOfBoundsError::Eastings),
64            (false, true) => Err(OutOfBoundsError::Northings),
65            (false, false) => {
66                let square_500k_east = (eastings + OFFSET_EAST) / _500KM;
67                let square_500k_north = (northings + OFFSET_NORTH) / _500KM;
68                let eastings = eastings % _500KM;
69                let northings = northings % _500KM;
70
71                Ok(Self {
72                    point: GridReference::new(eastings, northings, resolution)?,
73                    square_500k_east,
74                    square_500k_north,
75                })
76            }
77        }
78    }
79
80    pub fn eastings(&self) -> u32 {
81        let east_500k = (self.square_500k_east * _500KM) - OFFSET_EAST;
82
83        east_500k + self.point.eastings()
84    }
85
86    pub fn northings(&self) -> u32 {
87        let north_500k = (self.square_500k_north * _500KM) - OFFSET_NORTH;
88
89        north_500k + self.point.northings()
90    }
91
92    pub fn resolution(&self) -> Resolution {
93        self.point.resolution()
94    }
95
96    /// Recalculates the grid reference to a new resolution.
97    ///
98    /// # Example
99    /// ```
100    /// use gridish::{OSGB, Resolution};
101    ///
102    /// let gridref_100m: OSGB = "SO892437".parse().unwrap();
103    /// let gridref_10k = gridref_100m.recalculate(Resolution::_10km);
104    ///
105    /// assert_eq!("SO84".to_string(), gridref_10k.to_string());
106    /// ```
107    pub fn recalculate(&self, resolution: Resolution) -> Self {
108        if resolution.metres() <= self.point.resolution().metres() {
109            self.clone()
110        } else {
111            Self {
112                square_500k_east: self.square_500k_east,
113                square_500k_north: self.square_500k_north,
114                point: self.point.recalculate(resolution),
115            }
116        }
117    }
118
119    /// Returns the point at the Grid Reference's South West corner - its origin.
120    ///
121    /// # Example
122    /// ```
123    /// use gridish::OSGB;
124    /// use geo_types::coord;
125    ///
126    /// let gridref: OSGB = "SO892437".parse().unwrap();
127    ///
128    /// assert_eq!(gridref.south_west(), coord! {x: 389_200.0, y: 243_700.0 }.into());
129    /// ```
130    pub fn south_west(&self) -> Point {
131        Point::new(self.eastings() as f64, self.northings() as f64)
132    }
133
134    /// Returns the point at the Grid Reference's North West corner.
135    ///
136    /// # Example
137    /// ```
138    /// use gridish::OSGB;
139    /// use geo_types::coord;
140    ///
141    /// let gridref: OSGB = "SO892437".parse().unwrap();
142    ///
143    /// assert_eq!(gridref.north_west(), coord! {x: 389_200.0, y: 243_800.0 }.into());
144    /// ```
145    pub fn north_west(&self) -> Point {
146        Point::new(
147            self.eastings() as f64,
148            (self.northings() + self.point.resolution().metres()) as f64,
149        )
150    }
151
152    /// Returns the point at the Grid Reference's North East corner.
153    ///
154    /// # Example
155    /// ```
156    /// use gridish::OSGB;
157    /// use geo_types::coord;
158    ///
159    /// let gridref: OSGB = "SO892437".parse().unwrap();
160    ///
161    /// assert_eq!(gridref.north_east(), coord! {x: 389_300.0, y: 243_800.0 }.into());
162    /// ```
163    pub fn north_east(&self) -> Point {
164        Point::new(
165            (self.eastings() + self.point.resolution().metres()) as f64,
166            (self.northings() + self.point.resolution().metres()) as f64,
167        )
168    }
169
170    /// Returns the point at the Grid Reference's South East corner.
171    ///
172    /// # Example
173    /// ```
174    /// use gridish::OSGB;
175    /// use geo_types::coord;
176    ///
177    /// let gridref: OSGB = "SO892437".parse().unwrap();
178    ///
179    /// assert_eq!(gridref.south_east(), coord! {x: 389_300.0, y: 243_700.0 }.into());
180    /// ```
181    pub fn south_east(&self) -> Point {
182        Point::new(
183            (self.eastings() + self.point.resolution().metres()) as f64,
184            self.northings() as f64,
185        )
186    }
187
188    /// Returns the point at the Grid Reference's centre.
189    ///
190    /// # Example
191    /// ```
192    /// use gridish::OSGB;
193    /// use geo_types::coord;
194    ///
195    /// let gridref: OSGB = "SO892437".parse().unwrap();
196    ///
197    /// assert_eq!(gridref.centre(), coord! {x: 389_250.0, y: 243_750.0 }.into());
198    /// ```
199    pub fn centre(&self) -> Point {
200        Point::new(
201            self.eastings() as f64 + (self.point.resolution().metres() as f64 / 2.0),
202            self.northings() as f64 + (self.point.resolution().metres() as f64 / 2.0),
203        )
204    }
205
206    /// Returns the Grid Reference's perimeter.
207    ///
208    /// # Example
209    /// ```
210    /// use gridish::OSGB;
211    /// use geo_types::{LineString, Point, Polygon};
212    ///
213    /// let gridref: OSGB = "SO892437".parse().unwrap();
214    ///
215    /// assert_eq!(
216    ///     gridref.perimeter(),
217    ///     Polygon::new(
218    ///         LineString::from(
219    ///             vec![
220    ///                 Point::new(389_200.0, 243_700.0),
221    ///                 Point::new(389_200.0, 243_800.0),
222    ///                 Point::new(389_300.0, 243_800.0),
223    ///                 Point::new(389_300.0, 243_700.0)
224    ///             ]
225    ///         ),
226    ///         vec![]
227    ///     )
228    /// );
229    /// ```
230    pub fn perimeter(&self) -> Polygon {
231        Polygon::new(
232            LineString::from(vec![
233                self.south_west(),
234                self.north_west(),
235                self.north_east(),
236                self.south_east(),
237            ]),
238            vec![],
239        )
240    }
241}
242
243impl FromStr for OSGB {
244    type Err = ParseError;
245
246    fn from_str(s: &str) -> Result<Self, Self::Err> {
247        match s.chars().next() {
248            Some(c) => {
249                let (east, north) = grid_to_coords(&c, &GRID)?;
250                match (
251                    (east as u32 * _500KM) < OFFSET_EAST,
252                    (north as u32 * _500KM) < OFFSET_NORTH,
253                ) {
254                    (true, true) => Err(ParseError::OutOfBounds(OutOfBoundsError::Both)),
255                    (true, false) => Err(ParseError::OutOfBounds(OutOfBoundsError::Eastings)),
256                    (false, true) => Err(ParseError::OutOfBounds(OutOfBoundsError::Northings)),
257                    (false, false) => Ok(Self {
258                        point: GridReference::from_string_rep(&s[1..])?,
259                        square_500k_east: east as u32,
260                        square_500k_north: north as u32,
261                    }),
262                }
263            }
264            None => Err(ParseError::InvalidString("Found empty string".to_string())),
265        }
266    }
267}
268
269impl Display for OSGB {
270    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
271        let square = coords_to_grid(
272            self.square_500k_east as usize,
273            self.square_500k_north as usize,
274            &GRID,
275        );
276
277        write!(f, "{}{}", square, self.point.to_string_rep())
278    }
279}
280
281#[cfg(feature = "serde")]
282mod serde {
283    use crate::OSGB;
284    use serde::{de, ser};
285    use std::fmt;
286
287    impl ser::Serialize for OSGB {
288        fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
289        where
290            S: ser::Serializer,
291        {
292            serializer.serialize_str(&self.to_string())
293        }
294    }
295
296    struct OSGBVisitor;
297
298    impl<'de> de::Visitor<'de> for OSGBVisitor {
299        type Value = OSGB;
300
301        fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
302            formatter.write_str("a formatted grid ref string")
303        }
304
305        fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
306        where
307            E: de::Error,
308        {
309            value.parse().map_err(E::custom)
310        }
311    }
312
313    impl<'de> de::Deserialize<'de> for OSGB {
314        fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
315        where
316            D: de::Deserializer<'de>,
317        {
318            deserializer.deserialize_str(OSGBVisitor)
319        }
320    }
321}
322
323#[cfg(test)]
324mod tests {
325    use super::*;
326
327    #[test]
328    fn points_print() {
329        let tests = vec![
330            (Resolution::_100km, "TL"),
331            #[cfg(feature = "quadrants")]
332            (Resolution::_50km, "TLSW"),
333            (Resolution::_10km, "TL03"),
334            #[cfg(feature = "quadrants")]
335            (Resolution::_5km, "TL03NW"),
336            #[cfg(feature = "tetrads")]
337            (Resolution::_2km, "TL03P"),
338            (Resolution::_1km, "TL0438"),
339            #[cfg(feature = "quadrants")]
340            (Resolution::_500m, "TL0438SE"),
341            (Resolution::_100m, "TL048380"),
342            #[cfg(feature = "quadrants")]
343            (Resolution::_50m, "TL048380SE"),
344            (Resolution::_10m, "TL04863802"),
345            #[cfg(feature = "quadrants")]
346            (Resolution::_5m, "TL04863802SE"),
347            (Resolution::_1m, "TL0486638023"),
348        ];
349        for test in tests {
350            let point = OSGB::new(504866, 238023, test.0).unwrap();
351
352            assert_eq!(&point.to_string(), test.1);
353        }
354    }
355
356    #[test]
357    fn strings_parse() {
358        let tests = vec![
359            ("TL", (Resolution::_100km, 500000, 200000)),
360            #[cfg(feature = "quadrants")]
361            ("TLSW", (Resolution::_50km, 500000, 200000)),
362            ("TL03", (Resolution::_10km, 500000, 230000)),
363            #[cfg(feature = "quadrants")]
364            ("TL03NW", (Resolution::_5km, 500000, 235000)),
365            #[cfg(feature = "tetrads")]
366            ("TL03P", (Resolution::_2km, 504000, 238000)),
367            ("TL0438", (Resolution::_1km, 504000, 238000)),
368            #[cfg(feature = "quadrants")]
369            ("TL0438SE", (Resolution::_500m, 504500, 238000)),
370            ("TL048380", (Resolution::_100m, 504800, 238000)),
371            #[cfg(feature = "quadrants")]
372            ("TL048380SE", (Resolution::_50m, 504850, 238000)),
373            ("TL04863802", (Resolution::_10m, 504860, 238020)),
374            #[cfg(feature = "quadrants")]
375            ("TL04863802SE", (Resolution::_5m, 504865, 238020)),
376            ("TL0486638023", (Resolution::_1m, 504866, 238023)),
377        ];
378
379        for test in tests {
380            println!("Testing string: {}", test.0);
381            let point = OSGB::from_str(test.0).unwrap();
382
383            assert_eq!(point.resolution(), test.1.0);
384            assert_eq!(point.eastings(), test.1.1);
385            assert_eq!(point.northings(), test.1.2);
386        }
387    }
388
389    #[test]
390    fn points_recalculate() {
391        let point = OSGB::new(504866, 238023, Resolution::_1m).unwrap();
392        let tests = vec![
393            (Resolution::_100km, (500000, 200000)),
394            #[cfg(feature = "quadrants")]
395            (Resolution::_50km, (500000, 200000)),
396            (Resolution::_10km, (500000, 230000)),
397            #[cfg(feature = "quadrants")]
398            (Resolution::_5km, (500000, 235000)),
399            #[cfg(feature = "tetrads")]
400            (Resolution::_2km, (504000, 238000)),
401            (Resolution::_1km, (504000, 238000)),
402            #[cfg(feature = "quadrants")]
403            (Resolution::_500m, (504500, 238000)),
404            (Resolution::_100m, (504800, 238000)),
405            #[cfg(feature = "quadrants")]
406            (Resolution::_50m, (504850, 238000)),
407            (Resolution::_10m, (504860, 238020)),
408            #[cfg(feature = "quadrants")]
409            (Resolution::_5m, (504865, 238020)),
410            (Resolution::_1m, (504866, 238023)),
411        ];
412
413        for test in tests {
414            let new = point.recalculate(test.0);
415            assert_eq!((new.eastings(), new.northings()), test.1);
416        }
417    }
418
419    #[test]
420    fn recalculate_does_not_increase_resolution() {
421        let tests = vec![
422            Resolution::_100km,
423            #[cfg(feature = "quadrants")]
424            Resolution::_50km,
425            Resolution::_10km,
426            #[cfg(feature = "quadrants")]
427            Resolution::_5km,
428            #[cfg(feature = "tetrads")]
429            Resolution::_2km,
430            Resolution::_1km,
431            #[cfg(feature = "quadrants")]
432            Resolution::_500m,
433            Resolution::_100m,
434            #[cfg(feature = "quadrants")]
435            Resolution::_50m,
436            Resolution::_10m,
437            #[cfg(feature = "quadrants")]
438            Resolution::_5m,
439        ];
440
441        for test in tests {
442            let point = OSGB::new(504866, 238023, test).unwrap();
443
444            assert_eq!(
445                point.recalculate(Resolution::_1m).resolution(),
446                point.resolution()
447            );
448        }
449    }
450
451    #[test]
452    #[cfg(not(feature = "tetrads"))]
453    fn tetrads_are_rejected_when_not_enabled() {
454        cfg_select! {
455            feature = "quadrants" => {
456                assert_eq!(
457                    OSGB::from_str("TL03P"),
458                    Err(ParseError::InvalidString("P is not a valid quadrant.".to_string()))
459                );
460            }
461            _ => {
462                assert_eq!(
463                    OSGB::from_str("TL03P"),
464                    Err(ParseError::InvalidString("Extra characters found after digits.".to_string()))
465                );
466            }
467        }
468    }
469
470    #[test]
471    #[cfg(not(feature = "quadrants"))]
472    fn quadrants_are_rejected_when_not_enabled() {
473        cfg_select! {
474            feature = "tetrads" => {
475                assert_eq!(
476                    OSGB::from_str("TL03SW"),
477                    Err(ParseError::InvalidString("SW is not a valid tetrad.".to_string()))
478                );
479            }
480            _ => {
481                assert_eq!(
482                    OSGB::from_str("TL03SW"),
483                    Err(ParseError::InvalidString("Extra characters found after digits.".to_string()))
484                );
485            }
486        }
487    }
488}