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,