From d0902423c15d9292cede04c194e9fc34b2e69a3a Mon Sep 17 00:00:00 2001 From: Gautzilla Date: Wed, 20 May 2026 17:58:05 +0200 Subject: [PATCH 01/28] add the osekit.core.annotation.Annotation class --- src/osekit/core/annotation.py | 62 +++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 src/osekit/core/annotation.py diff --git a/src/osekit/core/annotation.py b/src/osekit/core/annotation.py new file mode 100644 index 00000000..439e0498 --- /dev/null +++ b/src/osekit/core/annotation.py @@ -0,0 +1,62 @@ +"""The Annotation class represents an annotation made on APLOSE.""" + +from pandas import Timestamp + +from osekit.core.event import Event + + +class Annotation(Event): + """Class represents an annotation made on APLOSE.""" + + def __init__( # noqa: PLR0913 + self, + project: str, + begin: Timestamp, + end: Timestamp, + min_frequency: int, + max_frequency: int, + annotation: str, + annotator: str, + confidence_indicator_label: str, + confidence_indicator_level: str, + *, + is_box: bool, + ) -> None: + """Initialize an Annotation object. + + Parameters + ---------- + project: str + Name of the project in which the annotation was made. + begin: Timestamp + Begin timestamp of the annotation. + end: Timestamp + End timestamp of the annotation. + min_frequency: int + Minimum frequency of the annotation. + max_frequency: int + Maximum frequency of the annotation. + annotation: str + Label of the annotation. + annotator: str + Name of the annotator or detector. + confidence_indicator_label: str + Name of the level of confidence. + confidence_indicator_level: str + Level of confidence relative to the maximum level available. + Should be formatted as ``n/m``, where ``n`` is the level of confidence + of the annotation and ``m`` is the maximum level available in the project. + is_box: bool + If ``True``, the annotation is a box. + If ``False``, the annotation is a weak annotation. + + """ + super().__init__(begin=begin, end=end) + self.project = project + self.min_frequency = min_frequency + self.max_frequency = max_frequency + self.annotation = annotation + self.annotator = annotator + self.confidence_indicator_label = confidence_indicator_label + self.confidence_indicator_level = confidence_indicator_level + self.is_box = is_box From 721525b29d2471d994553d18961d1f53c01f9121 Mon Sep 17 00:00:00 2001 From: Gautzilla Date: Wed, 27 May 2026 18:05:50 +0200 Subject: [PATCH 02/28] add Annotation deserialization from csv --- src/osekit/core/annotation.py | 313 ++++++++++++++++++++++++++++++---- 1 file changed, 277 insertions(+), 36 deletions(-) diff --git a/src/osekit/core/annotation.py b/src/osekit/core/annotation.py index 439e0498..a9748a36 100644 --- a/src/osekit/core/annotation.py +++ b/src/osekit/core/annotation.py @@ -1,62 +1,303 @@ """The Annotation class represents an annotation made on APLOSE.""" +from dataclasses import dataclass +from pathlib import Path +from typing import Literal, Self + +import pandas as pd from pandas import Timestamp from osekit.core.event import Event +KNOWN_KEYS = { + "dataset", + "analysis", + "filename", + "annotation_id", + "is_update_of_id", + "start_time", + "end_time", + "start_frequency", + "end_frequency", + "min_frequency", + "max_frequency", + "annotation", + "annotator", + "annotator_expertise", + "start_datetime", + "end_datetime", + "is_box", + "type", + "confidence_indicator_label", + "confidence_indicator_level", + "comments", + "signal_quantity", + "signal_is_intensity_too_low", + "signal_does_overlap_other_signals", + "signal_start_frequency", + "signal_end_frequency", + "signal_relative_min_frequency_count", + "signal_relative_max_frequency_count", + "signal_steps_count", + "signal_has_harmonics", + "signal_trend", + "signal_sidebands", + "signal_subharmonics", + "signal_frequency_jumps", + "signal_deterministic_chaos", + "created_at_phase", +} + + +@dataclass +class FrequencyBounds: + """Class representing the frequency bounds of an annotation. + + Parameters + ---------- + min: int + Lower frequency bound. + max: int + Upper frequency bound. + + """ + + min: int + max: int + + @property + def bandwidth(self) -> int: + """Bandwidth of the annotation.""" + return self.max - self.min + + +@dataclass +class AnnotatorInfo: + """Class representing an annotator info.""" + + annotator: str + annotator_expertise: Literal["NOVICE", "AVERAGE", "EXPERT"] + + +@dataclass +class SignalParameters: + """Class representing parameters of an annoted signal.""" + + is_itensity_too_low: bool + does_overlap_other_signals: bool + min_frequency: int + max_frequency: int + nb_relative_mins: int + nb_relative_maxes: int + nb_steps: int + trend: Literal["FLAT", "ASCENDING", "DESCENDING", "MODULATED"] + frequency_jumps: bool | int + has_harmonics: bool + has_sidebands: bool + has_subharmonics: bool + has_deterministic_chaos: bool + + +@dataclass +class ConfidenceIndicator: + """Class that represents an annotation confidence indicator. + + Parameters + ---------- + confidence_indicator_label: str + Name of the level of confidence. + confidence_indicator_level: str + Level of confidence relative to the maximum level available. + Should be formatted as ``n/m``, where ``n`` is the level of confidence + of the annotation and ``m`` is the maximum level available in the project. + + """ + + confidence_indicator_label: str + confidence_indicator_level: str + + +@dataclass +class AnnotationMetaData: + """Class that represents the metadata of an annotation. + + Parameters + ---------- + project: str + Name of the project in which the annotation was made. + output: str + Name of the output ``SpectroDataset`` this annotation was made on. + filename: str + Name of the file this annotation was made on. + annotation_id: int + ID of the annotation. + base_id: int + ID of the base annotation. + May differ from ``id`` if the annotation is an update/correction. + + """ + + project: str + output: str + filename: str + annotation_id: int + base_id: int + + +@dataclass +class Verification: + """Class that represents a verification of an annotation.""" + + verificator: str + is_validated: bool + class Annotation(Event): - """Class represents an annotation made on APLOSE.""" + """Class that represents an annotation made on APLOSE.""" def __init__( # noqa: PLR0913 self, - project: str, + metadata: AnnotationMetaData, begin: Timestamp, end: Timestamp, - min_frequency: int, - max_frequency: int, - annotation: str, - annotator: str, - confidence_indicator_label: str, - confidence_indicator_level: str, - *, - is_box: bool, + frequency_bounds: FrequencyBounds, + label: str, + annotator_info: AnnotatorInfo, + annotation_type: Literal["WEAK", "POINT", "BOX"], + confidence_indicator: ConfidenceIndicator, + comments: str, + phase: Literal["ANNOTATION", "VERIFICATION"], + signal_quantity: Literal["SINGLE", "MULTIPLE"], + signal_parameters: SignalParameters | None, + verifications: list[Verification], ) -> None: """Initialize an Annotation object. Parameters ---------- - project: str - Name of the project in which the annotation was made. + metadata: AnnotationMetaData + Metadata on the annotation. begin: Timestamp Begin timestamp of the annotation. end: Timestamp End timestamp of the annotation. - min_frequency: int - Minimum frequency of the annotation. - max_frequency: int - Maximum frequency of the annotation. - annotation: str + frequency_bounds: FrequencyBounds + Frequency bounds of the annotation. + label: str Label of the annotation. - annotator: str - Name of the annotator or detector. - confidence_indicator_label: str - Name of the level of confidence. - confidence_indicator_level: str - Level of confidence relative to the maximum level available. - Should be formatted as ``n/m``, where ``n`` is the level of confidence - of the annotation and ``m`` is the maximum level available in the project. - is_box: bool - If ``True``, the annotation is a box. - If ``False``, the annotation is a weak annotation. + annotator_info: AnnotatorInfo + Information on the annotator or detector. + annotation_type: Literal["WEAK", "POINT", "BOX"] + Type of the annotation. + ``WEAK``: Annotation made on the whole spectrogram. + ``POINT``: Annotation made on one pixel of the spectrogram. + ``BOX``: Annotation made on one box within the spectrogram. + confidence_indicator: ConfidenceIndicator + Indicator of the confidence of the annotator. + comments: str + Comments left by the annotator. + phase: Literal["ANNOTATION", "VERIFICATION"] + Phase during which the annotation was created. + signal_quantity: Literal["SINGLE","MULTIPLE"] + Whether there is only one signal in the annotation or more. + signal_parameters: SignalParameters | None + Parameters of the annotated signal. + ```None`` if ``signal_quantity`` is ``MULTIPLE``. + verifications: list[Verification] + Verifications made on this annotation. """ + self.metadata = metadata + self.label = label + self.annotator_info = annotator_info + self.frequency_bounds = frequency_bounds + self.type = annotation_type + self.confidence_indicator = confidence_indicator + self.comments = comments + self.phase = phase + self.signal_quantity = signal_quantity + self.signal_parameters = signal_parameters + self.verifications = verifications + super().__init__(begin=begin, end=end) - self.project = project - self.min_frequency = min_frequency - self.max_frequency = max_frequency - self.annotation = annotation - self.annotator = annotator - self.confidence_indicator_label = confidence_indicator_label - self.confidence_indicator_level = confidence_indicator_level - self.is_box = is_box + + def __repr__(self) -> str: + """Override the string representation of the annotation.""" + return str(self.metadata.annotation_id) + + @classmethod + def from_dict(cls, row: dict) -> Self: + """Deserialize an Annotation object.""" + metadata = AnnotationMetaData( + project=row["project"] if "project" in row else row["dataset"], + output=row["output"] if "output" in row else row["analysis"], + filename=row["filename"], + annotation_id=row["annotation_id"], + base_id=row["is_update_of_id"], + ) + annotator_info = AnnotatorInfo( + annotator=row["annotator"], + annotator_expertise=row["annotator_expertise"], + ) + frequency_bounds = FrequencyBounds( + min=row["min_frequency"], + max=row["max_frequency"], + ) + confidence_indicator = ConfidenceIndicator( + confidence_indicator_label=row["confidence_indicator_label"], + confidence_indicator_level=row["confidence_indicator_level"], + ) + + signal_quantity = row["signal_quantity"] + signal_parameters = ( + SignalParameters( + does_overlap_other_signals=row["signal_is_intensity_too_low"], + frequency_jumps=row["signal_frequency_jumps"], + has_deterministic_chaos=row["signal_deterministic_chaos"], + has_harmonics=row["signal_has_harmonics"], + has_sidebands=row["signal_sidebands"], + has_subharmonics=row["signal_subharmonics"], + is_itensity_too_low=row["signal_is_intensity_too_low"], + max_frequency=row["signal_end_frequency"], + min_frequency=row["signal_start_frequency"], + nb_relative_maxes=row["signal_relative_max_frequency_count"], + nb_relative_mins=row["signal_relative_min_frequency_count"], + nb_steps=row["signal_steps_count"], + trend=row["signal_trend"], + ) + if signal_quantity == "SINGLE" + else None + ) + + verifications = [ + Verification( + verificator=key, + is_validated=value, + ) + for key, value in row.items() + if key not in KNOWN_KEYS + ] + + return cls( + metadata=metadata, + label=row["annotation"], + annotator_info=annotator_info, + begin=Timestamp(row["start_datetime"]), + end=Timestamp(row["end_datetime"]), + frequency_bounds=frequency_bounds, + annotation_type=row["type"], + confidence_indicator=confidence_indicator, + comments=row["comments"], + phase=row["created_at_phase"], + signal_quantity=row["signal_quantity"], + signal_parameters=signal_parameters, + verifications=verifications, + ) + + @classmethod + def from_csv(cls, csv: Path) -> list[Self]: + """Deserialize a list of Annotation from an annotations csv file.""" + return [ + cls.from_dict(record) + for record in pd.read_csv(filepath_or_buffer=csv).to_dict(orient="records") + ] From 425691fc5f33550f5d86d82f62e54f9f3086642b Mon Sep 17 00:00:00 2001 From: Gautzilla Date: Thu, 28 May 2026 12:21:54 +0200 Subject: [PATCH 03/28] move comments and phase to the AnnotationMetadata class --- src/osekit/core/annotation.py | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/src/osekit/core/annotation.py b/src/osekit/core/annotation.py index a9748a36..92b8af60 100644 --- a/src/osekit/core/annotation.py +++ b/src/osekit/core/annotation.py @@ -134,6 +134,10 @@ class AnnotationMetaData: base_id: int ID of the base annotation. May differ from ``id`` if the annotation is an update/correction. + comments: str + Comments left by the annotator. + phase: Literal["ANNOTATION", "VERIFICATION"] + Phase during which the annotation was created. """ @@ -142,6 +146,8 @@ class AnnotationMetaData: filename: str annotation_id: int base_id: int + comments: str + phase: Literal["ANNOTATION", "VERIFICATION"] @dataclass @@ -165,8 +171,6 @@ def __init__( # noqa: PLR0913 annotator_info: AnnotatorInfo, annotation_type: Literal["WEAK", "POINT", "BOX"], confidence_indicator: ConfidenceIndicator, - comments: str, - phase: Literal["ANNOTATION", "VERIFICATION"], signal_quantity: Literal["SINGLE", "MULTIPLE"], signal_parameters: SignalParameters | None, verifications: list[Verification], @@ -194,10 +198,6 @@ def __init__( # noqa: PLR0913 ``BOX``: Annotation made on one box within the spectrogram. confidence_indicator: ConfidenceIndicator Indicator of the confidence of the annotator. - comments: str - Comments left by the annotator. - phase: Literal["ANNOTATION", "VERIFICATION"] - Phase during which the annotation was created. signal_quantity: Literal["SINGLE","MULTIPLE"] Whether there is only one signal in the annotation or more. signal_parameters: SignalParameters | None @@ -213,8 +213,6 @@ def __init__( # noqa: PLR0913 self.frequency_bounds = frequency_bounds self.type = annotation_type self.confidence_indicator = confidence_indicator - self.comments = comments - self.phase = phase self.signal_quantity = signal_quantity self.signal_parameters = signal_parameters self.verifications = verifications @@ -234,6 +232,8 @@ def from_dict(cls, row: dict) -> Self: filename=row["filename"], annotation_id=row["annotation_id"], base_id=row["is_update_of_id"], + comments=row["comments"], + phase=row["created_at_phase"], ) annotator_info = AnnotatorInfo( annotator=row["annotator"], @@ -287,8 +287,6 @@ def from_dict(cls, row: dict) -> Self: frequency_bounds=frequency_bounds, annotation_type=row["type"], confidence_indicator=confidence_indicator, - comments=row["comments"], - phase=row["created_at_phase"], signal_quantity=row["signal_quantity"], signal_parameters=signal_parameters, verifications=verifications, From 5fe8a61c6c97cd87236c5204d1066d2ad074a5fc Mon Sep 17 00:00:00 2001 From: Gautzilla Date: Tue, 2 Jun 2026 11:05:58 +0200 Subject: [PATCH 04/28] filter na in annotation read_csv() --- src/osekit/core/annotation.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/osekit/core/annotation.py b/src/osekit/core/annotation.py index 92b8af60..86502c67 100644 --- a/src/osekit/core/annotation.py +++ b/src/osekit/core/annotation.py @@ -297,5 +297,7 @@ def from_csv(cls, csv: Path) -> list[Self]: """Deserialize a list of Annotation from an annotations csv file.""" return [ cls.from_dict(record) - for record in pd.read_csv(filepath_or_buffer=csv).to_dict(orient="records") + for record in pd.read_csv(filepath_or_buffer=csv, na_filter=False).to_dict( + orient="records", + ) ] From f077bdc83d298aa45f155451ab58ede42953c3d6 Mon Sep 17 00:00:00 2001 From: Gautzilla Date: Tue, 2 Jun 2026 15:30:30 +0200 Subject: [PATCH 05/28] add FrequencyBounds post_init validity check --- src/osekit/core/annotation.py | 20 ++++++++++++++++++++ tests/test_annotation.py | 0 2 files changed, 20 insertions(+) create mode 100644 tests/test_annotation.py diff --git a/src/osekit/core/annotation.py b/src/osekit/core/annotation.py index 86502c67..2b4d0b2c 100644 --- a/src/osekit/core/annotation.py +++ b/src/osekit/core/annotation.py @@ -65,6 +65,26 @@ class FrequencyBounds: min: int max: int + def __post_init__(self) -> None: + """Check the validity of the frequency bounds.""" + error_msgs = [] + if self.min < 0: + error_msgs.append( + f"Min frequency must be greater than or equal to 0, got {self.min}.", + ) + if self.max < 0: + error_msgs.append( + f"Max frequency must be greater than or equal to 0, got {self.max}.", + ) + if self.min > self.max: + error_msgs.append( + f"Max frequency must be greater than min frequency, " + f"got ({self.min},{self.max}).", + ) + if error_msgs: + msg = "\n".join(error_msgs) + raise ValueError(msg) + @property def bandwidth(self) -> int: """Bandwidth of the annotation.""" diff --git a/tests/test_annotation.py b/tests/test_annotation.py new file mode 100644 index 00000000..e69de29b From a3abd305e1b4e8b6d1b1cf776d66747d366d4893 Mon Sep 17 00:00:00 2001 From: Gautzilla Date: Tue, 2 Jun 2026 15:30:44 +0200 Subject: [PATCH 06/28] add FrequencyBounds tests --- tests/test_annotation.py | 67 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/tests/test_annotation.py b/tests/test_annotation.py index e69de29b..824c61cf 100644 --- a/tests/test_annotation.py +++ b/tests/test_annotation.py @@ -0,0 +1,67 @@ +from contextlib import AbstractContextManager, nullcontext + +import pytest + +from osekit.core.annotation import ( + FrequencyBounds, +) + + +@pytest.mark.parametrize( + ("min_frequency", "max_frequency", "expectation"), + [ + pytest.param( + 0, + 1000, + nullcontext(1000), + id="box_from_bottom", + ), + pytest.param( + 300, + 1000, + nullcontext(700), + id="box_bandwidth_from_higher_than_0", + ), + pytest.param( + -10, + 1000, + pytest.raises(ValueError, match=r"Min frequency.*-10"), + id="negative_min_frequency_raises", + ), + pytest.param( + 0, + -5, + pytest.raises(ValueError, match=r"Max frequency.*-5"), + id="negative_max_frequency_raises", + ), + pytest.param( + 80, + 50, + pytest.raises( + ValueError, + match=r"Max frequency.*greater.*min frequency.*\(80,50\)", + ), + id="min_greater_than_max_raises", + ), + pytest.param( + -20, + -30, + pytest.raises( + ValueError, + match=r"(?s)" # Activates the DOTALL mode: includes \n in regex .* + r"(?=.*Min frequency.*got -20)" + r"(?=.*Max frequency.*got -30)" + r"(?=.*Max frequency.*greater.*min frequency.*\(-20,-30\))", + ), + id="errors_concatenation", + ), + ], +) +def test_frequency_bounds( + min_frequency: int, + max_frequency: int, + expectation: AbstractContextManager, +) -> None: + with expectation as e: + frequency_bounds = FrequencyBounds(min=min_frequency, max=max_frequency) + assert frequency_bounds.bandwidth == e From 448ce25b65b417f35c892d24999ce0d4d1f05aa0 Mon Sep 17 00:00:00 2001 From: Gautzilla Date: Tue, 2 Jun 2026 17:27:15 +0200 Subject: [PATCH 07/28] parse missing cell values as None --- src/osekit/core/annotation.py | 28 +++++++++++++++++++--------- 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/src/osekit/core/annotation.py b/src/osekit/core/annotation.py index 2b4d0b2c..a52d0e0d 100644 --- a/src/osekit/core/annotation.py +++ b/src/osekit/core/annotation.py @@ -1,5 +1,6 @@ """The Annotation class represents an annotation made on APLOSE.""" +import math from dataclasses import dataclass from pathlib import Path from typing import Literal, Self @@ -96,7 +97,7 @@ class AnnotatorInfo: """Class representing an annotator info.""" annotator: str - annotator_expertise: Literal["NOVICE", "AVERAGE", "EXPERT"] + annotator_expertise: Literal["NOVICE", "AVERAGE", "EXPERT"] | None = None @dataclass @@ -259,10 +260,14 @@ def from_dict(cls, row: dict) -> Self: annotator=row["annotator"], annotator_expertise=row["annotator_expertise"], ) - frequency_bounds = FrequencyBounds( - min=row["min_frequency"], - max=row["max_frequency"], + + min_frequency, max_frequency = row["min_frequency"], row["max_frequency"] + frequency_bounds = ( + FrequencyBounds(min=min_frequency, max=max_frequency) + if not any(m is None for m in (min_frequency, max_frequency)) + else None ) + confidence_indicator = ConfidenceIndicator( confidence_indicator_label=row["confidence_indicator_label"], confidence_indicator_level=row["confidence_indicator_level"], @@ -315,9 +320,14 @@ def from_dict(cls, row: dict) -> Self: @classmethod def from_csv(cls, csv: Path) -> list[Self]: """Deserialize a list of Annotation from an annotations csv file.""" - return [ - cls.from_dict(record) - for record in pd.read_csv(filepath_or_buffer=csv, na_filter=False).to_dict( - orient="records", - ) + records = pd.read_csv(filepath_or_buffer=csv).to_dict( + orient="records", + ) + records = [ + { + key: None if type(value) is float and math.isnan(value) else value + for key, value in record.items() + } + for record in records ] + return [cls.from_dict(record) for record in records] From 7ffd859616d3d2ecfb842dc68ed15eb0f00505de Mon Sep 17 00:00:00 2001 From: Gautzilla Date: Tue, 2 Jun 2026 17:31:37 +0200 Subject: [PATCH 08/28] remove analysis column from result csv --- src/osekit/core/annotation.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/osekit/core/annotation.py b/src/osekit/core/annotation.py index a52d0e0d..21b1d09d 100644 --- a/src/osekit/core/annotation.py +++ b/src/osekit/core/annotation.py @@ -12,7 +12,7 @@ KNOWN_KEYS = { "dataset", - "analysis", + "project", "filename", "annotation_id", "is_update_of_id", @@ -146,8 +146,6 @@ class AnnotationMetaData: ---------- project: str Name of the project in which the annotation was made. - output: str - Name of the output ``SpectroDataset`` this annotation was made on. filename: str Name of the file this annotation was made on. annotation_id: int @@ -163,7 +161,6 @@ class AnnotationMetaData: """ project: str - output: str filename: str annotation_id: int base_id: int @@ -249,7 +246,6 @@ def from_dict(cls, row: dict) -> Self: """Deserialize an Annotation object.""" metadata = AnnotationMetaData( project=row["project"] if "project" in row else row["dataset"], - output=row["output"] if "output" in row else row["analysis"], filename=row["filename"], annotation_id=row["annotation_id"], base_id=row["is_update_of_id"], From 97935d9ce3901405d523fbf3d9e497bb79adac59 Mon Sep 17 00:00:00 2001 From: Gautzilla Date: Tue, 2 Jun 2026 17:57:07 +0200 Subject: [PATCH 09/28] fix docstring parameter name --- src/osekit/core/annotation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/osekit/core/annotation.py b/src/osekit/core/annotation.py index 21b1d09d..3db968b9 100644 --- a/src/osekit/core/annotation.py +++ b/src/osekit/core/annotation.py @@ -152,7 +152,7 @@ class AnnotationMetaData: ID of the annotation. base_id: int ID of the base annotation. - May differ from ``id`` if the annotation is an update/correction. + May differ from ``annotation_id`` if the annotation is an update/correction. comments: str Comments left by the annotator. phase: Literal["ANNOTATION", "VERIFICATION"] From 34197dc081a5bb27dad92ceaef5dbba4fa4d4732 Mon Sep 17 00:00:00 2001 From: Gautzilla Date: Tue, 2 Jun 2026 18:14:39 +0200 Subject: [PATCH 10/28] add AnnotatorInfo hash() method --- src/osekit/core/annotation.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/osekit/core/annotation.py b/src/osekit/core/annotation.py index 3db968b9..80f679a8 100644 --- a/src/osekit/core/annotation.py +++ b/src/osekit/core/annotation.py @@ -99,6 +99,10 @@ class AnnotatorInfo: annotator: str annotator_expertise: Literal["NOVICE", "AVERAGE", "EXPERT"] | None = None + def __hash__(self) -> int: + """Return a hash for the annotator.""" + return hash((self.annotator, self.annotator_expertise)) + @dataclass class SignalParameters: From 521f96e234dd2699c21a7ec528446bdf40eb5343 Mon Sep 17 00:00:00 2001 From: Gautzilla Date: Tue, 2 Jun 2026 18:16:04 +0200 Subject: [PATCH 11/28] add AnnotatorInfo hash test --- tests/test_annotation.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tests/test_annotation.py b/tests/test_annotation.py index 824c61cf..dbfea31f 100644 --- a/tests/test_annotation.py +++ b/tests/test_annotation.py @@ -3,6 +3,7 @@ import pytest from osekit.core.annotation import ( + AnnotatorInfo, FrequencyBounds, ) @@ -65,3 +66,19 @@ def test_frequency_bounds( with expectation as e: frequency_bounds = FrequencyBounds(min=min_frequency, max=max_frequency) assert frequency_bounds.bandwidth == e + + +def test_annotator_info() -> None: + annotators = [ + AnnotatorInfo(annotator="ruby", annotator_expertise="NOVICE"), + AnnotatorInfo(annotator="ruby", annotator_expertise="NOVICE"), + AnnotatorInfo(annotator="haunt", annotator_expertise="EXPERT"), + AnnotatorInfo(annotator="haunt", annotator_expertise="EXPERT"), + AnnotatorInfo(annotator="nevada", annotator_expertise="EXPERT"), + AnnotatorInfo(annotator="nevada", annotator_expertise="EXPERT"), + AnnotatorInfo(annotator="haunt", annotator_expertise=None), + ] + + nb_unique_annotators = 4 + + assert sum(1 for _ in set(annotators)) == nb_unique_annotators From d4b54ed360aac7cbcb47b91060bcfc3814862216 Mon Sep 17 00:00:00 2001 From: Gautzilla Date: Wed, 3 Jun 2026 10:33:19 +0200 Subject: [PATCH 12/28] add default None value for SignalParameters parameters --- src/osekit/core/annotation.py | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/src/osekit/core/annotation.py b/src/osekit/core/annotation.py index 80f679a8..e3c799e2 100644 --- a/src/osekit/core/annotation.py +++ b/src/osekit/core/annotation.py @@ -108,19 +108,19 @@ def __hash__(self) -> int: class SignalParameters: """Class representing parameters of an annoted signal.""" - is_itensity_too_low: bool - does_overlap_other_signals: bool - min_frequency: int - max_frequency: int - nb_relative_mins: int - nb_relative_maxes: int - nb_steps: int - trend: Literal["FLAT", "ASCENDING", "DESCENDING", "MODULATED"] - frequency_jumps: bool | int - has_harmonics: bool - has_sidebands: bool - has_subharmonics: bool - has_deterministic_chaos: bool + is_itensity_too_low: bool | None = None + does_overlap_other_signals: bool | None = None + min_frequency: int | None = None + max_frequency: int | None = None + nb_relative_mins: int | None = None + nb_relative_maxes: int | None = None + nb_steps: int | None = None + trend: Literal["FLAT", "ASCENDING", "DESCENDING", "MODULATED"] | None = None + frequency_jumps: bool | int | None = None + has_harmonics: bool | None = None + has_sidebands: bool | None = None + has_subharmonics: bool | None = None + has_deterministic_chaos: bool | None = None @dataclass From 2f8d6200593d7302261648acec7b405af714c843 Mon Sep 17 00:00:00 2001 From: Gautzilla Date: Wed, 3 Jun 2026 10:40:07 +0200 Subject: [PATCH 13/28] add ConfidenceIndicator level parsing in post_init() --- src/osekit/core/annotation.py | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/src/osekit/core/annotation.py b/src/osekit/core/annotation.py index e3c799e2..df4bf92f 100644 --- a/src/osekit/core/annotation.py +++ b/src/osekit/core/annotation.py @@ -29,8 +29,8 @@ "end_datetime", "is_box", "type", - "confidence_indicator_label", - "confidence_indicator_level", + "label", + "level", "comments", "signal_quantity", "signal_is_intensity_too_low", @@ -129,17 +129,21 @@ class ConfidenceIndicator: Parameters ---------- - confidence_indicator_label: str + label: str Name of the level of confidence. - confidence_indicator_level: str + level: str Level of confidence relative to the maximum level available. Should be formatted as ``n/m``, where ``n`` is the level of confidence of the annotation and ``m`` is the maximum level available in the project. """ - confidence_indicator_label: str - confidence_indicator_level: str + label: str + level: str + + def __post_init__(self) -> None: + """Parse the level of confidence of the annotation.""" + self.level, self.maximum_level = map(int, self.level.split("/")) @dataclass @@ -269,8 +273,8 @@ def from_dict(cls, row: dict) -> Self: ) confidence_indicator = ConfidenceIndicator( - confidence_indicator_label=row["confidence_indicator_label"], - confidence_indicator_level=row["confidence_indicator_level"], + label=row["label"], + level=row["level"], ) signal_quantity = row["signal_quantity"] From f69ddefc0c638260158756afd8bcb52fd537714f Mon Sep 17 00:00:00 2001 From: Gautzilla Date: Wed, 3 Jun 2026 10:56:35 +0200 Subject: [PATCH 14/28] clarify ConfidenceIndicator parameters --- src/osekit/core/annotation.py | 47 ++++++++++++++++++++++++++++------- 1 file changed, 38 insertions(+), 9 deletions(-) diff --git a/src/osekit/core/annotation.py b/src/osekit/core/annotation.py index df4bf92f..1f817cfb 100644 --- a/src/osekit/core/annotation.py +++ b/src/osekit/core/annotation.py @@ -131,19 +131,48 @@ class ConfidenceIndicator: ---------- label: str Name of the level of confidence. - level: str - Level of confidence relative to the maximum level available. - Should be formatted as ``n/m``, where ``n`` is the level of confidence - of the annotation and ``m`` is the maximum level available in the project. + level: int + Level of confidence of the annotation. + maximum_level: int + Maximum level of confidence authorized in the project. """ label: str - level: str + level: int + maximum_level: int def __post_init__(self) -> None: - """Parse the level of confidence of the annotation.""" - self.level, self.maximum_level = map(int, self.level.split("/")) + """Check the validity of the level and maximum level values.""" + if self.level > self.maximum_level: + msg = ( + f"Confidence level {self.level} is higher than " + f"maximum level {self.maximum_level} authorized in the project." + ) + raise ValueError(msg) + + @classmethod + def from_relative_level_string(cls, label: str, relative_level_string: str) -> Self: + """Return a ``ConfidenceIndicator`` from a string representing its level. + + Parameters + ---------- + label: str + Name of the level of confidence. + relative_level_string: str + Level of confidence relative to the maximum level available. + Should be formatted as ``n/m``, where ``n`` is the level of confidence + of the annotation and ``m`` is the maximum level available in the project. + + Returns + ------- + ConfidenceIndicator + The confidence indicator parsed from the input string. + + """ + level, maximum_level = map(int, relative_level_string.split("/")) + + return cls(label=label, level=level, maximum_level=maximum_level) @dataclass @@ -272,9 +301,9 @@ def from_dict(cls, row: dict) -> Self: else None ) - confidence_indicator = ConfidenceIndicator( + confidence_indicator = ConfidenceIndicator.from_relative_level_string( label=row["label"], - level=row["level"], + relative_level_string=row["level"], ) signal_quantity = row["signal_quantity"] From e8a2976ace6a20fb261ac8c9dc85d1f761d6fa52 Mon Sep 17 00:00:00 2001 From: Gautzilla Date: Wed, 3 Jun 2026 11:06:11 +0200 Subject: [PATCH 15/28] add ConfidenceIndicator post_init() tests --- tests/test_annotation.py | 48 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/tests/test_annotation.py b/tests/test_annotation.py index dbfea31f..b29bcd52 100644 --- a/tests/test_annotation.py +++ b/tests/test_annotation.py @@ -4,6 +4,7 @@ from osekit.core.annotation import ( AnnotatorInfo, + ConfidenceIndicator, FrequencyBounds, ) @@ -82,3 +83,50 @@ def test_annotator_info() -> None: nb_unique_annotators = 4 assert sum(1 for _ in set(annotators)) == nb_unique_annotators + + +@pytest.mark.parametrize( + ("label", "level", "max_level", "expectation"), + [ + pytest.param( + "Sure", + 1, + 1, + nullcontext(), + id="max_level_is_ok", + ), + pytest.param( + "Not sure", + 0, + 1, + nullcontext(), + id="level_0_is_ok", + ), + pytest.param( + "Moderate", + 1, + 2, + nullcontext(), + id="between_0_and_max_is_ok", + ), + pytest.param( + "Moderate", + 3, + 2, + pytest.raises(ValueError, match=r"level 3.*higher.*maximum level 2"), + id="higher_than_max_raises", + ), + ], +) +def test_confidence_indicator_value_check( + label: str, + level: int, + max_level: int, + expectation: AbstractContextManager, +) -> None: + with expectation: + ConfidenceIndicator( + label=label, + level=level, + maximum_level=max_level, + ) From d06fffcc62570e51a272497754d7dc2aba0e728c Mon Sep 17 00:00:00 2001 From: Gautzilla Date: Wed, 3 Jun 2026 11:13:03 +0200 Subject: [PATCH 16/28] add ConfidenceIndicator.from_relative_level_string() test --- tests/test_annotation.py | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/tests/test_annotation.py b/tests/test_annotation.py index b29bcd52..5bb08f81 100644 --- a/tests/test_annotation.py +++ b/tests/test_annotation.py @@ -130,3 +130,42 @@ def test_confidence_indicator_value_check( level=level, maximum_level=max_level, ) + + +@pytest.mark.parametrize( + ("label", "relative_level_string", "expectation"), + [ + pytest.param( + "cool", + "1/6", + nullcontext( + ConfidenceIndicator( + label="cool", + level=1, + maximum_level=6, + ), + ), + id="correct_levels", + ), + pytest.param( + "cool", + "4/2", + pytest.raises(ValueError, match=r"level 4.*higher.*maximum level 2"), + id="incorrect_levels_should_raise", + ), + ], +) +def test_confidence_indicator_from_relative_level_string( + label: str, + relative_level_string: str, + expectation: AbstractContextManager, +) -> None: + with expectation as e: + ci = ConfidenceIndicator.from_relative_level_string( + label=label, + relative_level_string=relative_level_string, + ) + + assert ci.label == e.label + assert ci.level == e.level + assert ci.maximum_level == e.maximum_level From a08a1f641e423c426dce75542debdb7a4a6e1cb2 Mon Sep 17 00:00:00 2001 From: Gautzilla Date: Wed, 3 Jun 2026 14:15:16 +0200 Subject: [PATCH 17/28] reverse index to the correct row name --- src/osekit/core/annotation.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/osekit/core/annotation.py b/src/osekit/core/annotation.py index 1f817cfb..1f906090 100644 --- a/src/osekit/core/annotation.py +++ b/src/osekit/core/annotation.py @@ -190,7 +190,7 @@ class AnnotationMetaData: base_id: int ID of the base annotation. May differ from ``annotation_id`` if the annotation is an update/correction. - comments: str + comments: str | None Comments left by the annotator. phase: Literal["ANNOTATION", "VERIFICATION"] Phase during which the annotation was created. @@ -200,8 +200,8 @@ class AnnotationMetaData: project: str filename: str annotation_id: int - base_id: int - comments: str + base_id: int | None + comments: str | None phase: Literal["ANNOTATION", "VERIFICATION"] @@ -302,8 +302,8 @@ def from_dict(cls, row: dict) -> Self: ) confidence_indicator = ConfidenceIndicator.from_relative_level_string( - label=row["label"], - relative_level_string=row["level"], + label=row["confidence_indicator_label"], + relative_level_string=row["confidence_indicator_level"], ) signal_quantity = row["signal_quantity"] From 0826e4709f728dc160f5906add8267f92161fadf Mon Sep 17 00:00:00 2001 From: Gautzilla Date: Wed, 3 Jun 2026 16:43:10 +0200 Subject: [PATCH 18/28] add annotation from csv integration test --- src/osekit/core/annotation.py | 28 +++++++++-- tests/_static/aplose_result.csv | 9 ++++ tests/test_annotation.py | 83 +++++++++++++++++++++++++++++++++ 3 files changed, 115 insertions(+), 5 deletions(-) create mode 100644 tests/_static/aplose_result.csv diff --git a/src/osekit/core/annotation.py b/src/osekit/core/annotation.py index 1f906090..af290f3c 100644 --- a/src/osekit/core/annotation.py +++ b/src/osekit/core/annotation.py @@ -29,8 +29,8 @@ "end_datetime", "is_box", "type", - "label", - "level", + "confidence_indicator_label", + "confidence_indicator_level", "comments", "signal_quantity", "signal_is_intensity_too_low", @@ -103,6 +103,13 @@ def __hash__(self) -> int: """Return a hash for the annotator.""" return hash((self.annotator, self.annotator_expertise)) + def __eq__(self, other: Self) -> bool: + """Return whether two annotators are equal.""" + return ( + self.annotator == other.annotator + and self.annotator_expertise == other.annotator_expertise + ) + @dataclass class SignalParameters: @@ -212,6 +219,17 @@ class Verification: verificator: str is_validated: bool + def __hash__(self) -> int: + """Return a hash of the verification.""" + return hash((self.verificator, self.is_validated)) + + def __eq__(self, other: Self) -> bool: + """Return whether the two verifications are equal.""" + return ( + self.verificator == other.verificator + and self.is_validated == other.is_validated + ) + class Annotation(Event): """Class that represents an annotation made on APLOSE.""" @@ -283,7 +301,7 @@ def from_dict(cls, row: dict) -> Self: """Deserialize an Annotation object.""" metadata = AnnotationMetaData( project=row["project"] if "project" in row else row["dataset"], - filename=row["filename"], + filename=str(row["filename"]), annotation_id=row["annotation_id"], base_id=row["is_update_of_id"], comments=row["comments"], @@ -327,14 +345,14 @@ def from_dict(cls, row: dict) -> Self: else None ) - verifications = [ + verifications = { Verification( verificator=key, is_validated=value, ) for key, value in row.items() if key not in KNOWN_KEYS - ] + } return cls( metadata=metadata, diff --git a/tests/_static/aplose_result.csv b/tests/_static/aplose_result.csv new file mode 100644 index 00000000..dd321bfe --- /dev/null +++ b/tests/_static/aplose_result.csv @@ -0,0 +1,9 @@ +dataset,filename,annotation_id,is_update_of_id,start_time,end_time,start_frequency,end_frequency,min_frequency,max_frequency,annotation,annotator,annotator_expertise,start_datetime,end_datetime,is_box,type,confidence_indicator_label,confidence_indicator_level,comments,signal_quantity,signal_is_intensity_too_low,signal_does_overlap_other_signals,signal_start_frequency,signal_end_frequency,signal_relative_min_frequency_count,signal_relative_max_frequency_count,signal_steps_count,signal_has_harmonics,signal_trend,signal_sidebands,signal_subharmonics,signal_frequency_jumps,signal_deterministic_chaos,created_at_phase,lookaftering,bunyan +great_tit,990694,586654,,0.0,20.0,0.0,24000.0,0.0,24000.0,bird,vashti,NOVICE,2021-01-01T00:00:00.000+00:00,2021-01-01T00:00:20.000+00:00,0,WEAK,Sure,1/1,great tits |- vashti,MULTIPLE,,,,,,,,,,,,,,ANNOTATION,True,False +great_tit,990694,586655,,1.412,3.651,2512.0,15661.0,2512.0,15661.0,bird,vashti,NOVICE,2021-01-01T00:00:01.412+00:00,2021-01-01T00:00:03.651+00:00,1,BOX,Not sure,0/1,fluffy-backed tit-babbler |- vashti,MULTIPLE,,,,,,,,,,,,,,ANNOTATION,False,False +great_tit,990694,586656,,0.0,20.0,0.0,24000.0,0.0,24000.0,rain,heartleap,,2021-01-01T00:00:00.000+00:00,2021-01-01T00:00:20.000+00:00,0,WEAK,Sure,1/1,,MULTIPLE,,,,,,,,,,,,,,ANNOTATION,True,True +great_tit,990694,586657,,3.53,4.71,11137.0,13997.0,11137.0,13997.0,rain,heartleap,,2021-01-01T00:00:03.530+00:00,2021-01-01T00:00:04.710+00:00,1,BOX,Sure,1/1,,SINGLE,,True,12000.0,13000.0,3,2,4,True,MOD,True,,True,True,ANNOTATION,True,True +great_tit,990694,586669,586655,1.412,3.651,2512.0,15660.0,2512.0,15660.0,bird,bunyan,EXPERT,2021-01-01T00:00:01.412+00:00,2021-01-01T00:00:03.651+00:00,1,BOX,Not sure,0/1,,MULTIPLE,,,,,,,,,,,,,,VERIFICATION,, +great_tit,990694,586710,586655,1.412,3.651,2512.0,15660.0,2512.0,15660.0,bird,lookaftering,EXPERT,2021-01-01T00:00:01.412+00:00,2021-01-01T00:00:03.651+00:00,1,BOX,Not sure,0/1,,MULTIPLE,,,,,,,,,,,,,,VERIFICATION,, +great_tit,994410,586671,,0.0,20.0,0.0,24000.0,0.0,24000.0,car,bunyan,EXPERT,2021-01-01T00:01:18.218+00:00,2021-01-01T00:01:38.218+00:00,0,WEAK,Sure,1/1,,MULTIPLE,,,,,,,,,,,,,,VERIFICATION,, +great_tit,994410,586672,,0.0,20.0,0.0,24000.0,0.0,24000.0,bird,bunyan,EXPERT,2021-01-01T00:01:18.218+00:00,2021-01-01T00:01:38.218+00:00,0,WEAK,Sure,1/1,,MULTIPLE,,,,,,,,,,,,,,VERIFICATION,, diff --git a/tests/test_annotation.py b/tests/test_annotation.py index 5bb08f81..e7cc7626 100644 --- a/tests/test_annotation.py +++ b/tests/test_annotation.py @@ -1,11 +1,15 @@ from contextlib import AbstractContextManager, nullcontext +from pathlib import Path +import numpy as np import pytest from osekit.core.annotation import ( + Annotation, AnnotatorInfo, ConfidenceIndicator, FrequencyBounds, + Verification, ) @@ -169,3 +173,82 @@ def test_confidence_indicator_from_relative_level_string( assert ci.label == e.label assert ci.level == e.level assert ci.maximum_level == e.maximum_level + + +def test_annotations_from_csv() -> None: + annotations = Annotation.from_csv( + csv=Path(r"_static/aplose_result.csv"), + ) + + # All records should be loaded + assert len(annotations) == 8 + assert all(a.metadata.project == "great_tit" for a in annotations) + + # Two distinct annotated files + filenames = {a.metadata.filename for a in annotations} + assert filenames == {"990694", "994410"} + + # Types + types = {a.type for a in annotations} + assert types == {"WEAK", "BOX"} + + # Phases + phases = {a.metadata.phase for a in annotations} + assert phases == {"ANNOTATION", "VERIFICATION"} + + # Single signal parameters + single = next(a for a in annotations if a.metadata.annotation_id == 586657) + assert single.signal_quantity == "SINGLE" + assert single.signal_parameters is not None + assert not single.signal_parameters.is_itensity_too_low + assert not single.signal_parameters.does_overlap_other_signals + assert single.signal_parameters.min_frequency == 12000 + assert single.signal_parameters.max_frequency == 13000 + assert single.signal_parameters.nb_relative_mins == 3 + assert single.signal_parameters.nb_relative_maxes == 2 + assert single.signal_parameters.nb_steps == 4 + assert single.signal_parameters.trend == "MOD" + assert single.signal_parameters.frequency_jumps + assert single.signal_parameters.has_harmonics + assert single.signal_parameters.has_sidebands + assert not single.signal_parameters.has_subharmonics + assert single.signal_parameters.has_deterministic_chaos + + # Multiple signal quantity: parameters should be None + multiple = next(a for a in annotations if a.metadata.annotation_id == 586654) + assert multiple.signal_quantity == "MULTIPLE" + assert multiple.signal_parameters is None + + # Annotation update + update = next(a for a in annotations if a.metadata.annotation_id == 586669) + assert update.metadata.base_id == 586655 + + # Annotation without base + base = next(a for a in annotations if a.metadata.annotation_id == 586655) + assert base.metadata.base_id is None + + # Annotator parsing + annotators = { + AnnotatorInfo(annotator="vashti", annotator_expertise="NOVICE"), + AnnotatorInfo(annotator="heartleap", annotator_expertise=None), + AnnotatorInfo(annotator="bunyan", annotator_expertise="EXPERT"), + AnnotatorInfo(annotator="lookaftering", annotator_expertise="EXPERT"), + } + assert np.array_equal( + annotators, + {a.annotator_info for a in annotations}, + ) + + # Verification parsing + verificated = next(a for a in annotations if a.metadata.annotation_id == 586654) + verification = { + Verification( + verificator="lookaftering", + is_validated=True, + ), + Verification( + verificator="bunyan", + is_validated=False, + ), + } + assert np.array_equal(verification, verificated.verifications) From 562b2ff98be1062c6618c6d23980d4d0e8081e53 Mon Sep 17 00:00:00 2001 From: Gautzilla Date: Wed, 3 Jun 2026 17:37:38 +0200 Subject: [PATCH 19/28] fix relative path to sample csv file in tests --- tests/test_annotation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_annotation.py b/tests/test_annotation.py index e7cc7626..bf681f30 100644 --- a/tests/test_annotation.py +++ b/tests/test_annotation.py @@ -177,7 +177,7 @@ def test_confidence_indicator_from_relative_level_string( def test_annotations_from_csv() -> None: annotations = Annotation.from_csv( - csv=Path(r"_static/aplose_result.csv"), + csv=Path(__file__).parent / "_static" / "aplose_result.csv", ) # All records should be loaded From a9c9efd4f87454b386b73c2a469e0e879a4b2ba7 Mon Sep 17 00:00:00 2001 From: Gautzilla Date: Thu, 4 Jun 2026 11:50:46 +0200 Subject: [PATCH 20/28] add Annotation.to_rectangle() method --- src/osekit/core/annotation.py | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/src/osekit/core/annotation.py b/src/osekit/core/annotation.py index af290f3c..df282fb0 100644 --- a/src/osekit/core/annotation.py +++ b/src/osekit/core/annotation.py @@ -6,6 +6,7 @@ from typing import Literal, Self import pandas as pd +from matplotlib.patches import Rectangle from pandas import Timestamp from osekit.core.event import Event @@ -246,7 +247,7 @@ def __init__( # noqa: PLR0913 confidence_indicator: ConfidenceIndicator, signal_quantity: Literal["SINGLE", "MULTIPLE"], signal_parameters: SignalParameters | None, - verifications: list[Verification], + verifications: set[Verification], ) -> None: """Initialize an Annotation object. @@ -276,7 +277,7 @@ def __init__( # noqa: PLR0913 signal_parameters: SignalParameters | None Parameters of the annotated signal. ```None`` if ``signal_quantity`` is ``MULTIPLE``. - verifications: list[Verification] + verifications: set[Verification] Verifications made on this annotation. """ @@ -368,6 +369,25 @@ def from_dict(cls, row: dict) -> Self: verifications=verifications, ) + def to_rectangle(self) -> Rectangle: + """Return a matplotlib Rectangle representing the annotation. + + Returns + ------- + matplotlib.patches.Rectangle + Rectangle representing the annotation. + The coordinates of the rectangle are in time x frequency. + + """ + return Rectangle( + xy=( # type: ignore[arg-type] + self.begin, + self.frequency_bounds.min, + ), + width=self.duration, # type: ignore[arg-type] + height=self.frequency_bounds.bandwidth, + ) + @classmethod def from_csv(cls, csv: Path) -> list[Self]: """Deserialize a list of Annotation from an annotations csv file.""" From 485a4140b5b17671f7a56368fa561cc4df8e53f8 Mon Sep 17 00:00:00 2001 From: Gautzilla Date: Thu, 4 Jun 2026 15:02:32 +0200 Subject: [PATCH 21/28] add None parameter in localize_timestamp --- src/osekit/utils/timestamp.py | 7 ++++--- tests/test_timestamp_utils.py | 6 ++++++ 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/osekit/utils/timestamp.py b/src/osekit/utils/timestamp.py index 9eb69ad4..560ee4fc 100644 --- a/src/osekit/utils/timestamp.py +++ b/src/osekit/utils/timestamp.py @@ -90,7 +90,7 @@ def normalize_datetime(datetime: tuple[str], template: str) -> tuple[str, str]: def localize_timestamp( timestamp: Timestamp, - timezone: str | pytz.timezone, + timezone: str | pytz.timezone | None, ) -> Timestamp: """Localize a timestamp in the given timezone. @@ -98,8 +98,9 @@ def localize_timestamp( ---------- timestamp: pandas.Timestamp The timestamp to localize. - timezone: str | pytz.timezone + timezone: str | pytz.timezone | None The timezone in which the timestamp is localized. + If None, the output timestamp is naive. Returns ------- @@ -109,7 +110,7 @@ def localize_timestamp( to the new timezone. """ - if not timestamp.tz: + if not timestamp.tz or timezone is None: return timestamp.tz_localize(timezone) if timestamp.utcoffset() != timestamp.tz_convert(timezone).utcoffset(): diff --git a/tests/test_timestamp_utils.py b/tests/test_timestamp_utils.py index 674ba46f..1ee533b9 100644 --- a/tests/test_timestamp_utils.py +++ b/tests/test_timestamp_utils.py @@ -583,6 +583,12 @@ def test_reformat_timestamp( Timestamp("2024-10-17T10:14:11.000+0000", tz="UTC"), id="negative_zero_UTC_offset_timezone", ), + pytest.param( + Timestamp("2024-10-17 10:14:11+0200"), + None, + Timestamp("2024-10-17T10:14:11"), + id="aware_to_naive", + ), ], ) def test_localize_timestamp( From bea61bdfbc1ac9c917545989e3cebfeb4207983a Mon Sep 17 00:00:00 2001 From: Gautzilla Date: Thu, 4 Jun 2026 15:03:15 +0200 Subject: [PATCH 22/28] add Event.localize() method --- src/osekit/core/event.py | 19 +++++++++++++++++ tests/test_event.py | 46 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+) diff --git a/src/osekit/core/event.py b/src/osekit/core/event.py index 575599df..60e1d5e3 100644 --- a/src/osekit/core/event.py +++ b/src/osekit/core/event.py @@ -7,6 +7,8 @@ from dataclasses import dataclass, field from typing import TYPE_CHECKING, TypeVar +from osekit.utils.timestamp import localize_timestamp + if TYPE_CHECKING: from pandas import Timedelta, Timestamp @@ -63,6 +65,23 @@ def __repr__(self) -> str: """Overwrite repr.""" return f"{self.begin} - {self.end}" + def localize(self, timezone: str | None) -> None: + """Localize the event begin and end in a timezone. + + If the event is already tz-aware, it will be converted + to the target timezone. + + Parameters + ---------- + timezone: str | None + Target timezone + + """ + # We use the private fields here because we can't compare + # naive and aware timestamps in the begin and end setters + self._begin = localize_timestamp(timestamp=self._begin, timezone=timezone) + self._end = localize_timestamp(timestamp=self._end, timezone=timezone) + def overlaps(self, other: type[Event] | Event) -> bool: """Return ``True`` if the other event shares time with the current event. diff --git a/tests/test_event.py b/tests/test_event.py index e9d21bdb..a5c02632 100644 --- a/tests/test_event.py +++ b/tests/test_event.py @@ -446,3 +446,49 @@ def test_repr() -> None: ) == "1990-09-12 12:00:00 - 1990-09-12 12:00:10" ) + + +@pytest.mark.parametrize( + ("event", "timezone", "expected"), + [ + pytest.param( + Event( + begin=Timestamp("18-02-1954 00:00:00"), + end=Timestamp("26-05-2022 00:00:00"), + ), + "UTC+0100", + Event( + begin=Timestamp("18-02-1954 00:00:00+0100"), + end=Timestamp("26-05-2022 00:00:00+0100"), + ), + id="naive_to_aware", + ), + pytest.param( + Event( + begin=Timestamp("18-02-1954 00:00:00+0100"), + end=Timestamp("26-05-2022 00:00:00+0100"), + ), + None, + Event( + begin=Timestamp("18-02-1954 00:00:00"), + end=Timestamp("26-05-2022 00:00:00"), + ), + id="aware_to_naive", + ), + pytest.param( + Event( + begin=Timestamp("18-02-1954 00:00:00+0100"), + end=Timestamp("26-05-2022 00:00:00+0100"), + ), + "UTC+0300", + Event( + begin=Timestamp("18-02-1954 02:00:00+0300"), + end=Timestamp("26-05-2022 02:00:00+0300"), + ), + id="aware_to_aware_converts_timezones", + ), + ], +) +def test_localize(event: Event, timezone: str | None, expected: Event) -> None: + event.localize(timezone) + assert event == expected From e313e8534cf991ae85f41fc48c6c442b2efcad96 Mon Sep 17 00:00:00 2001 From: Gautzilla Date: Thu, 4 Jun 2026 17:23:38 +0200 Subject: [PATCH 23/28] pass kwargs through Annotation.to_rectangle() --- src/osekit/core/annotation.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/osekit/core/annotation.py b/src/osekit/core/annotation.py index df282fb0..22dfae84 100644 --- a/src/osekit/core/annotation.py +++ b/src/osekit/core/annotation.py @@ -369,15 +369,22 @@ def from_dict(cls, row: dict) -> Self: verifications=verifications, ) - def to_rectangle(self) -> Rectangle: + def to_rectangle(self, **kwargs) -> Rectangle: """Return a matplotlib Rectangle representing the annotation. + Parameters + ---------- + kwargs: + Additional keyword arguments + Returns ------- matplotlib.patches.Rectangle Rectangle representing the annotation. The coordinates of the rectangle are in time x frequency. + + """ return Rectangle( xy=( # type: ignore[arg-type] @@ -386,6 +393,7 @@ def to_rectangle(self) -> Rectangle: ), width=self.duration, # type: ignore[arg-type] height=self.frequency_bounds.bandwidth, + **kwargs, ) @classmethod From d11ec1ed0b31b5d14826e60a85504c0af6f5cdcf Mon Sep 17 00:00:00 2001 From: Gautzilla Date: Thu, 4 Jun 2026 17:26:57 +0200 Subject: [PATCH 24/28] make SpetroData.plot() return the Axes on which the spetro has been plotted --- src/osekit/core/spectro_data.py | 10 ++++++++-- tests/test_spectro.py | 5 ++++- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/src/osekit/core/spectro_data.py b/src/osekit/core/spectro_data.py index 127b122e..bf5d8c15 100644 --- a/src/osekit/core/spectro_data.py +++ b/src/osekit/core/spectro_data.py @@ -404,12 +404,12 @@ def plot( ax: plt.Axes | None = None, sx: np.ndarray | None = None, scale: Scale | None = None, - ) -> None: + ) -> plt.Axes: """Plot the spectrogram on a specific ``Axes``. Parameters ---------- - ax: plt.axes | None + ax: plt.Axes | None ``Axes`` on which the spectrogram should be plotted. Defaulted to ``osekit.utils.plot.get_default_axes()``. sx: np.ndarray | None @@ -417,6 +417,11 @@ def plot( scale: osekit.core.frequecy_scale.Scale Custom frequency scale to use for plotting the spectrogram. + Returns + ------- + plt.Axes + The ``Axes`` on which the spectrogram has been plotted. + """ ax = ax if ax is not None else get_default_axes() sx = self.get_value() if sx is None else sx @@ -439,6 +444,7 @@ def plot( interpolation="none", extent=(date2num(time[0]), date2num(time[-1]), freq[0], freq[-1]), ) + return ax def get_db_value(self, sx: np.ndarray | None = None) -> np.ndarray: """Return the ``Sx`` spectrum of the spectrogram expressed in ``dB``. diff --git a/tests/test_spectro.py b/tests/test_spectro.py index 9fc38ae3..4e4e309f 100644 --- a/tests/test_spectro.py +++ b/tests/test_spectro.py @@ -1425,7 +1425,8 @@ def mock_imshow( monkeypatch.setattr(plt.Axes, "imshow", mock_imshow) - sd.plot() + _, ax = plt.subplots() + sd_ax = sd.plot(ax=ax) assert (plot_kwargs["vmin"], plot_kwargs["vmax"]) == sd.v_lim assert plot_kwargs["cmap"] == sd.colormap @@ -1441,6 +1442,8 @@ def mock_imshow( assert f1 == sd.fft.f[0] assert f2 == sd.fft.f[-1] + assert sd_ax == ax + def test_spectro_default_v_lim(audio_files: pytest.fixture) -> None: files, _ = audio_files From 7ec806691d5a0faacb6829a03476132e339884aa Mon Sep 17 00:00:00 2001 From: Gautzilla Date: Thu, 4 Jun 2026 17:40:01 +0200 Subject: [PATCH 25/28] add Annotation.__repr__() test --- tests/test_annotation.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/test_annotation.py b/tests/test_annotation.py index bf681f30..4946aa67 100644 --- a/tests/test_annotation.py +++ b/tests/test_annotation.py @@ -252,3 +252,7 @@ def test_annotations_from_csv() -> None: ), } assert np.array_equal(verification, verificated.verifications) + + # Repr should be the annotation ID + annotation = annotations[0] + assert str(annotation) == str(annotation.metadata.annotation_id) From 4897136d200a95567f903d5d361a148503450721 Mon Sep 17 00:00:00 2001 From: Gautzilla Date: Mon, 8 Jun 2026 10:55:19 +0200 Subject: [PATCH 26/28] add sample Annotation fixture --- tests/test_annotation.py | 56 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/tests/test_annotation.py b/tests/test_annotation.py index 4946aa67..dcc61067 100644 --- a/tests/test_annotation.py +++ b/tests/test_annotation.py @@ -3,16 +3,72 @@ import numpy as np import pytest +from pandas import Timestamp from osekit.core.annotation import ( Annotation, + AnnotationMetaData, AnnotatorInfo, ConfidenceIndicator, FrequencyBounds, + SignalParameters, Verification, ) +@pytest.fixture +def sample_annotation() -> Annotation: + return Annotation( + metadata=AnnotationMetaData( + annotation_id=35173, + base_id=None, + comments="He's a sneaky, sneaky dog friend", + filename="its_teasy", + phase="ANNOTATION", + project="mockasin", + ), + begin=Timestamp("2013-11-05 00:00:00"), + end=Timestamp("2013-11-05 00:00:10"), + frequency_bounds=FrequencyBounds( + min=1_000, + max=3_000, + ), + label="Connan", + annotator_info=AnnotatorInfo( + annotator="Mockasin", + annotator_expertise="EXPERT", + ), + annotation_type="BOX", + confidence_indicator=ConfidenceIndicator( + label="Sure", + level=2, + maximum_level=2, + ), + signal_quantity="SINGLE", + signal_parameters=SignalParameters( + does_overlap_other_signals=False, + frequency_jumps=True, + has_deterministic_chaos=True, + has_harmonics=True, + has_sidebands=True, + has_subharmonics=False, + is_itensity_too_low=False, + max_frequency=2_800, + min_frequency=1_300, + nb_relative_maxes=2, + nb_relative_mins=3, + nb_steps=4, + trend="MODULATED", + ), + verifications={ + Verification( + verificator="soft_hair", + is_validated=True, + ), + }, + ) + + @pytest.mark.parametrize( ("min_frequency", "max_frequency", "expectation"), [ From bedd7d74d29bb6483d91efb6e8e9ec2acd39ef08 Mon Sep 17 00:00:00 2001 From: Gautzilla Date: Mon, 8 Jun 2026 10:59:30 +0200 Subject: [PATCH 27/28] add Annotation.to_rectangle() test --- src/osekit/core/annotation.py | 4 ++-- tests/test_annotation.py | 17 +++++++++++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/src/osekit/core/annotation.py b/src/osekit/core/annotation.py index 22dfae84..02b087dd 100644 --- a/src/osekit/core/annotation.py +++ b/src/osekit/core/annotation.py @@ -3,7 +3,7 @@ import math from dataclasses import dataclass from pathlib import Path -from typing import Literal, Self +from typing import Any, Literal, Self import pandas as pd from matplotlib.patches import Rectangle @@ -369,7 +369,7 @@ def from_dict(cls, row: dict) -> Self: verifications=verifications, ) - def to_rectangle(self, **kwargs) -> Rectangle: + def to_rectangle(self, **kwargs: Any) -> Rectangle: """Return a matplotlib Rectangle representing the annotation. Parameters diff --git a/tests/test_annotation.py b/tests/test_annotation.py index dcc61067..9b6691ab 100644 --- a/tests/test_annotation.py +++ b/tests/test_annotation.py @@ -312,3 +312,20 @@ def test_annotations_from_csv() -> None: # Repr should be the annotation ID annotation = annotations[0] assert str(annotation) == str(annotation.metadata.annotation_id) + + +def test_annotation_to_rectangle(sample_annotation: Annotation) -> None: + rectangle = sample_annotation.to_rectangle() + + t1, t2 = sample_annotation.begin, sample_annotation.end + + f_box = sample_annotation.frequency_bounds + f1, f2 = f_box.min, f_box.max + + x, y = rectangle.xy + + assert x == t1 + assert y == f1 + + assert x + rectangle.get_width() == t2 + assert y + rectangle.get_height() == f2 From 4566eaefd27f8b171e9a3b8c0cc0cbf1748f4eba Mon Sep 17 00:00:00 2001 From: Gautzilla Date: Mon, 8 Jun 2026 11:39:46 +0200 Subject: [PATCH 28/28] rename AnnotatorInfo fields --- src/osekit/core/annotation.py | 15 ++++++--------- tests/test_annotation.py | 26 +++++++++++++------------- 2 files changed, 19 insertions(+), 22 deletions(-) diff --git a/src/osekit/core/annotation.py b/src/osekit/core/annotation.py index 02b087dd..d96e26ea 100644 --- a/src/osekit/core/annotation.py +++ b/src/osekit/core/annotation.py @@ -97,19 +97,16 @@ def bandwidth(self) -> int: class AnnotatorInfo: """Class representing an annotator info.""" - annotator: str - annotator_expertise: Literal["NOVICE", "AVERAGE", "EXPERT"] | None = None + name: str + expertise: Literal["NOVICE", "AVERAGE", "EXPERT"] | None = None def __hash__(self) -> int: """Return a hash for the annotator.""" - return hash((self.annotator, self.annotator_expertise)) + return hash((self.name, self.expertise)) def __eq__(self, other: Self) -> bool: """Return whether two annotators are equal.""" - return ( - self.annotator == other.annotator - and self.annotator_expertise == other.annotator_expertise - ) + return self.name == other.name and self.expertise == other.expertise @dataclass @@ -309,8 +306,8 @@ def from_dict(cls, row: dict) -> Self: phase=row["created_at_phase"], ) annotator_info = AnnotatorInfo( - annotator=row["annotator"], - annotator_expertise=row["annotator_expertise"], + name=row["annotator"], + expertise=row["annotator_expertise"], ) min_frequency, max_frequency = row["min_frequency"], row["max_frequency"] diff --git a/tests/test_annotation.py b/tests/test_annotation.py index 9b6691ab..0460453a 100644 --- a/tests/test_annotation.py +++ b/tests/test_annotation.py @@ -35,8 +35,8 @@ def sample_annotation() -> Annotation: ), label="Connan", annotator_info=AnnotatorInfo( - annotator="Mockasin", - annotator_expertise="EXPERT", + name="Mockasin", + expertise="EXPERT", ), annotation_type="BOX", confidence_indicator=ConfidenceIndicator( @@ -131,13 +131,13 @@ def test_frequency_bounds( def test_annotator_info() -> None: annotators = [ - AnnotatorInfo(annotator="ruby", annotator_expertise="NOVICE"), - AnnotatorInfo(annotator="ruby", annotator_expertise="NOVICE"), - AnnotatorInfo(annotator="haunt", annotator_expertise="EXPERT"), - AnnotatorInfo(annotator="haunt", annotator_expertise="EXPERT"), - AnnotatorInfo(annotator="nevada", annotator_expertise="EXPERT"), - AnnotatorInfo(annotator="nevada", annotator_expertise="EXPERT"), - AnnotatorInfo(annotator="haunt", annotator_expertise=None), + AnnotatorInfo(name="ruby", expertise="NOVICE"), + AnnotatorInfo(name="ruby", expertise="NOVICE"), + AnnotatorInfo(name="haunt", expertise="EXPERT"), + AnnotatorInfo(name="haunt", expertise="EXPERT"), + AnnotatorInfo(name="nevada", expertise="EXPERT"), + AnnotatorInfo(name="nevada", expertise="EXPERT"), + AnnotatorInfo(name="haunt", expertise=None), ] nb_unique_annotators = 4 @@ -285,10 +285,10 @@ def test_annotations_from_csv() -> None: # Annotator parsing annotators = { - AnnotatorInfo(annotator="vashti", annotator_expertise="NOVICE"), - AnnotatorInfo(annotator="heartleap", annotator_expertise=None), - AnnotatorInfo(annotator="bunyan", annotator_expertise="EXPERT"), - AnnotatorInfo(annotator="lookaftering", annotator_expertise="EXPERT"), + AnnotatorInfo(name="vashti", expertise="NOVICE"), + AnnotatorInfo(name="heartleap", expertise=None), + AnnotatorInfo(name="bunyan", expertise="EXPERT"), + AnnotatorInfo(name="lookaftering", expertise="EXPERT"), } assert np.array_equal( annotators,