wavesimpro 1.16.0__tar.gz → 1.17.0__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 (28) hide show
  1. {wavesimpro-1.16.0 → wavesimpro-1.17.0}/PKG-INFO +35 -1
  2. {wavesimpro-1.16.0 → wavesimpro-1.17.0}/README.md +34 -0
  3. {wavesimpro-1.16.0 → wavesimpro-1.17.0}/pyproject.toml +1 -1
  4. {wavesimpro-1.16.0 → wavesimpro-1.17.0}/src/wavesimpro/__init__.py +1 -1
  5. {wavesimpro-1.16.0 → wavesimpro-1.17.0}/src/wavesimpro/client.py +111 -3
  6. {wavesimpro-1.16.0 → wavesimpro-1.17.0}/src/wavesimpro/execute.py +10 -1
  7. {wavesimpro-1.16.0 → wavesimpro-1.17.0}/src/wavesimpro/json_helper.py +28 -6
  8. {wavesimpro-1.16.0 → wavesimpro-1.17.0}/src/wavesimpro/runs.py +35 -7
  9. {wavesimpro-1.16.0 → wavesimpro-1.17.0}/src/wavesimpro/simulate.py +17 -0
  10. {wavesimpro-1.16.0 → wavesimpro-1.17.0}/src/wavesimpro/validate.py +2 -1
  11. {wavesimpro-1.16.0 → wavesimpro-1.17.0}/src/wavesimpro.egg-info/PKG-INFO +35 -1
  12. {wavesimpro-1.16.0 → wavesimpro-1.17.0}/setup.cfg +0 -0
  13. {wavesimpro-1.16.0 → wavesimpro-1.17.0}/src/wavesimpro/__main__.py +0 -0
  14. {wavesimpro-1.16.0 → wavesimpro-1.17.0}/src/wavesimpro/binary_utils.py +0 -0
  15. {wavesimpro-1.16.0 → wavesimpro-1.17.0}/src/wavesimpro/config.py +0 -0
  16. {wavesimpro-1.16.0 → wavesimpro-1.17.0}/src/wavesimpro/exceptions.py +0 -0
  17. {wavesimpro-1.16.0 → wavesimpro-1.17.0}/src/wavesimpro/model.py +0 -0
  18. {wavesimpro-1.16.0 → wavesimpro-1.17.0}/src/wavesimpro/py.typed +0 -0
  19. {wavesimpro-1.16.0 → wavesimpro-1.17.0}/src/wavesimpro/setup.py +0 -0
  20. {wavesimpro-1.16.0 → wavesimpro-1.17.0}/src/wavesimpro/studio_mode.py +0 -0
  21. {wavesimpro-1.16.0 → wavesimpro-1.17.0}/src/wavesimpro.egg-info/SOURCES.txt +0 -0
  22. {wavesimpro-1.16.0 → wavesimpro-1.17.0}/src/wavesimpro.egg-info/dependency_links.txt +0 -0
  23. {wavesimpro-1.16.0 → wavesimpro-1.17.0}/src/wavesimpro.egg-info/requires.txt +0 -0
  24. {wavesimpro-1.16.0 → wavesimpro-1.17.0}/src/wavesimpro.egg-info/top_level.txt +0 -0
  25. {wavesimpro-1.16.0 → wavesimpro-1.17.0}/tests/test_boundary_profile.py +0 -0
  26. {wavesimpro-1.16.0 → wavesimpro-1.17.0}/tests/test_command_wait.py +0 -0
  27. {wavesimpro-1.16.0 → wavesimpro-1.17.0}/tests/test_engine_mode.py +0 -0
  28. {wavesimpro-1.16.0 → wavesimpro-1.17.0}/tests/test_studio_mode.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: wavesimpro
3
- Version: 1.16.0
3
+ Version: 1.17.0
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
@@ -75,4 +75,38 @@ field = wp.read_binary_field(run.download("output.bin"))
75
75
  - Preflight memory check and cost estimate before a run.
76
76
  - Binary upload/download helpers for permittivity volumes, source fields and monitors.
77
77
 
