wavesimpro 1.9.0__tar.gz → 1.10.1__tar.gz

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.
Files changed (22) hide show
  1. {wavesimpro-1.9.0 → wavesimpro-1.10.1}/PKG-INFO +10 -3
  2. {wavesimpro-1.9.0 → wavesimpro-1.10.1}/README.md +9 -2
  3. {wavesimpro-1.9.0 → wavesimpro-1.10.1}/pyproject.toml +1 -1
  4. {wavesimpro-1.9.0 → wavesimpro-1.10.1}/src/wavesimpro/__init__.py +20 -2
  5. {wavesimpro-1.9.0 → wavesimpro-1.10.1}/src/wavesimpro/client.py +121 -172
  6. {wavesimpro-1.9.0 → wavesimpro-1.10.1}/src/wavesimpro/json_helper.py +486 -9
  7. {wavesimpro-1.9.0 → wavesimpro-1.10.1}/src/wavesimpro.egg-info/PKG-INFO +10 -3
  8. {wavesimpro-1.9.0 → wavesimpro-1.10.1}/src/wavesimpro.egg-info/SOURCES.txt +2 -1
  9. wavesimpro-1.10.1/tests/test_command_wait.py +64 -0
  10. {wavesimpro-1.9.0 → wavesimpro-1.10.1}/setup.cfg +0 -0
  11. {wavesimpro-1.9.0 → wavesimpro-1.10.1}/src/wavesimpro/__main__.py +0 -0
  12. {wavesimpro-1.9.0 → wavesimpro-1.10.1}/src/wavesimpro/binary_utils.py +0 -0
  13. {wavesimpro-1.9.0 → wavesimpro-1.10.1}/src/wavesimpro/config.py +0 -0
  14. {wavesimpro-1.9.0 → wavesimpro-1.10.1}/src/wavesimpro/exceptions.py +0 -0
  15. {wavesimpro-1.9.0 → wavesimpro-1.10.1}/src/wavesimpro/execute.py +0 -0
  16. {wavesimpro-1.9.0 → wavesimpro-1.10.1}/src/wavesimpro/py.typed +0 -0
  17. {wavesimpro-1.9.0 → wavesimpro-1.10.1}/src/wavesimpro/setup.py +0 -0
  18. {wavesimpro-1.9.0 → wavesimpro-1.10.1}/src/wavesimpro/simulate.py +0 -0
  19. {wavesimpro-1.9.0 → wavesimpro-1.10.1}/src/wavesimpro/validate.py +0 -0
  20. {wavesimpro-1.9.0 → wavesimpro-1.10.1}/src/wavesimpro.egg-info/dependency_links.txt +0 -0
  21. {wavesimpro-1.9.0 → wavesimpro-1.10.1}/src/wavesimpro.egg-info/requires.txt +0 -0
  22. {wavesimpro-1.9.0 → wavesimpro-1.10.1}/src/wavesimpro.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: wavesimpro
3
- Version: 1.9.0
3
+ Version: 1.10.1
4
4
  Summary: WavesimPro Python API — cloud execution client for the Rayfos simulation platform
5
5
  Author: Rayfos
6
6
  Project-URL: Repository, https://github.com/rayfos/wavesimpro
@@ -59,8 +59,15 @@ field = wp.read_binary_field(run.download("output.bin"))
59
59
 
60
60
  ## What the API covers
61
61
 
62
- - Setup helpers for domains, media, primitives (box, STL, voxel arrays), sources
63
- (point, plane wave, Gaussian beam, custom field, engine-solved waveguide mode) and monitors.
62
+ - Setup helpers for domains, media, primitives (box, sphere, ellipsoid, cylinder, cone,
63
+ N-sided prism, STL, GDSII layouts, voxel arrays), sources (point, plane wave, Gaussian beam,
64
+ custom field, custom-from-file, engine-solved waveguide mode) and monitors:
65
+ `create_box_primitive`, `create_sphere_primitive`, `create_ellipsoid_primitive`,
66
+ `create_cylinder_primitive`, `create_cone_primitive`, `create_prism_primitive`,
67
+ `create_stl_primitive`, `create_gds_primitive` (with `create_gds_layer_config` /
68
+ `create_gds_structure_override`), `create_voxels_model_primitive`.
69
+ - Per-primitive sub-pixel flag (`subpixel=True/False` on every shape, STL and GDSII helper)
70
+ to choose which shapes get smoothed interfaces.
64
71
  - Per-axis pixel sizes (`pixel_size=[dx, dy, dz]`) and sub-pixel permittivity averaging
65
72
  (`subpixel_factor=`), when the compute node supports them.
66
73
  - Preflight memory check and cost estimate before a run.
@@ -36,8 +36,15 @@ field = wp.read_binary_field(run.download("output.bin"))
36
36
 
37
37
  ## What the API covers
38
38
 
39
- - Setup helpers for domains, media, primitives (box, STL, voxel arrays), sources
40
- (point, plane wave, Gaussian beam, custom field, engine-solved waveguide mode) and monitors.
39
+ - Setup helpers for domains, media, primitives (box, sphere, ellipsoid, cylinder, cone,
40
+ N-sided prism, STL, GDSII layouts, voxel arrays), sources (point, plane wave, Gaussian beam,
41
+ custom field, custom-from-file, engine-solved waveguide mode) and monitors:
42
+ `create_box_primitive`, `create_sphere_primitive`, `create_ellipsoid_primitive`,
43
+ `create_cylinder_primitive`, `create_cone_primitive`, `create_prism_primitive`,
44
+ `create_stl_primitive`, `create_gds_primitive` (with `create_gds_layer_config` /
45
+ `create_gds_structure_override`), `create_voxels_model_primitive`.
46
+ - Per-primitive sub-pixel flag (`subpixel=True/False` on every shape, STL and GDSII helper)
47
+ to choose which shapes get smoothed interfaces.
41
48
  - Per-axis pixel sizes (`pixel_size=[dx, dy, dz]`) and sub-pixel permittivity averaging
42
49
  (`subpixel_factor=`), when the compute node supports them.
43
50
  - Preflight memory check and cost estimate before a run.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "wavesimpro"
7
- version = "1.9.0"
7
+ version = "1.10.1"
8
8
  description = "WavesimPro Python API — cloud execution client for the Rayfos simulation platform"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.8"
@@ -6,7 +6,7 @@ supporting both cloud and local deployment modes, as well as direct interaction
6
6
  with RayfosMachineService via its REST API.
