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()
# Node: Tidy3DWebExporter
NodeRecomputeSimInfo = enum.auto()
NodeUploadSimulation = enum.auto()
NodeReleaseUploadedTask = enum.auto()
NodeRunSimulation = enum.auto()
NodeReloadTrackedTask = enum.auto()
NodeEstCostTrackedTask = enum.auto()
ReleaseTrackedTask = enum.auto()

View File

@ -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',

View File

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

View File

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

View File

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

View File

@ -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
####################

View File

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

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):
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)

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

View File

@ -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
####################

View File

@ -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: <https://github.com/flexcompute/tidy3d/blob/453055e89dcff6d619597120b47817e996f1c198/tidy3d/web/core/task_info.py>)
"""
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,