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.
- luminarycloud/__init__.py +2 -0
- luminarycloud/_client/client.py +2 -0
- luminarycloud/_helpers/_wait_for_mesh.py +6 -5
- luminarycloud/_helpers/_wait_for_simulation.py +3 -3
- luminarycloud/_helpers/pagination.py +62 -0
- luminarycloud/_helpers/upload.py +3 -6
- luminarycloud/_proto/api/v0/luminarycloud/geometry/geometry_pb2.py +168 -124
- luminarycloud/_proto/api/v0/luminarycloud/geometry/geometry_pb2.pyi +125 -3
- luminarycloud/_proto/api/v0/luminarycloud/geometry/geometry_pb2_grpc.py +66 -0
- luminarycloud/_proto/api/v0/luminarycloud/geometry/geometry_pb2_grpc.pyi +20 -0
- luminarycloud/_proto/api/v0/luminarycloud/inference/inference_pb2.py +8 -8
- luminarycloud/_proto/api/v0/luminarycloud/inference/inference_pb2.pyi +5 -5
- luminarycloud/_proto/api/v0/luminarycloud/vis/vis_pb2.py +124 -27
- luminarycloud/_proto/api/v0/luminarycloud/vis/vis_pb2.pyi +177 -0
- luminarycloud/_proto/api/v0/luminarycloud/vis/vis_pb2_grpc.py +99 -0
- luminarycloud/_proto/api/v0/luminarycloud/vis/vis_pb2_grpc.pyi +30 -0
- luminarycloud/_proto/assistant/assistant_pb2.py +61 -41
- luminarycloud/_proto/assistant/assistant_pb2.pyi +43 -1
- luminarycloud/_proto/assistant/assistant_pb2_grpc.py +33 -0
- luminarycloud/_proto/assistant/assistant_pb2_grpc.pyi +10 -0
- luminarycloud/_proto/base/base_pb2.py +9 -6
- luminarycloud/_proto/base/base_pb2.pyi +12 -0
- luminarycloud/_proto/client/simulation_pb2.py +490 -348
- luminarycloud/_proto/client/simulation_pb2.pyi +570 -8
- luminarycloud/_proto/inferenceservice/inferenceservice_pb2.py +10 -10
- luminarycloud/_proto/inferenceservice/inferenceservice_pb2.pyi +5 -5
- luminarycloud/_proto/quantity/quantity_pb2.py +24 -15
- luminarycloud/_proto/quantity/quantity_pb2.pyi +10 -4
- luminarycloud/enum/__init__.py +1 -0
- luminarycloud/enum/quantity_type.py +9 -0
- luminarycloud/enum/vis_enums.py +23 -3
- luminarycloud/exceptions.py +7 -1
- luminarycloud/geometry.py +44 -2
- luminarycloud/geometry_version.py +57 -3
- luminarycloud/mesh.py +1 -2
- luminarycloud/params/enum/_enum_wrappers.py +537 -30
- luminarycloud/params/simulation/adaptive_mesh_refinement_.py +4 -0
- luminarycloud/params/simulation/physics/__init__.py +0 -1
- luminarycloud/params/simulation/physics/periodic_pair_.py +12 -31
- luminarycloud/physics_ai/architectures.py +5 -5
- luminarycloud/physics_ai/inference.py +13 -13
- luminarycloud/pipelines/__init__.py +8 -0
- luminarycloud/pipelines/api.py +160 -10
- luminarycloud/pipelines/arguments.py +15 -0
- luminarycloud/pipelines/operators.py +74 -17
- luminarycloud/project.py +5 -44
- luminarycloud/simulation.py +10 -5
- luminarycloud/simulation_param.py +0 -9
- luminarycloud/vis/__init__.py +17 -0
- luminarycloud/vis/data_extraction.py +20 -4
- luminarycloud/vis/interactive_report.py +110 -0
- luminarycloud/vis/interactive_scene.py +29 -2
- luminarycloud/vis/report.py +252 -0
- luminarycloud/vis/visualization.py +127 -5
- luminarycloud/volume_selection.py +58 -9
- {luminarycloud-0.19.1.dist-info → luminarycloud-0.21.0.dist-info}/METADATA +1 -1
- {luminarycloud-0.19.1.dist-info → luminarycloud-0.21.0.dist-info}/RECORD +58 -60
- luminarycloud/params/simulation/physics/periodic_pair/__init__.py +0 -2
- luminarycloud/params/simulation/physics/periodic_pair/periodicity_type/__init__.py +0 -2
- luminarycloud/params/simulation/physics/periodic_pair/periodicity_type/rotational_periodicity_.py +0 -31
- luminarycloud/params/simulation/physics/periodic_pair/periodicity_type/translational_periodicity_.py +0 -29
- luminarycloud/params/simulation/physics/periodic_pair/periodicity_type_.py +0 -25
- {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
|
|
792
|
-
|
|
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:
|
luminarycloud/simulation.py
CHANGED
|
@@ -124,8 +124,7 @@ class Simulation(ProtoWrapperBase):
|
|
|
124
124
|
interval_seconds=interval_seconds,
|
|
125
125
|
timeout_seconds=timeout_seconds,
|
|
126
126
|
)
|
|
127
|
-
self.
|
|
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
|
-
|
|
212
|
-
|
|
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))
|
luminarycloud/vis/__init__.py
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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)
|