From c82862dde949bd4cfbfc6275f732440c10a57e97 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sofus=20Albert=20H=C3=B8gsbro=20Rose?= Date: Wed, 24 Apr 2024 18:36:29 +0200 Subject: [PATCH] fix: Implement explicit no-flow w/FlowSignal --- TODO.md | 34 +- .../contracts/operator_types.py | 7 + .../maxwell_sim_nodes/contracts/__init__.py | 60 +-- .../contracts/flow_signals.py | 71 ++++ .../nodes/analysis/extract_data.py | 356 +++++++++++++----- .../nodes/analysis/math/filter_math.py | 86 +++-- .../nodes/analysis/math/map_math.py | 51 ++- .../maxwell_sim_nodes/nodes/analysis/viz.py | 73 ++-- .../maxwell_sim_nodes/nodes/base.py | 33 +- .../maxwell_sim_nodes/nodes/events.py | 2 + .../web_importers/tidy_3d_web_importer.py | 158 +++++--- .../web_exporters/tidy3d_web_exporter.py | 4 +- .../maxwell_sim_nodes/sockets/base.py | 22 +- .../maxwell_sim_nodes/sockets/basic/data.py | 4 - .../sockets/maxwell/fdtd_sim.py | 4 - .../sockets/maxwell/fdtd_sim_data.py | 4 - .../sockets/tidy3d/cloud_task.py | 263 ++++++------- src/blender_maxwell/services/tdcloud.py | 4 +- src/blender_maxwell/utils/bl_cache.py | 5 +- 19 files changed, 791 insertions(+), 450 deletions(-) create mode 100644 src/blender_maxwell/node_trees/maxwell_sim_nodes/contracts/flow_signals.py diff --git a/TODO.md b/TODO.md index 9bcf0a3..e4b6519 100644 --- a/TODO.md +++ b/TODO.md @@ -541,19 +541,19 @@ We need support for arbitrary objects, but still backed by the persistance seman - [ ] Implement Enum property, (also see ) - 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). diff --git a/src/blender_maxwell/contracts/operator_types.py b/src/blender_maxwell/contracts/operator_types.py index 1d05521..c95c326 100644 --- a/src/blender_maxwell/contracts/operator_types.py +++ b/src/blender_maxwell/contracts/operator_types.py @@ -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() diff --git a/src/blender_maxwell/node_trees/maxwell_sim_nodes/contracts/__init__.py b/src/blender_maxwell/node_trees/maxwell_sim_nodes/contracts/__init__.py index 84766f3..eb53078 100644 --- a/src/blender_maxwell/node_trees/maxwell_sim_nodes/contracts/__init__.py +++ b/src/blender_maxwell/node_trees/maxwell_sim_nodes/contracts/__init__.py @@ -1,25 +1,25 @@ from blender_maxwell.contracts import ( - BLClass, - BLColorRGBA, - BLEnumElement, - BLEnumID, - BLIcon, - BLIconSet, - BLIDStruct, - BLKeymapItem, - BLModifierType, - BLNodeTreeInterfaceID, - BLOperatorStatus, - BLPropFlag, - BLRegionType, - BLSpaceType, - KeymapItemDef, - ManagedObjName, - OperatorType, - PanelType, - PresetName, - SocketName, - addon, + BLClass, + BLColorRGBA, + BLEnumElement, + BLEnumID, + BLIcon, + BLIconSet, + BLIDStruct, + BLKeymapItem, + BLModifierType, + BLNodeTreeInterfaceID, + BLOperatorStatus, + BLPropFlag, + BLRegionType, + BLSpaceType, + KeymapItemDef, + ManagedObjName, + OperatorType, + PanelType, + PresetName, + SocketName, + addon, ) from .bl_socket_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', ] diff --git a/src/blender_maxwell/node_trees/maxwell_sim_nodes/contracts/flow_signals.py b/src/blender_maxwell/node_trees/maxwell_sim_nodes/contracts/flow_signals.py new file mode 100644 index 0000000..84fdce0 --- /dev/null +++ b/src/blender_maxwell/node_trees/maxwell_sim_nodes/contracts/flow_signals.py @@ -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 diff --git a/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/analysis/extract_data.py b/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/analysis/extract_data.py index bbc428a..4265233 100644 --- a/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/analysis/extract_data.py +++ b/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/analysis/extract_data.py @@ -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,145 @@ 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): + log.critical('Sim Data was Not FlowSignal') + return sim_data + + log.critical('Sim Data was FlowSignal: %s', str(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`. + """ + log.critical('Searching Extract Filters') + log.critical(self.sim_data_monitor_nametype) + log.critical(self.monitor_data_components) + 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 +201,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 +219,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 +393,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']], diff --git a/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/analysis/math/filter_math.py b/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/analysis/math/filter_math.py index 18e6a98..20281c6 100644 --- a/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/analysis/math/filter_math.py +++ b/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/analysis/math/filter_math.py @@ -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. diff --git a/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/analysis/math/map_math.py b/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/analysis/math/map_math.py index 57b250b..7681941 100644 --- a/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/analysis/math/map_math.py +++ b/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/analysis/math/map_math.py @@ -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'] diff --git a/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/analysis/viz.py b/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/analysis/viz.py index 469ae56..fa5fe78 100644 --- a/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/analysis/viz.py +++ b/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/analysis/viz.py @@ -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'])( diff --git a/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/base.py b/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/base.py index 3029b1a..3e9bb13 100644 --- a/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/base.py +++ b/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/base.py @@ -597,7 +597,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 +645,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) @@ -777,6 +771,12 @@ class MaxwellSimNode(bpy.types.Node): # Invalidate Input Socket Cache if input_socket_name is not None: if socket_kinds is None: + log.critical( + '[%s] Invalidating: (%s, %s)', + self.sim_node_name, + input_socket_name, + str(socket_kinds), + ) self._compute_input.invalidate( input_socket_name=input_socket_name, kind=..., @@ -784,6 +784,12 @@ class MaxwellSimNode(bpy.types.Node): ) else: for socket_kind in socket_kinds: + log.critical( + '[%s] Invalidating: (%s, %s)', + self.sim_node_name, + input_socket_name, + str(socket_kind), + ) self._compute_input.invalidate( input_socket_name=input_socket_name, kind=socket_kind, @@ -838,6 +844,15 @@ 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, + socket_name, + socket_kinds, + prop_name, + event_method.callback_info, + ) event_method(self) # Propagate Event to All Sockets in "Trigger Direction" diff --git a/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/events.py b/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/events.py index e1a5fe4..e70bf2b 100644 --- a/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/events.py +++ b/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/events.py @@ -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, diff --git a/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/inputs/web_importers/tidy_3d_web_importer.py b/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/inputs/web_importers/tidy_3d_web_importer.py index 9812036..608d570 100644 --- a/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/inputs/web_importers/tidy_3d_web_importer.py +++ b/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/inputs/web_importers/tidy_3d_web_importer.py @@ -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 = { diff --git a/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/outputs/web_exporters/tidy3d_web_exporter.py b/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/outputs/web_exporters/tidy3d_web_exporter.py index 0276a33..f28c0d3 100644 --- a/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/outputs/web_exporters/tidy3d_web_exporter.py +++ b/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/outputs/web_exporters/tidy3d_web_exporter.py @@ -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 diff --git a/src/blender_maxwell/node_trees/maxwell_sim_nodes/sockets/base.py b/src/blender_maxwell/node_trees/maxwell_sim_nodes/sockets/base.py index 73785db..9208e36 100644 --- a/src/blender_maxwell/node_trees/maxwell_sim_nodes/sockets/base.py +++ b/src/blender_maxwell/node_trees/maxwell_sim_nodes/sockets/base.py @@ -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 diff --git a/src/blender_maxwell/node_trees/maxwell_sim_nodes/sockets/basic/data.py b/src/blender_maxwell/node_trees/maxwell_sim_nodes/sockets/basic/data.py index fcf55a8..db53d9e 100644 --- a/src/blender_maxwell/node_trees/maxwell_sim_nodes/sockets/basic/data.py +++ b/src/blender_maxwell/node_trees/maxwell_sim_nodes/sockets/basic/data.py @@ -34,10 +34,6 @@ class DataBLSocket(base.MaxwellSimSocket): must_match={'format': self.format}, ) - @property - def value(self): - return None - #################### # - UI #################### diff --git a/src/blender_maxwell/node_trees/maxwell_sim_nodes/sockets/maxwell/fdtd_sim.py b/src/blender_maxwell/node_trees/maxwell_sim_nodes/sockets/maxwell/fdtd_sim.py index e00e71f..283bd98 100644 --- a/src/blender_maxwell/node_trees/maxwell_sim_nodes/sockets/maxwell/fdtd_sim.py +++ b/src/blender_maxwell/node_trees/maxwell_sim_nodes/sockets/maxwell/fdtd_sim.py @@ -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 diff --git a/src/blender_maxwell/node_trees/maxwell_sim_nodes/sockets/maxwell/fdtd_sim_data.py b/src/blender_maxwell/node_trees/maxwell_sim_nodes/sockets/maxwell/fdtd_sim_data.py index 9bc4ec5..cdabfd2 100644 --- a/src/blender_maxwell/node_trees/maxwell_sim_nodes/sockets/maxwell/fdtd_sim_data.py +++ b/src/blender_maxwell/node_trees/maxwell_sim_nodes/sockets/maxwell/fdtd_sim_data.py @@ -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 diff --git a/src/blender_maxwell/node_trees/maxwell_sim_nodes/sockets/tidy3d/cloud_task.py b/src/blender_maxwell/node_trees/maxwell_sim_nodes/sockets/tidy3d/cloud_task.py index 774c3e5..211f652 100644 --- a/src/blender_maxwell/node_trees/maxwell_sim_nodes/sockets/tidy3d/cloud_task.py +++ b/src/blender_maxwell/node_trees/maxwell_sim_nodes/sockets/tidy3d/cloud_task.py @@ -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='') #################### diff --git a/src/blender_maxwell/services/tdcloud.py b/src/blender_maxwell/services/tdcloud.py index 4b135b6..9883fe7 100644 --- a/src/blender_maxwell/services/tdcloud.py +++ b/src/blender_maxwell/services/tdcloud.py @@ -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()) diff --git a/src/blender_maxwell/utils/bl_cache.py b/src/blender_maxwell/utils/bl_cache.py index 74ceaa5..68bec7a 100644 --- a/src/blender_maxwell/utils/bl_cache.py +++ b/src/blender_maxwell/utils/bl_cache.py @@ -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)