boris-behav-obs 9.7.7__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.

Potentially problematic release.


This version of boris-behav-obs might be problematic. Click here for more details.

Files changed (109) hide show
  1. boris/__init__.py +26 -0
  2. boris/__main__.py +25 -0
  3. boris/about.py +143 -0
  4. boris/add_modifier.py +635 -0
  5. boris/add_modifier_ui.py +303 -0
  6. boris/advanced_event_filtering.py +455 -0
  7. boris/analysis_plugins/__init__.py +0 -0
  8. boris/analysis_plugins/_latency.py +59 -0
  9. boris/analysis_plugins/irr_cohen_kappa.py +109 -0
  10. boris/analysis_plugins/irr_cohen_kappa_with_modifiers.py +112 -0
  11. boris/analysis_plugins/irr_weighted_cohen_kappa.py +157 -0
  12. boris/analysis_plugins/irr_weighted_cohen_kappa_with_modifiers.py +162 -0
  13. boris/analysis_plugins/list_of_dataframe_columns.py +22 -0
  14. boris/analysis_plugins/number_of_occurences.py +22 -0
  15. boris/analysis_plugins/number_of_occurences_by_independent_variable.py +54 -0
  16. boris/analysis_plugins/time_budget.py +61 -0
  17. boris/behav_coding_map_creator.py +1110 -0
  18. boris/behavior_binary_table.py +305 -0
  19. boris/behaviors_coding_map.py +239 -0
  20. boris/boris_cli.py +340 -0
  21. boris/cmd_arguments.py +49 -0
  22. boris/coding_pad.py +280 -0
  23. boris/config.py +785 -0
  24. boris/config_file.py +356 -0
  25. boris/connections.py +409 -0
  26. boris/converters.py +333 -0
  27. boris/converters_ui.py +225 -0
  28. boris/cooccurence.py +250 -0
  29. boris/core.py +5901 -0
  30. boris/core_qrc.py +15958 -0
  31. boris/core_ui.py +1107 -0
  32. boris/db_functions.py +324 -0
  33. boris/dev.py +134 -0
  34. boris/dialog.py +1108 -0
  35. boris/duration_widget.py +238 -0
  36. boris/edit_event.py +245 -0
  37. boris/edit_event_ui.py +233 -0
  38. boris/event_operations.py +1040 -0
  39. boris/events_cursor.py +61 -0
  40. boris/events_snapshots.py +596 -0
  41. boris/exclusion_matrix.py +141 -0
  42. boris/export_events.py +1006 -0
  43. boris/export_observation.py +1203 -0
  44. boris/external_processes.py +332 -0
  45. boris/geometric_measurement.py +941 -0
  46. boris/gui_utilities.py +135 -0
  47. boris/image_overlay.py +72 -0
  48. boris/import_observations.py +242 -0
  49. boris/ipc_mpv.py +325 -0
  50. boris/irr.py +634 -0
  51. boris/latency.py +244 -0
  52. boris/measurement_widget.py +161 -0
  53. boris/media_file.py +115 -0
  54. boris/menu_options.py +213 -0
  55. boris/modifier_coding_map_creator.py +1013 -0
  56. boris/modifiers_coding_map.py +157 -0
  57. boris/mpv.py +2016 -0
  58. boris/mpv2.py +2193 -0
  59. boris/observation.py +1453 -0
  60. boris/observation_operations.py +2538 -0
  61. boris/observation_ui.py +679 -0
  62. boris/observations_list.py +337 -0
  63. boris/otx_parser.py +442 -0
  64. boris/param_panel.py +201 -0
  65. boris/param_panel_ui.py +305 -0
  66. boris/player_dock_widget.py +198 -0
  67. boris/plot_data_module.py +536 -0
  68. boris/plot_events.py +634 -0
  69. boris/plot_events_rt.py +237 -0
  70. boris/plot_spectrogram_rt.py +316 -0
  71. boris/plot_waveform_rt.py +230 -0
  72. boris/plugins.py +431 -0
  73. boris/portion/__init__.py +31 -0
  74. boris/portion/const.py +95 -0
  75. boris/portion/dict.py +365 -0
  76. boris/portion/func.py +52 -0
  77. boris/portion/interval.py +581 -0
  78. boris/portion/io.py +181 -0
  79. boris/preferences.py +510 -0
  80. boris/preferences_ui.py +770 -0
  81. boris/project.py +2007 -0
  82. boris/project_functions.py +2041 -0
  83. boris/project_import_export.py +1096 -0
  84. boris/project_ui.py +794 -0
  85. boris/qrc_boris.py +10389 -0
  86. boris/qrc_boris5.py +2579 -0
  87. boris/select_modifiers.py +312 -0
  88. boris/select_observations.py +210 -0
  89. boris/select_subj_behav.py +286 -0
  90. boris/state_events.py +197 -0
  91. boris/subjects_pad.py +106 -0
  92. boris/synthetic_time_budget.py +290 -0
  93. boris/time_budget_functions.py +1136 -0
  94. boris/time_budget_widget.py +1039 -0
  95. boris/transitions.py +365 -0
  96. boris/utilities.py +1810 -0
  97. boris/version.py +24 -0
  98. boris/video_equalizer.py +159 -0
  99. boris/video_equalizer_ui.py +248 -0
  100. boris/video_operations.py +310 -0
  101. boris/view_df.py +104 -0
  102. boris/view_df_ui.py +75 -0
  103. boris/write_event.py +538 -0
  104. boris_behav_obs-9.7.7.dist-info/METADATA +139 -0
  105. boris_behav_obs-9.7.7.dist-info/RECORD +109 -0
  106. boris_behav_obs-9.7.7.dist-info/WHEEL +5 -0
  107. boris_behav_obs-9.7.7.dist-info/entry_points.txt +2 -0
  108. boris_behav_obs-9.7.7.dist-info/licenses/LICENSE.TXT +674 -0
  109. boris_behav_obs-9.7.7.dist-info/top_level.txt +1 -0
