From 8a01209ded308af429e825e48b23e3c1f9138505 Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Wed, 9 Dec 2020 20:35:30 -0800 Subject: [PATCH] Add data for lat/lon conversions. --- game/theater/caucasus.py | 8 + game/theater/nevada.py | 8 + game/theater/normandy.py | 8 + game/theater/persiangulf.py | 8 + game/theater/projections.py | 31 ++++ game/theater/syria.py | 8 + game/theater/thechannel.py | 8 + requirements.txt | 2 + resources/tools/coord_export.lua | 38 ++++ resources/tools/export_coordinates.py | 255 ++++++++++++++++++++++++++ 10 files changed, 374 insertions(+) create mode 100644 game/theater/caucasus.py create mode 100644 game/theater/nevada.py create mode 100644 game/theater/normandy.py create mode 100644 game/theater/persiangulf.py create mode 100644 game/theater/projections.py create mode 100644 game/theater/syria.py create mode 100644 game/theater/thechannel.py create mode 100644 resources/tools/coord_export.lua create mode 100644 resources/tools/export_coordinates.py diff --git a/game/theater/caucasus.py b/game/theater/caucasus.py new file mode 100644 index 00000000..8d0d0adc --- /dev/null +++ b/game/theater/caucasus.py @@ -0,0 +1,8 @@ +from game.theater.projections import TransverseMercator + +PARAMETERS = TransverseMercator( + central_meridian=33, + false_easting=-99516.9999999732, + false_northing=-4998114.999999984, + scale_factor=0.9996, +) diff --git a/game/theater/nevada.py b/game/theater/nevada.py new file mode 100644 index 00000000..ad245611 --- /dev/null +++ b/game/theater/nevada.py @@ -0,0 +1,8 @@ +from game.theater.projections import TransverseMercator + +PARAMETERS = TransverseMercator( + central_meridian=-117, + false_easting=-193996.80999964548, + false_northing=-4410028.063999966, + scale_factor=0.9996, +) diff --git a/game/theater/normandy.py b/game/theater/normandy.py new file mode 100644 index 00000000..a74c692d --- /dev/null +++ b/game/theater/normandy.py @@ -0,0 +1,8 @@ +from game.theater.projections import TransverseMercator + +PARAMETERS = TransverseMercator( + central_meridian=-3, + false_easting=-195526.00000000204, + false_northing=-5484812.999999951, + scale_factor=0.9996, +) diff --git a/game/theater/persiangulf.py b/game/theater/persiangulf.py new file mode 100644 index 00000000..600801dd --- /dev/null +++ b/game/theater/persiangulf.py @@ -0,0 +1,8 @@ +from game.theater.projections import TransverseMercator + +PARAMETERS = TransverseMercator( + central_meridian=57, + false_easting=75755.99999999645, + false_northing=-2894933.0000000377, + scale_factor=0.9996, +) diff --git a/game/theater/projections.py b/game/theater/projections.py new file mode 100644 index 00000000..90f24fe2 --- /dev/null +++ b/game/theater/projections.py @@ -0,0 +1,31 @@ +from dataclasses import dataclass + +from pyproj import CRS + + +@dataclass(frozen=True) +class TransverseMercator: + central_meridian: int + false_easting: float + false_northing: float + scale_factor: float + + def to_crs(self) -> CRS: + return CRS.from_proj4( + " ".join( + [ + "+proj=tmerc", + "+lat_0=0", + f"+lon_0={self.central_meridian}", + f"+k_0={self.scale_factor}", + f"+x_0={self.false_easting}", + f"+y_0={self.false_northing}", + "+towgs84=0,0,0,0,0,0,0", + "+units=m", + "+vunits=m", + "+ellps=WGS84", + "+no_defs", + "+axis=neu", + ] + ) + ) diff --git a/game/theater/syria.py b/game/theater/syria.py new file mode 100644 index 00000000..7fe83db3 --- /dev/null +++ b/game/theater/syria.py @@ -0,0 +1,8 @@ +from game.theater.projections import TransverseMercator + +PARAMETERS = TransverseMercator( + central_meridian=39, + false_easting=282801.00000003993, + false_northing=-3879865.9999999935, + scale_factor=0.9996, +) diff --git a/game/theater/thechannel.py b/game/theater/thechannel.py new file mode 100644 index 00000000..33137bd7 --- /dev/null +++ b/game/theater/thechannel.py @@ -0,0 +1,8 @@ +from game.theater.projections import TransverseMercator + +PARAMETERS = TransverseMercator( + central_meridian=3, + false_easting=99376.00000000288, + false_northing=-5636889.00000001, + scale_factor=0.9996, +) diff --git a/requirements.txt b/requirements.txt index 56a02f0a..7630d67d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,7 @@ altgraph==0.17 appdirs==1.4.4 black==21.4b0 +certifi==2020.12.5 cfgv==3.2.0 click==7.1.2 distlib==0.3.1 @@ -17,6 +18,7 @@ pefile==2019.4.18 Pillow==8.1.1 pre-commit==2.10.1 PyInstaller==3.6 +pyproj==3.0.1 PySide2==5.15.2 pywin32-ctypes==0.2.0 PyYAML==5.4.1 diff --git a/resources/tools/coord_export.lua b/resources/tools/coord_export.lua new file mode 100644 index 00000000..c5542522 --- /dev/null +++ b/resources/tools/coord_export.lua @@ -0,0 +1,38 @@ +local function dump_coords() + local coordinates = {} + local bases = world.getAirbases() + for i = 1, #bases do + local base = bases[i] + point = Airbase.getPoint(base) + lat, lon, alt = coord.LOtoLL(point) + coordinates[Airbase.getName(base)] = { + ["point"] = point, + ["LL"] = { + ["lat"] = lat, + ["lon"] = lon, + ["alt"] = alt, + }, + } + end + + zero = { + ["x"] = 0, + ["y"] = 0, + ["z"] = 0, + } + lat, lon, alt = coord.LOtoLL(zero) + coordinates["zero"] = { + ["point"] = zero, + ["LL"] = { + ["lat"] = lat, + ["lon"] = lon, + ["alt"] = alt, + }, + } + + local fp = io.open(lfs.writedir() .. "\\coords.json", 'w') + fp:write(json:encode(coordinates)) + fp:close() +end + +dump_coords() \ No newline at end of file diff --git a/resources/tools/export_coordinates.py b/resources/tools/export_coordinates.py new file mode 100644 index 00000000..d3605238 --- /dev/null +++ b/resources/tools/export_coordinates.py @@ -0,0 +1,255 @@ +"""Command line tool for exporting coordinates from DCS to derive projection data. + +DCS X/Z coordinates are meter-scale projections of a transverse mercator grid. The +projection has a few required parameters: + +1. Scale factor. Is 0.9996 for most regions: + https://proj.org/operations/projections/tmerc.html. +2. Central meridian of the projection. Easily guessed because there are only 60 UTM + zones and one of those is always used. +3. A false easting and northing (offsets from UTM's center point to DCS's). These aren't + easily guessed, but can be computed by using an offset of 0 and finding the error of + projecting the 0 point from DCS. + +This tool creates a mission that will dump the lat/lon and x/z coordinates of the 0/0 +point and also every airport in the given theater. The data for the zero point is used +to compute the false easting and northing for the map. The data for each airport is used +to test the projection for errors. + +The resulting data is exported to game/theater/.py as a TransverseMercator object. +""" +from __future__ import annotations + +import argparse +import json +import math +import sys +import textwrap +from contextlib import contextmanager +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Dict + +from dcs import Mission +from dcs.action import DoScriptFile +from dcs.terrain.caucasus import Caucasus +from dcs.terrain.nevada import Nevada +from dcs.terrain.normandy import Normandy +from dcs.terrain.persiangulf import PersianGulf +from dcs.terrain.syria import Syria +from dcs.terrain.terrain import Terrain +from dcs.terrain.thechannel import TheChannel +from dcs.triggers import TriggerStart +from pyproj import CRS, Transformer + +from game import persistency +from game.theater.projections import TransverseMercator +from qt_ui import liberation_install + +THIS_DIR = Path(__file__).resolve().parent +JSON_LUA = THIS_DIR.parent / "plugins/base/json.lua" +EXPORT_LUA = THIS_DIR / "coord_export.lua" +SAVE_DIR = THIS_DIR.parent / "coordinate_reference" + + +ARG_TO_TERRAIN_MAP = { + "caucasus": Caucasus(), + "nevada": Nevada(), + "normandy": Normandy(), + "persiangulf": PersianGulf(), + "thechannel": TheChannel(), + "syria": Syria(), +} + +# https://gisgeography.com/central-meridian/ +# UTM zones determined by guess and check. There are only a handful in the region for +# each map and getting the wrong one will be flagged with errors when processing. +CENTRAL_MERIDIANS = { + "caucasus": 33, + "nevada": -117, + "normandy": -3, + "persiangulf": 57, + "thechannel": 3, + "syria": 39, +} + + +@dataclass(frozen=True) +class Coordinates: + x: float + y: float + z: float + + latitude: float + longitude: float + altitude: float + + @classmethod + def from_json(cls, data: Dict[str, Any]) -> Coordinates: + return cls( + x=data["point"]["x"], + y=data["point"]["y"], + z=data["point"]["z"], + latitude=data["LL"]["lat"], + longitude=data["LL"]["lon"], + altitude=data["LL"]["alt"], + ) + + +def create_mission(terrain: Terrain) -> Path: + m = Mission(terrain) + + json_trigger = TriggerStart(comment=f"Load JSON") + json_lua = m.map_resource.add_resource_file(JSON_LUA) + json_trigger.add_action(DoScriptFile(json_lua)) + m.triggerrules.triggers.append(json_trigger) + + export_trigger = TriggerStart(comment=f"Load coordinate export") + export_lua = m.map_resource.add_resource_file(EXPORT_LUA) + export_trigger.add_action(DoScriptFile(export_lua)) + m.triggerrules.triggers.append(export_trigger) + + mission_path = persistency.mission_path_for(f"export_{terrain.name.lower()}.miz") + m.save(mission_path) + return Path(mission_path) + + +def load_coordinate_data(data: Dict[str, Any]) -> Dict[str, Coordinates]: + airbases = {} + for name, coord_data in data.items(): + airbases[name] = Coordinates.from_json(coord_data) + return airbases + + +def test_for_errors( + name: str, + lat_lon_to_x_z: Transformer, + x_z_to_lat_lon: Transformer, + coords: Coordinates, +) -> bool: + errors = False + + x, z = lat_lon_to_x_z.transform(coords.latitude, coords.longitude) + if not math.isclose(x, coords.x) or not math.isclose(z, coords.z): + error_x = x - coords.x + error_z = z - coords.z + error_pct_x = error_x / coords.x * 100 + error_pct_z = error_z / coords.z * 100 + print(f"{name} has error of {error_pct_x}% {error_pct_z}%") + errors = True + + lat, lon = x_z_to_lat_lon.transform(coords.x, coords.z) + if not math.isclose(lat, coords.latitude) or not math.isclose( + lon, coords.longitude + ): + error_lat = lat - coords.latitude + error_lon = lon - coords.longitude + error_pct_lon = error_lat / coords.latitude * 100 + error_pct_lat = error_lon / coords.longitude * 100 + print(f"{name} has error of {error_pct_lat}% {error_pct_lon}%") + errors = True + + return errors + + +def test_parameters( + airbases: Dict[str, Coordinates], parameters: TransverseMercator +) -> bool: + errors = False + wgs84 = CRS("WGS84") + crs = parameters.to_crs() + lat_lon_to_x_z = Transformer.from_crs(wgs84, crs) + x_z_to_lat_lon = Transformer.from_crs(crs, wgs84) + for name, coords in airbases.items(): + if name == "zero": + continue + if test_for_errors(name, lat_lon_to_x_z, x_z_to_lat_lon, coords): + errors = True + return errors + + +def compute_tmerc_parameters( + coordinates_file: Path, terrain: str +) -> TransverseMercator: + + data = json.loads(coordinates_file.read_text()) + airbases = load_coordinate_data(data) + wgs84 = CRS("WGS84") + + # Creates a transformer with 0 for the false easting and northing, but otherwise has + # the correct parameters. We'll use this to transform the zero point from the + # mission to calculate the error from the actual zero point to determine the correct + # false easting and northing. + bad = TransverseMercator( + central_meridian=CENTRAL_MERIDIANS[terrain], + false_easting=0, + false_northing=0, + scale_factor=0.9996, + ).to_crs() + zero_finder = Transformer.from_crs(wgs84, bad) + z, x = zero_finder.transform(airbases["zero"].latitude, airbases["zero"].longitude) + + parameters = TransverseMercator( + central_meridian=CENTRAL_MERIDIANS[terrain], + false_easting=-x, + false_northing=-z, + scale_factor=0.9996, + ) + + if test_parameters(airbases, parameters): + sys.exit("Found errors in projection parameters. Quitting.") + + return parameters + + +@contextmanager +def mission_scripting(): + liberation_install.replace_mission_scripting_file() + try: + yield + finally: + liberation_install.restore_original_mission_scripting() + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser() + + parser.add_argument("map", choices=list(ARG_TO_TERRAIN_MAP.keys())) + + return parser.parse_args() + + +def main() -> None: + if liberation_install.init(): + print("Set up Liberation first.") + return + + args = parse_args() + terrain = ARG_TO_TERRAIN_MAP[args.map] + mission = create_mission(terrain) + with mission_scripting(): + input( + f"Created {mission} and replaced MissionScript.lua. Open DCS and load the " + "mission. Once the mission starts running, close it and press enter." + ) + coords_path = Path(persistency.base_path()) / "coords.json" + parameters = compute_tmerc_parameters(coords_path, args.map) + out_file = THIS_DIR.parent.parent / "game/theater" / f"{args.map}.py" + out_file.write_text( + textwrap.dedent( + f"""\ + from game.theater.projections import TransverseMercator + + PARAMETERS = TransverseMercator( + central_meridian={parameters.central_meridian}, + false_easting={parameters.false_easting}, + false_northing={parameters.false_northing}, + scale_factor={parameters.scale_factor}, + ) + """ + ) + ) + + +if __name__ == "__main__": + main()