igs-slm 0.1.5b2__py3-none-any.whl → 0.2.0b0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. {igs_slm-0.1.5b2.dist-info → igs_slm-0.2.0b0.dist-info}/METADATA +2 -2
  2. {igs_slm-0.1.5b2.dist-info → igs_slm-0.2.0b0.dist-info}/RECORD +47 -34
  3. slm/__init__.py +1 -1
  4. slm/admin.py +40 -3
  5. slm/api/edit/views.py +37 -2
  6. slm/api/public/serializers.py +1 -1
  7. slm/defines/CoordinateMode.py +9 -0
  8. slm/defines/SiteLogFormat.py +19 -6
  9. slm/defines/__init__.py +24 -22
  10. slm/file_views/apps.py +7 -0
  11. slm/file_views/config.py +253 -0
  12. slm/file_views/settings.py +124 -0
  13. slm/file_views/static/slm/file_views/banner_header.png +0 -0
  14. slm/file_views/static/slm/file_views/css/listing.css +82 -0
  15. slm/file_views/templates/slm/file_views/listing.html +70 -0
  16. slm/file_views/urls.py +47 -0
  17. slm/file_views/views.py +472 -0
  18. slm/forms.py +22 -4
  19. slm/jinja2/slm/sitelog/ascii_9char.log +1 -1
  20. slm/jinja2/slm/sitelog/legacy.log +1 -1
  21. slm/management/commands/check_upgrade.py +11 -11
  22. slm/management/commands/generate_sinex.py +9 -7
  23. slm/map/settings.py +0 -0
  24. slm/migrations/0001_alter_archivedsitelog_size_and_more.py +44 -0
  25. slm/migrations/0032_archiveindex_valid_range_and_more.py +8 -1
  26. slm/migrations/simplify_daily_index_files.py +86 -0
  27. slm/models/index.py +73 -6
  28. slm/models/sitelog.py +6 -0
  29. slm/models/system.py +35 -2
  30. slm/parsing/__init__.py +10 -0
  31. slm/parsing/legacy/binding.py +3 -2
  32. slm/receivers/cache.py +25 -0
  33. slm/settings/root.py +22 -0
  34. slm/settings/routines.py +1 -0
  35. slm/settings/slm.py +71 -10
  36. slm/settings/urls.py +1 -1
  37. slm/settings/validation.py +5 -4
  38. slm/signals.py +33 -23
  39. slm/static/slm/js/enums.js +7 -6
  40. slm/static/slm/js/form.js +25 -14
  41. slm/static/slm/js/slm.js +4 -2
  42. slm/templatetags/slm.py +3 -3
  43. slm/utils.py +161 -36
  44. slm/validators.py +51 -0
  45. {igs_slm-0.1.5b2.dist-info → igs_slm-0.2.0b0.dist-info}/WHEEL +0 -0
  46. {igs_slm-0.1.5b2.dist-info → igs_slm-0.2.0b0.dist-info}/entry_points.txt +0 -0
  47. {igs_slm-0.1.5b2.dist-info → igs_slm-0.2.0b0.dist-info}/licenses/LICENSE +0 -0
slm/signals.py CHANGED
@@ -1,6 +1,7 @@
1
1
  """
2
- All SLM specific signals sent by the system are defined here. These signals
3
- mostly include events relating to the site log edit/moderate/publish life cycle.
2
+ All SLM specific :doc:`django:topics/signals` sent by the system are defined here.
3
+ These signals mostly include events relating to the site log edit/moderate/publish life
4
+ cycle.
4
5
 
5
6
  All signals contain a request object that holds the request that initiated the
6
7
  event. This object is provided mostly for logging purposes and is not
@@ -12,6 +13,7 @@ import sys
12
13
  from django.dispatch import Signal
13
14
  from django.utils.module_loading import import_string
14
15
 
16
+ site_proposed = Signal()
15
17
  """
16
18
  Signal sent when a new site is proposed.
17
19
 
@@ -24,8 +26,9 @@ Signal sent when a new site is proposed.
24
26
  responsible agencies.
25
27
  :param kwargs: Misc other key word arguments
26
28
  """
27
- site_proposed = Signal()
28
29
 
30
+
31
+ site_published = Signal()
29
32
  """
30
33
  Sent when a site log is published, or when a section of a site log is
31
34
  published.