78
+ ## Monitors: which voxels you get
79
+
80
+ A monitor is an axis-aligned box in micrometres, relative to the coordinate-system centre. The
81
+ domain of N voxels spans [-S/2, S/2], so voxel *i* occupies [-S/2 + i·v, -S/2 + (i+1)·v] with
82
+ v = S/N (centre at -S/2 + (i+0.5)·v, for even and odd N alike). The compute node extracts the
83
+ **outer cover** of the box: every voxel it touches — first index floor(lo), last index
84
+ ceil(hi) − 1 with lo/hi the box edges in voxel units. An edge exactly on a voxel face does not
85
+ pull in the neighbour; a zero-thickness box is the one voxel containing the plane. So a box of
86
+ exactly k voxels aligned to the grid gives k planes, and the same box centred on a voxel centre
87
+ straddles k + 1; the result is never smaller than what you asked for unless the domain clamps it.
88
+
89
+ The node writes `monitors.json` next to the `monitor_<name>.bin` files with, per monitor, the
90
+ inclusive index ranges, the shape, the voxel size and the realised physical box; read it with
91
+ `run.monitor_regions()`. Monitor files are full resolution — nothing is downsampled on download.
92
+
93
+ ## Cost before upload
94
+
95
+ `client.preflight_check(..., max_iterations=n)` (called for you by `simulate*`, `submit` and
96
+ `execute_via_api`) returns a `cost` block on the cloud server: the upper-bound compute-token
97
+ cost at the run's iteration cap (voxels × iterations × field components × rate), your remaining
98
+ balance and whether the submit would be allowed. The run is charged for the iterations it
99
+ actually takes. The high-level calls stop before uploading anything when the cost is refused.
100
+ `client.get_quota()` returns the balance; `client.estimate_cost(project_id, version_id)` prices a
101
+ saved version.
102
+
103
+ ## Errors and warnings
104
+
105
+ Every `RayfosAPIError` message carries the server's full detail: the message, each
106
+ `errors[]`/`warnings[]` entry and, for a quota refusal, the estimated cost and remaining balance.
107
+ Pass `sampling_warnings=False` (or `configure(sampling_warnings=False)`) to silence the
108
+ "pixels per wavelength" advisory when your sampling is deliberate; Nyquist warnings stay.
109
+ `run.status()["commandStatus"]["engine"]` names the engine the node actually ran (`"Cpp"` or
110
+ `"Python"`), also for `engine="Any"`.
111
+
78
112
  See `CHANGELOG.md` for what each version added. Issues and feature requests: support@rayfos.com.
@@ -52,4 +52,38 @@ field = wp.read_binary_field(run.download("output.bin"))
52
52
  - Preflight memory check and cost estimate before a run.
53
53
  - Binary upload/download helpers for permittivity volumes, source fields and monitors.
54
54
 
55
+ ## Monitors: which voxels you get
56
+
57
+ A monitor is an axis-aligned box in micrometres, relative to the coordinate-system centre. The
58
+ domain of N voxels spans [-S/2, S/2], so voxel *i* occupies [-S/2 + i·v, -S/2 + (i+1)·v] with
59
+ v = S/N (centre at -S/2 + (i+0.5)·v, for even and odd N alike). The compute node extracts the
60
+ **outer cover** of the box: every voxel it touches — first index floor(lo), last index
61
+ ceil(hi) − 1 with lo/hi the box edges in voxel units. An edge exactly on a voxel face does not
62
+ pull in the neighbour; a zero-thickness box is the one voxel containing the plane. So a box of
63
+ exactly k voxels aligned to the grid gives k planes, and the same box centred on a voxel centre
64
+ straddles k + 1; the result is never smaller than what you asked for unless the domain clamps it.
65
+
66
+ The node writes `monitors.json` next to the `monitor_<name>.bin` files with, per monitor, the
67
+ inclusive index ranges, the shape, the voxel size and the realised physical box; read it with
68
+ `run.monitor_regions()`. Monitor files are full resolution — nothing is downsampled on download.
69
+
70
+ ## Cost before upload
71
+
72
+ `client.preflight_check(..., max_iterations=n)` (called for you by `simulate*`, `submit` and
73
+ `execute_via_api`) returns a `cost` block on the cloud server: the upper-bound compute-token
74
+ cost at the run's iteration cap (voxels × iterations × field components × rate), your remaining
75
+ balance and whether the submit would be allowed. The run is charged for the iterations it
76
+ actually takes. The high-level calls stop before uploading anything when the cost is refused.
77
+ `client.get_quota()` returns the balance; `client.estimate_cost(project_id, version_id)` prices a
78
+ saved version.
79
+
80
+ ## Errors and warnings
81
+
82
+ Every `RayfosAPIError` message carries the server's full detail: the message, each
83
+ `errors[]`/`warnings[]` entry and, for a quota refusal, the estimated cost and remaining balance.
84
+ Pass `sampling_warnings=False` (or `configure(sampling_warnings=False)`) to silence the
85
+ "pixels per wavelength" advisory when your sampling is deliberate; Nyquist warnings stay.
86
+ `run.status()["commandStatus"]["engine"]` names the engine the node actually ran (`"Cpp"` or
87
+ `"Python"`), also for `engine="Any"`.
88
+
55
89
  See `CHANGELOG.md` for what each version added. Issues and feature requests: support@rayfos.com.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "wavesimpro"
7
- version = "1.16.0"
7
+ version = "1.17.0"
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"
@@ -55,7 +55,7 @@ from .json_helper import (
55
55
  parse_progress_data,
56
56
  )
57
57
 
58
- __version__ = "1.16.0"
58
+ __version__ = "1.17.0"
59
59
  __all__ = [
60
60
  # Client
61
61
  "RayfosClient",
@@ -248,10 +248,11 @@ class RayfosClient:
248
248
  except requests.exceptions.HTTPError as e:
249
249
  status_code = response.status_code
250
250
 
251
- # Try to get error message from response
251
+ # The server's full error detail: message + errors[].details + the quota figures of
252
+ # a 402. It used to be only "message"; the useful part sat in exception.response.text.
252
253
  try:
253
254
  error_data = response.json()
254
- message = error_data.get("message") or error_data.get("error") or str(e)
255
+ message = format_error_body(error_data, str(e))
255
256
  except ValueError:
256
257
  message = response.text or str(e)
257
258
 
@@ -329,10 +330,11 @@ class RayfosClient:
329
330
  engine_preference: str = "Any",
330
331
  use_gpu: Optional[bool] = None,
331
332
  is_vectorial: Optional[bool] = None,
333
+ max_iterations: Optional[int] = None,
332
334
  ) -> Dict[str, Any]:
333
335
  """
334
336
  Ask the server whether a simulation of the given shape will fit on the
335
- available compute targets, BEFORE uploading any files.
337
+ available compute targets, and what it will cost, BEFORE uploading any files.
336
338
 
337
339
  This calls POST /api/preflightCheck. The server runs the engine's own
338
340
  memory predictor (Wavesim.MemoryPredict) on exactly the grid the engine
@@ -352,16 +354,31 @@ class RayfosClient:
352
354
  is_vectorial: True = Maxwell (3 field components), False = Helmholtz
353
355
  (scalar). Memory differs ~3x, so always pass it: this payload
354
356
  carries no sources for the server to infer it from.
357
+ max_iterations: the run's residual-norm iteration cap. The cost is
358
+ priced on it (voxels x iterations x components x rate), so pass
359
+ the value the run will use; None = the server default (10000).
355
360
 
356
361
  Returns:
357
362
  PreflightCheckResponse dict with keys:
358
363
  - status: "Ok" | "Warning" | "Error"
359
364
  - description: one-sentence verdict (memory, grid, which machine fits)
360
365
  - gpuPathMemoryGB / cpuPathMemoryGB: peak memory of each path
366
+ - hostMemoryGB: peak system RAM on the compute node for the requested
367
+ path (voxelization, or the result field gathered back to the host);
368
+ checked against each node's RAM since server 1.17.0
361
369
  - estimate: { nx, ny, nz, paddedNx, ..., totalMemoryGB, ... } for the requested path
362
370
  - machines: [{ machineId, gpuMemoryGB, cpuMemoryGB, fitsOnGpu, fitsOnCpu, ... }]
363
371
  - recommendedMachineId
364
372
  - suggestedAdjustments: [str, ...]
373
+ - cost (cloud server only, else absent/None): the compute-token
374
+ check the submit will apply:
375
+ { allowed, reason, enforced, computeTokensRemaining,
376
+ estimate: { estimatedCost, iterations, totalVoxels,
377
+ componentsPerCell, rate, target, ... } }
378
+ estimatedCost is the UPPER BOUND (max_iterations); the run is
379
+ reconciled to the iterations it actually took. Stop before
380
+ uploading when cost["allowed"] is False. See
381
+ ``preflight_cost_summary`` for a one-line rendering.
365
382
  """
366
383
  size_x, size_y, size_z = domain_um
367
384
  periodic_x, periodic_y, periodic_z = periodic
@@ -399,6 +416,7 @@ class RayfosClient:
399
416
  "periodicX": bool(periodic_x),
400
417
  "periodicY": bool(periodic_y),
401
418
  "periodicZ": bool(periodic_z),
419
+ **({"residualNormMaxIterations": int(max_iterations)} if max_iterations else {}),
402
420
  },
403
421
  "enginePreference": engine_preference,
404
422
  "useGpu": use_gpu,
@@ -415,6 +433,38 @@ class RayfosClient:
415
433
  response = self.session.post(url, json=body, timeout=self.config.timeout, verify=verify)
416
434
  return self._handle_response(response)
417
435
 
436
+ def get_quota(self) -> Dict[str, Any]:
437
+ """
438
+ The caller's compute-token and storage quota: GET /api/quota/me.
439
+
440
+ Returns a dict with (camelCase) ``computeTokensGranted``, ``computeTokensUsed``,
441
+ ``computeTokensReserved``, ``computeTokensRemaining``, the storage figures and the
442
+ allowance period. ``computeTokensRemaining`` is what a submit is checked against.
443
+ """
444
+ return self._get("/api/quota/me")
445
+
446
+ def estimate_cost(
447
+ self,
448
+ project_id: str,
449
+ version_id: str,
450
+ use_gpu: Optional[bool] = None,
451
+ engine_preference: str = "Any",
452
+ ) -> Dict[str, Any]:
453
+ """
454
+ Price a SAVED version without submitting it: POST /api/quota/estimate.
455
+
456
+ Same result shape as the ``cost`` block of ``preflight_check``: ``allowed``,
457
+ ``reason``, ``computeTokensRemaining`` and ``estimate.estimatedCost`` (the upper
458
+ bound at the version's max iterations). Use ``preflight_check(..., max_iterations=)``
459
+ instead when nothing has been uploaded yet.
460
+ """
461
+ return self._post("/api/quota/estimate", json={
462
+ "projectId": project_id,
463
+ "versionId": version_id,
464
+ "useGpu": use_gpu,
465
+ "enginePreference": engine_preference,
466
+ })
467
+
418
468
  def get_projects(self) -> List[Dict[str, Any]]:
