From 006d4eb138318f9ddf8ca80b987ff89727689537 Mon Sep 17 00:00:00 2001 From: Espen Hagen <2492641+espenhgn@users.noreply.github.com> Date: Mon, 30 Mar 2026 11:38:31 +0200 Subject: [PATCH 01/10] Fix edge case in LaminarCurrentSourceDensity Fixes #196 Fixes #189 Fixes #195 --- .github/workflows/coveralls.yml | 2 +- .github/workflows/flake8.yml | 2 +- .github/workflows/python-app.yml | 2 +- .gitignore | 2 ++ lfpykit/models.py | 50 +++++++++++++++++++++------ lfpykit/tests/test_module.py | 53 ++++++++++++++++++++++++++-- lfpykit/version.py | 2 +- pyproject.toml | 21 ++++++++++++ setup.cfg | 2 -- setup.py | 59 -------------------------------- 10 files changed, 117 insertions(+), 78 deletions(-) create mode 100644 pyproject.toml delete mode 100644 setup.cfg delete mode 100644 setup.py diff --git a/.github/workflows/coveralls.yml b/.github/workflows/coveralls.yml index d7b0786..f9b511c 100644 --- a/.github/workflows/coveralls.yml +++ b/.github/workflows/coveralls.yml @@ -16,7 +16,7 @@ jobs: strategy: matrix: os: [ubuntu-latest] - python-version: [3.8] + python-version: [3.12] steps: - uses: actions/checkout@v3 diff --git a/.github/workflows/flake8.yml b/.github/workflows/flake8.yml index 628f33e..51d5f9d 100644 --- a/.github/workflows/flake8.yml +++ b/.github/workflows/flake8.yml @@ -12,7 +12,7 @@ jobs: - name: Set up Python environment uses: actions/setup-python@v3 with: - python-version: "3.8" + python-version: "3.12" - name: flake8 Lint uses: reviewdog/action-flake8@v3 with: diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml index e2ccc54..1e9eb77 100644 --- a/.github/workflows/python-app.yml +++ b/.github/workflows/python-app.yml @@ -16,7 +16,7 @@ jobs: strategy: matrix: os: [ubuntu-latest] - python-version: ["3.7", "3.8", "3.9", "3.10", "3.11"] + python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] steps: - uses: actions/checkout@v3 diff --git a/.gitignore b/.gitignore index cada93f..219f1ca 100644 --- a/.gitignore +++ b/.gitignore @@ -130,6 +130,7 @@ dmypy.json # MacOS crap .DS_Store +__MACOSX/ # NEURON arm64/ @@ -140,3 +141,4 @@ doc/source/_build # examples examples/src +L5bPCmodelsEH/ diff --git a/lfpykit/models.py b/lfpykit/models.py index ca2a303..246afa8 100644 --- a/lfpykit/models.py +++ b/lfpykit/models.py @@ -2063,8 +2063,34 @@ def get_transformation_matrix(self): z2 = np.array([z_i[0], z_i[1], z_i[0]]) z3 = np.array([z_i[0], z_i[1], z_i[1]]) + + def _get_fraction_from_z_crossing(k, z_cross, endpoint): + z0 = self.cell.z[k, 0] + z1 = self.cell.z[k, 1] + dz = z1 - z0 + if np.isclose(dz, 0.): + return None + + t = (z_cross - z0) / dz + if not (0. <= t <= 1.): + return None + + if endpoint == 0: + return abs(t) + return abs(1. - t) + # iterate over lower, right, upper boundary for k in np.where(inds)[0]: + # If both endpoints are inside the cylindrical radius, the + # in-volume fraction follows directly from z-intersection. + if kk1[k]: + z_cross = z_i[1] if self.cell.z[k, 1] >= z_i[1] else z_i[0] + frac = _get_fraction_from_z_crossing( + k=k, z_cross=z_cross, endpoint=0) + if frac is not None: + M[i, k] = frac + continue + for ll in range(3): Pr, Pz, hit = _PrPz(r0=R[k, 0], z0=self.cell.z[k, 0], r1=R[k, 1], z1=self.cell.z[k, 1], @@ -2080,6 +2106,16 @@ def get_transformation_matrix(self): inds = (~ll0) & ll1 for k in np.where(inds)[0]: + # If both endpoints are inside the cylindrical radius, the + # in-volume fraction follows directly from z-intersection. + if kk0[k]: + z_cross = z_i[1] if self.cell.z[k, 0] >= z_i[1] else z_i[0] + frac = _get_fraction_from_z_crossing( + k=k, z_cross=z_cross, endpoint=1) + if frac is not None: + M[i, k] = frac + continue + for ll in range(3): Pr, Pz, hit = _PrPz(r0=R[k, 0], z0=self.cell.z[k, 0], r1=R[k, 1], z1=self.cell.z[k, 1], @@ -2106,15 +2142,7 @@ def _PrPz(r0, z0, r1, z1, r2, z2, r3, z3): - (r0 - r1) * (r2 * z3 - r3 * z2)) / denom) Pz = (((r0 * z1 - z0 * r1) * (z2 - z3) - (z0 - z1) * (r2 * z3 - r3 * z2)) / denom) - # check if intersection point lies on lines - if (Pr >= r0) & (Pr <= r1) & (Pz >= z0) & (Pz <= z1): - hit = True - elif (Pr <= r0) & (Pr >= r1) & (Pz >= z0) & (Pz <= z1): - hit = True - elif (Pr >= r0) & (Pr <= r1) & (Pz <= z0) & (Pz >= z1): - hit = True - elif (Pr <= r0) & (Pr >= r1) & (Pz <= z0) & (Pz >= z1): - hit = True - else: - hit = False + # check if intersection point lies on line segment (r0, z0) -> (r1, z1) + hit = (min(r0, r1) <= Pr <= max(r0, r1) + and min(z0, z1) <= Pz <= max(z0, z1)) return Pr, Pz, hit diff --git a/lfpykit/tests/test_module.py b/lfpykit/tests/test_module.py index f82ce0b..df79b39 100644 --- a/lfpykit/tests/test_module.py +++ b/lfpykit/tests/test_module.py @@ -732,7 +732,7 @@ def test_LaminarCurrentSourceDensity_02(self): np.testing.assert_allclose(M_gt, M) - def test_LaminarCurrentSourceDensity_3(self): + def test_LaminarCurrentSourceDensity_03(self): '''test LaminarCurrentSourceDensity implementation''' cell = get_cell(n_seg=3) cell.z = cell.z * 10 @@ -754,7 +754,7 @@ def test_LaminarCurrentSourceDensity_3(self): np.testing.assert_allclose(M_gt, M) - def test_LaminarCurrentSourceDensity_4(self): + def test_LaminarCurrentSourceDensity_04(self): '''test LaminarCurrentSourceDensity implementation''' cell = get_cell(n_seg=4) cell.z = cell.z * 10 @@ -826,6 +826,55 @@ def test_LaminarCurrentSourceDensity_06(self): np.testing.assert_allclose(M_gt, M) + + def test_LaminarCurrentSourceDensity_07(self): + '''test LaminarCurrentSourceDensity implementation + + Issue #196 (https://github.com/LFPy/LFPykit/issues/196) + ''' + cell = lfp.CellGeometry(x=np.array([[50, 50], [50, -50]]), + y=np.array([[0, 0], [0, 0]]), + z=np.array([[-100, -50], [-50, 50]]), + d=np.array([1, 1])) + + h = 150. + r = 200. + M_gt = np.array([[1., 0.5], + [0., 0.5]]) / (np.pi * r**2 * h) + + z = np.array([[-h, 0.], [0., h]]) + r = np.array([r, r]) + + # instantiate electrode, get linear response matrix + csd_lam = lfp.LaminarCurrentSourceDensity(cell=cell, z=z, r=r) + M = csd_lam.get_transformation_matrix() + + np.testing.assert_allclose(M_gt, M) + + def test_LaminarCurrentSourceDensity_08(self): + '''test LaminarCurrentSourceDensity implementation + + Issue #196 (https://github.com/LFPy/LFPykit/issues/196) + ''' + cell = lfp.CellGeometry(x=np.array([[0, 0], [0, 100]]), + y=np.array([[0, 0], [0, 0]]), + z=np.array([[-100, -50], [-50, 50]]), + d=np.array([1, 1])) + + h = 150. + r = 200. + M_gt = np.array([[1., 0.5], + [0., 0.5]]) / (np.pi * r**2 * h) + + z = np.array([[-h, 0.], [0., h]]) + r = np.array([r, r]) + + # instantiate electrode, get linear response matrix + csd_lam = lfp.LaminarCurrentSourceDensity(cell=cell, z=z, r=r) + M = csd_lam.get_transformation_matrix() + + np.testing.assert_allclose(M_gt, M) + def test_VolumetricCurrentSourceDensity_00(self): cell = get_cell(n_seg=1) cell.z = cell.z * 10. diff --git a/lfpykit/version.py b/lfpykit/version.py index abe1e1c..aaf05b3 100644 --- a/lfpykit/version.py +++ b/lfpykit/version.py @@ -1 +1 @@ -version = "0.5.1" +version = "0.6.0" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..0d9ac41 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,21 @@ +[build-system] +requires = ["poetry-core>=1.0.0"] +build-backend = "poetry.core.masonry.api" + +[tool.poetry] +name = "LFPykit" +version = "0.6.0" +description = "Electrostatic models for multicompartment neuron models" +authors = ["LFPy-team "] + +[tool.poetry.dependencies] +python = ">=3.10" +numpy = ">=1.15.2" +scipy = ">=1.5.2" +meautility = ">=1.5.1" + +[tool.poetry.extras] +tests = ["pytest", "sympy"] +docs = ["sphinx", "numpydoc", "sphinx_rtd_theme", "recommonmark"] + +[tool.poetry.dev-dependencies] diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index b88034e..0000000 --- a/setup.cfg +++ /dev/null @@ -1,2 +0,0 @@ -[metadata] -description-file = README.md diff --git a/setup.py b/setup.py deleted file mode 100644 index bb5b56b..0000000 --- a/setup.py +++ /dev/null @@ -1,59 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- -'''LFPykit setuptools file - -''' - -import os -import setuptools - -d = {} -exec(open(os.path.join('lfpykit', 'version.py')).read(), None, d) -version = d['version'] - - -with open('README.md', 'r') as fh: - long_description = fh.read() - -setuptools.setup( - name='LFPykit', - version=version, - author='LFPy-team', - author_email='lfpy@users.noreply.github.com', - description='Electrostatic models for multicompartment neuron models', - long_description=long_description, - long_description_content_type='text/markdown', - url='https://github.com/LFPy/LFPykit', - packages=setuptools.find_packages(), - classifiers=[ - 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.7', - 'Programming Language :: Python :: 3.8', - 'Programming Language :: Python :: 3.9', - 'Programming Language :: Python :: 3.10', - 'Programming Language :: Python :: 3.11', - 'License :: OSI Approved :: GNU General Public License (GPL)', - 'Operating System :: OS Independent', - 'Topic :: Scientific/Engineering', - 'Topic :: Scientific/Engineering :: Physics', - 'Topic :: Utilities', - 'Intended Audience :: Developers', - 'Intended Audience :: Science/Research', - 'Development Status :: 4 - Beta', - ], - python_requires='>=3.7', - install_requires=[ - 'numpy>=1.15.2', - 'scipy', - 'meautility'], - package_data={'lfpykit': [os.path.join('tests', '*.npz'), - os.path.join('tests', '*.py')]}, - include_package_data=True, - extras_require={'tests': ['pytest', 'sympy'], - 'docs': ['sphinx', 'numpydoc', 'sphinx_rtd_theme', - 'recommonmark'], - }, - dependency_links=[], - provides=['lfpykit'], - zip_safe=False -) From 020975f5072a0d95ff8ca37ff51e1660214a57f0 Mon Sep 17 00:00:00 2001 From: Espen Hagen <2492641+espenhgn@users.noreply.github.com> Date: Mon, 30 Mar 2026 11:46:24 +0200 Subject: [PATCH 02/10] autopep8 --- lfpykit/tests/test_module.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/lfpykit/tests/test_module.py b/lfpykit/tests/test_module.py index df79b39..0960a92 100644 --- a/lfpykit/tests/test_module.py +++ b/lfpykit/tests/test_module.py @@ -826,7 +826,6 @@ def test_LaminarCurrentSourceDensity_06(self): np.testing.assert_allclose(M_gt, M) - def test_LaminarCurrentSourceDensity_07(self): '''test LaminarCurrentSourceDensity implementation @@ -841,7 +840,7 @@ def test_LaminarCurrentSourceDensity_07(self): r = 200. M_gt = np.array([[1., 0.5], [0., 0.5]]) / (np.pi * r**2 * h) - + z = np.array([[-h, 0.], [0., h]]) r = np.array([r, r]) @@ -865,7 +864,7 @@ def test_LaminarCurrentSourceDensity_08(self): r = 200. M_gt = np.array([[1., 0.5], [0., 0.5]]) / (np.pi * r**2 * h) - + z = np.array([[-h, 0.], [0., h]]) r = np.array([r, r]) From 821159f70f5cf83fe8d0f48f6ee3ab8f074119fc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 30 Mar 2026 09:59:58 +0000 Subject: [PATCH 03/10] Fix poetry extras: declare packages as optional dependencies before referencing in extras Agent-Logs-Url: https://github.com/LFPy/LFPykit/sessions/6172e06c-8d32-44a4-9019-3a15086bbb76 Co-authored-by: espenhgn <2492641+espenhgn@users.noreply.github.com> --- pyproject.toml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 0d9ac41..3b21b57 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,6 +13,12 @@ python = ">=3.10" numpy = ">=1.15.2" scipy = ">=1.5.2" meautility = ">=1.5.1" +pytest = {version = "*", optional = true} +sympy = {version = "*", optional = true} +sphinx = {version = "*", optional = true} +numpydoc = {version = "*", optional = true} +sphinx_rtd_theme = {version = "*", optional = true} +recommonmark = {version = "*", optional = true} [tool.poetry.extras] tests = ["pytest", "sympy"] From 34d7a2e2fb9982e22dca9f7bb0e797eb21337017 Mon Sep 17 00:00:00 2001 From: Espen Hagen <2492641+espenhgn@users.noreply.github.com> Date: Mon, 30 Mar 2026 12:51:15 +0200 Subject: [PATCH 04/10] update publish action --- .github/workflows/python-publish.yml | 17 +++++++++-------- pyproject.toml | 2 +- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml index ea9f559..5302002 100644 --- a/.github/workflows/python-publish.yml +++ b/.github/workflows/python-publish.yml @@ -13,26 +13,27 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python - uses: actions/setup-python@v3 + uses: actions/setup-python@v5 with: - python-version: '3.x' + python-version: '3.10' - name: Install dependencies run: | python -m pip install --upgrade pip - pip install setuptools twine wheel + pip install poetry twine + - name: Build package + run: | + poetry build - name: Build and publish to Test PYPI env: TWINE_USERNAME: ${{ secrets.TEST_PYPI_USERNAME }} TWINE_PASSWORD: ${{ secrets.TEST_PYPI_PASSWORD }} run: | - python setup.py sdist bdist_wheel - twine upload --repository-url https://test.pypi.org/legacy/ dist/* --verbose - - name: Build and publish + twine upload --repository-url https://test.pypi.org/legacy/ dist/* --verbose + - name: Publish to PYPI env: TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} run: | - python setup.py sdist bdist_wheel twine upload dist/* --verbose diff --git a/pyproject.toml b/pyproject.toml index 3b21b57..77af28f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,4 +24,4 @@ recommonmark = {version = "*", optional = true} tests = ["pytest", "sympy"] docs = ["sphinx", "numpydoc", "sphinx_rtd_theme", "recommonmark"] -[tool.poetry.dev-dependencies] +[tool.poetry.group.dev.dependencies] From 99b4a4e57b93f81bc904d16f01aef31725a0ad52 Mon Sep 17 00:00:00 2001 From: Espen Hagen <2492641+espenhgn@users.noreply.github.com> Date: Mon, 30 Mar 2026 13:07:17 +0200 Subject: [PATCH 05/10] Python >= 3.10 --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index b1daf77..e8845d4 100644 --- a/README.md +++ b/README.md @@ -266,7 +266,7 @@ https://lfpykit.readthedocs.io/en/latest ## Dependencies -`LFPykit` is implemented in Python and is written (and continuously tested) for `Python >= 3.7`. +`LFPykit` is implemented in Python and is written (and continuously tested) for `Python >= 3.10`. The main `LFPykit` module depends on `numpy`, `scipy` and `MEAutility` (https://github.com/alejoe91/MEAutility, https://meautility.readthedocs.io/en/latest/). Running all unit tests and example files may in addition require `py.test`, `matplotlib`, From a2f813c0e9e0b704834f1a08ace0f5a4b3516865 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torbj=C3=B8rn=20V=20Ness?= Date: Mon, 30 Mar 2026 22:39:56 +0200 Subject: [PATCH 06/10] Added test that cell compartments are shorter than CSD resolution --- README.md | 6 ++++-- lfpykit/models.py | 3 +++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index e8845d4..20c510b 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,11 @@ # LFPykit -This Python module contain freestanding implementations of electrostatic +The online documentation of `LFPykit` can be found here: +https://lfpykit.readthedocs.io/en/latest + +`LFPykit` is a freestanding implementations of electrostatic forward models incorporated in LFPy (https://github.com/LFPy/LFPy, https://LFPy.readthedocs.io). - The aim of the `LFPykit` module is to provide electrostatic models in a manner that facilitates forward-model predictions of extracellular potentials and related measures from multicompartment neuron models, but diff --git a/lfpykit/models.py b/lfpykit/models.py index 246afa8..bd3deeb 100644 --- a/lfpykit/models.py +++ b/lfpykit/models.py @@ -1994,6 +1994,9 @@ def __init__(self, cell, z, r): assert r.ndim == 1, 'r.ndim != 1' assert r.shape[0] == z.shape[0], 'r.shape[0] != z.shape[0]' assert np.all(r > 0), 'r must be greater than 0' + len_msg = "All compartments must be shorter than CSD resolution. " +\ + "Increase cell spatial resolution or decrease CSD resolution." + assert np.max(np.diff(z, axis=-1)) > np.max(cell.length), len_msg self.z = z self.r = r From d840bb7e048ab1b5ad16e8ece9f17190c2b24a59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torbj=C3=B8rn=20Vefferstad=20Ness?= Date: Mon, 30 Mar 2026 22:45:54 +0200 Subject: [PATCH 07/10] Add assertion for compartment length relative to CSD --- lfpykit/models.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lfpykit/models.py b/lfpykit/models.py index 246afa8..bd3deeb 100644 --- a/lfpykit/models.py +++ b/lfpykit/models.py @@ -1994,6 +1994,9 @@ def __init__(self, cell, z, r): assert r.ndim == 1, 'r.ndim != 1' assert r.shape[0] == z.shape[0], 'r.shape[0] != z.shape[0]' assert np.all(r > 0), 'r must be greater than 0' + len_msg = "All compartments must be shorter than CSD resolution. " +\ + "Increase cell spatial resolution or decrease CSD resolution." + assert np.max(np.diff(z, axis=-1)) > np.max(cell.length), len_msg self.z = z self.r = r From cbc1f6ded3fe7a5ddbf896c3e22df858e620b2bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torbj=C3=B8rn=20Vefferstad=20Ness?= Date: Mon, 30 Mar 2026 22:48:46 +0200 Subject: [PATCH 08/10] Update README with documentation link and description It is easy to miss that the top link is to the LFPy documentation, and not the LFPykit documentation. I suggest adding the LFPykit documentation link at the top. --- README.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index e8845d4..907cb79 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,9 @@ # LFPykit -This Python module contain freestanding implementations of electrostatic +The online documentation of `LFPykit` can be found here: +https://lfpykit.readthedocs.io/en/latest + +`LFPykit` is a freestanding implementations of electrostatic forward models incorporated in LFPy (https://github.com/LFPy/LFPy, https://LFPy.readthedocs.io). From 793fa5cd23e4d5d59d87b90e0d6622ca4b18794a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torbj=C3=B8rn=20V=20Ness?= Date: Tue, 7 Apr 2026 22:40:18 +0200 Subject: [PATCH 09/10] Changed assert to UserWarning, to allow more flexibility in testing --- lfpykit/models.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/lfpykit/models.py b/lfpykit/models.py index bd3deeb..d10a286 100644 --- a/lfpykit/models.py +++ b/lfpykit/models.py @@ -17,6 +17,7 @@ import sys from copy import deepcopy import numpy as np +import warnings from . import lfpcalc import MEAutility as mu @@ -1994,10 +1995,14 @@ def __init__(self, cell, z, r): assert r.ndim == 1, 'r.ndim != 1' assert r.shape[0] == z.shape[0], 'r.shape[0] != z.shape[0]' assert np.all(r > 0), 'r must be greater than 0' - len_msg = "All compartments must be shorter than CSD resolution. " +\ - "Increase cell spatial resolution or decrease CSD resolution." - assert np.max(np.diff(z, axis=-1)) > np.max(cell.length), len_msg + if np.max(np.diff(z, axis=-1)) > np.max(cell.length): + warn_msg = ( + "All compartments should be shorter than CSD resolution. " + "Consider increasing cell spatial resolution or " + "decreasing CSD resolution." + ) + warnings.warn(warn_msg, UserWarning) self.z = z self.r = r From ee7d501eb3d1544489386e3606960fb745b626d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torbj=C3=B8rn=20V=20Ness?= Date: Tue, 7 Apr 2026 22:47:16 +0200 Subject: [PATCH 10/10] Error in merging caused both warning and assert --- lfpykit/models.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/lfpykit/models.py b/lfpykit/models.py index 4933bf5..09f82b8 100644 --- a/lfpykit/models.py +++ b/lfpykit/models.py @@ -1995,10 +1995,6 @@ def __init__(self, cell, z, r): assert r.ndim == 1, 'r.ndim != 1' assert r.shape[0] == z.shape[0], 'r.shape[0] != z.shape[0]' assert np.all(r > 0), 'r must be greater than 0' - len_msg = "All compartments must be shorter than CSD resolution. " +\ - "Increase cell spatial resolution or decrease CSD resolution." - assert np.max(np.diff(z, axis=-1)) > np.max(cell.length), len_msg - if np.max(np.diff(z, axis=-1)) > np.max(cell.length): warn_msg = ( "All compartments should be shorter than CSD resolution. "