Passed
Push — 2.x ( a653ea...997a25 )
by Ramon
06:42
created

senaite.core.api.geo.to_latitude_dms()   B

Complexity

Conditions 6

Size

Total Lines 27
Code Lines 14

Duplication

Lines 27
Ratio 100 %

Importance

Changes 0
Metric Value
eloc 14
dl 27
loc 27
rs 8.6666
c 0
b 0
f 0
cc 6
nop 3
1
# -*- coding: utf-8 -*-
2
#
3
# This file is part of SENAITE.CORE.
4
#
5
# SENAITE.CORE is free software: you can redistribute it and/or modify it under
6
# the terms of the GNU General Public License as published by the Free Software
7
# Foundation, version 2.
8
#
9
# This program is distributed in the hope that it will be useful, but WITHOUT
10
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
11
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
12
# details.
13
#
14
# You should have received a copy of the GNU General Public License along with
15
# this program; if not, write to the Free Software Foundation, Inc., 51
16
# Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
17
#
18
# Copyright 2018-2024 by it's authors.
19
# Some rights reserved, see README and LICENSE.
20
21
import math
22
import pycountry
23
24
from bika.lims.api import is_floatable
25
from bika.lims.api import to_float
26
from bika.lims.api import to_utf8
27
from six import string_types
28
29
_marker = object()
30
31
32
def get_countries():
33
    """Return the list of countries sorted by name ascending
34
    :return: list of countries sorted by name ascending
35
    :rtype: list of Country objects
36
    """
37
    countries = pycountry.countries
38
    return sorted(list(countries), key=lambda s: s.name)
39
40
41
def get_country(thing, default=_marker):
42
    """Returns the country object
43
    :param thing: country, subdivision object or search term
44
    :type thing: Country/Subdivision/string
45
    :returns: the country that matches with the parameter passed in
46
    :rtype: pycountry.db.Country
47
    """
48
    if is_country(thing):
49
        return thing
50
51
    if is_subdivision(thing):
52
        return get_country(thing.country_code)
53
54
    if not isinstance(thing, string_types):
55
        if default is _marker:
56
            raise TypeError("{} is not supported".format(repr(thing)))
57
        return default
58
59
    try:
60
        return pycountry.countries.lookup(thing)
61
    except LookupError as e:
62
        if default is _marker:
63
            raise ValueError(str(e))
64
        return default
65
66
67
def get_country_code(thing, default=_marker):
68
    """Returns the 2-character code (alpha2) of the country
69
    :param thing: country, subdivision object or search term
70
    :return: the 2-character (alpha2) code of the country
71
    :rtype: string
72
    """
73
    thing = get_country_or_subdivision(thing, default=default)
74
    if is_country(thing):
75
        return thing.alpha_2
76
    if is_subdivision(thing):
77
        return thing.country_code
78
    return default
79
80
81
def get_subdivision(subdivision_or_term, parent=None, default=_marker):
82
    """Returns the Subdivision object
83
    :param subdivision_or_term: subdivision or search term
84
    :param subdivision_or_term: Subdivision/string
85
    :param parent: filter by parent subdivision or country
86
    :returns: the subdivision that matches with the parameter passed in
87
    :rtype: pycountry.db.Subdivision
88
    """
89
    if is_subdivision(subdivision_or_term):
90
        return subdivision_or_term
91
92
    if not isinstance(subdivision_or_term, string_types):
93
        if default is _marker:
94
            raise TypeError("{} is not supported".format(
95
                repr(subdivision_or_term)))
96
        return default
97
98
    # Search by parent
99
    if parent:
100
101
        def is_match(subdivision):
102
            terms = [subdivision.name, subdivision.code]
103
            needle = to_utf8(subdivision_or_term)
104
            return needle in map(to_utf8, terms)
105
106
        subdivisions = get_subdivisions(parent, default=[])
107
        subdivisions = filter(lambda subdiv: is_match(subdiv), subdivisions)
0 ignored issues
show
introduced by
The variable is_match does not seem to be defined for all execution paths.
Loading history...
108
        if len(subdivisions) == 1:
109
            return subdivisions[0]