419
469
  """
420
470
  Get all projects for the authenticated user
@@ -1890,3 +1940,61 @@ class RayfosClient:
1890
1940
  """Command state name from an integer (legacy ``commandStatus``) or a name (current ``status``)."""
1891
1941
  return command_status_name({"status": status_value})
1892
1942
 
1943
+
1944
+ def format_error_body(data: Any, fallback: str) -> str:
1945
+ """Render a Rayfos error body in full: ``message``, then each of ``errors[]`` /
1946
+ ``warnings[]`` (strings, or ``{code, message, field, details}`` objects from the server's
1947
+ simulation validation), then the figures of a quota refusal (402: ``estimatedCost``,
1948
+ ``remaining``, ``resolution``, ``maxGridCells``)."""
1949
+ if not isinstance(data, dict):
1950
+ return str(data) if data else fallback
1951
+ parts = []
1952
+ head = data.get("message") or data.get("error") or data.get("title") or data.get("detail")
1953
+ parts.append(str(head) if head else fallback)
1954
+ for key, label in (("errors", "error"), ("warnings", "warning")):
1955
+ items = data.get(key)
1956
+ if not isinstance(items, list):
1957
+ continue
1958
+ for it in items:
1959
+ if isinstance(it, dict):
1960
+ bits = [str(it[k]) for k in ("field", "code", "message", "details", "detail") if it.get(k)]
1961
+ text = ": ".join(bits) if bits else str(it)
1962
+ else:
1963
+ text = str(it)
1964
+ parts.append(f" [{label}] {text}")
1965
+ quota = [f"{k}={data[k]}" for k in ("reason", "estimatedCost", "remaining", "resolution", "totalVoxels", "maxGridCells")
1966
+ if data.get(k) is not None]
1967
+ if quota:
1968
+ parts.append(" " + ", ".join(quota))
1969
+ return "\n".join(parts)
1970
+
1971
+
1972
+ def preflight_cost_summary(preflight: Optional[Dict[str, Any]]) -> Optional[str]:
1973
+ """One line describing the ``cost`` block of a ``preflight_check`` result, or None when the
1974
+ server did not price the run (local machine service / older server)."""
1975
+ cost = (preflight or {}).get("cost") if isinstance(preflight, dict) else None
1976
+ if not isinstance(cost, dict):
1977
+ return None
1978
+ est = cost.get("estimate") or {}
1979
+ tokens = est.get("estimatedCost")
1980
+ remaining = cost.get("computeTokensRemaining")
1981
+ iters = est.get("iterations")
1982
+ voxels = est.get("totalVoxels")
1983
+ comps = est.get("componentsPerCell")
1984
+ text = f"Cost: up to {tokens:,} compute tokens" if isinstance(tokens, (int, float)) else "Cost: unknown"
1985
+ detail = []
1986
+ if voxels:
1987
+ detail.append(f"{voxels:,} voxels")
1988
+ if iters:
1989
+ detail.append(f"max {iters:,} iterations")
1990
+ if comps:
1991
+ detail.append(f"{comps} component(s)")
1992
+ if detail:
1993
+ text += " (" + " x ".join(detail) + "; charged for the iterations actually run)"
1994
+ if isinstance(remaining, (int, float)):
1995
+ text += f"; balance {remaining:,}"
1996
+ if cost.get("enforced") is False:
1997
+ text += "; quota not enforced on this server"
1998
+ elif cost.get("allowed") is False:
1999
+ text += f"; NOT allowed ({cost.get('reason')})"
2000
+ return text
@@ -19,7 +19,7 @@ from typing import Tuple, Optional, Callable, Dict, Any, List, Union, Sequence
19
19
 
20
20
  import numpy as np
21
21
 
22
- from .client import RayfosClient
22
+ from .client import RayfosClient, preflight_cost_summary
23
23
  from .binary_utils import (
24
24
  upload_array_as_binary_zipped,
25
25
  upload_source_field_as_binary_zipped,
@@ -147,6 +147,7 @@ def execute_via_api(
147
147
  alpha: float = 0.75,
148
148
  keep_residuals_list: bool = False,
149
149
  strict_validation: bool = False,
150
+ sampling_warnings: bool = True,
150
151
  gpu_memory_max_gb: Optional[float] = None,
151
152
  monitors: Optional[List[Dict[str, Any]]] = None,
152
153
  pseudo_propagation_distance: float = 0.0,
@@ -374,6 +375,7 @@ def execute_via_api(
374
375
  engine_preference=engine_preference,
375
376
  use_gpu=use_gpu,
376
377
  is_vectorial=is_vectorial,
378
+ max_iterations=max_iterations,
377
379
  )
378
380
  except Exception as exc:
379
381
  logger.warning(f"Preflight check failed ({exc}). Proceeding without it.")
@@ -393,6 +395,12 @@ def execute_via_api(
393
395
  (logger.warning if status == "warning" else logger.info)(f"Preflight: {pre_description}")
394
396
  for s in suggestions:
395
397
  logger.info(f" Suggestion: {s}")
398
+ cost_line = preflight_cost_summary(pre)
399
+ if cost_line:
400
+ logger.info(f"Preflight: {cost_line}")
401
+ cost = pre.get("cost") or {}
402
+ if cost.get("allowed") is False and cost.get("enforced", True):
403
+ raise RuntimeError(f"Preflight refused the simulation before upload: {cost_line}")
396
404
 
397
405
  # 6. Create or reuse project/version
398
406
  auto_create = not project_id or not version_id
@@ -422,6 +430,7 @@ def execute_via_api(
422
430
  em_data, sources_info, wavelength, ps,
423
431
  threshold, max_iterations, boundary_width, is_vectorial,
424
432
  strict_validation=strict_validation,
433
+ sampling_warnings=sampling_warnings,
425
434
  gpu_memory_max_gb=gpu_memory_max_gb,
426
435
  periodic=periodic,
427
436
  pseudo_propagation_distance=pseudo_propagation_distance,
@@ -1160,16 +1160,34 @@ def create_monitor(
1160
1160
  Create a monitor: a positioned 3D subcube (region of interest) of the field.
1161
1161
 
1162
1162
  The monitor result is written in the same binary format as output.bin
1163
- (complex64, all polarization components), so it is read with read_monitor_binary
1164
- / read_binary_field. Matches RayfosCommandModels.WavesimMonitor — there is a single
1165
- monitor type; there is no per-component recording, plane, frequency or decimation
1166
- (large monitors are decimated at fetch time, like output.bin).
1163
+ (complex64, all polarization components) at FULL resolution — nothing is
1164
+ downsampled on the way to you — so it is read with read_monitor_binary /
1165
+ read_binary_field. Matches RayfosCommandModels.WavesimMonitor.
1167
1166
 
1168
1167
  Two positioning modes:
1169
1168
  - CenterAndSize (default): ``center_*`` is the cuboid centre, ``size_*`` its
1170
1169
  Width/Height/Depth (X/Y/Z extents).
1171
1170
  - MinMaxBounds: pass ``min_bounds`` and ``max_bounds`` as (x, y, z) corners; the
1172
- center/size arguments are then ignored.
1171
+ center/size arguments are then ignored (Position/Size are filled in from the
1172
+ corners so every reader, Studio included, sees the same box).
1173
+
1174
+ Coordinates are relative to the coordinate-system centre of the setup (0 unless
1175
+ you moved it); the domain itself is centred there.
1176
+
1177
+ Which voxels a monitor selects (the same rule on every axis): the domain of N
1178
+ voxels spans [-S/2, S/2], so voxel i occupies [-S/2 + i*v, -S/2 + (i+1)*v] with
1179
+ v = S/N (its centre is at -S/2 + (i+0.5)*v, for even and odd N alike). The
1180
+ monitor takes the OUTER COVER of its box: every voxel the box touches, first
1181
+ index floor(lo), last index ceil(hi) - 1 with lo/hi the box edges in voxel
1182
+ units; an edge exactly on a voxel face does not pull in the neighbour, and a
1183
+ zero-thickness box selects the one voxel containing the plane. So a box of
1184
+ exactly k voxels aligned to the grid returns k planes, while the same box
1185
+ centred on a voxel centre straddles k+1. The realised region is never smaller
1186
+ than what you asked for (unless clamped by the domain). The compute node writes
1187
+ the exact result per monitor to ``monitors.json`` next to the monitor files:
1188
+ inclusive voxel index ranges, the shape, the voxel size and the realised
1189
+ physical box (``Run.monitor_regions()`` reads it). Work in multiples of the
1190
+ voxel size, aligned to voxel faces, when you need an exact plane count.
1173
1191
 
1174
1192
  Args:
1175
1193
  name: Monitor name.
@@ -1186,6 +1204,11 @@ def create_monitor(
1186
1204
  return {"X": _len(t[0]), "Y": _len(t[1]), "Z": _len(t[2])}
1187
1205
 
1188
1206
  use_bounds = min_bounds is not None and max_bounds is not None
1207
+ if use_bounds:
1208
+ lo = [min(float(a), float(b)) for a, b in zip(min_bounds, max_bounds)]
1209
+ hi = [max(float(a), float(b)) for a, b in zip(min_bounds, max_bounds)]
1210
+ center_x, center_y, center_z = [(l + h) / 2 for l, h in zip(lo, hi)]
1211
+ size_x, size_y, size_z = [h - l for l, h in zip(lo, hi)]
1189
1212
  return {
1190
1213
  "Id": str(uuid.uuid4()),
1191
1214
  "Name": name,
@@ -1205,7 +1228,6 @@ def create_monitor(
1205
1228
  "MaxBounds": _xyz(max_bounds) if use_bounds else None,
1206
1229
  }
1207
1230
 
1208
-
1209
1231
  def create_wavesim_properties(
1210
1232
  simulation_domain: Dict[str, Any],
1211
1233
  primitives: List[Dict[str, Any]],
@@ -34,7 +34,7 @@ from .binary_utils import (
34
34
  upload_array_as_binary_zipped,
35
35
  upload_source_field_as_binary_zipped,
36
36
  )
37
- from .client import RayfosClient, command_status_name, is_terminal_command_status
37
+ from .client import RayfosClient, command_status_name, is_terminal_command_status, preflight_cost_summary
38
38
  from .json_helper import (
39
39
  create_custom_field_source,
40
40
  create_simulation_domain,
@@ -536,8 +536,9 @@ class Setup:
536
536
  subpixel_factor=self.subpixel_factor if any_smooth else None,
537
537
  )
538
538
 
539
- def validate(self, strict: bool = False) -> List[str]:
540
- """Client-side parameter validation (the server preflight decides memory)."""
539
+ def validate(self, strict: bool = False, sampling_warnings: bool = True) -> List[str]:
540
+ """Client-side parameter validation (the server preflight decides memory).
541
+ ``sampling_warnings=False`` silences the pixels-per-wavelength advisory."""
541
542
  dx, dy, dz = self.pixel_sizes
542
543
  sources_info = []
543
544
  for s in self.field_sources:
@@ -551,7 +552,7 @@ class Setup:
551
552
  domain.array if domain is not None else None,
552
553
  sources_info, self.wavelength, self.pixel_sizes,
553
554
  self.threshold, self.max_iterations, self.boundary_width, self.is_vectorial,
554
- strict_validation=strict, periodic=self.periodic,
555
+ strict_validation=strict, sampling_warnings=sampling_warnings, periodic=self.periodic,
555
556
  pseudo_propagation_distance=self.pseudo_propagation_distance,
556
557
  data_type=domain.data_type if domain is not None else "Permittivity",
557
558
  grid_shape=self.grid, n_max=self.n_max(),
@@ -574,6 +575,7 @@ def submit(
574
575
  version_id: Optional[str] = None,
575
576
  machine_service_url: Optional[str] = None,
576
577
  strict_validation: bool = False,
578
+ sampling_warnings: bool = True,
577
579
  description: Optional[str] = None,
578
580
  client: Optional[RayfosClient] = None,
579
581
  ) -> "Run":
@@ -588,6 +590,8 @@ def submit(
588
590
  project_id, version_id: reuse an existing project/version instead of creating one.
589
591
  machine_service_url: talk to a machine service directly (local mode).
590
592
  strict_validation: soft validation issues raise instead of warning.
593
+ sampling_warnings: False silences the "pixels per wavelength" advisory when the
594
+ sampling is deliberate (Nyquist warnings stay).
591
595
  description: project name / job label (default: ``setup.name`` or the calling script).
592
596
  client: a ready ``RayfosClient`` (tests / custom sessions); api_key/api_url are then unused.
593
597
  """
