Skip to content

Commit ca41666

Browse files
committed
Initial commit
0 parents  commit ca41666

File tree

16 files changed

+910
-0
lines changed

16 files changed

+910
-0
lines changed

.github/workflows/test.yml

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
name: Run tests
2+
3+
on: [push, pull_request]
4+
5+
jobs:
6+
build:
7+
strategy:
8+
matrix:
9+
os: [ubuntu-latest, macos-latest, windows-latest]
10+
python-version: ["3.10", "3.11", "3.12", "3.13"]
11+
12+
runs-on: ${{ matrix.os }}
13+
steps:
14+
- uses: actions/checkout@v4
15+
- name: Set up Python ${{ matrix.python-version }}
16+
uses: actions/setup-python@v5
17+
with:
18+
python-version: ${{ matrix.python-version }}
19+
cache: "pip"
20+
- name: Install dependencies
21+
run: |
22+
pip install --upgrade pip
23+
pip install .[dev,test]
24+
- name: Run pre-commit checks
25+
if: startsWith(matrix.os, 'ubuntu-')
26+
run: |
27+
pre-commit run --all-files --show-diff-on-failure
28+
- name: Run tests
29+
run: |
30+
pytest

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
__pycache__

.pre-commit-config.yaml

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
exclude: ^tests/data/
2+
repos:
3+
- repo: https://github.com/pre-commit/pre-commit-hooks
4+
rev: v5.0.0
5+
hooks:
6+
- id: check-case-conflict
7+
- id: check-executables-have-shebangs
8+
- id: check-merge-conflict
9+
- id: check-shebang-scripts-are-executable
10+
- id: end-of-file-fixer
11+
- id: fix-byte-order-marker
12+
- id: mixed-line-ending
13+
args: ["--fix", "lf"]
14+
- id: trailing-whitespace
15+
- repo: https://github.com/astral-sh/ruff-pre-commit
16+
rev: v0.11.0
17+
hooks:
18+
- id: ruff
19+
args: ["--fix"]
20+
- id: ruff-format
21+
- repo: local
22+
hooks:
23+
- id: mypy
24+
name: mypy
25+
entry: mypy
26+
language: system
27+
types: [python]
28+
require_serial: true
29+
- repo: https://github.com/pre-commit/mirrors-prettier
30+
rev: v3.1.0
31+
hooks:
32+
- id: prettier
33+
- repo: https://github.com/pappasam/toml-sort
34+
rev: v0.24.2
35+
hooks:
36+
- id: toml-sort-fix
37+
- repo: https://github.com/crate-ci/typos
38+
rev: v1.30.2
39+
hooks:
40+
- id: typos

LICENSE

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2025 Finnish Meteorological Institute
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.

README.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
# Ceilopyter
2+
3+
Python package for reading ceilometer data.
4+
5+
## Supported ceilometers
6+
7+
- Campbell Scientific CS135
8+
- Vaisala CL31
9+
- Vaisala CL51
10+
- Vaisala CT25K
11+
12+
## License
13+
14+
MIT

ceilopyter/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
from .read_cl import read_cl_file as read_cl_file
2+
from .read_cl import read_cl_message as read_cl_message
3+
from .read_cs import read_cs_file as read_cs_file
4+
from .read_cs import read_cs_message as read_cs_message
5+
from .read_ct import read_ct_file as read_ct_file
6+
from .read_ct import read_ct_message as read_ct_message

ceilopyter/common.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
from dataclasses import dataclass
2+
3+
import numpy as np
4+
import numpy.typing as npt
5+
6+
7+
class InvalidMessageError(Exception):
8+
pass
9+
10+
11+
@dataclass
12+
class Message:
13+
"""Data message from ceilometer.
14+
15+
Attributes:
16+
range_resolution: Range resolution (m).
17+
laser_pulse_energy: Laser pulse energy (%).
18+
laser_temperature: Laser temperature (degC).
19+
tilt_angle: Tilt angle (deg).
20+
background_light: Background light (mV).
21+
n_pulses: Number of pulses.
22+
sample_rate: Sampling rate (MHz).
23+
beta: Backscatter coefficient (sr-1 m-1).
24+
"""
25+
26+
range_resolution: int
27+
laser_pulse_energy: int
28+
laser_temperature: int
29+
tilt_angle: int
30+
background_light: int
31+
n_pulses: int
32+
sample_rate: int
33+
beta: npt.NDArray[np.floating]
34+
35+
36+
class Status:
37+
def __repr__(self):
38+
return (
39+
self.__class__.__name__
40+
+ "("
41+
+ ", ".join(f"{key}=True" for key, value in vars(self).items() if value)
42+
+ ")"
43+
)

ceilopyter/py.typed

Whitespace-only changes.

ceilopyter/read_cl.py

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
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

Comments
 (0)