boris/export_events.py ADDED
@@ -0,0 +1,1006 @@
1
+ """
2
+ BORIS
3
+ Behavioral Observation Research Interactive Software
4
+ Copyright 2012-2025 Olivier Friard
5
+
6
+ This file is part of BORIS.
7
+
8
+ BORIS is free software; you can redistribute it and/or modify
9
+ it under the terms of the GNU General Public License as published by
10
+ the Free Software Foundation; either version 3 of the License, or
11
+ any later version.
12
+
13
+ BORIS is distributed in the hope that it will be useful,
14
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
15
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
16
+ GNU General Public License for more details.
17
+
18
+ You should have received a copy of the GNU General Public License
19
+ along with this program; if not see <http://www.gnu.org/licenses/>.
20
+
21
+ """
22
+
23
+ import datetime as dt
24
+ import logging
25
+ import math
26
+ import os
27
+ import tablib
28
+ import pathlib as pl
29
+ from decimal import Decimal as dec
30
+
31
+ from . import observation_operations
32
+ from . import utilities as util
33
+ from . import config as cfg
34
+ from . import select_observations
35
+ from . import export_observation
36
+ from . import select_subj_behav
37
+ from . import project_functions
38
+ from . import dialog
39
+ from . import db_functions
40
+
41
+ from PySide6.QtWidgets import QApplication, QFileDialog, QMessageBox, QInputDialog
42
+
43
+
44
+ def export_events_as_behavioral_sequences(self, separated_subjects=False, timed=False):
45
+ """
46
+ export events from selected observations by subject as behavioral sequences (plain text file)
47
+ behaviors are separated by character specified in self.behav_seq_separator (usually pipe |)
48
+ for use with Behatrix (see https://www.boris.unito.it/pages/behatrix)
49
+
50
+ Args:
51
+ separated_subjects (bool):
52
+ timed (bool):
53
+ """
54
+
55
+ # ask user for observations to analyze
56
+ _, selected_observations = select_observations.select_observations2(
57
+ self, cfg.MULTIPLE, "Select observations to export as behavioral sequences"
58
+ )
59
+
60
+ if not selected_observations:
61
+ return
62
+
63
+ # check if coded behaviors are defined in ethogram
64
+ if project_functions.check_coded_behaviors_in_obs_list(self.pj, selected_observations):
65
+ return
66
+
67
+ # check if state events are paired
68
+ not_ok, selected_observations = project_functions.check_state_events(self.pj, selected_observations)
69
+ if not_ok or not selected_observations:
70
+ return
71
+
72
+ if len(selected_observations) == 1:
73
+ max_media_duration_all_obs, _ = observation_operations.media_duration(self.pj[cfg.OBSERVATIONS], selected_observations)
74
+ start_coding, end_coding, _ = observation_operations.coding_time(self.pj[cfg.OBSERVATIONS], selected_observations)
75
+ start_interval, end_interval = observation_operations.time_intervals_range(self.pj[cfg.OBSERVATIONS], selected_observations)
76
+ else:
77
+ max_media_duration_all_obs = None
78
+ start_coding, end_coding = dec("NaN"), dec("NaN")
79
+ start_interval, end_interval = None, None
80
+
81
+ parameters = select_subj_behav.choose_obs_subj_behav_category(
82
+ self,
83
+ selected_observations,
84
+ start_coding=start_coding,
85
+ end_coding=end_coding,
86
+ start_interval=start_interval,
87
+ end_interval=end_interval,
88
+ maxTime=max_media_duration_all_obs,
89
+ show_include_modifiers=True,
90
+ show_exclude_non_coded_behaviors=False,
91
+ n_observations=len(selected_observations),
92
+ )
93
+
94
+ if parameters == {}:
95
+ return
96
+ if not parameters[cfg.SELECTED_SUBJECTS] or not parameters[cfg.SELECTED_BEHAVIORS]:
97
+ QMessageBox.warning(None, cfg.programName, "Select subject(s) and behavior(s) to analyze")
98
+ return
99
+
100
+ file_name, _ = QFileDialog.getSaveFileName(self, "Export events as behavioral sequences", "", "Text files (*.txt);;All files (*)")
101
+
102
+ if not file_name:
103
+ return
104
+ r, msg = export_observation.observation_to_behavioral_sequences(
105
+ pj=self.pj,
106
+ selected_observations=selected_observations,
107
+ parameters=parameters,
108
+ behaviors_separator=self.behav_seq_separator,
109
+ separated_subjects=separated_subjects,
110
+ timed=timed,
111
+ file_name=file_name,
112
+ )
113
+ if not r:
114
+ logging.critical(f"Error while exporting events as behavioral sequences: {msg}")
115
+ QMessageBox.critical(
116
+ None,
117
+ cfg.programName,
118
+ f"Error while exporting events as behavioral sequences:<br>{msg}",
119
+ QMessageBox.Ok | QMessageBox.Default,
120
+ QMessageBox.NoButton,
121
+ )
122
+
123
+
124
+ def export_tabular_events(self, mode: str = "tabular") -> None:
125
+ """
126
+ * select observations
127
+ * export events from the selected observations in various formats: TSV, CSV, ODS, XLSX, XLS, HTML
128
+
129
+ Args:
130
+ mode (str): export mode: must be ["tabular", "jwatcher"]
131
+ """
132
+
133
+ # ask user observations to analyze
134
+ _, selected_observations = select_observations.select_observations2(
135
+ self, cfg.MULTIPLE, windows_title="Select observations for exporting events"
136
+ )
137
+
138
+ if not selected_observations:
139
+ return
140
+
141
+ if mode == "jwatcher":
142
+ # check if images observation in list
143
+ max_obs_length, _ = observation_operations.observation_length(self.pj, selected_observations)
144
+
145
+ # exit with message if events do not have timestamp
146
+ if max_obs_length.is_nan():
147
+ QMessageBox.critical(
148
+ None,
149
+ cfg.programName,
150
+ ("This function is not available for observations with events that do not have timestamp"),
151
+ QMessageBox.Ok | QMessageBox.Default,
152
+ QMessageBox.NoButton,
153
+ )
154
+ return
155
+
156
+ # check if coded behaviors are defined in ethogram
157
+ if project_functions.check_coded_behaviors_in_obs_list(self.pj, selected_observations):
158
+ return
159
+
160
+ # check if state events are paired
161
+ not_ok, selected_observations = project_functions.check_state_events(self.pj, selected_observations)
162
+ if not_ok or not selected_observations:
163
+ return
164
+
165
+ if len(selected_observations) == 1:
166
+ max_media_duration_all_obs, _ = observation_operations.media_duration(self.pj[cfg.OBSERVATIONS], selected_observations)
167
+ start_coding, end_coding, _ = observation_operations.coding_time(self.pj[cfg.OBSERVATIONS], selected_observations)
168
+ start_interval, end_interval = observation_operations.time_intervals_range(self.pj[cfg.OBSERVATIONS], selected_observations)
169
+ else:
170
+ max_media_duration_all_obs = None
171
+ start_coding, end_coding = dec("NaN"), dec("NaN")
172
+ start_interval, end_interval = None, None
173
+
174
+ parameters = select_subj_behav.choose_obs_subj_behav_category(
175
+ self,
176
+ selected_observations,
177
+ start_coding=start_coding,
178
+ end_coding=end_coding,
179
+ start_interval=start_interval,
180
+ end_interval=end_interval,
181
+ maxTime=max_media_duration_all_obs,
182
+ show_include_modifiers=False,
183
+ show_exclude_non_coded_behaviors=False,
184
+ n_observations=len(selected_observations),
185
+ )
186
+ if parameters == {}:
187
+ return
188
+ if not parameters[cfg.SELECTED_SUBJECTS] or not parameters[cfg.SELECTED_BEHAVIORS]:
189
+ QMessageBox.warning(None, cfg.programName, "Select subject(s) and behavior(s) to analyze")
190
+ return
191
+
192
+ if mode == "tabular":
193
+ available_formats = (
194
+ cfg.TSV,
195
+ cfg.CSV,
196
+ cfg.ODS,
197
+ cfg.XLSX,
198
+ cfg.XLS,
199
+ cfg.HTML,
200
+ cfg.PANDAS_DF,
201
+ cfg.RDS,
202
+ )
203
+ if len(selected_observations) > 1: # choose directory for exporting observations
204
+ item, ok = QInputDialog.getItem(
205
+ self,
206
+ "Export events format",
207
+ "Available formats",
208
+ available_formats,
209
+ 0,
210
+ False,
211
+ )
212
+ if not ok:
213
+ return
214
+ output_format = cfg.FILE_NAME_SUFFIX[item]
215
+
216
+ exportDir = QFileDialog().getExistingDirectory(
217
+ self,
218
+ "Choose a directory to export events",
219
+ os.path.expanduser("~"),
220
+ options=QFileDialog.ShowDirsOnly,
221
+ )
222
+ if not exportDir:
223
+ return
224
+
225
+ if len(selected_observations) == 1:
226
+ file_dialog_options = QFileDialog.Options()
227
+ file_dialog_options |= QFileDialog.DontConfirmOverwrite
228
+
229
+ file_name, filter_ = QFileDialog().getSaveFileName(
230
+ self, "Export events", "", ";;".join(available_formats), options=file_dialog_options
231
+ )
232
+ if not file_name:
233
+ return
234
+
235
+ output_format = cfg.FILE_NAME_SUFFIX[filter_]
236
+ if pl.Path(file_name).suffix != "." + output_format:
237
+ file_name = str(pl.Path(file_name)) + "." + output_format
238
+ # check if file with new extension already exists
239
+ if pl.Path(file_name).exists():
240
+ if (
241
+ dialog.MessageDialog(cfg.programName, f"The file {file_name} already exists.", [cfg.CANCEL, cfg.OVERWRITE])
242
+ == cfg.CANCEL
243
+ ):
244
+ return
245
+
246
+ if mode == "jwatcher":
247
+ exportDir = QFileDialog().getExistingDirectory(
248
+ self, "Choose a directory to export events", os.path.expanduser("~"), options=QFileDialog.ShowDirsOnly
249
+ )
250
+ if not exportDir:
251
+ return
252
+
253
+ output_format = "dat"
254
+
255
+ mem_command = "" # remember user choice when file already exists
256
+ for obs_id in selected_observations:
257
+ if len(selected_observations) > 1 or mode == "jwatcher":
258
+ file_name = f"{pl.Path(exportDir) / util.safeFileName(obs_id)}.{output_format}"
259
+ # check if file with new extension already exists
260
+ if mem_command != cfg.OVERWRITE_ALL and pl.Path(file_name).is_file():
261
+ if mem_command == cfg.SKIP_ALL:
262
+ continue
263
+ mem_command = dialog.MessageDialog(
264
+ cfg.programName,
265
+ f"The file {file_name} already exists.",
266
+ [cfg.OVERWRITE, cfg.OVERWRITE_ALL, cfg.SKIP, cfg.SKIP_ALL, cfg.CANCEL],
267
+ )
268
+ if mem_command == cfg.CANCEL:
269
+ return
270
+ if mem_command in [cfg.SKIP, cfg.SKIP_ALL]:
271
+ continue
272
+
273
+ if mode == "tabular":
274
+ r, msg = export_observation.export_tabular_events(
275
+ self.pj,
276
+ parameters,
277
+ obs_id,
278
+ self.pj[cfg.OBSERVATIONS][obs_id],
279
+ self.pj[cfg.ETHOGRAM],
280
+ file_name,
281
+ output_format,
282
+ )
283
+
284
+ if mode == "jwatcher":
285
+ r, msg = export_observation.export_events_jwatcher(
286
+ parameters, obs_id, self.pj[cfg.OBSERVATIONS][obs_id], self.pj[cfg.ETHOGRAM], file_name, output_format
287
+ )
288
+
289
+ if not r and msg:
290
+ QMessageBox.critical(None, cfg.programName, msg, QMessageBox.Ok | QMessageBox.Default, QMessageBox.NoButton)
291
+
292
+
293
+ def export_aggregated_events(self):
294
+ """
295
+ - select observations.
296
+ - select subjects and behaviors
297
+ - export events in aggregated format
298
+
299
+ Formats can be SQL (sql), SDIS (sds), Tabular format (tsv, csv, ods, xlsx, xls, html) or Pandas dataframe
300
+ """
301
+
302
+ def fields_type(max_modif_number: int) -> dict:
303
+ fields_type_dict: dict = {
304
+ "Observation id": str,
305
+ "Observation date": dt.datetime,
306
+ "Description": str,
307
+ "Observation type": str,
308
+ "Source": str,
309
+ "Time offset (s)": str,
310
+ "Coding duration": float,
311
+ "Media duration (s)": str,
312
+ "FPS (frame/s)": str,
313
+ }
314
+ # TODO: "Media duration (s)" and "FPS (frame/s)" can be float for observation from 1 video
315
+
316
+ if cfg.INDEPENDENT_VARIABLES in self.pj:
317
+ for idx in util.sorted_keys(self.pj[cfg.INDEPENDENT_VARIABLES]):
318
+ if self.pj[cfg.INDEPENDENT_VARIABLES][idx]["type"] == "timestamp":
319
+ fields_type_dict[self.pj[cfg.INDEPENDENT_VARIABLES][idx]["label"]] = dt.datetime
320
+ elif self.pj[cfg.INDEPENDENT_VARIABLES][idx]["type"] == "numeric":
321
+ fields_type_dict[self.pj[cfg.INDEPENDENT_VARIABLES][idx]["label"]] = float
322
+ else:
323
+ fields_type_dict[self.pj[cfg.INDEPENDENT_VARIABLES][idx]["label"]] = str
324
+
325
+ fields_type_dict.update(
326
+ {
327
+ "Subject": str,
328
+ "Observation duration by subject by observation": float,
329
+ "Behavior": str,
330
+ "Behavioral category": str,
331
+ }
332
+ )
333
+
334
+ # max number of modifiers
335
+ for i in range(max_modif_number):
336
+ fields_type_dict[f"Modifier #{i + 1}"] = str
337
+
338
+ fields_type_dict.update(
339
+ {
340
+ "Behavior type": str,
341
+ "Start (s)": float,
342
+ "Stop (s)": float,
343
+ "Duration (s)": float,
344
+ "Media file name": str,
345
+ "Image index start": float, # add image index and image file path to header
346
+ "Image index stop": float,
347
+ "Image file path start": str,
348
+ "Image file path stop": str,
349
+ "Comment start": str,
350
+ "Comment stop": str,
351
+ }
352
+ )
353
+
354
+ return fields_type_dict
355
+
356
+ _, selected_observations = select_observations.select_observations2(self, cfg.MULTIPLE, "Select observations for exporting events")
357
+ if not selected_observations:
358
+ return
359
+
360
+ # check if coded behaviors are defined in ethogram
361
+ if project_functions.check_coded_behaviors_in_obs_list(self.pj, selected_observations):
362
+ return
363
+
364
+ # check if state events are paired
365
+ not_ok, selected_observations = project_functions.check_state_events(self.pj, selected_observations)
366
+ if not_ok or not selected_observations:
367
+ return
368
+
369
+ if len(selected_observations) == 1:
370
+ max_media_duration_all_obs, _ = observation_operations.media_duration(self.pj[cfg.OBSERVATIONS], selected_observations)
371
+ start_coding, end_coding, _ = observation_operations.coding_time(self.pj[cfg.OBSERVATIONS], selected_observations)
372
+ start_interval, end_interval = observation_operations.time_intervals_range(self.pj[cfg.OBSERVATIONS], selected_observations)
373
+ else:
374
+ max_media_duration_all_obs = None
375
+ start_coding, end_coding = dec("NaN"), dec("NaN")
376
+ start_interval, end_interval = None, None
377
+
378
+ parameters = select_subj_behav.choose_obs_subj_behav_category(
379
+ self,
380
+ selected_observations,
381
+ start_coding=start_coding,
382
+ end_coding=end_coding,
383
+ start_interval=start_interval,
384
+ end_interval=end_interval,
385
+ maxTime=max_media_duration_all_obs,
386
+ show_include_modifiers=False,
387
+ show_exclude_non_coded_behaviors=False,
388
+ n_observations=len(selected_observations),
389
+ )
390
+ if parameters == {}:
391
+ return
392
+ if not parameters[cfg.SELECTED_SUBJECTS] or not parameters[cfg.SELECTED_BEHAVIORS]:
393
+ QMessageBox.warning(None, cfg.programName, "Select subject(s) and behavior(s) to export")
394
+ return
395
+
396
+ # check for grouping results
397
+ flag_group = True
398
+ if len(selected_observations) > 1:
399
+ flag_group = (
400
+ dialog.MessageDialog(cfg.programName, "Group events from selected observations in one file?", [cfg.YES, cfg.NO]) == cfg.YES
401
+ )
402
+
403
+ if flag_group:
404
+ file_formats = (
405
+ cfg.TSV,
406
+ cfg.CSV,
407
+ cfg.ODS,
408
+ cfg.XLSX,
409
+ cfg.XLS,
410
+ cfg.HTML,
411
+ cfg.SDIS,
412
+ cfg.TBS,
413
+ cfg.SQL,
414
+ cfg.PANDAS_DF,
415
+ cfg.RDS,
416
+ )
417
+
418
+ file_dialog_options = QFileDialog.Options()
419
+ file_dialog_options |= QFileDialog.DontConfirmOverwrite
420
+
421
+ fileName, filter_ = QFileDialog().getSaveFileName(
422
+ self, "Export aggregated events", "", ";;".join(file_formats), options=file_dialog_options
423
+ )
424
+
425
+ if not fileName:
426
+ return
427
+
428
+ outputFormat = cfg.FILE_NAME_SUFFIX[filter_]
429
+ if pl.Path(fileName).suffix != "." + outputFormat:
430
+ # check if file with new extension already exists
431
+ fileName = str(pl.Path(fileName)) + "." + outputFormat
432
+ if pl.Path(fileName).exists():
433
+ if dialog.MessageDialog(cfg.programName, f"The file {fileName} already exists.", [cfg.CANCEL, cfg.OVERWRITE]) == cfg.CANCEL:
434
+ return
435
+
436
+ else: # not grouping
437
+ file_formats = (
438
+ cfg.TSV,
439
+ cfg.CSV,
440
+ cfg.ODS,
441
+ cfg.XLSX,
442
+ cfg.XLS,
443
+ cfg.HTML,
444
+ cfg.SDIS,
445
+ cfg.TBS,
446
+ cfg.PANDAS_DF,
447
+ cfg.RDS,
448
+ )
449
+ item, ok = QInputDialog.getItem(self, "Export events format", "Available formats", file_formats, 0, False)
450
+ if not ok:
451
+ return
452
+ # read the output format code
453
+ outputFormat = cfg.FILE_NAME_SUFFIX[item]
454
+
455
+ exportDir = QFileDialog().getExistingDirectory(
456
+ self, "Choose a directory to export events", os.path.expanduser("~"), options=QFileDialog.ShowDirsOnly
457
+ )
458
+ if not exportDir:
459
+ return
460
+
461
+ if outputFormat == cfg.SQL_EXT:
462
+ _, _, conn = db_functions.load_aggregated_events_in_db(
463
+ self.pj, parameters[cfg.SELECTED_SUBJECTS], selected_observations, parameters[cfg.SELECTED_BEHAVIORS]
464
+ )
465
+ try:
466
+ with open(fileName, "w") as f:
467
+ for line in conn.iterdump():
468
+ f.write(f"{line}\n")
469
+ except Exception:
470
+ QMessageBox.critical(
471
+ None,
472
+ cfg.programName,
473
+ f"The file {fileName} can not be saved",
474
+ QMessageBox.Ok | QMessageBox.Default,
475
+ QMessageBox.NoButton,
476
+ )
477
+
478
+ return
479
+
480
+ # compute the maximum number of modifiers
481
+ tot_max_modifiers: int = 0
482
+ for obs_id in selected_observations:
483
+ _, max_modifiers = export_observation.export_aggregated_events(self.pj, parameters, obs_id)
484
+ tot_max_modifiers = max(tot_max_modifiers, max_modifiers)
485
+
486
+ logging.debug(f"tot_max_modifiers: {tot_max_modifiers}")
487
+
488
+ data_grouped_obs = tablib.Dataset()
489
+
490
+ mem_command: str = "" # remember user choice when file already exists
491
+ header = list(fields_type(tot_max_modifiers).keys())
492
+
493
+ for obs_id in selected_observations:
494
+ logging.debug(f"Exporting aggregated events for obs Id: {obs_id}")
495
+
496
+ data_single_obs, _ = export_observation.export_aggregated_events(
497
+ self.pj, parameters, obs_id, force_number_modifiers=tot_max_modifiers
498
+ )
499
+
500
+ try:
501
+ # order by start time
502
+ index = header.index("Start (s)")
503
+ if cfg.NA not in [x[index] for x in list(data_single_obs)]:
504
+ data_single_obs_sorted = tablib.Dataset(
505
+ *sorted(list(data_single_obs), key=lambda x: float(x[index])),
506
+ headers=list(fields_type(tot_max_modifiers).keys()),
507
+ )
508
+ else:
509
+ # order by image index
510
+ index = header.index("Image index start")
511
+ data_single_obs_sorted = tablib.Dataset(
512
+ *sorted(list(data_single_obs), key=lambda x: float(x[index])),
513
+ headers=list(fields_type(tot_max_modifiers).keys()),
514
+ )
515
+ except Exception:
516
+ # if error no order
517
+ data_single_obs_sorted = tablib.Dataset(
518
+ *list(data_single_obs),
519
+ headers=list(fields_type(tot_max_modifiers).keys()),
520
+ )
521
+
522
+ data_single_obs_sorted.title = obs_id
523
+
524
+ if (not flag_group) and (outputFormat not in (cfg.SDIS_EXT, cfg.TBS_EXT)):
525
+ fileName = f"{pl.Path(exportDir) / util.safeFileName(obs_id)}.{outputFormat}"
526
+ # check if file with new extension already exists
527
+ if mem_command != cfg.OVERWRITE_ALL and pl.Path(fileName).is_file():
528
+ if mem_command == cfg.SKIP_ALL:
529
+ continue
530
+ mem_command = dialog.MessageDialog(
531
+ cfg.programName,
532
+ f"The file {fileName} already exists.",
533
+ [cfg.OVERWRITE, cfg.OVERWRITE_ALL, cfg.SKIP, cfg.SKIP_ALL, cfg.CANCEL],
534
+ )
535
+ if mem_command == cfg.CANCEL:
536
+ return
537
+ if mem_command in (cfg.SKIP, cfg.SKIP_ALL):
538
+ continue
539
+
540
+ r, msg = export_observation.dataset_write(data_single_obs_sorted, fileName, outputFormat, dtype=fields_type(max_modifiers))
541
+ if not r:
542
+ QMessageBox.warning(None, cfg.programName, msg, QMessageBox.Ok | QMessageBox.Default, QMessageBox.NoButton)
543
+
544
+ """
545
+ # disabled after introduction of the force_number_modifiers parameter in export_aggregated_events function
546
+ if len(data_single_obs_sorted) and max_modifiers < tot_max_modifiers:
547
+ for i in range(tot_max_modifiers - max_modifiers):
548
+ data_single_obs_sorted.insert_col(
549
+ 14,
550
+ col=[""] * (len(list(data_single_obs_sorted))),
551
+ header=f"Modif #{i}",
552
+ )
553
+ """
554
+
555
+ data_grouped_obs.extend(data_single_obs_sorted)
556
+
557
+ data_grouped_obs_all = tablib.Dataset(headers=list(fields_type(tot_max_modifiers).keys()))
558
+
559
+ data_grouped_obs_all.extend(data_grouped_obs)
560
+ data_grouped_obs_all.title = "Aggregated events"
561
+
562
+ start_idx = header.index("Start (s)")
563
+ stop_idx = header.index("Stop (s)")
564
+
565
+ if outputFormat == cfg.TBS_EXT: # Timed behavioral sequences
566
+ out: str = ""
567
+ for obs_id in selected_observations:
568
+ # observation id
569
+ out += f"# {obs_id}\n"
570
+
571
+ for event in list(data_grouped_obs_all):
572
+ if event[0] == obs_id:
573
+ behavior = event[header.index("Behavior")]
574
+ subject = event[header.index("Subject")]
575
+ # replace various char by _
576
+ for char in (" ", "-", "/"):
577
+ behavior = behavior.replace(char, "_")
578
+ subject = subject.replace(char, "_")
579
+ event_start = f"{float(event[start_idx]):.3f}" # start event
580
+ if not event[stop_idx]: # stop event (from end)
581
+ event_stop = f"{float(event[start_idx]) + 0.001:.3f}"
582
+ else:
583
+ event_stop = f"{float(event[stop_idx]):.3f}"
584
+
585
+ bs_timed = [f"{subject}_{behavior}"] * round((float(event_stop) - float(event_start)) * 100)
586
+ out += "|".join(bs_timed)
587
+
588
+ out += "\n"
589
+
590
+ if not flag_group:
591
+ fileName = f"{pl.Path(exportDir) / util.safeFileName(obs_id)}.{outputFormat}"
592
+ with open(fileName, "wb") as f:
593
+ f.write(str.encode(out))
594
+ out = ""
595
+
596
+ if flag_group:
597
+ with open(fileName, "wb") as f:
598
+ f.write(str.encode(out))
599
+ return
600
+
601
+ if outputFormat == cfg.SDIS_EXT: # SDIS format
602
+ out: str = f"% SDIS file created by BORIS (www.boris.unito.it) at {util.datetime_iso8601(dt.datetime.now())}\nTimed <seconds>;\n"
603
+ for obs_id in selected_observations:
604
+ # observation id
605
+ out += "\n<{}>\n".format(obs_id)
606
+
607
+ for event in list(data_grouped_obs_all):
608
+ if event[0] == obs_id:
609
+ behavior = event[header.index("Behavior")]
610
+ subject = event[header.index("Subject")]
611
+ # replace various char by _
612
+ for char in (" ", "-", "/"):
613
+ behavior = behavior.replace(char, "_")
614
+ subject = subject.replace(char, "_")
615
+
616
+ event_start = f"{float(event[start_idx]):.3f}" # start event
617
+ if not event[stop_idx]: # stop event (from end)
618
+ event_stop = f"{float(event[start_idx]) + 0.001:.3f}"
619
+ else:
620
+ event_stop = f"{float(event[stop_idx]):.3f}"
621
+ out += f"{subject}_{behavior},{event_start}-{event_stop} "
622
+
623
+ out += "/\n\n"
624
+ if not flag_group:
625
+ fileName = f"{pl.Path(exportDir) / util.safeFileName(obs_id)}.{outputFormat}"
626
+ with open(fileName, "wb") as f:
627
+ f.write(str.encode(out))
628
+ out = f"% SDIS file created by BORIS (www.boris.unito.it) at {util.datetime_iso8601(dt.datetime.now())}\nTimed <seconds>;\n"
629
+
630
+ if flag_group:
631
+ with open(fileName, "wb") as f:
632
+ f.write(str.encode(out))
633
+ return
634
+
635
+ if flag_group:
636
+ r, msg = export_observation.dataset_write(data_grouped_obs_all, fileName, outputFormat, dtype=fields_type(max_modifiers))
637
+ if not r:
638
+ QMessageBox.warning(None, cfg.programName, msg, QMessageBox.Ok | QMessageBox.Default, QMessageBox.NoButton)
639
+
640
+
641
+ def export_events_as_textgrid(self) -> None:
642
+ """
643
+ * select observations
644
+ * select subjects, behaviors and time interval
645
+ * export state events of selected observations as Praat textgrid
646
+ """
647
+
648
+ _, selected_observations = select_observations.select_observations2(self, mode=cfg.MULTIPLE, windows_title="")
649
+
650
+ if not selected_observations:
651
+ return
652
+
653
+ # check if coded behaviors are defined in ethogram
654
+ if project_functions.check_coded_behaviors_in_obs_list(self.pj, selected_observations):
655
+ return
656
+
657
+ # check if state events are paired
658
+ not_ok, selected_observations = project_functions.check_state_events(self.pj, selected_observations)
659
+ if not_ok or not selected_observations:
660
+ return
661
+
662
+ max_obs_length, _ = observation_operations.observation_length(self.pj, selected_observations)
663
+
664
+ # exit with message if events do not have timestamp
665
+ if max_obs_length.is_nan():
666
+ QMessageBox.critical(
667
+ None,
668
+ cfg.programName,
669
+ ("This function is not available for observations with events that do not have timestamp"),
670
+ QMessageBox.Ok | QMessageBox.Default,
671
+ QMessageBox.NoButton,
672
+ )
673
+ return
674
+
675
+ start_coding, end_coding, _ = observation_operations.coding_time(self.pj[cfg.OBSERVATIONS], selected_observations)
676
+
677
+ start_interval, end_interval = observation_operations.time_intervals_range(self.pj[cfg.OBSERVATIONS], selected_observations)
678
+
679
+ parameters = select_subj_behav.choose_obs_subj_behav_category(
680
+ self,
681
+ selected_observations,
682
+ start_coding=start_coding,
683
+ end_coding=end_coding,
684
+ # start_interval=start_interval,
685
+ # end_interval=end_interval,
686
+ start_interval=None,
687
+ end_interval=None,
688
+ show_include_modifiers=False,
689
+ show_exclude_non_coded_behaviors=False,
690
+ maxTime=max_obs_length,
691
+ n_observations=len(selected_observations),
692
+ )
693
+ if parameters == {}:
694
+ return
695
+ if not parameters[cfg.SELECTED_SUBJECTS] or not parameters[cfg.SELECTED_BEHAVIORS]:
696
+ QMessageBox.warning(None, cfg.programName, "Select subject(s) and behavior(s) to export")
697
+ return
698
+
699
+ export_dir = QFileDialog.getExistingDirectory(
700
+ self, "Export events as Praat TextGrid", os.path.expanduser("~"), options=QFileDialog.ShowDirsOnly
701
+ )
702
+ if not export_dir:
703
+ return
704
+
705
+ mem_command: str = ""
706
+
707
+ # see https://www.fon.hum.uva.nl/praat/manual/TextGrid_file_formats.html
708
+
709
+ interval_subject_header = (
710
+ " item [{subject_index}]:\n"
711
+ ' class = "IntervalTier"\n'
712
+ ' name = "{subject}"\n'
713
+ " xmin = 0.0\n"
714
+ " xmax = {intervalsMax}\n"
715
+ " intervals: size = {intervalsSize}\n"
716
+ )
717
+
718
+ interval_template = ' intervals [{count}]:\n xmin = {xmin}\n xmax = {xmax}\n text = "{name}"\n'
719
+
720
+ point_subject_header = (
721
+ " item [{subject_index}]:\n"
722
+ ' class = "TextTier"\n'
723
+ ' name = "{subject}"\n'
724
+ " xmin = {intervalsMin}\n"
725
+ " xmax = {intervalsMax}\n"
726
+ " points: size = {intervalsSize}\n"
727
+ )
728
+
729
+ point_template = ' points [{count}]:\n number = {number}\n mark = "{mark}"\n'
730
+
731
+ # widget for results
732
+ self.results = dialog.Results_dialog()
733
+ self.results.setWindowTitle(f"{cfg.programName} - Export events as Praat TextGrid")
734
+ self.results.show()
735
+
736
+ ok, msg, db_connector = db_functions.load_aggregated_events_in_db(
737
+ self.pj, parameters[cfg.SELECTED_SUBJECTS], selected_observations, parameters[cfg.SELECTED_BEHAVIORS]
738
+ )
739
+
740
+ if db_connector is None:
741
+ logging.critical("Error when loading aggregated events in DB")
742
+ return
743
+
744
+ cursor = db_connector.cursor()
745
+
746
+ file_count: int = 0
747
+
748
+ for obs_id in selected_observations:
749
+ if parameters["time"] == cfg.TIME_EVENTS:
750
+ start_coding, end_coding, coding_duration = observation_operations.coding_time(self.pj[cfg.OBSERVATIONS], [obs_id])
751
+ if start_coding is None and end_coding is None: # no events
752
+ self.results.ptText.appendHtml(f"The observation <b>{obs_id}</b> does not have events.")
753
+ QApplication.processEvents()
754
+ continue
755
+
756
+ if math.isnan(start_coding) or math.isnan(end_coding): # obs with no timestamp
757
+ self.results.ptText.appendHtml(f"The observation <b>{obs_id}</b> does not have timestamp.")
758
+ QApplication.processEvents()
759
+ continue
760
+
761
+ min_time = float(start_coding)
762
+ max_time = float(end_coding)
763
+
764
+ if parameters["time"] == cfg.TIME_FULL_OBS:
765
+ if self.pj[cfg.OBSERVATIONS][obs_id][cfg.TYPE] == cfg.MEDIA:
766
+ max_media_duration, _ = observation_operations.media_duration(self.pj[cfg.OBSERVATIONS], [obs_id])
767
+ min_time = float(0)
768
+ max_time = float(max_media_duration)
769
+ coding_duration = max_media_duration
770
+
771
+ if self.pj[cfg.OBSERVATIONS][obs_id][cfg.TYPE] in (cfg.LIVE, cfg.IMAGES):
772
+ start_coding, end_coding, coding_duration = observation_operations.coding_time(self.pj[cfg.OBSERVATIONS], [obs_id])
773
+ if start_coding is None and end_coding is None: # no events
774
+ self.results.ptText.appendHtml(f"The observation <b>{obs_id}</b> does not have events.")
775
+ QApplication.processEvents()
776
+ continue
777
+ if math.isnan(start_coding) or math.isnan(end_coding): # obs with no timestamp
778
+ self.results.ptText.appendHtml(f"The observation <b>{obs_id}</b> does not have timestamp.")
779
+ QApplication.processEvents()
780
+ continue
781
+
782
+ min_time = float(start_coding)
783
+ max_time = float(end_coding)
784
+
785
+ if parameters["time"] == cfg.TIME_ARBITRARY_INTERVAL:
786
+ min_time = float(parameters[cfg.START_TIME])
787
+ max_time = float(parameters[cfg.END_TIME])
788
+
789
+ if parameters["time"] == cfg.TIME_OBS_INTERVAL:
790
+ max_media_duration, _ = observation_operations.media_duration(self.pj[cfg.OBSERVATIONS], [obs_id])
791
+ obs_interval = self.pj[cfg.OBSERVATIONS][obs_id].get(cfg.OBSERVATION_TIME_INTERVAL, [0, 0])
792
+ offset = float(self.pj[cfg.OBSERVATIONS][obs_id][cfg.TIME_OFFSET])
793
+ min_time = float(obs_interval[0]) + offset
794
+ # Use max media duration for max time if no interval is defined (=0)
795
+ max_time = float(obs_interval[1]) + offset if obs_interval[1] != 0 else float(max_media_duration)
796
+
797
+ # delete events outside time interval
798
+
799
+ cursor.execute(
800
+ "DELETE FROM aggregated_events WHERE observation = ? AND (start < ? AND stop < ?) OR (start > ? AND stop > ?)",
801
+ (
802
+ obs_id,
803
+ min_time,
804
+ min_time,
805
+ max_time,
806
+ max_time,
807
+ ),
808
+ )
809
+ cursor.execute(
810
+ "UPDATE aggregated_events SET start = ? WHERE observation = ? AND start < ? AND stop BETWEEN ? AND ?",
811
+ (
812
+ min_time,
813
+ obs_id,
814
+ min_time,
815
+ min_time,
816
+ max_time,
817
+ ),
818
+ )
819
+ cursor.execute(
820
+ "UPDATE aggregated_events SET stop = ? WHERE observation = ? AND stop > ? AND start BETWEEN ? AND ?",
821
+ (
822
+ max_time,
823
+ obs_id,
824
+ max_time,
825
+ min_time,
826
+ max_time,
827
+ ),
828
+ )
829
+ cursor.execute(
830
+ "UPDATE aggregated_events SET start = ?, stop = ? WHERE observation = ? AND start < ? AND stop > ?",
831
+ (
832
+ min_time,
833
+ max_time,
834
+ obs_id,
835
+ min_time,
836
+ max_time,
837
+ ),
838
+ )
839
+
840
+ next_obs: bool = False
841
+
842
+ # number of items for size parameter
843
+ cursor.execute(
844
+ (
845
+ "SELECT COUNT(*) FROM (SELECT * FROM aggregated_events "
846
+ f"WHERE observation = ? AND subject IN ({','.join(['?'] * len(parameters[cfg.SELECTED_SUBJECTS]))}) GROUP BY subject, behavior) "
847
+ ),
848
+ [obs_id] + parameters[cfg.SELECTED_SUBJECTS],
849
+ )
850
+
851
+ subjects_num = int(cursor.fetchone()[0])
852
+ subjects_max = max_time
853
+
854
+ out = (
855
+ 'File type = "ooTextFile"\n'
856
+ 'Object class = "TextGrid"\n'
857
+ "\n"
858
+ f"xmin = 0.0\n"
859
+ f"xmax = {subjects_max}\n"
860
+ "tiers? <exists>\n"
861
+ f"size = {subjects_num}\n"
862
+ "item []:\n"
863
+ )
864
+
865
+ subject_index: int = 0
866
+ for subject in parameters[cfg.SELECTED_SUBJECTS]:
867
+ if subject not in [
868
+ x[cfg.EVENT_SUBJECT_FIELD_IDX] if x[cfg.EVENT_SUBJECT_FIELD_IDX] else cfg.NO_FOCAL_SUBJECT
869
+ for x in self.pj[cfg.OBSERVATIONS][obs_id][cfg.EVENTS]
870
+ ]:
871
+ continue
872
+
873
+ intervalsMin = min_time
874
+ intervalsMax = max_time
875
+
876
+ # STATE events
877
+ cursor.execute(
878
+ (
879
+ "SELECT start, stop, behavior FROM aggregated_events "
880
+ "WHERE observation = ? AND subject = ? AND type = 'STATE' ORDER BY start"
881
+ ),
882
+ (obs_id, subject),
883
+ )
884
+
885
+ rows = [
886
+ {"start": util.float2decimal(r["start"]), "stop": util.float2decimal(r["stop"]), "code": r["behavior"]}
887
+ for r in cursor.fetchall()
888
+ ]
889
+ if rows:
890
+ out += interval_subject_header
891
+
892
+ count = 0
893
+
894
+ # check if 1st behavior starts at the beginning
895
+ if rows[0]["start"] > 0:
896
+ count += 1
897
+ out += interval_template.format(count=count, name="null", xmin=0.0, xmax=rows[0]["start"])
898
+
899
+ for idx, row in enumerate(rows):
900
+ # check if events are overlapping
901
+ if (idx + 1 < len(rows)) and (row["stop"] > rows[idx + 1]["start"]):
902
+ self.results.ptText.appendHtml(
903
+ (
904
+ f"The events overlap for subject <b>{subject}</b> in the observation <b>{obs_id}</b>. "
905
+ "It is not possible to create the Praat TextGrid file."
906
+ )
907
+ )
908
+ QApplication.processEvents()
909
+
910
+ next_obs = True
911
+ break
912
+
913
+ count += 1
914
+
915
+ if (idx + 1 < len(rows)) and (rows[idx + 1]["start"] - dec("0.001") <= row["stop"] < rows[idx + 1]["start"]):
916
+ xmax = rows[idx + 1]["start"]
917
+ else:
918
+ xmax = row["stop"]
919
+
920
+ out += interval_template.format(count=count, name=row["code"], xmin=row["start"], xmax=xmax)
921
+
922
+ # check if no behavior
923
+ if (idx + 1 < len(rows)) and (row["stop"] < rows[idx + 1]["start"] - dec("0.001")):
924
+ count += 1
925
+ out += interval_template.format(
926
+ count=count,
927
+ name="null",
928
+ xmin=row["stop"],
929
+ xmax=rows[idx + 1]["start"],
930
+ )
931
+
932
+ if next_obs:
933
+ break
934
+
935
+ # check if last event ends at the end of media file
936
+ if rows[-1]["stop"] < max_time:
937
+ count += 1
938
+ out += interval_template.format(count=count, name="null", xmin=rows[-1]["stop"], xmax=max_time)
939
+
940
+ # add info
941
+ subject_index += 1
942
+ out = out.format(
943
+ subject_index=subject_index,
944
+ subject=subject,
945
+ intervalsSize=count,
946
+ intervalsMin=intervalsMin,
947
+ intervalsMax=intervalsMax,
948
+ )
949
+
950
+ # POINT events
951
+ cursor.execute(
952
+ ("SELECT start, behavior FROM aggregated_events WHERE observation = ? AND subject = ? AND type = 'POINT' ORDER BY start"),
953
+ (obs_id, subject),
954
+ )
955
+
956
+ rows = [{"start": util.float2decimal(r["start"]), "code": r["behavior"]} for r in cursor.fetchall()]
957
+ if not rows:
958
+ continue
959
+
960
+ out += point_subject_header
961
+
962
+ count = 0
963
+
964
+ for idx, row in enumerate(rows):
965
+ count += 1
966
+ out += point_template.format(count=count, mark=row["code"], number=row["start"])
967
+
968
+ # add info
969
+ subject_index += 1
970
+ out = out.format(
971
+ subject_index=subject_index,
972
+ subject=subject,
973
+ intervalsSize=count,
974
+ intervalsMin=intervalsMin,
975
+ intervalsMax=intervalsMax,
976
+ )
977
+
978
+ if next_obs:
979
+ continue
980
+
981
+ # check if file already exists
982
+ if mem_command != cfg.OVERWRITE_ALL and pl.Path(f"{pl.Path(export_dir) / util.safeFileName(obs_id)}.TextGrid").is_file():
983
+ if mem_command == cfg.SKIP_ALL:
984
+ continue
985
+ mem_command = dialog.MessageDialog(
986
+ cfg.programName,
987
+ f"The file <b>{pl.Path(export_dir) / util.safeFileName(obs_id)}.TextGrid</b> already exists.",
988
+ [cfg.OVERWRITE, cfg.OVERWRITE_ALL, cfg.SKIP, cfg.SKIP_ALL, cfg.CANCEL],
989
+ )
990
+ if mem_command == cfg.CANCEL:
991
+ return
992
+ if mem_command in (cfg.SKIP, cfg.SKIP_ALL):
993
+ continue
994
+
995
+ try:
996
+ with open(f"{pl.Path(export_dir) / util.safeFileName(obs_id)}.TextGrid", "w") as f:
997
+ f.write(out)
998
+ file_count += 1
999
+ self.results.ptText.appendHtml(f"File {pl.Path(export_dir) / util.safeFileName(obs_id)}.TextGrid was created.")
1000
+ QApplication.processEvents()
1001
+ except Exception:
1002
+ self.results.ptText.appendHtml(f"The file {pl.Path(export_dir) / util.safeFileName(obs_id)}.TextGrid can not be created.")
1003
+ QApplication.processEvents()
1004
+
1005
+ self.results.ptText.appendHtml(f"Done. {file_count} file(s) were created in {export_dir}.")
1006
+ QApplication.processEvents()