refactor: symbolic flow support

Massive boost.
Hard to quantify everything.

We're almost batch/inverse design!
- All units are now correct at realization (they weren't before), and
  several operations were simply wrong.
- Near-optimal "flow narrowing", which globally minimizes `DataChanged`
  even in the face of complex internal property dependencies.
- Sockets now cache `FlowKind` output themselves, with invalidation
  provided for and flow narrowing happening before it even enters the
  node flow.
- New FlowKind (`PreviewsFlow`) w/plotting, 3D, efficient caching, with
  all previewed nodes already adapted.
- Drastically prettified plot output, due to seaborn theme integration,
  dict-like data transport w/better labelling, etc. .
- Deep, reliable unit/unit dimension deduction and arithmetic for
  `PhysicalType` deduction using dimensional analysis on arbitrary
  expression operations.
- Fourier transform w/Nyquist limits as boundary conditions (preserving original shift).
- Vastly improved math node operation checks.
- Symbol constant node, integrating domain presumptions.
- Flow integration for ExprSocket, including for flow-dynamic capabilities and color.
- Viewer node got a big update, incl. live-display of sympy types (even
  matrix) and a few Tidy3D types, as well as seperating deeper options
  into a "Debug" mode
- Deeply streamlined symbolic flow, including
- **Differentiable Box structure**, w/generic lazy parameter support - only the start!
main
Sofus Albert Høgsbro Rose 2024-05-27 16:48:27 +02:00
parent bcba444a8b
commit a3551c68b7
Signed by: so-rose
GPG Key ID: AD901CB0F3701434
63 changed files with 2993 additions and 1207 deletions

Binary file not shown.

View File

@ -46,10 +46,11 @@ from .flow_kinds import (
ArrayFlow, ArrayFlow,
CapabilitiesFlow, CapabilitiesFlow,
FlowKind, FlowKind,
InfoFlow,
RangeFlow,
FuncFlow, FuncFlow,
InfoFlow,
ParamsFlow, ParamsFlow,
PreviewsFlow,
RangeFlow,
ScalingMode, ScalingMode,
ValueFlow, ValueFlow,
) )
@ -118,6 +119,7 @@ __all__ = [
'CapabilitiesFlow', 'CapabilitiesFlow',
'FlowKind', 'FlowKind',
'InfoFlow', 'InfoFlow',
'PreviewsFlow',
'RangeFlow', 'RangeFlow',
'FuncFlow', 'FuncFlow',
'ParamsFlow', 'ParamsFlow',

View File

@ -230,7 +230,6 @@ class BLSocketType(enum.StrEnum):
return { return {
# Blender # Blender
# Basic # Basic
BLST.Bool: MT.Bool,
# Float # Float
BLST.Float: MT.Real, BLST.Float: MT.Real,
BLST.FloatAngle: MT.Real, BLST.FloatAngle: MT.Real,

View File

@ -35,10 +35,6 @@ class FlowEvent(enum.StrEnum):
This event can lock a subset of the node tree graph. This event can lock a subset of the node tree graph.
DisableLock: Indicates that the node/socket should disable locking. DisableLock: Indicates that the node/socket should disable locking.
This event can unlock part of a locked subgraph. This event can unlock part of a locked subgraph.
ShowPreview: Indicates that the node/socket should enable its primary preview.
This should be used if a more specific preview-esque event doesn't apply.
ShowPlot: Indicates that the node/socket should enable its plotted preview.
This should generally be used if the node is rendering to an image, for viewing through the Blender image editor.
LinkChanged: Indicates that a link to a node/socket was added/removed. LinkChanged: Indicates that a link to a node/socket was added/removed.
Is translated to `DataChanged` on sockets before propagation. Is translated to `DataChanged` on sockets before propagation.
DataChanged: Indicates that data flowing through a node/socket was altered. DataChanged: Indicates that data flowing through a node/socket was altered.
@ -50,15 +46,12 @@ class FlowEvent(enum.StrEnum):
EnableLock = enum.auto() EnableLock = enum.auto()
DisableLock = enum.auto() DisableLock = enum.auto()
# Preview Events
ShowPreview = enum.auto()
ShowPlot = enum.auto()
# Data Events # Data Events
LinkChanged = enum.auto() LinkChanged = enum.auto()
DataChanged = enum.auto() DataChanged = enum.auto()
# Non-Triggered Events # Non-Triggered Events
ShowPlot = enum.auto()
OutputRequested = enum.auto() OutputRequested = enum.auto()
# Properties # Properties
@ -79,9 +72,6 @@ class FlowEvent(enum.StrEnum):
# Lock Events # Lock Events
FlowEvent.EnableLock: 'input', FlowEvent.EnableLock: 'input',
FlowEvent.DisableLock: 'input', FlowEvent.DisableLock: 'input',
# Preview Events
FlowEvent.ShowPreview: 'input',
FlowEvent.ShowPlot: 'input',
# Data Events # Data Events
FlowEvent.LinkChanged: 'output', FlowEvent.LinkChanged: 'output',
FlowEvent.DataChanged: 'output', FlowEvent.DataChanged: 'output',

View File

@ -21,6 +21,7 @@ from .info import InfoFlow
from .lazy_func import FuncFlow from .lazy_func import FuncFlow
from .lazy_range import RangeFlow, ScalingMode from .lazy_range import RangeFlow, ScalingMode
from .params import ParamsFlow from .params import ParamsFlow
from .previews import PreviewsFlow
from .value import ValueFlow from .value import ValueFlow
__all__ = [ __all__ = [
@ -32,5 +33,6 @@ __all__ = [
'ScalingMode', 'ScalingMode',
'FuncFlow', 'FuncFlow',
'ParamsFlow', 'ParamsFlow',
'PreviewsFlow',
'ValueFlow', 'ValueFlow',
] ]

View File

@ -21,7 +21,6 @@ import typing as typ
import jaxtyping as jtyp import jaxtyping as jtyp
import numpy as np import numpy as np
import sympy as sp import sympy as sp
import sympy.physics.units as spu
from blender_maxwell.utils import extra_sympy_units as spux from blender_maxwell.utils import extra_sympy_units as spux
from blender_maxwell.utils import logger from blender_maxwell.utils import logger
@ -117,13 +116,19 @@ class ArrayFlow:
new_unit: An (optional) new unit to scale the result to. new_unit: An (optional) new unit to scale the result to.
""" """
# Compile JAX-Compatible Rescale Function # Compile JAX-Compatible Rescale Function
## -> Generally, we try to keep things nice and rational.
## -> However, too-large ints may cause JAX to suffer from an overflow.
## -> Jax works in 32-bit domain by default, for performance.
## -> While it can be adjusted, that would also have tradeoffs.
## -> Instead, a quick .n() turns all the big-ints into floats.
## -> Not super satisfying, but hey - it's all numerical anyway.
a = self.mathtype.sp_symbol_a a = self.mathtype.sp_symbol_a
rescale_expr = ( rescale_expr = (
spux.scale_to_unit(rescale_func(a * self.unit), new_unit) spux.scale_to_unit(rescale_func(a * self.unit), new_unit)
if self.unit is not None if self.unit is not None
else rescale_func(a) else rescale_func(a)
) )
_rescale_func = sp.lambdify(a, rescale_expr, 'jax') _rescale_func = sp.lambdify(a, rescale_expr.n(), 'jax')
values = _rescale_func(self.values) values = _rescale_func(self.values)
# Return ArrayFlow # Return ArrayFlow

View File

@ -24,9 +24,30 @@ from .flow_kinds import FlowKind
@dataclasses.dataclass(frozen=True, kw_only=True) @dataclasses.dataclass(frozen=True, kw_only=True)
class CapabilitiesFlow: class CapabilitiesFlow:
"""Describes the compatibility relationship between two sockets, which governs whether they can be linked.
By default, socket type (which may impact color) and active `FlowKind` (which impacts shape) must match in order for two sockets to be compatible.
However, in many cases, more expressiveness beyond this naive constraint is desirable.
For example:
- Allow any socket to be linked to the `ViewerNode` input.
- Allow only _angled_ sources to be passed as inputs to the input-derived `BlochBoundCond` node.
- Allow `Expr:Value` to connect to `Expr:Func`, but only allow the converse if `PhysicalType`, `MathType`, and `Size` match.
In many cases, it's desirable
"""
# Defaults
socket_type: SocketType socket_type: SocketType
active_kind: FlowKind active_kind: FlowKind
# Relationships
allow_out_to_in: dict[FlowKind, FlowKind] = dataclasses.field(default_factory=dict) allow_out_to_in: dict[FlowKind, FlowKind] = dataclasses.field(default_factory=dict)
allow_out_to_in_if_matches: dict[FlowKind, (FlowKind, bool)] = dataclasses.field(
default_factory=dict
)
is_universal: bool = False is_universal: bool = False
@ -41,12 +62,19 @@ class CapabilitiesFlow:
def is_compatible_with(self, other: typ.Self) -> bool: def is_compatible_with(self, other: typ.Self) -> bool:
return other.is_universal or ( return other.is_universal or (
self.socket_type == other.socket_type self.socket_type is other.socket_type
and ( and (
self.active_kind == other.active_kind self.active_kind is other.active_kind
or ( or (
other.active_kind in other.allow_out_to_in other.active_kind in other.allow_out_to_in
and self.active_kind == other.allow_out_to_in[other.active_kind] and self.active_kind is other.allow_out_to_in[other.active_kind]
)
or (
other.active_kind in other.allow_out_to_in_if_matches
and self.active_kind
is other.allow_out_to_in_if_matches[other.active_kind][0]
and self.allow_out_to_in_if_matches[other.active_kind][1]
== other.allow_out_to_in_if_matches[other.active_kind][1]
) )
) )
# == Constraint # == Constraint

View File

@ -15,6 +15,7 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
import enum import enum
import functools
import typing as typ import typing as typ
from blender_maxwell.utils import extra_sympy_units as spux from blender_maxwell.utils import extra_sympy_units as spux
@ -23,6 +24,17 @@ from blender_maxwell.utils.staticproperty import staticproperty
log = logger.get(__name__) log = logger.get(__name__)
_PROPERTY_NAMES = {
'capabilities',
'previews',
'value',
'array',
'lazy_range',
'lazy_func',
'params',
'info',
}
class FlowKind(enum.StrEnum): class FlowKind(enum.StrEnum):
"""Defines a kind of data that can flow between nodes. """Defines a kind of data that can flow between nodes.
@ -50,14 +62,15 @@ class FlowKind(enum.StrEnum):
""" """
Capabilities = enum.auto() Capabilities = enum.auto()
Previews = enum.auto()
# Values # Values
Value = enum.auto() ## 'value' Value = enum.auto() ## 'value'
Array = enum.auto() ## 'array' Array = enum.auto() ## 'array'
# Lazy # Lazy
Func = enum.auto() ## 'lazy_func'
Range = enum.auto() ## 'lazy_range' Range = enum.auto() ## 'lazy_range'
Func = enum.auto() ## 'lazy_func'
# Auxiliary # Auxiliary
Params = enum.auto() ## 'params' Params = enum.auto() ## 'params'
@ -70,12 +83,13 @@ class FlowKind(enum.StrEnum):
def to_name(v: typ.Self) -> str: def to_name(v: typ.Self) -> str:
return { return {
FlowKind.Capabilities: 'Capabilities', FlowKind.Capabilities: 'Capabilities',
FlowKind.Previews: 'Previews',
# Values # Values
FlowKind.Value: 'Value', FlowKind.Value: 'Value',
FlowKind.Array: 'Array', FlowKind.Array: 'Array',
# Lazy # Lazy
FlowKind.Range: 'Range',
FlowKind.Func: 'Func', FlowKind.Func: 'Func',
FlowKind.Range: 'Range',
# Auxiliary # Auxiliary
FlowKind.Params: 'Params', FlowKind.Params: 'Params',
FlowKind.Info: 'Info', FlowKind.Info: 'Info',
@ -88,6 +102,48 @@ class FlowKind(enum.StrEnum):
#################### ####################
# - Static Properties # - Static Properties
#################### ####################
@staticproperty
def property_names() -> set[str]:
"""Set of strings for (socket) properties associated with a `FlowKind`.
Usable for optimized O(1) lookup, to check whether a property name can be converted to a `FlowKind`.
To actually retrieve the `FlowKind` from one of these names, use `FlowKind.from_property_name()`.
"""
return _PROPERTY_NAMES
@functools.cache
@staticmethod
def from_property_name(prop_name: str) -> typ.Self:
"""Retrieve the `FlowKind` associated with a particular property name.
Parameters:
prop_name: The name of the property.
**Must** be a string defined in `FlowKind.property_names`.
"""
return {
'capabilities': FlowKind.Capabilities,
'previews': FlowKind.Previews,
'value': FlowKind.Value,
'array': FlowKind.Array,
'lazy_range': FlowKind.Range,
'lazy_func': FlowKind.Func,
'params': FlowKind.Params,
'info': FlowKind.Info,
}[prop_name]
@functools.cached_property
def property_name(self) -> typ.Self:
"""Retrieve the `FlowKind` associated with a particular property name.
Parameters:
prop_name: The name of the property.
**Must** be a string defined in `FlowKind.property_names`.
"""
return {
FlowKind.from_property_name(prop_name): prop_name
for prop_name in FlowKind.property_names
}[self]
@staticproperty @staticproperty
def active_kinds() -> list[typ.Self]: def active_kinds() -> list[typ.Self]:
"""Return a list of `FlowKind`s that are able to be considered "active". """Return a list of `FlowKind`s that are able to be considered "active".
@ -121,6 +177,7 @@ class FlowKind(enum.StrEnum):
#################### ####################
# - Class Methods # - Class Methods
#################### ####################
## TODO: Remove this (only events uses it).
@classmethod @classmethod
def scale_to_unit_system( def scale_to_unit_system(
cls, cls,

View File

@ -93,7 +93,7 @@ class InfoFlow:
return list(self.dims.keys())[idx] return list(self.dims.keys())[idx]
return None return None
def dim_by_name(self, dim_name: str) -> int: def dim_by_name(self, dim_name: str, optional: bool = False) -> int | None:
"""The integer axis occupied by the dimension. """The integer axis occupied by the dimension.
Can be used to index `.shape` of the represented raw array. Can be used to index `.shape` of the represented raw array.
@ -102,6 +102,9 @@ class InfoFlow:
if len(dims_with_name) == 1: if len(dims_with_name) == 1:
return dims_with_name[0] return dims_with_name[0]
if optional:
return None
msg = f'Dim name {dim_name} not found in InfoFlow (or >1 found)' msg = f'Dim name {dim_name} not found in InfoFlow (or >1 found)'
raise ValueError(msg) raise ValueError(msg)
@ -127,14 +130,15 @@ class InfoFlow:
return False return False
def is_idx_uniform(self, dim: sim_symbols.SimSymbol) -> bool: def is_idx_uniform(self, dim: sim_symbols.SimSymbol) -> bool:
"""Whether the (int) dim has explicitly uniform indexing. """Whether the given dim has explicitly uniform indexing.
This is needed primarily to check whether a Fourier Transform can be meaningfully performed on the data over the dimension's axis. This is needed primarily to check whether a Fourier Transform can be meaningfully performed on the data over the dimension's axis.
In practice, we've decided that only `RangeFlow` really truly _guarantees_ uniform indexing. In practice, we've decided that only `RangeFlow` really truly _guarantees_ uniform indexing.
While `ArrayFlow` may be uniform in practice, it's a very expensive to check, and it's far better to enforce that the user perform that check and opt for a `RangeFlow` instead, at the time of dimension definition. While `ArrayFlow` may be uniform in practice, it's a very expensive to check, and it's far better to enforce that the user perform that check and opt for a `RangeFlow` instead, at the time of dimension definition.
""" """
return isinstance(self.dims[dim], RangeFlow) and self.dims[dim].scaling == 'lin' dim_idx = self.dims[dim]
return isinstance(dim_idx, RangeFlow) and dim_idx.scaling == 'lin'
def dim_axis(self, dim: sim_symbols.SimSymbol) -> int: def dim_axis(self, dim: sim_symbols.SimSymbol) -> int:
"""The integer axis occupied by the dimension. """The integer axis occupied by the dimension.
@ -194,7 +198,7 @@ class InfoFlow:
return { return {
dim.name_pretty: { dim.name_pretty: {
'length': str(len(dim_idx)) if dim_idx is not None else '', 'length': str(len(dim_idx)) if dim_idx is not None else '',
'mathtype': dim.mathtype.label_pretty, 'mathtype': dim.mathtype_size_label,
'unit': dim.unit_label, 'unit': dim.unit_label,
} }
for dim, dim_idx in self.dims.items() for dim, dim_idx in self.dims.items()
@ -315,10 +319,10 @@ class InfoFlow:
op: typ.Callable[[spux.SympyExpr, spux.SympyExpr], spux.SympyExpr], op: typ.Callable[[spux.SympyExpr, spux.SympyExpr], spux.SympyExpr],
unit_op: typ.Callable[[spux.SympyExpr, spux.SympyExpr], spux.SympyExpr], unit_op: typ.Callable[[spux.SympyExpr, spux.SympyExpr], spux.SympyExpr],
) -> spux.SympyExpr: ) -> spux.SympyExpr:
if self.dims == other.dims:
sym_name = sim_symbols.SimSymbolName.Expr sym_name = sim_symbols.SimSymbolName.Expr
expr = op(self.output.sp_symbol_phy, other.output.sp_symbol_phy) expr = op(self.output.sp_symbol_phy, other.output.sp_symbol_phy)
unit_expr = unit_op(self.output.unit_factor, other.output.unit_factor) unit_expr = unit_op(self.output.unit_factor, other.output.unit_factor)
## TODO: Handle per-cell matrix units?
return InfoFlow( return InfoFlow(
dims=self.dims, dims=self.dims,
@ -326,16 +330,12 @@ class InfoFlow:
pinned_values=self.pinned_values, pinned_values=self.pinned_values,
) )
msg = f'InfoFlow: operate_output cannot be used when dimensions are not identical ({self.dims} | {other.dims}).'
raise ValueError(msg)
#################### ####################
# - Operations: Fold # - Operations: Fold
#################### ####################
def fold_last_input(self): def fold_last_input(self):
"""Fold the last input dimension into the output.""" """Fold the last input dimension into the output."""
last_key = list(self.dims.keys())[-1] last_idx = self.dims[self.last_dim]
last_idx = list(self.dims.values())[-1]
rows = self.output.rows rows = self.output.rows
cols = self.output.cols cols = self.output.cols
@ -351,7 +351,9 @@ class InfoFlow:
return InfoFlow( return InfoFlow(
dims={ dims={
dim: dim_idx for dim, dim_idx in self.dims.items() if dim != last_key dim: dim_idx
for dim, dim_idx in self.dims.items()
if dim != self.last_dim
}, },
output=new_output, output=new_output,
pinned_values=self.pinned_values, pinned_values=self.pinned_values,

View File

@ -20,10 +20,14 @@ import typing as typ
from types import MappingProxyType from types import MappingProxyType
import jax import jax
import jaxtyping as jtyp
from blender_maxwell.utils import extra_sympy_units as spux from blender_maxwell.utils import extra_sympy_units as spux
from blender_maxwell.utils import logger, sim_symbols from blender_maxwell.utils import logger, sim_symbols
from .array import ArrayFlow
from .info import InfoFlow
from .lazy_range import RangeFlow
from .params import ParamsFlow from .params import ParamsFlow
log = logger.get(__name__) log = logger.get(__name__)
@ -314,14 +318,66 @@ class FuncFlow:
) -> typ.Self: ) -> typ.Self:
if self.supports_jax: if self.supports_jax:
return self.func_jax( return self.func_jax(
*params.scaled_func_args(self.func_args, symbol_values), *params.scaled_func_args(symbol_values),
*params.scaled_func_kwargs(self.func_args, symbol_values), **params.scaled_func_kwargs(symbol_values),
) )
return self.func( return self.func(
*params.scaled_func_args(self.func_kwargs, symbol_values), *params.scaled_func_args(symbol_values),
*params.scaled_func_kwargs(self.func_kwargs, symbol_values), **params.scaled_func_kwargs(symbol_values),
) )
def realize_as_data(
self,
info: InfoFlow,
params: ParamsFlow,
symbol_values: dict[sim_symbols.SimSymbol, spux.SympyExpr] = MappingProxyType(
{}
),
) -> dict[sim_symbols.SimSymbol, jtyp.Inexact[jtyp.Array, '...']]:
"""Realize as an ordered dictionary mapping each realized `self.dims` entry, with the last entry containing all output data as mapped from the `self.output`."""
data = {}
for dim, dim_idx in info.dims.items():
# Continuous Index (*)
## -> Continuous dimensions **must** be symbols in ParamsFlow.
## -> ...Since the output data shape is parameterized by it.
if info.has_idx_cont(dim):
if dim in params.symbols:
# Scalar Realization
## -> Conform & cast the sympy expr to the dimension.
if isinstance(symbol_values[dim], spux.SympyType):
data |= {dim: dim.scale(symbol_values[dim])}
# Array Realization
## -> Scale the array to the dimension's unit & get values.
if isinstance(symbol_values[dim], RangeFlow | ArrayFlow):
data |= {
dim: symbol_values[dim].rescale_to_unit(dim.unit).values
}
else:
msg = f'ParamsFlow does not contain dimension symbol {dim} (info={info}, params={params})'
raise RuntimeError(msg)
# Discrete Index (Q|R)
## -> Realize ArrayFlow|RangeFlow
if info.has_idx_discrete(dim):
data |= {dim: dim_idx.values}
# Labelled Index (Z)
## -> Passthrough the string labels.
if info.has_idx_labels(dim):
data |= {dim: dim_idx}
return data | {info.output: self.realize(params, symbol_values=symbol_values)}
# return {
# dim: (
# dim_idx
# if info.has_idx_cont(dim) or info.has_idx_labels(dim)
# else ??
# )
# for dim, dim_idx in self.dims
# } | {info.output: output_data}
#################### ####################
# - Composition Operations # - Composition Operations
#################### ####################

View File

@ -218,9 +218,6 @@ class RangeFlow:
) )
return combined_mathtype return combined_mathtype
####################
# - Methods
####################
@property @property
def ideal_midpoint(self) -> spux.SympyExpr: def ideal_midpoint(self) -> spux.SympyExpr:
return (self.stop + self.start) / 2 return (self.stop + self.start) / 2
@ -229,6 +226,41 @@ class RangeFlow:
def ideal_range(self) -> spux.SympyExpr: def ideal_range(self) -> spux.SympyExpr:
return self.stop - self.start return self.stop - self.start
####################
# - Methods
####################
@staticmethod
def try_from_array(
array: ArrayFlow, uniformity_tolerance: float = 1e-9
) -> ArrayFlow | typ.Self:
"""Attempt to create a RangeFlow from a potentially uniform ArrayFlow, falling back to that same ArrayFlow if it isn't uniform.
For functional (ex. Fourier Transform) and memory-related reasons, it's important to be explicit about the uniformity of index elements.
For this reason, only `RangeFlow`s are considered uniform - `ArrayFlow`s are not, as it's expensive to check in a hot loop, while `RangeFlow`s have this property simply by existing.
Of course, real-world data sources may not come in a memory-efficient configuration, even if they are, in fact, monotonically increasing with uniform finite differences.
This method bridges that gap: If (within `uniformity_tolerance`) **all** finite differences are the same, then the `ArrayFlow` can be converted losslessly to a `RangeFlow.
**Otherwise**, the `ArrayFlow` is returned verbatim.
Notes:
A few other checks are also performed to guarantee the semantics of a resulting `RangeFlow`: The array must be sorted, there must be at least two values, and the first value must be strictly smaller than the last value.
"""
diffs = jnp.diff(array.values)
if (
jnp.all(jnp.abs(diffs - diffs[0]) < uniformity_tolerance)
and len(array.values) > 2 # noqa: PLR2004
and array.values[0] < array.values[-1]
and array.is_sorted
):
return RangeFlow(
start=sp.S(array.values[0]),
stop=sp.S(array.values[-1]),
steps=len(array.values),
unit=array.unit,
)
return array
def rescale( def rescale(
self, rescale_func, reverse: bool = False, new_unit: spux.Unit | None = None self, rescale_func, reverse: bool = False, new_unit: spux.Unit | None = None
) -> typ.Self: ) -> typ.Self:
@ -612,8 +644,8 @@ class RangeFlow:
symbols=self.symbols, symbols=self.symbols,
) )
return RangeFlow( return RangeFlow(
start=self.start * unit, start=self.start,
stop=self.stop * unit, stop=self.stop,
steps=self.steps, steps=self.steps,
scaling=self.scaling, scaling=self.scaling,
unit=unit, unit=unit,

View File

@ -31,8 +31,6 @@ from .expr_info import ExprInfo
from .flow_kinds import FlowKind from .flow_kinds import FlowKind
from .lazy_range import RangeFlow from .lazy_range import RangeFlow
# from .info import InfoFlow
log = logger.get(__name__) log = logger.get(__name__)
@ -44,11 +42,18 @@ class ParamsFlow:
All symbols valid for use in the expression. All symbols valid for use in the expression.
""" """
arg_targets: list[sim_symbols.SimSymbol] = dataclasses.field(default_factory=list)
kwarg_targets: list[str, sim_symbols.SimSymbol] = dataclasses.field(
default_factory=dict
)
func_args: list[spux.SympyExpr] = dataclasses.field(default_factory=list) func_args: list[spux.SympyExpr] = dataclasses.field(default_factory=list)
func_kwargs: dict[str, spux.SympyExpr] = dataclasses.field(default_factory=dict) func_kwargs: dict[str, spux.SympyExpr] = dataclasses.field(default_factory=dict)
symbols: frozenset[sim_symbols.SimSymbol] = frozenset() symbols: frozenset[sim_symbols.SimSymbol] = frozenset()
is_differentiable: bool = False
#################### ####################
# - Symbols # - Symbols
#################### ####################
@ -76,8 +81,9 @@ class ParamsFlow:
#################### ####################
# - JIT'ed Callables for Numerical Function Arguments # - JIT'ed Callables for Numerical Function Arguments
#################### ####################
@functools.cached_property
def func_args_n( def func_args_n(
self, target_syms: list[sim_symbols.SimSymbol] self,
) -> list[ ) -> list[
typ.Callable[ typ.Callable[
[int | float | complex | jtyp.Inexact[jtyp.Array, '...'], ...], [int | float | complex | jtyp.Inexact[jtyp.Array, '...'], ...],
@ -86,15 +92,12 @@ class ParamsFlow:
]: ]:
"""Callable functions for evaluating each `self.func_args` entry numerically. """Callable functions for evaluating each `self.func_args` entry numerically.
Before simplification, each `self.func_args` entry will be conformed to the corresponding (by-index) `SimSymbol` in `target_syms`. Before simplification, each `self.func_args` entry will be conformed to the corresponding (by-index) `SimSymbol` in `self.target_syms`.
Notes: Notes:
Before using any `sympy` expressions as arguments to the returned callablees, they **must** be fully conformed and scaled to the corresponding `self.symbols` entry using that entry's `SimSymbol.scale()` method. Before using any `sympy` expressions as arguments to the returned callablees, they **must** be fully conformed and scaled to the corresponding `self.symbols` entry using that entry's `SimSymbol.scale()` method.
This ensures conformance to the `SimSymbol` properties (like units), as well as adherance to a numerical type identity compatible with `sp.lambdify()`. This ensures conformance to the `SimSymbol` properties (like units), as well as adherance to a numerical type identity compatible with `sp.lambdify()`.
Parameters:
target_syms: `SimSymbol`s describing how a particular `ParamsFlow` function argument should be scaled when performing a purely numerical insertion.
""" """
return [ return [
sp.lambdify( sp.lambdify(
@ -102,11 +105,14 @@ class ParamsFlow:
target_sym.conform(func_arg, strip_unit=True), target_sym.conform(func_arg, strip_unit=True),
'jax', 'jax',
) )
for func_arg, target_sym in zip(self.func_args, target_syms, strict=True) for func_arg, target_sym in zip(
self.func_args, self.arg_targets, strict=True
)
] ]
@functools.cached_property
def func_kwargs_n( def func_kwargs_n(
self, target_syms: dict[str, sim_symbols.SimSymbol] self,
) -> dict[ ) -> dict[
str, str,
typ.Callable[ typ.Callable[
@ -120,12 +126,12 @@ class ParamsFlow:
This ensures conformance to the `SimSymbol` properties, as well as adherance to a numerical type identity compatible with `sp.lambdify()` This ensures conformance to the `SimSymbol` properties, as well as adherance to a numerical type identity compatible with `sp.lambdify()`
""" """
return { return {
func_arg_key: sp.lambdify( key: sp.lambdify(
self.sorted_sp_symbols, self.sorted_sp_symbols,
target_syms[func_arg_key].scale(func_arg), self.kwarg_targets[key].conform(func_arg, strip_unit=True),
'jax', 'jax',
) )
for func_arg_key, func_arg in self.func_kwargs.items() for key, func_arg in self.func_kwargs.items()
} }
#################### ####################
@ -182,7 +188,6 @@ class ParamsFlow:
#################### ####################
def scaled_func_args( def scaled_func_args(
self, self,
target_syms: list[sim_symbols.SimSymbol] = (),
symbol_values: dict[sim_symbols.SimSymbol, spux.SympyExpr] = MappingProxyType( symbol_values: dict[sim_symbols.SimSymbol, spux.SympyExpr] = MappingProxyType(
{} {}
), ),
@ -196,32 +201,24 @@ class ParamsFlow:
1. Conform Symbols: Arbitrary `sympy` expressions passed as `symbol_values` must first be conformed to match the ex. units of `SimSymbol`s found in `self.symbols`, before they can be used. 1. Conform Symbols: Arbitrary `sympy` expressions passed as `symbol_values` must first be conformed to match the ex. units of `SimSymbol`s found in `self.symbols`, before they can be used.
2. Conform Function Arguments: Arbitrary `sympy` expressions encoded in `self.func_args` must, **after** inserting the conformed numerical symbols, themselves be conformed to the expected ex. units of the function that they are to be used within. 2. Conform Function Arguments: Arbitrary `sympy` expressions encoded in `self.func_args` must, **after** inserting the conformed numerical symbols, themselves be conformed to the expected ex. units of the function that they are to be used within.
**`ParamsFlow` doesn't contain information about the `SimSymbol`s that `self.func_args` are expected to conform to** (on purpose).
Therefore, the user is required to pass a `target_syms` with identical length to `self.func_args`, describing the `SimSymbol`s to conform the function arguments to.
Our implementation attempts to utilize simple, powerful primitives to accomplish this in roughly three steps: Our implementation attempts to utilize simple, powerful primitives to accomplish this in roughly three steps:
1. **Realize Symbols**: Particular passed symbolic values `symbol_values`, which are arbitrary `sympy` expressions, are conformed to the definitions in `self.symbols` (ex. to match units), then cast to numerical values (pure Python / jax array). 1. **Realize Symbols**: Particular passed symbolic values `symbol_values`, which are arbitrary `sympy` expressions, are conformed to the definitions in `self.symbols` (ex. to match units), then cast to numerical values (pure Python / jax array).
2. **Lazy Function Arguments**: Stored function arguments `self.func_args`, which are arbitrary `sympy` expressions, are conformed to the definitions in `target_syms` (ex. to match units), then cast to numerical values (pure Python / jax array). 2. **Lazy Function Arguments**: Stored function arguments `self.func_args`, which are arbitrary `sympy` expressions, are conformed to the definitions in `self.target_syms` (ex. to match units), then cast to numerical values (pure Python / jax array).
_Technically, this happens as part of `self.func_args_n`._ _Technically, this happens as part of `self.func_args_n`._
3. **Numerical Evaluation**: The numerical values for each symbol are passed as parameters to each (callable) element of `self.func_args_n`, which produces a correct numerical value for each function argument. 3. **Numerical Evaluation**: The numerical values for each symbol are passed as parameters to each (callable) element of `self.func_args_n`, which produces a correct numerical value for each function argument.
Parameters: Parameters:
target_syms: `SimSymbol`s describing how the function arguments returned by this method are intended to be used. symbol_values: Particular values for all symbols in `self.symbols`, which will be conformed and used to compute the function arguments (before they are conformed to `self.target_syms`).
**Generally**, the parallel `FuncFlow.func_args` should be inserted here, and guarantees correct results when this output is inserted into `FuncFlow.func(...)`.
symbol_values: Particular values for all symbols in `self.symbols`, which will be conformed and used to compute the function arguments (before they are conformed to `target_syms`).
""" """
realized_symbols = list(self.realize_symbols(symbol_values).values()) realized_symbols = list(self.realize_symbols(symbol_values).values())
return [ return [func_arg_n(*realized_symbols) for func_arg_n in self.func_args_n]
func_arg_n(*realized_symbols)
for func_arg_n in self.func_args_n(target_syms)
]
def scaled_func_kwargs( def scaled_func_kwargs(
self, self,
target_syms: list[sim_symbols.SimSymbol] = (),
symbol_values: dict[spux.Symbol, spux.SympyExpr] = MappingProxyType({}), symbol_values: dict[spux.Symbol, spux.SympyExpr] = MappingProxyType({}),
) -> dict[ ) -> dict[
str, int | float | Fraction | float | complex | jtyp.Shaped[jtyp.Array, '...'] str, int | float | Fraction | float | complex | jtyp.Shaped[jtyp.Array, '...']
@ -233,7 +230,7 @@ class ParamsFlow:
realized_symbols = self.realize_symbols(symbol_values) realized_symbols = self.realize_symbols(symbol_values)
return { return {
func_arg_name: func_arg_n(**realized_symbols) func_arg_name: func_arg_n(**realized_symbols)
for func_arg_name, func_arg_n in self.func_kwargs_n(target_syms).items() for func_arg_name, func_arg_n in self.func_kwargs_n.items()
} }
#################### ####################
@ -249,27 +246,41 @@ class ParamsFlow:
The next composed function will receive a tuple of two arrays, instead of just one, allowing binary operations to occur. The next composed function will receive a tuple of two arrays, instead of just one, allowing binary operations to occur.
""" """
return ParamsFlow( return ParamsFlow(
arg_targets=self.arg_targets + other.arg_targets,
kwarg_targets=self.kwarg_targets | other.kwarg_targets,
func_args=self.func_args + other.func_args, func_args=self.func_args + other.func_args,
func_kwargs=self.func_kwargs | other.func_kwargs, func_kwargs=self.func_kwargs | other.func_kwargs,
symbols=self.symbols | other.symbols, symbols=self.symbols | other.symbols,
is_differentiable=self.is_differentiable & other.is_differentiable,
) )
def compose_within( def compose_within(
self, self,
enclosing_arg_targets: list[sim_symbols.SimSymbol] = (),
enclosing_kwarg_targets: list[sim_symbols.SimSymbol] = (),
enclosing_func_args: list[spux.SympyExpr] = (), enclosing_func_args: list[spux.SympyExpr] = (),
enclosing_func_kwargs: dict[str, spux.SympyExpr] = MappingProxyType({}), enclosing_func_kwargs: dict[str, spux.SympyExpr] = MappingProxyType({}),
enclosing_symbols: frozenset[spux.Symbol] = frozenset(), enclosing_symbols: frozenset[sim_symbols.SimSymbol] = frozenset(),
enclosing_is_differentiable: bool = False,
) -> typ.Self: ) -> typ.Self:
return ParamsFlow( return ParamsFlow(
arg_targets=self.arg_targets + list(enclosing_arg_targets),
kwarg_targets=self.kwarg_targets | dict(enclosing_kwarg_targets),
func_args=self.func_args + list(enclosing_func_args), func_args=self.func_args + list(enclosing_func_args),
func_kwargs=self.func_kwargs | dict(enclosing_func_kwargs), func_kwargs=self.func_kwargs | dict(enclosing_func_kwargs),
symbols=self.symbols | enclosing_symbols, symbols=self.symbols | enclosing_symbols,
is_differentiable=(
self.is_differentiable
if not enclosing_symbols
else (self.is_differentiable & enclosing_is_differentiable)
),
) )
#################### ####################
# - Generate ExprSocketDef # - Generate ExprSocketDef
#################### ####################
def sym_expr_infos(self, use_range: bool = False) -> dict[str, ExprInfo]: @functools.cached_property
def sym_expr_infos(self) -> dict[str, ExprInfo]:
"""Generate keyword arguments for defining all `ExprSocket`s needed to realize all `self.symbols`. """Generate keyword arguments for defining all `ExprSocket`s needed to realize all `self.symbols`.
Many nodes need actual data, and as such, they require that the user select actual values for any symbols in the `ParamsFlow`. Many nodes need actual data, and as such, they require that the user select actual values for any symbols in the `ParamsFlow`.
@ -284,28 +295,16 @@ class ParamsFlow:
} }
``` ```
Parameters:
info: The InfoFlow associated with the `Expr` being realized.
Each symbol in `self.symbols` **must** have an associated same-named dimension in `info`.
use_range: Causes the
The `ExprInfo`s can be directly defererenced `**expr_info`) The `ExprInfo`s can be directly defererenced `**expr_info`)
""" """
for sym in self.sorted_symbols: for sym in self.sorted_symbols:
if use_range and sym.mathtype is spux.MathType.Complex:
msg = 'No support for complex range in ExprInfo'
raise NotImplementedError(msg)
if use_range and (sym.rows > 1 or sym.cols > 1):
msg = 'No support for non-scalar elements of range in ExprInfo'
raise NotImplementedError(msg)
if sym.rows > 3 or sym.cols > 1: if sym.rows > 3 or sym.cols > 1:
msg = 'No support for >Vec3 / Matrix values in ExprInfo' msg = 'No support for >Vec3 / Matrix values in ExprInfo'
raise NotImplementedError(msg) raise NotImplementedError(msg)
return { return {
sym.name: { sym: {
'active_kind': FlowKind.Value if not use_range else FlowKind.Range, 'default_steps': 25,
'default_steps': 50,
} }
| sym.expr_info | sym.expr_info
for sym in self.sorted_symbols for sym in self.sorted_symbols

View File

@ -0,0 +1,193 @@
# blender_maxwell
# Copyright (C) 2024 blender_maxwell Project Contributors
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import typing as typ
import bpy
import pydantic as pyd
from blender_maxwell.utils import logger
IMAGE_AREA_TYPE = 'IMAGE_EDITOR'
IMAGE_SPACE_TYPE = 'IMAGE_EDITOR'
log = logger.get(__name__)
####################
# - Global Collection Handling
####################
MANAGED_COLLECTION_NAME = 'BLMaxwell'
PREVIEW_COLLECTION_NAME = 'BLMaxwell Visible'
def collection(collection_name: str, view_layer_exclude: bool) -> bpy.types.Collection:
# Init the "Managed Collection"
# Ensure Collection exists (and is in the Scene collection)
if collection_name not in bpy.data.collections:
collection = bpy.data.collections.new(collection_name)
bpy.context.scene.collection.children.link(collection)
else:
collection = bpy.data.collections[collection_name]
## Ensure synced View Layer exclusion
if (
layer_collection := bpy.context.view_layer.layer_collection.children[
collection_name
]
).exclude != view_layer_exclude:
layer_collection.exclude = view_layer_exclude
return collection
def managed_collection() -> bpy.types.Collection:
return collection(MANAGED_COLLECTION_NAME, view_layer_exclude=True)
def preview_collection() -> bpy.types.Collection:
return collection(PREVIEW_COLLECTION_NAME, view_layer_exclude=False)
####################
# - Global Collection Handling
####################
class PreviewsFlow(pyd.BaseModel):
"""Represents global references to previewable entries."""
model_config = pyd.ConfigDict(frozen=True)
bl_image_name: str | None = None
bl_object_names: frozenset[str] = frozenset()
####################
# - Operations
####################
def __or__(self, other: typ.Self) -> typ.Self:
return PreviewsFlow(
bl_image_name=other.bl_image_name,
bl_object_names=self.bl_object_names | other.bl_object_names,
)
####################
# - Image Editor UX
####################
@classmethod
def preview_area(cls) -> bpy.types.Area | None:
"""Deduces a Blender UI area that can be used for image preview.
Returns:
A Blender UI area, if an appropriate one is visible; else `None`,
"""
valid_areas = [
area for area in bpy.context.screen.areas if area.type == IMAGE_AREA_TYPE
]
if valid_areas:
return valid_areas[0]
return None
@classmethod
def preview_space(cls) -> bpy.types.SpaceProperties | None:
"""Deduces a Blender UI space, within `self.preview_area`, that can be used for image preview.
Returns:
A Blender UI space within `self.preview_area`, if it isn't None; else, `None`.
"""
preview_area = cls.preview_area()
if preview_area is not None:
valid_spaces = [
space for space in preview_area.spaces if space.type == IMAGE_SPACE_TYPE
]
if valid_spaces:
return valid_spaces[0]
return None
return None
####################
# - Preview Plot
####################
@classmethod
def hide_image_preview(cls) -> None:
"""Show all image previews in the first available image editor.
If no image editors are visible, then nothing happens.
"""
preview_space = cls.preview_space()
if preview_space is not None and preview_space.image is not None:
cls.preview_space().image = None
def update_image_preview(self) -> None:
"""Show the image preview in the first available image editor.
If no image editors are visible, then nothing happens.
"""
preview_space = self.preview_space()
if self.bl_image_name is not None:
bl_image = bpy.data.images.get(self.bl_image_name)
# Replace Preview Space Image
if (
bl_image is not None
and preview_space is not None
and preview_space.image is not bl_image
):
preview_space.image = bl_image
# Remove Image
if bl_image is None and preview_space.image is not None:
preview_space.image = None
elif preview_space.image is not None:
preview_space.image = None
####################
# - Preview Objects
####################
@classmethod
def hide_bl_object_previews(cls) -> None:
"""Hide all previewed Blender objects."""
for bl_object_name in [obj.name for obj in preview_collection().objects]:
bl_object = bpy.data.objects.get(bl_object_name)
if bl_object is not None and bl_object.name in preview_collection().objects:
preview_collection().objects.unlink(bl_object)
def update_bl_object_previews(self) -> frozenset[str]:
"""Preview objects that need previewing and unpreview objects that need unpreviewing.
Designed to utilize the least possible amount of operations to achieve a desired set of previewed objects, from the current set of previewed objects.
"""
# Deduce Change in Previewed Objects
## -> Examine the preview collection for all currently previewed names.
## -> Preview names that shouldn't exist should be added.
## -> Preview names that should exist should be removed.
previewed_bl_objects = {obj.name for obj in preview_collection().objects}
bl_objects_to_remove = previewed_bl_objects - self.bl_object_names
bl_objects_to_add = self.bl_object_names - previewed_bl_objects
# Remove Previews
## -> Never presume that bl_object is already defined.
for bl_object_name in bl_objects_to_remove:
bl_object = bpy.data.objects.get(bl_object_name)
if bl_object is not None and bl_object.name in preview_collection().objects:
preview_collection().objects.unlink(bl_object)
# Add Previews
## -> Never presume that bl_object is already defined.
for bl_object_name in bl_objects_to_add:
bl_object = bpy.data.objects.get(bl_object_name)
if (
bl_object is not None
and bl_object.name not in preview_collection().objects
):
preview_collection().objects.link(bl_object)

View File

@ -38,6 +38,7 @@ class NodeType(blender_type_enum.BlenderTypeEnum):
Scene = enum.auto() Scene = enum.auto()
## Inputs / Constants ## Inputs / Constants
ExprConstant = enum.auto() ExprConstant = enum.auto()
SymbolConstant = enum.auto()
ScientificConstant = enum.auto() ScientificConstant = enum.auto()
UnitSystemConstant = enum.auto() UnitSystemConstant = enum.auto()
BlenderConstant = enum.auto() BlenderConstant = enum.auto()

View File

@ -19,7 +19,7 @@ import typing as typ
import bpy import bpy
from blender_maxwell.utils import logger from blender_maxwell.utils import logger, serialize
from . import contracts as ct from . import contracts as ct
from .managed_objs.managed_bl_image import ManagedBLImage from .managed_objs.managed_bl_image import ManagedBLImage

View File

@ -86,12 +86,14 @@ def extract_info(monitor_data, monitor_attr: str) -> ct.InfoFlow | None: # noqa
if xarr is None: if xarr is None:
return None return None
def mk_idx_array(axis: str) -> ct.ArrayFlow: def mk_idx_array(axis: str) -> ct.RangeFlow | ct.ArrayFlow:
return ct.ArrayFlow( return ct.RangeFlow.try_from_array(
ct.ArrayFlow(
values=xarr.get_index(axis).values, values=xarr.get_index(axis).values,
unit=symbols[axis].unit, unit=symbols[axis].unit,
is_sorted=True, is_sorted=True,
) )
)
# Compute InfoFlow from XArray # Compute InfoFlow from XArray
symbols = { symbols = {

View File

@ -124,12 +124,12 @@ class FilterOperation(enum.StrEnum):
# - Computed Properties # - Computed Properties
#################### ####################
@property @property
def func_args(self) -> list[spux.MathType]: def func_args(self) -> list[sim_symbols.SimSymbol]:
FO = FilterOperation FO = FilterOperation
return { return {
# Pin # Pin
FO.Pin: [spux.MathType.Integer], FO.Pin: [sim_symbols.idx(None)],
FO.PinIdx: [spux.MathType.Integer], FO.PinIdx: [sim_symbols.idx(None)],
}.get(self, []) }.get(self, [])
#################### ####################
@ -155,10 +155,10 @@ class FilterOperation(enum.StrEnum):
match self: match self:
# Slice # Slice
case FO.Slice: case FO.Slice:
return [dim for dim in info.dims if not dim.has_idx_labels(dim)] return [dim for dim in info.dims if not info.has_idx_labels(dim)]
case FO.SliceIdx: case FO.SliceIdx:
return [dim for dim in info.dims if not dim.has_idx_labels(dim)] return [dim for dim in info.dims if not info.has_idx_labels(dim)]
# Pin # Pin
case FO.PinLen1: case FO.PinLen1:
@ -272,10 +272,15 @@ class FilterMathNode(base.MaxwellSimNode):
# - Properties: Expr InfoFlow # - Properties: Expr InfoFlow
#################### ####################
@events.on_value_changed( @events.on_value_changed(
# Trigger
socket_name={'Expr'}, socket_name={'Expr'},
# Loaded
input_sockets={'Expr'}, input_sockets={'Expr'},
input_socket_kinds={'Expr': ct.FlowKind.Info}, input_socket_kinds={'Expr': ct.FlowKind.Info},
input_sockets_optional={'Expr': True}, input_sockets_optional={'Expr': True},
# Flow
## -> See docs in TransformMathNode
stop_propagation=True,
) )
def on_input_exprs_changed(self, input_sockets) -> None: # noqa: D102 def on_input_exprs_changed(self, input_sockets) -> None: # noqa: D102
has_info = not ct.FlowSignal.check(input_sockets['Expr']) has_info = not ct.FlowSignal.check(input_sockets['Expr'])
@ -593,11 +598,17 @@ class FilterMathNode(base.MaxwellSimNode):
pinned_value, require_sorted=True pinned_value, require_sorted=True
) )
return params.compose_within(enclosing_func_args=[nearest_idx_to_value]) return params.compose_within(
enclosing_arg_targets=[sim_symbols.idx(None)],
enclosing_func_args=[sp.S(nearest_idx_to_value)],
)
# Pin by-Index # Pin by-Index
if props['operation'] is FilterOperation.PinIdx and has_pinned_axis: if props['operation'] is FilterOperation.PinIdx and has_pinned_axis:
return params.compose_within(enclosing_func_args=[pinned_axis]) return params.compose_within(
enclosing_arg_targets=[sim_symbols.idx(None)],
enclosing_func_args=[sp.S(pinned_axis)],
)
return params return params

View File

@ -236,7 +236,7 @@ class MapOperation(enum.StrEnum):
MO.Sinc: lambda expr: sp.sinc(expr), MO.Sinc: lambda expr: sp.sinc(expr),
# By Vector # By Vector
# Vector -> Number # Vector -> Number
MO.Norm2: lambda expr: sp.sqrt(expr.T @ expr), MO.Norm2: lambda expr: sp.sqrt(expr.T @ expr)[0],
# By Matrix # By Matrix
# Matrix -> Number # Matrix -> Number
MO.Det: lambda expr: sp.det(expr), MO.Det: lambda expr: sp.det(expr),
@ -467,10 +467,15 @@ class MapMathNode(base.MaxwellSimNode):
# - Properties # - Properties
#################### ####################
@events.on_value_changed( @events.on_value_changed(
# Trigger
socket_name={'Expr'}, socket_name={'Expr'},
# Loaded
input_sockets={'Expr'}, input_sockets={'Expr'},
input_socket_kinds={'Expr': ct.FlowKind.Info}, input_socket_kinds={'Expr': ct.FlowKind.Info},
input_sockets_optional={'Expr': True}, input_sockets_optional={'Expr': True},
# Flow
## -> See docs in TransformMathNode
stop_propagation=True,
) )
def on_input_exprs_changed(self, input_sockets) -> None: # noqa: D102 def on_input_exprs_changed(self, input_sockets) -> None: # noqa: D102
has_info = not ct.FlowSignal.check(input_sockets['Expr']) has_info = not ct.FlowSignal.check(input_sockets['Expr'])

View File

@ -210,7 +210,7 @@ class BinaryOperation(enum.StrEnum):
): ):
ops += [BO.Cross] ops += [BO.Cross]
return ops return ops_el_el + ops
## Vector | Matrix ## Vector | Matrix
case (1, 2): case (1, 2):
@ -374,10 +374,15 @@ class OperateMathNode(base.MaxwellSimNode):
# - Properties # - Properties
#################### ####################
@events.on_value_changed( @events.on_value_changed(
# Trigger
socket_name={'Expr L', 'Expr R'}, socket_name={'Expr L', 'Expr R'},
# Loaded
input_sockets={'Expr L', 'Expr R'}, input_sockets={'Expr L', 'Expr R'},
input_socket_kinds={'Expr L': ct.FlowKind.Info, 'Expr R': ct.FlowKind.Info}, input_socket_kinds={'Expr L': ct.FlowKind.Info, 'Expr R': ct.FlowKind.Info},
input_sockets_optional={'Expr L': True, 'Expr R': True}, input_sockets_optional={'Expr L': True, 'Expr R': True},
# Flow
## -> See docs in TransformMathNode
stop_propagation=True,
) )
def on_input_exprs_changed(self, input_sockets) -> None: # noqa: D102 def on_input_exprs_changed(self, input_sockets) -> None: # noqa: D102
has_info_l = not ct.FlowSignal.check(input_sockets['Expr L']) has_info_l = not ct.FlowSignal.check(input_sockets['Expr L'])

View File

@ -17,7 +17,6 @@
"""Declares `TransformMathNode`.""" """Declares `TransformMathNode`."""
import enum import enum
import functools
import typing as typ import typing as typ
import bpy import bpy
@ -39,13 +38,25 @@ log = logger.get(__name__)
# - Operation Enum # - Operation Enum
#################### ####################
class TransformOperation(enum.StrEnum): class TransformOperation(enum.StrEnum):
"""Valid operations for the `MapMathNode`. """Valid operations for the `TransformMathNode`.
Attributes: Attributes:
FreqToVacWL: Transform frequency axes to be indexed by vacuum wavelength. FreqToVacWL: Transform an frequency dimension to vacuum wavelength.
VacWLToFreq: Transform vacuum wavelength axes to be indexed by frequency. VacWLToFreq: Transform a vacuum wavelength dimension to frequency.
FFT: Compute the fourier transform of the input expression. ConvertIdxUnit: Convert the unit of a dimension to a compatible unit.
InvFFT: Compute the inverse fourier transform of the input expression. SetIdxUnit: Set all properties of a dimension.
FirstColToFirstIdx: Extract the first data column and set the first dimension's index array equal to it.
**For 2D integer-indexed data only**.
IntDimToComplex: Fold a last length-2 integer dimension into the output, transforming it from a real-like type to complex type.
DimToVec: Fold the last dimension into the scalar output, creating a vector output type.
DimsToMat: Fold the last two dimensions into the scalar output, creating a matrix output type.
FT: Compute the 1D fourier transform along a dimension.
New dimensional bounds are computing using the Nyquist Limit.
For higher dimensions, simply repeat along more dimensions.
InvFT1D: Compute the inverse 1D fourier transform along a dimension.
New dimensional bounds are computing using the Nyquist Limit.
For higher dimensions, simply repeat along more dimensions.
""" """
# Covariant Transform # Covariant Transform
@ -79,7 +90,7 @@ class TransformOperation(enum.StrEnum):
TO.VacWLToFreq: 'λᵥ → 𝑓', TO.VacWLToFreq: 'λᵥ → 𝑓',
TO.ConvertIdxUnit: 'Convert Dim', TO.ConvertIdxUnit: 'Convert Dim',
TO.SetIdxUnit: 'Set Dim', TO.SetIdxUnit: 'Set Dim',
TO.FirstColToFirstIdx: '1st Col → Dim', TO.FirstColToFirstIdx: '1st Col → 1st Dim',
# Fold # Fold
TO.IntDimToComplex: '', TO.IntDimToComplex: '',
TO.DimToVec: '→ Vector', TO.DimToVec: '→ Vector',
@ -87,10 +98,14 @@ class TransformOperation(enum.StrEnum):
## TODO: Vector to new last-dim integer ## TODO: Vector to new last-dim integer
## TODO: Matrix to two last-dim integers ## TODO: Matrix to two last-dim integers
# Fourier # Fourier
TO.FT1D: '𝑓', TO.FT1D: 'FT',
TO.InvFT1D: '𝑓', TO.InvFT1D: 'iFT',
}[value] }[value]
@property
def name(self) -> str:
return TransformOperation.to_name(self)
@staticmethod @staticmethod
def to_icon(_: typ.Self) -> str: def to_icon(_: typ.Self) -> str:
return '' return ''
@ -108,49 +123,32 @@ class TransformOperation(enum.StrEnum):
#################### ####################
# - Methods # - Methods
#################### ####################
@property
def num_dim_inputs(self) -> None:
"""The number of axes that should be passed as inputs to `func_jax` when evaluating it.
Especially useful for `ParamFlow`, when deciding whether to pass an integer-axis argument based on a user-selected dimension.
"""
TO = TransformOperation
return {
# Covariant Transform
TO.FreqToVacWL: 1,
TO.VacWLToFreq: 1,
TO.ConvertIdxUnit: 1,
TO.SetIdxUnit: 1,
TO.FirstColToFirstIdx: 0,
# Fold
TO.IntDimToComplex: 0,
TO.DimToVec: 0,
TO.DimsToMat: 0,
## TODO: Vector to new last-dim integer
## TODO: Matrix to two last-dim integers
# Fourier
TO.FT1D: 1,
TO.InvFT1D: 1,
}[self]
def valid_dims(self, info: ct.InfoFlow) -> list[typ.Self]: def valid_dims(self, info: ct.InfoFlow) -> list[typ.Self]:
TO = TransformOperation TO = TransformOperation
match self: match self:
case TO.FreqToVacWL | TO.FT1D: case TO.FreqToVacWL:
return [ return [
dim dim
for dim in info.dims for dim in info.dims
if dim.physical_type is spux.PhysicalType.Freq if dim.physical_type is spux.PhysicalType.Freq
] ]
case TO.VacWLToFreq | TO.InvFT1D: case TO.VacWLToFreq:
return [ return [
dim dim
for dim in info.dims for dim in info.dims
if dim.physical_type is spux.PhysicalType.Length if dim.physical_type is spux.PhysicalType.Length
] ]
case TO.ConvertIdxUnit | TO.SetIdxUnit: case TO.ConvertIdxUnit:
return [
dim
for dim in info.dims
if not info.has_idx_labels(dim)
and spux.PhysicalType.from_unit(dim.unit, optional=True) is not None
]
case TO.SetIdxUnit:
return [dim for dim in info.dims if not info.has_idx_labels(dim)] return [dim for dim in info.dims if not info.has_idx_labels(dim)]
## ColDimToComplex: Implicit Last Dimension ## ColDimToComplex: Implicit Last Dimension
@ -198,13 +196,11 @@ class TransformOperation(enum.StrEnum):
# Fold # Fold
## Last Dim -> Complex ## Last Dim -> Complex
if ( if (
info.dims len(info.dims) >= 1
# Output is Int|Rat|Real
and ( and (
info.output.mathtype info.output.mathtype
in [spux.MathType.Integer, spux.MathType.Rational, spux.MathType.Real] in [spux.MathType.Integer, spux.MathType.Rational, spux.MathType.Real]
) )
# Last Axis is Integer of Length 2
and info.last_dim.mathtype is spux.MathType.Integer and info.last_dim.mathtype is spux.MathType.Integer
and info.has_idx_labels(info.last_dim) and info.has_idx_labels(info.last_dim)
and len(info.dims[info.last_dim]) == 2 # noqa: PLR2004 and len(info.dims[info.last_dim]) == 2 # noqa: PLR2004
@ -231,14 +227,13 @@ class TransformOperation(enum.StrEnum):
#################### ####################
# - Function Properties # - Function Properties
#################### ####################
@functools.cached_property def jax_func(self, axis: int | None = None):
def jax_func(self):
TO = TransformOperation TO = TransformOperation
return { return {
# Covariant Transform # Covariant Transform
## -> Freq <-> WL is a rescale (noop) AND flip (not noop). ## -> Freq <-> WL is a rescale (noop) AND flip (not noop).
TO.FreqToVacWL: lambda expr, axis: jnp.flip(expr, axis=axis), TO.FreqToVacWL: lambda expr: jnp.flip(expr, axis=axis),
TO.VacWLToFreq: lambda expr, axis: jnp.flip(expr, axis=axis), TO.VacWLToFreq: lambda expr: jnp.flip(expr, axis=axis),
TO.ConvertIdxUnit: lambda expr: expr, TO.ConvertIdxUnit: lambda expr: expr,
TO.SetIdxUnit: lambda expr: expr, TO.SetIdxUnit: lambda expr: expr,
TO.FirstColToFirstIdx: lambda expr: jnp.delete(expr, 0, axis=1), TO.FirstColToFirstIdx: lambda expr: jnp.delete(expr, 0, axis=1),
@ -250,8 +245,8 @@ class TransformOperation(enum.StrEnum):
TO.DimToVec: lambda expr: expr, TO.DimToVec: lambda expr: expr,
TO.DimsToMat: lambda expr: expr, TO.DimsToMat: lambda expr: expr,
# Fourier # Fourier
TO.FT1D: lambda expr, axis: jnp.fft(expr, axis=axis), TO.FT1D: lambda expr: jnp.fft(expr, axis=axis),
TO.InvFT1D: lambda expr, axis: jnp.ifft(expr, axis=axis), TO.InvFT1D: lambda expr: jnp.ifft(expr, axis=axis),
}[self] }[self]
def transform_info( def transform_info(
@ -268,25 +263,21 @@ class TransformOperation(enum.StrEnum):
# Covariant Transform # Covariant Transform
TO.FreqToVacWL: lambda: info.replace_dim( TO.FreqToVacWL: lambda: info.replace_dim(
(f_dim := dim), (f_dim := dim),
[
sim_symbols.wl(unit), sim_symbols.wl(unit),
info.dims[f_dim].rescale( info.dims[f_dim].rescale(
lambda el: sci_constants.vac_speed_of_light / el, lambda el: sci_constants.vac_speed_of_light / el,
reverse=True, reverse=True,
new_unit=unit, new_unit=unit,
), ),
],
), ),
TO.VacWLToFreq: lambda: info.replace_dim( TO.VacWLToFreq: lambda: info.replace_dim(
(wl_dim := dim), (wl_dim := dim),
[
sim_symbols.freq(unit), sim_symbols.freq(unit),
info.dims[wl_dim].rescale( info.dims[wl_dim].rescale(
lambda el: sci_constants.vac_speed_of_light / el, lambda el: sci_constants.vac_speed_of_light / el,
reverse=True, reverse=True,
new_unit=unit, new_unit=unit,
), ),
],
), ),
TO.ConvertIdxUnit: lambda: info.replace_dim( TO.ConvertIdxUnit: lambda: info.replace_dim(
dim, dim,
@ -300,7 +291,9 @@ class TransformOperation(enum.StrEnum):
TO.SetIdxUnit: lambda: info.replace_dim( TO.SetIdxUnit: lambda: info.replace_dim(
dim, dim,
dim.update( dim.update(
sym_name=new_dim_name, physical_type=physical_type, unit=unit sym_name=new_dim_name,
physical_type=physical_type,
unit=unit,
), ),
( (
info.dims[dim].correct_unit(unit) info.dims[dim].correct_unit(unit)
@ -311,10 +304,12 @@ class TransformOperation(enum.StrEnum):
TO.FirstColToFirstIdx: lambda: info.replace_dim( TO.FirstColToFirstIdx: lambda: info.replace_dim(
info.first_dim, info.first_dim,
info.first_dim.update( info.first_dim.update(
sym_name=new_dim_name,
mathtype=spux.MathType.from_jax_array(data_col), mathtype=spux.MathType.from_jax_array(data_col),
physical_type=physical_type,
unit=unit, unit=unit,
), ),
ct.ArrayFlow(values=data_col, unit=unit), ct.RangeFlow.try_from_array(ct.ArrayFlow(values=data_col, unit=unit)),
).slice_dim(info.last_dim, (1, len(info.dims[info.last_dim]), 1)), ).slice_dim(info.last_dim, (1, len(info.dims[info.last_dim]), 1)),
# Fold # Fold
TO.IntDimToComplex: lambda: info.delete_dim(info.last_dim).update_output( TO.IntDimToComplex: lambda: info.delete_dim(info.last_dim).update_output(
@ -380,10 +375,18 @@ class TransformMathNode(base.MaxwellSimNode):
# - Properties: Expr InfoFlow # - Properties: Expr InfoFlow
#################### ####################
@events.on_value_changed( @events.on_value_changed(
# Trigger
socket_name={'Expr'}, socket_name={'Expr'},
# Loaded
input_sockets={'Expr'}, input_sockets={'Expr'},
input_socket_kinds={'Expr': ct.FlowKind.Info}, input_socket_kinds={'Expr': ct.FlowKind.Info},
input_sockets_optional={'Expr': True}, input_sockets_optional={'Expr': True},
# Flow
## -> Expr wants to emit DataChanged, which is usually fine.
## -> However, this node sets `expr_info`, which causes DC to emit.
## -> One action should emit one DataChanged pipe.
## -> Therefore, defer responsibility for DataChanged to self.expr_info.
stop_propagation=True,
) )
def on_input_exprs_changed(self, input_sockets) -> None: # noqa: D102 def on_input_exprs_changed(self, input_sockets) -> None: # noqa: D102
has_info = not ct.FlowSignal.check(input_sockets['Expr']) has_info = not ct.FlowSignal.check(input_sockets['Expr'])
@ -440,7 +443,7 @@ class TransformMathNode(base.MaxwellSimNode):
@bl_cache.cached_bl_property(depends_on={'expr_info', 'active_dim'}) @bl_cache.cached_bl_property(depends_on={'expr_info', 'active_dim'})
def dim(self) -> sim_symbols.SimSymbol | None: def dim(self) -> sim_symbols.SimSymbol | None:
if self.expr_info is not None and self.active_dim is not None: if self.expr_info is not None and self.active_dim is not None:
return self.expr_info.dim_by_name(self.active_dim) return self.expr_info.dim_by_name(self.active_dim, optional=True)
return None return None
#################### ####################
@ -454,36 +457,42 @@ class TransformMathNode(base.MaxwellSimNode):
) )
active_new_unit: enum.StrEnum = bl_cache.BLField( active_new_unit: enum.StrEnum = bl_cache.BLField(
enum_cb=lambda self, _: self.search_units(), enum_cb=lambda self, _: self.search_units(),
cb_depends_on={'dim', 'new_physical_type'}, cb_depends_on={'dim', 'new_physical_type', 'operation'},
) )
def search_units(self) -> list[ct.BLEnumElement]: def search_units(self) -> list[ct.BLEnumElement]:
if self.dim is not None: TO = TransformOperation
if self.dim.physical_type is not spux.PhysicalType.NonPhysical: match self.operation:
unit_name = sp.sstr(self.dim.unit) # Covariant Transform
return [ case TO.ConvertIdxUnit if self.dim is not None:
( physical_type = spux.PhysicalType.from_unit(
sp.sstr(unit), self.dim.unit, optional=True
spux.sp_to_str(unit),
sp.sstr(unit),
'',
0,
) )
for unit in self.dim.physical_type.valid_units if physical_type is not None:
] valid_units = physical_type.valid_units
else:
valid_units = []
case TO.FreqToVacWL if self.dim is not None:
valid_units = spux.PhysicalType.Length.valid_units
case TO.VacWLToFreq if self.dim is not None:
valid_units = spux.PhysicalType.Freq.valid_units
case TO.SetIdxUnit if (
self.dim is not None
and self.new_physical_type is not spux.PhysicalType.NonPhysical
):
valid_units = self.new_physical_type.valid_units
case TO.FirstColToFirstIdx if (
self.new_physical_type is not spux.PhysicalType.NonPhysical
):
valid_units = self.new_physical_type.valid_units
case _:
valid_units = []
if self.dim.unit is not None:
unit_name = sp.sstr(self.dim.unit)
return [
(
unit_name,
spux.sp_to_str(self.dim.unit),
unit_name,
'',
0,
)
]
if self.new_physical_type is not spux.PhysicalType.NonPhysical:
return [ return [
( (
sp.sstr(unit), sp.sstr(unit),
@ -492,11 +501,9 @@ class TransformMathNode(base.MaxwellSimNode):
'', '',
i, i,
) )
for i, unit in enumerate(self.new_physical_type.valid_units) for i, unit in enumerate(valid_units)
] ]
return []
@bl_cache.cached_bl_property(depends_on={'active_new_unit'}) @bl_cache.cached_bl_property(depends_on={'active_new_unit'})
def new_unit(self) -> spux.Unit: def new_unit(self) -> spux.Unit:
if self.active_new_unit is not None: if self.active_new_unit is not None:
@ -507,103 +514,192 @@ class TransformMathNode(base.MaxwellSimNode):
#################### ####################
# - UI # - UI
#################### ####################
def draw_label(self): @bl_cache.cached_bl_property(depends_on={'new_unit'})
if self.operation is not None: def new_unit_str(self) -> str:
return 'T: ' + TransformOperation.to_name(self.operation) if self.new_unit is None:
return ''
return spux.sp_to_str(self.new_unit)
def draw_label(self):
TO = TransformOperation
match self.operation:
case TO.FreqToVacWL if self.dim is not None:
return f'T: {self.dim.name_pretty} | 𝑓{self.new_unit_str}'
case TO.VacWLToFreq if self.dim is not None:
return f'T: {self.dim.name_pretty} | λᵥ → {self.new_unit_str}'
case TO.ConvertIdxUnit if self.dim is not None:
return f'T: {self.dim.name_pretty}{self.new_unit_str}'
case TO.SetIdxUnit if self.dim is not None:
return f'T: {self.dim.name_pretty}{self.new_name.name_pretty} | {self.new_unit_str}'
case (
TO.IntDimToComplex
| TO.DimToVec
| TO.DimsToMat
) if self.expr_info is not None and self.expr_info.dims:
return f'T: {self.expr_info.last_dim.name_unit_label} {self.operation.name}'
case TO.FT1D if self.dim is not None:
return f'T: FT[{self.dim.name_unit_label}]'
case TO.InvFT1D if self.dim is not None:
return f'T: iFT[{self.dim.name_unit_label}]'
case _:
if self.operation is not None:
return f'T: {self.operation.name}'
return self.bl_label return self.bl_label
def draw_props(self, _: bpy.types.Context, layout: bpy.types.UILayout) -> None: def draw_props(self, _: bpy.types.Context, layout: bpy.types.UILayout) -> None:
layout.prop(self, self.blfields['operation'], text='') layout.prop(self, self.blfields['operation'], text='')
if self.operation is not None and self.operation.num_dim_inputs == 1:
TO = TransformOperation TO = TransformOperation
layout.prop(self, self.blfields['active_dim'], text='') match self.operation:
case TO.ConvertIdxUnit:
row = layout.row(align=True)
row.prop(self, self.blfields['active_dim'], text='')
row.prop(self, self.blfields['active_new_unit'], text='')
if self.operation in [TO.ConvertIdxUnit, TO.SetIdxUnit]: case TO.FreqToVacWL:
row = layout.row(align=True)
row.prop(self, self.blfields['active_dim'], text='')
row.prop(self, self.blfields['active_new_unit'], text='')
case TO.VacWLToFreq:
row = layout.row(align=True)
row.prop(self, self.blfields['active_dim'], text='')
row.prop(self, self.blfields['active_new_unit'], text='')
case TO.SetIdxUnit:
row = layout.row(align=True)
row.prop(self, self.blfields['active_dim'], text='')
row.prop(self, self.blfields['new_name'], text='')
row = layout.row(align=True)
row.prop(self, self.blfields['new_physical_type'], text='')
row.prop(self, self.blfields['active_new_unit'], text='')
case TO.FirstColToFirstIdx:
col = layout.column(align=True) col = layout.column(align=True)
if self.operation is TransformOperation.ConvertIdxUnit:
col.prop(self, self.blfields['active_new_unit'], text='')
if self.operation is TransformOperation.SetIdxUnit:
col.prop(self, self.blfields['new_physical_type'], text='')
row = col.row(align=True) row = col.row(align=True)
row.prop(self, self.blfields['new_name'], text='') row.prop(self, self.blfields['new_name'], text='')
row.prop(self, self.blfields['active_new_unit'], text='') row.prop(self, self.blfields['active_new_unit'], text='')
row = col.row(align=True)
row.prop(self, self.blfields['new_physical_type'], text='')
case TO.FT1D | TO.InvFT1D:
layout.prop(self, self.blfields['active_dim'], text='')
#################### ####################
# - Compute: Func / Array # - Compute: Func / Array
#################### ####################
@events.computes_output_socket( @events.computes_output_socket(
'Expr', 'Expr',
kind=ct.FlowKind.Func, kind=ct.FlowKind.Func,
props={'operation'}, # Loaded
props={'operation', 'dim'},
input_sockets={'Expr'}, input_sockets={'Expr'},
input_socket_kinds={ input_socket_kinds={
'Expr': ct.FlowKind.Func, 'Expr': {ct.FlowKind.Func, ct.FlowKind.Info},
}, },
) )
def compute_func(self, props, input_sockets) -> ct.FuncFlow | ct.FlowSignal: def compute_func(self, props, input_sockets) -> ct.FuncFlow | ct.FlowSignal:
"""Transform the input `InfoFlow` depending on the transform operation."""
TO = TransformOperation
operation = props['operation'] operation = props['operation']
lazy_func = input_sockets['Expr'] lazy_func = input_sockets['Expr'][ct.FlowKind.Func]
info = input_sockets['Expr'][ct.FlowKind.Info]
has_info = not ct.FlowSignal.check(info)
has_lazy_func = not ct.FlowSignal.check(lazy_func) has_lazy_func = not ct.FlowSignal.check(lazy_func)
if has_lazy_func and operation is not None: if operation is not None and has_lazy_func and has_info:
# Retrieve Properties
dim = props['dim']
# Match Pattern by Operation
match operation:
case TO.FreqToVacWL | TO.VacWLToFreq | TO.FT1D | TO.InvFT1D:
if dim is not None and info.has_idx_discrete(dim):
return lazy_func.compose_within( return lazy_func.compose_within(
operation.jax_func, operation.jax_func(axis=info.dim_axis(dim)),
supports_jax=True, supports_jax=True,
) )
return ct.FlowSignal.FlowPending return ct.FlowSignal.FlowPending
case _:
return lazy_func.compose_within(
operation.jax_func(),
supports_jax=True,
)
return ct.FlowSignal.FlowPending
#################### ####################
# - FlowKind.Info # - FlowKind.Info
#################### ####################
@events.computes_output_socket( @events.computes_output_socket(
'Expr', 'Expr',
kind=ct.FlowKind.Info, kind=ct.FlowKind.Info,
# Loaded
props={'operation', 'dim', 'new_name', 'new_unit', 'new_physical_type'}, props={'operation', 'dim', 'new_name', 'new_unit', 'new_physical_type'},
input_sockets={'Expr'}, input_sockets={'Expr'},
input_socket_kinds={ input_socket_kinds={
'Expr': {ct.FlowKind.Func, ct.FlowKind.Info, ct.FlowKind.Params} 'Expr': {ct.FlowKind.Func, ct.FlowKind.Info, ct.FlowKind.Params}
}, },
) )
def compute_info( def compute_info( # noqa: PLR0911
self, props: dict, input_sockets: dict self, props: dict, input_sockets: dict
) -> ct.InfoFlow | typ.Literal[ct.FlowSignal.FlowPending]: ) -> ct.InfoFlow | typ.Literal[ct.FlowSignal.FlowPending]:
"""Transform the input `InfoFlow` depending on the transform operation."""
TO = TransformOperation
operation = props['operation'] operation = props['operation']
info = input_sockets['Expr'][ct.FlowKind.Info] info = input_sockets['Expr'][ct.FlowKind.Info]
has_info = not ct.FlowSignal.check(info) has_info = not ct.FlowSignal.check(info)
if has_info and operation is not None:
# Retrieve Properties
dim = props['dim'] dim = props['dim']
new_name = props['new_name'] new_name = props['new_name']
new_unit = props['new_unit'] new_unit = props['new_unit']
new_physical_type = props['new_physical_type'] new_physical_type = props['new_physical_type']
if has_info and operation is not None:
# First Column to First Index # Retrieve Expression Data
## -> We have to evaluate the lazy function at this point.
## -> It's the only way to get at the column data.
if operation is TransformOperation.FirstColToFirstIdx:
lazy_func = input_sockets['Expr'][ct.FlowKind.Func] lazy_func = input_sockets['Expr'][ct.FlowKind.Func]
params = input_sockets['Expr'][ct.FlowKind.Params] params = input_sockets['Expr'][ct.FlowKind.Params]
has_lazy_func = not ct.FlowSignal.check(lazy_func) has_lazy_func = not ct.FlowSignal.check(lazy_func)
has_params = not ct.FlowSignal.check(lazy_func) has_params = not ct.FlowSignal.check(lazy_func)
if has_lazy_func and has_params and not params.symbols: # Match Pattern by Operation
data = lazy_func.realize(params) match operation:
if data.shape is not None and len(data.shape) == 2: # Covariant Transform
data_col = data[:, 0] ## -> Needs: Dim, Unit
return operation.transform_info(info, data_col=data_col) case TO.ConvertIdxUnit if dim is not None and new_unit is not None:
physical_type = spux.PhysicalType.from_unit(dim.unit, optional=True)
if (
physical_type is not None
and new_unit in physical_type.valid_units
):
return operation.transform_info(info, dim=dim, unit=new_unit)
return ct.FlowSignal.FlowPending return ct.FlowSignal.FlowPending
# Check Not-Yet-Updated Dimension case TO.FreqToVacWL if dim is not None and new_unit is not None and new_unit in spux.PhysicalType.Length.valid_units:
## - Operation changes before dimensions. return operation.transform_info(info, dim=dim, unit=new_unit)
## - If InfoFlow is requested in this interim, big problem.
if dim is None and operation.num_dim_inputs > 0:
return ct.FlowSignal.FlowPending
case TO.VacWLToFreq if dim is not None and new_unit is not None and new_unit in spux.PhysicalType.Freq.valid_units:
return operation.transform_info(info, dim=dim, unit=new_unit)
## -> Needs: Dim, Unit, Physical Type
case TO.SetIdxUnit if (
dim is not None
and new_physical_type is not None
and new_unit in new_physical_type.valid_units
):
return operation.transform_info( return operation.transform_info(
info, info,
dim=dim, dim=dim,
@ -611,6 +707,39 @@ class TransformMathNode(base.MaxwellSimNode):
unit=new_unit, unit=new_unit,
physical_type=new_physical_type, physical_type=new_physical_type,
) )
## -> Needs: Data Column, Name, Unit, Physical Type
## -> We have to evaluate the lazy function at this point.
## -> It's the only way to get at the column's data.
case TO.FirstColToFirstIdx if (
has_lazy_func
and has_params
and not params.symbols
and new_name is not None
and new_physical_type is not None
and new_unit in new_physical_type.valid_units
):
data = lazy_func.realize(params)
if data.shape is not None and len(data.shape) == 2: # noqa: PLR2004
data_col = data[:, 0]
return operation.transform_info(
info,
new_dim_name=new_name,
data_col=data_col,
unit=new_unit,
physical_type=new_physical_type,
)
# Fold
## -> Needs: Nothing
case TO.IntDimToComplex | TO.DimToVec | TO.DimsToMat:
return operation.transform_info(info)
# Fourier
## -> Needs: Dimension
case TO.FT1D | TO.InvFT1D if dim is not None:
return operation.transform_info(info, dim=dim)
return ct.FlowSignal.FlowPending return ct.FlowSignal.FlowPending
#################### ####################
@ -619,30 +748,19 @@ class TransformMathNode(base.MaxwellSimNode):
@events.computes_output_socket( @events.computes_output_socket(
'Expr', 'Expr',
kind=ct.FlowKind.Params, kind=ct.FlowKind.Params,
props={'operation', 'dim'}, # Loaded
props={'operation'},
input_sockets={'Expr'}, input_sockets={'Expr'},
input_socket_kinds={'Expr': {ct.FlowKind.Params, ct.FlowKind.Info}}, input_socket_kinds={'Expr': ct.FlowKind.Params},
) )
def compute_params(self, props, input_sockets) -> ct.ParamsFlow | ct.FlowSignal: def compute_params(self, props, input_sockets) -> ct.ParamsFlow | ct.FlowSignal:
info = input_sockets['Expr'][ct.FlowKind.Info]
params = input_sockets['Expr'][ct.FlowKind.Params]
has_info = not ct.FlowSignal.check(info)
has_params = not ct.FlowSignal.check(params)
operation = props['operation'] operation = props['operation']
dim = props['dim'] params = input_sockets['Expr']
if has_info and has_params and operation is not None:
# Axis Required: Insert by-Dimension
## -> Some transformations ex. FT require setting an axis.
## -> The user selects which dimension the op should be done along.
## -> This dimension is converted to an axis integer.
## -> Finally, we pass the argument via params.
if operation.num_dim_inputs == 1:
axis = info.dim_axis(dim) if dim is not None else None
return params.compose_within(enclosing_func_args=[axis])
has_params = not ct.FlowSignal.check(params)
if has_params and operation is not None:
return params return params
return ct.FlowSignal.FlowPending return ct.FlowSignal.FlowPending

View File

@ -104,6 +104,7 @@ class VizMode(enum.StrEnum):
"""Given the input `InfoFlow`, deduce which visualization modes are valid to use with the described data.""" """Given the input `InfoFlow`, deduce which visualization modes are valid to use with the described data."""
Z = spux.MathType.Integer Z = spux.MathType.Integer
R = spux.MathType.Real R = spux.MathType.Real
C = spux.MathType.Complex
VM = VizMode VM = VizMode
return { return {
@ -115,6 +116,9 @@ class VizMode(enum.StrEnum):
VM.Points2D, VM.Points2D,
VM.Bar, VM.Bar,
], ],
((R,), (1, 1, C)): [
VM.Curve2D,
],
((R, Z), (1, 1, R)): [ ((R, Z), (1, 1, R)): [
VM.Curves2D, VM.Curves2D,
VM.FilledCurves2D, VM.FilledCurves2D,
@ -231,10 +235,15 @@ class VizNode(base.MaxwellSimNode):
## - Properties ## - Properties
##################### #####################
@events.on_value_changed( @events.on_value_changed(
# Trigger
socket_name={'Expr'}, socket_name={'Expr'},
# Loaded
input_sockets={'Expr'}, input_sockets={'Expr'},
input_socket_kinds={'Expr': ct.FlowKind.Info}, input_socket_kinds={'Expr': ct.FlowKind.Info},
input_sockets_optional={'Expr': True}, input_sockets_optional={'Expr': True},
# Flow
## -> See docs in TransformMathNode
stop_propagation=True,
) )
def on_input_exprs_changed(self, input_sockets) -> None: # noqa: D102 def on_input_exprs_changed(self, input_sockets) -> None: # noqa: D102
has_info = not ct.FlowSignal.check(input_sockets['Expr']) has_info = not ct.FlowSignal.check(input_sockets['Expr'])
@ -326,7 +335,7 @@ class VizNode(base.MaxwellSimNode):
if self.viz_target is VizTarget.Plot2D: if self.viz_target is VizTarget.Plot2D:
row = col.row(align=True) row = col.row(align=True)
row.alignment = 'CENTER' row.alignment = 'CENTER'
row.label(text='Width/Height/DPI') row.label(text='Width | Height | DPI')
row = col.row(align=True) row = col.row(align=True)
row.prop(self, self.blfields['plot_width'], text='') row.prop(self, self.blfields['plot_width'], text='')
@ -339,8 +348,10 @@ class VizNode(base.MaxwellSimNode):
# - Events # - Events
#################### ####################
@events.on_value_changed( @events.on_value_changed(
# Trigger
socket_name='Expr', socket_name='Expr',
run_on_init=True, run_on_init=True,
# Loaded
input_sockets={'Expr'}, input_sockets={'Expr'},
input_socket_kinds={'Expr': {ct.FlowKind.Info, ct.FlowKind.Params}}, input_socket_kinds={'Expr': {ct.FlowKind.Info, ct.FlowKind.Params}},
input_sockets_optional={'Expr': True}, input_sockets_optional={'Expr': True},
@ -355,14 +366,19 @@ class VizNode(base.MaxwellSimNode):
# Declare Loose Sockets that Realize Symbols # Declare Loose Sockets that Realize Symbols
## -> This happens if Params contains not-yet-realized symbols. ## -> This happens if Params contains not-yet-realized symbols.
if has_info and has_params and params.symbols: if has_info and has_params and params.symbols:
if set(self.loose_input_sockets) != { if set(self.loose_input_sockets) != {sym.name for sym in params.symbols}:
sym.name for sym in params.symbols if sym in info.dims
}:
self.loose_input_sockets = { self.loose_input_sockets = {
dim_name: sockets.ExprSocketDef(**expr_info) sym.name: sockets.ExprSocketDef(
for dim_name, expr_info in params.sym_expr_infos( **(
use_range=True expr_info
).items() | {
'active_kind': ct.FlowKind.Range
if sym in info.dims
else ct.FlowKind.Value
}
)
)
for sym, expr_info in params.sym_expr_infos.items()
} }
elif self.loose_input_sockets: elif self.loose_input_sockets:
@ -373,9 +389,10 @@ class VizNode(base.MaxwellSimNode):
##################### #####################
@events.computes_output_socket( @events.computes_output_socket(
'Preview', 'Preview',
kind=ct.FlowKind.Value, kind=ct.FlowKind.Previews,
# Loaded # Loaded
props={ props={
'sim_node_name',
'viz_mode', 'viz_mode',
'viz_target', 'viz_target',
'colormap', 'colormap',
@ -391,7 +408,7 @@ class VizNode(base.MaxwellSimNode):
) )
def compute_dummy_value(self, props, input_sockets, loose_input_sockets): def compute_dummy_value(self, props, input_sockets, loose_input_sockets):
"""Needed for the plot to regenerate in the viewer.""" """Needed for the plot to regenerate in the viewer."""
return ct.FlowSignal.NoFlow return ct.PreviewsFlow(bl_image_name=props['sim_node_name'])
##################### #####################
## - On Show Plot ## - On Show Plot
@ -416,6 +433,7 @@ class VizNode(base.MaxwellSimNode):
def on_show_plot( def on_show_plot(
self, managed_objs, props, input_sockets, loose_input_sockets self, managed_objs, props, input_sockets, loose_input_sockets
) -> None: ) -> None:
log.critical('Show Plot (too many times)')
lazy_func = input_sockets['Expr'][ct.FlowKind.Func] lazy_func = input_sockets['Expr'][ct.FlowKind.Func]
info = input_sockets['Expr'][ct.FlowKind.Info] info = input_sockets['Expr'][ct.FlowKind.Info]
params = input_sockets['Expr'][ct.FlowKind.Params] params = input_sockets['Expr'][ct.FlowKind.Params]
@ -427,23 +445,17 @@ class VizNode(base.MaxwellSimNode):
viz_mode = props['viz_mode'] viz_mode = props['viz_mode']
viz_target = props['viz_target'] viz_target = props['viz_target']
if has_info and has_params and viz_mode is not None and viz_target is not None: if has_info and has_params and viz_mode is not None and viz_target is not None:
# Realize Data w/Realized Symbols # Retrieve Data
## -> The loose input socket values are user-selected symbol values. ## -> The loose input socket values are user-selected symbol values.
## -> These expressions are used to realize the lazy data. ## -> These are used to get rid of symbols in the ParamsFlow.
## -> `.realize()` ensures all ex. units are correctly conformed. ## -> What's left is a dictionary from SimSymbol -> Data
realized_syms = { data = lazy_func.realize_as_data(
info,
params,
symbol_values={
sym: loose_input_sockets[sym.name] for sym in params.sorted_symbols sym: loose_input_sockets[sym.name] for sym in params.sorted_symbols
} },
output_data = lazy_func.realize(params, symbol_values=realized_syms)
data = {
dim: (
realized_syms[dim].values
if dim in realized_syms
else info.dims[dim]
) )
for dim in info.dims
} | {info.output: output_data}
# Match Viz Type & Perform Visualization # Match Viz Type & Perform Visualization
## -> Viz Target determines how to plot. ## -> Viz Target determines how to plot.
@ -459,7 +471,6 @@ class VizNode(base.MaxwellSimNode):
width_inches=plot_width, width_inches=plot_width,
height_inches=plot_height, height_inches=plot_height,
dpi=plot_dpi, dpi=plot_dpi,
bl_select=True,
) )
case VizTarget.Pixels: case VizTarget.Pixels:
@ -468,7 +479,6 @@ class VizNode(base.MaxwellSimNode):
plot.map_2d_to_image( plot.map_2d_to_image(
data, data,
colormap=colormap, colormap=colormap,
bl_select=True,
) )
case VizTarget.PixelsPlane: case VizTarget.PixelsPlane:

View File

@ -21,6 +21,7 @@ Attributes:
""" """
import enum import enum
import functools
import typing as typ import typing as typ
from collections import defaultdict from collections import defaultdict
from types import MappingProxyType from types import MappingProxyType
@ -62,7 +63,6 @@ class MaxwellSimNode(bpy.types.Node, bl_instance.BLInstance):
Used as a node-specific cache index. Used as a node-specific cache index.
sim_node_name: A unique human-readable name identifying the node. sim_node_name: A unique human-readable name identifying the node.
Used when naming managed objects and exporting. Used when naming managed objects and exporting.
preview_active: Whether the preview (if any) is currently active.
locked: Whether the node is currently 'locked' aka. non-editable. locked: Whether the node is currently 'locked' aka. non-editable.
""" """
@ -98,7 +98,6 @@ class MaxwellSimNode(bpy.types.Node, bl_instance.BLInstance):
loose_output_sockets: dict[str, sockets.base.SocketDef] = bl_cache.BLField({}) loose_output_sockets: dict[str, sockets.base.SocketDef] = bl_cache.BLField({})
# UI Options # UI Options
preview_active: bool = bl_cache.BLField(False)
locked: bool = bl_cache.BLField(False, use_prop_update=False) locked: bool = bl_cache.BLField(False, use_prop_update=False)
# Active Socket Set # Active Socket Set
@ -264,35 +263,6 @@ class MaxwellSimNode(bpy.types.Node, bl_instance.BLInstance):
## TODO: Account for FlowKind ## TODO: Account for FlowKind
bl_socket.value = socket_value bl_socket.value = socket_value
####################
# - Events: Preview | Plot
####################
@events.on_show_plot(stop_propagation=False)
def _on_show_plot(self):
node_tree = self.id_data
if len(self.event_methods_by_event[ct.FlowEvent.ShowPlot]) > 1:
## TODO: Is this check good enough?
## TODO: Set icon/indicator/something to make it clear which node is being previewed.
node_tree.report_show_plot(self)
@events.on_show_preview()
def _on_show_preview(self):
node_tree = self.id_data
node_tree.report_show_preview(self)
# Set Preview to Active
## Implicitly triggers any @on_value_changed for preview_active.
if not self.preview_active:
self.preview_active = True
@events.on_value_changed(
prop_name='preview_active', props={'preview_active'}, stop_propagation=True
)
def _on_preview_changed(self, props):
if not props['preview_active']:
for mobj in self.managed_objs.values():
mobj.hide_preview()
#################### ####################
# - Events: Lock # - Events: Lock
#################### ####################
@ -521,14 +491,17 @@ class MaxwellSimNode(bpy.types.Node, bl_instance.BLInstance):
return { return {
ct.FlowEvent.EnableLock: lambda *_: True, ct.FlowEvent.EnableLock: lambda *_: True,
ct.FlowEvent.DisableLock: lambda *_: True, ct.FlowEvent.DisableLock: lambda *_: True,
ct.FlowEvent.DataChanged: lambda event_method, socket_name, prop_name, _: ( ct.FlowEvent.DataChanged: lambda event_method, socket_name, prop_names, _: (
( (
socket_name socket_name
and socket_name in event_method.callback_info.on_changed_sockets and socket_name in event_method.callback_info.on_changed_sockets
) )
or ( or (
prop_name prop_names
and prop_name in event_method.callback_info.on_changed_props and any(
prop_name in event_method.callback_info.on_changed_props
for prop_name in prop_names
)
) )
or ( or (
socket_name socket_name
@ -536,6 +509,7 @@ class MaxwellSimNode(bpy.types.Node, bl_instance.BLInstance):
and socket_name in self.loose_input_sockets and socket_name in self.loose_input_sockets
) )
), ),
# Non-Triggered
ct.FlowEvent.OutputRequested: lambda output_socket_method, ct.FlowEvent.OutputRequested: lambda output_socket_method,
output_socket_name, output_socket_name,
_, _,
@ -546,7 +520,6 @@ class MaxwellSimNode(bpy.types.Node, bl_instance.BLInstance):
== output_socket_method.callback_info.output_socket_name == output_socket_method.callback_info.output_socket_name
) )
), ),
ct.FlowEvent.ShowPreview: lambda *_: True,
ct.FlowEvent.ShowPlot: lambda *_: True, ct.FlowEvent.ShowPlot: lambda *_: True,
} }
@ -595,6 +568,9 @@ class MaxwellSimNode(bpy.types.Node, bl_instance.BLInstance):
bl_socket = self.inputs.get(input_socket_name) bl_socket = self.inputs.get(input_socket_name)
if bl_socket is not None: if bl_socket is not None:
if bl_socket.instance_id: if bl_socket.instance_id:
if kind is ct.FlowKind.Previews:
return bl_socket.compute_data(kind=kind)
return ( return (
ct.FlowKind.scale_to_unit_system( ct.FlowKind.scale_to_unit_system(
kind, kind,
@ -610,12 +586,10 @@ class MaxwellSimNode(bpy.types.Node, bl_instance.BLInstance):
## -> Anyone needing results will need to wait on preinit(). ## -> Anyone needing results will need to wait on preinit().
return ct.FlowSignal.FlowInitializing return ct.FlowSignal.FlowInitializing
# if optional: if kind is ct.FlowKind.Previews:
return ct.PreviewsFlow()
return ct.FlowSignal.NoFlow return ct.FlowSignal.NoFlow
msg = f'{self.sim_node_name}: Input socket "{input_socket_name}" cannot be computed, as it is not an active input socket'
raise ValueError(msg)
#################### ####################
# - Compute Event: Output Socket # - Compute Event: Output Socket
#################### ####################
@ -638,33 +612,64 @@ class MaxwellSimNode(bpy.types.Node, bl_instance.BLInstance):
The value of the output socket, as computed by the dedicated method The value of the output socket, as computed by the dedicated method
registered using the `@computes_output_socket` decorator. registered using the `@computes_output_socket` decorator.
""" """
if self.outputs.get(output_socket_name) is None: # Previews: Aggregate All Input Sockets
if optional: ## -> All PreviewsFlows on all input sockets are combined.
return None ## -> Output Socket Methods can add additional PreviewsFlows.
if kind is ct.FlowKind.Previews:
input_previews = functools.reduce(
lambda a, b: a | b,
[
self._compute_input(
socket, kind=ct.FlowKind.Previews, unit_system=None
)
for socket in [bl_socket.name for bl_socket in self.inputs]
],
ct.PreviewsFlow(),
)
msg = f"Can't compute nonexistent output socket name {output_socket_name}, as it's not currently active" # No Output Socket: No Flow
raise RuntimeError(msg) ## -> All PreviewsFlows on all input sockets are combined.
## -> Output Socket Methods can add additional PreviewsFlows.
if self.outputs.get(output_socket_name) is None:
return ct.FlowSignal.NoFlow
output_socket_methods = self.filtered_event_methods_by_event( output_socket_methods = self.filtered_event_methods_by_event(
ct.FlowEvent.OutputRequested, ct.FlowEvent.OutputRequested,
(output_socket_name, None, kind), (output_socket_name, None, kind),
) )
# Run (=1) Method # Exactly One Output Socket Method
if output_socket_methods: ## -> All PreviewsFlows on all input sockets are combined.
## -> Output Socket Methods can add additional PreviewsFlows.
if len(output_socket_methods) == 1:
res = output_socket_methods[0](self)
# Res is PreviewsFlow: Concatenate
## -> This will add the elements within the returned PreviewsFluw.
if kind is ct.FlowKind.Previews and not ct.FlowSignal.check(res):
input_previews |= res
return res
# > One Output Socket Method: Error
if len(output_socket_methods) > 1: if len(output_socket_methods) > 1:
msg = f'More than one method found for ({output_socket_name}, {kind.value!s}.' msg = (
f'More than one method found for ({output_socket_name}, {kind.value!s}.'
)
raise RuntimeError(msg) raise RuntimeError(msg)
return output_socket_methods[0](self) if kind is ct.FlowKind.Previews:
return input_previews
# Auxiliary Fallbacks
return ct.FlowSignal.NoFlow return ct.FlowSignal.NoFlow
# 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) # - Plot
####################
def compute_plot(self):
plot_methods = self.filtered_event_methods_by_event(ct.FlowEvent.ShowPlot, ())
for plot_method in plot_methods:
plot_method(self)
#################### ####################
# - Event Trigger # - Event Trigger
@ -674,11 +679,11 @@ class MaxwellSimNode(bpy.types.Node, bl_instance.BLInstance):
method_info: events.InfoOutputRequested, method_info: events.InfoOutputRequested,
input_socket_name: ct.SocketName | None, input_socket_name: ct.SocketName | None,
input_socket_kinds: set[ct.FlowKind] | None, input_socket_kinds: set[ct.FlowKind] | None,
prop_name: str | None, prop_names: set[str] | None,
) -> bool: ) -> bool:
return ( return (
prop_name is not None prop_names is not None
and prop_name in method_info.depon_props and any(prop_name in method_info.depon_props for prop_name in prop_names)
or input_socket_name is not None or input_socket_name is not None
and ( and (
input_socket_name in method_info.depon_input_sockets input_socket_name in method_info.depon_input_sockets
@ -704,41 +709,63 @@ class MaxwellSimNode(bpy.types.Node, bl_instance.BLInstance):
) )
@bl_cache.cached_bl_property() @bl_cache.cached_bl_property()
def _dependent_outputs( def output_socket_invalidates(
self, self,
) -> dict[ ) -> dict[
tuple[ct.SocketName, ct.FlowKind], set[tuple[ct.SocketName, ct.FlowKind]] tuple[ct.SocketName, ct.FlowKind], set[tuple[ct.SocketName, ct.FlowKind]]
]: ]:
## TODO: Cleanup """Deduce which output socket | `FlowKind` combos are altered in response to a given output socket | `FlowKind` combo.
## TODO: Detect cycles?
## TODO: Networkx? Returns:
A dictionary, wher eeach key is a tuple representing an output socket name and its flow kind that has been altered, and each value is a set of tuples representing output socket names and flow kind.
Indexing by any particular `(output_socket_name, flow_kind)` will produce a set of all `{(output_socket_name, flow_kind)}` that rely on it.
"""
altered_to_invalidated = defaultdict(set) altered_to_invalidated = defaultdict(set)
# Iterate ALL Methods that Compute Output Sockets
## -> We call it the "altered method".
## -> Our approach will be to deduce what relies on it.
output_requested_methods = self.event_methods_by_event[ output_requested_methods = self.event_methods_by_event[
ct.FlowEvent.OutputRequested ct.FlowEvent.OutputRequested
] ]
for altered_method in output_requested_methods: for altered_method in output_requested_methods:
altered_info = altered_method.callback_info altered_info = altered_method.callback_info
altered_key = (altered_info.output_socket_name, altered_info.kind) altered_key = (altered_info.output_socket_name, altered_info.kind)
# Inner: Iterate ALL Methods that Compute Output Sockets
## -> We call it the "invalidated method".
## -> While O(n^2), it runs only once per-node, and is then cached.
## -> `n` is rarely so large as to be a startup-time concern.
## -> Thus, in this case, using a simple implementation is better.
for invalidated_method in output_requested_methods: for invalidated_method in output_requested_methods:
invalidated_info = invalidated_method.callback_info invalidated_info = invalidated_method.callback_info
# Check #0: Inv. Socket depends on Altered Socket
## -> Simply check if the altered name is in the dependencies.
if ( if (
altered_info.output_socket_name altered_info.output_socket_name
in invalidated_info.depon_output_sockets in invalidated_info.depon_output_sockets
): ):
# Check #2: FlowKinds Match
## -> Case 1: Single Altered Kind was Requested by Inv
## -> Case 2: Altered Kind in set[Requested Kinds] is
## -> Case 3: Altered Kind is FlowKind.Value
## This encapsulates the actual events decorator semantics.
is_same_kind = ( is_same_kind = (
altered_info.kind altered_info.kind
== ( is (
_kind := invalidated_info.depon_output_socket_kinds.get( _kind := invalidated_info.depon_output_socket_kinds.get(
altered_info.output_socket_name altered_info.output_socket_name
) )
) )
or (isinstance(_kind, set) and altered_info.kind in _kind) or (isinstance(_kind, set) and altered_info.kind in _kind)
or altered_info.kind == ct.FlowKind.Value or altered_info.kind is ct.FlowKind.Value
) )
# Check Success: Add Invalidated (name,kind) to Altered Set
## -> We've now confirmed a dependency.
## -> Thus, this name|kind should be included.
if is_same_kind: if is_same_kind:
invalidated_key = ( invalidated_key = (
invalidated_info.output_socket_name, invalidated_info.output_socket_name,
@ -753,7 +780,7 @@ class MaxwellSimNode(bpy.types.Node, bl_instance.BLInstance):
event: ct.FlowEvent, event: ct.FlowEvent,
socket_name: ct.SocketName | None = None, socket_name: ct.SocketName | None = None,
socket_kinds: set[ct.FlowKind] | None = None, socket_kinds: set[ct.FlowKind] | None = None,
prop_name: ct.SocketName | None = None, prop_names: set[str] | None = None,
) -> None: ) -> None:
"""Recursively triggers events forwards or backwards along the node tree, allowing nodes in the update path to react. """Recursively triggers events forwards or backwards along the node tree, allowing nodes in the update path to react.
@ -770,124 +797,141 @@ class MaxwellSimNode(bpy.types.Node, bl_instance.BLInstance):
socket_name: The input socket that was altered, if any, in order to trigger this event. socket_name: The input socket that was altered, if any, in order to trigger this event.
pop_name: The property that was altered, if any, in order to trigger this event. pop_name: The property that was altered, if any, in order to trigger this event.
""" """
log.debug( # log.debug(
'%s: Triggered Event %s (socket_name=%s, socket_kinds=%s, prop_name=%s)', # '[%s] [%s] Triggered (socket_name=%s, socket_kinds=%s, prop_names=%s)',
self.sim_node_name, # self.sim_node_name,
event, # event,
str(socket_name), # str(socket_name),
str(socket_kinds), # str(socket_kinds),
str(prop_name), # str(prop_names),
) # )
# Outflow Socket Kinds
## -> Something has happened!
## -> The effect is yet to be determined...
## -> We will watch for which kinds actually invalidate.
## -> ...Then ONLY propagate kinds that have an invalidated outsck.
## -> This way, kinds get "their own" invalidation chains.
## -> ...While still respecting "crossovers".
altered_socket_kinds = set()
# Invalidate Caches on DataChanged # Invalidate Caches on DataChanged
## -> socket_kinds MUST NOT be None
## -> Trigger direction is always 'forwards' for DataChanged
## -> Track which FlowKinds are actually altered per-output-socket.
altered_socket_kinds: dict[ct.SocketName, set[ct.FlowKind]] = defaultdict(set)
if event is ct.FlowEvent.DataChanged: if event is ct.FlowEvent.DataChanged:
input_socket_name = socket_name ## Trigger direction is forwards in_sckname = socket_name
# Invalidate Input Socket Cache # Clear Input Socket Cache(s)
if input_socket_name is not None: ## -> The input socket cache for each altered FlowKinds is cleared.
if socket_kinds is None: ## -> Since it's non-persistent, it will be lazily re-filled.
if in_sckname is not None:
for in_kind in socket_kinds:
# log.debug(
# '![%s] Clear Input Socket Cache (%s, %s)',
# self.sim_node_name,
# in_sckname,
# in_kind,
# )
self._compute_input.invalidate( self._compute_input.invalidate(
input_socket_name=input_socket_name, input_socket_name=in_sckname,
kind=..., kind=in_kind,
unit_system=...,
)
else:
for socket_kind in socket_kinds:
self._compute_input.invalidate(
input_socket_name=input_socket_name,
kind=socket_kind,
unit_system=..., unit_system=...,
) )
# Invalidate Output Socket Cache # Clear Output Socket Cache(s)
for output_socket_method in self.event_methods_by_event[ for output_socket_method in self.event_methods_by_event[
ct.FlowEvent.OutputRequested ct.FlowEvent.OutputRequested
]: ]:
# Determine Consequences of Changed (Socket|Kind) / Prop
## -> Each '@computes_output_socket' declares data to load.
## -> Compare what was changed to what each output socket needs.
## -> IF what is needed, was changed, THEN:
## --- The output socket needs recomputing.
method_info = output_socket_method.callback_info method_info = output_socket_method.callback_info
if self._should_recompute_output_socket( if self._should_recompute_output_socket(
method_info, socket_name, socket_kinds, prop_name method_info, socket_name, socket_kinds, prop_names
): ):
out_sckname = method_info.output_socket_name out_sckname = method_info.output_socket_name
kind = method_info.kind out_kind = method_info.kind
# Invalidate Output Directly # log.debug(
# log.critical( # '![%s] Clear Output Socket Cache (%s, %s)',
# '[%s] Invalidating: (%s, %s)',
# self.sim_node_name, # self.sim_node_name,
# out_sckname, # out_sckname,
# str(kind), # out_kind,
# ) # )
altered_socket_kinds.add(kind)
self.compute_output.invalidate( self.compute_output.invalidate(
output_socket_name=out_sckname, output_socket_name=out_sckname,
kind=kind, kind=out_kind,
) )
altered_socket_kinds[out_sckname].add(out_kind)
# Invalidate Any Dependent Outputs # Invalidate Dependent Output Sockets
if ( ## -> Other outscks may depend on the altered outsck.
dep_outs := self._dependent_outputs.get((out_sckname, kind)) ## -> The property 'output_socket_invalidates' encodes this.
) is not None: ## -> The property 'output_socket_invalidates' encodes this.
for dep_out in dep_outs: cleared_outscks_kinds = self.output_socket_invalidates.get(
# log.critical( (out_sckname, out_kind)
# '![%s] Invalidating: (%s, %s)',
# self.sim_node_name,
# dep_out[0],
# dep_out[1],
# )
altered_socket_kinds.add(dep_out[1])
self.compute_output.invalidate(
output_socket_name=dep_out[0],
kind=dep_out[1],
) )
if cleared_outscks_kinds is not None:
for dep_out_sckname, dep_out_kind in cleared_outscks_kinds:
# log.debug(
# '!![%s] Clear Output Socket Cache (%s, %s)',
# self.sim_node_name,
# out_sckname,
# out_kind,
# )
self.compute_output.invalidate(
output_socket_name=dep_out_sckname,
kind=dep_out_kind,
)
altered_socket_kinds[dep_out_sckname].add(dep_out_kind)
# Run Triggered Event Methods # Run Triggered Event Methods
## -> A triggered event method may request to stop propagation.
## -> A triggered event method may request to stop propagation.
stop_propagation = False stop_propagation = False
triggered_event_methods = self.filtered_event_methods_by_event( triggered_event_methods = self.filtered_event_methods_by_event(
event, (socket_name, prop_name, None) event, (socket_name, prop_names, None)
) )
for event_method in triggered_event_methods: for event_method in triggered_event_methods:
stop_propagation |= event_method.stop_propagation stop_propagation |= event_method.stop_propagation
# log.critical( # log.debug(
# '%s: Running %s', # '![%s] Running: %s',
# self.sim_node_name, # self.sim_node_name,
# str(event_method.callback_info), # str(event_method.callback_info),
# ) # )
event_method(self) event_method(self)
# DataChanged Propagation Stop: No Altered Socket Kinds # Propagate Event
## -> If no FlowKinds were altered, then propagation makes no sense. ## -> If 'stop_propagation' was tripped, don't propagate.
## -> Semantically, **nothing has changed** == no DataChanged! ## -> If no sockets were altered during DataChanged, don't propagate.
if event is ct.FlowEvent.DataChanged and not altered_socket_kinds: ## -> Each FlowEvent decides whether to flow forwards/backwards.
return
# Constrain ShowPlot to First Node: Workaround
if event is ct.FlowEvent.ShowPlot:
return
# Propagate Event to All Sockets in "Trigger Direction"
## -> The trigger chain goes node/socket/socket/node/socket/... ## -> The trigger chain goes node/socket/socket/node/socket/...
## -> Unlinked sockets naturally stop the propagation.
if not stop_propagation: if not stop_propagation:
direc = ct.FlowEvent.flow_direction[event] direc = ct.FlowEvent.flow_direction[event]
triggered_sockets = self._bl_sockets(direc=direc) for bl_socket in self._bl_sockets(direc=direc):
for bl_socket in triggered_sockets: # DataChanged: Propagate Altered SocketKinds
if direc == 'output' and not bl_socket.is_linked: ## -> Only altered FlowKinds for the socket will propagate.
continue ## -> In this way, we guarantee no extraneous (noop) flow.
if event is ct.FlowEvent.DataChanged:
# log.critical( if bl_socket.name in altered_socket_kinds:
# '![%s] Propagating: (%s, %s)', # log.debug(
# '![%s] [%s] Propagating (direction=%s, altered_socket_kinds=%s)',
# self.sim_node_name, # self.sim_node_name,
# event, # event,
# altered_socket_kinds, # direc,
# altered_socket_kinds[bl_socket.name],
# ) # )
bl_socket.trigger_event(event, socket_kinds=altered_socket_kinds) bl_socket.trigger_event(
event, socket_kinds=altered_socket_kinds[bl_socket.name]
)
## -> Otherwise, do nothing - guarantee no extraneous flow.
# Propagate Normally
else:
# log.debug(
# '![%s] [%s] Propagating (direction=%s)',
# self.sim_node_name,
# event,
# direc,
# )
bl_socket.trigger_event(event)
#################### ####################
# - Property Event: On Update # - Property Event: On Update
@ -903,18 +947,22 @@ class MaxwellSimNode(bpy.types.Node, bl_instance.BLInstance):
Parameters: Parameters:
prop_name: The name of the property that changed. prop_name: The name of the property that changed.
""" """
# All Attributes: Trigger Event
## -> This declares that the single property has changed.
## -> This should happen first, in case dependents need a cache.
if hasattr(self, prop_name):
self.trigger_event(ct.FlowEvent.DataChanged, prop_name=prop_name)
# BLField Attributes: Invalidate BLField Dependents # BLField Attributes: Invalidate BLField Dependents
## -> Dependent props will generally also trigger on_prop_changed. ## -> All invalidated blfields will have their caches cleared.
## -> The recursion ends with the depschain. ## -> The (topologically) ordered list of cleared blfields is returned.
## -> WARNING: The chain is not checked for ex. cycles. ## -> WARNING: The chain is not checked for ex. cycles.
if prop_name in self.blfields: if prop_name in self.blfields:
self.invalidate_blfield_deps(prop_name) cleared_blfields = self.clear_blfields_after(prop_name)
# log.debug(
# '%s (Node): Set of Cleared BLFields: %s',
# self.bl_label,
# str(cleared_blfields),
# )
self.trigger_event(
ct.FlowEvent.DataChanged,
prop_names={prop_name for prop_name, _ in cleared_blfields},
)
#################### ####################
# - UI Methods # - UI Methods

View File

@ -18,15 +18,18 @@ from . import (
blender_constant, blender_constant,
expr_constant, expr_constant,
scientific_constant, scientific_constant,
symbol_constant,
) )
BL_REGISTER = [ BL_REGISTER = [
*expr_constant.BL_REGISTER, *expr_constant.BL_REGISTER,
*symbol_constant.BL_REGISTER,
*scientific_constant.BL_REGISTER, *scientific_constant.BL_REGISTER,
*blender_constant.BL_REGISTER, *blender_constant.BL_REGISTER,
] ]
BL_NODES = { BL_NODES = {
**expr_constant.BL_NODES, **expr_constant.BL_NODES,
**symbol_constant.BL_NODES,
**scientific_constant.BL_NODES, **scientific_constant.BL_NODES,
**blender_constant.BL_NODES, **blender_constant.BL_NODES,
} }

View File

@ -76,13 +76,13 @@ class ScientificConstantNode(base.MaxwellSimNode):
"""Retrieve a symbol for the scientific constant.""" """Retrieve a symbol for the scientific constant."""
if self.sci_constant is not None and self.sci_constant_info is not None: if self.sci_constant is not None and self.sci_constant_info is not None:
unit = self.sci_constant_info['units'] unit = self.sci_constant_info['units']
return sim_symbols.SimSymbol( return sim_symbols.SimSymbol.from_expr(
sym_name=self.sci_constant_name, self.sci_constant_name,
mathtype=spux.MathType.from_expr(self.sci_constant), self.sci_constant,
# physical_type= ## TODO: Formalize unit w/o physical_type unit,
unit=unit,
is_constant=True, is_constant=True,
) )
return None return None
#################### ####################
@ -125,7 +125,7 @@ class ScientificConstantNode(base.MaxwellSimNode):
if self.sci_constant_info: if self.sci_constant_info:
row = _col.row(align=True) row = _col.row(align=True)
# row.alignment = 'CENTER' # row.alignment = 'CENTER'
row.label(text=f'{self.sci_constant_info["units"]}') row.label(text=f'{spux.sp_to_str(self.sci_constant_info["units"].n(4))}')
row = _col.row(align=True) row = _col.row(align=True)
# row.alignment = 'CENTER' # row.alignment = 'CENTER'
@ -184,13 +184,18 @@ class ScientificConstantNode(base.MaxwellSimNode):
@events.computes_output_socket( @events.computes_output_socket(
'Expr', 'Expr',
kind=ct.FlowKind.Params, kind=ct.FlowKind.Params,
props={'sci_constant'}, props={'sci_constant', 'sci_constant_sym'},
) )
def compute_params(self, props: dict) -> typ.Any: def compute_params(self, props: dict) -> typ.Any:
sci_constant = props['sci_constant'] sci_constant = props['sci_constant']
sci_constant_sym = props['sci_constant_sym']
if sci_constant is not None: if sci_constant is not None and sci_constant_sym is not None:
return ct.ParamsFlow(func_args=[sci_constant]) return ct.ParamsFlow(
arg_targets=[sci_constant_sym],
func_args=[sci_constant],
is_differentiable=True,
)
return ct.FlowSignal.FlowPending return ct.FlowSignal.FlowPending

View File

@ -0,0 +1,266 @@
# blender_maxwell
# Copyright (C) 2024 blender_maxwell Project Contributors
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import enum
import typing as typ
import bpy
import sympy as sp
from blender_maxwell.utils import bl_cache, logger, sim_symbols
from blender_maxwell.utils import extra_sympy_units as spux
from .... import contracts as ct
from .... import sockets
from ... import base, events
log = logger.get(__name__)
class SymbolConstantNode(base.MaxwellSimNode):
node_type = ct.NodeType.SymbolConstant
bl_label = 'Symbol'
input_sockets: typ.ClassVar = {}
output_sockets: typ.ClassVar = {
'Expr': sockets.ExprSocketDef(
active_kind=ct.FlowKind.Func,
show_info_columns=True,
),
}
####################
# - Socket Interface
####################
sym_name: sim_symbols.SimSymbolName = bl_cache.BLField(
sim_symbols.SimSymbolName.Constant
)
size: spux.NumberSize1D = bl_cache.BLField(spux.NumberSize1D.Scalar)
mathtype: spux.MathType = bl_cache.BLField(spux.MathType.Real)
physical_type: spux.PhysicalType = bl_cache.BLField(spux.PhysicalType.NonPhysical)
####################
# - Properties: Unit
####################
active_unit: enum.StrEnum = bl_cache.BLField(
enum_cb=lambda self, _: self.search_valid_units(),
cb_depends_on={'physical_type'},
)
def search_valid_units(self) -> list[ct.BLEnumElement]:
"""Compute Blender enum elements of valid units for the current `physical_type`."""
if self.physical_type is not spux.PhysicalType.NonPhysical:
return [
(sp.sstr(unit), spux.sp_to_str(unit), sp.sstr(unit), '', i)
for i, unit in enumerate(self.physical_type.valid_units)
]
return []
@bl_cache.cached_bl_property(depends_on={'active_unit'})
def unit(self) -> spux.Unit | None:
"""Gets the current active unit.
Returns:
The current active `sympy` unit.
If the socket expression is unitless, this returns `None`.
"""
if self.active_unit is not None:
return spux.unit_str_to_unit(self.active_unit)
return None
@property
def unit_factor(self) -> spux.Unit | None:
"""Like `self.unit`, except `1` instead of `None` when unitless."""
return sp.Integer(1) if self.unit is None else self.unit
####################
# - Domain
####################
interval_finite_z: tuple[int, int] = bl_cache.BLField((0, 1))
interval_finite_q: tuple[tuple[int, int], tuple[int, int]] = bl_cache.BLField(
((0, 1), (1, 1))
)
interval_finite_re: tuple[float, float] = bl_cache.BLField((0.0, 1.0))
interval_inf: tuple[bool, bool] = bl_cache.BLField((True, True))
interval_closed: tuple[bool, bool] = bl_cache.BLField((True, True))
interval_finite_im: tuple[float, float] = bl_cache.BLField((0.0, 1.0))
interval_inf_im: tuple[bool, bool] = bl_cache.BLField((True, True))
interval_closed_im: tuple[bool, bool] = bl_cache.BLField((True, True))
####################
# - Computed Properties
####################
@bl_cache.cached_bl_property(
depends_on={
'sym_name',
'size',
'mathtype',
'physical_type',
'unit',
'interval_finite_z',
'interval_finite_q',
'interval_finite_re',
'interval_inf',
'interval_closed',
'interval_finite_im',
'interval_inf_im',
'interval_closed_im',
}
)
def symbol(self) -> sim_symbols.SimSymbol:
return sim_symbols.SimSymbol(
sym_name=self.sym_name,
mathtype=self.mathtype,
physical_type=self.physical_type,
unit=self.unit,
rows=self.size.rows,
cols=self.size.cols,
interval_finite_z=self.interval_finite_z,
interval_finite_q=self.interval_finite_q,
interval_finite_re=self.interval_finite_re,
interval_inf=self.interval_inf,
interval_closed=self.interval_closed,
interval_finite_im=self.interval_finite_im,
interval_inf_im=self.interval_inf_im,
interval_closed_im=self.interval_closed_im,
)
####################
# - UI
####################
def draw_label(self):
return self.symbol.def_label
def draw_props(self, _: bpy.types.Context, col: bpy.types.UILayout) -> None:
col.prop(self, self.blfields['sym_name'], text='')
row = col.row(align=True)
row.alignment = 'CENTER'
row.label(text='Assumptions')
row = col.row(align=True)
row.prop(self, self.blfields['mathtype'], text='')
row = col.row(align=True)
row.prop(self, self.blfields['size'], text='')
row.prop(self, self.blfields['active_unit'], text='')
col.prop(self, self.blfields['physical_type'], text='')
row = col.row(align=True)
row.alignment = 'CENTER'
row.label(text='Domain')
match self.mathtype:
case spux.MathType.Integer:
col.prop(self, self.blfields['interval_finite_z'], text='')
col.prop(self, self.blfields['interval_inf'], text='Infinite')
col.prop(self, self.blfields['interval_closed'], text='Closed')
case spux.MathType.Rational:
col.prop(self, self.blfields['interval_finite_q'], text='')
col.prop(self, self.blfields['interval_inf'], text='Infinite')
col.prop(self, self.blfields['interval_closed'], text='Closed')
case spux.MathType.Real:
col.prop(self, self.blfields['interval_finite_re'], text='')
col.prop(self, self.blfields['interval_inf'], text='Infinite')
col.prop(self, self.blfields['interval_closed'], text='Closed')
case spux.MathType.Complex:
col.prop(self, self.blfields['interval_finite_re'], text='')
col.prop(self, self.blfields['interval_inf'], text=' Infinite')
col.prop(self, self.blfields['interval_closed'], text=' Closed')
col.separator()
col.prop(self, self.blfields['interval_finite_im'], text='𝕀')
col.prop(self, self.blfields['interval_inf'], text='𝕀 Infinite')
col.prop(self, self.blfields['interval_closed'], text='𝕀 Closed')
####################
# - FlowKinds
####################
@events.computes_output_socket(
# Trigger
'Expr',
kind=ct.FlowKind.Value,
# Loaded
props={'symbol'},
)
def compute_value(self, props) -> typ.Any:
return props['symbol'].sp_symbol
@events.computes_output_socket(
# Trigger
'Expr',
kind=ct.FlowKind.Func,
# Loaded
props={'symbol'},
)
def compute_lazy_func(self, props) -> typ.Any:
sp_sym = props['symbol'].sp_symbol
return ct.FuncFlow(
func=sp.lambdify(sp_sym, sp_sym, 'jax'),
func_args=[sp_sym],
supports_jax=True,
)
####################
# - FlowKinds: Auxiliary
####################
@events.computes_output_socket(
# Trigger
'Expr',
kind=ct.FlowKind.Info,
# Loaded
props={'symbol'},
)
def compute_info(self, props) -> typ.Any:
return ct.InfoFlow(
output=props['symbol'],
)
@events.computes_output_socket(
# Trigger
'Expr',
kind=ct.FlowKind.Params,
# Loaded
props={'symbol'},
)
def compute_params(self, props) -> typ.Any:
sym = props['symbol']
return ct.ParamsFlow(
arg_targets=[sym],
func_args=[sym.sp_symbol],
symbols={sym},
is_differentiable=(
sym.mathtype in [spux.MathType.Real, spux.MathType.Complex]
),
)
####################
# - Blender Registration
####################
BL_REGISTER = [
SymbolConstantNode,
]
BL_NODES = {ct.NodeType.SymbolConstant: (ct.NodeCategory.MAXWELLSIM_INPUTS_CONSTANTS)}

View File

@ -50,10 +50,15 @@ class DataFileImporterNode(base.MaxwellSimNode):
# - Properties # - Properties
#################### ####################
@events.on_value_changed( @events.on_value_changed(
# Trigger
socket_name={'File Path'}, socket_name={'File Path'},
# Loaded
input_sockets={'File Path'}, input_sockets={'File Path'},
input_socket_kinds={'File Path': ct.FlowKind.Value}, input_socket_kinds={'File Path': ct.FlowKind.Value},
input_sockets_optional={'File Path': True}, input_sockets_optional={'File Path': True},
# Flow
## -> See docs in TransformMathNode
stop_propagation=True,
) )
def on_input_exprs_changed(self, input_sockets) -> None: # noqa: D102 def on_input_exprs_changed(self, input_sockets) -> None: # noqa: D102
has_file_path = not ct.FlowSignal.check(input_sockets['File Path']) has_file_path = not ct.FlowSignal.check(input_sockets['File Path'])
@ -83,7 +88,15 @@ class DataFileImporterNode(base.MaxwellSimNode):
#################### ####################
# - Output Info # - Output Info
#################### ####################
@bl_cache.cached_bl_property(depends_on={'file_path'}) @bl_cache.cached_bl_property(
depends_on={
'output_name',
'output_mathtype',
'output_physical_type',
'output_unit',
}
| {f'dim_{i}_name' for i in range(6)}
)
def expr_info(self) -> ct.InfoFlow | None: def expr_info(self) -> ct.InfoFlow | None:
"""Retrieve the output expression's `InfoFlow`.""" """Retrieve the output expression's `InfoFlow`."""
info = self.compute_output('Expr', kind=ct.FlowKind.Info) info = self.compute_output('Expr', kind=ct.FlowKind.Info)
@ -184,19 +197,19 @@ class DataFileImporterNode(base.MaxwellSimNode):
@events.computes_output_socket( @events.computes_output_socket(
'Expr', 'Expr',
kind=ct.FlowKind.Func, kind=ct.FlowKind.Func,
# Loaded
input_sockets={'File Path'}, input_sockets={'File Path'},
) )
def compute_func(self, input_sockets: dict) -> td.Simulation: def compute_func(self, input_sockets) -> td.Simulation:
"""Declare a lazy, composable function that returns the loaded data. """Declare a lazy, composable function that returns the loaded data.
Returns: Returns:
A completely empty `ParamsFlow`, ready to be composed. A completely empty `ParamsFlow`, ready to be composed.
""" """
file_path = input_sockets['File Path'] file_path = input_sockets['File Path']
has_file_path = not ct.FlowSignal.check(file_path)
has_file_path = not ct.FlowSignal.check(input_sockets['File Path']) if has_file_path and file_path is not None:
if has_file_path:
data_file_format = ct.DataFileFormat.from_path(file_path) data_file_format = ct.DataFileFormat.from_path(file_path)
if data_file_format is not None: if data_file_format is not None:
# Jax Compatibility: Lazy Data Loading # Jax Compatibility: Lazy Data Loading

View File

@ -195,18 +195,23 @@ class EHFieldMonitorNode(base.MaxwellSimNode):
#################### ####################
# - Preview # - Preview
#################### ####################
@events.on_value_changed( @events.computes_output_socket(
# Trigger 'Time Monitor',
prop_name='preview_active', kind=ct.FlowKind.Previews,
# Loaded # Loaded
managed_objs={'modifier'}, props={'sim_node_name'},
props={'preview_active'},
) )
def on_preview_changed(self, managed_objs, props): def compute_previews_time(self, props):
if props['preview_active']: return ct.PreviewsFlow(bl_object_names={props['sim_node_name']})
managed_objs['modifier'].show_preview()
else: @events.computes_output_socket(
managed_objs['modifier'].hide_preview() 'Freq Monitor',
kind=ct.FlowKind.Previews,
# Loaded
props={'sim_node_name'},
)
def compute_previews_freq(self, props):
return ct.PreviewsFlow(bl_object_names={props['sim_node_name']})
@events.on_value_changed( @events.on_value_changed(
# Trigger # Trigger

View File

@ -170,18 +170,23 @@ class PowerFluxMonitorNode(base.MaxwellSimNode):
#################### ####################
# - Preview - Changes to Input Sockets # - Preview - Changes to Input Sockets
#################### ####################
@events.on_value_changed( @events.computes_output_socket(
# Trigger 'Time Monitor',
prop_name='preview_active', kind=ct.FlowKind.Previews,
# Loaded # Loaded
managed_objs={'modifier'}, props={'sim_node_name'},
props={'preview_active'},
) )
def on_preview_changed(self, managed_objs, props): def compute_previews_time(self, props):
if props['preview_active']: return ct.PreviewsFlow(bl_object_names={props['sim_node_name']})
managed_objs['modifier'].show_preview()
else: @events.computes_output_socket(
managed_objs['modifier'].hide_preview() 'Freq Monitor',
kind=ct.FlowKind.Previews,
# Loaded
props={'sim_node_name'},
)
def compute_previews_freq(self, props):
return ct.PreviewsFlow(bl_object_names={props['sim_node_name']})
@events.on_value_changed( @events.on_value_changed(
# Trigger # Trigger

View File

@ -119,18 +119,14 @@ class PermittivityMonitorNode(base.MaxwellSimNode):
#################### ####################
# - Preview # - Preview
#################### ####################
@events.on_value_changed( @events.computes_output_socket(
# Trigger 'Permittivity Monitor',
prop_name='preview_active', kind=ct.FlowKind.Previews,
# Loaded # Loaded
managed_objs={'modifier'}, props={'sim_node_name'},
props={'preview_active'},
) )
def on_preview_changed(self, managed_objs, props): def compute_previews_freq(self, props):
if props['preview_active']: return ct.PreviewsFlow(bl_object_names={props['sim_node_name']})
managed_objs['modifier'].show_preview()
else:
managed_objs['modifier'].hide_preview()
@events.on_value_changed( @events.on_value_changed(
# Trigger # Trigger

View File

@ -232,8 +232,17 @@ class DataFileExporterNode(base.MaxwellSimNode):
dim.name for dim in params.symbols if dim in info.dims dim.name for dim in params.symbols if dim in info.dims
}: }:
self.loose_input_sockets = { self.loose_input_sockets = {
dim_name: sockets.ExprSocketDef(**expr_info) sym.name: sockets.ExprSocketDef(
for dim_name, expr_info in params.sym_expr_infos(info).items() **(
expr_info
| {
'active_kind': ct.FlowKind.Range
if sym in info.dims
else ct.FlowKind.Value
}
)
)
for sym, expr_info in params.sym_expr_infos.items()
} }
elif self.loose_input_sockets: elif self.loose_input_sockets:

View File

@ -18,6 +18,7 @@ import typing as typ
import bpy import bpy
import sympy as sp import sympy as sp
import tidy3d as td
from blender_maxwell.utils import bl_cache, logger from blender_maxwell.utils import bl_cache, logger
from blender_maxwell.utils import extra_sympy_units as spux from blender_maxwell.utils import extra_sympy_units as spux
@ -88,32 +89,79 @@ class ViewerNode(base.MaxwellSimNode):
socket_name='Any', socket_name='Any',
) )
def on_input_changed(self) -> None: def on_input_changed(self) -> None:
self.input_flow = bl_cache.Signal.InvalidateCache """Lightweight invalidator, which invalidates the more specific `cached_bl_property` used to determine when something ex. plot-related has changed.
@bl_cache.cached_bl_property()
def input_flow(self) -> dict[ct.FlowKind, typ.Any | None]:
input_flow = {}
Calls `get_flow`, which will be called again when regenerating the `cached_bl_property`s.
This **does not** call the flow twice, as `self._compute_input()` will be cached the first time.
"""
for flow_kind in list(ct.FlowKind): for flow_kind in list(ct.FlowKind):
flow = self.get_flow(
flow_kind, always_load=flow_kind is ct.FlowKind.Previews
)
if flow is not None:
setattr(
self,
'input_' + flow_kind.property_name,
bl_cache.Signal.InvalidateCache,
)
@bl_cache.cached_bl_property(depends_on={'auto_expr'})
def input_capabilities(self) -> ct.CapabilitiesFlow | None:
return self.get_flow(ct.FlowKind.Capabilities)
@bl_cache.cached_bl_property(depends_on={'auto_expr'})
def input_previews(self) -> ct.PreviewsFlow | None:
return self.get_flow(ct.FlowKind.Previews, always_load=True)
@bl_cache.cached_bl_property(depends_on={'auto_expr'})
def input_value(self) -> ct.ValueFlow | None:
return self.get_flow(ct.FlowKind.Value)
@bl_cache.cached_bl_property(depends_on={'auto_expr'})
def input_array(self) -> ct.ArrayFlow | None:
return self.get_flow(ct.FlowKind.Array)
@bl_cache.cached_bl_property(depends_on={'auto_expr'})
def input_lazy_range(self) -> ct.RangeFlow | None:
return self.get_flow(ct.FlowKind.Range)
@bl_cache.cached_bl_property(depends_on={'auto_expr'})
def input_lazy_func(self) -> ct.FuncFlow | None:
return self.get_flow(ct.FlowKind.Func)
@bl_cache.cached_bl_property(depends_on={'auto_expr'})
def input_params(self) -> ct.ParamsFlow | None:
return self.get_flow(ct.FlowKind.Params)
@bl_cache.cached_bl_property(depends_on={'auto_expr'})
def input_info(self) -> ct.InfoFlow | None:
return self.get_flow(ct.FlowKind.Info)
def get_flow(
self, flow_kind: ct.FlowKind, always_load: bool = False
) -> typ.Any | None:
"""Generic interface to simplify getting `FlowKind` properties on the viewer node."""
if self.auto_expr or always_load:
flow = self._compute_input('Any', kind=flow_kind) flow = self._compute_input('Any', kind=flow_kind)
has_flow = not ct.FlowSignal.check(flow) has_flow = not ct.FlowSignal.check(flow)
if has_flow: if has_flow:
input_flow |= {flow_kind: flow} return flow
else: return None
input_flow |= {flow_kind: None} return None
return input_flow
#################### ####################
# - Property: Input Expression String Lines # - Property: Input Expression String Lines
#################### ####################
@bl_cache.cached_bl_property(depends_on={'input_flow'}) @bl_cache.cached_bl_property(depends_on={'input_value'})
def input_expr_str_entries(self) -> list[list[str]] | None: def input_expr_str_entries(self) -> list[list[str]] | None:
value = self.input_flow.get(ct.FlowKind.Value) value = self.input_value
if value is None:
return None
# Parse SympyType
def sp_pretty(v: spux.SympyExpr) -> spux.SympyExpr: def sp_pretty(v: spux.SympyExpr) -> spux.SympyExpr:
## sp.pretty makes new lines and wreaks havoc. ## -> The real sp.pretty makes new lines and wreaks havoc.
return spux.sp_to_str(v.n(4)) return spux.sp_to_str(v.n(4))
if isinstance(value, spux.SympyType): if isinstance(value, spux.SympyType):
@ -124,6 +172,25 @@ class ViewerNode(base.MaxwellSimNode):
] ]
return [[sp_pretty(value)]] return [[sp_pretty(value)]]
# Parse Tidy3D Types
if isinstance(value, td.Structure):
return [
[str(key), str(value)]
for key, value in dict(value).items()
if key not in ['type', 'geometry', 'medium']
] + [
[str(key), str(value)]
for key, value in dict(value.geometry).items()
if key != 'type'
]
if isinstance(value, td.components.base.Tidy3dBaseModel):
return [
[str(key), str(value)]
for key, value in dict(value).items()
if key != 'type'
]
return None return None
#################### ####################
@ -132,12 +199,12 @@ class ViewerNode(base.MaxwellSimNode):
def draw_props(self, _: bpy.types.Context, layout: bpy.types.UILayout): def draw_props(self, _: bpy.types.Context, layout: bpy.types.UILayout):
row = layout.row(align=True) row = layout.row(align=True)
# Automatic Expression Printing
row.prop(self, self.blfields['auto_expr'], text='Live', toggle=True)
# Debug Mode On/Off # Debug Mode On/Off
row.prop(self, self.blfields['debug_mode'], text='Debug', toggle=True) row.prop(self, self.blfields['debug_mode'], text='Debug', toggle=True)
# Automatic Expression Printing
row.prop(self, self.blfields['auto_expr'], text='Expr', toggle=True)
# Debug Mode Operators # Debug Mode Operators
if self.debug_mode: if self.debug_mode:
layout.prop(self, self.blfields['console_print_kind'], text='') layout.prop(self, self.blfields['console_print_kind'], text='')
@ -210,47 +277,47 @@ class ViewerNode(base.MaxwellSimNode):
# - Methods # - Methods
#################### ####################
def print_data_to_console(self): def print_data_to_console(self):
if not self.inputs['Any'].is_linked: flow = self._compute_input('Any', kind=self.console_print_kind)
return
log.info('Printing to Console') log.info('Printing to Console')
data = self._compute_input('Any', kind=self.console_print_kind, optional=True) if isinstance(flow, spux.SympyType):
console.print(sp.pretty(flow, use_unicode=True))
if isinstance(data, spux.SympyType):
console.print(sp.pretty(data, use_unicode=True))
else: else:
console.print(data) console.print(flow)
#################### ####################
# - Event Methods # - Event Methods
#################### ####################
@events.on_value_changed( @events.on_value_changed(
socket_name='Any', # Trigger
prop_name='auto_plot', prop_name={'input_previews', 'auto_plot'},
props={'auto_plot'}, # Loaded
props={'input_previews', 'auto_plot'},
) )
def on_changed_plot_preview(self, props): def on_changed_plot_preview(self, props):
node_tree = self.id_data previews = props['input_previews']
if previews is not None:
if props['auto_plot']:
bl_socket = self.inputs['Any']
if bl_socket.is_linked:
bl_socket.links[0].from_node.compute_plot()
# Unset Plot if Nothing Plotted previews.update_image_preview()
with node_tree.replot(): else:
if props['auto_plot'] and self.inputs['Any'].is_linked: ct.PreviewsFlow.hide_image_preview()
self.inputs['Any'].links[0].from_socket.node.trigger_event(
ct.FlowEvent.ShowPlot
)
@events.on_value_changed( @events.on_value_changed(
socket_name='Any', # Trigger
prop_name='auto_3d_preview', prop_name={'input_previews', 'auto_3d_preview'},
props={'auto_3d_preview'}, # Loaded
props={'input_previews', 'auto_3d_preview'},
) )
def on_changed_3d_preview(self, props): def on_changed_3d_preview(self, props):
node_tree = self.id_data previews = props['input_previews']
if previews is not None and props['auto_3d_preview']:
# Remove Non-Repreviewed Previews on Close previews.update_bl_object_previews()
with node_tree.repreview_all(): else:
if props['auto_3d_preview']: ct.PreviewsFlow.hide_bl_object_previews()
self.trigger_event(ct.FlowEvent.ShowPreview)
#################### ####################

View File

@ -64,20 +64,22 @@ class FDTDSimNode(base.MaxwellSimNode):
}, },
) )
def compute_fdtd_sim(self, input_sockets: dict) -> sp.Expr: def compute_fdtd_sim(self, input_sockets: dict) -> sp.Expr:
## TODO: Visualize the boundary conditions on top of the sim domain if any(ct.FlowSignal.check(inp) for inp in input_sockets):
return ct.FlowSignal.FlowPending
sim_domain = input_sockets['Domain'] sim_domain = input_sockets['Domain']
sources = input_sockets['Sources'] sources = input_sockets['Sources']
structures = input_sockets['Structures'] structures = input_sockets['Structures']
bounds = input_sockets['BCs'] bounds = input_sockets['BCs']
monitors = input_sockets['Monitors'] monitors = input_sockets['Monitors']
return td.Simulation( return td.Simulation(
**sim_domain, ## run_time=, size=, grid=, medium= **sim_domain,
structures=structures, structures=structures,
sources=sources, sources=sources,
monitors=monitors, monitors=monitors,
boundary_spec=bounds, boundary_spec=bounds,
) )
## TODO: Visualize the boundary conditions on top of the sim domain
#################### ####################

View File

@ -93,18 +93,14 @@ class SimDomainNode(base.MaxwellSimNode):
#################### ####################
# - Preview # - Preview
#################### ####################
@events.on_value_changed( @events.computes_output_socket(
# Trigger 'Domain',
prop_name='preview_active', kind=ct.FlowKind.Previews,
# Loaded # Loaded
managed_objs={'modifier'}, props={'sim_node_name'},
props={'preview_active'},
) )
def on_preview_changed(self, managed_objs, props): def compute_previews(self, props):
if props['preview_active']: return ct.PreviewsFlow(bl_object_names={props['sim_node_name']})
managed_objs['modifier'].show_preview()
else:
managed_objs['modifier'].hide_preview()
@events.on_value_changed( @events.on_value_changed(
## Trigger ## Trigger

View File

@ -165,18 +165,14 @@ class GaussianBeamSourceNode(base.MaxwellSimNode):
#################### ####################
# - Preview - Changes to Input Sockets # - Preview - Changes to Input Sockets
#################### ####################
@events.on_value_changed( @events.computes_output_socket(
# Trigger 'Angled Source',
prop_name='preview_active', kind=ct.FlowKind.Previews,
# Loaded # Loaded
managed_objs={'modifier'}, props={'sim_node_name'},
props={'preview_active'},
) )
def on_preview_changed(self, managed_objs, props): def compute_previews(self, props):
if props['preview_active']: return ct.PreviewsFlow(bl_object_names={props['sim_node_name']})
managed_objs['modifier'].show_preview()
else:
managed_objs['modifier'].hide_preview()
@events.on_value_changed( @events.on_value_changed(
# Trigger # Trigger

View File

@ -129,18 +129,14 @@ class PlaneWaveSourceNode(base.MaxwellSimNode):
#################### ####################
# - Preview - Changes to Input Sockets # - Preview - Changes to Input Sockets
#################### ####################
@events.on_value_changed( @events.computes_output_socket(
# Trigger 'Angled Source',
prop_name='preview_active', kind=ct.FlowKind.Previews,
# Loaded # Loaded
managed_objs={'modifier'}, props={'sim_node_name'},
props={'preview_active'},
) )
def on_preview_changed(self, managed_objs, props): def compute_previews(self, props):
if props['preview_active']: return ct.PreviewsFlow(bl_object_names={props['sim_node_name']})
managed_objs['modifier'].show_preview()
else:
managed_objs['modifier'].hide_preview()
@events.on_value_changed( @events.on_value_changed(
# Trigger # Trigger

View File

@ -104,18 +104,14 @@ class PointDipoleSourceNode(base.MaxwellSimNode):
#################### ####################
# - Preview # - Preview
#################### ####################
@events.on_value_changed( @events.computes_output_socket(
# Trigger 'Source',
prop_name='preview_active', kind=ct.FlowKind.Previews,
# Loaded # Loaded
managed_objs={'modifier'}, props={'sim_node_name'},
props={'preview_active'},
) )
def on_preview_changed(self, managed_objs, props): def compute_previews(self, props):
if props['preview_active']: return ct.PreviewsFlow(bl_object_names={props['sim_node_name']})
managed_objs['modifier'].show_preview()
else:
managed_objs['modifier'].hide_preview()
@events.on_value_changed( @events.on_value_changed(
socket_name={'Center'}, socket_name={'Center'},

View File

@ -132,18 +132,14 @@ class GeoNodesStructureNode(base.MaxwellSimNode):
#################### ####################
# - Events: Preview # - Events: Preview
#################### ####################
@events.on_value_changed( @events.computes_output_socket(
# Trigger 'Structure',
prop_name='preview_active', kind=ct.FlowKind.Previews,
# Loaded # Loaded
managed_objs={'modifier'}, props={'sim_node_name'},
props={'preview_active'},
) )
def on_preview_changed(self, managed_objs, props): def compute_previews(self, props):
if props['preview_active']: return ct.PreviewsFlow(bl_object_names={props['sim_node_name']})
managed_objs['modifier'].show_preview()
else:
managed_objs['modifier'].hide_preview()
@events.on_value_changed( @events.on_value_changed(
# Trigger # Trigger

View File

@ -16,13 +16,15 @@
import typing as typ import typing as typ
import bpy
import sympy as sp import sympy as sp
import sympy.physics.units as spu import sympy.physics.units as spu
import tidy3d as td import tidy3d as td
import tidy3d.plugins.adjoint as tdadj
from blender_maxwell.assets.geonodes import GeoNodes, import_geonodes from blender_maxwell.assets.geonodes import GeoNodes, import_geonodes
from blender_maxwell.utils import bl_cache, logger
from blender_maxwell.utils import extra_sympy_units as spux from blender_maxwell.utils import extra_sympy_units as spux
from blender_maxwell.utils import logger
from .... import contracts as ct from .... import contracts as ct
from .... import managed_objs, sockets from .... import managed_objs, sockets
@ -62,41 +64,172 @@ class BoxStructureNode(base.MaxwellSimNode):
} }
#################### ####################
# - Outputs # - Properties
####################
differentiable: bool = bl_cache.BLField(False)
####################
# - UI
####################
def draw_props(self, _: bpy.types.Context, layout: bpy.types.UILayout):
layout.prop(
self,
self.blfields['differentiable'],
text='Differentiable',
toggle=True,
)
####################
# - FlowKind.Value
#################### ####################
@events.computes_output_socket( @events.computes_output_socket(
'Structure', 'Structure',
kind=ct.FlowKind.Value,
# Loaded
props={'differentiable'},
input_sockets={'Medium', 'Center', 'Size'}, input_sockets={'Medium', 'Center', 'Size'},
unit_systems={'Tidy3DUnits': ct.UNITS_TIDY3D}, output_sockets={'Structure'},
scale_input_sockets={ output_socket_kinds={'Structure': ct.FlowKind.Params},
'Center': 'Tidy3DUnits',
'Size': 'Tidy3DUnits',
},
) )
def compute_structure(self, input_sockets, unit_systems) -> td.Box: def compute_value(self, props, input_sockets, output_sockets) -> td.Box:
output_params = output_sockets['Structure']
center = input_sockets['Center']
size = input_sockets['Size']
medium = input_sockets['Medium']
has_output_params = not ct.FlowSignal.check(output_params)
has_center = not ct.FlowSignal.check(center)
has_size = not ct.FlowSignal.check(size)
has_medium = not ct.FlowSignal.check(medium)
if (
has_center
and has_size
and has_medium
and has_output_params
and not props['differentiable']
and not output_params.symbols
):
return td.Structure( return td.Structure(
geometry=td.Box( geometry=td.Box(
center=input_sockets['Center'], center=spux.scale_to_unit_system(center, ct.UNITS_TIDY3D),
size=input_sockets['Size'], size=spux.scale_to_unit_system(size, ct.UNITS_TIDY3D),
), ),
medium=input_sockets['Medium'], medium=medium,
) )
return ct.FlowSignal.FlowPending
####################
# - FlowKind.Func
####################
@events.computes_output_socket(
'Structure',
kind=ct.FlowKind.Func,
# Loaded
props={'differentiable'},
input_sockets={'Medium', 'Center', 'Size'},
input_socket_kinds={
'Medium': ct.FlowKind.Func,
'Center': ct.FlowKind.Func,
'Size': ct.FlowKind.Func,
},
output_sockets={'Structure'},
output_socket_kinds={'Structure': ct.FlowKind.Params},
)
def compute_lazy_structure(self, props, input_sockets, output_sockets) -> td.Box:
output_params = output_sockets['Structure']
center = input_sockets['Center']
size = input_sockets['Size']
medium = input_sockets['Medium']
has_output_params = not ct.FlowSignal.check(output_params)
has_center = not ct.FlowSignal.check(center)
has_size = not ct.FlowSignal.check(size)
has_medium = not ct.FlowSignal.check(medium)
differentiable = props['differentiable']
if (
has_output_params
and has_center
and has_size
and has_medium
and differentiable == output_params.is_differentiable
):
if differentiable:
return (center | size | medium).compose_within(
enclosing_func=lambda els: tdadj.JaxStructure(
geometry=tdadj.JaxBox(
center=tuple(els[0][0].flatten()),
size=tuple(els[0][1].flatten()),
),
medium=els[1],
),
supports_jax=True,
)
return (center | size | medium).compose_within(
enclosing_func=lambda els: td.Structure(
geometry=td.Box(
center=tuple(els[0][0].flatten()),
size=tuple(els[0][1].flatten()),
),
medium=els[1],
),
supports_jax=False,
)
return ct.FlowSignal.FlowPending
####################
# - FlowKind.Params
####################
@events.computes_output_socket(
'Structure',
kind=ct.FlowKind.Params,
# Loaded
props={'differentiable'},
input_sockets={'Medium', 'Center', 'Size'},
input_socket_kinds={
'Medium': ct.FlowKind.Params,
'Center': ct.FlowKind.Params,
'Size': ct.FlowKind.Params,
},
)
def compute_params(self, props, input_sockets) -> td.Box:
center = input_sockets['Center']
size = input_sockets['Size']
medium = input_sockets['Medium']
has_center = not ct.FlowSignal.check(center)
has_size = not ct.FlowSignal.check(size)
has_medium = not ct.FlowSignal.check(medium)
if has_center and has_size and has_medium:
if props['differentiable'] == (
center.is_differentiable
& size.is_differentiable
& medium.is_differentiable
):
return center | size | medium
return ct.FlowSignal.FlowPending
return ct.FlowSignal.FlowPending
#################### ####################
# - Events: Preview # - Events: Preview
#################### ####################
@events.on_value_changed( @events.computes_output_socket(
# Trigger 'Structure',
prop_name='preview_active', kind=ct.FlowKind.Previews,
# Loaded # Loaded
managed_objs={'modifier'}, props={'sim_node_name'},
props={'preview_active'}, output_sockets={'Structure'},
output_socket_kinds={'Structure': ct.FlowKind.Params},
) )
def on_preview_changed(self, managed_objs, props): def compute_previews(self, props, output_sockets):
if props['preview_active']: output_params = output_sockets['Structure']
managed_objs['modifier'].show_preview() has_output_params = not ct.FlowSignal.check(output_params)
else:
managed_objs['modifier'].hide_preview() if has_output_params and not output_params.symbols:
return ct.PreviewsFlow(bl_object_names={props['sim_node_name']})
return ct.PreviewsFlow()
@events.on_value_changed( @events.on_value_changed(
# Trigger # Trigger
@ -105,28 +238,25 @@ class BoxStructureNode(base.MaxwellSimNode):
# Loaded # Loaded
input_sockets={'Center', 'Size'}, input_sockets={'Center', 'Size'},
managed_objs={'modifier'}, managed_objs={'modifier'},
unit_systems={'BlenderUnits': ct.UNITS_BLENDER}, output_sockets={'Structure'},
scale_input_sockets={ output_socket_kinds={'Structure': ct.FlowKind.Params},
'Center': 'BlenderUnits',
},
) )
def on_inputs_changed( def on_inputs_changed(self, managed_objs, input_sockets, output_sockets):
self, output_params = output_sockets['Structure']
managed_objs, has_output_params = not ct.FlowSignal.check(output_params)
input_sockets, if has_output_params and not output_params.symbols:
unit_systems,
):
# Push Loose Input Values to GeoNodes Modifier # Push Loose Input Values to GeoNodes Modifier
center = input_sockets['Center']
managed_objs['modifier'].bl_modifier( managed_objs['modifier'].bl_modifier(
'NODES', 'NODES',
{ {
'node_group': import_geonodes(GeoNodes.StructurePrimitiveBox), 'node_group': import_geonodes(GeoNodes.StructurePrimitiveBox),
'unit_system': unit_systems['BlenderUnits'], 'unit_system': ct.UNITS_BLENDER,
'inputs': { 'inputs': {
'Size': input_sockets['Size'], 'Size': input_sockets['Size'],
}, },
}, },
location=input_sockets['Center'], location=spux.scale_to_unit_system(center, ct.UNITS_BLENDER),
) )

View File

@ -89,18 +89,14 @@ class CylinderStructureNode(base.MaxwellSimNode):
#################### ####################
# - Preview # - Preview
#################### ####################
@events.on_value_changed( @events.computes_output_socket(
# Trigger 'Structure',
prop_name='preview_active', kind=ct.FlowKind.Previews,
# Loaded # Loaded
managed_objs={'modifier'}, props={'sim_node_name'},
props={'preview_active'},
) )
def on_preview_changed(self, managed_objs, props): def compute_previews(self, props):
if props['preview_active']: return ct.PreviewsFlow(bl_object_names={props['sim_node_name']})
managed_objs['modifier'].show_preview()
else:
managed_objs['modifier'].hide_preview()
@events.on_value_changed( @events.on_value_changed(
# Trigger # Trigger

View File

@ -83,18 +83,14 @@ class SphereStructureNode(base.MaxwellSimNode):
#################### ####################
# - Preview # - Preview
#################### ####################
@events.on_value_changed( @events.computes_output_socket(
# Trigger 'Structure',
prop_name='preview_active', kind=ct.FlowKind.Previews,
# Loaded # Loaded
managed_objs={'modifier'}, props={'sim_node_name'},
props={'preview_active'},
) )
def on_preview_changed(self, managed_objs, props): def compute_previews(self, props):
if props['preview_active']: return ct.PreviewsFlow(bl_object_names={props['sim_node_name']})
managed_objs['modifier'].show_preview()
else:
managed_objs['modifier'].hide_preview()
@events.on_value_changed( @events.on_value_changed(
# Trigger # Trigger

View File

@ -50,8 +50,10 @@ class SocketDef(pyd.BaseModel, abc.ABC):
Parameters: Parameters:
bl_socket: The Blender node socket to alter using data from this SocketDef. bl_socket: The Blender node socket to alter using data from this SocketDef.
""" """
log.debug('%s: Start Socket Preinit', bl_socket.bl_label)
bl_socket.reset_instance_id() bl_socket.reset_instance_id()
bl_socket.regenerate_dynamic_field_persistance() bl_socket.regenerate_dynamic_field_persistance()
log.debug('%s: End Socket Preinit', bl_socket.bl_label)
def postinit(self, bl_socket: bpy.types.NodeSocket) -> None: def postinit(self, bl_socket: bpy.types.NodeSocket) -> None:
"""Pre-initialize a real Blender node socket from this socket definition. """Pre-initialize a real Blender node socket from this socket definition.
@ -59,8 +61,12 @@ class SocketDef(pyd.BaseModel, abc.ABC):
Parameters: Parameters:
bl_socket: The Blender node socket to alter using data from this SocketDef. bl_socket: The Blender node socket to alter using data from this SocketDef.
""" """
log.debug('%s: Start Socket Postinit', bl_socket.bl_label)
bl_socket.is_initializing = False bl_socket.is_initializing = False
bl_socket.on_active_kind_changed() bl_socket.on_active_kind_changed()
bl_socket.on_socket_props_changed(set(bl_socket.blfields))
bl_socket.on_data_changed(set(ct.FlowKind))
log.debug('%s: End Socket Postinit', bl_socket.bl_label)
@abc.abstractmethod @abc.abstractmethod
def init(self, bl_socket: bpy.types.NodeSocket) -> None: def init(self, bl_socket: bpy.types.NodeSocket) -> None:
@ -135,6 +141,8 @@ class MaxwellSimSocket(bpy.types.NodeSocket, bl_instance.BLInstance):
socket_type: ct.SocketType socket_type: ct.SocketType
bl_label: str bl_label: str
use_linked_capabilities: bool = bl_cache.BLField(False, use_prop_update=False)
## Computed by Subclass ## Computed by Subclass
bl_idname: str bl_idname: str
@ -181,17 +189,17 @@ class MaxwellSimSocket(bpy.types.NodeSocket, bl_instance.BLInstance):
""" """
self.display_shape = self.active_kind.socket_shape self.display_shape = self.active_kind.socket_shape
def on_socket_prop_changed(self, prop_name: str) -> None: def on_socket_props_changed(self, prop_names: set[str]) -> None:
"""Called when a property has been updated. """Called when a set of properties has been updated.
Notes: Notes:
Can be overridden if a socket needs to respond to a property change. Can be overridden if a socket needs to respond to property changes.
**Always prefer using node events instead of overriding this in a socket**. **Always prefer using node events instead of overriding this in a socket**.
Think **very carefully** before using this, and use it with the greatest of care. Think **very carefully** before using this, and use it with the greatest of care.
Attributes: Attributes:
prop_name: The name of the property that was changed. prop_names: The set of property names that were changed.
""" """
def on_prop_changed(self, prop_name: str) -> None: def on_prop_changed(self, prop_name: str) -> None:
@ -207,30 +215,49 @@ class MaxwellSimSocket(bpy.types.NodeSocket, bl_instance.BLInstance):
Attributes: Attributes:
prop_name: The name of the property that was changed. prop_name: The name of the property that was changed.
""" """
# All Attributes: Trigger Local Event # BLField Attributes: Invalidate BLField Dependents
## -> While initializing, only `DataChanged` won't trigger. ## -> All invalidated blfields will have their caches cleared.
if hasattr(self, prop_name): ## -> The (topologically) ordered list of cleared blfields is returned.
# Property Callbacks: Active Kind ## -> WARNING: The chain is not checked for ex. cycles.
## -> WARNING: May NOT rely on flow. if not self.is_initializing and prop_name in self.blfields:
if prop_name == 'active_kind': cleared_blfields = self.clear_blfields_after(prop_name)
set_of_cleared_blfields = set(cleared_blfields)
# Property Callbacks: Internal
## -> NOTE: May NOT recurse on_prop_changed.
if ('active_kind', 'invalidate') in set_of_cleared_blfields:
# log.debug(
# '%s (NodeSocket): Changed Active Kind',
# self.bl_label,
# )
self.on_active_kind_changed() self.on_active_kind_changed()
# Property Callbacks: Per-Socket # Property Callbacks: Per-Socket
## -> WARNING: May NOT rely on flow. ## -> NOTE: User-defined handlers might recurse on_prop_changed.
self.on_socket_prop_changed(prop_name) self.is_initializing = True
self.on_socket_props_changed(set_of_cleared_blfields)
self.is_initializing = False
# Not Initializing: Trigger Event # Trigger Event
## -> This declares that the socket has changed. ## -> Before SocketDef.postinit(), never emit DataChanged.
## -> This should happen first, in case dependents need a cache. ## -> ONLY emit DataChanged if a FlowKind-bound prop was cleared.
if not self.is_initializing: ## -> ONLY emit a single DataChanged w/set of altered FlowKinds.
self.trigger_event(ct.FlowEvent.DataChanged) ## w/node's trigger_event, we've guaranteed a minimal action.
socket_kinds = {
# BLField Attributes: Invalidate BLField Dependents ct.FlowKind.from_property_name(prop_name)
## -> Dependent props will generally also trigger on_prop_changed. for prop_name in {
## -> The recursion ends with the depschain. prop_name
## -> WARNING: The chain is not checked for ex. cycles. for prop_name, clear_method in set_of_cleared_blfields
if prop_name in self.blfields: if clear_method == 'invalidate'
self.invalidate_blfield_deps(prop_name) }.intersection(ct.FlowKind.property_names)
}
# log.debug(
# '%s (NodeSocket): Computed SocketKind Frontier: %s',
# self.bl_label,
# str(socket_kinds),
# )
if socket_kinds:
self.trigger_event(ct.FlowEvent.DataChanged, socket_kinds=socket_kinds)
#################### ####################
# - Link Event: Consent / On Change # - Link Event: Consent / On Change
@ -273,11 +300,29 @@ class MaxwellSimSocket(bpy.types.NodeSocket, bl_instance.BLInstance):
return False return False
# Capability Check # Capability Check
if not link.from_socket.capabilities.is_compatible_with(self.capabilities): ## -> "Use Linked Capabilities" allow sockets flow-dependent caps.
## -> The tradeoff: No link if there is no InfoFlow.
if self.use_linked_capabilities:
info = self.compute_data(kind=ct.FlowKind.Info)
has_info = not ct.FlowSignal.check(info)
if has_info:
incoming_capabilities = link.from_socket.linked_capabilities(info)
else:
log.error(
'Attempted to link output socket "%s" to input socket "%s" (%s), but linked capabilities of the output socket could not be determined',
link.from_socket.bl_label,
self.bl_label,
self.capabilities,
)
return False
else:
incoming_capabilities = link.from_socket.capabilities
if not incoming_capabilities.is_compatible_with(self.capabilities):
log.error( log.error(
'Attempted to link output socket "%s" (%s) to input socket "%s" (%s), but capabilities are incompatible', 'Attempted to link output socket "%s" (%s) to input socket "%s" (%s), but capabilities are incompatible',
link.from_socket.bl_label, link.from_socket.bl_label,
link.from_socket.capabilities, incoming_capabilities,
self.bl_label, self.bl_label,
self.capabilities, self.capabilities,
) )
@ -288,6 +333,8 @@ class MaxwellSimSocket(bpy.types.NodeSocket, bl_instance.BLInstance):
def on_link_added(self, link: bpy.types.NodeLink) -> None: # noqa: ARG002 def on_link_added(self, link: bpy.types.NodeLink) -> None: # noqa: ARG002
"""Triggers a `ct.FlowEvent.LinkChanged` event when a link is added. """Triggers a `ct.FlowEvent.LinkChanged` event when a link is added.
Calls `self.trigger_event()` with `FlowKind`s, since an added link requires recomputing **all** data that depends on flow.
Notes: Notes:
Called by the node tree, generally (but not guaranteed) after `self.allow_add_link()` has given consent to add the link. Called by the node tree, generally (but not guaranteed) after `self.allow_add_link()` has given consent to add the link.
@ -295,7 +342,7 @@ class MaxwellSimSocket(bpy.types.NodeSocket, bl_instance.BLInstance):
link: The node link that was added. link: The node link that was added.
Currently unused. Currently unused.
""" """
self.trigger_event(ct.FlowEvent.LinkChanged) self.trigger_event(ct.FlowEvent.LinkChanged, socket_kinds=set(ct.FlowKind))
def allow_remove_link(self, from_socket: bpy.types.NodeSocket) -> bool: # noqa: ARG002 def allow_remove_link(self, from_socket: bpy.types.NodeSocket) -> bool: # noqa: ARG002
"""Called to ask whether a link may be removed from this `to_socket`. """Called to ask whether a link may be removed from this `to_socket`.
@ -333,6 +380,8 @@ class MaxwellSimSocket(bpy.types.NodeSocket, bl_instance.BLInstance):
def on_link_removed(self, from_socket: bpy.types.NodeSocket) -> None: # noqa: ARG002 def on_link_removed(self, from_socket: bpy.types.NodeSocket) -> None: # noqa: ARG002
"""Triggers a `ct.FlowEvent.LinkChanged` event when a link is removed. """Triggers a `ct.FlowEvent.LinkChanged` event when a link is removed.
Calls `self.trigger_event()` with `FlowKind`s, since a removed link requires recomputing **all** data that depends on flow.
Notes: Notes:
Called by the node tree, generally (but not guaranteed) after `self.allow_remove_link()` has given consent to remove the link. Called by the node tree, generally (but not guaranteed) after `self.allow_remove_link()` has given consent to remove the link.
@ -340,7 +389,7 @@ class MaxwellSimSocket(bpy.types.NodeSocket, bl_instance.BLInstance):
from_socket: The node socket that was attached to before link removal. from_socket: The node socket that was attached to before link removal.
Currently unused. Currently unused.
""" """
self.trigger_event(ct.FlowEvent.LinkChanged) self.trigger_event(ct.FlowEvent.LinkChanged, socket_kinds=set(ct.FlowKind))
def remove_invalidated_links(self) -> None: def remove_invalidated_links(self) -> None:
"""Reevaluates the capabilities of all socket links, and removes any that no longer match. """Reevaluates the capabilities of all socket links, and removes any that no longer match.
@ -371,6 +420,41 @@ class MaxwellSimSocket(bpy.types.NodeSocket, bl_instance.BLInstance):
#################### ####################
# - Event Chain # - Event Chain
#################### ####################
def on_data_changed(self, socket_kinds: set[ct.FlowKind]) -> None:
"""Called when `ct.FlowEvent.DataChanged` flows through this socket.
Parameters:
socket_kinds: The altered `ct.FlowKind`s flowing through.
"""
self.on_socket_data_changed(socket_kinds)
def on_socket_data_changed(self, socket_kinds: set[ct.FlowKind]) -> None:
"""Called when `ct.FlowEvent.DataChanged` flows through this socket.
Notes:
Can be overridden if a socket needs to respond to `DataChanged` in a custom way.
**Always prefer using node events instead of overriding this in a socket**.
Think **very carefully** before using this, and use it with the greatest of care.
Parameters:
socket_kinds: The altered `ct.FlowKind`s flowing through.
"""
def on_link_changed(self) -> None:
"""Called when `ct.FlowEvent.LinkChanged` flows through this socket."""
self.on_socket_link_changed()
def on_socket_link_changed(self) -> None:
"""Called when `ct.FlowEvent.LinkChanged` flows through this socket.
Notes:
Can be overridden if a socket needs to respond to `LinkChanged` in a custom way.
**Always prefer using node events instead of overriding this in a socket**.
Think **very carefully** before using this, and use it with the greatest of care.
"""
def trigger_event( def trigger_event(
self, self,
event: ct.FlowEvent, event: ct.FlowEvent,
@ -384,7 +468,6 @@ class MaxwellSimSocket(bpy.types.NodeSocket, bl_instance.BLInstance):
- **Output Socket -> Input**: Trigger event on node (w/`socket_name`). - **Output Socket -> Input**: Trigger event on node (w/`socket_name`).
- **Output Socket -> Output**: Trigger event on `to_socket`s along output links. - **Output Socket -> Output**: Trigger event on `to_socket`s along output links.
Notes: Notes:
This can be an unpredictably heavy function, depending on the node graph topology. This can be an unpredictably heavy function, depending on the node graph topology.
@ -395,11 +478,41 @@ class MaxwellSimSocket(bpy.types.NodeSocket, bl_instance.BLInstance):
event: The event to report along the node tree. event: The event to report along the node tree.
The value of `ct.FlowEvent.flow_direction[event]` (`input` or `output`) determines the direction that an event flows. The value of `ct.FlowEvent.flow_direction[event]` (`input` or `output`) determines the direction that an event flows.
""" """
# log.debug(
# '[%s] [%s] Triggered (socket_kinds=%s)',
# self.name,
# event,
# str(socket_kinds),
# )
# Local DataChanged Callbacks
## -> socket_kinds MUST NOT be None
if event is ct.FlowEvent.DataChanged:
# WORKAROUND
## -> Altering value/lazy_range like this causes MANY DataChanged
## -> If we pretend we're initializing, we can block on_prop_changed
## -> This works because _unit conversion doesn't change the value_
## -> Only the displayed values change - which are inv. on __set__.
## -> For this reason alone, we can get away with it :)
## -> TODO: This is not clean :)
self.is_initializing = True
self.on_data_changed(socket_kinds)
self.is_initializing = False
# Local LinkChanged Callbacks
## -> socket_kinds MUST NOT be None
if event is ct.FlowEvent.LinkChanged:
self.is_initializing = True
self.on_link_changed()
self.on_data_changed(socket_kinds)
self.is_initializing = False
flow_direction = ct.FlowEvent.flow_direction[event] flow_direction = ct.FlowEvent.flow_direction[event]
# Locking # Locking
if event in [ct.FlowEvent.EnableLock, ct.FlowEvent.DisableLock]: if event is ct.FlowEvent.EnableLock:
self.locked = event == ct.FlowEvent.EnableLock self.locked = True
elif event is ct.FlowEvent.DisableLock:
self.locked = False
# Event by Socket Orientation | Flow Direction # Event by Socket Orientation | Flow Direction
match (self.is_output, flow_direction): match (self.is_output, flow_direction):
@ -408,7 +521,7 @@ class MaxwellSimSocket(bpy.types.NodeSocket, bl_instance.BLInstance):
link.from_socket.trigger_event(event, socket_kinds=socket_kinds) link.from_socket.trigger_event(event, socket_kinds=socket_kinds)
case (False, 'output'): case (False, 'output'):
if event == ct.FlowEvent.LinkChanged: if event is ct.FlowEvent.LinkChanged:
self.node.trigger_event( self.node.trigger_event(
ct.FlowEvent.DataChanged, ct.FlowEvent.DataChanged,
socket_name=self.name, socket_name=self.name,
@ -432,6 +545,10 @@ class MaxwellSimSocket(bpy.types.NodeSocket, bl_instance.BLInstance):
# - FlowKind: Auxiliary # - FlowKind: Auxiliary
#################### ####################
# Capabilities # Capabilities
def linked_capabilities(self, info: ct.InfoFlow) -> ct.CapabilitiesFlow:
"""Try this first when `is_linked and use_linked_capabilities`."""
raise NotImplementedError
@property @property
def capabilities(self) -> None: def capabilities(self) -> None:
"""By default, the socket is linkeable with any other socket of the same type and active kind. """By default, the socket is linkeable with any other socket of the same type and active kind.
@ -592,21 +709,16 @@ class MaxwellSimSocket(bpy.types.NodeSocket, bl_instance.BLInstance):
Raises: Raises:
ValueError: When referencing a socket that's meant to be directly referenced. ValueError: When referencing a socket that's meant to be directly referenced.
""" """
kind_data_map = { return {
ct.FlowKind.Capabilities: lambda: self.capabilities, ct.FlowKind.Capabilities: lambda: self.capabilities,
ct.FlowKind.Previews: lambda: ct.PreviewsFlow(),
ct.FlowKind.Value: lambda: self.value, ct.FlowKind.Value: lambda: self.value,
ct.FlowKind.Array: lambda: self.array, ct.FlowKind.Array: lambda: self.array,
ct.FlowKind.Func: lambda: self.lazy_func, ct.FlowKind.Func: lambda: self.lazy_func,
ct.FlowKind.Range: lambda: self.lazy_range, ct.FlowKind.Range: lambda: self.lazy_range,
ct.FlowKind.Params: lambda: self.params, ct.FlowKind.Params: lambda: self.params,
ct.FlowKind.Info: lambda: self.info, ct.FlowKind.Info: lambda: self.info,
} }[kind]()
if kind in kind_data_map:
return kind_data_map[kind]()
## TODO: Reflect this constraint in the type
msg = f'Socket {self.bl_label} ({self.socket_type}): Kind {kind} cannot be computed within a socket "compute_data", as it is meant to be referenced directly'
raise ValueError(msg)
def compute_data( def compute_data(
self, self,
@ -635,7 +747,7 @@ class MaxwellSimSocket(bpy.types.NodeSocket, bl_instance.BLInstance):
return self.node.compute_output(self.name, kind=kind) return self.node.compute_output(self.name, kind=kind)
# Compute Input Socket # Compute Input Socket
## Unlinked: Retrieve Socket Value ## -> Unlinked: Retrieve Socket Value
if not self.is_linked: if not self.is_linked:
return self._compute_data(kind) return self._compute_data(kind)
@ -645,7 +757,8 @@ class MaxwellSimSocket(bpy.types.NodeSocket, bl_instance.BLInstance):
linked_values = [link.from_socket.compute_data(kind) for link in self.links] linked_values = [link.from_socket.compute_data(kind) for link in self.links]
# Return Single Value / List of Values # Return Single Value / List of Values
if len(linked_values) == 1: ## -> Multi-input sockets are not yet supported.
if linked_values:
return linked_values[0] return linked_values[0]
# Edge Case: While Dragging Link (but not yet removed) # Edge Case: While Dragging Link (but not yet removed)
@ -653,12 +766,8 @@ class MaxwellSimSocket(bpy.types.NodeSocket, bl_instance.BLInstance):
## - self.is_linked = True, since the user hasn't confirmed anything. ## - self.is_linked = True, since the user hasn't confirmed anything.
## - self.links will be empty, since the link object was freed. ## - self.links will be empty, since the link object was freed.
## When this particular condition is met, pretend that we're not linked. ## When this particular condition is met, pretend that we're not linked.
if len(linked_values) == 0:
return self._compute_data(kind) return self._compute_data(kind)
msg = f'Socket {self.bl_label} ({self.socket_type}): Multi-input sockets are not yet supported'
raise NotImplementedError(msg)
#################### ####################
# - UI - Color # - UI - Color
#################### ####################

View File

@ -14,6 +14,8 @@
# You should have received a copy of the GNU Affero General Public License # You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
from blender_maxwell.utils import bl_cache
from ... import contracts as ct from ... import contracts as ct
from .. import base from .. import base
@ -25,8 +27,8 @@ class AnyBLSocket(base.MaxwellSimSocket):
socket_type = ct.SocketType.Any socket_type = ct.SocketType.Any
bl_label = 'Any' bl_label = 'Any'
@property @bl_cache.cached_bl_property(depends_on={'active_kind'})
def capabilities(self): def capabilities(self) -> ct.CapabilitiesFlow:
return ct.CapabilitiesFlow( return ct.CapabilitiesFlow(
socket_type=self.socket_type, socket_type=self.socket_type,
active_kind=self.active_kind, active_kind=self.active_kind,

View File

@ -16,7 +16,7 @@
import bpy import bpy
from blender_maxwell.utils import bl_cache, logger from blender_maxwell.utils import bl_cache
from ... import contracts as ct from ... import contracts as ct
from .. import base from .. import base
@ -43,7 +43,7 @@ class BoolBLSocket(base.MaxwellSimSocket):
#################### ####################
# - Computation of Default Value # - Computation of Default Value
#################### ####################
@property @bl_cache.cached_bl_property(depends_on={'raw_value'})
def value(self) -> bool: def value(self) -> bool:
return self.raw_value return self.raw_value

View File

@ -48,7 +48,7 @@ class FilePathBLSocket(base.MaxwellSimSocket):
#################### ####################
# - FlowKind: Value # - FlowKind: Value
#################### ####################
@property @bl_cache.cached_bl_property(depends_on={'raw_value'})
def value(self) -> Path: def value(self) -> Path:
return self.raw_value return self.raw_value

View File

@ -16,6 +16,8 @@
import bpy import bpy
from blender_maxwell.utils import bl_cache
from ... import contracts as ct from ... import contracts as ct
from .. import base from .. import base
@ -30,12 +32,7 @@ class StringBLSocket(base.MaxwellSimSocket):
#################### ####################
# - Properties # - Properties
#################### ####################
raw_value: bpy.props.StringProperty( raw_value: str = bl_cache.BLField('')
name='String',
description='Represents a string',
default='',
update=(lambda self, context: self.on_prop_changed('raw_value', context)),
)
#################### ####################
# - Socket UI # - Socket UI
@ -46,7 +43,7 @@ class StringBLSocket(base.MaxwellSimSocket):
#################### ####################
# - Computation of Default Value # - Computation of Default Value
#################### ####################
@property @bl_cache.cached_bl_property(depends_on={'raw_value'})
def value(self) -> str: def value(self) -> str:
return self.raw_value return self.raw_value

View File

@ -71,7 +71,7 @@ class BlenderGeoNodesBLSocket(base.MaxwellSimSocket):
#################### ####################
# - Default Value # - Default Value
#################### ####################
@property @bl_cache.cached_bl_property(depends_on={'raw_value'})
def value(self) -> bpy.types.NodeTree | ct.FlowSignal: def value(self) -> bpy.types.NodeTree | ct.FlowSignal:
return self.raw_value if self.raw_value is not None else ct.FlowSignal.NoFlow return self.raw_value if self.raw_value is not None else ct.FlowSignal.NoFlow

View File

@ -16,6 +16,8 @@
import bpy import bpy
from blender_maxwell.utils import bl_cache, logger
from ... import contracts as ct from ... import contracts as ct
from .. import base from .. import base
@ -30,12 +32,7 @@ class BlenderImageBLSocket(base.MaxwellSimSocket):
#################### ####################
# - Properties # - Properties
#################### ####################
raw_value: bpy.props.PointerProperty( raw_value: bpy.types.Image = bl_cache.BLField()
name='Blender Image',
description='Represents a Blender Image',
type=bpy.types.Image,
update=(lambda self, context: self.on_prop_changed('raw_value', context)),
)
#################### ####################
# - UI # - UI
@ -46,7 +43,7 @@ class BlenderImageBLSocket(base.MaxwellSimSocket):
#################### ####################
# - Default Value # - Default Value
#################### ####################
@property @bl_cache.cached_bl_property(depends_on={'raw_value'})
def value(self) -> bpy.types.Image | None: def value(self) -> bpy.types.Image | None:
return self.raw_value return self.raw_value

View File

@ -16,6 +16,8 @@
import bpy import bpy
from blender_maxwell.utils import bl_cache, logger
from ... import contracts as ct from ... import contracts as ct
from .. import base from .. import base
@ -27,12 +29,7 @@ class BlenderMaterialBLSocket(base.MaxwellSimSocket):
#################### ####################
# - Properties # - Properties
#################### ####################
raw_value: bpy.props.PointerProperty( raw_value: bpy.types.Material = bl_cache.BLField()
name='Blender Material',
description='Represents a Blender material',
type=bpy.types.Material,
update=(lambda self, context: self.on_prop_changed('raw_value', context)),
)
#################### ####################
# - UI # - UI
@ -43,7 +40,7 @@ class BlenderMaterialBLSocket(base.MaxwellSimSocket):
#################### ####################
# - Default Value # - Default Value
#################### ####################
@property @bl_cache.cached_bl_property(depends_on={'raw_value'})
def value(self) -> bpy.types.Material | None: def value(self) -> bpy.types.Material | None:
return self.raw_value return self.raw_value

View File

@ -16,29 +16,12 @@
import bpy import bpy
from blender_maxwell.utils import bl_cache, logger
from ... import contracts as ct from ... import contracts as ct
from .. import base from .. import base
log = logger.get(__name__)
####################
# - Create and Assign BL Object
####################
class BlenderMaxwellCreateAndAssignBLObject(bpy.types.Operator):
bl_idname = 'blender_maxwell.create_and_assign_bl_object'
bl_label = 'Create and Assign BL Object'
node_tree_name = bpy.props.StringProperty(name='Node Tree Name')
node_name = bpy.props.StringProperty(name='Node Name')
socket_name = bpy.props.StringProperty(name='Socket Name')
def execute(self, context):
node_tree = bpy.data.node_groups[self.node_tree_name]
node = node_tree.nodes[self.node_name]
socket = node.inputs[self.socket_name]
socket.create_and_assign_bl_object()
return {'FINISHED'}
#################### ####################
@ -51,47 +34,18 @@ class BlenderObjectBLSocket(base.MaxwellSimSocket):
#################### ####################
# - Properties # - Properties
#################### ####################
raw_value: bpy.props.PointerProperty( raw_value: bpy.types.Object = bl_cache.BLField()
name='Blender Object',
description='Represents a Blender object',
type=bpy.types.Object,
update=(lambda self, context: self.on_prop_changed('raw_value', context)),
)
#################### ####################
# - UI # - UI
#################### ####################
def draw_label_row(self, label_col_row, text):
label_col_row.label(text=text)
op = label_col_row.operator(
'blender_maxwell.create_and_assign_bl_object',
text='',
icon='ADD',
)
op.socket_name = self.name
op.node_name = self.node.name
op.node_tree_name = self.node.id_data.name
def draw_value(self, col: bpy.types.UILayout) -> None: def draw_value(self, col: bpy.types.UILayout) -> None:
col.prop(self, 'raw_value', text='') col.prop(self, 'raw_value', text='')
####################
# - Methods
####################
def create_and_assign_bl_object(self):
node_tree = self.node.id_data
mesh = bpy.data.meshes.new('MaxwellMesh')
new_bl_object = bpy.data.objects.new('MaxwellObject', mesh)
bpy.context.collection.objects.link(new_bl_object)
self.value = new_bl_object
#################### ####################
# - Default Value # - Default Value
#################### ####################
@property @bl_cache.cached_bl_property(depends_on={'raw_value'})
def value(self) -> bpy.types.Object | None: def value(self) -> bpy.types.Object | None:
return self.raw_value return self.raw_value
@ -114,6 +68,5 @@ class BlenderObjectSocketDef(base.SocketDef):
# - Blender Registration # - Blender Registration
#################### ####################
BL_REGISTER = [ BL_REGISTER = [
BlenderMaxwellCreateAndAssignBLObject,
BlenderObjectBLSocket, BlenderObjectBLSocket,
] ]

View File

@ -16,9 +16,13 @@
import bpy import bpy
from blender_maxwell.utils import bl_cache, logger
from ... import contracts as ct from ... import contracts as ct
from .. import base from .. import base
log = logger.get(__name__)
#################### ####################
# - Blender Socket # - Blender Socket
@ -30,12 +34,7 @@ class BlenderTextBLSocket(base.MaxwellSimSocket):
#################### ####################
# - Properties # - Properties
#################### ####################
raw_value: bpy.props.PointerProperty( raw_value: bpy.types.Text = bl_cache.BLField()
name='Blender Text',
description='Represents a Blender text datablock',
type=bpy.types.Text,
update=(lambda self, context: self.on_prop_changed('raw_value', context)),
)
#################### ####################
# - UI # - UI
@ -46,7 +45,7 @@ class BlenderTextBLSocket(base.MaxwellSimSocket):
#################### ####################
# - Default Value # - Default Value
#################### ####################
@property @bl_cache.cached_bl_property(depends_on={'raw_value'})
def value(self) -> bpy.types.Text: def value(self) -> bpy.types.Text:
return self.raw_value return self.raw_value

View File

@ -64,21 +64,21 @@ class InfoDisplayCol(enum.StrEnum):
@staticmethod @staticmethod
def to_name(value: typ.Self) -> str: def to_name(value: typ.Self) -> str:
"""Friendly, single-letter, human-readable column names.
Must be concise, as there is not a lot of header space to contain these.
"""
IDC = InfoDisplayCol IDC = InfoDisplayCol
return { return {
IDC.Length: 'L', IDC.Length: 'L',
IDC.MathType: '', IDC.MathType: 'M',
IDC.Unit: 'U', IDC.Unit: 'U',
}[value] }[value]
@staticmethod @staticmethod
def to_icon(value: typ.Self) -> str: def to_icon(_: typ.Self) -> str:
IDC = InfoDisplayCol """No icons."""
return { return ''
IDC.Length: '',
IDC.MathType: '',
IDC.Unit: '',
}[value]
#################### ####################
@ -109,6 +109,7 @@ class ExprBLSocket(base.MaxwellSimSocket):
socket_type = ct.SocketType.Expr socket_type = ct.SocketType.Expr
bl_label = 'Expr' bl_label = 'Expr'
use_socket_color = True
#################### ####################
# - Socket Interface # - Socket Interface
@ -117,6 +118,58 @@ class ExprBLSocket(base.MaxwellSimSocket):
mathtype: spux.MathType = bl_cache.BLField(spux.MathType.Real) mathtype: spux.MathType = bl_cache.BLField(spux.MathType.Real)
physical_type: spux.PhysicalType = bl_cache.BLField(spux.PhysicalType.NonPhysical) physical_type: spux.PhysicalType = bl_cache.BLField(spux.PhysicalType.NonPhysical)
@bl_cache.cached_bl_property(
depends_on={
'active_kind',
'symbols',
'raw_value_spstr',
'raw_min_spstr',
'raw_max_spstr',
'output_name',
'mathtype',
'physical_type',
'unit',
'size',
}
)
def output_sym(self) -> sim_symbols.SimSymbol | None:
"""Compute an appropriate `SimSymbol` to represent the mathematical and physical properties of the socket's own output.
For the parsed string expression, functionality is derived heavily from the internal method `self._parse_expr_symbol()`.
Raises:
NotImplementedError: When `active_kind` is neither `Value`, `Func`, or `Range`.
"""
if self.symbols:
if self.active_kind in [ct.FlowKind.Value, ct.FlowKind.Func]:
return self._parse_expr_symbol(
self._parse_expr_str(self.raw_value_spstr)
)
if self.active_kind is ct.FlowKind.Range:
## TODO: Support RangeFlow
## -- It's hard; we need a min-span set over bound domains.
## -- We... Don't use this anywhere. Yet?
# sym_start = self._parse_expr_symbol(
# self._parse_expr_str(self.raw_min_spstr)
# )
# sym_stop = self._parse_expr_symbol(
# self._parse_expr_str(self.raw_max_spstr)
# )
msg = 'RangeFlow support not yet implemented for when self.symbols is not empty'
raise NotImplementedError(msg)
raise NotImplementedError
return sim_symbols.SimSymbol(
sym_name=self.output_name,
mathtype=self.mathtype,
physical_type=self.physical_type,
unit=self.unit,
rows=self.size.rows,
cols=self.size.cols,
)
#################### ####################
# - Symbols # - Symbols
#################### ####################
@ -140,6 +193,11 @@ class ExprBLSocket(base.MaxwellSimSocket):
"""Computes `sympy` symbols from `self.sorted_symbols`.""" """Computes `sympy` symbols from `self.sorted_symbols`."""
return [sym.sp_symbol_matsym for sym in self.sorted_symbols] return [sym.sp_symbol_matsym for sym in self.sorted_symbols]
@bl_cache.cached_bl_property(depends_on={'symbols'})
def sorted_symbol_names(self) -> list[sp.Symbol | sp.MatrixSymbol]:
"""Computes the name of symbols in `self.sorted_symbols`."""
return [sym.name for sym in self.sorted_symbols]
#################### ####################
# - Units # - Units
#################### ####################
@ -171,8 +229,13 @@ class ExprBLSocket(base.MaxwellSimSocket):
return None return None
@property @bl_cache.cached_bl_property(depends_on={'unit'})
def unit_factor(self) -> spux.Unit | None: def unit_factor(self) -> spux.Unit | None:
"""Gets the current active unit as a factor, where unitless is `1`.
Returns:
Same as `self.unit`, except `1` instead of `None` when there is no units.
"""
return sp.Integer(1) if self.unit is None else self.unit return sp.Integer(1) if self.unit is None else self.unit
prev_unit: str | None = bl_cache.BLField(None) prev_unit: str | None = bl_cache.BLField(None)
@ -228,26 +291,92 @@ class ExprBLSocket(base.MaxwellSimSocket):
#################### ####################
# - Computed String Expressions # - Computed String Expressions
#################### ####################
@bl_cache.cached_bl_property(depends_on={'raw_value_spstr'}) @bl_cache.cached_bl_property(
depends_on={'raw_value_spstr', 'sorted_symbol_names', 'symbols'}
)
def raw_value_sp(self) -> spux.SympyExpr: def raw_value_sp(self) -> spux.SympyExpr:
"""Parse the given symbolic `FlowKind.Value` string into a `sympy` expression.
Notes:
The `self.*` properties used by `_parse_expr_str` must be included in the `depends_on` of any `cached_bl_property`s that use it.
Directly derived from the internal method `self._parse_expr_str()`, which acts on `raw_value_spstr`.
"""
return self._parse_expr_str(self.raw_value_spstr) return self._parse_expr_str(self.raw_value_spstr)
@bl_cache.cached_bl_property(depends_on={'raw_min_spstr'}) @bl_cache.cached_bl_property(
depends_on={'raw_min_spstr', 'sorted_symbol_names', 'symbols'}
)
def raw_min_sp(self) -> spux.SympyExpr: def raw_min_sp(self) -> spux.SympyExpr:
"""Parse the given symbolic `FlowKind.Range` string (for the lower bound) into a `sympy` expression.
Notes:
The `self.*` properties used by `_parse_expr_str` must be included in the `depends_on` of any `cached_bl_property`s that use it.
Directly derived from the internal method `self._parse_expr_str()`, which acts on `raw_min_spstr`.
"""
return self._parse_expr_str(self.raw_min_spstr) return self._parse_expr_str(self.raw_min_spstr)
@bl_cache.cached_bl_property(depends_on={'raw_max_spstr'}) @bl_cache.cached_bl_property(
depends_on={'raw_max_spstr', 'sorted_symbol_names', 'symbols'}
)
def raw_max_sp(self) -> spux.SympyExpr: def raw_max_sp(self) -> spux.SympyExpr:
"""Parse the given symbolic `FlowKind.Range` string (for the upper bound) into a `sympy` expression.
Notes:
The `self.*` properties used by `_parse_expr_str` must be included in the `depends_on` of any `cached_bl_property`s that use it.
Directly derived from the internal method `self._parse_expr_str()`, which acts on `raw_max_spstr`.
"""
return self._parse_expr_str(self.raw_max_spstr) return self._parse_expr_str(self.raw_max_spstr)
#################### ####################
# - Prop-Change Callback # - Event Callbacks
#################### ####################
def on_socket_prop_changed(self, prop_name: str) -> None: def on_socket_data_changed(self, socket_kinds: set[ct.FlowKind]) -> None:
"""Alter the socket's color in response to flow.
- `FlowKind.Info`: Any change causes the socket color to be updated with the physical type of the output symbol.
Notes:
Overridden method called whenever `FlowEvent.LinkChanged` is generated on this socket, in response to link add/link remove.
See `MaxwellSimTree` for more detail on the link callbacks.
"""
## NODE: Depends on suppressed on_prop_changed
if ct.FlowKind.Info in socket_kinds:
info = self.compute_data(kind=ct.FlowKind.Info)
has_info = not ct.FlowSignal.check(info)
# Alter Color
pt_color = (
info.output.physical_type.color
if has_info
else self.physical_type.color
)
if self.socket_color != pt_color:
self.socket_color = pt_color
def on_socket_props_changed(
self,
cleared_blfields: set[
tuple[str, typ.Literal['invalidate', 'reset_enum', 'reset_strsearch']]
],
) -> None:
"""Alter the socket in response to local property changes.
Notes:
Overridden method called whenever `FlowEvent.LinkChanged` is generated on this socket, in response to link add/link remove.
See `MaxwellSimTree` for more detail on the link callbacks.
"""
## NODE: Depends on suppressed on_prop_changed
# Conditional Unit-Conversion # Conditional Unit-Conversion
## -> This is niche functionality, but the only way to convert units. ## -> This is niche functionality, but the only way to convert units.
## -> We can only catch 'unit' since it's at the end of a depschain. ## -> We can only catch 'unit' since it's at the end of a depschain.
if prop_name == 'unit': if ('unit', 'invalidate') in cleared_blfields:
# Check Unit Change # Check Unit Change
## -> self.prev_unit only updates here; "lags" behind self.unit. ## -> self.prev_unit only updates here; "lags" behind self.unit.
## -> 1. "Laggy" unit must be different than new unit. ## -> 1. "Laggy" unit must be different than new unit.
@ -272,37 +401,6 @@ class ExprBLSocket(base.MaxwellSimSocket):
#################### ####################
# - Value Utilities # - Value Utilities
#################### ####################
def _parse_expr_info(
self, expr: spux.SympyExpr
) -> tuple[spux.MathType, tuple[int, ...] | None, spux.UnitDimension]:
"""Parse a given expression for mathtype and size information.
Various compatibility checks are also performed, allowing this method to serve as a generic runtime validator/parser for any expressions that need to enter the socket.
"""
# Parse MathType
mathtype = spux.MathType.from_expr(expr)
if not self.mathtype.is_compatible(mathtype):
msg = f'MathType is {self.mathtype}, but tried to set expr {expr} with mathtype {mathtype}'
raise ValueError(msg)
# Parse Symbols
if expr.free_symbols and not expr.free_symbols.issubset(self.sp_symbols):
msg = f'Tried to set expr {expr} with free symbols {expr.free_symbols}, which is incompatible with socket symbols {self.symbols}'
raise ValueError(msg)
# Parse Dimensions
shape = spux.parse_shape(expr)
if not self.size.supports_shape(shape):
msg = f'Expr {expr} has non-1D shape {shape}, which is incompatible with the expr socket (shape {self.shape})'
raise ValueError(msg)
size = spux.NumberSize1D.from_shape(shape)
if self.size != size:
msg = f'Expr {expr} has 1D size {size}, which is incompatible with the expr socket (size {self.size})'
raise ValueError(msg)
return mathtype, size
def _to_raw_value(self, expr: spux.SympyExpr, force_complex: bool = False): def _to_raw_value(self, expr: spux.SympyExpr, force_complex: bool = False):
"""Cast the given expression to the appropriate raw value, with scaling guided by `self.unit`.""" """Cast the given expression to the appropriate raw value, with scaling guided by `self.unit`."""
if self.unit is not None: if self.unit is not None:
@ -324,38 +422,117 @@ class ExprBLSocket(base.MaxwellSimSocket):
return pyvalue return pyvalue
def _parse_expr_symbol(
self, expr: spux.SympyExpr | None
) -> sim_symbols.SimSymbol | None:
"""Deduce the `SimSymbol` corresponding to the given `expr`, else None."""
if expr is not None and (
not expr.free_symbols or expr.free_symbols.issubset(self.sp_symbols)
):
# Compute Units of Expression
## -> The output units may not be physically meaningful.
## -> However, "weird units" may be a good indicator of problems.
## -> So, we let the user shoot their foot off.
unit_expr = expr.subs(
{sym.sp_symbol: sym.unit_factor for sym in self.symbols}
)
return sim_symbols.SimSymbol.from_expr(
self.output_name, expr, unit_expr, optional=True
)
return None
def _parse_expr_str(self, expr_spstr: str) -> spux.SympyExpr | None: def _parse_expr_str(self, expr_spstr: str) -> spux.SympyExpr | None:
"""Parse an expression string by choosing opinionated options for `sp.sympify`. """Parse an expression string by choosing opinionated options for `sp.sympify`.
Uses `self._parse_expr_info()` to validate the parsed result. Uses `self._parse_expr_symbol()` to validate the parsed result.
Returns: Returns:
The parsed expression, if it manages to validate; else None. The parsed expression, if it manages to validate; else None.
""" """
expr = sp.sympify( expr = sp.parsing.sympy_parser.parse_expr(
expr_spstr,
locals={sym.name: sym.sp_symbol_matsym for sym in self.symbols},
strict=False,
convert_xor=True,
).subs(spux.UNIT_BY_SYMBOL)
# Try Parsing and Returning the Expression
try:
self._parse_expr_info(expr)
except ValueError:
log.exception(
'Couldn\'t parse expression "%s" in Expr socket.',
expr_spstr, expr_spstr,
local_dict=(
{sym.name: sym.sp_symbol_matsym for sym in self.symbols}
| {sym.name: unit for sym, unit in spux.UNIT_BY_SYMBOL.items()}
),
transformations=[
# Lambda Notation: Symbolic Anonymous Functions
## -> Interpret 'lambda: x/8' to sp.Lambda((), x/0)
sp.parsing.sympy_parser.lambda_notation,
# Automatic Symbols
## -> Interpret known functions as their sympy equivs.
## -> Interpret unknown 'x' as sp.Symbol('x')
## -> NOTE: Must check for extraneous/unwelcome unknowns.
sp.parsing.sympy_parser.auto_symbol,
# Repeated Decimals
## -> Interpret '0.2[1]' as 0.211111...
sp.parsing.sympy_parser.repeated_decimals,
# Number Literals
## -> Interpret ints/float literals.
## -> Interpret 'I' as the imaginary number literal.
## -> TODO: Maybe special-case the variable name 'I'?
sp.parsing.sympy_parser.auto_number,
# Factorial Notation
## -> Allow 'x!' to be the factorial of x.
sp.parsing.sympy_parser.factorial_notation,
# Rationalize Float -> Rational
## -> Helps numerical stability for pure-symbolic math.
## -> AFTER auto_number
sp.parsing.sympy_parser.rationalize,
# Carrot Exponentiation
## -> Interpret '^' as power, instead of as XOR.
sp.parsing.sympy_parser.convert_xor,
# Symbol Splitting
## -> Interpret 'xyz' as 'x*y*z' for convenience.
## -> NEVER split greek character names (ex. theta).
## -> NEVER split symbol names in 'self.symbols'.
sp.parsing.sympy_parser.split_symbols_custom(
predicate=lambda sym_name: (
sp.parsing.sympy_parser._token_splittable(sym_name) # noqa: SLF001
if sym_name not in self.sorted_symbol_names
else False
)
),
# Implicit Mult/Call
## -> Most times, allow '2x' as '2*x' / '2 x y' as '2*x*y'.
## -> Sometimes, allow 'sin 2x' as 'sin(2*x)'
## -> Allow functions to be exponentiated ex. 'sin^2 x'
sp.parsing.sympy_parser.implicit_multiplication,
sp.parsing.sympy_parser.implicit_application,
sp.parsing.sympy_parser.function_exponentiation,
],
) )
else:
return expr
if self._parse_expr_symbol(expr) is not None:
return expr
return None return None
#################### ####################
# - FlowKind: Value # - FlowKind: Value
#################### ####################
@property @bl_cache.cached_bl_property(
depends_on={
'symbols',
'unit',
'mathtype',
'size',
'raw_value_sp',
'raw_value_int',
'raw_value_rat',
'raw_value_float',
'raw_value_complex',
'raw_value_int2',
'raw_value_rat2',
'raw_value_float2',
'raw_value_complex2',
'raw_value_int3',
'raw_value_rat3',
'raw_value_float3',
'raw_value_complex3',
}
)
def value(self) -> spux.SympyExpr: def value(self) -> spux.SympyExpr:
"""Return the expression defined by the socket as `FlowKind.Value`. """Return the expression defined by the socket as `FlowKind.Value`.
@ -382,8 +559,8 @@ class ExprBLSocket(base.MaxwellSimSocket):
## -> ExprSocket doesn't support Vec4 (yet?). ## -> ExprSocket doesn't support Vec4 (yet?).
## -> I mean, have you _seen_ that mess of attributes up top? ## -> I mean, have you _seen_ that mess of attributes up top?
NS = spux.NumberSize1D NS = spux.NumberSize1D
if self.size == NS.Vec4: if self.size is NS.Vec4:
return ct.Flow return ct.FlowSignal.NoFlow
MT_Z = spux.MathType.Integer MT_Z = spux.MathType.Integer
MT_Q = spux.MathType.Rational MT_Q = spux.MathType.Rational
@ -430,7 +607,6 @@ class ExprBLSocket(base.MaxwellSimSocket):
Notes: Notes:
Called to set the internal `FlowKind.Value` of this socket. Called to set the internal `FlowKind.Value` of this socket.
""" """
_mathtype, _size = self._parse_expr_info(expr)
if self.symbols: if self.symbols:
self.raw_value_spstr = sp.sstr(expr) self.raw_value_spstr = sp.sstr(expr)
else: else:
@ -473,7 +649,22 @@ class ExprBLSocket(base.MaxwellSimSocket):
#################### ####################
# - FlowKind: Range # - FlowKind: Range
#################### ####################
@property @bl_cache.cached_bl_property(
depends_on={
'symbols',
'unit',
'mathtype',
'size',
'steps',
'scaling',
'raw_min_sp',
'raw_max_sp',
'raw_range_int',
'raw_range_rat',
'raw_range_float',
'raw_range_complex',
}
)
def lazy_range(self) -> ct.RangeFlow: def lazy_range(self) -> ct.RangeFlow:
"""Return the not-yet-computed uniform array defined by the socket. """Return the not-yet-computed uniform array defined by the socket.
@ -519,18 +710,18 @@ class ExprBLSocket(base.MaxwellSimSocket):
) )
@lazy_range.setter @lazy_range.setter
def lazy_range(self, value: ct.RangeFlow) -> None: def lazy_range(self, lazy_range: ct.RangeFlow) -> None:
"""Set the not-yet-computed uniform array defined by the socket. """Set the not-yet-computed uniform array defined by the socket.
Notes: Notes:
Called to compute the internal `FlowKind.Range` of this socket. Called to compute the internal `FlowKind.Range` of this socket.
""" """
self.steps = value.steps self.steps = lazy_range.steps
self.scaling = value.scaling self.scaling = lazy_range.scaling
if self.symbols: if self.symbols:
self.raw_min_spstr = sp.sstr(value.start) self.raw_min_spstr = sp.sstr(lazy_range.start)
self.raw_max_spstr = sp.sstr(value.stop) self.raw_max_spstr = sp.sstr(lazy_range.stop)
else: else:
MT_Z = spux.MathType.Integer MT_Z = spux.MathType.Integer
@ -538,32 +729,40 @@ class ExprBLSocket(base.MaxwellSimSocket):
MT_R = spux.MathType.Real MT_R = spux.MathType.Real
MT_C = spux.MathType.Complex MT_C = spux.MathType.Complex
unit = value.unit if value.unit is not None else 1 unit = lazy_range.unit if lazy_range.unit is not None else 1
if self.mathtype == MT_Z: if self.mathtype == MT_Z:
self.raw_range_int = [ self.raw_range_int = [
self._to_raw_value(bound * unit) self._to_raw_value(bound * unit)
for bound in [value.start, value.stop] for bound in [lazy_range.start, lazy_range.stop]
] ]
elif self.mathtype == MT_Q: elif self.mathtype == MT_Q:
self.raw_range_rat = [ self.raw_range_rat = [
self._to_raw_value(bound * unit) self._to_raw_value(bound * unit)
for bound in [value.start, value.stop] for bound in [lazy_range.start, lazy_range.stop]
] ]
elif self.mathtype == MT_R: elif self.mathtype == MT_R:
self.raw_range_float = [ self.raw_range_float = [
self._to_raw_value(bound * unit) self._to_raw_value(bound * unit)
for bound in [value.start, value.stop] for bound in [lazy_range.start, lazy_range.stop]
] ]
elif self.mathtype == MT_C: elif self.mathtype == MT_C:
self.raw_range_complex = [ self.raw_range_complex = [
self._to_raw_value(bound * unit, force_complex=True) self._to_raw_value(bound * unit, force_complex=True)
for bound in [value.start, value.stop] for bound in [lazy_range.start, lazy_range.stop]
] ]
#################### ####################
# - FlowKind: Func (w/Params if Constant) # - FlowKind: Func (w/Params if Constant)
#################### ####################
@property @bl_cache.cached_bl_property(
depends_on={
'value',
'symbols',
'sorted_sp_symbols',
'sorted_symbols',
'output_sym',
}
)
def lazy_func(self) -> ct.FuncFlow: def lazy_func(self) -> ct.FuncFlow:
"""Returns a lazy value that computes the expression returned by `self.value`. """Returns a lazy value that computes the expression returned by `self.value`.
@ -574,15 +773,21 @@ class ExprBLSocket(base.MaxwellSimSocket):
## -> `self.value` is guaranteed to be an expression with unknowns. ## -> `self.value` is guaranteed to be an expression with unknowns.
## -> The function computes `self.value` with unknowns as arguments. ## -> The function computes `self.value` with unknowns as arguments.
if self.symbols: if self.symbols:
value = self.value
has_value = not ct.FlowSignal.check(value)
output_sym = self.output_sym
if output_sym is not None and has_value:
return ct.FuncFlow( return ct.FuncFlow(
func=sp.lambdify( func=sp.lambdify(
self.sorted_sp_symbols, self.sorted_sp_symbols,
spux.strip_unit_system(self.value), output_sym.conform(value, strip_unit=True),
'jax', 'jax',
), ),
func_args=list(self.sorted_symbols), func_args=list(self.sorted_symbols),
supports_jax=True, supports_jax=True,
) )
return ct.FlowSignal.FlowPending
# Constant # Constant
## -> When a `self.value` has no unknowns, use a dummy function. ## -> When a `self.value` has no unknowns, use a dummy function.
@ -591,15 +796,25 @@ class ExprBLSocket(base.MaxwellSimSocket):
## -> Generally only useful for operations with other expressions. ## -> Generally only useful for operations with other expressions.
return ct.FuncFlow( return ct.FuncFlow(
func=lambda v: v, func=lambda v: v,
func_args=[ func_args=[self.output_sym],
sim_symbols.SimSymbol.from_expr(
sim_symbols.SimSymbolName.Constant, self.value, self.unit_factor
)
],
supports_jax=True, supports_jax=True,
) )
@property @bl_cache.cached_bl_property(depends_on={'sorted_symbols'})
def is_differentiable(self) -> bool:
"""Whether all symbols are differentiable.
If there are no symbols, then there is nothing to differentiate, and thus the expression is differentiable.
"""
if not self.sorted_symbols:
return True
return all(
sym.mathtype in [spux.MathType.Real, spux.MathType.Complex]
for sym in self.sorted_symbols
)
@bl_cache.cached_bl_property(depends_on={'sorted_symbols', 'output_sym', 'value'})
def params(self) -> ct.ParamsFlow: def params(self) -> ct.ParamsFlow:
"""Returns parameter symbols/values to accompany `self.lazy_func`. """Returns parameter symbols/values to accompany `self.lazy_func`.
@ -611,19 +826,28 @@ class ExprBLSocket(base.MaxwellSimSocket):
## -> They should be realized later, ex. in a Viz node. ## -> They should be realized later, ex. in a Viz node.
## -> Therefore, we just dump the symbols. Easy! ## -> Therefore, we just dump the symbols. Easy!
## -> NOTE: func_args must have the same symbol order as was lambdified. ## -> NOTE: func_args must have the same symbol order as was lambdified.
if self.symbols: if self.sorted_symbols:
output_sym = self.output_sym
if output_sym is not None:
return ct.ParamsFlow( return ct.ParamsFlow(
func_args=[sym.sp_symbol_phy for sym in self.sorted_symbols], arg_targets=list(self.sorted_symbols),
func_args=[sym.sp_symbol for sym in self.sorted_symbols],
symbols=self.sorted_symbols, symbols=self.sorted_symbols,
is_differentiable=self.is_differentiable,
) )
return ct.FlowSignal.FlowPending
# Constant # Constant
## -> Simply pass self.value verbatim as a function argument. ## -> Simply pass self.value verbatim as a function argument.
## -> Easy dice, easy life! ## -> Easy dice, easy life!
return ct.ParamsFlow(func_args=[self.value]) return ct.ParamsFlow(
arg_targets=[self.output_sym],
func_args=[self.value],
is_differentiable=self.is_differentiable,
)
@property @bl_cache.cached_bl_property(depends_on={'sorted_symbols', 'output_sym'})
def info(self) -> ct.ArrayFlow: def info(self) -> ct.InfoFlow:
r"""Returns parameter symbols/values to accompany `self.lazy_func`. r"""Returns parameter symbols/values to accompany `self.lazy_func`.
The output name/size/mathtype/unit corresponds directly the `ExprSocket`. The output name/size/mathtype/unit corresponds directly the `ExprSocket`.
@ -634,37 +858,78 @@ class ExprBLSocket(base.MaxwellSimSocket):
Otherwise, only the output name/size/mathtype/unit corresponding to the socket is passed along. Otherwise, only the output name/size/mathtype/unit corresponding to the socket is passed along.
""" """
output_sym = sim_symbols.SimSymbol(
sym_name=self.output_name,
mathtype=self.mathtype,
physical_type=self.physical_type,
unit=self.unit,
rows=self.size.rows,
cols=self.size.cols,
)
# Constant # Constant
## -> The input SimSymbols become continuous dimensional indices. ## -> The input SimSymbols become continuous dimensional indices.
## -> All domain validity information is defined on the SimSymbol keys. ## -> All domain validity information is defined on the SimSymbol keys.
if self.symbols: if self.sorted_symbols:
output_sym = self.output_sym
if output_sym is not None:
return ct.InfoFlow( return ct.InfoFlow(
dims={sym: None for sym in self.sorted_symbols}, dims={sym: None for sym in self.sorted_symbols},
output=output_sym, output=self.output_sym,
) )
return ct.FlowSignal.FlowPending
# Constant # Constant
## -> We only need the output symbol to describe the raw data. ## -> We only need the output symbol to describe the raw data.
return ct.InfoFlow(output=output_sym) return ct.InfoFlow(output=self.output_sym)
#################### ####################
# - FlowKind: Capabilities # - FlowKind: Capabilities
#################### ####################
@property def linked_capabilities(self, info: ct.InfoFlow) -> ct.CapabilitiesFlow:
def capabilities(self) -> None: """When this socket is linked as an output socket, expose these capabilities instead of querying `self.capabilities`.
Only used when `use_linked_capabilities == True`.
"""
return ct.CapabilitiesFlow( return ct.CapabilitiesFlow(
socket_type=self.socket_type, socket_type=self.socket_type,
active_kind=self.active_kind, active_kind=self.active_kind,
allow_out_to_in={ct.FlowKind.Func: ct.FlowKind.Value}, allow_out_to_in={
ct.FlowKind.Func: ct.FlowKind.Value,
},
allow_out_to_in_if_matches={
ct.FlowKind.Value: (
ct.FlowKind.Func,
(
info.output.physical_type,
info.output.mathtype,
info.output.rows,
info.output.cols,
),
),
},
)
@bl_cache.cached_bl_property(depends_on={'active_kind', 'output_sym'})
def capabilities(self) -> ct.CapabilitiesFlow:
"""Expose capabilities for use when checking socket link compatibility.
Only used when `use_linked_capabilities == True`.
"""
output_sym = self.output_sym
if output_sym is not None:
allow_out_to_in_if_matches = {
ct.FlowKind.Value: (
ct.FlowKind.Func,
(
output_sym.physical_type,
output_sym.mathtype,
output_sym.rows,
output_sym.cols,
),
),
}
else:
allow_out_to_in_if_matches = {}
return ct.CapabilitiesFlow(
socket_type=self.socket_type,
active_kind=self.active_kind,
allow_out_to_in={
ct.FlowKind.Func: ct.FlowKind.Value,
},
allow_out_to_in_if_matches=allow_out_to_in_if_matches,
) )
#################### ####################
@ -692,6 +957,7 @@ class ExprBLSocket(base.MaxwellSimSocket):
Notes: Notes:
Whether information about the expression passing through a linked socket is shown is governed by `self.show_info_columns`. Whether information about the expression passing through a linked socket is shown is governed by `self.show_info_columns`.
""" """
if self.active_kind is ct.FlowKind.Func:
info = self.compute_data(kind=ct.FlowKind.Info) info = self.compute_data(kind=ct.FlowKind.Info)
has_info = not ct.FlowSignal.check(info) has_info = not ct.FlowSignal.check(info)
@ -715,6 +981,8 @@ class ExprBLSocket(base.MaxwellSimSocket):
text='', text='',
icon=ct.Icon.ToggleSocketInfo, icon=ct.Icon.ToggleSocketInfo,
) )
else:
row.label(text=text)
def draw_output_label_row(self, row: bpy.types.UILayout, text) -> None: def draw_output_label_row(self, row: bpy.types.UILayout, text) -> None:
"""Provide a dropdown for enabling the `InfoFlow` UI in the linked output label row. """Provide a dropdown for enabling the `InfoFlow` UI in the linked output label row.
@ -724,6 +992,7 @@ class ExprBLSocket(base.MaxwellSimSocket):
Notes: Notes:
Whether information about the expression passing through a linked socket is shown is governed by `self.show_info_columns`. Whether information about the expression passing through a linked socket is shown is governed by `self.show_info_columns`.
""" """
if self.active_kind is ct.FlowKind.Func:
info = self.compute_data(kind=ct.FlowKind.Info) info = self.compute_data(kind=ct.FlowKind.Info)
has_info = not ct.FlowSignal.check(info) has_info = not ct.FlowSignal.check(info)
@ -749,6 +1018,8 @@ class ExprBLSocket(base.MaxwellSimSocket):
_col.label(text='') _col.label(text='')
else: else:
_row = row _row = row
else:
_row = row
_row.label(text=text) _row.label(text=text)
@ -860,42 +1131,38 @@ class ExprBLSocket(base.MaxwellSimSocket):
Uses `draw_value` to draw the base UI Uses `draw_value` to draw the base UI
""" """
if self.show_func_ui: if self.show_func_ui:
# Non-Symbolic: Size/Mathtype Selector
## -> Symbols imply str expr input.
## -> For arbitrary str exprs, size/mathtype are derived from the expr.
## -> Otherwise, size/mathtype must be pre-specified for a nice UI.
if not self.symbols:
row = col.row(align=True)
row.prop(self, self.blfields['size'], text='')
row.prop(self, self.blfields['mathtype'], text='')
# Base UI
## -> Draws the UI appropriate for the above choice of constraints.
self.draw_value(col)
# Physical Type Selector
## -> Determines whether/which unit-dropdown will be shown.
col.prop(self, self.blfields['physical_type'], text='')
# Symbol UI
## -> Draws the UI appropriate for the above choice of constraints.
## -> TODO
# Output Name Selector # Output Name Selector
## -> The name of the output ## -> The name of the output
if self.show_name_selector: if self.show_name_selector:
row = col.row() row = col.row()
row.alignment = 'CENTER'
row.prop(self, self.blfields['output_name'], text='Name') row.prop(self, self.blfields['output_name'], text='Name')
# Non-Symbolic: Size/Mathtype Selector
## -> Symbols imply str expr input.
## -> For arbitrary str exprs, size/mathtype are derived from the expr.
## -> Otherwise, size/mathtype must be pre-specified for a nice UI.
if self.symbols:
self.draw_value(col)
# TODO: Symbol UI
else:
row = col.row(align=True)
row.prop(self, self.blfields['size'], text='')
row.prop(self, self.blfields['mathtype'], text='')
self.draw_value(col)
col.prop(self, self.blfields['physical_type'], text='')
#################### ####################
# - UI: InfoFlow # - UI: InfoFlow
#################### ####################
def draw_info(self, info: ct.InfoFlow, col: bpy.types.UILayout) -> None: def draw_info(self, info: ct.InfoFlow, col: bpy.types.UILayout) -> None:
"""Visualize the `InfoFlow` information passing through the socket.""" """Visualize the `InfoFlow` information passing through the socket."""
if ( if (
self.active_kind == ct.FlowKind.Func self.active_kind is ct.FlowKind.Func
and self.show_info_columns and self.show_info_columns
and self.is_linked and (self.is_linked or self.is_output)
): ):
row = col.row() row = col.row()
box = row.box() box = row.box()
@ -922,7 +1189,7 @@ class ExprBLSocket(base.MaxwellSimSocket):
if InfoDisplayCol.Length in self.info_columns: if InfoDisplayCol.Length in self.info_columns:
grid.label(text='', icon=ct.Icon.DataSocketOutput) grid.label(text='', icon=ct.Icon.DataSocketOutput)
if InfoDisplayCol.MathType in self.info_columns: if InfoDisplayCol.MathType in self.info_columns:
grid.label(text=info.output.def_label) grid.label(text=info.output.mathtype_size_label)
if InfoDisplayCol.Unit in self.info_columns: if InfoDisplayCol.Unit in self.info_columns:
grid.label(text=info.output.unit_label) grid.label(text=info.output.unit_label)
@ -935,7 +1202,6 @@ class ExprSocketDef(base.SocketDef):
active_kind: typ.Literal[ active_kind: typ.Literal[
ct.FlowKind.Value, ct.FlowKind.Value,
ct.FlowKind.Range, ct.FlowKind.Range,
ct.FlowKind.Array,
ct.FlowKind.Func, ct.FlowKind.Func,
] = ct.FlowKind.Value ] = ct.FlowKind.Value
output_name: sim_symbols.SimSymbolName = sim_symbols.SimSymbolName.Expr output_name: sim_symbols.SimSymbolName = sim_symbols.SimSymbolName.Expr
@ -1240,6 +1506,7 @@ class ExprSocketDef(base.SocketDef):
def init(self, bl_socket: ExprBLSocket) -> None: def init(self, bl_socket: ExprBLSocket) -> None:
bl_socket.active_kind = self.active_kind bl_socket.active_kind = self.active_kind
bl_socket.output_name = self.output_name bl_socket.output_name = self.output_name
bl_socket.use_linked_capabilities = True
# Socket Interface # Socket Interface
## -> Recall that auto-updates are turned off during init() ## -> Recall that auto-updates are turned off during init()

View File

@ -15,6 +15,7 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
import bpy import bpy
import pydantic as pyd
import tidy3d as td import tidy3d as td
from blender_maxwell.utils import bl_cache, logger from blender_maxwell.utils import bl_cache, logger
@ -59,7 +60,9 @@ class MaxwellBoundCondBLSocket(base.MaxwellSimSocket):
#################### ####################
# - FlowKind # - FlowKind
#################### ####################
@property @bl_cache.cached_bl_property(
depends_on={'active_kind', 'allow_axes', 'present_axes'}
)
def capabilities(self) -> ct.CapabilitiesFlow: def capabilities(self) -> ct.CapabilitiesFlow:
return ct.CapabilitiesFlow( return ct.CapabilitiesFlow(
socket_type=self.socket_type, socket_type=self.socket_type,
@ -68,7 +71,7 @@ class MaxwellBoundCondBLSocket(base.MaxwellSimSocket):
present_any=self.present_axes, present_any=self.present_axes,
) )
@property @bl_cache.cached_bl_property(depends_on={'default'})
def value(self) -> td.BoundaryEdge: def value(self) -> td.BoundaryEdge:
return self.default.tidy3d_boundary_edge return self.default.tidy3d_boundary_edge
@ -84,16 +87,20 @@ class MaxwellBoundCondSocketDef(base.SocketDef):
socket_type: ct.SocketType = ct.SocketType.MaxwellBoundCond socket_type: ct.SocketType = ct.SocketType.MaxwellBoundCond
default: ct.BoundCondType = ct.BoundCondType.Pml default: ct.BoundCondType = ct.BoundCondType.Pml
allow_axes: set[ct.SimSpaceAxis] = { allow_axes: set[ct.SimSpaceAxis] = pyd.Field(
default={
ct.SimSpaceAxis.X, ct.SimSpaceAxis.X,
ct.SimSpaceAxis.Y, ct.SimSpaceAxis.Y,
ct.SimSpaceAxis.Z, ct.SimSpaceAxis.Z,
} }
present_axes: set[ct.SimSpaceAxis] = { )
present_axes: set[ct.SimSpaceAxis] = pyd.Field(
default={
ct.SimSpaceAxis.X, ct.SimSpaceAxis.X,
ct.SimSpaceAxis.Y, ct.SimSpaceAxis.Y,
ct.SimSpaceAxis.Z, ct.SimSpaceAxis.Z,
} }
)
def init(self, bl_socket: MaxwellBoundCondBLSocket) -> None: def init(self, bl_socket: MaxwellBoundCondBLSocket) -> None:
bl_socket.default = self.default bl_socket.default = self.default

View File

@ -86,7 +86,9 @@ class MaxwellBoundCondsBLSocket(base.MaxwellSimSocket):
#################### ####################
# - Computation of Default Value # - Computation of Default Value
#################### ####################
@property @bl_cache.cached_bl_property(
depends_on={'x_pos', 'x_neg', 'y_pos', 'y_neg', 'z_pos', 'z_neg'}
)
def value(self) -> td.BoundarySpec: def value(self) -> td.BoundarySpec:
"""Compute a user-defined default value for simulation boundary conditions, from certain common/sensible options. """Compute a user-defined default value for simulation boundary conditions, from certain common/sensible options.

View File

@ -18,6 +18,7 @@ import bpy
import scipy as sc import scipy as sc
import sympy.physics.units as spu import sympy.physics.units as spu
import tidy3d as td import tidy3d as td
import tidy3d.plugins.adjoint as tdadj
from blender_maxwell.utils import bl_cache, logger from blender_maxwell.utils import bl_cache, logger
from blender_maxwell.utils import extra_sympy_units as spux from blender_maxwell.utils import extra_sympy_units as spux
@ -39,12 +40,14 @@ class MaxwellMediumBLSocket(base.MaxwellSimSocket):
#################### ####################
# - Properties # - Properties
#################### ####################
rel_permittivity: tuple[float, float] = bl_cache.BLField((1.0, 0.0), float_prec=2) eps_rel: tuple[float, float] = bl_cache.BLField((1.0, 0.0), float_prec=2)
differentiable: bool = bl_cache.BLField(False)
#################### ####################
# - FlowKinds # - FlowKinds
#################### ####################
@property @bl_cache.cached_bl_property(depends_on={'eps_rel', 'differentiable'})
def value(self) -> td.Medium: def value(self) -> td.Medium:
freq = ( freq = (
spu.convert_to( spu.convert_to(
@ -53,31 +56,49 @@ class MaxwellMediumBLSocket(base.MaxwellSimSocket):
) )
/ spu.hertz / spu.hertz
) )
if self.differentiable:
return tdadj.JaxMedium.from_nk(
n=self.eps_rel[0],
k=self.eps_rel[1],
freq=freq,
)
return td.Medium.from_nk( return td.Medium.from_nk(
n=self.rel_permittivity[0], n=self.eps_rel[0],
k=self.rel_permittivity[1], k=self.eps_rel[1],
freq=freq, freq=freq,
) )
@value.setter @value.setter
def value( def value(self, eps_rel: tuple[float, float]) -> None:
self, value: tuple[spux.ConstrSympyExpr(allow_variables=False), complex] self.eps_rel = eps_rel
) -> None:
rel_permittivity = value
self.rel_permittivity = (rel_permittivity.real, rel_permittivity.imag) @bl_cache.cached_bl_property(depends_on={'value', 'differentiable'})
def lazy_func(self) -> ct.FuncFlow:
return ct.FuncFlow(
func=lambda: self.value,
supports_jax=self.differentiable,
)
@bl_cache.cached_bl_property(depends_on={'differentiable'})
def params(self) -> ct.FuncFlow:
return ct.ParamsFlow(is_differentiable=self.differentiable)
#################### ####################
# - UI # - UI
#################### ####################
def draw_value(self, col: bpy.types.UILayout) -> None: def draw_value(self, col: bpy.types.UILayout) -> None:
split = col.split(factor=0.35, align=False) col.prop(
self, self.blfields['differentiable'], text='Differentiable', toggle=True
)
col.separator()
split = col.split(factor=0.25, align=False)
col = split.column(align=True) _col = split.column(align=True)
col.label(text='ϵ_r ()') _col.label(text='εᵣ')
col = split.column(align=True) _col = split.column(align=True)
col.prop(self, self.blfields['rel_permittivity'], text='') _col.prop(self, self.blfields['eps_rel'], text='')
#################### ####################
@ -90,7 +111,7 @@ class MaxwellMediumSocketDef(base.SocketDef):
default_permittivity_imag: float = 0.0 default_permittivity_imag: float = 0.0
def init(self, bl_socket: MaxwellMediumBLSocket) -> None: def init(self, bl_socket: MaxwellMediumBLSocket) -> None:
bl_socket.rel_permittivity = ( bl_socket.eps_rel = (
self.default_permittivity_real, self.default_permittivity_real,
self.default_permittivity_imag, self.default_permittivity_imag,
) )

View File

@ -49,7 +49,7 @@ class MaxwellSimGridBLSocket(base.MaxwellSimSocket):
#################### ####################
# - Computation of Default Value # - Computation of Default Value
#################### ####################
@property @bl_cache.cached_bl_property(depends_on={'min_steps_per_wl'})
def value(self) -> td.GridSpec: def value(self) -> td.GridSpec:
return td.GridSpec.auto( return td.GridSpec.auto(
min_steps_per_wvl=self.min_steps_per_wl, min_steps_per_wvl=self.min_steps_per_wl,

View File

@ -50,7 +50,9 @@ class ReloadFolderList(bpy.types.Operator):
tdcloud.TidyCloudTasks.update_tasks(bl_socket.existing_folder_id) tdcloud.TidyCloudTasks.update_tasks(bl_socket.existing_folder_id)
bl_socket.existing_folder_id = bl_cache.Signal.ResetEnumItems bl_socket.existing_folder_id = bl_cache.Signal.ResetEnumItems
bl_socket.existing_folder_id = bl_cache.Signal.InvalidateCache
bl_socket.existing_task_id = bl_cache.Signal.ResetEnumItems bl_socket.existing_task_id = bl_cache.Signal.ResetEnumItems
bl_socket.existing_task_id = bl_cache.Signal.InvalidateCache
return {'FINISHED'} return {'FINISHED'}
@ -77,7 +79,9 @@ class Authenticate(bpy.types.Operator):
bl_socket.api_key = '' bl_socket.api_key = ''
bl_socket.existing_folder_id = bl_cache.Signal.ResetEnumItems bl_socket.existing_folder_id = bl_cache.Signal.ResetEnumItems
bl_socket.existing_folder_id = bl_cache.Signal.InvalidateCache
bl_socket.existing_task_id = bl_cache.Signal.ResetEnumItems bl_socket.existing_task_id = bl_cache.Signal.ResetEnumItems
bl_socket.existing_task_id = bl_cache.Signal.InvalidateCache
return {'FINISHED'} return {'FINISHED'}
@ -102,62 +106,18 @@ class Tidy3DCloudTaskBLSocket(base.MaxwellSimSocket):
#################### ####################
# - Properties # - Properties
#################### ####################
api_key: str = bl_cache.BLField('', prop_ui=True, str_secret=True) api_key: str = bl_cache.BLField('', str_secret=True)
should_exist: bool = bl_cache.BLField(False) should_exist: bool = bl_cache.BLField(False)
new_task_name: str = bl_cache.BLField('')
####################
# - Properties: Cloud Folders
####################
existing_folder_id: enum.StrEnum = bl_cache.BLField( existing_folder_id: enum.StrEnum = bl_cache.BLField(
prop_ui=True, enum_cb=lambda self, _: self.search_cloud_folders() enum_cb=lambda self, _: self.search_cloud_folders()
)
existing_task_id: enum.StrEnum = bl_cache.BLField(
prop_ui=True, enum_cb=lambda self, _: self.search_cloud_tasks()
) )
new_task_name: str = bl_cache.BLField('', prop_ui=True)
####################
# - FlowKinds
####################
@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},
)
@property
def value(
self,
) -> ct.NewSimCloudTask | tdcloud.CloudTask | ct.FlowSignal:
if tdcloud.IS_AUTHENTICATED:
# Retrieve Folder
cloud_folder = tdcloud.TidyCloudFolders.folders().get(
self.existing_folder_id
)
if cloud_folder is None:
return ct.FlowSignal.NoFlow ## Folder deleted somewhere else
# Case: New Task
if not self.should_exist:
return ct.NewSimCloudTask(
task_name=self.new_task_name, cloud_folder=cloud_folder
)
# Case: Existing Task
if self.existing_task_id is not None:
cloud_task = tdcloud.TidyCloudTasks.tasks(cloud_folder).get(
self.existing_task_id
)
if cloud_folder is None:
return ct.FlowSignal.NoFlow ## Task deleted somewhere else
return cloud_task
return ct.FlowSignal.FlowPending
####################
# - Searchers
####################
def search_cloud_folders(self) -> list[ct.BLEnumElement]: def search_cloud_folders(self) -> list[ct.BLEnumElement]:
if tdcloud.IS_AUTHENTICATED: if tdcloud.IS_AUTHENTICATED:
return [ return [
@ -175,6 +135,13 @@ class Tidy3DCloudTaskBLSocket(base.MaxwellSimSocket):
return [] return []
####################
# - Properties: Cloud Tasks
####################
existing_task_id: enum.StrEnum = bl_cache.BLField(
enum_cb=lambda self, _: self.search_cloud_tasks()
)
def search_cloud_tasks(self) -> list[ct.BLEnumElement]: def search_cloud_tasks(self) -> list[ct.BLEnumElement]:
if self.existing_folder_id is None or not tdcloud.IS_AUTHENTICATED: if self.existing_folder_id is None or not tdcloud.IS_AUTHENTICATED:
return [] return []
@ -228,6 +195,54 @@ class Tidy3DCloudTaskBLSocket(base.MaxwellSimSocket):
) )
] ]
####################
# - FlowKinds
####################
@bl_cache.cached_bl_property(depends_on={'active_kind', 'should_exist'})
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},
)
@bl_cache.cached_bl_property(
depends_on={
'should_exist',
'new_task_name',
'existing_folder_id',
'existing_task_id',
}
)
def value(
self,
) -> ct.NewSimCloudTask | tdcloud.CloudTask | ct.FlowSignal:
if tdcloud.IS_AUTHENTICATED:
# Retrieve Folder
cloud_folder = tdcloud.TidyCloudFolders.folders().get(
self.existing_folder_id
)
if cloud_folder is None:
return ct.FlowSignal.NoFlow ## Folder deleted somewhere else
# Case: New Task
if not self.should_exist:
return ct.NewSimCloudTask(
task_name=self.new_task_name, cloud_folder=cloud_folder
)
# Case: Existing Task
if self.existing_task_id is not None:
cloud_task = tdcloud.TidyCloudTasks.tasks(cloud_folder).get(
self.existing_task_id
)
if cloud_folder is None:
return ct.FlowSignal.NoFlow ## Task deleted somewhere else
return cloud_task
return ct.FlowSignal.FlowPending
#################### ####################
# - UI # - UI
#################### ####################

View File

@ -16,6 +16,7 @@
"""Implements various key caches on instances of Blender objects, especially nodes and sockets.""" """Implements various key caches on instances of Blender objects, especially nodes and sockets."""
import contextlib
import functools import functools
import inspect import inspect
import typing as typ import typing as typ
@ -166,7 +167,7 @@ class BLField:
self.cb_depends_on: set[str] | None = cb_depends_on self.cb_depends_on: set[str] | None = cb_depends_on
# Update Suppressing # Update Suppressing
self.suppress_update: dict[str, bool] = {} self.suppressed_update: dict[str, bool] = {}
#################### ####################
# - Descriptor Setup # - Descriptor Setup
@ -253,9 +254,38 @@ class BLField:
return self.bl_prop.default_value ## TODO: Good idea? return self.bl_prop.default_value ## TODO: Good idea?
return cached_value return cached_value
def suppress_next_update(self, bl_instance) -> None: @contextlib.contextmanager
self.suppress_update[bl_instance.instance_id] = True def suppress_update(self, bl_instance: bl_instance.BLInstance) -> None:
## TODO: Make it a context manager to prevent the worst of surprises """A context manager that suppresses all calls to `on_prop_changed()` for fields of the given `bl_instance` while active.
Any change to a `BLProp` managed by this descriptor inevitably trips `bl_instance.on_bl_prop_changed()`.
In response to these changes, `bl_instance.on_bl_prop_changed()` always signals the `Signal.InvalidateCache` via this descriptor.
Unless something interferes, this results in a call to `bl_instance.on_prop_changed()`.
Usually, this is great.
But sometimes, like when ex. refreshing enum items, we **want** to be able to set the value of the `BLProp` **without** triggering that `bl_instance.on_prop_changed()`.
By default, there is absolutely no way to accomplish this.
That's where this context manager comes into play.
While active, all calls to `bl_instance.on_prop_changed()` will be ignored for the given `bl_instance`, allowing us to freely set persistent properties without side effects.
Examples:
A simple illustrative example could look something like:
```python
with self.suppress_update(bl_instance):
self.bl_prop.write(bl_instance, 'I won't trigger an update')
self.bl_prop.write(bl_instance, 'I will trigger an update')
```
"""
self.suppressed_update[bl_instance.instance_id] = True
try:
yield
finally:
self.suppressed_update[bl_instance.instance_id] = False
## -> We could .pop(None).
## -> But keeping a reused memory location around is GC friendly.
def __set__( def __set__(
self, bl_instance: bl_instance.BLInstance | None, value: typ.Any self, bl_instance: bl_instance.BLInstance | None, value: typ.Any
@ -263,7 +293,7 @@ class BLField:
"""Sets the value described by the BLField. """Sets the value described by the BLField.
In general, any BLField modified in the UI will set `InvalidateCache` on this descriptor. In general, any BLField modified in the UI will set `InvalidateCache` on this descriptor.
If `self.prop_info['use_prop_update']` is set, the method `bl_instance.on_prop_changed(self.bl_prop.name)` will then be called and start a `FlowKind.DataChanged` event chain. If `self.prop_info['use_prop_update']` is set, the method `bl_instance.on_prop_changed(self.bl_prop.name)` will then be called and start a `FlowEvent.DataChanged` event chain.
Notes: Notes:
Run by Python when the attribute described by the descriptor is set. Run by Python when the attribute described by the descriptor is set.
@ -273,28 +303,29 @@ class BLField:
bl_instance: Instance that is accessing the attribute. bl_instance: Instance that is accessing the attribute.
owner: The class that owns the instance. owner: The class that owns the instance.
""" """
# Perform Update Chain
## -> We still respect 'use_prop_update', since it is user-sourced.
if value is Signal.DoUpdate:
if self.prop_info['use_prop_update']:
bl_instance.on_prop_changed(self.bl_prop.name)
# Invalidate Cache # Invalidate Cache
## -> This empties the non-persistent cache. ## -> This empties the non-persistent cache.
## -> As a result, the value must be reloaded from the property. ## -> As a result, the value must be reloaded from the property.
## The 'on_prop_changed' method on the bl_instance might also be called. ## The 'on_prop_changed' method on the bl_instance might also be called.
elif value is Signal.InvalidateCache or value is Signal.InvalidateCacheNoUpdate: if value is Signal.InvalidateCache or value is Signal.InvalidateCacheNoUpdate:
self.bl_prop.invalidate_nonpersist(bl_instance) self.bl_prop.invalidate_nonpersist(bl_instance)
# Update Suppression # Trigger Update Chain
if self.suppress_update.get(bl_instance.instance_id): ## -> User can disable w/'use_prop_update=False'.
self.suppress_update[bl_instance.instance_id] = False ## -> Use InvalidateCacheNoUpdate to explicitly disable update.
## -> If 'suppressed_update' context manager is active, don't update.
# ELSE: Trigger Update Chain if (
elif self.prop_info['use_prop_update'] and value is Signal.InvalidateCache: self.prop_info['use_prop_update']
and value is Signal.InvalidateCache
and not self.suppressed_update.get(bl_instance.instance_id, False)
):
bl_instance.on_prop_changed(self.bl_prop.name) bl_instance.on_prop_changed(self.bl_prop.name)
# Reset Enum Items # Reset Enum Items
## -> If there is no enum items callback, do nothing.
## -> Re-run the enum items callback and set it active.
## -> If the old item can be retained, then do so.
## -> Otherwise, set the first item.
elif value is Signal.ResetEnumItems: elif value is Signal.ResetEnumItems:
if self.bl_prop_enum_items is None: if self.bl_prop_enum_items is None:
return return
@ -335,7 +366,7 @@ class BLField:
# Swap Enum Items # Swap Enum Items
## -> This is the hot stuff - the enum elements are overwritten. ## -> This is the hot stuff - the enum elements are overwritten.
## -> The safe_enum_cb will pick up on this immediately. ## -> The safe_enum_cb will pick up on this immediately.
self.suppress_next_update(bl_instance) with self.suppress_update(bl_instance):
self.bl_prop_enum_items.write(bl_instance, current_items) self.bl_prop_enum_items.write(bl_instance, current_items)
# Old Item in Current Items # Old Item in Current Items
@ -344,9 +375,8 @@ class BLField:
## -> Thus, we set it - Blender sees a change, user doesn't. ## -> Thus, we set it - Blender sees a change, user doesn't.
## -> DO NOT trigger on_prop_changed (since "nothing changed"). ## -> DO NOT trigger on_prop_changed (since "nothing changed").
if any(raw_old_item == item[0] for item in current_items): if any(raw_old_item == item[0] for item in current_items):
self.suppress_next_update(bl_instance) with self.suppress_update(bl_instance):
self.bl_prop.write(bl_instance, old_item) self.bl_prop.write(bl_instance, old_item)
## -> TODO: Don't write if not needed.
# Old Item Not in Current Items # Old Item Not in Current Items
## -> In this case, fallback to the first current item. ## -> In this case, fallback to the first current item.
@ -355,28 +385,27 @@ class BLField:
raw_first_current_item = current_items[0][0] raw_first_current_item = current_items[0][0]
first_current_item = self.bl_prop.decode(raw_first_current_item) first_current_item = self.bl_prop.decode(raw_first_current_item)
self.suppress_next_update(bl_instance) with self.suppress_update(bl_instance):
self.bl_prop.write(bl_instance, first_current_item) self.bl_prop.write(bl_instance, first_current_item)
if self.prop_info['use_prop_update']:
bl_instance.on_prop_changed(self.bl_prop.name)
# Reset Str Search # Reset Str Search
## -> If there is no string search method, do nothing.
## -> Simply invalidate the non-persistent cache
elif value is Signal.ResetStrSearch: elif value is Signal.ResetStrSearch:
if self.bl_prop_str_search is None: if self.bl_prop_str_search is None:
return return
self.bl_prop_str_search.invalidate_nonpersist(bl_instance) self.bl_prop_str_search.invalidate_nonpersist(bl_instance)
# General __set__ # Default __set__
else: else:
with self.suppress_update(bl_instance):
self.bl_prop.write(bl_instance, value) self.bl_prop.write(bl_instance, value)
# Update Semantics # Update Semantics
if self.suppress_update.get(bl_instance.instance_id): if self.prop_info['use_prop_update'] and not self.suppressed_update.get(
self.suppress_update[bl_instance.instance_id] = False bl_instance.instance_id, False
):
elif self.prop_info['use_prop_update']:
bl_instance.on_prop_changed(self.bl_prop.name) bl_instance.on_prop_changed(self.bl_prop.name)
#################### ####################

View File

@ -16,6 +16,7 @@
"""Implements various key caches on instances of Blender objects, especially nodes and sockets.""" """Implements various key caches on instances of Blender objects, especially nodes and sockets."""
import contextlib
import inspect import inspect
import typing as typ import typing as typ
@ -76,7 +77,7 @@ class CachedBLProperty:
self.decode_type: type = inspect.signature(getter_method).return_annotation self.decode_type: type = inspect.signature(getter_method).return_annotation
# Write Suppressing # Write Suppressing
self.suppress_write: dict[str, bool] = {} self.suppressed_update: dict[str, bool] = {}
# Check Non-Empty Type Annotation # Check Non-Empty Type Annotation
## For now, just presume that all types can be encoded/decoded. ## For now, just presume that all types can be encoded/decoded.
@ -125,9 +126,38 @@ class CachedBLProperty:
return Signal.CacheNotReady return Signal.CacheNotReady
return cached_value return cached_value
def suppress_next_write(self, bl_instance) -> None: @contextlib.contextmanager
self.suppress_write[bl_instance.instance_id] = True def suppress_update(self, bl_instance: bl_instance.BLInstance) -> None:
## TODO: Make it a context manager to prevent the worst of surprises """A context manager that suppresses all calls to `on_prop_changed()` for fields of the given `bl_instance` while active.
Any change to a `BLProp` managed by this descriptor inevitably trips `bl_instance.on_bl_prop_changed()`.
In response to these changes, `bl_instance.on_bl_prop_changed()` always signals the `Signal.InvalidateCache` via this descriptor.
Unless something interferes, this results in a call to `bl_instance.on_prop_changed()`.
Usually, this is great.
But sometimes, like when ex. refreshing enum items, we **want** to be able to set the value of the `BLProp` **without** triggering that `bl_instance.on_prop_changed()`.
By default, there is absolutely no way to accomplish this.
That's where this context manager comes into play.
While active, all calls to `bl_instance.on_prop_changed()` will be ignored for the given `bl_instance`, allowing us to freely set persistent properties without side effects.
Examples:
A simple illustrative example could look something like:
```python
with self.suppress_update(bl_instance):
self.bl_prop.write(bl_instance, 'I won't trigger an update')
self.bl_prop.write(bl_instance, 'I will trigger an update')
```
"""
self.suppressed_update[bl_instance.instance_id] = True
try:
yield
finally:
self.suppressed_update[bl_instance.instance_id] = False
## -> We could .pop(None).
## -> But keeping a reused memory location around is GC friendly.
def __set__( def __set__(
self, bl_instance: bl_instance.BLInstance | None, value: typ.Any self, bl_instance: bl_instance.BLInstance | None, value: typ.Any
@ -141,43 +171,58 @@ class CachedBLProperty:
Parameters: Parameters:
bl_instance: The Blender object this prop bl_instance: The Blender object this prop
""" """
if value is Signal.DoUpdate: # Invalidate Cache
bl_instance.on_prop_changed(self.bl_prop.name) ## -> This empties the non-persistent cache.
## -> If persist=True, this also writes the persistent cache (no update).
elif value is Signal.InvalidateCache or value is Signal.InvalidateCacheNoUpdate: ## The 'on_prop_changed' method on the bl_instance might also be called.
if value is Signal.InvalidateCache or value is Signal.InvalidateCacheNoUpdate:
# Invalidate Partner Non-Persistent Caches # Invalidate Partner Non-Persistent Caches
## -> Only for the invalidation case do we also invalidate partners. ## -> Only for the invalidation case do we also invalidate partners.
if bl_instance is not None: if bl_instance is not None:
# Fill Caches # Fill Caches
## -> persist: Fill Persist and Non-Persist Cache ## -> persist=True: Fill Persist and Non-Persist Cache
## -> else: Fill Non-Persist Cache ## -> persist=False: Fill Non-Persist Cache
if self.persist and not self.suppress_write.get( if self.persist:
with self.suppress_update(bl_instance):
self.bl_prop.write(bl_instance, self.getter_method(bl_instance))
else:
self.bl_prop.write_nonpersist(
bl_instance, self.getter_method(bl_instance)
)
# Trigger Update
## -> Use InvalidateCacheNoUpdate to explicitly disable update.
## -> If 'suppress_update' context manager is active, don't update.
if value is Signal.InvalidateCache and not self.suppressed_update.get(
bl_instance.instance_id bl_instance.instance_id
): ):
self.bl_prop.write(bl_instance, self.getter_method(bl_instance))
else:
self.bl_prop.write_nonpersist(
bl_instance, self.getter_method(bl_instance)
)
if value == Signal.InvalidateCache:
bl_instance.on_prop_changed(self.bl_prop.name) bl_instance.on_prop_changed(self.bl_prop.name)
# Call Setter
elif self.setter_method is not None: elif self.setter_method is not None:
if bl_instance is not None:
# Run Setter # Run Setter
## -> The user-provided setter should do any updating of partners. ## -> The user-provided setter can set values as it sees fit.
if self.setter_method is not None: ## -> The user-provided setter will not immediately trigger updates.
with self.suppress_update(bl_instance):
self.setter_method(bl_instance, value) self.setter_method(bl_instance, value)
# Fill Non-Persistant (and maybe Persistent) Cache # Fill Caches
if self.persist and not self.suppress_write.get(bl_instance.instance_id): ## -> persist=True: Fill Persist and Non-Persist Cache
## -> persist=False: Fill Non-Persist Cache
if self.persist:
with self.suppress_update(bl_instance):
self.bl_prop.write(bl_instance, self.getter_method(bl_instance)) self.bl_prop.write(bl_instance, self.getter_method(bl_instance))
else: else:
self.bl_prop.write_nonpersist( self.bl_prop.write_nonpersist(
bl_instance, self.getter_method(bl_instance) bl_instance, self.getter_method(bl_instance)
) )
# Trigger Update
## -> If 'suppress_update' context manager is active, don't update.
if not self.suppressed_update.get(bl_instance.instance_id):
bl_instance.on_prop_changed(self.bl_prop.name) bl_instance.on_prop_changed(self.bl_prop.name)
else: else:

View File

@ -39,8 +39,6 @@ class Signal(enum.StrEnum):
InvalidateCache: The cache should be invalidated. InvalidateCache: The cache should be invalidated.
InvalidateCacheNoUpdate: The cache should be invalidated, but no update method should be run. InvalidateCacheNoUpdate: The cache should be invalidated, but no update method should be run.
DoUpdate: Any update method that the cache triggers on change should be run.
An update is **not guaranteeed** to be run, merely requested.
ResetEnumItems: Cached dynamic enum items should be recomputed on next use. ResetEnumItems: Cached dynamic enum items should be recomputed on next use.
ResetStrSearch: Cached string-search items should be recomputed on next use. ResetStrSearch: Cached string-search items should be recomputed on next use.
@ -53,7 +51,6 @@ class Signal(enum.StrEnum):
# Invalidation # Invalidation
InvalidateCache: str = str(uuid.uuid4()) InvalidateCache: str = str(uuid.uuid4())
InvalidateCacheNoUpdate: str = str(uuid.uuid4()) InvalidateCacheNoUpdate: str = str(uuid.uuid4())
DoUpdate: str = str(uuid.uuid4())
# Reset Signals # Reset Signals
## -> Invalidates data adjascent to fields. ## -> Invalidates data adjascent to fields.

View File

@ -220,7 +220,13 @@ class BLInstance:
for str_search_prop_name in self.blfields_str_search: for str_search_prop_name in self.blfields_str_search:
setattr(self, str_search_prop_name, bl_cache.Signal.ResetStrSearch) setattr(self, str_search_prop_name, bl_cache.Signal.ResetStrSearch)
def invalidate_blfield_deps(self, prop_name: str) -> None: def trace_blfields_to_clear(
self,
prop_name: str,
prev_blfields_to_clear: list[
tuple[str, typ.Literal['invalidate', 'reset_enum', 'reset_strsearch']]
] = (),
) -> list[str]:
"""Invalidates all properties that depend on `prop_name`. """Invalidates all properties that depend on `prop_name`.
A property can recursively depend on other properties, including specificity as to whether the cache should be invalidated, the enum items be recomputed, or the string search items be recomputed. A property can recursively depend on other properties, including specificity as to whether the cache should be invalidated, the enum items be recomputed, or the string search items be recomputed.
@ -232,36 +238,111 @@ class BLInstance:
The dictionaries governing exactly what invalidates what, and how, are encoded as `self.blfield_deps`, `self.blfield_dynamic_enum_deps`, and `self.blfield_str_search_deps`. The dictionaries governing exactly what invalidates what, and how, are encoded as `self.blfield_deps`, `self.blfield_dynamic_enum_deps`, and `self.blfield_str_search_deps`.
All of these are filled when creating the `BLInstance` subclass, using `self.declare_blfield_dep()`, generally via the `BLField` descriptor (which internally uses `BLProp`). All of these are filled when creating the `BLInstance` subclass, using `self.declare_blfield_dep()`, generally via the `BLField` descriptor (which internally uses `BLProp`).
""" """
if prev_blfields_to_clear:
blfields_to_clear = prev_blfields_to_clear.copy()
else:
blfields_to_clear = []
# Invalidate Dependent Properties (incl. DynEnums and StrSearch) # Invalidate Dependent Properties (incl. DynEnums and StrSearch)
## -> NOTE: Dependent props may also trigger `on_prop_changed`. ## -> InvalidateCacheNoUpdate: Exactly what it sounds like.
## -> Don't abuse dependencies :) ## -> ResetEnumItems: Won't trigger on_prop_changed.
for deps, invalidate_signal in zip( ## -> -- To get on_prop_changed after, do explicit 'InvalidateCache'.
## -> StrSearch: It's a straight computation, no on_prop_changed.
for deps, clear_method in zip(
[ [
self.blfield_deps, self.blfield_deps,
self.blfield_dynamic_enum_deps, self.blfield_dynamic_enum_deps,
self.blfield_str_search_deps, self.blfield_str_search_deps,
], ],
[ ['invalidate', 'reset_enum', 'reset_strsearch'],
bl_cache.Signal.InvalidateCache,
bl_cache.Signal.ResetEnumItems,
bl_cache.Signal.ResetStrSearch,
],
strict=True, strict=True,
): ):
if prop_name in deps: if prop_name in deps:
for dst_prop_name in deps[prop_name]: for dst_prop_name in deps[prop_name]:
log.debug( # Mark Dependency for Clearance
'%s: "%s" is invalidating "%s"', ## -> Duplicates are OK for now, we'll clear them later.
self.bl_label, blfields_to_clear.append((dst_prop_name, clear_method))
prop_name,
# Compute Recursive Dependencies for Clearance
## -> As we go deeper, 'previous fields' is set.
if dst_prop_name in self.blfields:
blfields_to_clear += self.trace_blfields_to_clear(
dst_prop_name, dst_prop_name,
prev_blfields_to_clear=blfields_to_clear,
) )
match (bool(prev_blfields_to_clear), bool(blfields_to_clear)):
# Nothing to Clear
## -> This is a recursive base case for no-dependency BLFields.
case (False, False):
return []
# Only Old: Return Old
## -> This is a recursive base case for the deepest field w/o deps.
## -> When there are previous BLFields, this cannot be recursive root
## -> Otherwise, we'd need to de-duplicate.
case (True, False):
return prev_blfields_to_clear ## Is never recursive root
# Only New: Deduplicate (from right) w/Order Preservation
## -> This is the recursive root.
## -> The first time there are new BLFields to clear, we dedupe.
## -> This is the ONLY case where we need to dedupe.
## -> Deduping deeper would be extraneous (though not damaging).
case (False, True):
return list(reversed(dict.fromkeys(reversed(blfields_to_clear))))
# New And Old: Concatenate
## -> This is merely a "transport" step, sandwiched btwn base/root.
## -> As such, deduplication would not be wrong, just extraneous.
## -> Since invalidation is in a hot-loop, don't do such things.
case (True, True):
return blfields_to_clear
def clear_blfields_after(self, prop_name: str) -> list[str]:
"""Clear (invalidate) all `BLField`s that have become invalid as a result of a change to `prop_name`.
Uses `self.trace_blfields_to_clear()` to deduce the names and unique ordering of `BLField`s to clear.
Then, update-less `bl_cache.Signal`s are written in order to invalidate each `BLField` cache without invoking `self.on_prop_changed()`.
Finally, the list of cleared `BLField`s is returned.
Notes:
Generally, this should be called from `on_prop_changed()`.
The resulting cleared fields can then be analyzed / used in a domain specific way as needed by the particular `BLInstance`.
Returns:
The topologically ordered right-de-duplicated list of BLFields that were cleared.
"""
blfields_to_clear = self.trace_blfields_to_clear(prop_name)
# Invalidate BLFields
## -> trace_blfields_to_clear only gave us what/how to invalidate.
## -> It's the responsibility of on_prop_changed to actually do so.
# log.debug(
# '%s (NodeSocket): Clearing BLFields after "%s": "%s"',
# self.bl_label,
# prop_name,
# blfields_to_clear,
# )
for blfield, clear_method in blfields_to_clear:
# log.debug(
# '%s (NodeSocket): Clearing BLField: %s (%s)',
# self.bl_label,
# blfield,
# clear_method,
# )
setattr( setattr(
self, self,
dst_prop_name, blfield,
invalidate_signal, {
'invalidate': bl_cache.Signal.InvalidateCacheNoUpdate,
'reset_enum': bl_cache.Signal.ResetEnumItems, ## No updates
'reset_strsearch': bl_cache.Signal.ResetStrSearch,
}[clear_method],
) )
return [(prop_name, 'invalidate'), *blfields_to_clear]
def on_bl_prop_changed(self, bl_prop_name: str, _: bpy.types.Context) -> None: def on_bl_prop_changed(self, bl_prop_name: str, _: bpy.types.Context) -> None:
"""Called when a property has been updated via the Blender UI. """Called when a property has been updated via the Blender UI.

View File

@ -44,6 +44,7 @@ from pydantic_core import core_schema as pyd_core_schema
from blender_maxwell import contracts as ct from blender_maxwell import contracts as ct
from . import logger from . import logger
from .staticproperty import staticproperty
log = logger.get(__name__) log = logger.get(__name__)
@ -69,7 +70,7 @@ class MathType(enum.StrEnum):
Complex = enum.auto() Complex = enum.auto()
@staticmethod @staticmethod
def combine(*mathtypes: list[typ.Self]) -> typ.Self: def combine(*mathtypes: list[typ.Self], optional: bool = False) -> typ.Self | None:
if MathType.Complex in mathtypes: if MathType.Complex in mathtypes:
return MathType.Complex return MathType.Complex
if MathType.Real in mathtypes: if MathType.Real in mathtypes:
@ -79,6 +80,9 @@ class MathType(enum.StrEnum):
if MathType.Integer in mathtypes: if MathType.Integer in mathtypes:
return MathType.Integer return MathType.Integer
if optional:
return None
msg = f"Can't combine mathtypes {mathtypes}" msg = f"Can't combine mathtypes {mathtypes}"
raise ValueError(msg) raise ValueError(msg)
@ -113,7 +117,7 @@ class MathType(enum.StrEnum):
return complex(pyobj, 0) return complex(pyobj, 0)
@staticmethod @staticmethod
def from_expr(sp_obj: SympyType) -> type: def from_expr(sp_obj: SympyType, optional: bool = False) -> type | None:
if isinstance(sp_obj, sp.MatrixBase): if isinstance(sp_obj, sp.MatrixBase):
return MathType.combine( return MathType.combine(
*[MathType.from_expr(v) for v in sp.flatten(sp_obj)] *[MathType.from_expr(v) for v in sp.flatten(sp_obj)]
@ -134,6 +138,9 @@ class MathType(enum.StrEnum):
if sp_obj in [sp.zoo, -sp.zoo]: if sp_obj in [sp.zoo, -sp.zoo]:
return MathType.Complex return MathType.Complex
if optional:
return None
msg = f"Can't determine MathType from sympy object: {sp_obj}" msg = f"Can't determine MathType from sympy object: {sp_obj}"
raise ValueError(msg) raise ValueError(msg)
@ -957,6 +964,48 @@ def unit_str_to_unit(unit_str: str) -> Unit | None:
#################### ####################
# - "Physical" Type # - "Physical" Type
#################### ####################
def unit_dim_to_unit_dim_deps(
unit_dims: SympyType,
) -> dict[spu.dimensions.Dimension, int] | None:
dimsys_SI = spu.systems.si.dimsys_SI
# Retrieve Dimensional Dependencies
try:
return dimsys_SI.get_dimensional_dependencies(unit_dims)
# Catch TypeError
## -> Happens if `+` or `-` is in `unit`.
## -> Generally, it doesn't make sense to add/subtract differing unit dims.
## -> Thus, when trying to figure out the unit dimension, there isn't one.
except TypeError:
return None
def unit_to_unit_dim_deps(
unit: SympyType,
) -> dict[spu.dimensions.Dimension, int] | None:
# Retrieve Dimensional Dependencies
## -> NOTE: .subs() alone seems to produce sp.Symbol atoms.
## -> This is extremely problematic; `Dims` arithmetic has key properties.
## -> So we have to go all the way to the dimensional dependencies.
## -> This isn't really respecting the args, but it seems to work :)
return unit_dim_to_unit_dim_deps(
unit.subs({arg: arg.dimension for arg in unit.atoms(spu.Quantity)})
)
def compare_unit_dims(unit_dim_l: SympyType, unit_dim_r: SympyType) -> bool:
return unit_dim_to_unit_dim_deps(unit_dim_l) == unit_dim_to_unit_dim_deps(
unit_dim_r
)
def compare_unit_dim_to_unit_dim_deps(
unit_dim: SympyType, unit_dim_deps: dict[spu.dimensions.Dimension, int]
) -> bool:
return unit_dim_to_unit_dim_deps(unit_dim) == unit_dim_deps
class PhysicalType(enum.StrEnum): class PhysicalType(enum.StrEnum):
"""Type identifiers for expressions with both `MathType` and a unit, aka a "physical" type.""" """Type identifiers for expressions with both `MathType` and a unit, aka a "physical" type."""
@ -1005,7 +1054,7 @@ class PhysicalType(enum.StrEnum):
Illuminance = enum.auto() Illuminance = enum.auto()
@functools.cached_property @functools.cached_property
def unit_dim(self): def unit_dim(self) -> SympyType:
PT = PhysicalType PT = PhysicalType
return { return {
PT.NonPhysical: None, PT.NonPhysical: None,
@ -1050,6 +1099,95 @@ class PhysicalType(enum.StrEnum):
PT.Illuminance: Dims.luminous_intensity / Dims.length**2, PT.Illuminance: Dims.luminous_intensity / Dims.length**2,
}[self] }[self]
@staticproperty
def unit_dims() -> dict[typ.Self, SympyType]:
return {
physical_type: physical_type.unit_dim
for physical_type in list(PhysicalType)
}
@functools.cached_property
def color(self):
"""A color corresponding to the physical type.
The color selections were initially generated using AI, as this is a rote task that's better adjusted than invented.
The LLM provided the following rationale for its choices:
> Non-Physical: Grey signifies neutrality and non-physical nature.
> Global:
> Time: Blue is often associated with calmness and the passage of time.
> Angle and Solid Angle: Different shades of blue and cyan suggest angular dimensions and spatial aspects.
> Frequency and Angular Frequency: Darker shades of blue to maintain the link to time.
> Cartesian:
> Length, Area, Volume: Shades of green to represent spatial dimensions, with intensity increasing with dimension.
> Mechanical:
> Velocity and Acceleration: Red signifies motion and dynamics, with lighter reds for related quantities.
> Mass: Dark red for the fundamental property.
> Force and Pressure: Shades of red indicating intensity.
> Energy:
> Work and Power: Orange signifies energy transformation, with lighter oranges for related quantities.
> Temperature: Yellow for heat.
> Electrodynamics:
> Current and related quantities: Cyan shades indicating flow.
> Voltage, Capacitance: Greenish and blueish cyan for electrical potential.
> Impedance, Conductance, Conductivity: Purples and magentas to signify resistance and conductance.
> Magnetic properties: Magenta shades for magnetism.
> Electric Field: Light blue.
> Magnetic Field: Grey, as it can be considered neutral in terms of direction.
> Luminal:
> Luminous properties: Yellows to signify light and illumination.
>
> This color mapping helps maintain intuitive connections for users interacting with these physical types.
"""
PT = PhysicalType
return {
PT.NonPhysical: (0.75, 0.75, 0.75, 1.0), # Light Grey: Non-physical
# Global
PT.Time: (0.5, 0.5, 1.0, 1.0), # Light Blue: Time
PT.Angle: (0.5, 0.75, 1.0, 1.0), # Light Blue: Angle
PT.SolidAngle: (0.5, 0.75, 0.75, 1.0), # Light Cyan: Solid Angle
PT.Freq: (0.5, 0.5, 0.9, 1.0), # Light Blue: Frequency
PT.AngFreq: (0.5, 0.5, 0.8, 1.0), # Light Blue: Angular Frequency
# Cartesian
PT.Length: (0.5, 1.0, 0.5, 1.0), # Light Green: Length
PT.Area: (0.6, 1.0, 0.6, 1.0), # Light Green: Area
PT.Volume: (0.7, 1.0, 0.7, 1.0), # Light Green: Volume
# Mechanical
PT.Vel: (1.0, 0.5, 0.5, 1.0), # Light Red: Velocity
PT.Accel: (1.0, 0.6, 0.6, 1.0), # Light Red: Acceleration
PT.Mass: (0.75, 0.5, 0.5, 1.0), # Light Red: Mass
PT.Force: (0.9, 0.5, 0.5, 1.0), # Light Red: Force
PT.Pressure: (1.0, 0.7, 0.7, 1.0), # Light Red: Pressure
# Energy
PT.Work: (1.0, 0.75, 0.5, 1.0), # Light Orange: Work
PT.Power: (1.0, 0.85, 0.5, 1.0), # Light Orange: Power
PT.PowerFlux: (1.0, 0.8, 0.6, 1.0), # Light Orange: Power Flux
PT.Temp: (1.0, 1.0, 0.5, 1.0), # Light Yellow: Temperature
# Electrodynamics
PT.Current: (0.5, 1.0, 1.0, 1.0), # Light Cyan: Current
PT.CurrentDensity: (0.5, 0.9, 0.9, 1.0), # Light Cyan: Current Density
PT.Charge: (0.5, 0.85, 0.85, 1.0), # Light Cyan: Charge
PT.Voltage: (0.5, 1.0, 0.75, 1.0), # Light Greenish Cyan: Voltage
PT.Capacitance: (0.5, 0.75, 1.0, 1.0), # Light Blueish Cyan: Capacitance
PT.Impedance: (0.6, 0.5, 0.75, 1.0), # Light Purple: Impedance
PT.Conductance: (0.7, 0.5, 0.8, 1.0), # Light Purple: Conductance
PT.Conductivity: (0.8, 0.5, 0.9, 1.0), # Light Purple: Conductivity
PT.MFlux: (0.75, 0.5, 0.75, 1.0), # Light Magenta: Magnetic Flux
PT.MFluxDensity: (
0.85,
0.5,
0.85,
1.0,
), # Light Magenta: Magnetic Flux Density
PT.Inductance: (0.8, 0.5, 0.8, 1.0), # Light Magenta: Inductance
PT.EField: (0.75, 0.75, 1.0, 1.0), # Light Blue: Electric Field
PT.HField: (0.75, 0.75, 0.75, 1.0), # Light Grey: Magnetic Field
# Luminal
PT.LumIntensity: (1.0, 0.95, 0.5, 1.0), # Light Yellow: Luminous Intensity
PT.LumFlux: (1.0, 0.95, 0.6, 1.0), # Light Yellow: Luminous Flux
PT.Illuminance: (1.0, 1.0, 0.75, 1.0), # Pale Yellow: Illuminance
}[self]
@functools.cached_property @functools.cached_property
def default_unit(self) -> list[Unit]: def default_unit(self) -> list[Unit]:
PT = PhysicalType PT = PhysicalType
@ -1256,17 +1394,59 @@ class PhysicalType(enum.StrEnum):
}[self] }[self]
@staticmethod @staticmethod
def from_unit(unit: Unit, optional: bool = False) -> list[Unit] | None: def from_unit(unit: Unit | None, optional: bool = False) -> typ.Self | None:
for physical_type in list(PhysicalType): """Attempt to determine a matching `PhysicalType` from a unit.
if unit in physical_type.valid_units:
NOTE: It is not guaranteed that `unit` is within `valid_units`, only that it can be converted to any unit in `valid_units`.
Returns:
The matched `PhysicalType`.
If none could be matched, then either return `None` (if `optional` is set) or error.
Raises:
ValueError: If no `PhysicalType` could be matched, and `optional` is `False`.
"""
if unit is None:
return ct.PhysicalType.NonPhysical
unit_dim_deps = unit_to_unit_dim_deps(unit)
if unit_dim_deps is not None:
for physical_type, candidate_unit_dim in PhysicalType.unit_dims.items():
if compare_unit_dim_to_unit_dim_deps(candidate_unit_dim, unit_dim_deps):
return physical_type return physical_type
## TODO: Optimize
if optional: if optional:
return None return None
msg = f'Could not determine PhysicalType for {unit}' msg = f'Could not determine PhysicalType for {unit}'
raise ValueError(msg) raise ValueError(msg)
@staticmethod
def from_unit_dim(
unit_dim: SympyType | None, optional: bool = False
) -> typ.Self | None:
"""Attempts to match an arbitrary unit dimension expression to a corresponding `PhysicalType`.
For comparing arbitrary unit dimensions (via expressions of `spu.dimensions.Dimension`), it is critical that equivalent dimensions are also compared as equal (ex. `mass*length/time^2 == force`).
To do so, we employ the `SI` unit conventions, for extracting the fundamental dimensional dependencies of unit dimension expressions.
Returns:
The matched `PhysicalType`.
If none could be matched, then either return `None` (if `optional` is set) or error.
Raises:
ValueError: If no `PhysicalType` could be matched, and `optional` is `False`.
"""
for physical_type, candidate_unit_dim in PhysicalType.unit_dims.items():
if compare_unit_dims(unit_dim, candidate_unit_dim):
return physical_type
if optional:
return None
msg = f'Could not determine PhysicalType for {unit_dim}'
raise ValueError(msg)
@functools.cached_property @functools.cached_property
def valid_shapes(self) -> list[typ.Literal[(3,), (2,)] | None]: def valid_shapes(self) -> list[typ.Literal[(3,), (2,)] | None]:
PT = PhysicalType PT = PhysicalType

View File

@ -17,7 +17,6 @@
"""Useful image processing operations for use in the addon.""" """Useful image processing operations for use in the addon."""
import enum import enum
import functools
import typing as typ import typing as typ
import jax import jax
@ -27,13 +26,13 @@ import matplotlib
import matplotlib.axis as mpl_ax import matplotlib.axis as mpl_ax
import matplotlib.backends.backend_agg import matplotlib.backends.backend_agg
import matplotlib.figure import matplotlib.figure
import matplotlib.style as mplstyle import numpy as np
import seaborn as sns import seaborn as sns
from blender_maxwell import contracts as ct from blender_maxwell import contracts as ct
from blender_maxwell.utils import extra_sympy_units as spux
from blender_maxwell.utils import logger from blender_maxwell.utils import logger
# mplstyle.use('fast') ## TODO: Does this do anything?
sns.set_theme() sns.set_theme()
log = logger.get(__name__) log = logger.get(__name__)
@ -139,7 +138,7 @@ def rgba_image_from_2d_map(
#################### ####################
# - MPL Helpers # - MPL Helpers
#################### ####################
@functools.lru_cache(maxsize=16) # @functools.lru_cache(maxsize=16)
def mpl_fig_canvas_ax(width_inches: float, height_inches: float, dpi: int): def mpl_fig_canvas_ax(width_inches: float, height_inches: float, dpi: int):
fig = matplotlib.figure.Figure( fig = matplotlib.figure.Figure(
figsize=[width_inches, height_inches], dpi=dpi, layout='tight' figsize=[width_inches, height_inches], dpi=dpi, layout='tight'
@ -160,9 +159,9 @@ def plot_box_plot_1d(data, ax: mpl_ax.Axis) -> None:
x_sym, y_sym = list(data.keys()) x_sym, y_sym = list(data.keys())
ax.boxplot([data[y_sym]]) ax.boxplot([data[y_sym]])
ax.set_title(f'{x_sym.name_pretty} -> {y_sym.name_pretty}') ax.set_title(f'{x_sym.name_pretty} {y_sym.name_pretty}')
ax.set_xlabel(x_sym.plot_label) ax.set_xlabel(x_sym.plot_label)
ax.set_xlabel(y_sym.plot_label) ax.set_ylabel(y_sym.plot_label)
def plot_bar(data, ax: mpl_ax.Axis) -> None: def plot_bar(data, ax: mpl_ax.Axis) -> None:
@ -173,26 +172,31 @@ def plot_bar(data, ax: mpl_ax.Axis) -> None:
ax.set_title(f'{x_sym.name_pretty} -> {heights_sym.name_pretty}') ax.set_title(f'{x_sym.name_pretty} -> {heights_sym.name_pretty}')
ax.set_xlabel(x_sym.plot_label) ax.set_xlabel(x_sym.plot_label)
ax.set_xlabel(heights_sym.plot_label) ax.set_ylabel(heights_sym.plot_label)
# () -> # () -> (| sometimes complex)
def plot_curve_2d(data, ax: mpl_ax.Axis) -> None: def plot_curve_2d(data, ax: mpl_ax.Axis) -> None:
x_sym, y_sym = list(data.keys()) x_sym, y_sym = list(data.keys())
if y_sym.mathtype is spux.MathType.Complex:
ax.plot(data[x_sym], data[y_sym].real, label='')
ax.plot(data[x_sym], data[y_sym].imag, label='𝕀')
ax.legend()
ax.plot(data[x_sym], data[y_sym]) ax.plot(data[x_sym], data[y_sym])
ax.set_title(f'{x_sym.name_pretty} -> {y_sym.name_pretty}') ax.set_title(f'{x_sym.name_pretty} {y_sym.name_pretty}')
ax.set_xlabel(x_sym.plot_label) ax.set_xlabel(x_sym.plot_label)
ax.set_xlabel(y_sym.plot_label) ax.set_ylabel(y_sym.plot_label)
def plot_points_2d(data, ax: mpl_ax.Axis) -> None: def plot_points_2d(data, ax: mpl_ax.Axis) -> None:
x_sym, y_sym = list(data.keys()) x_sym, y_sym = list(data.keys())
ax.scatter(data[x_sym], data[y_sym]) ax.scatter(data[x_sym], data[y_sym])
ax.set_title(f'{x_sym.name_pretty} -> {y_sym.name_pretty}') ax.set_title(f'{x_sym.name_pretty} {y_sym.name_pretty}')
ax.set_xlabel(x_sym.plot_label) ax.set_xlabel(x_sym.plot_label)
ax.set_xlabel(y_sym.plot_label) ax.set_ylabel(y_sym.plot_label)
# (, ) -> # (, ) ->
@ -202,34 +206,30 @@ def plot_curves_2d(data, ax: mpl_ax.Axis) -> None:
for i, label in enumerate(data[label_sym]): for i, label in enumerate(data[label_sym]):
ax.plot(data[x_sym], data[y_sym][:, i], label=label) ax.plot(data[x_sym], data[y_sym][:, i], label=label)
ax.set_title(f'{x_sym.name_pretty} -> {y_sym.name_pretty}') ax.set_title(f'{x_sym.name_pretty} {y_sym.name_pretty}')
ax.set_xlabel(x_sym.plot_label) ax.set_xlabel(x_sym.plot_label)
ax.set_xlabel(y_sym.plot_label) ax.set_ylabel(y_sym.plot_label)
ax.legend() ax.legend()
def plot_filled_curves_2d( def plot_filled_curves_2d(data, ax: mpl_ax.Axis) -> None:
data: jtyp.Float32[jtyp.Array, 'x_size 2'], info, ax: mpl_ax.Axis x_sym, _, y_sym = list(data.keys(data))
) -> None:
x_sym, _, y_sym = list(data.keys())
ax.fill_between(data[x_sym], data[y_sym][:, 0], data[x_sym], data[y_sym][:, 1]) ax.fill_between(data[x_sym], data[y_sym][:, 0], data[x_sym], data[y_sym][:, 1])
ax.set_title(f'{x_sym.name_pretty} -> {y_sym.name_pretty}') ax.set_title(f'{x_sym.name_pretty} {y_sym.name_pretty}')
ax.set_xlabel(x_sym.plot_label) ax.set_xlabel(x_sym.plot_label)
ax.set_xlabel(y_sym.plot_label) ax.set_ylabel(y_sym.plot_label)
ax.legend() ax.legend()
# (, ) -> # (, ) ->
def plot_heatmap_2d( def plot_heatmap_2d(data, ax: mpl_ax.Axis) -> None:
data: jtyp.Float32[jtyp.Array, 'x_size y_size'], info, ax: mpl_ax.Axis
) -> None:
x_sym, y_sym, c_sym = list(data.keys()) x_sym, y_sym, c_sym = list(data.keys())
heatmap = ax.imshow(data[c_sym], aspect='equal', interpolation='none') heatmap = ax.imshow(data[c_sym], aspect='equal', interpolation='none')
ax.figure.colorbar(heatmap, cax=ax) ax.figure.colorbar(heatmap, cax=ax)
ax.set_title(f'({x_sym.name_pretty}, {y_sym.name_pretty}) -> {c_sym.plot_label}') ax.set_title(f'({x_sym.name_pretty}, {y_sym.name_pretty}) {c_sym.plot_label}')
ax.set_xlabel(x_sym.plot_label) ax.set_xlabel(x_sym.plot_label)
ax.set_xlabel(y_sym.plot_label) ax.set_xlabel(y_sym.plot_label)
ax.legend() ax.legend()

View File

@ -24,7 +24,6 @@ from fractions import Fraction
import jaxtyping as jtyp import jaxtyping as jtyp
import pydantic as pyd import pydantic as pyd
import sympy as sp import sympy as sp
import sympy.physics.units as spu
from . import extra_sympy_units as spux from . import extra_sympy_units as spux
from . import logger, serialize from . import logger, serialize
@ -88,6 +87,7 @@ class SimSymbolName(enum.StrEnum):
Wavelength = enum.auto() Wavelength = enum.auto()
Frequency = enum.auto() Frequency = enum.auto()
Perm = enum.auto()
PermXX = enum.auto() PermXX = enum.auto()
PermYY = enum.auto() PermYY = enum.auto()
PermZZ = enum.auto() PermZZ = enum.auto()
@ -161,6 +161,7 @@ class SimSymbolName(enum.StrEnum):
# Optics # Optics
SSN.Wavelength: 'wl', SSN.Wavelength: 'wl',
SSN.Frequency: 'freq', SSN.Frequency: 'freq',
SSN.Perm: 'eps_r',
SSN.PermXX: 'eps_xx', SSN.PermXX: 'eps_xx',
SSN.PermYY: 'eps_yy', SSN.PermYY: 'eps_yy',
SSN.PermZZ: 'eps_zz', SSN.PermZZ: 'eps_zz',
@ -179,6 +180,7 @@ class SimSymbolName(enum.StrEnum):
SSN.LowerTheta: 'θ', SSN.LowerTheta: 'θ',
SSN.LowerPhi: 'φ', SSN.LowerPhi: 'φ',
# Fields # Fields
SSN.Er: 'Er',
SSN.Etheta: '', SSN.Etheta: '',
SSN.Ephi: '', SSN.Ephi: '',
SSN.Hr: 'Hr', SSN.Hr: 'Hr',
@ -186,10 +188,11 @@ class SimSymbolName(enum.StrEnum):
SSN.Hphi: '', SSN.Hphi: '',
# Optics # Optics
SSN.Wavelength: 'λ', SSN.Wavelength: 'λ',
SSN.Frequency: '𝑓', SSN.Frequency: 'fᵣ',
SSN.PermXX: 'ε_xx', SSN.Perm: 'εᵣ',
SSN.PermYY: 'ε_yy', SSN.PermXX: 'εᵣ[xx]',
SSN.PermZZ: 'ε_zz', SSN.PermYY: 'εᵣ[yy]',
SSN.PermZZ: 'εᵣ[zz]',
}.get(self, self.name) }.get(self, self.name)
@ -248,6 +251,8 @@ class SimSymbol(pyd.BaseModel):
## -> See self.domain. ## -> See self.domain.
## -> We have to deconstruct symbolic interval semantics a bit for UI. ## -> We have to deconstruct symbolic interval semantics a bit for UI.
is_constant: bool = False is_constant: bool = False
exclude_zero: bool = False
interval_finite_z: tuple[int, int] = (0, 1) interval_finite_z: tuple[int, int] = (0, 1)
interval_finite_q: tuple[tuple[int, int], tuple[int, int]] = ((0, 1), (1, 1)) interval_finite_q: tuple[tuple[int, int], tuple[int, int]] = ((0, 1), (1, 1))
interval_finite_re: tuple[float, float] = (0.0, 1.0) interval_finite_re: tuple[float, float] = (0.0, 1.0)
@ -284,20 +289,25 @@ class SimSymbol(pyd.BaseModel):
@functools.cached_property @functools.cached_property
def unit_label(self) -> str: def unit_label(self) -> str:
"""Pretty unit label, which is an empty string when there is no unit.""" """Pretty unit label, which is an empty string when there is no unit."""
return spux.sp_to_str(self.unit) if self.unit is not None else '' return spux.sp_to_str(self.unit.n(4)) if self.unit is not None else ''
@functools.cached_property
def name_unit_label(self) -> str:
"""Pretty name | unit label, which is just the name when there is no unit."""
if self.unit is None:
return self.name_pretty
return f'{self.name_pretty} | {self.unit_label}'
@functools.cached_property @functools.cached_property
def def_label(self) -> str: def def_label(self) -> str:
"""Pretty definition label, exposing the symbol definition.""" """Pretty definition label, exposing the symbol definition."""
return f'{self.name_pretty} | {self.unit_label}{self.mathtype_size_label}' return f'{self.name_unit_label}{self.mathtype_size_label}'
## TODO: Domain of validity from self.domain? ## TODO: Domain of validity from self.domain?
@functools.cached_property @functools.cached_property
def plot_label(self) -> str: def plot_label(self) -> str:
"""Pretty plot-oriented label.""" """Pretty plot-oriented label."""
return f'{self.name_pretty}' + ( return f'{self.name_pretty} ({self.unit_label})'
f'({self.unit})' if self.unit is not None else ''
)
#################### ####################
# - Computed Properties # - Computed Properties
@ -307,6 +317,11 @@ class SimSymbol(pyd.BaseModel):
"""Factor corresponding to the tracked unit, which can be multiplied onto exported values without `None`-checking.""" """Factor corresponding to the tracked unit, which can be multiplied onto exported values without `None`-checking."""
return self.unit if self.unit is not None else sp.S(1) return self.unit if self.unit is not None else sp.S(1)
@functools.cached_property
def unit_dim(self) -> spux.SympyExpr:
"""Unit dimension factor corresponding to the tracked unit, which can be multiplied onto exported values without `None`-checking."""
return self.unit if self.unit is not None else sp.S(1)
@functools.cached_property @functools.cached_property
def size(self) -> tuple[int, ...] | None: def size(self) -> tuple[int, ...] | None:
return { return {
@ -403,6 +418,29 @@ class SimSymbol(pyd.BaseModel):
case (False, False): case (False, False):
return sp.S(self.mathtype.coerce_compatible_pyobj(-1)) return sp.S(self.mathtype.coerce_compatible_pyobj(-1))
@functools.cached_property
def is_nonzero(self) -> bool:
if self.exclude_zero:
return True
def check_real_domain(real_domain):
return (
(
real_domain.left == 0
and real_domain.left_open
or real_domain.right == 0
and real_domain.right_open
)
or real_domain.left > 0
or real_domain.right < 0
)
if self.mathtype is spux.MathType.Complex:
return check_real_domain(self.domain[0]) and check_real_domain(
self.domain[1]
)
return check_real_domain(self.domain)
#################### ####################
# - Properties # - Properties
#################### ####################
@ -434,19 +472,11 @@ class SimSymbol(pyd.BaseModel):
mathtype_kwargs |= {'complex': True} mathtype_kwargs |= {'complex': True}
# Non-Zero Assumption # Non-Zero Assumption
if ( if self.is_nonzero:
(
self.domain.left == 0
and self.domain.left_open
or self.domain.right == 0
and self.domain.right_open
)
or self.domain.left > 0
or self.domain.right < 0
):
mathtype_kwargs |= {'nonzero': True} mathtype_kwargs |= {'nonzero': True}
# Positive/Negative Assumption # Positive/Negative Assumption
if self.mathtype is not spux.MathType.Complex:
if self.domain.left >= 0: if self.domain.left >= 0:
mathtype_kwargs |= {'positive': True} mathtype_kwargs |= {'positive': True}
elif self.domain.right <= 0: elif self.domain.right <= 0:
@ -521,8 +551,8 @@ class SimSymbol(pyd.BaseModel):
self.valid_domain_value, strip_unit=True self.valid_domain_value, strip_unit=True
), ),
# Defaults: FlowKind.Range # Defaults: FlowKind.Range
'default_min': self.domain.start, 'default_min': self.conform(self.domain.start, strip_unit=True),
'default_max': self.domain.end, 'default_max': self.conform(self.domain.end, strip_unit=True),
} }
msg = f'Tried to generate an ExprSocket from a SymSymbol "{self.name}", but its unit ({self.unit}) is not a valid unit of its physical type ({self.physical_type}) (SimSymbol={self})' msg = f'Tried to generate an ExprSocket from a SymSymbol "{self.name}", but its unit ({self.unit}) is not a valid unit of its physical type ({self.physical_type}) (SimSymbol={self})'
raise NotImplementedError(msg) raise NotImplementedError(msg)
@ -671,7 +701,9 @@ class SimSymbol(pyd.BaseModel):
sym_name: SimSymbolName, sym_name: SimSymbolName,
expr: spux.SympyExpr, expr: spux.SympyExpr,
unit_expr: spux.SympyExpr, unit_expr: spux.SympyExpr,
) -> typ.Self: is_constant: bool = False,
optional: bool = False,
) -> typ.Self | None:
"""Deduce a `SimSymbol` that matches the output of a given expression (and unit expression). """Deduce a `SimSymbol` that matches the output of a given expression (and unit expression).
This is an essential method, allowing for the ded This is an essential method, allowing for the ded
@ -697,12 +729,28 @@ class SimSymbol(pyd.BaseModel):
# MathType from Expr Assumptions # MathType from Expr Assumptions
## -> All input symbols have assumptions, because we are very pedantic. ## -> All input symbols have assumptions, because we are very pedantic.
## -> Therefore, we should be able to reconstruct the MathType. ## -> Therefore, we should be able to reconstruct the MathType.
mathtype = spux.MathType.from_expr(expr) mathtype = spux.MathType.from_expr(expr, optional=optional)
if mathtype is None:
return None
# PhysicalType as "NonPhysical" # PhysicalType as "NonPhysical"
## -> 'unit' still applies - but we can't guarantee a PhysicalType will. ## -> 'unit' still applies - but we can't guarantee a PhysicalType will.
## -> Therefore, this is what we gotta do. ## -> Therefore, this is what we gotta do.
if spux.uses_units(unit_expr):
simplified_unit_expr = sp.simplify(unit_expr)
expr_physical_type = spux.PhysicalType.from_unit(
simplified_unit_expr, optional=True
)
physical_type = (
spux.PhysicalType.NonPhysical
if expr_physical_type is None
else expr_physical_type
)
unit = simplified_unit_expr
else:
physical_type = spux.PhysicalType.NonPhysical physical_type = spux.PhysicalType.NonPhysical
unit = None
# Rows/Cols from Expr (if Matrix) # Rows/Cols from Expr (if Matrix)
rows, cols = expr.shape if isinstance(expr, sp.MatrixBase) else (1, 1) rows, cols = expr.shape if isinstance(expr, sp.MatrixBase) else (1, 1)
@ -711,9 +759,11 @@ class SimSymbol(pyd.BaseModel):
sym_name=sym_name, sym_name=sym_name,
mathtype=mathtype, mathtype=mathtype,
physical_type=physical_type, physical_type=physical_type,
unit=unit_expr if unit_expr != 1 else None, unit=unit,
rows=rows, rows=rows,
cols=cols, cols=cols,
is_constant=is_constant,
exclude_zero=expr.is_zero is not None and not expr.is_zero,
) )
#################### ####################