luminarycloud 0.19.1__py3-none-any.whl → 0.21.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 (63) hide show
  1. luminarycloud/__init__.py +2 -0
  2. luminarycloud/_client/client.py +2 -0
  3. luminarycloud/_helpers/_wait_for_mesh.py +6 -5
  4. luminarycloud/_helpers/_wait_for_simulation.py +3 -3
  5. luminarycloud/_helpers/pagination.py +62 -0
  6. luminarycloud/_helpers/upload.py +3 -6
  7. luminarycloud/_proto/api/v0/luminarycloud/geometry/geometry_pb2.py +168 -124
  8. luminarycloud/_proto/api/v0/luminarycloud/geometry/geometry_pb2.pyi +125 -3
  9. luminarycloud/_proto/api/v0/luminarycloud/geometry/geometry_pb2_grpc.py +66 -0
  10. luminarycloud/_proto/api/v0/luminarycloud/geometry/geometry_pb2_grpc.pyi +20 -0
  11. luminarycloud/_proto/api/v0/luminarycloud/inference/inference_pb2.py +8 -8
  12. luminarycloud/_proto/api/v0/luminarycloud/inference/inference_pb2.pyi +5 -5
  13. luminarycloud/_proto/api/v0/luminarycloud/vis/vis_pb2.py +124 -27
  14. luminarycloud/_proto/api/v0/luminarycloud/vis/vis_pb2.pyi +177 -0
  15. luminarycloud/_proto/api/v0/luminarycloud/vis/vis_pb2_grpc.py +99 -0
  16. luminarycloud/_proto/api/v0/luminarycloud/vis/vis_pb2_grpc.pyi +30 -0
  17. luminarycloud/_proto/assistant/assistant_pb2.py +61 -41
  18. luminarycloud/_proto/assistant/assistant_pb2.pyi +43 -1
  19. luminarycloud/_proto/assistant/assistant_pb2_grpc.py +33 -0
  20. luminarycloud/_proto/assistant/assistant_pb2_grpc.pyi +10 -0
  21. luminarycloud/_proto/base/base_pb2.py +9 -6
  22. luminarycloud/_proto/base/base_pb2.pyi +12 -0
  23. luminarycloud/_proto/client/simulation_pb2.py +490 -348
  24. luminarycloud/_proto/client/simulation_pb2.pyi +570 -8
  25. luminarycloud/_proto/inferenceservice/inferenceservice_pb2.py +10 -10
  26. luminarycloud/_proto/inferenceservice/inferenceservice_pb2.pyi +5 -5
  27. luminarycloud/_proto/quantity/quantity_pb2.py +24 -15
  28. luminarycloud/_proto/quantity/quantity_pb2.pyi +10 -4
  29. luminarycloud/enum/__init__.py +1 -0
  30. luminarycloud/enum/quantity_type.py +9 -0
  31. luminarycloud/enum/vis_enums.py +23 -3
  32. luminarycloud/exceptions.py +7 -1
  33. luminarycloud/geometry.py +44 -2
  34. luminarycloud/geometry_version.py +57 -3
  35. luminarycloud/mesh.py +1 -2
  36. luminarycloud/params/enum/_enum_wrappers.py +537 -30
  37. luminarycloud/params/simulation/adaptive_mesh_refinement_.py +4 -0
  38. luminarycloud/params/simulation/physics/__init__.py +0 -1
  39. luminarycloud/params/simulation/physics/periodic_pair_.py +12 -31
  40. luminarycloud/physics_ai/architectures.py +5 -5
  41. luminarycloud/physics_ai/inference.py +13 -13
  42. luminarycloud/pipelines/__init__.py +8 -0
  43. luminarycloud/pipelines/api.py +160 -10
  44. luminarycloud/pipelines/arguments.py +15 -0
  45. luminarycloud/pipelines/operators.py +74 -17
  46. luminarycloud/project.py +5 -44
  47. luminarycloud/simulation.py +10 -5
  48. luminarycloud/simulation_param.py +0 -9
  49. luminarycloud/vis/__init__.py +17 -0
  50. luminarycloud/vis/data_extraction.py +20 -4
  51. luminarycloud/vis/interactive_report.py +110 -0
  52. luminarycloud/vis/interactive_scene.py +29 -2
  53. luminarycloud/vis/report.py +252 -0
  54. luminarycloud/vis/visualization.py +127 -5
  55. luminarycloud/volume_selection.py +58 -9
  56. {luminarycloud-0.19.1.dist-info → luminarycloud-0.21.0.dist-info}/METADATA +1 -1
  57. {luminarycloud-0.19.1.dist-info → luminarycloud-0.21.0.dist-info}/RECORD +58 -60
  58. luminarycloud/params/simulation/physics/periodic_pair/__init__.py +0 -2
  59. luminarycloud/params/simulation/physics/periodic_pair/periodicity_type/__init__.py +0 -2
  60. luminarycloud/params/simulation/physics/periodic_pair/periodicity_type/rotational_periodicity_.py +0 -31
  61. luminarycloud/params/simulation/physics/periodic_pair/periodicity_type/translational_periodicity_.py +0 -29
  62. luminarycloud/params/simulation/physics/periodic_pair/periodicity_type_.py +0 -25
  63. {luminarycloud-0.19.1.dist-info → luminarycloud-0.21.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
@@ -785,53 +786,13 @@ def list_projects() -> list[Project]:
785
786
  return list(iterate_projects())
786
787
 
787
788
 
788
- class ProjectIterator:
789
+ class ProjectIterator(PaginationIterator[Project]):
789
790
  """Iterator class for projects that provides length hint."""
790
791
 
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)
792
+ def _fetch_page(self, page_size: int, page_token: str) -> tuple[list[Project], str, int]:
793
+ req = projectpb.ListProjectsRequest(page_size=page_size, page_token=page_token)
820
794
  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)
