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,
CapabilitiesFlow,
FlowKind,
InfoFlow,
RangeFlow,
FuncFlow,
InfoFlow,
ParamsFlow,
PreviewsFlow,
RangeFlow,
ScalingMode,
ValueFlow,
)
@ -118,6 +119,7 @@ __all__ = [
'CapabilitiesFlow',
'FlowKind',
'InfoFlow',
'PreviewsFlow',
'RangeFlow',
'FuncFlow',
'ParamsFlow',

View File

@ -230,7 +230,6 @@ class BLSocketType(enum.StrEnum):
return {
# Blender
# Basic
BLST.Bool: MT.Bool,
# Float
BLST.Float: 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.
DisableLock: Indicates that the node/socket should disable locking.
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.
Is translated to `DataChanged` on sockets before propagation.
DataChanged: Indicates that data flowing through a node/socket was altered.
@ -50,15 +46,12 @@ class FlowEvent(enum.StrEnum):
EnableLock = enum.auto()
DisableLock = enum.auto()
# Preview Events
ShowPreview = enum.auto()
ShowPlot = enum.auto()
# Data Events
LinkChanged = enum.auto()
DataChanged = enum.auto()
# Non-Triggered Events
ShowPlot = enum.auto()
OutputRequested = enum.auto()
# Properties
@ -79,9 +72,6 @@ class FlowEvent(enum.StrEnum):
# Lock Events
FlowEvent.EnableLock: 'input',
FlowEvent.DisableLock: 'input',
# Preview Events
FlowEvent.ShowPreview: 'input',
FlowEvent.ShowPlot: 'input',
# Data Events
FlowEvent.LinkChanged: 'output',
FlowEvent.DataChanged: 'output',

View File

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

View File

@ -21,7 +21,6 @@ import typing as typ
import jaxtyping as jtyp
import numpy as np
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 logger
@ -117,13 +116,19 @@ class ArrayFlow:
new_unit: An (optional) new unit to scale the result to.
"""
# 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
rescale_expr = (
spux.scale_to_unit(rescale_func(a * self.unit), new_unit)
if self.unit is not None
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)
# Return ArrayFlow

View File

@ -24,9 +24,30 @@ from .flow_kinds import FlowKind
@dataclasses.dataclass(frozen=True, kw_only=True)
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
active_kind: FlowKind
# Relationships
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
@ -41,12 +62,19 @@ class CapabilitiesFlow:
def is_compatible_with(self, other: typ.Self) -> bool:
return other.is_universal or (
self.socket_type == other.socket_type
self.socket_type is other.socket_type
and (
self.active_kind == other.active_kind
self.active_kind is other.active_kind
or (
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

View File

@ -15,6 +15,7 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import enum
import functools
import typing as typ
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__)
_PROPERTY_NAMES = {
'capabilities',
'previews',
'value',
'array',
'lazy_range',
'lazy_func',
'params',
'info',
}
class FlowKind(enum.StrEnum):
"""Defines a kind of data that can flow between nodes.
@ -50,14 +62,15 @@ class FlowKind(enum.StrEnum):
"""
Capabilities = enum.auto()
Previews = enum.auto()
# Values
Value = enum.auto() ## 'value'
Array = enum.auto() ## 'array'
# Lazy
Func = enum.auto() ## 'lazy_func'
Range = enum.auto() ## 'lazy_range'
Func = enum.auto() ## 'lazy_func'
# Auxiliary
Params = enum.auto() ## 'params'
@ -70,12 +83,13 @@ class FlowKind(enum.StrEnum):
def to_name(v: typ.Self) -> str:
return {
FlowKind.Capabilities: 'Capabilities',
FlowKind.Previews: 'Previews',
# Values
FlowKind.Value: 'Value',
FlowKind.Array: 'Array',
# Lazy
FlowKind.Range: 'Range',
FlowKind.Func: 'Func',
FlowKind.Range: 'Range',
# Auxiliary
FlowKind.Params: 'Params',
FlowKind.Info: 'Info',
@ -88,6 +102,48 @@ class FlowKind(enum.StrEnum):
####################
# - 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
def active_kinds() -> list[typ.Self]:
"""Return a list of `FlowKind`s that are able to be considered "active".
@ -121,6 +177,7 @@ class FlowKind(enum.StrEnum):
####################
# - Class Methods
####################
## TODO: Remove this (only events uses it).
@classmethod
def scale_to_unit_system(
cls,

View File

@ -93,7 +93,7 @@ class InfoFlow:
return list(self.dims.keys())[idx]
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.
Can be used to index `.shape` of the represented raw array.
@ -102,6 +102,9 @@ class InfoFlow:
if len(dims_with_name) == 1:
return dims_with_name[0]
if optional:
return None
msg = f'Dim name {dim_name} not found in InfoFlow (or >1 found)'
raise ValueError(msg)
@ -127,14 +130,15 @@ class InfoFlow:
return False
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.
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.
"""
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:
"""The integer axis occupied by the dimension.
@ -194,7 +198,7 @@ class InfoFlow:
return {
dim.name_pretty: {
'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,
}
for dim, dim_idx in self.dims.items()
@ -315,27 +319,23 @@ class InfoFlow:
op: typ.Callable[[spux.SympyExpr, spux.SympyExpr], spux.SympyExpr],
unit_op: typ.Callable[[spux.SympyExpr, spux.SympyExpr], spux.SympyExpr],
) -> spux.SympyExpr:
if self.dims == other.dims:
sym_name = sim_symbols.SimSymbolName.Expr
expr = op(self.output.sp_symbol_phy, other.output.sp_symbol_phy)
unit_expr = unit_op(self.output.unit_factor, other.output.unit_factor)
sym_name = sim_symbols.SimSymbolName.Expr
expr = op(self.output.sp_symbol_phy, other.output.sp_symbol_phy)
unit_expr = unit_op(self.output.unit_factor, other.output.unit_factor)
## TODO: Handle per-cell matrix units?
return InfoFlow(
dims=self.dims,
output=sim_symbols.SimSymbol.from_expr(sym_name, expr, unit_expr),
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)
return InfoFlow(
dims=self.dims,
output=sim_symbols.SimSymbol.from_expr(sym_name, expr, unit_expr),
pinned_values=self.pinned_values,
)
####################
# - Operations: Fold
####################
def fold_last_input(self):
"""Fold the last input dimension into the output."""
last_key = list(self.dims.keys())[-1]
last_idx = list(self.dims.values())[-1]
last_idx = self.dims[self.last_dim]
rows = self.output.rows
cols = self.output.cols
@ -351,7 +351,9 @@ class InfoFlow:
return InfoFlow(
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,
pinned_values=self.pinned_values,

View File

@ -20,10 +20,14 @@ import typing as typ
from types import MappingProxyType
import jax
import jaxtyping as jtyp
from blender_maxwell.utils import extra_sympy_units as spux
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
log = logger.get(__name__)
@ -314,14 +318,66 @@ class FuncFlow:
) -> typ.Self:
if self.supports_jax:
return self.func_jax(
*params.scaled_func_args(self.func_args, symbol_values),
*params.scaled_func_kwargs(self.func_args, symbol_values),
*params.scaled_func_args(symbol_values),
**params.scaled_func_kwargs(symbol_values),
)
return self.func(
*params.scaled_func_args(self.func_kwargs, symbol_values),
*params.scaled_func_kwargs(self.func_kwargs, symbol_values),
*params.scaled_func_args(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
####################

View File

@ -218,9 +218,6 @@ class RangeFlow:
)
return combined_mathtype
####################
# - Methods
####################
@property
def ideal_midpoint(self) -> spux.SympyExpr:
return (self.stop + self.start) / 2
@ -229,6 +226,41 @@ class RangeFlow:
def ideal_range(self) -> spux.SympyExpr:
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(
self, rescale_func, reverse: bool = False, new_unit: spux.Unit | None = None
) -> typ.Self:
@ -612,8 +644,8 @@ class RangeFlow:
symbols=self.symbols,
)
return RangeFlow(
start=self.start * unit,
stop=self.stop * unit,
start=self.start,
stop=self.stop,
steps=self.steps,
scaling=self.scaling,
unit=unit,

View File

@ -31,8 +31,6 @@ from .expr_info import ExprInfo
from .flow_kinds import FlowKind
from .lazy_range import RangeFlow
# from .info import InfoFlow
log = logger.get(__name__)
@ -44,11 +42,18 @@ class ParamsFlow:
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_kwargs: dict[str, spux.SympyExpr] = dataclasses.field(default_factory=dict)
symbols: frozenset[sim_symbols.SimSymbol] = frozenset()
is_differentiable: bool = False
####################
# - Symbols
####################
@ -76,8 +81,9 @@ class ParamsFlow:
####################
# - JIT'ed Callables for Numerical Function Arguments
####################
@functools.cached_property
def func_args_n(
self, target_syms: list[sim_symbols.SimSymbol]
self,
) -> list[
typ.Callable[
[int | float | complex | jtyp.Inexact[jtyp.Array, '...'], ...],
@ -86,15 +92,12 @@ class ParamsFlow:
]:
"""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:
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()`.
Parameters:
target_syms: `SimSymbol`s describing how a particular `ParamsFlow` function argument should be scaled when performing a purely numerical insertion.
"""
return [
sp.lambdify(
@ -102,11 +105,14 @@ class ParamsFlow:
target_sym.conform(func_arg, strip_unit=True),
'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(
self, target_syms: dict[str, sim_symbols.SimSymbol]
self,
) -> dict[
str,
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()`
"""
return {
func_arg_key: sp.lambdify(
key: sp.lambdify(
self.sorted_sp_symbols,
target_syms[func_arg_key].scale(func_arg),
self.kwarg_targets[key].conform(func_arg, strip_unit=True),
'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(
self,
target_syms: list[sim_symbols.SimSymbol] = (),
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.
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:
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`._
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:
target_syms: `SimSymbol`s describing how the function arguments returned by this method are intended to be used.
**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`).
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`).
"""
realized_symbols = list(self.realize_symbols(symbol_values).values())
return [
func_arg_n(*realized_symbols)
for func_arg_n in self.func_args_n(target_syms)
]
return [func_arg_n(*realized_symbols) for func_arg_n in self.func_args_n]
def scaled_func_kwargs(
self,
target_syms: list[sim_symbols.SimSymbol] = (),
symbol_values: dict[spux.Symbol, spux.SympyExpr] = MappingProxyType({}),
) -> dict[
str, int | float | Fraction | float | complex | jtyp.Shaped[jtyp.Array, '...']
@ -233,7 +230,7 @@ class ParamsFlow:
realized_symbols = self.realize_symbols(symbol_values)
return {
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.
"""
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_kwargs=self.func_kwargs | other.func_kwargs,
symbols=self.symbols | other.symbols,
is_differentiable=self.is_differentiable & other.is_differentiable,
)
def compose_within(
self,
enclosing_arg_targets: list[sim_symbols.SimSymbol] = (),
enclosing_kwarg_targets: list[sim_symbols.SimSymbol] = (),
enclosing_func_args: list[spux.SympyExpr] = (),
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:
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_kwargs=self.func_kwargs | dict(enclosing_func_kwargs),
symbols=self.symbols | enclosing_symbols,
is_differentiable=(
self.is_differentiable
if not enclosing_symbols
else (self.is_differentiable & enclosing_is_differentiable)
),
)
####################
# - 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`.
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`)
"""
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:
msg = 'No support for >Vec3 / Matrix values in ExprInfo'
raise NotImplementedError(msg)
return {
sym.name: {
'active_kind': FlowKind.Value if not use_range else FlowKind.Range,
'default_steps': 50,
sym: {
'default_steps': 25,
}
| sym.expr_info
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()
## Inputs / Constants
ExprConstant = enum.auto()
SymbolConstant = enum.auto()
ScientificConstant = enum.auto()
UnitSystemConstant = enum.auto()
BlenderConstant = enum.auto()

View File

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

View File

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

View File

@ -124,12 +124,12 @@ class FilterOperation(enum.StrEnum):
# - Computed Properties
####################
@property
def func_args(self) -> list[spux.MathType]:
def func_args(self) -> list[sim_symbols.SimSymbol]:
FO = FilterOperation
return {
# Pin
FO.Pin: [spux.MathType.Integer],
FO.PinIdx: [spux.MathType.Integer],
FO.Pin: [sim_symbols.idx(None)],
FO.PinIdx: [sim_symbols.idx(None)],
}.get(self, [])
####################
@ -155,10 +155,10 @@ class FilterOperation(enum.StrEnum):
match self:
# 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:
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
case FO.PinLen1:
@ -272,10 +272,15 @@ class FilterMathNode(base.MaxwellSimNode):
# - Properties: Expr InfoFlow
####################
@events.on_value_changed(
# Trigger
socket_name={'Expr'},
# Loaded
input_sockets={'Expr'},
input_socket_kinds={'Expr': ct.FlowKind.Info},
input_sockets_optional={'Expr': True},
# Flow
## -> See docs in TransformMathNode
stop_propagation=True,
)
def on_input_exprs_changed(self, input_sockets) -> None: # noqa: D102
has_info = not ct.FlowSignal.check(input_sockets['Expr'])
@ -593,11 +598,17 @@ class FilterMathNode(base.MaxwellSimNode):
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
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

View File

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

View File

@ -210,7 +210,7 @@ class BinaryOperation(enum.StrEnum):
):
ops += [BO.Cross]
return ops
return ops_el_el + ops
## Vector | Matrix
case (1, 2):
@ -374,10 +374,15 @@ class OperateMathNode(base.MaxwellSimNode):
# - Properties
####################
@events.on_value_changed(
# Trigger
socket_name={'Expr L', 'Expr R'},
# Loaded
input_sockets={'Expr L', 'Expr R'},
input_socket_kinds={'Expr L': ct.FlowKind.Info, 'Expr R': ct.FlowKind.Info},
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
has_info_l = not ct.FlowSignal.check(input_sockets['Expr L'])

View File

@ -17,7 +17,6 @@
"""Declares `TransformMathNode`."""
import enum
import functools
import typing as typ
import bpy
@ -39,13 +38,25 @@ log = logger.get(__name__)
# - Operation Enum
####################
class TransformOperation(enum.StrEnum):
"""Valid operations for the `MapMathNode`.
"""Valid operations for the `TransformMathNode`.
Attributes:
FreqToVacWL: Transform frequency axes to be indexed by vacuum wavelength.
VacWLToFreq: Transform vacuum wavelength axes to be indexed by frequency.
FFT: Compute the fourier transform of the input expression.
InvFFT: Compute the inverse fourier transform of the input expression.
FreqToVacWL: Transform an frequency dimension to vacuum wavelength.
VacWLToFreq: Transform a vacuum wavelength dimension to frequency.
ConvertIdxUnit: Convert the unit of a dimension to a compatible unit.
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
@ -79,7 +90,7 @@ class TransformOperation(enum.StrEnum):
TO.VacWLToFreq: 'λᵥ → 𝑓',
TO.ConvertIdxUnit: 'Convert Dim',
TO.SetIdxUnit: 'Set Dim',
TO.FirstColToFirstIdx: '1st Col → Dim',
TO.FirstColToFirstIdx: '1st Col → 1st Dim',
# Fold
TO.IntDimToComplex: '',
TO.DimToVec: '→ Vector',
@ -87,10 +98,14 @@ class TransformOperation(enum.StrEnum):
## TODO: Vector to new last-dim integer
## TODO: Matrix to two last-dim integers
# Fourier
TO.FT1D: '𝑓',
TO.InvFT1D: '𝑓',
TO.FT1D: 'FT',
TO.InvFT1D: 'iFT',
}[value]
@property
def name(self) -> str:
return TransformOperation.to_name(self)
@staticmethod
def to_icon(_: typ.Self) -> str:
return ''
@ -108,49 +123,32 @@ class TransformOperation(enum.StrEnum):
####################
# - 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]:
TO = TransformOperation
match self:
case TO.FreqToVacWL | TO.FT1D:
case TO.FreqToVacWL:
return [
dim
for dim in info.dims
if dim.physical_type is spux.PhysicalType.Freq
]
case TO.VacWLToFreq | TO.InvFT1D:
case TO.VacWLToFreq:
return [
dim
for dim in info.dims
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)]
## ColDimToComplex: Implicit Last Dimension
@ -198,13 +196,11 @@ class TransformOperation(enum.StrEnum):
# Fold
## Last Dim -> Complex
if (
info.dims
# Output is Int|Rat|Real
len(info.dims) >= 1
and (
info.output.mathtype
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.has_idx_labels(info.last_dim)
and len(info.dims[info.last_dim]) == 2 # noqa: PLR2004
@ -231,14 +227,13 @@ class TransformOperation(enum.StrEnum):
####################
# - Function Properties
####################
@functools.cached_property
def jax_func(self):
def jax_func(self, axis: int | None = None):
TO = TransformOperation
return {
# Covariant Transform
## -> Freq <-> WL is a rescale (noop) AND flip (not noop).
TO.FreqToVacWL: lambda expr, axis: jnp.flip(expr, axis=axis),
TO.VacWLToFreq: lambda expr, axis: jnp.flip(expr, axis=axis),
TO.FreqToVacWL: lambda expr: jnp.flip(expr, axis=axis),
TO.VacWLToFreq: lambda expr: jnp.flip(expr, axis=axis),
TO.ConvertIdxUnit: lambda expr: expr,
TO.SetIdxUnit: lambda expr: expr,
TO.FirstColToFirstIdx: lambda expr: jnp.delete(expr, 0, axis=1),
@ -250,8 +245,8 @@ class TransformOperation(enum.StrEnum):
TO.DimToVec: lambda expr: expr,
TO.DimsToMat: lambda expr: expr,
# Fourier
TO.FT1D: lambda expr, axis: jnp.fft(expr, axis=axis),
TO.InvFT1D: lambda expr, axis: jnp.ifft(expr, axis=axis),
TO.FT1D: lambda expr: jnp.fft(expr, axis=axis),
TO.InvFT1D: lambda expr: jnp.ifft(expr, axis=axis),
}[self]
def transform_info(
@ -268,25 +263,21 @@ class TransformOperation(enum.StrEnum):
# Covariant Transform
TO.FreqToVacWL: lambda: info.replace_dim(
(f_dim := dim),
[
sim_symbols.wl(unit),
info.dims[f_dim].rescale(
lambda el: sci_constants.vac_speed_of_light / el,
reverse=True,
new_unit=unit,
),
],
sim_symbols.wl(unit),
info.dims[f_dim].rescale(
lambda el: sci_constants.vac_speed_of_light / el,
reverse=True,
new_unit=unit,
),
),
TO.VacWLToFreq: lambda: info.replace_dim(
(wl_dim := dim),
[
sim_symbols.freq(unit),
info.dims[wl_dim].rescale(
lambda el: sci_constants.vac_speed_of_light / el,
reverse=True,
new_unit=unit,
),
],
sim_symbols.freq(unit),
info.dims[wl_dim].rescale(
lambda el: sci_constants.vac_speed_of_light / el,
reverse=True,
new_unit=unit,
),
),
TO.ConvertIdxUnit: lambda: info.replace_dim(
dim,
@ -300,7 +291,9 @@ class TransformOperation(enum.StrEnum):
TO.SetIdxUnit: lambda: info.replace_dim(
dim,
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)
@ -311,10 +304,12 @@ class TransformOperation(enum.StrEnum):
TO.FirstColToFirstIdx: lambda: info.replace_dim(
info.first_dim,
info.first_dim.update(
sym_name=new_dim_name,
mathtype=spux.MathType.from_jax_array(data_col),
physical_type=physical_type,
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)),
# Fold
TO.IntDimToComplex: lambda: info.delete_dim(info.last_dim).update_output(
@ -380,10 +375,18 @@ class TransformMathNode(base.MaxwellSimNode):
# - Properties: Expr InfoFlow
####################
@events.on_value_changed(
# Trigger
socket_name={'Expr'},
# Loaded
input_sockets={'Expr'},
input_socket_kinds={'Expr': ct.FlowKind.Info},
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
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'})
def dim(self) -> sim_symbols.SimSymbol | 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
####################
@ -454,48 +457,52 @@ class TransformMathNode(base.MaxwellSimNode):
)
active_new_unit: enum.StrEnum = bl_cache.BLField(
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]:
if self.dim is not None:
if self.dim.physical_type is not spux.PhysicalType.NonPhysical:
unit_name = sp.sstr(self.dim.unit)
return [
(
sp.sstr(unit),
spux.sp_to_str(unit),
sp.sstr(unit),
'',
0,
)
for unit in self.dim.physical_type.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 [
(
sp.sstr(unit),
spux.sp_to_str(unit),
sp.sstr(unit),
'',
i,
TO = TransformOperation
match self.operation:
# Covariant Transform
case TO.ConvertIdxUnit if self.dim is not None:
physical_type = spux.PhysicalType.from_unit(
self.dim.unit, optional=True
)
for i, unit in enumerate(self.new_physical_type.valid_units)
]
if physical_type is not None:
valid_units = physical_type.valid_units
else:
valid_units = []
return []
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 = []
return [
(
sp.sstr(unit),
spux.sp_to_str(unit),
sp.sstr(unit),
'',
i,
)
for i, unit in enumerate(valid_units)
]
@bl_cache.cached_bl_property(depends_on={'active_new_unit'})
def new_unit(self) -> spux.Unit:
@ -507,30 +514,85 @@ class TransformMathNode(base.MaxwellSimNode):
####################
# - UI
####################
def draw_label(self):
if self.operation is not None:
return 'T: ' + TransformOperation.to_name(self.operation)
@bl_cache.cached_bl_property(depends_on={'new_unit'})
def new_unit_str(self) -> str:
if self.new_unit is None:
return ''
return spux.sp_to_str(self.new_unit)
return self.bl_label
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
def draw_props(self, _: bpy.types.Context, layout: bpy.types.UILayout) -> None:
layout.prop(self, self.blfields['operation'], text='')
if self.operation is not None and self.operation.num_dim_inputs == 1:
TO = TransformOperation
layout.prop(self, self.blfields['active_dim'], text='')
TO = TransformOperation
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)
if self.operation is TransformOperation.ConvertIdxUnit:
col.prop(self, self.blfields['active_new_unit'], text='')
row = col.row(align=True)
row.prop(self, self.blfields['new_name'], text='')
row.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.prop(self, self.blfields['new_physical_type'], text='')
row = col.row(align=True)
row.prop(self, self.blfields['new_name'], text='')
row.prop(self, self.blfields['active_new_unit'], text='')
case TO.FT1D | TO.InvFT1D:
layout.prop(self, self.blfields['active_dim'], text='')
####################
# - Compute: Func / Array
@ -538,23 +600,43 @@ class TransformMathNode(base.MaxwellSimNode):
@events.computes_output_socket(
'Expr',
kind=ct.FlowKind.Func,
props={'operation'},
# Loaded
props={'operation', 'dim'},
input_sockets={'Expr'},
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:
"""Transform the input `InfoFlow` depending on the transform operation."""
TO = TransformOperation
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)
if has_lazy_func and operation is not None:
return lazy_func.compose_within(
operation.jax_func,
supports_jax=True,
)
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(
operation.jax_func(axis=info.dim_axis(dim)),
supports_jax=True,
)
return ct.FlowSignal.FlowPending
case _:
return lazy_func.compose_within(
operation.jax_func(),
supports_jax=True,
)
return ct.FlowSignal.FlowPending
####################
@ -563,54 +645,101 @@ class TransformMathNode(base.MaxwellSimNode):
@events.computes_output_socket(
'Expr',
kind=ct.FlowKind.Info,
# Loaded
props={'operation', 'dim', 'new_name', 'new_unit', 'new_physical_type'},
input_sockets={'Expr'},
input_socket_kinds={
'Expr': {ct.FlowKind.Func, ct.FlowKind.Info, ct.FlowKind.Params}
},
)
def compute_info(
def compute_info( # noqa: PLR0911
self, props: dict, input_sockets: dict
) -> ct.InfoFlow | typ.Literal[ct.FlowSignal.FlowPending]:
"""Transform the input `InfoFlow` depending on the transform operation."""
TO = TransformOperation
operation = props['operation']
info = input_sockets['Expr'][ct.FlowKind.Info]
has_info = not ct.FlowSignal.check(info)
dim = props['dim']
new_name = props['new_name']
new_unit = props['new_unit']
new_physical_type = props['new_physical_type']
if has_info and operation is not None:
# First Column to First Index
## -> 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]
params = input_sockets['Expr'][ct.FlowKind.Params]
has_lazy_func = not ct.FlowSignal.check(lazy_func)
has_params = not ct.FlowSignal.check(lazy_func)
# Retrieve Properties
dim = props['dim']
new_name = props['new_name']
new_unit = props['new_unit']
new_physical_type = props['new_physical_type']
if has_lazy_func and has_params and not params.symbols:
# Retrieve Expression Data
lazy_func = input_sockets['Expr'][ct.FlowKind.Func]
params = input_sockets['Expr'][ct.FlowKind.Params]
has_lazy_func = not ct.FlowSignal.check(lazy_func)
has_params = not ct.FlowSignal.check(lazy_func)
# Match Pattern by Operation
match operation:
# Covariant Transform
## -> Needs: Dim, Unit
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
case TO.FreqToVacWL if dim is not None and new_unit is not None and new_unit in spux.PhysicalType.Length.valid_units:
return operation.transform_info(info, dim=dim, unit=new_unit)
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(
info,
dim=dim,
new_dim_name=new_name,
unit=new_unit,
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:
if data.shape is not None and len(data.shape) == 2: # noqa: PLR2004
data_col = data[:, 0]
return operation.transform_info(info, data_col=data_col)
return ct.FlowSignal.FlowPending
return operation.transform_info(
info,
new_dim_name=new_name,
data_col=data_col,
unit=new_unit,
physical_type=new_physical_type,
)
# Check Not-Yet-Updated Dimension
## - Operation changes before dimensions.
## - If InfoFlow is requested in this interim, big problem.
if dim is None and operation.num_dim_inputs > 0:
return ct.FlowSignal.FlowPending
# 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 operation.transform_info(
info,
dim=dim,
new_dim_name=new_name,
unit=new_unit,
physical_type=new_physical_type,
)
return ct.FlowSignal.FlowPending
####################
@ -619,30 +748,19 @@ class TransformMathNode(base.MaxwellSimNode):
@events.computes_output_socket(
'Expr',
kind=ct.FlowKind.Params,
props={'operation', 'dim'},
# Loaded
props={'operation'},
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:
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']
dim = props['dim']
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])
params = input_sockets['Expr']
has_params = not ct.FlowSignal.check(params)
if has_params and operation is not None:
return params
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."""
Z = spux.MathType.Integer
R = spux.MathType.Real
C = spux.MathType.Complex
VM = VizMode
return {
@ -115,6 +116,9 @@ class VizMode(enum.StrEnum):
VM.Points2D,
VM.Bar,
],
((R,), (1, 1, C)): [
VM.Curve2D,
],
((R, Z), (1, 1, R)): [
VM.Curves2D,
VM.FilledCurves2D,
@ -231,10 +235,15 @@ class VizNode(base.MaxwellSimNode):
## - Properties
#####################
@events.on_value_changed(
# Trigger
socket_name={'Expr'},
# Loaded
input_sockets={'Expr'},
input_socket_kinds={'Expr': ct.FlowKind.Info},
input_sockets_optional={'Expr': True},
# Flow
## -> See docs in TransformMathNode
stop_propagation=True,
)
def on_input_exprs_changed(self, input_sockets) -> None: # noqa: D102
has_info = not ct.FlowSignal.check(input_sockets['Expr'])
@ -326,7 +335,7 @@ class VizNode(base.MaxwellSimNode):
if self.viz_target is VizTarget.Plot2D:
row = col.row(align=True)
row.alignment = 'CENTER'
row.label(text='Width/Height/DPI')
row.label(text='Width | Height | DPI')
row = col.row(align=True)
row.prop(self, self.blfields['plot_width'], text='')
@ -339,8 +348,10 @@ class VizNode(base.MaxwellSimNode):
# - Events
####################
@events.on_value_changed(
# Trigger
socket_name='Expr',
run_on_init=True,
# Loaded
input_sockets={'Expr'},
input_socket_kinds={'Expr': {ct.FlowKind.Info, ct.FlowKind.Params}},
input_sockets_optional={'Expr': True},
@ -355,14 +366,19 @@ class VizNode(base.MaxwellSimNode):
# Declare Loose Sockets that Realize Symbols
## -> This happens if Params contains not-yet-realized symbols.
if has_info and has_params and params.symbols:
if set(self.loose_input_sockets) != {
sym.name for sym in params.symbols if sym in info.dims
}:
if set(self.loose_input_sockets) != {sym.name for sym in params.symbols}:
self.loose_input_sockets = {
dim_name: sockets.ExprSocketDef(**expr_info)
for dim_name, expr_info in params.sym_expr_infos(
use_range=True
).items()
sym.name: sockets.ExprSocketDef(
**(
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:
@ -373,9 +389,10 @@ class VizNode(base.MaxwellSimNode):
#####################
@events.computes_output_socket(
'Preview',
kind=ct.FlowKind.Value,
kind=ct.FlowKind.Previews,
# Loaded
props={
'sim_node_name',
'viz_mode',
'viz_target',
'colormap',
@ -391,7 +408,7 @@ class VizNode(base.MaxwellSimNode):
)
def compute_dummy_value(self, props, input_sockets, loose_input_sockets):
"""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
@ -416,6 +433,7 @@ class VizNode(base.MaxwellSimNode):
def on_show_plot(
self, managed_objs, props, input_sockets, loose_input_sockets
) -> None:
log.critical('Show Plot (too many times)')
lazy_func = input_sockets['Expr'][ct.FlowKind.Func]
info = input_sockets['Expr'][ct.FlowKind.Info]
params = input_sockets['Expr'][ct.FlowKind.Params]
@ -427,23 +445,17 @@ class VizNode(base.MaxwellSimNode):
viz_mode = props['viz_mode']
viz_target = props['viz_target']
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.
## -> These expressions are used to realize the lazy data.
## -> `.realize()` ensures all ex. units are correctly conformed.
realized_syms = {
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}
## -> These are used to get rid of symbols in the ParamsFlow.
## -> What's left is a dictionary from SimSymbol -> Data
data = lazy_func.realize_as_data(
info,
params,
symbol_values={
sym: loose_input_sockets[sym.name] for sym in params.sorted_symbols
},
)
# Match Viz Type & Perform Visualization
## -> Viz Target determines how to plot.
@ -459,7 +471,6 @@ class VizNode(base.MaxwellSimNode):
width_inches=plot_width,
height_inches=plot_height,
dpi=plot_dpi,
bl_select=True,
)
case VizTarget.Pixels:
@ -468,7 +479,6 @@ class VizNode(base.MaxwellSimNode):
plot.map_2d_to_image(
data,
colormap=colormap,
bl_select=True,
)
case VizTarget.PixelsPlane:

View File

@ -21,6 +21,7 @@ Attributes:
"""
import enum
import functools
import typing as typ
from collections import defaultdict
from types import MappingProxyType
@ -62,7 +63,6 @@ class MaxwellSimNode(bpy.types.Node, bl_instance.BLInstance):
Used as a node-specific cache index.
sim_node_name: A unique human-readable name identifying the node.
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.
"""
@ -98,7 +98,6 @@ class MaxwellSimNode(bpy.types.Node, bl_instance.BLInstance):
loose_output_sockets: dict[str, sockets.base.SocketDef] = bl_cache.BLField({})
# UI Options
preview_active: bool = bl_cache.BLField(False)
locked: bool = bl_cache.BLField(False, use_prop_update=False)
# Active Socket Set
@ -264,35 +263,6 @@ class MaxwellSimNode(bpy.types.Node, bl_instance.BLInstance):
## TODO: Account for FlowKind
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
####################
@ -521,14 +491,17 @@ class MaxwellSimNode(bpy.types.Node, bl_instance.BLInstance):
return {
ct.FlowEvent.EnableLock: 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
and socket_name in event_method.callback_info.on_changed_sockets
)
or (
prop_name
and prop_name in event_method.callback_info.on_changed_props
prop_names
and any(
prop_name in event_method.callback_info.on_changed_props
for prop_name in prop_names
)
)
or (
socket_name
@ -536,6 +509,7 @@ class MaxwellSimNode(bpy.types.Node, bl_instance.BLInstance):
and socket_name in self.loose_input_sockets
)
),
# Non-Triggered
ct.FlowEvent.OutputRequested: lambda output_socket_method,
output_socket_name,
_,
@ -546,7 +520,6 @@ class MaxwellSimNode(bpy.types.Node, bl_instance.BLInstance):
== output_socket_method.callback_info.output_socket_name
)
),
ct.FlowEvent.ShowPreview: 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)
if bl_socket is not None:
if bl_socket.instance_id:
if kind is ct.FlowKind.Previews:
return bl_socket.compute_data(kind=kind)
return (
ct.FlowKind.scale_to_unit_system(
kind,
@ -610,12 +586,10 @@ class MaxwellSimNode(bpy.types.Node, bl_instance.BLInstance):
## -> Anyone needing results will need to wait on preinit().
return ct.FlowSignal.FlowInitializing
# if optional:
if kind is ct.FlowKind.Previews:
return ct.PreviewsFlow()
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
####################
@ -638,33 +612,64 @@ class MaxwellSimNode(bpy.types.Node, bl_instance.BLInstance):
The value of the output socket, as computed by the dedicated method
registered using the `@computes_output_socket` decorator.
"""
if self.outputs.get(output_socket_name) is None:
if optional:
return None
# Previews: Aggregate All Input Sockets
## -> All PreviewsFlows on all input sockets are combined.
## -> 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"
raise RuntimeError(msg)
# No Output Socket: No Flow
## -> 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(
ct.FlowEvent.OutputRequested,
(output_socket_name, None, kind),
)
# Run (=1) Method
if output_socket_methods:
if len(output_socket_methods) > 1:
msg = f'More than one method found for ({output_socket_name}, {kind.value!s}.'
raise RuntimeError(msg)
# Exactly One Output Socket Method
## -> 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)
return 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
# Auxiliary Fallbacks
return res
# > One Output Socket Method: Error
if len(output_socket_methods) > 1:
msg = (
f'More than one method found for ({output_socket_name}, {kind.value!s}.'
)
raise RuntimeError(msg)
if kind is ct.FlowKind.Previews:
return input_previews
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
@ -674,11 +679,11 @@ class MaxwellSimNode(bpy.types.Node, bl_instance.BLInstance):
method_info: events.InfoOutputRequested,
input_socket_name: ct.SocketName | None,
input_socket_kinds: set[ct.FlowKind] | None,
prop_name: str | None,
prop_names: set[str] | None,
) -> bool:
return (
prop_name is not None
and prop_name in method_info.depon_props
prop_names is not None
and any(prop_name in method_info.depon_props for prop_name in prop_names)
or input_socket_name is not None
and (
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()
def _dependent_outputs(
def output_socket_invalidates(
self,
) -> dict[
tuple[ct.SocketName, ct.FlowKind], set[tuple[ct.SocketName, ct.FlowKind]]
]:
## TODO: Cleanup
## TODO: Detect cycles?
## TODO: Networkx?
"""Deduce which output socket | `FlowKind` combos are altered in response to a given output socket | `FlowKind` combo.
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)
# 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[
ct.FlowEvent.OutputRequested
]
for altered_method in output_requested_methods:
altered_info = altered_method.callback_info
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:
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 (
altered_info.output_socket_name
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 = (
altered_info.kind
== (
is (
_kind := invalidated_info.depon_output_socket_kinds.get(
altered_info.output_socket_name
)
)
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:
invalidated_key = (
invalidated_info.output_socket_name,
@ -753,7 +780,7 @@ class MaxwellSimNode(bpy.types.Node, bl_instance.BLInstance):
event: ct.FlowEvent,
socket_name: ct.SocketName | None = None,
socket_kinds: set[ct.FlowKind] | None = None,
prop_name: ct.SocketName | None = None,
prop_names: set[str] | None = None,
) -> None:
"""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.
pop_name: The property that was altered, if any, in order to trigger this event.
"""
log.debug(
'%s: Triggered Event %s (socket_name=%s, socket_kinds=%s, prop_name=%s)',
self.sim_node_name,
event,
str(socket_name),
str(socket_kinds),
str(prop_name),
)
# 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()
# log.debug(
# '[%s] [%s] Triggered (socket_name=%s, socket_kinds=%s, prop_names=%s)',
# self.sim_node_name,
# event,
# str(socket_name),
# str(socket_kinds),
# str(prop_names),
# )
# 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:
input_socket_name = socket_name ## Trigger direction is forwards
in_sckname = socket_name
# Invalidate Input Socket Cache
if input_socket_name is not None:
if socket_kinds is None:
# Clear Input Socket Cache(s)
## -> The input socket cache for each altered FlowKinds is cleared.
## -> 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(
input_socket_name=input_socket_name,
kind=...,
input_socket_name=in_sckname,
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=...,
)
# Invalidate Output Socket Cache
# Clear Output Socket Cache(s)
for output_socket_method in self.event_methods_by_event[
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
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
kind = method_info.kind
out_kind = method_info.kind
# Invalidate Output Directly
# log.critical(
# '[%s] Invalidating: (%s, %s)',
# log.debug(
# '![%s] Clear Output Socket Cache (%s, %s)',
# self.sim_node_name,
# out_sckname,
# str(kind),
# out_kind,
# )
altered_socket_kinds.add(kind)
self.compute_output.invalidate(
output_socket_name=out_sckname,
kind=kind,
kind=out_kind,
)
altered_socket_kinds[out_sckname].add(out_kind)
# Invalidate Any Dependent Outputs
if (
dep_outs := self._dependent_outputs.get((out_sckname, kind))
) is not None:
for dep_out in dep_outs:
# log.critical(
# '![%s] Invalidating: (%s, %s)',
# Invalidate Dependent Output Sockets
## -> Other outscks may depend on the altered outsck.
## -> The property 'output_socket_invalidates' encodes this.
## -> The property 'output_socket_invalidates' encodes this.
cleared_outscks_kinds = self.output_socket_invalidates.get(
(out_sckname, out_kind)
)
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,
# dep_out[0],
# dep_out[1],
# out_sckname,
# out_kind,
# )
altered_socket_kinds.add(dep_out[1])
self.compute_output.invalidate(
output_socket_name=dep_out[0],
kind=dep_out[1],
output_socket_name=dep_out_sckname,
kind=dep_out_kind,
)
altered_socket_kinds[dep_out_sckname].add(dep_out_kind)
# 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
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:
stop_propagation |= event_method.stop_propagation
# log.critical(
# '%s: Running %s',
# log.debug(
# '![%s] Running: %s',
# self.sim_node_name,
# str(event_method.callback_info),
# )
event_method(self)
# DataChanged Propagation Stop: No Altered Socket Kinds
## -> If no FlowKinds were altered, then propagation makes no sense.
## -> Semantically, **nothing has changed** == no DataChanged!
if event is ct.FlowEvent.DataChanged and not altered_socket_kinds:
return
# Constrain ShowPlot to First Node: Workaround
if event is ct.FlowEvent.ShowPlot:
return
# Propagate Event to All Sockets in "Trigger Direction"
# Propagate Event
## -> If 'stop_propagation' was tripped, don't propagate.
## -> If no sockets were altered during DataChanged, don't propagate.
## -> Each FlowEvent decides whether to flow forwards/backwards.
## -> The trigger chain goes node/socket/socket/node/socket/...
## -> Unlinked sockets naturally stop the propagation.
if not stop_propagation:
direc = ct.FlowEvent.flow_direction[event]
triggered_sockets = self._bl_sockets(direc=direc)
for bl_socket in triggered_sockets:
if direc == 'output' and not bl_socket.is_linked:
continue
for bl_socket in self._bl_sockets(direc=direc):
# DataChanged: Propagate Altered SocketKinds
## -> Only altered FlowKinds for the socket will propagate.
## -> In this way, we guarantee no extraneous (noop) flow.
if event is ct.FlowEvent.DataChanged:
if bl_socket.name in altered_socket_kinds:
# log.debug(
# '![%s] [%s] Propagating (direction=%s, altered_socket_kinds=%s)',
# self.sim_node_name,
# event,
# direc,
# altered_socket_kinds[bl_socket.name],
# )
bl_socket.trigger_event(
event, socket_kinds=altered_socket_kinds[bl_socket.name]
)
# log.critical(
# '![%s] Propagating: (%s, %s)',
# self.sim_node_name,
# event,
# altered_socket_kinds,
# )
bl_socket.trigger_event(event, socket_kinds=altered_socket_kinds)
## -> 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
@ -903,18 +947,22 @@ class MaxwellSimNode(bpy.types.Node, bl_instance.BLInstance):
Parameters:
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
## -> Dependent props will generally also trigger on_prop_changed.
## -> The recursion ends with the depschain.
## -> All invalidated blfields will have their caches cleared.
## -> The (topologically) ordered list of cleared blfields is returned.
## -> WARNING: The chain is not checked for ex. cycles.
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

View File

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

View File

@ -76,13 +76,13 @@ class ScientificConstantNode(base.MaxwellSimNode):
"""Retrieve a symbol for the scientific constant."""
if self.sci_constant is not None and self.sci_constant_info is not None:
unit = self.sci_constant_info['units']
return sim_symbols.SimSymbol(
sym_name=self.sci_constant_name,
mathtype=spux.MathType.from_expr(self.sci_constant),
# physical_type= ## TODO: Formalize unit w/o physical_type
unit=unit,
return sim_symbols.SimSymbol.from_expr(
self.sci_constant_name,
self.sci_constant,
unit,
is_constant=True,
)
return None
####################
@ -125,7 +125,7 @@ class ScientificConstantNode(base.MaxwellSimNode):
if self.sci_constant_info:
row = _col.row(align=True)
# 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.alignment = 'CENTER'
@ -184,13 +184,18 @@ class ScientificConstantNode(base.MaxwellSimNode):
@events.computes_output_socket(
'Expr',
kind=ct.FlowKind.Params,
props={'sci_constant'},
props={'sci_constant', 'sci_constant_sym'},
)
def compute_params(self, props: dict) -> typ.Any:
sci_constant = props['sci_constant']
sci_constant_sym = props['sci_constant_sym']
if sci_constant is not None:
return ct.ParamsFlow(func_args=[sci_constant])
if sci_constant is not None and sci_constant_sym is not None:
return ct.ParamsFlow(
arg_targets=[sci_constant_sym],
func_args=[sci_constant],
is_differentiable=True,
)
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
####################
@events.on_value_changed(
# Trigger
socket_name={'File Path'},
# Loaded
input_sockets={'File Path'},
input_socket_kinds={'File Path': ct.FlowKind.Value},
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
has_file_path = not ct.FlowSignal.check(input_sockets['File Path'])
@ -83,7 +88,15 @@ class DataFileImporterNode(base.MaxwellSimNode):
####################
# - 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:
"""Retrieve the output expression's `InfoFlow`."""
info = self.compute_output('Expr', kind=ct.FlowKind.Info)
@ -184,19 +197,19 @@ class DataFileImporterNode(base.MaxwellSimNode):
@events.computes_output_socket(
'Expr',
kind=ct.FlowKind.Func,
# Loaded
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.
Returns:
A completely empty `ParamsFlow`, ready to be composed.
"""
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:
if has_file_path and file_path is not None:
data_file_format = ct.DataFileFormat.from_path(file_path)
if data_file_format is not None:
# Jax Compatibility: Lazy Data Loading

View File

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

View File

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

View File

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

View File

@ -232,8 +232,17 @@ class DataFileExporterNode(base.MaxwellSimNode):
dim.name for dim in params.symbols if dim in info.dims
}:
self.loose_input_sockets = {
dim_name: sockets.ExprSocketDef(**expr_info)
for dim_name, expr_info in params.sym_expr_infos(info).items()
sym.name: sockets.ExprSocketDef(
**(
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:

View File

@ -18,6 +18,7 @@ import typing as typ
import bpy
import sympy as sp
import tidy3d as td
from blender_maxwell.utils import bl_cache, logger
from blender_maxwell.utils import extra_sympy_units as spux
@ -88,32 +89,79 @@ class ViewerNode(base.MaxwellSimNode):
socket_name='Any',
)
def on_input_changed(self) -> None:
self.input_flow = bl_cache.Signal.InvalidateCache
@bl_cache.cached_bl_property()
def input_flow(self) -> dict[ct.FlowKind, typ.Any | None]:
input_flow = {}
"""Lightweight invalidator, which invalidates the more specific `cached_bl_property` used to determine when something ex. plot-related has changed.
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):
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)
has_flow = not ct.FlowSignal.check(flow)
if has_flow:
input_flow |= {flow_kind: flow}
else:
input_flow |= {flow_kind: None}
return input_flow
return flow
return None
return None
####################
# - 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:
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:
## 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))
if isinstance(value, spux.SympyType):
@ -124,6 +172,25 @@ class ViewerNode(base.MaxwellSimNode):
]
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
####################
@ -132,12 +199,12 @@ class ViewerNode(base.MaxwellSimNode):
def draw_props(self, _: bpy.types.Context, layout: bpy.types.UILayout):
row = layout.row(align=True)
# Automatic Expression Printing
row.prop(self, self.blfields['auto_expr'], text='Live', toggle=True)
# Debug Mode On/Off
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
if self.debug_mode:
layout.prop(self, self.blfields['console_print_kind'], text='')
@ -210,47 +277,47 @@ class ViewerNode(base.MaxwellSimNode):
# - Methods
####################
def print_data_to_console(self):
if not self.inputs['Any'].is_linked:
return
flow = self._compute_input('Any', kind=self.console_print_kind)
log.info('Printing to Console')
data = self._compute_input('Any', kind=self.console_print_kind, optional=True)
if isinstance(data, spux.SympyType):
console.print(sp.pretty(data, use_unicode=True))
if isinstance(flow, spux.SympyType):
console.print(sp.pretty(flow, use_unicode=True))
else:
console.print(data)
console.print(flow)
####################
# - Event Methods
####################
@events.on_value_changed(
socket_name='Any',
prop_name='auto_plot',
props={'auto_plot'},
# Trigger
prop_name={'input_previews', 'auto_plot'},
# Loaded
props={'input_previews', 'auto_plot'},
)
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
with node_tree.replot():
if props['auto_plot'] and self.inputs['Any'].is_linked:
self.inputs['Any'].links[0].from_socket.node.trigger_event(
ct.FlowEvent.ShowPlot
)
previews.update_image_preview()
else:
ct.PreviewsFlow.hide_image_preview()
@events.on_value_changed(
socket_name='Any',
prop_name='auto_3d_preview',
props={'auto_3d_preview'},
# Trigger
prop_name={'input_previews', 'auto_3d_preview'},
# Loaded
props={'input_previews', 'auto_3d_preview'},
)
def on_changed_3d_preview(self, props):
node_tree = self.id_data
# Remove Non-Repreviewed Previews on Close
with node_tree.repreview_all():
if props['auto_3d_preview']:
self.trigger_event(ct.FlowEvent.ShowPreview)
previews = props['input_previews']
if previews is not None and props['auto_3d_preview']:
previews.update_bl_object_previews()
else:
ct.PreviewsFlow.hide_bl_object_previews()
####################

View File

@ -64,20 +64,22 @@ class FDTDSimNode(base.MaxwellSimNode):
},
)
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']
sources = input_sockets['Sources']
structures = input_sockets['Structures']
bounds = input_sockets['BCs']
monitors = input_sockets['Monitors']
return td.Simulation(
**sim_domain, ## run_time=, size=, grid=, medium=
**sim_domain,
structures=structures,
sources=sources,
monitors=monitors,
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
####################
@events.on_value_changed(
# Trigger
prop_name='preview_active',
@events.computes_output_socket(
'Domain',
kind=ct.FlowKind.Previews,
# Loaded
managed_objs={'modifier'},
props={'preview_active'},
props={'sim_node_name'},
)
def on_preview_changed(self, managed_objs, props):
if props['preview_active']:
managed_objs['modifier'].show_preview()
else:
managed_objs['modifier'].hide_preview()
def compute_previews(self, props):
return ct.PreviewsFlow(bl_object_names={props['sim_node_name']})
@events.on_value_changed(
## Trigger

View File

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

View File

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

View File

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

View File

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

View File

@ -16,13 +16,15 @@
import typing as typ
import bpy
import sympy as sp
import sympy.physics.units as spu
import tidy3d as td
import tidy3d.plugins.adjoint as tdadj
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 logger
from .... import contracts as ct
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(
'Structure',
kind=ct.FlowKind.Value,
# Loaded
props={'differentiable'},
input_sockets={'Medium', 'Center', 'Size'},
unit_systems={'Tidy3DUnits': ct.UNITS_TIDY3D},
scale_input_sockets={
'Center': 'Tidy3DUnits',
'Size': 'Tidy3DUnits',
output_sockets={'Structure'},
output_socket_kinds={'Structure': ct.FlowKind.Params},
)
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(
geometry=td.Box(
center=spux.scale_to_unit_system(center, ct.UNITS_TIDY3D),
size=spux.scale_to_unit_system(size, ct.UNITS_TIDY3D),
),
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_structure(self, input_sockets, unit_systems) -> td.Box:
return td.Structure(
geometry=td.Box(
center=input_sockets['Center'],
size=input_sockets['Size'],
),
medium=input_sockets['Medium'],
)
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.on_value_changed(
# Trigger
prop_name='preview_active',
@events.computes_output_socket(
'Structure',
kind=ct.FlowKind.Previews,
# Loaded
managed_objs={'modifier'},
props={'preview_active'},
props={'sim_node_name'},
output_sockets={'Structure'},
output_socket_kinds={'Structure': ct.FlowKind.Params},
)
def on_preview_changed(self, managed_objs, props):
if props['preview_active']:
managed_objs['modifier'].show_preview()
else:
managed_objs['modifier'].hide_preview()
def compute_previews(self, props, output_sockets):
output_params = output_sockets['Structure']
has_output_params = not ct.FlowSignal.check(output_params)
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(
# Trigger
@ -105,29 +238,26 @@ class BoxStructureNode(base.MaxwellSimNode):
# Loaded
input_sockets={'Center', 'Size'},
managed_objs={'modifier'},
unit_systems={'BlenderUnits': ct.UNITS_BLENDER},
scale_input_sockets={
'Center': 'BlenderUnits',
},
output_sockets={'Structure'},
output_socket_kinds={'Structure': ct.FlowKind.Params},
)
def on_inputs_changed(
self,
managed_objs,
input_sockets,
unit_systems,
):
# Push Loose Input Values to GeoNodes Modifier
managed_objs['modifier'].bl_modifier(
'NODES',
{
'node_group': import_geonodes(GeoNodes.StructurePrimitiveBox),
'unit_system': unit_systems['BlenderUnits'],
'inputs': {
'Size': input_sockets['Size'],
def on_inputs_changed(self, managed_objs, input_sockets, output_sockets):
output_params = output_sockets['Structure']
has_output_params = not ct.FlowSignal.check(output_params)
if has_output_params and not output_params.symbols:
# Push Loose Input Values to GeoNodes Modifier
center = input_sockets['Center']
managed_objs['modifier'].bl_modifier(
'NODES',
{
'node_group': import_geonodes(GeoNodes.StructurePrimitiveBox),
'unit_system': ct.UNITS_BLENDER,
'inputs': {
'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
####################
@events.on_value_changed(
# Trigger
prop_name='preview_active',
@events.computes_output_socket(
'Structure',
kind=ct.FlowKind.Previews,
# Loaded
managed_objs={'modifier'},
props={'preview_active'},
props={'sim_node_name'},
)
def on_preview_changed(self, managed_objs, props):
if props['preview_active']:
managed_objs['modifier'].show_preview()
else:
managed_objs['modifier'].hide_preview()
def compute_previews(self, props):
return ct.PreviewsFlow(bl_object_names={props['sim_node_name']})
@events.on_value_changed(
# Trigger

View File

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

View File

@ -50,8 +50,10 @@ class SocketDef(pyd.BaseModel, abc.ABC):
Parameters:
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.regenerate_dynamic_field_persistance()
log.debug('%s: End Socket Preinit', bl_socket.bl_label)
def postinit(self, bl_socket: bpy.types.NodeSocket) -> None:
"""Pre-initialize a real Blender node socket from this socket definition.
@ -59,8 +61,12 @@ class SocketDef(pyd.BaseModel, abc.ABC):
Parameters:
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.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
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
bl_label: str
use_linked_capabilities: bool = bl_cache.BLField(False, use_prop_update=False)
## Computed by Subclass
bl_idname: str
@ -181,17 +189,17 @@ class MaxwellSimSocket(bpy.types.NodeSocket, bl_instance.BLInstance):
"""
self.display_shape = self.active_kind.socket_shape
def on_socket_prop_changed(self, prop_name: str) -> None:
"""Called when a property has been updated.
def on_socket_props_changed(self, prop_names: set[str]) -> None:
"""Called when a set of properties has been updated.
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**.
Think **very carefully** before using this, and use it with the greatest of care.
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:
@ -207,30 +215,49 @@ class MaxwellSimSocket(bpy.types.NodeSocket, bl_instance.BLInstance):
Attributes:
prop_name: The name of the property that was changed.
"""
# All Attributes: Trigger Local Event
## -> While initializing, only `DataChanged` won't trigger.
if hasattr(self, prop_name):
# Property Callbacks: Active Kind
## -> WARNING: May NOT rely on flow.
if prop_name == 'active_kind':
# BLField Attributes: Invalidate BLField Dependents
## -> All invalidated blfields will have their caches cleared.
## -> The (topologically) ordered list of cleared blfields is returned.
## -> WARNING: The chain is not checked for ex. cycles.
if not self.is_initializing and prop_name in self.blfields:
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()
# Property Callbacks: Per-Socket
## -> WARNING: May NOT rely on flow.
self.on_socket_prop_changed(prop_name)
## -> NOTE: User-defined handlers might recurse on_prop_changed.
self.is_initializing = True
self.on_socket_props_changed(set_of_cleared_blfields)
self.is_initializing = False
# Not Initializing: Trigger Event
## -> This declares that the socket has changed.
## -> This should happen first, in case dependents need a cache.
if not self.is_initializing:
self.trigger_event(ct.FlowEvent.DataChanged)
# BLField Attributes: Invalidate BLField Dependents
## -> Dependent props will generally also trigger on_prop_changed.
## -> The recursion ends with the depschain.
## -> WARNING: The chain is not checked for ex. cycles.
if prop_name in self.blfields:
self.invalidate_blfield_deps(prop_name)
# Trigger Event
## -> Before SocketDef.postinit(), never emit DataChanged.
## -> ONLY emit DataChanged if a FlowKind-bound prop was cleared.
## -> ONLY emit a single DataChanged w/set of altered FlowKinds.
## w/node's trigger_event, we've guaranteed a minimal action.
socket_kinds = {
ct.FlowKind.from_property_name(prop_name)
for prop_name in {
prop_name
for prop_name, clear_method in set_of_cleared_blfields
if clear_method == 'invalidate'
}.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
@ -273,11 +300,29 @@ class MaxwellSimSocket(bpy.types.NodeSocket, bl_instance.BLInstance):
return False
# 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(
'Attempted to link output socket "%s" (%s) to input socket "%s" (%s), but capabilities are incompatible',
link.from_socket.bl_label,
link.from_socket.capabilities,
incoming_capabilities,
self.bl_label,
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
"""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:
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.
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
"""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
"""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:
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.
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:
"""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
####################
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(
self,
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 -> Output**: Trigger event on `to_socket`s along output links.
Notes:
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.
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]
# Locking
if event in [ct.FlowEvent.EnableLock, ct.FlowEvent.DisableLock]:
self.locked = event == ct.FlowEvent.EnableLock
if event is ct.FlowEvent.EnableLock:
self.locked = True
elif event is ct.FlowEvent.DisableLock:
self.locked = False
# Event by Socket Orientation | 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)
case (False, 'output'):
if event == ct.FlowEvent.LinkChanged:
if event is ct.FlowEvent.LinkChanged:
self.node.trigger_event(
ct.FlowEvent.DataChanged,
socket_name=self.name,
@ -432,6 +545,10 @@ class MaxwellSimSocket(bpy.types.NodeSocket, bl_instance.BLInstance):
# - FlowKind: Auxiliary
####################
# Capabilities
def linked_capabilities(self, info: ct.InfoFlow) -> ct.CapabilitiesFlow:
"""Try this first when `is_linked and use_linked_capabilities`."""
raise NotImplementedError
@property
def capabilities(self) -> None:
"""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:
ValueError: When referencing a socket that's meant to be directly referenced.
"""
kind_data_map = {
return {
ct.FlowKind.Capabilities: lambda: self.capabilities,
ct.FlowKind.Previews: lambda: ct.PreviewsFlow(),
ct.FlowKind.Value: lambda: self.value,
ct.FlowKind.Array: lambda: self.array,
ct.FlowKind.Func: lambda: self.lazy_func,
ct.FlowKind.Range: lambda: self.lazy_range,
ct.FlowKind.Params: lambda: self.params,
ct.FlowKind.Info: lambda: self.info,
}
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)
}[kind]()
def compute_data(
self,
@ -635,7 +747,7 @@ class MaxwellSimSocket(bpy.types.NodeSocket, bl_instance.BLInstance):
return self.node.compute_output(self.name, kind=kind)
# Compute Input Socket
## Unlinked: Retrieve Socket Value
## -> Unlinked: Retrieve Socket Value
if not self.is_linked:
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]
# 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]
# Edge Case: While Dragging Link (but not yet removed)
@ -653,11 +766,7 @@ class MaxwellSimSocket(bpy.types.NodeSocket, bl_instance.BLInstance):
## - self.is_linked = True, since the user hasn't confirmed anything.
## - self.links will be empty, since the link object was freed.
## When this particular condition is met, pretend that we're not linked.
if len(linked_values) == 0:
return self._compute_data(kind)
msg = f'Socket {self.bl_label} ({self.socket_type}): Multi-input sockets are not yet supported'
raise NotImplementedError(msg)
return self._compute_data(kind)
####################
# - UI - Color

View File

@ -14,6 +14,8 @@
# 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/>.
from blender_maxwell.utils import bl_cache
from ... import contracts as ct
from .. import base
@ -25,8 +27,8 @@ class AnyBLSocket(base.MaxwellSimSocket):
socket_type = ct.SocketType.Any
bl_label = 'Any'
@property
def capabilities(self):
@bl_cache.cached_bl_property(depends_on={'active_kind'})
def capabilities(self) -> ct.CapabilitiesFlow:
return ct.CapabilitiesFlow(
socket_type=self.socket_type,
active_kind=self.active_kind,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -16,29 +16,12 @@
import bpy
from blender_maxwell.utils import bl_cache, logger
from ... import contracts as ct
from .. import base
####################
# - 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'}
log = logger.get(__name__)
####################
@ -51,47 +34,18 @@ class BlenderObjectBLSocket(base.MaxwellSimSocket):
####################
# - Properties
####################
raw_value: bpy.props.PointerProperty(
name='Blender Object',
description='Represents a Blender object',
type=bpy.types.Object,
update=(lambda self, context: self.on_prop_changed('raw_value', context)),
)
raw_value: bpy.types.Object = bl_cache.BLField()
####################
# - 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:
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
####################
@property
@bl_cache.cached_bl_property(depends_on={'raw_value'})
def value(self) -> bpy.types.Object | None:
return self.raw_value
@ -114,6 +68,5 @@ class BlenderObjectSocketDef(base.SocketDef):
# - Blender Registration
####################
BL_REGISTER = [
BlenderMaxwellCreateAndAssignBLObject,
BlenderObjectBLSocket,
]

View File

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

View File

@ -64,21 +64,21 @@ class InfoDisplayCol(enum.StrEnum):
@staticmethod
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
return {
IDC.Length: 'L',
IDC.MathType: '',
IDC.MathType: 'M',
IDC.Unit: 'U',
}[value]
@staticmethod
def to_icon(value: typ.Self) -> str:
IDC = InfoDisplayCol
return {
IDC.Length: '',
IDC.MathType: '',
IDC.Unit: '',
}[value]
def to_icon(_: typ.Self) -> str:
"""No icons."""
return ''
####################
@ -109,6 +109,7 @@ class ExprBLSocket(base.MaxwellSimSocket):
socket_type = ct.SocketType.Expr
bl_label = 'Expr'
use_socket_color = True
####################
# - Socket Interface
@ -117,6 +118,58 @@ class ExprBLSocket(base.MaxwellSimSocket):
mathtype: spux.MathType = bl_cache.BLField(spux.MathType.Real)
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
####################
@ -140,6 +193,11 @@ class ExprBLSocket(base.MaxwellSimSocket):
"""Computes `sympy` symbols from `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
####################
@ -171,8 +229,13 @@ class ExprBLSocket(base.MaxwellSimSocket):
return None
@property
@bl_cache.cached_bl_property(depends_on={'unit'})
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
prev_unit: str | None = bl_cache.BLField(None)
@ -228,26 +291,92 @@ class ExprBLSocket(base.MaxwellSimSocket):
####################
# - 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:
"""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)
@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:
"""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)
@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:
"""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)
####################
# - 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
## -> 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.
if prop_name == 'unit':
if ('unit', 'invalidate') in cleared_blfields:
# Check Unit Change
## -> self.prev_unit only updates here; "lags" behind self.unit.
## -> 1. "Laggy" unit must be different than new unit.
@ -272,37 +401,6 @@ class ExprBLSocket(base.MaxwellSimSocket):
####################
# - 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):
"""Cast the given expression to the appropriate raw value, with scaling guided by `self.unit`."""
if self.unit is not None:
@ -324,38 +422,117 @@ class ExprBLSocket(base.MaxwellSimSocket):
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:
"""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:
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)
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,
],
)
# 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,
)
else:
if self._parse_expr_symbol(expr) is not None:
return expr
return None
####################
# - 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:
"""Return the expression defined by the socket as `FlowKind.Value`.
@ -382,8 +559,8 @@ class ExprBLSocket(base.MaxwellSimSocket):
## -> ExprSocket doesn't support Vec4 (yet?).
## -> I mean, have you _seen_ that mess of attributes up top?
NS = spux.NumberSize1D
if self.size == NS.Vec4:
return ct.Flow
if self.size is NS.Vec4:
return ct.FlowSignal.NoFlow
MT_Z = spux.MathType.Integer
MT_Q = spux.MathType.Rational
@ -430,7 +607,6 @@ class ExprBLSocket(base.MaxwellSimSocket):
Notes:
Called to set the internal `FlowKind.Value` of this socket.
"""
_mathtype, _size = self._parse_expr_info(expr)
if self.symbols:
self.raw_value_spstr = sp.sstr(expr)
else:
@ -473,7 +649,22 @@ class ExprBLSocket(base.MaxwellSimSocket):
####################
# - 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:
"""Return the not-yet-computed uniform array defined by the socket.
@ -519,18 +710,18 @@ class ExprBLSocket(base.MaxwellSimSocket):
)
@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.
Notes:
Called to compute the internal `FlowKind.Range` of this socket.
"""
self.steps = value.steps
self.scaling = value.scaling
self.steps = lazy_range.steps
self.scaling = lazy_range.scaling
if self.symbols:
self.raw_min_spstr = sp.sstr(value.start)
self.raw_max_spstr = sp.sstr(value.stop)
self.raw_min_spstr = sp.sstr(lazy_range.start)
self.raw_max_spstr = sp.sstr(lazy_range.stop)
else:
MT_Z = spux.MathType.Integer
@ -538,32 +729,40 @@ class ExprBLSocket(base.MaxwellSimSocket):
MT_R = spux.MathType.Real
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:
self.raw_range_int = [
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:
self.raw_range_rat = [
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:
self.raw_range_float = [
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:
self.raw_range_complex = [
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)
####################
@property
@bl_cache.cached_bl_property(
depends_on={
'value',
'symbols',
'sorted_sp_symbols',
'sorted_symbols',
'output_sym',
}
)
def lazy_func(self) -> ct.FuncFlow:
"""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.
## -> The function computes `self.value` with unknowns as arguments.
if self.symbols:
return ct.FuncFlow(
func=sp.lambdify(
self.sorted_sp_symbols,
spux.strip_unit_system(self.value),
'jax',
),
func_args=list(self.sorted_symbols),
supports_jax=True,
)
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(
func=sp.lambdify(
self.sorted_sp_symbols,
output_sym.conform(value, strip_unit=True),
'jax',
),
func_args=list(self.sorted_symbols),
supports_jax=True,
)
return ct.FlowSignal.FlowPending
# Constant
## -> 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.
return ct.FuncFlow(
func=lambda v: v,
func_args=[
sim_symbols.SimSymbol.from_expr(
sim_symbols.SimSymbolName.Constant, self.value, self.unit_factor
)
],
func_args=[self.output_sym],
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:
"""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.
## -> Therefore, we just dump the symbols. Easy!
## -> NOTE: func_args must have the same symbol order as was lambdified.
if self.symbols:
return ct.ParamsFlow(
func_args=[sym.sp_symbol_phy for sym in self.sorted_symbols],
symbols=self.sorted_symbols,
)
if self.sorted_symbols:
output_sym = self.output_sym
if output_sym is not None:
return ct.ParamsFlow(
arg_targets=list(self.sorted_symbols),
func_args=[sym.sp_symbol for sym in self.sorted_symbols],
symbols=self.sorted_symbols,
is_differentiable=self.is_differentiable,
)
return ct.FlowSignal.FlowPending
# Constant
## -> Simply pass self.value verbatim as a function argument.
## -> 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
def info(self) -> ct.ArrayFlow:
@bl_cache.cached_bl_property(depends_on={'sorted_symbols', 'output_sym'})
def info(self) -> ct.InfoFlow:
r"""Returns parameter symbols/values to accompany `self.lazy_func`.
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.
"""
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
## -> The input SimSymbols become continuous dimensional indices.
## -> All domain validity information is defined on the SimSymbol keys.
if self.symbols:
return ct.InfoFlow(
dims={sym: None for sym in self.sorted_symbols},
output=output_sym,
)
if self.sorted_symbols:
output_sym = self.output_sym
if output_sym is not None:
return ct.InfoFlow(
dims={sym: None for sym in self.sorted_symbols},
output=self.output_sym,
)
return ct.FlowSignal.FlowPending
# Constant
## -> 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
####################
@property
def capabilities(self) -> None:
def linked_capabilities(self, info: ct.InfoFlow) -> ct.CapabilitiesFlow:
"""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(
socket_type=self.socket_type,
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,29 +957,32 @@ class ExprBLSocket(base.MaxwellSimSocket):
Notes:
Whether information about the expression passing through a linked socket is shown is governed by `self.show_info_columns`.
"""
info = self.compute_data(kind=ct.FlowKind.Info)
has_info = not ct.FlowSignal.check(info)
if self.active_kind is ct.FlowKind.Func:
info = self.compute_data(kind=ct.FlowKind.Info)
has_info = not ct.FlowSignal.check(info)
if has_info:
split = row.split(factor=0.85, align=True)
_row = split.row(align=False)
if has_info:
split = row.split(factor=0.85, align=True)
_row = split.row(align=False)
else:
_row = row
_row.label(text=text)
if has_info:
if self.show_info_columns:
_row.prop(self, self.blfields['info_columns'])
_row = split.row(align=True)
_row.alignment = 'RIGHT'
_row.prop(
self,
self.blfields['show_info_columns'],
toggle=True,
text='',
icon=ct.Icon.ToggleSocketInfo,
)
else:
_row = row
_row.label(text=text)
if has_info:
if self.show_info_columns:
_row.prop(self, self.blfields['info_columns'])
_row = split.row(align=True)
_row.alignment = 'RIGHT'
_row.prop(
self,
self.blfields['show_info_columns'],
toggle=True,
text='',
icon=ct.Icon.ToggleSocketInfo,
)
row.label(text=text)
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.
@ -724,29 +992,32 @@ class ExprBLSocket(base.MaxwellSimSocket):
Notes:
Whether information about the expression passing through a linked socket is shown is governed by `self.show_info_columns`.
"""
info = self.compute_data(kind=ct.FlowKind.Info)
has_info = not ct.FlowSignal.check(info)
if self.active_kind is ct.FlowKind.Func:
info = self.compute_data(kind=ct.FlowKind.Info)
has_info = not ct.FlowSignal.check(info)
if has_info:
split = row.split(factor=0.15, align=True)
if has_info:
split = row.split(factor=0.15, align=True)
_row = split.row(align=True)
_row.prop(
self,
self.blfields['show_info_columns'],
toggle=True,
text='',
icon=ct.Icon.ToggleSocketInfo,
)
_row = split.row(align=True)
_row.prop(
self,
self.blfields['show_info_columns'],
toggle=True,
text='',
icon=ct.Icon.ToggleSocketInfo,
)
_row = split.row(align=False)
_row.alignment = 'RIGHT'
if self.show_info_columns:
_row.prop(self, self.blfields['info_columns'])
_row = split.row(align=False)
_row.alignment = 'RIGHT'
if self.show_info_columns:
_row.prop(self, self.blfields['info_columns'])
else:
_col = _row.column()
_col.alignment = 'EXPAND'
_col.label(text='')
else:
_col = _row.column()
_col.alignment = 'EXPAND'
_col.label(text='')
_row = row
else:
_row = row
@ -860,42 +1131,38 @@ class ExprBLSocket(base.MaxwellSimSocket):
Uses `draw_value` to draw the base 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
## -> The name of the output
if self.show_name_selector:
row = col.row()
row.alignment = 'CENTER'
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
####################
def draw_info(self, info: ct.InfoFlow, col: bpy.types.UILayout) -> None:
"""Visualize the `InfoFlow` information passing through the socket."""
if (
self.active_kind == ct.FlowKind.Func
self.active_kind is ct.FlowKind.Func
and self.show_info_columns
and self.is_linked
and (self.is_linked or self.is_output)
):
row = col.row()
box = row.box()
@ -922,7 +1189,7 @@ class ExprBLSocket(base.MaxwellSimSocket):
if InfoDisplayCol.Length in self.info_columns:
grid.label(text='', icon=ct.Icon.DataSocketOutput)
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:
grid.label(text=info.output.unit_label)
@ -935,7 +1202,6 @@ class ExprSocketDef(base.SocketDef):
active_kind: typ.Literal[
ct.FlowKind.Value,
ct.FlowKind.Range,
ct.FlowKind.Array,
ct.FlowKind.Func,
] = ct.FlowKind.Value
output_name: sim_symbols.SimSymbolName = sim_symbols.SimSymbolName.Expr
@ -1240,6 +1506,7 @@ class ExprSocketDef(base.SocketDef):
def init(self, bl_socket: ExprBLSocket) -> None:
bl_socket.active_kind = self.active_kind
bl_socket.output_name = self.output_name
bl_socket.use_linked_capabilities = True
# Socket Interface
## -> 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/>.
import bpy
import pydantic as pyd
import tidy3d as td
from blender_maxwell.utils import bl_cache, logger
@ -59,7 +60,9 @@ class MaxwellBoundCondBLSocket(base.MaxwellSimSocket):
####################
# - FlowKind
####################
@property
@bl_cache.cached_bl_property(
depends_on={'active_kind', 'allow_axes', 'present_axes'}
)
def capabilities(self) -> ct.CapabilitiesFlow:
return ct.CapabilitiesFlow(
socket_type=self.socket_type,
@ -68,7 +71,7 @@ class MaxwellBoundCondBLSocket(base.MaxwellSimSocket):
present_any=self.present_axes,
)
@property
@bl_cache.cached_bl_property(depends_on={'default'})
def value(self) -> td.BoundaryEdge:
return self.default.tidy3d_boundary_edge
@ -84,16 +87,20 @@ class MaxwellBoundCondSocketDef(base.SocketDef):
socket_type: ct.SocketType = ct.SocketType.MaxwellBoundCond
default: ct.BoundCondType = ct.BoundCondType.Pml
allow_axes: set[ct.SimSpaceAxis] = {
ct.SimSpaceAxis.X,
ct.SimSpaceAxis.Y,
ct.SimSpaceAxis.Z,
}
present_axes: set[ct.SimSpaceAxis] = {
ct.SimSpaceAxis.X,
ct.SimSpaceAxis.Y,
ct.SimSpaceAxis.Z,
}
allow_axes: set[ct.SimSpaceAxis] = pyd.Field(
default={
ct.SimSpaceAxis.X,
ct.SimSpaceAxis.Y,
ct.SimSpaceAxis.Z,
}
)
present_axes: set[ct.SimSpaceAxis] = pyd.Field(
default={
ct.SimSpaceAxis.X,
ct.SimSpaceAxis.Y,
ct.SimSpaceAxis.Z,
}
)
def init(self, bl_socket: MaxwellBoundCondBLSocket) -> None:
bl_socket.default = self.default

View File

@ -86,7 +86,9 @@ class MaxwellBoundCondsBLSocket(base.MaxwellSimSocket):
####################
# - 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:
"""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 sympy.physics.units as spu
import tidy3d as td
import tidy3d.plugins.adjoint as tdadj
from blender_maxwell.utils import bl_cache, logger
from blender_maxwell.utils import extra_sympy_units as spux
@ -39,12 +40,14 @@ class MaxwellMediumBLSocket(base.MaxwellSimSocket):
####################
# - 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
####################
@property
@bl_cache.cached_bl_property(depends_on={'eps_rel', 'differentiable'})
def value(self) -> td.Medium:
freq = (
spu.convert_to(
@ -53,31 +56,49 @@ class MaxwellMediumBLSocket(base.MaxwellSimSocket):
)
/ 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(
n=self.rel_permittivity[0],
k=self.rel_permittivity[1],
n=self.eps_rel[0],
k=self.eps_rel[1],
freq=freq,
)
@value.setter
def value(
self, value: tuple[spux.ConstrSympyExpr(allow_variables=False), complex]
) -> None:
rel_permittivity = value
def value(self, eps_rel: tuple[float, float]) -> None:
self.eps_rel = eps_rel
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
####################
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.label(text='ϵ_r ()')
_col = split.column(align=True)
_col.label(text='εᵣ')
col = split.column(align=True)
col.prop(self, self.blfields['rel_permittivity'], text='')
_col = split.column(align=True)
_col.prop(self, self.blfields['eps_rel'], text='')
####################
@ -90,7 +111,7 @@ class MaxwellMediumSocketDef(base.SocketDef):
default_permittivity_imag: float = 0.0
def init(self, bl_socket: MaxwellMediumBLSocket) -> None:
bl_socket.rel_permittivity = (
bl_socket.eps_rel = (
self.default_permittivity_real,
self.default_permittivity_imag,
)

View File

@ -49,7 +49,7 @@ class MaxwellSimGridBLSocket(base.MaxwellSimSocket):
####################
# - Computation of Default Value
####################
@property
@bl_cache.cached_bl_property(depends_on={'min_steps_per_wl'})
def value(self) -> td.GridSpec:
return td.GridSpec.auto(
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)
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.InvalidateCache
return {'FINISHED'}
@ -77,7 +79,9 @@ class Authenticate(bpy.types.Operator):
bl_socket.api_key = ''
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.InvalidateCache
return {'FINISHED'}
@ -102,62 +106,18 @@ class Tidy3DCloudTaskBLSocket(base.MaxwellSimSocket):
####################
# - 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)
new_task_name: str = bl_cache.BLField('')
####################
# - Properties: Cloud Folders
####################
existing_folder_id: enum.StrEnum = bl_cache.BLField(
prop_ui=True, 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()
enum_cb=lambda self, _: self.search_cloud_folders()
)
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]:
if tdcloud.IS_AUTHENTICATED:
return [
@ -175,6 +135,13 @@ class Tidy3DCloudTaskBLSocket(base.MaxwellSimSocket):
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]:
if self.existing_folder_id is None or not tdcloud.IS_AUTHENTICATED:
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
####################

View File

@ -16,6 +16,7 @@
"""Implements various key caches on instances of Blender objects, especially nodes and sockets."""
import contextlib
import functools
import inspect
import typing as typ
@ -166,7 +167,7 @@ class BLField:
self.cb_depends_on: set[str] | None = cb_depends_on
# Update Suppressing
self.suppress_update: dict[str, bool] = {}
self.suppressed_update: dict[str, bool] = {}
####################
# - Descriptor Setup
@ -253,9 +254,38 @@ class BLField:
return self.bl_prop.default_value ## TODO: Good idea?
return cached_value
def suppress_next_update(self, bl_instance) -> None:
self.suppress_update[bl_instance.instance_id] = True
## TODO: Make it a context manager to prevent the worst of surprises
@contextlib.contextmanager
def suppress_update(self, bl_instance: bl_instance.BLInstance) -> None:
"""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__(
self, bl_instance: bl_instance.BLInstance | None, value: typ.Any
@ -263,7 +293,7 @@ class BLField:
"""Sets the value described by the BLField.
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:
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.
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
## -> This empties the non-persistent cache.
## -> As a result, the value must be reloaded from the property.
## 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)
# Update Suppression
if self.suppress_update.get(bl_instance.instance_id):
self.suppress_update[bl_instance.instance_id] = False
# ELSE: Trigger Update Chain
elif self.prop_info['use_prop_update'] and value is Signal.InvalidateCache:
# Trigger Update Chain
## -> User can disable w/'use_prop_update=False'.
## -> Use InvalidateCacheNoUpdate to explicitly disable update.
## -> If 'suppressed_update' context manager is active, don't update.
if (
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)
# 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:
if self.bl_prop_enum_items is None:
return
@ -335,8 +366,8 @@ class BLField:
# Swap Enum Items
## -> This is the hot stuff - the enum elements are overwritten.
## -> The safe_enum_cb will pick up on this immediately.
self.suppress_next_update(bl_instance)
self.bl_prop_enum_items.write(bl_instance, current_items)
with self.suppress_update(bl_instance):
self.bl_prop_enum_items.write(bl_instance, current_items)
# Old Item in Current Items
## -> It's possible that the old enum key is in the new enum.
@ -344,9 +375,8 @@ class BLField:
## -> Thus, we set it - Blender sees a change, user doesn't.
## -> DO NOT trigger on_prop_changed (since "nothing changed").
if any(raw_old_item == item[0] for item in current_items):
self.suppress_next_update(bl_instance)
self.bl_prop.write(bl_instance, old_item)
## -> TODO: Don't write if not needed.
with self.suppress_update(bl_instance):
self.bl_prop.write(bl_instance, old_item)
# Old Item Not in Current Items
## -> In this case, fallback to the first current item.
@ -355,28 +385,27 @@ class BLField:
raw_first_current_item = current_items[0][0]
first_current_item = self.bl_prop.decode(raw_first_current_item)
self.suppress_next_update(bl_instance)
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)
with self.suppress_update(bl_instance):
self.bl_prop.write(bl_instance, first_current_item)
# Reset Str Search
## -> If there is no string search method, do nothing.
## -> Simply invalidate the non-persistent cache
elif value is Signal.ResetStrSearch:
if self.bl_prop_str_search is None:
return
self.bl_prop_str_search.invalidate_nonpersist(bl_instance)
# General __set__
# Default __set__
else:
self.bl_prop.write(bl_instance, value)
with self.suppress_update(bl_instance):
self.bl_prop.write(bl_instance, value)
# Update Semantics
if self.suppress_update.get(bl_instance.instance_id):
self.suppress_update[bl_instance.instance_id] = False
elif self.prop_info['use_prop_update']:
if self.prop_info['use_prop_update'] and not self.suppressed_update.get(
bl_instance.instance_id, False
):
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."""
import contextlib
import inspect
import typing as typ
@ -76,7 +77,7 @@ class CachedBLProperty:
self.decode_type: type = inspect.signature(getter_method).return_annotation
# Write Suppressing
self.suppress_write: dict[str, bool] = {}
self.suppressed_update: dict[str, bool] = {}
# Check Non-Empty Type Annotation
## For now, just presume that all types can be encoded/decoded.
@ -125,9 +126,38 @@ class CachedBLProperty:
return Signal.CacheNotReady
return cached_value
def suppress_next_write(self, bl_instance) -> None:
self.suppress_write[bl_instance.instance_id] = True
## TODO: Make it a context manager to prevent the worst of surprises
@contextlib.contextmanager
def suppress_update(self, bl_instance: bl_instance.BLInstance) -> None:
"""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__(
self, bl_instance: bl_instance.BLInstance | None, value: typ.Any
@ -141,44 +171,59 @@ class CachedBLProperty:
Parameters:
bl_instance: The Blender object this prop
"""
if value is Signal.DoUpdate:
bl_instance.on_prop_changed(self.bl_prop.name)
elif value is Signal.InvalidateCache or value is Signal.InvalidateCacheNoUpdate:
# Invalidate Cache
## -> This empties the non-persistent cache.
## -> If persist=True, this also writes the persistent cache (no update).
## 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
## -> Only for the invalidation case do we also invalidate partners.
if bl_instance is not None:
# Fill Caches
## -> persist: Fill Persist and Non-Persist Cache
## -> else: Fill Non-Persist Cache
if self.persist and not self.suppress_write.get(
bl_instance.instance_id
):
self.bl_prop.write(bl_instance, self.getter_method(bl_instance))
## -> 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))
else:
self.bl_prop.write_nonpersist(
bl_instance, self.getter_method(bl_instance)
)
if value == Signal.InvalidateCache:
# 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.on_prop_changed(self.bl_prop.name)
# Call Setter
elif self.setter_method is not None:
# Run Setter
## -> The user-provided setter should do any updating of partners.
if self.setter_method is not None:
self.setter_method(bl_instance, value)
if bl_instance is not None:
# Run Setter
## -> The user-provided setter can set values as it sees fit.
## -> The user-provided setter will not immediately trigger updates.
with self.suppress_update(bl_instance):
self.setter_method(bl_instance, value)
# Fill Non-Persistant (and maybe Persistent) Cache
if self.persist and not self.suppress_write.get(bl_instance.instance_id):
self.bl_prop.write(bl_instance, self.getter_method(bl_instance))
# Fill Caches
## -> 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))
else:
self.bl_prop.write_nonpersist(
bl_instance, self.getter_method(bl_instance)
)
bl_instance.on_prop_changed(self.bl_prop.name)
else:
self.bl_prop.write_nonpersist(
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)
else:
msg = f'Tried to set "{value}" to "{self.prop_name}" on "{bl_instance.bl_label}", but a setter was not defined'

View File

@ -39,8 +39,6 @@ class Signal(enum.StrEnum):
InvalidateCache: The cache should be invalidated.
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.
ResetStrSearch: Cached string-search items should be recomputed on next use.
@ -53,7 +51,6 @@ class Signal(enum.StrEnum):
# Invalidation
InvalidateCache: str = str(uuid.uuid4())
InvalidateCacheNoUpdate: str = str(uuid.uuid4())
DoUpdate: str = str(uuid.uuid4())
# Reset Signals
## -> Invalidates data adjascent to fields.

View File

@ -220,7 +220,13 @@ class BLInstance:
for str_search_prop_name in self.blfields_str_search:
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`.
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,35 +238,110 @@ 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`.
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)
## -> NOTE: Dependent props may also trigger `on_prop_changed`.
## -> Don't abuse dependencies :)
for deps, invalidate_signal in zip(
## -> InvalidateCacheNoUpdate: Exactly what it sounds like.
## -> ResetEnumItems: Won't trigger on_prop_changed.
## -> -- 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_dynamic_enum_deps,
self.blfield_str_search_deps,
],
[
bl_cache.Signal.InvalidateCache,
bl_cache.Signal.ResetEnumItems,
bl_cache.Signal.ResetStrSearch,
],
['invalidate', 'reset_enum', 'reset_strsearch'],
strict=True,
):
if prop_name in deps:
for dst_prop_name in deps[prop_name]:
log.debug(
'%s: "%s" is invalidating "%s"',
self.bl_label,
prop_name,
dst_prop_name,
)
setattr(
self,
dst_prop_name,
invalidate_signal,
)
# Mark Dependency for Clearance
## -> Duplicates are OK for now, we'll clear them later.
blfields_to_clear.append((dst_prop_name, clear_method))
# 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,
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(
self,
blfield,
{
'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:
"""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 . import logger
from .staticproperty import staticproperty
log = logger.get(__name__)
@ -69,7 +70,7 @@ class MathType(enum.StrEnum):
Complex = enum.auto()
@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:
return MathType.Complex
if MathType.Real in mathtypes:
@ -79,6 +80,9 @@ class MathType(enum.StrEnum):
if MathType.Integer in mathtypes:
return MathType.Integer
if optional:
return None
msg = f"Can't combine mathtypes {mathtypes}"
raise ValueError(msg)
@ -113,7 +117,7 @@ class MathType(enum.StrEnum):
return complex(pyobj, 0)
@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):
return MathType.combine(
*[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]:
return MathType.Complex
if optional:
return None
msg = f"Can't determine MathType from sympy object: {sp_obj}"
raise ValueError(msg)
@ -957,6 +964,48 @@ def unit_str_to_unit(unit_str: str) -> Unit | None:
####################
# - "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):
"""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()
@functools.cached_property
def unit_dim(self):
def unit_dim(self) -> SympyType:
PT = PhysicalType
return {
PT.NonPhysical: None,
@ -1050,6 +1099,95 @@ class PhysicalType(enum.StrEnum):
PT.Illuminance: Dims.luminous_intensity / Dims.length**2,
}[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
def default_unit(self) -> list[Unit]:
PT = PhysicalType
@ -1256,17 +1394,59 @@ class PhysicalType(enum.StrEnum):
}[self]
@staticmethod
def from_unit(unit: Unit, optional: bool = False) -> list[Unit] | None:
for physical_type in list(PhysicalType):
if unit in physical_type.valid_units:
return physical_type
## TODO: Optimize
def from_unit(unit: Unit | None, optional: bool = False) -> typ.Self | None:
"""Attempt to determine a matching `PhysicalType` from a unit.
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
if optional:
return None
msg = f'Could not determine PhysicalType for {unit}'
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
def valid_shapes(self) -> list[typ.Literal[(3,), (2,)] | None]:
PT = PhysicalType

View File

@ -17,7 +17,6 @@
"""Useful image processing operations for use in the addon."""
import enum
import functools
import typing as typ
import jax
@ -27,13 +26,13 @@ import matplotlib
import matplotlib.axis as mpl_ax
import matplotlib.backends.backend_agg
import matplotlib.figure
import matplotlib.style as mplstyle
import numpy as np
import seaborn as sns
from blender_maxwell import contracts as ct
from blender_maxwell.utils import extra_sympy_units as spux
from blender_maxwell.utils import logger
# mplstyle.use('fast') ## TODO: Does this do anything?
sns.set_theme()
log = logger.get(__name__)
@ -139,7 +138,7 @@ def rgba_image_from_2d_map(
####################
# - 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):
fig = matplotlib.figure.Figure(
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())
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(y_sym.plot_label)
ax.set_ylabel(y_sym.plot_label)
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_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:
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.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(y_sym.plot_label)
ax.set_ylabel(y_sym.plot_label)
def plot_points_2d(data, ax: mpl_ax.Axis) -> None:
x_sym, y_sym = list(data.keys())
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(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]):
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(y_sym.plot_label)
ax.set_ylabel(y_sym.plot_label)
ax.legend()
def plot_filled_curves_2d(
data: jtyp.Float32[jtyp.Array, 'x_size 2'], info, ax: mpl_ax.Axis
) -> None:
x_sym, _, y_sym = list(data.keys())
def plot_filled_curves_2d(data, ax: mpl_ax.Axis) -> None:
x_sym, _, y_sym = list(data.keys(data))
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(y_sym.plot_label)
ax.set_ylabel(y_sym.plot_label)
ax.legend()
# (, ) ->
def plot_heatmap_2d(
data: jtyp.Float32[jtyp.Array, 'x_size y_size'], info, ax: mpl_ax.Axis
) -> None:
def plot_heatmap_2d(data, ax: mpl_ax.Axis) -> None:
x_sym, y_sym, c_sym = list(data.keys())
heatmap = ax.imshow(data[c_sym], aspect='equal', interpolation='none')
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(y_sym.plot_label)
ax.legend()

View File

@ -24,7 +24,6 @@ from fractions import Fraction
import jaxtyping as jtyp
import pydantic as pyd
import sympy as sp
import sympy.physics.units as spu
from . import extra_sympy_units as spux
from . import logger, serialize
@ -88,6 +87,7 @@ class SimSymbolName(enum.StrEnum):
Wavelength = enum.auto()
Frequency = enum.auto()
Perm = enum.auto()
PermXX = enum.auto()
PermYY = enum.auto()
PermZZ = enum.auto()
@ -161,6 +161,7 @@ class SimSymbolName(enum.StrEnum):
# Optics
SSN.Wavelength: 'wl',
SSN.Frequency: 'freq',
SSN.Perm: 'eps_r',
SSN.PermXX: 'eps_xx',
SSN.PermYY: 'eps_yy',
SSN.PermZZ: 'eps_zz',
@ -179,6 +180,7 @@ class SimSymbolName(enum.StrEnum):
SSN.LowerTheta: 'θ',
SSN.LowerPhi: 'φ',
# Fields
SSN.Er: 'Er',
SSN.Etheta: '',
SSN.Ephi: '',
SSN.Hr: 'Hr',
@ -186,10 +188,11 @@ class SimSymbolName(enum.StrEnum):
SSN.Hphi: '',
# Optics
SSN.Wavelength: 'λ',
SSN.Frequency: '𝑓',
SSN.PermXX: 'ε_xx',
SSN.PermYY: 'ε_yy',
SSN.PermZZ: 'ε_zz',
SSN.Frequency: 'fᵣ',
SSN.Perm: 'εᵣ',
SSN.PermXX: 'εᵣ[xx]',
SSN.PermYY: 'εᵣ[yy]',
SSN.PermZZ: 'εᵣ[zz]',
}.get(self, self.name)
@ -248,6 +251,8 @@ class SimSymbol(pyd.BaseModel):
## -> See self.domain.
## -> We have to deconstruct symbolic interval semantics a bit for UI.
is_constant: bool = False
exclude_zero: bool = False
interval_finite_z: tuple[int, int] = (0, 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)
@ -284,20 +289,25 @@ class SimSymbol(pyd.BaseModel):
@functools.cached_property
def unit_label(self) -> str:
"""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
def def_label(self) -> str:
"""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?
@functools.cached_property
def plot_label(self) -> str:
"""Pretty plot-oriented label."""
return f'{self.name_pretty}' + (
f'({self.unit})' if self.unit is not None else ''
)
return f'{self.name_pretty} ({self.unit_label})'
####################
# - 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."""
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
def size(self) -> tuple[int, ...] | None:
return {
@ -403,6 +418,29 @@ class SimSymbol(pyd.BaseModel):
case (False, False):
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
####################
@ -434,23 +472,15 @@ class SimSymbol(pyd.BaseModel):
mathtype_kwargs |= {'complex': True}
# Non-Zero Assumption
if (
(
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
):
if self.is_nonzero:
mathtype_kwargs |= {'nonzero': True}
# Positive/Negative Assumption
if self.domain.left >= 0:
mathtype_kwargs |= {'positive': True}
elif self.domain.right <= 0:
mathtype_kwargs |= {'negative': True}
if self.mathtype is not spux.MathType.Complex:
if self.domain.left >= 0:
mathtype_kwargs |= {'positive': True}
elif self.domain.right <= 0:
mathtype_kwargs |= {'negative': True}
# Scalar: Return Symbol
if self.rows == 1 and self.cols == 1:
@ -521,8 +551,8 @@ class SimSymbol(pyd.BaseModel):
self.valid_domain_value, strip_unit=True
),
# Defaults: FlowKind.Range
'default_min': self.domain.start,
'default_max': self.domain.end,
'default_min': self.conform(self.domain.start, strip_unit=True),
'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})'
raise NotImplementedError(msg)
@ -671,7 +701,9 @@ class SimSymbol(pyd.BaseModel):
sym_name: SimSymbolName,
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).
This is an essential method, allowing for the ded
@ -697,12 +729,28 @@ class SimSymbol(pyd.BaseModel):
# MathType from Expr Assumptions
## -> All input symbols have assumptions, because we are very pedantic.
## -> 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"
## -> 'unit' still applies - but we can't guarantee a PhysicalType will.
## -> Therefore, this is what we gotta do.
physical_type = spux.PhysicalType.NonPhysical
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
unit = None
# Rows/Cols from Expr (if Matrix)
rows, cols = expr.shape if isinstance(expr, sp.MatrixBase) else (1, 1)
@ -711,9 +759,11 @@ class SimSymbol(pyd.BaseModel):
sym_name=sym_name,
mathtype=mathtype,
physical_type=physical_type,
unit=unit_expr if unit_expr != 1 else None,
unit=unit,
rows=rows,
cols=cols,
is_constant=is_constant,
exclude_zero=expr.is_zero is not None and not expr.is_zero,
)
####################