diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 303c626c..c9155559 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -8,6 +8,7 @@ CHANGELOG * We have decided internally to revert the versioning schema back to SemVer, starting at ``1.0.0``, in order to improve better backward and forward compatibility support for plugins. * Fix bug related to Probabilistic History Matching result reader. The shape of result was wrong. * Add the heat transfer mechanism for fluid materials composed by a parcel of radiation and convection. +* Add support for references inside tables defined by plugins. 2024.2 (2024-09-10) diff --git a/src/alfasim_sdk/__init__.py b/src/alfasim_sdk/__init__.py index 21a9815f..2a221b67 100644 --- a/src/alfasim_sdk/__init__.py +++ b/src/alfasim_sdk/__init__.py @@ -118,6 +118,12 @@ def get_alfasim_sdk_api_path(): from alfasim_sdk._internal.alfacase.case_description import ( PressureContainerDescription, ) +from alfasim_sdk._internal.alfacase.case_description import ( + PluginTableColumnForInternalReference, +) +from alfasim_sdk._internal.alfacase.case_description import ( + PluginTableColumnForTracerReference, +) from alfasim_sdk._internal.alfacase.case_description import ( PressureNodePropertiesDescription, ) @@ -470,6 +476,8 @@ def get_alfasim_sdk_api_path(): "PipeThermalModelType", "PipeThermalPositionInput", "PluginDescription", + "PluginTableColumnForInternalReference", + "PluginTableColumnForTracerReference", "PositionalPipeTrendDescription", "PressureContainerDescription", "PressureNodePropertiesDescription", diff --git a/src/alfasim_sdk/_internal/alfacase/case_description.py b/src/alfasim_sdk/_internal/alfacase/case_description.py index 804b6a66..ddb84219 100644 --- a/src/alfasim_sdk/_internal/alfacase/case_description.py +++ b/src/alfasim_sdk/_internal/alfacase/case_description.py @@ -20,6 +20,7 @@ from barril.units import Array from barril.units import Scalar +from ..validators import non_empty_str from .case_description_attributes import attrib_array from .case_description_attributes import attrib_curve from .case_description_attributes import attrib_dict_of @@ -32,6 +33,7 @@ from .case_description_attributes import dict_of_array from .case_description_attributes import dict_with_scalar from .case_description_attributes import InvalidReferenceError +from .case_description_attributes import list_of_optional_integers from .case_description_attributes import list_of_strings from .case_description_attributes import Numpy1DArray from .case_description_attributes import numpy_array_validator @@ -86,6 +88,7 @@ class PluginTracerReference: @attr.s(frozen=True, slots=True) class PluginInternalReference: plugin_item_id = attr.ib(default=None) + container_key = attr.ib(default=None) @attr.s(frozen=True, slots=True) @@ -94,6 +97,17 @@ class PluginMultipleReference: item_id_list = attr.ib(default=attr.Factory(list)) +@attr.s(frozen=True, slots=True) +class PluginTableColumnForTracerReference: + tracer_ids = attr.ib(validator=list_of_optional_integers) + + +@attr.s(frozen=True, slots=True) +class PluginTableColumnForInternalReference: + plugin_item_ids = attr.ib(validator=list_of_optional_integers) + container_key = attr.ib(validator=non_empty_str) + + @attr.s(frozen=True, slots=True) class PluginTableContainer: columns = attr.ib(default=attr.Factory(dict)) diff --git a/src/alfasim_sdk/_internal/alfacase/case_description_attributes.py b/src/alfasim_sdk/_internal/alfacase/case_description_attributes.py index 7691cab7..0e4c29cf 100644 --- a/src/alfasim_sdk/_internal/alfacase/case_description_attributes.py +++ b/src/alfasim_sdk/_internal/alfacase/case_description_attributes.py @@ -30,6 +30,9 @@ list_of_strings = deep_iterable( member_validator=optional(instance_of(str)), iterable_validator=instance_of(list) ) +list_of_optional_integers = deep_iterable( + member_validator=optional(instance_of(int)), iterable_validator=instance_of(list) +) AttrNothingType = type(attr.NOTHING) ScalarLike = Union[Tuple[Number, str], Scalar] ArrayLike = Union[Tuple[Sequence[Number], str], Array] diff --git a/src/alfasim_sdk/_internal/alfacase/plugin_alfacase_to_case.py b/src/alfasim_sdk/_internal/alfacase/plugin_alfacase_to_case.py index 0bae6a0e..e155db60 100644 --- a/src/alfasim_sdk/_internal/alfacase/plugin_alfacase_to_case.py +++ b/src/alfasim_sdk/_internal/alfacase/plugin_alfacase_to_case.py @@ -25,6 +25,12 @@ from alfasim_sdk._internal.alfacase.case_description import PluginFileContent from alfasim_sdk._internal.alfacase.case_description import PluginInternalReference from alfasim_sdk._internal.alfacase.case_description import PluginMultipleReference +from alfasim_sdk._internal.alfacase.case_description import ( + PluginTableColumnForInternalReference, +) +from alfasim_sdk._internal.alfacase.case_description import ( + PluginTableColumnForTracerReference, +) from alfasim_sdk._internal.alfacase.case_description import PluginTableContainer from alfasim_sdk._internal.alfacase.case_description import PluginTracerReference from alfasim_sdk._internal.alfacase.case_description_attributes import ( @@ -311,8 +317,11 @@ def _convert_reference( if is_dict_with_keys(value, "tracer_id"): return PluginTracerReference(tracer_id=int(value["tracer_id"])) else: - if is_dict_with_keys(value, "plugin_item_id"): - return PluginInternalReference(plugin_item_id=int(value["plugin_item_id"])) + if is_dict_with_keys(value, "plugin_item_id", strict=False): + return PluginInternalReference( + container_key=value.get("container_key"), + plugin_item_id=int(value["plugin_item_id"]), + ) raise InvalidPluginDataError(f"Can not convert to a reference: {value!r}") @@ -341,6 +350,37 @@ def _convert_string( raise InvalidPluginDataError(f"Can not convert to a string: {value!r}") +def _convert_table_column_contents( + raw_col: object, +) -> Union[ + Array, PluginTableColumnForInternalReference, PluginTableColumnForTracerReference +]: + """ + Try to convert an object into a valid table column content. + + This is a helper function to `_convert_table` and is not registered + in `_PLUGIN_FIELD_TO_CASEDESCRIPTION`. + """ + if is_dict_with_keys(raw_col, "values", "unit"): + # Quantity. + return Array([float(v) for v in raw_col["values"]], raw_col["unit"]) + elif is_dict_with_keys(raw_col, "tracer_ids"): + # Reference to tracer. + return PluginTableColumnForTracerReference( + [(int(v) if v != "None" else None) for v in raw_col["tracer_ids"]] + ) + elif is_dict_with_keys(raw_col, "plugin_item_ids", "container_key"): + # Reference to plugin model. + return PluginTableColumnForInternalReference( + container_key=raw_col["container_key"], + plugin_item_ids=[ + (int(v) if v != "None" else None) for v in raw_col["plugin_item_ids"] + ], + ) + else: + raise InvalidPluginDataError(f"Can not convert table column: {raw_col!r}") + + def _convert_table( value: object, type_from_plugin: BaseField, alfacase_path: Path ) -> PluginTableContainer: @@ -352,17 +392,9 @@ def _convert_table( raw_columns = value["columns"] col_ids = [col.id for col in type_from_plugin.rows] if is_dict_with_keys(raw_columns, *col_ids): - columns = {} - for col in col_ids: - raw_col = raw_columns[col] - if is_dict_with_keys(raw_col, "values", "unit"): - columns[col] = Array( - [float(v) for v in raw_col["values"]], raw_col["unit"] - ) - else: - raise InvalidPluginDataError( - f"Can not convert table column: {raw_col!r}" - ) + columns = { + col: _convert_table_column_contents(raw_columns[col]) for col in col_ids + } return PluginTableContainer(columns=columns) raise InvalidPluginDataError(f"Can not convert to a table: {value!r}") diff --git a/src/alfasim_sdk/_internal/types.py b/src/alfasim_sdk/_internal/types.py index 9e986fcb..f86c300b 100644 --- a/src/alfasim_sdk/_internal/types.py +++ b/src/alfasim_sdk/_internal/types.py @@ -1,8 +1,8 @@ import numbers from typing import Callable -from typing import FrozenSet from typing import List from typing import Optional +from typing import Sequence from typing import Union import attr @@ -16,38 +16,38 @@ from alfasim_sdk._internal.validators import valid_unit -@attr.s(kw_only=True) +@attr.s(kw_only=True, frozen=True) class ALFAsimType: name: str = attrib(default="ALFAsim") -@attr.s(kw_only=True) +@attr.s(kw_only=True, frozen=True) class TracerType(ALFAsimType): _CONTAINER_TYPE = "TracerModelContainer" -@attr.s(kw_only=True) +@attr.s(kw_only=True, frozen=True) class Tab: """ Base class for tab attributes available at ALFAsim. """ -@attr.s(kw_only=True) +@attr.s(kw_only=True, frozen=True) class Tabs: """ Base class for tabs attributes available at ALFAsim. """ -@attr.s(kw_only=True) +@attr.s(kw_only=True, frozen=True) class Group: """ Base class for Group attribute available at ALFAsim. """ -@attr.s(kw_only=True) +@attr.s(kw_only=True, frozen=True) class BaseField: """ A base field for all types available at ALFAsim. @@ -205,7 +205,7 @@ def alfasim_get_data_model_type(): ) -@attr.s(kw_only=True) +@attr.s(kw_only=True, frozen=True) class String(BaseField): """ The String field represents an input that allows the user to enter and edit a single line of plain text. @@ -249,7 +249,7 @@ class MyModel: value: str = attrib(validator=non_empty_str) -@attr.s(kw_only=True) +@attr.s(kw_only=True, frozen=True) class Enum(BaseField): """ The Enum field provides list of options to the user, showing only the selected item but providing a way to display @@ -331,7 +331,7 @@ def check( # pylint: disable=arguments-differ ) -@attr.s(kw_only=True) +@attr.s(kw_only=True, frozen=True) class BaseReference(BaseField): ref_type: type = attrib() container_type: Optional[str] = attrib( @@ -340,7 +340,11 @@ class BaseReference(BaseField): def __attrs_post_init__(self): if issubclass(self.ref_type, ALFAsimType): - self.container_type = self.ref_type._CONTAINER_TYPE + if self.container_type is not None: + raise TypeError( + "When using a ALFAsimType the container_type field must be None" + ) + object.__setattr__(self, "container_type", self.ref_type._CONTAINER_TYPE) else: if self.container_type is None: raise TypeError( @@ -364,7 +368,7 @@ def check(self, attr: Attribute, value) -> None: ) -@attr.s(kw_only=True) +@attr.s(kw_only=True, frozen=True) class Reference(BaseReference): """ The Reference field provides a list of options to the user and displays the current item selected. @@ -464,7 +468,7 @@ class MyContainer: """ -@attr.s(kw_only=True) +@attr.s(kw_only=True, frozen=True) class MultipleReference(BaseReference): """ The MultipleReference field works similar to :class:`Reference`, providing a list of options @@ -638,13 +642,13 @@ class MyModel: class TableColumn(BaseField): """ The TableColumn component provides columns for a :class:`Table` field. - Currently only columns with a :class:`Quantity` fields are available. + Currently only columns with :class:`Quantity` and :class:`Reference` fields are available. Check out the documentation from :class:`Table` to see more details about the usage and how to retrieve values. """ id: str = attrib(validator=non_empty_str) - value: Quantity = attrib() + value: Union[Quantity, Reference] = attrib() caption: str = attrib(init=False, default="") def __attrs_post_init__(self) -> None: @@ -652,10 +656,19 @@ def __attrs_post_init__(self) -> None: @value.validator def check( # pylint: disable=arguments-differ - self, attr: Attribute, values: Quantity + self, attr: Attribute, values: Union[Quantity, Reference] ) -> None: - if not isinstance(values, Quantity): - raise TypeError(f"{attr.name} must be a Quantity, got a {type(values)}.") + if isinstance(values, Quantity): + return # Always OK. + elif isinstance(values, Reference): + if values.container_type is None: + raise ValueError( + "When placed on table columns a Reference requires a non None container_type." + ) + else: + raise TypeError( + f"{attr.name} must be a Quantity or a Reference, got a {type(values)}." + ) @attr.s(kw_only=True, frozen=True) @@ -667,6 +680,14 @@ class Table(BaseField): .. code-block:: python + @data_model(caption="Wolf") + class Wolf: + name = String(value="Wolf", caption="Name") + + @container_model(caption="The Pack", model=Wolf, icon="") + class Pack: + pass + @data_model(icon="", caption="My Model") class MyModel: table_field=Table( @@ -687,10 +708,20 @@ class MyModel: caption="Pressure Column Caption", ), ), + TableColumn( + id="tracer", + value=Refence( + ref_type=Wolf, + container_type="Pack", + caption="Wolf Column Caption", + ), + ), ], caption="Table Field", ) + TODO: ASIM-5862: update docs table_field_example_1.png image! + The image above illustrates the output from the example above. .. image:: /_static/images/api/table_field_example_1.png @@ -750,7 +781,7 @@ class MyModel: """ - rows: FrozenSet[TableColumn] = attrib(converter=tuple) + rows: Sequence[TableColumn] = attrib(converter=tuple) @rows.validator def check( # pylint: disable=arguments-differ @@ -763,7 +794,7 @@ def check( # pylint: disable=arguments-differ raise TypeError(f"{attr.name} must be a list of TableColumn.") -@attr.s(kw_only=True) +@attr.s(kw_only=True, frozen=True) class Boolean(BaseField): """ The Boolean field provides a checkbox to select/deselect a property. @@ -809,7 +840,7 @@ class MyModel: value: bool = attrib(validator=instance_of(bool)) -@attr.s(kw_only=True) +@attr.s(kw_only=True, frozen=True) class FileContent(BaseField): """ The FileContent component provides a platform-native file dialog to the user to be able to select a file. diff --git a/tests/alfacase/test_plugin_alfacase_to_case.py b/tests/alfacase/test_plugin_alfacase_to_case.py index 486cd89c..8172c042 100644 --- a/tests/alfacase/test_plugin_alfacase_to_case.py +++ b/tests/alfacase/test_plugin_alfacase_to_case.py @@ -19,6 +19,12 @@ from alfasim_sdk._internal.alfacase.case_description import PluginFileContent from alfasim_sdk._internal.alfacase.case_description import PluginInternalReference from alfasim_sdk._internal.alfacase.case_description import PluginMultipleReference +from alfasim_sdk._internal.alfacase.case_description import ( + PluginTableColumnForInternalReference, +) +from alfasim_sdk._internal.alfacase.case_description import ( + PluginTableColumnForTracerReference, +) from alfasim_sdk._internal.alfacase.case_description import PluginTableContainer from alfasim_sdk._internal.alfacase.case_description import PluginTracerReference from alfasim_sdk._internal.alfacase.case_description_attributes import ( @@ -279,6 +285,10 @@ def prepare_foobar_plugin_impl(data_model_fields: List[str]) -> None: class Bar: pass + @alfasim_sdk.container_model(model=Bar, icon="", caption="Bar Container") + class BarContainer: + pass + {data_model_source} @alfasim_sdk.container_model(model=Foo, icon="", caption="Foo Container") @@ -287,7 +297,7 @@ class FooContainer: @alfasim_sdk.hookimpl def alfasim_get_data_model_type(): - return [FooContainer] + return [FooContainer, BarContainer] """ ) plugin_file_source = plugin_file_source.format( @@ -703,138 +713,215 @@ def test_load_string(datadir, prepare_foobar_plugin): convert_alfacase_to_description(alfacase) -def test_load_table(datadir, prepare_foobar_plugin): - prepare_foobar_plugin( - [ - """x = alfasim_sdk.Table( - rows=[ - alfasim_sdk.TableColumn(id='aaa', value=alfasim_sdk.Quantity(value=1, unit='m', caption='a')), - alfasim_sdk.TableColumn(id='sss', value=alfasim_sdk.Quantity(value=1, unit='m', caption='s')), - ], - caption='x', - )""" - ] - ) - alfacase = datadir / "test.alfacase" - - alfacase.write_text( - textwrap.dedent( - """\ - plugins: - - name: foobar - is_enabled: True - gui_models: - FooContainer: - _children_list: - - x: - columns: - aaa: - values: [1.2, 2.3, 3.4] - unit: s - sss: - values: [9.8, 8.7, 7.6] - unit: m - """ - ) - ) - case = convert_alfacase_to_description(alfacase) - assert case.plugins == [ - PluginDescription( - name="foobar", - is_enabled=True, - gui_models={ - "FooContainer": { - "_children_list": [ - { - "x": PluginTableContainer( - columns={ - "aaa": Array([1.2, 2.3, 3.4], "s"), - "sss": Array([9.8, 8.7, 7.6], "m"), - } - ) - }, +class TestLoadTable: + def test_quantity( + self, datadir: Path, prepare_foobar_plugin: Callable[[List[str]], None] + ) -> None: + prepare_foobar_plugin( + [ + """x = alfasim_sdk.Table( + rows=[ + alfasim_sdk.TableColumn(id='aaa', value=alfasim_sdk.Quantity(value=1, unit='m', caption='a')), + alfasim_sdk.TableColumn(id='sss', value=alfasim_sdk.Quantity(value=1, unit='m', caption='s')), ], + caption='x', + )""" + ] + ) + alfacase = datadir / "test.alfacase" + + alfacase.write_text( + textwrap.dedent( + """\ + plugins: + - name: foobar + is_enabled: True + gui_models: + FooContainer: + _children_list: + - x: + columns: + aaa: + values: [1.2, 2.3, 3.4] + unit: s + sss: + values: [9.8, 8.7, 7.6] + unit: m + """ + ) + ) + case = convert_alfacase_to_description(alfacase) + assert case.plugins == [ + PluginDescription( + name="foobar", + is_enabled=True, + gui_models={ + "FooContainer": { + "_children_list": [ + { + "x": PluginTableContainer( + columns={ + "aaa": Array([1.2, 2.3, 3.4], "s"), + "sss": Array([9.8, 8.7, 7.6], "m"), + } + ) + }, + ], + }, }, - }, - ), - ] + ), + ] - alfacase.write_text( - textwrap.dedent( - """\ - plugins: - - name: foobar - is_enabled: True - gui_models: - FooContainer: - _children_list: - - x: - columns: - aaa: - values: [1.2, 2.3, 3.4] - unit: s - """ + alfacase.write_text( + textwrap.dedent( + """\ + plugins: + - name: foobar + is_enabled: True + gui_models: + FooContainer: + _children_list: + - x: + columns: + aaa: + values: [1.2, 2.3, 3.4] + unit: s + """ + ) ) - ) - with pytest.raises(InvalidPluginDataError, match="Can not convert"): - convert_alfacase_to_description(alfacase) - - alfacase.write_text( - textwrap.dedent( - """\ - plugins: - - name: foobar - is_enabled: True - gui_models: - FooContainer: - _children_list: - - x: - columns: + with pytest.raises(InvalidPluginDataError, match="Can not convert"): + convert_alfacase_to_description(alfacase) + + alfacase.write_text( + textwrap.dedent( + """\ + plugins: + - name: foobar + is_enabled: True + gui_models: + FooContainer: + _children_list: + - x: + columns: + aaa: + values: [1.2, 2.3, 3.4] + unit: s + sss: + values: [9.8, 8.7, 7.6] + """ + ) + ) + with pytest.raises(InvalidPluginDataError, match="Can not convert"): + convert_alfacase_to_description(alfacase) + + alfacase.write_text( + textwrap.dedent( + """\ + plugins: + - name: foobar + is_enabled: True + gui_models: + FooContainer: + _children_list: + - x: aaa: values: [1.2, 2.3, 3.4] unit: s - sss: - values: [9.8, 8.7, 7.6] - """ + """ + ) ) - ) - with pytest.raises(InvalidPluginDataError, match="Can not convert"): - convert_alfacase_to_description(alfacase) - - alfacase.write_text( - textwrap.dedent( - """\ - plugins: - - name: foobar - is_enabled: True - gui_models: - FooContainer: - _children_list: - - x: - aaa: - values: [1.2, 2.3, 3.4] - unit: s - """ + with pytest.raises(InvalidPluginDataError, match="Can not convert"): + convert_alfacase_to_description(alfacase) + + alfacase.write_text( + textwrap.dedent( + """\ + plugins: + - name: foobar + is_enabled: True + gui_models: + FooContainer: + _children_list: + - x: A + """ + ) ) - ) - with pytest.raises(InvalidPluginDataError, match="Can not convert"): - convert_alfacase_to_description(alfacase) + with pytest.raises(InvalidPluginDataError, match="Can not convert"): + convert_alfacase_to_description(alfacase) - alfacase.write_text( - textwrap.dedent( - """\ - plugins: - - name: foobar - is_enabled: True - gui_models: - FooContainer: - _children_list: - - x: A - """ + def test_references( + self, datadir: Path, prepare_foobar_plugin: Callable[[List[str]], None] + ) -> None: + # Setup. + prepare_foobar_plugin( + [ + """x = alfasim_sdk.Table( + rows=[ + alfasim_sdk.TableColumn( + id='aaa', + value=alfasim_sdk.Reference( + container_type='BarContainer', ref_type=Bar, caption="A Bar" + ), + ), + alfasim_sdk.TableColumn( + id='sss', + value=alfasim_sdk.Reference( + ref_type=alfasim_sdk.TracerType, caption="A Tracer" + ), + ), + ], + caption='x', + )""" + ] ) - ) - with pytest.raises(InvalidPluginDataError, match="Can not convert"): - convert_alfacase_to_description(alfacase) + alfacase = datadir / "test.alfacase" + + alfacase.write_text( + textwrap.dedent( + """\ + plugins: + - name: foobar + is_enabled: True + gui_models: + FooContainer: + _children_list: + - x: + columns: + aaa: + container_key: BarContainer + plugin_item_ids: [0, 0, 2, 2] + sss: + tracer_ids: [1, 2, 3, 3] + """ + ) + ) + # Test. + case = convert_alfacase_to_description(alfacase) + assert case.plugins == [ + PluginDescription( + name="foobar", + is_enabled=True, + gui_models={ + "FooContainer": { + "_children_list": [ + { + "x": PluginTableContainer( + columns={ + "aaa": PluginTableColumnForInternalReference( + container_key="BarContainer", + plugin_item_ids=[0, 0, 2, 2], + ), + "sss": PluginTableColumnForTracerReference( + tracer_ids=[1, 2, 3, 3] + ), + } + ) + }, + ], + }, + }, + ), + ] def test_dump_file_contents_and_update_plugins(datadir, monkeypatch): diff --git a/tests/plugins/test_types.py b/tests/plugins/test_types.py index 2573e5bf..447138b2 100644 --- a/tests/plugins/test_types.py +++ b/tests/plugins/test_types.py @@ -163,7 +163,7 @@ def test_table_column(): from alfasim_sdk._internal.types import TableColumn, Quantity with pytest.raises( - TypeError, match="value must be a Quantity, got a ." + TypeError, match="value must be a Quantity or a Reference, got a ." ): TableColumn(id="id", value="")