Files
2026-07-13 12:18:07 +08:00

169 lines
6.5 KiB
Python

# Copyright 2025 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Tests for NaN-handling and interpolation utilities in the base module.
``strip_leading_nans`` and ``linear_interpolation`` sit on the critical
inference path: every user input passes through them before being
patched and fed to the transformer. Incorrect behavior here — silently
keeping NaN values or interpolating the wrong indices — causes NaN
propagation through the entire model and produces garbage forecasts.
"""
import numpy as np
from timesfm.timesfm_2p5.timesfm_2p5_base import (
linear_interpolation,
strip_leading_nans,
)
# ---------------------------------------------------------------------------
# strip_leading_nans
# ---------------------------------------------------------------------------
class TestStripLeadingNans:
"""Tests for strip_leading_nans — removes leading NaN prefix."""
def test_no_nans_returns_unchanged(self):
"""An array without NaN values must pass through unmodified."""
arr = np.array([1.0, 2.0, 3.0])
result = strip_leading_nans(arr)
np.testing.assert_array_equal(result, arr)
def test_strips_leading_nans_only(self):
"""Leading NaNs are removed; NaNs embedded in the middle are kept."""
arr = np.array([np.nan, np.nan, 1.0, np.nan, 3.0])
result = strip_leading_nans(arr)
expected = np.array([1.0, np.nan, 3.0])
np.testing.assert_array_equal(result, expected)
def test_single_leading_nan(self):
"""Edge case: exactly one leading NaN."""
arr = np.array([np.nan, 5.0, 6.0])
result = strip_leading_nans(arr)
np.testing.assert_array_equal(result, np.array([5.0, 6.0]))
def test_no_leading_nan_with_internal_nans(self):
"""If the first element is valid, nothing is stripped regardless of
internal NaNs."""
arr = np.array([1.0, np.nan, np.nan, 4.0])
result = strip_leading_nans(arr)
np.testing.assert_array_equal(result, arr)
def test_single_valid_element(self):
"""A single non-NaN element must be returned as-is."""
arr = np.array([42.0])
result = strip_leading_nans(arr)
np.testing.assert_array_equal(result, np.array([42.0]))
def test_all_nans_returns_full_array(self):
"""When every element is NaN, ``np.argmax`` on an all-False mask
returns 0 — so the implementation returns the original array, not an
empty one.
This documents the *actual* behavior (which differs from the
docstring claim of returning an empty array). Downstream code
(``linear_interpolation``) is designed to handle this case.
"""
arr = np.array([np.nan, np.nan, np.nan])
result = strip_leading_nans(arr)
# Actual behavior: argmax(~isnan) = 0 when all NaN → returns full array.
assert len(result) == 3
assert np.all(np.isnan(result))
def test_preserves_dtype(self):
"""Output dtype must match input dtype (float32 stays float32)."""
arr = np.array([np.nan, 1.0, 2.0], dtype=np.float32)
result = strip_leading_nans(arr)
assert result.dtype == np.float32
# ---------------------------------------------------------------------------
# linear_interpolation
# ---------------------------------------------------------------------------
class TestLinearInterpolation:
"""Tests for linear_interpolation — fills NaN gaps via ``np.interp``."""
def test_no_nans_returns_identical(self):
"""Without NaN values the array is returned as-is (fast path)."""
arr = np.array([1.0, 2.0, 3.0])
result = linear_interpolation(arr.copy())
np.testing.assert_array_equal(result, arr)
def test_interpolates_single_interior_nan(self):
"""A single interior NaN is linearly interpolated from neighbors."""
arr = np.array([0.0, np.nan, 2.0])
result = linear_interpolation(arr)
np.testing.assert_allclose(result, [0.0, 1.0, 2.0])
def test_interpolates_multiple_interior_nans(self):
"""Multiple consecutive interior NaN values are interpolated."""
arr = np.array([0.0, np.nan, np.nan, 3.0])
result = linear_interpolation(arr)
np.testing.assert_allclose(result, [0.0, 1.0, 2.0, 3.0])
def test_extrapolates_trailing_nans(self):
"""Trailing NaN values are filled via ``np.interp`` which holds the
last known value (nearest-neighbor extrapolation)."""
arr = np.array([1.0, 2.0, np.nan, np.nan])
result = linear_interpolation(arr)
# np.interp extrapolates by clamping to boundary values.
np.testing.assert_allclose(result, [1.0, 2.0, 2.0, 2.0])
def test_extrapolates_leading_nans(self):
"""Leading NaN values are filled with the first valid value.
In practice ``strip_leading_nans`` runs first, but the function must
still be robust on its own.
"""
arr = np.array([np.nan, np.nan, 3.0, 4.0])
result = linear_interpolation(arr)
np.testing.assert_allclose(result, [3.0, 3.0, 3.0, 4.0])
def test_output_has_no_nans(self):
"""After interpolation, no NaN values should remain."""
arr = np.array([np.nan, 1.0, np.nan, np.nan, 4.0, np.nan])
result = linear_interpolation(arr)
assert not np.any(np.isnan(result))
def test_preserves_non_nan_values(self):
"""Non-NaN values in the original array must never be modified."""
arr = np.array([10.0, np.nan, 30.0, np.nan, 50.0])
original_valid = arr[~np.isnan(arr)].copy()
result = linear_interpolation(arr)
np.testing.assert_array_equal(
result[~np.isnan(np.array([10.0, np.nan, 30.0, np.nan, 50.0]))],
original_valid,
)
def test_interpolation_is_monotone_for_monotone_input(self):
"""If the known values are strictly increasing, the interpolated
result must also be non-decreasing — a basic sanity check on the
interpolation direction."""
arr = np.array([1.0, np.nan, np.nan, 4.0, np.nan, 6.0])
result = linear_interpolation(arr)
diffs = np.diff(result)
assert np.all(diffs >= 0)
def test_single_non_nan_fills_all_gaps(self):
"""With only one valid value, every NaN is replaced by that value
(np.interp clamps to the single known point)."""
arr = np.array([np.nan, 5.0, np.nan])
result = linear_interpolation(arr)
np.testing.assert_allclose(result, [5.0, 5.0, 5.0])