|
| 1 | +defmodule Transport.IRVE.CoordinateCorrection do |
| 2 | + @moduledoc """ |
| 3 | + Detects and corrects lon/lat coordinate inversions in IRVE data. |
| 4 | +
|
| 5 | + Some producers submit `coordonneesXY` as `[latitude, longitude]` instead of the |
| 6 | + schema-required `[longitude, latitude]`. Detection is based on a bounding box for |
| 7 | + metropolitan France + Corsica (lon ∈ [-5.5, 9.7], lat ∈ [41.0, 51.5]); see `inverted?/2`. |
| 8 | + """ |
| 9 | + |
| 10 | + @metro_lon_min -5.5 |
| 11 | + @metro_lon_max 9.7 |
| 12 | + @metro_lat_min 41.0 |
| 13 | + @metro_lat_max 51.5 |
| 14 | + |
| 15 | + require Explorer.DataFrame, as: DF |
| 16 | + alias Explorer.Series |
| 17 | + |
| 18 | + @doc """ |
| 19 | + Corrects inverted coordinates in a DataFrame with `longitude` and `latitude` float |
| 20 | + columns. Adds `consolidated_is_lon_lat_correct` (`false` = row was swapped). |
| 21 | +
|
| 22 | + iex> df = Explorer.DataFrame.new(longitude: [2.35, 48.85, 55.4], latitude: [48.85, 2.35, -21.1]) |
| 23 | + iex> r = Transport.IRVE.CoordinateCorrection.detect_and_correct(df) |
| 24 | + iex> Explorer.Series.to_list(r["longitude"]) |
| 25 | + [2.35, 2.35, 55.4] |
| 26 | + iex> Explorer.Series.to_list(r["latitude"]) |
| 27 | + [48.85, 48.85, -21.1] |
| 28 | + iex> Explorer.Series.to_list(r["consolidated_is_lon_lat_correct"]) |
| 29 | + [true, false, true] |
| 30 | +
|
| 31 | + """ |
| 32 | + def detect_and_correct(%DF{} = df) do |
| 33 | + DF.mutate_with(df, fn df -> |
| 34 | + lon = df["longitude"] |
| 35 | + lat = df["latitude"] |
| 36 | + inverted = inverted?(lon, lat) |
| 37 | + |
| 38 | + %{ |
| 39 | + longitude: Series.select(inverted, lat, lon), |
| 40 | + latitude: Series.select(inverted, lon, lat), |
| 41 | + consolidated_is_lon_lat_correct: Series.not(inverted) |
| 42 | + } |
| 43 | + end) |
| 44 | + end |
| 45 | + |
| 46 | + @doc """ |
| 47 | + Returns a boolean Series: `true` where `lon_series` ∈ [#{@metro_lat_min}, #{@metro_lat_max}] |
| 48 | + AND `lat_series` ∈ [#{@metro_lon_min}, #{@metro_lon_max}]. |
| 49 | +
|
| 50 | + Test cases: |
| 51 | +
|
| 52 | + 1. Metro France, correct — lon 2.35 (Paris), not in lat range → `false` |
| 53 | + 2. Metro France, inverted — lon 48.85 is in [41, 51.5] and lat 2.35 is in [-5.5, 9.7] → `true` |
| 54 | + 3. Mayotte, correct — lon 45.1 falls in the lat range [41, 51.5], but lat −12.8 is |
| 55 | + below −5.5, so both conditions are not met → `false` |
| 56 | + 4. Corsica, inverted — edge case: lat 9.56 is just above the 9.5 ceiling one might |
| 57 | + naively use; the 9.7 upper bound is required to catch it → `true` |
| 58 | +
|
| 59 | + iex> lon = Explorer.Series.from_list([2.35, 48.85, 45.1, 42.4]) |
| 60 | + iex> lat = Explorer.Series.from_list([48.85, 2.35, -12.8, 9.56]) |
| 61 | + iex> Explorer.Series.to_list(Transport.IRVE.CoordinateCorrection.inverted?(lon, lat)) |
| 62 | + [false, true, false, true] |
| 63 | +
|
| 64 | + """ |
| 65 | + def inverted?(lon_series, lat_series) do |
| 66 | + lon_in_lat_range = |
| 67 | + Series.and( |
| 68 | + Series.greater_equal(lon_series, @metro_lat_min), |
| 69 | + Series.less_equal(lon_series, @metro_lat_max) |
| 70 | + ) |
| 71 | + |
| 72 | + lat_in_lon_range = |
| 73 | + Series.and( |
| 74 | + Series.greater_equal(lat_series, @metro_lon_min), |
| 75 | + Series.less_equal(lat_series, @metro_lon_max) |
| 76 | + ) |
| 77 | + |
| 78 | + Series.and(lon_in_lat_range, lat_in_lon_range) |
| 79 | + end |
| 80 | +end |
0 commit comments