From a8384fba3478a7e3e39589f8429a0d2c5e3c2998 Mon Sep 17 00:00:00 2001 From: Zebedee Nicholls Date: Fri, 8 Aug 2025 11:21:32 +0200 Subject: [PATCH 1/8] Finish off testing --- src/pandas_openscm/index_manipulation.py | 138 ++++++++++++++++-- ...x_manipulation_update_levels_from_other.py | 95 +++++++++++- 2 files changed, 219 insertions(+), 14 deletions(-) diff --git a/src/pandas_openscm/index_manipulation.py b/src/pandas_openscm/index_manipulation.py index fc4267c..260c0fd 100644 --- a/src/pandas_openscm/index_manipulation.py +++ b/src/pandas_openscm/index_manipulation.py @@ -405,6 +405,53 @@ def create_new_level_and_codes_by_mapping( return new_level, new_codes +def create_new_level_and_codes_by_mapping_multiple( + ini: pd.MultiIndex, + levels_to_create_from: tuple[str, ...], + mapper: Callable[[Any], Any] | dict[Any, Any] | pd.Series[Any], +) -> tuple[pd.Index[Any], npt.NDArray[np.integer[Any]]]: + """ + Create a new level and associated codes by mapping existing levels + + This is a thin function intended for internal use + to handle some slightly tricky logic. + + Parameters + ---------- + ini + Input index + + levels_to_create_from + Levels to create the new level from + + mapper + Function to use to map existing levels to new levels + + Returns + ------- + new_level : + New level + + new_codes : + New codes + """ + # You could probably do some optimisation here + # that checks for unique combinations of codes + # for the levels we're using, + # then only applies the mapping to those unique combos + # to reduce the number of evaluations of mapper. + # That feels tricky to get right, so just doing the brute force way for now. + dup_level = ini.droplevel( + ini.names.difference(list(levels_to_create_from)) # type: ignore # pandas-stubs confused + ).map(mapper) + + # Brute force: get codes from new levels + new_level = dup_level.unique() + new_codes = new_level.get_indexer(dup_level) + + return new_level, new_codes + + def update_index_levels_func( df: pd.DataFrame, updates: Mapping[Any, Callable[[Any], Any] | dict[Any, Any] | pd.Series[Any]], @@ -629,7 +676,11 @@ def update_index_levels_from_other_func( def update_levels_from_other( ini: pd.MultiIndex, update_sources: dict[ - Any, tuple[Any, Callable[[Any], Any] | dict[Any, Any] | pd.Series[Any]] + Any, + tuple[ + Any | tuple[Any, ...], + Callable[[Any], Any] | dict[Any, Any] | pd.Series[Any], + ], ], remove_unused_levels: bool = True, ) -> pd.MultiIndex: @@ -650,13 +701,24 @@ def update_levels_from_other( Each key is the level to which the updates will be applied (or the level that will be created if it doesn't already exist). - Each value is a tuple of which the first element + There are two options for the values. + + The first is used when only one level is used to update the 'target level'. + In this case, each value is a tuple of which the first element is the level to use to generate the values (the 'source level') and the second is mapper of the form used by [pd.Index.map][pandas.Index.map] which will be applied to the source level to update/create the level of interest. + Each value is a tuple of which the first element + is the level or levels (if a tuple) + to use to generate the values (the 'source level') + and the second is mapper of the form used by + [pd.Index.map][pandas.Index.map] + which will be applied to the source level + to update/create the level of interest. + remove_unused_levels Call `ini.remove_unused_levels` before updating the levels @@ -718,6 +780,19 @@ def update_levels_from_other( ('sa', 'model sa', 'v2', 'km')], names=['scenario', 'model', 'variable', 'unit']) >>> + >>> # Create a new level based on multiple existing levels + >>> update_levels_from_other( + ... start, + ... { + ... "model || scenario": (("model", "scenario"), lambda x: " || ".join(x)), + ... }, + ... ) + MultiIndex([('sa', 'ma', 'v1', 'kg', 'sa || ma'), + ('sb', 'ma', 'v2', 'm', 'sb || ma'), + ('sa', 'mb', 'v1', 'kg', 'sa || mb'), + ('sa', 'mb', 'v2', 'm', 'sa || mb')], + names=['scenario', 'model', 'variable', 'unit', 'model || scenario']) + >>> >>> # Both at the same time >>> update_levels_from_other( ... start, @@ -731,7 +806,28 @@ def update_levels_from_other( ('sa', 'mb', 'v1', nan, 'Sa'), ('sa', 'mb', 'v2', nan, 'Sa')], names=['scenario', 'model', 'variable', 'unit', 'title']) - """ + >>> + >>> # Setting with a range of different methods + >>> update_levels_from_other( + ... start, + ... { + ... # callable + ... "y-label": (("variable", "unit"), lambda x: f"{x[0]} ({x[1]})"), + ... # dict + ... "title": ("scenario", {"sa": "Scenario A", "sb": "Delta"}), + ... # pd.Series + ... "Source": ( + ... "model", + ... pd.Series(["Internal", "External"], index=["ma", "mb"]), + ... ), + ... }, + ... ) + MultiIndex([('sa', 'ma', 'v1', 'kg', 'v1 (kg)', 'Scenario A', 'Internal'), + ('sb', 'ma', 'v2', 'm', 'v2 (m)', 'Delta', 'Internal'), + ('sa', 'mb', 'v1', 'kg', 'v1 (kg)', 'Scenario A', 'External'), + ('sa', 'mb', 'v2', 'm', 'v2 (m)', 'Scenario A', 'External')], + names=['scenario', 'model', 'variable', 'unit', 'y-label', 'title', 'Source']) + """ # noqa: E501 if remove_unused_levels: ini = ini.remove_unused_levels() # type: ignore @@ -740,17 +836,35 @@ def update_levels_from_other( names: list[str] = list(ini.names) for level, (source, updater) in update_sources.items(): - if source not in ini.names: - msg = ( - f"{source} is not available in the index. Available levels: {ini.names}" + if isinstance(source, tuple): + missing_levels = set(source) - set(ini.names) + if missing_levels: + conj = "is" if len(missing_levels) == 1 else "are" + msg = ( + f"{missing_levels} {conj} not available in the index. " + f"Available levels: {ini.names}" + ) + raise KeyError(msg) + + new_level, new_codes = create_new_level_and_codes_by_mapping_multiple( + ini=ini, + levels_to_create_from=source, + mapper=updater, ) - raise KeyError(msg) - new_level, new_codes = create_new_level_and_codes_by_mapping( - ini=ini, - level_to_create_from=source, - mapper=updater, - ) + else: + if source not in ini.names: + msg = ( + f"{source} is not available in the index. " + f"Available levels: {ini.names}" + ) + raise KeyError(msg) + + new_level, new_codes = create_new_level_and_codes_by_mapping( + ini=ini, + level_to_create_from=source, + mapper=updater, + ) if level in ini.names: level_idx = ini.names.index(level) diff --git a/tests/integration/index_manipulation/test_integration_index_manipulation_update_levels_from_other.py b/tests/integration/index_manipulation/test_integration_index_manipulation_update_levels_from_other.py index b320414..6951cd6 100644 --- a/tests/integration/index_manipulation/test_integration_index_manipulation_update_levels_from_other.py +++ b/tests/integration/index_manipulation/test_integration_index_manipulation_update_levels_from_other.py @@ -143,14 +143,62 @@ }, id="multiple-updates-incl-external-func", ), + pytest.param( + pd.MultiIndex.from_tuples( + [ + ("sa", "va", "kg", 0), + ("sb", "vb", "m", -1), + ("sa", "va", "kg", -2), + ("sa", "vb", "kg", 2), + ], + names=["scenario", "variable", "unit", "run_id"], + ), + { + "vv": (("scenario", "variable"), lambda x: " - ".join(x)), + "sv": ( + ("scenario", "variable"), + { + ("sa", "va"): "hi", + ("sb", "vb"): "bye", + ("sa", "vb"): "psi", + }, + ), + "su": ( + ("scenario", "unit"), + pd.Series( + ["alpha", "beta"], + index=pd.MultiIndex.from_tuples( + [ + ("sa", "kg"), + ("sb", "m"), + ], + names=["scenario", "unit"], + ), + ), + ), + "unit": ("unit", lambda x: x.replace("kg", "g").replace("m", "km")), + "u_run_id_abs": ( + ("unit", "run_id"), + lambda x: f"{x[0]}_{np.abs(x[1])}", + ), + }, + id="multiple-updates-multiple-sources-incl-dict-series-external-func", + ), ), ) def test_update_levels_from_other(start, update_sources): res = update_levels_from_other(start, update_sources=update_sources) - exp = start.to_frame(index=False) + # Need this so we order of updates doesn't matter + helper = start.to_frame(index=False) + exp = helper.copy() for level, (source, mapper) in update_sources.items(): - exp[level] = exp[source].map(mapper) + if isinstance(source, tuple): + exp[level] = pd.MultiIndex.from_frame(helper[list(source)]).map(mapper) + + else: + exp[level] = helper[source].map(mapper) + exp = pd.MultiIndex.from_frame(exp) pd.testing.assert_index_equal(res, exp) @@ -181,6 +229,49 @@ def test_update_levels_from_other_missing_level(): update_levels_from_other(start, update_sources=update_sources) +@pytest.mark.parametrize( + "sources, exp", + ( + ( + ("units", "variable"), + pytest.raises( + KeyError, + match=re.escape( + f"{set(['units'])} is not available in the index. " + f"Available levels: {['scenario', 'variable', 'unit', 'run_id']}" + ), + ), + ), + ( + ("units", "variables"), + pytest.raises( + KeyError, + match=re.escape( + f"{set(['units', 'variables'])} are not available in the index. " + f"Available levels: {['scenario', 'variable', 'unit', 'run_id']}" + ), + ), + ), + ), +) +def test_update_levels_from_other_missing_levels(sources, exp): + start = pd.MultiIndex.from_tuples( + [ + ("sa", "va", "kg", 0), + ("sb", "vb", "m", -1), + ("sa", "va", "kg", -2), + ("sa", "vb", "kg", 2), + ], + names=["scenario", "variable", "unit", "run_id"], + ) + update_sources = { + "uu": (sources, lambda x: x), + } + + with exp: + update_levels_from_other(start, update_sources=update_sources) + + def test_doesnt_trip_over_droped_levels(setup_pandas_accessors): def update_func(in_v: int) -> int: if in_v < 0: From ab0e74d9e487efaf8f754008dabc7dd1dca308b3 Mon Sep 17 00:00:00 2001 From: Zebedee Nicholls Date: Fri, 8 Aug 2025 11:26:37 +0200 Subject: [PATCH 2/8] One more test --- ...x_manipulation_update_levels_from_other.py | 37 ++++++++++++++++--- 1 file changed, 32 insertions(+), 5 deletions(-) diff --git a/tests/integration/index_manipulation/test_integration_index_manipulation_update_levels_from_other.py b/tests/integration/index_manipulation/test_integration_index_manipulation_update_levels_from_other.py index 6951cd6..4ebb7b6 100644 --- a/tests/integration/index_manipulation/test_integration_index_manipulation_update_levels_from_other.py +++ b/tests/integration/index_manipulation/test_integration_index_manipulation_update_levels_from_other.py @@ -363,8 +363,24 @@ def test_accessor(setup_pandas_accessors): ) update_sources = { + # callables single source "vv": ("variable", lambda x: x.replace("v", "vv")), "unit": ("unit", lambda x: x.replace("kg", "g").replace("m", "km")), + # callables multi source + "y-label": (("variable", "unit"), lambda x: f"{x[0]} ({x[1]})"), + # dict + "title": ("scenario", {"sa": "Scenario A", "sb": "Delta"}), + # pd.Series + "Source": ( + ("scenario", "variable"), + pd.Series( + ["Internal", "External", "External"], + index=pd.MultiIndex.from_tuples( + [("sa", "va"), ("sb", "vb"), ("sa", "vb")], + names=["scenario", "variable"], + ), + ), + ), } exp = pd.DataFrame( @@ -372,12 +388,23 @@ def test_accessor(setup_pandas_accessors): columns=start.columns, index=pd.MultiIndex.from_tuples( [ - ("sa", "va", "g", 0, "vva"), - ("sb", "vb", "km", -1, "vvb"), - ("sa", "va", "g", -2, "vva"), - ("sa", "vb", "g", 2, "vvb"), + # Updates not done sequentially + # hence y-label uses units from original data + ("sa", "va", "g", 0, "vva", "va (kg)", "Scenario A", "Internal"), + ("sb", "vb", "km", -1, "vvb", "vb (m)", "Delta", "External"), + ("sa", "va", "g", -2, "vva", "va (kg)", "Scenario A", "Internal"), + ("sa", "vb", "g", 2, "vvb", "vb (kg)", "Scenario A", "External"), + ], + names=[ + "scenario", + "variable", + "unit", + "run_id", + "vv", + "y-label", + "title", + "Source", ], - names=["scenario", "variable", "unit", "run_id", "vv"], ), ) From 68fd6c51386c679c0dbd0b7887248468f8b852ab Mon Sep 17 00:00:00 2001 From: Zebedee Nicholls Date: Fri, 8 Aug 2025 11:28:45 +0200 Subject: [PATCH 3/8] Update docs up to accessor --- src/pandas_openscm/accessors/dataframe.py | 21 ++++++++++++++++++--- src/pandas_openscm/index_manipulation.py | 19 +++++++++++++++++-- 2 files changed, 35 insertions(+), 5 deletions(-) diff --git a/src/pandas_openscm/accessors/dataframe.py b/src/pandas_openscm/accessors/dataframe.py index 4344721..417de86 100644 --- a/src/pandas_openscm/accessors/dataframe.py +++ b/src/pandas_openscm/accessors/dataframe.py @@ -838,7 +838,11 @@ def update_index_levels( def update_index_levels_from_other( self, update_sources: dict[ - Any, tuple[Any, Callable[[Any], Any] | dict[Any, Any] | pd.Series[Any]] + Any, + tuple[ + Any | tuple[Any, ...], + Callable[[Any], Any] | dict[Any, Any] | pd.Series[Any], + ], ], copy: bool = True, remove_unused_levels: bool = True, @@ -849,18 +853,29 @@ def update_index_levels_from_other( Parameters ---------- update_sources - Updates to apply to `df`'s index + Updates to apply to the data's index Each key is the level to which the updates will be applied (or the level that will be created if it doesn't already exist). - Each value is a tuple of which the first element + There are two options for the values. + + The first is used when only one level is used to update the 'target level'. + In this case, each value is a tuple of which the first element is the level to use to generate the values (the 'source level') and the second is mapper of the form used by [pd.Index.map][pandas.Index.map] which will be applied to the source level to update/create the level of interest. + Each value is a tuple of which the first element + is the level or levels (if a tuple) + to use to generate the values (the 'source level') + and the second is mapper of the form used by + [pd.Index.map][pandas.Index.map] + which will be applied to the source level + to update/create the level of interest. + copy Should the [pd.DataFrame][pandas.DataFrame] be copied before returning? diff --git a/src/pandas_openscm/index_manipulation.py b/src/pandas_openscm/index_manipulation.py index 260c0fd..c603f6f 100644 --- a/src/pandas_openscm/index_manipulation.py +++ b/src/pandas_openscm/index_manipulation.py @@ -611,7 +611,11 @@ def update_levels( def update_index_levels_from_other_func( df: pd.DataFrame, update_sources: dict[ - Any, tuple[Any, Callable[[Any], Any] | dict[Any, Any] | pd.Series[Any]] + Any, + tuple[ + Any | tuple[Any, ...], + Callable[[Any], Any] | dict[Any, Any] | pd.Series[Any], + ], ], copy: bool = True, remove_unused_levels: bool = True, @@ -633,13 +637,24 @@ def update_index_levels_from_other_func( Each key is the level to which the updates will be applied (or the level that will be created if it doesn't already exist). - Each value is a tuple of which the first element + There are two options for the values. + + The first is used when only one level is used to update the 'target level'. + In this case, each value is a tuple of which the first element is the level to use to generate the values (the 'source level') and the second is mapper of the form used by [pd.Index.map][pandas.Index.map] which will be applied to the source level to update/create the level of interest. + Each value is a tuple of which the first element + is the level or levels (if a tuple) + to use to generate the values (the 'source level') + and the second is mapper of the form used by + [pd.Index.map][pandas.Index.map] + which will be applied to the source level + to update/create the level of interest. + copy Should `df` be copied before returning? From b924febf01de5259599bc091a55d2e33ad281be8 Mon Sep 17 00:00:00 2001 From: Zebedee Nicholls Date: Fri, 8 Aug 2025 11:30:17 +0200 Subject: [PATCH 4/8] Fix up type hints --- src/pandas_openscm/accessors/dataframe.py | 6 +++++- src/pandas_openscm/index_manipulation.py | 12 ++++++++++-- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/src/pandas_openscm/accessors/dataframe.py b/src/pandas_openscm/accessors/dataframe.py index 417de86..38fed7e 100644 --- a/src/pandas_openscm/accessors/dataframe.py +++ b/src/pandas_openscm/accessors/dataframe.py @@ -840,8 +840,12 @@ def update_index_levels_from_other( update_sources: dict[ Any, tuple[ - Any | tuple[Any, ...], + Any, Callable[[Any], Any] | dict[Any, Any] | pd.Series[Any], + ] + | tuple[ + tuple[Any, ...], + Callable[[tuple[Any, ...]], Any] | dict[Any, Any] | pd.Series[Any], ], ], copy: bool = True, diff --git a/src/pandas_openscm/index_manipulation.py b/src/pandas_openscm/index_manipulation.py index c603f6f..8fd6a01 100644 --- a/src/pandas_openscm/index_manipulation.py +++ b/src/pandas_openscm/index_manipulation.py @@ -613,8 +613,12 @@ def update_index_levels_from_other_func( update_sources: dict[ Any, tuple[ - Any | tuple[Any, ...], + Any, Callable[[Any], Any] | dict[Any, Any] | pd.Series[Any], + ] + | tuple[ + tuple[Any, ...], + Callable[[tuple[Any, ...]], Any] | dict[Any, Any] | pd.Series[Any], ], ], copy: bool = True, @@ -693,8 +697,12 @@ def update_levels_from_other( update_sources: dict[ Any, tuple[ - Any | tuple[Any, ...], + Any, Callable[[Any], Any] | dict[Any, Any] | pd.Series[Any], + ] + | tuple[ + tuple[Any, ...], + Callable[[tuple[Any, ...]], Any] | dict[Any, Any] | pd.Series[Any], ], ], remove_unused_levels: bool = True, From d24b6ca49090111553f68adac2e126d44b66460a Mon Sep 17 00:00:00 2001 From: Zebedee Nicholls Date: Fri, 8 Aug 2025 11:31:24 +0200 Subject: [PATCH 5/8] Fix up type hints more --- src/pandas_openscm/accessors/dataframe.py | 4 +++- src/pandas_openscm/index_manipulation.py | 8 ++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/pandas_openscm/accessors/dataframe.py b/src/pandas_openscm/accessors/dataframe.py index 38fed7e..8e2f1e8 100644 --- a/src/pandas_openscm/accessors/dataframe.py +++ b/src/pandas_openscm/accessors/dataframe.py @@ -845,7 +845,9 @@ def update_index_levels_from_other( ] | tuple[ tuple[Any, ...], - Callable[[tuple[Any, ...]], Any] | dict[Any, Any] | pd.Series[Any], + Callable[[tuple[Any, ...]], Any] + | dict[tuple[Any, ...], Any] + | pd.Series[Any], ], ], copy: bool = True, diff --git a/src/pandas_openscm/index_manipulation.py b/src/pandas_openscm/index_manipulation.py index 8fd6a01..802219e 100644 --- a/src/pandas_openscm/index_manipulation.py +++ b/src/pandas_openscm/index_manipulation.py @@ -618,7 +618,9 @@ def update_index_levels_from_other_func( ] | tuple[ tuple[Any, ...], - Callable[[tuple[Any, ...]], Any] | dict[Any, Any] | pd.Series[Any], + Callable[[tuple[Any, ...]], Any] + | dict[tuple[Any, ...], Any] + | pd.Series[Any], ], ], copy: bool = True, @@ -702,7 +704,9 @@ def update_levels_from_other( ] | tuple[ tuple[Any, ...], - Callable[[tuple[Any, ...]], Any] | dict[Any, Any] | pd.Series[Any], + Callable[[tuple[Any, ...]], Any] + | dict[tuple[Any, ...], Any] + | pd.Series[Any], ], ], remove_unused_levels: bool = True, From 09e63fafa02fef1e4da644ba62eae3e4015317ee Mon Sep 17 00:00:00 2001 From: Zebedee Nicholls Date: Fri, 8 Aug 2025 11:37:15 +0200 Subject: [PATCH 6/8] CHANGELOG --- changelog/28.improvement.md | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 changelog/28.improvement.md diff --git a/changelog/28.improvement.md b/changelog/28.improvement.md new file mode 100644 index 0000000..bbea57c --- /dev/null +++ b/changelog/28.improvement.md @@ -0,0 +1,2 @@ +[pandas_openscm.index_manipulation.update_levels_from_other][] now supports updating levels based on multiple other levels from the index at once (see the docstring for examples). +This update also propagates to [pandas_openscm.index_manipulation.update_levels_from_other_func][] and [pandas_openscm.accessors.dataframe.PandasDataFrameOpenSCMAccessor.update_index_levels_from_other][]. From 4024bb709dee80d2e3e0eaf35952a3d9cb2c5675 Mon Sep 17 00:00:00 2001 From: Zebedee Nicholls Date: Fri, 8 Aug 2025 11:38:57 +0200 Subject: [PATCH 7/8] Sort missing levels --- src/pandas_openscm/index_manipulation.py | 2 +- ...ntegration_index_manipulation_update_levels_from_other.py | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/pandas_openscm/index_manipulation.py b/src/pandas_openscm/index_manipulation.py index 802219e..11a9a85 100644 --- a/src/pandas_openscm/index_manipulation.py +++ b/src/pandas_openscm/index_manipulation.py @@ -868,7 +868,7 @@ def update_levels_from_other( if missing_levels: conj = "is" if len(missing_levels) == 1 else "are" msg = ( - f"{missing_levels} {conj} not available in the index. " + f"{sorted(missing_levels)} {conj} not available in the index. " f"Available levels: {ini.names}" ) raise KeyError(msg) diff --git a/tests/integration/index_manipulation/test_integration_index_manipulation_update_levels_from_other.py b/tests/integration/index_manipulation/test_integration_index_manipulation_update_levels_from_other.py index 4ebb7b6..ff82f05 100644 --- a/tests/integration/index_manipulation/test_integration_index_manipulation_update_levels_from_other.py +++ b/tests/integration/index_manipulation/test_integration_index_manipulation_update_levels_from_other.py @@ -237,7 +237,7 @@ def test_update_levels_from_other_missing_level(): pytest.raises( KeyError, match=re.escape( - f"{set(['units'])} is not available in the index. " + f"{sorted(set(['units']))} is not available in the index. " f"Available levels: {['scenario', 'variable', 'unit', 'run_id']}" ), ), @@ -247,7 +247,8 @@ def test_update_levels_from_other_missing_level(): pytest.raises( KeyError, match=re.escape( - f"{set(['units', 'variables'])} are not available in the index. " + f"{sorted(set(['units', 'variables']))} " + "are not available in the index. " f"Available levels: {['scenario', 'variable', 'unit', 'run_id']}" ), ), From 661b097ee55bdda2fcec8accbe24dbb55fd3a641 Mon Sep 17 00:00:00 2001 From: Zebedee Nicholls Date: Fri, 8 Aug 2025 12:09:28 +0200 Subject: [PATCH 8/8] Fix CHANGELOG --- changelog/28.improvement.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog/28.improvement.md b/changelog/28.improvement.md index bbea57c..1beec6a 100644 --- a/changelog/28.improvement.md +++ b/changelog/28.improvement.md @@ -1,2 +1,2 @@ [pandas_openscm.index_manipulation.update_levels_from_other][] now supports updating levels based on multiple other levels from the index at once (see the docstring for examples). -This update also propagates to [pandas_openscm.index_manipulation.update_levels_from_other_func][] and [pandas_openscm.accessors.dataframe.PandasDataFrameOpenSCMAccessor.update_index_levels_from_other][]. +This update also propagates to [pandas_openscm.index_manipulation.update_index_levels_from_other_func][] and [pandas_openscm.accessors.dataframe.PandasDataFrameOpenSCMAccessor.update_index_levels_from_other][].