Skip to content

Utilities & Logging

WebATM.utils

WebATM.utils

Utility functions for WebATM.

make_json_serializable

make_json_serializable(obj)

Convert an object to a JSON-serializable format.

Recursively converts numpy arrays and scalars, dictionaries (including BlueSky's msgpack-serialized numpy arrays, identified by the numpy, data, type and shape byte keys), lists, tuples, and arbitrary objects (via vars()) into plain Python types that json.dumps can handle. Byte dictionary keys are decoded to strings.

Parameters:

Name Type Description Default
obj Any

The object to convert. May be a numpy array/scalar, dict, list, tuple, or any object exposing __dict__.

required

Returns:

Type Description
Any

A JSON-serializable equivalent of obj (list, dict, int, float, str, or the object itself if already serializable).

Source code in WebATM/utils.py
def make_json_serializable(obj):
    """Convert an object to a JSON-serializable format.

    Recursively converts numpy arrays and scalars, dictionaries (including
    BlueSky's msgpack-serialized numpy arrays, identified by the ``numpy``,
    ``data``, ``type`` and ``shape`` byte keys), lists, tuples, and arbitrary
    objects (via ``vars()``) into plain Python types that ``json.dumps`` can
    handle. Byte dictionary keys are decoded to strings.

    Args:
        obj (Any): The object to convert. May be a numpy array/scalar, dict, list,
            tuple, or any object exposing ``__dict__``.

    Returns:
        Any: A JSON-serializable equivalent of ``obj`` (list, dict, int,
            float, str, or the object itself if already serializable).
    """
    if isinstance(obj, np.ndarray):
        return obj.tolist()
    elif isinstance(obj, np.integer):
        return int(obj)
    elif isinstance(obj, np.floating):
        return float(obj)
    elif isinstance(obj, dict):
        # Handle BlueSky's serialized numpy arrays
        if b"numpy" in obj and b"data" in obj and b"type" in obj and b"shape" in obj:
            try:
                # This is a BlueSky serialized numpy array - deserialize it
                import struct

                dtype = (
                    obj[b"type"].decode()
                    if isinstance(obj[b"type"], bytes)
                    else obj[b"type"]
                )
                shape = obj[b"shape"]
                data_bytes = obj[b"data"]

                # Convert numpy dtype string to struct format
                dtype_map = {
                    "<f8": "d",  # double
                    "<f4": "f",  # float
                    "<i8": "q",  # long long
                    "<i4": "i",  # int
                    "|b1": "?",  # bool
                }

                if dtype in dtype_map:
                    format_char = dtype_map[dtype]
                    num_elements = 1
                    for dim in shape:
                        num_elements *= dim

                    # Unpack the binary data
                    values = list(
                        struct.unpack(f"<{num_elements}{format_char}", data_bytes)
                    )

                    # Return as list for JSON serialization
                    return values
                else:
                    logger.warning(
                        f"Utils: Unknown numpy dtype {dtype}, returning raw data"
                    )
                    return obj[b"data"].hex()  # Return as hex string if we can't parse

            except Exception as e:
                logger.warning(f"Utils: Error deserializing numpy array: {e}")
                # Fall back to converting dict normally
                pass

        # Normal dict processing
        return {
            (key.decode() if isinstance(key, bytes) else key): make_json_serializable(
                value
            )
            for key, value in obj.items()
        }
    elif isinstance(obj, (list, tuple)):
        return [make_json_serializable(item) for item in obj]
    elif hasattr(obj, "__dict__"):
        try:
            return make_json_serializable(vars(obj))
        except Exception:
            return str(obj)
    else:
        return obj

empty_traffic_data

empty_traffic_data()

Return a fresh empty ACDATA payload for clearing all aircraft.

Emitted whenever the map must drop stale traffic (simulation reset, active-node change, or server disconnect). A new dict is returned on each call so callers can cache or mutate it without sharing state.

Returns:

Type Description
dict

An acdata payload with empty per-field arrays and zeroed conflict/LOS counters.

Source code in WebATM/utils.py
def empty_traffic_data():
    """Return a fresh empty ACDATA payload for clearing all aircraft.

    Emitted whenever the map must drop stale traffic (simulation reset,
    active-node change, or server disconnect). A new dict is returned on each
    call so callers can cache or mutate it without sharing state.

    Returns:
        dict: An ``acdata`` payload with empty per-field arrays and zeroed
            conflict/LOS counters.
    """
    return {
        "id": [],
        "lat": [],
        "lon": [],
        "alt": [],
        "actype": [],  # only sent by bluesky/amvlab
        "tas": [],
        "trk": [],
        "vs": [],
        "inconf": [],
        "tcpamax": [],
        "nconf_cur": 0,
        "nconf_tot": 0,
        "nlos_cur": 0,
        "nlos_tot": 0,
    }

id2str

id2str(node_id)

Convert a BlueSky node/sender ID to its hex-string form.

Node IDs arrive from the network as raw bytes; WebATM keys its tracking maps and Socket.IO payloads by the hex-string form.

Parameters:

Name Type Description Default
node_id bytes | str | None

Raw node/sender identifier.

required

Returns:

Type Description
str | None

Hex string for bytes input, str(node_id) for other non-None values, or None.

Source code in WebATM/utils.py
def id2str(node_id):
    """Convert a BlueSky node/sender ID to its hex-string form.

    Node IDs arrive from the network as raw bytes; WebATM keys its tracking
    maps and Socket.IO payloads by the hex-string form.

    Args:
        node_id (bytes | str | None): Raw node/sender identifier.

    Returns:
        str | None: Hex string for bytes input, ``str(node_id)`` for other
            non-None values, or None.
    """
    if isinstance(node_id, bytes):
        return node_id.hex()
    return str(node_id) if node_id is not None else None

i2txt

i2txt(i, n)

Convert an integer to a zero-padded string of fixed width.

Parameters:

Name Type Description Default
i int

The integer to format.

required
n int

The total width of the resulting string.

required

Returns:

Type Description
str

i rendered with leading zeros to exactly n characters.

Source code in WebATM/utils.py
def i2txt(i, n):
    """Convert an integer to a zero-padded string of fixed width.

    Args:
        i (int): The integer to format.
        n (int): The total width of the resulting string.

    Returns:
        str: ``i`` rendered with leading zeros to exactly ``n`` characters.
    """
    return f"{i:0{n}d}"

tim2txt

tim2txt(t)

Convert a time value in seconds to an HH:MM:SS.hh string.

Parameters:

Name Type Description Default
t float

Time in seconds (e.g. simulation time).

required

Returns:

Type Description
str

The formatted time string with hundredths of a second.

Source code in WebATM/utils.py
def tim2txt(t):
    """Convert a time value in seconds to an ``HH:MM:SS.hh`` string.

    Args:
        t (float): Time in seconds (e.g. simulation time).

    Returns:
        str: The formatted time string with hundredths of a second.
    """
    return strftime("%H:%M:%S.", gmtime(t)) + i2txt(int((t - int(t)) * 100.0), 2)

WebATM.logger

WebATM.logger

Provide centralized logging configuration for WebATM.

Provides standardized logging similar to TypeScript logging with:

  • Different log levels (DEBUG, INFO, WARNING, ERROR, CRITICAL)
  • Automatic filename prefixes: [FileName] log information
  • Consistent formatting across all Python modules

FileNameFormatter

Bases: Formatter

Custom formatter that adds a filename prefix to log messages.

format

format(record)

Format a log record, prefixing the message with its source filename.

Werkzeug (Flask's HTTP server) records are prefixed with [Werkzeug]; all other records use the CamelCased stem of the source file name. The record is restored afterwards, so a record that passes through several handlers (e.g. console and file) is prefixed exactly once per output.

Parameters:

Name Type Description Default
record LogRecord

The log record to format.

required

Returns:

Type Description
str

The formatted log message.

Source code in WebATM/logger.py
def format(self, record):
    """Format a log record, prefixing the message with its source filename.

    Werkzeug (Flask's HTTP server) records are prefixed with ``[Werkzeug]``;
    all other records use the CamelCased stem of the source file name. The
    record is restored afterwards, so a record that passes through several
    handlers (e.g. console and file) is prefixed exactly once per output.

    Args:
        record (logging.LogRecord): The log record to format.

    Returns:
        str: The formatted log message.
    """
    if record.name == "werkzeug":
        filename = "Werkzeug"
    else:
        stem = Path(record.pathname).stem
        filename = stem.replace("_", " ").title().replace(" ", "")

    original_msg = record.msg
    record.msg = f"[{filename}] {record.msg}"
    try:
        return super().format(record)
    finally:
        record.msg = original_msg

configure_logging

configure_logging(
    level: int = logging.INFO,
    log_file: str | None = None,
    include_console: bool = True,
)

Configure global logging settings for WebATM.

Resets the WebATM root logger and attaches console and/or file handlers using the shared :class:FileNameFormatter. Module loggers from :func:get_logger delegate their level to this root logger, so calling this again (e.g. to switch to DEBUG at runtime) takes effect everywhere, including loggers created before the call.

Parameters:

Name Type Description Default
level int

Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL).

INFO
log_file str | None

Optional file path to write logs to.

None
include_console bool

Whether to include console output.

True
Source code in WebATM/logger.py
def configure_logging(
    level: int = logging.INFO,
    log_file: str | None = None,
    include_console: bool = True,
):
    """Configure global logging settings for WebATM.

    Resets the ``WebATM`` root logger and attaches console and/or file
    handlers using the shared :class:`FileNameFormatter`. Module loggers from
    :func:`get_logger` delegate their level to this root logger, so calling
    this again (e.g. to switch to DEBUG at runtime) takes effect everywhere,
    including loggers created before the call.

    Args:
        level (int): Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL).
        log_file (str | None): Optional file path to write logs to.
        include_console (bool): Whether to include console output.
    """
    root_logger = logging.getLogger("WebATM")
    root_logger.setLevel(level)
    root_logger.handlers.clear()

    formatter = FileNameFormatter(_log_format, datefmt=_date_format)

    if include_console:
        console_handler = logging.StreamHandler(sys.stdout)
        console_handler.setFormatter(formatter)
        root_logger.addHandler(console_handler)

    if log_file:
        file_handler = logging.FileHandler(log_file)
        file_handler.setFormatter(formatter)
        root_logger.addHandler(file_handler)

get_logger

get_logger(name: str | None = None) -> logging.Logger

Get or create a logger for a module.

The returned logger is a child of the WebATM root logger and carries no level of its own, so it always follows the level set by :func:configure_logging — including changes made after it was created. logging.getLogger caches by name, so repeated calls with the same name return the same instance.

Parameters:

Name Type Description Default
name str | None

Optional custom name for the logger. If not provided, uses the calling module's filename.

None

Returns:

Type Description
Logger

A configured logger instance.

Example

logger = get_logger() logger.info("Starting process") 2025-11-06 10:30:45 - INFO - [Main] Starting process

Source code in WebATM/logger.py
def get_logger(name: str | None = None) -> logging.Logger:
    """Get or create a logger for a module.

    The returned logger is a child of the ``WebATM`` root logger and carries
    no level of its own, so it always follows the level set by
    :func:`configure_logging` — including changes made after it was created.
    ``logging.getLogger`` caches by name, so repeated calls with the same name
    return the same instance.

    Args:
        name (str | None): Optional custom name for the logger. If not
            provided, uses the calling module's filename.

    Returns:
        logging.Logger: A configured logger instance.

    Example:
        >>> logger = get_logger()
        >>> logger.info("Starting process")
        2025-11-06 10:30:45 - INFO - [Main] Starting process
    """
    if name is None:
        frame = inspect.currentframe()
        if frame and frame.f_back:
            caller_filename = frame.f_back.f_globals.get("__file__", "Unknown")
            name = Path(caller_filename).stem

    return logging.getLogger(f"WebATM.{name}")

WebATM.proxy.perf

WebATM.proxy.perf

Opt-in performance instrumentation for the proxy ACDATA hot path.

Serializing every ACDATA frame (make_json_serializable in pure Python) is the dominant per-frame CPU cost under heavy node load. This module measures it so you can see whether the single -w 1 worker is saturating, and quantifies the saving from only serializing frames that are actually emitted.

Disabled by default; set WEBATM_PERF=1 to enable. When off, each record_* call is a single boolean check, so the hot path pays effectively nothing. A one-line summary is logged every WEBATM_PERF_INTERVAL seconds (default 5)::

[Perf] acdata 5.0s | recv=250 filtered=200 emit=48 throttled=2 |
serialize avg=3.10ms max=8.40ms | emit avg=0.90ms | datapath cpu=0.4% |
projected pre-opt cpu~=15.8% | max emit gap=140ms

datapath cpu is the wall-clock share this worker spent serializing/emitting ACDATA; projected pre-opt cpu estimates the old serialize-every-frame cost (one serialize per received frame) so a single run shows the before/after.

DataPathPerf

DataPathPerf()

Accumulate ACDATA serialize/emit timings and log periodic summaries.

All record_* methods are cheap no-ops unless WEBATM_PERF=1 is set in the environment, so instrumentation can stay in the hot path.

Attributes:

Name Type Description
enabled bool

Whether instrumentation is active (WEBATM_PERF=1).

interval float

Seconds between logged summaries (WEBATM_PERF_INTERVAL, minimum 1.0, default 5.0).

Source code in WebATM/proxy/perf.py
def __init__(self) -> None:
    self.enabled = os.environ.get("WEBATM_PERF") == "1"
    try:
        self.interval = max(1.0, float(os.environ.get("WEBATM_PERF_INTERVAL", "5")))
    except ValueError:
        self.interval = 5.0
    self._window_start = time.time()
    self._last_emit_wall: float | None = None
    self._max_emit_gap = 0.0
    self._reset_counters()

record_received

record_received() -> None

Count one ACDATA frame arriving at the handler (any node).

Source code in WebATM/proxy/perf.py
def record_received(self) -> None:
    """Count one ACDATA frame arriving at the handler (any node)."""
    if self.enabled:
        self.received += 1

record_filtered

record_filtered() -> None

Count one frame dropped by the active-node filter.

Source code in WebATM/proxy/perf.py
def record_filtered(self) -> None:
    """Count one frame dropped by the active-node filter."""
    if self.enabled:
        self.filtered += 1

record_serialize

record_serialize(seconds: float) -> None

Accumulate the wall-clock time of one frame serialization.

Parameters:

Name Type Description Default
seconds float

Time spent in make_json_serializable.

required
Source code in WebATM/proxy/perf.py
def record_serialize(self, seconds: float) -> None:
    """Accumulate the wall-clock time of one frame serialization.

    Args:
        seconds (float): Time spent in ``make_json_serializable``.
    """
    if not self.enabled:
        return
    self.serialize_s += seconds
    if seconds > self.serialize_max_s:
        self.serialize_max_s = seconds

record_emit

record_emit(seconds: float) -> None

Accumulate the wall-clock time of one Socket.IO emit.

Also tracks the maximum gap between consecutive emits, which surfaces stalls in the data path.

Parameters:

Name Type Description Default
seconds float

Time spent emitting the serialized frame.

required
Source code in WebATM/proxy/perf.py
def record_emit(self, seconds: float) -> None:
    """Accumulate the wall-clock time of one Socket.IO emit.

    Also tracks the maximum gap between consecutive emits, which surfaces
    stalls in the data path.

    Args:
        seconds (float): Time spent emitting the serialized frame.
    """
    if not self.enabled:
        return
    self.emits += 1
    self.emit_s += seconds
    now = time.time()
    if self._last_emit_wall is not None:
        gap = now - self._last_emit_wall
        if gap > self._max_emit_gap:
            self._max_emit_gap = gap
    self._last_emit_wall = now

maybe_log

maybe_log() -> None

Log a one-line summary and reset counters when the window elapsed.

Called from the data path after each frame; does nothing until interval seconds have passed since the last summary.

Source code in WebATM/proxy/perf.py
def maybe_log(self) -> None:
    """Log a one-line summary and reset counters when the window elapsed.

    Called from the data path after each frame; does nothing until
    ``interval`` seconds have passed since the last summary.
    """
    if not self.enabled:
        return
    now = time.time()
    elapsed = now - self._window_start
    if elapsed < self.interval:
        return
    if self.received:
        throttled = max(0, self.received - self.filtered - self.emits)
        avg_ser_s = self.serialize_s / self.emits if self.emits else 0.0
        avg_emit_ms = 1000.0 * self.emit_s / self.emits if self.emits else 0.0
        datapath_cpu = 100.0 * (self.serialize_s + self.emit_s) / elapsed
        # Estimate the pre-optimization cost: one serialize per received
        # frame (no throttle, no active-node filter), with the same emits.
        projected_cpu = 100.0 * (avg_ser_s * self.received + self.emit_s) / elapsed
        logger.info(
            "acdata %.1fs | recv=%d filtered=%d emit=%d throttled=%d | "
            "serialize avg=%.2fms max=%.2fms | emit avg=%.2fms | "
            "datapath cpu=%.1f%% | projected pre-opt cpu~=%.1f%% | "
            "max emit gap=%.0fms",
            elapsed,
            self.received,
            self.filtered,
            self.emits,
            throttled,
            1000.0 * avg_ser_s,
            1000.0 * self.serialize_max_s,
            avg_emit_ms,
            datapath_cpu,
            projected_cpu,
            1000.0 * self._max_emit_gap,
        )
    self._window_start = now
    self._max_emit_gap = 0.0
    self._reset_counters()