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.
main
Sofus Albert Høgsbro Rose 2024-05-03 11:47:51 +02:00
parent 695eedca98
commit b221f9ae2b
Signed by: so-rose
GPG Key ID: AD901CB0F3701434
12 changed files with 620 additions and 324 deletions

View File

@ -26,8 +26,8 @@ class OperatorType(enum.StrEnum):
NodeLoadCloudSim = enum.auto() NodeLoadCloudSim = enum.auto()
# Node: Tidy3DWebExporter # Node: Tidy3DWebExporter
NodeRecomputeSimInfo = enum.auto()
NodeUploadSimulation = enum.auto() NodeUploadSimulation = enum.auto()
NodeReleaseUploadedTask = enum.auto()
NodeRunSimulation = enum.auto() NodeRunSimulation = enum.auto()
NodeReloadTrackedTask = enum.auto() NodeReloadTrackedTask = enum.auto()
NodeEstCostTrackedTask = enum.auto()
ReleaseTrackedTask = enum.auto()

View File

@ -40,7 +40,7 @@ from .flow_signals import FlowSignal
from .icons import Icon from .icons import Icon
from .mobj_types import ManagedObjType from .mobj_types import ManagedObjType
from .node_types import NodeType 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_colors import SOCKET_COLORS
from .socket_types import SocketType from .socket_types import SocketType
from .tree_types import TreeType from .tree_types import TreeType
@ -79,6 +79,7 @@ __all__ = [
'BLSocketType', 'BLSocketType',
'NodeType', 'NodeType',
'BoundCondType', 'BoundCondType',
'NewSimCloudTask',
'SimSpaceAxis', 'SimSpaceAxis',
'manual_amp_time', 'manual_amp_time',
'NodeCategory', 'NodeCategory',

View File

@ -38,6 +38,7 @@ class NodeType(blender_type_enum.BlenderTypeEnum):
Viewer = enum.auto() Viewer = enum.auto()
## Outputs / File Exporters ## Outputs / File Exporters
Tidy3DWebExporter = enum.auto() Tidy3DWebExporter = enum.auto()
Tidy3DWebRunner = enum.auto()
## Outputs / Web Exporters ## Outputs / Web Exporters
JSONFileExporter = enum.auto() JSONFileExporter = enum.auto()

View File

@ -1,12 +1,18 @@
"""Declares various simulation types for use by nodes and sockets.""" """Declares various simulation types for use by nodes and sockets."""
import dataclasses
import enum import enum
import typing as typ import typing as typ
import jax.numpy as jnp import jax.numpy as jnp
import tidy3d as td import tidy3d as td
from blender_maxwell.services import tdcloud
####################
# - JAX-Helpers
####################
def manual_amp_time(self, time: float) -> complex: 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. """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! ## TODO: Sim Domain type, w/pydantic checks!
####################
# - Global Simulation Coordinate System
####################
class SimSpaceAxis(enum.StrEnum): class SimSpaceAxis(enum.StrEnum):
"""The axis labels of the global simulation coordinate system.""" """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] return {SSA.X: 0, SSA.Y: 1, SSA.Z: 2}[self]
####################
# - Boundary Condition Type
####################
class BoundCondType(enum.StrEnum): class BoundCondType(enum.StrEnum):
r"""A type of boundary condition, applied to a half-axis of a simulation domain. 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.Pmc: td.PMCBoundary(),
BCT.NaiveBloch: td.Periodic(), BCT.NaiveBloch: td.Periodic(),
}[self] }[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

View File

@ -1,8 +1,8 @@
from . import tidy_3d_web_importer #from . import tidy_3d_web_importer
BL_REGISTER = [ BL_REGISTER = [
*tidy_3d_web_importer.BL_REGISTER, #*tidy_3d_web_importer.BL_REGISTER,
] ]
BL_NODES = { BL_NODES = {
**tidy_3d_web_importer.BL_NODES, #**tidy_3d_web_importer.BL_NODES,
} }

View File

@ -35,7 +35,7 @@ class LoadCloudSim(bpy.types.Operator):
node = context.node node = context.node
# Try Loading Simulation Data # Try Loading Simulation Data
#node.sim_data = bl_cache.Signal.InvalidateCache # node.sim_data = bl_cache.Signal.InvalidateCache
sim_data = node.sim_data sim_data = node.sim_data
if sim_data is None: if sim_data is None:
self.report( self.report(
@ -48,16 +48,6 @@ class LoadCloudSim(bpy.types.Operator):
return {'FINISHED'} 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 # - Node
#################### ####################

View File

@ -1,8 +1,10 @@
from . import tidy3d_web_exporter from . import tidy3d_web_exporter, tidy3d_web_runner
BL_REGISTER = [ BL_REGISTER = [
*tidy3d_web_exporter.BL_REGISTER, *tidy3d_web_exporter.BL_REGISTER,
*tidy3d_web_runner.BL_REGISTER,
] ]
BL_NODES = { BL_NODES = {
**tidy3d_web_exporter.BL_NODES, **tidy3d_web_exporter.BL_NODES,
**tidy3d_web_runner.BL_NODES,
} }

View File

@ -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): class UploadSimulation(bpy.types.Operator):
bl_idname = ct.OperatorType.NodeUploadSimulation bl_idname = ct.OperatorType.NodeUploadSimulation
bl_label = 'Upload Tidy3D Simulation' bl_label = 'Upload Tidy3D Simulation'
@ -24,119 +54,48 @@ class UploadSimulation(bpy.types.Operator):
@classmethod @classmethod
def poll(cls, context): def poll(cls, context):
return ( 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 hasattr(context.node, 'node_type')
and context.node.node_type == ct.NodeType.Tidy3DWebExporter and context.node.node_type == ct.NodeType.Tidy3DWebExporter
and context.node.lock_tree # Check Sim is Available (aka. uploadeable)
and tdcloud.IS_AUTHENTICATED and context.node.is_sim_uploadable
and not context.node.tracked_task_id and context.node.uploaded_task_id == ''
and context.node.inputs['Sim'].is_linked
) )
def execute(self, context): def execute(self, context):
node = context.node node = context.node
node.upload_sim() 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,
)
node.uploaded_task_id = cloud_task.task_id
return {'FINISHED'} return {'FINISHED'}
class RunSimulation(bpy.types.Operator): class ReleaseUploadedTask(bpy.types.Operator):
bl_idname = ct.OperatorType.NodeRunSimulation bl_idname = ct.OperatorType.NodeReleaseUploadedTask
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'
)
def execute(self, context):
node = context.node
node.run_tracked_task()
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
bl_label = 'Release Tracked Tidy3D Cloud Task' bl_label = 'Release Tracked Tidy3D Cloud Task'
bl_description = 'Release the currently tracked simulation task' bl_description = 'Release the currently tracked simulation task'
@classmethod @classmethod
def poll(cls, context): def poll(cls, context):
return ( return (
# Check Tidy3DWebExporter is Accessible
hasattr(context, 'node') hasattr(context, 'node')
and hasattr(context.node, 'node_type') and hasattr(context.node, 'node_type')
and context.node.node_type == ct.NodeType.Tidy3DWebExporter and context.node.node_type == ct.NodeType.Tidy3DWebExporter
# and tdcloud.IS_AUTHENTICATED # Check Sim is Available (aka. uploadeable)
and context.node.tracked_task_id and context.node.uploaded_task_id != ''
) )
def execute(self, context): def execute(self, context):
node = context.node node = context.node
node.tracked_task_id = '' node.uploaded_task_id = ''
return {'FINISHED'} return {'FINISHED'}
@ -154,9 +113,6 @@ class Tidy3DWebExporterNode(base.MaxwellSimNode):
), ),
} }
output_sockets: typ.ClassVar = { output_sockets: typ.ClassVar = {
'Sim Data': sockets.Tidy3DCloudTaskSocketDef(
should_exist=True,
),
'Cloud Task': sockets.Tidy3DCloudTaskSocketDef( 'Cloud Task': sockets.Tidy3DCloudTaskSocketDef(
should_exist=True, should_exist=True,
), ),
@ -165,11 +121,12 @@ class Tidy3DWebExporterNode(base.MaxwellSimNode):
#################### ####################
# - Properties # - Properties
#################### ####################
lock_tree: bool = bl_cache.BLField(False, prop_ui=True) sim_info_available: bool = bl_cache.BLField(False)
tracked_task_id: str = bl_cache.BLField('', prop_ui=True) 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) @bl_cache.cached_bl_property(persist=False)
def sim(self) -> td.Simulation | None: def sim(self) -> td.Simulation | None:
@ -177,67 +134,90 @@ class Tidy3DWebExporterNode(base.MaxwellSimNode):
has_sim = not ct.FlowSignal.check(sim) has_sim = not ct.FlowSignal.check(sim)
if has_sim: if has_sim:
sim.validate_pre_upload(source_required=True)
return sim return sim
return None return None
@bl_cache.cached_bl_property(persist=False) @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: if self.sim is not None:
return sum(self.sim.monitors_data_size.values()) 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) @bl_cache.cached_bl_property(persist=False)
def est_cost(self) -> float | None: def uploaded_est_cost(self) -> float | None:
if self.tracked_task_id != '': task_info = self.uploaded_task_info
task_info = tdcloud.TidyCloudTasks.task_info(self.tracked_task_id)
if task_info is not None: if task_info is not None:
return task_info.cost_est() est_cost = task_info.cost_est()
if est_cost is not None:
return est_cost
return None return None
#################### ####################
# - Methods # - Computed - Combined
#################### ####################
def upload_sim(self): @bl_cache.cached_bl_property(persist=False)
if self.sim is None: def is_sim_uploadable(self) -> bool:
msg = 'Tried to upload simulation, but none is attached' if (
raise ValueError(msg) self.sim is not None
and self.uploaded_task_id == ''
if (new_task := self._compute_input('Cloud Task')) is None or isinstance( and self.new_cloud_task is not None
new_task, and self.new_cloud_task.task_name != ''
tdcloud.CloudTask,
): ):
msg = ( try:
'Tried to upload simulation to new task, but existing task was selected' self.sim.validate_pre_upload(source_required=True)
) except:
raise ValueError(msg) log.exception()
return False
# Create Cloud Task else:
cloud_task = tdcloud.TidyCloudTasks.mk_task( return True
task_name=new_task[0], return False
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.
#################### ####################
# - UI # - UI
@ -249,21 +229,9 @@ class Tidy3DWebExporterNode(base.MaxwellSimNode):
ct.OperatorType.NodeUploadSimulation, ct.OperatorType.NodeUploadSimulation,
text='Upload', text='Upload',
) )
tree_lock_icon = 'LOCKED' if self.lock_tree else 'UNLOCKED' if self.uploaded_task_id:
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( row.operator(
ct.OperatorType.NodeRunSimulation, ct.OperatorType.NodeReleaseUploadedTask,
text='Run',
)
if self.tracked_task_id:
tree_lock_icon = 'LOOP_BACK'
row.operator(
ct.OperatorType.ReleaseTrackedTask,
icon='LOOP_BACK', icon='LOOP_BACK',
text='', text='',
) )
@ -290,54 +258,30 @@ class Tidy3DWebExporterNode(base.MaxwellSimNode):
col.label(icon=conn_icon) col.label(icon=conn_icon)
# Simulation Info # Simulation Info
if self.inputs['Sim'].is_linked: if self.sim is not None:
row = layout.row() row = layout.row()
row.alignment = 'CENTER' row.alignment = 'CENTER'
row.label(text='Sim Info') row.label(text='Sim Info')
box = layout.box() box = layout.box()
split = box.split(factor=0.4)
if self.sim_info_invalidated:
box.operator(ct.OperatorType.NodeRecomputeSimInfo, text='Regenerate')
else:
split = box.split(factor=0.5)
## Split: Left Column ## Split: Left Column
col = split.column(align=False) col = split.column(align=False)
col.label(text='𝝨 Output') col.label(text='𝝨 Data')
## Split: Right Column ## Split: Right Column
col = split.column(align=False) col = split.column(align=False)
col.alignment = 'RIGHT' col.alignment = 'RIGHT'
col.label(text=f'{self.total_monitor_data / 1_000_000:.2f}MB') col.label(text=f'{self.total_monitor_data / 1_000_000:.2f}MB')
# Cloud Task Info if self.uploaded_task_info is not None:
if self.tracked_task_id and tdcloud.IS_AUTHENTICATED: # Uploaded Task Information
task_info = tdcloud.TidyCloudTasks.task_info(self.tracked_task_id)
if task_info is None:
return
## 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
box = layout.box() box = layout.box()
split = box.split(factor=0.4) split = box.split(factor=0.6)
## Split: Left Column ## Split: Left Column
col = split.column(align=False) col = split.column(align=False)
@ -346,16 +290,20 @@ class Tidy3DWebExporterNode(base.MaxwellSimNode):
col.label(text='Real Cost') col.label(text='Real Cost')
## Split: Right Column ## 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 = ( cost_real = (
f'{task_info.cost_real:.2f}' f'{self.uploaded_task_info.cost_real:.2f}'
if task_info.cost_real is not None if self.uploaded_task_info.cost_real is not None
else 'TBD' else 'TBD'
) )
col = split.column(align=False) col = split.column(align=False)
col.alignment = 'RIGHT' 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_est} creds')
col.label(text=f'{cost_real} creds') col.label(text=f'{cost_real} creds')
@ -364,43 +312,90 @@ class Tidy3DWebExporterNode(base.MaxwellSimNode):
#################### ####################
# - Events # - Events
#################### ####################
@events.on_value_changed(prop_name='lock_tree', props={'lock_tree'}) @events.on_value_changed(
def on_lock_tree_changed(self, props): socket_name='Sim',
if props['lock_tree']: 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.trigger_event(ct.FlowEvent.EnableLock)
self.locked = False self.locked = False
for bl_socket in self.inputs:
if bl_socket.name == 'Sim':
continue
bl_socket.locked = False
else: else:
self.trigger_event(ct.FlowEvent.DisableLock) self.trigger_event(ct.FlowEvent.DisableLock)
@events.on_value_changed(prop_name='tracked_task_id', props={'tracked_task_id'}) max_tries = 10
def on_tracked_task_id_changed(self, props): for _ in range(max_tries):
if props['tracked_task_id']: self.uploaded_est_cost = bl_cache.Signal.InvalidateCache
self.inputs['Cloud Task'].locked = True if self.uploaded_est_cost is not None:
break
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
#################### ####################
# - Output Methods # - Outputs
#################### ####################
## TODO: Retrieve simulation data if/when the simulation is done
@events.computes_output_socket( @events.computes_output_socket(
'Cloud Task', 'Cloud Task',
input_sockets={'Cloud Task'}, props={'uploaded_task_id', 'uploaded_task'},
) )
def compute_cloud_task(self, input_sockets: dict) -> tdcloud.CloudTask | None: def compute_cloud_task(self, props) -> tdcloud.CloudTask | None:
if isinstance(cloud_task := input_sockets['Cloud Task'], tdcloud.CloudTask): if props['uploaded_task_id'] != '':
return cloud_task return props['uploaded_task']
return None return ct.FlowSignal.FlowPending
#################### ####################
@ -408,11 +403,9 @@ class Tidy3DWebExporterNode(base.MaxwellSimNode):
#################### ####################
BL_REGISTER = [ BL_REGISTER = [
UploadSimulation, UploadSimulation,
RunSimulation, ReleaseUploadedTask,
ReloadTrackedTask,
EstCostTrackedTask,
ReleaseTrackedTask,
Tidy3DWebExporterNode, Tidy3DWebExporterNode,
RecomputeSimInfo,
] ]
BL_NODES = { BL_NODES = {
ct.NodeType.Tidy3DWebExporter: (ct.NodeCategory.MAXWELLSIM_OUTPUTS_WEBEXPORTERS) ct.NodeType.Tidy3DWebExporter: (ct.NodeCategory.MAXWELLSIM_OUTPUTS_WEBEXPORTERS)

View File

@ -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)
}

View File

@ -262,9 +262,12 @@ class MaxwellSimSocket(bpy.types.NodeSocket):
""" """
## TODO: Evaluate this properly ## TODO: Evaluate this properly
if self.initializing: if self.initializing:
return log.debug(
'%s: Rejected on_prop_changed("%s") while initializing',
if hasattr(self, prop_name): self.bl_label,
prop_name,
)
elif hasattr(self, prop_name):
# Invalidate UI BLField Caches # Invalidate UI BLField Caches
if prop_name in self.ui_blfields: if prop_name in self.ui_blfields:
setattr(self, prop_name, bl_cache.Signal.InvalidateCache) setattr(self, prop_name, bl_cache.Signal.InvalidateCache)

View File

@ -32,7 +32,9 @@ class ReloadFolderList(bpy.types.Operator):
tdcloud.TidyCloudFolders.update_folders() tdcloud.TidyCloudFolders.update_folders()
tdcloud.TidyCloudTasks.update_tasks(bl_socket.existing_folder_id) 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'} return {'FINISHED'}
@ -57,7 +59,9 @@ class Authenticate(bpy.types.Operator):
if not tdcloud.check_authentication(): if not tdcloud.check_authentication():
tdcloud.authenticate_with_api_key(bl_socket.api_key) tdcloud.authenticate_with_api_key(bl_socket.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'} return {'FINISHED'}
@ -96,20 +100,6 @@ class Tidy3DCloudTaskBLSocket(base.MaxwellSimSocket):
new_task_name: str = bl_cache.BLField('', prop_ui=True) 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 # - FlowKinds
#################### ####################
@ -124,31 +114,28 @@ class Tidy3DCloudTaskBLSocket(base.MaxwellSimSocket):
@property @property
def value( def value(
self, self,
) -> tuple[tdcloud.CloudTaskName, tdcloud.CloudFolder] | tdcloud.CloudTask | None: ) -> ct.NewSimCloudTask | tdcloud.CloudTask | ct.FlowSignal:
if tdcloud.IS_AUTHENTICATED: if tdcloud.IS_AUTHENTICATED:
# Retrieve Folder # Retrieve Folder
cloud_folder = tdcloud.TidyCloudFolders.folders().get( cloud_folder = tdcloud.TidyCloudFolders.folders().get(
self.existing_folder_id self.existing_folder_id
) )
if cloud_folder is None: if cloud_folder is None:
msg = f"Selected folder {cloud_folder} doesn't exist (it was probably deleted elsewhere)" return ct.FlowSignal.NoFlow ## Folder deleted somewhere else
raise RuntimeError(msg)
# Doesn't Exist: Return Construction Information # Case: New Task
if not self.should_exist: 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 # Case: Existing Task
if self.existing_task_id is None: if self.existing_task_id is not None:
return None
# Retrieve Cloud Task
cloud_task = tdcloud.TidyCloudTasks.tasks(cloud_folder).get( cloud_task = tdcloud.TidyCloudTasks.tasks(cloud_folder).get(
self.existing_task_id self.existing_task_id
) )
if cloud_task is None: if cloud_folder is None:
msg = f"Selected task {cloud_task} doesn't exist (it was probably deleted elsewhere)" return ct.FlowSignal.NoFlow ## Task deleted somewhere else
raise RuntimeError(msg)
return cloud_task return cloud_task
@ -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 # - UI
#################### ####################

View File

@ -8,6 +8,7 @@ import datetime as dt
import functools import functools
import tempfile import tempfile
import typing as typ import typing as typ
import urllib
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
@ -46,11 +47,27 @@ def set_offline():
IS_ONLINE = False 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 # - Cloud Authentication
#################### ####################
def check_authentication() -> bool: def check_authentication() -> bool:
global IS_AUTHENTICATED # noqa: PLW0603 global IS_AUTHENTICATED # noqa: PLW0603
log.critical('Checking Authentication')
# Check Previous Authentication # Check Previous Authentication
## If we authenticated once, we presume that it'll work again. ## 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() 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 # - Cloud Folder
#################### ####################
@ -145,11 +170,12 @@ class TidyCloudFolders:
#################### ####################
@dataclass @dataclass
class CloudTaskInfo: class CloudTaskInfo:
"""Toned-down, simplified `dataclass` variant of TaskInfo. """Toned-down `dataclass` variant of `tidy3d`'s TaskInfo.
See TaskInfo for more: <https://github.com/flexcompute/tidy3d/blob/453055e89dcff6d619597120b47817e996f1c198/tidy3d/web/core/task_info.py>) See TaskInfo for more: <https://github.com/flexcompute/tidy3d/blob/453055e89dcff6d619597120b47817e996f1c198/tidy3d/web/core/task_info.py>)
""" """
task_id: str
task_name: str task_name: str
status: str status: str
created_at: dt.datetime created_at: dt.datetime
@ -168,6 +194,15 @@ class CloudTaskInfo:
version_solver: str | None = None ## solverVersion version_solver: str | None = None ## solverVersion
callback_url: str | None = None ## callbackUrl 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: class TidyCloudTasks:
"""Greatly simplifies working with Tidy3D Tasks in the Cloud, specifically, via the lowish-level `tidy3d.web.core.task_core.SimulationTask` object. """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 ## Task Info Cache
for task_id, cloud_task in cloud_tasks.items(): for task_id, cloud_task in cloud_tasks.items():
cls.cache_task_info[task_id] = CloudTaskInfo( cls.cache_task_info[task_id] = CloudTaskInfo(
task_id=task_id,
task_name=cloud_task.taskName, task_name=cloud_task.taskName,
status=cloud_task.status, status=cloud_task.status,
created_at=cloud_task.created_at, created_at=cloud_task.created_at,
@ -346,6 +382,7 @@ class TidyCloudTasks:
## Task Info Cache ## Task Info Cache
cls.cache_task_info[cloud_task.task_id] = CloudTaskInfo( cls.cache_task_info[cloud_task.task_id] = CloudTaskInfo(
task_id=cloud_task.task_id,
task_name=cloud_task.taskName, task_name=cloud_task.taskName,
status=cloud_task.status, status=cloud_task.status,
created_at=cloud_task.created_at, created_at=cloud_task.created_at,