110
        elif len(subdivisions) > 1:
111
            if default is _marker:
112
                raise ValueError("More than one subdivision found")
113
            return default
114
        else:
115
            if default is _marker:
116
                raise ValueError("No subdivisions found")
117
            return None
118
119
    # Search directly by term
120
    try:
121
        return pycountry.subdivisions.lookup(subdivision_or_term)
122
    except LookupError as e:
123
        if default is _marker:
124
            raise ValueError(str(e))
125
        return default
126
127
128
def is_country(thing):
129
    """Returns whether the value passed in is a country object
130
    """
131
    if not thing:
132
        return False
133
    # pycountry generates the classes dynamically, we cannot use isinstance
134
    return "Country" in repr(type(thing))
135
136
137
def is_subdivision(thing):
138
    """Returns whether the value passed in is a subdivision object
139
    """
140
    if not thing:
141
        return False
142
    # pycountry generates the classes dynamically, we cannot use isinstance
143
    return "Subdivision" in repr(type(thing))
144
145
146
def get_subdivisions(thing, default=_marker):
147
    """Returns the first-level subdivisions of the country or subdivision,
148
    sorted by code ascending
149
    :param thing: country, subdivision object or search term
150
    :return: the list of first-level subdivisions of the subdivision/country
151
    :rtype: list of pycountry.db.Subdivision
152
    """
153
    try:
154
        country_or_subdivision = get_country_or_subdivision(thing)
155
        country_code = get_country_code(country_or_subdivision)
156
    except (ValueError, TypeError) as e:
157
        if default is _marker:
158
            raise e
159
        return default
160
161
    # Extract the subdivisions
162
    subdivisions = pycountry.subdivisions.get(country_code=country_code)
163
164
    # Bail out those that are not first-level
165
    if is_subdivision(country_or_subdivision):
166
        code = country_or_subdivision.code
167
        subdivisions = filter(lambda sub: sub.parent_code == code, subdivisions)
0 ignored issues
show
introduced by
The variable code does not seem to be defined for all execution paths.
Loading history...
168
    else:
169
        subdivisions = filter(lambda sub: sub.parent_code is None, subdivisions)
170
171
    # Sort by code
172
    return sorted(subdivisions, key=lambda s: s.code)
173
174
175
def get_country_or_subdivision(thing, default=_marker):
176
    """Returns the country or subdivision for the thing passed-in
177
    :param thing: the thing or search term to look for a country or subdivision
178
    :type thing: Country/Subdivision/string
179
    :return: the country or subdivision for the given thing
180
    """
181
    if is_country(thing):
182
        return thing
183
    if is_subdivision(thing):
184
        return thing
185
186
    if not isinstance(thing, string_types):
187
        if default is _marker:
188
            raise TypeError("{} is not supported".format(repr(thing)))
189
        return default
190
191
    # Maybe a country
192
    country = get_country(thing, default=None)
193
    if country:
194
        return country
195
196
    # Maybe a subdivision
197
    subdivision = get_subdivision(thing, default=None)
198
    if subdivision:
199
        return subdivision
200
201
    if default is _marker:
202
        raise ValueError("Could not find a record for '{}'".format(
203
            thing.lower()))
204
    return default
205
206
207
def to_dms(degrees, precision=4, default=_marker):
208
    """Converts a geographical coordinate in decimal degrees to a dict with
209
    degrees, minutes and seconds as the keys
210
211
    :param degrees: coordinate in decimal degrees
212
    :type degrees: string,int,float
213
    :param precision: number of decimals for seconds
214
    :type precision: int
215
    :return: a dict with degrees, minutes and seconds as keys
216
    """
217
    if not is_floatable(degrees):
218
        if default is _marker:
219
            raise ValueError("Expected decimal degrees to be a floatable, but "
220
                             "got %r" % degrees)
221
        return default
222
223
    # calculate the DMS
224
    decimal_degrees = to_float(degrees)
225
    degrees = math.trunc(decimal_degrees)
226
    minutes = math.trunc((decimal_degrees - degrees) * 60)
227
    seconds = decimal_degrees * 3600 % 60
228
229
    # check precision type
