From 279fb42ec86d4d7d064c1063aac9e29cdbcbe062 Mon Sep 17 00:00:00 2001 From: Dave Walker Date: Wed, 20 May 2026 11:32:26 +0100 Subject: [PATCH] Classify bat sequences by regions and phase lengths --- data/templates/bat_phase_analysis.py | 7 +++- src/common/storage.py | 57 ---------------------------- src/ecology/batphase.py | 50 +++++++++++++++++++++--- src/examples/batbuzz.py | 13 ++++--- src/support/extract_pulses.py | 43 +++++++++++++++++++-- src/support/extract_timings.py | 7 +++- src/support/minimiser.conf | 2 +- 7 files changed, 105 insertions(+), 74 deletions(-) delete mode 100644 src/common/storage.py diff --git a/data/templates/bat_phase_analysis.py b/data/templates/bat_phase_analysis.py index 1b03f6c..e4dbad4 100644 --- a/data/templates/bat_phase_analysis.py +++ b/data/templates/bat_phase_analysis.py @@ -1,4 +1,4 @@ -from batphase import detect_feeding_buzz_phases +from batphase import detect_feeding_buzz_phases, classify from tabulate import build_table, print_table #: Pulse widths in ms @@ -11,7 +11,10 @@ DPRI = $DPRI #: Detect the feeding buzz phases -_, regions, classification = detect_feeding_buzz_phases(WIDTHS, PRI, DPRI) +_, regions = detect_feeding_buzz_phases(WIDTHS, PRI, DPRI) + +#: Classify the sequence +classification = classify(regions) #: Build the phase table table = build_table(regions, ("Phase", "Start", "End", "Length")) diff --git a/src/common/storage.py b/src/common/storage.py deleted file mode 100644 index e60eb30..0000000 --- a/src/common/storage.py +++ /dev/null @@ -1,57 +0,0 @@ -from ti_system import store_string, recall_string - -SEPARATOR = "|" - - -def encode(data): - """ - Encode a dictionary of values for string storage - - :param data: Dictionary of simple values to store - :return: Encoded string for storage - """ - parts = [] - for k in data: - parts.append(str(k) + "=" + str(data[k])) - - return SEPARATOR.join(parts) - - -def decode(text): - """ - Decode a string from storage format to a simple dictionary of values - - :param text: Encoded string - :return: Dictionary of decoded values - """ - data = {} - if text == "": - return data - for part in text.split(SEPARATOR): - k, v = part.split("=") - data[k] = v - return data - - -def save(slot, data): - """ - Save a dictionary of simple values - - :param slot: String name - :param data: Dictionary of simple values to store - """ - name = "SAVE" + str(slot) - encoded = encode(data) - store_string(name, encoded) - - -def load(slot): - """ - Retrieve a dictionary of simple values from storage - - :param slot: String name - :return: Dictionary of simple values - """ - name = "SAVE" + str(slot) - text = recall_string(name) - return decode(text) if text else None diff --git a/src/ecology/batphase.py b/src/ecology/batphase.py index 1ee37ce..cc57429 100644 --- a/src/ecology/batphase.py +++ b/src/ecology/batphase.py @@ -248,12 +248,52 @@ def detect_feeding_buzz_phases(widths, pri, dpri): for i in range(buzz_end + 1, n): phases[i] = EXIT + return phases, build_regions(phases) + + +def classify(regions): + """ + Classify a sequence based on its regions and their length + + :param regions: List of tuples of regions + :return: Classification + """ + total = 0 + lengths = {} + + # Collate the number of pulses by phase assignment + for phase, _, _, length in regions: + total += length + lengths[phase] = lengths.get(phase, 0) + length + + # Extract the lengths of each phase assignment + search_pulses = lengths.get("SEARCH", 0) + approach_pulses = lengths.get("APPROACH", 0) + buzz_pulses = lengths.get("BUZZ", 0) + exit_pulses = lengths.get("EXIT", 0) + + # Calculate the fraction of the total that are search pulses + search_frac = search_pulses / total if total else 0 + + # Determine which regions are present + has_buzz = buzz_pulses > 0 + has_approach = approach_pulses > 0 + has_exit = exit_pulses > 0 + # Classify the sequence - if BUZZ in phases: + if has_buzz and has_exit and buzz_pulses >= 6 and exit_pulses >= 5: + classification = "FEEDING PASS WITH RECOVERY EXIT" + elif has_buzz and buzz_pulses >= 6: classification = "FEEDING BUZZ" - elif APPROACH in phases: - classification = "APPROACH ONLY" - else: + elif has_buzz: + classification = "SHORT OR WEAK BUZZ" + elif has_approach and approach_pulses >= 5: + classification = "APPROACH WITHOUT BUZZ" + elif has_approach: + classification = "BRIEF APPROACH ONLY" + elif search_frac >= 0.85: classification = "SEARCH ONLY" + else: + classification = "MIXED OR UNCERTAIN PASS" - return phases, build_regions(phases), classification + return classification diff --git a/src/examples/batbuzz.py b/src/examples/batbuzz.py index 73ec545..c82f30d 100644 --- a/src/examples/batbuzz.py +++ b/src/examples/batbuzz.py @@ -1,17 +1,20 @@ -from batphase import detect_feeding_buzz_phases +from batphase import detect_feeding_buzz_phases, classify from tabulate import build_table, print_table #: Pulse widths in ms -WIDTHS = (0.05111111111111111, 0.04693877551020406, 0.046485260770975034, 0.03356009070294785, 0.04643990929705216, 0.040362811791383235, 0.049024943310657654, 0.04435374149659865, 0.04825396825396833, 0.0386848072562358, 0.0338321995464852, 0.04022675736961445, 0.03573696145124727, 0.039682539682539764, 0.04240362811791387, 0.040181405895691746, 0.03959183673469391, 0.04367346938775518, 0.04349206349206347, 0.045941043083900235, 0.04589569160997731, 0.0401360544217686, 0.036190476190476106, 0.03396825396825398, 0.029251700680272164, 0.02285714285714291, 0.02408163265306107, 0.03224489795918384, 0.03396825396825376, 0.03519274376417236, 0.034739229024943086, 0.03795918367346918, 0.03428571428571425, 0.03609977324263047, 0.03256235827664433, 0.04548752834467118, 0.04081632653061229, 0.04099773242630356, 0.04004535147392252, 0.04040816326530594, 0.028888888888888964, 0.0394557823129249, 0.026848072562358105, 0.03859410430838972, 0.0439002267573696, 0.044625850340136, 0.04757369614512452, 0.04349206349206369, 0.04258503401360514, 0.03727891156462615) +WIDTHS = (0.04240362811791387, 0.040181405895691746, 0.03959183673469391, 0.04367346938775518, 0.04349206349206347, 0.045941043083900235, 0.04589569160997731, 0.0401360544217686, 0.036190476190476106, 0.03396825396825398, 0.029251700680272164) #: Pulse Repetition Interval, PRI -PRI = (0.08707482993197277, 0.09188208616780047, 0.0875283446712018, 0.08712018140589572, 0.09052154195011336, 0.09011337868480729, 0.09560090702947843, 0.07941043083900223, 0.08739229024943318, 0.09002267573696143, 0.08444444444444443, 0.081859410430839, 0.08802721088435383, 0.08263038548752832, 0.0840362811791382, 0.06390022675736962, 0.08589569160997734, 0.0734240362811791, 0.051337868480725746, 0.05764172335600892, 0.04335600907029491, 0.045578231292517035, 0.028843537414965814, 0.043809523809523965, 0.03428571428571425, 0.027936507936507926, 0.37918367346938786, 0.07800453514739214, 0.08585034013605464, 0.0790022675736961, 0.08331065759637157, 0.08195011337868507, 0.07818594104308385, 0.07183673469387752, 0.08258503401360562, 0.08145124716553287, 0.08485260770975023, 0.09065759637188231, 0.11800453514739218, 0.09024943310657596, 0.09283446712018151, 0.08947845804988663, 0.08916099773242614, 0.08580498866213171, 0.08825396825396803, 0.08916099773242658, 0.08829931972789096, 0.09814058956916138, 0.09247165532879809, None) +PRI = (0.0840362811791382, 0.06390022675736962, 0.08589569160997734, 0.0734240362811791, 0.051337868480725746, 0.05764172335600892, 0.04335600907029491, 0.045578231292517035, 0.028843537414965814, 0.043809523809523965, None) #: Delta-PRI, DPRI -DPRI = (None, 0.0048072562358277005, -0.004353741496598673, -0.00040816326530607183, 0.0034013605442176353, -0.00040816326530607183, 0.005487528344671144, -0.0161904761904762, 0.007981859410430947, 0.002630385487528253, -0.005578231292517, -0.002585034013605436, 0.006167800453514838, -0.005396825396825511, 0.0014058956916098708, -0.02013605442176858, 0.021995464852607727, -0.012471655328798237, -0.02208616780045336, 0.006303854875283177, -0.014285714285714013, 0.0022222222222221255, -0.01673469387755122, 0.01496598639455815, -0.009523809523809712, -0.006349206349206327, 0.35124716553287993, -0.3011791383219957, 0.007845804988662497, -0.0068480725623585315, 0.004308390022675468, -0.001360544217686499, -0.0037641723356012236, -0.006349206349206327, 0.010748299319728094, -0.0011337868480727487, 0.003401360544217358, 0.005804988662132082, 0.027346938775509866, -0.027755102040816215, 0.002585034013605547, -0.003356009070294874, -0.00031746031746049397, -0.00335600907029443, 0.00244897959183632, 0.0009070294784585542, -0.0008616780045356265, 0.009841269841270428, -0.005668934240363299, None) +DPRI = (None, -0.02013605442176858, 0.021995464852607727, -0.012471655328798237, -0.02208616780045336, 0.006303854875283177, -0.014285714285714013, 0.0022222222222221255, -0.01673469387755122, 0.01496598639455815, None) #: Detect the feeding buzz phases -_, regions, classification = detect_feeding_buzz_phases(WIDTHS, PRI, DPRI) +_, regions = detect_feeding_buzz_phases(WIDTHS, PRI, DPRI) + +#: Classify the sequence +classification = classify(regions) #: Build the phase table table = build_table(regions, ("Phase", "Start", "End", "Length")) diff --git a/src/support/extract_pulses.py b/src/support/extract_pulses.py index 1a94789..44fce3f 100644 --- a/src/support/extract_pulses.py +++ b/src/support/extract_pulses.py @@ -52,11 +52,26 @@ import os import argparse from pathlib import Path +from datetime import datetime DEFAULT_TEMPLATE = Path(__file__).parent.parent.parent / "data" / "templates" / "bat_pulse_chart.py" -def load_pulse_json(input_file_path: str | Path) -> dict: +def print_message(message: str) -> None: + """ + Show a timestamped message + + :param message: Message text + """ + timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + print(f"{timestamp} : {message}") + + +def load_pulse_json( + input_file_path: str | Path, + start_index: int = 1, + end_index: int = None +) -> dict: """ Load the consensus parameter set from the seasonal modelling consensus JSON file for the species @@ -64,25 +79,43 @@ def load_pulse_json(input_file_path: str | Path) -> dict: :param input_file_path: JSON file path :return: Tuple of the species namd and the filtered list of parameters """ + print_message(f"Loading {input_file_path}") with open(input_file_path, "r", encoding="utf-8") as f: data = json.load(f) # Extract the original input file and extract it's name as the source source_file = data.get("input", data.get("input_file", None)) source = Path(source_file).name + print_message(f"Source file name {source}") # Extract the analysis mode mode = data["analysis_mode"] + print_message(f"Detected mode {mode}") + + # Extract only the required range of pulses + pulses = data["pulses"] + max_pulse_index = len(pulses) + print_message(f"Detected {max_pulse_index} pulses") + + if start_index is None or start_index <= 0: + start_index = 1 + + if end_index is None or end_index > max_pulse_index: + end_index = max_pulse_index + + pulses = pulses[start_index - 1:end_index] + print_message(f"{len(pulses)} pulses remain after extracting the slice from {start_index} to {end_index}") # Extract the pulses. Use the "real" start time, end time and peak timing fiels. # For heterodyne recordings, these will be the same as the "non-real" versions # but for time expansion the "non-real" versions reflect the expanded timings timings = [] - for pulse in data["pulses"]: + for pulse in pulses: timings.append(pulse["real_start_time_s"]) timings.append(pulse["real_end_time_s"]) timings.append(pulse["real_peak_time_s"]) + print_message(f"Extracted {len(timings) // 3} sets of timing values") return source, mode, tuple(timings) @@ -93,6 +126,7 @@ def read_template(template_file_path: str | Path) -> str: :param template_file_path: Path to the template :return: Contents of the template """ + print_message(f"Loading template {template_file_path}") with open(template_file_path, "r", encoding="utf-8") as f: return f.read() @@ -116,6 +150,7 @@ def write_analysis_script(output_path: str | Path, script: str): :param output_path: Path to the folder where the script is to be written :param script: File contents """ + print_message(f"Writing launcher {output_path}") with open(output_path, "w", encoding="utf-8") as f: return f.write(script) @@ -128,11 +163,13 @@ def main(): parser.add_argument("-i", "--input", required=True, help="Path to the input JSON file") parser.add_argument("-t", "--template", default=DEFAULT_TEMPLATE, help="Template used to build the bat call analysis script") + parser.add_argument("-si", "--start-index", type=int, default=1, help="Index of first pulse to include") + parser.add_argument("-ei", "--end-index", type=int, default=None, help="Index of last pulse to include") parser.add_argument("-o", "--output", required=True, help="Path to the output script") args = parser.parse_args() # Load the data and extract the pulse information into a tuple - _, _, pulses = load_pulse_json(args.input) + _, _, pulses = load_pulse_json(args.input, args.start_index, args.end_index) # Load the template and generate the script content template = read_template(args.template) diff --git a/src/support/extract_timings.py b/src/support/extract_timings.py index 660e7e2..48f6265 100644 --- a/src/support/extract_timings.py +++ b/src/support/extract_timings.py @@ -45,6 +45,9 @@ scripts/extract-timings.sh -i data/spectrogram/BD-A-99-001-analysis.json -o src/examples/batbuzz.py +If only part of the sequence is to be included, the --start-index and --end-index arguments can +be used to specify the 1-based indices for the first and last pulse to include + The resulting script can then be minified and transferred to the calculator along with the bat phase analysis library module and then run to generate the pulse characteristics. @@ -114,11 +117,13 @@ def main(): parser.add_argument("-i", "--input", required=True, help="Path to the input JSON file") parser.add_argument("-t", "--template", default=DEFAULT_TEMPLATE, help="Template used to build the bat phase analysis script") + parser.add_argument("-si", "--start-index", type=int, default=1, help="Index of first pulse to include") + parser.add_argument("-ei", "--end-index", type=int, default=None, help="Index of last pulse to include") parser.add_argument("-o", "--output", required=True, help="Path to the output script") args = parser.parse_args() # Load the pulse data from the JSON file and generate the pulse timing information - _, _, pulses = load_pulse_json(args.input) + _, _, pulses = load_pulse_json(args.input, args.start_index, args.end_index) widths, pri, _, dpri = analyse_pulse_timings(pulses) # Load the template and generate the script content diff --git a/src/support/minimiser.conf b/src/support/minimiser.conf index d840493..25bddcb 100644 --- a/src/support/minimiser.conf +++ b/src/support/minimiser.conf @@ -38,7 +38,7 @@ "batphase.py": { "aggressive": true, "preserve": [ - "classify_sequence", + "classify", "detect_feeding_buzz_phases" ] },