Skip to content

Misc helpers (overlay, compare, reindex)

Three sibling functions from upstream TimeSeriesEcon.jl/src/various.jl, sharing the LikeWorkspace dispatch (Workspace / MVTSeries / Mapping):

  • overlay — first-valid-wins composition over TSeries / Workspace / MVTSeries values. The dtype-appropriate typenan (NaN for floats, iinfo.max for integers, False for booleans) marks "missing"; the leftmost non-missing value wins position-by-position.
  • compare — recursive structural / numeric comparison. Returns a CompareResult (truthy on equality; .differences carries the per-leaf diff). Tolerance kwargs (atol / rtol / nans) match the Julia compare / @compare surface name-for-name.
  • reindex — shift every MIT-keyed position inside a container so that from maps to to; values are preserved. Dispatches over MIT / MITRange / TSeries / MVTSeries / Workspace.

tsecon._various

Misc helpers from TimeSeriesEcon.jl's various.jl.

Three sibling functions sharing the LikeWorkspace dispatch (Workspace / MVTSeries / :class:collections.abc.Mapping):

  • :func:overlay — first-valid-wins composition over TSeries / Workspace / MVTSeries values. NaN (and the dtype-appropriate typenan) marks "missing"; the leftmost non-missing value wins position-by-position.
  • :func:compare — recursive structural / numeric comparison. Returns a :class:CompareResult (truthy on equality; .differences carries the per-leaf diff). Tolerance kwargs (atol / rtol / nans) and walk kwargs (ignoremissing / showequal / quiet / trange) match the Julia surface name-for-name.
  • :func:reindex — shift every MIT-keyed position inside a container so that from maps to to; the underlying values are preserved. Dispatches over MIT / MITRange / TSeries / MVTSeries / Workspace.

Julia's @compare macro collapses to a plain function alias here: the macro existed only to capture the variable names for printing, and :class:CompareResult.differences already exposes that information without needing AST rewriting. Code that wrote @compare(v1, v2, atol=1e-5) ports to compare(v1, v2, atol=1e-5) — the Python idiom of returning a structured diff matches the Julia behaviour for both interactive (print(result)) and programmatic (result.differences) use.

Remaining pieces of various.jl are deferred:

  • @showall — Julia macro for non-truncating display; Python uses print(repr(x)) directly (the truncation lives in our __repr__, not in a separate IO context).
  • clean_old_frequencies — Julia v0.4→v0.5 frequency-sanitiser; not applicable to tsecon's cached-singleton frequency model. Tracked as parity gap G14.
  • TOML.print overloads — left to v1.0 if the JSON round-trip (io/json.py) doesn't cover the use case.
  • is_yearly / is_quarterly / etc. — already ported in :mod:tsecon.frequencies with PEP-8 underscore naming.

CompareDifference dataclass

One line of the recursive diff produced by :func:compare.

Attributes:

Name Type Description
path tuple[str, ...]

Hierarchical tuple of names, e.g. ('_', 'y', '2020Q3').

message str

Short human-readable description: 'different', 'missing in <name>', or 'same'.

Source code in src/tsecon/_various.py
@dataclass(frozen=True)
class CompareDifference:
    """One line of the recursive diff produced by :func:`compare`.

    Attributes
    ----------
    path
        Hierarchical tuple of names, e.g. ``('_', 'y', '2020Q3')``.
    message
        Short human-readable description: ``'different'``,
        ``'missing in <name>'``, or ``'same'``.
    """

    path: tuple[str, ...]
    message: str

    def __str__(self) -> str:
        return f"{'.'.join(self.path)}: {self.message}"

CompareResult dataclass

The structured return value of :func:compare.

Truthy iff the two inputs compared as equal — use if compare(a, b): ... for the Julia-style one-line check. str(result) reproduces the printed diff (one line per :class:CompareDifference). .differences exposes the diff programmatically.

