wavesimpro 0.9.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,936 @@
1
+ """
2
+ Binary data utilities for uploading/downloading numpy arrays
3
+
4
+ This module provides utilities to convert numpy arrays to/from binary files
5
+ for use with VoxelsModel primitives and CustomField sources.
6
+
7
+ Also provides RMON (Rayfos MONitor) format reader/writer for self-describing
8
+ monitor binary files.
9
+ """
10
+
11
+ import gzip
12
+ import struct
13
+ import numpy as np
14
+ from pathlib import Path
15
+ from typing import Tuple, Union, Optional, Dict, Any
16
+ import tempfile
17
+ import logging
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+ # RMON format constants
22
+ RMON_MAGIC = b'RMON'
23
+ RMON_VERSION = 3
24
+ RMON_FIXED_HEADER_SIZE = 64
25
+
26
+ # RMON data type codes
27
+ RMON_DATATYPE_COMPLEX64 = 0
28
+ RMON_DATATYPE_FLOAT32 = 1
29
+ RMON_DATATYPE_FLOAT64 = 2
30
+ RMON_DATATYPE_COMPLEX128 = 3
31
+
32
+ # RMON monitor type codes
33
+ RMON_MONITOR_PERMITTIVITY = 0
34
+ RMON_MONITOR_FIELD = 1
35
+ RMON_MONITOR_FLUX = 2
36
+ RMON_MONITOR_DIFFRACTION = 3
37
+
38
+ # RMON plane codes
39
+ RMON_PLANE_XY = 0
40
+ RMON_PLANE_YZ = 1
41
+ RMON_PLANE_XZ = 2
42
+ RMON_PLANE_VOLUME = 3
43
+
44
+
45
+ class VoxelDataFormat:
46
+ """Binary data format specifications"""
47
+ FLOAT32 = "Float32"
48
+ FLOAT64 = "Float64"
49
+ COMPLEX64 = "Complex64"
50
+ COMPLEX128 = "Complex128"
51
+
52
+
53
+ def upload_array_as_binary(
54
+ client,
55
+ project_id: str,
56
+ version_id: str,
57
+ data_array: np.ndarray,
58
+ filename: str,
59
+ data_type: str = "float32",
60
+ ) -> dict:
61
+ """
62
+ Upload a numpy array as a binary file
63
+
64
+ This function converts a numpy array to a binary file and uploads it
65
+ to a project version. Useful for uploading refractive index distributions
66
+ or field data for custom primitives and sources.
67
+
68
+ Args:
69
+ client: RayfosClient instance
70
+ project_id: Project UUID
71
+ version_id: Version UUID
72
+ data_array: Numpy array of any dimensions
73
+ filename: Desired filename for upload
74
+ data_type: Data type - 'float32', 'float64', 'complex64', 'complex128'
75
+
76
+ Returns:
77
+ dict: Upload result with keys:
78
+ - fileId or id or blobName: File identifier
79
+ - dataFormat: VoxelDataFormat string
80
+ - dimensions: Shape of the uploaded array
81
+ - originalDataType: The data_type parameter
82
+
83
+ Binary File Format:
84
+ Real data: Values written sequentially in C-order (row-major)
85
+ Complex data: Values written as interleaved [real1, imag1, real2, imag2, ...]
86
+
87
+ Examples:
88
+ # Upload refractive index array
89
+ import numpy as np
90
+ w = np.ones((50, 40, 200), dtype=np.float32)
91
+ file_info = upload_array_as_binary(client, proj_id, ver_id, w, 'ri_data.bin', 'float32')
92
+
93
+ # Upload complex field data
94
+ Es = np.random.rand(50, 40, 1) + 1j * np.random.rand(50, 40, 1)
95
+ file_info = upload_array_as_binary(client, proj_id, ver_id, Es, 'source_field.bin', 'complex64')
96
+ """
97
+
98
+ # Validate inputs
99
+ if not isinstance(data_array, np.ndarray):
100
+ raise TypeError(f"data_array must be a numpy array, got {type(data_array)}")
101
+
102
+ if data_array.size == 0:
103
+ raise ValueError("data_array cannot be empty")
104
+
105
+ # Store original dimensions
106
+ original_shape = data_array.shape
107
+
108
+ # Determine data format and prepare data
109
+ data_type_lower = data_type.lower()
110
+
111
+ if data_type_lower == 'float32':
112
+ format_str = VoxelDataFormat.FLOAT32
113
+ data = data_array.astype(np.float32).flatten(order='C')
114
+ dtype_write = np.float32
115
+
116
+ elif data_type_lower == 'float64':
117
+ format_str = VoxelDataFormat.FLOAT64
118
+ data = data_array.astype(np.float64).flatten(order='C')
119
+ dtype_write = np.float64
120
+
121
+ elif data_type_lower == 'complex64':
122
+ format_str = VoxelDataFormat.COMPLEX64
123
+ # Interleave real and imaginary parts
124
+ data_flat = data_array.astype(np.complex64).flatten(order='C')
125
+ data = np.empty(data_flat.size * 2, dtype=np.float32)
126
+ data[0::2] = data_flat.real
127
+ data[1::2] = data_flat.imag
128
+ dtype_write = np.float32
129
+
130
+ elif data_type_lower == 'complex128':
131
+ format_str = VoxelDataFormat.COMPLEX128
132
+ # Interleave real and imaginary parts
133
+ data_flat = data_array.astype(np.complex128).flatten(order='C')
134
+ data = np.empty(data_flat.size * 2, dtype=np.float64)
135
+ data[0::2] = data_flat.real
136
+ data[1::2] = data_flat.imag
137
+ dtype_write = np.float64
138
+
139
+ else:
140
+ raise ValueError(
141
+ f"Unknown data type: {data_type}. "
142
+ f"Valid options: 'float32', 'float64', 'complex64', 'complex128'"
143
+ )
144
+
145
+ # Create temporary file
146
+ with tempfile.NamedTemporaryFile(mode='wb', suffix='.bin', delete=False) as tmp_file:
147
+ temp_path = tmp_file.name
148
+
149
+ # Write header (20 bytes total):
150
+ # - Version (int32, 4 bytes) = 1
151
+ # - DataTypeCode (int32, 4 bytes) = format index
152
+ # - Nx (int32, 4 bytes)
153
+ # - Ny (int32, 4 bytes)
154
+ # - Nz (int32, 4 bytes)
155
+ import struct
156
+
157
+ version = 1
158
+ # Map data format to code: 0=Complex64, 1=Float32, 2=Float64, 3=Complex128
159
+ format_code_map = {
160
+ VoxelDataFormat.FLOAT32: 1,
161
+ VoxelDataFormat.FLOAT64: 2,
162
+ VoxelDataFormat.COMPLEX64: 0,
163
+ VoxelDataFormat.COMPLEX128: 3
164
+ }
165
+ data_type_code = format_code_map.get(format_str, 0)
166
+
167
+ # Pad dimensions to 3D if needed
168
+ shape_3d = list(original_shape)
169
+ while len(shape_3d) < 3:
170
+ shape_3d.append(1)
171
+ nx, ny, nz = shape_3d[0], shape_3d[1], shape_3d[2]
172
+
173
+ # Write header
174
+ header = struct.pack('<5i', version, data_type_code, nx, ny, nz)
175
+ tmp_file.write(header)
176
+
177
+ # Write data
178
+ data.tofile(tmp_file)
179
+
180
+ try:
181
+ # Upload file
182
+ file_info = client.upload_file(project_id, version_id, temp_path, 'input')
183
+
184
+ # Add metadata
185
+ file_info['dataFormat'] = format_str
186
+ file_info['dimensions'] = list(original_shape)
187
+ file_info['originalDataType'] = data_type
188
+
189
+ logger.info(
190
+ f"✓ Uploaded array as {format_str} ({filename}): {original_shape}"
191
+ )
192
+
193
+ return file_info
194
+
195
+ finally:
196
+ # Clean up temporary file
197
+ Path(temp_path).unlink(missing_ok=True)
198
+
199
+
200
+ def read_binary_field(
201
+ file_path_or_data: Union[str, Path, bytes],
202
+ dimensions: Optional[Tuple[int, ...]] = None,
203
+ data_format: str = 'Complex64'
204
+ ) -> np.ndarray:
205
+ """
206
+ Read binary field data from wavesim output (file or memory)
207
+
208
+ This function reads binary field data files produced by wavesim simulations
209
+ and reconstructs the numpy array with proper dimensions. Supports reading from
210
+ both file paths and in-memory bytes data.
211
+
212
+ Args:
213
+ file_path_or_data: Path to binary file or bytes data
214
+ dimensions: Array dimensions (nx, ny, nz) or (nx, ny, nz, n_components)
215
+ data_format: Data format - 'Float32', 'Float64', 'Complex64', 'Complex128'
216
+
217
+ Returns:
218
+ np.ndarray: Field data with specified dimensions
219
+
220
+ Binary File Format:
221
+ Version 1 Header (20 bytes):
222
+ - Version (int32): 1
223
+ - DataTypeCode (int32): 0=Complex64, 1=Float32, 2=Float64, 3=Complex128
224
+ - Nx, Ny, Nz (3x int32)
225
+ Version 2 Header (24 bytes):
226
+ - Version (int32): 2
227
+ - DataTypeCode (int32): 0=Complex64, 1=Float32, 2=Float64, 3=Complex128
228
+ - nPol (int32): Number of polarization components (1=scalar, 3=vectorial)
229
+ - Nx, Ny, Nz (3x int32)
230
+ Data:
231
+ - Real data: Values stored sequentially
232
+ - Complex data: Interleaved [real1, imag1, real2, imag2, ...]
233
+
234
+ Examples:
235
+ # Read from file
236
+ E = read_binary_field('output.bin', (50, 40, 200), 'Complex64')
237
+
238
+ # Read from memory
239
+ data_bytes = client.download_file_data(project_id, version_id, file_id)
240
+ E = read_binary_field(data_bytes, (50, 40, 200), 'Complex64')
241
+ """
242
+
243
+ # Handle input: file path or bytes
244
+ if isinstance(file_path_or_data, bytes):
245
+ # Read from memory
246
+ file_data = file_path_or_data
247
+ file_size = len(file_data)
248
+ else:
249
+ # Read from file
250
+ file_path = Path(file_path_or_data)
251
+ if not file_path.exists():
252
+ raise FileNotFoundError(f"File not found: {file_path}")
253
+ file_size = file_path.stat().st_size
254
+ with open(file_path, 'rb') as f:
255
+ file_data = f.read()
256
+
257
+ # Validate inputs
258
+ if not dimensions:
259
+ raise ValueError("dimensions must be a non-empty tuple")
260
+
261
+ # Try to read header
262
+ # Version 1: 20 bytes (5 int32s: version, dataType, nx, ny, nz)
263
+ # Version 2: 24 bytes (6 int32s: version, dataType, nPol, nx, ny, nz)
264
+ has_header = file_size >= 20
265
+ version = 2 # Default to version 2 (solver output)
266
+ n_pol_from_header = 1
267
+ header_dims = None
268
+
269
+ if has_header:
270
+ try:
271
+ # Read first int32 to check version
272
+ ver_check = struct.unpack('<i', file_data[:4])[0]
273
+
274
+ if ver_check == 1:
275
+ # Version 1: 20-byte header [version, dataType, nx, ny, nz]
276
+ header = struct.unpack('<5i', file_data[:20])
277
+ version, data_type_code, nx_file, ny_file, nz_file = header
278
+ n_pol_from_header = 1
279
+ header_dims = (nx_file, ny_file, nz_file)
280
+ logger.info(f"Found binary header (version 1): dims=[{nx_file}, {ny_file}, {nz_file}]")
281
+ file_data = file_data[20:]
282
+ file_size -= 20
283
+ elif ver_check == 2:
284
+ # Version 2: 24-byte header [version, dataType, nPol, nx, ny, nz]
285
+ header = struct.unpack('<6i', file_data[:24])
286
+ version, data_type_code, n_pol_from_header, nx_file, ny_file, nz_file = header
287
+ if n_pol_from_header > 1:
288
+ header_dims = (n_pol_from_header, nx_file, ny_file, nz_file)
289
+ else:
290
+ header_dims = (nx_file, ny_file, nz_file)
291
+ logger.info(f"Found binary header (version 2): nPol={n_pol_from_header}, dims=[{nx_file}, {ny_file}, {nz_file}]")
292
+ file_data = file_data[24:]
293
+ file_size -= 24
294
+ else:
295
+ # Not a valid header, treat as raw data (version 2)
296
+ logger.info("No valid header found, assuming version 2 (raw data)")
297
+ version = 2
298
+ except struct.error:
299
+ # Failed to unpack header, treat as raw data
300
+ logger.info("Failed to read header, assuming version 2 (raw data)")
301
+ version = 2
302
+ else:
303
+ logger.info("File too small for header, assuming version 2 (raw data)")
304
+ version = 2
305
+
306
+ # Use header dimensions if caller didn't provide them
307
+ if dimensions is None or len(dimensions) == 0:
308
+ if header_dims is not None:
309
+ dimensions = header_dims
310
+ logger.info(f"Auto-detected dimensions from header: {dimensions}")
311
+ else:
312
+ raise ValueError("No dimensions provided and no valid header found to auto-detect")
313
+
314
+ # Determine precision and whether data is complex
315
+ if data_format == 'Float32':
316
+ dtype_read = np.float32
317
+ is_complex = False
318
+ bytes_per_value = 4
319
+
320
+ elif data_format == 'Float64':
321
+ dtype_read = np.float64
322
+ is_complex = False
323
+ bytes_per_value = 8
324
+
325
+ elif data_format == 'Complex64':
326
+ dtype_read = np.float32
327
+ is_complex = True
328
+ bytes_per_value = 8 # 4 bytes real + 4 bytes imag
329
+
330
+ elif data_format == 'Complex128':
331
+ dtype_read = np.float64
332
+ is_complex = True
333
+ bytes_per_value = 16 # 8 bytes real + 8 bytes imag
334
+
335
+ else:
336
+ raise ValueError(
337
+ f"Unknown data format: {data_format}. "
338
+ f"Valid options: 'Float32', 'Float64', 'Complex64', 'Complex128'"
339
+ )
340
+
341
+ # Calculate expected number of elements
342
+ total_elements = np.prod(dimensions)
343
+ expected_size = total_elements * bytes_per_value
344
+
345
+ if file_size != expected_size:
346
+ logger.warning(
347
+ f"File size mismatch!\n"
348
+ f" Expected: {expected_size} bytes ({total_elements} elements × {bytes_per_value} bytes/element)\n"
349
+ f" Actual: {file_size} bytes\n"
350
+ f" Attempting to read anyway..."
351
+ )
352
+
353
+ # Read data
354
+ if is_complex:
355
+ # Read interleaved complex data
356
+ data = np.frombuffer(file_data, dtype=dtype_read)
357
+
358
+ if data.size < total_elements * 2:
359
+ logger.warning(
360
+ f"Read fewer elements than expected: {data.size} / {total_elements * 2}"
361
+ )
362
+
363
+ # De-interleave: [r1, i1, r2, i2, ...] -> [r1, r2, ...] + 1j*[i1, i2, ...]
364
+ real_part = data[0::2]
365
+ imag_part = data[1::2]
366
+ field_data = real_part + 1j * imag_part
367
+
368
+ else:
369
+ # Read real data
370
+ field_data = np.frombuffer(file_data, dtype=dtype_read)
371
+
372
+ if field_data.size < total_elements:
373
+ logger.warning(
374
+ f"Read fewer elements than expected: {field_data.size} / {total_elements}"
375
+ )
376
+
377
+ # Handle dimension permutation based on version
378
+ if version == 1:
379
+ # Version 1: Data was permuted before writing, need to reverse
380
+ reversed_dims = dimensions[::-1]
381
+ field_data = field_data.reshape(reversed_dims, order='C')
382
+ # Reverse permutation
383
+ perm_order = tuple(range(len(dimensions) - 1, -1, -1))
384
+ field_data = np.transpose(field_data, perm_order)
385
+ else:
386
+ # Version 2: Data is already in correct order
387
+ field_data = field_data.reshape(dimensions, order='C')
388
+
389
+ logger.info(
390
+ f"✓ Read {data_format} field data (version {version}): {field_data.shape} ({file_size} bytes)"
391
+ )
392
+
393
+ return field_data
394
+
395
+
396
+ def write_array_to_binary(
397
+ data_array: np.ndarray,
398
+ file_path: Union[str, Path],
399
+ data_type: str = "float32"
400
+ ) -> Path:
401
+ """
402
+ Write a numpy array to a binary file
403
+
404
+ Helper function to create binary files locally before uploading.
405
+
406
+ Args:
407
+ data_array: Numpy array to write
408
+ file_path: Path where to save the binary file
409
+ data_type: Data type - 'float32', 'float64', 'complex64', 'complex128'
410
+
411
+ Returns:
412
+ Path: Path to the created file
413
+
414
+ Example:
415
+ import numpy as np
416
+ w = np.ones((50, 40, 200), dtype=np.float32)
417
+ file_path = write_array_to_binary(w, 'ri_data.bin', 'float32')
418
+ """
419
+
420
+ file_path = Path(file_path)
421
+
422
+ # Determine data format and prepare data
423
+ data_type_lower = data_type.lower()
424
+
425
+ if data_type_lower == 'float32':
426
+ data = data_array.astype(np.float32).flatten(order='C')
427
+
428
+ elif data_type_lower == 'float64':
429
+ data = data_array.astype(np.float64).flatten(order='C')
430
+
431
+ elif data_type_lower == 'complex64':
432
+ # Interleave real and imaginary parts
433
+ data_flat = data_array.astype(np.complex64).flatten(order='C')
434
+ data = np.empty(data_flat.size * 2, dtype=np.float32)
435
+ data[0::2] = data_flat.real
436
+ data[1::2] = data_flat.imag
437
+
438
+ elif data_type_lower == 'complex128':
439
+ # Interleave real and imaginary parts
440
+ data_flat = data_array.astype(np.complex128).flatten(order='C')
441
+ data = np.empty(data_flat.size * 2, dtype=np.float64)
442
+ data[0::2] = data_flat.real
443
+ data[1::2] = data_flat.imag
444
+
445
+ else:
446
+ raise ValueError(
447
+ f"Unknown data type: {data_type}. "
448
+ f"Valid options: 'float32', 'float64', 'complex64', 'complex128'"
449
+ )
450
+
451
+ # Write to file
452
+ data.tofile(file_path)
453
+
454
+ logger.info(f"✓ Wrote {data_array.shape} array to {file_path} ({file_path.stat().st_size} bytes)")
455
+
456
+ return file_path
457
+
458
+
459
+ def write_monitor_binary(
460
+ file_path: Union[str, Path],
461
+ data: np.ndarray,
462
+ name: str = "",
463
+ monitor_type: int = RMON_MONITOR_FIELD,
464
+ plane: int = RMON_PLANE_XY,
465
+ field_mask: int = 0,
466
+ voxel_size_um: float = 0.0,
467
+ origin_um: Tuple[float, float, float] = (0.0, 0.0, 0.0),
468
+ wavelength_um: float = 0.0,
469
+ ) -> Path:
470
+ """
471
+ Write a numpy array as an RMON (Rayfos MONitor) self-describing binary file.
472
+
473
+ The RMON format contains a full metadata header (monitor type, plane, voxel size,
474
+ world-space origin, wavelength, etc.) so the file can be interpreted without
475
+ external context.
476
+
477
+ Args:
478
+ file_path: Output file path
479
+ data: Numpy array of shape (nx, ny, nz) or (nComponents, nx, ny, nz).
480
+ Complex arrays are stored as interleaved real/imag pairs.
481
+ name: Monitor name (stored in header)
482
+ monitor_type: RMON_MONITOR_* constant (0=Permittivity, 1=Field, 2=Flux, 3=Diffraction)
483
+ plane: RMON_PLANE_* constant (0=XY, 1=YZ, 2=XZ, 3=Volume)
484
+ field_mask: Bitmask of recorded field components (bit0=Ex, bit1=Ey, etc.)
485
+ voxel_size_um: Voxel size in micrometers
486
+ origin_um: World-space origin (x, y, z) in micrometers
487
+ wavelength_um: Simulation wavelength in micrometers
488
+
489
+ Returns:
490
+ Path to the created file
491
+
492
+ Example:
493
+ import numpy as np
494
+ E = np.random.rand(50, 40, 1) + 1j * np.random.rand(50, 40, 1)
495
+ write_monitor_binary('monitor_Field_0.bin', E, name='Field_0',
496
+ voxel_size_um=0.05, origin_um=(-1.25, -1.0, 0.0))
497
+ """
498
+ file_path = Path(file_path)
499
+
500
+ # Determine shape and components
501
+ if data.ndim == 4:
502
+ n_components, nx, ny, nz = data.shape
503
+ elif data.ndim == 3:
504
+ n_components = 1
505
+ nx, ny, nz = data.shape
506
+ data = data[np.newaxis, ...] # Add component dimension
507
+ else:
508
+ raise ValueError(f"data must be 3D or 4D, got {data.ndim}D")
509
+
510
+ # Determine data type
511
+ if np.iscomplexobj(data):
512
+ data_type_code = RMON_DATATYPE_COMPLEX64
513
+ write_dtype = np.float32
514
+ else:
515
+ data_type_code = RMON_DATATYPE_FLOAT32
516
+ write_dtype = np.float32
517
+
518
+ # Encode name
519
+ name_bytes = name.encode('utf-8')
520
+ name_length = len(name_bytes)
521
+
522
+ # Calculate total header size (aligned to 4 bytes)
523
+ variable_size = 4 + name_length # NameLength + Name
524
+ total_header = RMON_FIXED_HEADER_SIZE + variable_size
525
+ total_header = (total_header + 3) & ~3 # Align to 4 bytes
526
+ padding = total_header - (RMON_FIXED_HEADER_SIZE + variable_size)
527
+
528
+ with open(file_path, 'wb') as f:
529
+ # Fixed header (64 bytes)
530
+ f.write(RMON_MAGIC) # 0-3: Magic
531
+ f.write(struct.pack('<i', RMON_VERSION)) # 4-7: Version
532
+ f.write(struct.pack('<i', total_header)) # 8-11: HeaderSize
533
+ f.write(struct.pack('<i', data_type_code)) # 12-15: DataType
534
+ f.write(struct.pack('<i', monitor_type)) # 16-19: MonitorType
535
+ f.write(struct.pack('<i', plane)) # 20-23: Plane
536
+ f.write(struct.pack('<i', field_mask)) # 24-27: FieldMask
537
+ f.write(struct.pack('<i', n_components)) # 28-31: nComponents
538
+ f.write(struct.pack('<i', nx)) # 32-35: ShapeX
539
+ f.write(struct.pack('<i', ny)) # 36-39: ShapeY
540
+ f.write(struct.pack('<i', nz)) # 40-43: ShapeZ
541
+ f.write(struct.pack('<f', voxel_size_um)) # 44-47: VoxelSize_um
542
+ f.write(struct.pack('<f', origin_um[0])) # 48-51: OriginX_um
543
+ f.write(struct.pack('<f', origin_um[1])) # 52-55: OriginY_um
544
+ f.write(struct.pack('<f', origin_um[2])) # 56-59: OriginZ_um
545
+ f.write(struct.pack('<f', wavelength_um)) # 60-63: Wavelength_um
546
+
547
+ # Variable header
548
+ f.write(struct.pack('<i', name_length)) # 64-67: NameLength
549
+ f.write(name_bytes) # 68+: Name
550
+ f.write(b'\x00' * padding) # Padding
551
+
552
+ # Data section: component-major, then X -> Y -> Z
553
+ for comp in range(n_components):
554
+ comp_data = data[comp]
555
+ if np.iscomplexobj(comp_data):
556
+ # Interleave real/imag
557
+ flat = comp_data.astype(np.complex64).flatten(order='C')
558
+ interleaved = np.empty(flat.size * 2, dtype=np.float32)
559
+ interleaved[0::2] = flat.real
560
+ interleaved[1::2] = flat.imag
561
+ interleaved.tofile(f)
562
+ else:
563
+ comp_data.astype(write_dtype).flatten(order='C').tofile(f)
564
+
565
+ file_size = file_path.stat().st_size
566
+ logger.info(
567
+ f"✓ Wrote RMON file: {file_path} ({nx}×{ny}×{nz}, {n_components} comp, {file_size} bytes)"
568
+ )
569
+
570
+ return file_path
571
+
572
+
573
+ def read_monitor_binary(
574
+ file_path_or_data: Union[str, Path, bytes],
575
+ ) -> Tuple[Dict[str, Any], np.ndarray]:
576
+ """
577
+ Read an RMON (Rayfos MONitor) binary file.
578
+
579
+ Auto-detects RMON format by checking for "RMON" magic bytes. If the data
580
+ is not RMON format, raises ValueError.
581
+
582
+ Args:
583
+ file_path_or_data: Path to binary file or bytes data
584
+
585
+ Returns:
586
+ Tuple of (header_dict, data_array):
587
+ - header_dict: Dictionary with all RMON header fields
588
+ - data_array: Numpy array of shape (nComponents, nx, ny, nz).
589
+ Complex data is returned as complex64 dtype.
590
+
591
+ Example:
592
+ header, data = read_monitor_binary('monitor_Field_0.bin')
593
+ print(f"Monitor: {header['name']}, Shape: {data.shape}")
594
+ magnitude = np.abs(data[0]) # First component magnitude
595
+ """
596
+ # Read data
597
+ if isinstance(file_path_or_data, bytes):
598
+ file_data = file_path_or_data
599
+ else:
600
+ file_path = Path(file_path_or_data)
601
+ if not file_path.exists():
602
+ raise FileNotFoundError(f"File not found: {file_path}")
603
+ with open(file_path, 'rb') as f:
604
+ file_data = f.read()
605
+
606
+ # Check magic bytes
607
+ if len(file_data) < RMON_FIXED_HEADER_SIZE:
608
+ raise ValueError("Data too small for RMON header")
609
+
610
+ if file_data[:4] != RMON_MAGIC:
611
+ raise ValueError(
612
+ f"Not an RMON file: magic bytes are {file_data[:4]!r}, expected {RMON_MAGIC!r}"
613
+ )
614
+
615
+ # Parse fixed header
616
+ (version,) = struct.unpack_from('<i', file_data, 4)
617
+ if version != 3:
618
+ raise ValueError(f"Unsupported RMON version: {version}. Expected 3.")
619
+
620
+ (header_size,) = struct.unpack_from('<i', file_data, 8)
621
+ (data_type_code,) = struct.unpack_from('<i', file_data, 12)
622
+ (monitor_type,) = struct.unpack_from('<i', file_data, 16)
623
+ (plane,) = struct.unpack_from('<i', file_data, 20)
624
+ (field_mask,) = struct.unpack_from('<i', file_data, 24)
625
+ (n_components,) = struct.unpack_from('<i', file_data, 28)
626
+ (nx,) = struct.unpack_from('<i', file_data, 32)
627
+ (ny,) = struct.unpack_from('<i', file_data, 36)
628
+ (nz,) = struct.unpack_from('<i', file_data, 40)
629
+ (voxel_size_um,) = struct.unpack_from('<f', file_data, 44)
630
+ (origin_x_um,) = struct.unpack_from('<f', file_data, 48)
631
+ (origin_y_um,) = struct.unpack_from('<f', file_data, 52)
632
+ (origin_z_um,) = struct.unpack_from('<f', file_data, 56)
633
+ (wavelength_um,) = struct.unpack_from('<f', file_data, 60)
634
+
635
+ # Parse variable header (name)
636
+ name = ""
637
+ if header_size > 68:
638
+ (name_length,) = struct.unpack_from('<i', file_data, 64)
639
+ if 0 < name_length < 1024:
640
+ name = file_data[68:68 + name_length].decode('utf-8', errors='replace')
641
+
642
+ monitor_type_names = {0: 'Permittivity', 1: 'Field', 2: 'Flux', 3: 'Diffraction'}
643
+ plane_names = {0: 'XY', 1: 'YZ', 2: 'XZ', 3: 'Volume'}
644
+
645
+ header = {
646
+ 'version': version,
647
+ 'header_size': header_size,
648
+ 'data_type': data_type_code,
649
+ 'monitor_type': monitor_type,
650
+ 'monitor_type_name': monitor_type_names.get(monitor_type, f'unknown({monitor_type})'),
651
+ 'plane': plane,
652
+ 'plane_name': plane_names.get(plane, f'unknown({plane})'),
653
+ 'field_mask': field_mask,
654
+ 'n_components': n_components,
655
+ 'shape': (nx, ny, nz),
656
+ 'voxel_size_um': voxel_size_um,
657
+ 'origin_um': (origin_x_um, origin_y_um, origin_z_um),
658
+ 'wavelength_um': wavelength_um,
659
+ 'name': name,
660
+ }
661
+
662
+ # Parse data section
663
+ raw_data = file_data[header_size:]
664
+ is_complex = data_type_code in (RMON_DATATYPE_COMPLEX64, RMON_DATATYPE_COMPLEX128)
665
+
666
+ total_voxels = n_components * nx * ny * nz
667
+
668
+ if is_complex:
669
+ if data_type_code == RMON_DATATYPE_COMPLEX64:
670
+ floats = np.frombuffer(raw_data, dtype=np.float32)
671
+ else:
672
+ floats = np.frombuffer(raw_data, dtype=np.float64)
673
+
674
+ real_part = floats[0::2]
675
+ imag_part = floats[1::2]
676
+ flat_complex = real_part + 1j * imag_part
677
+ data = flat_complex[:total_voxels].reshape((n_components, nx, ny, nz), order='C')
678
+ if data_type_code == RMON_DATATYPE_COMPLEX64:
679
+ data = data.astype(np.complex64)
680
+ else:
681
+ if data_type_code == RMON_DATATYPE_FLOAT32:
682
+ flat = np.frombuffer(raw_data, dtype=np.float32)
683
+ else:
684
+ flat = np.frombuffer(raw_data, dtype=np.float64)
685
+ data = flat[:total_voxels].reshape((n_components, nx, ny, nz), order='C')
686
+
687
+ logger.info(
688
+ f"✓ Read RMON file: name='{name}', type={header['monitor_type_name']}, "
689
+ f"shape=({nx},{ny},{nz}), {n_components} comp"
690
+ )
691
+
692
+ return header, data
693
+
694
+
695
+ def is_rmon_file(file_path_or_data: Union[str, Path, bytes]) -> bool:
696
+ """
697
+ Check if a file or bytes data is in RMON format by checking magic bytes.
698
+
699
+ Args:
700
+ file_path_or_data: Path to file or bytes data
701
+
702
+ Returns:
703
+ True if the data starts with RMON magic bytes
704
+ """
705
+ if isinstance(file_path_or_data, bytes):
706
+ return len(file_path_or_data) >= 4 and file_path_or_data[:4] == RMON_MAGIC
707
+
708
+ file_path = Path(file_path_or_data)
709
+ if not file_path.exists() or file_path.stat().st_size < 4:
710
+ return False
711
+
712
+ with open(file_path, 'rb') as f:
713
+ magic = f.read(4)
714
+ return magic == RMON_MAGIC
715
+
716
+
717
+ def _prepare_binary_data(
718
+ data_array: np.ndarray,
719
+ data_type: str,
720
+ header_version: int,
721
+ ) -> Tuple[bytes, str, list]:
722
+ """
723
+ Prepare binary data with header for upload.
724
+
725
+ Args:
726
+ data_array: Numpy array
727
+ data_type: 'float32', 'float64', 'complex64', 'complex128'
728
+ header_version: 1 for source fields, 2 for RI/permittivity
729
+
730
+ Returns:
731
+ Tuple of (binary_data_bytes, format_string, shape_3d_list)
732
+ """
733
+ if not isinstance(data_array, np.ndarray):
734
+ raise TypeError(f"data_array must be a numpy array, got {type(data_array)}")
735
+ if data_array.size == 0:
736
+ raise ValueError("data_array cannot be empty")
737
+
738
+ original_shape = data_array.shape
739
+ shape_3d = list(original_shape)
740
+ while len(shape_3d) < 3:
741
+ shape_3d.append(1)
742
+ nx, ny, nz = shape_3d[0], shape_3d[1], shape_3d[2]
743
+
744
+ data_type_lower = data_type.lower()
745
+
746
+ format_code_map = {
747
+ 'float32': (VoxelDataFormat.FLOAT32, 1),
748
+ 'float64': (VoxelDataFormat.FLOAT64, 2),
749
+ 'complex64': (VoxelDataFormat.COMPLEX64, 0),
750
+ 'complex128': (VoxelDataFormat.COMPLEX128, 3),
751
+ }
752
+
753
+ if data_type_lower not in format_code_map:
754
+ raise ValueError(
755
+ f"Unknown data type: {data_type}. "
756
+ f"Valid options: 'float32', 'float64', 'complex64', 'complex128'"
757
+ )
758
+
759
+ format_str, data_type_code = format_code_map[data_type_lower]
760
+ is_complex = data_type_lower in ('complex64', 'complex128')
761
+
762
+ # Permute and flatten based on header version
763
+ if header_version == 2:
764
+ # Version 2 (RI/permittivity): XYZ order, Z-fastest
765
+ # C-order flatten of (nx, ny, nz) gives Z-fastest directly — no transpose needed.
766
+ # (The old transpose + flatten('C') produced X-fastest, which was wrong.)
767
+ flat = data_array.reshape(nx, ny, nz).flatten(order='C')
768
+ else:
769
+ # Version 1 (source fields): C-order flatten (z-slowest, x-fastest)
770
+ reshaped = data_array.reshape(nx, ny, nz)
771
+ flat = np.empty(nx * ny * nz, dtype=data_array.dtype)
772
+ idx = 0
773
+ for iz in range(nz):
774
+ for iy in range(ny):
775
+ for ix in range(nx):
776
+ flat[idx] = reshaped[ix, iy, iz]
777
+ idx += 1
778
+
779
+ # Convert to interleaved real/imag for complex types
780
+ if is_complex:
781
+ if data_type_lower == 'complex64':
782
+ flat_c = flat.astype(np.complex64)
783
+ write_dtype = np.float32
784
+ else:
785
+ flat_c = flat.astype(np.complex128)
786
+ write_dtype = np.float64
787
+ interleaved = np.empty(flat_c.size * 2, dtype=write_dtype)
788
+ interleaved[0::2] = flat_c.real
789
+ interleaved[1::2] = flat_c.imag
790
+ data_bytes_payload = interleaved.tobytes()
791
+ else:
792
+ if data_type_lower == 'float32':
793
+ data_bytes_payload = flat.astype(np.float32).tobytes()
794
+ else:
795
+ data_bytes_payload = flat.astype(np.float64).tobytes()
796
+
797
+ # Build header
798
+ header = struct.pack('<5i', header_version, data_type_code, nx, ny, nz)
799
+
800
+ return header + data_bytes_payload, format_str, [nx, ny, nz]
801
+
802
+
803
+ def upload_source_field_as_binary(
804
+ client,
805
+ project_id: str,
806
+ version_id: str,
807
+ data_array: np.ndarray,
808
+ filename: str,
809
+ data_type: str = "complex64",
810
+ ) -> dict:
811
+ """
812
+ Upload a source field array as a binary file with Version 1 header.
813
+
814
+ Source fields use Version 1 header format with C-order data layout
815
+ (z-slowest, x-fastest), matching MATLAB's uploadSourceFieldAsBinary.
816
+
817
+ Args:
818
+ client: RayfosClient instance
819
+ project_id: Project UUID
820
+ version_id: Version UUID
821
+ data_array: Numpy array (typically complex)
822
+ filename: Desired filename for upload
823
+ data_type: 'float32', 'float64', 'complex64', 'complex128'
824
+
825
+ Returns:
826
+ dict: Upload result with fileId/id/blobName, dataFormat, dimensions
827
+ """
828
+ binary_data, format_str, dims = _prepare_binary_data(data_array, data_type, header_version=1)
829
+
830
+ with tempfile.NamedTemporaryFile(mode='wb', suffix='.bin', delete=False) as tmp:
831
+ temp_path = tmp.name
832
+ tmp.write(binary_data)
833
+
834
+ try:
835
+ file_info = client.upload_file(project_id, version_id, temp_path, 'input')
836
+ file_info['dataFormat'] = format_str
837
+ file_info['dimensions'] = dims
838
+ file_info['originalDataType'] = data_type
839
+ logger.info(f"Uploaded source field as {format_str} ({filename}): {dims}")
840
+ return file_info
841
+ finally:
842
+ Path(temp_path).unlink(missing_ok=True)
843
+
844
+
845
+ def upload_array_as_binary_zipped(
846
+ client,
847
+ project_id: str,
848
+ version_id: str,
849
+ data_array: np.ndarray,
850
+ filename: str,
851
+ data_type: str = "float32",
852
+ ) -> dict:
853
+ """
854
+ Upload a numpy array as a gzip-compressed binary file with Version 2 header.
855
+
856
+ Used for refractive index / permittivity data. Version 2 header with
857
+ permuted data order (z-fastest after permutation), matching MATLAB's
858
+ uploadArrayAsBinaryZipped.
859
+
860
+ Args:
861
+ client: RayfosClient instance
862
+ project_id: Project UUID
863
+ version_id: Version UUID
864
+ data_array: Numpy array
865
+ filename: Desired filename (without .gz)
866
+ data_type: 'float32', 'float64', 'complex64', 'complex128'
867
+
868
+ Returns:
869
+ dict: Upload result with fileId, dataFormat, dimensions, isCompressed
870
+ """
871
+ binary_data, format_str, dims = _prepare_binary_data(data_array, data_type, header_version=2)
872
+
873
+ gz_filename = filename + '.gz'
874
+ with tempfile.NamedTemporaryFile(mode='wb', suffix='.bin.gz', delete=False) as tmp:
875
+ temp_path = tmp.name
876
+ tmp.write(gzip.compress(binary_data))
877
+
878
+ try:
879
+ file_info = client.upload_file(project_id, version_id, temp_path, 'input')
880
+ file_info['dataFormat'] = format_str
881
+ file_info['dimensions'] = dims
882
+ file_info['originalDataType'] = data_type
883
+ file_info['isCompressed'] = True
884
+ file_info['compressionType'] = 'gzip'
885
+ file_info['uncompressedSize'] = len(binary_data)
886
+ logger.info(f"Uploaded compressed array as {format_str} ({gz_filename}): {dims}")
887
+ return file_info
888
+ finally:
889
+ Path(temp_path).unlink(missing_ok=True)
890
+
891
+
892
+ def upload_source_field_as_binary_zipped(
893
+ client,
894
+ project_id: str,
895
+ version_id: str,
896
+ data_array: np.ndarray,
897
+ filename: str,
898
+ data_type: str = "complex64",
899
+ ) -> dict:
900
+ """
901
+ Upload a source field as a gzip-compressed binary file with Version 1 header.
902
+
903
+ Combines the source field binary format (Version 1 header, C-order data)
904
+ with gzip compression, matching MATLAB's uploadSourceFieldAsBinaryZipped.
905
+
906
+ Args:
907
+ client: RayfosClient instance
908
+ project_id: Project UUID
909
+ version_id: Version UUID
910
+ data_array: Numpy array (typically complex)
911
+ filename: Desired filename (without .gz)
912
+ data_type: 'float32', 'float64', 'complex64', 'complex128'
913
+
914
+ Returns:
915
+ dict: Upload result with fileId, dataFormat, dimensions, isCompressed
916
+ """
917
+ binary_data, format_str, dims = _prepare_binary_data(data_array, data_type, header_version=1)
918
+
919
+ gz_filename = filename + '.gz'
920
+ with tempfile.NamedTemporaryFile(mode='wb', suffix='.bin.gz', delete=False) as tmp:
921
+ temp_path = tmp.name
922
+ tmp.write(gzip.compress(binary_data))
923
+
924
+ try:
925
+ file_info = client.upload_file(project_id, version_id, temp_path, 'input')
926
+ file_info['dataFormat'] = format_str
927
+ file_info['dimensions'] = dims
928
+ file_info['originalDataType'] = data_type
929
+ file_info['isCompressed'] = True
930
+ file_info['compressionType'] = 'gzip'
931
+ file_info['uncompressedSize'] = len(binary_data)
932
+ logger.info(f"Uploaded compressed source field as {format_str} ({gz_filename}): {dims}")
933
+ return file_info
934
+ finally:
935
+ Path(temp_path).unlink(missing_ok=True)
936
+