Skip to content

Utils

btorch.utils.bench

Benchmarking utilities.

Performance measurement tools for PyTorch code, supporting both CPU wall-clock and GPU event-based timing with warmup and statistical summarization.

Classes

PerfTimer

Context manager for measuring execution time.

Example

with PerfTimer() as timer: ... result = some_function() print(f"Took {timer.elapsed_ms():.2f} ms")

Source code in btorch/utils/bench.py
class PerfTimer:
    """Context manager for measuring execution time.

    Example:
        >>> with PerfTimer() as timer:
        ...     result = some_function()
        >>> print(f"Took {timer.elapsed_ms():.2f} ms")
    """

    def __init__(self):
        self.start_time = None
        self.end_time = None

    def __enter__(self):
        self.start_time = time.perf_counter()
        return self

    def __exit__(self, *args):
        self.end_time = time.perf_counter()

    def elapsed_ms(self) -> float:
        """Return elapsed time in milliseconds.

        Returns:
            Elapsed time from ``__enter__`` to ``__exit__`` (or now
            if ``__exit__`` hasn't been called).

        Raises:
            RuntimeError: If timer was never started.
        """
        if self.start_time is None:
            raise RuntimeError("Timer never started")
        end = self.end_time if self.end_time is not None else time.perf_counter()
        return (end - self.start_time) * 1000
Functions
elapsed_ms()

Return elapsed time in milliseconds.

Returns:

Type Description
float