795
+ return [Project(p) for p in res.projects], res.next_page_token, res.total_count
835
796
 
836
797
 
837
798
  def iterate_projects(page_size: int = 50) -> ProjectIterator:
@@ -124,8 +124,7 @@ class Simulation(ProtoWrapperBase):
124
124
  interval_seconds=interval_seconds,
125
125
  timeout_seconds=timeout_seconds,
126
126
  )
127
- self._proto = get_simulation(self.id)._proto
128
- return self.status
127
+ return self.refresh().status
129
128
 
130
129
  def refresh(self) -> "Simulation":
131
130
  """
@@ -196,6 +195,12 @@ class Simulation(ProtoWrapperBase):
196
195
  """
197
196
  Downloads surface outputs (e.g. lift, drag, ...) in csv format.
198
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
+
199
204
  Parameters
200
205
  ----------
201
206
  quantity_type : luminarycloud.enum.QuantityType
@@ -207,9 +212,9 @@ class Simulation(ProtoWrapperBase):
207
212
  Other Parameters
208
213
  ----------------
209
214
  reference_values : ReferenceValues, optional
210
- Reference values used for computing forces, moments and
211
- other non-dimensional output quantities. If not provided, default
212
- 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.
213
218
  calculation_type : CalculationType, optional
214
219
  Whether the calculation should be done for all the surfaces together or each surface
215
220
  individually. Default is AGGREGATE.
@@ -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))
@@ -4,8 +4,14 @@ from .visualization import (
4
4
  EntityType as EntityType,
5
5
  list_renders as list_renders,
6
6
  list_quantities as list_quantities,
7
+ list_quantities as list_quantities,
8
+ list_cameras as list_cameras,
9
+ RangeResult as RangeResult,
10
+ range_query as range_query,
11
+ get_camera as get_camera,
7
12
  DirectionalCamera as DirectionalCamera,
8
13
  LookAtCamera as LookAtCamera,
14
+ CameraEntry as CameraEntry,
9
15
  )
10
16
 