@@ -39,13 +42,13 @@ published.
39
42
  section was published this will be the Section object instance.
40
43
  :param kwargs: Misc other key word arguments
41
44
  """
42
- site_published = Signal()
43
45
 
46
+
47
+ site_status_changed = Signal()
44
48
  """
45
- Sent when a site log is published, or when a section of a site log is
46
- published. Its possible that both the previous and new status states are
47
- Published. If this happens a site log was edited and published simultaneously.
48
- The published timestamp will have increased.
49
+ Sent when a site log's status changes. Its possible that both the previous and
50
+ new status states are Published. If this happens a site log was edited and
51
+ published simultaneously. The published timestamp will have increased.
49
52
 
50
53
  :param sender: The sending object (unreliable).
51
54
  :param site: The Site object.
@@ -54,8 +57,9 @@ The published timestamp will have increased.
54
57
  :param reverted: If true this status change was the result of a reversion
55
58
  :param kwargs: Misc other key word arguments
56
59
  """
57
- site_status_changed = Signal()
58
60
 
61
+
62
+ section_edited = Signal()
59
63
  """
60
64
  Sent when a site log section is edited.
61
65
 
@@ -68,8 +72,9 @@ Sent when a site log section is edited.
68
72
  :param fields: The list of edited fields
69
73
  :param kwargs: Misc other key word arguments
70
74
  """
71
- section_edited = Signal()
72
75
 
76
+
77
+ section_added = Signal()
73
78
  """
74
79
  Sent when a site log section is added.
75
80
 
@@ -81,8 +86,9 @@ Sent when a site log section is added.
81
86
  :param section: The section object that was added.
82
87
  :param kwargs: Misc other key word arguments
83
88
  """
84
- section_added = Signal()
85
89
 
90
+
91
+ section_deleted = Signal()
86
92
  """
87
93
  Sent when a site log section is deleted.
88
94
 
@@ -94,8 +100,9 @@ Sent when a site log section is deleted.
94
100
  :param section: The section object that was deleted.
95
101
  :param kwargs: Misc other key word arguments
96
102
  """
97
- section_deleted = Signal()
98
103
 
104
+
105
+ fields_flagged = Signal()
99
106
  """
100
107
  Sent when a site log section has fields flagged.
101
108
 
@@ -109,8 +116,9 @@ Sent when a site log section has fields flagged.
109
116
  :param fields: The list of fields that were flagged.
110
117
  :param kwargs: Misc other key word arguments
111
118
  """
112
- fields_flagged = Signal()
113
119
 
120
+
121
+ flags_cleared = Signal()
114
122
  """
115
123
  Sent when a site log section has fields flagged.
116
124
 
@@ -126,8 +134,9 @@ Sent when a site log section has fields flagged.
126
134
  :param clear: True if the section has no remaining flags - false otherwise.
127
135
  :param kwargs: Misc other key word arguments
128
136
  """
129
- flags_cleared = Signal()
130
137
 
138
+
139
+ site_file_uploaded = Signal()
131
140
  """
132
141
  Sent when a user uploads a site log.
133
142
 
@@ -139,8 +148,9 @@ Sent when a user uploads a site log.
139
148
  :param upload: The uploaded file (SiteFileUpload).
140
149
  :param kwargs: Misc other key word arguments
141
150
  """
142
- site_file_uploaded = Signal()
143
151
 
152
+
153
+ site_file_published = Signal()
144
154
  """
145
155
  Sent when a moderator publishes a site file upload - could be an attachment or
146
156
  an image.
@@ -153,9 +163,9 @@ an image.
153
163
  :param upload: The uploaded file (SiteFileUpload).
154
164
  :param kwargs: Misc other key word arguments
155
165
  """
156
- site_file_published = Signal()
157
166
 
158
167
 
168
+ site_file_unpublished = Signal()
159
169
  """
160
170
  Sent when a moderator retracts a site file upload - could be an attachment
161
171
  or an image.
@@ -168,9 +178,9 @@ or an image.
168
178
  :param upload: The uploaded file (SiteFileUpload).
169
179
  :param kwargs: Misc other key word arguments
170
180
  """
171
- site_file_unpublished = Signal()
172
181
 
173
182
 
183
+ site_file_deleted = Signal()
174
184
  """
175
185
  Sent when a user deletes a site file upload through the API. The file must be
176
186
  a type other than a site log - could be an attachment or an image.