230
    if not isinstance(precision, int):
231
        raise TypeError("Expected precision to be an `int`, but got %r"
232
                        % type(precision))
233
234
    # apply the precision to seconds
235
    template = "{:.%df}" % precision
236
    seconds = template.format(seconds)
237
238
    return {
239
        "degrees": degrees,
240
        "minutes": minutes,
241
        "seconds": to_float(seconds),
242
    }
243
244
245 View Code Duplication
def to_latitude_dms(degrees, precision=4, default=_marker):
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated in your project.
Loading history...
246
    """Converts a geographical latitude in decimal degrees to a dict with
247
    degrees, minutes, seconds and bearing as the keys
248
249
    :param degrees: latitude in decimal degrees
250
    :type degrees: string,int,float
251
    :param precision: number of decimals for seconds
252
    :type precision: int
253
    :return: a dict with degrees, minutes, seconds and bearing as keys
254
    """
255
    if not is_floatable(degrees):
256
        if default is _marker:
257
            raise ValueError("Expected decimal degrees to be a floatable, but "
258
                             "got %r" % degrees)
259
        return default
260
261
    # check latitude is in range
262
    latitude = to_float(degrees)
263
    if abs(latitude) > 90:
264
        if default is _marker:
265
            raise ValueError("Latitude must be within -90 and 90 degrees")
266
        return default
267
268
    # calculate the DMS
269
    dms = to_dms(abs(latitude), precision=precision)
270
    dms["bearing"] = "N" if latitude >= 0 else "S"
271
    return dms
272
273
274 View Code Duplication
def to_longitude_dms(degrees, precision=4, default=_marker):
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated in your project.
Loading history...
275
    """Converts a geographical longitude in decimal degrees to a dict with
276
    degrees, minutes, seconds and bearing as the keys
277
278
    :param degrees: longitude in decimal degrees
279
    :type degrees: string,int,float
280
    :param precision: number of decimals for seconds
281
    :type precision: int
282
    :return: a dict with degrees, minutes, seconds and bearing as keys
283
    """
284
    if not is_floatable(degrees):
285
        if default is _marker:
286
            raise ValueError("Expected decimal degrees to be a floatable, but "
287
                             "got %r" % degrees)
288
        return default
289
290
    # check longitude is in range
291
    longitude = to_float(degrees)
292
    if abs(longitude) > 180:
293
        if default is _marker:
294
            raise ValueError("Longitude must be within -180 and 180 degrees")
295
        return default
296
297
    # calculate the DMS
298
    dms = to_dms(abs(longitude), precision=precision)
299
    dms["bearing"] = "E" if longitude >= 0 else "W"
300
    return dms
301
302
303
def to_decimal_degrees(dms, precision=7, default=_marker):
304
    """Converts a geographical coordinate in DMS format to decimal degrees
305
306
    :param dms: coordinate in DMS
307
    :type dms: dict
308
    :param precision: number of decimals for decimal degrees
309
    :type precision: int
310
    :return: a float representing a geographical coordinate in decimal degrees
311
    """
312
    if not isinstance(dms, dict):
313
        if default is _marker:
314
            raise TypeError("Expected dms to be a dict, but got %r" % dms)
315
        return default
316
317
    # get the degrees, minutes and seconds
318
    degrees = to_float(dms.get("degrees"), default=0)
319
    minutes = to_float(dms.get("minutes"), default=0)
320
    seconds = to_float(dms.get("seconds"), default=0)
321
322
    # calculate the decimal degrees
323
    decimal_degrees = abs(degrees + (minutes / 60) + (seconds / 3600))
324
325
    # Use +/- to express N/S, W/E
326
    bearing = dms.get("bearing")
327
    if bearing and bearing in "SW":
328
        decimal_degrees = -decimal_degrees
329
330
    # check precision type
331
    if not isinstance(precision, int):
332
        raise TypeError("Expected precision to be an `int`, but got %r"
333
                        % type(precision))
334
335
    # apply the precision
336
    template = "{:.%df}" % precision
337
    decimal_degrees = template.format(decimal_degrees)
338
339
    # return the float value
340
    return to_float(decimal_degrees)
341