-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathGUI_batch_download_plot_FAST.py
More file actions
2163 lines (1946 loc) · 90.8 KB
/
Copy pathGUI_batch_download_plot_FAST.py
File metadata and controls
2163 lines (1946 loc) · 90.8 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 -*-
# !/usr/bin/env python
"""
GUI for downloading and batch-plotting FAST ESA CDF data.
Provides a PySide6 Material-Design interface with three pages:
- **Download** — select instruments, years, and output folder; delegates to
``FAST_CDF_download.FAST_ESA_CDF_download`` via a background QThread.
- **Plot** — select data/output folders, axis scales, colormap, cusp marker
style, and noise percentile; delegates to
``batch_multi_plot_FAST_spectrograms.FAST_plot_spectrograms_directory``
via a background QThread.
- **Single Day** — render one CDF file's pitch-angle grid (defaults to
downloading by date and instrument), or one orbit's multi-instrument grid
from a data folder, without running a full batch; delegates to
``configurable_spectrograms.fast.plotting`` via a background QThread.
"""
from __future__ import annotations
__authors__: list[str] = ["Ev Hansen"]
__contact__: str = "ephansen+gh@terpmail.umd.edu"
__credits__: list[list[str]] = [
["Ev Hansen", "Python code"],
["Emma Mirizio", "Co-Mentor"],
["Marilia Samara", "Co-Mentor"],
]
__date__: str = "2025-06-17"
__status__: str = "Development"
__version__: str = "0.0.1"
__license__: str = "GPL-3.0"
import datetime as dt
import functools
import multiprocessing
import os
import sys
from pathlib import Path
from PySide6.QtCore import QDate, Qt, QThread, Signal
from PySide6.QtGui import QCloseEvent, QColor, QFont, QIcon, QIntValidator, QPainter, QPixmap
from PySide6.QtWidgets import (
QApplication,
QCheckBox,
QComboBox,
QDateEdit,
QFileDialog,
QFrame,
QGridLayout,
QHBoxLayout,
QLabel,
QLineEdit,
QMainWindow,
QMessageBox,
QPushButton,
QScrollArea,
QSizePolicy,
QSlider,
QStackedWidget,
QVBoxLayout,
QWidget,
)
from qt_material import apply_stylesheet, QtStyleTools
from qt_material_icons import MaterialIcon
# PySide6 re-exposes each scoped enum member as a flat, backward-compatible
# alias on the enclosing class (e.g. Qt.AlignCenter for
# Qt.AlignmentFlag.AlignCenter). These aliases exist and work at runtime,
# but ty's PySide6 stubs don't model them, so uses of them below carry an
# unresolved-attribute suppression directive.
# --- Constants ---
MIN_YEAR, MAX_YEAR = 1996, 2009
INSTRUMENT_OPTIONS: tuple[str, ...] = ("eeb", "ees", "esv", "ieb", "ies")
SCALE_OPTIONS: tuple[str, ...] = ("linear", "log")
COLORMAP_OPTIONS: tuple[str, ...] = (
"viridis",
"cividis",
"plasma",
"inferno",
"turbo",
)
CUSP_STYLE_OPTIONS: tuple[str, ...] = ("both", "line", "bracket")
PLOT_MODE_OPTIONS: tuple[str, ...] = ("Single Instrument (pitch-angle grid)", "Full Orbit (instrument grid)")
#: Full Orbit mode's three ways of supplying its instrument CDF files.
DATA_SOURCE_OPTIONS: tuple[str, ...] = (
"Data Folder + Orbit Number",
"Select Files (one per instrument)",
"Download by Date",
)
#: Single Instrument mode's two ways of supplying its one CDF file.
SINGLE_SOURCE_OPTIONS: tuple[str, ...] = ("Download by Date", "Select File")
#: Instruments supported by FAST_plot_instrument_grid (matches DEFAULT_INSTRUMENT_ORDER).
FULL_ORBIT_INSTRUMENTS: tuple[str, ...] = ("eeb", "ees", "ieb", "ies")
DEFAULT_NOISE_PERCENTILE: float = 90.0
#: Default CDF data folder (input or output) and plot output folder, so
#: users with an existing local setup can hit Confirm without browsing.
DEFAULT_CDF_FOLDER: str = "./FAST_data"
DEFAULT_PLOT_FOLDER: str = "./FAST_plots"
THEME_DARK, THEME_LIGHT = "dark_teal.xml", "light_purple.xml"
PRIMARY: str = os.environ.get("QTMATERIAL_PRIMARYCOLOR", "#2196F3")
ERROR: str = "#d32f2f"
# --- Subprocess target functions (module-level for multiprocessing "spawn" pickling) ---
def _download_year_in_process(instruments: set, year: int, data_folder: str) -> None:
"""Run inside a child process; imports kept local to avoid spawn overhead."""
from FAST_CDF_download import FAST_ESA_CDF_download
FAST_ESA_CDF_download(instruments=list(instruments), year=year, data_folder=data_folder)
def _plot_in_process(
directory_path: str,
output_base: str,
y_scale: str,
z_scale: str,
verbose: bool,
use_tqdm: bool,
colormap: str,
max_processing_percentile: float,
cusp_marker_style: str = "line",
) -> None:
"""Run inside a child process; imports kept local to avoid spawn overhead."""
from batch_multi_plot_FAST_spectrograms import FAST_plot_spectrograms_directory
FAST_plot_spectrograms_directory(
directory_path=directory_path,
output_base=output_base,
y_scale=y_scale,
z_scale=z_scale,
verbose=verbose,
use_tqdm=use_tqdm,
colormap=colormap,
max_processing_percentile=max_processing_percentile,
cusp_marker_style=cusp_marker_style,
)
def _single_plot_in_process(
mode: str,
cdf_file_path: str,
data_source: str,
orbit_folder_path: str,
orbit_number: int | None,
instrument_file_paths: dict[str, str],
download_date: dt.date | None,
download_instruments: set[str],
output_folder: str,
y_scale: str,
z_scale: str,
colormap: str,
cusp_marker_style: str,
) -> None:
"""Run inside a child process; imports kept local to avoid spawn overhead.
Renders either a single CDF's pitch-angle grid (``mode ==
"single_instrument"``, orbit number auto-parsed from the filename) or
one orbit's multi-instrument grid (``mode == "full_orbit"``), saving
each figure under ``{output_folder}/{year}/{month}/{orbit}/``, matching
the batch plotting pipeline's directory hierarchy. ``data_source ==
"download"`` fetches *download_date* from CDA Web first, for
*download_instruments* -- for Single Instrument mode, every orbit pass
found that day for every selected instrument is plotted, each saved as
its own figure (one per instrument per orbit); for Full Orbit mode, all
Full Orbit instruments are fetched for one combined figure of the day's
shared orbit. Full Orbit mode's other sources resolve its instrument CDF
files without downloading: ``"folder"`` discovers them from
*orbit_folder_path* and *orbit_number*; ``"files"`` uses the
caller-supplied *instrument_file_paths* directly.
"""
from configurable_spectrograms.cdf_utils import (
get_cdf_file_type,
get_timestamps_for_orbit,
load_fast_cdf_dataset,
load_filtered_orbits,
)
from configurable_spectrograms.download import download_single_day_cdf
from configurable_spectrograms.fast.orbit_discovery import (
_parse_year_month,
discover_orbit_files,
extract_orbit_and_instrument,
resolve_orbit_from_files,
resolve_shared_orbit,
)
from configurable_spectrograms.fast.plotting import FAST_plot_instrument_grid, FAST_plot_pitch_angle_grid
filtered_orbits_df = load_filtered_orbits()
def _cusp_tag(file_path: str, orbit: int | None, instrument: str | None) -> str:
"""Return "_cusp" when the orbit has cusp-boundary markers, else ""."""
if orbit is None or instrument is None:
return ""
try:
time_unix_array = load_fast_cdf_dataset(file_path)["times"]
has_lines = bool(get_timestamps_for_orbit(filtered_orbits_df, orbit, instrument, time_unix_array))
except Exception:
return ""
return "_cusp" if has_lines else ""
if mode == "single_instrument":
if data_source == "download":
assert download_date is not None
instruments_to_download = sorted(download_instruments)
day_files = download_single_day_cdf(date=download_date, instruments=instruments_to_download)
files_to_plot = [path for instrument in instruments_to_download for path in day_files.get(instrument, [])]
if not files_to_plot:
names = ", ".join(i.upper() for i in instruments_to_download)
raise RuntimeError(f"No {names} CDF file found for {download_date.isoformat()}.")
else:
files_to_plot = [cdf_file_path]
saved_count = 0
for file_path in files_to_plot:
parsed = extract_orbit_and_instrument(file_path)
resolved_orbit = parsed[0] if parsed is not None else None
instrument_type = parsed[1] if parsed is not None else get_cdf_file_type(file_path)
fig, _canvas = FAST_plot_pitch_angle_grid(
file_path,
filtered_orbits_df=filtered_orbits_df,
orbit_number=resolved_orbit,
scale_function_y=y_scale,
scale_function_z=z_scale,
show=False,
colormap=colormap,
cusp_marker_style=cusp_marker_style,
)
if fig is None:
continue
year, month = _parse_year_month(file_path)
orbit_label = resolved_orbit if resolved_orbit is not None else "unknown"
output_dir = os.path.join(output_folder, str(year), str(month), str(orbit_label))
os.makedirs(output_dir, exist_ok=True)
cusp_tag = _cusp_tag(file_path, resolved_orbit, instrument_type)
fname = (
f"{orbit_label}{cusp_tag}_pitch-angle_ESA_{instrument_type}_y-{y_scale}_z-{z_scale}_raw-{colormap}.png"
)
fig.savefig(os.path.join(output_dir, fname), dpi=200)
saved_count += 1
if saved_count == 0:
raise RuntimeError("No data available to plot for the given input.")
else:
if data_source == "files":
instrument_files = instrument_file_paths
resolved_orbit = resolve_orbit_from_files(instrument_files)
elif data_source == "download":
assert download_date is not None
day_files = download_single_day_cdf(date=download_date, instruments=list(download_instruments))
resolved_orbit, instrument_files = resolve_shared_orbit(day_files)
else:
resolved_orbit = orbit_number
instrument_files = discover_orbit_files(orbit_folder_path).get(orbit_number, {})
if not instrument_files:
raise RuntimeError("No instrument CDF files found for the given input.")
fig, _canvas = FAST_plot_instrument_grid(
instrument_files,
filtered_orbits_df=filtered_orbits_df,
orbit_number=resolved_orbit,
scale_function_y=y_scale,
scale_function_z=z_scale,
show=False,
colormap=colormap,
cusp_marker_style=cusp_marker_style,
)
if fig is None:
raise RuntimeError("No data available to plot for the given input.")
first_path = next(iter(instrument_files.values()), None)
year, month = _parse_year_month(first_path) if first_path else ("unknown", "unknown")
orbit_label = resolved_orbit if resolved_orbit is not None else "unknown"
output_dir = os.path.join(output_folder, str(year), str(month), str(orbit_label))
os.makedirs(output_dir, exist_ok=True)
fname = f"{orbit_label}_instrument-grid_ESA_y-{y_scale}_z-{z_scale}_raw-{colormap}.png"
fig.savefig(os.path.join(output_dir, fname), dpi=200)
# --- Supplemental stylesheet ---
# Custom tokens resolved by _make_extra_stylesheet():
# %(BTN_TEXT_COLOR)s — button icon/text (white dark, #3c3c3c light)
# %(CHIP_BORDER_COLOR)s — semi-transparent chip outline
# %(CONTENT_TEXT_COLOR)s — body/input text readable on the current theme
_EXTRA_CSS: str = """
QWidget#sidebar { border-right: 1px solid %(QTMATERIAL_SECONDARYDARKCOLOR)s; padding: 0px; }
QWidget#root { padding: 0px; }
QPushButton#navBtn {
background-color: transparent; border: none; border-radius: 16px;
font-size: 11px; padding: 4px 0px; text-align: center;
color: %(BTN_TEXT_COLOR)s;
}
QPushButton#navBtn:hover { background-color: %(QTMATERIAL_SECONDARYLIGHTCOLOR)s; }
QPushButton#navBtn[selected="true"] {
background-color: %(QTMATERIAL_SECONDARYLIGHTCOLOR)s;
color: %(QTMATERIAL_PRIMARYCOLOR)s; font-weight: 600;
}
QPushButton#chip {
border: 1px solid %(CHIP_BORDER_COLOR)s; border-radius: 8px;
font-size: 13px; font-weight: 500; padding: 6px 18px; min-width: 52px;
color: %(BTN_TEXT_COLOR)s; text-transform: none;
}
QPushButton#chip[selected="true"] {
background-color: %(QTMATERIAL_PRIMARYCOLOR)s;
color: %(QTMATERIAL_PRIMARYTEXTCOLOR)s;
border: 1px solid %(QTMATERIAL_PRIMARYCOLOR)s;
}
QPushButton#ctaBtn, QPushButton#folderBtn { color: %(BTN_TEXT_COLOR)s; }
QPushButton#ctaBtn:disabled, QPushButton#folderBtn:disabled { color: %(DISABLED_TEXT_COLOR)s; }
QLabel#folderPath { font-size: 12px; font-style: italic; }
QLabel#noteText { font-size: 11px; font-style: italic; }
QFrame#divider { border: none; max-height: 1px; }
QLabel#pageTitle { font-size: 22px; font-weight: 700; letter-spacing: -0.3px; }
QLabel#sectionTitle { font-size: 15px; font-weight: 600; }
QLabel#bodyText { font-size: 13px; }
QLabel#statusLabel { font-size: 12px; font-weight: 500; }
QComboBox#styledCombo, QLineEdit#percentileEntry { color: %(CONTENT_TEXT_COLOR)s; }
/* QAbstractItemView popup is a top-level window; descendant selector won't match it */
QAbstractItemView { color: %(CONTENT_TEXT_COLOR)s; }
QScrollBar:vertical { background: %(SCROLLBAR_TRACK_COLOR)s; width: 8px; margin: 0px; }
/* Override qt_material's opacity(0.1) default — one rule per line; Qt QSS drops all but
the last selector in a comma group when sub-controls are involved */
QScrollBar::handle { background: %(QTMATERIAL_PRIMARYCOLOR)s; border-radius: 4px; min-height: 24px; }
QScrollBar::handle:vertical { background: %(QTMATERIAL_PRIMARYCOLOR)s; border-radius: 4px; min-height: 24px; }
QScrollBar::handle:horizontal { background: %(QTMATERIAL_PRIMARYCOLOR)s; border-radius: 4px; min-height: 24px; }
QScrollBar::handle:vertical:hover { background: %(QTMATERIAL_PRIMARYCOLOR)s; }
QScrollBar::handle:horizontal:hover { background: %(QTMATERIAL_PRIMARYCOLOR)s; }
QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { height: 0px; }
QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical { background: none; }
"""
# --- Theme helpers ---
def _system_is_dark() -> bool:
"""Return ``True`` when the OS colour scheme is dark.
Prefers the Qt 6.5+ ``styleHints().colorScheme()`` API; falls back to
measuring the luminance of the window background colour on older builds.
"""
hints = QApplication.styleHints()
if hasattr(hints, "colorScheme"):
return hints.colorScheme() == Qt.ColorScheme.Dark
palette = QApplication.palette()
return palette.color(palette.Window).lightnessF() < 0.5 # ty: ignore[unresolved-attribute]
def _make_extra_stylesheet(is_dark: bool) -> str:
"""Substitute dynamic colour tokens into ``_EXTRA_CSS`` for the current theme.
Uses an explicit token dict rather than spreading all of ``os.environ`` to
avoid collisions with arbitrary environment variable values.
Parameters
----------
is_dark : bool
``True`` for the dark theme variant; ``False`` for light.
Returns
-------
str
Resolved CSS string ready to append to the application stylesheet.
"""
# light_purple secondaryDarkColor is #e6e6e6 (near-white), so pin an
# explicit foreground rather than relying on the theme value.
fg = "#ffffff" if is_dark else "#3c3c3c"
return _EXTRA_CSS % {
"QTMATERIAL_SECONDARYDARKCOLOR": os.environ.get("QTMATERIAL_SECONDARYDARKCOLOR", "#37474f"),
"QTMATERIAL_SECONDARYLIGHTCOLOR": os.environ.get("QTMATERIAL_SECONDARYLIGHTCOLOR", "#cfd8dc"),
"QTMATERIAL_PRIMARYCOLOR": os.environ.get("QTMATERIAL_PRIMARYCOLOR", "#2196F3"),
"QTMATERIAL_PRIMARYTEXTCOLOR": os.environ.get("QTMATERIAL_PRIMARYTEXTCOLOR", "#ffffff"),
"CONTENT_TEXT_COLOR": fg,
"BTN_TEXT_COLOR": fg,
"DISABLED_TEXT_COLOR": "rgba(255,255,255,0.38)" if is_dark else "#9e9e9e",
"CHIP_BORDER_COLOR": ("rgba(255,255,255,0.45)" if is_dark else "rgba(0,0,0,0.28)"),
"SCROLLBAR_TRACK_COLOR": ("rgba(255,255,255,0.12)" if is_dark else "rgba(0,0,0,0.10)"),
}
def _apply_app_theme(app: QApplication, is_dark: bool) -> None:
"""Apply qt_material theme, supplemental CSS, and Roboto font to *app*.
Parameters
----------
app : QApplication
The running application instance.
is_dark : bool
``True`` selects dark_teal; ``False`` selects light_purple.
"""
apply_stylesheet(app, theme=THEME_DARK if is_dark else THEME_LIGHT, extra={})
app.setStyleSheet(app.styleSheet() + _make_extra_stylesheet(is_dark))
font = QFont("Roboto", 10)
font.setHintingPreference(QFont.PreferFullHinting) # ty: ignore[unresolved-attribute]
app.setFont(font)
def _colored_pixmap(icon: QIcon, size: int, hex_color: str) -> QPixmap:
"""Return a copy of *icon* recoloured to *hex_color* at *size* × *size* px.
Copies the icon pixmap (preserving its device pixel ratio) then applies
``SourceIn`` composition to replace every pixel's RGB with *hex_color*
while keeping the original alpha channel (icon shape).
Parameters
----------
icon : QIcon
Source icon whose shape (alpha channel) is used as the mask.
size : int
Width and height of the output pixmap in pixels.
hex_color : str
Target colour as a hex string (e.g. ``'#ffffff'``).
Returns
-------
QPixmap
Recoloured pixmap with premultiplied alpha matching the source shape.
"""
result = icon.pixmap(size, size).copy() # .copy() preserves devicePixelRatio
painter = QPainter(result)
painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_SourceIn)
painter.fillRect(result.rect(), QColor(hex_color))
painter.end()
return result
# --- UI builder helpers ---
def _make_scroll_page(parent: QWidget) -> tuple[QVBoxLayout, QScrollArea]:
"""Wrap *parent* in a frameless, horizontally-locked scroll area.
Returns the inner ``QVBoxLayout`` and the ``QScrollArea`` so callers can
set widget-level scrollbar CSS that survives Qt style re-polish events.
"""
outer = QVBoxLayout(parent)
outer.setContentsMargins(0, 0, 0, 0)
scroll = QScrollArea()
scroll.setWidgetResizable(True)
scroll.setFrameShape(QFrame.NoFrame) # ty: ignore[unresolved-attribute]
scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) # ty: ignore[unresolved-attribute]
container = QWidget()
scroll.setWidget(container)
layout = QVBoxLayout(container)
layout.setContentsMargins(40, 32, 40, 32)
layout.setSpacing(0)
outer.addWidget(scroll)
return layout, scroll
def _wrap_section(layout: QVBoxLayout) -> tuple[QWidget, QVBoxLayout]:
"""Add a zero-margin container widget to *layout* and return it with its own inner layout.
Content built inside the returned inner layout (labels, rows, and their
``addSpacing`` gaps) collapses to zero height when the container is
hidden via ``setVisible(False)`` -- unlike spacer items added directly
to a shared outer layout, which stay put regardless of sibling widget
visibility and leave a stranded gap. Callers that toggle a whole group
of widgets together should build that group inside one such container.
"""
container = QWidget()
inner = QVBoxLayout(container)
inner.setContentsMargins(0, 0, 0, 0)
inner.setSpacing(0)
layout.addWidget(container)
return container, inner
def _add_divider(layout: QVBoxLayout, before: int = 28, after: int = 24) -> None:
"""Insert a horizontal rule into *layout* with optional surrounding spacing.
Parameters
----------
layout : QVBoxLayout
Target layout.
before : int, default 28
Pixels above the divider; 0 to suppress.
after : int, default 24
Pixels below the divider; 0 to suppress.
"""
if before:
layout.addSpacing(before)
divider = QFrame()
divider.setObjectName("divider")
divider.setFrameShape(QFrame.HLine) # ty: ignore[unresolved-attribute]
layout.addWidget(divider)
if after:
layout.addSpacing(after)
def _section_label(
layout: QVBoxLayout,
text: str,
spacing: int = 10,
point_size: int | None = None,
) -> QLabel:
"""Add a ``sectionTitle``-styled label to *layout* and return it.
Parameters
----------
layout : QVBoxLayout
Target layout.
text : str
Label text.
spacing : int, default 10
Pixels added below the label.
point_size : int or None, optional
Font point-size override; inherits the stylesheet default when ``None``.
Returns
-------
QLabel
The created label widget.
"""
lbl = QLabel(text)
lbl.setObjectName("sectionTitle")
if point_size is not None:
font = lbl.font()
font.setPointSize(point_size)
lbl.setFont(font)
layout.addWidget(lbl)
layout.addSpacing(spacing)
return lbl
def _page_header(layout: QVBoxLayout, title: str, body: str) -> None:
"""Insert a large page title and word-wrapped description into *layout*.
Parameters
----------
layout : QVBoxLayout
Target layout.
title : str
Large page title (``pageTitle`` style).
body : str
Paragraph placed under a "Description" sub-heading.
"""
title_lbl = QLabel(title)
title_lbl.setObjectName("pageTitle")
layout.addWidget(title_lbl)
layout.addSpacing(20)
_section_label(layout, "Description", spacing=8)
body_lbl = QLabel(body)
body_lbl.setObjectName("bodyText")
body_lbl.setWordWrap(True)
layout.addWidget(body_lbl)
def _folder_selector(
layout: QVBoxLayout,
click_fn,
*,
note: str | None = None,
initial: str | None = None,
) -> tuple[QPushButton, QLabel]:
"""Add a folder-selector button row to *layout* and return its widgets.
An optional italicised hint note is prepended above the button. The
returned path label is updated by the caller after a folder is chosen.
Parameters
----------
layout : QVBoxLayout
Target layout.
click_fn : callable
Slot invoked when the "Select Folder" button is clicked.
note : str or None, optional
Hint text rendered above the button row.
initial : str or None, optional
Pre-filled folder path shown (truncated) in the path label instead
of "No folder selected" -- e.g. a default folder so users with an
existing setup don't need to browse before confirming.
Returns
-------
tuple[QPushButton, QLabel]
The folder button and the path display label.
"""
if note:
note_lbl = QLabel(note)
note_lbl.setObjectName("noteText")
note_lbl.setWordWrap(True)
layout.addWidget(note_lbl)
layout.addSpacing(10)
row = QHBoxLayout()
row.setSpacing(12)
row.setContentsMargins(0, 0, 0, 0)
btn = QPushButton(" Select Folder")
btn.setIcon(QIcon(_colored_pixmap(MaterialIcon("folder_open"), 24, "#ffffff")))
btn.setObjectName("folderBtn")
btn.setFixedHeight(38)
btn.clicked.connect(click_fn)
row.addWidget(btn)
row.addStretch()
layout.addLayout(row)
layout.addSpacing(6)
path_lbl = QLabel(_truncated_path(initial) if initial else "No folder selected")
path_lbl.setObjectName("folderPath")
layout.addWidget(path_lbl)
return btn, path_lbl
def _add_toggle_section(
layout: QVBoxLayout,
title: str,
check_label: str,
note: str,
initial: bool,
slot,
) -> QCheckBox:
"""Add a titled checkbox with a hint note to *layout* and return it.
Parameters
----------
layout : QVBoxLayout
Target layout.
title : str
Section-title text displayed above the checkbox.
check_label : str
Text displayed next to the checkbox.
note : str
Italicised hint rendered below the checkbox.
initial : bool
Initial checked state.
slot : callable
Connected to ``QCheckBox.toggled``; receives the new ``bool`` state.
Returns
-------
QCheckBox
The created checkbox widget.
"""
_section_label(layout, title, spacing=8)
cb = QCheckBox(check_label)
cb.setObjectName("toggleCheck")
cb.setChecked(initial)
cb.toggled.connect(slot)
layout.addWidget(cb)
layout.addSpacing(4)
note_lbl = QLabel(note)
note_lbl.setObjectName("noteText")
note_lbl.setWordWrap(True)
layout.addWidget(note_lbl)
return cb
def _make_combo(options: tuple[str, ...], default: str) -> QComboBox:
"""Return a ``styledCombo`` QComboBox pre-populated with *options* and *default* selected."""
combo = QComboBox()
combo.setObjectName("styledCombo")
combo.addItems(options)
combo.setCurrentText(default)
return combo
def _percentile_slider_section(layout: QVBoxLayout, initial: float) -> tuple[QSlider, QLineEdit, QLabel]:
"""Add a linked noise-cutoff percentile slider + entry + hint note to *layout*.
The slider (0-100) and the numeric entry stay in sync with each other;
callers connect to ``slider.valueChanged`` / ``entry.editingFinished``
themselves since the note text and stored value differ per page.
Parameters
----------
layout : QVBoxLayout
Target layout.
initial : float
Initial percentile value for both the slider and the entry field.
Returns
-------
tuple[QSlider, QLineEdit, QLabel]
The slider, the numeric entry field, and the hint note label (whose
text the caller should update via its own change handlers).
"""
pct_row = QHBoxLayout()
pct_row.setSpacing(14)
pct_row.setContentsMargins(0, 0, 0, 0)
slider = QSlider(Qt.Horizontal) # ty: ignore[unresolved-attribute]
slider.setObjectName("percentileSlider")
slider.setRange(0, 100)
slider.setValue(int(initial))
slider.setFixedHeight(32)
slider.setMaximumWidth(400)
entry = QLineEdit(str(int(initial)))
entry.setObjectName("percentileEntry")
entry.setFixedHeight(36)
entry.setMaximumWidth(72)
entry.setAlignment(Qt.AlignCenter) # ty: ignore[unresolved-attribute]
pct_row.addWidget(slider)
pct_row.addWidget(entry)
pct_row.addStretch()
layout.addLayout(pct_row)
layout.addSpacing(4)
note_lbl = QLabel()
note_lbl.setObjectName("noteText")
note_lbl.setWordWrap(True)
layout.addWidget(note_lbl)
return slider, entry, note_lbl
def _make_stop_btn(click_fn) -> QPushButton:
"""Return a pre-styled red stop button, initially hidden, connected to *click_fn*."""
btn = QPushButton(" Stop")
btn.setIcon(QIcon(_colored_pixmap(MaterialIcon("stop"), 24, "#ffffff")))
btn.setFixedHeight(48)
btn.setVisible(False)
btn.clicked.connect(click_fn)
btn.setStyleSheet(
"QPushButton { background-color: #c62828; color: #ffffff; border: none;"
" border-radius: 4px; padding: 0 18px; font-size: 13px; font-weight: 500; }"
" QPushButton:hover { background-color: #b71c1c; }"
" QPushButton:pressed { background-color: #7f0000; }"
)
return btn
def _scrollbar_css(primary: str, track: str) -> str:
"""Return a QScrollBar widget stylesheet pinning handle colour to *primary*."""
return (
f"QScrollBar:vertical {{ background: {track}; width: 8px; margin: 0px; }}"
f" QScrollBar::handle:vertical {{ background: {primary}; border-radius: 4px; min-height: 24px; }}"
" QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { height: 0px; }"
" QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical { background: none; }"
)
def _truncated_path(path: str, max_len: int = 60) -> str:
"""Return *path* with a leading ellipsis when longer than *max_len* characters."""
return path if len(path) < max_len else "…" + path[-(max_len - 3) :]
# --- Background worker threads ---
class _BaseWorker(QThread):
"""Shared base providing the four standard signals and ``request_stop``."""
progress: Signal = Signal(str)
# Deliberately redeclares QThread's own no-argument `finished` signal:
# this one fires only on confirmed success, distinct from QThread's
# built-in `finished` (which fires whenever run() returns, including
# after an error or a requested stop).
finished: Signal = Signal() # ty: ignore[invalid-attribute-override]
stopped: Signal = Signal()
error: Signal = Signal(str)
def __init__(self, parent=None) -> None:
super().__init__(parent)
self._proc: multiprocessing.Process | None = None
def request_stop(self) -> None:
"""Set the interruption flag and kill the active subprocess immediately."""
self.requestInterruption()
if (proc := self._proc) is not None and proc.is_alive():
proc.kill()
class DownloadWorker(_BaseWorker):
"""Background thread that downloads FAST ESA CDF files via CDA Web.
Parameters
----------
given_instruments : set[str]
Instrument codes to download (e.g. ``{'eeb', 'ees'}``).
years : list[int]
Calendar years to fetch.
data_folder : str
Root directory where downloaded files are saved.
parent : QObject or None, optional
Qt parent object.
"""
def __init__(
self,
given_instruments: set[str],
years: list[int],
data_folder: str,
parent=None,
) -> None:
super().__init__(parent)
self.given_instruments = given_instruments
self.years = years
self.data_folder = data_folder
def run(self) -> None:
"""Execute the download loop; emits ``finished``, ``stopped``, or ``error``."""
try:
for year in self.years:
if self.isInterruptionRequested():
self.stopped.emit()
return
self.progress.emit(f"Downloading year {year}…")
self._proc = multiprocessing.Process(
target=_download_year_in_process,
args=(self.given_instruments, year, self.data_folder),
)
self._proc.start()
self._proc.join()
exitcode, self._proc = self._proc.exitcode, None
if self.isInterruptionRequested():
self.stopped.emit()
return
if exitcode != 0:
self.error.emit(f"Download process exited with code {exitcode} for year {year}")
return
self.finished.emit()
except Exception as exc:
self.error.emit(str(exc))
class PlotWorker(_BaseWorker):
"""Background thread that generates FAST ESA spectrograms.
Parameters
----------
directory_path : str
Root directory containing FAST CDF files.
output_base : str
Base directory for output PNG files.
y_scale : {'linear', 'log'}
Energy (y-axis) scaling.
z_scale : {'linear', 'log'}
Intensity (color) scaling.
verbose : bool
Whether to emit detailed log output.
use_tqdm : bool
Whether to show a tqdm progress bar in the console.
colormap : str
Matplotlib colormap name.
max_processing_percentile : float
Upper noise-cutoff percentile forwarded to the batch plotter.
cusp_marker_style : str, default 'line'
Cusp-boundary marker style forwarded to the batch plotter.
parent : QObject or None, optional
Qt parent object.
"""
def __init__(
self,
directory_path: str,
output_base: str,
y_scale: str,
z_scale: str,
verbose: bool,
use_tqdm: bool,
colormap: str,
max_processing_percentile: float,
cusp_marker_style: str = "line",
parent=None,
) -> None:
super().__init__(parent)
self.directory_path = directory_path
self.output_base = output_base
self.y_scale = y_scale
self.z_scale = z_scale
self.verbose = verbose
self.use_tqdm = use_tqdm
self.colormap = colormap
self.max_processing_percentile = max_processing_percentile
self.cusp_marker_style = cusp_marker_style
def run(self) -> None:
"""Execute the plotting pass; emits ``finished``, ``stopped``, or ``error``."""
try:
self.progress.emit("Generating spectrograms…")
self._proc = multiprocessing.Process(
target=_plot_in_process,
kwargs=dict(
directory_path=self.directory_path,
output_base=self.output_base,
y_scale=self.y_scale,
z_scale=self.z_scale,
verbose=self.verbose,
use_tqdm=self.use_tqdm,
colormap=self.colormap,
max_processing_percentile=self.max_processing_percentile,
cusp_marker_style=self.cusp_marker_style,
),
)
self._proc.start()
self._proc.join()
exitcode, self._proc = self._proc.exitcode, None
if self.isInterruptionRequested():
self.stopped.emit()
elif exitcode == 0:
self.finished.emit()
else:
self.error.emit(f"Plotting process exited with code {exitcode}")
except Exception as exc:
self.error.emit(str(exc))
class SinglePlotWorker(_BaseWorker):
"""Background thread that renders one FAST ESA spectrogram figure.
Parameters
----------
mode : {'single_instrument', 'full_orbit'}
Which single-output plotting function to call.
cdf_file_path : str
Path to one CDF file (used when ``mode == 'single_instrument'``).
data_source : {'folder', 'files', 'download'}
How ``mode == 'full_orbit'`` obtains its instrument CDF files:
discovered from ``orbit_folder_path`` + ``orbit_number``, supplied
directly via ``instrument_file_paths``, or fetched from CDA Web for
``download_date`` / ``download_instruments``.
orbit_folder_path : str
Root data folder (used when ``data_source == 'folder'``).
orbit_number : int or None
Orbit number to resolve within ``orbit_folder_path`` (used when
``data_source == 'folder'``).
instrument_file_paths : dict of {str: str}
Instrument -> CDF file path mapping (used when
``data_source == 'files'``).
download_date : datetime.date or None
Calendar day to fetch (used when ``data_source == 'download'``).
download_instruments : set of str
Instrument codes to fetch (used when ``data_source == 'download'``).
output_folder : str
Root output folder; figures are saved under
``{output_folder}/{year}/{month}/{orbit}/``, matching the batch
plotting pipeline's directory hierarchy. ``mode == 'single_instrument'``
with ``data_source == 'download'`` saves one figure per orbit pass
found that day, for every selected instrument.
y_scale, z_scale : {'linear', 'log'}
Axis scaling.
colormap : str
Matplotlib colormap name.
cusp_marker_style : {'line', 'bracket'}
Cusp-boundary marker style.
parent : QObject or None, optional
Qt parent object.
"""
def __init__(
self,
mode: str,
cdf_file_path: str,
data_source: str,
orbit_folder_path: str,
orbit_number: int | None,
instrument_file_paths: dict[str, str],
download_date: dt.date | None,
download_instruments: set[str],
output_folder: str,
y_scale: str,
z_scale: str,
colormap: str,
cusp_marker_style: str,
parent=None,
) -> None:
super().__init__(parent)
self.mode = mode
self.cdf_file_path = cdf_file_path
self.data_source = data_source
self.orbit_folder_path = orbit_folder_path
self.orbit_number = orbit_number
self.instrument_file_paths = instrument_file_paths
self.download_date = download_date
self.download_instruments = download_instruments
self.output_folder = output_folder
self.y_scale = y_scale
self.z_scale = z_scale
self.colormap = colormap
self.cusp_marker_style = cusp_marker_style
def run(self) -> None:
"""Execute the single-plot render; emits ``finished``, ``stopped``, or ``error``."""
try:
self.progress.emit("Generating spectrogram…")
self._proc = multiprocessing.Process(
target=_single_plot_in_process,
kwargs=dict(
mode=self.mode,
cdf_file_path=self.cdf_file_path,
data_source=self.data_source,
orbit_folder_path=self.orbit_folder_path,
orbit_number=self.orbit_number,
instrument_file_paths=self.instrument_file_paths,
download_date=self.download_date,
download_instruments=self.download_instruments,
output_folder=self.output_folder,
y_scale=self.y_scale,
z_scale=self.z_scale,
colormap=self.colormap,
cusp_marker_style=self.cusp_marker_style,
),
)
self._proc.start()
self._proc.join()
exitcode, self._proc = self._proc.exitcode, None
if self.isInterruptionRequested():
self.stopped.emit()
elif exitcode == 0:
self.finished.emit()
else:
self.error.emit(f"Plotting process exited with code {exitcode}")
except Exception as exc:
self.error.emit(str(exc))
# --- Custom widgets ---