File size: 6,571 Bytes
34acdd0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
#!/usr/bin/env python3
"""
Copyright (c) 2020, Carleton University Biomedical Informatics Collaboratory

This source code is licensed under the MIT license found in the
LICENSE file in the root directory of this source tree.
"""

from typing import List
import numpy as np

VALID_FREQUENCIES = [125, 250, 500, 750, 1000, 1500, 2000, 3000, 4000, 6000, 8000, 16000]
VALID_THRESHOLDS = list(range(-10, 135, 5))
THRESHOLDS = list(range(-10, 130, 10))
OCTAVE_FREQS_HZ = [125, 250, 500, 1000, 2000, 4000, 8000]
INTEROCTAVE_FREQS_HZ = [750, 1500, 3000, 6000]
OCTAVE_FREQS_KHZ = [0.125, 0.25, 0.5, 1, 2, 4, 8]
INTEROCTAVE_FREQS_KHZ = [0.750, 1.5, 3, 6]

def round_threshold(threshold: float) -> int:
    """Returns the nearest multiple of 5 for the threshold input.

    Parameters
    ----------
    threshold : float
    The threshold snapped to the nearest multiple of 5 along the y-axis.

    Returns
    -------
    float
    A ``snapped`` threshold value.
    """
    return VALID_THRESHOLDS[np.argmin([abs(threshold - t) for t in VALID_THRESHOLDS])]

def round_frequency(frequency: float) -> int:
    """Returns the nearest audiologically meaningful frequency.
    Parameters
    ----------
    frequency : float
    The frequency to be snapped to the nearest clinically meaningful frequency.
    Returns
    -------
    float
    A ``snapped`` frequency value.
    """
    return VALID_FREQUENCIES[np.argmin([abs(frequency - f) for f in VALID_FREQUENCIES])]

def round_frequency_bone(frequency: float, direction: str, epsilon: float = 0.15) -> int:
    """Returns the nearest audiologically meaningful frequency.

    Parameters
    ----------
    frequency : float
    The frequency to be snapped to the nearest clinically meaningful frequency.

    epsilon: float
    Distance (in octaves) below which a frequency is considered to be
    exactly on the nearest valid frequency. (default: 0.15 octaves)

    direction: str
    This parameter will influence the snapping behavior as some
    audiologists draw bone conduction symbols next to the target frequency,
    while other draw it right on it.

    epsilon: float
    The frequency will be snapped in to the nearest frequency in the
    provided direction, unless the distance to the nearest
    frequency is < ε (some small distance (IN OCTAVE UNITS), in which
    case the frequency will be snapped to that value.

    Eg:

     1K         2K                   1K        2K
      |         |                     |         |
      | >       | will be snapped to  >         |
      |         |                     |         |

      but if the threshold fell directly (within a very small distance of
      1.5K, it would be snapped to that.

     1K        2K                    1K  1.5K  2K
      |         |                     |         |
      |    >    | will be snapped to  |    >    |
      |         |                     |         |

      because it is really close to 1.5 and the audiologist likely
      intentionally meant to indicate 1.5K rather than 1K.

      Note: ε is a tweakable parameter that can be optimized over the
      dataset.


    Returns
    -------
    float
    A ``snapped`` frequency value.
    """
    assert direction == "left" or direction == "right"

    distances = [abs(frequency_to_octave(frequency) - frequency_to_octave(f)) for f in VALID_FREQUENCIES]
    nearest_frequency_index = np.argmin(distances)

    snapped = None
    if distances[nearest_frequency_index] < epsilon:
        snapped = VALID_FREQUENCIES[nearest_frequency_index]
    elif direction == "left":
        if VALID_FREQUENCIES[nearest_frequency_index] > frequency:
            snapped = VALID_FREQUENCIES[nearest_frequency_index - 1] if nearest_frequency_index > 0 else VALID_FREQUENCIES[nearest_frequency_index]
        else:
            snapped = VALID_FREQUENCIES[nearest_frequency_index]
    else:
        if VALID_FREQUENCIES[nearest_frequency_index] > frequency:
            snapped = VALID_FREQUENCIES[nearest_frequency_index]
        else:
            snapped = VALID_FREQUENCIES[nearest_frequency_index + 1] if nearest_frequency_index < len(VALID_FREQUENCIES) - 1 else VALID_FREQUENCIES[nearest_frequency_index]

    return snapped

def frequency_to_octave(frequency: float) -> float:
    """Converts a frequency (in Hz) to an octave value (linear units).

    By convention, the 0th octave is 125Hz.

    Parameters
    ----------
    frequency : float
    The frequency (a positive real) to be converted to an octave value.

    Returns
    -------
    float
    The octave corresponding to the input frequency.
    """
    return np.log(frequency / 125) / np.log(2)

def octave_to_frequency(octave: float) -> float:
    """Converts an octave to its corresponding frequency value (in Hz).

    By convention, the 0th octave is 125Hz.

    Parameters
    ----------
    octave : float
    The octave to put on a frequency scale.

    Returns
    -------
    float
    The frequency value corresponding to the octave.
    """
    return 125 * 2 ** octave

def stringify_measurement(measurement: dict) -> str:
    """Returns a string describing the measurement type that is compatible
    with the NIHL portal format.

    eg. An air conduction threshold for the right ear with no masking
    would yield the string `AIR_UNMASKED_RIGHT`.

    Parameters
    ----------
    measurement: dict
    A dictionary describing a threshold. Should have the keys `ear`,
    `conduction` and `masking`.

    Returns
    -------
    str
    The string describing the measurement type in the NIHL portal format.
    """
    masking = "masked" if measurement["masking"] else "unmasked"
    return f"{measurement['conduction']}_{masking}_{measurement['ear']}".upper()


def measurement_string_to_dict(measurement_type: str) -> dict:
    """Converts a measurement type string in the NIHL portal format into
    a dictionary with the equivalent information for use with the digitizer.

    eg. `AIR_UNMASKED_RIGHT` would be equivalent to the dictionary:
    {`ear`: `right`, `conduction`: `air`, `masking`: False}

    Parameters
    ----------
    measurement: dict
    A dictionary describing a threshold. Should have the keys `ear`,
    `conduction` and `masking`.

    Returns
    -------
    str
    The string describing the measurement type in the NIHL portal format.
    """
    return {
        "ear": "left" if "LEFT" in measurement_type else "right",
        "conduction": "air" if "AIR" in measurement_type else "bone",
        "masking": False if "UNMASKED" in measurement_type else True
    }