Source code in src/tsecon/_various.py
@dataclass
class CompareResult:
    """The structured return value of :func:`compare`.

    Truthy iff the two inputs compared as equal — use
    ``if compare(a, b): ...`` for the Julia-style one-line check.
    ``str(result)`` reproduces the printed diff (one line per
    :class:`CompareDifference`). ``.differences`` exposes the diff
    programmatically.
    """

    equal: bool
    differences: list[CompareDifference] = field(default_factory=list)

    def __bool__(self) -> bool:
        return self.equal

    def __str__(self) -> str:
        if not self.differences:
            return "(no differences)"
        return "\n".join(str(d) for d in self.differences)

    def __repr__(self) -> str:
        n = len(self.differences)
        suffix = "" if n == 1 else "s"
        return f"CompareResult(equal={self.equal}, {n} difference{suffix})"

overlay

overlay(*args: Any, rng: MITRange | None = None) -> Any

Return the first non-missing value, position-by-position, from left to right.

"Missing" is defined by the dtype-appropriate typenan (NaN for floats, iinfo(dtype).max for integers, False for booleans). The dispatch is on the argument types — all arguments must agree on a container family for the recursive form to apply:

  • All :class:~tsecon.tseries.TSeries — build a new TSeries over either rng= (when given) or the union of all input ranges (rangeof_span(*args)). For each position, the leftmost input that has a non-typenan value at that position wins. The output dtype is the NumPy promotion of all input dtypes.
  • All :class:~tsecon.mvtseries.MVTSeries — return an MVTSeries over the union range and the ordered union of column names; each column is the overlay of the corresponding TSeries columns.
  • All Workspace / MVTSeries / Mapping — return a :class:~tsecon.workspace.Workspace. For each key present in any input, collect the values across inputs that have it and recurse; the recursive call may dispatch back into the TSeries / Workspace / MVTSeries / scalar paths.
  • Mixed types (e.g. a TSeries and a scalar) — fall through to the scalar walk: return the leftmost non-typenan value.

Parameters:

Name Type Description Default
*args Any

One or more values to overlay. At least one is required.

()
rng MITRange | None

Optional :class:~tsecon.mitrange.MITRange to force the output range. Only valid when all arguments are TSeries; raises :class:TypeError otherwise.

None

Returns:

Type Description
Any

A TSeries / MVTSeries / Workspace / scalar matching the input family.

Raises:

Type Description
TypeError

On empty argument list, on mixed-frequency inputs, or on rng= passed with non-TSeries arguments.

Source code in src/tsecon/_various.py
def overlay(*args: Any, rng: MITRange | None = None) -> Any:
    """Return the first non-missing value, position-by-position, from left to right.

    "Missing" is defined by the dtype-appropriate ``typenan`` (NaN for
    floats, ``iinfo(dtype).max`` for integers, ``False`` for booleans).
    The dispatch is on the argument types — all arguments must agree on a
    container family for the recursive form to apply:

    * **All :class:`~tsecon.tseries.TSeries`** — build a new TSeries over
      either ``rng=`` (when given) or the union of all input ranges
      (``rangeof_span(*args)``). For each position, the leftmost input
      that has a non-typenan value at that position wins. The output
      dtype is the NumPy promotion of all input dtypes.
    * **All :class:`~tsecon.mvtseries.MVTSeries`** — return an MVTSeries
      over the union range and the ordered union of column names; each
      column is the overlay of the corresponding TSeries columns.
    * **All Workspace / MVTSeries / Mapping** — return a
      :class:`~tsecon.workspace.Workspace`. For each key present in any
      input, collect the values across inputs that have it and recurse;
      the recursive call may dispatch back into the TSeries / Workspace
      / MVTSeries / scalar paths.
    * **Mixed types** (e.g. a TSeries and a scalar) — fall through to
      the scalar walk: return the leftmost non-typenan value.

    Parameters
    ----------
    *args
        One or more values to overlay. At least one is required.
    rng
        Optional :class:`~tsecon.mitrange.MITRange` to force the output
        range. Only valid when all arguments are TSeries; raises
        :class:`TypeError` otherwise.

    Returns
    -------
    Any
        A TSeries / MVTSeries / Workspace / scalar matching the input
        family.

    Raises
    ------
    TypeError
        On empty argument list, on mixed-frequency inputs, or on
        ``rng=`` passed with non-TSeries arguments.
    """
    if not args:
        msg = "overlay() requires at least one argument."
        raise TypeError(msg)

    if all(isinstance(a, TSeries) for a in args):
        if rng is None:
            rng = rangeof_span(*(t.range for t in args))
        elif not isinstance(rng, MITRange):
            # Defensive runtime check: the signature types ``rng`` as
            # ``MITRange | None`` so mypy considers this branch unreachable,
            # but callers may pass Any-typed values (e.g. from JSON
            # deserialization) and we'd rather raise at the call site than
            # produce a confusing AttributeError deep inside _overlay_tseries.
            msg = f"overlay rng= must be an MITRange; got {type(rng).__name__}."  # type: ignore[unreachable]
            raise TypeError(msg)
        return _overlay_tseries(rng, *args)

    if rng is not None:
        msg = "overlay(..., rng=) is only valid when all arguments are TSeries."
        raise TypeError(msg)

    if all(isinstance(a, MVTSeries) for a in args):
        return _overlay_mvtseries(*args)

    if all(_is_like_workspace(a) for a in args):
        return _overlay_workspaces(*args)

    # Scalar / mixed fallback: first non-typenan wins (Julia's
    # `overlay(head, tail...) = istypenan(head) ? overlay(tail...) : head`).
    return _overlay_scalar(args)