7
7
  """
8
8
 
9
- from .client import RayfosClient
9
+ from .client import RayfosClient, command_status_name, is_terminal_command_status
10
10
  from .config import ClientConfig
11
11
  from .exceptions import (
12
12
  RayfosAPIError,
@@ -33,6 +33,14 @@ from .json_helper import (
33
33
  create_simulation_domain,
34
34
  create_stl_primitive,
35
35
  create_box_primitive,
36
+ create_sphere_primitive,
37
+ create_ellipsoid_primitive,
38
+ create_cylinder_primitive,
39
+ create_cone_primitive,
40
+ create_prism_primitive,
41
+ create_gds_primitive,
42
+ create_gds_layer_config,
43
+ create_gds_structure_override,
36
44
  create_medium,
37
45
  create_point_source,
38
46
  create_plane_wave_source,
@@ -46,10 +54,12 @@ from .json_helper import (
46
54
  parse_progress_data,
47
55
  )
48
56
 
49
- __version__ = "1.9.0"
57
+ __version__ = "1.10.1"
50
58
  __all__ = [
51
59
  # Client
52
60
  "RayfosClient",
61
+ "command_status_name",
62
+ "is_terminal_command_status",
53
63
  "ClientConfig",
54
64
  # Exceptions
55
65
  "RayfosAPIError",
@@ -78,6 +88,14 @@ __all__ = [
78
88
  "create_simulation_domain",
79
89
  "create_stl_primitive",
80
90
  "create_box_primitive",
91
+ "create_sphere_primitive",
92
+ "create_ellipsoid_primitive",
93
+ "create_cylinder_primitive",
94
+ "create_cone_primitive",
95
+ "create_prism_primitive",
96
+ "create_gds_primitive",
97
+ "create_gds_layer_config",
98
+ "create_gds_structure_override",
81
99
  "create_medium",
82
100
  "create_point_source",
83
101
  "create_plane_wave_source",
@@ -77,6 +77,49 @@ def _make_progress_callback(total_bytes: int, desc: str):
77
77
  logger = logging.getLogger(__name__)
78
78
 
79
79
 
80
+
81
+ # CommandStatus enum (C# RayfosCommandModels) by value; the server sends the NAME in "status"
82
+ # (JsonStringEnumConverter). Older servers sent the integer in "commandStatus".
83
+ _COMMAND_STATUS_BY_INT = {
84
+ 0: 'New', 1: 'Assigned', 2: 'SentToMachine', 3: 'SendToMachineForceCancellation',
85
+ 4: 'SendToMachineForceDeleteTask', 5: 'RequestedForceCancellationInMachine',
86
+ 6: 'RequestedForceStopTaskInMachine', 7: 'Cancelled', 8: 'Deleted', 9: 'Running',
87
+ 10: 'Completed', 11: 'Failed', 12: 'FailedRemovedFromMachine',
88
+ }
89
+ _TERMINAL_STATUSES = {'Cancelled', 'Deleted', 'Completed', 'Failed', 'FailedRemovedFromMachine'}
90
+
91
+
92
+ def command_status_name(status: Dict[str, Any]) -> str:
93
+ """The command state name from a command-status dict, whatever the server version.
94
+
95
+ Reads ``status`` (current servers: the enum name, e.g. ``"Completed"``), falling back to the
96
+ legacy ``commandStatus`` / ``CommandStatus`` (integer or name). Returns ``"Unknown"`` when absent.
97
+ """
98
+ if not isinstance(status, dict):
99
+ return 'Unknown'
100
+ for key in ('status', 'Status', 'commandStatus', 'CommandStatus'):
101
+ raw = status.get(key)
102
+ if raw is None or raw == '':
103
+ continue
104
+ if isinstance(raw, bool):
105
+ continue
106
+ if isinstance(raw, int):
107
+ return _COMMAND_STATUS_BY_INT.get(raw, f'Unknown({raw})')
108
+ text = str(raw).strip()
109
+ if text.isdigit():
110
+ return _COMMAND_STATUS_BY_INT.get(int(text), f'Unknown({text})')
111
+ for name in _COMMAND_STATUS_BY_INT.values():
112
+ if name.lower() == text.lower():
113
+ return name
114
+ return text
115
+ return 'Unknown'
116
+
117
+
118
+ def is_terminal_command_status(name: str) -> bool:
119
+ """True for Completed, Failed, FailedRemovedFromMachine, Cancelled and Deleted."""
120
+ return name in _TERMINAL_STATUSES
121
+
122
+
80
123
  class RayfosClient:
81
124
  """
82
125
  Client for interacting with RayfosJobServer API
@@ -740,9 +783,9 @@ class RayfosClient:
740
783
 
741
784
  Example:
742
785
  >>> version = client.get_project_version(project_id, version_id)
743
- >>> if version.get('command') and version['command'].get('outputFiles'):
744
- ... for file in version['command']['outputFiles']:
745
- ... print(f"Output file: {file['originalFileName']}")
786
+ >>> for file_id in version.get('outputFileIds', []):
787
+ ... info = client.get_file_info(project_id, version_id, file_id)
788
+ ... print(f"Output file: {info.get('fileName')} ({info.get('fileSize')} bytes)")
746
789
  """
747
790
  return self._get(f"/api/projects/{project_id}/versions/{version_id}")
748
791
 
@@ -1287,7 +1330,7 @@ class RayfosClient:
1287
1330
  Returns:
1288
1331
  File metadata dictionary
1289
1332
  """
1290
- return self._get(f"/api/projects/{project_id}/versions/{version_id}/files/{file_id}")
1333
+ return self._get(f"/api/projects/{project_id}/versions/{version_id}/files/{file_id}/metadata")
1291
1334
 
1292
1335
  # ========================================================================
1293
1336
  # COMMAND OPERATIONS
@@ -1382,7 +1425,8 @@ class RayfosClient:
1382
1425
  Ignored when using a local machine service URL.
1383
1426
 
1384
1427
  Returns:
1385
- Command status dictionary with the created command ID and status
1428
+ Command status dictionary. The command id is under ``"id"`` and, for convenience,
1429
+ ``"commandId"``; the state is under ``"status"`` (read it with ``command_status_name``).
1386
1430
 
1387
1431
  Example:
1388
1432
  >>> command = client.create_command(project_id, version_id,
@@ -1390,9 +1434,17 @@ class RayfosClient:
1390
1434
  >>> print(f"Command created: {command['id']}")
1391
1435
  """
1392
1436
  if self.config.machine_service_url:
1393
- return self._create_command_machine_service(project_id, version_id)
1437
+ result = self._create_command_machine_service(project_id, version_id)
1394
1438
  else:
1395
- return self._create_command_cloud(project_id, version_id, engine_preference, use_gpu, preferred_machine_id)
1439
+ result = self._create_command_cloud(project_id, version_id, engine_preference, use_gpu, preferred_machine_id)
1440
+ # The server names the command id "id"; other API calls and version records use "commandId".
1441
+ # Return both so either spelling works.
1442
+ if isinstance(result, dict):
1443
+ cid = result.get('id') or result.get('commandId') or result.get('Id')
1444
+ if cid:
1445
+ result.setdefault('id', cid)
1446
+ result.setdefault('commandId', cid)
1447
+ return result
1396
1448
 
1397
1449
  def _create_command_machine_service(
1398
1450
  self,
@@ -1439,35 +1491,8 @@ class RayfosClient:
1439
1491
 
1440
1492
  @staticmethod
1441
1493
  def _parse_command_status(status_raw) -> str:
1442
- """
1443
- Parse commandStatus from C# enum (integer) to string name
1444
-
1445
- Args:
1446
- status_raw: Either integer enum value or string name
1447
-
1448
- Returns:
1449
- String name of the status
1450
- """
1451
- # CommandStatus enum from C# RayfosCommandModels
1452
- COMMAND_STATUS_MAP = {
1453
- 0: 'New',
1454
- 1: 'Assigned',
1455
- 2: 'SentToMachine',
1456
- 3: 'SendToMachineForceCancellation',
1457
- 4: 'SendToMachineForceDeleteTask',
1458
- 5: 'RequestedForceCancellationInMachine',
1459
- 6: 'RequestedForceStopTaskInMachine',
1460
- 7: 'Cancelled',
1461
- 8: 'Deleted',
1462
- 9: 'Running',
1463
- 10: 'Completed',
1464
- 11: 'Failed',
1465
- 12: 'FailedRemovedFromMachine'
1466
- }
1467
-
1468
- if isinstance(status_raw, int):
1469
- return COMMAND_STATUS_MAP.get(status_raw, f'Unknown({status_raw})')
1470
- return status_raw
1494
+ """Command state name from an integer (legacy ``commandStatus``) or a name (current ``status``)."""
1495
+ return command_status_name({"status": status_raw})
1471
1496
 
1472
1497
  @staticmethod
1473
1498
  def _parse_progress_data(progress_obj) -> Dict[str, Any]:
@@ -1542,16 +1567,16 @@ class RayfosClient:
1542
1567
  command_id: Command UUID
1543
1568
 
1544
1569
  Returns:
1545
- CommandStatusDto dictionary with status, progress, and output files
1546
- Note: commandStatus is returned as integer from C#, use _parse_command_status() to convert
1547
-
1570
+ Command-status dict: ``id``, ``status`` (enum name such as ``"Running"`` or
1571
+ ``"Completed"``), ``statusText``, ``progressValue``. Older servers sent
1572
+ the state as the integer ``commandStatus``; :func:`command_status_name` reads either.
1573
+
1548
1574
  Example:
1549
1575
  >>> status = client.get_command_status("command-uuid")
1550
- >>> status_name = client._parse_command_status(status['commandStatus'])
1551
- >>> print(f"Status: {status_name}")
1552
- >>> print(f"Progress: {status.get('progressValue', 0)}%")
1553
- >>> if status['commandStatus'] == 10: # Completed
1554
- ... print(f"Output files: {status.get('outputFileIds', [])}")
1576
+ >>> name = command_status_name(status) # "Running", "Completed", ...
1577
+ >>> print(f"{name}: {status.get('statusText')} ({status.get('progressValue', 0)}%)")
1578
+ >>> if name == "Completed":
1579
+ ... files = client.get_project_version(project_id, version_id)["outputFileIds"]
1555
1580
  """
1556
1581
  # Always call RayfosJobServer directly for command status (user endpoint)
1557
1582
  # Machine service is only for file operations, not command status
@@ -1561,113 +1586,73 @@ class RayfosClient:
1561
1586
  self,
1562
1587
  command_id: str,
1563
1588
  poll_interval: float = 2.0,
1564
- timeout: Optional[float] = None,
1589
+ timeout: Optional[float] = 24 * 3600,
1565
1590
  progress_callback: Optional[callable] = None
1566
1591
  ) -> Dict[str, Any]:
1567
1592
  """
1568
- Wait for a command to complete (synchronous execution)
1569
-
1570
- This method polls the command status until it reaches a terminal state
1571
- (Completed, Failed, Cancelled). Useful for blocking until simulation finishes.
1572
-
1593
+ Wait for a command to finish (synchronous execution).
1594
+
1595
+ Polls :meth:`get_command_status` until the command reaches a terminal state and returns the
1596
+ final status dict on ``Completed``. The state is read with :func:`command_status_name`, which
1597
+ understands both the current ``status`` field (enum name) and the legacy ``commandStatus``
1598
+ integer, so this works against every server version.
1599
+
1573
1600
  Args:
1574
- command_id: Command UUID to wait for
1575
- poll_interval: Seconds between status checks (default: 2.0)
1576
- timeout: Maximum seconds to wait before raising TimeoutError (default: None - no timeout)
1577
- progress_callback: Optional callback(status_dict) called on each status check
1578
-
1601
+ command_id: Command UUID (``create_command(...)["id"]``, also available as ``["commandId"]``)
1602
+ poll_interval: Seconds between status checks (default 2.0)
1603
+ timeout: Maximum seconds to wait (default 24 h; ``None`` waits forever)
1604
+ progress_callback: Optional ``callback(status_dict)`` called on each poll
1605
+
1579
1606
  Returns:
1580
- Final CommandStatusDto dictionary when command completes
1581
-
1607
+ Final command-status dict (``status == "Completed"``). To get the result files, list the
1608
+ version's ``outputFileIds`` (``get_project_version(project_id, version_id)["outputFileIds"]``,
1609
+ then ``download_file``); the ``outputFileIds`` field
1610
+ of the status is not filled by current servers.
1611
+
1582
1612
  Raises:
1583
- TimeoutError: If timeout is reached before completion
1584
- RayfosAPIError: If command fails or is cancelled
1585
-
1613
+ TimeoutError: the command did not finish within ``timeout`` seconds
1614
+ RayfosAPIError: the command failed, was cancelled or was deleted
1615
+
1586
1616
  Example:
1587
- >>> # Create command
1588
- >>> command = client.create_command(project_id, version_id, args_json)
1589
- >>>
1590
- >>> # Wait for completion with progress updates
1591
- >>> def show_progress(status):
1592
- ... print(f"Progress: {status.get('progressValue', 0)}% - {status.get('statusText', 'Running')}")
1593
- >>>
1594
- >>> final_status = client.wait_for_command_completion(
1595
- ... command['id'],
1596
- ... progress_callback=show_progress,
1597
- ... timeout=3600 # 1 hour timeout
1598
- ... )
1599
- >>> print(f"Command completed! Output files: {final_status.get('outputFileIds', [])}")
1617
+ >>> command = client.create_command(project_id, version_id)
1618
+ >>> final = client.wait_for_command_completion(command["id"], timeout=3600,
1619
+ ... progress_callback=lambda s: print(command_status_name(s), s.get("statusText")))
1600
1620
  """
1601
1621
  import time
1602
-
1603
- # CommandStatus enum values from C# (serialized as integers)
1604
- # New=0, Assigned=1, SentToMachine=2, etc.
1605
- COMMAND_STATUS = {
1606
- 0: 'New',
1607
- 1: 'Assigned',
1608
- 2: 'SentToMachine',
1609
- 3: 'SendToMachineForceCancellation',
1610
- 4: 'SendToMachineForceDeleteTask',
1611
- 5: 'RequestedForceCancellationInMachine',
1612
- 6: 'RequestedForceStopTaskInMachine',
1613
- 7: 'Cancelled',
1614
- 8: 'Deleted',
1615
- 9: 'Running',
1616
- 10: 'Completed',
1617
- 11: 'Failed',
1618
- 12: 'FailedRemovedFromMachine'
1619
- }
1620
-
1621
- # Terminal states (by integer value and name)
1622
- TERMINAL_STATUS_INTS = {7, 8, 10, 11, 12} # Cancelled, Deleted, Completed, Failed, FailedRemovedFromMachine
1623
- TERMINAL_STATUS_NAMES = {'Cancelled', 'Deleted', 'Completed', 'Failed', 'FailedRemovedFromMachine'}
1624
-
1622
+
1625
1623
  start_time = time.time()
1626
-
1627
1624
  while True:
1628
- # Check timeout
1629
- if timeout and (time.time() - start_time) > timeout:
1630
- raise TimeoutError(f"Command {command_id} did not complete within {timeout} seconds")
1631
-
1632
- # Get current status
1633
1625
  status = self.get_command_status(command_id)
1634
- command_status_raw = status.get('commandStatus')
1635
-
1636
- # Handle both integer (from C# enum) and string formats
1637
- if isinstance(command_status_raw, int):
1638
- command_status = COMMAND_STATUS.get(command_status_raw, f'Unknown({command_status_raw})')
1639
- is_terminal = command_status_raw in TERMINAL_STATUS_INTS
1640
- else:
1641
- command_status = command_status_raw
1642
- is_terminal = command_status in TERMINAL_STATUS_NAMES
1643
-
1644
- # Call progress callback if provided
1626
+ name = command_status_name(status)
1627
+
1645
1628
  if progress_callback:
1646
1629
  progress_callback(status)
1647
-
1648
- # Check if command reached terminal state
1649
- if is_terminal:
1650
- if command_status in ('Completed', 10):
1630
+
1631
+ if is_terminal_command_status(name):
1632
+ if name == 'Completed':
1651
1633
  return status
1652
- elif command_status in ('Failed', 'FailedRemovedFromMachine', 11, 12):
1653
- error_msg = status.get('statusText', 'Command failed')
1654
- raise RayfosAPIError(f"Command {command_id} failed: {error_msg}")
1655
- elif command_status in ('Cancelled', 7):
1634
+ detail = status.get('statusText') or ''
1635
+ if name in ('Failed', 'FailedRemovedFromMachine'):
1636
+ raise RayfosAPIError(f"Command {command_id} failed: {detail or name}")
1637
+ if name == 'Cancelled':
1656
1638
  raise RayfosAPIError(f"Command {command_id} was cancelled")
1657
- elif command_status in ('Deleted', 8):
1658
- raise RayfosAPIError(f"Command {command_id} was deleted")
1659
-
1660
- # Wait before next poll
1639
+ raise RayfosAPIError(f"Command {command_id} was deleted")
1640
+
1641
+ if timeout is not None and (time.time() - start_time) > timeout:
1642
+ raise TimeoutError(f"Command {command_id} did not finish within {timeout:.0f} s (last status: {name})")
1643
+
1661
1644
  time.sleep(poll_interval)
