Source code for lost_years.utils
"""Shared helpers for reading input frames and matching to life-table rows."""
import logging
from pathlib import Path
from typing import TYPE_CHECKING, Any
import pandas as pd
import requests
if TYPE_CHECKING:
import numpy as np
import numpy.typing as npt
# Setup logger
logger = logging.getLogger(__name__)
[docs]
def isstring(s: Any) -> bool:
"""Report whether ``s`` is a string.
Args:
s: Value to test.
Returns:
True when ``s`` is a ``str``.
"""
return isinstance(s, str)
[docs]
def column_exists(df: pd.DataFrame, col: str | None) -> bool:
"""Check the column name exists in the DataFrame.
Args:
df: Pandas DataFrame.
col: Column name.
Returns:
bool: True if exists, False if not exists.
"""
if col and (col not in df.columns):
logger.warning("The specified column `%s` was not found in the input file", col)
return False
return True
[docs]
def fixup_columns(cols: list[Any]) -> list[str]:
"""Replace index location column to name with `col` prefix.
Args:
cols: List of original columns
Returns:
List of column names
"""
out_cols = []
for col in cols:
if isinstance(col, int):
out_cols.append(f"col{col:d}")
else:
out_cols.append(col)
return out_cols
[docs]
def closest(
lst: "list[float] | npt.NDArray[np.floating[Any]]",
c: float,
tolerance: float | None = None,
) -> float:
"""Find closest value in list or array.
A missing target is rejected rather than matched. ``abs(x - nan)`` is
``nan`` and ``nan`` compares False against everything, so ``min`` used to
fall through to the first element: a row with no age silently returned the
life expectancy at age 0.
``tolerance`` bounds how far the answer may sit from the question. Without
it, asking for the year 1900 or 2500 against a table that holds only 2022
returns the 2022 figure with nothing to say it is not an answer.
Args:
lst: List of floats or numpy array
c: Target value to find closest match for
tolerance: Largest accepted distance between ``c`` and the match.
None accepts any distance.
Returns:
Closest value in the list/array
Raises:
ValueError: If ``c`` is missing, if there is no non-missing candidate,
or if the closest candidate is further than ``tolerance`` away.
"""
if c is None or pd.isna(c):
raise ValueError("cannot match a missing value")
working_list: list[float] = lst if isinstance(lst, list) else lst.tolist()
candidates = [v for v in working_list if v is not None and not pd.isna(v)]
if not candidates:
raise ValueError("no candidate values to match against")
match = min(candidates, key=lambda v: abs(v - c))
if tolerance is not None and abs(match - c) > tolerance:
raise ValueError(
f"closest available value {match} is more than {tolerance} from {c}"
)
return match
[docs]
def download_file(url: str, local_path: str | Path | None = None) -> None:
"""Stream ``url`` to disk.
Args:
url: Source URL.
local_path: Destination path. Defaults to the URL's last path segment.
"""
match local_path:
case None:
local_path = Path(url.split("/")[-1])
case str():
local_path = Path(local_path)
case _:
pass # Already a Path object
# These are multi-megabyte life tables on slow public hosts; the timeout is
# per-read, not for the whole transfer, so a generous value is safe.
r = requests.get(url, timeout=60)
with local_path.open("wb") as f:
for chunk in r.iter_content(chunk_size=512 * 1024):
if chunk: # filter out keep-alive new chunks
f.write(chunk)