As an exercise in using PyQt, the Python switch statement and abstract classes, I made a little app to make conversions between geodetic (WGS84) and PSD93 (Oman) and UTM 40N projections (instead of the ubiquitous calculator).
The app has a main window and 3 dialog windows; 4 types of conversions (geod_proj), (proj_geod), (proj_proj) and (geod_geod); and input/ out for geodetic coordinates can be either in decimal degrees or DMS.
The main and dialog windows designs are made with PyQt designer in XML. Conversions are done in the module convert_tools.py
.
The main app is given below. I would appreciate a review and feedback. The entire source code is on GitHub.
'''
PyQt application for conversion of coordinates for Oman PSD93 projectiom
coordinate system (EPSG 3440)
@ 2022 howdimain; [email protected]
'''
import sys
from dataclasses import dataclass
from abc import ABCMeta, abstractmethod
from enum import Enum
from pathlib import Path
from PyQt5 import uic, QtWidgets
from convert_tools import ConvertTools
@dataclass
class UIInterface:
MainWindow: str = 'convert_main.ui'
DialogFloatFloat: str = 'convert_dlg_float_float.ui'
DialogDMSFloat: str = 'convert_dlg_DMS_float.ui'
DialogFloatDMS: str = 'convert_dlg_float_DMS.ui'
class ConvertChoice(Enum):
wgs84_psd93 = 1
psd93_wgs84 = 2
psd93_utm40 = 3
utm40_psd93 = 4
lon_lat = 5
class FormatChoice(Enum):
Degrees = 1
DMS = 2
convert = ConvertTools()
class MainWindow(QtWidgets.QMainWindow):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
uic.loadUi(Path(__file__).parent / getattr(UIInterface, __class__.__name__), self)
self.actionQuit.triggered.connect(self.action_quit)
self.actionDegrees.triggered.connect(lambda x: self.select_format(FormatChoice.Degrees))
self.actionDMS.triggered.connect(lambda x: self.select_format(FormatChoice.DMS))
self.pb1_wgs84_psd93.clicked.connect(lambda x: self.select_convert(ConvertChoice.wgs84_psd93))
self.pb2_psd93_wgs84.clicked.connect(lambda x: self.select_convert(ConvertChoice.psd93_wgs84))
self.pb3_psd93_utm40.clicked.connect(lambda x: self.select_convert(ConvertChoice.psd93_utm40))
self.pb4_utm40_psd93.clicked.connect(lambda x: self.select_convert(ConvertChoice.utm40_psd93))
self.pb5_lon_lat.clicked.connect(lambda x: self.select_convert(ConvertChoice.lon_lat))
self.dlg_class_proj_proj = DialogFloatFloat
self.dlg_class_geog_proj = DialogFloatFloat
self.dlg_class_proj_geog = DialogFloatFloat
self.dlg_class_geog_geog = DialogFloatDMS
def select_format(self, format_choice):
match format_choice:
case FormatChoice.Degrees:
self.actionDegrees.setChecked(True)
self.actionDMS.setChecked(False)
self.menuFormat.setTitle('Degrees')
self.dlg_class_proj_proj = DialogFloatFloat
self.dlg_class_geog_proj = DialogFloatFloat
self.dlg_class_proj_geog = DialogFloatFloat
self.dlg_class_geog_geog = DialogFloatDMS
case FormatChoice.DMS:
self.actionDegrees.setChecked(False)
self.actionDMS.setChecked(True)
self.menuFormat.setTitle('DMS')
self.dlg_class_proj_proj = DialogFloatFloat
self.dlg_class_geog_proj = DialogDMSFloat
self.dlg_class_proj_geog = DialogFloatDMS
self.dlg_class_geog_geog = DialogDMSFloat
case _:
assert False, (
f'check {format_choice} in {__class__.__name__} / '
f'{__class__.select_format.__name__}'
)
def select_convert(self, convert_choice):
match convert_choice:
case ConvertChoice.wgs84_psd93:
dlg = self.dlg_class_geog_proj(
self, convert_choice, 'WGS84 to PSD93', 'Longitude', 'Latitude',
'Easting', 'Northing'
)
case ConvertChoice.psd93_wgs84:
dlg = self.dlg_class_proj_geog(
self, convert_choice, 'PSD93 to WGS84', 'Easting', 'Northing',
'Longitude', 'Latitude'
)
case ConvertChoice.psd93_utm40:
dlg = self.dlg_class_proj_proj(
self, convert_choice, 'PSD93 to UTM 40N', 'Easting', 'Northing',
'Easting', 'Northing'
)
case ConvertChoice.utm40_psd93:
dlg = self.dlg_class_proj_proj(
self, convert_choice, 'UTM 40N to PSD93', 'Easting', 'Northing',
'Easting', 'Northing'
)
case ConvertChoice.lon_lat:
dlg = self.dlg_class_geog_geog(
self, convert_choice, 'DMS to Degrees', 'Longitude', 'Latitude',
'Longitude', 'Latitude'
)
case _:
assert False, (
f'Check {convert_choice} in {__class__.__name__} / '
f'{__class__.select_convert.__name__}'
)
dlg.exec_()
def action_quit(self):
self.close()
sys.exit()
class QtMixinMeta(type(QtWidgets.QDialog), ABCMeta):
pass
class DialogMeta(QtWidgets.QDialog, metaclass=QtMixinMeta):
def __init__(self, parent, convert_choice, title, input1, input2, output1, output2):
super().__init__(parent)
uic.loadUi(Path(__file__).parent / getattr(UIInterface, (self.__class__.__name__)), self)
self.TitleText.setText(title)
self.TextInput_1.setText(input1)
self.TextInput_2.setText(input2)
self.TextOutput_1.setText(output1)
self.TextOutput_2.setText(output2)
self.pb_exit.clicked.connect(self.action_exit)
self.pb_convert.clicked.connect(lambda x: self.action_convert(convert_choice))
@abstractmethod
def action_convert(self, convert_choice):
pass
def action_exit(self):
self.close()
class DialogFloatFloat(DialogMeta):
def __init__(self, parent, convert_choice, title, input1, input2, output1, output2):
super().__init__(parent, convert_choice, title, input1, input2, output1, output2)
def action_convert(self, convert_choice):
self.lineEditOutput_1.setText('')
self.lineEditOutput_2.setText('')
try:
val1 = float(self.lineEditInput_1.text())
val2 = float(self.lineEditInput_2.text())
except ValueError:
return
match convert_choice:
case ConvertChoice.wgs84_psd93:
val1, val2 = convert.wgs84_to_psd93(val1, val2)
f_fmt = '.2f'
case ConvertChoice.psd93_wgs84:
val1, val2 = convert.psd93_to_wgs84(val1, val2)
f_fmt = '.6f'
case ConvertChoice.psd93_utm40:
val1, val2 = convert.psd93_to_utm40n(val1, val2)
f_fmt = '.2f'
case ConvertChoice.utm40_psd93:
val1, val2 = convert.utm40n_to_psd93(val1, val2)
f_fmt = '.2f'
case _:
assert False, f'Check {convert_choice} in {__class__.__name__}'
self.lineEditOutput_1.setText(f'{val1:{f_fmt}}')
self.lineEditOutput_2.setText(f'{val2:{f_fmt}}')
class DialogDMSFloat(DialogMeta):
def __init__(self, parent, convert_choice, title, input1, input2, output1, output2):
super().__init__(parent, convert_choice, title, input1, input2, output1, output2)
def action_convert(self, convert_choice):
self.lineEditOutput_1.setText('')
self.lineEditOutput_2.setText('')
val1 = self.lineEditInput_1.text()
val2 = self.lineEditInput_2.text() if self.lineEditInput_2.text() else '0'
val3 = self.lineEditInput_3.text() if self.lineEditInput_3.text() else '0'
val4 = self.lineEditInput_4.text()
val5 = self.lineEditInput_5.text() if self.lineEditInput_5.text() else '0'
val6 = self.lineEditInput_6.text() if self.lineEditInput_6.text() else '0'
val1 = ''.join([val1, '\u00B0', val2,'\'', val3, '\"', 'E'])
val2 = ''.join([val4, '\u00B0', val5,'\'', val6, '\"', 'N'])
val1, val2 = convert.convert_dms_to_dec_degree(val1, val2)
if val1 == -1 and val2 == -1:
return
match convert_choice:
case ConvertChoice.wgs84_psd93:
val1, val2 = convert.wgs84_to_psd93(val1, val2)
f_fmt = '.2f'
case ConvertChoice.lon_lat:
f_fmt = '.6f'
case _:
assert False, f'Check {convert_choice} in {__class__.__name__}'
self.lineEditOutput_1.setText(f'{val1:{f_fmt}}')
self.lineEditOutput_2.setText(f'{val2:{f_fmt}}')
class DialogFloatDMS(DialogMeta):
def __init__(self, parent, convert_choice, title, input1, input2, output1, output2):
super().__init__(parent, convert_choice, title, input1, input2, output1, output2)
def action_convert(self, convert_choice):
self.lineEditOutput_1.setText('')
self.lineEditOutput_2.setText('')
self.lineEditOutput_3.setText('')
self.lineEditOutput_4.setText('')
self.lineEditOutput_5.setText('')
self.lineEditOutput_6.setText('')
self.lineEditOutput_7.setText('')
self.lineEditOutput_8.setText('')
try:
val1 = float(self.lineEditInput_1.text())
val2 = float(self.lineEditInput_2.text())
except ValueError:
return
match convert_choice:
case ConvertChoice.psd93_wgs84:
val1, val2 = convert.psd93_to_wgs84(val1, val2)
case ConvertChoice.lon_lat:
pass
case _:
assert False, f'Check {convert_choice} in {__class__.__name__}'
val1, val2 = convert.convert_dec_degree_to_dms(val1, val2)
lon, lat = convert.strip_lon_lat(val1, val2)
if lon and lat:
self.lineEditOutput_1.setText(f'{float(lon.group(1)):.0f}')
self.lineEditOutput_2.setText(f'{float(lon.group(2)):.0f}')
self.lineEditOutput_3.setText(f'{float(lon.group(3)):.2f}')
self.lineEditOutput_4.setText(f'{lon.group(4)}')
self.lineEditOutput_5.setText(f'{float(lat.group(1)):.0f}')
self.lineEditOutput_6.setText(f'{float(lat.group(2)):.0f}')
self.lineEditOutput_7.setText(f'{float(lat.group(3)):.2f}')
self.lineEditOutput_8.setText(f'{lat.group(4)}')
def start_app():
app = QtWidgets.QApplication([])
main_window = MainWindow()
main_window.show()
app.exec_()
if __name__ == '__main__':
start_app()
1 Answer 1
The members of UIInterface
should all be lower_snake_case, as in main_window
.
ConvertChoice
members can use the auto
feature of enum instead of writing out integers. Same for FormatChoice
.
Isn't getattr(UIInterface, __class__.__name__)
just.. the string 'UIInterface'
? I think the getattr
magic has limited utility. Perhaps the calculation of this path could be moved to a @classmethod
on UIInterface
.
On pb1_wgs84_psd93
etc., rather than lambdas, try setData
on the elements for each ConvertChoice
and having only one callback.
select_format()
would be better re-expressed as a dictionary lookup, with all seven of those resulting properties sitting in the dictionary value as a tuple or named tuple. Get rid of your assert False
and rely on the indexing operation []
throwing a KeyError
if format_choice
doesn't make sense. Similar for select_convert
.
Add PEP484 typehints, especially to things like the signature of DialogFloatFloat.__init__
.
These join
calls:
val1 = ''.join([val1, '\u00B0', val2,'\'', val3, '\"', 'E'])
val2 = ''.join([val4, '\u00B0', val5,'\'', val6, '\"', 'N'])
are better-expressed with string interpolation:
val1 = f'{val1}\u00B0{val2}\'{val3}"E'