Source code for arlmet.header

"""Binary codec for the fixed 50-byte ARL record header."""

import string
from collections.abc import Callable
from dataclasses import dataclass
from math import floor, log10
from typing import Any, ClassVar

import pandas as pd

from arlmet._time import ensure_timestamp
from arlmet.grid import Grid

# ---------------------------------------------------------------------------
# Helper functions
# ---------------------------------------------------------------------------


def restore_year(yr: str | int):
    """
    Convert 2-digit year to 4-digit year.

    Years < 40 are mapped to 2000+yr, otherwise 1900+yr.
    Already 4-digit years (>= 1900) are returned unchanged.
    """
    yr = int(yr)
    if yr >= 1900:
        return yr
    return 2000 + yr if (yr < 40) else 1900 + yr


def letter_to_thousands(char: str) -> int:
    """Convert letter to thousands digit for large grids. A=1000, B=2000, …"""
    if char in string.ascii_uppercase:
        return (string.ascii_uppercase.index(char) + 1) * 1000
    return 0


def thousands_to_letter(value: int) -> str:
    """
    Convert thousands value back to ARL grid header character.

    Zero is encoded as ``9`` in the files seen so far. Positive thousands
    are encoded as letters with ``A=1000``.
    """
    if value == 0:
        return "9"
    if value % 1000 != 0 or value < 0 or value > 26000:
        raise ValueError(f"Unsupported grid thousands value: {value}")
    return string.ascii_uppercase[(value // 1000) - 1]


def format_fortran_float(value: float) -> str:
    """Format a float using the ARL/Fortran-style scientific notation."""
    if value == 0.0:
        return " 0.0000000E+00"
    exponent = floor(log10(abs(value))) + 1
    mantissa = value / (10**exponent)
    return f"{mantissa:10.7f}E{exponent:+03d}"


def format_fixed_width_float(value: float, width: int) -> str:
    """Format a float into a fixed-width decimal field for index records."""
    if width < 2:
        raise ValueError("width must be at least 2")

    if value == 0.0:
        return "." + ("0" * (width - 1))

    for decimals in range(width, -1, -1):
        text = f"{value:.{decimals}f}"
        if text.startswith("0.") and len(text) - 1 <= width:
            text = text[1:]
        elif text.startswith("-0.") and len(text) - 1 <= width:
            text = "-" + text[2:]

        if len(text) <= width:
            return text.rjust(width)

    raise ValueError(f"Value {value} cannot be represented in width {width}")


def split_grid_component(total: int) -> tuple[int, int]:
    """Split a total grid dimension into thousands and remainder components."""
    if total < 0:
        raise ValueError("Grid dimensions must be non-negative.")
    return (total // 1000) * 1000, total % 1000


def record_length_from_grid(grid: Grid) -> int:
    """
    Calculate the ARL record length for a given grid.

    Record length is the fixed header length plus the number of grid points.
    """
    return Header.N_BYTES + grid.nx * grid.ny


# ---------------------------------------------------------------------------
# Header
# ---------------------------------------------------------------------------