@@ -183,9 +193,9 @@ a type other than a site log - could be an attachment or an image.
183
193
  :param upload: The uploaded file (SiteFileUpload).
184
194
  :param kwargs: Misc other key word arguments
185
195
  """
186
- site_file_deleted = Signal()
187
196
 
188
197
 
198
+ review_requested = Signal()
189
199
  """
190
200
  Sent when a user requests a site log be reviewed and published by moderators.
191
201
 
@@ -195,8 +205,8 @@ Sent when a user requests a site log be reviewed and published by moderators.
195
205
  :param request: The Django request object that contained the request.
196
206
  :param kwargs: Misc other key word arguments
197
207
  """
198
- review_requested = Signal()
199
208
 
209
+ updates_rejected = Signal()
200
210
  """
201
211
  Sent when a moderator rejects edits requested for publish in a review request.
202
212
 
@@ -206,8 +216,9 @@ Sent when a moderator rejects edits requested for publish in a review request.
206
216
  :param request: The Django request object that contained the rejection.
207
217
  :param kwargs: Misc other key word arguments
208
218
  """
209
- updates_rejected = Signal()
210
219
 
220
+
221
+ alert_issued = Signal()
211
222
  """
212
223
  Sent when an alert is issued.
213
224
 
@@ -215,8 +226,9 @@ Sent when an alert is issued.
215
226
  :param alert: The alert object that was issued.
216
227
  :param kwargs: Misc other key word arguments
217
228
  """
218
- alert_issued = Signal()
219
229
 
230
+
231
+ alert_cleared = Signal()
220
232
  """
221
233
  Sent when an alert is cleared.
222
234
 
@@ -224,8 +236,6 @@ Sent when an alert is cleared.
224
236
  :param alert: The alert object that was issued.
225
237
  :param kwargs: Misc other key word arguments
