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.
- licelformat/__init__.py +29 -0
- licelformat/licelfile.py +264 -0
- licelformat/licelpack.py +100 -0
- licelformat/licelprofile.py +177 -0
- licelformat-0.1.0.dist-info/METADATA +174 -0
- licelformat-0.1.0.dist-info/RECORD +9 -0
- licelformat-0.1.0.dist-info/WHEEL +5 -0
- licelformat-0.1.0.dist-info/licenses/LICENSE +674 -0
- licelformat-0.1.0.dist-info/top_level.txt +1 -0
licelformat/__init__.py
ADDED
|
@@ -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
|
+
]
|
licelformat/licelfile.py
ADDED
|
@@ -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
|
licelformat/licelpack.py
ADDED
|
@@ -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
|
+
}
|