from __future__ import annotations import datetime import inspect from collections.abc import Sequence from typing import Any, Literal from edfio._header_field import encode_str def _repr_from_init(obj: Any) -> str: parameters = [] for name in inspect.signature(obj.__class__).parameters: parameters.append(f"{name}={getattr(obj, name)!r}") return f"{obj.__class__.__name__}({', '.join(parameters)})" _MONTH_NAMES = ( "JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SEP", "OCT", "NOV", "DEC", ) def _decode_edfplus_date(date: str) -> datetime.date: day, month, year = date.split("-") try: month_int = _MONTH_NAMES.index(month.upper()) + 1 except ValueError: raise ValueError(f"Invalid month: {month}, options: {_MONTH_NAMES}") from None return datetime.date(int(year), month_int, int(day)) def _encode_edfplus_date(date: datetime.date) -> str: return f"{date.day:02}-{_MONTH_NAMES[date.month - 1]}-{date.year:02}" def _validate_subfields(subfields: dict[str, str]) -> None: for key, value in subfields.items(): if not value: raise ValueError(f"Subfield {key} must not be an empty string") if " " in value: raise ValueError(f"Subfield {key} contains spaces: {value!r}") class AnonymizedDateError(ValueError): """Raised when trying to access an anonymized startdate or birthdate.""" class Patient: """ Object representation of the local patient identification. Parsing from/to the string containing the local_patient_identification header field is done according to the EDF+ specs. Subfields must be ASCII (32..126) and may not contain spaces. Parameters ---------- code : str, default: `"X"` The code by which the patient is known in the hospital administration. sex : `{"X", "F", "M"}`, default: `"X"` Sex, `F` for female, `M` for male, `X` if anonymized. birthdate : datetime.date | None, default: None Patient birthdate, stored as `X` if `None`. name : str, default: `"X"` The patient's name, stored as `X` if `None`. additional : Sequence[str], default: `()` Optional additional subfields. Will be stored in the header field separated by spaces. """ def __init__( self, *, code: str = "X", sex: Literal["F", "M", "X"] = "X", birthdate: datetime.date | None = None, name: str = "X", additional: Sequence[str] = (), ) -> None: if sex not in ("F", "M", "X"): raise ValueError(f"Invalid sex: {sex}, must be one of F, M, X") if birthdate is None: birthdate_field = "X" else: birthdate_field = _encode_edfplus_date(birthdate) subfields = { "code": code, "sex": sex, "birthdate": birthdate_field, "name": name, **{f"additional[{i}]": v for i, v in enumerate(additional)}, } _validate_subfields(subfields) local_patient_identification = " ".join(subfields.values()) encode_str(local_patient_identification, 80) self._local_patient_identification = local_patient_identification def __repr__(self) -> str: try: return _repr_from_init(self) except Exception: return repr(self._local_patient_identification) @classmethod def _from_str(cls, string: str) -> Patient: obj = object.__new__(cls) obj._local_patient_identification = string return obj def _to_str(self) -> str: return self._local_patient_identification @property def code(self) -> str: """The code by which the patient is known in the hospital administration.""" return self.get_subfield(0) @property def sex(self) -> str: """Sex, `F` for female, `M` for male, `X` if anonymized.""" return self.get_subfield(1) @property def birthdate(self) -> datetime.date: """Patient birthdate.""" birthdate_field = self.get_subfield(2) if birthdate_field == "X": raise AnonymizedDateError("Patient birthdate is not available ('X').") return _decode_edfplus_date(birthdate_field) @property def name(self) -> str: """The patient's name.""" return self.get_subfield(3) @property def additional(self) -> tuple[str, ...]: """Optional additional subfields.""" return tuple(self._local_patient_identification.split()[4:]) def get_subfield(self, idx: int) -> str: """ Access a subfield of the local patient identification field by index. Parameters ---------- idx : int The index of the subfield to access. Returns ------- str The subfield at the specified index. If the index exceeds the actually available number of subfields, the return value is `"X"`. """ subfields = self._local_patient_identification.split() if len(subfields) <= idx: return "X" return subfields[idx] class Recording: """ Object representation of the local recording identification. Parsing from/to the string containing the local_recording_identification header field is done according to the EDF+ specs. Subfields must be ASCII (32..126) and may not contain spaces. Parameters ---------- startdate : datetime.date | None, default: None The recording startdate. hospital_administration_code : str, default: `"X"` The hospital administration code of the investigation, e.g., EEG number or PSG number. investigator_technician_code : str, default: `"X"` A code specifying the responsible investigator or technician. equipment_code : str, default: `"X"` A code specifying the used equipment. additional : Sequence[str], default: `()` Optional additional subfields. Will be stored in the header field separated by spaces. """ def __init__( self, *, startdate: datetime.date | None = None, hospital_administration_code: str = "X", investigator_technician_code: str = "X", equipment_code: str = "X", additional: Sequence[str] = (), ) -> None: if startdate is None: startdate_field = "X" else: startdate_field = _encode_edfplus_date(startdate) subfields = { "startdate": startdate_field, "hospital_administration_code": hospital_administration_code, "investigator_technician_code": investigator_technician_code, "equipment_code": equipment_code, **{f"additional[{i}]": v for i, v in enumerate(additional)}, } _validate_subfields(subfields) local_recording_identification = " ".join(("Startdate", *subfields.values())) encode_str(local_recording_identification, 80) self._local_recording_identification = local_recording_identification def __repr__(self) -> str: try: return _repr_from_init(self) except Exception: return repr(self._local_recording_identification) @classmethod def _from_str(cls, string: str) -> Recording: obj = object.__new__(cls) obj._local_recording_identification = string return obj def _to_str(self) -> str: return self._local_recording_identification @property def startdate(self) -> datetime.date: """The recording startdate.""" if not self._local_recording_identification.startswith("Startdate "): raise ValueError( f"Local recording identification field {self._local_recording_identification!r} does not follow EDF+ standard." ) startdate_field = self.get_subfield(1) if startdate_field == "X": raise AnonymizedDateError("Recording startdate is not available ('X').") return _decode_edfplus_date(startdate_field) @property def hospital_administration_code(self) -> str: """The hospital administration code of the investigation.""" return self.get_subfield(2) @property def investigator_technician_code(self) -> str: """A code specifying the responsible investigator or technician.""" return self.get_subfield(3) @property def equipment_code(self) -> str: """A code specifying the used equipment.""" return self.get_subfield(4) @property def additional(self) -> tuple[str, ...]: """Optional additional subfields.""" return tuple(self._local_recording_identification.split()[5:]) def get_subfield(self, idx: int) -> str: """ Access a subfield of the local recording identification field by index. Parameters ---------- idx : int The index of the subfield to access. The first subfield (starting at index 0) should always be "Startdate" according to the EDF+ spedification. Returns ------- str The subfield at the specified index. If the index exceeds the actually available number of subfields, the return value is `"X"`. """ subfields = self._local_recording_identification.split() if len(subfields) <= idx: return "X" return subfields[idx]