luminarycloud 0.20.0__py3-none-any.whl → 0.22.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.
Files changed (95) hide show
  1. luminarycloud/__init__.py +5 -1
  2. luminarycloud/_client/client.py +5 -0
  3. luminarycloud/_client/http_client.py +10 -8
  4. luminarycloud/_feature_flag.py +22 -0
  5. luminarycloud/_helpers/_create_simulation.py +7 -2
  6. luminarycloud/_helpers/_upload_mesh.py +1 -0
  7. luminarycloud/_helpers/download.py +3 -1
  8. luminarycloud/_helpers/pagination.py +62 -0
  9. luminarycloud/_helpers/proto_decorator.py +13 -5
  10. luminarycloud/_helpers/upload.py +18 -12
  11. luminarycloud/_proto/api/v0/luminarycloud/feature_flag/feature_flag_pb2.py +55 -0
  12. luminarycloud/_proto/api/v0/luminarycloud/feature_flag/feature_flag_pb2.pyi +52 -0
  13. luminarycloud/_proto/api/v0/luminarycloud/feature_flag/feature_flag_pb2_grpc.py +72 -0
  14. luminarycloud/_proto/api/v0/luminarycloud/feature_flag/feature_flag_pb2_grpc.pyi +35 -0
  15. luminarycloud/_proto/api/v0/luminarycloud/geometry/geometry_pb2.py +168 -124
  16. luminarycloud/_proto/api/v0/luminarycloud/geometry/geometry_pb2.pyi +133 -4
  17. luminarycloud/_proto/api/v0/luminarycloud/geometry/geometry_pb2_grpc.py +66 -0
  18. luminarycloud/_proto/api/v0/luminarycloud/geometry/geometry_pb2_grpc.pyi +20 -0
  19. luminarycloud/_proto/api/v0/luminarycloud/inference/inference_pb2.py +8 -8
  20. luminarycloud/_proto/api/v0/luminarycloud/inference/inference_pb2.pyi +5 -5
  21. luminarycloud/_proto/api/v0/luminarycloud/mesh/mesh_pb2.py +74 -73
  22. luminarycloud/_proto/api/v0/luminarycloud/mesh/mesh_pb2.pyi +17 -3
  23. luminarycloud/_proto/api/v0/luminarycloud/physics_ai/physics_ai_pb2.py +33 -20
  24. luminarycloud/_proto/api/v0/luminarycloud/physics_ai/physics_ai_pb2.pyi +21 -1
  25. luminarycloud/_proto/api/v0/luminarycloud/project/project_pb2.py +16 -16
  26. luminarycloud/_proto/api/v0/luminarycloud/project/project_pb2.pyi +7 -3
  27. luminarycloud/_proto/api/v0/luminarycloud/simulation/simulation_pb2.py +97 -61
  28. luminarycloud/_proto/api/v0/luminarycloud/simulation/simulation_pb2.pyi +72 -3
  29. luminarycloud/_proto/api/v0/luminarycloud/simulation/simulation_pb2_grpc.py +34 -0
  30. luminarycloud/_proto/api/v0/luminarycloud/simulation/simulation_pb2_grpc.pyi +12 -0
  31. luminarycloud/_proto/api/v0/luminarycloud/simulation_template/simulation_template_pb2.py +33 -31
  32. luminarycloud/_proto/api/v0/luminarycloud/simulation_template/simulation_template_pb2.pyi +23 -2
  33. luminarycloud/_proto/api/v0/luminarycloud/vis/vis_pb2.py +68 -19
  34. luminarycloud/_proto/api/v0/luminarycloud/vis/vis_pb2.pyi +98 -0
  35. luminarycloud/_proto/api/v0/luminarycloud/vis/vis_pb2_grpc.py +33 -0
  36. luminarycloud/_proto/api/v0/luminarycloud/vis/vis_pb2_grpc.pyi +10 -0
  37. luminarycloud/_proto/assistant/assistant_pb2.py +74 -41
  38. luminarycloud/_proto/assistant/assistant_pb2.pyi +64 -2
  39. luminarycloud/_proto/assistant/assistant_pb2_grpc.py +33 -0
  40. luminarycloud/_proto/assistant/assistant_pb2_grpc.pyi +10 -0
  41. luminarycloud/_proto/base/base_pb2.py +20 -7
  42. luminarycloud/_proto/base/base_pb2.pyi +38 -0
  43. luminarycloud/_proto/cad/shape_pb2.py +39 -19
  44. luminarycloud/_proto/cad/shape_pb2.pyi +86 -34
  45. luminarycloud/_proto/cad/transformation_pb2.py +60 -16
  46. luminarycloud/_proto/cad/transformation_pb2.pyi +138 -32
  47. luminarycloud/_proto/client/simulation_pb2.py +490 -348
  48. luminarycloud/_proto/client/simulation_pb2.pyi +570 -8
  49. luminarycloud/_proto/geometry/geometry_pb2.py +77 -63
  50. luminarycloud/_proto/geometry/geometry_pb2.pyi +42 -3
  51. luminarycloud/_proto/hexmesh/hexmesh_pb2.py +24 -18
  52. luminarycloud/_proto/hexmesh/hexmesh_pb2.pyi +23 -2
  53. luminarycloud/_proto/inferenceservice/inferenceservice_pb2.py +10 -10
  54. luminarycloud/_proto/inferenceservice/inferenceservice_pb2.pyi +5 -5
  55. luminarycloud/_proto/quantity/quantity_options_pb2.py +6 -6
  56. luminarycloud/_proto/quantity/quantity_options_pb2.pyi +10 -1
  57. luminarycloud/_proto/quantity/quantity_pb2.py +176 -167
  58. luminarycloud/_proto/quantity/quantity_pb2.pyi +11 -5
  59. luminarycloud/enum/__init__.py +1 -0
  60. luminarycloud/enum/gpu_type.py +2 -0
  61. luminarycloud/enum/quantity_type.py +9 -0
  62. luminarycloud/enum/vis_enums.py +23 -3
  63. luminarycloud/feature_modification.py +45 -35
  64. luminarycloud/geometry.py +104 -8
  65. luminarycloud/geometry_version.py +57 -3
  66. luminarycloud/meshing/mesh_generation_params.py +8 -8
  67. luminarycloud/params/enum/_enum_wrappers.py +537 -30
  68. luminarycloud/params/simulation/adaptive_mesh_refinement_.py +4 -0
  69. luminarycloud/params/simulation/physics/__init__.py +0 -1
  70. luminarycloud/params/simulation/physics/periodic_pair_.py +12 -31
  71. luminarycloud/physics_ai/architectures.py +5 -5
  72. luminarycloud/physics_ai/inference.py +13 -13
  73. luminarycloud/physics_ai/solution.py +3 -1
  74. luminarycloud/pipelines/__init__.py +11 -3
  75. luminarycloud/pipelines/api.py +240 -4
  76. luminarycloud/pipelines/arguments.py +15 -0
  77. luminarycloud/pipelines/core.py +113 -96
  78. luminarycloud/pipelines/{operators.py → stages.py} +96 -39
  79. luminarycloud/project.py +15 -47
  80. luminarycloud/simulation.py +66 -3
  81. luminarycloud/simulation_param.py +0 -9
  82. luminarycloud/types/matrix3.py +12 -0
  83. luminarycloud/vis/__init__.py +2 -0
  84. luminarycloud/vis/interactive_report.py +79 -93
  85. luminarycloud/vis/report.py +219 -65
  86. luminarycloud/vis/visualization.py +60 -0
  87. luminarycloud/volume_selection.py +132 -69
  88. {luminarycloud-0.20.0.dist-info → luminarycloud-0.22.0.dist-info}/METADATA +1 -1
  89. {luminarycloud-0.20.0.dist-info → luminarycloud-0.22.0.dist-info}/RECORD +90 -89
  90. luminarycloud/params/simulation/physics/periodic_pair/__init__.py +0 -2
  91. luminarycloud/params/simulation/physics/periodic_pair/periodicity_type/__init__.py +0 -2
  92. luminarycloud/params/simulation/physics/periodic_pair/periodicity_type/rotational_periodicity_.py +0 -31
  93. luminarycloud/params/simulation/physics/periodic_pair/periodicity_type/translational_periodicity_.py +0 -29
  94. luminarycloud/params/simulation/physics/periodic_pair/periodicity_type_.py +0 -25
  95. {luminarycloud-0.20.0.dist-info → luminarycloud-0.22.0.dist-info}/WHEEL +0 -0
