|
| 1 | +import datetime |
| 2 | +import logging |
| 3 | +from dataclasses import dataclass |
| 4 | +from os import PathLike |
| 5 | +from pathlib import Path |
| 6 | + |
| 7 | +from . import utils |
| 8 | +from .common import InvalidMessageError, Message, Status |
| 9 | + |
| 10 | +FORMAT = utils.date_format_to_regex(rb"-%Y-%m-%d %H:%M:%S\r?\n") |
| 11 | + |
| 12 | + |
| 13 | +class ClStatus(Status): |
| 14 | + """Decoded status bits from Vaisala CL31 or CL51. |
| 15 | +
|
| 16 | + Attributes: |
| 17 | + transmitter_shutoff_alarm: Transmitter shut-off. |
| 18 | + transmitter_fail_alarm: Transmitter failure. |
| 19 | + receiver_fail_alarm: Receiver failure. |
| 20 | + voltage_fail_alarm: Voltage failure. |
| 21 | + memory_error_alarm: Memory error. |
| 22 | + light_path_obstruction_alarm: Light path obstruction. |
| 23 | + receiver_saturation_alarm: Receiver saturation. |
| 24 | + coaxial_cable_fail_alarm: Coaxial cable failure. |
| 25 | + ceilometer_board_fail_alarm: Ceilometer engine board failure. |
| 26 | + window_contam_warning: Window contamination. |
| 27 | + battery_low_warning: Battery voltage low. |
| 28 | + transmitter_expire_warning: Transmitter expires. |
| 29 | + humidity_high_warning: High humidity. |
| 30 | + blower_fail_warning: Blower failure. |
| 31 | + humidity_sensor_fail_warning: Humidity sensor failure. |
| 32 | + heater_fault_warning: Heater fault. |
| 33 | + background_radiance_warning: High background radiance. |
| 34 | + ceilometer_board_warning: Ceilometer engine board failure. |
| 35 | + battery_fail_warning: Battery failure. |
| 36 | + laser_monitor_fail_warning: Laser monitor failure. |
| 37 | + receiver_warning: Receiver warning. |
| 38 | + tilt_angle_warning: Tilt angle > 45 degrees warning. |
| 39 | + blower_status: Blower is on. |
| 40 | + blower_heater_status: Blower heater is on. |
| 41 | + internal_heater_status: Internal heater is on. |
| 42 | + battery_power_status: Working from battery. |
| 43 | + standby_status: Standby mode is on. |
| 44 | + self_test_status: Self test in progress. |
| 45 | + manual_data_status: Manual data acquisition settings are effective. |
| 46 | + units_meters: Units are meters if on, else feet. |
| 47 | + manual_blower_status: Manual blower control. |
| 48 | + polling_mode_status: Polling mode is on. |
| 49 | + """ |
| 50 | + |
| 51 | + def __init__(self, status_bits: int): |
| 52 | + self.transmitter_shutoff_alarm = bool(status_bits & 0x800000000000) |
| 53 | + self.transmitter_fail_alarm = bool(status_bits & 0x400000000000) |
| 54 | + self.receiver_fail_alarm = bool(status_bits & 0x200000000000) |
| 55 | + self.voltage_fail_alarm = bool(status_bits & 0x100000000000) |
| 56 | + self.memory_error_alarm = bool(status_bits & 0x040000000000) |
| 57 | + self.light_path_obstruction_alarm = bool(status_bits & 0x020000000000) |
| 58 | + self.receiver_saturation_alarm = bool(status_bits & 0x010000000000) |
| 59 | + self.coaxial_cable_fail_alarm = bool(status_bits & 0x000200000000) |
| 60 | + self.ceilometer_board_fail_alarm = bool(status_bits & 0x000100000000) |
| 61 | + self.window_contam_warning = bool(status_bits & 0x000080000000) |
| 62 | + self.battery_low_warning = bool(status_bits & 0x000040000000) |
| 63 | + self.transmitter_expire_warning = bool(status_bits & 0x000020000000) |
| 64 | + self.humidity_high_warning = bool(status_bits & 0x000010000000) |
| 65 | + self.blower_fail_warning = bool(status_bits & 0x000004000000) |
| 66 | + self.humidity_sensor_fail_warning = bool(status_bits & 0x000001000000) |
| 67 | + self.heater_fault_warning = bool(status_bits & 0x000000800000) |
| 68 | + self.background_radiance_warning = bool(status_bits & 0x000000400000) |
| 69 | + self.ceilometer_board_warning = bool(status_bits & 0x000000200000) |
| 70 | + self.battery_fail_warning = bool(status_bits & 0x000000100000) |
| 71 | + self.laser_monitor_fail_warning = bool(status_bits & 0x000000080000) |
| 72 | + self.receiver_warning = bool(status_bits & 0x000000040000) |
| 73 | + self.tilt_angle_warning = bool(status_bits & 0x000000020000) |
| 74 | + self.blower_status = bool(status_bits & 0x000000008000) |
| 75 | + self.blower_heater_status = bool(status_bits & 0x000000004000) |
| 76 | + self.internal_heater_status = bool(status_bits & 0x000000002000) |
| 77 | + self.battery_power_status = bool(status_bits & 0x000000001000) |
| 78 | + self.standby_status = bool(status_bits & 0x000000000800) |
| 79 | + self.self_test_status = bool(status_bits & 0x000000000400) |
| 80 | + self.manual_data_status = bool(status_bits & 0x000000000200) |
| 81 | + self.units_meters = bool(status_bits & 0x000000000080) |
| 82 | + self.manual_blower_status = bool(status_bits & 0x000000000040) |
| 83 | + self.polling_mode_status = bool(status_bits & 0x000000000020) |
| 84 | + |
| 85 | + |
| 86 | +@dataclass |
| 87 | +class ClMessage(Message): |
| 88 | + """Data message from Vaisala CL31 or CL51. |
| 89 | +
|
| 90 | + Attributes: |
| 91 | + window_transmission: Window transmission (%). |
| 92 | + status: Decoded status bits. |
| 93 | + """ |
| 94 | + |
| 95 | + window_transmission: int |
| 96 | + status: ClStatus |
| 97 | + |
| 98 | + |
| 99 | +def read_cl_file( |
| 100 | + filename: str | PathLike, |
| 101 | +) -> tuple[list[datetime.datetime], list[ClMessage]]: |
| 102 | + """Read Vaisala CL31 or CL51 file.""" |
| 103 | + content = Path(filename).read_bytes() |
| 104 | + time = [] |
| 105 | + data = [] |
| 106 | + for ts, msg in utils.parse_file(content, FORMAT): |
| 107 | + try: |
| 108 | + data.append(read_cl_message(msg)) |
| 109 | + time.append(ts) |
| 110 | + except (InvalidMessageError, ValueError) as e: |
| 111 | + logging.debug("Invalid message: %s", e) |
| 112 | + return time, data |
| 113 | + |
| 114 | + |
| 115 | +def read_cl_message(message: bytes) -> ClMessage: |
| 116 | + """Read Vaisala CL31 or CL51 data message.""" |
| 117 | + lines = iter(message.splitlines()) |
| 118 | + |
| 119 | + # Line 1 |
| 120 | + line1 = utils.next_line(lines, 8, prefix=b"\x01", suffix=b"\x02") |
| 121 | + if line1[:2] != b"CL": |
| 122 | + msg = "Invalid line 1" |
| 123 | + raise InvalidMessageError(msg) |
| 124 | + msg_no = line1[6:7] |
| 125 | + if msg_no not in (b"1", b"2"): |
| 126 | + msg = f"Invalid message number: {msg_no.decode()}" |
| 127 | + raise InvalidMessageError(msg) |
| 128 | + subclass = line1[7:8] |
| 129 | + if subclass in (b"1", b"2", b"3", b"4"): |
| 130 | + line3_len = 31 # CL31 |
| 131 | + elif subclass == b"6": |
| 132 | + line3_len = 40 # CL51 |
| 133 | + else: |
| 134 | + msg = f"Invalid message subclass: {subclass.decode()}" |
| 135 | + raise InvalidMessageError(msg) |
| 136 | + check_content = line1 + b"\x02\r\n" |
| 137 | + |
| 138 | + # Line 2 |
| 139 | + line2 = utils.next_line(lines, 33) |
| 140 | + status_bits = int(line2[21:], 16) |
| 141 | + status = ClStatus(status_bits) |
| 142 | + check_content += line2 + b"\r\n" |
| 143 | + |
| 144 | + # Line 3: sky condition |
| 145 | + if msg_no == b"2": |
| 146 | + line3 = utils.next_line(lines).rjust(line3_len) |
| 147 | + check_content += line3 + b"\r\n" |
| 148 | + |
| 149 | + # Line 3/4 |
| 150 | + line4 = utils.next_line(lines, 47) |
| 151 | + scale = int(line4[0:5]) |
| 152 | + range_resolution = int(line4[6:8]) |
| 153 | + n_samples = int(line4[9:13]) |
| 154 | + laser_pulse_energy = int(line4[14:17]) |
| 155 | + laser_temperature = int(line4[18:21]) |
| 156 | + window_transmission = int(line4[22:25]) |
| 157 | + tilt_angle = int(line4[26:28]) |
| 158 | + background_light = int(line4[29:33]) |
| 159 | + n_pulses = 1024 * int(line4[35:39]) |
| 160 | + sample_rate = int(line4[41:43]) |
| 161 | + check_content += line4 + b"\r\n" |
| 162 | + |
| 163 | + # Line 4/5: profile |
| 164 | + line5 = utils.next_line(lines, 5 * n_samples) |
| 165 | + raw = utils.read_hex(line5, 5, n_samples) |
| 166 | + beta = raw * 1e-8 * scale / 100 |
| 167 | + check_content += line5 + b"\r\n\x03" |
| 168 | + |
| 169 | + # Line 5/6: checksum |
| 170 | + line6 = utils.next_line(lines, 4, prefix=b"\x03", suffix=b"\x04") |
| 171 | + expected_checksum = int(line6, 16) |
| 172 | + |
| 173 | + actual_checksum = utils.crc16(check_content) |
| 174 | + if expected_checksum != actual_checksum: |
| 175 | + msg = ( |
| 176 | + "Invalid checksum: " |
| 177 | + f"expected {expected_checksum:04x}, " |
| 178 | + f"got {actual_checksum:04x}" |
| 179 | + ) |
| 180 | + raise InvalidMessageError(msg) |
| 181 | + |
| 182 | + return ClMessage( |
| 183 | + range_resolution=range_resolution, |
| 184 | + laser_pulse_energy=laser_pulse_energy, |
| 185 | + laser_temperature=laser_temperature, |
| 186 | + window_transmission=window_transmission, |
| 187 | + tilt_angle=tilt_angle, |
| 188 | + background_light=background_light, |
| 189 | + n_pulses=n_pulses, |
| 190 | + sample_rate=sample_rate, |
| 191 | + status=status, |
| 192 | + beta=beta, |
| 193 | + ) |
0 commit comments