11
17
  from .primitives import (
@@ -52,3 +58,14 @@ from .interactive_scene import (
52
58
  from .interactive_inference import (
53
59
  InteractiveInference as InteractiveInference,
54
60
  )
61
+
62
+ # Unreleased/internal for testing now
63
+
64
+ # from .report import (
65
+ # Report as Report,
66
+ # )
67
+
68
+ # from .interactive_report import (
69
+ # InteractiveReport as InteractiveReport,
70
+ # ReportEntry as ReportEntry,
71
+ # )
@@ -22,6 +22,7 @@ from luminarycloud.params.simulation.physics.fluid.boundary_conditions import Fa
22
22
  from .._helpers._code_representation import CodeRepr
23
23
  from ..types import SimulationID
24
24
  from collections import defaultdict
25
+ import copy
25
26
 
26
27
 
27
28
  logger = logging.getLogger(__name__)
@@ -227,14 +228,14 @@ class ExtractOutput:
227
228
  extract_id: str,
228
229
  project_id: str,
229
230
  name: str,
230
- desciption: str,
231
+ description: str,
231
232
  status: ExtractStatusType,
232
233
  ) -> None:
233
234
  self._extract_id = extract_id
234
235
  self._project_id = project_id
235
236
  self.status = status
236
237
  self.name = name
237
- self.description = desciption
238
+ self.description = description
238
239
 
239
240
  def __repr__(self) -> str:
240
241
  return f"ExtractOutput (Id: {self._extract_id} status: {self.status})"
@@ -563,7 +564,7 @@ class DataExtractor:
563
564
  extract_id=res.extract.extract_id,
564
565
  project_id=self._project_id,
565
566
  name=name,
566
- desciption=description,
567
+ description=description,
567
568
  status=ExtractStatusType(res.extract.status),
568
569
  )
569
570
  return extract_output
@@ -657,6 +658,21 @@ class DataExtractor:
657
658
 
658
659
  return imports + code
659
660
 
661
+ def clone(self, solution: Solution) -> "DataExtractor":
662
+ """
663
+ Clone this extract based on a new solution. This is a deep copy
664
+ operation. Both solution must be compatible with one another, meaning
665
+ they share tags or surfaces ids for extractors such as
666
+ IntersectionCurve.
667
+ """
668
+ if not isinstance(solution, Solution):
669
+ raise TypeError("Expected a Solution object.")
670
+ cloned = DataExtractor(solution)
671
+ for extract in self._extracts:
672
+ copied_extract = copy.deepcopy(extract)
673
+ cloned.add_data_extract(copied_extract)
674
+ return cloned
675
+
660
676
 
661
677
  def list_data_extracts(solution: Solution) -> list[ExtractOutput]:
662
678
  """
@@ -700,7 +716,7 @@ def list_data_extracts(solution: Solution) -> list[ExtractOutput]:
700
716
  extract_id=extract.extract_id,
701
717
  project_id=extract.project_id,
702
718
  name=extract.name,
703
- desciption=extract.description,
719
+ description=extract.description,
704
720
  status=ExtractStatusType(extract.status),
705
721
  )
706
722
  # This need to be fixed on the backend, but manually refreshing works for now.
@@ -0,0 +1,110 @@
1
+ import io
2
+ from .visualization import RenderOutput
3
+ from .report import ReportEntry
4
+
5
+ try:
6
+ import luminarycloud_jupyter as lcj
7
+ except ImportError:
8
+ lcj = None
9
+
10
+
11
+ class InteractiveReport:
12
+ """
13
+ Interactive report widget with lazy loading for large datasets.
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
+ """
28
+
29
+ # TODO Will/Matt: this list of report entries could be how we store stuff in the DB
30
+ # for interactive reports, to reference the post proc. extracts. A report is essentially
31
+ # a bunch of extracts + metadata.
32
+ def __init__(self, entries: list[ReportEntry]) -> None:
33
+ if not lcj:
34
+ raise ImportError("InteractiveScene requires luminarycloud[jupyter] to be installed")
35
+
36
+ self.entries = entries
37
+ if len(self.entries) == 0:
38
+ raise ValueError("Invalid number of entries, must be > 0")
39
+
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
54
+
55
+ nrows = len(self.entries)
56
+
57
+ # Create widget with metadata but without data
58
+ self.widget = lcj.EnsembleWidget([re._metadata for re in self.entries], nrows, ncols)
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
104
+
105
+ def _ipython_display_(self) -> None:
106
+ """
107
+ When the InteractiveReport is shown in Jupyter we show the underlying widget
108
+ to run the widget's frontend code
109
+ """
110
+ self.widget._ipython_display_()
@@ -18,7 +18,16 @@ try:
18
18
  import luminarycloud_jupyter as lcj
19
19
 
20
20
  if TYPE_CHECKING:
21
- from luminarycloud_jupyter import InteractiveLCVisWidget, LCVisPlaneWidget
21
+ from luminarycloud_jupyter import (
22
+ InteractiveLCVisWidget,
23
+ LCVisPlaneWidget,
24
+ LCVisLineWidget,
25
+ LCVisBoxWidget,
26
+ LCVisCylinderWidget,
27
+ LCVisHalfSphereWidget,
28
+ LCVisSphereWidget,
29
+ LCVisFinitePlaneWidget,
30
+ )
22
31
  except ImportError:
23
32
  lcj = None
24
33
 
@@ -134,7 +143,7 @@ class InteractiveScene:
134
143
  def set_surface_visibility(self, surface_id: str, visible: bool) -> None:
135
144
  self.widget.set_surface_visibility(surface_id, visible)
136
145
 
137
- def set_surface_color(self, surface_id: str, color: list[float]) -> None:
146
+ def set_surface_color(self, surface_id: str, color: "list[float]") -> None:
138
147
  self.widget.set_surface_color(surface_id, color)
139
148
 
140
149
  def set_display_attributes(self, object_id: str, attrs: DisplayAttributes) -> None:
@@ -199,6 +208,24 @@ class InteractiveScene:
199
208
  def add_plane_widget(self) -> "LCVisPlaneWidget":
200
209
  return self.widget.add_plane_widget()
201
210
 
211
+ def add_line_widget(self) -> "LCVisLineWidget":
212
+ return self.widget.add_line_widget()
213
+
214
+ def add_box_widget(self) -> "LCVisBoxWidget":
215
+ return self.widget.add_box_widget()
216
+
217
+ def add_cylinder_widget(self) -> "LCVisCylinderWidget":
218
+ return self.widget.add_cylinder_widget()
219
+
220
+ def add_half_sphere_widget(self) -> "LCVisHalfSphereWidget":
221
+ return self.widget.add_half_sphere_widget()
222
+
223
+ def add_finite_plane_widget(self) -> "LCVisFinitePlaneWidget":
224
+ return self.widget.add_finite_plane_widget()
225
+
226
+ def add_sphere_widget(self) -> "LCVisSphereWidget":
227
+ return self.widget.add_sphere_widget()
228
+
202
229
  def delete_widget(self, widget: "InteractiveLCVisWidget") -> None:
203
230
  self.widget.delete_widget(widget)
204
231
 
@@ -0,0 +1,252 @@
1
+ import json
2
+ import os
3
+ from typing import TYPE_CHECKING
4
+
5
+ from .visualization import Scene, RenderOutput, range_query
6
+ from .data_extraction import DataExtractor, ExtractOutput
7
+ from ..enum import RenderStatusType, ExtractStatusType, FieldAssociation
8
+ from ..solution import Solution
9
+ from .vis_util import _InternalToken, _get_status
10
+ from time import sleep
11
+ from .._proto.api.v0.luminarycloud.vis import vis_pb2
12
+ from .._client import get_default_client
13
+ from .._helpers._get_project_id import _get_project_id
14
+ import luminarycloud.enum.quantity_type as quantity_type
15
+
16
+ if TYPE_CHECKING:
17
+ from .interactive_report import InteractiveReport
18
+
19
+
20
+ # TODO Will/Matt: this could be something like what we store in the DB
21
+ # A report can contain a list of report entries that reference post proc.
22
+ # extracts + styling info for how they should be displayed
23
+ class ReportEntry:
24
+ """
25
+ A single entry in a report, containing references to extracts and metadata.
26
+ Each extract can have multiple pieces of data (e.g. multiple images for a
27
+ RenderOutput, or multiple curves for an ExtractOutput). Metadata is a
28
+ dictionary of key/value pairs that can be used to store additional
29
+ information about the report entry. Typically, the metadata would include
30
+ things like simulation ID, lift/drag values, and scalar ranges for each
31
+ solution. The metadata is used to filter and sort data in the ensemble
32
+ widget.
33
+ """
34
+
35
+ def __init__(
36
+ self, project_id: str, extract_ids: list[str] = [], metadata: dict[str, str | float] = {}
37
+ ) -> None:
38
+ self._project_id = project_id
39
+ self._extract_ids = extract_ids
40
+ self._extracts: list[ExtractOutput | RenderOutput] = []
41
+ self._metadata = metadata
42
+ self._statuses = statuses = [RenderStatusType.INVALID] * len(self._extract_ids)
43
+
44
+ def to_dict(self) -> dict:
45
+ return {
46
+ "project_id": self._project_id,
47
+ "extract_ids": self._extract_ids,
48
+ "metadata": self._metadata,
49
+ }
50
+
51
+ @classmethod
52
+ def from_dict(cls, data: dict) -> "ReportEntry":
53
+ return cls(
54
+ project_id=data["project_id"],
55
+ extract_ids=data.get("extract_ids", []),
56
+ metadata=data.get("metadata", {}),
57
+ )
58
+
59
+ def refresh_statuses(self) -> None:
60
+ for i, eid in enumerate(self._extract_ids):
61
+ self._statuses[i] = _get_status(self._project_id, eid)
62
+
63
+ def is_complete(self) -> bool:
64
+ self.refresh_statuses()
65
+ return all(
66
+ (status == RenderStatusType.COMPLETED or status == RenderStatusType.FAILED)
67
+ for status in self._statuses
68
+ )
69
+
70
+ # Download all extracts for this report entry
71
+ def download_extracts(self) -> None:
72
+ self._extracts = []
73
+ for eid in self._extract_ids:
74
+ status = _get_status(self._project_id, eid)
75
+ if status != ExtractStatusType.COMPLETED:
76
+ raise Exception(f"Extract {eid} is not complete")
77
+ req = vis_pb2.DownloadExtractRequest()
78
+ req.extract_id = eid
79
+ req.project_id = self._project_id
80
+ # TODO: This is a bit awkward in that we download the extract to figure out what type
81
+ # it is, but this is just a temporary thing, later we'll have a report DB table that
82
+ # stores the extracts for a report and their types, etc.
83
+ res: vis_pb2.DownloadExtractResponse = get_default_client().DownloadExtract(req)
84
+ extract = (
85
+ ExtractOutput(_InternalToken())
86
+ if res.HasField("line_data")
87
+ else RenderOutput(_InternalToken())
88
+ )
89
+ extract._set_data(eid, self._project_id, eid, eid, status)
90
+ self._extracts.append(extract)
91
+
92
+
93
+ class Report:
94
+ """
95
+ A report containing multiple report entries. There is support for
96
+ serialization and deserialization to/from JSON since generating the extracts
97
+ and metadata can be expensive.
98
+ """
99
+
100
+ def __init__(self, entries: list[ReportEntry]):
101
+ self._entries = entries
102
+
103
+ def to_dict(self) -> dict:
104
+ return {"entries": [entry.to_dict() for entry in self._entries]}
105
+
106
+ @classmethod
107
+ def from_dict(cls, data: dict) -> "Report":
108
+ entries = [ReportEntry.from_dict(e) for e in data.get("entries", [])]
109
+ return cls(entries)
110
+
111
+ def _check_status(self) -> bool:
112
+ """Check the status of all ReportEntries and their extracts, grouped by entry."""
113
+ still_pending = False
114
+ print("\n" + "=" * 60)
115
+ print("STATUS CHECK".center(60))
116
+ print("=" * 60)
117
+
118
+ if not self._entries:
119
+ raise RuntimeError("No report entries to check status.")
120
+
121
+ print(f"{'Entry':<8} | {'Extract ID':<20} | {'Status':<15}")
122
+ print("-" * 60)
123
+ for idx, entry in enumerate(self._entries):
124
+ entry.refresh_statuses()
125
+ for eid, status in zip(entry._extract_ids, entry._statuses):
126
+ if status != RenderStatusType.COMPLETED and status != RenderStatusType.FAILED:
127
+ still_pending = True
128
+ print(f"{idx:<8} | {eid:<20} | {status.name:<15}")
129
+ print("=" * 60)
130
+ return still_pending
131
+
132
+ def wait_for_completion(self):
133
+ """Wait for all report entries' extracts to complete."""
134
+ if not self._entries:
135
+ raise RuntimeError("No report entries to wait for.")
136
+ while self._check_status():
137
+ sleep(5)
138
+ print("All report entries' extracts have completed.")
139
+
140
+ def interact(self) -> "InteractiveReport":
141
+ from .interactive_report import InteractiveReport
142
+
143
+ if not self._check_status:
144
+ raise ValueError("Error: report entries are still pending")
145
+ return InteractiveReport(self._entries)
146
+
147
+
148
+ class ReportGenerator:
149
+ """
150
+ A helper for generating reports from multiple solutions, scenes, data extractors and
151
+ per solution metatdata.
152
+
153
+ Attributes:
154
+ -----------
155
+ calculate_ranges: bool
156
+ Whether to auto-calculate solution quantity ranges and add them to the
157
+ metadata. Default is False.
158
+ """
159
+
160
+ def __init__(self, solutions: list[Solution]):
161
+ self._scenes: list[Scene] = []
162
+ self._data_extractors: list[DataExtractor] = []
163
+ self._solution: list[Solution] = solutions
164
+ # When we fire off requests we use these objects to track the progress.
165
+ self._extract_outputs: list[ExtractOutput] = []
166
+ self._render_outputs: list[RenderOutput] = []
167
+ # Controls if we should calculate solution quanity ranges
168
+ self.calculate_ranges: bool = False
169
+ # Key is solution ID, value is the metadata dict
170
+ self._metadata: dict[str, dict[str, str | float]] = {}
171
+ for solution in solutions:
172
+ if not isinstance(solution, Solution):
173
+ raise TypeError("Expected a list of Solution objects.")
174
+
175
+ def add_scene(self, scene: Scene):
176
+ if not isinstance(scene, Scene):
177
+ raise TypeError("Expected a Scene object.")
178
+ self._scenes.append(scene)
179
+
180
+ # TODO(Matt): we could just make this a single data extract then control how they
181
+ # are added to each solution.
182
+ def add_data_extractor(self, data_extractor: DataExtractor):
183
+ if not isinstance(data_extractor, DataExtractor):
184
+ raise TypeError("Expected a DataExtractor object.")
185
+ self._data_extractors.append(data_extractor)
186
+
187
+ def add_metadata(self, solution_id: str, metadata: dict[str, str | float]):
188
+ if solution_id not in self._metadata:
189
+ self._metadata[solution_id] = {}
190
+ self._metadata[solution_id].update(metadata)
191
+
192
+ def create_report(self) -> Report:
193
+ entries = []
194
+ for solution in self._solution:
195
+ extract_ids = []
196
+ project_id = _get_project_id(solution)
197
+ if not project_id:
198
+ raise ValueError("Solution does not have a project_id.")
199
+ metadata = self._metadata.get(solution.id, {})
200
+ metadata["solution id"] = solution.id
201
+ if self.calculate_ranges:
202
+ print(f"Calculating solution quantity ranges {solution.id}")
203
+ ranges = range_query(solution, FieldAssociation.CELLS)
204
+ for range_res in ranges:
205
+ if not quantity_type._is_vector(range_res.quantity):
206
+ metadata[f"{range_res.field_name} min"] = range_res.ranges[0].min_value
207
+ metadata[f"{range_res.field_name} max"] = range_res.ranges[0].max_value
208
+ else:
209
+ for r in range(len(range_res.ranges)):
210
+ if r == 0:
211
+ comp = "x"
212
+ elif r == 1:
213
+ comp = "y"
214
+ elif r == 2:
215
+ comp = "z"
216
+ elif r == 3:
217
+ comp = "mag"
218
+ comp_range = range_res.ranges[r]
219
+ metadata[f"{range_res.field_name} min ({comp})"] = comp_range.min_value
220
+ metadata[f"{range_res.field_name} max ({comp})"] = comp_range.max_value
221
+ for extractor in self._data_extractors:
222
+ sol_extractor = extractor.clone(solution)
223
+ extract = sol_extractor.create_extracts(
224
+ name="Report Extract", description="Generated Report Extract"
225
+ )
226
+ extract_ids.append(extract._extract_id)
227
+
228
+ for scene in self._scenes:
229
+ sol_scene = scene.clone(solution)
230
+ render_extract = sol_scene.render_images(
231
+ name="Report Scene", description="Generated Report Scene"
232
+ )
233
+ extract_ids.append(render_extract._extract_id)
234
+
235
+ entries.append(ReportEntry(project_id, extract_ids, metadata))
236
+ return Report(entries)
237
+
238
+
239
+ def save_report_to_json(report: Report, name: str, directory: str = ".") -> str:
240
+ """Save a Report object to a JSON file named {name}_lcreport.json in the specified directory."""
241
+ filename = f"{name}_lcreport.json"
242
+ filepath = os.path.join(directory, filename)
243
+ with open(filepath, "w") as f:
244
+ json.dump(report.to_dict(), f, indent=2)
245
+ return filepath
246
+
247
+
248
+ def load_report_from_json(filepath: str) -> "Report":
249
+ """Load a Report object from a JSON file at the given file path."""
250
+ with open(filepath, "r") as f:
251
+ data = json.load(f)
252
+ return Report.from_dict(data)