isobath 0.1.0__tar.gz
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.
- isobath-0.1.0/.gitignore +31 -0
- isobath-0.1.0/LICENSE +21 -0
- isobath-0.1.0/PKG-INFO +118 -0
- isobath-0.1.0/README.md +69 -0
- isobath-0.1.0/isobath/__init__.py +1 -0
- isobath-0.1.0/isobath/__main__.py +662 -0
- isobath-0.1.0/isobath/area_selector.py +229 -0
- isobath-0.1.0/isobath/lasso_selector.py +55 -0
- isobath-0.1.0/isobath/sample.nc +0 -0
- isobath-0.1.0/pyproject.toml +43 -0
isobath-0.1.0/.gitignore
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*.pyo
|
|
5
|
+
*.pyd
|
|
6
|
+
*.egg
|
|
7
|
+
*.egg-info/
|
|
8
|
+
.eggs/
|
|
9
|
+
|
|
10
|
+
# Build & distribution
|
|
11
|
+
build/
|
|
12
|
+
dist/
|
|
13
|
+
*.whl
|
|
14
|
+
|
|
15
|
+
# Virtual environments
|
|
16
|
+
.venv/
|
|
17
|
+
venv/
|
|
18
|
+
env/
|
|
19
|
+
|
|
20
|
+
# IDE
|
|
21
|
+
.vscode/
|
|
22
|
+
.idea/
|
|
23
|
+
*.swp
|
|
24
|
+
*.swo
|
|
25
|
+
|
|
26
|
+
# OS
|
|
27
|
+
.DS_Store
|
|
28
|
+
Thumbs.db
|
|
29
|
+
|
|
30
|
+
# Output files
|
|
31
|
+
file_coord_angle.txt
|
isobath-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Amol Prakash
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
isobath-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: isobath
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A GUI tool for computing slope angles along depth contours from NetCDF bathymetry data
|
|
5
|
+
Project-URL: Homepage, https://github.com/p-amol/isobath
|
|
6
|
+
Project-URL: Issues, https://github.com/p-amol/isobath/issues
|
|
7
|
+
Author-email: "P. Amol" <prakashamol@gmail.com>
|
|
8
|
+
License: MIT License
|
|
9
|
+
|
|
10
|
+
Copyright (c) 2026 Amol Prakash
|
|
11
|
+
|
|
12
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
13
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
14
|
+
in the Software without restriction, including without limitation the rights
|
|
15
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
16
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
17
|
+
furnished to do so, subject to the following conditions:
|
|
18
|
+
|
|
19
|
+
The above copyright notice and this permission notice shall be included in all
|
|
20
|
+
copies or substantial portions of the Software.
|
|
21
|
+
|
|
22
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
23
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
24
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
25
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
26
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
27
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
28
|
+
SOFTWARE.
|
|
29
|
+
License-File: LICENSE
|
|
30
|
+
Keywords: bathymetry,gui,isobath,netcdf,oceanography,slope
|
|
31
|
+
Classifier: Development Status :: 3 - Alpha
|
|
32
|
+
Classifier: Intended Audience :: Science/Research
|
|
33
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
34
|
+
Classifier: Programming Language :: Python :: 3
|
|
35
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
36
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
37
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
38
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
39
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
40
|
+
Classifier: Topic :: Scientific/Engineering :: Atmospheric Science
|
|
41
|
+
Classifier: Topic :: Scientific/Engineering :: Visualization
|
|
42
|
+
Requires-Python: >=3.8
|
|
43
|
+
Requires-Dist: matplotlib
|
|
44
|
+
Requires-Dist: netcdf4
|
|
45
|
+
Requires-Dist: numpy
|
|
46
|
+
Requires-Dist: pyqt6
|
|
47
|
+
Requires-Dist: scipy
|
|
48
|
+
Description-Content-Type: text/markdown
|
|
49
|
+
|
|
50
|
+
# isobath
|
|
51
|
+
|
|
52
|
+
A GUI tool for computing slope angles along depth contours (isobaths) from NetCDF bathymetry data.
|
|
53
|
+
|
|
54
|
+
## Features
|
|
55
|
+
|
|
56
|
+
- Load NetCDF bathymetry files and interactively select a region of interest
|
|
57
|
+
- Visualise depth contours on a map
|
|
58
|
+
- Compute slope angles at each coordinate interval along a chosen isobath using linear regression
|
|
59
|
+
- Step through intervals with Previous / Next navigation
|
|
60
|
+
- Remove outlier points using a lasso selector and recompute
|
|
61
|
+
- Export results as a two-column text file (coordinate, angle)
|
|
62
|
+
|
|
63
|
+
## Installation
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
pip install isobath
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Usage
|
|
70
|
+
|
|
71
|
+
Launch the GUI from the terminal:
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
isobath
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Or run as a module:
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
python -m isobath
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
### Workflow
|
|
84
|
+
|
|
85
|
+
1. Click **Open** to load a NetCDF file, or **Sample File** to load the bundled example dataset.
|
|
86
|
+
2. In the Area Selector window, choose the variable and its latitude/longitude dimensions, then draw a rectangle to subset the region of interest.
|
|
87
|
+
3. In the Settings panel (right dock), set the **Contour Depth** and adjust the coordinate axis, start/end points, averaging interval, and coordinate interval.
|
|
88
|
+
4. Click **Compute Angle** to perform the first linear regression and view the slope angle.
|
|
89
|
+
5. Use **Next** / **Previous** to step through coordinate intervals.
|
|
90
|
+
6. Use **Lasso** to select outlier points, then **Delete** to remove them and recompute. **Undo** restores the previous point set.
|
|
91
|
+
7. Click **Save** or **Save As** to export the results.
|
|
92
|
+
|
|
93
|
+
## Output
|
|
94
|
+
|
|
95
|
+
Results are saved as a plain text file with two columns:
|
|
96
|
+
|
|
97
|
+
```
|
|
98
|
+
coordinate angle (degrees)
|
|
99
|
+
8.0000 127.4396
|
|
100
|
+
9.0000 125.5481
|
|
101
|
+
...
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## Dependencies
|
|
105
|
+
|
|
106
|
+
- [PyQt6](https://pypi.org/project/PyQt6/)
|
|
107
|
+
- [matplotlib](https://matplotlib.org/)
|
|
108
|
+
- [numpy](https://numpy.org/)
|
|
109
|
+
- [netCDF4](https://unidata.github.io/netcdf4-python/)
|
|
110
|
+
- [scipy](https://scipy.org/)
|
|
111
|
+
|
|
112
|
+
## License
|
|
113
|
+
|
|
114
|
+
MIT License. See [LICENSE](LICENSE) for details.
|
|
115
|
+
|
|
116
|
+
## Note
|
|
117
|
+
|
|
118
|
+
[Claude](https://claude.ai) (Anthropic) was used to assist with refactoring, bug fixing, and packaging of this project.
|
isobath-0.1.0/README.md
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# isobath
|
|
2
|
+
|
|
3
|
+
A GUI tool for computing slope angles along depth contours (isobaths) from NetCDF bathymetry data.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- Load NetCDF bathymetry files and interactively select a region of interest
|
|
8
|
+
- Visualise depth contours on a map
|
|
9
|
+
- Compute slope angles at each coordinate interval along a chosen isobath using linear regression
|
|
10
|
+
- Step through intervals with Previous / Next navigation
|
|
11
|
+
- Remove outlier points using a lasso selector and recompute
|
|
12
|
+
- Export results as a two-column text file (coordinate, angle)
|
|
13
|
+
|
|
14
|
+
## Installation
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
pip install isobath
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Usage
|
|
21
|
+
|
|
22
|
+
Launch the GUI from the terminal:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
isobath
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Or run as a module:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
python -m isobath
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
### Workflow
|
|
35
|
+
|
|
36
|
+
1. Click **Open** to load a NetCDF file, or **Sample File** to load the bundled example dataset.
|
|
37
|
+
2. In the Area Selector window, choose the variable and its latitude/longitude dimensions, then draw a rectangle to subset the region of interest.
|
|
38
|
+
3. In the Settings panel (right dock), set the **Contour Depth** and adjust the coordinate axis, start/end points, averaging interval, and coordinate interval.
|
|
39
|
+
4. Click **Compute Angle** to perform the first linear regression and view the slope angle.
|
|
40
|
+
5. Use **Next** / **Previous** to step through coordinate intervals.
|
|
41
|
+
6. Use **Lasso** to select outlier points, then **Delete** to remove them and recompute. **Undo** restores the previous point set.
|
|
42
|
+
7. Click **Save** or **Save As** to export the results.
|
|
43
|
+
|
|
44
|
+
## Output
|
|
45
|
+
|
|
46
|
+
Results are saved as a plain text file with two columns:
|
|
47
|
+
|
|
48
|
+
```
|
|
49
|
+
coordinate angle (degrees)
|
|
50
|
+
8.0000 127.4396
|
|
51
|
+
9.0000 125.5481
|
|
52
|
+
...
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Dependencies
|
|
56
|
+
|
|
57
|
+
- [PyQt6](https://pypi.org/project/PyQt6/)
|
|
58
|
+
- [matplotlib](https://matplotlib.org/)
|
|
59
|
+
- [numpy](https://numpy.org/)
|
|
60
|
+
- [netCDF4](https://unidata.github.io/netcdf4-python/)
|
|
61
|
+
- [scipy](https://scipy.org/)
|
|
62
|
+
|
|
63
|
+
## License
|
|
64
|
+
|
|
65
|
+
MIT License. See [LICENSE](LICENSE) for details.
|
|
66
|
+
|
|
67
|
+
## Note
|
|
68
|
+
|
|
69
|
+
[Claude](https://claude.ai) (Anthropic) was used to assist with refactoring, bug fixing, and packaging of this project.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,662 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
import os
|
|
3
|
+
from PyQt6.QtWidgets import (
|
|
4
|
+
QApplication,
|
|
5
|
+
QPushButton,
|
|
6
|
+
QMainWindow,
|
|
7
|
+
QFileDialog,
|
|
8
|
+
QDockWidget,
|
|
9
|
+
QWidget,
|
|
10
|
+
QLabel,
|
|
11
|
+
QMessageBox,
|
|
12
|
+
QRadioButton,
|
|
13
|
+
QFormLayout,
|
|
14
|
+
QVBoxLayout,
|
|
15
|
+
QSpinBox,
|
|
16
|
+
QDoubleSpinBox,
|
|
17
|
+
)
|
|
18
|
+
from PyQt6.QtCore import Qt
|
|
19
|
+
|
|
20
|
+
from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas
|
|
21
|
+
from matplotlib.backends.backend_qt5agg import NavigationToolbar2QT as NavigationToolbar
|
|
22
|
+
import matplotlib.pyplot as plt
|
|
23
|
+
from matplotlib.path import Path
|
|
24
|
+
|
|
25
|
+
import numpy as np
|
|
26
|
+
import netCDF4 as nc
|
|
27
|
+
from scipy import stats
|
|
28
|
+
from .area_selector import WindowSelector
|
|
29
|
+
from .lasso_selector import SelectFromCollection
|
|
30
|
+
|
|
31
|
+
PACKAGE_DIR = os.path.dirname(__file__)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class NavigateWidget(QWidget):
|
|
35
|
+
def __init__(self, *args, **kwargs):
|
|
36
|
+
super().__init__(*args, **kwargs)
|
|
37
|
+
self.setWindowTitle("Navigation Settings")
|
|
38
|
+
|
|
39
|
+
fbox = QFormLayout()
|
|
40
|
+
|
|
41
|
+
text1 = QLabel("Contour Depth")
|
|
42
|
+
fbox.addRow(text1)
|
|
43
|
+
|
|
44
|
+
self.sbd = QDoubleSpinBox()
|
|
45
|
+
self.sbd.setMinimum(-5000)
|
|
46
|
+
self.sbd.setMaximum(5000)
|
|
47
|
+
fbox.addRow(self.sbd)
|
|
48
|
+
|
|
49
|
+
text2 = QLabel("Contour Number")
|
|
50
|
+
fbox.addRow(text2)
|
|
51
|
+
self.sb = QSpinBox()
|
|
52
|
+
self.sb.setMinimum(0)
|
|
53
|
+
self.sb.setMaximum(10)
|
|
54
|
+
fbox.addRow(self.sb)
|
|
55
|
+
|
|
56
|
+
text3 = QLabel("Coordinate Axis")
|
|
57
|
+
fbox.addRow(text3)
|
|
58
|
+
self.rb1 = QRadioButton("Latitude")
|
|
59
|
+
self.rb1.setChecked(True)
|
|
60
|
+
self.rb2 = QRadioButton("Longitude")
|
|
61
|
+
fbox.addRow(self.rb1)
|
|
62
|
+
fbox.addRow(self.rb2)
|
|
63
|
+
|
|
64
|
+
text4 = QLabel("Start Point")
|
|
65
|
+
fbox.addRow(text4)
|
|
66
|
+
self.start_point = QDoubleSpinBox()
|
|
67
|
+
self.start_point.setMinimum(-180)
|
|
68
|
+
self.start_point.setMaximum(180)
|
|
69
|
+
fbox.addRow(self.start_point)
|
|
70
|
+
|
|
71
|
+
text5 = QLabel("End Point")
|
|
72
|
+
fbox.addRow(text5)
|
|
73
|
+
self.end_point = QDoubleSpinBox()
|
|
74
|
+
self.end_point.setMinimum(-180)
|
|
75
|
+
self.end_point.setMaximum(180)
|
|
76
|
+
fbox.addRow(self.end_point)
|
|
77
|
+
|
|
78
|
+
text6 = QLabel("Average Interval")
|
|
79
|
+
fbox.addRow(text6)
|
|
80
|
+
self.avg_interval = QDoubleSpinBox()
|
|
81
|
+
self.avg_interval.setMinimum(0)
|
|
82
|
+
self.avg_interval.setMaximum(30)
|
|
83
|
+
fbox.addRow(self.avg_interval)
|
|
84
|
+
|
|
85
|
+
text7 = QLabel("Coordinate Interval")
|
|
86
|
+
fbox.addRow(text7)
|
|
87
|
+
self.coord_interval = QDoubleSpinBox()
|
|
88
|
+
self.coord_interval.setMinimum(0)
|
|
89
|
+
self.coord_interval.setMaximum(30)
|
|
90
|
+
fbox.addRow(self.coord_interval)
|
|
91
|
+
|
|
92
|
+
self.anglebutton = QPushButton("Compute Angle")
|
|
93
|
+
fbox.addRow(self.anglebutton)
|
|
94
|
+
|
|
95
|
+
self.setLayout(fbox)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class MainWindow(QMainWindow):
|
|
99
|
+
def __init__(self):
|
|
100
|
+
"""MainWindow constructor."""
|
|
101
|
+
super().__init__()
|
|
102
|
+
|
|
103
|
+
self.setWindowTitle("Slope Calculator")
|
|
104
|
+
self.resize(1280, 640)
|
|
105
|
+
|
|
106
|
+
self.index = 0
|
|
107
|
+
self.error = False
|
|
108
|
+
self.outfile = None
|
|
109
|
+
|
|
110
|
+
self.figure = plt.figure()
|
|
111
|
+
self.canvas = FigureCanvas(self.figure)
|
|
112
|
+
self.mpltool = NavigationToolbar(self.canvas, self)
|
|
113
|
+
|
|
114
|
+
layout = QVBoxLayout()
|
|
115
|
+
layout.addWidget(self.mpltool)
|
|
116
|
+
layout.addWidget(self.canvas)
|
|
117
|
+
|
|
118
|
+
widget = QWidget()
|
|
119
|
+
widget.setLayout(layout)
|
|
120
|
+
|
|
121
|
+
self.setCentralWidget(widget)
|
|
122
|
+
|
|
123
|
+
menubar = self.menuBar()
|
|
124
|
+
file_menu = menubar.addMenu("File")
|
|
125
|
+
self.open_menu = file_menu.addAction("Open", self.open_file_button)
|
|
126
|
+
self.open_menu.setShortcut("Ctrl+O")
|
|
127
|
+
file_menu.addSeparator()
|
|
128
|
+
|
|
129
|
+
self.save_menu = file_menu.addAction("Save", self.save_button)
|
|
130
|
+
self.save_menu.setShortcut("Ctrl+S")
|
|
131
|
+
self.save_menu.setEnabled(False)
|
|
132
|
+
self.saveas_menu = file_menu.addAction("Save As", self.saveas_button)
|
|
133
|
+
self.saveas_menu.setShortcut("Ctrl+Shift+S")
|
|
134
|
+
self.saveas_menu.setEnabled(False)
|
|
135
|
+
self.quit_menu = file_menu.addAction("Quit", self.close)
|
|
136
|
+
self.quit_menu.setShortcut("Ctrl+Q")
|
|
137
|
+
|
|
138
|
+
dockwidget = QDockWidget("Settings")
|
|
139
|
+
self.nav = NavigateWidget()
|
|
140
|
+
dockwidget.setWidget(self.nav)
|
|
141
|
+
self.addDockWidget(Qt.DockWidgetArea.RightDockWidgetArea, dockwidget)
|
|
142
|
+
|
|
143
|
+
view_menu = menubar.addMenu("View")
|
|
144
|
+
self.dock_menu = view_menu.addAction("Settings", dockwidget.show)
|
|
145
|
+
|
|
146
|
+
self.contour_index = -1
|
|
147
|
+
self.nav.sb.valueChanged.connect(self.contindexchange)
|
|
148
|
+
self.contour = 0
|
|
149
|
+
self.nav.sbd.valueChanged.connect(self.contchange)
|
|
150
|
+
self.nav.anglebutton.clicked.connect(self.compute_button)
|
|
151
|
+
self.nav.rb1.clicked.connect(self.radio_button)
|
|
152
|
+
self.nav.rb2.clicked.connect(self.radio_button)
|
|
153
|
+
|
|
154
|
+
toolbar = self.addToolBar("File Operations")
|
|
155
|
+
self.open = toolbar.addAction("Open", self.open_file_button)
|
|
156
|
+
toolbar.addAction("Sample File", self.sample_file_button)
|
|
157
|
+
self.save = toolbar.addAction("Save", self.save_button)
|
|
158
|
+
self.saveas = toolbar.addAction("Save As", self.saveas_button)
|
|
159
|
+
|
|
160
|
+
toolbar2 = self.addToolBar("Compute Navigation")
|
|
161
|
+
self.prev = toolbar2.addAction("Previous", self.prev_button)
|
|
162
|
+
self.next = toolbar2.addAction("Next", self.next_button)
|
|
163
|
+
|
|
164
|
+
toolbar3 = self.addToolBar("Selection Tools")
|
|
165
|
+
self.lasso = toolbar3.addAction("Lasso", self.lasso_activate)
|
|
166
|
+
self.undo = toolbar3.addAction("Undo", self.undo_button)
|
|
167
|
+
self.delete = toolbar3.addAction("Delete", self.delete_button)
|
|
168
|
+
|
|
169
|
+
self.disable_buttons()
|
|
170
|
+
|
|
171
|
+
def lasso_activate(self):
|
|
172
|
+
if self.mpltool.mode == "zoom rect":
|
|
173
|
+
self.mpltool.zoom()
|
|
174
|
+
elif self.mpltool.mode == "pan/zoom":
|
|
175
|
+
self.mpltool.pan()
|
|
176
|
+
|
|
177
|
+
if hasattr(self, "selector") and self.selector:
|
|
178
|
+
if hasattr(self, "pts") and self.pts:
|
|
179
|
+
self.selector = SelectFromCollection(self.ax2, self.pts)
|
|
180
|
+
self.canvas.draw_idle()
|
|
181
|
+
|
|
182
|
+
def contchange(self):
|
|
183
|
+
self.contour = self.nav.sbd.value()
|
|
184
|
+
self.plot_data()
|
|
185
|
+
|
|
186
|
+
def set_contnumber(self):
|
|
187
|
+
self.nav.sb.setMinimum(0)
|
|
188
|
+
self.nav.sb.setMaximum(self.cslen)
|
|
189
|
+
|
|
190
|
+
def contindexchange(self):
|
|
191
|
+
self.contour_index = self.nav.sb.value() - 1
|
|
192
|
+
self.plot_data()
|
|
193
|
+
|
|
194
|
+
def radio_button(self):
|
|
195
|
+
if self.nav.rb1.isChecked():
|
|
196
|
+
self.nav.start_point.setValue(min(self.yin))
|
|
197
|
+
self.nav.end_point.setValue(max(self.yin))
|
|
198
|
+
elif self.nav.rb2.isChecked():
|
|
199
|
+
self.nav.start_point.setValue(min(self.xin))
|
|
200
|
+
self.nav.end_point.setValue(max(self.xin))
|
|
201
|
+
|
|
202
|
+
def compute_button(self):
|
|
203
|
+
self.zstart = self.nav.start_point.value()
|
|
204
|
+
self.zend = self.nav.end_point.value()
|
|
205
|
+
self.avginterval = self.nav.avg_interval.value()
|
|
206
|
+
self.zinterval = self.nav.coord_interval.value()
|
|
207
|
+
|
|
208
|
+
self.index = 1
|
|
209
|
+
self.lastindex = int(np.ceil((self.zend - self.zstart) / self.zinterval))
|
|
210
|
+
|
|
211
|
+
self.pick_interval()
|
|
212
|
+
|
|
213
|
+
if self.error:
|
|
214
|
+
self.error = False
|
|
215
|
+
return
|
|
216
|
+
|
|
217
|
+
self.plot_slope()
|
|
218
|
+
|
|
219
|
+
self.next.setEnabled(True)
|
|
220
|
+
self.lasso.setEnabled(True)
|
|
221
|
+
self.delete.setEnabled(True)
|
|
222
|
+
self.undo.setEnabled(True)
|
|
223
|
+
self.save.setEnabled(True)
|
|
224
|
+
self.saveas.setEnabled(True)
|
|
225
|
+
self.save_menu.setEnabled(True)
|
|
226
|
+
self.saveas_menu.setEnabled(True)
|
|
227
|
+
|
|
228
|
+
def plot_data(self):
|
|
229
|
+
if self.index == 0:
|
|
230
|
+
self.figure.clear()
|
|
231
|
+
self.ax1 = self.figure.add_subplot(121)
|
|
232
|
+
self.ax2 = self.figure.add_subplot(122)
|
|
233
|
+
self.ax1.axis("off")
|
|
234
|
+
|
|
235
|
+
self.ax1.cla()
|
|
236
|
+
self.ax1.contourf(self.xin, self.yin, self.datain)
|
|
237
|
+
cs = self.ax1.contour(self.xin, self.yin, self.datain, [self.contour])
|
|
238
|
+
|
|
239
|
+
paths = []
|
|
240
|
+
for path in cs.get_paths():
|
|
241
|
+
vertices = path.vertices
|
|
242
|
+
codes = path.codes
|
|
243
|
+
|
|
244
|
+
current_path_vertices = []
|
|
245
|
+
current_path_codes = []
|
|
246
|
+
|
|
247
|
+
for i, (vert, code) in enumerate(zip(vertices, codes)):
|
|
248
|
+
if code == Path.MOVETO and current_path_vertices:
|
|
249
|
+
paths.append(
|
|
250
|
+
Path(
|
|
251
|
+
np.array(current_path_vertices),
|
|
252
|
+
np.array(current_path_codes),
|
|
253
|
+
)
|
|
254
|
+
)
|
|
255
|
+
current_path_vertices = [vert]
|
|
256
|
+
current_path_codes = [code]
|
|
257
|
+
else:
|
|
258
|
+
current_path_vertices.append(vert)
|
|
259
|
+
current_path_codes.append(code)
|
|
260
|
+
|
|
261
|
+
if current_path_vertices:
|
|
262
|
+
paths.append(
|
|
263
|
+
Path(np.array(current_path_vertices), np.array(current_path_codes))
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
self.cslen = len(paths)
|
|
267
|
+
self.set_contnumber()
|
|
268
|
+
|
|
269
|
+
if self.cslen == 0:
|
|
270
|
+
QMessageBox.warning(
|
|
271
|
+
self, "No Contour Found", f"No contour found for depth {self.contour}."
|
|
272
|
+
)
|
|
273
|
+
self.xcoord = np.array([])
|
|
274
|
+
self.ycoord = np.array([])
|
|
275
|
+
self.canvas.draw_idle()
|
|
276
|
+
return
|
|
277
|
+
|
|
278
|
+
if self.contour_index == -1:
|
|
279
|
+
if paths:
|
|
280
|
+
p = max(paths, key=len)
|
|
281
|
+
v = p.vertices
|
|
282
|
+
else:
|
|
283
|
+
QMessageBox.warning(
|
|
284
|
+
self,
|
|
285
|
+
"No Contour Data",
|
|
286
|
+
"No contour paths were generated. Please check your data or contour settings.",
|
|
287
|
+
)
|
|
288
|
+
self.xcoord = np.array([])
|
|
289
|
+
self.ycoord = np.array([])
|
|
290
|
+
self.canvas.draw_idle()
|
|
291
|
+
return
|
|
292
|
+
else:
|
|
293
|
+
try:
|
|
294
|
+
p = paths[self.contour_index]
|
|
295
|
+
v = p.vertices
|
|
296
|
+
except IndexError:
|
|
297
|
+
QMessageBox.warning(
|
|
298
|
+
self,
|
|
299
|
+
"Invalid Contour Index",
|
|
300
|
+
f"Contour index {self.contour_index + 1} is out of bounds. Please choose a valid number between 1 and {self.cslen}.",
|
|
301
|
+
)
|
|
302
|
+
self.xcoord = np.array([])
|
|
303
|
+
self.ycoord = np.array([])
|
|
304
|
+
self.canvas.draw_idle()
|
|
305
|
+
return
|
|
306
|
+
|
|
307
|
+
self.xcoord = v[:, 0]
|
|
308
|
+
self.ycoord = v[:, 1]
|
|
309
|
+
|
|
310
|
+
self.ax1.plot(
|
|
311
|
+
self.xcoord, self.ycoord, color="red", label=f"Contour {self.contour}"
|
|
312
|
+
)
|
|
313
|
+
if self.index > 0 and hasattr(self, "xx") and hasattr(self, "yy") and self.xx.size > 0:
|
|
314
|
+
self.ax1.plot(
|
|
315
|
+
self.xx,
|
|
316
|
+
self.yy,
|
|
317
|
+
"o",
|
|
318
|
+
color="blue",
|
|
319
|
+
markersize=3,
|
|
320
|
+
label="Selected Points",
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
self.ax1.legend()
|
|
324
|
+
self.canvas.draw_idle()
|
|
325
|
+
|
|
326
|
+
def pick_interval(self):
|
|
327
|
+
z = self.zstart
|
|
328
|
+
dz = self.avginterval
|
|
329
|
+
|
|
330
|
+
z1 = z + dz / 2.0
|
|
331
|
+
z2 = z - dz / 2.0
|
|
332
|
+
|
|
333
|
+
self.x1 = np.array([])
|
|
334
|
+
self.y1 = np.array([])
|
|
335
|
+
|
|
336
|
+
if self.nav.rb1.isChecked():
|
|
337
|
+
coord_to_check = self.ycoord
|
|
338
|
+
elif self.nav.rb2.isChecked():
|
|
339
|
+
coord_to_check = self.xcoord
|
|
340
|
+
else:
|
|
341
|
+
self.error = True
|
|
342
|
+
return
|
|
343
|
+
|
|
344
|
+
mask = (coord_to_check > z2) & (coord_to_check < z1)
|
|
345
|
+
self.x1 = self.xcoord[mask]
|
|
346
|
+
self.y1 = self.ycoord[mask]
|
|
347
|
+
|
|
348
|
+
if self.x1.size < 2 or self.y1.size < 2:
|
|
349
|
+
msg = QMessageBox()
|
|
350
|
+
msg.setIcon(QMessageBox.Icon.Critical)
|
|
351
|
+
msg.setText("Error")
|
|
352
|
+
msg.setInformativeText(
|
|
353
|
+
f"No enough points found in the interval {z2:.2f} to {z1:.2f} degrees for {z:.2f} degree."
|
|
354
|
+
)
|
|
355
|
+
msg.setWindowTitle("Error")
|
|
356
|
+
msg.exec()
|
|
357
|
+
self.error = True
|
|
358
|
+
|
|
359
|
+
def plot_slope(self):
|
|
360
|
+
if self.x1.size < 2:
|
|
361
|
+
QMessageBox.warning(
|
|
362
|
+
self,
|
|
363
|
+
"Insufficient Data",
|
|
364
|
+
"Cannot compute slope with less than 2 points.",
|
|
365
|
+
)
|
|
366
|
+
return
|
|
367
|
+
|
|
368
|
+
slope, intercept, r_value, p_value, std_err = stats.linregress(self.x1, self.y1)
|
|
369
|
+
ymodel = slope * self.x1 + intercept
|
|
370
|
+
angle = np.arctan(slope) * 180 / np.pi
|
|
371
|
+
if angle < 0:
|
|
372
|
+
angle = 180 + angle
|
|
373
|
+
|
|
374
|
+
if self.index == 1:
|
|
375
|
+
self.output = np.zeros((int(self.lastindex) + 1, 2))
|
|
376
|
+
|
|
377
|
+
i = self.index - 1
|
|
378
|
+
if 0 <= i < self.output.shape[0]:
|
|
379
|
+
self.output[i, :] = self.zstart, angle
|
|
380
|
+
else:
|
|
381
|
+
QMessageBox.warning(
|
|
382
|
+
self,
|
|
383
|
+
"Computation Error",
|
|
384
|
+
"Output array index out of bounds. This indicates a potential issue with interval calculation.",
|
|
385
|
+
)
|
|
386
|
+
return
|
|
387
|
+
|
|
388
|
+
self.ax2.cla()
|
|
389
|
+
textstr = "\n".join(
|
|
390
|
+
(
|
|
391
|
+
r"Coordinate: $%.1f^\circ$" % (self.zstart,),
|
|
392
|
+
r"Angle: $%.1f^\circ$" % (angle,),
|
|
393
|
+
r"R-squared: $%.3f$" % (r_value**2,),
|
|
394
|
+
)
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
props = dict(boxstyle="round", facecolor="wheat", alpha=0.5)
|
|
398
|
+
|
|
399
|
+
self.xx = self.x1
|
|
400
|
+
self.yy = self.y1
|
|
401
|
+
self.plot_data()
|
|
402
|
+
|
|
403
|
+
self.pts = self.ax2.scatter(
|
|
404
|
+
self.x1, self.y1, color="k", label="Selected Points"
|
|
405
|
+
)
|
|
406
|
+
self.ax2.plot(self.x1, ymodel, color="C1", label="Linear Regression")
|
|
407
|
+
self.ax2.text(
|
|
408
|
+
0.05,
|
|
409
|
+
0.95,
|
|
410
|
+
textstr,
|
|
411
|
+
transform=self.ax2.transAxes,
|
|
412
|
+
fontsize=10,
|
|
413
|
+
verticalalignment="top",
|
|
414
|
+
bbox=props,
|
|
415
|
+
)
|
|
416
|
+
self.ax2.set_xlabel(
|
|
417
|
+
self.xin.name if hasattr(self.xin, "name") else "X-coordinate"
|
|
418
|
+
)
|
|
419
|
+
self.ax2.set_ylabel(
|
|
420
|
+
self.yin.name if hasattr(self.yin, "name") else "Y-coordinate"
|
|
421
|
+
)
|
|
422
|
+
self.ax2.set_title(f"Slope for {self.zstart:.1f} degrees")
|
|
423
|
+
self.ax2.legend()
|
|
424
|
+
self.selector = SelectFromCollection(self.ax2, self.pts)
|
|
425
|
+
self.canvas.draw_idle()
|
|
426
|
+
|
|
427
|
+
def get_data(self, x_data, y_data, data_values):
|
|
428
|
+
self.xin = x_data
|
|
429
|
+
self.yin = y_data
|
|
430
|
+
self.datain = data_values
|
|
431
|
+
|
|
432
|
+
min_y = min(y_data) if y_data.size > 0 else -180
|
|
433
|
+
max_y = max(y_data) if y_data.size > 0 else 180
|
|
434
|
+
|
|
435
|
+
self.nav.start_point.setValue(min_y)
|
|
436
|
+
self.nav.end_point.setValue(max_y)
|
|
437
|
+
self.nav.avg_interval.setValue(1)
|
|
438
|
+
self.nav.coord_interval.setValue(1)
|
|
439
|
+
|
|
440
|
+
self.nav.rb1.setChecked(True)
|
|
441
|
+
|
|
442
|
+
self.activate_buttons()
|
|
443
|
+
|
|
444
|
+
self.index = 0
|
|
445
|
+
self.plot_data()
|
|
446
|
+
|
|
447
|
+
def disable_buttons(self):
|
|
448
|
+
self.next.setEnabled(False)
|
|
449
|
+
self.prev.setEnabled(False)
|
|
450
|
+
self.lasso.setEnabled(False)
|
|
451
|
+
self.undo.setEnabled(False)
|
|
452
|
+
self.delete.setEnabled(False)
|
|
453
|
+
self.save.setEnabled(False)
|
|
454
|
+
self.saveas.setEnabled(False)
|
|
455
|
+
self.save_menu.setEnabled(False)
|
|
456
|
+
self.saveas_menu.setEnabled(False)
|
|
457
|
+
|
|
458
|
+
self.nav.anglebutton.setEnabled(False)
|
|
459
|
+
self.nav.start_point.setEnabled(False)
|
|
460
|
+
self.nav.end_point.setEnabled(False)
|
|
461
|
+
self.nav.avg_interval.setEnabled(False)
|
|
462
|
+
self.nav.coord_interval.setEnabled(False)
|
|
463
|
+
self.nav.sbd.setEnabled(False)
|
|
464
|
+
self.nav.sb.setEnabled(False)
|
|
465
|
+
self.nav.rb1.setEnabled(False)
|
|
466
|
+
self.nav.rb2.setEnabled(False)
|
|
467
|
+
|
|
468
|
+
def activate_buttons(self):
|
|
469
|
+
self.nav.anglebutton.setEnabled(True)
|
|
470
|
+
self.nav.start_point.setEnabled(True)
|
|
471
|
+
self.nav.end_point.setEnabled(True)
|
|
472
|
+
self.nav.avg_interval.setEnabled(True)
|
|
473
|
+
self.nav.coord_interval.setEnabled(True)
|
|
474
|
+
self.nav.sbd.setEnabled(True)
|
|
475
|
+
self.nav.sb.setEnabled(True)
|
|
476
|
+
self.nav.rb1.setEnabled(True)
|
|
477
|
+
self.nav.rb2.setEnabled(True)
|
|
478
|
+
|
|
479
|
+
def open_file_button(self):
|
|
480
|
+
filename, _ = QFileDialog.getOpenFileName(
|
|
481
|
+
self,
|
|
482
|
+
caption="Open NetCDF file",
|
|
483
|
+
filter="NetCDF Files (*.nc *.cdf);;All Files (*)",
|
|
484
|
+
)
|
|
485
|
+
if not filename:
|
|
486
|
+
return
|
|
487
|
+
|
|
488
|
+
self.filewindow = WindowSelector(filename)
|
|
489
|
+
self.filewindow.submitted.connect(self.get_data)
|
|
490
|
+
self.filewindow.show()
|
|
491
|
+
|
|
492
|
+
def sample_file_button(self):
|
|
493
|
+
sample_path = os.path.join(PACKAGE_DIR, "sample.nc")
|
|
494
|
+
try:
|
|
495
|
+
ds = nc.Dataset(sample_path)
|
|
496
|
+
self.xin = ds["X7208_7808151_541"][:]
|
|
497
|
+
self.yin = ds["Y2703_3604211_632"][:]
|
|
498
|
+
self.datain = ds["DEPTH"][:]
|
|
499
|
+
ds.close()
|
|
500
|
+
except FileNotFoundError:
|
|
501
|
+
QMessageBox.critical(
|
|
502
|
+
self,
|
|
503
|
+
"File Error",
|
|
504
|
+
"sample.nc not found. Please ensure it's in the package directory.",
|
|
505
|
+
)
|
|
506
|
+
self.disable_buttons()
|
|
507
|
+
return
|
|
508
|
+
except Exception as e:
|
|
509
|
+
QMessageBox.critical(
|
|
510
|
+
self,
|
|
511
|
+
"Error Loading Sample",
|
|
512
|
+
f"An error occurred loading sample.nc: {e}",
|
|
513
|
+
)
|
|
514
|
+
self.disable_buttons()
|
|
515
|
+
return
|
|
516
|
+
|
|
517
|
+
self.contour_index = -1
|
|
518
|
+
self.index = 0
|
|
519
|
+
self.plot_data()
|
|
520
|
+
|
|
521
|
+
self.nav.start_point.setValue(8)
|
|
522
|
+
self.nav.end_point.setValue(20)
|
|
523
|
+
self.nav.avg_interval.setValue(1)
|
|
524
|
+
self.nav.coord_interval.setValue(1)
|
|
525
|
+
|
|
526
|
+
self.activate_buttons()
|
|
527
|
+
|
|
528
|
+
def _write_output(self, outfile):
|
|
529
|
+
np.savetxt(outfile, self.output[0 : self.index], fmt="%4.4f")
|
|
530
|
+
QMessageBox.information(self, "File Saved", f"Successfully saved to {outfile}")
|
|
531
|
+
|
|
532
|
+
def _check_output(self):
|
|
533
|
+
if not hasattr(self, "output") or self.output is None or self.output.size == 0:
|
|
534
|
+
QMessageBox.warning(
|
|
535
|
+
self,
|
|
536
|
+
"No Data to Save",
|
|
537
|
+
"There is no data to save. Please compute angles first.",
|
|
538
|
+
)
|
|
539
|
+
return False
|
|
540
|
+
return True
|
|
541
|
+
|
|
542
|
+
def save_button(self):
|
|
543
|
+
if not self._check_output():
|
|
544
|
+
return
|
|
545
|
+
|
|
546
|
+
if self.outfile is None:
|
|
547
|
+
outfile, _ = QFileDialog.getSaveFileName(
|
|
548
|
+
self,
|
|
549
|
+
"Save File",
|
|
550
|
+
"file_coord_angle.txt",
|
|
551
|
+
"Text Files (*.txt);;Data Files (*.dat);;All Files (*)",
|
|
552
|
+
)
|
|
553
|
+
if not outfile:
|
|
554
|
+
return
|
|
555
|
+
self.outfile = outfile
|
|
556
|
+
|
|
557
|
+
try:
|
|
558
|
+
self._write_output(self.outfile)
|
|
559
|
+
except Exception as e:
|
|
560
|
+
QMessageBox.critical(
|
|
561
|
+
self, "Save Error", f"An error occurred while saving the file: {e}"
|
|
562
|
+
)
|
|
563
|
+
|
|
564
|
+
def saveas_button(self):
|
|
565
|
+
if not self._check_output():
|
|
566
|
+
return
|
|
567
|
+
|
|
568
|
+
outfile, _ = QFileDialog.getSaveFileName(
|
|
569
|
+
self,
|
|
570
|
+
"Save File As",
|
|
571
|
+
self.outfile or "file_coord_angle.txt",
|
|
572
|
+
"Text Files (*.txt);;Data Files (*.dat);;All Files (*)",
|
|
573
|
+
)
|
|
574
|
+
if not outfile:
|
|
575
|
+
return
|
|
576
|
+
|
|
577
|
+
try:
|
|
578
|
+
self._write_output(outfile)
|
|
579
|
+
self.outfile = outfile
|
|
580
|
+
except Exception as e:
|
|
581
|
+
QMessageBox.critical(
|
|
582
|
+
self, "Save Error", f"An error occurred while saving the file: {e}"
|
|
583
|
+
)
|
|
584
|
+
|
|
585
|
+
def prev_button(self):
|
|
586
|
+
if self.zstart - self.zinterval >= self.nav.start_point.value():
|
|
587
|
+
self.index -= 1
|
|
588
|
+
self.zstart = self.zstart - self.zinterval
|
|
589
|
+
if self.index >= 1:
|
|
590
|
+
self.pick_interval()
|
|
591
|
+
if not self.error:
|
|
592
|
+
self.plot_slope()
|
|
593
|
+
else:
|
|
594
|
+
self.next.setEnabled(True)
|
|
595
|
+
self.error = False
|
|
596
|
+
else:
|
|
597
|
+
self.prev.setEnabled(False)
|
|
598
|
+
if self.index == 0:
|
|
599
|
+
self.next.setEnabled(True)
|
|
600
|
+
else:
|
|
601
|
+
self.prev.setEnabled(False)
|
|
602
|
+
QMessageBox.information(self, "Navigation Limit", "Cannot go further back.")
|
|
603
|
+
|
|
604
|
+
def next_button(self):
|
|
605
|
+
if self.zstart + self.zinterval <= self.nav.end_point.value():
|
|
606
|
+
self.index += 1
|
|
607
|
+
self.zstart = self.zstart + self.zinterval
|
|
608
|
+
if self.index > 1:
|
|
609
|
+
self.prev.setEnabled(True)
|
|
610
|
+
|
|
611
|
+
self.pick_interval()
|
|
612
|
+
if not self.error:
|
|
613
|
+
self.plot_slope()
|
|
614
|
+
else:
|
|
615
|
+
self.prev.setEnabled(True)
|
|
616
|
+
self.error = False
|
|
617
|
+
else:
|
|
618
|
+
self.next.setEnabled(False)
|
|
619
|
+
QMessageBox.information(
|
|
620
|
+
self, "Navigation Limit", "Cannot go further forward."
|
|
621
|
+
)
|
|
622
|
+
return
|
|
623
|
+
|
|
624
|
+
def undo_button(self):
|
|
625
|
+
if hasattr(self, "xx") and hasattr(self, "yy") and self.xx.size > 0:
|
|
626
|
+
self.x1 = self.xx
|
|
627
|
+
self.y1 = self.yy
|
|
628
|
+
self.plot_slope()
|
|
629
|
+
else:
|
|
630
|
+
QMessageBox.information(
|
|
631
|
+
self,
|
|
632
|
+
"Undo",
|
|
633
|
+
"No previous points to undo to or no computation has been done yet.",
|
|
634
|
+
)
|
|
635
|
+
|
|
636
|
+
def delete_button(self):
|
|
637
|
+
if (
|
|
638
|
+
hasattr(self, "selector")
|
|
639
|
+
and self.selector
|
|
640
|
+
and self.selector.ind is not None
|
|
641
|
+
and len(self.selector.ind) > 0
|
|
642
|
+
):
|
|
643
|
+
self.x1 = np.delete(self.x1, self.selector.ind)
|
|
644
|
+
self.y1 = np.delete(self.y1, self.selector.ind)
|
|
645
|
+
self.ax2.set_title("")
|
|
646
|
+
self.canvas.draw_idle()
|
|
647
|
+
self.plot_slope()
|
|
648
|
+
else:
|
|
649
|
+
QMessageBox.information(
|
|
650
|
+
self, "Delete", "No points selected by lasso to delete."
|
|
651
|
+
)
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
def main():
|
|
655
|
+
app = QApplication(sys.argv)
|
|
656
|
+
main_window = MainWindow()
|
|
657
|
+
main_window.show()
|
|
658
|
+
sys.exit(app.exec())
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
if __name__ == "__main__":
|
|
662
|
+
main()
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
from PyQt6.QtWidgets import QDialog
|
|
2
|
+
from PyQt6.QtWidgets import QPushButton, QLabel, QComboBox, QMessageBox
|
|
3
|
+
from PyQt6.QtWidgets import QFormLayout, QGridLayout
|
|
4
|
+
from PyQt6.QtCore import pyqtSignal
|
|
5
|
+
|
|
6
|
+
import matplotlib.pyplot as plt
|
|
7
|
+
from matplotlib.widgets import RectangleSelector
|
|
8
|
+
from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas
|
|
9
|
+
from matplotlib.backends.backend_qt5agg import NavigationToolbar2QT as NavigationToolbar
|
|
10
|
+
|
|
11
|
+
import numpy as np
|
|
12
|
+
import netCDF4 as nc
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def find_index(data, value):
|
|
16
|
+
i = data.searchsorted(value)
|
|
17
|
+
if i > (len(data) - 1):
|
|
18
|
+
i = i - 1
|
|
19
|
+
if i != 0:
|
|
20
|
+
x2 = abs(data[i] - value)
|
|
21
|
+
x1 = abs(data[i - 1] - value)
|
|
22
|
+
if x2 > x1:
|
|
23
|
+
i = i - 1
|
|
24
|
+
return i
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class WindowSelector(QDialog):
|
|
28
|
+
submitted = pyqtSignal(np.ndarray, np.ndarray, np.ndarray)
|
|
29
|
+
|
|
30
|
+
def __init__(self, filename):
|
|
31
|
+
super().__init__()
|
|
32
|
+
|
|
33
|
+
self.setWindowTitle("Area Selector")
|
|
34
|
+
self.resize(960, 640)
|
|
35
|
+
|
|
36
|
+
self.figure = plt.figure(figsize=(10, 5))
|
|
37
|
+
self.canvas = FigureCanvas(self.figure)
|
|
38
|
+
self.savebutton = QPushButton("Save and Continue")
|
|
39
|
+
|
|
40
|
+
self.ds = nc.Dataset(filename)
|
|
41
|
+
varlist = list(self.ds.variables.keys())
|
|
42
|
+
|
|
43
|
+
vartext = QLabel("Variables")
|
|
44
|
+
lattext = QLabel("Latitude")
|
|
45
|
+
lontext = QLabel("Longitude")
|
|
46
|
+
|
|
47
|
+
fbox = QFormLayout()
|
|
48
|
+
gbox = QGridLayout()
|
|
49
|
+
|
|
50
|
+
self.cbvar = QComboBox()
|
|
51
|
+
self.cbdim1 = QComboBox()
|
|
52
|
+
self.cbdim2 = QComboBox()
|
|
53
|
+
self.default_text = "--Select--"
|
|
54
|
+
self.cbdim1.addItem(self.default_text)
|
|
55
|
+
self.cbdim2.addItem(self.default_text)
|
|
56
|
+
self.cbvar.addItem(self.default_text)
|
|
57
|
+
|
|
58
|
+
gbox.addWidget(vartext, 0, 0)
|
|
59
|
+
gbox.addWidget(lontext, 0, 1)
|
|
60
|
+
gbox.addWidget(lattext, 2, 1)
|
|
61
|
+
for varname in varlist:
|
|
62
|
+
var = self.ds[varname]
|
|
63
|
+
if var.ndim > 1:
|
|
64
|
+
self.cbvar.addItem(var.name)
|
|
65
|
+
gbox.addWidget(self.cbvar, 1, 0)
|
|
66
|
+
|
|
67
|
+
gbox.addWidget(self.cbdim1, 1, 1)
|
|
68
|
+
gbox.addWidget(self.cbdim2, 3, 1)
|
|
69
|
+
self.cbdim1.setEnabled(False)
|
|
70
|
+
self.cbdim2.setEnabled(False)
|
|
71
|
+
|
|
72
|
+
self.cbvar.currentIndexChanged.connect(self.selectvar)
|
|
73
|
+
self.cbdim1.currentIndexChanged.connect(self.selectcbdim1)
|
|
74
|
+
self.cbdim2.currentIndexChanged.connect(self.selectcbdim2)
|
|
75
|
+
|
|
76
|
+
self.submit_button = QPushButton("Submit")
|
|
77
|
+
fbox.addRow(gbox)
|
|
78
|
+
fbox.addRow(self.submit_button)
|
|
79
|
+
self.mpltool = NavigationToolbar(self.canvas, self)
|
|
80
|
+
self.area_button = QPushButton("Rectangle Selector")
|
|
81
|
+
self.mpltool.addWidget(self.area_button)
|
|
82
|
+
fbox.addRow(self.mpltool)
|
|
83
|
+
fbox.addRow(self.canvas)
|
|
84
|
+
fbox.addRow(self.savebutton)
|
|
85
|
+
|
|
86
|
+
self.area_button.clicked.connect(self.area_select_activate)
|
|
87
|
+
self.submit_button.clicked.connect(self.on_submit)
|
|
88
|
+
self.savebutton.clicked.connect(self.on_save)
|
|
89
|
+
self.setLayout(fbox)
|
|
90
|
+
self.show()
|
|
91
|
+
|
|
92
|
+
def area_select_activate(self):
|
|
93
|
+
if self.mpltool.mode == "zoom rect":
|
|
94
|
+
self.mpltool.zoom()
|
|
95
|
+
elif self.mpltool.mode == "pan/zoom":
|
|
96
|
+
self.mpltool.pan()
|
|
97
|
+
|
|
98
|
+
def selectcbdim1(self, i):
|
|
99
|
+
if self.cbdim1.currentText() != self.default_text:
|
|
100
|
+
self.x = self.ds[self.cbdim1.currentText()][:]
|
|
101
|
+
self.xslice = self.x
|
|
102
|
+
|
|
103
|
+
def selectcbdim2(self, i):
|
|
104
|
+
if self.cbdim2.currentText() != self.default_text:
|
|
105
|
+
self.y = self.ds[self.cbdim2.currentText()][:]
|
|
106
|
+
self.yslice = self.y
|
|
107
|
+
|
|
108
|
+
def selectvar(self, i):
|
|
109
|
+
if self.cbvar.currentText() == self.default_text:
|
|
110
|
+
return
|
|
111
|
+
|
|
112
|
+
self.data = self.ds[self.cbvar.currentText()][:]
|
|
113
|
+
self.dataslice = self.data
|
|
114
|
+
|
|
115
|
+
var = self.ds[self.cbvar.currentText()]
|
|
116
|
+
|
|
117
|
+
if var.ndim == 1 or var.ndim > 2:
|
|
118
|
+
msg = QMessageBox()
|
|
119
|
+
msg.setIcon(QMessageBox.Icon.Critical)
|
|
120
|
+
msg.setText("Error")
|
|
121
|
+
msg.setInformativeText("No. of dimension should be 2")
|
|
122
|
+
msg.exec()
|
|
123
|
+
return
|
|
124
|
+
|
|
125
|
+
self.cbdim1.blockSignals(True)
|
|
126
|
+
self.cbdim2.blockSignals(True)
|
|
127
|
+
self.cbdim1.clear()
|
|
128
|
+
self.cbdim2.clear()
|
|
129
|
+
self.cbdim1.addItem(self.default_text)
|
|
130
|
+
self.cbdim2.addItem(self.default_text)
|
|
131
|
+
for i in range(var.ndim):
|
|
132
|
+
self.cbdim1.addItem(var.dimensions[i])
|
|
133
|
+
self.cbdim2.addItem(var.dimensions[i])
|
|
134
|
+
self.cbdim1.blockSignals(False)
|
|
135
|
+
self.cbdim2.blockSignals(False)
|
|
136
|
+
self.cbdim1.setEnabled(True)
|
|
137
|
+
self.cbdim2.setEnabled(True)
|
|
138
|
+
|
|
139
|
+
def on_submit(self):
|
|
140
|
+
if (
|
|
141
|
+
self.cbvar.currentText() == self.default_text
|
|
142
|
+
or self.cbdim1.currentText() == self.default_text
|
|
143
|
+
or self.cbdim2.currentText() == self.default_text
|
|
144
|
+
):
|
|
145
|
+
msg = QMessageBox()
|
|
146
|
+
msg.setIcon(QMessageBox.Icon.Critical)
|
|
147
|
+
msg.setText("Error")
|
|
148
|
+
msg.setInformativeText("Item not selected from drop list")
|
|
149
|
+
msg.setWindowTitle("Error")
|
|
150
|
+
msg.exec()
|
|
151
|
+
else:
|
|
152
|
+
self.ax1 = self.figure.add_subplot(121)
|
|
153
|
+
self.ax2 = self.figure.add_subplot(122)
|
|
154
|
+
|
|
155
|
+
self.ax1.contourf(self.x, self.y, self.data)
|
|
156
|
+
self.ax1.set_title("Select the region of interest", fontsize=6)
|
|
157
|
+
self.ax2.clear()
|
|
158
|
+
self.ax2.contourf(self.x, self.y, self.data)
|
|
159
|
+
textstr = "\n".join(
|
|
160
|
+
(
|
|
161
|
+
r"Latitude: $%.1f^\circ$N, $%.1f^\circ$N" % (self.y[0], self.y[-1]),
|
|
162
|
+
r"Longitude: $%.1f^\circ$N, $%.1f^\circ$N" % (self.x[0], self.x[-1]),
|
|
163
|
+
)
|
|
164
|
+
)
|
|
165
|
+
props = dict(boxstyle="round", facecolor="wheat", alpha=0.5)
|
|
166
|
+
self.ax2.text(
|
|
167
|
+
0.05,
|
|
168
|
+
0.15,
|
|
169
|
+
textstr,
|
|
170
|
+
transform=self.ax2.transAxes,
|
|
171
|
+
fontsize=6,
|
|
172
|
+
verticalalignment="top",
|
|
173
|
+
bbox=props,
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
self.canvas.draw()
|
|
177
|
+
self.span = RectangleSelector(self.ax1, self.onselect, interactive=True)
|
|
178
|
+
|
|
179
|
+
def onselect(self, eclick, erelease):
|
|
180
|
+
self.yc = eclick.ydata
|
|
181
|
+
self.yr = erelease.ydata
|
|
182
|
+
self.xc = eclick.xdata
|
|
183
|
+
self.xr = erelease.xdata
|
|
184
|
+
|
|
185
|
+
i1 = find_index(self.x, self.xc)
|
|
186
|
+
i2 = find_index(self.x, self.xr)
|
|
187
|
+
j1 = find_index(self.y, self.yc)
|
|
188
|
+
j2 = find_index(self.y, self.yr)
|
|
189
|
+
|
|
190
|
+
self.xslice = self.x[i1:i2]
|
|
191
|
+
self.yslice = self.y[j1:j2]
|
|
192
|
+
self.dataslice = self.data[j1:j2, i1:i2]
|
|
193
|
+
|
|
194
|
+
textstr = "\n".join(
|
|
195
|
+
(
|
|
196
|
+
r"Latitude: $%.1f^\circ$N, $%.1f^\circ$N" % (self.yc, self.yr),
|
|
197
|
+
r"Longitude: $%.1f^\circ$N, $%.1f^\circ$N" % (self.xc, self.xr),
|
|
198
|
+
)
|
|
199
|
+
)
|
|
200
|
+
self.ax2.clear()
|
|
201
|
+
self.ax2.contourf(self.xslice, self.yslice, self.dataslice)
|
|
202
|
+
props = dict(boxstyle="round", facecolor="wheat", alpha=0.5)
|
|
203
|
+
self.ax2.text(
|
|
204
|
+
0.05,
|
|
205
|
+
0.15,
|
|
206
|
+
textstr,
|
|
207
|
+
transform=self.ax2.transAxes,
|
|
208
|
+
fontsize=6,
|
|
209
|
+
verticalalignment="top",
|
|
210
|
+
bbox=props,
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
self.canvas.draw_idle()
|
|
214
|
+
self.canvas.flush_events()
|
|
215
|
+
|
|
216
|
+
def on_save(self):
|
|
217
|
+
if not hasattr(self, "xslice") or not hasattr(self, "yslice") or not hasattr(self, "dataslice"):
|
|
218
|
+
QMessageBox.warning(
|
|
219
|
+
self,
|
|
220
|
+
"No Selection",
|
|
221
|
+
"Please draw a rectangle to select a region before saving.",
|
|
222
|
+
)
|
|
223
|
+
return
|
|
224
|
+
self.submitted.emit(self.xslice, self.yslice, self.dataslice)
|
|
225
|
+
self.close()
|
|
226
|
+
|
|
227
|
+
def closeEvent(self, event):
|
|
228
|
+
self.ds.close()
|
|
229
|
+
super().closeEvent(event)
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
from matplotlib.widgets import LassoSelector
|
|
3
|
+
from matplotlib.path import Path
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class SelectFromCollection:
|
|
7
|
+
"""
|
|
8
|
+
Select indices from a matplotlib collection using `LassoSelector`.
|
|
9
|
+
|
|
10
|
+
Selected indices are saved in the `ind` attribute. This tool fades out the
|
|
11
|
+
points that are not part of the selection (i.e., reduces their alpha
|
|
12
|
+
values). If your collection has alpha < 1, this tool will permanently
|
|
13
|
+
alter the alpha values.
|
|
14
|
+
|
|
15
|
+
Parameters
|
|
16
|
+
----------
|
|
17
|
+
ax : `~matplotlib.axes.Axes`
|
|
18
|
+
Axes to interact with.
|
|
19
|
+
collection : `matplotlib.collections.Collection` subclass
|
|
20
|
+
Collection you want to select from.
|
|
21
|
+
alpha_other : 0 <= float <= 1
|
|
22
|
+
To highlight a selection, this tool sets all selected points to an
|
|
23
|
+
alpha value of 1 and non-selected points to *alpha_other*.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(self, ax, collection, alpha_other=0.3):
|
|
27
|
+
self.canvas = ax.figure.canvas
|
|
28
|
+
self.collection = collection
|
|
29
|
+
self.alpha_other = alpha_other
|
|
30
|
+
|
|
31
|
+
self.xys = collection.get_offsets()
|
|
32
|
+
self.Npts = len(self.xys)
|
|
33
|
+
|
|
34
|
+
self.fc = collection.get_facecolors()
|
|
35
|
+
if len(self.fc) == 0:
|
|
36
|
+
raise ValueError("Collection must have a facecolor")
|
|
37
|
+
elif len(self.fc) == 1:
|
|
38
|
+
self.fc = np.tile(self.fc, (self.Npts, 1))
|
|
39
|
+
|
|
40
|
+
self.lasso = LassoSelector(ax, onselect=self.onselect)
|
|
41
|
+
self.ind = []
|
|
42
|
+
|
|
43
|
+
def onselect(self, verts):
|
|
44
|
+
path = Path(verts)
|
|
45
|
+
self.ind = np.nonzero(path.contains_points(self.xys))[0]
|
|
46
|
+
self.fc[:, -1] = self.alpha_other
|
|
47
|
+
self.fc[self.ind, -1] = 1
|
|
48
|
+
self.collection.set_facecolors(self.fc)
|
|
49
|
+
self.canvas.draw_idle()
|
|
50
|
+
|
|
51
|
+
def disconnect(self):
|
|
52
|
+
self.lasso.disconnect_events()
|
|
53
|
+
self.fc[:, -1] = 1
|
|
54
|
+
self.collection.set_facecolors(self.fc)
|
|
55
|
+
self.canvas.draw_idle()
|
|
Binary file
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "isobath"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "A GUI tool for computing slope angles along depth contours from NetCDF bathymetry data"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { file = "LICENSE" }
|
|
11
|
+
requires-python = ">=3.8"
|
|
12
|
+
authors = [{ name = "P. Amol", email = "prakashamol@gmail.com" }]
|
|
13
|
+
keywords = ["bathymetry", "isobath", "slope", "netcdf", "oceanography", "gui"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"Intended Audience :: Science/Research",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3.8",
|
|
20
|
+
"Programming Language :: Python :: 3.9",
|
|
21
|
+
"Programming Language :: Python :: 3.10",
|
|
22
|
+
"Programming Language :: Python :: 3.11",
|
|
23
|
+
"Programming Language :: Python :: 3.12",
|
|
24
|
+
"Topic :: Scientific/Engineering :: Visualization",
|
|
25
|
+
"Topic :: Scientific/Engineering :: Atmospheric Science",
|
|
26
|
+
]
|
|
27
|
+
dependencies = ["PyQt6", "matplotlib", "numpy", "netCDF4", "scipy"]
|
|
28
|
+
|
|
29
|
+
[project.scripts]
|
|
30
|
+
isobath = "isobath.__main__:main"
|
|
31
|
+
|
|
32
|
+
[project.urls]
|
|
33
|
+
Homepage = "https://github.com/p-amol/isobath"
|
|
34
|
+
Issues = "https://github.com/p-amol/isobath/issues"
|
|
35
|
+
|
|
36
|
+
[tool.hatch.build.targets.wheel]
|
|
37
|
+
packages = ["isobath"]
|
|
38
|
+
|
|
39
|
+
[tool.hatch.build.targets.wheel.sources]
|
|
40
|
+
"isobath" = "isobath"
|
|
41
|
+
|
|
42
|
+
[tool.hatch.build.targets.sdist]
|
|
43
|
+
include = ["isobath/", "README.md", "LICENSE", "pyproject.toml"]
|