1662
-
1645
+
1663
1646
  def create_command_and_wait(
1664
1647
  self,
1665
1648
  project_id: str,
1666
1649
  version_id: str,
1667
1650
  local_machine_service_id: Optional[str] = None,
1668
1651
  poll_interval: float = 2.0,
1669
- timeout: Optional[float] = None,
1670
- progress_callback: Optional[callable] = None
1652
+ timeout: Optional[float] = 24 * 3600,
1653
+ progress_callback: Optional[callable] = None,
1654
+ engine_preference: str = 'Any',
1655
+ use_gpu: Optional[bool] = None,
1671
1656
  ) -> Dict[str, Any]:
1672
1657
  """
1673
1658
  Create a command and wait for it to complete (synchronous execution)
@@ -1706,8 +1691,11 @@ class RayfosClient:
1706
1691
  ... client.download_file(project_id, version_id, file_id, f"output/{file_id}")
1707
1692
  """
1708
1693
  # Create the command (async)
1709
- command_status = self.create_command(project_id, version_id, local_machine_service_id)
1710
- command_id = command_status.get('id')
1694
+ # local_machine_service_id pins the run to that compute node (it used to be passed, by
1695
+ # position, as the engine preference, which the server rejects).
1696
+ command_status = self.create_command(project_id, version_id, engine_preference=engine_preference,
1697
+ use_gpu=use_gpu, preferred_machine_id=local_machine_service_id)
1698
+ command_id = command_status.get('id') or command_status.get('commandId')
1711
1699
 
1712
1700
  if not command_id:
1713
1701
  raise RayfosAPIError("Failed to create command: no command ID returned")
@@ -1898,46 +1886,7 @@ class RayfosClient:
1898
1886
  )
1899
1887
  return self._handle_response(response)
1900
1888
 
1901
- def parse_command_status(self, status_value: int) -> str:
1902
- """
1903
- Parse commandStatus from C# enum (integer) to string name
1904
-
1905
- Args:
1906
- status_value: Integer status code from CommandStatusDto
1907
-
1908
- Returns:
1909
- Human-readable status name
1910
-
1911
- CommandStatus enum values from C# RayfosCommandModels:
1912
- 0 = New
1913
- 1 = Assigned
1914
- 2 = SentToMachine
1915
- 3 = SendToMachineForceCancellation
1916
- 4 = SendToMachineForceDeleteTask
1917
- 5 = RequestedForceCancellationInMachine
1918
- 6 = RequestedForceStopTaskInMachine
1919
- 7 = Cancelled
1920
- 8 = Deleted
1921
- 9 = Running
1922
- 10 = Completed
1923
- 11 = Failed
1924
- 12 = FailedRemovedFromMachine
1925
- """
1926
- status_map = {
1927
- 0: "New",
1928
- 1: "Assigned",
1929
- 2: "SentToMachine",
1930
- 3: "SendToMachineForceCancellation",
1931
- 4: "SendToMachineForceDeleteTask",
1932
- 5: "RequestedForceCancellationInMachine",
1933
- 6: "RequestedForceStopTaskInMachine",
1934
- 7: "Cancelled",
1935
- 8: "Deleted",
1936
- 9: "Running",
1937
- 10: "Completed",
1938
- 11: "Failed",
1939
- 12: "FailedRemovedFromMachine",
1940
- }
1941
-
1942
- return status_map.get(status_value, f"Unknown({status_value})")
1889
+ def parse_command_status(self, status_value) -> str:
1890
+ """Command state name from an integer (legacy ``commandStatus``) or a name (current ``status``)."""
1891
+ return command_status_name({"status": status_value})
1943
1892
 
@@ -42,6 +42,84 @@ def create_simulation_domain(
42
42
  }
43
43
 
44
44
 