@@ -617,7 +621,7 @@ def submit(
617
621
  f"Monitors: {len(setup.monitors)}")
618
622
 
619
623
  # 1. Client-side validation and overlap check (nothing uploaded yet).
620
- setup.validate(strict=strict_validation)
624
+ setup.validate(strict=strict_validation, sampling_warnings=sampling_warnings)
621
625
  setup.check_overlaps()
622
626
  props_preview = setup.to_properties() # raises early on an inconsistent setup
623
627
 
@@ -627,7 +631,7 @@ def submit(
627
631
  try:
628
632
  pre = client.preflight_check(domain_um=setup.domain_um, voxel_um=ps, boundary_sides_um=sides,
629
633
  periodic=setup.periodic, engine_preference=engine, use_gpu=use_gpu,
630
- is_vectorial=vectorial)
634
+ is_vectorial=vectorial, max_iterations=setup.max_iterations)
631
635
  except Exception as exc: # the preflight is advisory when the endpoint is unreachable
632
636
  logger.warning(f"Preflight check failed ({exc}). Proceeding without it.")
633
637
  pre = None
@@ -641,6 +645,12 @@ def submit(
641
645
  (logger.warning if status == "warning" else logger.info)(f"Preflight: {text}")
642
646
  for s in suggestions:
643
647
  logger.info(f" Suggestion: {s}")
648
+ cost_line = preflight_cost_summary(pre)
649
+ if cost_line:
650
+ logger.info(f"Preflight: {cost_line}")
651
+ cost = pre.get("cost") or {}
652
+ if cost.get("allowed") is False and cost.get("enforced", True):
653
+ raise RuntimeError(f"Preflight refused the simulation before upload: {cost_line}")
644
654
 
645
655
  # 3. Project / version.
646
656
  if not project_id or not version_id:
@@ -905,6 +915,23 @@ class Run:
905
915
  data = gzip.decompress(data)
906
916
  return json.loads(data.decode("utf-8-sig"))
907
917
 
918
+ def monitor_regions(self) -> Dict[str, Dict[str, Any]]:
919
+ """What each monitor file actually contains, from the ``monitors.json`` the compute node
920
+ writes next to the ``monitor_<name>.bin`` files: ``{name: {"File", "IndexMin", "IndexMax"
921
+ (inclusive voxel indices, x/y/z), "Shape", "VoxelSizeUm", "DomainShape", "RequestedMinUm",
922
+ "RequestedMaxUm", "RealisedMinUm", "RealisedMaxUm"}}``. The realised box is the outer cover
923
+ of the requested one (see ``create_monitor``), in the monitor's own coordinates. Raises
924
+ ``FileNotFoundError`` for runs from nodes older than 1.17 (no monitors.json)."""
925
+ f = self.find_file("monitors.json")
926
+ if f is None:
927
+ raise FileNotFoundError("the run has no monitors.json: no monitor, or the compute node predates 1.17")
928
+ data = self.client.download_file_data(self.project_id, self.version_id, f["id"], command_id=self.command_id)
929
+ if data[:2] == b"\x1f\x8b":
930
+ import gzip
931
+ data = gzip.decompress(data)
932
+ doc = json.loads(data.decode("utf-8-sig"))
933
+ return {m.get("Name", ""): m for m in doc.get("Monitors", [])}
934
+
908
935
  def download(self, name: str, dest: Optional[Union[str, Path]] = None) -> Path:
909
936
  """Download one output file by name (e.g. ``"output.bin"``, ``"wavesim.json"``,
910
937
  ``"permittivityDomain1.bin"``). ``dest`` is a directory or a file path; default:
@@ -952,7 +979,8 @@ class Run:
952
979
  return self._read_file("output.bin", dims)
953
980
 
954
981
  def get_monitors(self) -> List[np.ndarray]:
955
- """The monitor fields, in the order the monitors were added (disabled ones skipped)."""
982
+ """The monitor fields, in the order the monitors were added (disabled ones skipped), at
983
+ full resolution. ``monitor_regions()`` tells which voxels each one holds."""
956
984
  self._ensure_done()
957
985
  by_name = {f["name"].lower(): f for f in self.list_files()}
958
986
  ordered = [(m.get("Name", ""), monitor_output_filename(m.get("Name", "")))
@@ -42,6 +42,7 @@ _defaults = {
42
42
  "engine_preference": "Any",
43
43
  "use_gpu": None,
44
44
  "strict_validation": False,
45
+ "sampling_warnings": True,
45
46
  "gpu_memory_max_gb": None,
46
47
  }
47
48
 
@@ -55,6 +56,7 @@ def configure(
55
56
  engine_preference: Optional[str] = None,
56
57
  use_gpu: Optional[bool] = None,
57
58
  strict_validation=_UNSET,
59
+ sampling_warnings: Optional[bool] = None,
58
60
  gpu_memory_max_gb=_UNSET,
59
61
  ):
60
62
  """Set module-level defaults for remote execution.
@@ -72,6 +74,9 @@ def configure(
72
74
  strict_validation: When True, soft validation issues (currently: a
73
75
  source field larger than the domain) raise instead of warning.
74
76
  Default False — simulations are not stopped by these.
77
+ sampling_warnings: False silences the advisory "Only N pixels per
78
+ wavelength ... recommend >= 10" when your sampling is deliberate.
79
+ Nyquist-limit warnings are kept. Default True.
75
80
  gpu_memory_max_gb: DEPRECATED and ignored (1.6.2). Memory fit is
76
81
  decided by the server preflight (/api/preflightCheck), which knows
77
82
  the engine's real allocation model and the registered machines'
@@ -92,6 +97,8 @@ def configure(
92
97
  _defaults["use_gpu"] = use_gpu
93
98
  if strict_validation is not _UNSET:
94
99
  _defaults["strict_validation"] = bool(strict_validation)
100
+ if sampling_warnings is not None:
101
+ _defaults["sampling_warnings"] = bool(sampling_warnings)
95
102
  if gpu_memory_max_gb is not _UNSET:
96
103
  _defaults["gpu_memory_max_gb"] = gpu_memory_max_gb
97
104
 
@@ -115,6 +122,7 @@ def simulate(
115
122
  crop_boundaries: bool = True, # local only — accepted and ignored
116
123
  callback: Optional[Callable] = None,
117
124
  strict_validation: Optional[bool] = None,
125
+ sampling_warnings: Optional[bool] = None, # False silences the pixels-per-wavelength advisory
118
126
  gpu_memory_max_gb=_UNSET,
119
127
  monitors=None, # list of dicts from create_monitor(); each captures a 3D ROI of the field
120
128
  pseudo_propagation_distance: float = 0.0, # μm; 0 = off. Overrides max_iterations. C++ engine only.
@@ -178,6 +186,9 @@ def simulate(
178
186
  effective_strict = (
179
187
  _defaults["strict_validation"] if strict_validation is None else bool(strict_validation)
180
188
  )
189
+ effective_sampling = (
190
+ _defaults["sampling_warnings"] if sampling_warnings is None else bool(sampling_warnings)
191
+ )
181
192
  effective_gpu_max = (
182
193
  _defaults["gpu_memory_max_gb"] if gpu_memory_max_gb is _UNSET else gpu_memory_max_gb
183
194
  )
@@ -239,6 +250,7 @@ def simulate(
239
250
  keep_residuals_list=full_residuals,
240
251
  progress_callback=_progress_cb,
241
252
  strict_validation=effective_strict,
253
+ sampling_warnings=effective_sampling,
242
254
  gpu_memory_max_gb=effective_gpu_max,
243
255
  monitors=monitors,
244
256
  pseudo_propagation_distance=pseudo_propagation_distance,
@@ -278,6 +290,7 @@ def simulate_pro(
278
290
  description: Optional[str] = None,
279
291
  server: Optional[str] = None,
280
292
  strict_validation: Optional[bool] = None,
293
+ sampling_warnings: Optional[bool] = None, # False silences the pixels-per-wavelength advisory
281
294
  gpu_memory_max_gb=_UNSET,
282
295
  monitors=None, # list of dicts from create_monitor(); each captures a 3D ROI of the field
283
296
  pseudo_propagation_distance: float = 0.0, # μm; 0 = off. Overrides max_iterations. C++ engine only.
@@ -329,6 +342,9 @@ def simulate_pro(
329
342
  effective_strict = (
330
343
  _defaults["strict_validation"] if strict_validation is None else bool(strict_validation)
331
344
  )
345
+ effective_sampling = (
346
+ _defaults["sampling_warnings"] if sampling_warnings is None else bool(sampling_warnings)
347
+ )
332
348
  effective_gpu_max = (
333
349
  _defaults["gpu_memory_max_gb"] if gpu_memory_max_gb is _UNSET else gpu_memory_max_gb
334
350
  )
@@ -391,6 +407,7 @@ def simulate_pro(
391
407
  keep_residuals_list=full_residuals,
392
408
  progress_callback=_progress_cb,
393
409
  strict_validation=effective_strict,
410
+ sampling_warnings=effective_sampling,
394
411
  gpu_memory_max_gb=effective_gpu_max,
395
412
  monitors=monitors,
396
413
  pseudo_propagation_distance=pseudo_propagation_distance,
@@ -53,6 +53,7 @@ def validate_parameters(
53
53
  periodic: Optional[tuple] = None,
54
54
  pseudo_propagation_distance: float = 0.0,
55
55
  data_type: str = "RefractiveIndex",
56
+ sampling_warnings: bool = True, # False silences the "pixels per wavelength" advisory; Nyquist warnings stay
56
57
  ) -> List[str]:
57
58
  """
58
59
  Validate simulation parameters before upload.
@@ -213,7 +214,7 @@ def validate_parameters(
213
214
  if not anisotropic:
214
215
  break
215
216
  pixels_per_wavelength = wavelength / (n_max * max(pixel_sizes))
216
- if pixels_per_wavelength < 10:
217
+ if pixels_per_wavelength < 10 and sampling_warnings:
217
218
  warnings.append(
218
219
  f"Only {pixels_per_wavelength:.1f} pixels per wavelength in the "
219
220
  f"highest-index medium (n_max={n_max:.2f}) on the coarsest axis. Recommend >= 10 "
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: wavesimpro
3
- Version: 1.16.0
3
+ Version: 1.17.0
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
@@ -75,4 +75,38 @@ field = wp.read_binary_field(run.download("output.bin"))
75
75
  - Preflight memory check and cost estimate before a run.
76
76
  - Binary upload/download helpers for permittivity volumes, source fields and monitors.
77
77
 
78
+ ## Monitors: which voxels you get
79
+
80
+ A monitor is an axis-aligned box in micrometres, relative to the coordinate-system centre. The
81
+ domain of N voxels spans [-S/2, S/2], so voxel *i* occupies [-S/2 + i·v, -S/2 + (i+1)·v] with
82
+ v = S/N (centre at -S/2 + (i+0.5)·v, for even and odd N alike). The compute node extracts the
83
+ **outer cover** of the box: every voxel it touches — first index floor(lo), last index
84
+ ceil(hi) − 1 with lo/hi the box edges in voxel units. An edge exactly on a voxel face does not
85
+ pull in the neighbour; a zero-thickness box is the one voxel containing the plane. So a box of
86
+ exactly k voxels aligned to the grid gives k planes, and the same box centred on a voxel centre
87
+ straddles k + 1; the result is never smaller than what you asked for unless the domain clamps it.
88
+
89
+ The node writes `monitors.json` next to the `monitor_<name>.bin` files with, per monitor, the
90
+ inclusive index ranges, the shape, the voxel size and the realised physical box; read it with
91
+ `run.monitor_regions()`. Monitor files are full resolution — nothing is downsampled on download.
92
+
93
+ ## Cost before upload
94
+
95
+ `client.preflight_check(..., max_iterations=n)` (called for you by `simulate*`, `submit` and
96
+ `execute_via_api`) returns a `cost` block on the cloud server: the upper-bound compute-token
97
+ cost at the run's iteration cap (voxels × iterations × field components × rate), your remaining
98
+ balance and whether the submit would be allowed. The run is charged for the iterations it
99
+ actually takes. The high-level calls stop before uploading anything when the cost is refused.
100
+ `client.get_quota()` returns the balance; `client.estimate_cost(project_id, version_id)` prices a
101
+ saved version.
102
+
103
+ ## Errors and warnings
104
+
105
+ Every `RayfosAPIError` message carries the server's full detail: the message, each
106
+ `errors[]`/`warnings[]` entry and, for a quota refusal, the estimated cost and remaining balance.
107
+ Pass `sampling_warnings=False` (or `configure(sampling_warnings=False)`) to silence the
108
+ "pixels per wavelength" advisory when your sampling is deliberate; Nyquist warnings stay.
109
+ `run.status()["commandStatus"]["engine"]` names the engine the node actually ran (`"Cpp"` or
110
+ `"Python"`), also for `engine="Any"`.
111
+
78
112
  See `CHANGELOG.md` for what each version added. Issues and feature requests: support@rayfos.com.
File without changes