dimensioned export (gwy, HDF5)

This commit is contained in:
2026-04-05 13:28:26 -07:00
parent 0f9b500c34
commit 08aff81f02
11 changed files with 1121 additions and 313 deletions

182
backend/exporters/line.py Normal file
View File

@@ -0,0 +1,182 @@
"""
Exporter for LINE values (1-D profiles as LineData or bare ndarrays).
PNG / TIFF render a plot image via Pillow; CSV / JSON / NPZ save the raw
(x, y, unit) arrays. The plot renderer is self-contained (no matplotlib
dependency) and handles SI-prefix axis labels.
"""
from __future__ import annotations
import csv
import json
from pathlib import Path
import numpy as np
from backend.data_types import LineData, _PREFIXABLE_UNITS, _SI_PREFIXES
from backend.exporters._base import FormatSpec
accepted_types: tuple[str, ...] = ("LINE",)
FORMATS: dict[str, FormatSpec] = {
"PNG": FormatSpec(ext=".png", round_trip=False, label="PNG plot"),
"TIFF": FormatSpec(ext=".tiff", round_trip=False, label="TIFF plot"),
"CSV": FormatSpec(ext=".csv", round_trip=True, label="CSV"),
"NPZ": FormatSpec(ext=".npz", round_trip=False, label="NumPy (.npz)"),
"JSON": FormatSpec(ext=".json", round_trip=True, label="JSON"),
}
def save(path: Path, value, format_name: str, *, plot_title: str = "", **_opts) -> None:
line = value if isinstance(value, LineData) else LineData(data=np.asarray(value).ravel())
y = np.asarray(line.data, dtype=np.float64).ravel()
if line.x_axis is not None:
x = np.asarray(line.x_axis, dtype=np.float64).ravel()[: len(y)]
else:
x = np.arange(len(y), dtype=np.float64)
if format_name in ("PNG", "TIFF"):
_save_line_plot(path, x, y, line.x_unit, line.y_unit, plot_title, format_name)
return
if format_name == "CSV":
with path.open("w", newline="", encoding="utf-8") as fh:
writer = csv.writer(fh)
writer.writerow(["x", "y", "x_unit", "y_unit"])
for xv, yv in zip(x, y):
writer.writerow([xv, yv, line.x_unit, line.y_unit])
return
if format_name == "NPZ":
np.savez(str(path), x=x, y=y)
return
if format_name == "JSON":
path.write_text(
json.dumps({
"x": x.tolist(),
"y": y.tolist(),
"x_unit": line.x_unit,
"y_unit": line.y_unit,
}, indent=2),
encoding="utf-8",
)
return
raise ValueError(f"Format {format_name!r} is not supported for LINE.")
def _save_line_plot(
path: Path,
x: np.ndarray,
y: np.ndarray,
x_unit: str,
y_unit: str,
title: str,
format_name: str,
) -> None:
"""Render a simple PNG/TIFF line plot with SI-prefixed axes.
Intentionally self-contained (Pillow only, no matplotlib) so that builds
stay lean. Layout is fixed 1200×750 with 5×5 grid and a single blue line.
"""
from PIL import Image, ImageDraw, ImageFont
w, h = 1200, 750
bg = (255, 255, 255)
line_color = (79, 142, 247) # #4f8ef7
grid_color = (200, 200, 200)
text_color = (60, 60, 60)
margin = {"left": 80, "right": 30, "top": 50, "bottom": 60}
img = Image.new("RGB", (w, h), bg)
draw = ImageDraw.Draw(img)
try:
font = ImageFont.truetype("DejaVuSans.ttf", 14)
font_small = ImageFont.truetype("DejaVuSans.ttf", 11)
font_title = ImageFont.truetype("DejaVuSans.ttf", 16)
except (OSError, IOError):
font = ImageFont.load_default()
font_small = font
font_title = font
pw = w - margin["left"] - margin["right"]
ph = h - margin["top"] - margin["bottom"]
def _si_scale(unit: str, vmin: float, vmax: float) -> tuple[float, str]:
"""Pick the best SI prefix for an axis range. Returns (divisor, prefixed_unit)."""
unit = (unit or "").strip()
if not unit or unit not in _PREFIXABLE_UNITS:
return 1.0, unit if unit else ""
peak = max(abs(vmin), abs(vmax))
if peak == 0:
return 1.0, unit
for scale, prefix in _SI_PREFIXES:
if peak / scale >= 1.0:
return scale, f"{prefix}{unit}"
return _SI_PREFIXES[-1][0], f"{_SI_PREFIXES[-1][1]}{unit}"
xmin, xmax = float(np.nanmin(x)), float(np.nanmax(x))
ymin, ymax = float(np.nanmin(y)), float(np.nanmax(y))
x_scale, x_label = _si_scale(x_unit, xmin, xmax)
y_scale, y_label = _si_scale(y_unit, ymin, ymax)
if not x_label:
x_label = "x"
if not y_label:
y_label = "y"
x = x / x_scale
y = y / y_scale
xmin, xmax = xmin / x_scale, xmax / x_scale
ymin, ymax = ymin / y_scale, ymax / y_scale
if ymax == ymin:
ymin, ymax = ymin - 1, ymax + 1
if xmax == xmin:
xmax = xmin + 1
ypad = (ymax - ymin) * 0.05
ymin -= ypad
ymax += ypad
def to_px(xv: float, yv: float) -> tuple[float, float]:
px = margin["left"] + (xv - xmin) / (xmax - xmin) * pw
py = margin["top"] + (1.0 - (yv - ymin) / (ymax - ymin)) * ph
return px, py
for i in range(6):
gy = ymin + (ymax - ymin) * i / 5
_, py = to_px(xmin, gy)
draw.line([(margin["left"], py), (margin["left"] + pw, py)], fill=grid_color, width=1)
label = f"{gy:.4g}"
draw.text((margin["left"] - 8, py - 6), label, fill=text_color, font=font_small, anchor="rm")
gx = xmin + (xmax - xmin) * i / 5
px, _ = to_px(gx, ymin)
draw.line([(px, margin["top"]), (px, margin["top"] + ph)], fill=grid_color, width=1)
label = f"{gx:.4g}"
draw.text((px, margin["top"] + ph + 6), label, fill=text_color, font=font_small, anchor="mt")
n = len(y)
step = max(1, n // pw)
xs, ys = x[::step], y[::step]
pts = [to_px(float(xs[i]), float(ys[i])) for i in range(len(xs))]
if len(pts) > 1:
draw.line(pts, fill=line_color, width=2)
draw.rectangle(
[margin["left"], margin["top"], margin["left"] + pw, margin["top"] + ph],
outline=(100, 100, 100), width=1,
)
draw.text((margin["left"] + pw // 2, h - 10), x_label, fill=text_color, font=font, anchor="mb")
y_label_img = Image.new("RGBA", (200, 20), (0, 0, 0, 0))
y_draw = ImageDraw.Draw(y_label_img)
y_draw.text((100, 10), y_label, fill=text_color, font=font, anchor="mm")
y_label_img = y_label_img.rotate(90, expand=True)
img.paste(y_label_img, (2, margin["top"] + ph // 2 - y_label_img.height // 2), y_label_img)
if title and title.strip():
draw.text((w // 2, 10), title.strip(), fill=text_color, font=font_title, anchor="mt")
ext = ".png" if format_name == "PNG" else ".tiff"
img.save(str(path.with_suffix(ext)))