45
+ # ---------------------------------------------------------------------------
46
+ # Primitive helpers
47
+ #
48
+ # Every primitive dict mirrors RayfosCommandModels.PrimitiveBase (Primitives.cs):
49
+ # Id (uuid), Name, ShapeType (enum serialised as its name, e.g. "Cylinder"),
50
+ # MediumId, Position {X,Y,Z: Length}, PositionAnchor (enum name), Rotation
51
+ # {X,Y,Z} in degrees, plus the per-shape properties. Lengths are {Value, Unit}.
52
+ # "PositioningMode": "CenterAndSize" is the Studio bookkeeping key the existing
53
+ # helpers already emit (ignored by the server).
54
+ #
55
+ # Sub-pixel flag ("SubPixel"): True = this primitive's interface voxels are
56
+ # probe-averaged (create_wavesim_properties' subpixel_factor probes per axis);
57
+ # False = centre-sampled staircase for this primitive; None (default) = key
58
+ # omitted, the primitive follows the simulation-level PermittivitySampling.Mode
59
+ # (the pre-1.10 behaviour).
60
+ # ---------------------------------------------------------------------------
61
+
62
+ _POSITION_ANCHORS = (
63
+ "Center", "BottomLeftFront", "BottomCenter", "BottomRightFront", "BottomLeftBack",
64
+ "BottomRightBack", "TopCenter", "TopLeftFront", "TopRightFront", "TopLeftBack", "TopRightBack",
65
+ )
66
+ # Anchors each shape's GetCanonicalCenterPosition() accepts (others throw on the server).
67
+ _BOX_ANCHORS = ("Center", "BottomLeftFront", "BottomCenter", "TopCenter")
68
+ _AXIAL_ANCHORS = ("Center", "BottomCenter", "TopCenter") # Cylinder, Cone, Prism
69
+ _AXES = ("X", "Y", "Z")
70
+
71
+
72
+ def _check_anchor(anchor: str, allowed: Tuple[str, ...], shape: str) -> str:
73
+ a = str(anchor)
74
+ if a not in allowed:
75
+ raise ValueError(f"position_anchor for {shape} must be one of {list(allowed)}, got {anchor!r}")
76
+ return a
77
+
78
+
79
+ def _check_axis(axis: str) -> str:
80
+ a = str(axis).upper()
81
+ if a not in _AXES:
82
+ raise ValueError(f"axis must be 'X', 'Y' or 'Z', got {axis!r}")
83
+ return a
84
+
85
+
86
+ def _length(value: float, unit: str) -> Dict[str, Any]:
87
+ return {"Value": float(value), "Unit": unit}
88
+
89
+
90
+ def _primitive_base(
91
+ shape_type: str,
92
+ name: str,
93
+ medium_id: str,
94
+ x: float,
95
+ y: float,
96
+ z: float,
97
+ rotation_x: float,
98
+ rotation_y: float,
99
+ rotation_z: float,
100
+ position_anchor: str,
101
+ unit: str,
102
+ subpixel: Optional[bool],
103
+ ) -> Dict[str, Any]:
104
+ prim: Dict[str, Any] = {
105
+ "Id": str(uuid.uuid4()),
106
+ "Name": name,
107
+ "ShapeType": shape_type,
108
+ "MediumId": medium_id,
109
+ "PositioningMode": "CenterAndSize",
110
+ "Position": {"X": _length(x, unit), "Y": _length(y, unit), "Z": _length(z, unit)},
111
+ "PositionAnchor": position_anchor,
112
+ "Rotation": {"X": float(rotation_x), "Y": float(rotation_y), "Z": float(rotation_z)},
113
+ }
114
+ _apply_subpixel(prim, subpixel)
115
+ return prim
116
+
117
+
118
+ def _apply_subpixel(prim: Dict[str, Any], subpixel: Optional[bool]) -> None:
119
+ if subpixel is not None:
120
+ prim["SubPixel"] = bool(subpixel)
121
+
122
+
45
123
  def create_stl_primitive(
46
124
  name: str,
47
125
  file_id: str,
@@ -55,7 +133,8 @@ def create_stl_primitive(
55
133
  scale_x: float = 1.0,
56
134
  scale_y: float = 1.0,
57
135
  scale_z: float = 1.0,
58
- unit: str = "Micrometers"
136
+ unit: str = "Micrometers",
137
+ subpixel: Optional[bool] = None,
59
138
  ) -> Dict[str, Any]:
60
139
  """
61
140
  Create STL primitive structure
@@ -68,8 +147,10 @@ def create_stl_primitive(
68
147
  rotation_x, rotation_y, rotation_z: Rotation angles in degrees
69
148
  scale_x, scale_y, scale_z: Scale factors
70
149
  unit: Unit of measurement
150
+ subpixel: True = sub-pixel average this mesh's interface voxels, False = staircase,
151
+ None (default) = key omitted, follow the simulation-level setting.
71
152
  """
72
- return {
153
+ prim = {
73
154
  "Id": str(uuid.uuid4()),
74
155
  "Name": name,
75
156
  "ShapeType": "StlModel",
@@ -94,6 +175,8 @@ def create_stl_primitive(
94
175
  },
95
176
  "_tempStlData": {}
96
177
  }
178
+ _apply_subpixel(prim, subpixel)
179
+ return prim
97
180
 
98
181
 
99
182
  def create_box_primitive(
@@ -105,19 +188,46 @@ def create_box_primitive(
105
188
  size_x: float,
106
189
  size_y: float,
107
190
  size_z: float,
108
- unit: str = "Micrometers"
191
+ unit: str = "Micrometers",
192
+ subpixel: Optional[bool] = None,
193
+ position_anchor: str = "Center",
194
+ rotation_x: float = 0.0,
195
+ rotation_y: float = 0.0,
196
+ rotation_z: float = 0.0,
109
197
  ) -> Dict[str, Any]:
110
- """Create a box primitive"""
111
- return {
198
+ """
199
+ Create a box primitive (BoxPrimitive, CenterAndSize representation).
200
+
201
+ Args:
202
+ name: Primitive name
203
+ medium_id: Medium/Material ID
204
+ center_x, center_y, center_z: Position (what it refers to is set by position_anchor)
205
+ size_x, size_y, size_z: Full extents along X/Y/Z (Size.Width/Height/Depth)
206
+ unit: Unit of measurement for position and size
207
+ subpixel: True = sub-pixel average this box's interface voxels, False = staircase,
208
+ None (default) = key omitted, follow the simulation-level setting.
209
+ position_anchor: "Center" (default), "BottomLeftFront" (minimum corner),
210
+ "BottomCenter" (centre of the -Y face) or "TopCenter" (centre of the +Y face).
211
+ The server rejects any other anchor for a box.
212
+ rotation_x, rotation_y, rotation_z: Rotation in degrees
213
+ """
214
+ anchor = _check_anchor(position_anchor, _BOX_ANCHORS, "Box")
215
+ prim: Dict[str, Any] = {
216
+ "Id": str(uuid.uuid4()),
217
+ # "Type" kept for older servers; "ShapeType" is the canonical discriminator (same value).
112
218
  "Type": "Box",
219
+ "ShapeType": "Box",
113
220
  "Name": name,
114
221
  "MediumId": medium_id,
115
222
  "PositioningMode": "CenterAndSize",
223
+ "Representation": "CenterAndSize",
116
224
  "Position": {
117
225
  "X": {"Value": center_x, "Unit": unit},
118
226
  "Y": {"Value": center_y, "Unit": unit},
119
227
  "Z": {"Value": center_z, "Unit": unit}
120
228
  },
229
+ "PositionAnchor": anchor,
230
+ "Rotation": {"X": float(rotation_x), "Y": float(rotation_y), "Z": float(rotation_z)},
121
231
  # Size3D on the server is Width/Height/Depth (X/Y/Z keys were silently ignored → 4 µm default).
122
232
  "Size": {
123
233
  "Width": {"Value": size_x, "Unit": unit},
@@ -125,6 +235,338 @@ def create_box_primitive(
125
235
  "Depth": {"Value": size_z, "Unit": unit}
126
236
  }
127
237
  }
238
+ _apply_subpixel(prim, subpixel)
239
+ return prim
240
+
241
+
242
+ def create_sphere_primitive(
243
+ name: str,
244
+ medium_id: str,
245
+ center_x: float,
246
+ center_y: float,
247
+ center_z: float,
248
+ radius: float,
249
+ rotation_x: float = 0.0,
250
+ rotation_y: float = 0.0,
251
+ rotation_z: float = 0.0,
252
+ position_anchor: str = "Center",
253
+ unit: str = "Micrometers",
254
+ subpixel: Optional[bool] = None,
255
+ ) -> Dict[str, Any]:
256
+ """
257
+ Create a sphere primitive (SpherePrimitive).
258
+
259
+ Args:
260
+ name: Primitive name
261
+ medium_id: Medium/Material ID
262
+ center_x, center_y, center_z: Sphere centre
263
+ radius: Radius (Length in ``unit``)
264
+ rotation_x, rotation_y, rotation_z: Rotation in degrees (no effect on a sphere)
265
+ position_anchor: Stored as given (default "Center"); the server always treats
266
+ Position as the centre of a sphere.
267
+ unit: Unit of measurement
268
+ subpixel: True / False / None (follow the simulation-level setting)
269
+ """
270
+ anchor = _check_anchor(position_anchor, _POSITION_ANCHORS, "Sphere")
271
+ prim = _primitive_base("Sphere", name, medium_id, center_x, center_y, center_z,
272
+ rotation_x, rotation_y, rotation_z, anchor, unit, subpixel)
273
+ prim["Radius"] = _length(radius, unit)
274
+ return prim
275
+
276
+
277
+ def create_ellipsoid_primitive(
278
+ name: str,
279
+ medium_id: str,
280
+ center_x: float,
281
+ center_y: float,
282
+ center_z: float,
283
+ radius_x: float,
284
+ radius_y: float,
285
+ radius_z: float,
286
+ rotation_x: float = 0.0,
287
+ rotation_y: float = 0.0,
288
+ rotation_z: float = 0.0,
289
+ position_anchor: str = "Center",
290
+ unit: str = "Micrometers",
291
+ subpixel: Optional[bool] = None,
292
+ ) -> Dict[str, Any]:
293
+ """
294
+ Create an ellipsoid primitive (EllipsoidPrimitive).
295
+
296
+ Args:
297
+ name: Primitive name
298
+ medium_id: Medium/Material ID
299
+ center_x, center_y, center_z: Ellipsoid centre
300
+ radius_x, radius_y, radius_z: Semi-axes along X/Y/Z (before rotation)
301
+ rotation_x, rotation_y, rotation_z: Rotation in degrees
302
+ position_anchor: Stored as given (default "Center"); the server always treats
303
+ Position as the centre of an ellipsoid.
304
+ unit: Unit of measurement
305
+ subpixel: True / False / None (follow the simulation-level setting)
306
+ """
307
+ anchor = _check_anchor(position_anchor, _POSITION_ANCHORS, "Ellipsoid")
308
+ prim = _primitive_base("Ellipsoid", name, medium_id, center_x, center_y, center_z,
309
+ rotation_x, rotation_y, rotation_z, anchor, unit, subpixel)
310
+ prim["RadiusX"] = _length(radius_x, unit)
311
+ prim["RadiusY"] = _length(radius_y, unit)
312
+ prim["RadiusZ"] = _length(radius_z, unit)
313
+ return prim
314
+
315
+
316
+ def create_cylinder_primitive(
317
+ name: str,
318
+ medium_id: str,
319
+ position_x: float,
320
+ position_y: float,
321
+ position_z: float,
322
+ radius: float,
323
+ height: float,
324
+ axis: str = "Z",
325
+ rotation_x: float = 0.0,
326
+ rotation_y: float = 0.0,
327
+ rotation_z: float = 0.0,
328
+ position_anchor: str = "BottomCenter",
329
+ unit: str = "Micrometers",
330
+ subpixel: Optional[bool] = None,
331
+ ) -> Dict[str, Any]:
332
+ """
333
+ Create a cylinder primitive (CylinderPrimitive).
334
+
335
+ Args:
336
+ name: Primitive name
337
+ medium_id: Medium/Material ID
338
+ position_x, position_y, position_z: Anchor point (see position_anchor)
339
+ radius: Radius
340
+ height: Extent along ``axis``
341
+ axis: "X", "Y" or "Z" (default "Z")
342
+ rotation_x, rotation_y, rotation_z: Rotation in degrees
343
+ position_anchor: "BottomCenter" (default, as the server and Studio): Position is the
344
+ centre of the base face and the cylinder extends +height along ``axis``;
345
+ "Center": Position is the geometric centre; "TopCenter": centre of the top face.
346
+ unit: Unit of measurement
347
+ subpixel: True / False / None (follow the simulation-level setting)
348
+ """
349
+ anchor = _check_anchor(position_anchor, _AXIAL_ANCHORS, "Cylinder")
350
+ prim = _primitive_base("Cylinder", name, medium_id, position_x, position_y, position_z,
351
+ rotation_x, rotation_y, rotation_z, anchor, unit, subpixel)
352
+ prim["Radius"] = _length(radius, unit)
353
+ prim["Height"] = _length(height, unit)
354
+ prim["Axis"] = _check_axis(axis)
355
+ return prim
356
+
357
+
358
+ def create_cone_primitive(
359
+ name: str,
360
+ medium_id: str,
361
+ position_x: float,
362
+ position_y: float,
363
+ position_z: float,
364
+ radius: float,
365
+ height: float,
366
+ axis: str = "Z",
367
+ rotation_x: float = 0.0,
368
+ rotation_y: float = 0.0,
369
+ rotation_z: float = 0.0,
370
+ position_anchor: str = "BottomCenter",
371
+ unit: str = "Micrometers",
372
+ subpixel: Optional[bool] = None,
373
+ ) -> Dict[str, Any]:
374
+ """
375
+ Create a cone primitive (ConePrimitive). The base is a disc of ``radius``; the apex
376
+ lies ``height`` further along +``axis``.
377
+
378
+ Args:
379
+ name: Primitive name
380
+ medium_id: Medium/Material ID
381
+ position_x, position_y, position_z: Anchor point (see position_anchor)
382
+ radius: Base radius
383
+ height: Base-to-apex distance along ``axis``
384
+ axis: "X", "Y" or "Z" (default "Z")
385
+ rotation_x, rotation_y, rotation_z: Rotation in degrees
386
+ position_anchor: "BottomCenter" (default, as the server and Studio): Position is the
387
+ centre of the base; "Center": Position is the CENTROID (height/4 above the base,
388
+ not height/2); "TopCenter": Position is the apex.
389
+ unit: Unit of measurement
390
+ subpixel: True / False / None (follow the simulation-level setting)
391
+ """
392
+ anchor = _check_anchor(position_anchor, _AXIAL_ANCHORS, "Cone")
393
+ prim = _primitive_base("Cone", name, medium_id, position_x, position_y, position_z,
394
+ rotation_x, rotation_y, rotation_z, anchor, unit, subpixel)
395
+ prim["Radius"] = _length(radius, unit)
396
+ prim["Height"] = _length(height, unit)
397
+ prim["Axis"] = _check_axis(axis)
398
+ return prim
399
+
400
+
401
+ def create_prism_primitive(
402
+ name: str,
403
+ medium_id: str,
404
+ position_x: float,
405
+ position_y: float,
406
+ position_z: float,
407
+ radius: float,
408
+ height: float,
409
+ number_of_sides: int = 6,
410
+ axis: str = "Z",
411
+ top_scale: float = 1.0,
412
+ twist_degrees: float = 0.0,
413
+ rotation_x: float = 0.0,
414
+ rotation_y: float = 0.0,
415
+ rotation_z: float = 0.0,
416
+ position_anchor: str = "BottomCenter",
417
+ unit: str = "Micrometers",
418
+ subpixel: Optional[bool] = None,
419
+ ) -> Dict[str, Any]:
420
+ """
421
+ Create a regular N-sided prism primitive (PrismPrimitive): a regular polygon with
422
+ ``number_of_sides`` corners on a circle of ``radius`` extruded ``height`` along ``axis``.
423
+
424
+ Args:
425
+ name: Primitive name
426
+ medium_id: Medium/Material ID
427
+ position_x, position_y, position_z: Anchor point (see position_anchor)
428
+ radius: Circumradius of the bottom polygon
429
+ height: Extent along ``axis``
430
+ number_of_sides: Polygon corners, integer >= 3 (3 = triangular prism, 4 = square,
431
+ large N approaches a cylinder). Default 6.
432
+ axis: "X", "Y" or "Z" (default "Z")
433
+ top_scale: Top-ring radius factor: 1 = straight prism (default), 0 = pyramid,
434
+ > 1 = flared
435
+ twist_degrees: Rotation of the top ring about ``axis`` in degrees (default 0)
436
+ rotation_x, rotation_y, rotation_z: Rotation in degrees
437
+ position_anchor: "BottomCenter" (default, as the server and Studio), "Center" or
438
+ "TopCenter" (±height/2 along ``axis``, as for a cylinder).
439
+ unit: Unit of measurement
440
+ subpixel: True / False / None (follow the simulation-level setting)
441
+ """
442
+ anchor = _check_anchor(position_anchor, _AXIAL_ANCHORS, "Prism")
443
+ n = int(number_of_sides)
444
+ if n != number_of_sides or n < 3:
445
+ raise ValueError(f"number_of_sides must be an integer >= 3, got {number_of_sides!r}")
446
+ if top_scale < 0:
447
+ raise ValueError(f"top_scale must be >= 0, got {top_scale!r}")
448
+ prim = _primitive_base("Prism", name, medium_id, position_x, position_y, position_z,
449
+ rotation_x, rotation_y, rotation_z, anchor, unit, subpixel)
450
+ prim["Radius"] = _length(radius, unit)
451
+ prim["Height"] = _length(height, unit)
452
+ prim["Axis"] = _check_axis(axis)
453
+ prim["NumberOfSides"] = n
454
+ prim["TopScale"] = float(top_scale)
455
+ prim["TwistDegrees"] = float(twist_degrees)
456
+ return prim
457
+
458
+
459
+ def create_gds_layer_config(
460
+ layer_number: int,
461
+ medium_id: Optional[str] = None,
462
+ height_um: Optional[float] = None,
463
+ z_offset_um: Optional[float] = None,
464
+ ) -> Dict[str, Any]:
465
+ """
466
+ Per-layer settings for create_gds_primitive(layer_configs=...) (GdsLayerConfig).
467
+
468
+ Args:
469
+ layer_number: GDS layer number
470
+ medium_id: Medium for this layer; None = the GDS primitive's medium_id
471
+ height_um: Extrusion thickness in micrometres; None = the primitive's layer_height_um
472
+ z_offset_um: Bottom of the layer in micrometres relative to the primitive;
473
+ None = auto-stacked
474
+ """
475
+ cfg: Dict[str, Any] = {"LayerNumber": int(layer_number)}
476
+ if medium_id is not None:
477
+ cfg["MediumId"] = medium_id
478
+ if height_um is not None:
479
+ cfg["Height"] = float(height_um)
480
+ if z_offset_um is not None:
481
+ cfg["ZOffset"] = float(z_offset_um)
482
+ return cfg
483
+
484
+
485
+ def create_gds_structure_override(
486
+ structure_name: str,
487
+ layer_overrides: Optional[List[Dict[str, Any]]] = None,
488
+ ) -> Dict[str, Any]:
489
+ """
490
+ Per-structure (GDS cell) overrides for create_gds_primitive(structure_overrides=...)
491
+ (GdsStructureConfig). ``layer_overrides`` is a list built with create_gds_layer_config()
492
+ (same keys as GdsStructureLayerOverride: LayerNumber, MediumId, Height, ZOffset); unset
493
+ fields inherit from the primitive's layer_configs.
494
+ """
495
+ cfg: Dict[str, Any] = {"StructureName": structure_name}
496
+ if layer_overrides is not None:
497
+ cfg["LayerOverrides"] = list(layer_overrides)
498
+ return cfg
499
+
500
+
501
+ def create_gds_primitive(
502
+ name: str,
503
+ file_id: str,
504
+ medium_id: str,
505
+ center_x: float = 0.0,
506
+ center_y: float = 0.0,
507
+ center_z: float = 0.0,
508
+ layer_height_um: float = 1.0,
509
+ selected_layers: Optional[List[int]] = None,
510
+ layer_configs: Optional[List[Dict[str, Any]]] = None,
511
+ structure_overrides: Optional[List[Dict[str, Any]]] = None,
512
+ metadata_file_id: Optional[str] = None,
513
+ voxelization_method: str = "ViaStl",
514
+ rotation_x: float = 0.0,
515
+ rotation_y: float = 0.0,
516
+ rotation_z: float = 0.0,
517
+ scale_x: float = 1.0,
518
+ scale_y: float = 1.0,
519
+ scale_z: float = 1.0,
520
+ position_anchor: str = "Center",
521
+ unit: str = "Micrometers",
522
+ subpixel: Optional[bool] = None,
523
+ ) -> Dict[str, Any]:
524
+ """
525
+ Create a GDSII layout primitive (GdsModelPrimitive) from an uploaded .gds file.
526
+
527
+ The layout's polygons are extruded per layer; Position is the centre of the top
528
+ structure's bounding box. Layer thickness and offsets are plain numbers in
529
+ MICROMETRES on the server model (not Length objects), whatever ``unit`` is.
530
+
531
+ Args:
532
+ name: Primitive name
533
+ file_id: File id of the uploaded GDS file (GdsDataId), as for create_stl_primitive
534
+ medium_id: Default medium for every layer
535
+ center_x, center_y, center_z: Position of the layout centre
536
+ layer_height_um: Default extrusion thickness of each layer in µm (LayerHeight)
537
+ selected_layers: Layer numbers to include; None = all layers
538
+ layer_configs: Per-layer settings from create_gds_layer_config(); None = every layer
539
+ uses medium_id / layer_height_um (legacy mode)
540
+ structure_overrides: Per-structure (cell) overrides from
541
+ create_gds_structure_override(); None = none
542
+ metadata_file_id: Optional uploaded layer-metadata YML file id (GdsMetadataId)
543
+ voxelization_method: "ViaStl" (default) or "Direct"
544
+ rotation_x, rotation_y, rotation_z: Rotation in degrees
545
+ scale_x, scale_y, scale_z: Scale factors
546
+ position_anchor: Stored as given (default "Center"); the server treats Position as
547
+ the layout centre whatever the anchor.
548
+ unit: Unit of Position
549
+ subpixel: True / False / None (follow the simulation-level setting)
550
+ """
551
+ anchor = _check_anchor(position_anchor, _POSITION_ANCHORS, "GdsModel")
552
+ method = str(voxelization_method)
553
+ if method not in ("ViaStl", "Direct"):
554
+ raise ValueError(f"voxelization_method must be 'ViaStl' or 'Direct', got {voxelization_method!r}")
555
+ prim = _primitive_base("GdsModel", name, medium_id, center_x, center_y, center_z,
556
+ rotation_x, rotation_y, rotation_z, anchor, unit, subpixel)
557
+ prim["GdsDataId"] = file_id
558
+ prim["ScaleFactors"] = {"X": float(scale_x), "Y": float(scale_y), "Z": float(scale_z)}
559
+ prim["LayerHeight"] = float(layer_height_um)
560
+ prim["VoxelizationMethod"] = method
561
+ if metadata_file_id is not None:
562
+ prim["GdsMetadataId"] = metadata_file_id
563
+ if selected_layers is not None:
564
+ prim["SelectedLayers"] = [int(n) for n in selected_layers]
565
+ if layer_configs is not None:
566
+ prim["LayerConfigs"] = list(layer_configs)
567
+ if structure_overrides is not None:
568
+ prim["StructureOverrides"] = list(structure_overrides)
569
+ return prim
128
570
 
129
571
 
130
572
  def create_medium(
@@ -189,9 +631,8 @@ def create_point_source(
189
631
  return {
190
632
  "Id": str(uuid.uuid4()),
191
633
  "Name": name,
192
- "Id": str(uuid.uuid4()),
193
634
  "SourceType": "Point",
194
- "isEnabled": True,
635
+ "IsEnabled": True,
195
636
  "UseInternalPythonSource": False,
196
637
  "Position": {
197
638
  "X": {"Value": position_x, "Unit": unit},
@@ -357,7 +798,9 @@ def create_voxels_model_primitive(
357
798
  grid_nz: int = 32,
358
799
  data_format: str = "Float32",
359
800
  data_type: str = "RefractiveIndex",
360
- unit: str = "Micrometers"
801
+ unit: str = "Micrometers",
802
+ subpixel: Optional[bool] = None,
803
+ antialias_width: Optional[float] = None,
361
804
  ) -> Dict[str, Any]:
362
805
  """
363
806
  Create VoxelsModel primitive for arbitrary refractive index distributions
@@ -372,6 +815,19 @@ def create_voxels_model_primitive(
372
815
  data_format: 'Float32', 'Float64', 'Complex64', 'Complex128'
373
816
  data_type: 'RefractiveIndex', 'ComplexRefractiveIndex', 'Permittivity', 'ComplexPermittivity'
374
817
  unit: Unit of measurement
818
+
819
+ subpixel: True anti-aliases the array on the service:
820
+ - array at the simulation voxel size: softened IN PLACE by a normalised Gaussian of
821
+ ``antialias_width`` voxels on the permittivity (same resolution, volume kept).
822
+ On reference shapes it halves the interface error vs true sub-pixel values.
823
+ - array finer by a whole number per axis (voxel size = simulation voxel / k): each
824
+ k_x x k_y x k_z block is averaged into one simulation voxel (the same rule as the
825
+ shapes' sub-pixel probes); cells the array only partly covers blend with what
826
+ lies underneath.
827
+ None/False: the array must be at the simulation voxel size and is copied voxel for
828
+ voxel. The simulation-level ``subpixel_factor`` does not apply to voxel arrays.
829
+ antialias_width: Gaussian width (standard deviation, voxels) for the in-place case;
830
+ None = 0.5 (best on average); thin waveguides prefer ~0.7. Range 0.1-3.
375
831
  """
376
832
  return {
377
833
  "ShapeType": "VoxelsModel",
@@ -395,7 +851,9 @@ def create_voxels_model_primitive(
395
851
  },
396
852
  "DataFormat": data_format,
397
853
  "DataType": data_type,
398
- "Rotation": {"X": 0, "Y": 0, "Z": 0}
854
+ "Rotation": {"X": 0, "Y": 0, "Z": 0},
855
+ **({"SubPixel": bool(subpixel)} if subpixel is not None else {}),
856
+ **({"AntiAliasWidth": float(antialias_width)} if antialias_width is not None else {}),
399
857
  }
400
858
 
401
859
 
@@ -701,6 +1159,16 @@ def create_wavesim_properties(
701
1159
  derives the iteration count from this distance and it OVERRIDES
702
1160
  residual_norm_max_iterations (threshold still stops the run early).
703
1161
  C++ engine only.
1162
+ subpixel_factor: Sub-pixel probe count per axis, s (1..64, 16 is the reference
1163
+ value). It is used by every primitive whose SubPixel is true; a primitive with
1164
+ no explicit flag follows the simulation-level mode, which this argument sets to
1165
+ SubPixel (s > 1) — so passing it alone smooths every primitive, STL and GDSII
1166
+ (the pre-1.10 behaviour). Per-primitive flags (``subpixel=True`` on the
1167
+ primitive helpers) are the preferred way to choose which shapes are smoothed.
1168
+ When this is None but some primitive carries an explicit flag, the factor is
1169
+ emitted with Mode "Staircase" and s = 16, so flagged primitives have a probe
1170
+ count and unflagged ones stay staircased. VoxelsModel arrays are never
1171
+ re-sampled.
704
1172
  """
705
1173
  props = {
706
1174
  "SimulationDomain": simulation_domain,
@@ -764,6 +1232,8 @@ def create_wavesim_properties(
764
1232
  # Staircase. The service probes each voxel of primitives / STL / GDSII at
765
1233
  # subpixel_factor points per axis and stores the mean permittivity; it does NOT
766
1234
  # re-sample a VoxelsModel array (average that yourself before upload).
1235
+ # Mode is the default for primitives without an explicit "SubPixel" flag; flagged
1236
+ # primitives decide for themselves and use Factor as their probe count.
767
1237
  if subpixel_factor is not None:
768
1238
  s = int(subpixel_factor)
769
1239
  if s < 1 or s > 64:
@@ -774,6 +1244,13 @@ def create_wavesim_properties(
774
1244
  "Rule": "ArithmeticMean",
775
1245
  "InterfaceOnly": True,
776
1246
  }
1247
+ elif any(isinstance(p, dict) and "SubPixel" in p for p in (primitives or [])):
1248
+ props["PermittivitySampling"] = {
1249
+ "Mode": "Staircase",
1250
+ "Factor": 16,
1251
+ "Rule": "ArithmeticMean",
1252
+ "InterfaceOnly": True,
1253
+ }
777
1254
 
778
1255
  return props
779
1256
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: wavesimpro
3
- Version: 1.9.0
3
+ Version: 1.10.1
4
4
  Summary: WavesimPro Python API — cloud execution client for the Rayfos simulation platform
5
5
  Author: Rayfos
6
6
  Project-URL: Repository, https://github.com/rayfos/wavesimpro
@@ -59,8 +59,15 @@ field = wp.read_binary_field(run.download("output.bin"))
59
59
 
60
60
  ## What the API covers
61
61
 
62
- - Setup helpers for domains, media, primitives (box, STL, voxel arrays), sources
63
- (point, plane wave, Gaussian beam, custom field, engine-solved waveguide mode) and monitors.
62
+ - Setup helpers for domains, media, primitives (box, sphere, ellipsoid, cylinder, cone,
63
+ N-sided prism, STL, GDSII layouts, voxel arrays), sources (point, plane wave, Gaussian beam,
64
+ custom field, custom-from-file, engine-solved waveguide mode) and monitors:
65
+ `create_box_primitive`, `create_sphere_primitive`, `create_ellipsoid_primitive`,
66
+ `create_cylinder_primitive`, `create_cone_primitive`, `create_prism_primitive`,
67
+ `create_stl_primitive`, `create_gds_primitive` (with `create_gds_layer_config` /
68
+ `create_gds_structure_override`), `create_voxels_model_primitive`.
69
+ - Per-primitive sub-pixel flag (`subpixel=True/False` on every shape, STL and GDSII helper)
70
+ to choose which shapes get smoothed interfaces.
64
71
  - Per-axis pixel sizes (`pixel_size=[dx, dy, dz]`) and sub-pixel permittivity averaging
65
72
  (`subpixel_factor=`), when the compute node supports them.
66
73
  - Preflight memory check and cost estimate before a run.
@@ -16,4 +16,5 @@ src/wavesimpro.egg-info/PKG-INFO
16
16
  src/wavesimpro.egg-info/SOURCES.txt
17
17
  src/wavesimpro.egg-info/dependency_links.txt
18
18
  src/wavesimpro.egg-info/requires.txt
19
- src/wavesimpro.egg-info/top_level.txt
19
+ src/wavesimpro.egg-info/top_level.txt
20
+ tests/test_command_wait.py
@@ -0,0 +1,64 @@
1
+ """wait_for_command_completion must work with both server shapes of the command status:
2
+ current servers send the state NAME in "status"; older ones sent an integer "commandStatus"."""
3
+ import pytest
4
+
5
+ from wavesimpro import RayfosClient, command_status_name, is_terminal_command_status
6
+ from wavesimpro.exceptions import RayfosAPIError
7
+
8
+
9
+ class FakeClient(RayfosClient):
10
+ """RayfosClient whose HTTP layer is replaced by a scripted sequence of status replies."""
11
+
12
+ def __init__(self, replies, created=None): # noqa: D401 - no network setup
13
+ self._replies = list(replies)
14
+ self._created = created
15
+ self.created_with = None
16
+
17
+ def get_command_status(self, command_id):
18
+ return self._replies.pop(0) if len(self._replies) > 1 else self._replies[0]
19
+
20
+ def create_command(self, project_id, version_id, engine_preference='Any', use_gpu=None, preferred_machine_id=None):
21
+ self.created_with = dict(engine_preference=engine_preference, use_gpu=use_gpu, preferred_machine_id=preferred_machine_id)
22
+ return dict(self._created)
23
+
24
+
25
+ def test_current_server_shape_returns_on_completed():
26
+ c = FakeClient([{"id": "c1", "status": "Running"}, {"id": "c1", "status": "Completed", "outputFileIds": ["f"]}])
27
+ final = c.wait_for_command_completion("c1", poll_interval=0, timeout=5)
28
+ assert final["outputFileIds"] == ["f"]
29
+
30
+
31
+ def test_legacy_integer_shape_still_works():
32
+ c = FakeClient([{"commandStatus": 9}, {"commandStatus": 10}])
33
+ assert c.wait_for_command_completion("c1", poll_interval=0, timeout=5)["commandStatus"] == 10
34
+
35
+
36
+ @pytest.mark.parametrize("state", ["Failed", "FailedRemovedFromMachine", "Cancelled", "Deleted"])
37
+ def test_terminal_failures_raise(state):
38
+ c = FakeClient([{"status": state, "statusText": "boom"}])
39
+ with pytest.raises(RayfosAPIError):
40
+ c.wait_for_command_completion("c1", poll_interval=0, timeout=5)
41
+
42
+
43
+ def test_timeout_names_the_last_state():
44
+ c = FakeClient([{"status": "Running"}])
45
+ with pytest.raises(TimeoutError, match="Running"):
46
+ c.wait_for_command_completion("c1", poll_interval=0.01, timeout=0.05)
47
+
48
+
49
+ def test_status_helpers():
50
+ assert command_status_name({"status": "Completed"}) == "Completed"
51
+ assert command_status_name({"status": "completed"}) == "Completed"
52
+ assert command_status_name({"commandStatus": 11}) == "Failed"
53
+ assert command_status_name({"commandStatus": "10"}) == "Completed"
54
+ assert command_status_name({"status": "", "commandStatus": 9}) == "Running"
55
+ assert command_status_name({}) == "Unknown"
56
+ assert is_terminal_command_status("Completed") and not is_terminal_command_status("Running")
57
+ assert RayfosClient._parse_command_status("Running") == "Running"
58
+ assert RayfosClient._parse_command_status(10) == "Completed"
59
+
60
+
61
+ def test_create_command_and_wait_pins_the_machine_and_accepts_either_id_key():
62
+ c = FakeClient([{"status": "Completed"}], created={"commandId": "c9", "status": "New"})
63
+ c.create_command_and_wait("p", "v", local_machine_service_id="node-1", poll_interval=0, timeout=5)
64
+ assert c.created_with == {"engine_preference": "Any", "use_gpu": None, "preferred_machine_id": "node-1"}
File without changes