226
238
  """
227
- alert_cleared = Signal()
228
-
229
239
 
230
240
  _signal_names_ = {
231
241
  value: f"slm.signals.{key}"
@@ -201,12 +201,12 @@ class SiteFileUploadStatus {
201
201
  }
202
202
  class SiteLogFormat {
203
203
 
204
- static LEGACY = new SiteLogFormat(1, "LEGACY", "Legacy (ASCII)", "text/plain", "bi bi-file-text", "log", ["text", "txt", "legacy"], ["txt", "sitelog"]);
205
- static GEODESY_ML = new SiteLogFormat(2, "GEODESY_ML", "GeodesyML", "application/xml", "bi bi-filetype-xml", "xml", ["xml"], ["gml"]);
206
- static JSON = new SiteLogFormat(3, "JSON", "JSON", "application/json", "bi bi-filetype-json", "json", ["json", "js"], ["js"]);
207
- static ASCII_9CHAR = new SiteLogFormat(4, "ASCII_9CHAR", "ASCII (9-Char)", "text/plain", "bi bi-file-text", "log", ["text", "txt", "9char"], ["txt", "sitelog"]);
204
+ static LEGACY = new SiteLogFormat(1, "LEGACY", "Legacy (ASCII)", "text/plain", "bi bi-file-text", "log", ["text", "txt", "legacy", "sitelog"], [], "log");
205
+ static GEODESY_ML = new SiteLogFormat(2, "GEODESY_ML", "GeodesyML", "application/xml", "bi bi-filetype-xml", "xml", ["xml", "gml"], [], "xml");
206
+ static JSON = new SiteLogFormat(3, "JSON", "JSON", "application/json", "bi bi-filetype-json", "json", ["json", "js"], [], "json");
207
+ static ASCII_9CHAR = new SiteLogFormat(4, "ASCII_9CHAR", "ASCII (9-Char)", "text/plain", "bi bi-file-text", "log", ["text", "txt", "9char", "sitelog"], "[(1, 'Legacy (ASCII)')]", "log");
208
208
 
209
- constructor (value, name, label, mimetype, icon, ext, alts, alt_exts) {
209
+ constructor (value, name, label, mimetype, icon, ext, alts, supersedes, suffix) {
210
210
  this.value = value;
211
211
  this.name = name;
212
212
  this.label = label;
@@ -214,7 +214,8 @@ class SiteLogFormat {
214
214
  this.icon = icon;
215
215
  this.ext = ext;
216
216
  this.alts = alts;
217
- this.alt_exts = alt_exts;
217
+ this.supersedes = supersedes;
218
+ this.suffix = suffix;
218
219
  }
219
220
 
220
221
  static ciCompare(a, b) {
slm/static/slm/js/form.js CHANGED
@@ -167,7 +167,16 @@ class Form extends slm.Persistable {
167
167
  data[field.name] = [value];
168
168
  }
169
169
  } else {
170
- data[field.name] = value;
170
+ if (Object.hasOwn(data, field.name)) {
171
+ const current = data[field.name];
172
+ if (Array.isArray(current)) {
173
+ current.push(value);
174
+ } else {
175
+ data[field.name] = [current, value];
176
+ }
177
+ } else {
178
+ data[field.name] = value;
179
+ }
171
180
  }
172
181
  }
173
182
 
@@ -234,16 +243,20 @@ class Form extends slm.Persistable {
234
243
  checked.prop('checked', true);
235
244
  this.element.find(`[name="${field}"]:checkbox`).not(checked).prop('checked', false);
236
245
  break;
237
- default:
238
- if (ipt.length > 0) {
239
- throw TypeError(`Array given for form data type: ${ipt.prop('type')}`);
246
+ case 'number':
247
+ if (value.length === ipt.length) {
248
+ let idx = 0;
249
+ for (const val of value) { $(ipt[idx++]).val(val); }
250
+ break;
240
251
  }
252
+ default:
253
+ throw TypeError(`Type mismatch: ${value} ${ipt.prop('type')}`);
241
254
  }
242
255
  } else if (ipt.length > 1) {
243
256
  const types = [];
244
257
  ipt.each(function () { types.push($(this).prop('type')); });
245
258
  const values = this.decompose(types, value);
246
- ipt.map((e, i) => { setField(e, values[i]); });
259
+ ipt.each((i, e) => setField($(e), values[i]));
247
260
  } else { setField(ipt, value); }
248
261
  }.bind(this));
249
262
 
@@ -302,22 +315,20 @@ class Form extends slm.Persistable {
302
315
  }
303
316
 
304
317
  decompose(types, value) {
305
- switch (types) {
306
- case ['date', 'time']:
318
+ const typeList = Array.isArray(types) ? types : Array.from(types);
319
+ if (typeList.length === 2 && typeList[0] === 'date' && typeList[1] === 'time') {
307
320
  if (value) { return value.split('T'); }
308
321
  return ['', ''];
309
- default:
310
- throw TypeError(`Unexpected decomposition types: ${types}`);
311
322
  }
323
+ throw TypeError(`Unexpected decomposition types: ${types}`);
312
324
  }
313
325
 
314
326
  compose(types, inputs) {
315
- switch (types) {
316
- case ['date', 'time']:
317
- return `${inputs.get(0).value}T${inputs.get(1).value || "00:00"}Z`
318
- default:
319
- throw TypeError(`Unexpected composition types: ${types}`);
327
+ const typeList = Array.isArray(types) ? types : Array.from(types);
328
+ if (typeList.length === 2 && typeList[0] === 'date' && typeList[1] === 'time') {
329
+ return `${inputs[0]}T${inputs[1] || "00:00"}Z`
320
330
  }
331
+ throw TypeError(`Unexpected composition types: ${[...typeList].join(', ')}`);
321
332
  }
322
333
 
323
334
  clear() {
slm/static/slm/js/slm.js CHANGED
@@ -130,10 +130,12 @@ slm.handlePostSuccess = function(form, response, status, jqXHR) {
130
130
  'button.accordion-button'
131
131
  ).removeClass('slm-section-deleted');
132
132
  form.find('.alert.slm-form-deleted').hide();
133
- form.find('.form-control:visible').removeAttr('disabled');
134
- form.find('input.form-check-input:visible').removeAttr('disabled', '');
135
133
  form.find('.slm-flag').show();
136
134
  form.find('div[contenteditable]').attr('contenteditable', true);
135
+
136
+ // TODO Form class setters/getters still not completely working for all forms
137
+ // const filterForm = new slm.Form(form);
138
+ // filterForm.data = data;
137
139
  }
138
140
 
139
141
  if (data.hasOwnProperty('_diff') && Object.keys(data._diff).length) {
slm/templatetags/slm.py CHANGED
@@ -111,7 +111,7 @@ def iso_utc_full(datetime_field):
111
111
  @register.filter(name="multi_line")
112
112
  def multi_line(text):
113
113
  if text:
114
- limit = 47
114
+ limit = 48
115
115
  lines = [line.rstrip() for line in text.split("\n")]
116
116
  limited = []
117
117
  for line in lines:
@@ -254,11 +254,11 @@ def file_icon(file):
254
254
  @register.filter(name="file_lines")
255
255
  def file_lines(file):
256
256
  if isinstance(file, str):
257
- return file.split("\n")
257
+ return file.splitlines()
258
258
  if file and os.path.exists(file.file.path):
259
259
  content = file.file.open().read()
260
260
  try:
261
- return content.decode(detect(content).get("encoding", "utf-8")).split("\n")
261
+ return content.decode(detect(content).get("encoding", "utf-8")).splitlines()
262
262
  except (UnicodeDecodeError, LookupError, ValueError):
263
263
  return [
264
264
  _("** Unable to determine text encoding - please upload as UTF-8. **")
slm/utils.py CHANGED
@@ -1,7 +1,8 @@
1
1
  import json
2
2
  import re
3
+ import typing as t
3
4
  from datetime import date, datetime, timedelta
4
- from math import atan2, cos, sin, sqrt
5
+ from math import atan2, copysign, cos, floor, sin, sqrt
5
6
 
6
7
  from dateutil import parser as date_parser
7
8
  from django.conf import settings
@@ -39,43 +40,121 @@ def get_record_model():
39
40
  ) from ve
40
41
 
41
42
 
42
- def dddmmssss_to_decimal(dddmmssss):
43
- if dddmmssss is not None:
44
- if isinstance(dddmmssss, str):
45
- dddmmssss = float(dddmmssss)
46
- dddmmssss /= 10000
47
- degrees = int(dddmmssss)
48
- minutes = (dddmmssss - degrees) * 100
49
- seconds = float((minutes - int(minutes)) * 100)
50
- return degrees + int(minutes) / 60 + seconds / 3600
51
- return None
52
-
53
-
54
- def decimal_to_dddmmssss(dec):
55
- if dec is not None:
56
- if isinstance(dec, str):
57
- dec = float(dec)
58
- degrees = int(dec)
59
- minutes = (dec - degrees) * 60
60
- seconds = float(minutes - int(minutes)) * 60
61
- return degrees * 10000 + int(minutes) * 100 + seconds
62
- return None
43
+ def dddmmssss_to_decimal(
44
+ dddmmssss: t.Optional[t.Union[float, str, int]], sec_digits: int = 6
45
+ ) -> t.Optional[float]:
46
+ """
47
+ Convert DDDMMSS.ss composite to decimal degrees.
48
+ Preserves -0.0 and normalizes 60s/60m carries.
63
49
 
