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.
wavesimpro/config.py ADDED
@@ -0,0 +1,123 @@
1
+ """
2
+ Configuration module for WavesimPythonAPI
3
+ """
4
+
5
+ import os
6
+ from typing import Optional
7
+ from dataclasses import dataclass
8
+
9
+
10
+ @dataclass
11
+ class ClientConfig:
12
+ """
13
+ Configuration for the Rayfos API client
14
+
15
+ Attributes:
16
+ api_token: User API token for authentication
17
+ server_url: Base URL of RayfosJobServer for project management (default: server.rayfos.com)
18
+ machine_service_url: Optional URL of RayfosMachineService for file uploads
19
+ timeout: Request timeout in seconds
20
+ verify_ssl: Whether to verify SSL certificates for server_url
21
+ verify_ssl_machine_service: Whether to verify SSL certificates for machine_service_url
22
+ """
23
+
24
+ api_token: str
25
+ server_url: str = "https://server.rayfos.com"
26
+ machine_service_url: Optional[str] = None
27
+ timeout: int = 30
28
+ verify_ssl: bool = True
29
+ verify_ssl_machine_service: bool = True
30
+
31
+ def __post_init__(self):
32
+ """Validate configuration after initialization"""
33
+ # Remove trailing slashes from URLs
34
+ if self.server_url:
35
+ self.server_url = self.server_url.rstrip('/')
36
+ if self.machine_service_url:
37
+ self.machine_service_url = self.machine_service_url.rstrip('/')
38
+
39
+ # Auto-detect SSL verification for localhost machine service
40
+ if not hasattr(self, '_verify_ssl_ms_set'):
41
+ if "localhost" in self.machine_service_url or "127.0.0.1" in self.machine_service_url:
42
+ self.verify_ssl_machine_service = False
43
+
44
+ @classmethod
45
+ def from_env(cls) -> "ClientConfig":
46
+ """
47
+ Create configuration from environment variables
48
+
49
+ Environment variables:
50
+ RAYFOS_API_TOKEN: API token (required)
51
+ RAYFOS_SERVER_URL: Project server URL (optional, defaults to server.rayfos.com)
52
+ RAYFOS_MACHINE_SERVICE_URL: Machine service URL for file uploads (optional)
53
+
54
+ Returns:
55
+ ClientConfig instance
56
+
57
+ Raises:
58
+ ValueError: If required environment variables are missing
59
+ """
60
+ api_token = os.getenv("RAYFOS_API_TOKEN")
61
+ if not api_token:
62
+ raise ValueError("RAYFOS_API_TOKEN environment variable is required")
63
+
64
+ # Get server URL (defaults to cloud server)
65
+ server_url = os.getenv("RAYFOS_SERVER_URL", "https://server.rayfos.com")
66
+
67
+ # Get optional machine service URL for file uploads
68
+ machine_service_url = os.getenv("RAYFOS_MACHINE_SERVICE_URL")
69
+
70
+ # SSL verification for main server (always True for production servers)
71
+ verify_ssl = True
72
+ if "localhost" in server_url or "127.0.0.1" in server_url:
73
+ verify_ssl = False
74
+
75
+ # SSL verification for machine service (auto-detect localhost)
76
+ verify_ssl_machine_service = True
77
+ if machine_service_url and ("localhost" in machine_service_url or "127.0.0.1" in machine_service_url):
78
+ verify_ssl_machine_service = False
79
+
80
+ return cls(
81
+ api_token=api_token,
82
+ server_url=server_url,
83
+ machine_service_url=machine_service_url,
84
+ timeout=30,
85
+ verify_ssl=verify_ssl,
86
+ verify_ssl_machine_service=verify_ssl_machine_service,
87
+ )
88
+
89
+ def get_api_url(self, endpoint: str) -> str:
90
+ """
91
+ Get the full URL for an API endpoint
92
+
93
+ Args:
94
+ endpoint: API endpoint path (e.g., '/api/projects')
95
+
96
+ Returns:
97
+ Full URL
98
+ """
99
+ if not endpoint.startswith('/'):
100
+ endpoint = f'/{endpoint}'
101
+ return f"{self.server_url}{endpoint}"
102
+
103
+ def get_machine_service_url(self, endpoint: str) -> str:
104
+ """
105
+ Get the full URL for a machine service endpoint
106
+
107
+ Args:
108
+ endpoint: Machine service endpoint path
109
+
110
+ Returns:
111
+ Full URL
112
+
113
+ Raises:
114
+ ValueError: If machine_service_url is not configured
115
+ """
116
+ if not self.machine_service_url:
117
+ raise ValueError("Machine service URL is not configured")
118
+
119
+ if not endpoint.startswith('/'):
120
+ endpoint = f'/{endpoint}'
121
+ return f"{self.machine_service_url}{endpoint}"
122
+
123
+
@@ -0,0 +1,40 @@
1
+ """
2
+ Exception classes for WavesimPythonAPI
3
+ """
4
+
5
+
6
+ class RayfosAPIError(Exception):
7
+ """Base exception for all Rayfos API errors"""
8
+
9
+ def __init__(self, message: str, status_code: int = None, response=None):
10
+ self.message = message
11
+ self.status_code = status_code
12
+ self.response = response
13
+ super().__init__(self.message)
14
+
15
+
16
+ class AuthenticationError(RayfosAPIError):
17
+ """Raised when authentication fails"""
18
+ pass
19
+
20
+
21
+ class NotFoundError(RayfosAPIError):
22
+ """Raised when a resource is not found (404)"""
23
+ pass
24
+
25
+
26
+ class ValidationError(RayfosAPIError):
27
+ """Raised when input validation fails (400)"""
28
+ pass
29
+
30
+
31
+ class ServerError(RayfosAPIError):
32
+ """Raised when the server encounters an error (5xx)"""
33
+ pass
34
+
35
+
36
+ class ConfigurationError(RayfosAPIError):
37
+ """Raised when configuration is invalid"""
38
+ pass
39
+
40
+
wavesimpro/execute.py ADDED
@@ -0,0 +1,461 @@
1
+ """
2
+ High-level single-call execution function for Wavesim simulations.
3
+
4
+ Ported from MATLAB executeViaRayfosAPI.m. Provides a single function that
5
+ handles the full workflow: credential loading, project creation, file upload,
6
+ command submission, polling, result download.
7
+ """
8
+
9
+ import os
10
+ import json
11
+ import math
12
+ import time
13
+ import logging
14
+ from typing import Tuple, Optional, Callable, Dict, Any, List
15
+
16
+ import numpy as np
17
+
18
+ from .client import RayfosClient
19
+ from .binary_utils import (
20
+ upload_array_as_binary_zipped,
21
+ upload_source_field_as_binary_zipped,
22
+ read_binary_field,
23
+ )
24
+ from .json_helper import (
25
+ create_simulation_domain,
26
+ create_medium,
27
+ create_voxels_model_primitive,
28
+ create_custom_field_source,
29
+ create_wavesim_properties,
30
+ parse_progress_data,
31
+ )
32
+ from .validate import validate_parameters
33
+
34
+ logger = logging.getLogger(__name__)
35
+
36
+
37
+ def _extract_file_id(file_info: dict) -> str:
38
+ """Extract file ID from upload response (handles different field names)."""
39
+ for key in ("fileId", "id", "blobName"):
40
+ if key in file_info:
41
+ return file_info[key]
42
+ raise KeyError(f"No file ID found in upload response. Keys: {list(file_info.keys())}")
43
+
44
+
45
+ def execute_via_api(
46
+ refractive_index: np.ndarray,
47
+ source,
48
+ wavelength: float,
49
+ pixel_size: float,
50
+ threshold: float = 1e-2,
51
+ max_iterations: int = 1000,
52
+ boundary_width: int = 0,
53
+ is_vectorial: bool = True,
54
+ data_type: str = "RefractiveIndex",
55
+ api_url: Optional[str] = None,
56
+ api_key: Optional[str] = None,
57
+ periodic: tuple = (False, False, False),
58
+ project_id: Optional[str] = None,
59
+ version_id: Optional[str] = None,
60
+ machine_service_url: Optional[str] = None,
61
+ description: str = "Wavesim Python API",
62
+ poll_interval: float = 10.0,
63
+ progress_callback: Optional[Callable[[Dict[str, Any]], None]] = None,
64
+ engine_preference: str = "Any",
65
+ use_gpu: Optional[bool] = None,
66
+ ) -> Tuple[np.ndarray, bool]:
67
+ """
68
+ Execute a Wavesim simulation via the Rayfos REST API.
69
+
70
+ Single-call function that handles the full simulation workflow.
71
+
72
+ Args:
73
+ refractive_index: [nx, ny, nz] array (refractive index or permittivity)
74
+ source: Source specification. Can be:
75
+ - dict with 'Es' (complex field array) and 'position' ([x,y,z] or [x,y,z,pol])
76
+ - list of such dicts for multiple sources
77
+ wavelength: Wavelength in micrometers
78
+ pixel_size: Pixel size in micrometers
79
+ threshold: Convergence threshold (default: 1e-2)
80
+ max_iterations: Max iterations (default: 1000)
81
+ boundary_width: Boundary width in pixels (default: 0)
82
+ is_vectorial: True for Maxwell, False for Helmholtz (default: True)
83
+ data_type: 'RefractiveIndex' or 'Permittivity' (default: 'RefractiveIndex')
84
+ api_url: API base URL (default: from RAYFOS_API_URL env var)
85
+ api_key: API key (default: from RAYFOS_API_KEY env var)
86
+ project_id: Existing project ID (default: auto-create)
87
+ version_id: Existing version ID (default: auto-create)
88
+ machine_service_url: Machine service URL (default: from RAYFOS_MACHINE_SERVICE_URL env var)
89
+ description: Command description
90
+ poll_interval: Polling interval in seconds (default: 10.0)
91
+ progress_callback: Optional callback(progress_dict) called during polling
92
+ engine_preference: Engine to use — 'Any' (default), 'Python', or 'Cpp'.
93
+ 'Any' lets the job server pick the best available machine.
94
+ Ignored when machine_service_url is set (local mode always uses that machine).
95
+ use_gpu: GPU selection — None (auto, default), True (require GPU), False (force CPU).
96
+ Ignored when machine_service_url is set.
97
+
98
+ Returns:
99
+ Tuple of (E_field, converged):
100
+ - E_field: Complex output field array. Vectorial: [3, nx, ny, nz], Scalar: [nx, ny, nz]
101
+ - converged: True if simulation completed successfully
102
+ """
103
+ # 1. Load credentials
104
+ api_url = api_url or os.environ.get("RAYFOS_API_URL", "http://localhost:5159")
105
+ api_key = api_key or os.environ.get("RAYFOS_API_KEY")
106
+ if not api_key:
107
+ raise ValueError("API key must be provided or set in RAYFOS_API_KEY environment variable")
108
+
109
+ project_id = project_id or os.environ.get("RAYFOS_PROJECT_ID")
110
+ version_id = version_id or os.environ.get("RAYFOS_VERSION_ID")
111
+ machine_service_url = machine_service_url or os.environ.get("RAYFOS_MACHINE_SERVICE_URL")
112
+
113
+ # Auto-detect SSL
114
+ verify_ssl = not ("localhost" in api_url.lower() or "127.0.0.1" in api_url)
115
+ verify_ssl_machine = True
116
+ if machine_service_url:
117
+ verify_ssl_machine = not ("localhost" in machine_service_url.lower() or "127.0.0.1" in machine_service_url)
118
+
119
+ logger.info("=" * 60)
120
+ logger.info("Wavesim Python API - Rayfos REST Execution")
121
+ logger.info("=" * 60)
122
+ logger.info(f" Mode: {'Vectorial (Maxwell)' if is_vectorial else 'Scalar (Helmholtz)'}")
123
+ logger.info(f" API URL: {api_url}")
124
+ logger.info(f" Engine: {engine_preference} GPU: {'auto' if use_gpu is None else ('GPU' if use_gpu else 'CPU')}")
125
+
126
+ # 2. Extract domain info
127
+ nx, ny, nz = refractive_index.shape[:3]
128
+
129
+ logger.info(f" Domain: [{nx}, {ny}, {nz}]")
130
+ logger.info(f" Wavelength: {wavelength:.3f} um")
131
+ logger.info(f" Pixel size: {pixel_size:.3f} um")
132
+ logger.info(f" Threshold: {threshold:.2e}")
133
+ logger.info(f" Max iterations: {max_iterations}")
134
+ logger.info(f" Boundary width: {boundary_width} pixels")
135
+
136
+ # 3. Prepare electromagnetic data
137
+ if data_type == "RefractiveIndex":
138
+ background_ri = float(np.min(np.real(refractive_index)))
139
+ else:
140
+ background_ri = float(np.sqrt(np.min(np.real(refractive_index))))
141
+ em_data = refractive_index
142
+
143
+ # 4. Prepare sources
144
+ if isinstance(source, dict):
145
+ source = [source]
146
+ elif not isinstance(source, list):
147
+ source = [source]
148
+
149
+ sources_info = []
150
+ for i, src_item in enumerate(source):
151
+ es = src_item["Es"]
152
+ position = src_item["position"]
153
+
154
+ # Extract polarization
155
+ if len(position) >= 4:
156
+ pol_matlab = position[3]
157
+ if is_vectorial:
158
+ pol_python = int(pol_matlab) - 1 # MATLAB 1-based -> Python 0-based
159
+ else:
160
+ pol_python = -1
161
+ else:
162
+ pol_python = 0 if is_vectorial else -1
163
+
164
+ src_x_um = float(position[0])
165
+ src_y_um = float(position[1])
166
+ src_z_um = float(position[2])
167
+
168
+ sources_info.append({
169
+ "field": es,
170
+ "position": [src_x_um, src_y_um, src_z_um],
171
+ "polarization": pol_python,
172
+ })
173
+ logger.info(
174
+ f" Source {i + 1}: pos=[{src_x_um:.4f}, {src_y_um:.4f}, {src_z_um:.4f}] um, "
175
+ f"shape={es.shape}, pol={pol_python}"
176
+ )
177
+
178
+ # 5. Create client
179
+ logger.info("Connecting to Rayfos API...")
180
+ client = RayfosClient(
181
+ api_token=api_key,
182
+ server_url=api_url,
183
+ machine_service_url=machine_service_url,
184
+ verify_ssl=verify_ssl,
185
+ verify_ssl_machine_service=verify_ssl_machine,
186
+ )
187
+
188
+ # 6. Create or reuse project/version
189
+ auto_create = not project_id or not version_id
190
+ if auto_create:
191
+ logger.info("Creating project...")
192
+ project_result = client.create_project(
193
+ name=f"{description} - Wavesim Python API",
194
+ app="wavesim",
195
+ description=f"Multi-source simulation ({len(sources_info)} source(s))",
196
+ )
197
+ project_id = project_result["id"]
198
+ versions = project_result.get("versions", [])
199
+ if not versions:
200
+ raise RuntimeError("No version found in created project")
201
+ version_result = versions[0]
202
+ version_id = version_result["id"]
203
+ properties_id = version_result["propertiesId"]
204
+ logger.info(f" Project ID: {project_id}")
205
+ logger.info(f" Version ID: {version_id}")
206
+ else:
207
+ logger.info(f"Using existing project: {project_id}")
208
+ version_info = client.get_project_version(project_id, version_id)
209
+ properties_id = version_info["propertiesId"]
210
+
211
+ # 7. Validate parameters
212
+ validate_parameters(
213
+ em_data, sources_info, wavelength, pixel_size,
214
+ threshold, max_iterations, boundary_width, is_vectorial
215
+ )
216
+
217
+ # 8. Upload RI/permittivity
218
+ logger.info(f"Uploading electromagnetic data ({data_type})...")
219
+ if np.isrealobj(em_data):
220
+ binary_data_type = "float32"
221
+ else:
222
+ binary_data_type = "complex64"
223
+
224
+ filename = "refractive_index.bin" if data_type == "RefractiveIndex" else "permittivity.bin"
225
+ em_file_info = upload_array_as_binary_zipped(
226
+ client, project_id, version_id, em_data, filename, binary_data_type
227
+ )
228
+ em_data_id = _extract_file_id(em_file_info)
229
+ logger.info(f" Uploaded {data_type} (ID: {em_data_id})")
230
+
231
+ # 9. Upload sources
232
+ logger.info("Uploading input sources...")
233
+ source_ids = []
234
+ for i, src in enumerate(sources_info):
235
+ src_filename = f"source{i}.bin"
236
+ src_file_info = upload_source_field_as_binary_zipped(
237
+ client, project_id, version_id, src["field"], src_filename, "complex64"
238
+ )
239
+ src_id = _extract_file_id(src_file_info)
240
+ source_ids.append(src_id)
241
+ logger.info(f" Source {i + 1} uploaded (ID: {src_id})")
242
+
243
+ # 10. Build WavesimProperties payload
244
+ logger.info("Building command payload...")
245
+ domain_x_um = float(nx) * float(pixel_size)
246
+ domain_y_um = float(ny) * float(pixel_size)
247
+ domain_z_um = float(nz) * float(pixel_size)
248
+
249
+ simulation_domain = create_simulation_domain(domain_x_um, domain_y_um, domain_z_um)
250
+ background_medium = create_medium("background", "Background", background_ri)
251
+
252
+ primitive_name = data_type.lower().replace(" ", "_") + "_map"
253
+ voxel_primitive = create_voxels_model_primitive(
254
+ primitive_name, em_data_id, "background",
255
+ 0.0, 0.0, 0.0,
256
+ float(pixel_size), float(pixel_size), float(pixel_size),
257
+ int(nx), int(ny), int(nz),
258
+ "Complex64", data_type,
259
+ )
260
+
261
+ input_sources = []
262
+ for i, src in enumerate(sources_info):
263
+ src_shape = src["field"].shape
264
+ while len(src_shape) < 3:
265
+ src_shape = src_shape + (1,)
266
+ src_nx, src_ny, src_nz = src_shape[0], src_shape[1], src_shape[2]
267
+
268
+ pol_num = src["polarization"]
269
+ pol_map = {0: "x", 1: "y", 2: "z"}
270
+ pol_str = pol_map.get(pol_num, "none")
271
+
272
+ custom_source = create_custom_field_source(
273
+ name=f"source_{i + 1}",
274
+ field_data_id=source_ids[i],
275
+ position_x=src["position"][0],
276
+ position_y=src["position"][1],
277
+ position_z=src["position"][2],
278
+ grid_nx=src_nx,
279
+ grid_ny=src_ny,
280
+ grid_nz=src_nz,
281
+ polarization=pol_str,
282
+ amplitude_real=1.0,
283
+ amplitude_imag=0.0,
284
+ data_format="Complex64",
285
+ origin="topleft",
286
+ )
287
+ input_sources.append(custom_source)
288
+
289
+ boundary_size_um = float(boundary_width) * float(pixel_size)
290
+
291
+ props = create_wavesim_properties(
292
+ simulation_domain=simulation_domain,
293
+ primitives=[voxel_primitive],
294
+ input_sources=input_sources,
295
+ mediums=[background_medium],
296
+ monitors=[],
297
+ background_medium_id="background",
298
+ wavelength=float(wavelength),
299
+ voxel_size=float(pixel_size),
300
+ boundary_size_um=boundary_size_um,
301
+ residual_norm_threshold=float(threshold),
302
+ residual_norm_max_iterations=int(max_iterations),
303
+ periodic_x=bool(periodic[0]),
304
+ periodic_y=bool(periodic[1]),
305
+ periodic_z=bool(periodic[2]),
306
+ )
307
+
308
+ logger.info(f" Payload created with {len(sources_info)} source(s)")
309
+
310
+ # 11. Update properties
311
+ logger.info("Updating project properties...")
312
+ client.update_project_properties(properties_id, props)
313
+
314
+ # 12. Submit command
315
+ logger.info("Submitting simulation command...")
316
+ cmd_status = client.create_command(project_id, version_id, engine_preference, use_gpu)
317
+ command_id = cmd_status.get("commandId") or cmd_status.get("id")
318
+ if not command_id:
319
+ raise RuntimeError("No command ID found in response")
320
+ logger.info(f" Command ID: {command_id}")
321
+
322
+ # 13. Poll for completion
323
+ logger.info("Waiting for simulation to complete...")
324
+ start_time = time.time()
325
+ consecutive_errors = 0
326
+ max_consecutive_errors = 3
327
+ final_status = None
328
+
329
+ while True:
330
+ try:
331
+ status = client.get_command_status(command_id)
332
+ consecutive_errors = 0
333
+ except Exception as e:
334
+ consecutive_errors += 1
335
+ if consecutive_errors >= max_consecutive_errors:
336
+ raise RuntimeError(
337
+ f"Failed to get command status after {consecutive_errors} attempts. "
338
+ f"Last error: {e}. Command ID: {command_id}"
339
+ )
340
+ logger.warning(
341
+ f"Status check failed (attempt {consecutive_errors}/{max_consecutive_errors}): {e}. "
342
+ f"Retrying in {poll_interval}s..."
343
+ )
344
+ time.sleep(poll_interval)
345
+ continue
346
+
347
+ elapsed = time.time() - start_time
348
+ status_str = status.get("status", "Unknown")
349
+
350
+ # Parse progress if available
351
+ progress_dto = status.get("progressDto")
352
+ if progress_dto:
353
+ progress_info = parse_progress_data(progress_dto)
354
+ if progress_callback:
355
+ progress_callback(progress_info)
356
+
357
+ residual = progress_info.get("residual_norm")
358
+ iteration = progress_info.get("iteration")
359
+ if residual is not None:
360
+ logger.info(f" [{elapsed:.0f}s] {status_str} (residual: {residual:.2e}, iter: {iteration})")
361
+ elif iteration is not None:
362
+ max_iter = progress_info.get("max_iterations", "?")
363
+ logger.info(f" [{elapsed:.0f}s] {status_str} (iter {iteration}/{max_iter})")
364
+ else:
365
+ logger.info(f" [{elapsed:.0f}s] {status_str}")
366
+ else:
367
+ logger.info(f" [{elapsed:.0f}s] {status_str}")
368
+
369
+ if status_str in ("Completed", "Failed", "Cancelled"):
370
+ final_status = status
371
+ break
372
+
373
+ time.sleep(poll_interval)
374
+
375
+ total_time = time.time() - start_time
376
+
377
+ if final_status.get("status") != "Completed":
378
+ # Build detailed error message
379
+ error_parts = [f"Simulation ended with status: {final_status.get('status')}"]
380
+ for field in ("errorMessage", "ErrorMessage", "statusText", "StatusText"):
381
+ val = final_status.get(field)
382
+ if val:
383
+ error_parts.append(str(val))
384
+ progress_dto = final_status.get("progressDto") or final_status.get("progress") or final_status.get("Progress")
385
+ if progress_dto and isinstance(progress_dto, dict):
386
+ for field in ("progress", "Progress"):
387
+ val = progress_dto.get(field)
388
+ if val:
389
+ error_parts.append(f"Details: {val}")
390
+ data_field = progress_dto.get("data") or progress_dto.get("Data")
391
+ if data_field:
392
+ try:
393
+ if isinstance(data_field, str):
394
+ data_obj = json.loads(data_field)
395
+ else:
396
+ data_obj = data_field
397
+ if "residual_norm" in data_obj:
398
+ error_parts.append(f"Final residual: {data_obj['residual_norm']:.2e}")
399
+ if "error" in data_obj and data_obj["error"]:
400
+ error_parts.append(f"Error: {data_obj['error']}")
401
+ except (json.JSONDecodeError, TypeError):
402
+ error_parts.append(f"Data: {data_field}")
403
+ raise RuntimeError(". ".join(error_parts))
404
+
405
+ logger.info(f" Simulation completed in {total_time:.1f} seconds!")
406
+
407
+ # 14. Download output field
408
+ logger.info("Downloading results...")
409
+ server_info = client.get_server_info()
410
+ use_machine_service = server_info["uses_machine_service"]
411
+
412
+ output_file_id = None
413
+
414
+ if use_machine_service:
415
+ files = client.list_command_files(command_id)
416
+ logger.info(f" Found {len(files)} files from machine service")
417
+ for f in files:
418
+ fname = f.get("fileName") or f.get("originalFileName", "")
419
+ if fname.lower() == "output.bin":
420
+ output_file_id = f.get("blobName")
421
+ break
422
+ else:
423
+ version_info = client.get_project_version(project_id, version_id)
424
+ output_file_ids = version_info.get("outputFileIds", [])
425
+ logger.info(f" Found {len(output_file_ids)} output file IDs from cloud storage")
426
+ for fid in output_file_ids:
427
+ try:
428
+ preview = client.get_file_preview(project_id, version_id, fid)
429
+ fname = preview.get("fileName") or preview.get("originalFileName", "")
430
+ if fname.lower() == "output.bin":
431
+ output_file_id = fid # Use the original ID from outputFileIds
432
+ break
433
+ except Exception:
434
+ continue
435
+
436
+ if not output_file_id:
437
+ raise RuntimeError("output.bin not found in command files")
438
+
439
+ logger.info(f" Found output.bin (ID: {output_file_id})")
440
+
441
+ # Download to memory
442
+ output_data = client.download_file_data(project_id, version_id, output_file_id)
443
+ logger.info(f" Downloaded {len(output_data) / (1024 * 1024):.2f} MB")
444
+
445
+ # 15. Parse output
446
+ logger.info("Reading output field...")
447
+ if is_vectorial:
448
+ dims = (3, nx, ny, nz)
449
+ else:
450
+ dims = (nx, ny, nz)
451
+
452
+ E = read_binary_field(output_data, dims, "Complex64")
453
+ logger.info(f" Output shape: {E.shape}")
454
+
455
+ converged = final_status.get("status") == "Completed"
456
+
457
+ logger.info("=" * 60)
458
+ logger.info("Simulation completed successfully!")
459
+ logger.info("=" * 60)
460
+
461
+ return E, converged