luminarycloud/project.py CHANGED
@@ -12,6 +12,7 @@ import concurrent
12
12
 
13
13
  import luminarycloud as lc
14
14
  from luminarycloud._helpers.named_variables import _named_variables_to_proto
15
+ from luminarycloud._helpers.pagination import PaginationIterator
15
16
  from luminarycloud.params.simulation.adjoint_ import Adjoint
16
17
 
17
18
  from ._client import get_default_client
@@ -198,8 +199,6 @@ class Project(ProtoWrapperBase):
198
199
  def load_geometry_to_setup(self, geometry: "Geometry") -> None:
199
200
  """
200
201
  Load a geometry to the setup phase.
201
- NOTE: this operation is irreversible and deletes all the existing meshes and simulations
202
- in the project.
203
202
 
204
203
  Parameters
205
204
  ----------
@@ -324,6 +323,8 @@ class Project(ProtoWrapperBase):
324
323
  names_to_file_paths: Dict[str, Union[PathLike[Any], str]],
325
324
  params: hexmeshpb.HexMeshSpec,
326
325
  use_internal_wrap: bool = False,
326
+ request_id: Optional[str] = None,
327
+ n_vcpus: Optional[int] = None,
327
328
  ) -> "Mesh":
328
329
  """
329
330
  Creates a hex mesh. Only for internal use.