50
+ :param dddmmssss: latitude or longitude in DDDMMSS.SS format
51
+ :return: the coordinate in decimal degrees
52
+ """
53
+ if dddmmssss is None:
54
+ return None
55
+ if isinstance(dddmmssss, str):
56
+ dddmmssss = float(dddmmssss)
57
+
58
+ sgn = copysign(1.0, dddmmssss)
59
+ v = abs(dddmmssss)
60
+
61
+ # Extract D, M, S from DDDMMSS.ss
62
+ degrees = int(v // 10000)
63
+ remainder = v - degrees * 10000
64
+ minutes = int(remainder // 100)
65
+ seconds = remainder - minutes * 100
66
+
67
+ # Clean up float noise and normalize carries
68
+ seconds = round(seconds, sec_digits)
69
+ if seconds >= 60.0:
70
+ seconds = 0.0
71
+ minutes += 1
72
+ if minutes >= 60:
73
+ minutes = 0
74
+ degrees += 1
75
+
76
+ dec = degrees + minutes / 60.0 + seconds / 3600.0
77
+ return copysign(dec, sgn)
78
+
79
+
80
+ def decimal_to_dddmmssss(
81
+ dec: t.Optional[t.Union[str, float]], sec_digits: int = 2
82
+ ) -> t.Optional[float]:
83
+ """
84
+ Convert decimal degrees to DDDMMSS.SS... as a float.
85
+ Preserves the sign of -0.0 and normalizes carry (sec/min -> min/deg).
64
86
 
