Compare commits

...

2 Commits

Author SHA1 Message Date
Sofus Albert Høgsbro Rose 785995117e
fix: Invalidate cache of removed input sockets.
This was a nasty (interesting?) one - usually, input sockets are not attempted used
after the socket no longer exists.
Various checks in ex. `events` tend to help that process along.

Unfortunately (fortunately?), `Extract` uses a `_compute_input` query with
`optional=True`, which results in a direct attempt to hit the cache
without any other checks.
Because old input socket caches were never deleted, it would
**continue to get cached data from sockets that no longer exist**.

While on the surface this could be considered a case of "the
private method (`_compute_input`) is private for a reason", or
alternatively, "don't hijack the graph flow", I'm more convinced that
the usage is actually quite clean, being read-only and generally
well-conceived. It's reasonable to presume that asking for a thing that
doesn't exist won't produce output!

Moreover, I wouldn't be surprised if several other mysterious bugs were
caused by this. Not to mention the memory leak of endless caching! (Well,
until the node is deleted). It's a good things we noticed!
2024-04-24 18:41:06 +02:00
Sofus Albert Høgsbro Rose c82862dde9
fix: Implement explicit no-flow w/FlowSignal 2024-04-24 18:36:29 +02:00
19 changed files with 775 additions and 450 deletions

34
TODO.md
View File

