From bddcf4d2fa95c50c49a2985e8fa7c56a2d1f0dd8 Mon Sep 17 00:00:00 2001 From: Brian Weir <94982354+WeirAE@users.noreply.github.com> Date: Tue, 4 Jun 2024 14:42:12 -0500 Subject: [PATCH] UW-586: Build driver for WaveWatchIII (#496) * base skeleton for the wavewatch3 component * split driver base class cycle subclassing still has issues * Many fixes, some coverage missing * test coverage fix * move drivers together, add directory tests need work * fix logic for multiple files, still errors * ww3 modified to match existing namelist behavior directory test still failing pending driver naming decisions * fix namelist_file errors * Apply suggestions from code review Co-authored-by: Paul Madden <136389411+maddenp-noaa@users.noreply.github.com> * rename base to Assets and revert to Driver(Assets) Combine all driver tests to one file * re-remove test_standalonedriver * multiple fixes to account for namelist changes * Update src/uwtools/drivers/driver.py Co-authored-by: Paul Madden <136389411+maddenp-noaa@users.noreply.github.com> * Update docs/sections/user_guide/yaml/components/ww3.rst Co-authored-by: NaureenBharwaniNOAA <136371446+NaureenBharwaniNOAA@users.noreply.github.com> * Next set of feedback comments Needs template render update and fix schema * convert from realizing to rendering in ww3 * fix test bug * Apply suggestions from code review Co-authored-by: Paul Madden <136389411+maddenp-noaa@users.noreply.github.com> * Move resources, fix validation, add schema tests * add cycle, remove non-nml, rename to template documentation updated test currently failing, in progress * Fix input file ref and doc error * Add'l doc change * Apply suggestions from code review Co-authored-by: Paul Madden <136389411+maddenp-noaa@users.noreply.github.com> * Update src/uwtools/resources/jsonschema/ww3.jsonschema Co-authored-by: Paul Madden <136389411+maddenp-noaa@users.noreply.github.com> * Update docs/shared/ww3.yaml Co-authored-by: Paul Madden <136389411+maddenp-noaa@users.noreply.github.com> * test and doc fixes * Apply suggestions from code review Co-authored-by: Paul Madden <136389411+maddenp-noaa@users.noreply.github.com> * small doc format * revert function name to namelist_file() --------- Co-authored-by: Paul Madden <136389411+maddenp-noaa@users.noreply.github.com> Co-authored-by: NaureenBharwaniNOAA <136371446+NaureenBharwaniNOAA@users.noreply.github.com> --- .../user_guide/yaml/components/index.rst | 1 + .../user_guide/yaml/components/ww3.rst | 32 ++++ docs/shared/ww3.yaml | 9 + src/uwtools/drivers/driver.py | 159 ++++++++++-------- src/uwtools/drivers/ww3.py | 88 ++++++++++ .../resources/jsonschema/ww3.jsonschema | 35 ++++ src/uwtools/strings.py | 1 + src/uwtools/tests/drivers/test_driver.py | 149 +++++++++++++--- src/uwtools/tests/drivers/test_ww3.py | 90 ++++++++++ src/uwtools/tests/test_schemas.py | 50 ++++++ 10 files changed, 518 insertions(+), 96 deletions(-) create mode 100644 docs/sections/user_guide/yaml/components/ww3.rst create mode 100644 docs/shared/ww3.yaml create mode 100644 src/uwtools/drivers/ww3.py create mode 100644 src/uwtools/resources/jsonschema/ww3.jsonschema create mode 100644 src/uwtools/tests/drivers/test_ww3.py diff --git a/docs/sections/user_guide/yaml/components/index.rst b/docs/sections/user_guide/yaml/components/index.rst index 0514f7bd3..7c0b0d2f3 100644 --- a/docs/sections/user_guide/yaml/components/index.rst +++ b/docs/sections/user_guide/yaml/components/index.rst @@ -17,3 +17,4 @@ UW YAML for Components shave ungrib upp + ww3 diff --git a/docs/sections/user_guide/yaml/components/ww3.rst b/docs/sections/user_guide/yaml/components/ww3.rst new file mode 100644 index 000000000..43d4d0d1b --- /dev/null +++ b/docs/sections/user_guide/yaml/components/ww3.rst @@ -0,0 +1,32 @@ +.. _ww3_yaml: + +ww3 +=== + +Structured YAML to configure WaveWatchIII as part of a compiled coupled executable is validated by JSON Schema and requires the ``ww3:`` block, described below. + +Here is a prototype UW YAML ``ww3:`` block, explained in detail below: + +.. highlight:: yaml +.. literalinclude:: ../../../../shared/ww3.yaml + +UW YAML for the ``ww3:`` Block +------------------------------ + +namelist: +^^^^^^^^^ + + .. important:: The WaveWatchIII namelist file is provisioned by rendering an input template file containing Jinja2 expressions. Unlike namelist files provisioned by ``uwtools`` for other components, the WaveWatchIII namelist file will not be validated. + + **template_file:** + + The path to the input template file containing Jinja2 expressions (perhaps named ``ww3_shel.nml.IN``), based on the ``ww3_shel.nml`` file from the WaveWatchIII build. Note that the non-namelist ``ww3_shel.inp`` file may not be used as the basis for the input template file. + + **template_values:** + + Key-value pairs necessary to render all Jinja2 expressions in the input template file named by ``template_file:``. + +run_dir: +^^^^^^^^ + +The path to the run directory. diff --git a/docs/shared/ww3.yaml b/docs/shared/ww3.yaml new file mode 100644 index 000000000..0b92584ab --- /dev/null +++ b/docs/shared/ww3.yaml @@ -0,0 +1,9 @@ +ww3: + namelist: + template_file: /path/to/ww3/ww3_shel.nml.IN + template_values: + input_forcing_winds: "C" + run_dir: /path/to/run/directory +platform: + account: me + scheduler: slurm diff --git a/src/uwtools/drivers/driver.py b/src/uwtools/drivers/driver.py index 101a38929..eca4cf8a5 100644 --- a/src/uwtools/drivers/driver.py +++ b/src/uwtools/drivers/driver.py @@ -24,9 +24,9 @@ from uwtools.utils.processing import execute -class Driver(ABC): +class Assets(ABC): """ - An abstract class for component drivers. + An abstract class to provision assets for component drivers. """ def __init__( @@ -74,14 +74,6 @@ def provisioned_run_directory(self): Run directory provisioned with all required content. """ - @tasks - def run(self): - """ - A run. - """ - yield self._taskname("run") - yield (self._run_via_batch_submission() if self._batch else self._run_via_local_execution()) - @external def validate(self): """ @@ -90,29 +82,6 @@ def validate(self): yield self._taskname("valid schema") yield asset(None, lambda: True) - @task - def _run_via_batch_submission(self): - """ - A run executed via the batch system. - """ - yield self._taskname("run via batch submission") - path = Path("%s.submit" % self._runscript_path) - yield asset(path, path.is_file) - yield self.provisioned_run_directory() - self._scheduler.submit_job(runscript=self._runscript_path, submit_file=path) - - @task - def _run_via_local_execution(self): - """ - A run executed directly on the local system. - """ - yield self._taskname("run via local execution") - path = self._rundir / f"done.{self._driver_name}" - yield asset(path, path.is_file) - yield self.provisioned_run_directory() - cmd = "{x} >{x}.out 2>&1".format(x=self._runscript_path) - execute(cmd=cmd, cwd=self._rundir, log_output=True) - # Private helper methods @staticmethod @@ -191,10 +160,95 @@ def _namelist_schema( schema = schema[schema_key] return schema + @property + def _rundir(self) -> Path: + """ + The path to the component's run directory. + """ + return Path(self._driver_config["run_dir"]) + + def _taskname(self, suffix: str) -> str: + """ + Returns a common tag for graph-task log messages. + + :param suffix: Log-string suffix. + """ + return "%s %s" % (self._driver_name, suffix) + + def _taskname_with_cycle(self, cycle: datetime, suffix: str) -> str: + """ + Returns a common tag for graph-task log messages. + + :param suffix: Log-string suffix. + """ + return "%s %s %s" % (cycle.strftime("%Y%m%d %HZ"), self._driver_name, suffix) + + def _taskname_with_cycle_and_leadtime( + self, cycle: datetime, leadtime: timedelta, suffix: str + ) -> str: + """ + Returns a common tag for graph-task log messages. + + :param suffix: Log-string suffix. + """ + return "%s %s %s" % ( + (cycle + leadtime).strftime("%Y%m%d %H:%M:%S"), + self._driver_name, + suffix, + ) + + def _validate(self) -> None: + """ + Perform all necessary schema validation. + """ + schema_name = self._driver_name.replace("_", "-") + validate_internal(schema_name=schema_name, config=self._config) + + +class Driver(Assets): + """ + An abstract class for standalone component drivers. + """ + + # Workflow tasks + + @tasks + def run(self): + """ + A run. + """ + yield self._taskname("run") + yield (self._run_via_batch_submission() if self._batch else self._run_via_local_execution()) + + @task + def _run_via_batch_submission(self): + """ + A run executed via the batch system. + """ + yield self._taskname("run via batch submission") + path = Path("%s.submit" % self._runscript_path) + yield asset(path, path.is_file) + yield self.provisioned_run_directory() + self._scheduler.submit_job(runscript=self._runscript_path, submit_file=path) + + @task + def _run_via_local_execution(self): + """ + A run executed directly on the local system. + """ + yield self._taskname("run via local execution") + path = self._rundir / f"done.{self._driver_name}" + yield asset(path, path.is_file) + yield self.provisioned_run_directory() + cmd = "{x} >{x}.out 2>&1".format(x=self._runscript_path) + execute(cmd=cmd, cwd=self._rundir, log_output=True) + + # Private helper methods + @property def _resources(self) -> Dict[str, Any]: """ - Returns configuration data for the runscript. + Returns platform configuration data. """ try: platform = self._config["platform"] @@ -222,13 +276,6 @@ def _runcmd(self) -> str: ] return " ".join(filter(None, components)) - @property - def _rundir(self) -> Path: - """ - The path to the component's run directory. - """ - return Path(self._driver_config["run_dir"]) - def _runscript( self, execution: List[str], @@ -279,36 +326,6 @@ def _scheduler(self) -> JobScheduler: """ return JobScheduler.get_scheduler(self._resources) - def _taskname(self, suffix: str) -> str: - """ - Returns a common tag for graph-task log messages. - - :param suffix: Log-string suffix. - """ - return "%s %s" % (self._driver_name, suffix) - - def _taskname_with_cycle(self, cycle: datetime, suffix: str) -> str: - """ - Returns a common tag for graph-task log messages. - - :param suffix: Log-string suffix. - """ - return "%s %s %s" % (cycle.strftime("%Y%m%d %HZ"), self._driver_name, suffix) - - def _taskname_with_cycle_and_leadtime( - self, cycle: datetime, leadtime: timedelta, suffix: str - ) -> str: - """ - Returns a common tag for graph-task log messages. - - :param suffix: Log-string suffix. - """ - return "%s %s %s" % ( - (cycle + leadtime).strftime("%Y%m%d %H:%M:%S"), - self._driver_name, - suffix, - ) - def _validate(self) -> None: """ Perform all necessary schema validation. diff --git a/src/uwtools/drivers/ww3.py b/src/uwtools/drivers/ww3.py new file mode 100644 index 000000000..965674e32 --- /dev/null +++ b/src/uwtools/drivers/ww3.py @@ -0,0 +1,88 @@ +""" +An assets driver for ww3. +""" + +from datetime import datetime +from pathlib import Path +from typing import List, Optional + +from iotaa import asset, task, tasks + +from uwtools.api.template import render +from uwtools.drivers.driver import Assets +from uwtools.strings import STR +from uwtools.utils.tasks import file + + +class WaveWatchIII(Assets): + """ + A library driver for ww3. + """ + + def __init__( + self, + cycle: datetime, + config: Optional[Path] = None, + dry_run: bool = False, + batch: bool = False, + key_path: Optional[List[str]] = None, + ): + """ + The driver. + + :param cycle: The cycle. + :param config: Path to config file (read stdin if missing or None). + :param dry_run: Run in dry-run mode? + :param batch: Run component via the batch system? + :param key_path: Keys leading through the config to the driver's configuration block. + """ + super().__init__( + config=config, dry_run=dry_run, batch=batch, cycle=cycle, key_path=key_path + ) + self._cycle = cycle + + # Workflow tasks + + @task + def namelist_file(self): + """ + Render the namelist from the template file. + """ + fn = "ww3_shel.nml" + yield self._taskname(fn) + path = self._rundir / fn + yield asset(path, path.is_file) + yield file(path=Path(self._driver_config["namelist"]["template_file"])) + render( + input_file=Path(self._driver_config["namelist"]["template_file"]), + output_file=path, + overrides=self._driver_config["namelist"]["template_values"], + ) + + @tasks + def provisioned_run_directory(self): + """ + Run directory provisioned with all required content. + """ + yield self._taskname("provisioned run directory") + yield [self.namelist_file(), self.restart_directory()] + + @task + def restart_directory(self): + """ + The restart directory. + """ + yield self._taskname("restart directory") + path = self._rundir / "restart_wave" + yield asset(path, path.is_dir) + yield None + path.mkdir(parents=True) + + # Private helper methods + + @property + def _driver_name(self) -> str: + """ + Returns the name of this driver. + """ + return STR.ww3 diff --git a/src/uwtools/resources/jsonschema/ww3.jsonschema b/src/uwtools/resources/jsonschema/ww3.jsonschema new file mode 100644 index 000000000..21ddcbe82 --- /dev/null +++ b/src/uwtools/resources/jsonschema/ww3.jsonschema @@ -0,0 +1,35 @@ +{ + "properties": { + "ww3": { + "additionalProperties": false, + "properties": { + "namelist": { + "additionalProperties": false, + "properties": { + "template_file": { + "type": "string" + }, + "template_values": { + "additionalProperties": { + "type": "string" + }, + "type": "object" + } + }, + "required": [ + "template_file" + ], + "type": "object" + }, + "run_dir": { + "type": "string" + } + }, + "required": [ + "namelist", + "run_dir" + ], + "type": "object" + } + } +} diff --git a/src/uwtools/strings.py b/src/uwtools/strings.py index 16835e544..cef7a9cf7 100644 --- a/src/uwtools/strings.py +++ b/src/uwtools/strings.py @@ -122,3 +122,4 @@ class STR: valsneeded: str = "values_needed" verbose: str = "verbose" version: str = "version" + ww3: str = "ww3" diff --git a/src/uwtools/tests/drivers/test_driver.py b/src/uwtools/tests/drivers/test_driver.py index 10b57580b..49c2cdd1a 100644 --- a/src/uwtools/tests/drivers/test_driver.py +++ b/src/uwtools/tests/drivers/test_driver.py @@ -23,6 +23,30 @@ # Helpers +class ConcreteAssets(driver.Assets): + """ + Driver subclass for testing purposes. + """ + + def provisioned_run_directory(self): + pass + + @property + def _driver_name(self) -> str: + return "concrete" + + def _taskname(self, suffix: str) -> str: + return "concrete" + + def _validate(self) -> None: + pass + + @task + def atask(self): + yield "atask" + yield asset("atask", lambda: True) + + class ConcreteDriver(driver.Driver): """ Driver subclass for testing purposes. @@ -83,6 +107,17 @@ def config(tmp_path): } +@fixture +def assetobj(config): + return ConcreteAssets( + config=config, + dry_run=False, + batch=True, + cycle=dt.datetime(2024, 3, 22, 18), + leadtime=dt.timedelta(hours=24), + ) + + @fixture def driverobj(config): return ConcreteDriver( @@ -94,24 +129,24 @@ def driverobj(config): ) -# Tests +# Asset Tests -def test_Driver(driverobj): - assert Path(driverobj._driver_config["base_file"]).name == "base.yaml" - assert driverobj._batch is True +def test_Assets(assetobj): + assert Path(assetobj._driver_config["base_file"]).name == "base.yaml" + assert assetobj._batch is True -def test_Driver_cycle_leadtime_error(config): +def test_Asset_cycle_leadtime_error(config): with raises(UWError) as e: - ConcreteDriver(config=config, leadtime=dt.timedelta(hours=24)) + ConcreteAssets(config=config, leadtime=dt.timedelta(hours=24)) assert "When leadtime is specified, cycle is required" in str(e) @pytest.mark.parametrize("val", (True, False)) -def test_Driver_dry_run(config, val): +def test_Asset_dry_run(config, val): with patch.object(driver, "dryrun") as dryrun: - ConcreteDriver(config=config, dry_run=val) + ConcreteAssets(config=config, dry_run=val) dryrun.assert_called_once_with(enable=val) @@ -119,7 +154,7 @@ def test_Driver_dry_run(config, val): def test_key_path(config): - driverobj = ConcreteDriver( + assetobj = ConcreteAssets( config={"foo": {"bar": config}}, dry_run=False, batch=True, @@ -127,7 +162,81 @@ def test_key_path(config): key_path=["foo", "bar"], leadtime=dt.timedelta(hours=24), ) - assert config == driverobj._config + assert config == assetobj._config + + +def test_Asset_validate(caplog, assetobj): + log.setLevel(logging.INFO) + assetobj.validate() + assert regex_logged(caplog, "State: Ready") + + +# Tests for private helper methods + + +@pytest.mark.parametrize( + "base_file,update_values,expected", + [ + (False, False, {}), + (False, True, {"a": 33}), + (True, False, {"a": 11, "b": 22}), + (True, True, {"a": 33, "b": 22}), + ], +) +def test_Asset__create_user_updated_config_base_file( + base_file, assetobj, expected, tmp_path, update_values +): + path = tmp_path / "updated.yaml" + dc = assetobj._driver_config + if not base_file: + del dc["base_file"] + if not update_values: + del dc["update_values"] + ConcreteAssets._create_user_updated_config(config_class=YAMLConfig, config_values=dc, path=path) + with open(path, "r", encoding="utf-8") as f: + updated = yaml.safe_load(f) + assert updated == expected + + +def test_Asset__driver_config_fail(assetobj): + del assetobj._config["concrete"] + with raises(UWConfigError) as e: + assert assetobj._driver_config + assert str(e.value) == "Required 'concrete' block missing in config" + + +def test_Asset__driver_config_pass(assetobj): + assert set(assetobj._driver_config.keys()) == { + "base_file", + "execution", + "run_dir", + "update_values", + } + + +def test_Asset__rundir(assetobj): + assert assetobj._rundir == Path("/path/to/2024032218/run") + + +def test_Asset__validate(assetobj): + with patch.object(assetobj, "_validate", driver.Assets._validate): + with patch.object(driver, "validate_internal") as validate_internal: + assetobj._validate(assetobj) + assert validate_internal.call_args_list[0].kwargs == { + "schema_name": "concrete", + "config": assetobj._config, + } + + +# Driver Tests + + +def test_Driver(driverobj): + assert Path(driverobj._driver_config["base_file"]).name == "base.yaml" + assert driverobj._batch is True + + +# Tests for workflow methods @pytest.mark.parametrize("batch", [True, False]) @@ -146,12 +255,6 @@ def test_Driver_run(batch, driverobj): rvle.assert_called_once_with() -def test_Driver_validate(caplog, driverobj): - log.setLevel(logging.INFO) - driverobj.validate() - assert regex_logged(caplog, "State: Ready") - - def test_Driver__run_via_batch_submission(driverobj): runscript = driverobj._runscript_path executable = Path(driverobj._driver_config["execution"]["executable"]) @@ -323,10 +426,6 @@ def test_Driver__runscript_execution_only(driverobj): assert driverobj._runscript(execution=["foo", "bar"]) == dedent(expected).strip() -def test_Driver__rundir(driverobj): - assert driverobj._rundir == Path("/path/to/2024032218/run") - - def test_Driver__runscript_path(driverobj): assert driverobj._runscript_path == Path("/path/to/2024032218/run/runscript.concrete") @@ -338,17 +437,17 @@ def test_Driver__scheduler(driverobj): JobScheduler.get_scheduler.assert_called_with(driverobj._resources) -def test_Driver__validate(driverobj): - with patch.object(driverobj, "_validate", driver.Driver._validate): +def test_Driver__validate(assetobj): + with patch.object(assetobj, "_validate", driver.Driver._validate): with patch.object(driver, "validate_internal") as validate_internal: - driverobj._validate(driverobj) + assetobj._validate(assetobj) assert validate_internal.call_args_list[0].kwargs == { "schema_name": "concrete", - "config": driverobj._config, + "config": assetobj._config, } assert validate_internal.call_args_list[1].kwargs == { "schema_name": "platform", - "config": driverobj._config, + "config": assetobj._config, } diff --git a/src/uwtools/tests/drivers/test_ww3.py b/src/uwtools/tests/drivers/test_ww3.py new file mode 100644 index 000000000..da74ef31d --- /dev/null +++ b/src/uwtools/tests/drivers/test_ww3.py @@ -0,0 +1,90 @@ +# pylint: disable=missing-function-docstring,protected-access,redefined-outer-name +""" +WaveWatchIII driver tests. +""" +import datetime as dt +from unittest.mock import DEFAULT as D +from unittest.mock import patch + +import yaml +from pytest import fixture + +from uwtools.drivers import ww3 + +# Fixtures + + +@fixture +def config(tmp_path): + return { + "ww3": { + "namelist": { + "template_file": str(tmp_path / "ww3_shel.nml.IN"), + "template_values": { + "input_forcing_winds": "C", + }, + }, + "run_dir": str(tmp_path), + }, + } + + +@fixture +def config_file(config, tmp_path): + path = tmp_path / "config.yaml" + with open(path, "w", encoding="utf-8") as f: + yaml.dump(config, f) + return path + + +@fixture +def cycle(): + return dt.datetime(2024, 2, 1, 18) + + +@fixture +def driverobj(config_file, cycle): + return ww3.WaveWatchIII(config=config_file, cycle=cycle, batch=True) + + +# Tests + + +def test_WaveWatchIII(driverobj): + assert isinstance(driverobj, ww3.WaveWatchIII) + + +def test_WaveWatchIII_namelist_file(driverobj): + src = driverobj._driver_config["namelist"]["template_file"] + with open(src, "w", encoding="utf-8") as f: + yaml.dump({}, f) + dst = driverobj._rundir / "ww3_shel.nml" + assert not dst.is_file() + driverobj.namelist_file() + assert dst.is_file() + + +def test_WaveWatchIII_provisioned_run_directory(driverobj): + with patch.multiple( + driverobj, + namelist_file=D, + restart_directory=D, + ) as mocks: + driverobj.provisioned_run_directory() + for m in mocks: + mocks[m].assert_called_once_with() + + +def test_WaveWatchIII_restart_directory(driverobj): + path = driverobj._rundir / "restart_wave" + assert not path.is_dir() + driverobj.restart_directory() + assert path.is_dir() + + +def test_WaveWatchIII__driver_config(driverobj): + assert driverobj._driver_config == driverobj._config["ww3"] + + +def test_WaveWatchIII__validate(driverobj): + driverobj._validate() diff --git a/src/uwtools/tests/test_schemas.py b/src/uwtools/tests/test_schemas.py index 53a84f1c7..eb1158815 100644 --- a/src/uwtools/tests/test_schemas.py +++ b/src/uwtools/tests/test_schemas.py @@ -101,6 +101,11 @@ def upp_prop(): return partial(schema_validator, "upp", "properties", "upp", "properties") +@fixture +def ww3_prop(): + return partial(schema_validator, "ww3", "properties", "ww3", "properties") + + # chgres-cube @@ -1282,3 +1287,48 @@ def test_schema_upp_run_dir(upp_prop): # Must be a string: assert not errors("/some/path") assert "88 is not of type 'string'" in errors(88) + + +# ungrib + + +def test_schema_ww3(): + config = { + "namelist": { + "template_file": "/tmp/ww3_shel.nml", + "template_values": { + "input_forcing_winds": "C", + }, + }, + "run_dir": "/tmp", + } + errors = schema_validator("ww3", "properties", "ww3") + # Basic correctness: + assert not errors(config) + # All top-level keys are required: + for key in ("namelist", "run_dir"): + assert f"'{key}' is a required property" in errors(with_del(config, key)) + # Additional top-level keys are not allowed: + assert "Additional properties are not allowed" in errors({**config, "foo": "bar"}) + + +def test_schema_ww3_namelist(ww3_prop): + errors = ww3_prop("namelist") + # At least template_file is required: + assert "'template_file' is a required property" in errors({}) + # Just template_file is ok: + assert not errors({"template_file": "/path/to/ww3_shel.nml"}) + # Both template_file and template_values are ok: + assert not errors( + { + "template_file": "/path/to/ww3_shel.nml", + "template_values": {"input_forcing_winds": "C"}, + } + ) + + +def test_schema_ww3_run_dir(ww3_prop): + errors = ww3_prop("run_dir") + # Must be a string: + assert not errors("/some/path") + assert "88 is not of type 'string'" in errors(88)