-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathpyTECTOR.py
More file actions
1557 lines (1413 loc) · 63.5 KB
/
Copy pathpyTECTOR.py
File metadata and controls
1557 lines (1413 loc) · 63.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
# -*- coding: utf-8 -*-
"""pyTECTOR desktop interface.
Run it yourself: pyTECTOR.bat (or python pyTECTOR.py)
Never launched from an automated shell: a QApplication started there pops a Qt
platform-plugin error box and exits.
The layout follows Angelier's own chain, Mesure -> Tensor -> Dessin:
left type records, watch them land on the stereogram
centre the stereograms, which are the deliverable
bottom the numbers, at a size you can actually read
Record format, four fields:
CS - 122 - 87W - 124
| | | |
| | | +-- pitch + quadrant (62N), or a bare trend (124)
| | +-------- dip + quadrant
| +-------------- strike
+------------------- confidence C/P/S + movement I/N/S/D
"""
import os
import sys
import traceback
os.environ.setdefault('QT_AUTO_SCREEN_SCALE_FACTOR', '1')
import numpy as np
from PyQt5 import QtCore, QtGui, QtWidgets
import matplotlib
matplotlib.use('Qt5Agg')
from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as Canvas
from matplotlib.figure import Figure
from pytector import (about, backtilt, core, diagnose, entry, hpgl, invdir,
modern, penrec, plot, report, retro, rotate, session,
splash, tensorfile)
from pytector.ui_style import QSS, MUTED
AXES = ('sigma1', 'sigma2', 'sigma3')
SYM = ('σ₁', 'σ₂', 'σ₃')
PHI = 'Φ'
DEG = '°'
#: The two runs, named after what they are rather than by a letter that would
#: imply one is the better one. INVDIR is Angelier's own name for the method;
#: S4MIN says only that it is the exact minimum of the same S4. Both inherit
#: the criterion's built-in bias, so neither is "the true stress".
#: key, display name, 4-character code for INFO1, one-line description
MODES = (
('A', 'INVDIR', 'INVD', 'as TENSOR 5.45 runs it'),
('B', 'S4MIN', 'S4MN', 'exact minimum of the same criterion'),
)
NAME = {k: nm for k, nm, _c, _d in MODES}
CODE = {k: c for k, _nm, c, _d in MODES}
#: MOHR1 is the one output with no header of its own: the original writes the
#: eigenvalue line, then five bare columns per fault, and expects the reader to
#: know the order. Decoded 2026-07-28 against L12 and 0406-7 and cross-checked
#: column by column against the same sites' INFO1, which does print names.
#: SIGMN and TAU are the Mohr coordinates of the datum, which is what the file
#: is for.
#: No %-formatting on these: they contain literal per-cent signs, and applying
#: the % operator to a string holding "0-200 %," raises at import and the
#: program never starts. Write the symbols in directly instead.
MOHR1_KEY = (
'<b>SIGMN</b> normal stress on the plane · '
'<b>TAU</b> shear magnitude · '
'<b>TAUST</b> shear along the observed striation (= TAU cos '
'ANG) · <b>RUP</b> misfit, 0–200 %, smaller is better '
'· <b>ANG</b> striation to predicted shear in degrees, smaller is '
'better. SIGMN and TAU are the point’s Mohr coordinates.<br>'
'<b>02</b> line: principal values (Σ S² = 3/2) and '
'Φ = (S2−S3)/(S1−S3). '
'<i>Column names are added here for reading; the file itself has none, '
'and saving is unaffected.</i>'
)
MOHR1_TIP = (
'MOHR1, five columns per fault, in this order:\n\n'
' SIGMN normal stress on the fault plane, sigma_n = n.T.n\n'
' TAU magnitude of the shear stress, |tau|\n'
' TAUST shear resolved along the OBSERVED striation, s.tau\n'
' equals TAU x cos(ANG), so TAUST <= TAU always\n'
' RUP misfit, 100|upsilon|/lambda with lambda = sqrt(3)/2.\n'
' Runs 0 to 200 per cent. Angelier: under 50 acceptable,\n'
' under 25 good. SMALLER IS BETTER.\n'
' ANG angle between the observed striation and the shear the\n'
' solution predicts, in degrees. SMALLER IS BETTER.\n\n'
'The 02 line holds the three principal values of the normalised\n'
'tensor (sum of squares = 3/2) and the shape ratio Phi.\n\n'
'SIGMN and TAU are exactly the x and y of the point on the Mohr\n'
'diagram; that is what the file was written for.\n\n'
'MOHR1 does not carry SIGMA, RMU or OBL. INFO1 does.')
INFO1_KEY = (
'Per fault: <b>SIGMA</b> |σ| · <b>SIGMN</b> σ<sub>n</sub> · '
'<b>TAU</b> |τ| · <b>TAUST</b> τ·s · '
'<b>RMU</b> |τ|/|σ<sub>n</sub>| · '
'<b>RUP</b> misfit % · <b>OBL</b> arctan(|σ<sub>n</sub>|/|τ|) '
'· <b>ANG</b> striation to predicted shear. '
'First four are ×100. RUP and ANG: smaller is better.'
)
INFO1_TIP = (
'INFO1 prints its own column header, and adds three columns that\n'
'MOHR1 leaves out:\n\n'
' SIGMA magnitude of the whole stress vector on the plane, |T.n|\n'
' RMU |tau| / |sigma_n|, a friction-like ratio, printed x100\n'
' OBL obliquity, arctan(|sigma_n|/|tau|), in degrees\n\n'
'The first four columns are scaled by 100.\n\n'
'In the summary block, RUP <75 and ANG <45 are two DIFFERENT\n'
'statistics: the first is over all data, the second only over the\n'
'subset below the threshold. They are not the same column twice.')
def heading(text):
lab = QtWidgets.QLabel(text.upper())
lab.setObjectName('heading')
return lab
def rule():
"""A hairline between sidebar sections. Delineates without the group gaps
that were rejected in the Argon Pipeline work: the column stays tight."""
f = QtWidgets.QFrame()
f.setObjectName('rule')
f.setFrameShape(QtWidgets.QFrame.HLine)
f.setFixedHeight(1)
return f
def _looks_like_session(path):
"""True for a pyTECTOR session under some name other than .tec.
A cheap sniff, not a parse: session files are JSON and start with
'{', which a TENSOR site file -- fixed-width columns, no braces -- never
does.
"""
try:
with open(path, encoding='utf-8', errors='replace') as fh:
head = fh.read(200).lstrip()
except OSError:
return False
return head.startswith('{') and '"program"' in head
# ------------------------------------------------------------------ worker --
class Worker(QtCore.QThread):
done = QtCore.pyqtSignal(object)
failed = QtCore.pyqtSignal(str)
def __init__(self, n, s, do_a, do_b, n_pass, lam_printed=None,
parent=None):
super(Worker, self).__init__(parent)
self.n, self.s = n, s
self.do_a, self.do_b, self.n_pass = do_a, do_b, n_pass
self.lam_printed = lam_printed
def run(self):
try:
out = {}
if self.do_a:
r = invdir.run(self.n, self.s, n_pass=self.n_pass,
lam_printed=self.lam_printed)
res = core.summary(r['T'], self.n, self.s)
res['T'] = r['T']
res['T_invdir'] = r['T_invdir'] # so a session can store it
res['lambda_trace'] = r['lambda_trace']
# the pre-PSIDIR solution, so INFO1 can print both blocks
res['invdir_summary'] = core.summary(r['T_invdir'],
self.n, self.s)
out['A'] = res
if self.do_b:
r = modern.run(self.n, self.s, n_starts=400)
res = core.summary(r['T'], self.n, self.s)
res['T'] = r['T']
out['B'] = res
self.done.emit(out)
except Exception:
self.failed.emit(traceback.format_exc())
# ------------------------------------------------------------ entry widget --
class EntryRow(QtWidgets.QWidget):
"""Four segmented fields with auto-advance. Enter commits."""
submitted = QtCore.pyqtSignal(object)
WIDTHS = (2, 3, 4, 4)
HINTS = ('CS', '122', '87W', '124')
TIPS = ('confidence C / P / S then movement I / N / S / D',
'strike, 000 to 360',
'dip and its quadrant, e.g. 87W',
'pitch and quadrant e.g. 62N, or a bare trend e.g. 124')
def __init__(self, parent=None):
super(EntryRow, self).__init__(parent)
lay = QtWidgets.QHBoxLayout(self)
lay.setContentsMargins(0, 0, 0, 0)
lay.setSpacing(3)
self.fields = []
for i, (w, hint, tip) in enumerate(zip(self.WIDTHS, self.HINTS,
self.TIPS)):
e = QtWidgets.QLineEdit()
e.setObjectName('seg')
e.setMaxLength(w)
e.setAlignment(QtCore.Qt.AlignCenter)
e.setFixedWidth(15 * w + 20)
e.setPlaceholderText(hint)
e.setToolTip(tip)
e.textEdited.connect(self._advance)
e.returnPressed.connect(self.commit)
e.installEventFilter(self)
self.fields.append(e)
lay.addWidget(e)
if i < len(self.WIDTHS) - 1:
d = QtWidgets.QLabel('-')
d.setStyleSheet('color:%s;' % MUTED)
lay.addWidget(d)
lay.addStretch(1)
def eventFilter(self, obj, ev):
# backspace in an empty field walks back one field
if (ev.type() == QtCore.QEvent.KeyPress
and ev.key() == QtCore.Qt.Key_Backspace
and isinstance(obj, QtWidgets.QLineEdit)
and not obj.text()):
i = self.fields.index(obj)
if i > 0:
self.fields[i - 1].setFocus()
self.fields[i - 1].setCursorPosition(
len(self.fields[i - 1].text()))
return True
return False
def _advance(self, _t):
src = self.sender()
i = self.fields.index(src)
if len(src.text()) >= src.maxLength() and i < len(self.fields) - 1:
self.fields[i + 1].setFocus()
self.fields[i + 1].selectAll()
def commit(self):
vals = [e.text().strip() for e in self.fields]
if not any(vals):
return
try:
rec = entry.parse_record(*vals)
except entry.RecordError as exc:
QtWidgets.QMessageBox.warning(self, 'pyTECTOR', str(exc))
return
self.submitted.emit(rec)
for e in self.fields:
e.clear()
self.fields[0].setFocus()
def focus(self):
self.fields[0].setFocus()
# ------------------------------------------------------------------ panel --
class Panel(QtWidgets.QFrame):
"""A framed panel that paints a DOS double-line border in 1991 mode.
Two nested rectangles a few pixels apart, which is what a terminal drew
when it printed the box characters. Qt stylesheets cannot express a double
border, so the retro stylesheet drops the border entirely and this paints
it instead.
"""
GAP = 3
def __init__(self, name='panel', parent=None):
super(Panel, self).__init__(parent)
self.setObjectName(name)
self.retro = False
def set_retro(self, on):
self.retro = bool(on)
self.update()
def paintEvent(self, ev):
super(Panel, self).paintEvent(ev)
if not self.retro:
return
p = QtGui.QPainter(self)
pen = QtGui.QPen(QtGui.QColor(retro.WHITE))
pen.setWidth(1)
p.setPen(pen)
r = self.rect().adjusted(0, 0, -1, -1)
p.drawRect(r)
p.drawRect(r.adjusted(self.GAP, self.GAP, -self.GAP, -self.GAP))
p.end()
# ----------------------------------------------------------- result widget --
class ResultStrip(Panel):
"""One row of results. Principal axes and the shape ratio go at full size;
only n and S4 are allowed to be small and grey."""
#: (axis labels, ratio label). The French is Angelier's own wording from
#: INFO1: 'AXIS SIGMA 1' and 'RATIO PHI' become 'AXE SIGMA 1' and
#: 'RAPPORT PHI'.
WORDS = {False: (('σ₁', 'σ₂', 'σ₃'), 'Φ'),
True: (('AXE SIGMA 1', 'AXE SIGMA 2', 'AXE SIGMA 3'),
'RAPPORT PHI')}
def __init__(self, title, parent=None):
super(ResultStrip, self).__init__('panel', parent)
self.words = ResultStrip.WORDS[False]
self._last = None
lay = QtWidgets.QVBoxLayout(self)
lay.setContentsMargins(11, 8, 11, 9)
lay.setSpacing(3)
top = QtWidgets.QHBoxLayout()
top.setSpacing(8)
self.title = heading(title)
top.addWidget(self.title)
top.addStretch(1)
self.small = QtWidgets.QLabel('')
self.small.setObjectName('secondary')
top.addWidget(self.small)
lay.addLayout(top)
row = QtWidgets.QHBoxLayout()
row.setSpacing(18)
self.axis_labels = []
for sym in SYM:
lab = QtWidgets.QLabel('%s -' % sym)
lab.setObjectName('axis')
self.axis_labels.append(lab)
row.addWidget(lab)
row.addSpacing(6)
self.phi = QtWidgets.QLabel('%s -' % PHI)
self.phi.setObjectName('value')
row.addWidget(self.phi)
self.ang = QtWidgets.QLabel('ANG -')
self.ang.setObjectName('value')
row.addWidget(self.ang)
self.rup = QtWidgets.QLabel('RUP -')
self.rup.setObjectName('value')
row.addWidget(self.rup)
row.addStretch(1)
lay.addLayout(row)
def set_language(self, retro_on):
"""Switch between the symbols and Angelier's own French wording."""
self.words = ResultStrip.WORDS[bool(retro_on)]
if self._last is None:
self.clear()
else:
self.show_result(*self._last)
def clear(self):
syms, phi = self.words
for lab, sym in zip(self.axis_labels, syms):
lab.setText('%s -' % sym)
self.phi.setText('%s -' % phi)
self.ang.setText('ANG -')
self.rup.setText('RUP -')
self.small.setText('')
self._last = None
def show_result(self, r, n_data=None):
self._last = (r, n_data)
syms, phi_lab = self.words
for lab, sym, key in zip(self.axis_labels, syms, AXES):
tr, pl = r[key]
lab.setText('%s %03d/%02d' % (sym, int(round(tr)) % 360,
int(round(pl))))
self.phi.setText('%s %.3f' % (phi_lab, r['phi']))
if 'ANG_mean' in r:
self.ang.setText('ANG %.1f%s' % (r['ANG_mean'], DEG))
self.rup.setText('RUP %.0f%%' % r['RUP_mean'])
bits = []
if n_data is not None:
bits.append('n %d' % n_data)
if 'S4' in r:
bits.append('S4 %.4f' % r['S4'])
if 'n_rup1' in r:
bits.append('RUP>75 %d' % r['n_rup1'])
self.small.setText(' '.join(bits))
# ------------------------------------------------------------------- main ---
class Main(QtWidgets.QMainWindow):
def __init__(self):
super(Main, self).__init__()
self.setWindowTitle('pyTECTOR')
self.resize(1500, 950)
self.records = []
self.results = {}
self.archive = None
self.bt_window = None
self.planes = []
self._loading = False
self.site_name = '01'
self.site_code = '01'
self.archive_lambda = None
self._build()
self._refresh()
@property
def plot_name(self):
return self.site_name
# ------------------------------------------------------------ layout --
def _build(self):
tb = self.addToolBar('main')
tb.setMovable(False)
tb.setToolButtonStyle(QtCore.Qt.ToolButtonTextOnly)
tb.addAction('Open site').triggered.connect(self.open_site)
a = tb.addAction('Open session')
a.setToolTip('Reopen a saved working state: the records, the reference '
'surfaces, the settings and the solutions already found.')
a.triggered.connect(self.open_session)
a = tb.addAction('Save session')
a.setToolTip('Write everything to one file so none of it has to be '
'entered or inverted again.')
a.triggered.connect(self.save_session)
# 'Scan folder' and 'Clear' were here. The first opened a folder only
# to show a bare list of paths; the Survey window lists the same runs
# with their numbers beside them and a double click opens one, so the
# button was the worse way in. The second is covered twice over:
# Open site replaces everything, and the fault table's own Delete
# takes a selection, so select-all-Delete empties it.
tb.addSeparator()
self.cb_a = QtWidgets.QCheckBox('INVDIR')
self.cb_a.setChecked(True)
self.cb_a.setToolTip(
"Angelier's direct inversion exactly as TENSOR 5.45 runs it, "
'including the lambda that stops before it converges. Use this to '
'reproduce archive numbers.')
self.cb_b = QtWidgets.QCheckBox('S4MIN')
self.cb_b.setChecked(True)
self.cb_b.setToolTip(
'The exact minimum of the same S4, lambda held at sqrt(3)/2. '
'Lower S4 on every archive site, but the criterion itself is '
'biased, so this is not "the true stress" either.')
tb.addWidget(self.cb_a)
tb.addWidget(self.cb_b)
self.cb_fit = QtWidgets.QCheckBox('Fitted shear')
self.cb_fit.setChecked(False)
self.cb_fit.setToolTip(
'Extra panel: the same fault planes carrying the shear stress the '
'solution predicts. Useful for spotting a datum whose observed '
'slip runs against the solution; off by default.')
self.cb_fit.toggled.connect(lambda _v: self._draw())
tb.addWidget(self.cb_fit)
self.cb_arrows = QtWidgets.QCheckBox('Arrows')
self.cb_arrows.setChecked(True)
self.cb_arrows.setToolTip(
'The heavy compression and extension arrows outside the circle.\n\n'
'In the original these were NOT computed: DIAGRA lists them under '
'"SPECIAL CODES" and asks "AZIMUTH OF ARROWS [0-360] ?", so '
'whoever made the plate typed each direction in by hand. An '
'archive plate can therefore carry both pairs, one, or none, '
'whatever its tensor.\n\n'
'Drawing them from sigma1 and sigma3 reproduces 85 of the 90 '
'archive runs. Turn this off to match one of the five that carry '
'no arrows, such as QS0711-1.')
self.cb_arrows.toggled.connect(lambda _v: self._draw())
tb.addWidget(self.cb_arrows)
lab = QtWidgets.QLabel(' INVDIR pass ')
lab.setStyleSheet('color:%s;' % MUTED)
tb.addWidget(lab)
self.sp_pass = QtWidgets.QSpinBox()
self.sp_pass.setRange(1, 8)
self.sp_pass.setToolTip('the "(NO k)" printed in the original INFO1')
tb.addWidget(self.sp_pass)
self.cb_lam = QtWidgets.QCheckBox('archive LAMBDA')
self.cb_lam.setEnabled(False)
self.cb_lam.setToolTip(
'Adopt the LAMBDA the site\'s own INFO1 records instead of '
're-deriving it. Where the surface is flat, re-deriving can land a '
'degree away with a worse fit; adopting the recorded value '
'reproduces that historical run. Only available when the site '
'came with an INFO1.')
tb.addWidget(self.cb_lam)
lab = QtWidgets.QLabel(' decl ')
lab.setStyleSheet('color:%s;' % MUTED)
lab.setToolTip('magnetic declination')
tb.addWidget(lab)
self.ed_decl = QtWidgets.QLineEdit()
self.ed_decl.setObjectName('seg')
self.ed_decl.setFixedWidth(52)
self.ed_decl.setAlignment(QtCore.Qt.AlignCenter)
self.ed_decl.setMaxLength(6)
self.ed_decl.setText('%.2f' % plot.MAGNETIC_OFFSET)
self.ed_decl.setToolTip(
'Where the M mark is drawn, in degrees east of geographic north. '
'The archive draws it at a fixed 1.95. This moves the mark only; '
'it does NOT rotate the data, so results never change behind your '
'back.')
self.ed_decl.textEdited.connect(lambda _t: self._draw())
tb.addWidget(self.ed_decl)
tb.addSeparator()
self.btn_run = QtWidgets.QPushButton('INVERT')
self.btn_run.setObjectName('run')
self.btn_run.setShortcut('Ctrl+Return')
self.btn_run.setToolTip('Ctrl+Enter')
self.btn_run.clicked.connect(self.invert)
tb.addWidget(self.btn_run)
# Back-tilting has a window of its own. This window shows the data as
# measured and nothing else, so a stereogram here never needs a caption
# to say which orientation it is in.
act = tb.addAction('Back-tilt')
act.setToolTip('Restore a tilted site in a separate window, measured '
'and restored side by side.')
act.triggered.connect(self.open_backtilt)
# This window answers "what is the stress at this site". A study asks
# what a whole set of sites says, and that question had no way in from
# the interface at all: it lived only in make_survey.py.
act = tb.addAction('Survey')
act.setToolTip('Many runs at once: assign phases, add coordinates, '
'and get a table, map points and a rose per phase.')
act.triggered.connect(self.open_survey)
tb.addSeparator()
tb.addAction('Save PNG').triggered.connect(self.save_png)
tb.addAction('Save HPGL').triggered.connect(self.save_hpgl)
tb.addAction('Save INFO1').triggered.connect(
lambda: self._save_report('INFO1'))
tb.addAction('Save MOHR1').triggered.connect(
lambda: self._save_report('MOHR1'))
spacer = QtWidgets.QWidget()
spacer.setSizePolicy(QtWidgets.QSizePolicy.Expanding,
QtWidgets.QSizePolicy.Preferred)
tb.addWidget(spacer)
# only appears once 1991 mode is on, so it is a way back rather than a
# spoiler sitting in the toolbar from the start
self.act_1991 = tb.addAction('MODE 1991 ×')
self.act_1991.setToolTip('back to the normal interface')
self.act_1991.setVisible(False)
self.act_1991.triggered.connect(lambda: self.toggle_1991(False))
tb.addAction('About').triggered.connect(self.show_about)
split = QtWidgets.QSplitter(QtCore.Qt.Horizontal)
split.setChildrenCollapsible(False)
self.setCentralWidget(split)
split.addWidget(self._sidebar())
split.addWidget(self._workspace())
split.setStretchFactor(1, 1)
split.setSizes([350, 1150])
self.status = self.statusBar()
self.progress = QtWidgets.QProgressBar()
self.progress.setRange(0, 0)
self.progress.setFixedWidth(120)
self.progress.hide()
self.status.addPermanentWidget(self.progress)
self.status.showMessage('type a record, for example CS 122 87W 124')
def _sidebar(self):
w = QtWidgets.QWidget()
v = QtWidgets.QVBoxLayout(w)
v.setContentsMargins(10, 10, 6, 10)
v.setSpacing(3) # tight, no group gaps
v.addWidget(heading('site'))
self.ed_site = QtWidgets.QLineEdit(self.site_name)
self.ed_site.textEdited.connect(self._rename)
v.addWidget(self.ed_site)
v.addSpacing(4)
v.addWidget(rule())
v.addSpacing(4)
v.addWidget(heading('new record'))
self.entry = EntryRow()
self.entry.submitted.connect(self.add_record)
v.addWidget(self.entry)
leg = QtWidgets.QLabel(
'C certain · P probable · S suppose\n'
'I inverse · N normal · S senestral · D dextral')
leg.setObjectName('legend')
v.addWidget(leg)
v.addSpacing(4)
v.addWidget(rule())
v.addSpacing(4)
v.addWidget(heading('reference planes'))
row = QtWidgets.QHBoxLayout()
row.setSpacing(3)
self.cmb_ptype = QtWidgets.QComboBox()
self.cmb_ptype.addItems(['plane', 'pole'])
self.cmb_ptype.setFixedWidth(64)
self.cmb_ptype.currentIndexChanged.connect(self._ptype_changed)
row.addWidget(self.cmb_ptype)
self.pl_fields = []
for _ in range(2):
e = QtWidgets.QLineEdit()
e.setObjectName('seg')
e.setFixedWidth(54)
e.setMaxLength(4)
e.setAlignment(QtCore.Qt.AlignCenter)
e.returnPressed.connect(self.add_plane)
self.pl_fields.append(e)
row.addWidget(e)
b = QtWidgets.QPushButton('Add')
b.clicked.connect(self.add_plane)
row.addWidget(b)
row.addStretch(1)
v.addLayout(row)
self.list_planes = QtWidgets.QListWidget()
self.list_planes.setMaximumHeight(84)
self.list_planes.setToolTip(
'Double-click a surface to make it the back-tilt reference. It is '
'then drawn with a longer dash, and the back-tilt window offers to '
'restore it to horizontal.')
self.list_planes.itemDoubleClicked.connect(self._star_plane)
v.addWidget(self.list_planes)
self._ptype_changed()
row = QtWidgets.QHBoxLayout()
row.setSpacing(3)
b = QtWidgets.QPushButton('Set as reference')
b.clicked.connect(lambda: self._star_plane(
self.list_planes.currentItem()))
row.addWidget(b)
b = QtWidgets.QPushButton('Remove')
b.clicked.connect(self.remove_plane)
row.addWidget(b)
row.addStretch(1)
v.addLayout(row)
v.addSpacing(4)
v.addWidget(rule())
v.addSpacing(4)
v.addWidget(heading('fault slips'))
self.tbl = QtWidgets.QTableWidget(0, 7)
self.tbl.setHorizontalHeaderLabels(
['#', 'use', 'type', 'as typed', 'strike', 'dip', 'rake'])
self.tbl.verticalHeader().hide()
self.tbl.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectRows)
self.tbl.setEditTriggers(QtWidgets.QAbstractItemView.NoEditTriggers)
# 'as typed' absorbs the slack. Stretching the LAST column instead
# pushes rake off the right edge as soon as the sidebar is narrow.
hh = self.tbl.horizontalHeader()
hh.setStretchLastSection(False)
for i, wd in enumerate((22, 28, 32, 60, 40, 38, 36)):
self.tbl.setColumnWidth(i, wd)
hh.setSectionResizeMode(i, QtWidgets.QHeaderView.Fixed)
hh.setSectionResizeMode(3, QtWidgets.QHeaderView.Stretch)
hh.setMinimumSectionSize(20)
self.tbl.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff)
self.tbl.verticalHeader().setDefaultSectionSize(19)
self.tbl.itemChanged.connect(self._use_changed)
v.addWidget(self.tbl, 1)
row = QtWidgets.QHBoxLayout()
row.setSpacing(3)
self.lbl_count = QtWidgets.QLabel('0 faults')
self.lbl_count.setObjectName('count')
row.addWidget(self.lbl_count)
row.addStretch(1)
b = QtWidgets.QPushButton('Delete')
b.setShortcut(QtGui.QKeySequence.Delete)
b.clicked.connect(self.delete_selected)
row.addWidget(b)
v.addLayout(row)
return w
def _workspace(self):
split = QtWidgets.QSplitter(QtCore.Qt.Vertical)
split.setChildrenCollapsible(False)
holder = Panel('plotpanel')
self.plot_holder = holder
hv = QtWidgets.QVBoxLayout(holder)
hv.setContentsMargins(4, 4, 4, 4)
hv.setSpacing(4)
# What is on screen must never be in doubt. One bar, always present:
# what the data are on the left, which state is drawn on the right.
bar = QtWidgets.QHBoxLayout()
bar.setSpacing(8)
bar.setContentsMargins(6, 3, 6, 1)
self.lbl_context = QtWidgets.QLabel('')
self.lbl_context.setObjectName('context')
bar.addWidget(self.lbl_context)
bar.addStretch(1)
self.lbl_stale = QtWidgets.QLabel('')
self.lbl_stale.setObjectName('stale')
self.lbl_stale.hide()
bar.addWidget(self.lbl_stale)
hv.addLayout(bar)
self.fig = Figure(figsize=(11, 5.4), facecolor='white')
self.canvas = Canvas(self.fig)
# captions are sized to the panel they land in, so they
# have to be re-sized when the panel changes
self.canvas.mpl_connect(
'resize_event', lambda _e: plot.fit_captions(self.fig))
hv.addWidget(self.canvas, 1)
split.addWidget(holder)
self.tabs = QtWidgets.QTabWidget()
page = QtWidgets.QWidget()
v = QtWidgets.QVBoxLayout(page)
v.setContentsMargins(8, 8, 8, 8)
v.setSpacing(4)
self.strip_ar = ResultStrip('archive what the old run recorded')
self.strip_ar.hide()
self.strip_a = ResultStrip('%s %s' % (MODES[0][1], MODES[0][3]))
self.strip_b = ResultStrip('%s %s' % (MODES[1][1], MODES[1][3]))
v.addWidget(self.strip_ar)
v.addWidget(self.strip_a)
v.addWidget(self.strip_b)
self.lbl_diff = QtWidgets.QLabel('')
self.lbl_diff.setObjectName('secondary')
self.lbl_diff.setContentsMargins(12, 2, 0, 0)
self.lbl_diff.setWordWrap(True)
v.addWidget(self.lbl_diff)
v.addStretch(1)
self.tabs.addTab(page, 'Results')
self.txt_info = QtWidgets.QPlainTextEdit()
self.txt_mohr = QtWidgets.QPlainTextEdit()
for t in (self.txt_info, self.txt_mohr):
t.setReadOnly(True)
t.setObjectName('report') # picks up the monospace rule
t.setLineWrapMode(QtWidgets.QPlainTextEdit.NoWrap)
self.tabs.addTab(self._report_tab(self.txt_info, INFO1_KEY, INFO1_TIP),
'INFO1')
self.tabs.addTab(self._report_tab(self.txt_mohr, MOHR1_KEY, MOHR1_TIP),
'MOHR1')
split.addWidget(self.tabs)
split.setStretchFactor(0, 3)
split.setSizes([560, 330])
return split
def _report_tab(self, pane, key, tip):
"""A report pane with its column key above it.
MOHR1 carries no header of its own -- the original writes five bare
columns of numbers and expects you to know the order -- so without this
the tab is unreadable unless you already know the format. The key lives
in a label rather than in the text because the text has to stay a
byte-for-byte match for what TENSOR writes.
"""
page = QtWidgets.QWidget()
v = QtWidgets.QVBoxLayout(page)
v.setContentsMargins(0, 0, 0, 0)
v.setSpacing(3)
lab = QtWidgets.QLabel(key)
lab.setObjectName('legend')
lab.setWordWrap(True)
lab.setTextFormat(QtCore.Qt.RichText)
lab.setContentsMargins(6, 4, 6, 0)
lab.setToolTip(tip)
pane.setToolTip(tip)
v.addWidget(lab)
v.addWidget(pane, 1)
return page
# -------------------------------------------------- reference planes --
def _ptype_changed(self, *_a):
"""Planes are entered as strike and dip with a quadrant, the same
convention as the fault records. Poles as trend and plunge."""
plane = self.cmb_ptype.currentText() == 'plane'
hints = ('122', '87W') if plane else ('045', '12')
tips = (('strike, 000 to 360', 'dip and its quadrant, e.g. 87W')
if plane else ('pole trend', 'pole plunge'))
for e, h, t in zip(self.pl_fields, hints, tips):
e.setPlaceholderText(h)
e.setToolTip(t)
def add_plane(self):
"""A surface, given as strike and dip or by its pole. Any number may
be entered; one of them can drive the back-tilt."""
txt = [e.text().strip() for e in self.pl_fields]
if not all(txt):
return
kind = self.cmb_ptype.currentText()
try:
if kind == 'plane':
if not txt[0].isdigit():
raise entry.RecordError('strike: "%s"' % txt[0])
strike = int(txt[0]) % 360
dip, quad = entry._split_num_quad(txt[1], 'dip')
if not 0 <= dip <= 90:
raise entry.RecordError('dip must be 0-90')
dipaz = entry.dip_azimuth(strike, quad)
a, b = float(strike), txt[1].upper()
else: # a pole names its own plane
trend, plunge = float(txt[0]) % 360.0, float(txt[1])
if not 0 <= plunge <= 90:
raise entry.RecordError('plunge must be 0-90')
dipaz, dip = (trend + 180.0) % 360.0, 90.0 - plunge
a, b = trend, plunge
except (entry.RecordError, ValueError) as exc:
QtWidgets.QMessageBox.warning(self, 'pyTECTOR', str(exc))
return
self.planes.append(dict(kind=kind, a=a, b=b, dipaz=dipaz, dip=dip,
ref=not any(p['ref'] for p in self.planes)))
for e in self.pl_fields:
e.clear()
self.pl_fields[0].setFocus()
self._refresh_planes()
def remove_plane(self):
i = self.list_planes.currentRow()
if 0 <= i < len(self.planes):
was_ref = self.planes[i]['ref']
del self.planes[i]
if was_ref and self.planes:
self.planes[0]['ref'] = True
self._refresh_planes()
def _star_plane(self, item):
if item is None:
return
i = self.list_planes.row(item)
for k, p in enumerate(self.planes):
p['ref'] = (k == i)
self._refresh_planes()
def _refresh_planes(self):
self.list_planes.clear()
for p in self.planes:
mark = '*' if p['ref'] else ' '
if p['kind'] == 'plane':
shown = '%03.0f %s' % (p['a'], p['b'])
else:
shown = '%03.0f / %02.0f' % (p['a'], p['b'])
self.list_planes.addItem(
'%s %-5s %-9s dip az %03.0f / %02.0f'
% (mark, p['kind'], shown, p['dipaz'], p['dip']))
self._draw()
def ref_plane(self):
"""The surface marked as the back-tilt reference, if any."""
for p in self.planes:
if p['ref']:
return (p['dipaz'], p['dip'])
return None
# --------------------------------------------------------- back-tilt --
def open_backtilt(self):
"""Open the back-tilt window, or raise it if it is already up.
Deliberately a separate window. Sharing one stereogram between measured
and restored data meant its meaning depended on a selector elsewhere on
screen, and the measured axes disappeared the moment a rotation was
applied. The other window shows both states at once and always says
which is which.
"""
if getattr(self, 'bt_window', None) is None:
self.bt_window = backtilt.BackTiltWindow(self)
else:
self.bt_window.reload()
self.bt_window.show()
self.bt_window.raise_()
self.bt_window.activateWindow()
def open_survey(self):
"""Open the survey window, or raise it if it is already up.
Kept alive between openings rather than rebuilt: the phase and type
columns are the user's own judgement, typed in by hand, and throwing
that away because a window was closed would be indefensible.
"""
from pytector import surveyui
if getattr(self, 'sv_window', None) is None:
self.sv_window = surveyui.SurveyWindow(self)
self.sv_window.show()
self.sv_window.raise_()
self.sv_window.activateWindow()
# -------------------------------------------------------------- data --
@property
def active(self):
"""The faults with their switch on. Everything downstream, the
inversion and the plots, uses only these; an excluded datum stays in
the table greyed out so the decision is visible and reversible."""
return [r for r in self.records if r.get('use', True)]
@property
def n_s(self):
"""Fault normals and slips, as measured. This window never rotates
anything; that is what the back-tilt window is for."""
return entry.records_to_arrays(self.active)
def reference_now(self, rot=None):
"""Every entered surface, for the dashed overlay.
rot turns them with the data, so on a back-tilted panel a correct
restoration is visible: the reference circle flattens onto the
primitive and its pole walks in to the centre.
"""
if not self.planes:
return None
out = []
for p in self.planes:
az, dp = p['dipaz'], p['dip']
if rot:
v = core.normal_from_dipaz(az, dp)
v = rotate.rotate_vectors(np.atleast_2d(v), *rot)[0]
az, dp = plot.reference_from_vectors(v)
out.append((az, dp, p['ref']))
return out
@property
def confidence(self):
return [r.get('confidence', 'C') for r in self.active]
@property
def sides(self):
"""Which side the barb sits on, from the strike-slip component."""
act = self.active
if not act:
return np.zeros(0)
return plot.strike_slip_sign(
[r['dipaz'] for r in act], [r['dip'] for r in act],
[r['rake'] + tensorfile.RAKE_OFFSET for r in act])
def _rename(self, txt):
self.site_name = txt or '01'
self._draw()
def add_record(self, rec):
rec['confidence'] = (rec.get('sense') or 'C')[0:1].upper()
rec['code'] = rec.get('sense', '')
self.records.append(rec)
self.results = {}
self._refresh()
self.status.showMessage('%d fault slips' % len(self.records))
def delete_selected(self):
rows = sorted({i.row() for i in self.tbl.selectedIndexes()},
reverse=True)
for r in rows:
if 0 <= r < len(self.records):
del self.records[r]
if rows:
self.results = {}
self._refresh()
@staticmethod
def quadrant(dipaz):
"""The single letter the field notation uses for the dip direction."""
a = float(dipaz) % 360.0
if a < 45 or a >= 315:
return 'N'
if a < 135:
return 'E'
if a < 225:
return 'S'
return 'W'
def _use_changed(self, item):
if self._loading or item.column() != 1:
return
i = item.row()
if 0 <= i < len(self.records):
on = item.checkState() == QtCore.Qt.Checked
if self.records[i].get('use', True) != on:
self.records[i]['use'] = on
self.results = {}
self._refresh()
def _refresh(self):
self._loading = True
self.tbl.setRowCount(len(self.records))
for i, r in enumerate(self.records):
# the entry convention here is strike and dip, not dip azimuth
strike = (r['dipaz'] - 90.0) % 360.0
vals = ['%d' % (i + 1), None, r.get('sense') or r.get('code', ''),
r.get('tail', ''), '%03.0f' % strike,
'%02d%s' % (r['dip'], self.quadrant(r['dipaz'])),
'%.0f' % r['rake']]
for j, val in enumerate(vals):
if j == 1:
it = QtWidgets.QTableWidgetItem()
it.setFlags(QtCore.Qt.ItemIsUserCheckable
| QtCore.Qt.ItemIsEnabled
| QtCore.Qt.ItemIsSelectable)
it.setCheckState(QtCore.Qt.Checked
if r.get('use', True)
else QtCore.Qt.Unchecked)
else:
it = QtWidgets.QTableWidgetItem(str(val))
if j in (0, 4, 5, 6):
it.setTextAlignment(QtCore.Qt.AlignRight
| QtCore.Qt.AlignVCenter)
if not r.get('use', True):
it.setForeground(QtGui.QBrush(QtGui.QColor('#A9A59C')))
self.tbl.setItem(i, j, it)
self._loading = False
used = len(self.active)
total = len(self.records)
self.lbl_count.setText('%d fault%s' % (used, '' if used == 1 else 's')