@ -541,19 +541,19 @@ We need support for arbitrary objects, but still backed by the persistance seman
- [ ] Implement Enum property, (also see <https://developer.blender.org/docs/release_notes/4.1/python_api/#enum-id-properties>)
- Use this to bridge the enum UI to actual StrEnum objects.
- This also maybe enables some very interesting use cases when it comes to ex. static verifiability of data provided to event callbacks.
- [ ] Ensure certain options, namely `name` (as `ui_name`), `default`, `subtype`, (numeric) `min`, `max`, `step`, `precision`, (string) `maxlen`, `search`, and `search_options`, can be passed down via the `BLField()` constructor.
- [x] Ensure certain options, namely `name` (as `ui_name`), `default`, `subtype`, (numeric) `min`, `max`, `step`, `precision`, (string) `maxlen`, `search`, and `search_options`, can be passed down via the `BLField()` constructor.
- [ ] Make a class method that parses the docstring.
- [ ] `description`: Use the docstring parser to extract the first description sentence of the attribute name from the subclass docstring, so we are both encouraged to document our nodes/sockets, and so we're not documenting twice.
### Niceness
- [x] Rename the internal property to 'blfield__'.
- [ ] Add a method that extracts the internal property name, for places where we need the Blender property name.
- [x] Add a method that extracts the internal property name, for places where we need the Blender property name.
- **Key use case**: `draw.prop(self, self.field_name._bl_prop_name)`, which is also nice b/c no implicit string-based reference.
- The work done above with types makes this as fast and useful as internal props. Just make sure we validate that the type can be usefully accessed like this.
- [ ] Add a field method (called w/instance) that updates min/max/etc. on the 'blfield__' prop, in a native property type compatible manner: https://developer.blender.org/docs/release_notes/3.0/python_api/#idproperty-ui-data-api
- [x] Add a field method (called w/instance) that updates min/max/etc. on the 'blfield__' prop, in a native property type compatible manner: https://developer.blender.org/docs/release_notes/3.0/python_api/#idproperty-ui-data-api
- Should also throw appropriate errors for invalid access from Python, while Blender handles access from the inside.
- This allows us
- [ ] Similarly, a field method that gets the 'blfield__' prop data as a dictionary.
- [x] Similarly, a field method that gets the 'blfield__' prop data as a dictionary.
### Parallel Features
- [x] Move serialization work to a `utils`.
@ -569,3 +569,29 @@ We need support for arbitrary objects, but still backed by the persistance seman
- Benefit: Any serializable object can be "simply used", at almost native speed (due to the aggressive read-cache).
- Benefit: Better error properties for updating, access, setting, etc. .
- Benefit: Validate usage in a vastly greater amount of contexts.
# Overnight Ideas
- [ ] Fix file-load hiccups by persisting `_enum_cb_cache` and `_str_cb_cache`.
- [x] Implement `FlowSignal`s as special return values for `@computes_output_socket`, instead of juggling `None`.
- `FlowSignal.FlowPending`: Data was asked for, and it's not yet available, but it's expected to become available.
- Semantically: "Just hold on for a hot second".
- Return: If in any socket data provided to cb, return the same signal insted of running the callback.
- Caches: Don't invalidate caches, since the user will expect their data to still persist.
- Net Effect: Absolutely nothing happens. Perhaps we can recolor the nodes, though.
- [ ] `FlowSignal.FlowLost`: Output socket requires data that simply isn't available.
- Generally, nodes don't return it
- Return: If in any socket data provided to cb, return the same signal insted of running the callback.
- Caches: Do invalidate caches, since the user will expect their data to still persist.
- Net Effect: Sometimes, stuff happens in the output method [BB
- Net Effect: `DataChanged` is an event that signifies Node data will reset along the flow.
- [ ] Packing Imported Data in `Tidy3D Web Importer`, `Tidy3D File Importer`.
- Just `.to_hdf5_gz()` it into a `BytesIO`, Base85
- [ ] Remove Matplotlib Bottlenecks (~70ms -> ~5ms)
- Reuse `fig` per-`ManagedBLImage` (~25ms)
- Use `Agg` backend, plot with `fig.canvas.draw()`, and load image buffer directly as np.frombuffer(ax.figure.canvas.tostring_rgb(), dtype=np.uint8) (~40ms).

View File

@ -15,3 +15,10 @@ class OperatorType(enum.StrEnum):
ManagePyDeps = enum.auto()
ConnectViewerNode = enum.auto()
# Socket: Tidy3DCloudTask
SocketCloudAuthenticate = enum.auto()
SocketReloadCloudFolderList = enum.auto()
# Node: Tidy3DWebImporter
NodeLoadCloudSim = 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_desc_map import BL_SOCKET_DESCR_ANNOT_STRING, BL_SOCKET_DESCR_TYPE_MAP
@ -28,15 +28,16 @@ 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
@ -93,4 +94,5 @@ __all__ = [
'LazyValueFuncFlow',
'ParamsFlow',
'ValueFlow',
'FlowSignal',
]

View File

@ -0,0 +1,71 @@
import enum
import typing as typ
_FLOW_SIGNAL_SET: set | None = None
class FlowSignal(enum.StrEnum):
"""Special output socket return value, which indicates a piece of information about the state of the flow, instead of data.
Attributes:
FlowPending: The data that was requested is not available, but it is expected to become available soon.
- **Behavior**: When encountered downstream in `events` decorators for all `FlowKind`s, either `FlowSignal.FlowPending` should return instead of the method, or the decorated method should simply not run.
- **Caching**: Don't invalidate caches, since the user will expect their data to persist.
- **Net Effect**: All nodes that encounter FlowPending are forward-locked, possibly with an explanatory indicator. In terms of data, nothing happens - including no changes to the user's data.
"""
FlowPending = enum.auto()
NoFlow = enum.auto()
@classmethod
def all(cls) -> set[typ.Self]:
"""Query all flow signals, using a simple cache to ensure minimal overhead when used in ex. `draw()` functions.
Returns:
Set of FlowSignal enum items, for easy `O(1)` lookup
"""
global _FLOW_SIGNAL_SET # noqa: PLW0603
if _FLOW_SIGNAL_SET is None:
_FLOW_SIGNAL_SET = set(FlowSignal)
return _FLOW_SIGNAL_SET
@classmethod
def check(cls, obj: typ.Any) -> set[typ.Self]:
"""Checks whether an arbitrary object is a `FlowSignal` with tiny overhead.
Notes:
Optimized by first performing an `isinstance` check against both `FlowSignal` and `str`.
Then, we can check membership in `cls.all()` with `O(1)`, since (by type narrowing like this) we've ensured that the object is hashable.
Returns:
Whether `obj` is a `FlowSignal`.
Examples:
A common pattern to ensure an object is **not** a `FlowSignal` is `not FlowSignal.check(obj)`.
"""
return isinstance(obj, FlowSignal | str) and obj in FlowSignal.all()
@classmethod
def check_single(cls, obj: typ.Any, single: typ.Self) -> set[typ.Self]:
"""Checks whether an arbitrary object is a particular `FlowSignal`, with tiny overhead.
Use this whenever it is important to make different decisions based on different `FlowSignal`s.
Notes:
Generally, you should use `cls.check()`.
It tends to only be important to know whether you're getting a proper object from the flow, or whether it's dumping a `FlowSignal` on you instead.
However, certain nodes might have a good reason to react differently .
One example is deciding whether to keep node-internal caches around in the absence of data: `FlowSignal.FlowPending` hints to keep it around (allowing the user to ex. change selections, etc.), while `FlowSignal.NoFlow` hints to get rid of it immediately (resetting the node entirely).
Returns:
Whether `obj` is a `FlowSignal`.
Examples:
A common pattern to ensure an object is **not** a `FlowSignal` is `not FlowSignal.check(obj)`.
"""
return isinstance(obj, FlowSignal | str) and obj == single

View File

@ -5,6 +5,7 @@ import bpy
import jax
import jax.numpy as jnp
import sympy.physics.units as spu
import tidy3d as td
from blender_maxwell.utils import bl_cache, logger
from blender_maxwell.utils import extra_sympy_units as spux
@ -15,9 +16,18 @@ from .. import base, events
log = logger.get(__name__)
TDMonitorData: typ.TypeAlias = td.components.data.monitor_data.MonitorData
class ExtractDataNode(base.MaxwellSimNode):
"""Node for extracting data from particular objects.
"""Extract data from sockets for further analysis.
# Socket Sets
## Sim Data
Extracts monitors from a `MaxwelFDTDSimDataSocket`.
## Monitor Data
Extracts array attributes from a `MaxwelFDTDSimDataSocket`.
Attributes:
extract_filter: Identifier for data to extract from the input.
@ -45,31 +55,140 @@ class ExtractDataNode(base.MaxwellSimNode):
enum_cb=lambda self, _: self.search_extract_filters(),
)
# Sim Data
sim_data_monitor_nametype: dict[str, str] = bl_cache.BLField(
{}, use_prop_update=False
)
# Monitor Data
monitor_data_type: str = bl_cache.BLField('', use_prop_update=False)
monitor_data_components: list[str] = bl_cache.BLField([], use_prop_update=False)
####################
# - Computed Properties
# - Computed: Sim Data
####################
@property
def has_sim_data(self) -> bool:
return self.active_socket_set == 'Sim Data' and self.sim_data_monitor_nametype
def sim_data(self) -> td.SimulationData | None:
"""Computes the (cached) simulation data from the input socket.
Return:
Either the simulation data, if available, or None.
"""
sim_data = self._compute_input(
'Sim Data', kind=ct.FlowKind.Value, optional=True
)
if not ct.FlowSignal.check(sim_data):
return sim_data
return None
@bl_cache.cached_bl_property()
def sim_data_monitor_nametype(self) -> dict[str, str] | None:
"""For simulation data, computes and and caches a map from name to "type".
Return:
The name to type of monitors in the simulation data.
"""
if self.sim_data is not None:
return {
monitor_name: monitor_data.type
for monitor_name, monitor_data in self.sim_data.monitor_data.items()
}
return None
####################
# - Computed Properties: Monitor Data
####################
@property
def has_monitor_data(self) -> bool:
return self.active_socket_set == 'Monitor Data' and self.monitor_data_type
def monitor_data(self) -> TDMonitorData | None:
"""Computes the (cached) monitor data from the input socket.
Return:
Either the monitor data, if available, or None.
"""
monitor_data = self._compute_input(
'Monitor Data', kind=ct.FlowKind.Value, optional=True
)
if not ct.FlowSignal.check(monitor_data):
return monitor_data
return None
@bl_cache.cached_bl_property()
def monitor_data_type(self) -> str | None:
"""For monitor data, computes and caches the monitor "type".
Notes:
Should be invalidated with (before) `self.monitor_data_components`.
Return:
The "type" of the monitor, if available, else None.
"""
if self.monitor_data is not None:
return self.monitor_data.type.removesuffix('Data')
return None
@bl_cache.cached_bl_property()
def monitor_data_components(self) -> list[str] | None:
r"""For monitor data, computes and caches the component sof the monitor.
The output depends entirely on the output of `self.monitor_data`.
- **Field(Time)**: Whichever `[E|H][x|y|z]` are not `None` on the monitor.
- **Permittivity**: Specifically `['xx', 'yy', 'zz']`.
- **Flux(Time)**: Only `['flux']`.
- **FieldProjection(...)**: All of $r$, $\theta$, $\phi$ for both `E` and `H`.
- **Diffraction**: Same as `FieldProjection`.
Notes:
Should be invalidated after with `self.monitor_data_type`.
Return:
The "type" of the monitor, if available, else None.
"""
if self.monitor_data is not None:
# Field/FieldTime
if self.monitor_data_type in ['Field', 'FieldTime']:
return [
field_component
for field_component in ['Ex', 'Ey', 'Ez', 'Hx', 'Hy', 'Hz']
if hasattr(self.monitor_data, field_component)
]
# Permittivity
if self.monitor_data_type == 'Permittivity':
return ['xx', 'yy', 'zz']
# Flux/FluxTime
if self.monitor_data_type in ['Flux', 'FluxTime']:
return ['flux']
# FieldProjection(Angle/Cartesian/KSpace)/Diffraction
if self.monitor_data_type in [
'FieldProjectionAngle',
'FieldProjectionCartesian',
'FieldProjectionKSpace',
'Diffraction',
]:
return [
'Er',
'Etheta',
'Ephi',
'Hr',
'Htheta',
'Hphi',
]
return None
####################
# - Extraction Filter Search
####################
def search_extract_filters(self) -> list[ct.BLEnumElement]:
if self.has_sim_data:
"""Compute valid values for `self.extract_filter`, for a dynamic `EnumProperty`.
Notes:
Should be reset (via `self.extract_filter`) with (after) `self.sim_data_monitor_nametype`, `self.monitor_data_components`, and (implicitly) `self.monitor_type`.
See `bl_cache.BLField` for more on dynamic `EnumProperty`.
Returns:
Valid `self.extract_filter` in a format compatible with dynamic `EnumProperty`.
"""
if self.sim_data_monitor_nametype is not None:
return [
(monitor_name, monitor_name, monitor_type.removesuffix('Data'), '', i)
for i, (monitor_name, monitor_type) in enumerate(
@ -77,9 +196,15 @@ class ExtractDataNode(base.MaxwellSimNode):
)
]
if self.has_monitor_data:
if self.monitor_data_components is not None:
return [
(component_name, component_name, f' {component_name[1]}-Pol', '', i)
(
component_name,
component_name,
f' {component_name[1]}-polarization of the {"electric" if component_name[0] == "E" else "magnetic"} field',
'',
i,
)
for i, component_name in enumerate(self.monitor_data_components)
]
@ -89,156 +214,172 @@ class ExtractDataNode(base.MaxwellSimNode):
# - UI
####################
def draw_props(self, _: bpy.types.Context, col: bpy.types.UILayout) -> None:
"""Draw node properties in the node.
Parameters:
col: UI target for drawing.
"""
col.prop(self, self.blfields['extract_filter'], text='')
def draw_info(self, _: bpy.types.Context, col: bpy.types.UILayout) -> None:
if self.has_sim_data or self.has_monitor_data:
"""Draw dynamic information in the node, for user consideration.
Parameters:
col: UI target for drawing.
"""
has_sim_data = self.sim_data_monitor_nametype is not None
has_monitor_data = self.monitor_data_components is not None
if has_sim_data or has_monitor_data:
# Header
row = col.row()
row.alignment = 'CENTER'
if self.has_sim_data:
if has_sim_data:
row.label(text=f'{len(self.sim_data_monitor_nametype)} Monitors')
elif self.has_monitor_data:
elif has_monitor_data:
row.label(text=f'{self.monitor_data_type} Monitor Data')
# Monitor Data Contents
## TODO: More compact double-split
## TODO: Output shape data.
## TODO: Local ENUM_MANY tabs for visible column selection?
row = col.row()
box = row.box()
grid = box.grid_flow(row_major=True, columns=2, even_columns=True)
for name, desc in [
(name, desc) for idname, name, desc, *_ in self.search_extract_filters()
]:
grid.label(text=name)
grid.label(text=desc if desc else '')
for monitor_name, monitor_type in self.sim_data_monitor_nametype.items():
grid.label(text=monitor_name)
grid.label(text=monitor_type)
####################
# - Events
####################
@events.on_value_changed(
# Trigger
socket_name={'Sim Data', 'Monitor Data'},
prop_name='active_socket_set',
input_sockets={'Sim Data', 'Monitor Data'},
input_sockets_optional={'Sim Data': True, 'Monitor Data': True},
run_on_init=True,
)
def on_sim_data_changed(self, input_sockets: dict):
if input_sockets['Sim Data'] is not None:
# Sim Data Monitors: Set Name -> Type
self.sim_data_monitor_nametype = {
monitor_name: monitor_data.type
for monitor_name, monitor_data in input_sockets[
'Sim Data'
].monitor_data.items()
}
elif self.sim_data_monitor_nametype:
self.sim_data_monitor_nametype = {}
if input_sockets['Monitor Data'] is not None:
# Monitor Data Type
self.monitor_data_type = input_sockets['Monitor Data'].type.removesuffix(
'Data'
)
# Field/FieldTime
if self.monitor_data_type in ['Field', 'FieldTime']:
self.monitor_data_components = [
field_component
for field_component in ['Ex', 'Ey', 'Ez', 'Hx', 'Hy', 'Hz']
if hasattr(input_sockets['Monitor Data'], field_component)
]
# Permittivity
if self.monitor_data_type == 'Permittivity':
self.monitor_data_components = ['xx', 'yy', 'zz']
# Flux/FluxTime
if self.monitor_data_type in ['Flux', 'FluxTime']:
self.monitor_data_components = ['flux']
# FieldProjection(Angle/Cartesian/KSpace)/Diffraction
if self.monitor_data_type in [
'FieldProjectionAngle',
'FieldProjectionCartesian',
'FieldProjectionKSpace',
'Diffraction',
]:
self.monitor_data_components = [
'Er',
'Etheta',
'Ephi',
'Hr',
'Htheta',
'Hphi',
]
else:
if self.monitor_data_type:
self.monitor_data_type = ''
if self.monitor_data_components:
self.monitor_data_components = []
# Invalidate Computed Property Caches
def on_input_sockets_changed(self) -> None:
"""Invalidate the cached properties for sim data / monitor data, and reset the extraction filter."""
self.sim_data_monitor_nametype = bl_cache.Signal.InvalidateCache
self.monitor_data_type = bl_cache.Signal.InvalidateCache
self.monitor_data_components = bl_cache.Signal.InvalidateCache
self.extract_filter = bl_cache.Signal.ResetEnumItems
####################
# - Output: Sim Data -> Monitor Data
####################
@events.computes_output_socket(
# Trigger
'Monitor Data',
kind=ct.FlowKind.Value,
# Loaded
props={'extract_filter'},
input_sockets={'Sim Data'},
)
def compute_monitor_data(self, props: dict, input_sockets: dict):
if input_sockets['Sim Data'] is not None and props['extract_filter'] != 'NONE':
def compute_monitor_data(
self, props: dict, input_sockets: dict
) -> TDMonitorData | ct.FlowSignal:
"""Compute `Monitor Data` by querying an attribute of `Sim Data`.
Notes:
The attribute to query is read directly from `self.extract_filter`.
This is also the mechanism that protects from trying to reference an invalid attribute.
Returns:
Monitor data, if available, else `ct.FlowSignal.FlowPending`.
"""
sim_data = input_sockets['Sim Data']
has_sim_data = not ct.FlowSignal.check(sim_data)
if has_sim_data and props['extract_filter'] != 'NONE':
return input_sockets['Sim Data'].monitor_data[props['extract_filter']]
return None
# Propagate NoFlow
if ct.FlowSignal.check_single(sim_data, ct.FlowSignal.NoFlow):
return ct.FlowSignal.NoFlow
return ct.FlowSignal.FlowPending
####################
# - Output: Monitor Data -> Data
####################
@events.computes_output_socket(
# Trigger
'Data',
kind=ct.FlowKind.Array,
# Loaded
props={'extract_filter'},
input_sockets={'Monitor Data'},
input_socket_kinds={'Monitor Data': ct.FlowKind.Value},
)
def compute_data(self, props: dict, input_sockets: dict) -> jax.Array | None:
if (
input_sockets['Monitor Data'] is not None
and props['extract_filter'] != 'NONE'
):
def compute_data(
self, props: dict, input_sockets: dict
) -> jax.Array | ct.FlowSignal:
"""Compute `Data:Array` by querying an array-like attribute of `Monitor Data`, then constructing an `ct.ArrayFlow`.
Uses the internal `xarray` data returned by Tidy3D.
Notes:
The attribute to query is read directly from `self.extract_filter`.
This is also the mechanism that protects from trying to reference an invalid attribute.
Used as the first part of the `LazyFuncValue` chain used for further array manipulations with Math nodes.
Returns:
The data array, if available, else `ct.FlowSignal.FlowPending`.
"""
has_monitor_data = not ct.FlowSignal.check(input_sockets['Monitor Data'])
if has_monitor_data and props['extract_filter'] != 'NONE':
xarray_data = getattr(
input_sockets['Monitor Data'], props['extract_filter']
)
return jnp.array(xarray_data.data)
## TODO: Let the array itself have its output unit too!
return ct.ArrayFlow(values=jnp.array(xarray_data.data), unit=None)
## TODO: Try np.array instead, as it removes a copy, while still (I believe) being JIT-compatible.
return None
return ct.FlowSignal.FlowPending
@events.computes_output_socket(
# Trigger
'Data',
kind=ct.FlowKind.LazyValueFunc,
# Loaded
output_sockets={'Data'},
output_socket_kinds={'Data': ct.FlowKind.Array},
)
def compute_extracted_data_lazy(
self, output_sockets: dict
) -> ct.LazyValueFuncFlow | None:
if output_sockets['Data'] is not None:
"""Declare `Data:LazyValueFunc` by creating a simple function that directly wraps `Data:Array`.
Returns:
The composable function array, if available, else `ct.FlowSignal.FlowPending`.
"""
has_output_data = not ct.FlowSignal.check(output_sockets['Data'])
if has_output_data:
return ct.LazyValueFuncFlow(
func=lambda: output_sockets['Data'], supports_jax=True
func=lambda: output_sockets['Data'].values, supports_jax=True
)
return None
return ct.FlowSignal.FlowPending
####################
# - Auxiliary: Monitor Data -> Data
####################
@events.computes_output_socket(
'Data',
kind=ct.FlowKind.Params,
)
def compute_data_params(self) -> ct.ParamsFlow:
return ct.ParamsFlow()
@events.computes_output_socket(
# Trigger
'Data',
kind=ct.FlowKind.Info,
# Loaded
props={'monitor_data_type', 'extract_filter'},
input_sockets={'Monitor Data'},
input_socket_kinds={'Monitor Data': ct.FlowKind.Value},
@ -247,14 +388,18 @@ class ExtractDataNode(base.MaxwellSimNode):
def compute_extracted_data_info(
self, props: dict, input_sockets: dict
) -> ct.InfoFlow:
"""Declare `Data:Info` by manually selecting appropriate axes, units, etc. for each monitor type.
Returns:
Information describing the `Data:LazyValueFunc`, if available, else `ct.FlowSignal.FlowPending`.
"""
has_monitor_data = not ct.FlowSignal.check(input_sockets['Monitor Data'])
# Retrieve XArray
if (
input_sockets['Monitor Data'] is not None
and props['extract_filter'] != 'NONE'
):
if has_monitor_data and props['extract_filter'] != 'NONE':
xarr = getattr(input_sockets['Monitor Data'], props['extract_filter'])
else:
return ct.InfoFlow()
return ct.FlowSignal.FlowPending
info_output_names = {
'output_names': [props['extract_filter']],

View File

@ -48,8 +48,12 @@ class FilterMathNode(base.MaxwellSimNode):
)
@property
def _info(self) -> ct.InfoFlow:
return self._compute_input('Data', kind=ct.FlowKind.Info)
def data_info(self) -> ct.InfoFlow | None:
info = self._compute_input('Data', kind=ct.FlowKind.Info)
if not ct.FlowSignal.check(info):
return info
return None
####################
# - Operation Search
@ -71,16 +75,18 @@ class FilterMathNode(base.MaxwellSimNode):
# - Dim Search
####################
def search_dims(self) -> list[ct.BLEnumElement]:
if (info := self._info).dim_names:
if self.data_info is not None:
dims = [
(dim_name, dim_name, dim_name, '', i)
for i, dim_name in enumerate(info.dim_names)
for i, dim_name in enumerate(self.data_info.dim_names)
]
# Squeeze: Dimension Must Have Length=1
## We must also correct the "NUMBER" of the enum.
if self.operation == 'SQUEEZE':
filtered_dims = [dim for dim in dims if info.dim_lens[dim[0]] == 1]
filtered_dims = [
dim for dim in dims if self.data_info.dim_lens[dim[0]] == 1
]
return [(*dim[:-1], i) for i, dim in enumerate(filtered_dims)]
return dims
@ -91,42 +97,43 @@ class FilterMathNode(base.MaxwellSimNode):
####################
def draw_props(self, _: bpy.types.Context, layout: bpy.types.UILayout) -> None:
layout.prop(self, self.blfields['operation'], text='')
if self._info.dim_names:
if self.data_info is not None and self.data_info.dim_names:
layout.prop(self, self.blfields['dim'], text='')
####################
# - Events
####################
@events.on_value_changed(
prop_name='active_socket_set',
run_on_init=True,
)
def on_socket_set_changed(self):
self.operation = bl_cache.Signal.ResetEnumItems
@events.on_value_changed(
socket_name='Data',
prop_name='active_socket_set',
props={'active_socket_set'},
run_on_init=True,
input_sockets={'Data'},
input_socket_kinds={'Data': ct.FlowKind.Info},
# run_on_init=True,
)
def on_any_change(self, props: dict, input_sockets: dict):
self.dim = bl_cache.Signal.ResetEnumItems
def on_any_change(self, input_sockets: dict):
if all(
not ct.FlowSignal.check_single(
input_socket_value, ct.FlowSignal.FlowPending
)
for input_socket_value in input_sockets.values()
):
self.operation = bl_cache.Signal.ResetEnumItems
self.dim = bl_cache.Signal.ResetEnumItems
@events.on_value_changed(
socket_name='Data',
prop_name='dim',
## run_on_init: Implicitly triggered.
props={'active_socket_set', 'dim'},
input_sockets={'Data'},
input_socket_kinds={'Data': ct.FlowKind.Info},
# run_on_init=True,
)
def on_dim_change(self, props: dict, input_sockets: dict):
if input_sockets['Data'] == ct.FlowSignal.FlowPending:
return
# Add/Remove Input Socket "Value"
if (
input_sockets['Data'] != ct.InfoFlow()
not ct.Flowsignal.check(input_sockets['Data'])
and props['active_socket_set'] == 'By Dim Value'
and props['dim'] != 'NONE'
):
@ -144,7 +151,7 @@ class FilterMathNode(base.MaxwellSimNode):
):
self.loose_input_sockets = {
'Value': wanted_socket_def(),
}
} ## TODO: Can we do the boilerplate in base.py?
elif self.loose_input_sockets:
self.loose_input_sockets = {}
@ -163,13 +170,16 @@ class FilterMathNode(base.MaxwellSimNode):
lazy_value_func = input_sockets['Data'][ct.FlowKind.LazyValueFunc]
info = input_sockets['Data'][ct.FlowKind.Info]
# Check Flow
if (
any(ct.FlowSignal.check(inp) for inp in [info, lazy_value_func])
or props['operation'] == 'NONE'
):
return ct.FlowSignal.FlowPending
# Compute Bound/Free Parameters
func_args = [int] if props['active_socket_set'] == 'By Dim Value' else []
if props['dim'] != 'NONE':
axis = info.dim_names.index(props['dim'])
else:
msg = 'Dimension cannot be empty'
raise ValueError(msg)
axis = info.dim_names.index(props['dim'])
# Select Function
filter_func: typ.Callable[[jax.Array], jax.Array] = {
@ -201,6 +211,10 @@ class FilterMathNode(base.MaxwellSimNode):
lazy_value_func = output_sockets['Data'][ct.FlowKind.LazyValueFunc]
params = output_sockets['Data'][ct.FlowKind.Params]
# Check Flow
if any(ct.FlowSignal.check(inp) for inp in [lazy_value_func, params]):
return ct.FlowSignal.FlowPending
# Compute Array
return ct.ArrayFlow(
values=lazy_value_func.func_jax(*params.func_args, **params.func_kwargs),
@ -221,15 +235,14 @@ class FilterMathNode(base.MaxwellSimNode):
# Retrieve Inputs
info = input_sockets['Data']
# Compute Bound/Free Parameters
## Empty Dimension -> Empty InfoFlow
if input_sockets['Data'] != ct.InfoFlow() and props['dim'] != 'NONE':
axis = info.dim_names.index(props['dim'])
else:
return ct.InfoFlow()
# Check Flow
if ct.FlowSignal.check(info) or props['dim'] == 'NONE':
return ct.FlowSignal.FlowPending
# Compute Information
## Compute Info w/By-Operation Change to Dimensions
axis = info.dim_names.index(props['dim'])
if (props['active_socket_set'], props['operation']) in [
('By Dim', 'SQUEEZE'),
('By Dim Value', 'FIX'),
@ -246,8 +259,8 @@ class FilterMathNode(base.MaxwellSimNode):
output_units=info.output_units,
)
# Fallback to Empty InfoFlow
return ct.InfoFlow()
msg = f'Active socket set {props["active_socket_set"]} and operation {props["operation"]} don\'t have an InfoFlow defined'
raise RuntimeError(msg)
@events.computes_output_socket(
'Data',
@ -264,6 +277,9 @@ class FilterMathNode(base.MaxwellSimNode):
info = input_sockets['Data'][ct.FlowKind.Info]
params = input_sockets['Data'][ct.FlowKind.Params]
if any(ct.FlowSignal.check(inp) for inp in [info, params]):
return ct.FlowSignal.FlowPending
# Compute Composed Parameters
## -> Only operations that add parameters.
## -> A dimension must be selected.
@ -274,7 +290,7 @@ class FilterMathNode(base.MaxwellSimNode):
('By Dim Value', 'FIX'),
]
and props['dim'] != 'NONE'
and input_sockets['Value'] is not None
and not ct.FlowSignal.check(input_sockets['Value'])
):
# Compute IDX Corresponding to Coordinate Value
## -> Each dimension declares a unit-aware real number at each index.

View File

@ -54,9 +54,8 @@ class MapMathNode(base.MaxwellSimNode):
)
def search_operations(self) -> list[ct.BLEnumElement]:
items = []
if self.active_socket_set == 'By Element':
items += [
items = [
# General
('REAL', '(v)', 'real(v) (by el)'),
('IMAG', 'Im(v)', 'imag(v) (by el)'),
@ -73,11 +72,11 @@ class MapMathNode(base.MaxwellSimNode):
('ATAN', 'atan v', 'atan(v) (by el)'),
]
elif self.active_socket_set in 'By Vector':
items += [
items = [
('NORM_2', '||v||₂', 'norm(v, 2) (by Vec)'),
]
elif self.active_socket_set == 'By Matrix':
items += [
items = [
# Matrix -> Number
('DET', 'det V', 'det(V) (by Mat)'),
('COND', 'κ(V)', 'cond(V) (by Mat)'),
@ -96,7 +95,10 @@ class MapMathNode(base.MaxwellSimNode):
('SVD', 'svd V', 'svd(V) -> U·Σ·V† (by Mat)'),
]
elif self.active_socket_set == 'Expr':
items += [('EXPR_EL', 'By Element', 'Expression-defined (by el)')]
items = [('EXPR_EL', 'By Element', 'Expression-defined (by el)')]
else:
msg = f'Active socket set {self.active_socket_set} is unknown'
raise RuntimeError(msg)
return [(*item, '', i) for i, item in enumerate(items)]
@ -127,6 +129,14 @@ class MapMathNode(base.MaxwellSimNode):
input_sockets_optional={'Mapper': True},
)
def compute_data(self, props: dict, input_sockets: dict):
if (
ct.FlowSignal.check(input_sockets['Data']) or props['operation'] == 'NONE'
) or (
props['active_socket_set'] == 'Expr'
and ct.FlowSignal.check(input_sockets['Mapper'])
):
return ct.FlowSignal.FlowPending
mapping_func: typ.Callable[[jax.Array], jax.Array] = {
'By Element': {
'REAL': lambda data: jnp.real(data),
@ -186,10 +196,16 @@ class MapMathNode(base.MaxwellSimNode):
def compute_array(self, output_sockets: dict) -> ct.ArrayFlow:
lazy_value_func = output_sockets['Data'][ct.FlowKind.LazyValueFunc]
params = output_sockets['Data'][ct.FlowKind.Params]
return ct.ArrayFlow(
values=lazy_value_func.func_jax(*params.func_args, **params.func_kwargs),
unit=None, ## TODO: Unit Propagation
)
if all(not ct.FlowSignal.check(inp) for inp in [lazy_value_func, params]):
return ct.ArrayFlow(
values=lazy_value_func.func_jax(
*params.func_args, **params.func_kwargs
),
unit=None,
)
return ct.FlowSignal.FlowPending
####################
# - Compute Auxiliary: Info / Params
@ -205,11 +221,16 @@ class MapMathNode(base.MaxwellSimNode):
info = input_sockets['Data']
# Complex -> Real
if props['active_socket_set'] == 'By Element' and props['operation'] in [
'REAL',
'IMAG',
'ABS',
]:
if (
props['active_socket_set'] == 'By Element'
and props['operation']
in [
'REAL',
'IMAG',
'ABS',
]
and not ct.FlowSignal.check(info)
):
return ct.InfoFlow(
dim_names=info.dim_names,
dim_idx=info.dim_idx,
@ -232,7 +253,7 @@ class MapMathNode(base.MaxwellSimNode):
input_sockets={'Data'},
input_socket_kinds={'Data': ct.FlowKind.Params},
)
def compute_data_params(self, input_sockets: dict) -> ct.ParamsFlow:
def compute_data_params(self, input_sockets: dict) -> ct.ParamsFlow | ct.FlowSignal:
return input_sockets['Data']

View File

@ -218,36 +218,42 @@ class VizNode(base.MaxwellSimNode):
## - Mode Searcher
#####################
@property
def _info(self) -> ct.InfoFlow:
def data_info(self) -> ct.InfoFlow:
return self._compute_input('Data', kind=ct.FlowKind.Info)
def search_modes(self) -> list[ct.BLEnumElement]:
info = self._info
return [
(
viz_mode,
VizMode.to_name(viz_mode),
VizMode.to_name(viz_mode),
VizMode.to_icon(viz_mode),
i,
)
for i, viz_mode in enumerate(VizMode.valid_modes_for(info))
]
if not ct.FlowSignal.check(self.data_info):
return [
(
viz_mode,
VizMode.to_name(viz_mode),
VizMode.to_name(viz_mode),
VizMode.to_icon(viz_mode),
i,
)
for i, viz_mode in enumerate(VizMode.valid_modes_for(self.data_info))
]
return []
#####################
## - Target Searcher
#####################
def search_targets(self) -> list[ct.BLEnumElement]:
return [
(
viz_target,
VizTarget.to_name(viz_target),
VizTarget.to_name(viz_target),
VizTarget.to_icon(viz_target),
i,
)
for i, viz_target in enumerate(VizTarget.valid_targets_for(self.viz_mode))
]
if self.viz_mode != 'NONE':
return [
(
viz_target,
VizTarget.to_name(viz_target),
VizTarget.to_name(viz_target),
VizTarget.to_icon(viz_target),
i,
)
for i, viz_target in enumerate(
VizTarget.valid_targets_for(self.viz_mode)
)
]
return []
#####################
## - UI
@ -264,17 +270,20 @@ class VizNode(base.MaxwellSimNode):
@events.on_value_changed(
socket_name='Data',
input_sockets={'Data'},
run_on_init=True,
input_socket_kinds={'Data': ct.FlowKind.Info},
input_sockets_optional={'Data': True},
run_on_init=True,
)
def on_socket_set_changed(self, input_sockets: dict):
self.viz_mode = bl_cache.Signal.ResetEnumItems
self.viz_target = bl_cache.Signal.ResetEnumItems
def on_any_changed(self, input_sockets: dict):
if not ct.FlowSignal.check_single(
input_sockets['Data'], ct.FlowSignal.FlowPending
):
self.viz_mode = bl_cache.Signal.ResetEnumItems
self.viz_target = bl_cache.Signal.ResetEnumItems
@events.on_value_changed(
prop_name='viz_mode',
# run_on_init=True,
## run_on_init: Implicitly triggered.
)
def on_viz_mode_changed(self):
self.viz_target = bl_cache.Signal.ResetEnumItems
@ -287,7 +296,6 @@ class VizNode(base.MaxwellSimNode):
props={'viz_mode', 'viz_target', 'colormap'},
input_sockets={'Data'},
input_socket_kinds={'Data': {ct.FlowKind.Array, ct.FlowKind.Info}},
input_sockets_optional={'Data': True},
stop_propagation=True,
)
def on_show_plot(
@ -296,12 +304,19 @@ class VizNode(base.MaxwellSimNode):
input_sockets: dict,
props: dict,
):
# Retrieve Inputs
array_flow = input_sockets['Data'][ct.FlowKind.Array]
info = input_sockets['Data'][ct.FlowKind.Info]
if input_sockets['Data'] is None:
# Check Flow
if (
any(ct.FlowSignal.check(inp) for inp in [array_flow, info])
or props['viz_mode'] == 'NONE'
or props['viz_target'] == 'NONE'
):
return
# Viz Target
if props['viz_target'] == VizTarget.Plot2D:
managed_objs['plot'].mpl_plot_to_image(
lambda ax: VizMode.to_plotter(props['viz_mode'])(

View File

@ -430,6 +430,11 @@ class MaxwellSimNode(bpy.types.Node):
# Remove Sockets
for bl_socket in bl_sockets_to_remove:
node_tree.on_node_socket_removed(bl_socket)
self._compute_input.invalidate(
input_socket_name=bl_socket.name,
kind=...,
unit_system=...,
)
all_bl_sockets.remove(bl_socket)
def _add_new_active_sockets(self):
@ -597,7 +602,7 @@ class MaxwellSimNode(bpy.types.Node):
)
if optional:
return None
return ct.FlowSignal.NoFlow
msg = f'Input socket "{input_socket_name}" on "{self.bl_idname}" is not an active input socket'
raise ValueError(msg)
@ -645,14 +650,8 @@ class MaxwellSimNode(bpy.types.Node):
return output_socket_methods[0](self)
# Auxiliary Fallbacks
if kind == ct.FlowKind.Info:
return ct.InfoFlow()
if kind == ct.FlowKind.Params:
return ct.ParamsFlow()
if optional:
return None
if optional or kind in [ct.FlowKind.Info, ct.FlowKind.Params]:
return ct.FlowSignal.NoFlow
msg = f'No output method for ({output_socket_name}, {kind})'
raise ValueError(msg)
@ -838,6 +837,11 @@ class MaxwellSimNode(bpy.types.Node):
)
for event_method in triggered_event_methods:
stop_propagation |= event_method.stop_propagation
# log.critical(
# '$[%s] [%s %s %s %s] Running: (%s)',
# self.sim_node_name,
# event_method.callback_info,
# )
event_method(self)
# Propagate Event to All Sockets in "Trigger Direction"

View File

@ -270,6 +270,8 @@ def event_decorator(
)
# Call Method
## If there is a FlowPending, then the method would fail.
## Therefore, propagate FlowPending if found.
return method(
node,
**method_kw_args,

View File

@ -1,9 +1,12 @@
import typing as typ
from pathlib import Path
from blender_maxwell.utils import logger
import bpy
import tidy3d as td
from blender_maxwell.services import tdcloud
from blender_maxwell.utils import bl_cache, logger
from ......services import tdcloud
from .... import contracts as ct
from .... import sockets
from ... import base, events
@ -11,6 +14,40 @@ from ... import base, events
log = logger.get(__name__)
class LoadCloudSim(bpy.types.Operator):
bl_idname = ct.OperatorType.NodeLoadCloudSim
bl_label = '(Re)Load Sim'
bl_description = '(Re)Load simulation data associated with the attached cloud task'
@classmethod
def poll(cls, context):
return (
# Node Type
hasattr(context, 'node')
and hasattr(context.node, 'node_type')
and context.node.node_type == ct.NodeType.Tidy3DWebImporter
# Cloud Status
and tdcloud.IS_ONLINE
and tdcloud.IS_AUTHENTICATED
)
def execute(self, context):
node = context.node
# Try Loading Simulation Data
node.sim_data = bl_cache.Signal.InvalidateCache
sim_data = node.sim_data
if sim_data is None:
self.report(
{'ERROR'},
'Sim Data could not be loaded. Check your network connection.',
)
else:
self.report({'INFO'}, 'Sim Data loaded.')
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.
@ -34,68 +71,89 @@ class Tidy3DWebImporterNode(base.MaxwellSimNode):
),
}
####################
# - Event Methods
####################
@events.computes_output_socket(
'FDTD Sim Data',
input_sockets={'Cloud Task'},
)
def compute_sim_data(self, input_sockets: dict) -> str:
## TODO: REMOVE TEST
log.info('Loading SimulationData File')
import sys
sim_data_loaded: bool = bl_cache.BLField(False)
for module_name, module in sys.modules.copy().items():
if module_name == '__mp_main__':
print('Problematic Module Entry', module_name)
print(module)
# print('MODULE REPR', module)
continue
# return td.SimulationData.from_file(
# fname='/home/sofus/src/blender_maxwell/dev/sim_demo.hdf5'
# )
# Validate Task Availability
if (cloud_task := input_sockets['Cloud Task']) is None:
msg = f'"{self.bl_label}" CloudTask doesn\'t exist'
raise RuntimeError(msg)
# Validate Task Existence
if not isinstance(cloud_task, tdcloud.CloudTask):
msg = f'"{self.bl_label}" CloudTask input "{cloud_task}" has wrong "should_exists", as it isn\'t an instance of tdcloud.CloudTask'
raise TypeError(msg)
# Validate Task Status
if cloud_task.status != 'success':
msg = f'"{self.bl_label}" CloudTask is "{cloud_task.status}", not "success"'
raise RuntimeError(msg)
# Download and Return SimData
return tdcloud.TidyCloudTasks.download_task_sim_data(
cloud_task, _sim_data_cache_path(cloud_task.task_id)
@bl_cache.cached_bl_property()
def sim_data(self) -> td.SimulationData | None:
cloud_task = self._compute_input(
'Cloud Task', kind=ct.FlowKind.Value, optional=True
)
@events.on_value_changed(
socket_name='Cloud Task', run_on_init=True, input_sockets={'Cloud Task'}
)
def on_cloud_task_changed(self, input_sockets: dict):
if (
(cloud_task := input_sockets['Cloud Task']) is not None
# Check Flow
not ct.FlowSignal.check(cloud_task)
# Check Task
and cloud_task is not None
and isinstance(cloud_task, tdcloud.CloudTask)
and cloud_task.status == 'success'
):
self.loose_output_sockets = {
'FDTD Sim Data': sockets.MaxwellFDTDSimDataSocketDef(),
}
sim_data = tdcloud.TidyCloudTasks.download_task_sim_data(
cloud_task, _sim_data_cache_path(cloud_task.task_id)
)
self.sim_data_loaded = True
return sim_data
return None
####################
# - UI
####################
def draw_operators(self, context, layout):
if self.sim_data_loaded:
layout.operator(ct.OperatorType.NodeLoadCloudSim, text='Reload Sim')
else:
layout.operator(ct.OperatorType.NodeLoadCloudSim, text='Load Sim')
####################
# - Events
####################
@events.on_value_changed(socket_name='Cloud Task')
def on_cloud_task_changed(self):
self.inputs['Cloud Task'].on_cloud_updated()
## TODO: Must we babysit sockets like this?
@events.on_value_changed(
prop_name='sim_data_loaded', run_on_init=True, props={'sim_data_loaded'}
)
def on_cloud_task_changed(self, props: dict):
if props['sim_data_loaded']:
if not self.loose_output_sockets:
self.loose_output_sockets = {
'Sim Data': sockets.MaxwellFDTDSimDataSocketDef(),
}
elif self.loose_output_sockets:
self.loose_output_sockets = {}
####################
# - Output
####################
@events.computes_output_socket(
'Sim Data',
props={'sim_data_loaded'},
input_sockets={'Cloud Task'},
)
def compute_sim_data(self, props: dict, input_sockets: dict) -> str:
if props['sim_data_loaded']:
cloud_task = input_sockets['Cloud Task']
if (
# Check Flow
not ct.FlowSignal.check(cloud_task)
# Check Task
and cloud_task is not None
and isinstance(cloud_task, tdcloud.CloudTask)
and cloud_task.status == 'success'
):
return self.sim_data
return ct.FlowSignal.FlowPending
return ct.FlowSignal.FlowPending
####################
# - Blender Registration
####################
BL_REGISTER = [
LoadCloudSim,
Tidy3DWebImporterNode,
]
BL_NODES = {

View File

@ -209,7 +209,7 @@ class Tidy3DWebExporterNode(base.MaxwellSimNode):
else:
self.cache_est_cost = -1.0
self.loose_output_sockets = {}
self.inputs['Cloud Task'].sync_prepare_new_task()
self.inputs['Cloud Task'].on_prepare_new_task()
self.inputs['Cloud Task'].locked = False
self.on_prop_changed('tracked_task_id', context)
@ -249,7 +249,7 @@ class Tidy3DWebExporterNode(base.MaxwellSimNode):
# 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'].sync_created_new_task(cloud_task)
self.inputs['Cloud Task'].on_new_task_created(cloud_task)
# Track the Newly Uploaded Task ID
self.tracked_task_id = cloud_task.task_id

View File

@ -548,7 +548,7 @@ class MaxwellSimSocket(bpy.types.NodeSocket):
Returns:
An empty `ct.InfoFlow`.
"""
return ct.InfoFlow()
return ct.FlowSignal.NoFlow
# Param
@property
@ -561,7 +561,7 @@ class MaxwellSimSocket(bpy.types.NodeSocket):
Returns:
An empty `ct.ParamsFlow`.
"""
return ct.ParamsFlow()
return ct.FlowSignal.NoFlow
####################
# - FlowKind: Auxiliary
@ -577,8 +577,7 @@ class MaxwellSimSocket(bpy.types.NodeSocket):
Raises:
NotImplementedError: When used without being overridden.
"""
msg = f'Socket {self.bl_label} {self.socket_type}): Tried to get "ct.FlowKind.Value", but socket does not define it'
raise NotImplementedError(msg)
return ct.FlowSignal.NoFlow
@value.setter
def value(self, value: ct.ValueFlow) -> None:
@ -604,8 +603,7 @@ class MaxwellSimSocket(bpy.types.NodeSocket):
Raises:
NotImplementedError: When used without being overridden.
"""
msg = f'Socket {self.bl_label} {self.socket_type}): Tried to get "ct.FlowKind.Array", but socket does not define it'
raise NotImplementedError(msg)
return ct.FlowSignal.NoFlow
@array.setter
def array(self, value: ct.ArrayFlow) -> None:
@ -631,8 +629,7 @@ class MaxwellSimSocket(bpy.types.NodeSocket):
Raises:
NotImplementedError: When used without being overridden.
"""
msg = f'Socket {self.bl_label} {self.socket_type}): Tried to get "ct.FlowKind.LazyValueFunc", but socket does not define it'
raise NotImplementedError(msg)
return ct.FlowSignal.NoFlow
@lazy_value_func.setter
def lazy_value_func(self, lazy_value_func: ct.LazyValueFuncFlow) -> None:
@ -658,8 +655,7 @@ class MaxwellSimSocket(bpy.types.NodeSocket):
Raises:
NotImplementedError: When used without being overridden.
"""
msg = f'Socket {self.bl_label} {self.socket_type}): Tried to get "ct.FlowKind.LazyArrayRange", but socket does not define it'
raise NotImplementedError(msg)
return ct.FlowSignal.NoFlow
@lazy_array_range.setter
def lazy_array_range(self, value: ct.LazyArrayRangeFlow) -> None:
@ -892,7 +888,8 @@ class MaxwellSimSocket(bpy.types.NodeSocket):
# Info Drawing
if self.use_info_draw:
info = self.compute_data(kind=ct.FlowKind.Info)
self.draw_info(info, col)
if not ct.FlowSignal.check(info):
self.draw_info(info, col)
def draw_output(
self,
@ -920,7 +917,8 @@ class MaxwellSimSocket(bpy.types.NodeSocket):
# Draw FlowKind.Info related Information
if self.use_info_draw:
info = self.compute_data(kind=ct.FlowKind.Info)
self.draw_info(info, col)
if not ct.FlowSignal.check(info):
self.draw_info(info, col)
####################
# - UI Methods: Active FlowKind

View File

@ -34,10 +34,6 @@ class DataBLSocket(base.MaxwellSimSocket):
must_match={'format': self.format},
)
@property
def value(self):
return None
####################
# - UI
####################

View File

@ -7,10 +7,6 @@ class MaxwellFDTDSimBLSocket(base.MaxwellSimSocket):
socket_type = ct.SocketType.MaxwellFDTDSim
bl_label = 'Maxwell FDTD Simulation'
@property
def value(self) -> None:
return None
####################
# - Socket Configuration

View File

@ -6,10 +6,6 @@ class MaxwellFDTDSimDataBLSocket(base.MaxwellSimSocket):
socket_type = ct.SocketType.MaxwellFDTDSimData
bl_label = 'Maxwell FDTD Simulation'
@property
def value(self):
return None
####################
# - Socket Configuration

View File

@ -1,6 +1,10 @@
import enum
import bpy
from .....services import tdcloud
from blender_maxwell.services import tdcloud
from blender_maxwell.utils import bl_cache
from ... import contracts as ct
from .. import base
@ -9,30 +13,32 @@ from .. import base
# - Operators
####################
class ReloadFolderList(bpy.types.Operator):
bl_idname = 'blender_maxwell.sockets__reload_folder_list'
bl_idname = ct.OperatorType.SocketReloadCloudFolderList
bl_label = 'Reload Tidy3D Folder List'
bl_description = 'Reload the the cached Tidy3D folder list'
@classmethod
def poll(cls, context):
return (
tdcloud.IS_AUTHENTICATED
tdcloud.IS_ONLINE
and tdcloud.IS_AUTHENTICATED
and hasattr(context, 'socket')
and hasattr(context.socket, 'socket_type')
and context.socket.socket_type == ct.SocketType.Tidy3DCloudTask
)
def execute(self, context):
socket = context.socket
bl_socket = context.socket
tdcloud.TidyCloudFolders.update_folders()
tdcloud.TidyCloudTasks.update_tasks(socket.existing_folder_id)
tdcloud.TidyCloudTasks.update_tasks(bl_socket.existing_folder_id)
bl_socket.on_cloud_updated()
return {'FINISHED'}
class Authenticate(bpy.types.Operator):
bl_idname = 'blender_maxwell.sockets__authenticate'
bl_idname = ct.OperatorType.SocketCloudAuthenticate
bl_label = 'Authenticate Tidy3D'
bl_description = 'Authenticate the Tidy3D Web API from a Cloud Task socket'
@ -51,6 +57,7 @@ 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()
return {'FINISHED'}
@ -59,6 +66,16 @@ class Authenticate(bpy.types.Operator):
# - Socket
####################
class Tidy3DCloudTaskBLSocket(base.MaxwellSimSocket):
"""Interact with Tidy3D Cloud Tasks.
Attributes:
api_key: API key for the Tidy3D cloud.
should_exist: Whether or not the cloud task should already exist.
existing_folder_id: ID of an existing folder on the Tidy3D cloud.
existing_task_id: ID of an existing task on the Tidy3D cloud.
new_task_name: Name of a new task to submit to the Tidy3D cloud.
"""
socket_type = ct.SocketType.Tidy3DCloudTask
bl_label = 'Tidy3D Cloud Task'
@ -67,81 +84,90 @@ class Tidy3DCloudTaskBLSocket(base.MaxwellSimSocket):
####################
# - Properties
####################
# Authentication
api_key: bpy.props.StringProperty(
name='API Key',
description='API Key for the Tidy3D Cloud',
default='',
options={'SKIP_SAVE'},
subtype='PASSWORD',
api_key: str = bl_cache.BLField('', prop_ui=True, str_secret=True)
should_exist: bool = bl_cache.BLField(False)
existing_folder_id: enum.Enum = bl_cache.BLField(
prop_ui=True, enum_cb=lambda self, _: self.search_cloud_folders()
)
existing_task_id: enum.Enum = bl_cache.BLField(
prop_ui=True, enum_cb=lambda self, _: self.search_cloud_tasks()
)
# Task Existance Presumption
should_exist: bpy.props.BoolProperty(
name='Cloud Task Should Exist',
description='Whether or not the cloud task should already exist',
default=False,
)
new_task_name: str = bl_cache.BLField('', prop_ui=True)
# Identifiers
existing_folder_id: bpy.props.EnumProperty(
name='Folder of Cloud Tasks',
description='An existing folder on the Tidy3D Cloud',
items=lambda self, _: self.retrieve_folders(),
update=(lambda self, context: self.on_prop_changed('existing_folder_id', context)),
)
existing_task_id: bpy.props.EnumProperty(
name='Existing Cloud Task',
description='An existing task on the Tidy3D Cloud, within the given folder',
items=lambda self, _: self.retrieve_tasks(),
update=(lambda self, context: self.on_prop_changed('existing_task_id', context)),
)
@property
def capabilities(self) -> ct.CapabilitiesFlow:
return ct.CapabilitiesFlow(
socket_type=self.socket_type,
active_kind=self.active_kind,
must_match={'should_exist': self.should_exist},
)
# (Potential) New Task
new_task_name: bpy.props.StringProperty(
name='New Cloud Task Name',
description='Name of a new task to submit to the Tidy3D Cloud',
default='',
update=(lambda self, context: self.on_prop_changed('new_task_name', context)),
)
####################
# - Property Methods
####################
def sync_existing_folder_id(self, context):
folder_task_ids = self.retrieve_tasks()
self.existing_task_id = folder_task_ids[0][0]
## There's guaranteed to at least be one element, even if it's "NONE".
self.on_prop_changed('existing_folder_id', context)
def retrieve_folders(self) -> list[tuple]:
folders = tdcloud.TidyCloudFolders.folders()
if not folders:
return [('NONE', 'None', 'No folders')]
return [
(
cloud_folder.folder_id,
cloud_folder.folder_name,
f"Folder 'cloud_folder.folder_name' with ID {folder_id}",
)
for folder_id, cloud_folder in folders.items()
]
def retrieve_tasks(self) -> list[tuple]:
if (
cloud_folder := tdcloud.TidyCloudFolders.folders().get(
@property
def value(
self,
) -> tuple[tdcloud.CloudTaskName, tdcloud.CloudFolder] | tdcloud.CloudTask | None:
if tdcloud.IS_AUTHENTICATED:
# Retrieve Folder
cloud_folder = tdcloud.TidyCloudFolders.folders().get(
self.existing_folder_id
)
) is None:
return [('NONE', 'None', "Folder doesn't exist")]
if cloud_folder is None:
msg = f"Selected folder {cloud_folder} doesn't exist (it was probably deleted elsewhere)"
raise RuntimeError(msg)
# Doesn't Exist: Return Construction Information
if not self.should_exist:
return (self.new_task_name, cloud_folder)
# No Task Selected: Return None
if self.existing_task_id == 'NONE':
return None
# 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 None
####################
# - Searchers
####################
def search_cloud_folders(self) -> list[ct.BLEnumElement]:
if tdcloud.IS_AUTHENTICATED:
return [
(
cloud_folder.folder_id,
cloud_folder.folder_name,
f'Folder {cloud_folder.folder_name} (ID={folder_id})',
'',
i,
)
for i, (folder_id, cloud_folder) in enumerate(
tdcloud.TidyCloudFolders.folders().items()
)
]
return []
def search_cloud_tasks(self) -> list[ct.BLEnumElement]:
if self.existing_folder_id == 'NONE' or not tdcloud.IS_AUTHENTICATED:
return []
# Get Cloud Folder
cloud_folder = tdcloud.TidyCloudFolders.folders().get(self.existing_folder_id)
if cloud_folder is None:
return []
# Get Cloud Tasks
tasks = tdcloud.TidyCloudTasks.tasks(cloud_folder)
if not tasks:
return [('NONE', 'None', 'No tasks in folder')]
return [
(
## Task ID
@ -158,9 +184,9 @@ class Tidy3DCloudTaskBLSocket(base.MaxwellSimSocket):
## Task Description
f'Task Status: {task.status}',
## Status Icon
_icon
icon
if (
_icon := {
icon := {
'draft': 'SEQUENCE_COLOR_08',
'initialized': 'SHADING_SOLID',
'queued': 'SEQUENCE_COLOR_03',
@ -185,52 +211,28 @@ class Tidy3DCloudTaskBLSocket(base.MaxwellSimSocket):
]
####################
# - Task Sync Methods
# - Node-Initiated Updates
####################
def sync_created_new_task(self, cloud_task):
"""Called whenever the task specified in `new_task_name` has been actually created.
This changes the socket somewhat: Folder/task IDs are set, and the socket is switched to presume that the task exists.
If the socket is linked, then an error is raised.
"""
# Propagate along Link
if self.is_linked:
msg = 'Cannot sync newly created task to linked Cloud Task socket.'
raise ValueError(msg)
## TODO: A little aggressive. Is there a good use case?
# Synchronize w/New Task Information
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 sync_prepare_new_task(self):
"""Called to switch the socket to no longer presume that the task it specifies exists (yet).
If the socket is linked, then an error is raised.
"""
# Propagate along Link
if self.is_linked:
msg = 'Cannot sync newly created task to linked Cloud Task socket.'
raise ValueError(msg)
## TODO: A little aggressive. Is there a good use case?
# Synchronize w/New Task Information
def on_prepare_new_task(self):
self.should_exist = False
def on_cloud_updated(self):
self.existing_folder_id = bl_cache.Signal.ResetEnumItems
self.existing_task_id = bl_cache.Signal.ResetEnumItems
####################
# - Socket UI
# - UI
####################
def draw_label_row(self, row: bpy.types.UILayout, text: str):
row.label(text=text)
auth_icon = 'LOCKVIEW_ON' if tdcloud.IS_AUTHENTICATED else 'LOCKVIEW_OFF'
row.operator(
Authenticate.bl_idname,
text='',
icon=auth_icon,
)
row.label(text='', icon=auth_icon)
def draw_prelock(
self,
@ -245,11 +247,11 @@ class Tidy3DCloudTaskBLSocket(base.MaxwellSimSocket):
row.label(text='Tidy3D API Key')
row = col.row()
row.prop(self, 'api_key', text='')
row.prop(self, self.blfields['api_key'], text='')
row = col.row()
row.operator(
Authenticate.bl_idname,
ct.OperatorType.SocketCloudAuthenticate,
text='Connect',
)
@ -260,9 +262,9 @@ class Tidy3DCloudTaskBLSocket(base.MaxwellSimSocket):
# Cloud Folder Selector
row = col.row()
row.label(icon='FILE_FOLDER')
row.prop(self, 'existing_folder_id', text='')
row.prop(self, self.blfields['existing_folder_id'], text='')
row.operator(
ReloadFolderList.bl_idname,
ct.OperatorType.SocketReloadCloudFolderList,
text='',
icon='FILE_REFRESH',
)
@ -272,47 +274,14 @@ class Tidy3DCloudTaskBLSocket(base.MaxwellSimSocket):
if not self.should_exist:
row = col.row()
row.label(icon='NETWORK_DRIVE')
row.prop(self, 'new_task_name', text='')
row.prop(self, self.blfields['new_task_name'], text='')
col.separator(factor=1.0)
box = col.box()
row = box.row()
row.prop(self, 'existing_task_id', text='')
@property
def value(
self,
) -> tuple[tdcloud.CloudTaskName, tdcloud.CloudFolder] | tdcloud.CloudTask | None:
# Retrieve Folder
## Authentication is presumed OK
if (
cloud_folder := tdcloud.TidyCloudFolders.folders().get(
self.existing_folder_id
)
) is None:
msg = "Selected folder doesn't exist (it was probably deleted elsewhere)"
raise RuntimeError(msg)
# No Tasks in Folder
## The UI should set to "NONE" when there are no tasks in a folder
if self.existing_task_id == 'NONE':
return None
# Retrieve Task
if self.should_exist:
if (
cloud_task := tdcloud.TidyCloudTasks.tasks(cloud_folder).get(
self.existing_task_id
)
) is None:
msg = "Selected task doesn't exist (it was probably deleted elsewhere)"
raise RuntimeError(msg)
return cloud_task
return (self.new_task_name, cloud_folder)
row.prop(self, self.blfields['existing_task_id'], text='')
####################

View File

@ -268,7 +268,7 @@ class TidyCloudTasks:
# Get Sim Data (from file and/or download)
if path_sim.is_file():
log.info('Loading Cloud Task "%s" from "%s"', cloud_task.cloud_id, path_sim)
log.info('Loading Cloud Task "%s" from "%s"', cloud_task.task_id, path_sim)
sim_data = td.SimulationData.from_file(str(path_sim))
else:
log.info(
@ -420,7 +420,7 @@ class TidyCloudTasks:
# Repopulate All Caches
## By deleting the folder ID, all tasks within will be reloaded
del cls.cache_folder_tasks[folder_id]
cls.cache_folder_tasks.pop(folder_id, None)
return dict(cls.tasks(cloud_folder).items())

View File

@ -375,6 +375,7 @@ class CachedBLProperty:
return
if value == Signal.InvalidateCache:
log.critical('![%s] Invalidating %s', str(bl_instance), str(self))
self._invalidate_cache(bl_instance)
return
@ -447,7 +448,7 @@ class CachedBLProperty:
####################
# - Property Decorators
####################
def cached_bl_property(persist: bool = ...):
def cached_bl_property(persist: bool = False):
"""Decorator creating a descriptor that caches a computed attribute of a Blender node/socket.
Many such `bl_instance`s rely on fast access to computed, cached properties, for example to ensure that `draw()` remains effectively non-blocking.
@ -545,6 +546,7 @@ class BLField:
self._str_cb = str_cb
self._enum_cb = enum_cb
## HUGE TODO: Persist these
self._str_cb_cache = {}
self._enum_cb_cache = {}
@ -575,6 +577,7 @@ class BLField:
Thus, whenever the user wants the items in the enum to update, they must manually set the descriptor attribute to the value `Signal.ResetEnumItems`.
"""
if self._enum_cb_cache.get(_self.instance_id) is None:
log.critical('REGEN ENUM')
# Retrieve Dynamic Enum Items
enum_items = self._enum_cb(_self, context)