@@ -361,7 +362,14 @@ class Project(ProtoWrapperBase):
361
362
 
362
363
  params.use_wrap = use_internal_wrap
363
364
 
364
- req = meshpb.CreateHexMeshRequest(project_id=self.id, hex_mesh_config=params)
365
+ if request_id is None:
366
+ request_id = str(uuid.uuid4())
367
+ req = meshpb.CreateHexMeshRequest(
368
+ project_id=self.id,
369
+ hex_mesh_config=params,
370
+ request_id=request_id,
371
+ n_vcpus=n_vcpus,
372
+ )
365
373
 
366
374
  res: meshpb.CreateHexMeshResponse = client.CreateHexMesh(req)
367
375
  get_mesh_res = client.GetMesh(meshpb.GetMeshRequest(id=res.mesh_id))
@@ -785,53 +793,13 @@ def list_projects() -> list[Project]:
785
793
  return list(iterate_projects())
786
794
 
787
795
 
788
- class ProjectIterator:
796
+ class ProjectIterator(PaginationIterator[Project]):
789
797
  """Iterator class for projects that provides length hint."""
790
798
 
791
- def __init__(self, page_size: int):
792
- self._page_size: int = page_size
793
- self._page_token: str = ""
794
- self._total_count: Optional[int] = None
795
- self._current_page: Optional[list[projectpb.Project]] = None
796
- self._client = get_default_client()
797
- self._iterated_count: int = 0
798
-
799
- def __iter__(self) -> "ProjectIterator":
800
- return self
801
-
802
- def __next__(self) -> Project:
803
- if self._current_page is None:
804
- self._fetch_next_page()
805
-
806
- # _current_page really can't be None here, but this assertion is needed to appease mypy
807
- assert self._current_page is not None
808
-
809
- if len(self._current_page) == 0:
810
- if not self._page_token:
811
- raise StopIteration
812
- self._fetch_next_page()
813
-
814
- self._iterated_count += 1
815
-
816
- return Project(self._current_page.pop(0))
817
-
818
- def _fetch_next_page(self) -> None:
819
- req = projectpb.ListProjectsRequest(page_size=self._page_size, page_token=self._page_token)
799
+ def _fetch_page(self, page_size: int, page_token: str) -> tuple[list[Project], str, int]:
800
+ req = projectpb.ListProjectsRequest(page_size=page_size, page_token=page_token)
820
801
  res = self._client.ListProjects(req)