65
- def dddmmss_ss_parts(dec):
87
+ :param: dec: string or float of decimal degrees of either latitude or longitude
88
+ :return: floating point lat or lon in DDDMMSS.SS format
89
+ """
90
+ if dec is None:
91
+ return None
92
+ if isinstance(dec, str):
93
+ dec = float(dec)
94
+
95
+ # Work with absolute value; keep original sign with copysign at the end
96
+ a = abs(dec)
97
+
98
+ degrees = int(a)
99
+ minutes_full = (a - degrees) * 60.0
100
+ minutes = int(minutes_full)
101
+ seconds = (minutes_full - minutes) * 60.0
102
+
103
+ # Round seconds, then normalize carries (60s -> +1m, 60m -> +1d)
104
+ seconds = round(seconds, sec_digits)
105
+ if seconds >= 60.0:
106
+ seconds = 0.0
107
+ minutes += 1
108
+ if minutes >= 60:
109
+ minutes = 0
110
+ degrees += 1
111
+
112
+ # Compose as DDDMMSS.ss (minutes and seconds are non-negative)
113
+ composite = degrees * 10000 + minutes * 100 + seconds
114
+ return copysign(composite, dec)
115
+
116
+
117
+ def dddmmss_ss_parts(
118
+ dec: t.Optional[t.Union[str, float, int]], sec_digits: int = 2
119
+ ) -> t.Tuple[t.Optional[float], t.Optional[int], t.Optional[float]]:
66
120
  """
67
121
  Return (degrees, minutes, seconds) from decimal degrees
68
122
  :param dec: Decimal degrees lat or lon
