From b221f9ae2b8507cccaa606aa9bd01f96b916ba60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sofus=20Albert=20H=C3=B8gsbro=20Rose?= Date: Fri, 3 May 2024 11:47:51 +0200 Subject: [PATCH] feat: E2E simulation design and analysis. This is it! This is the milestone. We can now make, run, and analyze simulations in one big chain. Jank remains: - Dynamic enums still need caching, lest the user think to restart Blender while the math nodes are riding high on `FlowPending`. - GN remains untested, and so forth. - Still no plane wave node. Easy to jank together, though. - Still no reindexing in the Transform math, so only frequencies for now. - Active kinds still don't update shape, we still need an explicit (postinit?) directive to do that. - No colors for expr sockets :( But we have a beautifully solid foundation to work on. The new abstractive tools for defining event-driven actions via nodes have had very few showstoppers, and are incredibly nice to work with. There are sharp edges, of course, but generally they only matter where the problem was so very difficult to begin with. We'll start doing physics immediately, and fixing bugs / implementing more nodes as we go. --- .../contracts/operator_types.py | 4 +- .../maxwell_sim_nodes/contracts/__init__.py | 61 +-- .../maxwell_sim_nodes/contracts/node_types.py | 1 + .../maxwell_sim_nodes/contracts/sim_types.py | 23 + .../nodes/inputs/web_importers/__init__.py | 6 +- .../web_importers/tidy_3d_web_importer.py | 12 +- .../nodes/outputs/web_exporters/__init__.py | 4 +- .../web_exporters/tidy3d_web_exporter.py | 451 +++++++++--------- .../web_exporters/tidy3d_web_runner.py | 270 +++++++++++ .../maxwell_sim_nodes/sockets/base.py | 9 +- .../sockets/tidy3d/cloud_task.py | 64 +-- src/blender_maxwell/services/tdcloud.py | 39 +- 12 files changed, 620 insertions(+), 324 deletions(-) create mode 100644 src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/outputs/web_exporters/tidy3d_web_runner.py diff --git a/src/blender_maxwell/contracts/operator_types.py b/src/blender_maxwell/contracts/operator_types.py index 6fc4f24..639914e 100644 --- a/src/blender_maxwell/contracts/operator_types.py +++ b/src/blender_maxwell/contracts/operator_types.py @@ -26,8 +26,8 @@ class OperatorType(enum.StrEnum): NodeLoadCloudSim = enum.auto() # Node: Tidy3DWebExporter + NodeRecomputeSimInfo = enum.auto() NodeUploadSimulation = enum.auto() + NodeReleaseUploadedTask = enum.auto() NodeRunSimulation = enum.auto() NodeReloadTrackedTask = enum.auto() - NodeEstCostTrackedTask = enum.auto() - ReleaseTrackedTask = enum.auto() diff --git a/src/blender_maxwell/node_trees/maxwell_sim_nodes/contracts/__init__.py b/src/blender_maxwell/node_trees/maxwell_sim_nodes/contracts/__init__.py index 17481e7..580f15f 100644 --- a/src/blender_maxwell/node_trees/maxwell_sim_nodes/contracts/__init__.py +++ b/src/blender_maxwell/node_trees/maxwell_sim_nodes/contracts/__init__.py @@ -1,25 +1,25 @@ from blender_maxwell.contracts import ( - BLClass, - BLColorRGBA, - BLEnumElement, - BLEnumID, - BLIcon, - BLIconSet, - BLIDStruct, - BLKeymapItem, - BLModifierType, - BLNodeTreeInterfaceID, - BLOperatorStatus, - BLPropFlag, - BLRegionType, - BLSpaceType, - KeymapItemDef, - ManagedObjName, - OperatorType, - PanelType, - PresetName, - SocketName, - addon, + BLClass, + BLColorRGBA, + BLEnumElement, + BLEnumID, + BLIcon, + BLIconSet, + BLIDStruct, + BLKeymapItem, + BLModifierType, + BLNodeTreeInterfaceID, + BLOperatorStatus, + BLPropFlag, + BLRegionType, + BLSpaceType, + KeymapItemDef, + ManagedObjName, + OperatorType, + PanelType, + PresetName, + SocketName, + addon, ) from .bl_socket_types import BLSocketInfo, BLSocketType @@ -27,20 +27,20 @@ from .category_labels import NODE_CAT_LABELS from .category_types import NodeCategory from .flow_events import FlowEvent from .flow_kinds import ( - ArrayFlow, - CapabilitiesFlow, - FlowKind, - InfoFlow, - LazyArrayRangeFlow, - LazyValueFuncFlow, - ParamsFlow, - ValueFlow, + ArrayFlow, + CapabilitiesFlow, + FlowKind, + InfoFlow, + LazyArrayRangeFlow, + LazyValueFuncFlow, + ParamsFlow, + ValueFlow, ) from .flow_signals import FlowSignal from .icons import Icon from .mobj_types import ManagedObjType from .node_types import NodeType -from .sim_types import BoundCondType, SimSpaceAxis, manual_amp_time +from .sim_types import BoundCondType, NewSimCloudTask, SimSpaceAxis, manual_amp_time from .socket_colors import SOCKET_COLORS from .socket_types import SocketType from .tree_types import TreeType @@ -79,6 +79,7 @@ __all__ = [ 'BLSocketType', 'NodeType', 'BoundCondType', + 'NewSimCloudTask', 'SimSpaceAxis', 'manual_amp_time', 'NodeCategory', diff --git a/src/blender_maxwell/node_trees/maxwell_sim_nodes/contracts/node_types.py b/src/blender_maxwell/node_trees/maxwell_sim_nodes/contracts/node_types.py index c5fe7b1..53e7bab 100644 --- a/src/blender_maxwell/node_trees/maxwell_sim_nodes/contracts/node_types.py +++ b/src/blender_maxwell/node_trees/maxwell_sim_nodes/contracts/node_types.py @@ -38,6 +38,7 @@ class NodeType(blender_type_enum.BlenderTypeEnum): Viewer = enum.auto() ## Outputs / File Exporters Tidy3DWebExporter = enum.auto() + Tidy3DWebRunner = enum.auto() ## Outputs / Web Exporters JSONFileExporter = enum.auto() diff --git a/src/blender_maxwell/node_trees/maxwell_sim_nodes/contracts/sim_types.py b/src/blender_maxwell/node_trees/maxwell_sim_nodes/contracts/sim_types.py index 0c328a3..4fadc71 100644 --- a/src/blender_maxwell/node_trees/maxwell_sim_nodes/contracts/sim_types.py +++ b/src/blender_maxwell/node_trees/maxwell_sim_nodes/contracts/sim_types.py @@ -1,12 +1,18 @@ """Declares various simulation types for use by nodes and sockets.""" +import dataclasses import enum import typing as typ import jax.numpy as jnp import tidy3d as td +from blender_maxwell.services import tdcloud + +#################### +# - JAX-Helpers +#################### def manual_amp_time(self, time: float) -> complex: """Copied implementation of `pulse.amp_time` for `tidy3d` temporal shapes, which replaces use of `numpy` with `jax.numpy` for `jit`-ability. @@ -42,6 +48,9 @@ def manual_amp_time(self, time: float) -> complex: ## TODO: Sim Domain type, w/pydantic checks! +#################### +# - Global Simulation Coordinate System +#################### class SimSpaceAxis(enum.StrEnum): """The axis labels of the global simulation coordinate system.""" @@ -89,6 +98,9 @@ class SimSpaceAxis(enum.StrEnum): return {SSA.X: 0, SSA.Y: 1, SSA.Z: 2}[self] +#################### +# - Boundary Condition Type +#################### class BoundCondType(enum.StrEnum): r"""A type of boundary condition, applied to a half-axis of a simulation domain. @@ -151,3 +163,14 @@ class BoundCondType(enum.StrEnum): BCT.Pmc: td.PMCBoundary(), BCT.NaiveBloch: td.Periodic(), }[self] + + +#################### +# - Cloud Task +#################### +@dataclasses.dataclass(kw_only=True, frozen=True) +class NewSimCloudTask: + """Not-yet-existing simulation-oriented cloud task.""" + + task_name: tdcloud.CloudTaskName + cloud_folder: tdcloud.CloudFolder diff --git a/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/inputs/web_importers/__init__.py b/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/inputs/web_importers/__init__.py index 374f8ca..59f26f0 100644 --- a/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/inputs/web_importers/__init__.py +++ b/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/inputs/web_importers/__init__.py @@ -1,8 +1,8 @@ -from . import tidy_3d_web_importer +#from . import tidy_3d_web_importer BL_REGISTER = [ - *tidy_3d_web_importer.BL_REGISTER, + #*tidy_3d_web_importer.BL_REGISTER, ] BL_NODES = { - **tidy_3d_web_importer.BL_NODES, + #**tidy_3d_web_importer.BL_NODES, } diff --git a/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/inputs/web_importers/tidy_3d_web_importer.py b/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/inputs/web_importers/tidy_3d_web_importer.py index eae2538..e2e43a0 100644 --- a/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/inputs/web_importers/tidy_3d_web_importer.py +++ b/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/inputs/web_importers/tidy_3d_web_importer.py @@ -35,7 +35,7 @@ class LoadCloudSim(bpy.types.Operator): node = context.node # Try Loading Simulation Data - #node.sim_data = bl_cache.Signal.InvalidateCache + # node.sim_data = bl_cache.Signal.InvalidateCache sim_data = node.sim_data if sim_data is None: self.report( @@ -48,16 +48,6 @@ class LoadCloudSim(bpy.types.Operator): return {'FINISHED'} -def _sim_data_cache_path(task_id: str) -> Path: - """Compute an appropriate location for caching simulations downloaded from the internet, unique to each task ID. - - Arguments: - task_id: The ID of the Tidy3D cloud task. - """ - (ct.addon.ADDON_CACHE / task_id).mkdir(exist_ok=True) - return ct.addon.ADDON_CACHE / task_id / 'sim_data.hdf5' - - #################### # - Node #################### diff --git a/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/outputs/web_exporters/__init__.py b/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/outputs/web_exporters/__init__.py index 1f026e9..6fa4993 100644 --- a/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/outputs/web_exporters/__init__.py +++ b/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/outputs/web_exporters/__init__.py @@ -1,8 +1,10 @@ -from . import tidy3d_web_exporter +from . import tidy3d_web_exporter, tidy3d_web_runner BL_REGISTER = [ *tidy3d_web_exporter.BL_REGISTER, + *tidy3d_web_runner.BL_REGISTER, ] BL_NODES = { **tidy3d_web_exporter.BL_NODES, + **tidy3d_web_runner.BL_NODES, } diff --git a/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/outputs/web_exporters/tidy3d_web_exporter.py b/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/outputs/web_exporters/tidy3d_web_exporter.py index 3d14627..dc387c0 100644 --- a/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/outputs/web_exporters/tidy3d_web_exporter.py +++ b/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/outputs/web_exporters/tidy3d_web_exporter.py @@ -14,8 +14,38 @@ log = logger.get(__name__) #################### -# - Web Uploader / Loader / Runner / Releaser +# - Operators #################### +class RecomputeSimInfo(bpy.types.Operator): + bl_idname = ct.OperatorType.NodeRecomputeSimInfo + bl_label = 'Recompute Tidy3D Sim Info' + bl_description = 'Recompute info for any currently attached sim info' + + @classmethod + def poll(cls, context): + return ( + # Check Tidy3DWebExporter is Accessible + hasattr(context, 'node') + and hasattr(context.node, 'node_type') + and context.node.node_type == ct.NodeType.Tidy3DWebExporter + # Check Sim is Available (aka. uploadeable) + and context.node.sim_info_available + and context.node.sim_info_invalidated + ) + + def execute(self, context): + node = context.node + + # Rehydrate the Cache + node.total_monitor_data = bl_cache.Signal.InvalidateCache + node.is_sim_uploadable = bl_cache.Signal.InvalidateCache + + # Remove the Invalidation Marker + ## -> This is OK, since we manually guaranteed that it's available. + node.sim_info_invalidated = False + return {'FINISHED'} + + class UploadSimulation(bpy.types.Operator): bl_idname = ct.OperatorType.NodeUploadSimulation bl_label = 'Upload Tidy3D Simulation' @@ -24,119 +54,48 @@ class UploadSimulation(bpy.types.Operator): @classmethod def poll(cls, context): return ( - hasattr(context, 'node') + # Check Tidy3D Cloud + tdcloud.IS_AUTHENTICATED + # Check Tidy3DWebExporter is Accessible + and hasattr(context, 'node') and hasattr(context.node, 'node_type') and context.node.node_type == ct.NodeType.Tidy3DWebExporter - and context.node.lock_tree - and tdcloud.IS_AUTHENTICATED - and not context.node.tracked_task_id - and context.node.inputs['Sim'].is_linked + # Check Sim is Available (aka. uploadeable) + and context.node.is_sim_uploadable + and context.node.uploaded_task_id == '' ) def execute(self, context): node = context.node - node.upload_sim() - return {'FINISHED'} - - -class RunSimulation(bpy.types.Operator): - bl_idname = ct.OperatorType.NodeRunSimulation - bl_label = 'Run Tracked Tidy3D Sim' - bl_description = 'Run the currently tracked simulation task' - - @classmethod - def poll(cls, context): - return ( - hasattr(context, 'node') - and hasattr(context.node, 'node_type') - and context.node.node_type == ct.NodeType.Tidy3DWebExporter - and tdcloud.IS_AUTHENTICATED - and context.node.tracked_task_id - and ( - task_info := tdcloud.TidyCloudTasks.task_info( - context.node.tracked_task_id - ) - ) - is not None - and task_info.status == 'draft' + cloud_task = tdcloud.TidyCloudTasks.mk_task( + task_name=node.new_cloud_task.task_name, + cloud_folder=node.new_cloud_task.cloud_folder, + sim=node.sim, + verbose=True, ) - - def execute(self, context): - node = context.node - node.run_tracked_task() + node.uploaded_task_id = cloud_task.task_id return {'FINISHED'} -class ReloadTrackedTask(bpy.types.Operator): - bl_idname = ct.OperatorType.NodeReloadTrackedTask - bl_label = 'Reload Tracked Tidy3D Cloud Task' - bl_description = 'Reload the currently tracked simulation task' - - @classmethod - def poll(cls, context): - return ( - hasattr(context, 'node') - and hasattr(context.node, 'node_type') - and context.node.node_type == ct.NodeType.Tidy3DWebExporter - and tdcloud.IS_AUTHENTICATED - and context.node.tracked_task_id - ) - - def execute(self, context): - node = context.node - if (cloud_task := tdcloud.TidyCloudTasks.task(node.tracked_task_id)) is None: - msg = "Tried to reload tracked task, but it doesn't exist" - raise RuntimeError(msg) - - cloud_task = tdcloud.TidyCloudTasks.update_task(cloud_task) - return {'FINISHED'} - - -class EstCostTrackedTask(bpy.types.Operator): - bl_idname = ct.OperatorType.NodeEstCostTrackedTask - bl_label = 'Est Cost of Tracked Tidy3D Cloud Task' - bl_description = 'Reload the currently tracked simulation task' - - @classmethod - def poll(cls, context): - return ( - hasattr(context, 'node') - and hasattr(context.node, 'node_type') - and context.node.node_type == ct.NodeType.Tidy3DWebExporter - and tdcloud.IS_AUTHENTICATED - and context.node.tracked_task_id - ) - - def execute(self, context): - node = context.node - if ( - task_info := tdcloud.TidyCloudTasks.task_info(context.node.tracked_task_id) - ) is None: - msg = "Tried to estimate cost of tracked task, but it doesn't exist" - raise RuntimeError(msg) - - node.cache_est_cost = task_info.cost_est() - return {'FINISHED'} - - -class ReleaseTrackedTask(bpy.types.Operator): - bl_idname = ct.OperatorType.ReleaseTrackedTask +class ReleaseUploadedTask(bpy.types.Operator): + bl_idname = ct.OperatorType.NodeReleaseUploadedTask bl_label = 'Release Tracked Tidy3D Cloud Task' bl_description = 'Release the currently tracked simulation task' @classmethod def poll(cls, context): return ( + # Check Tidy3DWebExporter is Accessible hasattr(context, 'node') and hasattr(context.node, 'node_type') and context.node.node_type == ct.NodeType.Tidy3DWebExporter - # and tdcloud.IS_AUTHENTICATED - and context.node.tracked_task_id + # Check Sim is Available (aka. uploadeable) + and context.node.uploaded_task_id != '' ) def execute(self, context): node = context.node - node.tracked_task_id = '' + node.uploaded_task_id = '' return {'FINISHED'} @@ -154,9 +113,6 @@ class Tidy3DWebExporterNode(base.MaxwellSimNode): ), } output_sockets: typ.ClassVar = { - 'Sim Data': sockets.Tidy3DCloudTaskSocketDef( - should_exist=True, - ), 'Cloud Task': sockets.Tidy3DCloudTaskSocketDef( should_exist=True, ), @@ -165,11 +121,12 @@ class Tidy3DWebExporterNode(base.MaxwellSimNode): #################### # - Properties #################### - lock_tree: bool = bl_cache.BLField(False, prop_ui=True) - tracked_task_id: str = bl_cache.BLField('', prop_ui=True) + sim_info_available: bool = bl_cache.BLField(False) + sim_info_invalidated: bool = bl_cache.BLField(False) + uploaded_task_id: str = bl_cache.BLField('') #################### - # - Computed + # - Computed - Sim #################### @bl_cache.cached_bl_property(persist=False) def sim(self) -> td.Simulation | None: @@ -177,67 +134,90 @@ class Tidy3DWebExporterNode(base.MaxwellSimNode): has_sim = not ct.FlowSignal.check(sim) if has_sim: - sim.validate_pre_upload(source_required=True) return sim return None @bl_cache.cached_bl_property(persist=False) - def total_monitor_data(self) -> float: + def total_monitor_data(self) -> float | None: if self.sim is not None: return sum(self.sim.monitors_data_size.values()) - return 0.0 + return None + + #################### + # - Computed - New Cloud Task + #################### + @property + def new_cloud_task(self) -> ct.NewSimCloudTask | None: + """Retrieve the current new cloud task from the input socket. + + If one can't be loaded, return None. + """ + new_cloud_task = self._compute_input( + 'Cloud Task', + kind=ct.FlowKind.Value, + ) + has_new_cloud_task = not ct.FlowSignal.check(new_cloud_task) + + if has_new_cloud_task and new_cloud_task.task_name != '': + return new_cloud_task + return None + + #################### + # - Computed - Uploaded Cloud Task + #################### + @property + def uploaded_task(self) -> tdcloud.CloudTask | None: + """Retrieve the uploaded cloud task. + + If one can't be loaded, return None. + """ + has_uploaded_task = self.uploaded_task_id != '' + + if has_uploaded_task: + return tdcloud.TidyCloudTasks.task(self.uploaded_task_id) + return None + + @property + def uploaded_task_info(self) -> tdcloud.CloudTask | None: + """Retrieve the uploaded cloud task. + + If one can't be loaded, return None. + """ + has_uploaded_task = self.uploaded_task_id != '' + + if has_uploaded_task: + return tdcloud.TidyCloudTasks.task_info(self.uploaded_task_id) + return None @bl_cache.cached_bl_property(persist=False) - def est_cost(self) -> float | None: - if self.tracked_task_id != '': - task_info = tdcloud.TidyCloudTasks.task_info(self.tracked_task_id) - if task_info is not None: - return task_info.cost_est() + def uploaded_est_cost(self) -> float | None: + task_info = self.uploaded_task_info + if task_info is not None: + est_cost = task_info.cost_est() + if est_cost is not None: + return est_cost return None #################### - # - Methods + # - Computed - Combined #################### - def upload_sim(self): - if self.sim is None: - msg = 'Tried to upload simulation, but none is attached' - raise ValueError(msg) - - if (new_task := self._compute_input('Cloud Task')) is None or isinstance( - new_task, - tdcloud.CloudTask, + @bl_cache.cached_bl_property(persist=False) + def is_sim_uploadable(self) -> bool: + if ( + self.sim is not None + and self.uploaded_task_id == '' + and self.new_cloud_task is not None + and self.new_cloud_task.task_name != '' ): - msg = ( - 'Tried to upload simulation to new task, but existing task was selected' - ) - raise ValueError(msg) - - # Create Cloud Task - cloud_task = tdcloud.TidyCloudTasks.mk_task( - task_name=new_task[0], - cloud_folder=new_task[1], - sim=self.sim, - verbose=True, - ) - - # Declare to Cloud Task that it Exists Now - ## This will change the UI to not allow free-text input. - ## If the socket is linked, this errors. - self.inputs['Cloud Task'].on_new_task_created(cloud_task) - - # Track the Newly Uploaded Task ID - self.tracked_task_id = cloud_task.task_id - - def run_tracked_task(self): - if (cloud_task := tdcloud.TidyCloudTasks.task(self.tracked_task_id)) is None: - msg = "Tried to run tracked task, but it doesn't exist" - raise RuntimeError(msg) - - cloud_task.submit() - tdcloud.TidyCloudTasks.update_task( - cloud_task - ) ## TODO: Check that status is actually immediately updated. + try: + self.sim.validate_pre_upload(source_required=True) + except: + log.exception() + return False + else: + return True + return False #################### # - UI @@ -249,21 +229,9 @@ class Tidy3DWebExporterNode(base.MaxwellSimNode): ct.OperatorType.NodeUploadSimulation, text='Upload', ) - tree_lock_icon = 'LOCKED' if self.lock_tree else 'UNLOCKED' - row.prop( - self, self.blfields['lock_tree'], toggle=True, icon=tree_lock_icon, text='' - ) - - # Row: Run Sim Buttons - row = layout.row(align=True) - row.operator( - ct.OperatorType.NodeRunSimulation, - text='Run', - ) - if self.tracked_task_id: - tree_lock_icon = 'LOOP_BACK' + if self.uploaded_task_id: row.operator( - ct.OperatorType.ReleaseTrackedTask, + ct.OperatorType.NodeReleaseUploadedTask, icon='LOOP_BACK', text='', ) @@ -290,54 +258,30 @@ class Tidy3DWebExporterNode(base.MaxwellSimNode): col.label(icon=conn_icon) # Simulation Info - if self.inputs['Sim'].is_linked: + if self.sim is not None: row = layout.row() row.alignment = 'CENTER' row.label(text='Sim Info') box = layout.box() - split = box.split(factor=0.4) - ## Split: Left Column - col = split.column(align=False) - col.label(text='𝝨 Output') + if self.sim_info_invalidated: + box.operator(ct.OperatorType.NodeRecomputeSimInfo, text='Regenerate') + else: + split = box.split(factor=0.5) - ## Split: Right Column - col = split.column(align=False) - col.alignment = 'RIGHT' - col.label(text=f'{self.total_monitor_data / 1_000_000:.2f}MB') + ## Split: Left Column + col = split.column(align=False) + col.label(text='𝝨 Data') - # Cloud Task Info - if self.tracked_task_id and tdcloud.IS_AUTHENTICATED: - task_info = tdcloud.TidyCloudTasks.task_info(self.tracked_task_id) - if task_info is None: - return + ## Split: Right Column + col = split.column(align=False) + col.alignment = 'RIGHT' + col.label(text=f'{self.total_monitor_data / 1_000_000:.2f}MB') - ## Header - row = layout.row() - row.alignment = 'CENTER' - row.label(text='Task Info') - - ## Progress Bar - row = layout.row(align=True) - row.progress( - factor=0.0, - type='BAR', - text=f'Status: {task_info.status.capitalize()}', - ) - row.operator( - ct.OperatorType.NodeReloadTrackedTask, - text='', - icon='FILE_REFRESH', - ) - row.operator( - ct.OperatorType.NodeEstCostTrackedTask, - text='', - icon='SORTTIME', - ) - - ## Information + if self.uploaded_task_info is not None: + # Uploaded Task Information box = layout.box() - split = box.split(factor=0.4) + split = box.split(factor=0.6) ## Split: Left Column col = split.column(align=False) @@ -346,16 +290,20 @@ class Tidy3DWebExporterNode(base.MaxwellSimNode): col.label(text='Real Cost') ## Split: Right Column - cost_est = f'{self.est_cost:.2f}' if self.est_cost >= 0 else 'TBD' + cost_est = ( + f'{self.uploaded_est_cost:.2f}' + if self.uploaded_est_cost is not None + else 'TBD' + ) cost_real = ( - f'{task_info.cost_real:.2f}' - if task_info.cost_real is not None + f'{self.uploaded_task_info.cost_real:.2f}' + if self.uploaded_task_info.cost_real is not None else 'TBD' ) col = split.column(align=False) col.alignment = 'RIGHT' - col.label(text=task_info.status.capitalize()) + col.label(text=self.uploaded_task_info.status.capitalize()) col.label(text=f'{cost_est} creds') col.label(text=f'{cost_real} creds') @@ -364,43 +312,90 @@ class Tidy3DWebExporterNode(base.MaxwellSimNode): #################### # - Events #################### - @events.on_value_changed(prop_name='lock_tree', props={'lock_tree'}) - def on_lock_tree_changed(self, props): - if props['lock_tree']: + @events.on_value_changed( + socket_name='Sim', + run_on_init=True, + props={'sim_info_available', 'sim_info_invalidated'}, + ) + def on_sim_changed(self, props) -> None: + # Sim Linked | First Value Change + if self.inputs['Sim'].is_linked and not props['sim_info_available']: + log.critical('First Change: Mark Sim Info Available') + self.sim = bl_cache.Signal.InvalidateCache + self.total_monitor_data = bl_cache.Signal.InvalidateCache + self.is_sim_uploadable = bl_cache.Signal.InvalidateCache + self.sim_info_available = True + + # Sim Linked | Second Value Change + if ( + self.inputs['Sim'].is_linked + and props['sim_info_available'] + and not props['sim_info_invalidated'] + ): + log.critical('Second Change: Mark Sim Info Invalided') + self.sim_info_invalidated = True + + # Sim Linked | Nth Time + ## -> Danger of infinite expensive recompute of the sim every change. + ## -> Instead, user must manually set "available & not invalidated". + ## -> The UI should explain that the caches are dry. + ## -> The UI should also provide such a "hydration" button. + + # Sim Not Linked + ## -> If the sim is straight-up not available, cache needs changing. + ## -> Luckily, since we know there's no sim, invalidation is cheap. + ## -> Ends up being a "circuit breaker" for sim_info_invalidated. + elif not self.inputs['Sim'].is_linked: + log.critical('Unlinked: Short Circuit Zap Cache') + self.sim = bl_cache.Signal.InvalidateCache + self.total_monitor_data = bl_cache.Signal.InvalidateCache + self.is_sim_uploadable = bl_cache.Signal.InvalidateCache + self.sim_info_available = False + self.sim_info_invalidated = False + + @events.on_value_changed( + socket_name='Cloud Task', + run_on_init=True, + ) + def on_new_cloud_task_changed(self): + self.is_sim_uploadable = bl_cache.Signal.InvalidateCache + + @events.on_value_changed( + # Trigger + prop_name='uploaded_task_id', + run_on_init=True, + # Loaded + props={'uploaded_task_id'}, + ) + def on_uploaded_task_changed(self, props): + log.critical('Uploaded Task Changed') + self.is_sim_uploadable = bl_cache.Signal.InvalidateCache + + if props['uploaded_task_id'] != '': self.trigger_event(ct.FlowEvent.EnableLock) self.locked = False - for bl_socket in self.inputs: - if bl_socket.name == 'Sim': - continue - bl_socket.locked = False else: self.trigger_event(ct.FlowEvent.DisableLock) - @events.on_value_changed(prop_name='tracked_task_id', props={'tracked_task_id'}) - def on_tracked_task_id_changed(self, props): - if props['tracked_task_id']: - self.inputs['Cloud Task'].locked = True - - else: - self.total_monitor_data = bl_cache.Signal.InvalidateCache - self.est_cost = bl_cache.Signal.InvalidateCache - self.inputs['Cloud Task'].on_prepare_new_task() - self.inputs['Cloud Task'].locked = False + max_tries = 10 + for _ in range(max_tries): + self.uploaded_est_cost = bl_cache.Signal.InvalidateCache + if self.uploaded_est_cost is not None: + break #################### - # - Output Methods + # - Outputs #################### - ## TODO: Retrieve simulation data if/when the simulation is done @events.computes_output_socket( 'Cloud Task', - input_sockets={'Cloud Task'}, + props={'uploaded_task_id', 'uploaded_task'}, ) - def compute_cloud_task(self, input_sockets: dict) -> tdcloud.CloudTask | None: - if isinstance(cloud_task := input_sockets['Cloud Task'], tdcloud.CloudTask): - return cloud_task + def compute_cloud_task(self, props) -> tdcloud.CloudTask | None: + if props['uploaded_task_id'] != '': + return props['uploaded_task'] - return None + return ct.FlowSignal.FlowPending #################### @@ -408,11 +403,9 @@ class Tidy3DWebExporterNode(base.MaxwellSimNode): #################### BL_REGISTER = [ UploadSimulation, - RunSimulation, - ReloadTrackedTask, - EstCostTrackedTask, - ReleaseTrackedTask, + ReleaseUploadedTask, Tidy3DWebExporterNode, + RecomputeSimInfo, ] BL_NODES = { ct.NodeType.Tidy3DWebExporter: (ct.NodeCategory.MAXWELLSIM_OUTPUTS_WEBEXPORTERS) diff --git a/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/outputs/web_exporters/tidy3d_web_runner.py b/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/outputs/web_exporters/tidy3d_web_runner.py new file mode 100644 index 0000000..e0544b6 --- /dev/null +++ b/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/outputs/web_exporters/tidy3d_web_runner.py @@ -0,0 +1,270 @@ +import typing as typ + +import bpy +import tidy3d as td + +from blender_maxwell.services import tdcloud +from blender_maxwell.utils import bl_cache, logger + +from .... import contracts as ct +from .... import sockets +from ... import base, events + +log = logger.get(__name__) + + +#################### +# - Operators +#################### +class RunSimulation(bpy.types.Operator): + """Run a Tidy3D simulation accessible from a `Tidy3DWebRunnerNode`.""" + + bl_idname = ct.OperatorType.NodeRunSimulation + bl_label = 'Run Sim' + bl_description = 'Run the currently tracked simulation task' + + @classmethod + def poll(cls, context): + return ( + # Check Tidy3D Cloud + tdcloud.IS_AUTHENTICATED + # Check Tidy3DWebRunnerNode is Accessible + and hasattr(context, 'node') + and hasattr(context.node, 'node_type') + and context.node.node_type == ct.NodeType.Tidy3DWebRunner + # Check Task is Runnable + and context.node.is_task_runnable + ) + + def execute(self, context): + node = context.node + node.cloud_task.submit() + + ## TODO: Start modal timer that checks progress in a subprocess. + + return {'FINISHED'} + + +class ReloadTrackedTask(bpy.types.Operator): + """Reload information of the selected task in a `Tidy3DWebRunnerNode`.""" + + bl_idname = ct.OperatorType.NodeReloadTrackedTask + bl_label = 'Reload Tracked Tidy3D Cloud Task' + bl_description = 'Reload the currently tracked simulation task' + + @classmethod + def poll(cls, context): + return ( + # Check Tidy3D Cloud + tdcloud.IS_AUTHENTICATED + # Check Tidy3DWebRunnerNode is Accessible + and hasattr(context, 'node') + and hasattr(context.node, 'node_type') + and context.node.node_type == ct.NodeType.Tidy3DWebRunner + ) + + def execute(self, context): + node = context.node + tdcloud.TidyCloudTasks.update_task(node.cloud_task) + node.sim_data = bl_cache.Signal.InvalidateCache + + return {'FINISHED'} + + +#################### +# - Node +#################### +class Tidy3DWebRunnerNode(base.MaxwellSimNode): + node_type = ct.NodeType.Tidy3DWebRunner + bl_label = 'Tidy3D Web Runner' + + input_sockets: typ.ClassVar = { + 'Cloud Task': sockets.Tidy3DCloudTaskSocketDef( + should_exist=True, ## Ensure it is never NewSimCloudTask + ), + } + output_sockets: typ.ClassVar = { + 'Sim Data': sockets.MaxwellFDTDSimDataSocketDef(), + } + + #################### + # - Computed (Cached) + #################### + @property + def cloud_task(self) -> tdcloud.CloudTask | None: + """Retrieve the current cloud task from the input socket. + + If one can't be loaded, return None. + """ + cloud_task = self._compute_input( + 'Cloud Task', + kind=ct.FlowKind.Value, + ) + has_cloud_task = not ct.FlowSignal.check(cloud_task) + + if has_cloud_task: + return cloud_task + return None + + @property + def task_info(self) -> tdcloud.CloudTaskInfo | None: + """Retrieve the current cloud task information from the input socket. + + If it can't be loaded, return None. + """ + cloud_task = self.cloud_task + if cloud_task is None: + return None + + # Retrieve Task Info + task_info = tdcloud.TidyCloudTasks.task_info(cloud_task.task_id) + if task_info is None: + return None + + return task_info + + @bl_cache.cached_bl_property(persist=False) + def sim_data(self) -> td.Simulation | None: + """Retrieve the simulation data of the current cloud task from the input socket. + + If it can't be loaded, return None. + """ + task_info = self.task_info + if task_info is None: + return None + + if task_info.status == 'success': + # Download Sim Data + ## -> self.cloud_task really shouldn't be able to be None here. + ## -> So, we check it by applying the Ostrich method. + sim_data = tdcloud.TidyCloudTasks.download_task_sim_data( + self.cloud_task, + tdcloud.TidyCloudTasks.task_info( + self.cloud_task.task_id + ).disk_cache_path(ct.addon.ADDON_CACHE), + ) + if sim_data is None: + return None + + return sim_data + + return None + + #################### + # - Computed (Uncached) + #################### + @property + def is_task_runnable(self) -> bool: + """Checks whether all conditions are satisfied to be able to actually run a simulation.""" + if self.task_info is not None: + return self.task_info.status == 'draft' + ## TODO: Rely on Visible Cost Estimate + return False + + #################### + # - UI + #################### + def draw_operators(self, context, layout): + # Row: Run Sim Buttons + row = layout.row(align=True) + row.operator( + ct.OperatorType.NodeRunSimulation, + text='Run Sim', + ) + + def draw_info(self, context, layout): + # Connection Info + auth_icon = 'CHECKBOX_HLT' if tdcloud.IS_AUTHENTICATED else 'CHECKBOX_DEHLT' + conn_icon = 'CHECKBOX_HLT' if tdcloud.IS_ONLINE else 'CHECKBOX_DEHLT' + + row = layout.row() + row.alignment = 'CENTER' + row.label(text='Cloud Status') + box = layout.box() + split = box.split(factor=0.85) + + ## Split: Left Column + col = split.column(align=False) + col.label(text='Authed') + col.label(text='Connected') + + ## Split: Right Column + col = split.column(align=False) + col.label(icon=auth_icon) + col.label(icon=conn_icon) + + # Cloud Task Info + if self.task_info is not None: + # Header + row = layout.row() + row.alignment = 'CENTER' + row.label(text='Task Info') + + # Task Run Progress + # row = layout.row(align=True) + # row.progress( + # factor=0.0, + # type='BAR', + # text=f'Status: {self.task_info.status.capitalize()}', + # ) + row.operator( + ct.OperatorType.NodeReloadTrackedTask, + text='', + icon='FILE_REFRESH', + ) + + # Task Information + box = layout.box() + split = box.split(factor=0.4) + + ## Split: Left Column + col = split.column(align=False) + col.label(text='Status') + col.label(text='Real Cost') + + ## Split: Right Column + cost_real = ( + f'{self.task_info.cost_real:.2f}' + if self.task_info.cost_real is not None + else 'TBD' + ) + + col = split.column(align=False) + col.alignment = 'RIGHT' + col.label(text=self.task_info.status.capitalize()) + col.label(text=f'{cost_real} creds') + + #################### + # - Output Methods + #################### + @events.on_value_changed( + socket_name='Cloud Task', + ) + def compute_cloud_task(self) -> None: + self.sim_data = bl_cache.Signal.InvalidateCache + + @events.computes_output_socket( + 'Sim Data', + props={'sim_data'}, + input_sockets={'Cloud Task'}, ## Keep to respect dependency chains. + ) + def compute_sim_data( + self, props, input_sockets + ) -> td.SimulationData | ct.FlowSignal: + if props['sim_data'] is None: + return ct.FlowSignal.FlowPending + + return props['sim_data'] + + +#################### +# - Blender Registration +#################### +BL_REGISTER = [ + RunSimulation, + ReloadTrackedTask, + Tidy3DWebRunnerNode, +] +BL_NODES = { + ct.NodeType.Tidy3DWebRunner: (ct.NodeCategory.MAXWELLSIM_OUTPUTS_WEBEXPORTERS) +} diff --git a/src/blender_maxwell/node_trees/maxwell_sim_nodes/sockets/base.py b/src/blender_maxwell/node_trees/maxwell_sim_nodes/sockets/base.py index 7608ae2..ba190cf 100644 --- a/src/blender_maxwell/node_trees/maxwell_sim_nodes/sockets/base.py +++ b/src/blender_maxwell/node_trees/maxwell_sim_nodes/sockets/base.py @@ -262,9 +262,12 @@ class MaxwellSimSocket(bpy.types.NodeSocket): """ ## TODO: Evaluate this properly if self.initializing: - return - - if hasattr(self, prop_name): + log.debug( + '%s: Rejected on_prop_changed("%s") while initializing', + self.bl_label, + prop_name, + ) + elif hasattr(self, prop_name): # Invalidate UI BLField Caches if prop_name in self.ui_blfields: setattr(self, prop_name, bl_cache.Signal.InvalidateCache) diff --git a/src/blender_maxwell/node_trees/maxwell_sim_nodes/sockets/tidy3d/cloud_task.py b/src/blender_maxwell/node_trees/maxwell_sim_nodes/sockets/tidy3d/cloud_task.py index 1147c0f..feddd54 100644 --- a/src/blender_maxwell/node_trees/maxwell_sim_nodes/sockets/tidy3d/cloud_task.py +++ b/src/blender_maxwell/node_trees/maxwell_sim_nodes/sockets/tidy3d/cloud_task.py @@ -32,7 +32,9 @@ class ReloadFolderList(bpy.types.Operator): tdcloud.TidyCloudFolders.update_folders() tdcloud.TidyCloudTasks.update_tasks(bl_socket.existing_folder_id) - bl_socket.on_cloud_updated() + + bl_socket.existing_folder_id = bl_cache.Signal.ResetEnumItems + bl_socket.existing_task_id = bl_cache.Signal.ResetEnumItems return {'FINISHED'} @@ -57,7 +59,9 @@ class Authenticate(bpy.types.Operator): if not tdcloud.check_authentication(): tdcloud.authenticate_with_api_key(bl_socket.api_key) bl_socket.api_key = '' - bl_socket.on_cloud_updated() + + bl_socket.existing_folder_id = bl_cache.Signal.ResetEnumItems + bl_socket.existing_task_id = bl_cache.Signal.ResetEnumItems return {'FINISHED'} @@ -96,20 +100,6 @@ class Tidy3DCloudTaskBLSocket(base.MaxwellSimSocket): new_task_name: str = bl_cache.BLField('', prop_ui=True) - #################### - # - Property Changes - #################### - def on_socket_prop_changed(self, prop_name: str) -> None: - if prop_name in [ - 'api_key', - 'existing_folder_id', - 'existing_task_id', - 'new_task_name', - 'should_exist', - ]: - self.existing_folder_id = bl_cache.Signal.ResetEnumItems - self.existing_task_id = bl_cache.Signal.ResetEnumItems - #################### # - FlowKinds #################### @@ -124,33 +114,30 @@ class Tidy3DCloudTaskBLSocket(base.MaxwellSimSocket): @property def value( self, - ) -> tuple[tdcloud.CloudTaskName, tdcloud.CloudFolder] | tdcloud.CloudTask | None: + ) -> ct.NewSimCloudTask | tdcloud.CloudTask | ct.FlowSignal: if tdcloud.IS_AUTHENTICATED: # Retrieve Folder cloud_folder = tdcloud.TidyCloudFolders.folders().get( self.existing_folder_id ) if cloud_folder is None: - msg = f"Selected folder {cloud_folder} doesn't exist (it was probably deleted elsewhere)" - raise RuntimeError(msg) + return ct.FlowSignal.NoFlow ## Folder deleted somewhere else - # Doesn't Exist: Return Construction Information + # Case: New Task if not self.should_exist: - return (self.new_task_name, cloud_folder) + return ct.NewSimCloudTask( + task_name=self.new_task_name, cloud_folder=cloud_folder + ) - # No Task Selected: Return None - if self.existing_task_id is None: - return None + # Case: Existing Task + if self.existing_task_id is not None: + cloud_task = tdcloud.TidyCloudTasks.tasks(cloud_folder).get( + self.existing_task_id + ) + if cloud_folder is None: + return ct.FlowSignal.NoFlow ## Task deleted somewhere else - # Retrieve Cloud Task - cloud_task = tdcloud.TidyCloudTasks.tasks(cloud_folder).get( - self.existing_task_id - ) - if cloud_task is None: - msg = f"Selected task {cloud_task} doesn't exist (it was probably deleted elsewhere)" - raise RuntimeError(msg) - - return cloud_task + return cloud_task return ct.FlowSignal.FlowPending @@ -227,17 +214,6 @@ class Tidy3DCloudTaskBLSocket(base.MaxwellSimSocket): ) ] - #################### - # - Node-Initiated Updates - #################### - def on_new_task_created(self, cloud_task: tdcloud.CloudTask) -> None: - self.existing_folder_id = cloud_task.folder_id - self.existing_task_id = cloud_task.task_id - self.should_exist = True - - def on_prepare_new_task(self): - self.should_exist = False - #################### # - UI #################### diff --git a/src/blender_maxwell/services/tdcloud.py b/src/blender_maxwell/services/tdcloud.py index 9883fe7..7226a83 100644 --- a/src/blender_maxwell/services/tdcloud.py +++ b/src/blender_maxwell/services/tdcloud.py @@ -8,6 +8,7 @@ import datetime as dt import functools import tempfile import typing as typ +import urllib from dataclasses import dataclass from pathlib import Path @@ -46,11 +47,27 @@ def set_offline(): IS_ONLINE = False +def check_online() -> bool: + global IS_ONLINE # noqa: PLW0603 + try: + urllib.request.urlopen( + 'https://docs.flexcompute.com/projects/tidy3d/en/latest/index.html', + timeout=2, + ) + except: + IS_ONLINE = False + return False + else: + IS_ONLINE = True + return True + + #################### # - Cloud Authentication #################### def check_authentication() -> bool: global IS_AUTHENTICATED # noqa: PLW0603 + log.critical('Checking Authentication') # Check Previous Authentication ## If we authenticated once, we presume that it'll work again. @@ -78,6 +95,14 @@ def authenticate_with_api_key(api_key: str) -> bool: return check_authentication() +TD_CONFIG = Path(td_web.cli.constants.CONFIG_FILE) + +## TODO: Robustness is key - internet might be down. +## -> I'm not a huge fan of the max 2sec startup time burden +if TD_CONFIG.is_file() and check_online(): + check_authentication() + + #################### # - Cloud Folder #################### @@ -145,11 +170,12 @@ class TidyCloudFolders: #################### @dataclass class CloudTaskInfo: - """Toned-down, simplified `dataclass` variant of TaskInfo. + """Toned-down `dataclass` variant of `tidy3d`'s TaskInfo. See TaskInfo for more: ) """ + task_id: str task_name: str status: str created_at: dt.datetime @@ -168,6 +194,15 @@ class CloudTaskInfo: version_solver: str | None = None ## solverVersion callback_url: str | None = None ## callbackUrl + def disk_cache_path(self, addon_cache: Path) -> Path: + """Compute an appropriate location for caching simulations downloaded from the internet, unique to each task ID. + + Arguments: + task_id: The ID of the Tidy3D cloud task. + """ + (addon_cache / self.task_id).mkdir(exist_ok=True) + return addon_cache / self.task_id / 'sim_data.hdf5' + class TidyCloudTasks: """Greatly simplifies working with Tidy3D Tasks in the Cloud, specifically, via the lowish-level `tidy3d.web.core.task_core.SimulationTask` object. @@ -232,6 +267,7 @@ class TidyCloudTasks: ## Task Info Cache for task_id, cloud_task in cloud_tasks.items(): cls.cache_task_info[task_id] = CloudTaskInfo( + task_id=task_id, task_name=cloud_task.taskName, status=cloud_task.status, created_at=cloud_task.created_at, @@ -346,6 +382,7 @@ class TidyCloudTasks: ## Task Info Cache cls.cache_task_info[cloud_task.task_id] = CloudTaskInfo( + task_id=cloud_task.task_id, task_name=cloud_task.taskName, status=cloud_task.status, created_at=cloud_task.created_at,