compare

compare(
    x: Any,
    y: Any,
    *,
    name: str = "_",
    showequal: bool = False,
    ignoremissing: bool = False,
    quiet: bool = False,
    left: str = "left",
    right: str = "right",
    atol: float = 0.0,
    rtol: float | None = None,
    nans: bool = False,
    trange: MITRange | None = None,
) -> CompareResult

Recursively compare x and y, returning a :class:CompareResult.

The walk dispatches over:

  • :class:~tsecon.workspace.Workspace, :class:~tsecon.mvtseries.MVTSeries, and :class:collections.abc.Mapping — treated as name-keyed containers and recursed key-by-key. Missing keys produce "missing in <left|right>" lines (unless ignoremissing=True).
  • :class:~tsecon.tseries.TSeries — compared range-by-range using :func:numpy.allclose with the given atol / rtol / nans. Mismatched ranges count as different (unless ignoremissing=True, in which case the intersection is compared).
  • NumPy arrays of numbers — compared by shape + element-wise :func:numpy.allclose.
  • NumPy arrays of other dtypes — walked element-by-element.
  • Scalars — compared via :func:numpy.isclose.
  • Everything else — compared with ==.

Parameters:

Name Type Description Default
x Any

The two values to compare.

required
y Any

The two values to compare.

required
name str