Elapsed time from __enter__ to __exit__ (or now

float

if __exit__ hasn't been called).

Raises:

Type Description
RuntimeError

If timer was never started.

Source code in btorch/utils/bench.py
def elapsed_ms(self) -> float:
    """Return elapsed time in milliseconds.

    Returns:
        Elapsed time from ``__enter__`` to ``__exit__`` (or now
        if ``__exit__`` hasn't been called).

    Raises:
        RuntimeError: If timer was never started.
    """
    if self.start_time is None:
        raise RuntimeError("Timer never started")
    end = self.end_time if self.end_time is not None else time.perf_counter()
    return (end - self.start_time) * 1000

Functions

do_bench(fn, warmup=25, rep=100, grad_to_none=None, quantiles=None, return_mode='mean', timing_method='cpu', sync_cuda=True)

Benchmark function runtime with warmup and statistics.

Supports both CPU wall-clock timing and GPU CUDA event timing. Warmup and repetition can be specified as iteration counts (int) or durations in milliseconds (float).

Parameters:

Name Type Description Default
fn Callable

Function to benchmark (callable with no arguments).

required
warmup int | float

Warmup iterations (int) or duration in ms (float).

25
rep int | float

Measurement iterations (int) or duration in ms (float).

100
grad_to_none Optional[Tensor]

Optional tensor whose gradient is reset to None between repetitions.

None
quantiles Optional[List[float]]

Optional quantiles to compute (e.g., [0.05, 0.95]).

None
return_mode Literal['min', 'max', 'mean', 'median', 'all']

Central statistic to return: "min", "max", "mean", "median", or "all" for all stats.

'mean'
timing_method Literal['gpu', 'cpu']

"gpu" for CUDA events (if available) or "cpu" for wall-clock timing.

'cpu'
sync_cuda bool

Whether to synchronize CUDA before/after timing (only applies to CPU timing).

True

Returns:

Type Description
Union[float, Dict[str, float]]

Timing result. Float for single statistics, dict for "all"

Union[float, Dict[str, float]]

or when quantiles are specified.

Example

def bench_fn(): ... return torch.mm(a, b) do_bench(bench_fn, warmup=10, rep=100, return_mode="median") 0.523

Source code in btorch/utils/bench.py
def do_bench(
    fn: Callable,
    warmup: int | float = 25,
    rep: int | float = 100,
    grad_to_none: Optional[torch.Tensor] = None,
    quantiles: Optional[List[float]] = None,
    return_mode: Literal["min", "max", "mean", "median", "all"] = "mean",
    timing_method: Literal["gpu", "cpu"] = "cpu",
    sync_cuda: bool = True,
) -> Union[float, Dict[str, float]]:
    """Benchmark function runtime with warmup and statistics.

    Supports both CPU wall-clock timing and GPU CUDA event timing.
    Warmup and repetition can be specified as iteration counts (int)
    or durations in milliseconds (float).

    Args:
        fn: Function to benchmark (callable with no arguments).
        warmup: Warmup iterations (int) or duration in ms (float).
        rep: Measurement iterations (int) or duration in ms (float).
        grad_to_none: Optional tensor whose gradient is reset to None
            between repetitions.
        quantiles: Optional quantiles to compute (e.g., [0.05, 0.95]).
        return_mode: Central statistic to return:
            "min", "max", "mean", "median", or "all" for all stats.
        timing_method: "gpu" for CUDA events (if available) or "cpu"
            for wall-clock timing.
        sync_cuda: Whether to synchronize CUDA before/after timing
            (only applies to CPU timing).

    Returns:
        Timing result. Float for single statistics, dict for "all"
        or when quantiles are specified.

    Example:
        >>> def bench_fn():
        ...     return torch.mm(a, b)
        >>> do_bench(bench_fn, warmup=10, rep=100, return_mode="median")
        0.523
    """
    if not callable(fn):
        raise TypeError("The 'fn' parameter must be callable")

    if timing_method == "total":
        timing_method = "cpu"
    if timing_method not in ["gpu", "cpu"]:
        raise ValueError("timing_method must be either 'gpu' or 'cpu'")

    if timing_method == "gpu" and not torch.cuda.is_available():
        print(
            "Warning: GPU timing requested but CUDA is not available. "
            "Falling back to cpu timing."
        )
        timing_method = "cpu"

    if timing_method == "gpu":
        use_reps = isinstance(warmup, int) and isinstance(rep, int)
        if use_reps:
            if warmup < 0 or rep < 1:
                raise ValueError("warmup must be >= 0 and rep must be >= 1")
        else:
            warmup = float(warmup)
            rep = float(rep)
            if warmup < 0.0 or rep <= 0.0:
                raise ValueError("warmup and rep must be positive durations in ms")

        from triton.testing import _summarize_statistics, runtime

        di = runtime.driver.active.get_device_interface()

        fn()
        di.synchronize()

        cache = runtime.driver.active.get_empty_cache_for_benchmark()

        if use_reps:
            n_warmup = warmup
            n_repeat = rep
        else:
            start_event = di.Event(enable_timing=True)
            end_event = di.Event(enable_timing=True)
            start_event.record()
            for _ in range(5):
                runtime.driver.active.clear_cache(cache)
                fn()
            end_event.record()
            di.synchronize()
            estimate_ms = start_event.elapsed_time(end_event) / 5
            n_warmup = max(1, int(warmup / estimate_ms))
            n_repeat = max(1, int(rep / estimate_ms))

        start_event = [di.Event(enable_timing=True) for _ in range(n_repeat)]
        end_event = [di.Event(enable_timing=True) for _ in range(n_repeat)]
        for _ in range(n_warmup):
            fn()
        for i in range(n_repeat):
            if grad_to_none is not None:
                for x in grad_to_none:
                    x.grad = None
            runtime.driver.active.clear_cache(cache)
            start_event[i].record()
            fn()
            end_event[i].record()
        di.synchronize()
        times = [s.elapsed_time(e) for s, e in zip(start_event, end_event)]
        return _summarize_statistics(times, quantiles, return_mode)

    use_reps = isinstance(warmup, int) and isinstance(rep, int)
    if use_reps:
        if warmup < 0 or rep < 1:
            raise ValueError("warmup must be >= 0 and rep must be >= 1")
    else:
        warmup = float(warmup)
        rep = float(rep)
        if warmup < 0.0 or rep <= 0.0:
            raise ValueError("warmup and rep must be positive durations in ms")

    if use_reps:
        for _ in range(warmup):
            fn()
    else:
        warmup_start = time.perf_counter()
        while (time.perf_counter() - warmup_start) * 1000 < warmup:
            fn()

    times = []
    if use_reps:
        rep_start = None
    else:
        rep_start = time.perf_counter()
    while True:
        if use_reps:
            if len(times) >= rep:
                break
        else:
            if (time.perf_counter() - rep_start) * 1000 >= rep:
                break
        if grad_to_none is not None:
            for x in grad_to_none:
                x.grad = None
        if sync_cuda and torch.cuda.is_available():
            torch.cuda.synchronize()
        with PerfTimer() as timer:
            fn()
            if sync_cuda and torch.cuda.is_available():
                torch.cuda.synchronize()
        times.append(timer.elapsed_ms())

    times = torch.tensor(times)

    if quantiles is not None:
        ret = torch.quantile(times, torch.tensor(quantiles, dtype=torch.float)).tolist()
        if len(ret) == 1:
            ret = ret[0]
        return ret
    return getattr(torch, return_mode)(times).item()

btorch.utils.conf

OmegaConf configuration utilities.

Helpers for loading, manipulating, and comparing structured configs. Provides CLI-style dotlist conversion and diff operations for configuration management workflows.

Attributes

ConfigT = TypeVar('ConfigT') module-attribute

Functions

diff_conf(conf_a, conf_b, mode=None)

Compare conf_b to conf_a and return a structured OmegaConf diff.

The returned config contains only the selected changed keys. For removed keys (when moded by mode), values are set to None so callers can render these entries via :func:to_dotlist as key=null.

Source code in btorch/utils/conf.py
def diff_conf(
    conf_a: DictConfig | ListConfig | Any,
    conf_b: DictConfig | ListConfig,
    mode: (Iterable[Literal["changed", "added", "removed"]] | None) = None,
) -> DictConfig | ListConfig:
    """Compare ``conf_b`` to ``conf_a`` and return a structured OmegaConf diff.

    The returned config contains only the selected changed keys. For removed
    keys (when moded by ``mode``), values are set to ``None`` so callers can
    render these entries via :func:`to_dotlist` as ``key=null``.
    """

    if not isinstance(conf_a, (DictConfig, ListConfig)):
        conf_a = OmegaConf.structured(conf_a)
    if not isinstance(conf_b, (DictConfig, ListConfig)):
        conf_b = OmegaConf.structured(conf_b)

    records = diff_conf_records(conf_a, conf_b, mode=mode)

    def _is_index(token: str) -> bool:
        return token.isdigit()

    def _empty_container(next_token: str):
        return [] if _is_index(next_token) else {}

    def _set_path(root, path: str, value):
        if path == "<root>":
            return value

        tokens = path.split(".")
        if root is None:
            root = _empty_container(tokens[0])

        cur = root
        for i, token in enumerate(tokens):
            is_last = i == len(tokens) - 1

            if isinstance(cur, dict):
                if is_last:
                    cur[token] = value
                    break
                if token not in cur:
                    cur[token] = _empty_container(tokens[i + 1])
                cur = cur[token]
                continue

            if isinstance(cur, list):
                idx = int(token)
                while len(cur) <= idx:
                    if is_last:
                        cur.append(None)
                    else:
                        cur.append(_empty_container(tokens[i + 1]))
                if is_last:
                    cur[idx] = value
                    break
                if cur[idx] is None:
                    cur[idx] = _empty_container(tokens[i + 1])
                cur = cur[idx]
                continue

            raise TypeError(f"Cannot set nested path '{path}' through scalar node.")

        return root

    diff_tree = None
    for path, entry in sorted(records.items()):
        if entry["status"] == "removed":
            value = None
        else:
            value = entry["new"]
        diff_tree = _set_path(diff_tree, path, value)

    if diff_tree is None:
        # Keep return type stable and flattenable.
        if isinstance(conf_b, ListConfig):
            return OmegaConf.create([])
        return OmegaConf.create({})

    if not isinstance(diff_tree, (dict, list, DictConfig, ListConfig)):
        raise ValueError(
            "diff_conf produced a scalar root diff. "
            "Expected a DictConfig/ListConfig root."
        )

    return OmegaConf.create(diff_tree)

diff_conf_dotlist(conf_a, conf_b, mode=None, removed_prefix='~')

Build CLI-style overrides that transform conf_a into conf_b.

For added and changed entries, this emits "path=value". For removed entries (when moded by mode), this emits "{removed_prefix}path".

Source code in btorch/utils/conf.py
def diff_conf_dotlist(
    conf_a: DictConfig | ListConfig,
    conf_b: DictConfig | ListConfig,
    mode: (Iterable[Literal["changed", "added", "removed"]] | None) = None,
    removed_prefix: str = "~",
) -> list[str]:
    """Build CLI-style overrides that transform ``conf_a`` into ``conf_b``.

    For ``added`` and ``changed`` entries, this emits ``"path=value"``.
    For ``removed`` entries (when moded by ``mode``), this emits
    ``"{removed_prefix}path"``.
    """

    records = diff_conf_records(conf_a, conf_b, mode=mode)

    dotlist: list[str] = []
    for key in sorted(records.keys()):
        status = records[key]["status"]
        new_value = records[key]["new"]

        if status == "removed":
            dotlist.append(f"{removed_prefix}{key}")
            continue

        if isinstance(new_value, (DictConfig, ListConfig, dict, list)):
            raise ValueError(
                "diff_conf_dotlist cannot serialize container-valued changes at "
                f"'{key}'. Use diff_conf_records for this case."
            )

        value = "null" if new_value is None else new_value
        dotlist.append(f"{key}={value}")

    return dotlist

diff_conf_records(conf_a, conf_b, mode=None)

Compare conf_b to conf_a and return per-key value-level records.

Each record has the shape {"status": str, "old": object, "new": object}.

  • status='changed': key exists in both, value differs.
  • status='added': key exists only in conf_b.
  • status='removed': key exists only in conf_a.

This representation is suitable when a caller needs both key names and values, for example to build child-process overrides from a baseline config.

Source code in btorch/utils/conf.py
def diff_conf_records(
    conf_a: DictConfig | ListConfig,
    conf_b: DictConfig | ListConfig,
    mode: (Iterable[Literal["changed", "added", "removed"]] | None) = None,
) -> dict[str, dict[str, object]]:
    """Compare ``conf_b`` to ``conf_a`` and return per-key value-level records.

    Each record has the shape ``{"status": str, "old": object, "new": object}``.

    - ``status='changed'``: key exists in both, value differs.
    - ``status='added'``: key exists only in ``conf_b``.
    - ``status='removed'``: key exists only in ``conf_a``.

    This representation is suitable when a caller needs both key names and values,
    for example to build child-process overrides from a baseline config.
    """

    if not isinstance(conf_a, (DictConfig, ListConfig)):
        raise TypeError(
            "diff_conf_records expects conf_a to be DictConfig or ListConfig."
        )
    if not isinstance(conf_b, (DictConfig, ListConfig)):
        raise TypeError(
            "diff_conf_records expects conf_b to be DictConfig or ListConfig."
        )

    if mode is None:
        mode_set = {"changed", "added", "removed"}
    else:
        mode_set = set(mode)

    valid_status = {"changed", "added", "removed"}
    if not mode_set.issubset(valid_status):
        raise ValueError("mode must only contain: 'changed', 'added', 'removed'.")

    changed: set[str] = set()
    added: set[str] = set()
    removed: set[str] = set()
    changed_values: dict[str, tuple[object, object]] = {}
    added_values: dict[str, object] = {}
    removed_values: dict[str, object] = {}

    # Use plain containers so structured union explicit type metadata (`_type_`)
    # emitted by OmegaConf is visible to the recursive diff.
    plain_a = OmegaConf.to_container(conf_a, resolve=False)
    plain_b = OmegaConf.to_container(conf_b, resolve=False)

    def _is_container(node) -> bool:
        return isinstance(node, (dict, list))

    def _select_plain(node, path: str):
        if path == "<root>":
            return node

        cur = node
        for token in path.split("."):
            if isinstance(cur, dict):
                cur = cur[token]
                continue
            if isinstance(cur, list):
                cur = cur[int(token)]
                continue
            raise KeyError(f"Cannot descend through scalar at token '{token}'.")
        return cur

    def _collect_leaf_paths(node, path: str) -> set[str]:
        if isinstance(node, dict):
            out: set[str] = set()
            for key, value in node.items():
                new_path = f"{path}.{key}" if path else str(key)
                out |= _collect_leaf_paths(value, new_path)
            return out

        if isinstance(node, list):
            out = set()
            for idx, value in enumerate(node):
                new_path = f"{path}.{idx}" if path else str(idx)
                out |= _collect_leaf_paths(value, new_path)
            return out

        return {path} if path else {"<root>"}

    def _walk(a_node, b_node, path: str = ""):
        if isinstance(a_node, dict) and isinstance(b_node, dict):
            # Structured union switch: treat as full subtree replacement so old
            # type keys are discarded in one step.
            if (
                "_type_" in a_node
                and "_type_" in b_node
                and a_node["_type_"] != b_node["_type_"]
            ):
                key = path if path else "<root>"
                changed.add(key)
                changed_values[key] = (a_node, b_node)
                return

            keys_a = set(a_node.keys())
            keys_b = set(b_node.keys())

            for key in keys_a - keys_b:
                key_path = f"{path}.{key}" if path else str(key)
                leaf_paths = _collect_leaf_paths(a_node[key], key_path)
                removed.update(leaf_paths)
                for leaf_path in leaf_paths:
                    removed_values[leaf_path] = _select_plain(plain_a, leaf_path)

            for key in keys_b - keys_a:
                key_path = f"{path}.{key}" if path else str(key)
                leaf_paths = _collect_leaf_paths(b_node[key], key_path)
                added.update(leaf_paths)
                for leaf_path in leaf_paths:
                    added_values[leaf_path] = _select_plain(plain_b, leaf_path)

            for key in keys_a & keys_b:
                key_path = f"{path}.{key}" if path else str(key)
                _walk(a_node[key], b_node[key], key_path)
            return

        if isinstance(a_node, list) and isinstance(b_node, list):
            len_a = len(a_node)
            len_b = len(b_node)
            common = min(len_a, len_b)

            for idx in range(common):
                key_path = f"{path}.{idx}" if path else str(idx)
                _walk(a_node[idx], b_node[idx], key_path)

            for idx in range(common, len_a):
                key_path = f"{path}.{idx}" if path else str(idx)
                leaf_paths = _collect_leaf_paths(a_node[idx], key_path)
                removed.update(leaf_paths)
                for leaf_path in leaf_paths:
                    removed_values[leaf_path] = _select_plain(plain_a, leaf_path)

            for idx in range(common, len_b):
                key_path = f"{path}.{idx}" if path else str(idx)
                leaf_paths = _collect_leaf_paths(b_node[idx], key_path)
                added.update(leaf_paths)
                for leaf_path in leaf_paths:
                    added_values[leaf_path] = _select_plain(plain_b, leaf_path)
            return

        if _is_container(a_node) != _is_container(b_node):
            key = path if path else "<root>"
            changed.add(key)
            changed_values[key] = (a_node, b_node)
            return

        if a_node != b_node:
            key = path if path else "<root>"
            changed.add(key)
            changed_values[key] = (a_node, b_node)

    _walk(plain_a, plain_b)

    def _is_under(key: str, parent: str) -> bool:
        if parent == "<root>":
            return True
        return key == parent or key.startswith(f"{parent}.")

    # If explicit union type changed ("..._type_"), collapse that subtree into a
    # single changed record at the parent path. This avoids emitting stale
    # per-leaf removals for the previous union member.
    union_switch_parents: set[str] = set()
    for key in changed:
        if key == "_type_":
            union_switch_parents.add("<root>")
            continue
        if key.endswith("._type_"):
            union_switch_parents.add(key.rsplit(".", 1)[0])

    for parent in sorted(union_switch_parents, key=lambda p: (p != "<root>", p)):
        for key in list(changed):
            if _is_under(key, parent):
                changed.remove(key)
                changed_values.pop(key, None)
        for key in list(added):
            if _is_under(key, parent):
                added.remove(key)
                added_values.pop(key, None)
        for key in list(removed):
            if _is_under(key, parent):
                removed.remove(key)
                removed_values.pop(key, None)

        changed.add(parent)
        if parent == "<root>":
            changed_values[parent] = (plain_a, plain_b)
        else:
            changed_values[parent] = (
                _select_plain(plain_a, parent),
                _select_plain(plain_b, parent),
            )

    out: set[str] = set()
    if "changed" in mode_set:
        out |= changed
    if "added" in mode_set:
        out |= added
    if "removed" in mode_set:
        out |= removed

    records: dict[str, dict[str, object]] = {}
    for key in sorted(out):
        if key in changed:
            old_value, new_value = changed_values[key]
            records[key] = {"status": "changed", "old": old_value, "new": new_value}
            continue
        if key in added:
            records[key] = {"status": "added", "old": None, "new": added_values[key]}
            continue
        records[key] = {"status": "removed", "old": removed_values[key], "new": None}

    return records

get_dotkey(obj, key, default=None)

Get nested attribute by dot-separated key.

Parameters:

Name Type Description Default
obj Any

Object to access (supports DictConfig/ListConfig or regular objects).

required
key str

Dot-separated path (e.g., "a.b.c").

required
default Any

Value to return if key not found.

None

Returns:

Type Description
Any

Value at the nested path, or default if not found.

Source code in btorch/utils/conf.py
def get_dotkey(obj: Any, key: str, default: Any = None) -> Any:
    """Get nested attribute by dot-separated key.

    Args:
        obj: Object to access (supports DictConfig/ListConfig or regular objects).
        key: Dot-separated path (e.g., "a.b.c").
        default: Value to return if key not found.

    Returns:
        Value at the nested path, or ``default`` if not found.
    """
    if isinstance(obj, (DictConfig, ListConfig)):
        return OmegaConf.select(obj, key, default=default)
    try:
        for part in key.split("."):
            obj = getattr(obj, part)
        return obj
    except AttributeError:
        return default

load_config(Param, use_config_file=True, search_path=Path('.'), argv_arglist=None, return_cli=False, make_concrete=True)

load_config(
    Param: type[ConfigT] | ConfigT,
    use_config_file: bool = True,
    search_path: Path = Path("."),
    argv_arglist: list[str] | None = None,
    return_cli: Literal[False] = False,
    make_concrete: Literal[True] = True,
) -> ConfigT
load_config(
    Param: type[ConfigT] | ConfigT,
    use_config_file: bool = True,
    search_path: Path = Path("."),
    argv_arglist: list[str] | None = None,
    return_cli: Literal[False] = False,
    make_concrete: Literal[False] = False,
) -> DictConfig | ListConfig
load_config(
    Param: type[ConfigT] | ConfigT,
    use_config_file: bool = True,
    search_path: Path = Path("."),
    argv_arglist: list[str] | None = None,
    return_cli: Literal[True] = True,
    make_concrete: Literal[True] = True,
) -> tuple[ConfigT, DictConfig | ListConfig]
load_config(
    Param: type[ConfigT] | ConfigT,
    use_config_file: bool = True,
    search_path: Path = Path("."),
    argv_arglist: list[str] | None = None,
    return_cli: Literal[True] = True,
    make_concrete: Literal[False] = False,
) -> tuple[
    DictConfig | ListConfig, DictConfig | ListConfig
]

Load structured config from defaults, file, and CLI arguments.

Merges configuration in order: dataclass defaults -> config file -> CLI arguments. Config file path is read from config_path in CLI arguments.

Parameters:

Name Type Description Default
Param type[ConfigT] | ConfigT

Dataclass type or instance defining the configuration schema.

required
use_config_file bool

Whether to load from file specified by config_path CLI argument.

True
search_path Path

Directory to search for relative config paths.

Path('.')
argv_arglist list[str] | None

Optional CLI arguments list (defaults to sys.argv).

None
return_cli bool

If True, also return raw CLI config.

False
make_concrete bool

If True, convert to Python objects. If False, return OmegaConf containers.

True

Returns:

Type Description
Any

Loaded configuration. Tuple of (config, cli_config) if

Any

return_cli=True.

Note

Does not support help text or Literal types in the schema.

Source code in btorch/utils/conf.py
def load_config(
    Param: type[ConfigT] | ConfigT,
    use_config_file: bool = True,
    search_path: Path = Path("."),
    argv_arglist: list[str] | None = None,
    return_cli: bool = False,
    make_concrete: bool = True,
) -> Any:
    """Load structured config from defaults, file, and CLI arguments.

    Merges configuration in order: dataclass defaults -> config file ->
    CLI arguments. Config file path is read from ``config_path`` in CLI
    arguments.

    Args:
        Param: Dataclass type or instance defining the configuration schema.
        use_config_file: Whether to load from file specified by
            ``config_path`` CLI argument.
        search_path: Directory to search for relative config paths.
        argv_arglist: Optional CLI arguments list (defaults to sys.argv).
        return_cli: If True, also return raw CLI config.
        make_concrete: If True, convert to Python objects. If False,
            return OmegaConf containers.

    Returns:
        Loaded configuration. Tuple of (config, cli_config) if
        ``return_cli=True``.

    Note:
        Does not support help text or Literal types in the schema.
    """
    defaults = OmegaConf.structured(Param)
    if argv_arglist is None:
        cli_cfg_ = cli_cfg = OmegaConf.from_cli()
    else:
        cli_cfg_ = cli_cfg = OmegaConf.from_cli(argv_arglist)
    if use_config_file and "config_path" in cli_cfg:
        assert "config_path" not in Param.__dataclass_fields__
        config_path = Path(cli_cfg.config_path)
        if not config_path.is_file():
            config_path = search_path / config_path
            assert config_path.is_file()
        cfg_cli_file = OmegaConf.load(cli_cfg.config_path)
        if return_cli:
            cli_cfg_ = cli_cfg.copy()
        cli_cfg.pop("config_path")
    else:
        cfg_cli_file = OmegaConf.create()
    cfg = OmegaConf.unsafe_merge(defaults, cfg_cli_file, cli_cfg)
    # workaround for from_cli doesn't treat integer index as dict key in some cases.
    # cli_dotlist = to_dotlist(cli_cfg, use_equal=True)
    # cfg.merge_with_dotlist(cli_dotlist)
    if make_concrete:
        cfg = OmegaConf.to_object(cfg)

    if return_cli:
        return cfg, cli_cfg_
    return cfg

set_dotkey(obj, key, value)

Set nested attribute by dot-separated key.

Parameters:

Name Type Description Default
obj Any

Object to modify (supports DictConfig/ListConfig or regular objects).

required
key str

Dot-separated path (e.g., "a.b.c").

required
value Any

Value to set.

required
Source code in btorch/utils/conf.py
def set_dotkey(obj: Any, key: str, value: Any) -> None:
    """Set nested attribute by dot-separated key.

    Args:
        obj: Object to modify (supports DictConfig/ListConfig or regular objects).
        key: Dot-separated path (e.g., "a.b.c").
        value: Value to set.
    """
    if isinstance(obj, (DictConfig, ListConfig)):
        OmegaConf.update(obj, key, value)
        return
    parts = key.split(".")
    for part in parts[:-1]:
        obj = getattr(obj, part)
    setattr(obj, parts[-1], value)

to_dotlist(conf, use_equal=True, include=None, exclude=None, subfield=None, missing_subfield_policy='raise')

Flatten DictConfig/ListConfig to CLI-style dotlist.

Parameters

conf: Root OmegaConf container. Must be DictConfig or ListConfig. use_equal: If True, emit ["a.b=1"] form. If False, emit ["a.b", "1"] pairs. include, exclude: Optional exact-path filters applied to leaf paths. Paths are evaluated relative to subfield (if provided), otherwise relative to the root conf. subfield: Optional dotted path used as the flattening start point. Supports list indices (e.g. "a.b.1"). missing_subfield_policy: Behavior when subfield cannot be resolved. "raise" (default) raises KeyError. "empty" returns [].

Examples

{"a": {"b": 1}} -> ["a.b=1"] subfield="a" -> ["b=1"]

Source code in btorch/utils/conf.py
def to_dotlist(
    conf,
    use_equal: bool = True,
    include: set | None = None,
    exclude: set | None = None,
    subfield: str | None = None,
    missing_subfield_policy: Literal["raise", "empty"] = "raise",
):
    """Flatten DictConfig/ListConfig to CLI-style dotlist.

    Parameters
    ----------
    conf:
        Root OmegaConf container. Must be ``DictConfig`` or ``ListConfig``.
    use_equal:
        If True, emit ``["a.b=1"]`` form. If False, emit ``["a.b", "1"]`` pairs.
    include, exclude:
        Optional exact-path filters applied to leaf paths.
        Paths are evaluated relative to ``subfield`` (if provided), otherwise
        relative to the root ``conf``.
    subfield:
        Optional dotted path used as the flattening start point.
        Supports list indices (e.g. ``"a.b.1"``).
    missing_subfield_policy:
        Behavior when ``subfield`` cannot be resolved.
        ``"raise"`` (default) raises ``KeyError``.
        ``"empty"`` returns ``[]``.

    Examples
    --------
    ``{"a": {"b": 1}}`` -> ``["a.b=1"]``
    ``subfield="a"`` -> ``["b=1"]``
    """

    if not isinstance(conf, (DictConfig, ListConfig)):
        raise TypeError("to_dotlist expects DictConfig or ListConfig.")

    ret = []

    def _select_subfield(cfg, path: str):
        # Traverse the dotted path against OmegaConf containers only.
        cur = cfg
        for token in path.split("."):
            if token == "":
                raise ValueError("subfield contains an empty token.")
            if isinstance(cur, DictConfig):
                if token not in cur:
                    raise KeyError(f"subfield '{path}' not found at token '{token}'.")
                cur = cur[token]
                continue
            if isinstance(cur, ListConfig):
                try:
                    idx = int(token)
                except ValueError as exc:
                    raise KeyError(
                        f"subfield '{path}' expects a list index at token '{token}'."
                    ) from exc
                if idx < 0 or idx >= len(cur):
                    raise KeyError(f"subfield '{path}' list index out of range: {idx}.")
                cur = cur[idx]
                continue
            raise KeyError(
                f"subfield '{path}' cannot descend through non-container "
                f"at token '{token}'."
            )
        return cur

    def flatten_conf(cfg, path=""):
        nonlocal ret
        # Recurse through OmegaConf containers and emit scalar leaves.
        if isinstance(cfg, DictConfig):
            items = cfg.items()
        elif isinstance(cfg, ListConfig):
            # For lists, indices become path tokens ("a.0.b").
            items = enumerate(cfg)
        else:
            # Base case: leaf scalar value.
            if path:
                # Keep "null" spelling to match OmegaConf textual conventions.
                value = "null" if cfg is None else cfg
                if include is not None:
                    if path not in include:
                        return
                if exclude is not None:
                    if path in exclude:
                        return
                if use_equal:
                    ret.append(f"{path}={value}")
                else:
                    ret += [path, str(value)]
            return

        for key, value in items:
            # For DictConfig, key is a string. For ListConfig, key is an int index.
            new_path = f"{path}.{key}" if path else str(key)

            # Recursively flatten nested configs
            if isinstance(value, (DictConfig, ListConfig)):
                flatten_conf(value, new_path)
            else:
                # Handle the final value
                flatten_conf(value, new_path)

    if subfield:
        try:
            start_cfg = _select_subfield(conf, subfield)
        except KeyError:
            if missing_subfield_policy == "empty":
                return []
            raise
    else:
        start_cfg = conf
    flatten_conf(start_cfg)
    return ret

btorch.utils.dict_utils

Dictionary manipulation utilities.

Helpers for transforming, flattening, and mapping nested dictionaries commonly used in configuration and data preprocessing pipelines.

Functions

flatten_dict(d, dot=False)

Flatten nested dictionary into single-level dictionary.

Parameters:

Name Type Description Default
d dict

Nested dictionary to flatten.

required
dot bool

If True, use dot-notation keys ("a.b"). If False, use tuple keys (("a", "b")).

False

Returns:

Type Description
dict

Flattened dictionary.

Example

flatten_dict({"a": {"b": 1}, "c": 2}) {("a", "b"): 1, ("c",): 2} flatten_dict({"a": {"b": 1}}, dot=True)

Source code in btorch/utils/dict_utils.py
def flatten_dict(d: dict, dot: bool = False) -> dict:
    """Flatten nested dictionary into single-level dictionary.

    Args:
        d: Nested dictionary to flatten.
        dot: If True, use dot-notation keys ("a.b"). If False,
            use tuple keys (("a", "b")).

    Returns:
        Flattened dictionary.

    Example:
        >>> flatten_dict({"a": {"b": 1}, "c": 2})
        {("a", "b"): 1, ("c",): 2}
        >>> flatten_dict({"a": {"b": 1}}, dot=True)
        {"a.b": 1}
    """

    def _flatten_dict(d, parent_key):
        items = []
        for k, v in d.items():
            new_key = parent_key + "." + k if dot else parent_key + (k,)
            if isinstance(v, dict):
                items.extend(_flatten_dict(v, new_key))
            else:
                items.append((new_key, v))
        return items

    items = _flatten_dict(d, "" if dot else ())
    if dot:
        # remove the leading '.'
        items = [(k.lstrip("."), v) for k, v in items]
    return dict(items)

recurse_dict(d, mapper, include_sequence=False)

Recursively apply function to dictionary leaf values.

Parameters:

Name Type Description Default
d dict

Input dictionary (potentially nested).

required
mapper Callable

Function called with (key, value) for each leaf.

required
include_sequence bool

If True, also recurse into tuples and lists.

False

Returns:

Type Description
dict

New dictionary with transformed leaf values.

Source code in btorch/utils/dict_utils.py
def recurse_dict(d: dict, mapper: Callable, include_sequence: bool = False) -> dict:
    """Recursively apply function to dictionary leaf values.

    Args:
        d: Input dictionary (potentially nested).
        mapper: Function called with (key, value) for each leaf.
        include_sequence: If True, also recurse into tuples and lists.

    Returns:
        New dictionary with transformed leaf values.
    """

    def _f(d, k):
        if isinstance(d, dict):
            return {k: _f(v, k) for k, v in d.items()}
        if include_sequence:
            if isinstance(d, tuple):
                return tuple(_f(ve, None) for ve in d)
            elif isinstance(d, list):
                return list(_f(ve, None) for ve in d)
        return mapper(k, d)

    return _f(d, None)

reverse_map(map)

Reverse a mapping, handling sequence values.

Flattens sequence values so each item maps to the original key. Non-sequence values map directly.

Parameters:

Name Type Description Default
map dict[Any, Any | Sequence[Any]]

Dictionary with scalar or sequence values.

required

Returns:

Type Description
dict[Any, Any]

Reversed mapping where each original value (or sequence item)

dict[Any, Any]

maps to its original key.

Example

reverse_map({"a": [1, 2], "b": 3})

Source code in btorch/utils/dict_utils.py
def reverse_map(map: dict[Any, Any | Sequence[Any]]) -> dict[Any, Any]:
    """Reverse a mapping, handling sequence values.

    Flattens sequence values so each item maps to the original key.
    Non-sequence values map directly.

    Args:
        map: Dictionary with scalar or sequence values.

    Returns:
        Reversed mapping where each original value (or sequence item)
        maps to its original key.

    Example:
        >>> reverse_map({"a": [1, 2], "b": 3})
        {1: "a", 2: "a", 3: "b"}
    """
    ret = {}
    for key, items in map.items():
        if isinstance(items, Sequence) and not isinstance(items, str):
            for item in items:
                ret[item] = key
        else:
            ret[items] = key
    return ret

unflatten_dict(flattened_dict, dot=False)

Unflatten dictionary with compound keys into nested structure.

Parameters:

Name Type Description Default
flattened_dict dict

Dictionary with tuple or dot-notation keys.

required
dot bool

If True, split keys on dots. If False, keys are tuples.

False

Returns:

Type Description
dict

Nested dictionary.

Example

unflatten_dict({("a",): 1, ("b", "c"): 2}) {"a": 1, "b": {"c": 2}} unflatten_dict({"a.b": 1}, dot=True) {"a": {"b": 1}}

Source code in btorch/utils/dict_utils.py
def unflatten_dict(flattened_dict: dict, dot: bool = False) -> dict:
    """Unflatten dictionary with compound keys into nested structure.

    Args:
        flattened_dict: Dictionary with tuple or dot-notation keys.
        dot: If True, split keys on dots. If False, keys are tuples.

    Returns:
        Nested dictionary.

    Example:
        >>> unflatten_dict({("a",): 1, ("b", "c"): 2})
        {"a": 1, "b": {"c": 2}}
        >>> unflatten_dict({"a.b": 1}, dot=True)
        {"a": {"b": 1}}
    """
    result = {}
    for key_tuple, value in flattened_dict.items():
        if dot:
            key_tuple = key_tuple.split(".")
        current_level = result
        for i, key_part in enumerate(key_tuple):
            if i == len(key_tuple) - 1:
                # Assign the value at the last key part
                current_level[key_part] = value
            else:
                # Ensure the key part exists and is a dict, then move down
                if key_part not in current_level:
                    current_level[key_part] = {}
                current_level = current_level[key_part]
    return result

btorch.utils.file

File path utilities.

Helpers for resolving figure output paths based on caller location within the repository structure.

Classes

FigPathConfig dataclass

Configuration for figure output directory structure.

Attributes:

Name Type Description
root_dir str

Root directory for all figures.

benchmark_dir str

Subdirectory for benchmark script outputs.

tests_dir str

Subdirectory for test script outputs.

other_dir str

Subdirectory for other script outputs.

Source code in btorch/utils/file.py
@dataclass(frozen=True)
class FigPathConfig:
    """Configuration for figure output directory structure.

    Attributes:
        root_dir: Root directory for all figures.
        benchmark_dir: Subdirectory for benchmark script outputs.
        tests_dir: Subdirectory for test script outputs.
        other_dir: Subdirectory for other script outputs.
    """

    root_dir: str = "fig"
    benchmark_dir: str = "benchmark"
    tests_dir: str = "tests"
    other_dir: str = "misc"

Functions

_is_relative_to(path, base)

Check if path is within base directory.

Source code in btorch/utils/file.py
def _is_relative_to(path: Path, base: Path) -> bool:
    """Check if path is within base directory."""
    try:
        path.relative_to(base)
        return True
    except ValueError:
        return False

_repo_root()

Return repository root directory.

Source code in btorch/utils/file.py
def _repo_root() -> Path:
    """Return repository root directory."""
    return Path(__file__).resolve().parents[2]

_resolve_cfg(cfg)

Merge user config with defaults.

Source code in btorch/utils/file.py
def _resolve_cfg(cfg: FigPathConfig | dict | conf.DictConfig | None):
    """Merge user config with defaults."""
    defaults = conf.OmegaConf.structured(FigPathConfig)
    if cfg is None:
        return defaults
    if isinstance(cfg, FigPathConfig):
        cfg = conf.OmegaConf.structured(cfg)
    elif isinstance(cfg, dict):
        cfg = conf.OmegaConf.create(cfg)
    return conf.OmegaConf.merge(defaults, cfg)

caller_file(stack_level=2)

Source code in btorch/utils/file.py
def caller_file(stack_level: int = 2) -> str:
    # 0 == this frame, 1 == caller, 2 == caller of caller
    try:
        from IPython.core.getipython import get_ipython

        shell = get_ipython()
        if shell is not None and hasattr(shell, "kernel"):
            # Running in a notebook - try to get the notebook path
            ns = shell.user_ns
            # VS Code sets this
            if "__vsc_ipynb_file__" in ns:
                return ns["__vsc_ipynb_file__"]
            # Some Jupyter setups set __file__
            if "__file__" in ns:
                return ns["__file__"]
            # Last resort: use cwd as a fake path for relative resolution
            import os

            return str(Path(os.getcwd()) / "__notebook__.ipynb")
    except Exception:
        pass
    return sys._getframe(stack_level).f_code.co_filename

fig_path(file=None, cfg=None)

Resolve figure output directory based on caller location.

Places outputs in fig/benchmark/, fig/tests/, or fig/misc/ depending on whether the caller is in the benchmark, tests, or other directory.

Parameters:

Name Type Description Default
file str | Path | None

File path to use for path resolution. If None, uses caller file.

None
cfg FigPathConfig | dict | None

Configuration for directory naming.

None

Returns:

Type Description
Path

Path object for the figure directory (created if needed).

Source code in btorch/utils/file.py
def fig_path(
    file: str | Path | None = None, cfg: FigPathConfig | dict | None = None
) -> Path:
    """Resolve figure output directory based on caller location.

    Places outputs in ``fig/benchmark/``, ``fig/tests/``, or ``fig/misc/``
    depending on whether the caller is in the benchmark, tests, or other
    directory.

    Args:
        file: File path to use for path resolution. If None, uses caller file.
        cfg: Configuration for directory naming.

    Returns:
        Path object for the figure directory (created if needed).
    """
    file_path = file if file is not None else caller_file()
    file_path = Path(file_path).resolve()
    root = _repo_root()
    cfg = _resolve_cfg(cfg)

    benchmark_roots = [root / "benchmark", root / "tests" / "benchmark"]
    for bench_root in benchmark_roots:
        if _is_relative_to(file_path, bench_root):
            rel = file_path.relative_to(bench_root)
            path = root / cfg.root_dir / cfg.benchmark_dir / rel.with_suffix("")
            path.mkdir(parents=True, exist_ok=True)
            return path

    tests_root = root / "tests"
    if _is_relative_to(file_path, tests_root):
        rel = file_path.relative_to(tests_root)
        path = root / cfg.root_dir / cfg.tests_dir / rel.with_suffix("")
        path.mkdir(parents=True, exist_ok=True)
        return path

    if _is_relative_to(file_path, root):
        rel = file_path.relative_to(root)
    else:
        rel = Path(file_path.name)
    path = root / cfg.root_dir / cfg.other_dir / rel.with_suffix("")
    path.mkdir(parents=True, exist_ok=True)
    return path

save_fig(fig, name=None, path=None, *, file=None, cfg=None, suffix='pdf', transparent=False)

Save matplotlib figure to appropriate directory.

Parameters:

Name Type Description Default
fig Figure

Matplotlib figure object.

required
name str | None

Output filename (without extension). If None, uses caller stem.

None
path Path | None

Output directory. If None, uses fig_path().

None
file str | Path | None

File path for context resolution. If None, uses caller file.

None
cfg FigPathConfig | dict | None

Configuration for directory naming.

None
suffix str

File extension (default: "pdf").

'pdf'
transparent bool

Save with transparent background.

False

Returns:

Type Description
Path

Path to the saved figure file.

Source code in btorch/utils/file.py
def save_fig(
    fig: matplotlib.figure.Figure,
    name: str | None = None,
    path: Path | None = None,
    *,
    file: str | Path | None = None,
    cfg: FigPathConfig | dict | None = None,
    suffix: str = "pdf",
    transparent: bool = False,
) -> Path:
    """Save matplotlib figure to appropriate directory.

    Args:
        fig: Matplotlib figure object.
        name: Output filename (without extension). If None, uses caller stem.
        path: Output directory. If None, uses ``fig_path()``.
        file: File path for context resolution. If None, uses caller file.
        cfg: Configuration for directory naming.
        suffix: File extension (default: "pdf").
        transparent: Save with transparent background.

    Returns:
        Path to the saved figure file.
    """
    file_path = Path(file) if file is not None else Path(caller_file())
    if path is None:
        path = fig_path(file_path, cfg=cfg)
    if name is None:
        name = file_path.stem
    path.mkdir(parents=True, exist_ok=True)
    output_path = path / f"{name}.{suffix}"
    fig.savefig(output_path.as_posix(), transparent=transparent)
    return output_path

btorch.utils.grad_checkpoint

Functions

checkpoint_wrapper(module, checkpoint_fn=None, **checkpoint_fn_kwargs)

Source code in btorch/utils/grad_checkpoint/checkpoint.py
def checkpoint_wrapper(
    module: torch.nn.Module,
    checkpoint_fn=None,
    **checkpoint_fn_kwargs,
) -> torch.nn.Module:
    return CheckpointWrapper(
        module,
        checkpoint_fn,
        **checkpoint_fn_kwargs,
    )

btorch.utils.hdf5_utils

HDF5 serialization utilities.

Helpers for saving and loading nested dictionaries containing arrays to HDF5 files with optional Blosc2 compression for large arrays.

Functions

load_dict_from_hdf5(folder_or_filename, filename=None)

Load nested dictionary from HDF5 file.

Parameters:

Name Type Description Default
folder_or_filename str | Path

Directory path if filename is provided, otherwise full file path.

required
filename Optional[str]

Optional filename when folder_or_filename is a directory.

None

Returns:

Type Description
dict

Nested dictionary with restored array values.

Source code in btorch/utils/hdf5_utils.py
def load_dict_from_hdf5(
    folder_or_filename: "str | Path",
    filename: Optional[str] = None,
) -> dict:
    """Load nested dictionary from HDF5 file.

    Args:
        folder_or_filename: Directory path if ``filename`` is provided,
            otherwise full file path.
        filename: Optional filename when ``folder_or_filename`` is a directory.

    Returns:
        Nested dictionary with restored array values.
    """
    file = (
        folder_or_filename
        if filename is None
        else os.path.join(folder_or_filename, filename)
    )

    def load_group(h5file, path):
        data = {}
        for k, v in h5file[path].items():
            if isinstance(v, h5py.Group):
                data[k] = load_group(h5file, f"{path}/{k}")
            else:
                if v.shape == ():
                    data[k] = v[()]  # Load scalar value
                else:
                    data[k] = v[:]  # Load array value
        return data

    with h5py.File(file, "r") as f:
        return load_group(f, "/")

save_dict_to_hdf5(folder_or_filename, data, compression=hdf5plugin.Blosc2(), filename=None, compression_threshold=1024 * 1024)

Save nested dictionary with array values to HDF5 file.

Recursively traverses data and saves arrays as datasets. Datasets larger than compression_threshold are compressed with the specified compression filter.

Parameters:

Name Type Description Default
folder_or_filename str | Path

Directory path if filename is provided, otherwise full file path.

required
data dict

Nested dictionary with array-like values to serialize.

required
compression Any

Compression filter (default: Blosc2).

Blosc2()
filename Optional[str]

Optional filename when folder_or_filename is a directory.

None
compression_threshold int

Minimum array size in bytes to trigger compression (default: 1 MiB).

1024 * 1024
Source code in btorch/utils/hdf5_utils.py
def save_dict_to_hdf5(
    folder_or_filename: "str | Path",
    data: dict,
    compression: Any = hdf5plugin.Blosc2(),
    filename: Optional[str] = None,
    compression_threshold: int = 1024 * 1024,  # 1MiB
) -> None:
    """Save nested dictionary with array values to HDF5 file.

    Recursively traverses ``data`` and saves arrays as datasets.
    Datasets larger than ``compression_threshold`` are compressed
    with the specified compression filter.

    Args:
        folder_or_filename: Directory path if ``filename`` is provided,
            otherwise full file path.
        data: Nested dictionary with array-like values to serialize.
        compression: Compression filter (default: Blosc2).
        filename: Optional filename when ``folder_or_filename`` is a directory.
        compression_threshold: Minimum array size in bytes to trigger
            compression (default: 1 MiB).
    """

    def save_array(h5file, path_k, v):
        if v.nbytes > compression_threshold:
            h5file.create_dataset(path_k, data=v, compression=compression)
        else:
            h5file.create_dataset(path_k, data=v)

    def save_group(h5file, path, data):
        for k, v in data.items():
            if v is None:
                continue
            elif isinstance(v, dict):
                h5file.create_group(f"{path}/{k}")
                save_group(h5file, f"{path}/{k}", v)
            elif hasattr(v, "shape") and hasattr(v, "dtype"):
                save_array(h5file, f"{path}/{k}", v)
            else:
                h5file.create_dataset(f"{path}/{k}", data=v)

    file = (
        folder_or_filename
        if filename is None
        else os.path.join(folder_or_filename, filename)
    )
    with h5py.File(file, "w") as f:
        save_group(f, "", data)

btorch.utils.hex

Hexagonal grid utilities (Red Blob Games algorithms).

https://www.redblobgames.com/grids/hexagons/ Primary: axial (q, r) with s = -q-r implicit.

Provides both functional API (for performance) and object-oriented struct-of-arrays types (for convenience).

Code adapted from flyvis (MIT License) and Hexy (MIT License).

Attributes

DIAGONALS = np.array([[2, -1], [1, -2], [-1, -1], [-2, 1], [-1, 2], [1, 1]]) module-attribute

DIRECTIONS = np.array([[1, 0], [1, -1], [0, -1], [-1, 0], [-1, 1], [0, 1]]) module-attribute

Orientation = Literal['pointy', 'flat'] module-attribute

__all__ = ['ring', 'disk', 'spiral', 'rectangle', 'disk_count', 'disk_radius', 'cube_from_axial', 'axial_from_cube', 'to_pixel', 'from_pixel', 'round_axial', 'rotate', 'reflect', 'Orientation', 'distance', 'radius', 'within_range', 'mask', 'DIRECTIONS', 'DIAGONALS', 'neighbor', 'neighbors', 'diagonal_neighbor', 'diagonal_neighbors', 'all_neighbors', 'align', 'permute', 'reflect_index', 'axial_to_rect_index', 'rect_index_to_axial', 'axial_to_hex_index', 'hex_index_to_axial', 'axial_to_triangle_index', 'triangle_index_to_axial', 'axial_to_rhombus_index', 'rhombus_index_to_axial', 'line', 'line_n', 'axial_to_odd_r', 'odd_r_to_axial', 'axial_to_even_r', 'even_r_to_axial', 'axial_to_odd_q', 'odd_q_to_axial', 'axial_to_even_q', 'even_q_to_axial', 'axial_to_zigzag', 'flywire_xy_to_pixel', 'flywire_to_pixel', 'zigzag_to_pixel', 'zigzag_to_axial', 'axial_to_doublewidth', 'doublewidth_to_axial', 'axial_to_doubleheight', 'doubleheight_to_axial', 'doublewidth_distance', 'doubleheight_distance', 'doublewidth_to_pixel', 'doubleheight_to_pixel', 'pixel_to_doublewidth', 'pixel_to_doubleheight', 'range_intersection', 'range_union', 'ranges_intersect', 'resolve_hex', 'hex_symbol_for', 'HexCoords', 'HexData', 'HexGrid'] module-attribute

Classes

HexCoords dataclass

Struct-of-arrays for hex coordinates (q, r).

This is the coordinate-only type. Use HexData for coords + values. All methods delegate to functional API for consistency.

Parameters:

Name Type Description Default
q ndarray

Axial q coordinates, shape (n_hexes,)

required
r ndarray

Axial r coordinates, shape (n_hexes,)

required
Example

coords = HexCoords.from_disk(radius=3) coords_q, coords_r = coords.q, coords.r px, py = coords.to_pixel() neighbors = coords.neighbors() # HexCoords with 6x coords

Source code in btorch/utils/hex/data.py
@dataclass
class HexCoords:
    """Struct-of-arrays for hex coordinates (q, r).

    This is the coordinate-only type. Use HexData for coords + values.
    All methods delegate to functional API for consistency.

    Args:
        q: Axial q coordinates, shape (n_hexes,)
        r: Axial r coordinates, shape (n_hexes,)

    Example:
        >>> coords = HexCoords.from_disk(radius=3)
        >>> coords_q, coords_r = coords.q, coords.r
        >>> px, py = coords.to_pixel()
        >>> neighbors = coords.neighbors()  # HexCoords with 6x coords
    """

    q: np.ndarray
    r: np.ndarray

    def __post_init__(self) -> None:
        """Validate shapes match."""
        if len(self.q) != len(self.r):
            raise ValueError(
                f"q and r must have same length, got {len(self.q)} and {len(self.r)}"
            )

    def __len__(self) -> int:
        return len(self.q)

    def __eq__(self, other: object) -> bool:
        """Check equality of all coordinates."""
        if not isinstance(other, HexCoords):
            return NotImplemented
        return bool(np.array_equal(self.q, other.q) and np.array_equal(self.r, other.r))

    def is_equal_elementwise(self, other: HexCoords) -> np.ndarray:
        """Boolean mask of per-element matching coordinates."""
        return (self.q == other.q) & (self.r == other.r)

    def __iter__(self) -> Iterator[tuple[int, int]]:
        """Iterate over (q, r) pairs."""
        return iter(zip(self.q, self.r))

    # ---- Construction methods ----

    @classmethod
    def from_disk(cls, radius: int, center_q: int = 0, center_r: int = 0) -> HexCoords:
        """Create from disk of given radius."""
        from .coords import disk

        q, r = disk(radius, center_q, center_r)
        return cls(q, r)

    @classmethod
    def from_ring(cls, radius: int, center_q: int = 0, center_r: int = 0) -> HexCoords:
        """Create from ring of given radius."""
        from .coords import ring

        q, r = ring(radius, center_q, center_r)
        return cls(q, r)

    @classmethod
    def from_spiral(
        cls, radius: int, center_q: int = 0, center_r: int = 0
    ) -> HexCoords:
        """Create in spiral order (center, ring1, ring2...)."""
        from .coords import spiral

        q, r = spiral(radius, center_q, center_r)
        return cls(q, r)

    @classmethod
    def from_zigzag(cls, x: np.ndarray, y: np.ndarray) -> HexCoords:
        """Create from zigzag (x, y) coordinates."""
        from .offset import zigzag_to_axial

        q, r = zigzag_to_axial(x, y)
        return cls(q, r)

    @classmethod
    def from_odd_r(cls, col: np.ndarray, row: np.ndarray) -> HexCoords:
        """Create from odd-r offset coordinates."""
        from .offset import odd_r_to_axial

        q, r = odd_r_to_axial(col, row)
        return cls(q, r)

    @classmethod
    def from_even_r(cls, col: np.ndarray, row: np.ndarray) -> HexCoords:
        """Create from even-r offset coordinates."""
        from .offset import even_r_to_axial

        q, r = even_r_to_axial(col, row)
        return cls(q, r)

    @classmethod
    def from_odd_q(cls, col: np.ndarray, row: np.ndarray) -> HexCoords:
        """Create from odd-q offset coordinates."""
        from .offset import odd_q_to_axial

        q, r = odd_q_to_axial(col, row)
        return cls(q, r)

    @classmethod
    def from_even_q(cls, col: np.ndarray, row: np.ndarray) -> HexCoords:
        """Create from even-q offset coordinates."""
        from .offset import even_q_to_axial

        q, r = even_q_to_axial(col, row)
        return cls(q, r)

    @classmethod
    def from_doublewidth(cls, col: np.ndarray, row: np.ndarray) -> HexCoords:
        """Create from double-width coordinates."""
        from .doubled import doublewidth_to_axial

        q, r = doublewidth_to_axial(col, row)
        return cls(q, r)

    @classmethod
    def from_doubleheight(cls, col: np.ndarray, row: np.ndarray) -> HexCoords:
        """Create from double-height coordinates."""
        from .doubled import doubleheight_to_axial

        q, r = doubleheight_to_axial(col, row)
        return cls(q, r)

    @classmethod
    def from_cube(cls, q: np.ndarray, r: np.ndarray, s: np.ndarray) -> HexCoords:
        """Create from cube coordinates (validates q+r+s=0)."""
        q, r, s = np.asarray(q), np.asarray(r), np.asarray(s)
        if not np.allclose(q + r + s, 0):
            raise ValueError("Cube coordinates must satisfy q + r + s = 0")
        return cls(q, r)

    @classmethod
    def from_pixel(
        cls,
        x: np.ndarray,
        y: np.ndarray,
        size: float = 1.0,
        orientation: str = "pointy",
    ) -> HexCoords:
        """Create from pixel coordinates (rounds to nearest hex)."""
        from .transform import from_pixel, round_axial

        fq, fr = from_pixel(x, y, size=size, orientation=orientation)
        q, r = round_axial(fq, fr)
        return cls(q, r)

    # ---- Coordinate transforms ----

    def to_pixel(
        self, size: float = 1.0, orientation: str = "pointy"
    ) -> tuple[np.ndarray, np.ndarray]:
        """Convert to pixel coordinates."""
        from .transform import to_pixel

        return to_pixel(self.q, self.r, size, orientation)

    def to_zigzag(self) -> tuple[np.ndarray, np.ndarray]:
        """Convert to zigzag (x, y) coordinates."""
        from .offset import axial_to_zigzag

        return axial_to_zigzag(self.q, self.r)

    def to_odd_r(self) -> tuple[np.ndarray, np.ndarray]:
        """Convert to odd-r offset coordinates (col, row)."""
        from .offset import axial_to_odd_r

        return axial_to_odd_r(self.q, self.r)

    def to_even_r(self) -> tuple[np.ndarray, np.ndarray]:
        """Convert to even-r offset coordinates (col, row)."""
        from .offset import axial_to_even_r

        return axial_to_even_r(self.q, self.r)

    def to_odd_q(self) -> tuple[np.ndarray, np.ndarray]:
        """Convert to odd-q offset coordinates (col, row)."""
        from .offset import axial_to_odd_q

        return axial_to_odd_q(self.q, self.r)

    def to_even_q(self) -> tuple[np.ndarray, np.ndarray]:
        """Convert to even-q offset coordinates (col, row)."""
        from .offset import axial_to_even_q

        return axial_to_even_q(self.q, self.r)

    def to_doublewidth(self) -> tuple[np.ndarray, np.ndarray]:
        """Convert to double-width coordinates (col, row)."""
        from .doubled import axial_to_doublewidth

        return axial_to_doublewidth(self.q, self.r)

    def to_doubleheight(self) -> tuple[np.ndarray, np.ndarray]:
        """Convert to double-height coordinates (col, row)."""
        from .doubled import axial_to_doubleheight

        return axial_to_doubleheight(self.q, self.r)

    def to_cube(self) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
        """Convert to cube coordinates (q, r, s) where s = -q - r."""
        from .transform import cube_from_axial

        return cube_from_axial(self.q, self.r)

    # ---- Geometric operations ----

    def distance(self, other: HexCoords | None = None) -> np.ndarray | int:
        """Distance to other coords, or from origin if other is None."""
        from .distance import distance, radius

        if other is None:
            return radius(self.q, self.r)
        return distance(self.q, self.r, other.q, other.r)

    @property
    def extent(self) -> int:
        """Maximum distance from origin."""
        return int(np.max(self.distance()))

    def neighbors(self) -> HexCoords:
        """Get 6 neighbors for each coordinate.

        Returns HexCoords with shape (6 * n_hexes,).
        """
        from .neighbor import neighbors

        qn, rn = neighbors(self.q, self.r)
        # Flatten to (6 * n,)
        return HexCoords(qn.flatten(), rn.flatten())

    def rotate(self, n: int) -> HexCoords:
        """Rotate by n * 60 degrees."""
        from .transform import rotate

        q, r = rotate(self.q, self.r, n)
        return HexCoords(q, r)

    def reflect(self, axis: str) -> HexCoords:
        """Reflect across axis ('q', 'r', or 's')."""
        from .transform import reflect

        q, r = reflect(self.q, self.r, axis)
        return HexCoords(q, r)

    def within_range(self, center: HexCoords, n: int) -> np.ndarray:
        """Boolean mask for coords within n steps of center."""
        from .distance import within_range

        return within_range(self.q, self.r, int(center.q[0]), int(center.r[0]), n)

    # ---- Masking and filtering ----

    def mask(self, condition: np.ndarray) -> HexCoords:
        """Filter by boolean mask."""
        return HexCoords(self.q[condition], self.r[condition])

    def sort(self) -> HexCoords:
        """Sort by q then r."""
        idx = np.lexsort((self.r, self.q))
        return HexCoords(self.q[idx], self.r[idx])
Attributes
extent property

Maximum distance from origin.

Functions
__eq__(other)

Check equality of all coordinates.

Source code in btorch/utils/hex/data.py
def __eq__(self, other: object) -> bool:
    """Check equality of all coordinates."""
    if not isinstance(other, HexCoords):
        return NotImplemented
    return bool(np.array_equal(self.q, other.q) and np.array_equal(self.r, other.r))
__iter__()

Iterate over (q, r) pairs.

Source code in btorch/utils/hex/data.py
def __iter__(self) -> Iterator[tuple[int, int]]:
    """Iterate over (q, r) pairs."""
    return iter(zip(self.q, self.r))
__post_init__()

Validate shapes match.

Source code in btorch/utils/hex/data.py
def __post_init__(self) -> None:
    """Validate shapes match."""
    if len(self.q) != len(self.r):
        raise ValueError(
            f"q and r must have same length, got {len(self.q)} and {len(self.r)}"
        )
distance(other=None)

Distance to other coords, or from origin if other is None.

Source code in btorch/utils/hex/data.py
def distance(self, other: HexCoords | None = None) -> np.ndarray | int:
    """Distance to other coords, or from origin if other is None."""
    from .distance import distance, radius

    if other is None:
        return radius(self.q, self.r)
    return distance(self.q, self.r, other.q, other.r)
from_cube(q, r, s) classmethod

Create from cube coordinates (validates q+r+s=0).

Source code in btorch/utils/hex/data.py
@classmethod
def from_cube(cls, q: np.ndarray, r: np.ndarray, s: np.ndarray) -> HexCoords:
    """Create from cube coordinates (validates q+r+s=0)."""
    q, r, s = np.asarray(q), np.asarray(r), np.asarray(s)
    if not np.allclose(q + r + s, 0):
        raise ValueError("Cube coordinates must satisfy q + r + s = 0")
    return cls(q, r)
from_disk(radius, center_q=0, center_r=0) classmethod

Create from disk of given radius.

Source code in btorch/utils/hex/data.py
@classmethod
def from_disk(cls, radius: int, center_q: int = 0, center_r: int = 0) -> HexCoords:
    """Create from disk of given radius."""
    from .coords import disk

    q, r = disk(radius, center_q, center_r)
    return cls(q, r)
from_doubleheight(col, row) classmethod

Create from double-height coordinates.

Source code in btorch/utils/hex/data.py
@classmethod
def from_doubleheight(cls, col: np.ndarray, row: np.ndarray) -> HexCoords:
    """Create from double-height coordinates."""
    from .doubled import doubleheight_to_axial

    q, r = doubleheight_to_axial(col, row)
    return cls(q, r)
from_doublewidth(col, row) classmethod

Create from double-width coordinates.

Source code in btorch/utils/hex/data.py
@classmethod
def from_doublewidth(cls, col: np.ndarray, row: np.ndarray) -> HexCoords:
    """Create from double-width coordinates."""
    from .doubled import doublewidth_to_axial

    q, r = doublewidth_to_axial(col, row)
    return cls(q, r)
from_even_q(col, row) classmethod

Create from even-q offset coordinates.

Source code in btorch/utils/hex/data.py
@classmethod
def from_even_q(cls, col: np.ndarray, row: np.ndarray) -> HexCoords:
    """Create from even-q offset coordinates."""
    from .offset import even_q_to_axial

    q, r = even_q_to_axial(col, row)
    return cls(q, r)
from_even_r(col, row) classmethod

Create from even-r offset coordinates.

Source code in btorch/utils/hex/data.py
@classmethod
def from_even_r(cls, col: np.ndarray, row: np.ndarray) -> HexCoords:
    """Create from even-r offset coordinates."""
    from .offset import even_r_to_axial

    q, r = even_r_to_axial(col, row)
    return cls(q, r)
from_odd_q(col, row) classmethod

Create from odd-q offset coordinates.

Source code in btorch/utils/hex/data.py
@classmethod
def from_odd_q(cls, col: np.ndarray, row: np.ndarray) -> HexCoords:
    """Create from odd-q offset coordinates."""
    from .offset import odd_q_to_axial

    q, r = odd_q_to_axial(col, row)
    return cls(q, r)
from_odd_r(col, row) classmethod

Create from odd-r offset coordinates.

Source code in btorch/utils/hex/data.py
@classmethod
def from_odd_r(cls, col: np.ndarray, row: np.ndarray) -> HexCoords:
    """Create from odd-r offset coordinates."""
    from .offset import odd_r_to_axial

    q, r = odd_r_to_axial(col, row)
    return cls(q, r)
from_pixel(x, y, size=1.0, orientation='pointy') classmethod

Create from pixel coordinates (rounds to nearest hex).

Source code in btorch/utils/hex/data.py
@classmethod
def from_pixel(
    cls,
    x: np.ndarray,
    y: np.ndarray,
    size: float = 1.0,
    orientation: str = "pointy",
) -> HexCoords:
    """Create from pixel coordinates (rounds to nearest hex)."""
    from .transform import from_pixel, round_axial

    fq, fr = from_pixel(x, y, size=size, orientation=orientation)
    q, r = round_axial(fq, fr)
    return cls(q, r)
from_ring(radius, center_q=0, center_r=0) classmethod

Create from ring of given radius.

Source code in btorch/utils/hex/data.py
@classmethod
def from_ring(cls, radius: int, center_q: int = 0, center_r: int = 0) -> HexCoords:
    """Create from ring of given radius."""
    from .coords import ring

    q, r = ring(radius, center_q, center_r)
    return cls(q, r)
from_spiral(radius, center_q=0, center_r=0) classmethod

Create in spiral order (center, ring1, ring2...).

Source code in btorch/utils/hex/data.py
@classmethod
def from_spiral(
    cls, radius: int, center_q: int = 0, center_r: int = 0
) -> HexCoords:
    """Create in spiral order (center, ring1, ring2...)."""
    from .coords import spiral

    q, r = spiral(radius, center_q, center_r)
    return cls(q, r)
from_zigzag(x, y) classmethod

Create from zigzag (x, y) coordinates.

Source code in btorch/utils/hex/data.py
@classmethod
def from_zigzag(cls, x: np.ndarray, y: np.ndarray) -> HexCoords:
    """Create from zigzag (x, y) coordinates."""
    from .offset import zigzag_to_axial

    q, r = zigzag_to_axial(x, y)
    return cls(q, r)
is_equal_elementwise(other)

Boolean mask of per-element matching coordinates.

Source code in btorch/utils/hex/data.py
def is_equal_elementwise(self, other: HexCoords) -> np.ndarray:
    """Boolean mask of per-element matching coordinates."""
    return (self.q == other.q) & (self.r == other.r)
mask(condition)

Filter by boolean mask.

Source code in btorch/utils/hex/data.py
def mask(self, condition: np.ndarray) -> HexCoords:
    """Filter by boolean mask."""
    return HexCoords(self.q[condition], self.r[condition])
neighbors()

Get 6 neighbors for each coordinate.

Returns HexCoords with shape (6 * n_hexes,).

Source code in btorch/utils/hex/data.py
def neighbors(self) -> HexCoords:
    """Get 6 neighbors for each coordinate.

    Returns HexCoords with shape (6 * n_hexes,).
    """
    from .neighbor import neighbors

    qn, rn = neighbors(self.q, self.r)
    # Flatten to (6 * n,)
    return HexCoords(qn.flatten(), rn.flatten())
reflect(axis)

Reflect across axis ('q', 'r', or 's').

Source code in btorch/utils/hex/data.py
def reflect(self, axis: str) -> HexCoords:
    """Reflect across axis ('q', 'r', or 's')."""
    from .transform import reflect

    q, r = reflect(self.q, self.r, axis)
    return HexCoords(q, r)
rotate(n)

Rotate by n * 60 degrees.

Source code in btorch/utils/hex/data.py
def rotate(self, n: int) -> HexCoords:
    """Rotate by n * 60 degrees."""
    from .transform import rotate

    q, r = rotate(self.q, self.r, n)
    return HexCoords(q, r)
sort()

Sort by q then r.

Source code in btorch/utils/hex/data.py
def sort(self) -> HexCoords:
    """Sort by q then r."""
    idx = np.lexsort((self.r, self.q))
    return HexCoords(self.q[idx], self.r[idx])
to_cube()

Convert to cube coordinates (q, r, s) where s = -q - r.

Source code in btorch/utils/hex/data.py
def to_cube(self) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
    """Convert to cube coordinates (q, r, s) where s = -q - r."""
    from .transform import cube_from_axial

    return cube_from_axial(self.q, self.r)
to_doubleheight()

Convert to double-height coordinates (col, row).

Source code in btorch/utils/hex/data.py
def to_doubleheight(self) -> tuple[np.ndarray, np.ndarray]:
    """Convert to double-height coordinates (col, row)."""
    from .doubled import axial_to_doubleheight

    return axial_to_doubleheight(self.q, self.r)
to_doublewidth()

Convert to double-width coordinates (col, row).

Source code in btorch/utils/hex/data.py
def to_doublewidth(self) -> tuple[np.ndarray, np.ndarray]:
    """Convert to double-width coordinates (col, row)."""
    from .doubled import axial_to_doublewidth

    return axial_to_doublewidth(self.q, self.r)
to_even_q()

Convert to even-q offset coordinates (col, row).

Source code in btorch/utils/hex/data.py
def to_even_q(self) -> tuple[np.ndarray, np.ndarray]:
    """Convert to even-q offset coordinates (col, row)."""
    from .offset import axial_to_even_q

    return axial_to_even_q(self.q, self.r)
to_even_r()

Convert to even-r offset coordinates (col, row).

Source code in btorch/utils/hex/data.py
def to_even_r(self) -> tuple[np.ndarray, np.ndarray]:
    """Convert to even-r offset coordinates (col, row)."""
    from .offset import axial_to_even_r

    return axial_to_even_r(self.q, self.r)
to_odd_q()

Convert to odd-q offset coordinates (col, row).

Source code in btorch/utils/hex/data.py
def to_odd_q(self) -> tuple[np.ndarray, np.ndarray]:
    """Convert to odd-q offset coordinates (col, row)."""
    from .offset import axial_to_odd_q

    return axial_to_odd_q(self.q, self.r)
to_odd_r()

Convert to odd-r offset coordinates (col, row).

Source code in btorch/utils/hex/data.py
def to_odd_r(self) -> tuple[np.ndarray, np.ndarray]:
    """Convert to odd-r offset coordinates (col, row)."""
    from .offset import axial_to_odd_r

    return axial_to_odd_r(self.q, self.r)
to_pixel(size=1.0, orientation='pointy')

Convert to pixel coordinates.

Source code in btorch/utils/hex/data.py
def to_pixel(
    self, size: float = 1.0, orientation: str = "pointy"
) -> tuple[np.ndarray, np.ndarray]:
    """Convert to pixel coordinates."""
    from .transform import to_pixel

    return to_pixel(self.q, self.r, size, orientation)
to_zigzag()

Convert to zigzag (x, y) coordinates.

Source code in btorch/utils/hex/data.py
def to_zigzag(self) -> tuple[np.ndarray, np.ndarray]:
    """Convert to zigzag (x, y) coordinates."""
    from .offset import axial_to_zigzag

    return axial_to_zigzag(self.q, self.r)
within_range(center, n)

Boolean mask for coords within n steps of center.

Source code in btorch/utils/hex/data.py
def within_range(self, center: HexCoords, n: int) -> np.ndarray:
    """Boolean mask for coords within n steps of center."""
    from .distance import within_range

    return within_range(self.q, self.r, int(center.q[0]), int(center.r[0]), n)

HexData dataclass

Struct-of-arrays for hex coordinates with associated values.

This is the primary user-facing type for hex grid data. Separates coordinates (coords) from data (values).

Parameters:

Name Type Description Default
coords HexCoords

HexCoords instance

required
values ndarray

Data values, shape (n_hexes,) or (n_hexes, n_features)

required
Example

coords = HexCoords.from_disk(radius=3) data = HexData(coords, np.random.randn(len(coords))) data_q = data.q # Access coords data_vals = data.values # Access values

Source code in btorch/utils/hex/data.py
@dataclass
class HexData:
    """Struct-of-arrays for hex coordinates with associated values.

    This is the primary user-facing type for hex grid data.
    Separates coordinates (coords) from data (values).

    Args:
        coords: HexCoords instance
        values: Data values, shape (n_hexes,) or (n_hexes, n_features)

    Example:
        >>> coords = HexCoords.from_disk(radius=3)
        >>> data = HexData(coords, np.random.randn(len(coords)))
        >>> data_q = data.q  # Access coords
        >>> data_vals = data.values  # Access values
    """

    coords: HexCoords
    values: np.ndarray

    def __post_init__(self) -> None:
        """Validate values shape matches coords."""
        if len(self.values) != len(self.coords):
            raise ValueError(
                f"values length {len(self.values)} must match "
                f"coords length {len(self.coords)}"
            )

    def __len__(self) -> int:
        return len(self.coords)

    @property
    def q(self) -> np.ndarray:
        return self.coords.q

    @property
    def r(self) -> np.ndarray:
        return self.coords.r

    # ---- Construction ----

    @classmethod
    def from_arrays(cls, q: np.ndarray, r: np.ndarray, values: np.ndarray) -> HexData:
        """Construct from separate arrays."""
        return cls(HexCoords(q, r), values)

    # ---- Coordinate operations (delegated to coords) ----

    def to_pixel(
        self, size: float = 1.0, orientation: str = "pointy"
    ) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
        """Convert to pixel coordinates with values."""
        x, y = self.coords.to_pixel(size, orientation)
        return x, y, self.values

    def rotate(self, n: int) -> HexData:
        """Rotate coordinates, preserving values."""
        return HexData(self.coords.rotate(n), self.values)

    def reflect(self, axis: str) -> HexData:
        """Reflect coordinates, preserving values."""
        return HexData(self.coords.reflect(axis), self.values)

    # ---- Value operations ----

    def mask(self, condition: np.ndarray) -> HexData:
        """Filter by boolean mask."""
        return HexData(self.coords.mask(condition), self.values[condition])

    def where_value(self, value: float, rtol: float = 0, atol: float = 0) -> np.ndarray:
        """Boolean mask where values match (supports np.nan)."""
        return np.isclose(self.values, value, rtol=rtol, atol=atol, equal_nan=True)

    def fill(self, value: float) -> HexData:
        """Return new HexData with all values set to value."""
        return HexData(self.coords, np.full_like(self.values, value))

    def sort(self) -> HexData:
        """Sort by coordinates."""
        idx = np.lexsort((self.coords.r, self.coords.q))
        return HexData(
            HexCoords(self.coords.q[idx], self.coords.r[idx]), self.values[idx]
        )
Functions
__post_init__()

Validate values shape matches coords.

Source code in btorch/utils/hex/data.py
def __post_init__(self) -> None:
    """Validate values shape matches coords."""
    if len(self.values) != len(self.coords):
        raise ValueError(
            f"values length {len(self.values)} must match "
            f"coords length {len(self.coords)}"
        )
fill(value)

Return new HexData with all values set to value.

Source code in btorch/utils/hex/data.py
def fill(self, value: float) -> HexData:
    """Return new HexData with all values set to value."""
    return HexData(self.coords, np.full_like(self.values, value))
from_arrays(q, r, values) classmethod

Construct from separate arrays.

Source code in btorch/utils/hex/data.py
@classmethod
def from_arrays(cls, q: np.ndarray, r: np.ndarray, values: np.ndarray) -> HexData:
    """Construct from separate arrays."""
    return cls(HexCoords(q, r), values)
mask(condition)

Filter by boolean mask.

Source code in btorch/utils/hex/data.py
def mask(self, condition: np.ndarray) -> HexData:
    """Filter by boolean mask."""
    return HexData(self.coords.mask(condition), self.values[condition])
reflect(axis)

Reflect coordinates, preserving values.

Source code in btorch/utils/hex/data.py
def reflect(self, axis: str) -> HexData:
    """Reflect coordinates, preserving values."""
    return HexData(self.coords.reflect(axis), self.values)
rotate(n)

Rotate coordinates, preserving values.

Source code in btorch/utils/hex/data.py
def rotate(self, n: int) -> HexData:
    """Rotate coordinates, preserving values."""
    return HexData(self.coords.rotate(n), self.values)
sort()

Sort by coordinates.

Source code in btorch/utils/hex/data.py
def sort(self) -> HexData:
    """Sort by coordinates."""
    idx = np.lexsort((self.coords.r, self.coords.q))
    return HexData(
        HexCoords(self.coords.q[idx], self.coords.r[idx]), self.values[idx]
    )
to_pixel(size=1.0, orientation='pointy')

Convert to pixel coordinates with values.

Source code in btorch/utils/hex/data.py
def to_pixel(
    self, size: float = 1.0, orientation: str = "pointy"
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
    """Convert to pixel coordinates with values."""
    x, y = self.coords.to_pixel(size, orientation)
    return x, y, self.values
where_value(value, rtol=0, atol=0)

Boolean mask where values match (supports np.nan).

Source code in btorch/utils/hex/data.py
def where_value(self, value: float, rtol: float = 0, atol: float = 0) -> np.ndarray:
    """Boolean mask where values match (supports np.nan)."""
    return np.isclose(self.values, value, rtol=rtol, atol=atol, equal_nan=True)

HexGrid

Regular hexagonal grid with extent.

This is a specialized HexData for regular hexagonal grids where coordinates form a complete disk of given radius.

Parameters:

Name Type Description Default
radius int

Grid radius (extent)

required
values ndarray | None

Optional initial values

None
center_q int

Q-coordinate of the center hex

0
center_r int

R-coordinate of the center hex

0
Example

grid = HexGrid(radius=5) grid.circle(radius=3) # Get circle of coords grid.hull # Outer ring

Source code in btorch/utils/hex/data.py
class HexGrid:
    """Regular hexagonal grid with extent.

    This is a specialized HexData for regular hexagonal grids where
    coordinates form a complete disk of given radius.

    Args:
        radius: Grid radius (extent)
        values: Optional initial values
        center_q: Q-coordinate of the center hex
        center_r: R-coordinate of the center hex

    Example:
        >>> grid = HexGrid(radius=5)
        >>> grid.circle(radius=3)  # Get circle of coords
        >>> grid.hull  # Outer ring
    """

    def __init__(
        self,
        radius: int,
        values: np.ndarray | None = None,
        center_q: int = 0,
        center_r: int = 0,
    ):
        self.radius = radius
        self.center = HexCoords(np.array([center_q]), np.array([center_r]))

        # Generate coordinates
        from .coords import disk

        q, r = disk(radius, center_q, center_r)
        coords = HexCoords(q, r)

        # Initialize values
        if values is None:
            values = np.full(len(coords), np.nan)
        elif len(values) != len(coords):
            raise ValueError(f"values length must match grid size {len(coords)}")

        self._data = HexData(coords, values)

    def __len__(self) -> int:
        return len(self._data)

    @property
    def q(self) -> np.ndarray:
        return self._data.q

    @property
    def r(self) -> np.ndarray:
        return self._data.r

    @property
    def values(self) -> np.ndarray:
        return self._data.values

    @values.setter
    def values(self, v: np.ndarray):
        self._data.values = v

    @property
    def coords(self) -> HexCoords:
        return self._data.coords

    @property
    def data(self) -> HexData:
        """Access as HexData."""
        return self._data

    @property
    def extent(self) -> int:
        """Maximum distance from center."""
        from .distance import distance

        distances = distance(self.q, self.r, self.center.q[0], self.center.r[0])
        return int(np.max(distances))

    @property
    def hull(self) -> HexData:
        """Outer ring of the grid."""
        return self.circle(radius=self.radius)

    def circle(self, radius: int | None = None) -> HexData:
        """Get circle of given radius from center.

        Returns HexData with values=1 on circle, others filtered out.
        """
        from .distance import distance

        r = radius if radius is not None else self.radius
        distances = distance(self.q, self.r, self.center.q[0], self.center.r[0])
        mask = distances == r
        values = np.where(mask, 1, np.nan)
        return HexData(self.coords.mask(mask), values[mask])

    def filled_circle(self, radius: int) -> HexData:
        """Get filled circle of given radius from center."""
        from .distance import distance

        distances = distance(self.q, self.r, self.center.q[0], self.center.r[0])
        mask = distances <= radius
        values = np.where(mask, 1, np.nan)
        return HexData(self.coords.mask(mask), values[mask])

    def line(self, angle: float) -> HexData:
        """Get line through center at given angle.

        Args:
            angle: Angle in radians

        Returns HexData with line coordinates and values=1.
        """
        from .coords import ring
        from .transform import round_axial

        # Find line span across the grid
        # Get distant hull points at target angle
        distant_q, distant_r = ring(2 * self.radius)
        distant_coords = HexCoords(distant_q, distant_r)

        # Calculate angles to find matching direction
        px, py = distant_coords.to_pixel()
        angles = np.arctan2(py, px)

        # Find closest to target angle (modulo pi for line)
        angle_diff = np.abs((angles - angle + np.pi) % np.pi - np.pi / 2)
        sorted_idx = np.argsort(angle_diff)

        # Get span points
        span_q = distant_q[sorted_idx[:2]]
        span_r = distant_r[sorted_idx[:2]]

        # Interpolate line between them
        from .distance import distance as hex_distance

        d = hex_distance(span_q[0:1], span_r[0:1], span_q[1:2], span_r[1:2])[0]
        line_q, line_r = [], []
        for i in range(int(d) + 1):
            t = i / d if d > 0 else 0
            q = span_q[0] + (span_q[1] - span_q[0]) * t
            r = span_r[0] + (span_r[1] - span_r[0]) * t
            # Round to nearest hex
            rq, rr = round_axial(np.array([q]), np.array([r]))
            line_q.append(rq[0])
            line_r.append(rr[0])

        line_coords = HexCoords(np.array(line_q), np.array(line_r))
        values = np.ones(len(line_coords))
        return HexData(line_coords, values)

    def valid_neighbors(self) -> tuple[tuple[int, ...], ...]:
        """Get valid neighbor indices for each hex in grid.

        Returns tuple of tuples, where each inner tuple contains indices
        of valid neighbors within the grid.
        """
        from .neighbor import neighbors

        qn, rn = neighbors(self.q, self.r)  # (6, n)
        coord_to_idx = {(int(self.q[i]), int(self.r[i])): i for i in range(len(self))}

        result = []
        for i in range(len(self)):
            neighbor_indices = []
            for j in range(6):
                key = (int(qn[j, i]), int(rn[j, i]))
                if key in coord_to_idx:
                    neighbor_indices.append(coord_to_idx[key])
            result.append(tuple(neighbor_indices))
        return tuple(result)

    def to_pixel(
        self, size: float = 1.0, orientation: str = "pointy"
    ) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
        """Convert to pixel coordinates with values."""
        return self._data.to_pixel(size, orientation)
Attributes
data property

Access as HexData.

extent property

Maximum distance from center.

hull property

Outer ring of the grid.

Functions
circle(radius=None)

Get circle of given radius from center.

Returns HexData with values=1 on circle, others filtered out.

Source code in btorch/utils/hex/data.py
def circle(self, radius: int | None = None) -> HexData:
    """Get circle of given radius from center.

    Returns HexData with values=1 on circle, others filtered out.
    """
    from .distance import distance

    r = radius if radius is not None else self.radius
    distances = distance(self.q, self.r, self.center.q[0], self.center.r[0])
    mask = distances == r
    values = np.where(mask, 1, np.nan)
    return HexData(self.coords.mask(mask), values[mask])
filled_circle(radius)

Get filled circle of given radius from center.

Source code in btorch/utils/hex/data.py
def filled_circle(self, radius: int) -> HexData:
    """Get filled circle of given radius from center."""
    from .distance import distance

    distances = distance(self.q, self.r, self.center.q[0], self.center.r[0])
    mask = distances <= radius
    values = np.where(mask, 1, np.nan)
    return HexData(self.coords.mask(mask), values[mask])
line(angle)

Get line through center at given angle.

Parameters:

Name Type Description Default
angle float

Angle in radians

required

Returns HexData with line coordinates and values=1.

Source code in btorch/utils/hex/data.py
def line(self, angle: float) -> HexData:
    """Get line through center at given angle.

    Args:
        angle: Angle in radians

    Returns HexData with line coordinates and values=1.
    """
    from .coords import ring
    from .transform import round_axial

    # Find line span across the grid
    # Get distant hull points at target angle
    distant_q, distant_r = ring(2 * self.radius)
    distant_coords = HexCoords(distant_q, distant_r)

    # Calculate angles to find matching direction
    px, py = distant_coords.to_pixel()
    angles = np.arctan2(py, px)

    # Find closest to target angle (modulo pi for line)
    angle_diff = np.abs((angles - angle + np.pi) % np.pi - np.pi / 2)
    sorted_idx = np.argsort(angle_diff)

    # Get span points
    span_q = distant_q[sorted_idx[:2]]
    span_r = distant_r[sorted_idx[:2]]

    # Interpolate line between them
    from .distance import distance as hex_distance

    d = hex_distance(span_q[0:1], span_r[0:1], span_q[1:2], span_r[1:2])[0]
    line_q, line_r = [], []
    for i in range(int(d) + 1):
        t = i / d if d > 0 else 0
        q = span_q[0] + (span_q[1] - span_q[0]) * t
        r = span_r[0] + (span_r[1] - span_r[0]) * t
        # Round to nearest hex
        rq, rr = round_axial(np.array([q]), np.array([r]))
        line_q.append(rq[0])
        line_r.append(rr[0])

    line_coords = HexCoords(np.array(line_q), np.array(line_r))
    values = np.ones(len(line_coords))
    return HexData(line_coords, values)
to_pixel(size=1.0, orientation='pointy')

Convert to pixel coordinates with values.

Source code in btorch/utils/hex/data.py
def to_pixel(
    self, size: float = 1.0, orientation: str = "pointy"
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
    """Convert to pixel coordinates with values."""
    return self._data.to_pixel(size, orientation)
valid_neighbors()

Get valid neighbor indices for each hex in grid.

Returns tuple of tuples, where each inner tuple contains indices of valid neighbors within the grid.

Source code in btorch/utils/hex/data.py
def valid_neighbors(self) -> tuple[tuple[int, ...], ...]:
    """Get valid neighbor indices for each hex in grid.

    Returns tuple of tuples, where each inner tuple contains indices
    of valid neighbors within the grid.
    """
    from .neighbor import neighbors

    qn, rn = neighbors(self.q, self.r)  # (6, n)
    coord_to_idx = {(int(self.q[i]), int(self.r[i])): i for i in range(len(self))}

    result = []
    for i in range(len(self)):
        neighbor_indices = []
        for j in range(6):
            key = (int(qn[j, i]), int(rn[j, i]))
            if key in coord_to_idx:
                neighbor_indices.append(coord_to_idx[key])
        result.append(tuple(neighbor_indices))
    return tuple(result)

Functions

align(q_target, r_target, q_source, r_source, values, fill=np.nan, use_numba=True)

Align source values to target coordinates. Missing -> fill.

Uses numba-accelerated implementation if available and use_numba=True.

Parameters:

Name Type Description Default
q_target ndarray

Target q coordinates.

required
r_target ndarray

Target r coordinates.

required
q_source ndarray

Source q coordinates.

required
r_source ndarray

Source r coordinates.

required
values ndarray

Source values to align.

required
fill float

Fill value for missing coordinates.

nan
use_numba bool

Whether to use numba acceleration if available.

True

Returns:

Type Description
ndarray

Aligned values with same length as target coordinates.

Source code in btorch/utils/hex/storage.py
def align(
    q_target: np.ndarray,
    r_target: np.ndarray,
    q_source: np.ndarray,
    r_source: np.ndarray,
    values: np.ndarray,
    fill: float = np.nan,
    use_numba: bool = True,
) -> np.ndarray:
    """Align source values to target coordinates. Missing -> fill.

    Uses numba-accelerated implementation if available and use_numba=True.

    Args:
        q_target: Target q coordinates.
        r_target: Target r coordinates.
        q_source: Source q coordinates.
        r_source: Source r coordinates.
        values: Source values to align.
        fill: Fill value for missing coordinates.
        use_numba: Whether to use numba acceleration if available.

    Returns:
        Aligned values with same length as target coordinates.
    """
    # Fall back to numpy when dtype promotion is needed (fill=nan with integer dtype)
    needs_dtype_promotion = np.isnan(fill) and not np.issubdtype(
        values.dtype, np.floating
    )
    can_use_numba = use_numba and HAS_NUMBA and not needs_dtype_promotion
    if can_use_numba:
        return _align_numba(q_target, r_target, q_source, r_source, values, fill)
    else:
        return _align_numpy(q_target, r_target, q_source, r_source, values, fill)

all_neighbors(q, r)

Get all 12 neighbors (6 cardinal + 6 diagonal).

Parameters:

Name Type Description Default
q ndarray

Axial q coordinates.

required
r ndarray

Axial r coordinates.

required

Returns:

Type Description
tuple[ndarray, ndarray]

Tuple of (q_neighbors, r_neighbors) arrays with shape (12, len(q)).

Source code in btorch/utils/hex/neighbor.py
def all_neighbors(q: np.ndarray, r: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
    """Get all 12 neighbors (6 cardinal + 6 diagonal).

    Args:
        q: Axial q coordinates.
        r: Axial r coordinates.

    Returns:
        Tuple of (q_neighbors, r_neighbors) arrays with shape (12, len(q)).
    """
    cardinal_q = q[None, :] + DIRECTIONS[:, 0:1]
    cardinal_r = r[None, :] + DIRECTIONS[:, 1:2]
    diagonal_q = q[None, :] + DIAGONALS[:, 0:1]
    diagonal_r = r[None, :] + DIAGONALS[:, 1:2]

    qn = np.vstack([cardinal_q, diagonal_q])
    rn = np.vstack([cardinal_r, diagonal_r])
    return qn, rn

axial_from_cube(q, r, s)

Cube to axial: drops s.

Parameters:

Name Type Description Default
q ndarray

Cube q coordinates.

required
r ndarray

Cube r coordinates.

required
s ndarray

Cube s coordinates (unused, for API consistency).

required

Returns:

Type Description
tuple[ndarray, ndarray]

Tuple of (q, r) axial coordinates.

Source code in btorch/utils/hex/transform.py
def axial_from_cube(
    q: np.ndarray, r: np.ndarray, s: np.ndarray
) -> tuple[np.ndarray, np.ndarray]:
    """Cube to axial: drops s.

    Args:
        q: Cube q coordinates.
        r: Cube r coordinates.
        s: Cube s coordinates (unused, for API consistency).

    Returns:
        Tuple of (q, r) axial coordinates.
    """
    _ = s  # s is implicit in axial, but validate input
    return q, r

axial_to_doubleheight(q, r)

Convert axial to double-height coordinates.

Formula: col = q, row = 2*r + q

Double-height is useful for flat-top hexes in rectangular maps. Every other row is used (odd rows are skipped).

Parameters:

Name Type Description Default
q ndarray

Axial q coordinates.

required
r ndarray

Axial r coordinates.

required

Returns:

Type Description
tuple[ndarray, ndarray]

Tuple of (col, row) double-height coordinates.

Example

col, row = axial_to_doubleheight(np.array([0, 1]), np.array([0, 1])) row array([0, 3])

Source code in btorch/utils/hex/doubled.py
def axial_to_doubleheight(
    q: np.ndarray, r: np.ndarray
) -> tuple[np.ndarray, np.ndarray]:
    """Convert axial to double-height coordinates.

    Formula: col = q, row = 2*r + q

    Double-height is useful for flat-top hexes in rectangular maps.
    Every other row is used (odd rows are skipped).

    Args:
        q: Axial q coordinates.
        r: Axial r coordinates.

    Returns:
        Tuple of (col, row) double-height coordinates.

    Example:
        >>> col, row = axial_to_doubleheight(np.array([0, 1]), np.array([0, 1]))
        >>> row
        array([0, 3])
    """
    q = np.asarray(q)
    r = np.asarray(r)
    col = q
    row = 2 * r + q
    return col, row

axial_to_doublewidth(q, r)

Convert axial to double-width coordinates.

Formula: col = 2*q + r, row = r

Double-width is useful for pointy-top hexes in rectangular maps. Every other column is used (odd columns are skipped).

Parameters:

Name Type Description Default
q ndarray

Axial q coordinates.

required
r ndarray

Axial r coordinates.

required

Returns:

Type Description
tuple[ndarray, ndarray]

Tuple of (col, row) double-width coordinates.

Example

col, row = axial_to_doublewidth(np.array([0, 1]), np.array([0, 1])) col array([0, 3])

Source code in btorch/utils/hex/doubled.py
def axial_to_doublewidth(q: np.ndarray, r: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
    """Convert axial to double-width coordinates.

    Formula: col = 2*q + r, row = r

    Double-width is useful for pointy-top hexes in rectangular maps.
    Every other column is used (odd columns are skipped).

    Args:
        q: Axial q coordinates.
        r: Axial r coordinates.

    Returns:
        Tuple of (col, row) double-width coordinates.

    Example:
        >>> col, row = axial_to_doublewidth(np.array([0, 1]), np.array([0, 1]))
        >>> col
        array([0, 3])
    """
    q = np.asarray(q)
    r = np.asarray(r)
    col = 2 * q + r
    row = r
    return col, row

axial_to_even_q(q, r)

Convert axial to even-q offset coordinates.

Formula: col = q, row = r + (q + (q & 1)) / 2

Parameters:

Name Type Description Default
q ndarray

Axial q coordinates.

required
r ndarray

Axial r coordinates.

required

Returns:

Type Description
tuple[ndarray, ndarray]

Tuple of (col, row) offset coordinates.

Source code in btorch/utils/hex/offset.py
def axial_to_even_q(q: np.ndarray, r: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
    """Convert axial to even-q offset coordinates.

    Formula: col = q, row = r + (q + (q & 1)) / 2

    Args:
        q: Axial q coordinates.
        r: Axial r coordinates.

    Returns:
        Tuple of (col, row) offset coordinates.
    """
    q = np.asarray(q)
    r = np.asarray(r)
    col = q
    row = r + (q + (q & 1)) // 2
    return col, row

axial_to_even_r(q, r)

Convert axial to even-r offset coordinates.

Formula: col = q + (r + (r & 1)) / 2, row = r

Parameters:

Name Type Description Default
q ndarray

Axial q coordinates.

required
r ndarray

Axial r coordinates.

required

Returns:

Type Description
tuple[ndarray, ndarray]

Tuple of (col, row) offset coordinates.

Source code in btorch/utils/hex/offset.py
def axial_to_even_r(q: np.ndarray, r: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
    """Convert axial to even-r offset coordinates.

    Formula: col = q + (r + (r & 1)) / 2, row = r

    Args:
        q: Axial q coordinates.
        r: Axial r coordinates.

    Returns:
        Tuple of (col, row) offset coordinates.
    """
    q = np.asarray(q)
    r = np.asarray(r)
    col = q + (r + (r & 1)) // 2
    row = r
    return col, row

axial_to_hex_index(q, r, radius)

Convert axial to array indices for hexagon-shaped map.

Row r (relative to center) has size 2*N+1 - abs(N-r) columns. Store at array[r + N][q - max(0, N-r) + N]

Parameters:

Name Type Description Default
q ndarray

Axial q coordinates.

required
r ndarray

Axial r coordinates.

required
radius int

Map radius (N).

required

Returns:

Type Description
tuple[ndarray, ndarray]

Tuple of (row, col) array indices.

Source code in btorch/utils/hex/storage.py
def axial_to_hex_index(
    q: np.ndarray, r: np.ndarray, radius: int
) -> tuple[np.ndarray, np.ndarray]:
    """Convert axial to array indices for hexagon-shaped map.

    Row r (relative to center) has size 2*N+1 - abs(N-r) columns.
    Store at array[r + N][q - max(0, N-r) + N]

    Args:
        q: Axial q coordinates.
        r: Axial r coordinates.
        radius: Map radius (N).

    Returns:
        Tuple of (row, col) array indices.
    """
    q = np.asarray(q)
    r = np.asarray(r)

    row = r + radius
    col = q - np.maximum(0, radius - r) + radius

    return row, col

axial_to_odd_q(q, r)

Convert axial to odd-q offset coordinates.

Formula: col = q, row = r + (q - (q & 1)) / 2

Parameters:

Name Type Description Default
q ndarray

Axial q coordinates.

required
r ndarray

Axial r coordinates.

required

Returns:

Type Description
tuple[ndarray, ndarray]

Tuple of (col, row) offset coordinates.

Source code in btorch/utils/hex/offset.py
def axial_to_odd_q(q: np.ndarray, r: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
    """Convert axial to odd-q offset coordinates.

    Formula: col = q, row = r + (q - (q & 1)) / 2

    Args:
        q: Axial q coordinates.
        r: Axial r coordinates.

    Returns:
        Tuple of (col, row) offset coordinates.
    """
    q = np.asarray(q)
    r = np.asarray(r)
    col = q
    row = r + (q - (q & 1)) // 2
    return col, row

axial_to_odd_r(q, r)

Convert axial to odd-r offset coordinates.

Formula: col = q + (r - (r & 1)) / 2, row = r

Parameters:

Name Type Description Default
q ndarray

Axial q coordinates.

required
r ndarray

Axial r coordinates.

required

Returns:

Type Description
tuple[ndarray, ndarray]

Tuple of (col, row) offset coordinates.

Example

col, row = axial_to_odd_r(np.array([0, 1]), np.array([0, 1])) col array([0, 1])

Source code in btorch/utils/hex/offset.py
def axial_to_odd_r(q: np.ndarray, r: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
    """Convert axial to odd-r offset coordinates.

    Formula: col = q + (r - (r & 1)) / 2, row = r

    Args:
        q: Axial q coordinates.
        r: Axial r coordinates.

    Returns:
        Tuple of (col, row) offset coordinates.

    Example:
        >>> col, row = axial_to_odd_r(np.array([0, 1]), np.array([0, 1]))
        >>> col
        array([0, 1])
    """
    q = np.asarray(q)
    r = np.asarray(r)
    col = q + (r - (r & 1)) // 2
    row = r
    return col, row

axial_to_rect_index(q, r, orientation='pointy')

Convert axial to rectangular array indices.

For pointy-top: store at array[r][q + floor(r/2)] For flat-top: store at array[q][r + floor(q/2)]

Parameters:

Name Type Description Default
q ndarray

Axial q coordinates.

required
r ndarray

Axial r coordinates.

required
orientation str

"pointy" or "flat".

'pointy'

Returns:

Type Description
tuple[ndarray, ndarray]

Tuple of (row, col) array indices.

Example

row, col = axial_to_rect_index(np.array([0, 1]), np.array([0, 1])) row array([0, 1])

Source code in btorch/utils/hex/storage.py
def axial_to_rect_index(
    q: np.ndarray, r: np.ndarray, orientation: str = "pointy"
) -> tuple[np.ndarray, np.ndarray]:
    """Convert axial to rectangular array indices.

    For pointy-top: store at array[r][q + floor(r/2)]
    For flat-top: store at array[q][r + floor(q/2)]

    Args:
        q: Axial q coordinates.
        r: Axial r coordinates.
        orientation: "pointy" or "flat".

    Returns:
        Tuple of (row, col) array indices.

    Example:
        >>> row, col = axial_to_rect_index(np.array([0, 1]), np.array([0, 1]))
        >>> row
        array([0, 1])
    """
    q = np.asarray(q)
    r = np.asarray(r)

    if orientation == "pointy":
        row = r
        col = q + np.floor(r / 2).astype(int)
    elif orientation == "flat":
        row = q
        col = r + np.floor(q / 2).astype(int)
    else:
        raise ValueError(f"Unknown orientation: {orientation}")

    return row, col

axial_to_rhombus_index(q, r)

Convert axial to array indices for rhombus-shaped map.

Rhombus maps store axial directly: array[r][q]

Parameters:

Name Type Description Default
q ndarray

Axial q coordinates.

required
r ndarray

Axial r coordinates.

required

Returns:

Type Description
tuple[ndarray, ndarray]

Tuple of (row, col) array indices.

Source code in btorch/utils/hex/storage.py
def axial_to_rhombus_index(
    q: np.ndarray, r: np.ndarray
) -> tuple[np.ndarray, np.ndarray]:
    """Convert axial to array indices for rhombus-shaped map.

    Rhombus maps store axial directly: array[r][q]

    Args:
        q: Axial q coordinates.
        r: Axial r coordinates.

    Returns:
        Tuple of (row, col) array indices.
    """
    q = np.asarray(q)
    r = np.asarray(r)
    return r, q

axial_to_triangle_index(q, r, size, pointing='down')

Convert axial to array indices for triangle-shaped map.

Down-pointing: store at array[r][q], row r has size N+1-r Up-pointing: store at array[r][q - N+1+r], row r has size 1+r

Parameters:

Name Type Description Default
q ndarray

Axial q coordinates.

required
r ndarray

Axial r coordinates.

required
size int

Triangle size (N).

required
pointing str

"down" or "up".

'down'

Returns:

Type Description
tuple[ndarray, ndarray]

Tuple of (row, col) array indices.

Source code in btorch/utils/hex/storage.py
def axial_to_triangle_index(
    q: np.ndarray, r: np.ndarray, size: int, pointing: str = "down"
) -> tuple[np.ndarray, np.ndarray]:
    """Convert axial to array indices for triangle-shaped map.

    Down-pointing: store at array[r][q], row r has size N+1-r
    Up-pointing: store at array[r][q - N+1+r], row r has size 1+r

    Args:
        q: Axial q coordinates.
        r: Axial r coordinates.
        size: Triangle size (N).
        pointing: "down" or "up".

    Returns:
        Tuple of (row, col) array indices.
    """
    q = np.asarray(q)
    r = np.asarray(r)

    if pointing == "down":
        row = r
        col = q
    elif pointing == "up":
        row = r
        col = q - size + 1 + r
    else:
        raise ValueError(f"Unknown pointing: {pointing}")

    return row, col

axial_to_zigzag(q, r)

Convert axial to zigzag offset coordinates (column-skipped layout).

This is the display-friendly version where x alternates cleanly between columns, creating the classic hex zigzag pattern:

col 0   col 1   col 0   col 1
  ●       ●       ●       ●
●       ●       ●       ●

Formula: x = floor((r - q) / 2), y = q + r

The reverse is exact and bidirectional with integers.

Parameters:

Name Type Description Default
q ndarray

Axial q coordinates.

required
r ndarray

Axial r coordinates.

required

Returns:

Type Description
tuple[ndarray, ndarray]

Tuple of (x, y) zigzag offset coordinates.

Example

axial_to_zigzag(np.array([0, 1, -1]), np.array([0, 0, 0])) (array([0, 0, 0]), array([0, 1, -1]))

axial_to_zigzag(np.array([0, 1, 0, -1]), np.array([0, -1, 1, 0])) (array([0, -1, 0, 0]), array([0, 0, 1, -1]))

Source code in btorch/utils/hex/offset.py
def axial_to_zigzag(q: np.ndarray, r: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
    """Convert axial to zigzag offset coordinates (column-skipped layout).

    This is the display-friendly version where x alternates cleanly
    between columns, creating the classic hex zigzag pattern:

        col 0   col 1   col 0   col 1
          ●       ●       ●       ●
        ●       ●       ●       ●

    Formula: x = floor((r - q) / 2), y = q + r

    The reverse is exact and bidirectional with integers.

    Args:
        q: Axial q coordinates.
        r: Axial r coordinates.

    Returns:
        Tuple of (x, y) zigzag offset coordinates.

    Example:
        >>> axial_to_zigzag(np.array([0, 1, -1]), np.array([0, 0, 0]))
        (array([0, 0, 0]), array([0, 1, -1]))

        >>> axial_to_zigzag(np.array([0, 1, 0, -1]), np.array([0, -1, 1, 0]))
        (array([0, -1, 0, 0]), array([0, 0, 1, -1]))
    """
    q = np.asarray(q)
    r = np.asarray(r)
    x = np.floor((r - q) / 2).astype(int)
    y = q + r
    return x, y

cube_from_axial(q, r)

Axial to cube: (q, r) -> (q, r, s) where s = -q - r.

Parameters:

Name Type Description Default
q ndarray

Axial q coordinates.

required
r ndarray

Axial r coordinates.

required

Returns:

Type Description
tuple[ndarray, ndarray, ndarray]

Tuple of (q, r, s) cube coordinates.

Source code in btorch/utils/hex/transform.py
def cube_from_axial(
    q: np.ndarray, r: np.ndarray
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
    """Axial to cube: (q, r) -> (q, r, s) where s = -q - r.

    Args:
        q: Axial q coordinates.
        r: Axial r coordinates.

    Returns:
        Tuple of (q, r, s) cube coordinates.
    """
    s = -q - r
    return q, r, s

diagonal_neighbor(q, r, direction)

Get diagonal neighbor in direction (0-5).

Diagonal directions are between the 6 cardinal directions: 0: NE-E, 1: E-SE, 2: SE-SW, 3: SW-W, 4: W-NW, 5: NW-NE

Parameters:

Name Type Description Default
q ndarray

Axial q coordinates.

required
r ndarray

Axial r coordinates.

required
direction int

Direction index (0-5).

required

Returns:

Type Description
tuple[ndarray, ndarray]

Diagonal neighbor coordinates (q, r).

Source code in btorch/utils/hex/neighbor.py
def diagonal_neighbor(
    q: np.ndarray, r: np.ndarray, direction: int
) -> tuple[np.ndarray, np.ndarray]:
    """Get diagonal neighbor in direction (0-5).

    Diagonal directions are between the 6 cardinal directions:
    0: NE-E, 1: E-SE, 2: SE-SW, 3: SW-W, 4: W-NW, 5: NW-NE

    Args:
        q: Axial q coordinates.
        r: Axial r coordinates.
        direction: Direction index (0-5).

    Returns:
        Diagonal neighbor coordinates (q, r).
    """
    dq, dr = DIAGONALS[direction % 6]
    return q + dq, r + dr

diagonal_neighbors(q, r)

Get all 6 diagonal neighbors.

Parameters:

Name Type Description Default
q ndarray

Axial q coordinates.

required
r ndarray

Axial r coordinates.

required

Returns:

Type Description
tuple[ndarray, ndarray]

Tuple of (q_neighbors, r_neighbors) arrays with shape (6, len(q)).

Source code in btorch/utils/hex/neighbor.py
def diagonal_neighbors(q: np.ndarray, r: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
    """Get all 6 diagonal neighbors.

    Args:
        q: Axial q coordinates.
        r: Axial r coordinates.

    Returns:
        Tuple of (q_neighbors, r_neighbors) arrays with shape (6, len(q)).
    """
    qn = q[None, :] + DIAGONALS[:, 0:1]
    rn = r[None, :] + DIAGONALS[:, 1:2]
    return qn, rn

disk(radius, center_q=0, center_r=0)

All hexes within radius of center. Count: 1 + 3radius(radius+1).

Parameters:

Name Type Description Default
radius int

Maximum distance from center.

required
center_q int

Center q coordinate.

0
center_r int

Center r coordinate.

0

Returns:

Type Description
tuple[ndarray, ndarray]

Tuple of (q, r) arrays for hex coordinates in the disk.

See Also

https://www.redblobgames.com/grids/hexagons/#range-coordinate

Source code in btorch/utils/hex/coords.py
def disk(
    radius: int, center_q: int = 0, center_r: int = 0
) -> tuple[np.ndarray, np.ndarray]:
    """All hexes within `radius` of center. Count: 1 + 3*radius*(radius+1).

    Args:
        radius: Maximum distance from center.
        center_q: Center q coordinate.
        center_r: Center r coordinate.

    Returns:
        Tuple of (q, r) arrays for hex coordinates in the disk.

    See Also:
        https://www.redblobgames.com/grids/hexagons/#range-coordinate
    """
    q, r = [], []
    for dq in range(-radius, radius + 1):
        for dr in range(max(-radius, -radius - dq), min(radius, radius - dq) + 1):
            q.append(center_q + dq)
            r.append(center_r + dr)
    return np.array(q), np.array(r)

disk_count(radius)

Number of hexes in disk: 1 + 3radius(radius+1).

Parameters:

Name Type Description Default
radius int

Disk radius.

required

Returns:

Type Description
int

Number of hexes in the disk.

Source code in btorch/utils/hex/coords.py
def disk_count(radius: int) -> int:
    """Number of hexes in disk: 1 + 3*radius*(radius+1).

    Args:
        radius: Disk radius.

    Returns:
        Number of hexes in the disk.
    """
    return 1 + 3 * radius * (radius + 1)

disk_radius(count)

Inverse of disk_count. Radius for given count.

Parameters:

Name Type Description Default
count int

Number of hexes.

required

Returns:

Type Description
int

Radius of disk containing approximately count hexes.

Note

Returns floor of exact value.

Source code in btorch/utils/hex/coords.py
def disk_radius(count: int) -> int:
    """Inverse of disk_count. Radius for given count.

    Args:
        count: Number of hexes.

    Returns:
        Radius of disk containing approximately count hexes.

    Note:
        Returns floor of exact value.
    """
    # Solve: count = 1 + 3*r*(r+1)
    # => 3*r^2 + 3*r + (1 - count) = 0
    # => r = (-3 + sqrt(9 - 12*(1-count))) / 6
    # => r = (-3 + sqrt(12*count - 3)) / 6
    if count < 1:
        return 0
    return int((-3 + np.sqrt(12 * count - 3)) / 6)

doubleheight_distance(c1, r1, c2, r2)

Distance in double-height coordinates (direct formula).

Formula: dcol + max(0, (drow-dcol)/2) where dcol = |c2-c1|, drow = |r2-r1|

Parameters:

Name Type Description Default
c1 int

First column coordinate.

required
r1 int

First row coordinate.

required
c2 int

Second column coordinate.

required
r2 int

Second row coordinate.

required

Returns:

Type Description
int

Hex distance between the two coordinates.

Example

doubleheight_distance(0, 0, 0, 2) 1

Source code in btorch/utils/hex/doubled.py
def doubleheight_distance(c1: int, r1: int, c2: int, r2: int) -> int:
    """Distance in double-height coordinates (direct formula).

    Formula: dcol + max(0, (drow-dcol)/2) where dcol = |c2-c1|,
    drow = |r2-r1|

    Args:
        c1: First column coordinate.
        r1: First row coordinate.
        c2: Second column coordinate.
        r2: Second row coordinate.

    Returns:
        Hex distance between the two coordinates.

    Example:
        >>> doubleheight_distance(0, 0, 0, 2)
        1
    """
    dcol = abs(c2 - c1)
    drow = abs(r2 - r1)
    return dcol + max(0, (drow - dcol) // 2)

doubleheight_to_axial(col, row)

Convert double-height to axial coordinates.

Formula: q = col, r = (row - col) / 2

Parameters:

Name Type Description Default
col ndarray

Double-height column coordinates.

required
row ndarray

Double-height row coordinates.

required

Returns:

Type Description
tuple[ndarray, ndarray]

Tuple of (q, r) axial coordinates.

Source code in btorch/utils/hex/doubled.py
def doubleheight_to_axial(
    col: np.ndarray, row: np.ndarray
) -> tuple[np.ndarray, np.ndarray]:
    """Convert double-height to axial coordinates.

    Formula: q = col, r = (row - col) / 2

    Args:
        col: Double-height column coordinates.
        row: Double-height row coordinates.

    Returns:
        Tuple of (q, r) axial coordinates.
    """
    col = np.asarray(col)
    row = np.asarray(row)
    q = col
    r = (row - col) // 2
    return q, r

doubleheight_to_pixel(col, row, size=1.0)

Convert double-height to pixel coordinates.

Formula: x = 3/2 * size * col, y = sqrt(3)/2 * size * row

Parameters:

Name Type Description Default
col ndarray

Double-height column coordinates.

required
row ndarray

Double-height row coordinates.

required
size float

Hexagon size (distance from center to corner).

1.0

Returns:

Type Description
tuple[ndarray, ndarray]

Tuple of (x, y) pixel coordinates.

Source code in btorch/utils/hex/doubled.py
def doubleheight_to_pixel(
    col: np.ndarray, row: np.ndarray, size: float = 1.0
) -> tuple[np.ndarray, np.ndarray]:
    """Convert double-height to pixel coordinates.

    Formula: x = 3/2 * size * col, y = sqrt(3)/2 * size * row

    Args:
        col: Double-height column coordinates.
        row: Double-height row coordinates.
        size: Hexagon size (distance from center to corner).

    Returns:
        Tuple of (x, y) pixel coordinates.
    """
    col = np.asarray(col)
    row = np.asarray(row)
    x = 3.0 / 2 * size * col
    y = np.sqrt(3) / 2 * size * row
    return x, y

doublewidth_distance(c1, r1, c2, r2)

Distance in double-width coordinates (direct formula).

Formula: drow + max(0, (dcol-drow)/2) where drow = |r2-r1|, dcol = |c2-c1|

Parameters:

Name Type Description Default
c1 int

First column coordinate.

required
r1 int

First row coordinate.

required
c2 int

Second column coordinate.

required
r2 int

Second row coordinate.

required

Returns:

Type Description
int

Hex distance between the two coordinates.

Example

doublewidth_distance(0, 0, 2, 0) 1

Source code in btorch/utils/hex/doubled.py
def doublewidth_distance(c1: int, r1: int, c2: int, r2: int) -> int:
    """Distance in double-width coordinates (direct formula).

    Formula: drow + max(0, (dcol-drow)/2) where drow = |r2-r1|,
    dcol = |c2-c1|

    Args:
        c1: First column coordinate.
        r1: First row coordinate.
        c2: Second column coordinate.
        r2: Second row coordinate.

    Returns:
        Hex distance between the two coordinates.

    Example:
        >>> doublewidth_distance(0, 0, 2, 0)
        1
    """
    dcol = abs(c2 - c1)
    drow = abs(r2 - r1)
    return drow + max(0, (dcol - drow) // 2)

doublewidth_to_axial(col, row)

Convert double-width to axial coordinates.

Formula: q = (col - row) / 2, r = row

Parameters:

Name Type Description Default
col ndarray

Double-width column coordinates.

required
row ndarray

Double-width row coordinates.

required

Returns:

Type Description
tuple[ndarray, ndarray]

Tuple of (q, r) axial coordinates.

Source code in btorch/utils/hex/doubled.py
def doublewidth_to_axial(
    col: np.ndarray, row: np.ndarray
) -> tuple[np.ndarray, np.ndarray]:
    """Convert double-width to axial coordinates.

    Formula: q = (col - row) / 2, r = row

    Args:
        col: Double-width column coordinates.
        row: Double-width row coordinates.

    Returns:
        Tuple of (q, r) axial coordinates.
    """
    col = np.asarray(col)
    row = np.asarray(row)
    q = (col - row) // 2
    r = row
    return q, r

doublewidth_to_pixel(col, row, size=1.0)

Convert double-width to pixel coordinates.

Formula: x = sqrt(3)/2 * size * col, y = 3/2 * size * row

Parameters:

Name Type Description Default
col ndarray

Double-width column coordinates.

required
row ndarray

Double-width row coordinates.

required
size float

Hexagon size (distance from center to corner).

1.0

Returns:

Type Description
tuple[ndarray, ndarray]

Tuple of (x, y) pixel coordinates.

Source code in btorch/utils/hex/doubled.py
def doublewidth_to_pixel(
    col: np.ndarray, row: np.ndarray, size: float = 1.0
) -> tuple[np.ndarray, np.ndarray]:
    """Convert double-width to pixel coordinates.

    Formula: x = sqrt(3)/2 * size * col, y = 3/2 * size * row

    Args:
        col: Double-width column coordinates.
        row: Double-width row coordinates.
        size: Hexagon size (distance from center to corner).

    Returns:
        Tuple of (x, y) pixel coordinates.
    """
    col = np.asarray(col)
    row = np.asarray(row)
    x = np.sqrt(3) / 2 * size * col
    y = 3.0 / 2 * size * row
    return x, y

even_q_to_axial(col, row)

Convert even-q offset to axial coordinates.

Formula: q = col, r = row - (col + (col & 1)) / 2

Parameters:

Name Type Description Default
col ndarray

Column coordinates.

required
row ndarray

Row coordinates.

required

Returns:

Type Description
tuple[ndarray, ndarray]

Tuple of (q, r) axial coordinates.

Source code in btorch/utils/hex/offset.py
def even_q_to_axial(col: np.ndarray, row: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
    """Convert even-q offset to axial coordinates.

    Formula: q = col, r = row - (col + (col & 1)) / 2

    Args:
        col: Column coordinates.
        row: Row coordinates.

    Returns:
        Tuple of (q, r) axial coordinates.
    """
    col = np.asarray(col)
    row = np.asarray(row)
    q = col
    r = row - (col + (col & 1)) // 2
    return q, r

even_r_to_axial(col, row)

Convert even-r offset to axial coordinates.

Formula: q = col - (row + (row & 1)) / 2, r = row

Parameters:

Name Type Description Default
col ndarray

Column coordinates.

required
row ndarray

Row coordinates.

required

Returns:

Type Description
tuple[ndarray, ndarray]

Tuple of (q, r) axial coordinates.

Source code in btorch/utils/hex/offset.py
def even_r_to_axial(col: np.ndarray, row: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
    """Convert even-r offset to axial coordinates.

    Formula: q = col - (row + (row & 1)) / 2, r = row

    Args:
        col: Column coordinates.
        row: Row coordinates.

    Returns:
        Tuple of (q, r) axial coordinates.
    """
    col = np.asarray(col)
    row = np.asarray(row)
    q = col - (row + (row & 1)) // 2
    r = row
    return q, r

flywire_to_pixel(p, q, size=1.0, rotation_deg=0.0)

Convert FlyWire visual-column coords (p,q) to pixel coordinates.

FlyWire visual columns map uses axial (p,q) data coordinates but displays them on DOM rows using derived zigzag indices:

  • x = floor((q - p) / 2)
  • y = p + q

The saved HTML/CSS page then lays out those rows with an alternating half-row indentation and fixed row pitch. This helper mirrors that display logic so btorch plots can match the website layout.

Source code in btorch/utils/hex/offset.py
def flywire_to_pixel(
    p: np.ndarray,
    q: np.ndarray,
    size: float = 1.0,
    rotation_deg: float = 0.0,
) -> tuple[np.ndarray, np.ndarray]:
    """Convert FlyWire visual-column coords `(p,q)` to pixel coordinates.

    FlyWire visual columns map uses axial `(p,q)` data coordinates but displays
    them on DOM rows using derived zigzag indices:

    - `x = floor((q - p) / 2)`
    - `y = p + q`

    The saved HTML/CSS page then lays out those rows with an alternating
    half-row indentation and fixed row pitch. This helper mirrors that display
    logic so btorch plots can match the website layout.
    """
    p = np.asarray(p)
    q = np.asarray(q)
    x_zigzag, y_zigzag = axial_to_zigzag(p, q)
    return flywire_xy_to_pixel(
        x_zigzag,
        y_zigzag,
        size=size,
        rotation_deg=rotation_deg,
    )

flywire_xy_to_pixel(x_zigzag, y_zigzag, size=1.0, rotation_deg=0.0)

Convert FlyWire official website display indices (x,y) to pixel positions.

The retina grid is rendered on DOM rows with: - row pitch of 24 px - tile step of 75 px - half-row indentation of 35 px on alternating rows

Values are normalized here relative to size=1.0.

Source code in btorch/utils/hex/offset.py
def flywire_xy_to_pixel(
    x_zigzag: np.ndarray,
    y_zigzag: np.ndarray,
    size: float = 1.0,
    rotation_deg: float = 0.0,
) -> tuple[np.ndarray, np.ndarray]:
    """Convert FlyWire official website display indices `(x,y)` to pixel
    positions.

    The retina grid is rendered on DOM rows with:
    - row pitch of 24 px
    - tile step of 75 px
    - half-row indentation of 35 px on alternating rows

    Values are normalized here relative to `size=1.0`.
    """
    x_zigzag = np.asarray(x_zigzag)
    y_zigzag = np.asarray(y_zigzag)

    css_hex_width = 45.0
    css_full_step_x = 75.0
    css_half_row_shift_x = 35.0
    css_row_step_y = 24.0
    css_radius = css_hex_width / 2.0

    full_step_x = (css_full_step_x / css_radius) * size
    half_row_shift_x = (css_half_row_shift_x / css_radius) * size
    row_step_y = (css_row_step_y / css_radius) * size

    parity = np.mod(y_zigzag, 2)
    x = -(full_step_x * x_zigzag + half_row_shift_x * parity)
    y = -row_step_y * y_zigzag

    if rotation_deg == 0.0:
        return x.astype(float), y.astype(float)

    theta = np.deg2rad(rotation_deg)
    cos_t = np.cos(theta)
    sin_t = np.sin(theta)
    xr = cos_t * x - sin_t * y
    yr = sin_t * x + cos_t * y
    return xr.astype(float), yr.astype(float)

from_pixel(x, y, size=1.0, orientation='pointy')

Pixel to axial hex (fractional, use round_axial).

Parameters:

Name Type Description Default
x ndarray

Pixel x coordinates.

required
y ndarray

Pixel y coordinates.

required
size float

Hexagon size.

1.0
orientation Orientation

"pointy" (point up) or "flat" (flat side up).

'pointy'

Returns:

Type Description
tuple[ndarray, ndarray]

Tuple of (q, r) fractional axial coordinates.

See Also

https://www.redblobgames.com/grids/hexagons/#pixel-to-hex

Source code in btorch/utils/hex/transform.py
def from_pixel(
    x: np.ndarray,
    y: np.ndarray,
    size: float = 1.0,
    orientation: Orientation = "pointy",
) -> tuple[np.ndarray, np.ndarray]:
    """Pixel to axial hex (fractional, use round_axial).

    Args:
        x: Pixel x coordinates.
        y: Pixel y coordinates.
        size: Hexagon size.
        orientation: "pointy" (point up) or "flat" (flat side up).

    Returns:
        Tuple of (q, r) fractional axial coordinates.

    See Also:
        https://www.redblobgames.com/grids/hexagons/#pixel-to-hex
    """
    if orientation == "pointy":
        q = (np.sqrt(3) / 3 * x - 1.0 / 3 * y) / size
        r = (2.0 / 3 * y) / size
    elif orientation == "flat":
        q = (2.0 / 3 * x) / size
        r = (-1.0 / 3 * x + np.sqrt(3) / 3 * y) / size
    else:
        raise ValueError(f"Unknown orientation: {orientation}")
    return q, r

hex_index_to_axial(row, col, radius)

Convert array indices back to axial for hexagon-shaped map.

Parameters:

Name Type Description Default
row ndarray

Array row indices.

required
col ndarray

Array column indices.

required
radius int

Map radius (N).

required

Returns:

Type Description
tuple[ndarray, ndarray]

Tuple of (q, r) axial coordinates.

Source code in btorch/utils/hex/storage.py
def hex_index_to_axial(
    row: np.ndarray, col: np.ndarray, radius: int
) -> tuple[np.ndarray, np.ndarray]:
    """Convert array indices back to axial for hexagon-shaped map.

    Args:
        row: Array row indices.
        col: Array column indices.
        radius: Map radius (N).

    Returns:
        Tuple of (q, r) axial coordinates.
    """
    row = np.asarray(row)
    col = np.asarray(col)

    r = row - radius
    q = col + np.maximum(0, radius - r) - radius

    return q, r

hex_symbol_for(layout)

Return the Plotly hex marker symbol for a given layout.

Parameters:

Name Type Description Default
layout str

One of "pointy", "flat", "flywire".

required

Returns:

Type Description
int

Plotly marker symbol int: 14 (pointy-top hexagon) or

int

15 (flat-top hexagon).

Source code in btorch/utils/hex/resolve.py
def hex_symbol_for(layout: str) -> int:
    """Return the Plotly hex marker symbol for a given layout.

    Args:
        layout: One of ``"pointy"``, ``"flat"``, ``"flywire"``.

    Returns:
        Plotly marker symbol int: ``14`` (pointy-top hexagon) or
        ``15`` (flat-top hexagon).
    """
    return _HEX_SYMBOLS.get(layout, 14)

line_n(q1, r1, q2, r2, n)

Line with exactly n+1 points including endpoints.

Parameters:

Name Type Description Default
q1 ndarray

Start q coordinate(s).

required
r1 ndarray

Start r coordinate(s).

required
q2 ndarray

End q coordinate(s).

required
r2 ndarray

End r coordinate(s).

required
n int

Number of steps (results in n+1 points).

required

Returns:

Type Description
tuple[ndarray, ndarray]

Tuple of (q, r) arrays for hexes on the line.

Source code in btorch/utils/hex/line.py
def line_n(
    q1: np.ndarray, r1: np.ndarray, q2: np.ndarray, r2: np.ndarray, n: int
) -> tuple[np.ndarray, np.ndarray]:
    """Line with exactly n+1 points including endpoints.

    Args:
        q1: Start q coordinate(s).
        r1: Start r coordinate(s).
        q2: End q coordinate(s).
        r2: End r coordinate(s).
        n: Number of steps (results in n+1 points).

    Returns:
        Tuple of (q, r) arrays for hexes on the line.
    """
    q1 = np.atleast_1d(q1)
    r1 = np.atleast_1d(r1)
    q2 = np.atleast_1d(q2)
    r2 = np.atleast_1d(r2)

    if n <= 0:
        return q1.copy(), r1.copy()

    t = np.linspace(0, 1, n + 1)
    qf = q1[0] * (1 - t) + q2[0] * t
    rf = r1[0] * (1 - t) + r2[0] * t

    rq, rr = round_axial(qf, rf)

    return _deduplicate(rq, rr)

mask(q, r, max_radius)

Boolean mask for coords within max_radius of origin.

Parameters:

Name Type Description Default
q ndarray

Axial q coordinates.

required
r ndarray

Axial r coordinates.

required
max_radius int

Maximum distance from origin.

required

Returns:

Type Description
ndarray

Boolean mask for coordinates within max_radius.

Source code in btorch/utils/hex/distance.py
def mask(q: np.ndarray, r: np.ndarray, max_radius: int) -> np.ndarray:
    """Boolean mask for coords within max_radius of origin.

    Args:
        q: Axial q coordinates.
        r: Axial r coordinates.
        max_radius: Maximum distance from origin.

    Returns:
        Boolean mask for coordinates within max_radius.
    """
    return radius(q, r) <= max_radius

neighbors(q, r)

Get all 6 neighbors.

Parameters:

Name Type Description Default
q ndarray

Axial q coordinates.

required
r ndarray

Axial r coordinates.

required

Returns:

Type Description
tuple[ndarray, ndarray]

Tuple of (q_neighbors, r_neighbors) arrays with shape (6, len(q)).

Source code in btorch/utils/hex/neighbor.py
def neighbors(q: np.ndarray, r: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
    """Get all 6 neighbors.

    Args:
        q: Axial q coordinates.
        r: Axial r coordinates.

    Returns:
        Tuple of (q_neighbors, r_neighbors) arrays with shape (6, len(q)).
    """
    qn = q[None, :] + DIRECTIONS[:, 0:1]
    rn = r[None, :] + DIRECTIONS[:, 1:2]
    return qn, rn

odd_q_to_axial(col, row)

Convert odd-q offset to axial coordinates.

Formula: q = col, r = row - (col - (col & 1)) / 2

Parameters:

Name Type Description Default
col ndarray

Column coordinates.

required
row ndarray

Row coordinates.

required

Returns:

Type Description
tuple[ndarray, ndarray]

Tuple of (q, r) axial coordinates.

Source code in btorch/utils/hex/offset.py
def odd_q_to_axial(col: np.ndarray, row: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
    """Convert odd-q offset to axial coordinates.

    Formula: q = col, r = row - (col - (col & 1)) / 2

    Args:
        col: Column coordinates.
        row: Row coordinates.

    Returns:
        Tuple of (q, r) axial coordinates.
    """
    col = np.asarray(col)
    row = np.asarray(row)
    q = col
    r = row - (col - (col & 1)) // 2
    return q, r

odd_r_to_axial(col, row)

Convert odd-r offset to axial coordinates.

Formula: q = col - (row - (row & 1)) / 2, r = row

Parameters:

Name Type Description Default
col ndarray

Column coordinates.

required
row ndarray

Row coordinates.

required

Returns:

Type Description
tuple[ndarray, ndarray]

Tuple of (q, r) axial coordinates.

Source code in btorch/utils/hex/offset.py
def odd_r_to_axial(col: np.ndarray, row: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
    """Convert odd-r offset to axial coordinates.

    Formula: q = col - (row - (row & 1)) / 2, r = row

    Args:
        col: Column coordinates.
        row: Row coordinates.

    Returns:
        Tuple of (q, r) axial coordinates.
    """
    col = np.asarray(col)
    row = np.asarray(row)
    q = col - (row - (row & 1)) // 2
    r = row
    return q, r

permute(radius, n_rot)

Permutation index for rotating spiral-ordered data by n_rot*60°.

Parameters:

Name Type Description Default
radius int

Grid radius.

required
n_rot int

Number of 60° rotations (positive = clockwise).

required

Returns:

Type Description
ndarray

Permutation indices for rotating data.

Source code in btorch/utils/hex/storage.py
def permute(radius: int, n_rot: int) -> np.ndarray:
    """Permutation index for rotating spiral-ordered data by n_rot*60°.

    Args:
        radius: Grid radius.
        n_rot: Number of 60° rotations (positive = clockwise).

    Returns:
        Permutation indices for rotating data.
    """
    from .coords import spiral
    from .transform import rotate

    # Get spiral-ordered coordinates
    q, r = spiral(radius)

    # Rotate coordinates
    qr, rr = rotate(q, r, n_rot)

    # Find permutation: for each target, find source index
    # Create lookup
    coord_to_idx = {(int(q[i]), int(r[i])): i for i in range(len(q))}

    # Build permutation
    perm = np.zeros(len(q), dtype=np.int64)
    for i, (qt, rt) in enumerate(zip(qr, rr)):
        key = (int(qt), int(rt))
        if key in coord_to_idx:
            perm[i] = coord_to_idx[key]
        else:
            # This shouldn't happen for spiral ordering
            perm[i] = i

    return perm

pixel_to_doubleheight(x, y, size=1.0)

Convert pixel to double-height coordinates.

Parameters:

Name Type Description Default
x ndarray

Pixel x coordinates.

required
y ndarray

Pixel y coordinates.

required
size float

Hexagon size.

1.0

Returns:

Type Description
tuple[ndarray, ndarray]

Tuple of (col, row) double-height coordinates.

Source code in btorch/utils/hex/doubled.py
def pixel_to_doubleheight(
    x: np.ndarray, y: np.ndarray, size: float = 1.0
) -> tuple[np.ndarray, np.ndarray]:
    """Convert pixel to double-height coordinates.

    Args:
        x: Pixel x coordinates.
        y: Pixel y coordinates.
        size: Hexagon size.

    Returns:
        Tuple of (col, row) double-height coordinates.
    """
    x = np.asarray(x)
    y = np.asarray(y)
    col = (2 * x) / (3 * size)
    row = (2 * y) / (np.sqrt(3) * size)
    return col, row

pixel_to_doublewidth(x, y, size=1.0)

Convert pixel to double-width coordinates.

Parameters:

Name Type Description Default
x ndarray

Pixel x coordinates.

required
y ndarray

Pixel y coordinates.

required
size float

Hexagon size.

1.0

Returns:

Type Description
tuple[ndarray, ndarray]

Tuple of (col, row) double-width coordinates.

Source code in btorch/utils/hex/doubled.py
def pixel_to_doublewidth(
    x: np.ndarray, y: np.ndarray, size: float = 1.0
) -> tuple[np.ndarray, np.ndarray]:
    """Convert pixel to double-width coordinates.

    Args:
        x: Pixel x coordinates.
        y: Pixel y coordinates.
        size: Hexagon size.

    Returns:
        Tuple of (col, row) double-width coordinates.
    """
    x = np.asarray(x)
    y = np.asarray(y)
    col = (2 * x) / (np.sqrt(3) * size)
    row = (2 * y) / (3 * size)
    return col, row

radius(q, r)

Distance from origin.

Parameters:

Name Type Description Default
q ndarray

Axial q coordinates.

required
r ndarray

Axial r coordinates.

required

Returns:

Type Description
ndarray

Distance from origin for each coordinate.

Source code in btorch/utils/hex/distance.py
def radius(q: np.ndarray, r: np.ndarray) -> np.ndarray:
    """Distance from origin.

    Args:
        q: Axial q coordinates.
        r: Axial r coordinates.

    Returns:
        Distance from origin for each coordinate.
    """
    return distance(q, r, 0, 0)

range_intersection(centers, radii)

Hexes that are within ALL of the given ranges.

Uses the algebraic approach from Red Blob Games: - Each range is: center-N <= q <= center+N, etc. - Intersection is: max(lows) <= q <= min(highs)

Parameters:

Name Type Description Default
centers list[tuple[int, int]]

List of (q, r) center coordinates.

required
radii list[int]

List of radii (one per center).

required

Returns:

Type Description
tuple[ndarray, ndarray]

Tuple of (q, r) arrays for hexes in the intersection.

Example
Two overlapping disks

centers = [(0, 0), (3, 0)] radii = [2, 2] q, r = range_intersection(centers, radii) len(q) # Hexes within both disks 3

Source code in btorch/utils/hex/range.py
def range_intersection(
    centers: list[tuple[int, int]], radii: list[int]
) -> tuple[np.ndarray, np.ndarray]:
    """Hexes that are within ALL of the given ranges.

    Uses the algebraic approach from Red Blob Games:
    - Each range is: center-N <= q <= center+N, etc.
    - Intersection is: max(lows) <= q <= min(highs)

    Args:
        centers: List of (q, r) center coordinates.
        radii: List of radii (one per center).

    Returns:
        Tuple of (q, r) arrays for hexes in the intersection.

    Example:
        >>> # Two overlapping disks
        >>> centers = [(0, 0), (3, 0)]
        >>> radii = [2, 2]
        >>> q, r = range_intersection(centers, radii)
        >>> len(q)  # Hexes within both disks
        3
    """
    if len(centers) != len(radii):
        raise ValueError("centers and radii must have same length")

    if not centers:
        return np.array([], dtype=int), np.array([], dtype=int)

    # Convert to cube for range calculation
    centers_cube = []
    for q, r in centers:
        s = -q - r
        centers_cube.append((q, r, s))

    # Find intersection bounds in cube coordinates
    q_min = max(c[0] - rad for c, rad in zip(centers_cube, radii))
    q_max = min(c[0] + rad for c, rad in zip(centers_cube, radii))
    r_min = max(c[1] - rad for c, rad in zip(centers_cube, radii))
    r_max = min(c[1] + rad for c, rad in zip(centers_cube, radii))
    s_min = max(c[2] - rad for c, rad in zip(centers_cube, radii))
    s_max = min(c[2] + rad for c, rad in zip(centers_cube, radii))

    # Generate hexes within intersection
    q_list, r_list = [], []
    for q in range(q_min, q_max + 1):
        for r in range(r_min, r_max + 1):
            s = -q - r
            if s_min <= s <= s_max:
                q_list.append(q)
                r_list.append(r)

    return np.array(q_list, dtype=int), np.array(r_list, dtype=int)

range_union(centers, radii)

Hexes that are within ANY of the given ranges.

Parameters:

Name Type Description Default
centers list[tuple[int, int]]

List of (q, r) center coordinates.

required
radii list[int]

List of radii (one per center).

required

Returns:

Type Description
tuple[ndarray, ndarray]

Tuple of (q, r) arrays for hexes in the union.

Example
Two overlapping disks

centers = [(0, 0), (5, 0)] radii = [2, 2] q, r = range_union(centers, radii) len(q) # Hexes in either disk (overlap counted once) 19

Source code in btorch/utils/hex/range.py
def range_union(
    centers: list[tuple[int, int]], radii: list[int]
) -> tuple[np.ndarray, np.ndarray]:
    """Hexes that are within ANY of the given ranges.

    Args:
        centers: List of (q, r) center coordinates.
        radii: List of radii (one per center).

    Returns:
        Tuple of (q, r) arrays for hexes in the union.

    Example:
        >>> # Two overlapping disks
        >>> centers = [(0, 0), (5, 0)]
        >>> radii = [2, 2]
        >>> q, r = range_union(centers, radii)
        >>> len(q)  # Hexes in either disk (overlap counted once)
        19
    """
    if len(centers) != len(radii):
        raise ValueError("centers and radii must have same length")

    if not centers:
        return np.array([], dtype=int), np.array([], dtype=int)

    # Collect all hexes from each range
    all_q, all_r = [], []
    from .coords import disk

    for (q, r), rad in zip(centers, radii):
        dq, dr = disk(rad, q, r)
        all_q.extend(dq.tolist())
        all_r.extend(dr.tolist())

    # Remove duplicates using unique
    all_coords = np.column_stack([all_q, all_r])
    unique_coords = np.unique(all_coords, axis=0)

    if len(unique_coords) == 0:
        return np.array([], dtype=int), np.array([], dtype=int)

    return unique_coords[:, 0], unique_coords[:, 1]

ranges_intersect(center1, radius1, center2, radius2)

Check if two hex ranges intersect.

Parameters:

Name Type Description Default
center1 tuple[int, int]

First center (q, r).

required
radius1 int

First radius.

required
center2 tuple[int, int]

Second center (q, r).

required
radius2 int

Second radius.

required

Returns:

Type Description
bool

True if the ranges intersect, False otherwise.

Example

ranges_intersect((0, 0), 2, (3, 0), 2) True ranges_intersect((0, 0), 1, (5, 0), 1) False

Source code in btorch/utils/hex/range.py
def ranges_intersect(
    center1: tuple[int, int], radius1: int, center2: tuple[int, int], radius2: int
) -> bool:
    """Check if two hex ranges intersect.

    Args:
        center1: First center (q, r).
        radius1: First radius.
        center2: Second center (q, r).
        radius2: Second radius.

    Returns:
        True if the ranges intersect, False otherwise.

    Example:
        >>> ranges_intersect((0, 0), 2, (3, 0), 2)
        True
        >>> ranges_intersect((0, 0), 1, (5, 0), 1)
        False
    """
    from .distance import distance

    q1, r1 = center1
    q2, r2 = center2
    dist = distance(np.array([q1]), np.array([r1]), np.array([q2]), np.array([r2]))[0]

    return bool(dist <= radius1 + radius2)

rect_index_to_axial(row, col, orientation='pointy')

Convert rectangular array indices back to axial.

Parameters:

Name Type Description Default
row ndarray

Array row indices.

required
col ndarray

Array column indices.

required
orientation str

"pointy" or "flat".

'pointy'

Returns:

Type Description
tuple[ndarray, ndarray]

Tuple of (q, r) axial coordinates.

Source code in btorch/utils/hex/storage.py
def rect_index_to_axial(
    row: np.ndarray, col: np.ndarray, orientation: str = "pointy"
) -> tuple[np.ndarray, np.ndarray]:
    """Convert rectangular array indices back to axial.

    Args:
        row: Array row indices.
        col: Array column indices.
        orientation: "pointy" or "flat".

    Returns:
        Tuple of (q, r) axial coordinates.
    """
    row = np.asarray(row)
    col = np.asarray(col)

    if orientation == "pointy":
        r = row
        q = col - np.floor(row / 2).astype(int)
    elif orientation == "flat":
        q = row
        r = col - np.floor(row / 2).astype(int)
    else:
        raise ValueError(f"Unknown orientation: {orientation}")

    return q, r

rectangle(width, height, orientation='pointy')

Rectangular region in axial coords (slanted edges).

Parameters:

Name Type Description Default
width int

Width of rectangle.

required
height int

Height of rectangle.

required
orientation Literal['pointy', 'flat']

Hexagon orientation ("pointy" or "flat").

'pointy'

Returns:

Type Description
tuple[ndarray, ndarray]

Tuple of (q, r) arrays for hex coordinates in rectangle.

Source code in btorch/utils/hex/coords.py
def rectangle(
    width: int, height: int, orientation: Literal["pointy", "flat"] = "pointy"
) -> tuple[np.ndarray, np.ndarray]:
    """Rectangular region in axial coords (slanted edges).

    Args:
        width: Width of rectangle.
        height: Height of rectangle.
        orientation: Hexagon orientation ("pointy" or "flat").

    Returns:
        Tuple of (q, r) arrays for hex coordinates in rectangle.
    """
    q, r = [], []
    for row in range(height):
        for col in range(width):
            if orientation == "pointy":
                # Offset odd rows
                q.append(col - (row // 2))
                r.append(row)
            else:  # flat
                # Offset odd columns
                q.append(col)
                r.append(row - (col // 2))
    return np.array(q), np.array(r)

reflect(q, r, axis)

Reflect across axis.

In cube coordinates: - reflect_q = (q, s, r) - reflect_r = (s, r, q) - reflect_s = (r, q, s)

Parameters:

Name Type Description Default
q ndarray

Axial q coordinates.

required
r ndarray

Axial r coordinates.

required
axis Literal['q', 'r', 's']

Axis to reflect across ("q", "r", or "s").

required

Returns:

Type Description
tuple[ndarray, ndarray]

Reflected coordinates (q, r).

Source code in btorch/utils/hex/transform.py
def reflect(
    q: np.ndarray, r: np.ndarray, axis: Literal["q", "r", "s"]
) -> tuple[np.ndarray, np.ndarray]:
    """Reflect across axis.

    In cube coordinates:
    - reflect_q = (q, s, r)
    - reflect_r = (s, r, q)
    - reflect_s = (r, q, s)

    Args:
        q: Axial q coordinates.
        r: Axial r coordinates.
        axis: Axis to reflect across ("q", "r", or "s").

    Returns:
        Reflected coordinates (q, r).
    """
    s = -q - r
    if axis == "q":
        return q, s
    elif axis == "r":
        return s, r
    elif axis == "s":
        return r, q
    else:
        raise ValueError(f"Unknown axis: {axis}. Must be 'q', 'r', or 's'.")

reflect_index(radius, axis)

Permutation index for reflecting spiral-ordered data.

Parameters:

Name Type Description Default
radius int

Grid radius.

required
axis Literal['q', 'r', 's']

Axis to reflect across ("q", "r", or "s").

required

Returns:

Type Description
ndarray

Permutation indices for reflecting data.

Source code in btorch/utils/hex/storage.py
def reflect_index(radius: int, axis: Literal["q", "r", "s"]) -> np.ndarray:
    """Permutation index for reflecting spiral-ordered data.

    Args:
        radius: Grid radius.
        axis: Axis to reflect across ("q", "r", or "s").

    Returns:
        Permutation indices for reflecting data.
    """
    from .coords import spiral
    from .transform import reflect

    # Get spiral-ordered coordinates
    q, r = spiral(radius)

    # Reflect coordinates
    qr, rr = reflect(q, r, axis)

    # Find permutation
    coord_to_idx = {(int(q[i]), int(r[i])): i for i in range(len(q))}

    perm = np.zeros(len(q), dtype=np.int64)
    for i, (qt, rt) in enumerate(zip(qr, rr)):
        key = (int(qt), int(rt))
        if key in coord_to_idx:
            perm[i] = coord_to_idx[key]
        else:
            perm[i] = i

    return perm

resolve_hex(c1, c2, coord_format='axial', layout='pointy', size=1.0, **layout_kw)

Convert any hex input to axial and pixel coordinates.

Single entry point used by all visualisation functions. Handles the two-stage pipeline: (1) convert input to axial (q, r), (2) project axial to screen (x, y).

Parameters:

Name Type Description Default
c1 ndarray

First coordinate. Meaning depends on coord_format: axial q, zigzag x, pixel x, doubled col, etc.

required
c2 ndarray

Second coordinate. Meaning depends on coord_format.

required
coord_format str

How to interpret c1, c2. One of: "axial", "odd_r", "even_r", "odd_q", "even_q", "doublewidth", "doubleheight", "zigzag" (or "flywire", alias), "cube", "pixel".

'axial'
layout str

Screen projection. One of: "pointy", "flat", "flywire", "pixel".

'pointy'
size float

Hexagon size (center-to-corner distance) for pixel projection.

1.0
**layout_kw Any

Extra args forwarded to the layout projector (e.g. rotation_deg for flywire).

{}

Returns:

Type Description
ndarray

(q, r, x, y) as np.ndarray. q and r are

ndarray

np.nan when coord_format="pixel".

Raises:

Type Description
ValueError

If coord_format or layout is not recognised.

Examples:

Axial input, pointy-top layout:

>>> q, r, x, y = resolve_hex(
...     np.array([0, 1]), np.array([0, 0]),
...     coord_format="axial", layout="pointy",
... )

Zigzag input (FlyWire saved-page data):

>>> q, r, x, y = resolve_hex(
...     zx, zy, coord_format="zigzag", layout="flat",
... )
References

Red Blob Games — Hexagonal Grids: https://www.redblobgames.com/grids/hexagons/

Source code in btorch/utils/hex/resolve.py
def resolve_hex(
    c1: np.ndarray,
    c2: np.ndarray,
    coord_format: str = "axial",
    layout: str = "pointy",
    size: float = 1.0,
    **layout_kw: Any,
) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
    """Convert any hex input to axial and pixel coordinates.

    Single entry point used by all visualisation functions. Handles
    the two-stage pipeline: (1) convert input to axial ``(q, r)``,
    (2) project axial to screen ``(x, y)``.

    Args:
        c1: First coordinate. Meaning depends on ``coord_format``:
            axial q, zigzag x, pixel x, doubled col, etc.
        c2: Second coordinate. Meaning depends on ``coord_format``.
        coord_format: How to interpret ``c1, c2``. One of:
            ``"axial"``, ``"odd_r"``, ``"even_r"``, ``"odd_q"``,
            ``"even_q"``, ``"doublewidth"``, ``"doubleheight"``,
            ``"zigzag"`` (or ``"flywire"``, alias), ``"cube"``,
            ``"pixel"``.
        layout: Screen projection. One of:
            ``"pointy"``, ``"flat"``, ``"flywire"``, ``"pixel"``.
        size: Hexagon size (center-to-corner distance) for pixel
            projection.
        **layout_kw: Extra args forwarded to the layout projector
            (e.g. ``rotation_deg`` for flywire).

    Returns:
        ``(q, r, x, y)`` as ``np.ndarray``.  ``q`` and ``r`` are
        ``np.nan`` when ``coord_format="pixel"``.

    Raises:
        ValueError: If ``coord_format`` or ``layout`` is not recognised.

    Examples:
        Axial input, pointy-top layout:

        >>> q, r, x, y = resolve_hex(
        ...     np.array([0, 1]), np.array([0, 0]),
        ...     coord_format="axial", layout="pointy",
        ... )

        Zigzag input (FlyWire saved-page data):

        >>> q, r, x, y = resolve_hex(
        ...     zx, zy, coord_format="zigzag", layout="flat",
        ... )

    References:
        Red Blob Games — Hexagonal Grids:
        https://www.redblobgames.com/grids/hexagons/
    """
    if coord_format in ("zigzag", "flywire"):
        coord_format = "zigzag"

    if coord_format == "pixel":
        x = np.asarray(c1, dtype=float)
        y = np.asarray(c2, dtype=float)
        return np.full_like(x, np.nan), np.full_like(y, np.nan), x, y

    if coord_format == "zigzag":
        zx = np.asarray(c1, dtype=float)
        zy = np.asarray(c2, dtype=float)
        q, r = zigzag_to_axial(np.rint(zx).astype(int), np.rint(zy).astype(int))
        q = np.asarray(q, dtype=float)
        r = np.asarray(r, dtype=float)
        if layout == "pixel":
            return q, r, zx, zy
        x, y = zigzag_to_pixel(zx, zy, size=size)
        return q, r, np.asarray(x, dtype=float), np.asarray(y, dtype=float)

    if coord_format == "cube":
        q, r = axial_from_cube(
            np.asarray(c1),
            np.asarray(c2),
            np.asarray(layout_kw.pop("c3", -c1 - c2)),
        )
        q, r = np.asarray(q, dtype=float), np.asarray(r, dtype=float)
    else:
        converter = _TO_AXIAL.get(coord_format)
        if converter is None:
            raise ValueError(f"Unknown coord_format: {coord_format}")
        q, r = converter(c1, c2)
        q, r = np.asarray(q, dtype=float), np.asarray(r, dtype=float)

    if layout == "pixel":
        return q, r, q, r

    projector = _TO_PIXEL.get(layout)
    if projector is None:
        raise ValueError(f"Unknown layout: {layout}")
    x, y = projector(q, r, size=size, **layout_kw)
    return q, r, np.asarray(x, dtype=float), np.asarray(y, dtype=float)

rhombus_index_to_axial(row, col)

Convert array indices back to axial for rhombus-shaped map.

Parameters:

Name Type Description Default
row ndarray

Array row indices.

required
col ndarray

Array column indices.

required

Returns:

Type Description
tuple[ndarray, ndarray]

Tuple of (q, r) axial coordinates.

Source code in btorch/utils/hex/storage.py
def rhombus_index_to_axial(
    row: np.ndarray, col: np.ndarray
) -> tuple[np.ndarray, np.ndarray]:
    """Convert array indices back to axial for rhombus-shaped map.

    Args:
        row: Array row indices.
        col: Array column indices.

    Returns:
        Tuple of (q, r) axial coordinates.
    """
    row = np.asarray(row)
    col = np.asarray(col)
    return col, row

ring(radius, center_q=0, center_r=0)

Hexes at exactly radius from center (6*radius hexes, or 1 if radius=0).

Parameters:

Name Type Description Default
radius int

Distance from center.

required
center_q int

Center q coordinate.

0
center_r int

Center r coordinate.

0

Returns:

Type Description
tuple[ndarray, ndarray]

Tuple of (q, r) arrays for hex coordinates on the ring.

Source code in btorch/utils/hex/coords.py
def ring(
    radius: int, center_q: int = 0, center_r: int = 0
) -> tuple[np.ndarray, np.ndarray]:
    """Hexes at exactly `radius` from center (6*radius hexes, or 1 if
    radius=0).

    Args:
        radius: Distance from center.
        center_q: Center q coordinate.
        center_r: Center r coordinate.

    Returns:
        Tuple of (q, r) arrays for hex coordinates on the ring.
    """
    if radius == 0:
        return np.array([center_q]), np.array([center_r])

    q, r = [], []
    # Start at radius steps in the -r direction from center
    q0, r0 = center_q, center_r - radius

    # Walk around the ring in 6 directions
    for dq, dr in [(1, 0), (0, 1), (-1, 1), (-1, 0), (0, -1), (1, -1)]:
        for _ in range(radius):
            q.append(q0)
            r.append(r0)
            q0 += dq
            r0 += dr

    return np.array(q), np.array(r)

rotate(q, r, n)

Rotate by n * 60°. Positive n = clockwise.

Formula: [q,r,s] -> [-r,-s,-q] for 60° cw. Axial: q' = -r, r' = q + r.

Parameters:

Name Type Description Default
q ndarray

Axial q coordinates.

required
r ndarray

Axial r coordinates.

required
n int

Number of 60° rotations (positive = clockwise).

required

Returns:

Type Description
tuple[ndarray, ndarray]

Rotated coordinates (q, r).

Source code in btorch/utils/hex/transform.py
def rotate(q: np.ndarray, r: np.ndarray, n: int) -> tuple[np.ndarray, np.ndarray]:
    """Rotate by n * 60°. Positive n = clockwise.

    Formula: [q,r,s] -> [-r,-s,-q] for 60° cw.
    Axial: q' = -r, r' = q + r.

    Args:
        q: Axial q coordinates.
        r: Axial r coordinates.
        n: Number of 60° rotations (positive = clockwise).

    Returns:
        Rotated coordinates (q, r).
    """
    n = n % 6  # Normalize to 0-5
    for _ in range(n):
        q, r = -r, q + r
    return q, r

round_axial(q, r)

Round fractional axial to nearest hex (RBG algorithm).

Parameters:

Name Type Description Default
q ndarray

Fractional axial q coordinates.

required
r ndarray

Fractional axial r coordinates.

required

Returns:

Type Description
tuple[ndarray, ndarray]

Rounded axial coordinates (q, r).

Source code in btorch/utils/hex/transform.py
def round_axial(q: np.ndarray, r: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
    """Round fractional axial to nearest hex (RBG algorithm).

    Args:
        q: Fractional axial q coordinates.
        r: Fractional axial r coordinates.

    Returns:
        Rounded axial coordinates (q, r).
    """
    s = -q - r
    rq, rr, _ = _cube_round(q, r, s)
    return rq.astype(q.dtype), rr.astype(r.dtype)

spiral(radius, center_q=0, center_r=0)

Hexes in spiral order: center, ring1, ring2, ..., ring_radius.

This ordering is stable under rotation (permutes by ring).

Parameters:

Name Type Description Default
radius int

Maximum distance from center.

required
center_q int

Center q coordinate.

0
center_r int

Center r coordinate.

0

Returns:

Type Description
tuple[ndarray, ndarray]

Tuple of (q, r) arrays in spiral order.

Source code in btorch/utils/hex/coords.py
def spiral(
    radius: int, center_q: int = 0, center_r: int = 0
) -> tuple[np.ndarray, np.ndarray]:
    """Hexes in spiral order: center, ring1, ring2, ..., ring_radius.

    This ordering is stable under rotation (permutes by ring).

    Args:
        radius: Maximum distance from center.
        center_q: Center q coordinate.
        center_r: Center r coordinate.

    Returns:
        Tuple of (q, r) arrays in spiral order.
    """
    q_all, r_all = [center_q], [center_r]

    for r in range(1, radius + 1):
        qr, rr = ring(r, center_q, center_r)
        q_all.extend(qr.tolist())
        r_all.extend(rr.tolist())

    return np.array(q_all), np.array(r_all)

to_pixel(q, r, size=1.0, orientation='pointy')

Axial hex to pixel.

Pointy: x = size * (sqrt(3)q + sqrt(3)/2r), y = size * (3/2r) Flat: x = size * (3/2q), y = size * (sqrt(3)/2q + sqrt(3)r)

Parameters:

Name Type Description Default
q ndarray

Axial q coordinates.

required
r ndarray

Axial r coordinates.

required
size float

Hexagon size (distance from center to corner).

1.0
orientation Orientation

"pointy" (point up) or "flat" (flat side up).

'pointy'

Returns:

Type Description
tuple[ndarray, ndarray]

Tuple of (x, y) pixel coordinates.

See Also

https://www.redblobgames.com/grids/hexagons/#hex-to-pixel

Source code in btorch/utils/hex/transform.py
def to_pixel(
    q: np.ndarray,
    r: np.ndarray,
    size: float = 1.0,
    orientation: Orientation = "pointy",
) -> tuple[np.ndarray, np.ndarray]:
    """Axial hex to pixel.

    Pointy: x = size * (sqrt(3)*q + sqrt(3)/2*r), y = size * (3/2*r)
    Flat:   x = size * (3/2*q), y = size * (sqrt(3)/2*q + sqrt(3)*r)

    Args:
        q: Axial q coordinates.
        r: Axial r coordinates.
        size: Hexagon size (distance from center to corner).
        orientation: "pointy" (point up) or "flat" (flat side up).

    Returns:
        Tuple of (x, y) pixel coordinates.

    See Also:
        https://www.redblobgames.com/grids/hexagons/#hex-to-pixel
    """
    if orientation == "pointy":
        x = size * (np.sqrt(3) * q + np.sqrt(3) / 2 * r)
        y = size * (3.0 / 2 * r)
    elif orientation == "flat":
        x = size * (3.0 / 2 * q)
        y = size * (np.sqrt(3) / 2 * q + np.sqrt(3) * r)
    else:
        raise ValueError(f"Unknown orientation: {orientation}")
    return x, y

triangle_index_to_axial(row, col, size, pointing='down')

Convert array indices back to axial for triangle-shaped map.

Parameters:

Name Type Description Default
row ndarray

Array row indices.

required
col ndarray

Array column indices.

required
size int

Triangle size (N).

required
pointing str

"down" or "up".

'down'

Returns:

Type Description
tuple[ndarray, ndarray]

Tuple of (q, r) axial coordinates.

Source code in btorch/utils/hex/storage.py
def triangle_index_to_axial(
    row: np.ndarray, col: np.ndarray, size: int, pointing: str = "down"
) -> tuple[np.ndarray, np.ndarray]:
    """Convert array indices back to axial for triangle-shaped map.

    Args:
        row: Array row indices.
        col: Array column indices.
        size: Triangle size (N).
        pointing: "down" or "up".

    Returns:
        Tuple of (q, r) axial coordinates.
    """
    row = np.asarray(row)
    col = np.asarray(col)

    if pointing == "down":
        r = row
        q = col
    elif pointing == "up":
        r = row
        q = col + size - 1 - r
    else:
        raise ValueError(f"Unknown pointing: {pointing}")

    return q, r

within_range(q, r, center_q, center_r, n)

Boolean mask for coords within n steps of center.

Parameters:

Name Type Description Default
q ndarray

Axial q coordinates.

required
r ndarray

Axial r coordinates.

required
center_q int

Center q coordinate.

required
center_r int

Center r coordinate.

required
n int

Maximum distance from center.

required

Returns:

Type Description
ndarray

Boolean mask for coordinates within range.

Source code in btorch/utils/hex/distance.py
def within_range(
    q: np.ndarray, r: np.ndarray, center_q: int, center_r: int, n: int
) -> np.ndarray:
    """Boolean mask for coords within n steps of center.

    Args:
        q: Axial q coordinates.
        r: Axial r coordinates.
        center_q: Center q coordinate.
        center_r: Center r coordinate.
        n: Maximum distance from center.

    Returns:
        Boolean mask for coordinates within range.
    """
    return distance(q, r, center_q, center_r) <= n

zigzag_to_axial(x, y)

Convert zigzag offset coordinates back to axial.

Formula: q = floor((y - 2*x) / 2), r = y - q

Parameters:

Name Type Description Default
x ndarray

Zigzag column coordinates.

required
y ndarray

Zigzag row coordinates.

required

Returns:

Type Description
tuple[ndarray, ndarray]

Tuple of (q, r) axial coordinates.

Example

zigzag_to_axial(np.array([0, 0, 0]), np.array([0, 1, -1])) (array([0, 1, -1]), array([0, 0, 0]))

Source code in btorch/utils/hex/offset.py
def zigzag_to_axial(x: np.ndarray, y: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
    """Convert zigzag offset coordinates back to axial.

    Formula: q = floor((y - 2*x) / 2), r = y - q

    Args:
        x: Zigzag column coordinates.
        y: Zigzag row coordinates.

    Returns:
        Tuple of (q, r) axial coordinates.

    Example:
        >>> zigzag_to_axial(np.array([0, 0, 0]), np.array([0, 1, -1]))
        (array([0, 1, -1]), array([0, 0, 0]))
    """
    x = np.asarray(x)
    y = np.asarray(y)
    q = np.floor((y - 2 * x) / 2).astype(int)
    r = y - q
    return q, r

zigzag_to_pixel(x, y, size=1.0)

Convert zigzag display coordinates to flat-top pixel space.

FlyWire website-style lattice display is a staggered-column layout where every second column is vertically shifted by half a cell.

Formula

pixel_x = 1.5 * size * x pixel_y = sqrt(3) * size * (y + 0.5 * (x mod 2))

Source code in btorch/utils/hex/offset.py
def zigzag_to_pixel(
    x: np.ndarray, y: np.ndarray, size: float = 1.0
) -> tuple[np.ndarray, np.ndarray]:
    """Convert zigzag display coordinates to flat-top pixel space.

    FlyWire website-style lattice display is a staggered-column layout where
    every second column is vertically shifted by half a cell.

    Formula:
        pixel_x = 1.5 * size * x
        pixel_y = sqrt(3) * size * (y + 0.5 * (x mod 2))
    """
    x = np.asarray(x)
    y = np.asarray(y)
    parity = np.mod(x, 2)
    pixel_x = 1.5 * size * x
    pixel_y = np.sqrt(3.0) * size * (y + 0.5 * parity)
    return pixel_x.astype(float), pixel_y.astype(float)

btorch.utils.pandas_utils

Pandas DataFrame utilities.

Helpers for common DataFrame operations used in connectome analysis and data aggregation workflows.

Functions

groupby_to_dict(df, column_select=None, **groupby_args)

Group DataFrame and return as dictionary mapping keys to subframes.

Parameters:

Name Type Description Default
df DataFrame

Input DataFrame to group.

required
column_select Optional[Sequence[str]]

Optional column subset to include in output values.

None
**groupby_args Any

Arguments passed to df.groupby().

{}

Returns:

Type Description
dict[Any, DataFrame]

Dictionary mapping group keys to DataFrame slices.

Example

df = pd.DataFrame({"a": [1, 1, 2], "b": [3, 4, 5]}) groupby_to_dict(df, column_select=["b"], by="a") {1: b 0 3 1 4, 2: b 2 5}

Source code in btorch/utils/pandas_utils.py
def groupby_to_dict(
    df: pd.DataFrame,
    column_select: Optional[Sequence[str]] = None,
    **groupby_args: Any,
) -> dict[Any, pd.DataFrame]:
    """Group DataFrame and return as dictionary mapping keys to subframes.

    Args:
        df: Input DataFrame to group.
        column_select: Optional column subset to include in output values.
        **groupby_args: Arguments passed to ``df.groupby()``.

    Returns:
        Dictionary mapping group keys to DataFrame slices.

    Example:
        >>> df = pd.DataFrame({"a": [1, 1, 2], "b": [3, 4, 5]})
        >>> groupby_to_dict(df, column_select=["b"], by="a")
        {1:    b
         0  3
         1  4, 2:    b
         2  5}
    """
    return {
        key: df.loc[ind, column_select] if column_select is not None else df.loc[ind]
        for key, ind in df.groupby(**groupby_args).groups.items()
    }

btorch.utils.yaml_utils

YAML serialization utilities.

Simple helpers for loading and saving Python objects to YAML files, with automatic directory creation.

Functions

load_yaml(folder_or_file, filename=None)

Load object from YAML file.

Parameters:

Name Type Description Default
folder_or_file str

Directory path if filename is provided, otherwise full file path.

required
filename str | None

Optional filename when folder_or_file is a directory.

None

Returns:

Type Description
Any

Deserialized Python object.

Source code in btorch/utils/yaml_utils.py
def load_yaml(folder_or_file: str, filename: "str | None" = None) -> Any:
    """Load object from YAML file.

    Args:
        folder_or_file: Directory path if ``filename`` is provided,
            otherwise full file path.
        filename: Optional filename when ``folder_or_file``
            is a directory.

    Returns:
        Deserialized Python object.
    """
    file = (
        folder_or_file if filename is None else os.path.join(folder_or_file, filename)
    )
    with open(file, "r") as f:
        return yaml.safe_load(f)

save_yaml(args, folder_or_file, filename=None)

Save object to YAML file.

Parameters:

Name Type Description Default
args Any

Object to serialize. Tries safe_dump first, falls back to dumping args.__dict__.

required
folder_or_file str

Directory path if filename is provided, otherwise full file path.

required
filename str | None

Optional filename when folder_or_file is a directory.

None
Source code in btorch/utils/yaml_utils.py
def save_yaml(args: Any, folder_or_file: str, filename: "str | None" = None) -> None:
    """Save object to YAML file.

    Args:
        args: Object to serialize. Tries ``safe_dump`` first, falls back
            to dumping ``args.__dict__``.
        folder_or_file: Directory path if ``filename`` is provided,
            otherwise full file path.
        filename: Optional filename when ``folder_or_file``
            is a directory.
    """
    try:
        args_text = yaml.safe_dump(args)
    except Exception:
        args_text = yaml.dump(args.__dict__)

    folder = os.path.dirname(folder_or_file) if filename is None else folder_or_file
    os.makedirs(folder, exist_ok=True)
    file = (
        folder_or_file if filename is None else os.path.join(folder_or_file, filename)
    )
    with open(file, "w") as f:
        f.write(args_text)