licelformat 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,29 @@
1
+ """
2
+ LicelFormat — Python port of the Go licelformat package.
3
+
4
+ Provides utilities for parsing and processing Licel format data files.
5
+ Supports reading, extracting metadata, and converting binary data into
6
+ usable formats. Works with Licel files containing measurement profiles
7
+ and other associated data.
8
+ """
9
+
10
+ from .licelfile import (
11
+ LicelFile,
12
+ LicelProfilesList,
13
+ LoadLicelFile,
14
+ LoadLicelFileFromReader,
15
+ )
16
+ from .licelpack import LicelPack, NewLicelPack, NewLicelPackFromZip
17
+ from .licelprofile import LICEL_MAX_RESERVED, LicelProfile
18
+
19
+ __all__ = [
20
+ "LicelProfile",
21
+ "LICEL_MAX_RESERVED",
22
+ "LicelFile",
23
+ "LicelProfilesList",
24
+ "LoadLicelFile",
25
+ "LoadLicelFileFromReader",
26
+ "LicelPack",
27
+ "NewLicelPack",
28
+ "NewLicelPackFromZip",
29
+ ]
@@ -0,0 +1,264 @@
1
+ """
2
+ LicelFile — structure representing a single Licel measurement.
3
+
4
+ Provides loading, saving, and querying functionality for Licel format files.
5
+ """
6
+
7
+ import os
8
+ import struct
9
+ from datetime import datetime
10
+ from io import BufferedReader, RawIOBase
11
+ from typing import IO, List, Optional
12
+
13
+ import numpy as np
14
+
15
+ from .licelprofile import LICEL_MAX_RESERVED, LicelProfile, _str2float, _str2int
16
+
17
+ LicelProfilesList = List[LicelProfile]
18
+
19
+
20
+ class LicelFile:
21
+ """Structure representing a single Licel measurement."""
22
+
23
+ __slots__ = (
24
+ "MeasurementSite",
25
+ "MeasurementStartTime",
26
+ "MeasurementStopTime",
27
+ "AltitudeAboveSeaLevel",
28
+ "Longitude",
29
+ "Latitude",
30
+ "Zenith",
31
+ "Laser1NShots",
32
+ "Laser1Freq",
33
+ "Laser2NShots",
34
+ "Laser2Freq",
35
+ "NDatasets",
36
+ "Laser3NShots",
37
+ "Laser3Freq",
38
+ "FileLoaded",
39
+ "Profiles",
40
+ )
41
+
42
+ def __init__(self):
43
+ self.MeasurementSite: str = ""
44
+ self.MeasurementStartTime: Optional[datetime] = None
45
+ self.MeasurementStopTime: Optional[datetime] = None
46
+ self.AltitudeAboveSeaLevel: float = 0.0
47
+ self.Longitude: float = 0.0
48
+ self.Latitude: float = 0.0
49
+ self.Zenith: float = 0.0
50
+ self.Laser1NShots: int = 0
51
+ self.Laser1Freq: int = 0
52
+ self.Laser2NShots: int = 0
53
+ self.Laser2Freq: int = 0
54
+ self.NDatasets: int = 0
55
+ self.Laser3NShots: int = 0
56
+ self.Laser3Freq: int = 0
57
+ self.FileLoaded: bool = False
58
+ self.Profiles: LicelProfilesList = []
59
+
60
+ def select_certain_wavelength(
61
+ self, is_photon: bool, wavelength: float
62
+ ) -> LicelProfile:
63
+ """Select a profile by its wavelength and type."""
64
+ for profile in self.Profiles:
65
+ if profile.Photon == is_photon and profile.Wavelength == wavelength:
66
+ return profile
67
+ return LicelProfile()
68
+
69
+ def save(self, fname: str) -> None:
70
+ """Save the Licel file to disk."""
71
+ with open(fname + "1", "wb") as f:
72
+ f.write(self._format_first_line(fname).encode("latin-1"))
73
+ f.write(self._format_second_line().encode("latin-1"))
74
+ f.write(self._format_third_line().encode("latin-1"))
75
+ for profile in self.Profiles:
76
+ f.write(profile.metadata().encode("latin-1"))
77
+ f.write(b"\r\n")
78
+ for profile in self.Profiles:
79
+ f.write(profile.profile())
80
+
81
+ def _format_first_line(self, fname: str) -> str:
82
+ """Return the first line of a LICEL file."""
83
+ return f" {fname:<77s}\r\n"
84
+
85
+ def _format_second_line(self) -> str:
86
+ """Return the second line of a LICEL file."""
87
+ s = (
88
+ f" {self.MeasurementSite} "
89
+ f"{self.MeasurementStartTime.strftime('%d/%m/%Y')} "
90
+ f"{self.MeasurementStartTime.strftime('%H:%M:%S')} "
91
+ f"{self.MeasurementStopTime.strftime('%d/%m/%Y')} "
92
+ f"{self.MeasurementStopTime.strftime('%H:%M:%S')} "
93
+ f"{self.AltitudeAboveSeaLevel:04.0f} "
94
+ f"{self.Longitude:06.1f} "
95
+ f"{self.Latitude:06.1f} "
96
+ f"{self.Zenith:02.0f}"
97
+ )
98
+ return f"{s:<78s}\r\n"
99
+
100
+ def _format_third_line(self) -> str:
101
+ """Return the third line of a LICEL file."""
102
+ s = (
103
+ f" {self.Laser1NShots:07d} {self.Laser1Freq:04d} "
104
+ f"{self.Laser2NShots:07d} {self.Laser2Freq:04d} "
105
+ f"{self.NDatasets:02d} {self.Laser3NShots:07d} "
106
+ f"{self.Laser3Freq:04d}"
107
+ )
108
+ return f"{s:<78s}\r\n"
109
+
110
+ def to_dict(self) -> dict:
111
+ """Convert LicelFile to a dictionary (for JSON serialization)."""
112
+ return {
113
+ "location": self.MeasurementSite,
114
+ "start_time": (
115
+ self.MeasurementStartTime.isoformat()
116
+ if self.MeasurementStartTime
117
+ else None
118
+ ),
119
+ "stop_time": (
120
+ self.MeasurementStopTime.isoformat()
121
+ if self.MeasurementStopTime
122
+ else None
123
+ ),
124
+ "lidar_altitude": self.AltitudeAboveSeaLevel,
125
+ "longitude": self.Longitude,
126
+ "latitude": self.Latitude,
127
+ "zenith": self.Zenith,
128
+ "laser1_nshots": self.Laser1NShots,
129
+ "laser1_freq": self.Laser1Freq,
130
+ "laser2_nshots": self.Laser2NShots,
131
+ "laser2_freq": self.Laser2Freq,
132
+ "dataset_count": self.NDatasets,
133
+ "laser3_nshots": self.Laser3NShots,
134
+ "laser3_freq": self.Laser3Freq,
135
+ "datasets": [p.to_dict() for p in self.Profiles],
136
+ }
137
+
138
+ def __repr__(self) -> str:
139
+ return (
140
+ f"LicelFile(site={self.MeasurementSite}, "
141
+ f"start={self.MeasurementStartTime}, "
142
+ f"profiles={len(self.Profiles)})"
143
+ )
144
+
145
+
146
+ # ---------------------------------------------------------------------------
147
+ # Helper functions
148
+ # ---------------------------------------------------------------------------
149
+
150
+
151
+ def _parse_time(s: str) -> datetime:
152
+ """Parse datetime string in format 'dd/mm/yyyy hh:mm:ss'."""
153
+ return datetime.strptime(s, "%d/%m/%Y %H:%M:%S")
154
+
155
+
156
+ def _read_and_trim_line(r: BufferedReader) -> str:
157
+ """Read a line from reader and trim right-side whitespace."""
158
+ line = r.readline()
159
+ if not line:
160
+ raise EOFError("Unexpected end of file while reading header line")
161
+ return line.decode("latin-1").rstrip("\t\r\n ")
162
+
163
+
164
+ def _skip_crlf(r: BufferedReader) -> None:
165
+ """Skip CR+LF (2 bytes) from reader."""
166
+ crlf = r.read(2)
167
+ if len(crlf) < 2:
168
+ raise EOFError("Unexpected end of file while skipping CRLF")
169
+
170
+
171
+ def _bytes_to_float64_array(b: bytes) -> np.ndarray:
172
+ """Convert raw bytes to numpy float64 array (little-endian int32 → float64)."""
173
+ # Interpret bytes as little-endian int32, then cast to float64
174
+ arr = np.frombuffer(b, dtype=np.int32).astype(np.float64)
175
+ return arr
176
+
177
+
178
+ # ---------------------------------------------------------------------------
179
+ # Main loading functions
180
+ # ---------------------------------------------------------------------------
181
+
182
+
183
+ def LoadLicelFile(fname: str) -> LicelFile:
184
+ """Load a LicelFile from the specified file path."""
185
+ with open(fname, "rb") as f:
186
+ return _load_licel_file_from_buffered_reader(f)
187
+
188
+
189
+ def LoadLicelFileFromReader(stream: IO[bytes], size: int = 0) -> LicelFile:
190
+ """Load a LicelFile from a binary reader/stream."""
191
+ # Wrap in BufferedReader if needed
192
+ if isinstance(stream, BufferedReader):
193
+ r = stream
194
+ else:
195
+ r = BufferedReader(stream) # type: ignore[arg-type]
196
+ return _load_licel_file_from_buffered_reader(r)
197
+
198
+
199
+ def _load_licel_file_from_buffered_reader(r: BufferedReader) -> LicelFile:
200
+ """Core loading logic from a buffered binary reader."""
201
+ licf = LicelFile()
202
+
203
+ # Skip first line (contains filename or is empty)
204
+ _read_and_trim_line(r)
205
+
206
+ # Second line: basic information
207
+ header = _read_and_trim_line(r)
208
+ tmp = header.split()
209
+
210
+ licf.MeasurementSite = tmp[0]
211
+ licf.MeasurementStartTime = _parse_time(tmp[1] + " " + tmp[2])
212
+ licf.MeasurementStopTime = _parse_time(tmp[3] + " " + tmp[4])
213
+ licf.AltitudeAboveSeaLevel = _str2float(tmp[5])
214
+ licf.Longitude = _str2float(tmp[6])
215
+ licf.Latitude = _str2float(tmp[7])
216
+ licf.Zenith = _str2float(tmp[8])
217
+
218
+ # Third line: laser parameters
219
+ header = _read_and_trim_line(r)
220
+ tmp = header.split()
221
+ licf.Laser1NShots = _str2int(tmp[0])
222
+ licf.Laser1Freq = _str2int(tmp[1])
223
+ licf.Laser2NShots = _str2int(tmp[2])
224
+ licf.Laser2Freq = _str2int(tmp[3])
225
+ licf.NDatasets = _str2int(tmp[4])
226
+ licf.Laser3NShots = _str2int(tmp[5])
227
+ licf.Laser3Freq = _str2int(tmp[6])
228
+
229
+ # Profiles (headers)
230
+ licf.Profiles = []
231
+ for _ in range(licf.NDatasets):
232
+ header = _read_and_trim_line(r)
233
+ licf.Profiles.append(LicelProfile(header))
234
+
235
+ # After headers — binary data
236
+ _skip_crlf(r)
237
+
238
+ for i in range(licf.NDatasets):
239
+ n_bytes = licf.Profiles[i].NDataPoints * 4
240
+ pr_tmp = r.read(n_bytes)
241
+ if len(pr_tmp) < n_bytes:
242
+ raise EOFError("Error reading binary data: unexpected end of file")
243
+
244
+ licf.Profiles[i].Data = _bytes_to_float64_array(pr_tmp)
245
+
246
+ # Apply scaling
247
+ if not licf.Profiles[i].Photon:
248
+ # Analog channel
249
+ adc_scale = 1 << licf.Profiles[i].AdcBits
250
+ scale = (
251
+ licf.Profiles[i].DiscrLevel
252
+ * 1000.0
253
+ / float(adc_scale * licf.Profiles[i].NShots)
254
+ )
255
+ else:
256
+ # Photon counting channel
257
+ scale = 1.0 / (float(licf.Profiles[i].NShots) * 0.05)
258
+
259
+ licf.Profiles[i].Data *= scale
260
+
261
+ _skip_crlf(r)
262
+
263
+ licf.FileLoaded = True
264
+ return licf
@@ -0,0 +1,100 @@
1
+ """
2
+ LicelPack — collection of Licel measurements loaded by file mask or from ZIP.
3
+
4
+ Provides loading and querying functionality for multiple Licel files.
5
+ """
6
+
7
+ import glob
8
+ import io
9
+ import re
10
+ import zipfile
11
+ from datetime import datetime
12
+ from typing import Dict, List, Optional
13
+
14
+ from .licelfile import (
15
+ LicelFile,
16
+ LicelProfilesList,
17
+ LoadLicelFile,
18
+ LoadLicelFileFromReader,
19
+ )
20
+
21
+
22
+ def _is_valid_filename(filename: str) -> bool:
23
+ """Check if filename matches the pattern 'b*.*'."""
24
+ return bool(re.match(r"^b.*\..+", filename))
25
+
26
+
27
+ class LicelPack:
28
+ """Collection of Licel measurements."""
29
+
30
+ __slots__ = ("StartTime", "Data")
31
+
32
+ def __init__(self):
33
+ self.StartTime: Optional[datetime] = None
34
+ self.Data: Dict[str, LicelFile] = {}
35
+
36
+ def select_certain_wavelength(
37
+ self, is_photon: bool, wavelength: float
38
+ ) -> LicelProfilesList:
39
+ """Select profiles by wavelength and type from all files in the pack."""
40
+ result: LicelProfilesList = []
41
+ for licf in self.Data.values():
42
+ profile = licf.select_certain_wavelength(is_photon, wavelength)
43
+ if profile.Wavelength != 0:
44
+ result.append(profile)
45
+ return result
46
+
47
+ def save(self) -> None:
48
+ """Save all files in the pack to disk."""
49
+ for fname, licf in self.Data.items():
50
+ licf.save(fname)
51
+
52
+ def __repr__(self) -> str:
53
+ return f"LicelPack(start={self.StartTime}, files={len(self.Data)})"
54
+
55
+ def to_dict(self) -> dict:
56
+ """Convert LicelPack to a dictionary (for JSON serialization)."""
57
+ return {
58
+ "start_time": (self.StartTime.isoformat() if self.StartTime else None),
59
+ "data": {name: lf.to_dict() for name, lf in self.Data.items()},
60
+ }
61
+
62
+
63
+ def NewLicelPack(mask: str) -> LicelPack:
64
+ """Load files according to a glob mask."""
65
+ pack = LicelPack()
66
+ files = glob.glob(mask)
67
+ if not files:
68
+ raise FileNotFoundError(f"No files found matching mask: {mask}")
69
+
70
+ for i, fname in enumerate(files):
71
+ pack.Data[fname] = LoadLicelFile(fname)
72
+ if i == 0:
73
+ pack.StartTime = pack.Data[fname].MeasurementStartTime
74
+
75
+ return pack
76
+
77
+
78
+ def NewLicelPackFromZip(zip_path: str) -> LicelPack:
79
+ """Load files from a ZIP archive."""
80
+ pack = LicelPack()
81
+
82
+ with zipfile.ZipFile(zip_path, "r") as zr:
83
+ for info in zr.infolist():
84
+ fname = info.filename
85
+ if not _is_valid_filename(fname):
86
+ continue
87
+
88
+ # Read file contents into memory
89
+ file_bytes = zr.read(fname)
90
+
91
+ # Load LicelFile from bytes
92
+ licf = LoadLicelFileFromReader(io.BytesIO(file_bytes), len(file_bytes))
93
+
94
+ full_path = "/" + fname
95
+ pack.Data[full_path] = licf
96
+
97
+ if len(pack.Data) == 1:
98
+ pack.StartTime = licf.MeasurementStartTime
99
+
100
+ return pack
@@ -0,0 +1,177 @@
1
+ """
2
+ LicelProfile — measurement channel structure.
3
+
4
+ Represents a single measurement channel (profile) in a Licel file.
5
+ """
6
+
7
+ import struct
8
+ from typing import List
9
+
10
+ LICEL_MAX_RESERVED = 3
11
+
12
+
13
+ def _str2bool(s: str) -> bool:
14
+ """Convert string to boolean."""
15
+ return s.lower() in ("1", "true", "yes")
16
+
17
+
18
+ def _str2int(s: str) -> int:
19
+ """Convert string to integer."""
20
+ return int(s)
21
+
22
+
23
+ def _str2float(s: str) -> float:
24
+ """Convert string to float."""
25
+ return float(s)
26
+
27
+
28
+ def _btoi(b: bool) -> int:
29
+ """Convert boolean to integer (0/1)."""
30
+ return 1 if b else 0
31
+
32
+
33
+ class LicelProfile:
34
+ """Represents a single measurement channel in a Licel file."""
35
+
36
+ __slots__ = (
37
+ "Active",
38
+ "Photon",
39
+ "LaserType",
40
+ "NDataPoints",
41
+ "Reserved",
42
+ "HighVoltage",
43
+ "BinWidth",
44
+ "Wavelength",
45
+ "Polarization",
46
+ "BinShift",
47
+ "DecBinShift",
48
+ "AdcBits",
49
+ "NShots",
50
+ "DiscrLevel",
51
+ "DeviceID",
52
+ "NCrate",
53
+ "Data",
54
+ )
55
+
56
+ def __init__(self, line: str = None):
57
+ self.Active: bool = False
58
+ self.Photon: bool = False
59
+ self.LaserType: int = 0
60
+ self.NDataPoints: int = 0
61
+ self.Reserved: List[int] = [0, 0, 0]
62
+ self.HighVoltage: int = 0
63
+ self.BinWidth: float = 0.0
64
+ self.Wavelength: float = 0.0
65
+ self.Polarization: str = ""
66
+ self.BinShift: int = 0
67
+ self.DecBinShift: int = 0
68
+ self.AdcBits: int = 0
69
+ self.NShots: int = 0
70
+ self.DiscrLevel: float = 0.0
71
+ self.DeviceID: str = ""
72
+ self.NCrate: int = 0
73
+ self.Data: List[float] = []
74
+
75
+ if line is not None:
76
+ self._parse(line)
77
+
78
+ def _parse(self, line: str) -> None:
79
+ """Parse a string line into LicelProfile."""
80
+ items = line.split()
81
+ wvlpol = items[7].split(".", 1)
82
+
83
+ self.Active = _str2bool(items[0])
84
+ self.Photon = _str2bool(items[1])
85
+ self.LaserType = _str2int(items[2])
86
+ self.NDataPoints = _str2int(items[3])
87
+ self.Reserved = [
88
+ _str2int(items[4]),
89
+ _str2int(items[8]),
90
+ _str2int(items[9]),
91
+ ]
92
+ self.HighVoltage = _str2int(items[5])
93
+ self.BinWidth = _str2float(items[6])
94
+ self.Wavelength = _str2float(wvlpol[0])
95
+ self.Polarization = wvlpol[1] if len(wvlpol) > 1 else ""
96
+ self.BinShift = _str2int(items[10])
97
+ self.DecBinShift = _str2int(items[11])
98
+ self.AdcBits = _str2int(items[12])
99
+ self.NShots = _str2int(items[13])
100
+ self.DiscrLevel = _str2float(items[14])
101
+ self.DeviceID = items[15][:2]
102
+ self.NCrate = _str2int(items[15][2:])
103
+
104
+ def metadata(self) -> str:
105
+ """Return the metadata string for this profile."""
106
+ if self.Photon:
107
+ s = (
108
+ f" {_btoi(self.Active):1d} {_btoi(self.Photon):1d} {self.LaserType:1d} "
109
+ f"{self.NDataPoints:05d} {self.Reserved[0]:1d} {self.HighVoltage:04d} "
110
+ f"{self.BinWidth:04.2f} {int(self.Wavelength):05d}.{self.Polarization:<1s} "
111
+ f"{0:1d} {0:1d} {self.BinShift:02d} {self.DecBinShift:03d} "
112
+ f"{self.AdcBits:02d} {self.NShots:06d} {self.DiscrLevel:05.4f} "
113
+ f"{self.DeviceID:2s}{self.NCrate:01d}"
114
+ )
115
+ else:
116
+ s = (
117
+ f" {_btoi(self.Active):1d} {_btoi(self.Photon):1d} {self.LaserType:1d} "
118
+ f"{self.NDataPoints:05d} {self.Reserved[0]:1d} {self.HighVoltage:04d} "
119
+ f"{self.BinWidth:04.2f} {int(self.Wavelength):05d}.{self.Polarization:<1s} "
120
+ f"{0:1d} {0:1d} {self.BinShift:02d} {self.DecBinShift:03d} "
121
+ f"{self.AdcBits:02d} {self.NShots:06d} {self.DiscrLevel:05.3f} "
122
+ f"{self.DeviceID:2s}{self.NCrate:01d}"
123
+ )
124
+ return f"{s:<78s}\r\n"
125
+
126
+ def scale_factor(self) -> float:
127
+ """Return the scaling factor that was applied during loading.
128
+
129
+ Analog channels: scale = DiscrLevel * 1000 / (2^AdcBits * NShots)
130
+ Photon channels: scale = 1 / (NShots * 0.05)
131
+ """
132
+ if not self.Photon:
133
+ adc_scale = 1 << self.AdcBits
134
+ return self.DiscrLevel * 1000.0 / float(adc_scale * self.NShots)
135
+ else:
136
+ return 1.0 / (float(self.NShots) * 0.05)
137
+
138
+ def profile(self) -> bytes:
139
+ """Convert profile data to binary bytes (little-endian int32).
140
+
141
+ Applies unscale first: raw = round(scaled_value / scale_factor),
142
+ then packs as little-endian int32, matching the original file format.
143
+ """
144
+ inv_scale = 1.0 / self.scale_factor() if self.scale_factor() != 0.0 else 1.0
145
+ buf = b""
146
+ for num in self.Data:
147
+ buf += struct.pack("<i", round(num * inv_scale))
148
+ return buf + b"\r\n"
149
+
150
+ def __repr__(self) -> str:
151
+ return (
152
+ f"LicelProfile(Active={self.Active}, Photon={self.Photon}, "
153
+ f"Wavelength={self.Wavelength}, NDataPoints={self.NDataPoints}, "
154
+ f"NShots={self.NShots})"
155
+ )
156
+
157
+ def to_dict(self) -> dict:
158
+ """Convert profile to a dictionary (for JSON serialization)."""
159
+ return {
160
+ "is_active": self.Active,
161
+ "is_photon": self.Photon,
162
+ "laser_type": self.LaserType,
163
+ "data_points": self.NDataPoints,
164
+ "reserved": self.Reserved,
165
+ "high_voltage": self.HighVoltage,
166
+ "bin_width": self.BinWidth,
167
+ "wavelength": self.Wavelength,
168
+ "polarization": self.Polarization,
169
+ "bin_shift": self.BinShift,
170
+ "dec_bin_shift": self.DecBinShift,
171
+ "adc_bits": self.AdcBits,
172
+ "n_shots": self.NShots,
173
+ "discr_level": self.DiscrLevel,
174
+ "device_id": self.DeviceID,
175
+ "n_crate": self.NCrate,
176
+ "data": self.Data,
177
+ }