821
-
822
- self._current_page = list(res.projects)
823
- self._page_token = res.next_page_token
824
-
825
- # Set length hint on first fetch if available
826
- if self._total_count is None:
827
- self._total_count = res.total_count or 0
828
-
829
- def __length_hint__(self) -> int:
830
- if self._total_count is None:
831
- # Fetch first page to get total size if not already fetched
832
- if self._current_page is None:
833
- self._fetch_next_page()
834
- return max(0, (self._total_count or 0) - self._iterated_count)
802
+ return [Project(p) for p in res.projects], res.next_page_token, res.total_count
835
803
 
836
804
 
837
805
  def iterate_projects(page_size: int = 50) -> ProjectIterator:
@@ -195,6 +195,12 @@ class Simulation(ProtoWrapperBase):
195
195
  """
196
196
  Downloads surface outputs (e.g. lift, drag, ...) in csv format.
197
197
 
198
+ Unless `reference_values` is explicitly passed, the Simulation's reference values -- i.e.
199
+ the ones specified when the Simulation was created -- will be used for computing
200
+ non-dimensional output quantities. While the Luminary Cloud UI lets you update the reference
201
+ values on a Simulation result after it has run, those updates only affect the output
202
+ calculations seen in the UI, they have no effect on the ones retrieved by this method.
203
+
198
204
  Parameters
199
205
  ----------
200
206
  quantity_type : luminarycloud.enum.QuantityType
@@ -206,9 +212,9 @@ class Simulation(ProtoWrapperBase):
206
212
  Other Parameters
207
213
  ----------------
208
214
  reference_values : ReferenceValues, optional
209
- Reference values used for computing forces, moments and
210
- other non-dimensional output quantities. If not provided, default
211
- reference values will be used.
215
+ Reference values used for computing forces, moments, and other non-dimensional output
216
+ quantities. If not provided, the simulation's reference values will be used, i.e., the
217
+ ones specified when the simulation was created.
212
218
  calculation_type : CalculationType, optional
213
219
  Whether the calculation should be done for all the surfaces together or each surface
214
220
  individually. Default is AGGREGATE.
@@ -305,6 +311,19 @@ class Simulation(ProtoWrapperBase):
305
311
  req = simulationpb.GetSimulationParametersRequest(id=self.id)
306
312
  return SimulationParam.from_proto(get_default_client().GetSimulationParameters(req))
307
313
 
314
+ def _get_workflow_id(self) -> Optional[str]:
315
+ """
316
+ Retrieves the workflow ID associated with the current simulation.
317
+
318
+ Returns
319
+ -------
320
+ str | None
321
+ The workflow ID corresponding to this simulation's ID, or None if the simulation
322
+ has no associated workflow.
323
+ """
324
+ result = _get_workflow_ids([self.id])
325
+ return result.get(self.id)
326
+
308
327
  @deprecated(
309
328
  "Use get_parameters() instead. This method will be removed in a future release.",
310
329
  )
@@ -355,3 +374,47 @@ def get_simulation(id: SimulationID) -> Simulation:
355
374
  req = simulationpb.GetSimulationRequest(id=id)
356
375
  res = get_default_client().GetSimulation(req)
357
376
  return Simulation(res.simulation)
377
+
378
+
379
+ def _get_workflow_ids(simulation_ids: list[SimulationID]) -> dict[SimulationID, str]:
380
+ """
381
+ Get the workflow IDs corresponding to simulation IDs.
382
+
383
+ This is useful for mapping between UI-created simulations (which have workflow IDs)
384
+ and the simulation IDs used in the API.
385
+
386
+ Parameters
387
+ ----------
388
+ simulation_ids : list[SimulationID]
389
+ The simulation IDs to look up.
390
+
391
+ Returns
392
+ -------
393
+ dict[SimulationID, str]
394
+ A mapping from simulation ID to workflow ID. Only simulation IDs that were
395
+ successfully resolved to workflow IDs are present as keys. Simulation IDs are
396
+ omitted if:
397
+
398
+ - The simulation ID does not exist
399
+ - The user lacks access to the simulation's project
400
+ - The simulation has no associated workflow ID
401
+
402
+ Examples
403
+ --------
404
+ >>> import luminarycloud as lc
405
+ >>> sim_ids = [lc.SimulationID("sim_123"), lc.SimulationID("sim_456")]
406
+ >>> workflow_ids = lc._get_workflow_ids(sim_ids)
407
+ >>> print(workflow_ids)
408
+ {SimulationID('sim_123'): 'wf_abc', SimulationID('sim_456'): 'wf_def'}
409
+
410
+ >>> # Check if a simulation has a workflow
411
+ >>> sim_id = lc.SimulationID("sim_123")
412
+ >>> if sim_id in workflow_ids:
413
+ ... print(f"Workflow ID: {workflow_ids[sim_id]}")
414
+ ... else:
415
+ ... print("No workflow found")
416
+ """
417
+ req = simulationpb.GetWorkflowIDsRequest(simulation_ids=simulation_ids)
418
+ res = get_default_client().GetWorkflowIDs(req)
419
+ # Return dict with SimulationID keys (not str keys)
420
+ return {SimulationID(sim_id): wf_id for sim_id, wf_id in res.data.items()}
@@ -232,15 +232,6 @@ class SimulationParam(_SimulationParam):
232
232
  f"physics {_stringify_identifier(v.physics_identifier)}. Overwriting..."
233
233
  ),
234
234
  )
235
- _remove_from_list_with_warning(
236
- _list=volume_physics_pairs,
237
- _accessor=lambda v: get_id(v.physics_identifier),
238
- _to_remove=get_id(physics.physics_identifier),
239
- _warning_message=lambda v: (
240
- f"Physics {_stringify_identifier(physics.physics_identifier)} has already been "
241
- f"assigned to volume {_stringify_identifier(v.volume_identifier)}. Overwriting..."
242
- ),
243
- )
244
235
 
245
236
  if volume_identifier.id not in (get_id(v.volume_identifier) for v in self.volume_entity):
246
237
  self.volume_entity.append(VolumeEntity(volume_identifier=volume_identifier))
@@ -20,7 +20,19 @@ class Matrix3:
20
20
  c=basepb.Vector3(x=self.c.x, y=self.c.y, z=self.c.z),
21
21
  )
22
22
 
23
+ def _to_ad_proto(self) -> basepb.AdMatrix3:
24
+ return basepb.AdMatrix3(
25
+ a=self.a._to_ad_proto(),
26
+ b=self.b._to_ad_proto(),
27
+ c=self.c._to_ad_proto(),
28
+ )
29
+
23
30
  def _from_proto(self, proto: basepb.Matrix3) -> None:
24
31
  self.a = Vector3(x=proto.a.x, y=proto.a.y, z=proto.a.z)
25
32
  self.b = Vector3(x=proto.b.x, y=proto.b.y, z=proto.b.z)
26
33
  self.c = Vector3(x=proto.c.x, y=proto.c.y, z=proto.c.z)
34
+
35
+ def _from_ad_proto(self, proto: basepb.AdMatrix3) -> None:
36
+ self.a = Vector3.from_ad_proto(proto.a)
37
+ self.b = Vector3.from_ad_proto(proto.b)
38
+ self.c = Vector3.from_ad_proto(proto.c)
@@ -6,6 +6,8 @@ from .visualization import (
6
6
  list_quantities as list_quantities,
7
7
  list_quantities as list_quantities,
8
8
  list_cameras as list_cameras,
9
+ RangeResult as RangeResult,
10
+ range_query as range_query,
9
11
  get_camera as get_camera,
10
12
  DirectionalCamera as DirectionalCamera,
11
13
  LookAtCamera as LookAtCamera,
@@ -1,10 +1,6 @@
1
1
  import io
2
- from . import ExtractOutput
3
- from .vis_util import _InternalToken, _get_status
4
2
  from .visualization import RenderOutput
5
- from ..enum import ExtractStatusType
6
- from .._client import get_default_client
7
- from .._proto.api.v0.luminarycloud.vis import vis_pb2
3
+ from .report import ReportEntry
8
4
 
9
5
  try:
10
6
  import luminarycloud_jupyter as lcj
@@ -12,42 +8,24 @@ except ImportError:
12
8
  lcj = None
13
9
 
14
10
 
15
- # TODO Will/Matt: this could be something like what we store in the DB
16
- # A report can contain a list of report entries that reference post proc.
17
- # extracts + styling info for how they should be displayed
18
- class ReportEntry:
19
- def __init__(
20
- self, project_id: str, extract_ids: list[str] = [], metadata: dict[str, str | float] = {}
21
- ) -> None:
22
- self._project_id = project_id
23
- self._extract_ids = extract_ids
24
- self._extracts: list[ExtractOutput | RenderOutput] = []
25
- self._metadata = metadata
26
-
27
- # Download all extracts for this report entry
28
- def download_extracts(self) -> None:
29
- self._extracts = []
30
- for eid in self._extract_ids:
31
- status = _get_status(self._project_id, eid)
32
- if status != ExtractStatusType.COMPLETED:
33
- raise Exception(f"Extract {eid} is not complete")
34
- req = vis_pb2.DownloadExtractRequest()
35
- req.extract_id = eid
36
- req.project_id = self._project_id
37
- # TODO: This is a bit awkward in that we download the extract to figure out what type
38
- # it is, but this is just a temporary thing, later we'll have a report DB table that
39
- # stores the extracts for a report and their types, etc.
40
- res: vis_pb2.DownloadExtractResponse = get_default_client().DownloadExtract(req)
41
- extract = (
42
- ExtractOutput(_InternalToken())
43
- if res.HasField("line_data")
44
- else RenderOutput(_InternalToken())
45
- )
46
- extract._set_data(eid, self._project_id, eid, eid, status)
47
- self._extracts.append(extract)
11
+ class InteractiveReport:
12
+ """
13
+ Interactive report widget with lazy loading for large datasets.
48
14
 
