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.
@@ -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.
@@ -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"]