From 0433d06e1bc8d81c91f0ec427bbd77bdaeeeb53c Mon Sep 17 00:00:00 2001 From: priyam0k Date: Fri, 2 Oct 2026 18:09:58 +0530 Subject: [PATCH 1/3] Centralize axis descriptors --- chainladder/core/axis.py | 82 ++++++++++ chainladder/core/base.py | 6 +- chainladder/core/pandas.py | 25 ++- chainladder/core/tests/test_triangle.py | 77 ++++++++- chainladder/core/triangle.py | 203 ++++++++---------------- 5 files changed, 231 insertions(+), 162 deletions(-) diff --git a/chainladder/core/axis.py b/chainladder/core/axis.py index 86ac3c434..7ac600935 100644 --- a/chainladder/core/axis.py +++ b/chainladder/core/axis.py @@ -3,6 +3,7 @@ from __future__ import annotations from typing import TYPE_CHECKING, Any, Callable +import numpy as np import pandas as pd from chainladder.utils.utility_functions import _get_axis_name, _get_axis_number @@ -162,3 +163,84 @@ def _set_index(obj: Triangle, value: Any) -> pd.DataFrame: elif hasattr(obj, "_axes") and (0 in obj._axes or "index" in obj._axes): obj._len_check(obj.index, value) return value.copy().reset_index(drop=True) + + +def _get_origin(obj: Triangle, raw: Any) -> pd.PeriodIndex | pd.Series: + """ + Transform raw origin timestamp array into public PeriodIndex or Series. + """ + if obj.is_pattern and len(raw) == 1: + return pd.Series(["(All)"]) + freq = { + "S": "2Q", + "H": "2Q", + }.get(obj.origin_grain, obj.origin_grain) + freq = freq if freq == "M" else freq + "-" + obj.origin_close + return pd.DatetimeIndex(raw, name="origin").to_period(freq=freq) + + +def _set_origin(obj: Triangle, value: Any) -> np.ndarray: + """ + Validate and transform assigned origin periods into internal timestamps array. + """ + if hasattr(obj, "values") and obj.values is not None: + obj._len_check(range(obj.values.shape[2]), value) + elif hasattr(obj, "_axes") and (2 in obj._axes or "origin" in obj._axes): + obj._len_check(obj.origin, value) + freq = { + "S": "2Q", + }.get(obj.origin_grain, obj.origin_grain) + freq = freq if freq == "M" else freq + "-" + obj.origin_close + value = pd.PeriodIndex(list(value), freq=freq) + return value.to_timestamp().values + + +def _get_development(obj: Triangle, raw: Any) -> pd.Series: + """ + Transform raw development lags/timestamps into public Series. + """ + ddims = raw.copy() + if obj.is_val_tri: + formats = {"Y": "%Y", "S": "%YQ%q", "Q": "%YQ%q", "M": "%Y-%m"} + ddims = ddims.to_period(freq=obj.development_grain.replace("S", "2Q")).strftime( + formats[obj.development_grain] + ) + elif obj.is_pattern: + offset = obj._dstep()["M"][obj.development_grain] + if obj.is_ultimate: + ddims[-1] = ddims[-2] + offset + if obj.is_cumulative: + ddims = ["{}-Ult".format(ddims[i]) for i in range(len(ddims))] + else: + ddims = [ + "{}-{}".format(ddims[i], ddims[i] + offset) for i in range(len(ddims)) + ] + return pd.Series(list(ddims), name="development") + + +def _set_development(obj: Triangle, value: Any) -> np.ndarray: + """ + Validate and transform assigned development periods into internal lag/timestamp array. + """ + if hasattr(obj, "values") and obj.values is not None: + obj._len_check(range(obj.values.shape[3]), value) + elif hasattr(obj, "_axes") and (3 in obj._axes or "development" in obj._axes): + obj._len_check(obj.development, value) + return np.array([value] if isinstance(value, str) else value) + + +def _set_axis(obj: Triangle, axis: int | str, value: Any) -> None: + """ + Set values on the specified axis of a Triangle instance. + + Parameters + ---------- + obj : Triangle + The Triangle instance to update. + axis : int or str + Axis identifier (0..3 or 'index', 'columns', 'origin', 'development'). + value : Any + New axis values. + """ + axis_name = _get_axis_name(axis) + setattr(obj, axis_name, value) diff --git a/chainladder/core/base.py b/chainladder/core/base.py index eda9aac28..b1ba8e0e6 100644 --- a/chainladder/core/base.py +++ b/chainladder/core/base.py @@ -26,6 +26,7 @@ from chainladder.utils.cupy import cp from chainladder.utils.dask import dp from chainladder.utils.sparse import sp +from chainladder.utils.utility_functions import _get_axis_name from chainladder.adjustments.disposal import DisposalMixin @@ -821,10 +822,7 @@ def compute(self, *args, **kwargs): return self def _get_axis_value(self, axis): - axis = self._get_axis(axis) - return {0: self.index, 1: self.columns, 2: self.origin, 3: self.development}[ - axis - ] + return getattr(self, _get_axis_name(axis)) def is_chainladder(estimator): diff --git a/chainladder/core/pandas.py b/chainladder/core/pandas.py index 0e518f294..e95240884 100644 --- a/chainladder/core/pandas.py +++ b/chainladder/core/pandas.py @@ -15,7 +15,7 @@ _deprecated_drop_argument, _warn_dask_parallel_deprecated, ) -from chainladder.utils.utility_functions import concat, num_to_nan +from chainladder.utils.utility_functions import _get_axis_number, concat, num_to_nan from typing import cast, TYPE_CHECKING @@ -258,22 +258,15 @@ def _get_axis( The integer representation of the requested axis """ - ax = { - **{0: 0, 1: 1, 2: 2, 3: 3}, - **{-1: 3, -2: 2, -3: 1, -4: 0}, - **{"index": 0, "columns": 1, "origin": 2, "development": 3}, - } - + if axis is None: + return 0 try: - return ax[axis] - except KeyError: - if axis is None: - return 0 - else: - raise ValueError( - "Invalid axis specified. Please specify the correct string or " - "integer representation of the desired axis." - ) + return _get_axis_number(axis) + except ValueError as err: + raise ValueError( + "Invalid axis specified. Please specify the correct string or " + "integer representation of the desired axis." + ) from err def dropna(self) -> Triangle: """ diff --git a/chainladder/core/tests/test_triangle.py b/chainladder/core/tests/test_triangle.py index bea17b628..d9f95c224 100644 --- a/chainladder/core/tests/test_triangle.py +++ b/chainladder/core/tests/test_triangle.py @@ -508,7 +508,11 @@ def test_triangle_axis_descriptor(): assert isinstance(cl.Triangle.index, TriangleAxis) assert cl.Triangle.index.__doc__ is not None assert isinstance(cl.Triangle.columns, TriangleAxis) + assert isinstance(cl.Triangle.origin, TriangleAxis) + assert isinstance(cl.Triangle.development, TriangleAxis) assert cl.Triangle.columns.__doc__ is not None + assert cl.Triangle.origin.__doc__ is not None + assert cl.Triangle.development.__doc__ is not None class MockTriangle: def __init__(self): @@ -579,6 +583,41 @@ def _len_check(self, target, val): m.columns = ["a", "b"] +def test_set_axis(raa): + """Verify Triangle._set_axis sets index, columns, origin, and development.""" + tri = raa.copy() + # Test setting index via int and str + new_idx = pd.DataFrame({"Company": ["Test"]}) + tri._set_axis(0, new_idx) + pd.testing.assert_frame_equal(tri.index, new_idx) + tri._set_axis("index", new_idx) + pd.testing.assert_frame_equal(tri.index, new_idx) + + # Test setting columns via int and str + tri._set_axis(1, ["ColA"]) + assert list(tri.columns) == ["ColA"] + tri._set_axis("columns", ["ColB"]) + assert list(tri.columns) == ["ColB"] + + # Test setting origin via int and str + orig_years = [str(y) for y in range(1981, 1991)] + tri._set_axis(2, orig_years) + assert list(tri.origin.year) == list(range(1981, 1991)) + tri._set_axis("origin", orig_years) + assert list(tri.origin.year) == list(range(1981, 1991)) + + # Test setting development via int and str + devs = list(range(1, 11)) + tri._set_axis(3, devs) + assert list(tri.development) == devs + tri._set_axis("development", devs) + assert list(tri.development) == devs + + # Invalid axis raises ValueError + with pytest.raises(ValueError): + tri._set_axis("invalid", [1]) + + def test_triangle_index_property(raa): """Verify Triangle.index descriptor and slicer reset.""" tri = raa.copy() @@ -695,6 +734,9 @@ def test_legacy_pickle_compatibility(raa): """Pickles saved prior to kdims/vdims/odims/ddims migration should unpickle cleanly with warning.""" import pickle + raw_odims = raa._odims.copy() + raw_ddims = raa._ddims.copy() + # 1. Simulate oldest serialized Triangle containing kdims, vdims, odims, and ddims state = raa.__dict__.copy() state.pop("_axes", None) @@ -702,8 +744,8 @@ def test_legacy_pickle_compatibility(raa): state.pop("_columns", None) state["kdims"] = raa.index.values state["vdims"] = raa.columns.values - state["odims"] = state.pop("_odims") - state["ddims"] = state.pop("_ddims") + state["odims"] = raw_odims + state["ddims"] = raw_ddims restored = cl.Triangle.__new__(cl.Triangle) with pytest.warns( @@ -723,6 +765,8 @@ def test_legacy_pickle_compatibility(raa): assert restored._axes[0].equals(raa.index) assert 1 in restored._axes assert restored._axes[1].equals(raa.columns) + assert 2 in restored._axes + assert 3 in restored._axes # 2. Simulate intermediate serialized Triangle containing _kdims and _vdims state_mid = raa.__dict__.copy() @@ -731,6 +775,8 @@ def test_legacy_pickle_compatibility(raa): state_mid.pop("_columns", None) state_mid["_kdims"] = raa.index.values state_mid["_vdims"] = raa.columns.values + state_mid["_odims"] = raw_odims + state_mid["_ddims"] = raw_ddims restored_mid = cl.Triangle.__new__(cl.Triangle) with pytest.warns( @@ -747,6 +793,8 @@ def test_legacy_pickle_compatibility(raa): assert restored_mid._axes[0].equals(raa.index) assert 1 in restored_mid._axes assert restored_mid._axes[1].equals(raa.columns) + assert 2 in restored_mid._axes + assert 3 in restored_mid._axes # 3. Simulate unprefixed kdims/vdims legacy pickle state_unprefixed = raa.__dict__.copy() @@ -757,6 +805,8 @@ def test_legacy_pickle_compatibility(raa): state_unprefixed.pop("_vdims", None) state_unprefixed["kdims"] = raa.index.values state_unprefixed["vdims"] = raa.columns.values + state_unprefixed["_odims"] = raw_odims + state_unprefixed["_ddims"] = raw_ddims restored_unprefixed = cl.Triangle.__new__(cl.Triangle) with pytest.warns( @@ -765,6 +815,8 @@ def test_legacy_pickle_compatibility(raa): restored_unprefixed.__setstate__(state_unprefixed) assert 0 in restored_unprefixed._axes assert restored_unprefixed.index.equals(raa.index) + assert 2 in restored_unprefixed._axes + assert 3 in restored_unprefixed._axes # 4. Simulate serialized Triangle containing _columns state_col = raa.__dict__.copy() @@ -773,6 +825,8 @@ def test_legacy_pickle_compatibility(raa): state_col.pop("_columns", None) state_col["_kdims"] = raa.index.values state_col["_columns"] = list(raa.columns) + state_col["_odims"] = raw_odims + state_col["_ddims"] = raw_ddims restored_col = cl.Triangle.__new__(cl.Triangle) with pytest.warns( @@ -781,6 +835,8 @@ def test_legacy_pickle_compatibility(raa): restored_col.__setstate__(state_col) assert list(restored_col.columns) == list(raa.columns) assert 1 in restored_col._axes + assert 2 in restored_col._axes + assert 3 in restored_col._axes # 5. Simulate string-keyed _axes state_str_axes = raa.__dict__.copy() @@ -789,16 +845,25 @@ def test_legacy_pickle_compatibility(raa): state_str_axes["_axes"] = { "index": raa.index.copy(), "columns": raa.columns.copy(), + "origin": raw_odims.copy(), + "development": raw_ddims.copy(), } restored_str = cl.Triangle.__new__(cl.Triangle) restored_str.__setstate__(state_str_axes) assert 0 in restored_str._axes assert 1 in restored_str._axes + assert 2 in restored_str._axes + assert 3 in restored_str._axes assert restored_str.index.equals(raa.index) # 6. Simulate redundant _index when 0 already in _axes state_redundant = raa.__dict__.copy() - state_redundant["_axes"] = {0: raa.index.copy(), 1: raa.columns.copy()} + state_redundant["_axes"] = { + 0: raa.index.copy(), + 1: raa.columns.copy(), + 2: raw_odims.copy(), + 3: raw_ddims.copy(), + } state_redundant["_index"] = raa.index.copy() restored_red = cl.Triangle.__new__(cl.Triangle) restored_red.__setstate__(state_redundant) @@ -814,6 +879,8 @@ def test_legacy_pickle_compatibility(raa): state_empty.pop("_kdims", None) state_empty.pop("vdims", None) state_empty.pop("_vdims", None) + state_empty["_odims"] = raw_odims + state_empty["_ddims"] = raw_ddims restored_empty = cl.Triangle.__new__(cl.Triangle) with warnings.catch_warnings(): @@ -823,6 +890,8 @@ def test_legacy_pickle_compatibility(raa): assert list(restored_empty.index.columns) == ["Total"] assert 0 in restored_empty._axes assert 1 in restored_empty._axes + assert 2 in restored_empty._axes + assert 3 in restored_empty._axes # 8. Verify re-pickling the migrated instance works without warnings with warnings.catch_warnings(): @@ -831,6 +900,8 @@ def test_legacy_pickle_compatibility(raa): assert roundtripped == raa assert 0 in roundtripped._axes assert 1 in roundtripped._axes + assert 2 in roundtripped._axes + assert 3 in roundtripped._axes def test_sort_axis_columns_out_of_order(raa): diff --git a/chainladder/core/triangle.py b/chainladder/core/triangle.py index f257a0db2..deb978d11 100644 --- a/chainladder/core/triangle.py +++ b/chainladder/core/triangle.py @@ -12,8 +12,13 @@ import warnings from chainladder.core.base import TriangleBase from chainladder.core.axis import TriangleAxis +from chainladder.core.axis import _get_development +from chainladder.core.axis import _get_origin +from chainladder.core.axis import _set_axis from chainladder.core.axis import _set_columns +from chainladder.core.axis import _set_development from chainladder.core.axis import _set_index +from chainladder.core.axis import _set_origin from chainladder.utils.sparse import sp from chainladder.core.slice import VirtualColumns from chainladder.core.correlation import DevelopmentCorrelation, ValuationCorrelation @@ -484,6 +489,31 @@ class Triangle(TriangleBase): fset=_set_columns, doc="Represents the column axis of the triangle.", ) + origin = TriangleAxis( + 2, + fget=_get_origin, + fset=_set_origin, + doc="Origin periods of the Triangle as a PeriodIndex.", + ) + development = TriangleAxis( + 3, + fget=_get_development, + fset=_set_development, + doc="Development periods of the Triangle as a Series.", + ) + + def _set_axis(self, axis: int | str, value: Any) -> None: + """ + Assign new labels to the specified axis. + + Parameters + ---------- + axis : int or str + The target axis (0..3 or 'index', 'columns', 'origin', 'development'). + value : Any + The new labels/values for the axis. + """ + _set_axis(self, axis, value) @_deprecated_rename_argument("development", "valuation", remove_in_version="v2.0") @_deprecated_rename_argument( @@ -825,65 +855,14 @@ def columns_label(self) -> list: return self.columns.to_list() @property - def origin(self): - """ - Origin periods of the Triangle as a ``PeriodIndex``. - - The frequency of the index reflects ``origin_grain`` (e.g. annual, - quarterly, monthly). When the Triangle holds aggregated patterns with a - single origin row, the property returns ``Series(['(All)'])`` instead. - - Returns - ------- - PeriodIndex or Series - One entry per origin period of the Triangle. - - Examples - -------- - Annual-origin Triangle. - - .. testsetup:: + def _odims(self): + return self._axes.get(2) - import chainladder as cl - - .. testcode:: - - tr = cl.load_sample('ukmotor') - print(tr.origin.year.tolist()) - - .. testoutput:: - - [2007, 2008, 2009, 2010, 2011, 2012, 2013] - - The number of origin periods matches ``Triangle.shape[-2]``. - - .. testcode:: - - print(len(tr.origin) == tr.shape[-2]) - - .. testoutput:: - - True - """ - if self.is_pattern and len(self._odims) == 1: - return pd.Series(["(All)"]) - else: - freq = { - "S": "2Q", - "H": "2Q", - }.get(self.origin_grain, self.origin_grain) - freq = freq if freq == "M" else freq + "-" + self.origin_close - return pd.DatetimeIndex(self._odims, name="origin").to_period(freq=freq) - - @origin.setter - def origin(self, value) -> None: - self._len_check(self.origin, value) - freq = { - "S": "2Q", - }.get(self.origin_grain, self.origin_grain) - freq = freq if freq == "M" else freq + "-" + self.origin_close - value = pd.PeriodIndex(list(value), freq=freq) - self._odims = value.to_timestamp().values + @_odims.setter + def _odims(self, value): + if not hasattr(self, "_axes"): + self._axes = {} + self._axes[2] = value @property def odims(self): @@ -906,82 +885,14 @@ def odims(self, value): self._odims = value @property - def development(self): - """ - Development periods of the Triangle as a Series. + def _ddims(self): + return self._axes.get(3) - For a development-lag Triangle (``is_val_tri=False``), values are - integer lags expressed in months from the start of the origin period. - For a valuation Triangle (``is_val_tri=True``), values are calendar - valuation labels formatted at ``development_grain``. For pattern - Triangles, labels carry their from/to development range (e.g. - ``"12-24"`` or ``"108-Ult"``). - - Returns - ------- - Series - One entry per development column of the Triangle. - - Examples - -------- - Annual-grain development on a loss Triangle is reported as month lags. - - .. testsetup: - - import chainladder as cl - - .. testcode:: - - tr = cl.load_sample('ukmotor') - print(tr.development.tolist()) - - .. testoutput:: - - [12, 24, 36, 48, 60, 72, 84] - - On a valuation Triangle the labels become calendar periods. - - .. testcode:: - - print(tr.dev_to_val().development.tolist()) - - .. testoutput:: - - ['2007', '2008', '2009', '2010', '2011', '2012', '2013'] - - On a link-ratio (pattern) Triangle the labels span the from/to lags. - - .. testcode:: - - print(tr.link_ratio.development.tolist()) - - .. testoutput:: - - ['12-24', '24-36', '36-48', '48-60', '60-72', '72-84'] - """ - ddims = self._ddims.copy() - if self.is_val_tri: - formats = {"Y": "%Y", "S": "%YQ%q", "Q": "%YQ%q", "M": "%Y-%m"} - ddims = ddims.to_period( - freq=self.development_grain.replace("S", "2Q") - ).strftime(formats[self.development_grain]) - elif self.is_pattern: - offset = self._dstep()["M"][self.development_grain] - if self.is_ultimate: - ddims[-1] = ddims[-2] + offset - if self.is_cumulative: - ddims = ["{}-Ult".format(ddims[i]) for i in range(len(ddims))] - else: - ddims = [ - "{}-{}".format(ddims[i], ddims[i] + offset) - for i in range(len(ddims)) - ] - return pd.Series(list(ddims), name="development") - - @development.setter - def development(self, value): - self._len_check(self.development, value) - self._ddims = np.array([value] if type(value) is str else value) + @_ddims.setter + def _ddims(self, value): + if not hasattr(self, "_axes"): + self._axes = {} + self._axes[3] = value @property def ddims(self): @@ -2262,12 +2173,26 @@ def __setstate__(self, state: dict) -> None: state["_axes"][1] = pd.Index(raw_cols, name="columns") else: state["_axes"][1] = pd.Index(["values"], name="columns") - for old_key, new_key in [ - ("odims", "_odims"), - ("ddims", "_ddims"), - ]: - if old_key in state and new_key not in state: - state[new_key] = state.pop(old_key) + if 2 not in state["_axes"]: + if "_odims" in state: + state["_axes"][2] = state.pop("_odims") + elif "origin" in state["_axes"]: + state["_axes"][2] = state["_axes"].pop("origin") + elif "odims" in state: + state["_axes"][2] = state.pop("odims") + else: + state.pop("_odims", None) + state.pop("odims", None) + if 3 not in state["_axes"]: + if "_ddims" in state: + state["_axes"][3] = state.pop("_ddims") + elif "development" in state["_axes"]: + state["_axes"][3] = state["_axes"].pop("development") + elif "ddims" in state: + state["_axes"][3] = state.pop("ddims") + else: + state.pop("_ddims", None) + state.pop("ddims", None) self.__dict__.update(state) def development_correlation(self, p_critical=0.5): From 69def0761a5dee66d85789ca2901366fa0ab857a Mon Sep 17 00:00:00 2001 From: priyam0k Date: Fri, 2 Oct 2026 18:37:56 +0530 Subject: [PATCH 2/3] Add axis typing exports --- chainladder/core/pandas.py | 16 ++++++++++++---- chainladder/core/tests/test_triangle.py | 21 ++++++++++++++++++++- chainladder/core/typing.py | 9 +++++++++ 3 files changed, 41 insertions(+), 5 deletions(-) diff --git a/chainladder/core/pandas.py b/chainladder/core/pandas.py index e95240884..aa601ef70 100644 --- a/chainladder/core/pandas.py +++ b/chainladder/core/pandas.py @@ -239,7 +239,7 @@ def hvplot(self, *args: Any, **kwargs: Any) -> Any: return df.hvplot(*args, **kwargs) @staticmethod - def _get_axis( + def _get_axis_number( axis: Literal["index", "columns", "origin", "development"] | int | None, ) -> int: """ @@ -250,14 +250,13 @@ def _get_axis( axis: Literal['index', 'columns', 'origin', 'development'] | int | None String or integer representation of the requested axis. If supplied as a string, returns the integer representation. If - supplied as an integer, returns the same integer. + supplied as an integer, returns the canonical positive integer. Returns ------- int - The integer representation of the requested axis + The integer representation of the requested axis (0..3). """ - if axis is None: return 0 try: @@ -268,6 +267,15 @@ def _get_axis( "integer representation of the desired axis." ) from err + @staticmethod + def _get_axis( + axis: Literal["index", "columns", "origin", "development"] | int | None, + ) -> int: + """ + Backward-compatible alias for _get_axis_number. + """ + return TrianglePandas._get_axis_number(axis) + def dropna(self) -> Triangle: """ Method that removes origin/development vectors from edge of a diff --git a/chainladder/core/tests/test_triangle.py b/chainladder/core/tests/test_triangle.py index d9f95c224..5455f850c 100644 --- a/chainladder/core/tests/test_triangle.py +++ b/chainladder/core/tests/test_triangle.py @@ -12,6 +12,7 @@ from chainladder.core.common import Common from chainladder.utils.utility_functions import ( _axis_orders, + _axis_to_axis_number, _get_axis_name, _get_axis_number, date_delta_adjustment, @@ -482,8 +483,17 @@ def test_triangle_axes(raa): assert axes[3].equals(raa.development) -def test_axis_mappings(): +def test_axis_mappings(raa): """Verify axis number and name resolutions in chainladder.core.typing.""" + from chainladder.core.typing import ( + _axis_orders as typing_axis_orders, + _axis_to_axis_number as typing_axis_to_axis_number, + _get_axis_name as typing_get_axis_name, + _get_axis_number as typing_get_axis_number, + ) + + assert typing_axis_orders == _axis_orders + assert typing_axis_to_axis_number == _axis_to_axis_number for idx, name in enumerate(_axis_orders): assert _get_axis_number(idx) == idx assert _get_axis_number(idx - 4) == idx @@ -491,6 +501,12 @@ def test_axis_mappings(): assert _get_axis_name(idx) == name assert _get_axis_name(idx - 4) == name assert _get_axis_name(name) == name + assert typing_get_axis_number(idx) == idx + assert typing_get_axis_name(idx) == name + assert raa._get_axis_number(idx) == idx + assert raa._get_axis_number(name) == idx + assert raa._get_axis(idx) == idx + assert raa._get_axis(name) == idx with pytest.raises( ValueError, match="No axis named invalid for object type Triangle" @@ -500,6 +516,9 @@ def test_axis_mappings(): with pytest.raises(ValueError, match=r"No axis named 5 for object type Triangle"): _get_axis_number(5) + with pytest.raises(ValueError, match="Invalid axis specified"): + raa._get_axis_number("invalid") + def test_triangle_axis_descriptor(): """Verify TriangleAxis descriptor correctly uses integer axis keys for index and columns.""" diff --git a/chainladder/core/typing.py b/chainladder/core/typing.py index e37a5d301..8a5967e3f 100644 --- a/chainladder/core/typing.py +++ b/chainladder/core/typing.py @@ -14,6 +14,13 @@ TypeAlias, ) +from chainladder.utils.utility_functions import ( + _axis_orders as _axis_orders, + _axis_to_axis_number as _axis_to_axis_number, + _get_axis_name as _get_axis_name, + _get_axis_number as _get_axis_number, +) + if TYPE_CHECKING: from collections.abc import Callable from numpy import ndarray @@ -166,3 +173,5 @@ def __setitem__( key: str | int, value: int | float | TriangleSlicer | Callable[[Triangle], TriangleSlicer], ) -> None: ... + def _get_axis_number(self, axis: int | str | None) -> int: ... + def _get_axis(self, axis: int | str | None) -> int: ... From deecc2afcc1163143bcc2ecaf57e95e291e3f4e7 Mon Sep 17 00:00:00 2001 From: priyam0k Date: Fri, 2 Oct 2026 19:49:12 +0530 Subject: [PATCH 3/3] Add axis edge tests --- chainladder/core/tests/test_triangle.py | 110 ++++++++++++++++++++++++ 1 file changed, 110 insertions(+) diff --git a/chainladder/core/tests/test_triangle.py b/chainladder/core/tests/test_triangle.py index 5455f850c..4932373ec 100644 --- a/chainladder/core/tests/test_triangle.py +++ b/chainladder/core/tests/test_triangle.py @@ -637,6 +637,116 @@ def test_set_axis(raa): tri._set_axis("invalid", [1]) +def test_centralized_axis_edge_cases(raa): + """Cover edge cases for TriangleAxis origin, development, and legacy pickles.""" + # 1. origin on 1-origin pattern returns Series(["(All)"]) + dev = cl.Development().fit(raa) + assert dev.ldf_.origin.iloc[0] == "(All)" + assert len(dev.ldf_.origin) == 1 + + # 2. _set_origin and _set_development on mock without values + class MockTriangleNoValues: + origin_grain = "Y" + origin_close = "DEC" + development_grain = "Y" + values = None + is_pattern = False + is_val_tri = False + _axes = { + 2: pd.period_range("2000", periods=3, freq="Y-DEC").to_timestamp().values, + 3: np.array([12, 24, 36]), + } + origin = cl.Triangle.origin + development = cl.Triangle.development + + def _len_check(self, a, b): + if len(a) != len(b): + raise ValueError("Length mismatch") + + mock = MockTriangleNoValues() + mock.origin = ["2001", "2002", "2003"] + assert len(mock._axes[2]) == 3 + with pytest.raises(ValueError, match="Length mismatch"): + mock.origin = ["2001"] + + mock.development = [12, 24, 36] + assert len(mock._axes[3]) == 3 + with pytest.raises(ValueError, match="Length mismatch"): + mock.development = [12] + + # 3. Development string assignment for single column + single_col_tri = raa.iloc[:, :, :, :1].copy() + single_col_tri.development = "1" + assert list(single_col_tri.development) == ["1"] + + # 4. Valuation triangle development and non-cumulative pattern development + val_tri = raa.dev_to_val() + assert list(val_tri.development) == [str(y) for y in range(1981, 1991)] + assert list(raa.link_ratio.development)[0] == "12-24" + assert list(dev.cdf_.development)[0] == "12-Ult" + + # 5. Pattern with is_ultimate True + class MockPatternUltimate: + is_pattern = True + is_ultimate = True + is_val_tri = False + is_cumulative = False + development_grain = "Y" + development = cl.Triangle.development + _axes = {3: np.array([12, 24, 999])} + + def _dstep(self): + return {"M": {"Y": 12}} + + mock_ult = MockPatternUltimate() + assert list(mock_ult.development) == ["12-24", "24-36", "36-48"] + + # 6. Triangle._odims and Triangle._ddims setters on uninitialized instance + t_uninit = cl.Triangle.__new__(cl.Triangle) + t_uninit._odims = np.array(["2000-01-01"], dtype="datetime64[ns]") + assert 2 in t_uninit._axes + + t2_uninit = cl.Triangle.__new__(cl.Triangle) + t2_uninit._ddims = np.array([12, 24]) + assert 3 in t2_uninit._axes + + # 7. Redundant legacy keys in __setstate__ + state_redundant = raa.__dict__.copy() + raw_odims = raa._odims.copy() + raw_ddims = raa._ddims.copy() + state_redundant["_axes"] = { + 0: raa.index.copy(), + 1: raa.columns.copy(), + 2: raw_odims.copy(), + 3: raw_ddims.copy(), + } + state_redundant["_odims"] = raw_odims.copy() + state_redundant["odims"] = raw_odims.copy() + state_redundant["_ddims"] = raw_ddims.copy() + state_redundant["ddims"] = raw_ddims.copy() + restored_red = cl.Triangle.__new__(cl.Triangle) + restored_red.__setstate__(state_redundant) + assert "_odims" not in restored_red.__dict__ + assert "odims" not in restored_red.__dict__ + assert "_ddims" not in restored_red.__dict__ + assert "ddims" not in restored_red.__dict__ + + # 8. Unprefixed origin/development legacy pickle and fallthrough + state_no_axes = raa.__dict__.copy() + state_no_axes["_axes"] = {} + restored_none = cl.Triangle.__new__(cl.Triangle) + restored_none.__setstate__(state_no_axes) + assert 2 not in restored_none._axes + assert 3 not in restored_none._axes + + # 9. _get_axis_number and _get_axis edge cases + assert raa._get_axis_number(None) == 0 + assert raa._get_axis(None) == 0 + for neg, pos in [(-1, 3), (-2, 2), (-3, 1), (-4, 0)]: + assert raa._get_axis_number(neg) == pos + assert raa._get_axis(neg) == pos + + def test_triangle_index_property(raa): """Verify Triangle.index descriptor and slicer reset.""" tri = raa.copy()