15
+ How it works:
16
+ 1. on initialization:
17
+ - sends metadata for all rows (for filtering/selection)
18
+ - downloads the first row (to determine grid dimensions)
19
+ - other rows remain unloaded
20
+
21
+ 2. on user request:
22
+ - _load_row_data() is called with row index
23
+ - downloads and sends images/plots for that specific row
24
+ - python sets row_states to 'loading' -> 'loaded' (or 'error')
25
+
26
+ This allows working with 1000+ row datasets without waiting for all data upfront.
27
+ """
49
28
 
50
- class InteractiveReport:
51
29
  # TODO Will/Matt: this list of report entries could be how we store stuff in the DB
52
30
  # for interactive reports, to reference the post proc. extracts. A report is essentially
53
31
  # a bunch of extracts + metadata.
@@ -59,62 +37,70 @@ class InteractiveReport:
59
37
  if len(self.entries) == 0:
60
38
  raise ValueError("Invalid number of entries, must be > 0")
61
39
 
62
- report_data = []
63
- for row, re in enumerate(self.entries):
64
- row_data = []
65
- re.download_extracts()
66
- for extract in re._extracts:
67
- if isinstance(extract, RenderOutput):
68
- image_and_label = extract.download_images()
69
- row_data.extend([il[0] for il in image_and_label])
70
- else:
71
- plot_data = extract.download_data()
72
- # Plot absolute pressure for each intersection curve we have
73
- # TODO will: make these params of the extract/report entry
74
- # We'll pick the first item that's not x/y/z coordinates to be the data we plot
75
- x_axis = "x"
76
- y_axis = [n for n in plot_data[0][0][0] if n != "x" and n != "y" and n != "z"][
77
- 0
78
- ]
79
- scatter_plots = []
80
- for p in plot_data:
81
- data = p[0]
82
- x_idx = data[0].index(x_axis)
83
- y_idx = data[0].index(y_axis)
84
-
85
- scatter_data = lcj.ScatterPlotData()
86
- scatter_data.name = f"plot-{row}"
87
- for r in data[1:]:
88
- scatter_data.x.append(r[x_idx])
89
- scatter_data.y.append(r[y_idx])
90
- scatter_plots.append(scatter_data)
91
- row_data.append(scatter_plots)
92
-
93
- report_data.append(row_data)
94
-
95
- # TODO Validate the grid configuration is valid, all report entries should have the
96
- # same # of extract IDs and metadata keys
97
- # maybe not needed, b/c this is something we'd control internally later?
98
- nrows = len(report_data)
99
- ncols = len(report_data[0]) if len(report_data) > 0 else 0
100
-
101
- for i, r in enumerate(report_data):
102
- if len(r) != ncols:
103
- raise ValueError(
104
- f"Invalid report configuration: row {i} does not have {ncols} columns"
105
- )
40
+ # Determine grid dimensions by downloading first entry
41
+ # to understand the structure (number of columns)
42
+ first_entry = self.entries[0]
43
+ first_entry.download_extracts()
44
+
45
+ # Calculate actual number of columns by counting how many cells
46
+ # each extract produces (RenderOutput can produce multiple images)
47
+ ncols = 0
48
+ for extract in first_entry._extracts:
49
+ if isinstance(extract, RenderOutput):
50
+ image_and_label = extract.download_images()
51
+ ncols += len(image_and_label)
52
+ else:
53
+ ncols += 1 # Plot data extracts produce one cell
106
54
 
55
+ nrows = len(self.entries)
56
+
57
+ # Create widget with metadata but without data
107
58
  self.widget = lcj.EnsembleWidget([re._metadata for re in self.entries], nrows, ncols)
108
- for row, row_data in enumerate(report_data):
109
- for col, col_data in enumerate(row_data):
110
- if isinstance(col_data, list) and isinstance(col_data[0], lcj.ScatterPlotData):
111
- x_axis = "x"
112
- y_axis = "Absolute Pressure (Pa)"
113
- self.widget.set_cell_scatter_plot(
114
- row, col, f"{row} {y_axis} v {x_axis}", x_axis, y_axis, col_data
115
- )
116
- elif isinstance(col_data, io.BytesIO):
117
- self.widget.set_cell_data(row, col, col_data.getvalue(), "jpg")
59
+
60
+ # Set the callback for lazy loading row data
61
+ self.widget.set_row_data_callback(self._load_row_data)
62
+
63
+ def _load_row_data(self, row: int) -> None:
64
+ """
65
+ Load and send data for a specific row to the widget.
66
+ This is called on-demand when the user requests data for a row.
67
+ """
68
+ re = self.entries[row]
69
+
70
+ # Download extracts if not already downloaded
71
+ if len(re._extracts) == 0:
72
+ re.download_extracts()
73
+
74
+ # Process each extract and send to widget
75
+ # Track the actual column index as we may have multiple cells per extract
76
+ col = 0
77
+ for extract in re._extracts:
78
+ if isinstance(extract, RenderOutput):
79
+ image_and_label = extract.download_images()
80
+ # Each image gets its own column
81
+ for il in image_and_label:
82
+ self.widget.set_cell_data(row, col, il[0].getvalue(), "jpg")
83
+ col += 1
84
+ else:
85
+ plot_data = extract.download_data()
86
+ data = plot_data[0][0]
87
+ all_axis_labels = data[0]
88
+
89
+ axis_data = []
90
+ for axis_idx in range(len(all_axis_labels)):
91
+ axis_values = [row[axis_idx] for row in data[1:]]
92
+ axis_data.append(axis_values)
93
+
94
+ self.widget.set_cell_scatter_plot(
95
+ row,
96
+ col,
97
+ f"Row #{row} - Multi-axis Plot",
98
+ all_axis_labels,
99
+ axis_data,
100
+ plot_name=f"plot-{row}",
101
+ plot_mode="markers",
102
+ )
103
+ col += 1
118
104
 
119
105
  def _ipython_display_(self) -> None:
120
106
  """