sonarcal 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
sonarcal/__init__.py ADDED
File without changes
sonarcal/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ """Provides a command line interface to the calibration program."""
2
+ from sonarcal import controller
3
+
4
+ controller.main()
@@ -0,0 +1,7 @@
1
+ import pandas as pd
2
+
3
+ def calculate_gain(sphere_ts):
4
+ """Calculate the beam calibration gain and other stats."""
5
+ df = pd.DataFrame(sphere_ts, columns=['timestamp', 'ts', 'range'])
6
+ return (df['ts'].mean(), df['ts'].std(), df['range'].mean(), len(df))
7
+
@@ -0,0 +1,19 @@
1
+ import pandas as pd
2
+
3
+ class calibrationData():
4
+ """Storage for sonar caliration results."""
5
+
6
+ def __init__(self):
7
+ self.data = pd.DataFrame(columns=['Time', 'Gain (dB)', 'RMS (dB)', 'Range (m)', 'Echoes'])
8
+ self.data.index.name = 'Beam'
9
+
10
+ def update(self, beam: int, timestamp: str, gain: float, rms: float, r: float, num: int):
11
+ self.data.loc[beam] = (timestamp, gain, rms, r, num)
12
+
13
+ def remove(self, beams: list[int]):
14
+ """Remove data for given beam."""
15
+ self.data.drop(index=beams, inplace=True)
16
+
17
+ def df(self):
18
+ return self.data # eventually return a better form of the data?
19
+
@@ -0,0 +1,317 @@
1
+
2
+ import webbrowser
3
+ import logging
4
+ from pathlib import Path
5
+ from datetime import datetime
6
+ import tkinter as tk
7
+ from tkinter import ttk
8
+ from tkinter import filedialog as fd
9
+ from tkinter import messagebox
10
+ from importlib.metadata import version
11
+ from PIL import Image, ImageTk
12
+ from .utils import window_closed, app_name
13
+ from .calibration_data import calibrationData
14
+ from .calculate_gains import calculate_gain
15
+ from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg
16
+ from platformdirs import PlatformDirs
17
+
18
+ logger = logging.getLogger(app_name)
19
+ icon_file = Path(__file__).parent.parent.parent/'assets'/'icon.png' # TODO get via a config file
20
+
21
+ class calibrationGUI:
22
+ """Provides the main GUI container and misc labels/buttons."""
23
+
24
+ def __init__(self, echogram, title='TITLE', help_uri=None):
25
+ self.echogram = echogram
26
+ self.help_uri = help_uri
27
+
28
+ # Calibration gains are stored in here
29
+ self.cal_data = calibrationData()
30
+ # sphere ts for the current beam calibration is stored in here
31
+ self.sphere_ts = []
32
+
33
+ # The GUI window
34
+ self.echogram.root.title(title)
35
+
36
+ # Dialogs that we keep around
37
+ self.results_dialog = None
38
+
39
+ # The toolbar and window icon/logo
40
+ self.icon = ImageTk.PhotoImage(Image.open(icon_file))
41
+ self.echogram.root.iconphoto(False, self.icon)
42
+
43
+ # Things to do with new pings
44
+ self.echogram.set_ping_callback(self.new_ping)
45
+
46
+ # Put the matplotlib plots into the GUI window.
47
+ canvas = FigureCanvasTkAgg(self.echogram.fig, master=self.echogram.root)
48
+ canvas.get_tk_widget().pack(side='top', fill='both', expand=True, padx=5, pady=5)
49
+
50
+ # Styles. These apply to all widgets, not just the ones create in this function
51
+ s = ttk.Style()
52
+ s.configure('TButton', font=('Arial', 16))
53
+ s.configure('TLabel', font=('Arial', 12))
54
+ s.configure('TCheckbutton', font=('Arial', 16))
55
+ s.configure('Treeview.Heading', font=('Arial', 12, 'bold'))
56
+ s.configure('Treeview', font=('Arial', 12))
57
+
58
+ # A label to show the last received message time
59
+ self.label = ttk.Label(self.echogram.root)
60
+ self.label.pack(side=tk.TOP, fill=tk.BOTH)
61
+ self.label.config(text='Waiting for data...', width=100, anchor=tk.W)
62
+
63
+ ttk.Separator(self.echogram.root, orient='horizontal').pack(fill='x', padx=10, pady=5)
64
+
65
+ # Buttons for help, on-axis toggle, config dialog, and close
66
+ self.onaxis_value = tk.BooleanVar(value=False)
67
+
68
+ frame = ttk.Frame(self.echogram.root)
69
+ results = ttk.Button(frame, text='Results', command=self.results)
70
+ config = ttk.Button(frame, text='Config', command=self.config)
71
+ onaxis = ttk.Checkbutton(frame, text='On-axis', variable=self.onaxis_value,
72
+ command=self.onaxis_changed)
73
+ help = ttk.Button(frame, text='Help', command=self.help)
74
+ about = ttk.Button(frame, text='About', command=self.about)
75
+ close = ttk.Button(frame, text='Close', command=self.close)
76
+
77
+ onaxis.pack(side=tk.LEFT)
78
+ close.pack(side=tk.RIGHT)
79
+ about.pack(side=tk.RIGHT)
80
+ help.pack(side=tk.RIGHT)
81
+ config.pack(side=tk.RIGHT)
82
+ results.pack(side=tk.RIGHT)
83
+
84
+ frame.pack(side=tk.TOP, fill=tk.BOTH)
85
+
86
+ # Start listening for sonar data
87
+ self.echogram.newPing(self.status_label())
88
+
89
+ def job(self):
90
+ return self.echogram.job
91
+
92
+ def root(self):
93
+ return self.echogram.root
94
+
95
+ def onaxis_changed(self):
96
+ """A beam calibration has either started or ended."""
97
+ if self.onaxis_value.get(): # start calibrating a beam
98
+ self.echogram.beamLine.freeze(True)
99
+ logger.info('Beam %d calibration started', self.echogram.beam)
100
+ else: # finished calibrating a beam
101
+ logger.info('Beam %d calibration complete', self.echogram.beam)
102
+ self.echogram.beamLine.freeze(False)
103
+ self.sphere_ts = []
104
+ if self.results_dialog:
105
+ self.results_dialog.update_rows(None) # unhighlights the previously active row
106
+
107
+ def new_ping(self):
108
+ """Orchestrates things for each new ping."""
109
+ e = self.echogram
110
+ if e.beamLine.frozen(): # a beam is being calibrated
111
+ # store the current ping's sphere echo info
112
+ self.sphere_ts.append((datetime.now().isoformat(), e.amp[1, -1], e.rangeMax))
113
+ # calculate the beam gain and other stats
114
+ (gain, rms, r, num) = calculate_gain(self.sphere_ts)
115
+ # store the latest beam gain values
116
+ self.cal_data.update(e.beam, datetime.now().strftime('%H:%M:%S'), gain, rms, r, num)
117
+ # update the results dialog if present
118
+ if self.results_dialog:
119
+ self.results_dialog.update_with(self.cal_data, e.beam)
120
+
121
+ def about(self):
122
+ message = (f'Sonarcal, version {version("sonarcal")}\n\n'
123
+ 'A program to assist with calibrating omni-directional sonars.\n\n'
124
+ 'Developed by Aqualyd Ltd\n\n'
125
+ 'www.aqualyd.nz')
126
+
127
+ messagebox.showinfo(title='About', message=message)
128
+
129
+ def close(self):
130
+ window_closed(self.echogram.root, self.echogram.job)
131
+
132
+ def results(self):
133
+ """Open the Results dialog box."""
134
+ # want one lasting instance of this dialog so manage that here
135
+ if not self.results_dialog:
136
+ self.results_dialog = resultsDialog(self.echogram.root, self.cal_data, self.icon)
137
+ else:
138
+ self.results_dialog.reopen()
139
+
140
+ def help(self):
141
+ """Open the help documentation in a web browser."""
142
+ if not webbrowser.open(self.help_uri, new=2):
143
+ logging.warning('Failed to start a webbrowser to show the help documentation')
144
+
145
+ def config(self):
146
+ """Open the Config dialog box."""
147
+ configDialog(self.echogram.root, self.icon)
148
+
149
+ def status_label(self):
150
+ return self.label
151
+
152
+
153
+ class configDialog:
154
+ """A dialog box to set and change application parameters."""
155
+ def __init__(self, parent, icon=None):
156
+ self.top = tk.Toplevel(parent)
157
+ self.top.title("Config")
158
+ if icon:
159
+ self.top.iconphoto(False, icon)
160
+
161
+ ttk.Label(self.top, text="Configs").pack(padx=20, pady=10)
162
+ ttk.Button(self.top, text="Close", command=self.close_dialog).pack(pady=5)
163
+ ttk.Button(self.top, text="Apply", command=self.apply).pack(pady=5)
164
+
165
+ def apply(self):
166
+ pass
167
+
168
+ def close_dialog(self):
169
+ self.top.destroy()
170
+
171
+
172
+ class resultsDialog:
173
+ """A dialog box to show completed calibration results per beam."""
174
+
175
+ def __init__(self, parent, data: dict=None, icon=None):
176
+
177
+ self.data = data
178
+
179
+ self.top = tk.Toplevel(parent)
180
+ self.top.title("Results")
181
+ if icon:
182
+ self.top.iconphoto(False, icon)
183
+
184
+ tree_frame = ttk.Frame(self.top)
185
+
186
+ # use a ttk.Treeview to show a table of the results
187
+ self.item_ids = {} # contains ids to rows that get added to the treeview
188
+ self.setup_treeview(tree_frame, self.data) # creates self.tree
189
+
190
+ # Make scrollbars for the treeview widget
191
+ vsb = ttk.Scrollbar(tree_frame, orient="vertical", command=self.tree.yview)
192
+ self.tree.configure(yscrollcommand=vsb.set)
193
+
194
+ # pack the scrollbars and treeview
195
+ vsb.pack(side="right", fill="y")
196
+ self.tree.pack(side=tk.TOP, fill=tk.BOTH, expand=True, padx=5)
197
+
198
+ # create and pack the buttons
199
+ btn_frame = ttk.Frame(self.top)
200
+ remove = ttk.Button(btn_frame, text="Remove selected", command=self.remove_rows)
201
+ save = ttk.Button(btn_frame, text="Save", command=self.save)
202
+ close = ttk.Button(btn_frame, text="Close", command=self.close_dialog)
203
+
204
+ close.pack(side=tk.RIGHT)
205
+ save.pack(side=tk.RIGHT)
206
+ remove.pack(side=tk.RIGHT)
207
+
208
+ tree_frame.pack(side=tk.TOP, fill=tk.BOTH, expand=True)
209
+ btn_frame.pack(side=tk.BOTTOM, fill=tk.X)
210
+
211
+ def setup_treeview(self, top, data):
212
+ """Create the treeview columns, etc."""
213
+
214
+ headings = [data.df().index.name] + list(data.df().columns)
215
+
216
+ self.tree = ttk.Treeview(top, columns=headings, show='headings')
217
+ self.tree.bind('<Button-1>', self.on_row_click)
218
+
219
+ # colour odd and even rows
220
+ self.tree.tag_configure('evenrow', background='white smoke')
221
+ self.tree.tag_configure('oddrow', background='white')
222
+ self.tree.tag_configure('active', background='orange2')
223
+
224
+ for col in headings:
225
+ self.tree.heading(col, text=col, anchor='e')
226
+ self.tree.column(col, width=100, anchor='e')
227
+
228
+ # Add rows (if any)
229
+ self.update_with(data)
230
+
231
+ def update_with(self, data, active_beam: int|None = None):
232
+ """Update dialog's display with given calibration data."""
233
+
234
+ for beam, row in data.df().iterrows():
235
+
236
+ # TODO
237
+ # This is hacky and fragile - find a better way that doesn't need to name the
238
+ # columns
239
+ values = [beam,
240
+ row['Time'],
241
+ f"{row['Gain (dB)']:0.1f}",
242
+ f"{row['RMS (dB)']:0.1f}",
243
+ f"{row['Range (m)']:0.1f}",
244
+ row['Echoes']]
245
+ # values = [beam] + list(row)
246
+
247
+ if beam in self.item_ids:
248
+ # row for this beam already exists, so update it
249
+ self.tree.item(self.item_ids[beam], values=values)
250
+ else:
251
+ # new beam, so add a row
252
+ item_id = self.tree.insert('', 'end', values=values)
253
+ self.item_ids[beam] = item_id
254
+
255
+ self.update_rows(active_beam)
256
+
257
+ # keep this to use in the save and remove functionalities
258
+ self.data = data
259
+
260
+ def update_rows(self, active_beam: int|None):
261
+ """Sorts calibration results rows by beam and sets background colours to look nice."""
262
+ for beam, index in zip(sorted(self.item_ids.keys()), range(len(self.item_ids))):
263
+ self.tree.move(self.item_ids[beam], '', index)
264
+
265
+ rowness = 'oddrow' if index % 2 == 0 else 'evenrow'
266
+ if active_beam and beam == active_beam:
267
+ rowness = 'active'
268
+
269
+ self.tree.item(self.item_ids[beam], tags=(rowness,))
270
+
271
+ def remove_rows(self):
272
+ """Remove selected rows from the results table."""
273
+
274
+ selected = self.tree.selection() # returns a tuple of item_ids
275
+
276
+ # Pick out the beam numbers of the selected rows
277
+ to_remove = []
278
+ for iid in selected:
279
+ item_data = self.tree.item(iid)
280
+ values = item_data['values']
281
+ to_remove.append(values[0])
282
+
283
+ if to_remove:
284
+ for beam in to_remove:
285
+ # remove selected rows from treeview
286
+ self.tree.delete(self.item_ids[beam])
287
+ # remove beam imtem from the map between beam and item ids
288
+ self.item_ids.pop(beam)
289
+ # remove beams from the cal_data store
290
+ self.data.remove(to_remove)
291
+ self.update_rows(None)
292
+
293
+ def save(self):
294
+ """Save the results to a file."""
295
+ timestamp = datetime.now().strftime('%Y%m%dT%H%M%S')
296
+ default_filename = 'sonar_calibration_' + timestamp + '.csv'
297
+ save_filename = fd.asksaveasfilename(title='Save as CSV', defaultextension='.csv',
298
+ initialdir=PlatformDirs.user_documents_dir,
299
+ initialfile=default_filename,
300
+ filetypes=[('CSV', '*.csv')])
301
+ if save_filename:
302
+ logger.info('Saved results to %s', save_filename)
303
+ self.data.df().to_csv(save_filename)
304
+
305
+ def on_row_click(self, event):
306
+ """Implement selection and deselection."""
307
+ # TODO - work out why it can take multiple clicks on a row to get it unselected
308
+ item_id = self.tree.identify_row(event.y)
309
+ if item_id and item_id in self.tree.selection():
310
+ self.tree.selection_remove(item_id)
311
+
312
+
313
+ def reopen(self):
314
+ self.top.deiconify()
315
+
316
+ def close_dialog(self):
317
+ self.top.withdraw()
sonarcal/controller.py ADDED
@@ -0,0 +1,108 @@
1
+ """Omnisonar calibration program
2
+
3
+ Provides omni and echogram displays and sphere amplitude plots for use when
4
+ calibrating omni-directional sonars.
5
+ """
6
+ # TODO:
7
+ # Choose beam_group based on beam type rather than requiring it in the config file
8
+
9
+ import configparser
10
+ import tkinter as tk
11
+ from functools import partial
12
+ import threading
13
+ import queue
14
+ import sys
15
+ from pathlib import Path
16
+
17
+ from platformdirs import PlatformDirs
18
+
19
+ from .echogram_plotter import echogramPlotter
20
+ from .utils import setupLogging, app_name, on_exit, window_closed
21
+ from .file_ops import file_listen, file_replay
22
+ from .calibration_gui import calibrationGUI
23
+
24
+ if sys.platform == "win32":
25
+ import win32api
26
+
27
+ # Configure logging
28
+ dirs = PlatformDirs(appname=app_name, appauthor="Aqualyd")
29
+ log_dir = Path(dirs.user_log_dir)
30
+ log_dir.mkdir(parents=True, exist_ok=True)
31
+ setupLogging(log_dir, app_name)
32
+
33
+
34
+ def main():
35
+ """Omnisonar calibration graphical user interface."""
36
+ ##########################################
37
+ # Sort out the configuration file
38
+ config_filename = Path(dirs.user_config_dir)/'config.ini'
39
+ config_filename.parent.mkdir(parents=True, exist_ok=True)
40
+
41
+ config = configparser.ConfigParser()
42
+ c = config.read(config_filename, encoding='utf8')
43
+
44
+ if not c: # config file not found, so make one
45
+ config['DEFAULT'] = {'numPingsToShow': 100,
46
+ 'maxRange': 50,
47
+ 'maxSv': -20,
48
+ 'minSv': -60,
49
+ 'replayRate': 'realtime',
50
+ 'horizontalBeamGroupPath': 'Sonar/Beam_group1',
51
+ 'watchDir': '.',
52
+ 'liveData': 'yes',
53
+ 'helpURI': 'https://aqualyd-limited.github.io/sonarCal/'
54
+ }
55
+
56
+ with open(config_filename, 'w', encoding='utf-8') as configfile:
57
+ config.write(configfile)
58
+ # TODO - open config dialog instead of exitting here
59
+ print('No config file was found, so ' + str(config_filename) +
60
+ ' was created. You may need to edit this file.')
61
+ sys.exit()
62
+
63
+ # Pull out the settings in the config file.
64
+ numPings = config.getint('DEFAULT', 'numPingsToShow')
65
+ maxRange = config.getfloat('DEFAULT', 'maxRange')
66
+ maxSv = config.getfloat('DEFAULT', 'maxSv')
67
+ minSv = config.getfloat('DEFAULT', 'minSv')
68
+ replayRate = config.get('DEFAULT', 'replayRate')
69
+ horizontalBeamGroup = config.get('DEFAULT', 'horizontalBeamGroupPath')
70
+ watchDir = Path(config.get('DEFAULT', 'watchDir'))
71
+ liveData = config.getboolean('DEFAULT', 'liveData')
72
+ helpURI = config.get('DEFAULT', 'helpURI')
73
+
74
+ ##########################################
75
+ # Start things...
76
+
77
+ # queue to communicate between two threads
78
+ msg_queue = queue.Queue()
79
+
80
+ # Tk GUI
81
+ root = tk.Tk()
82
+
83
+ # handle to the function that does the echogram drawing
84
+ # job = None
85
+
86
+ echogram = echogramPlotter(numPings, maxRange, maxSv, minSv, msg_queue, root)
87
+ gui = calibrationGUI(echogram, title='Sonar calibration', help_uri=helpURI)
88
+ # Check periodically for new echogram data
89
+ # job = root.after(echogram.checkQueueInterval, echogram.newPing, gui.status_label())
90
+
91
+ # Start receive in a separate thread
92
+ if liveData:
93
+ t = threading.Thread(target=file_listen, args=(watchDir, horizontalBeamGroup, msg_queue))
94
+ else:
95
+ t = threading.Thread(target=file_replay, args=(watchDir, horizontalBeamGroup, msg_queue,
96
+ replayRate))
97
+ t.daemon = True # makes the thread close when main() ends
98
+ t.start()
99
+
100
+ # For Windows, catch when the console is closed
101
+ if sys.platform == "win32":
102
+ win32api.SetConsoleCtrlHandler(partial(on_exit, gui.root(), gui.job()), True)
103
+
104
+ # And start things...
105
+ root.protocol("WM_DELETE_WINDOW", lambda: window_closed(gui.root(), gui.job()))
106
+ root.mainloop()
107
+
108
+