Top-level name in the printed/structured diff ("_" by default — matches Julia's Symbol("_")).

'_'
showequal bool

When True, also emit "name: same" lines for matching leaves. Default False (only differences are reported, plus a final top-level summary line).

False
ignoremissing bool

When True, ignore keys present in one side but not the other (they don't print and don't affect equal). Default False.

False
quiet bool

When True, suppress printing to stdout. The :class:CompareResult is still populated either way.

False
left str

Names used in "missing in <name>" messages. Default "left" / "right".

'left'
right str

Names used in "missing in <name>" messages. Default "left" / "right".

'left'
atol float

Tolerances forwarded to :func:numpy.isclose / :func:numpy.allclose. rtol=None (the default) resolves to :math:\\sqrt{\\varepsilon} when atol == 0 and to 0 when atol > 0 (matching Julia's isapprox precedent).

0.0
rtol float

Tolerances forwarded to :func:numpy.isclose / :func:numpy.allclose. rtol=None (the default) resolves to :math:\\sqrt{\\varepsilon} when atol == 0 and to 0 when atol > 0 (matching Julia's isapprox precedent).

0.0
nans float

Tolerances forwarded to :func:numpy.isclose / :func:numpy.allclose. rtol=None (the default) resolves to :math:\\sqrt{\\varepsilon} when atol == 0 and to 0 when atol > 0 (matching Julia's isapprox precedent).

0.0
trange MITRange | None

Optional :class:~tsecon.mitrange.MITRange restricting the compared window for TSeries-vs-TSeries leaves only.

None

Returns:

Type Description
CompareResult

Truthy iff x and y compared as equal; .differences carries the recursive diff regardless of quiet.

Notes

Julia's accompanying @compare macro folds into this same function. The macro existed only to capture the input variable names for printing; Python callers can pass left= and right= if they want the same labelling.

Source code in src/tsecon/_various.py
def compare(
    x: Any,
    y: Any,
    *,
    name: str = "_",
    showequal: bool = False,
    ignoremissing: bool = False,
    quiet: bool = False,
    left: str = "left",
    right: str = "right",
    atol: float = 0.0,
    rtol: float | None = None,
    nans: bool = False,
    trange: MITRange | None = None,
) -> CompareResult:
    r"""Recursively compare ``x`` and ``y``, returning a :class:`CompareResult`.

    The walk dispatches over:

    * :class:`~tsecon.workspace.Workspace`, :class:`~tsecon.mvtseries.MVTSeries`,
      and :class:`collections.abc.Mapping` — treated as name-keyed
      containers and recursed key-by-key. Missing keys produce
      ``"missing in <left|right>"`` lines (unless ``ignoremissing=True``).
    * :class:`~tsecon.tseries.TSeries` — compared range-by-range using
      :func:`numpy.allclose` with the given ``atol`` / ``rtol`` / ``nans``.
      Mismatched ranges count as different (unless ``ignoremissing=True``,
      in which case the intersection is compared).
    * NumPy arrays of numbers — compared by shape + element-wise
      :func:`numpy.allclose`.
    * NumPy arrays of other dtypes — walked element-by-element.
    * Scalars — compared via :func:`numpy.isclose`.
    * Everything else — compared with ``==``.

    Parameters
    ----------
    x, y
        The two values to compare.
    name
        Top-level name in the printed/structured diff (``"_"`` by default —
        matches Julia's ``Symbol("_")``).
    showequal
        When True, also emit ``"name: same"`` lines for matching leaves.
        Default False (only differences are reported, plus a final
        top-level summary line).
    ignoremissing
        When True, ignore keys present in one side but not the other
        (they don't print and don't affect ``equal``). Default False.
    quiet
        When True, suppress printing to stdout. The :class:`CompareResult`
        is still populated either way.
    left, right
        Names used in ``"missing in <name>"`` messages. Default
        ``"left"`` / ``"right"``.
    atol, rtol, nans
        Tolerances forwarded to :func:`numpy.isclose` /
        :func:`numpy.allclose`. ``rtol=None`` (the default) resolves to
        :math:`\\sqrt{\\varepsilon}` when ``atol == 0`` and to ``0`` when
        ``atol > 0`` (matching Julia's ``isapprox`` precedent).
    trange
        Optional :class:`~tsecon.mitrange.MITRange` restricting the
        compared window for TSeries-vs-TSeries leaves only.

    Returns
    -------
    CompareResult
        Truthy iff ``x`` and ``y`` compared as equal; ``.differences``
        carries the recursive diff regardless of ``quiet``.

    Notes
    -----
    Julia's accompanying ``@compare`` macro folds into this same
    function. The macro existed only to capture the input variable names
    for printing; Python callers can pass ``left=`` and ``right=`` if
    they want the same labelling.
    """
    if rtol is None:
        rtol = _DEFAULT_RTOL if atol == 0.0 else 0.0

    result = CompareResult(equal=True)
    kw: dict[str, Any] = {
        "showequal": showequal,
        "ignoremissing": ignoremissing,
        "quiet": quiet,
        "left": left,
        "right": right,
        "atol": atol,
        "rtol": rtol,
        "nans": nans,
        "trange": trange,
    }
    _compare_recurse(x, y, [name], result, kw)
    return result

reindex

reindex(
    x: Any,
    old_to_new: tuple[MIT, MIT],
    *,
    copy: bool = False,
) -> Any

Shift every MIT-keyed position so that old maps to new.

old_to_new is a 2-tuple (old_mit, new_mit). In MIT-value space, every output position is the input position plus new_mit.value - old_mit.value, and the result carries new_mit.frequency as its frequency label.

Parameters:

Name Type Description Default
x Any

The value to reindex. Supported types:

  • :class:~tsecon.mit.MIT — single instant.
  • :class:~tsecon.mitrange.MITRange — both endpoints shifted; step is preserved.
  • :class:~tsecon.tseries.TSeries — anchor shifted; values unchanged.
  • :class:~tsecon.mvtseries.MVTSeries — same, on the matrix.
  • :class:~tsecon.workspace.Workspace — members whose frequency matches old_mit.frequency are reindexed (recursively for nested Workspaces); other members are carried through.
required
old_to_new tuple[MIT, MIT]

Pair (old_mit, new_mit): the input MIT (or one matching the input's frequency) to remap, and the target MIT (with the desired output frequency).

required
copy bool

When True, force an independent values buffer for the returned :class:~tsecon.tseries.TSeries / :class:~tsecon.mvtseries.MVTSeries and apply :func:copy.copy to non-reindexable Workspace members. Default False matches the wrap-by-default contract of :mod:tsecon constructors (see the project's design notes on constructor copy semantics).

False

Returns:

Type Description
The same kind of object as ``x``, with the MIT label shifted.

Raises:

Type Description
TypeError

On a malformed old_to_new argument, an unsupported x type, or a frequency mismatch between x and old_mit.

Examples:

Re-anchor a quarterly TSeries from a 2021Q1 origin to a Unit 1-based origin::

>>> from tsecon import TSeries, qq, period, Unit, reindex
>>> ts = TSeries(qq(2021, 1), [1.0, 2.0, 3.0])
>>> reindex(ts, (qq(2021, 1), period(Unit(), 1))).firstdate
1U
Source code in src/tsecon/_various.py
def reindex(
    x: Any,
    old_to_new: tuple[MIT, MIT],
    *,
    copy: bool = False,
) -> Any:
    """Shift every MIT-keyed position so that ``old`` maps to ``new``.

    ``old_to_new`` is a 2-tuple ``(old_mit, new_mit)``. In MIT-value
    space, every output position is the input position plus
    ``new_mit.value - old_mit.value``, and the result carries
    ``new_mit.frequency`` as its frequency label.

    Parameters
    ----------
    x
        The value to reindex. Supported types:

        * :class:`~tsecon.mit.MIT` — single instant.
        * :class:`~tsecon.mitrange.MITRange` — both endpoints shifted;
          ``step`` is preserved.
        * :class:`~tsecon.tseries.TSeries` — anchor shifted; values
          unchanged.
        * :class:`~tsecon.mvtseries.MVTSeries` — same, on the matrix.
        * :class:`~tsecon.workspace.Workspace` — members whose
          frequency matches ``old_mit.frequency`` are reindexed
          (recursively for nested Workspaces); other members are
          carried through.

    old_to_new
        Pair ``(old_mit, new_mit)``: the input MIT (or one matching
        the input's frequency) to remap, and the target MIT (with
        the desired output frequency).
    copy
        When True, force an independent values buffer for the returned
        :class:`~tsecon.tseries.TSeries` / :class:`~tsecon.mvtseries.MVTSeries`
        and apply :func:`copy.copy` to non-reindexable Workspace members.
        Default False matches the wrap-by-default contract of
        :mod:`tsecon` constructors (see the project's design notes on
        constructor copy semantics).

    Returns
    -------
    The same kind of object as ``x``, with the MIT label shifted.

    Raises
    ------
    TypeError
        On a malformed ``old_to_new`` argument, an unsupported ``x``
        type, or a frequency mismatch between ``x`` and ``old_mit``.

    Examples
    --------
    Re-anchor a quarterly TSeries from a 2021Q1 origin to a Unit
    1-based origin::

        >>> from tsecon import TSeries, qq, period, Unit, reindex
        >>> ts = TSeries(qq(2021, 1), [1.0, 2.0, 3.0])
        >>> reindex(ts, (qq(2021, 1), period(Unit(), 1))).firstdate
        1U
    """
    if not isinstance(old_to_new, tuple) or len(old_to_new) != 2:
        # Defensive: signature types `old_to_new` as `tuple[MIT, MIT]` so
        # mypy considers this branch unreachable, but Any-typed callers
        # (e.g. JSON deserialization) need the friendly error.
        msg = (  # type: ignore[unreachable]
            f"reindex pair must be a 2-tuple (old_mit, new_mit); got {type(old_to_new).__name__}."
        )
        raise TypeError(msg)
    old_mit, new_mit = old_to_new
    if not isinstance(old_mit, MIT) or not isinstance(new_mit, MIT):
        msg = (  # type: ignore[unreachable]
            "reindex pair must contain two MIT instances; got "
            f"({type(old_mit).__name__}, {type(new_mit).__name__})."
        )
        raise TypeError(msg)
    return _reindex_dispatch(x, old_mit, new_mit, copy=copy)