69
- :return:
123
+ :return: a 3-tuple of degrees, minutes seconds components. The degrees component will be a
124
+ whole number, but is a float so that it may contain the sign.
70
125
  """
71
- if dec is not None:
72
- if isinstance(dec, str):
73
- dec = float(dec)
74
- degrees = int(dec)
75
- minutes = (dec - degrees) * 60
76
- seconds = float(minutes - int(minutes)) * 60
77
- return degrees, abs(int(minutes)), abs(seconds)
78
- return None, None, None
126
+ if dec is None:
127
+ return None, None, None
128
+ if isinstance(dec, str):
129
+ dec = float(dec)
130
+
131
+ sign = int(copysign(1, dec))
132
+ v = abs(dec)
133
+
134
+ degrees = floor(v)
135
+ minutes_full = (v - degrees) * 60
136
+ minutes = floor(minutes_full)
137
+ seconds = (minutes_full - minutes) * 60
138
+
139
+ seconds = round(seconds, sec_digits)
140
+
141
+ # Handle rounding overflow (e.g. 59.9999 → 60.0)
142
+ if seconds >= 60.0:
143
+ seconds = 0.0
144
+ minutes += 1
145
+ if minutes >= 60:
146
+ minutes = 0
147
+ degrees += 1
148
+
149
+ return (sign * float(degrees), minutes, seconds)
150
+
151
+
152
+ def lon_180_to_360(lon):
153
+ return lon % 360.0
154
+
155
+
156
+ def lon_360_to_180(lon):
157
+ return ((lon + 180) % 360) - 180
79
158
 
80
159
 
81
160
  def set_protocol(request):
@@ -249,11 +328,20 @@ def get_exif_tags(file_path):
249
328
  return {}
250
329
 
251
330
 
252
- def xyz2llh(xyz):
253
- a_e = 6378.1366e3 # meters
254
- f_e = 1 / 298.25642 # IERS2000 standards
331
+ def xyz2llh(*xyz, geodetic=True) -> t.Tuple[float, float, float]:
332
+ """
333
+ Convert ECEF to LLH using ITRF2020 Ellipsoid.
334
+
335
+ :param xyz: A 3-tuple or 3 xyz parameters, e.g. xyz2llh(x,y,z) or xyz2llh((x,y,z))
336
+ :return: A 3-tuple of latitude, longitude, height. Longitude is geodetic (+/-180) by default and height is in meters.
337
+ """
338
+ a_e = 6378.137e3 # meters
339
+ f_e = 1 / 298.257222101 # ITRF2020 flattening
255
340
  radians2degree = 45 / atan2(1, 1)
256
341
 
342
+ # allow xyz2llh(x,y,z) or xyz2llh((x,y,z))
343
+ xyz = xyz[0] if len(xyz) == 1 else xyz
344
+
257
345
  xyz_array = [v / a_e for v in xyz]
258
346
  (x, y, z) = (xyz_array[0], xyz_array[1], xyz_array[2])
259
347
  e2 = f_e * (2 - f_e)
@@ -271,7 +359,44 @@ def xyz2llh(xyz):
271
359
  lon = lon + 360
272
360
  h = a_e * (p * cos(phi) + z * sin(phi) - sqrt(1 - e2 * (sin(phi)) ** 2))
273
361
 
274
- return lat, lon, h
362
+ return lat, lon_360_to_180(lon) if geodetic else lon, h
363
+
364
+
365
+ def llh2xyz(*llh) -> t.Tuple[float, float, float]:
366
+ """
367
+ Convert LLH (latitude, longitude, height) to ECEF (X, Y, Z)
368
+ using ITRF2020 Ellipsoid.
369
+
370
+ :param llh: A 3-tuple or 3 separate parameters, e.g. llh2xyz(lat, lon, h) or llh2xyz((lat, lon, h))
371
+ :returns: (x, y, z) in meters
372
+ """
373
+ a_e = 6378.137e3 # semi-major axis in meters
374
+ f_e = 1 / 298.257222101 # flattening (ITRF2020)
375
+ radians_per_degree = atan2(1, 1) / 45 # inverse of your radians2degree
376
+
377
+ # allow llh2xyz(lat, lon, h) or llh2xyz((lat, lon, h))
378
+ llh = llh[0] if len(llh) == 1 else llh
379
+ lat, lon, h = llh
380
+
381
+ # convert to radians
382
+ phi = lat * radians_per_degree
383
+ lam = lon * radians_per_degree
384
+
385
+ e2 = f_e * (2 - f_e)
386
+ sin_phi = sin(phi)
387
+ cos_phi = cos(phi)
388
+ sin_lam = sin(lam)
389
+ cos_lam = cos(lam)
390
+
391
+ # prime vertical radius of curvature
392
+ N = a_e / sqrt(1 - e2 * sin_phi**2)
393
+
394
+ # ECEF coordinates
395
+ x = (N + h) * cos_phi * cos_lam
396
+ y = (N + h) * cos_phi * sin_lam
397
+ z = ((1 - e2) * N + h) * sin_phi
398
+
399
+ return x, y, z
275
400
 
276
401
 
277
402
  def convert_9to4(text: str, name: str) -> str:
slm/validators.py CHANGED
@@ -295,3 +295,54 @@ class TimeRangeValidator(SLMValidator):
295
295
  instance,
296
296
  field,
297
297
  )
298
+
299
+
300
+ class PositionsMatchValidator(SLMValidator):
301
+ """
302
+ Attach this validator to SiteLocation llh and/or xyz fields to validate that these
303
+ positions are within the given tolerance of each other.
304
+ """
305
+
306
+ tolerance = 1.0
307
+ """
308
+ 3D tolerance in meters between the positions before flagging.
309
+ """
310
+
311
+ def __init__(
312
+ self,
313
+ *args,
314
+ severity=FlagSeverity.BLOCK_SAVE,
315
+ tolerance: float = tolerance,
316
+ **kwargs,
317
+ ):
318
+ self.tolerance = tolerance
319
+ super().__init__(*args, severity=severity, **kwargs)
320
+
321
+ def __call__(self, instance, field, value):
322
+ from math import sqrt
323
+
324
+ from slm.utils import llh2xyz
325
+
326
+ fieldname = field.name
327
+ xyz1 = value if fieldname == "xyz" else llh2xyz(value)
328
+ other = "xyz" if fieldname == "llh" else "llh"
329
+ otherfield = instance._meta.get_field(other)
330
+ if xyz1:
331
+ xyz2 = (
332
+ llh2xyz(getattr(instance, other))
333
+ if other == "llh"
334
+ else getattr(instance, other)
335
+ )
336
+ if xyz2:
337
+ diff = sqrt(
338
+ (xyz1[0] - xyz2[0]) ** 2
339
+ + (xyz1[1] - xyz2[1]) ** 2
340
+ + (xyz1[2] - xyz2[2]) ** 2
341
+ )
342
+ if diff > self.tolerance:
343
+ print(instance.site.name, diff)
344
+ self.throw_error(
345
+ f"{diff:.2f} meters away from {otherfield.verbose_name}",
346
+ instance,
347
+ field,
348
+ )