From 3a7de8366be1b90d8195b49621b98eed62a6936a Mon Sep 17 00:00:00 2001 From: Felipe Bordeu Date: Wed, 10 Jun 2026 09:54:39 +0930 Subject: [PATCH 01/35] predict_client.py --- src/plaid/utils/predict_client.py | 57 +++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 src/plaid/utils/predict_client.py diff --git a/src/plaid/utils/predict_client.py b/src/plaid/utils/predict_client.py new file mode 100644 index 00000000..a4be32ff --- /dev/null +++ b/src/plaid/utils/predict_client.py @@ -0,0 +1,57 @@ +"""Class to use the /predict capability of a server.""" + +import json +from typing import Any +from urllib import request + +from plaid.containers.sample import Sample +from plaid.utils.sample_json import sample_from_json_payload, sample_to_json_payload + + +class PlaidClient(): + def __init__(self, host, port): + self.host = host + self.port = port + self.endpoints = { + "health": "/health", + "predict": "/predict", + "problem_definition": "/problem_definition", + "samples": "/samples", + } + self.protocol = "http" + self.timeout = 100 # timeout for the response + + def _request_json(self, endpoint: str, payload: dict[str, object]) -> dict[str, object]: + req = request.Request( + url=f"{self.protocol}://{self.host}:{self.port}{self.endpoints[endpoint]}", + data=json.dumps(payload).encode("utf-8"), + headers={"Content-Type": "application/json"}, + method="POST", + ) + with request.urlopen(req, timeout=self.timeout) as response: + return json.loads(response.read().decode("utf-8")) + + def check_connection(self) -> bool: + try: + data = self._request_json("health", {}).get("status", "payload missing") + if data != "ok": + print("Server health check failed: status not ok") + print(f"Received data: {data}") + return False + return True + except Exception as e: + print(f"Connection check failed: {e}") + return False + + def predict(self, sample: Sample) -> Sample: + payload : dict [str,Any] = {"sample": sample_to_json_payload(sample) } + response = self._request_json("predict", payload) + return sample_from_json_payload(response["samples"][0]) + + def problem_definition(self): + return self._request_json("problem_definition", {}) + + def samples(self, sample_ids: list[int], split: str) -> list[Sample]: + payload : dict [str,Any] = {"sample_ids": sample_ids, "split": split} + response = self._request_json("samples", payload) + return [sample_from_json_payload(sample_payload) for sample_payload in response["samples"]] \ No newline at end of file From cf78e9015db9c9150241f5bf1bac9c656cafe5cf Mon Sep 17 00:00:00 2001 From: Felipe Bordeu Date: Wed, 10 Jun 2026 09:54:39 +0930 Subject: [PATCH 02/35] SimplePredict.py --- examples/client_server/SimplePredict.py | 145 ++++++++++++++++++++++++ 1 file changed, 145 insertions(+) create mode 100644 examples/client_server/SimplePredict.py diff --git a/examples/client_server/SimplePredict.py b/examples/client_server/SimplePredict.py new file mode 100644 index 00000000..5dbade2b --- /dev/null +++ b/examples/client_server/SimplePredict.py @@ -0,0 +1,145 @@ +# --- +# jupyter: +# jupytext: +# formats: ipynb,py:percent +# text_representation: +# extension: .py +# format_name: percent +# format_version: '1.3' +# jupytext_version: 1.17.3 +# kernelspec: +# display_name: Python 3 +# language: python +# name: python3 +# --- + +# %% +# Import required libraries + +from typing import Any + +import sys +import numpy as np +from matplotlib import pyplot as plt + +from plaid import Sample +from plaid.utils.predict_client import PlaidClient + +# %% [markdown] +# # Connecion to the prediction server + +# %% +plaidserver = PlaidClient(host="localhost", port=8000) +if plaidserver.check_connection(): + print("Connection to PLAID server successful.") +else: + print("Failed to connect to PLAID server.") + sys.exit() + +pb = plaidserver.problem_definition() +print(pb) + +# %% [markdown] +# # Load a sample for modification + +# %% +#load a sample from a distant storage +#from plaid.downloadable_examples import samples +#sample = samples.tensile2d +#... + +# from a local disk +#from plaid.storage.reader import init_from_disk, load_infos_from_disk +#from plaid.storage.common.reader import load_problem_definitions_from_disk +#datasetdict, converterdict = init_from_disk("../Datasets/Tensile2d/") +#infos = load_infos_from_disk("../Datasets/Tensile2d/") +#pds = load_problem_definitions_from_disk("../Datasets/Tensile2d/") +#input_features = [x for x in pds['PLAID_benchmark'].input_features if x.startswith("Global")] +#output_features = [x for x in pds['PLAID_benchmark'].output_features if x.startswith("Global")] +#sample: Sample = converterdict["test"].to_plaid(datasetdict["test"], 1) + +# from the server +sample: Sample = plaidserver.samples(sample_ids=[0], split=pb['training_split'][0])[0] +input_features = [x for x in pb["input_features"] if x.startswith("Global")] +output_features = [x for x in pb["output_features"] if x.startswith("Global")] +print(sample) + +# %% [markdown] +# # Select active feature + +# %% + +#create a const function to encapsulate the prediction +print(f"{input_features=}") +active_input_feature = input_features[0].strip("Global/") +print(f"{active_input_feature=}") + +print(f"{output_features=}") +active_output_feature = output_features[0].strip("Global/") +print(f"{active_output_feature=}") + +minmax = {} +minmax["P"] = (-49.99 ,-40.01) +minmax["p1"] = (10.01, 19.99) +minmax["p2"] = (300.3, 599.7) +minmax["p3"] = (1001.0, 1999.0) +minmax["p4"] = (1001.0, 1999.0) +minmax["p5"] = (50050.0, 99950.0) + +# %% [markdown] +# # Define the const function +# in this case the function return the value of the active output + +# %% + +def cost_fuction(x: Any) -> float : + # 1) here we recover the current optimisation point and map it to the sample. + for f,v in zip([active_input_feature], x): + sample.update_features_by_path("Global/"+f,v) + + # 2) Then send the sample for evaluation/prediction and recover the sample + for f in output_features : + if f.startswith("Global"): + global_name = f.strip("Global/") + if global_name in sample.get_global_names() : + sample.del_global(global_name) + else: + sample.del_feature_by_path(f) + response: Sample = plaidserver.predict(sample) + + # 3) evaluate the cost function + output: float = response.get_global(active_output_feature) + return output + +# %% [markdown] +# # Evaluate the cost function at one point + +# %% +print(cost_fuction([-45])) + +# %% [markdown] +# # Call the predictor for a range of + +# %% + +nb_calls = 50 +x = np.empty(nb_calls) +y = np.empty(nb_calls) + +for i,v in enumerate(np.linspace(minmax[active_input_feature][0],minmax[active_input_feature][1],nb_calls)): + x[i] = v + #sample.del_global(active_output_features) + y[i] = cost_fuction([v]) + +# %% [markdown] +# # Plot output + +# %% + +plt.scatter(x,y) +plt.xlabel(active_input_feature) +plt.ylabel(active_output_feature) +plt.title(f"{active_input_feature} vs {active_output_feature}") +plt.grid() +plt.show() +# %% From aa1b7b1585b0f9105b1d74af706ac99b2d9f9b72 Mon Sep 17 00:00:00 2001 From: Felipe Bordeu Date: Wed, 10 Jun 2026 09:54:39 +0930 Subject: [PATCH 03/35] add update_value_by_path in features.py --- src/plaid/cli/paraview_plugin/__init__.py | 34 ++++++++++++++++++++++ src/plaid/containers/sample.py | 35 +++++++++++++++++++++++ 2 files changed, 69 insertions(+) create mode 100644 src/plaid/cli/paraview_plugin/__init__.py diff --git a/src/plaid/cli/paraview_plugin/__init__.py b/src/plaid/cli/paraview_plugin/__init__.py new file mode 100644 index 00000000..04aa9f31 --- /dev/null +++ b/src/plaid/cli/paraview_plugin/__init__.py @@ -0,0 +1,34 @@ +from pathlib import Path +import subprocess +import os + + +paraview_exec = "paraview" + +def get_ParaView_plugin_path(): + return Path(__file__).parent + +def convert_wsl_to_win(wsl_path: str) -> str: + """Converts a WSL path (e.g., /mnt/c/Users) to Windows (C:\\Users).""" + result = subprocess.run( + ["wslpath", "-w", wsl_path], capture_output=True, text=True, check=True + ) + return result.stdout.strip() + +def run_paraview_with_plugin(): + + + my_env = os.environ.copy() + + my_env["PV_PLUGIN_PATH"] = str(get_ParaView_plugin_path()) + my_env["PARAVIEW_LOG_PLUGIN_VERBOSITY"] = "ON" + + + current_pv_path = os.environ.get("PARAVIEW_EXEC",paraview_exec) + if current_pv_path.startswith("/mnt/") and os : + # we are in a wsl and the paraview exec is windows, we need to convert the wsl path + #my_env["PV_PLUGIN_PATH"] = convert_wsl_to_win(str(get_ParaView_plugin_path())) + my_env["WSLENV"] = "PV_PLUGIN_PATH/p:PARAVIEW_LOG_PLUGIN_VERBOSITY/p" + + process = subprocess.Popen([os.environ.get("PARAVIEW_EXEC",paraview_exec)], env=my_env) + return process diff --git a/src/plaid/containers/sample.py b/src/plaid/containers/sample.py index c6ac9985..cb94fcbb 100644 --- a/src/plaid/containers/sample.py +++ b/src/plaid/containers/sample.py @@ -1946,3 +1946,38 @@ def show_tree(self, time: Optional[float] = None) -> None: if self.data is not None: CGH.show_cgns_tree(self.data.get(time)) + + def update_value_by_path(self, path: str, field: np.ndarray, time:float = None) -> None: + """Update a field in the CGNS tree by its path. + + Args: + path (str): The path to the field node in the CGNS tree. + field (np.ndarray): The new field data to be set at the specified path. + time (float, optional): The time associated with the field. Defaults to None, which will use the default time. + + Raises: + KeyError: Raised if the specified path does not exist in the CGNS tree. + """ + # init_tree will look for default time + self.init_tree(None) + path_parts = path.strip("/").split("/") + + root_node = self.get_base(base=path_parts[0], time=time) + node_path = "/" + "/".join(path_parts[1:]) + node = CGU.getNodeByPath(root_node, node_path) + + if node is None: + raise KeyError(f"There is no node at path '{node_path}' in the CGNS tree for time {time}.") + + field = np.asarray(field) + try: + field.shape = CGU.getValue(node).shape + except Exception as ex: + raise ValueError(f"value of node {path} has shape : {np.asarray(CGU.getValue(node)).shape} but incomming data has shape {np.asarray(field).shape}") from ex + + if np.asarray(CGU.getValue(node)).shape != np.asarray(field).shape: + print(field) + print(CGU.getValue(node)) + logger.warning(f"value of node {path} has shape : {np.asarray(CGU.getValue(node)).shape} but incomming data has shape {np.asarray(field).shape}") + + CGU.setValue(node, np.asfortranarray(field)) From 73118f178b7ad7b30e9087684f0d75cb3b9ae854 Mon Sep 17 00:00:00 2001 From: Felipe Bordeu Date: Wed, 10 Jun 2026 09:54:39 +0930 Subject: [PATCH 04/35] JSON serialization helpers for CGNS trees --- src/plaid/utils/cgns_json.py | 219 +++++++++++++++++++++++++++++++++ src/plaid/utils/sample_json.py | 141 +++++++++++++++++++++ 2 files changed, 360 insertions(+) create mode 100644 src/plaid/utils/cgns_json.py create mode 100644 src/plaid/utils/sample_json.py diff --git a/src/plaid/utils/cgns_json.py b/src/plaid/utils/cgns_json.py new file mode 100644 index 00000000..0ef94a2c --- /dev/null +++ b/src/plaid/utils/cgns_json.py @@ -0,0 +1,219 @@ +"""JSON serialization helpers for CGNS trees. + +The helpers in this module serialize a single pyCGNS-style tree node of the +form ``[name, value, children, label]`` to a language-neutral JSON payload. +NumPy arrays are encoded as base64 little-endian C-contiguous bytes with +explicit dtype and shape metadata so the payload can be decoded from Python, +MATLAB, R, JavaScript, or any language with base64 and typed-array support. +""" + +from __future__ import annotations + +import base64 +import json +from typing import Any + +import numpy as np + +FORMAT_NAME = "plaid-cgns-tree-json" +FORMAT_VERSION = 1 +ARRAY_ENCODING = "base64" +BYTE_ORDER = "little" + +JSONValue = None | bool | int | float | str | list["JSONValue"] | dict[str, "JSONValue"] + + +def cgns_tree_to_json_payload(tree: list[Any]) -> dict[str, Any]: + """Convert a CGNS tree to a JSON-compatible payload. + + Args: + tree: pyCGNS-style node ``[name, value, children, label]``. + + Returns: + A JSON-compatible dictionary containing format metadata and the encoded + tree. + """ + return { + "format": FORMAT_NAME, + "version": FORMAT_VERSION, + "array_encoding": ARRAY_ENCODING, + "byte_order": BYTE_ORDER, + "tree": _encode_node(tree), + } + + +def cgns_tree_from_json_payload(payload: dict[str, Any]) -> list[Any]: + """Rebuild a CGNS tree from a JSON-compatible payload. + + Args: + payload: Payload produced by :func:`cgns_tree_to_json_payload`. + + Returns: + A pyCGNS-style node ``[name, value, children, label]``. + + Raises: + ValueError: If the payload format or version is unsupported. + """ + if payload.get("format") != FORMAT_NAME: + raise ValueError(f"Unsupported CGNS JSON format: {payload.get('format')!r}") + if payload.get("version") != FORMAT_VERSION: + raise ValueError(f"Unsupported CGNS JSON version: {payload.get('version')!r}") + return _decode_node(payload["tree"]) + + +def cgns_tree_to_json(tree: list[Any], **json_kwargs: Any) -> str: + """Convert a CGNS tree to a JSON string. + + Args: + tree: pyCGNS-style node ``[name, value, children, label]``. + **json_kwargs: Extra keyword arguments forwarded to :func:`json.dumps`. + + Returns: + A JSON string containing the encoded CGNS tree. + """ + return json.dumps(cgns_tree_to_json_payload(tree), **json_kwargs) + + +def cgns_tree_from_json(text: str) -> list[Any]: + """Rebuild a CGNS tree from a JSON string. + + Args: + text: JSON string produced by :func:`cgns_tree_to_json`. + + Returns: + A pyCGNS-style node ``[name, value, children, label]``. + """ + payload = json.loads(text) + return cgns_tree_from_json_payload(payload) + + +def _encode_node(node: list[Any]) -> dict[str, Any]: + """Encode one pyCGNS-style node as a JSON-compatible dictionary.""" + if not isinstance(node, list) or len(node) != 4: + raise ValueError( + "CGNS nodes must be lists of the form [name, value, children, label]" + ) + + name, value, children, label = node + if children is None: + children = [] + if not isinstance(children, list): + raise ValueError(f"Children of CGNS node {name!r} must be a list") + + return { + "name": str(name), + "label": str(label), + "value": _encode_value(value), + "children": [_encode_node(child) for child in children], + } + + +def _decode_node(node: dict[str, Any]) -> list[Any]: + """Decode one JSON node dictionary into pyCGNS-style node form.""" + if not isinstance(node, dict): + raise ValueError("Encoded CGNS nodes must be dictionaries") + required = {"name", "label", "value", "children"} + missing = required - set(node) + if missing: + raise ValueError(f"Encoded CGNS node is missing keys: {sorted(missing)}") + if not isinstance(node["children"], list): + raise ValueError( + f"Children of encoded CGNS node {node['name']!r} must be a list" + ) + + return [ + node["name"], + _decode_value(node["value"]), + [_decode_node(child) for child in node["children"]], + node["label"], + ] + + +def _encode_value(value: Any) -> Any: + """Encode a CGNS node value into JSON-compatible data.""" + if value is None or isinstance(value, (bool, int, float, str)): + return value + + if isinstance(value, bytes): + return { + "kind": "bytes", + "encoding": ARRAY_ENCODING, + "data": base64.b64encode(value).decode("ascii"), + } + + if isinstance(value, np.generic): + return _encode_value(value.item()) + + if isinstance(value, np.ndarray): + return _encode_array(value) + + if isinstance(value, (list, tuple)): + return [_encode_value(item) for item in value] + + raise TypeError( + f"Unsupported CGNS value type for JSON serialization: {type(value)!r}" + ) + + +def _decode_value(value: Any) -> Any: + """Decode one JSON-compatible CGNS value.""" + if isinstance(value, dict): + kind = value.get("kind") + if kind == "ndarray": + return _decode_array(value) + if kind == "bytes": + return base64.b64decode(value["data"]) + if isinstance(value, list): + return [_decode_value(item) for item in value] + return value + + +def _encode_array(value: np.ndarray) -> dict[str, Any]: + """Encode a NumPy array using portable metadata plus base64 bytes.""" + array = np.asarray(value) + + if array.dtype.kind == "O": + raise TypeError("Object dtype arrays are not supported in CGNS JSON payloads") + + if array.dtype.kind == "U": + return { + "kind": "ndarray", + "encoding": "json", + "dtype": array.dtype.str, + "shape": list(array.shape), + "order": "C", + "byte_order": "not-applicable", + "data": array.tolist(), + } + + contiguous = np.ascontiguousarray(array) + byte_order = "not-applicable" + if contiguous.dtype.byteorder not in ("|", "=") or contiguous.dtype.itemsize > 1: + contiguous = contiguous.astype(contiguous.dtype.newbyteorder("<"), copy=False) + byte_order = BYTE_ORDER + + return { + "kind": "ndarray", + "encoding": ARRAY_ENCODING, + "dtype": contiguous.dtype.str, + "shape": list(contiguous.shape), + "order": "C", + "byte_order": byte_order, + "data": base64.b64encode(contiguous.tobytes(order="C")).decode("ascii"), + } + + +def _decode_array(value: dict[str, Any]) -> np.ndarray: + """Decode an array object from the JSON payload schema.""" + encoding = value.get("encoding") + dtype = np.dtype(value["dtype"]) + shape = tuple(value["shape"]) + + if encoding == "json": + return np.array(value["data"], dtype=dtype).reshape(shape) + + if encoding != ARRAY_ENCODING: + raise ValueError(f"Unsupported ndarray encoding: {encoding!r}") + + raw = base64.b64decode(value["data"]) + return np.frombuffer(raw, dtype=dtype).reshape(shape).copy() diff --git a/src/plaid/utils/sample_json.py b/src/plaid/utils/sample_json.py new file mode 100644 index 00000000..e382002c --- /dev/null +++ b/src/plaid/utils/sample_json.py @@ -0,0 +1,141 @@ +"""JSON serialization helpers for :class:`plaid.containers.sample.Sample`. + +This module provides a language-neutral payload for full ``Sample`` objects. +Each timestamped CGNS tree is encoded with :mod:`plaid.utils.cgns_json` and +wrapped in a versioned top-level schema. +""" + +from __future__ import annotations + +import json +from typing import Any + +import numpy as np + +from ..containers.sample import Sample +from .cgns_json import cgns_tree_from_json_payload, cgns_tree_to_json_payload + +FORMAT_NAME = "plaid-sample-json" +FORMAT_VERSION = 1 + + +def sample_to_json_payload(sample: Sample) -> dict[str, Any]: + """Convert a full Sample to a JSON-compatible payload. + + Args: + sample: Sample instance to serialize. + + Returns: + A JSON-compatible dictionary containing format metadata and all + timestamped CGNS trees from the sample. + """ + trees_payload = [] + for time, tree in sample.data.items(): + trees_payload.append( + { + "time": _encode_time(time), + "tree": cgns_tree_to_json_payload(tree), + } + ) + + return { + "format": FORMAT_NAME, + "version": FORMAT_VERSION, + "trees": trees_payload, + } + + +def sample_from_json_payload(payload: dict[str, Any]) -> Sample: + """Rebuild a full Sample from a JSON-compatible payload. + + Args: + payload: Payload produced by :func:`sample_to_json_payload`. + + Returns: + A reconstructed :class:`Sample`. + + Raises: + ValueError: If payload format or version is unsupported. + """ + if payload.get("format") != FORMAT_NAME: + raise ValueError(f"Unsupported Sample JSON format: {payload.get('format')!r}") + if payload.get("version") != FORMAT_VERSION: + raise ValueError( + f"Unsupported Sample JSON version: {payload.get('version')!r}" + ) + + trees = payload.get("trees") + if not isinstance(trees, list): + raise ValueError("Sample JSON payload must contain a list in 'trees'") + + from ..containers.sample import Sample + + sample = Sample(path=None) + for entry in trees: + if not isinstance(entry, dict): + raise ValueError("Each Sample JSON tree entry must be a dictionary") + if "time" not in entry or "tree" not in entry: + raise ValueError("Each Sample JSON tree entry must contain 'time' and 'tree'") + + time_value = _decode_time(entry["time"]) + sample.data[time_value] = cgns_tree_from_json_payload(entry["tree"]) + + return sample + + +def sample_to_json(sample: "Sample", **json_kwargs: Any) -> str: + """Convert a full Sample to a JSON string. + + Args: + sample: Sample instance to serialize. + **json_kwargs: Extra keyword arguments forwarded to :func:`json.dumps`. + + Returns: + A JSON string containing the encoded sample. + """ + return json.dumps(sample_to_json_payload(sample), **json_kwargs) + + +def sample_from_json(text: str) -> Sample: + """Rebuild a Sample from a JSON string. + + Args: + text: JSON string produced by :func:`sample_to_json`. + + Returns: + A reconstructed :class:`Sample`. + """ + payload = json.loads(text) + return sample_from_json_payload(payload) + + +def _encode_time(value: Any) -> float | int: + """Convert time keys to JSON scalar values. + + Args: + value: Time key from ``sample.data``. + + Returns: + A Python ``float`` or ``int``. + """ + if isinstance(value, np.generic): + value = value.item() + + if isinstance(value, (int, float)): + return value + + raise TypeError(f"Unsupported time key type for Sample JSON: {type(value)!r}") + + +def _decode_time(value: Any) -> float: + """Decode a serialized time value to float. + + Args: + value: Encoded JSON scalar time. + + Returns: + Time as float. + """ + if not isinstance(value, (int, float)): + raise ValueError("Sample JSON time entries must be numeric") + return float(value) From 1311268c4ccfe5d495c6e0b9ad0f335c965d4957 Mon Sep 17 00:00:00 2001 From: Felipe Bordeu Date: Wed, 10 Jun 2026 09:54:39 +0930 Subject: [PATCH 05/35] Tests for JSON serialization helpers for CGNS trees --- tests/utils/test_cgns_json.py | 137 ++++++++++++++++++++++++++++++++ tests/utils/test_sample_json.py | 110 +++++++++++++++++++++++++ 2 files changed, 247 insertions(+) create mode 100644 tests/utils/test_cgns_json.py create mode 100644 tests/utils/test_sample_json.py diff --git a/tests/utils/test_cgns_json.py b/tests/utils/test_cgns_json.py new file mode 100644 index 00000000..42c7a3a0 --- /dev/null +++ b/tests/utils/test_cgns_json.py @@ -0,0 +1,137 @@ +"""Tests for language-neutral CGNS JSON serialization helpers.""" + +import json + +import numpy as np +import pytest + +from plaid.utils.cgns_helper import compare_cgns_trees +from plaid.utils.cgns_json import ( + cgns_tree_from_json, + cgns_tree_from_json_payload, + cgns_tree_to_json, + cgns_tree_to_json_payload, +) + + +def _assert_no_numpy_objects(value): + """Assert recursively that a JSON payload contains no NumPy objects.""" + if isinstance(value, dict): + for item in value.values(): + _assert_no_numpy_objects(item) + elif isinstance(value, list): + for item in value: + _assert_no_numpy_objects(item) + else: + assert not isinstance(value, (np.ndarray, np.generic)) + + +def test_cgns_tree_json_roundtrip_with_numpy_arrays(): + """A simple CGNS tree with common array dtypes survives JSON roundtrip.""" + tree = [ + "CGNSTree", + None, + [ + [ + "Base_2_2", + np.array([2, 2], dtype=np.int32), + [ + [ + "CoordinateX", + np.array([1.0, 2.0, 3.0], dtype=np.float64), + [], + "DataArray_t", + ], + [ + "Connectivity", + np.array([[1, 2], [2, 3]], dtype=np.int64), + [], + "IndexArray_t", + ], + [ + "FamilyName", + np.array([b"A", b"B"], dtype="|S1"), + [], + "DataArray_t", + ], + [ + "UnicodeName", + np.array(["alpha", "beta"], dtype=" None: + """Assert that two Samples carry the same timestamped CGNS trees.""" + assert sorted(reference.data.keys()) == sorted(candidate.data.keys()) + + for time in reference.data: + assert compare_cgns_trees( + reference.data[time], + candidate.data[time], + ) + + +def test_empty_sample_json_payload_roundtrip(sample: Sample): + """An empty Sample can be serialized and reconstructed from payload.""" + payload = sample_to_json_payload(sample) + decoded = sample_from_json_payload(payload) + + assert payload["format"] == "plaid-sample-json" + assert payload["version"] == 1 + assert payload["trees"] == [] + _assert_same_sample_content(sample, decoded) + + +def test_real_sample_json_roundtrip(sample_with_tree): + """A Sample with a real CGNS tree survives JSON-string roundtrip.""" + text = sample_to_json(sample_with_tree) + decoded = sample_from_json(text) + + _assert_same_sample_content(sample_with_tree, decoded) + + +def test_sample_json_payload_roundtrip_with_multiple_timestamps(sample_with_tree, tree): + """All timestamps are serialized and restored for full Sample deserialization.""" + sample_with_tree.add_tree(tree, time=1.0) + + payload = sample_to_json_payload(sample_with_tree) + decoded = sample_from_json_payload(payload) + + assert len(payload["trees"]) == 2 + _assert_same_sample_content(sample_with_tree, decoded) + + +def test_sample_json_payload_is_json_compatible(sample_with_tree): + """Serialized sample payload contains only JSON-compatible scalar/container types.""" + payload = sample_to_json_payload(sample_with_tree) + + _assert_no_numpy_objects(payload) + json.dumps(payload) + + +def test_sample_json_rejects_invalid_payloads(): + """Invalid Sample payload metadata and malformed trees raise explicit errors.""" + with pytest.raises(ValueError, match="Unsupported Sample JSON format"): + sample_from_json_payload({"format": "other", "version": 1, "trees": []}) + + with pytest.raises(ValueError, match="Unsupported Sample JSON version"): + sample_from_json_payload( + {"format": "plaid-sample-json", "version": 999, "trees": []} + ) + + with pytest.raises(ValueError, match="must contain a list in 'trees'"): + sample_from_json_payload( + {"format": "plaid-sample-json", "version": 1, "trees": {}} + ) + + with pytest.raises(ValueError, match="must be a dictionary"): + sample_from_json_payload( + { + "format": "plaid-sample-json", + "version": 1, + "trees": ["not-a-dict"], + } + ) + + with pytest.raises(ValueError, match="must contain 'time' and 'tree'"): + sample_from_json_payload( + { + "format": "plaid-sample-json", + "version": 1, + "trees": [{"time": 0.0}], + } + ) From da544a16e65fe8a10aaa454c3ba3d4ff62bd8d41 Mon Sep 17 00:00:00 2001 From: Felipe Bordeu Date: Wed, 10 Jun 2026 09:54:39 +0930 Subject: [PATCH 06/35] Add Tests --- tests/containers/test_sample.py | 91 ++++++++++++++- tests/containers/test_utils.py | 64 +++++++++++ tests/utils/test_cgns_json.py | 102 ++++++++++++++++ tests/utils/test_predict_client.py | 179 +++++++++++++++++++++++++++++ 4 files changed, 435 insertions(+), 1 deletion(-) create mode 100644 tests/utils/test_predict_client.py diff --git a/tests/containers/test_sample.py b/tests/containers/test_sample.py index 0f36e3cf..35d59644 100644 --- a/tests/containers/test_sample.py +++ b/tests/containers/test_sample.py @@ -1284,13 +1284,102 @@ def test_get_feature_from_identifier(self, sample_with_tree_and_scalar): is not None ) + def test_update_value_by_path(self, sample_with_tree): + path = "Base_2_2/Zone/VertexFields/test_node_field_1" + new_field = np.linspace(0.0, 1.0, 5) + + sample_with_tree.update_value_by_path(path, new_field) + + assert np.allclose(sample_with_tree.get_feature_by_path(path), new_field) + + def test_update_value_by_path_at_specific_time(self, sample, tree): + path = "Base_2_2/Zone/VertexFields/test_node_field_1" + time_0_field = np.linspace(0.0, 1.0, 5) + time_1_field = np.linspace(10.0, 14.0, 5) + + sample.add_tree(copy.deepcopy(tree), time=0.0) + sample.add_tree(copy.deepcopy(tree), time=1.0) + sample.update_value_by_path(path, time_0_field, time=0.0) + sample.update_value_by_path(path, time_1_field, time=1.0) + + assert np.allclose(sample.get_feature_by_path(path, time=0.0), time_0_field) + assert np.allclose(sample.get_feature_by_path(path, time=1.0), time_1_field) + + def test_update_value_by_path_rejects_unknown_path(self, sample_with_tree): + with pytest.raises(KeyError, match="There is no node at path"): + sample_with_tree.update_value_by_path( + "Base_2_2/Zone/VertexFields/missing_field", + np.zeros(5), + ) + + def test_update_value_by_path_rejects_incompatible_shape(self, sample_with_tree): + with pytest.raises(ValueError, match="incomming data has shape"): + sample_with_tree.update_value_by_path( + "Base_2_2/Zone/VertexFields/test_node_field_1", + np.zeros(6), + ) + def test_update_features_by_path(self, sample_with_tree_and_scalar): - sample_with_tree_and_scalar.update_features_by_path( + original_value = sample_with_tree_and_scalar.get_feature_by_path( + "Global/test_scalar_1" + ) + + updated_sample = sample_with_tree_and_scalar.update_features_by_path( "Global/test_scalar_1", features=3.141592, in_place=False, ) + assert updated_sample is not sample_with_tree_and_scalar + assert updated_sample.get_feature_by_path("Global/test_scalar_1") == 3.141592 + assert ( + sample_with_tree_and_scalar.get_feature_by_path("Global/test_scalar_1") + == original_value + ) + + def test_update_features_by_path_in_place(self, sample_with_tree_and_scalar): + updated_sample = sample_with_tree_and_scalar.update_features_by_path( + "Global/test_scalar_1", + features=2.718281, + in_place=True, + ) + + assert updated_sample is sample_with_tree_and_scalar + assert sample_with_tree_and_scalar.get_feature_by_path( + "Global/test_scalar_1" + ) == pytest.approx(2.718281) + + def test_update_features_by_path_updates_multiple_features( + self, sample_with_tree_and_scalar + ): + new_field = np.linspace(10.0, 14.0, 5) + + updated_sample = sample_with_tree_and_scalar.update_features_by_path( + [ + "Global/test_scalar_1", + "Base_2_2/Zone/VertexFields/test_node_field_1", + ], + [42.0, new_field], + in_place=False, + ) + + assert updated_sample.get_feature_by_path("Global/test_scalar_1") == 42.0 + assert np.allclose( + updated_sample.get_feature_by_path( + "Base_2_2/Zone/VertexFields/test_node_field_1" + ), + new_field, + ) + + def test_update_features_by_path_rejects_mismatched_lengths( + self, sample_with_tree_and_scalar + ): + with pytest.raises(AssertionError): + sample_with_tree_and_scalar.update_features_by_path( + ["Global/test_scalar_1", "Global/r"], + [1.0], + ) + def test_get_all_features_by_type(self, sample_with_tree_and_scalar): feat_paths = sample_with_tree_and_scalar.get_all_features_by_type("field") diff --git a/tests/containers/test_utils.py b/tests/containers/test_utils.py index f8b35c4b..27b2df89 100644 --- a/tests/containers/test_utils.py +++ b/tests/containers/test_utils.py @@ -56,6 +56,7 @@ def test_get_number_of_samples_with_str(self, current_directory): "name": "IterationValues", }, ), + ("Global/Time", {"type": "global", "sub_type": "time"}), ( "Global/Mach", {"type": "global", "sub_type": "scalar", "name": "Mach"}, @@ -128,6 +129,65 @@ def test_get_number_of_samples_with_str(self, current_directory): "name": "rov", }, ), + ( + "Base_2_2/Zone/CellCenterFields/cell_pressure", + { + "base": "Base_2_2", + "zone": "Zone", + "type": "field", + "location": "CellCenter", + "name": "cell_pressure", + }, + ), + ( + "Base_2_2/Zone/SurfaceData/wall_flux", + { + "base": "Base_2_2", + "zone": "Zone", + "type": "field", + "location": "FaceCenter", + "name": "wall_flux", + }, + ), + ( + "Base_2_2/Zone/gauss_IntegrationPointFields/strain", + { + "base": "Base_2_2", + "zone": "Zone", + "type": "field", + "location": "IntegrationPoint", + "name": "strain", + }, + ), + ( + "Base_2_2/Zone/ZoneBC/inlet", + { + "base": "Base_2_2", + "zone": "Zone", + "type": "boundary_condition", + "name": "inlet", + "sub_type": "bc", + }, + ), + ( + "Base_2_2/Zone/ZoneBC/inlet/PointList", + { + "base": "Base_2_2", + "zone": "Zone", + "type": "boundary_condition", + "name": "inlet", + "sub_type": "PointList", + }, + ), + ( + "Base_2_2/Zone/Elements_QUAD_4/UnexpectedLeaf", + { + "base": "Base_2_2", + "zone": "Zone", + "type": "elements", + "element_type": "QUAD_4", + }, + ), ( "Base_2_2/Zone/Time/IterationValues", { @@ -142,6 +202,10 @@ def test_get_number_of_samples_with_str(self, current_directory): def test_get_feature_details_from_path(self, url, expected): assert get_feature_details_from_path(url) == expected + def test_get_feature_details_from_path_rejects_unknown_base(self): + with pytest.raises(AssertionError, match="path not recognized"): + get_feature_details_from_path("NotABase/Zone/VertexFields/field") + def test_validate_required_only(self): infos = { "owner": "Joh Doe", diff --git a/tests/utils/test_cgns_json.py b/tests/utils/test_cgns_json.py index 42c7a3a0..d2467b7b 100644 --- a/tests/utils/test_cgns_json.py +++ b/tests/utils/test_cgns_json.py @@ -7,6 +7,11 @@ from plaid.utils.cgns_helper import compare_cgns_trees from plaid.utils.cgns_json import ( + _decode_array, + _decode_node, + _decode_value, + _encode_node, + _encode_value, cgns_tree_from_json, cgns_tree_from_json_payload, cgns_tree_to_json, @@ -135,3 +140,100 @@ def test_cgns_tree_json_rejects_object_arrays(): with pytest.raises(TypeError, match="Object dtype arrays"): cgns_tree_to_json_payload(tree) + + +def test_encode_node_accepts_none_children_as_empty_list(): + """CGNS nodes with None children are normalized to an empty child list.""" + encoded = _encode_node(["Root", None, None, "CGNSTree_t"]) + + assert encoded == { + "name": "Root", + "label": "CGNSTree_t", + "value": None, + "children": [], + } + + +@pytest.mark.parametrize( + "node, message", + [ + (("Root", None, [], "CGNSTree_t"), "CGNS nodes must be lists"), + (["Root", None, [], "CGNSTree_t", "extra"], "CGNS nodes must be lists"), + (["Root", None, "not-a-list", "CGNSTree_t"], "Children of CGNS node"), + ], +) +def test_encode_node_rejects_malformed_nodes(node, message): + """Malformed pyCGNS-style nodes raise explicit errors before encoding.""" + with pytest.raises(ValueError, match=message): + _encode_node(node) + + +@pytest.mark.parametrize( + "encoded, message", + [ + ([], "Encoded CGNS nodes must be dictionaries"), + ({"name": "Root", "label": "CGNSTree_t", "value": None}, "missing keys"), + ( + { + "name": "Root", + "label": "CGNSTree_t", + "value": None, + "children": "not-a-list", + }, + "Children of encoded CGNS node", + ), + ], +) +def test_decode_node_rejects_malformed_encoded_nodes(encoded, message): + """Malformed encoded node dictionaries are rejected before decoding.""" + with pytest.raises(ValueError, match=message): + _decode_node(encoded) + + +@pytest.mark.parametrize( + "value, expected", + [ + (np.int32(7), 7), + (np.float64(1.25), 1.25), + ((np.int64(1), np.int64(2)), [1, 2]), + ], +) +def test_encode_value_normalizes_numpy_scalars_and_tuples(value, expected): + """NumPy scalar values and tuples are converted to JSON-compatible data.""" + assert _encode_value(value) == expected + + +def test_encode_decode_value_roundtrips_bytes(): + """Bytes values are encoded as base64 objects and decoded back to bytes.""" + value = b"CGNS bytes" + + encoded = _encode_value(value) + + assert encoded["kind"] == "bytes" + assert _decode_value(encoded) == value + + +def test_encode_value_rejects_unsupported_values(): + """Unsupported value types raise a TypeError with a clear message.""" + with pytest.raises(TypeError, match="Unsupported CGNS value type"): + _encode_value({"not": "a supported CGNS value"}) + + +def test_decode_value_leaves_unknown_dict_kind_unchanged(): + """Unknown dictionary payloads are passed through unchanged.""" + value = {"kind": "custom", "data": [1, 2, 3]} + + assert _decode_value(value) is value + + +def test_decode_array_rejects_unknown_encoding(): + """Only JSON and base64 ndarray encodings are supported.""" + with pytest.raises(ValueError, match="Unsupported ndarray encoding"): + _decode_array( + { + "encoding": "unsupported", + "dtype": " None: + """Assert that two samples contain equivalent timestamped CGNS trees.""" + assert sorted(reference.data.keys()) == sorted(candidate.data.keys()) + for time in reference.data: + assert compare_cgns_trees(reference.data[time], candidate.data[time]) + + +def test_plaid_client_initializes_default_configuration(): + """Client construction stores host, port, endpoints, protocol and timeout.""" + client = PlaidClient("localhost", 8080) + + assert client.host == "localhost" + assert client.port == 8080 + assert client.protocol == "http" + assert client.timeout == 100 + assert client.endpoints == { + "health": "/health", + "predict": "/predict", + "problem_definition": "/problem_definition", + "samples": "/samples", + } + + +def test_request_json_posts_payload_and_decodes_response(monkeypatch): + """Low-level JSON requests are sent as POST and decoded from UTF-8 JSON.""" + calls = [] + response_payload = {"status": "ok", "answer": 42} + + def fake_urlopen(req, timeout): + calls.append(SimpleNamespace(req=req, timeout=timeout)) + return _FakeResponse(response_payload) + + monkeypatch.setattr("plaid.utils.predict_client.request.urlopen", fake_urlopen) + client = PlaidClient("example.test", 1234) + + result = client._request_json("health", {"ping": True}) + + assert result == response_payload + assert len(calls) == 1 + req = calls[0].req + assert req.full_url == "http://example.test:1234/health" + assert req.get_method() == "POST" + assert json.loads(req.data.decode("utf-8")) == {"ping": True} + assert req.headers["Content-type"] == "application/json" + assert calls[0].timeout == 100 + + +def test_check_connection_returns_true_for_ok_status(monkeypatch): + """The health endpoint is considered connected only when status is ok.""" + client = PlaidClient("localhost", 8000) + calls = [] + + def fake_request_json(endpoint, payload): + calls.append((endpoint, payload)) + return {"status": "ok"} + + monkeypatch.setattr(client, "_request_json", fake_request_json) + + assert client.check_connection() is True + assert calls == [("health", {})] + + +def test_check_connection_returns_false_for_bad_status(monkeypatch, capsys): + """A non-ok health response returns False and reports the bad status.""" + client = PlaidClient("localhost", 8000) + monkeypatch.setattr( + client, "_request_json", lambda endpoint, payload: {"status": "bad"} + ) + + assert client.check_connection() is False + + captured = capsys.readouterr() + assert "Server health check failed" in captured.out + assert "Received data: bad" in captured.out + + +def test_check_connection_returns_false_on_exception(monkeypatch, capsys): + """Connection exceptions are caught and converted to False.""" + client = PlaidClient("localhost", 8000) + + def raise_error(endpoint, payload): + raise RuntimeError("server unavailable") + + monkeypatch.setattr(client, "_request_json", raise_error) + + assert client.check_connection() is False + + assert "Connection check failed: server unavailable" in capsys.readouterr().out + + +def test_predict_sends_sample_payload_and_decodes_first_sample( + monkeypatch, sample_with_tree +): + """Prediction serializes one sample and returns the first sample in response.""" + client = PlaidClient("localhost", 8000) + response_sample = sample_with_tree.copy() + calls = [] + + def fake_request_json(endpoint, payload): + calls.append((endpoint, payload)) + return {"samples": [sample_to_json_payload(response_sample)]} + + monkeypatch.setattr(client, "_request_json", fake_request_json) + + predicted = client.predict(sample_with_tree) + + assert calls == [("predict", {"sample": sample_to_json_payload(sample_with_tree)})] + _assert_same_sample_content(response_sample, predicted) + + +def test_problem_definition_requests_problem_definition_endpoint(monkeypatch): + """Problem definition requests are delegated to their configured endpoint.""" + client = PlaidClient("localhost", 8000) + expected = {"features": ["pressure"]} + calls = [] + + def fake_request_json(endpoint, payload): + calls.append((endpoint, payload)) + return expected + + monkeypatch.setattr(client, "_request_json", fake_request_json) + + assert client.problem_definition() == expected + assert calls == [("problem_definition", {})] + + +def test_samples_sends_selection_payload_and_decodes_samples( + monkeypatch, sample_with_tree +): + """Sample retrieval sends ids/split and reconstructs all returned samples.""" + client = PlaidClient("localhost", 8000) + first_sample = sample_with_tree.copy() + second_sample = Sample(path=None) + calls = [] + + def fake_request_json(endpoint, payload): + calls.append((endpoint, payload)) + return { + "samples": [ + sample_to_json_payload(first_sample), + sample_to_json_payload(second_sample), + ] + } + + monkeypatch.setattr(client, "_request_json", fake_request_json) + + samples = client.samples(sample_ids=[3, 5], split="test") + + assert calls == [("samples", {"sample_ids": [3, 5], "split": "test"})] + assert len(samples) == 2 + _assert_same_sample_content(first_sample, samples[0]) + _assert_same_sample_content(second_sample, samples[1]) From 12467b7392a8df8de5e576376f772d572fd0ab7e Mon Sep 17 00:00:00 2001 From: Felipe Bordeu Date: Wed, 10 Jun 2026 09:54:39 +0930 Subject: [PATCH 07/35] make ruff happy --- src/plaid/cli/paraview_plugin/__init__.py | 25 +++++---- src/plaid/containers/sample.py | 18 ++++--- src/plaid/utils/predict_client.py | 62 ++++++++++++++++++++--- src/plaid/utils/sample_json.py | 8 +-- tests/containers/test_sample.py | 25 +++++++++ tests/utils/test_predict_client.py | 4 +- 6 files changed, 113 insertions(+), 29 deletions(-) diff --git a/src/plaid/cli/paraview_plugin/__init__.py b/src/plaid/cli/paraview_plugin/__init__.py index 04aa9f31..d06a5678 100644 --- a/src/plaid/cli/paraview_plugin/__init__.py +++ b/src/plaid/cli/paraview_plugin/__init__.py @@ -1,34 +1,37 @@ -from pathlib import Path -import subprocess -import os +"""Utilities to launch ParaView with the PLAID plugin configured.""" +import os +import subprocess +from pathlib import Path paraview_exec = "paraview" + def get_ParaView_plugin_path(): + """Returns the path to the ParaView plugin directory.""" return Path(__file__).parent + def convert_wsl_to_win(wsl_path: str) -> str: - """Converts a WSL path (e.g., /mnt/c/Users) to Windows (C:\\Users).""" + r"""Converts a WSL path (e.g., /mnt/c/Users) to Windows (C:\\Users).""" result = subprocess.run( ["wslpath", "-w", wsl_path], capture_output=True, text=True, check=True ) return result.stdout.strip() -def run_paraview_with_plugin(): - +def run_paraview_with_plugin(): + """Launches ParaView with environment variables set to load the plugin.""" my_env = os.environ.copy() my_env["PV_PLUGIN_PATH"] = str(get_ParaView_plugin_path()) my_env["PARAVIEW_LOG_PLUGIN_VERBOSITY"] = "ON" - - current_pv_path = os.environ.get("PARAVIEW_EXEC",paraview_exec) - if current_pv_path.startswith("/mnt/") and os : + current_pv_path = os.environ.get("PARAVIEW_EXEC", paraview_exec) + if current_pv_path.startswith("/mnt/") and os: # we are in a wsl and the paraview exec is windows, we need to convert the wsl path - #my_env["PV_PLUGIN_PATH"] = convert_wsl_to_win(str(get_ParaView_plugin_path())) + # my_env["PV_PLUGIN_PATH"] = convert_wsl_to_win(str(get_ParaView_plugin_path())) my_env["WSLENV"] = "PV_PLUGIN_PATH/p:PARAVIEW_LOG_PLUGIN_VERBOSITY/p" - process = subprocess.Popen([os.environ.get("PARAVIEW_EXEC",paraview_exec)], env=my_env) + process = subprocess.Popen([current_pv_path], env=my_env) return process diff --git a/src/plaid/containers/sample.py b/src/plaid/containers/sample.py index cb94fcbb..aa270e94 100644 --- a/src/plaid/containers/sample.py +++ b/src/plaid/containers/sample.py @@ -1947,7 +1947,9 @@ def show_tree(self, time: Optional[float] = None) -> None: if self.data is not None: CGH.show_cgns_tree(self.data.get(time)) - def update_value_by_path(self, path: str, field: np.ndarray, time:float = None) -> None: + def update_value_by_path( + self, path: str, field: np.ndarray, time: float = None + ) -> None: """Update a field in the CGNS tree by its path. Args: @@ -1967,17 +1969,21 @@ def update_value_by_path(self, path: str, field: np.ndarray, time:float = None) node = CGU.getNodeByPath(root_node, node_path) if node is None: - raise KeyError(f"There is no node at path '{node_path}' in the CGNS tree for time {time}.") + raise KeyError( + f"There is no node at path '{node_path}' in the CGNS tree for time {time}." + ) field = np.asarray(field) try: field.shape = CGU.getValue(node).shape except Exception as ex: - raise ValueError(f"value of node {path} has shape : {np.asarray(CGU.getValue(node)).shape} but incomming data has shape {np.asarray(field).shape}") from ex + raise ValueError( + f"value of node {path} has shape : {np.asarray(CGU.getValue(node)).shape} but incomming data has shape {np.asarray(field).shape}" + ) from ex if np.asarray(CGU.getValue(node)).shape != np.asarray(field).shape: - print(field) - print(CGU.getValue(node)) - logger.warning(f"value of node {path} has shape : {np.asarray(CGU.getValue(node)).shape} but incomming data has shape {np.asarray(field).shape}") + logger.warning( + f"value of node {path} has shape : {np.asarray(CGU.getValue(node)).shape} but incomming data has shape {np.asarray(field).shape}" + ) CGU.setValue(node, np.asfortranarray(field)) diff --git a/src/plaid/utils/predict_client.py b/src/plaid/utils/predict_client.py index a4be32ff..a3c10845 100644 --- a/src/plaid/utils/predict_client.py +++ b/src/plaid/utils/predict_client.py @@ -8,8 +8,17 @@ from plaid.utils.sample_json import sample_from_json_payload, sample_to_json_payload -class PlaidClient(): +class PlaidClient: + """Client for making requests to a PLAID prediction server.""" + def __init__(self, host, port): + """Initialize a prediction server client. + + Args: + host: Hostname or IP address of the prediction server. + port: Port number used by the prediction server. + + """ self.host = host self.port = port self.endpoints = { @@ -19,9 +28,21 @@ def __init__(self, host, port): "samples": "/samples", } self.protocol = "http" - self.timeout = 100 # timeout for the response + self.timeout = 100 # timeout for the response + + def _request_json( + self, endpoint: str, payload: dict[str, object] + ) -> dict[str, object]: + """Send a JSON POST request to an endpoint and decode the response. - def _request_json(self, endpoint: str, payload: dict[str, object]) -> dict[str, object]: + Args: + endpoint: Endpoint key configured in ``self.endpoints``. + payload: JSON-serializable payload to send in the request body. + + Returns: + Decoded JSON response payload. + + """ req = request.Request( url=f"{self.protocol}://{self.host}:{self.port}{self.endpoints[endpoint]}", data=json.dumps(payload).encode("utf-8"), @@ -32,6 +53,7 @@ def _request_json(self, endpoint: str, payload: dict[str, object]) -> dict[str, return json.loads(response.read().decode("utf-8")) def check_connection(self) -> bool: + """Check if the server is healthy by querying the health endpoint.""" try: data = self._request_json("health", {}).get("status", "payload missing") if data != "ok": @@ -44,14 +66,42 @@ def check_connection(self) -> bool: return False def predict(self, sample: Sample) -> Sample: - payload : dict [str,Any] = {"sample": sample_to_json_payload(sample) } + """Send a sample to the predict endpoint and return the predicted sample. + + The input sample is converted to a JSON payload, sent to the server, and the response is converted back to a Sample. + + Args: + sample: A Sample object containing the input data for prediction. + + Returns: + A Sample object containing the predicted output from the server. + + """ + payload: dict[str, Any] = {"sample": sample_to_json_payload(sample)} response = self._request_json("predict", payload) return sample_from_json_payload(response["samples"][0]) def problem_definition(self): + """Get the problem definition from the server. + + Returns: + A dictionary containing the problem definition as provided by the server. + """ return self._request_json("problem_definition", {}) def samples(self, sample_ids: list[int], split: str) -> list[Sample]: - payload : dict [str,Any] = {"sample_ids": sample_ids, "split": split} + """Request samples from the server by sample IDs and split. + + Args: + sample_ids: A list of integers representing the IDs of the samples to request. + split: A string indicating the data split (e.g., "training", "validation", "test") from which to request the samples. + + Returns: + A list of Sample objects corresponding to the requested sample IDs and split. + """ + payload: dict[str, Any] = {"sample_ids": sample_ids, "split": split} response = self._request_json("samples", payload) - return [sample_from_json_payload(sample_payload) for sample_payload in response["samples"]] \ No newline at end of file + return [ + sample_from_json_payload(sample_payload) + for sample_payload in response["samples"] + ] diff --git a/src/plaid/utils/sample_json.py b/src/plaid/utils/sample_json.py index e382002c..c8bae7d5 100644 --- a/src/plaid/utils/sample_json.py +++ b/src/plaid/utils/sample_json.py @@ -60,9 +60,7 @@ def sample_from_json_payload(payload: dict[str, Any]) -> Sample: if payload.get("format") != FORMAT_NAME: raise ValueError(f"Unsupported Sample JSON format: {payload.get('format')!r}") if payload.get("version") != FORMAT_VERSION: - raise ValueError( - f"Unsupported Sample JSON version: {payload.get('version')!r}" - ) + raise ValueError(f"Unsupported Sample JSON version: {payload.get('version')!r}") trees = payload.get("trees") if not isinstance(trees, list): @@ -75,7 +73,9 @@ def sample_from_json_payload(payload: dict[str, Any]) -> Sample: if not isinstance(entry, dict): raise ValueError("Each Sample JSON tree entry must be a dictionary") if "time" not in entry or "tree" not in entry: - raise ValueError("Each Sample JSON tree entry must contain 'time' and 'tree'") + raise ValueError( + "Each Sample JSON tree entry must contain 'time' and 'tree'" + ) time_value = _decode_time(entry["time"]) sample.data[time_value] = cgns_tree_from_json_payload(entry["tree"]) diff --git a/tests/containers/test_sample.py b/tests/containers/test_sample.py index 35d59644..ab47c0b8 100644 --- a/tests/containers/test_sample.py +++ b/tests/containers/test_sample.py @@ -1292,6 +1292,31 @@ def test_update_value_by_path(self, sample_with_tree): assert np.allclose(sample_with_tree.get_feature_by_path(path), new_field) + def test_update_value_by_path_warns_when_array_shape_differs( + self, sample_with_tree, caplog, monkeypatch + ): + path = "Base_2_2/Zone/VertexFields/test_node_field_1" + base_node = sample_with_tree.get_base("Base_2_2") + node = CGU.getNodeByPath(base_node, "/Zone/VertexFields/test_node_field_1") + original_get_value = CGU.getValue + calls = 0 + + def get_value_with_different_shape_once(current_node): + nonlocal calls + if current_node is node: + calls += 1 + if calls == 1: + return np.zeros(5) + return np.zeros((1, 5)) + return original_get_value(current_node) + + monkeypatch.setattr(CGU, "getValue", get_value_with_different_shape_once) + + with caplog.at_level("WARNING", logger="plaid.containers.sample"): + sample_with_tree.update_value_by_path(path, np.linspace(0.0, 1.0, 5)) + + assert "incomming data has shape" in caplog.text + def test_update_value_by_path_at_specific_time(self, sample, tree): path = "Base_2_2/Zone/VertexFields/test_node_field_1" time_0_field = np.linspace(0.0, 1.0, 5) diff --git a/tests/utils/test_predict_client.py b/tests/utils/test_predict_client.py index de277441..42687c9f 100644 --- a/tests/utils/test_predict_client.py +++ b/tests/utils/test_predict_client.py @@ -91,7 +91,7 @@ def test_check_connection_returns_false_for_bad_status(monkeypatch, capsys): """A non-ok health response returns False and reports the bad status.""" client = PlaidClient("localhost", 8000) monkeypatch.setattr( - client, "_request_json", lambda endpoint, payload: {"status": "bad"} + client, "_request_json", lambda _endpoint, _payload: {"status": "bad"} ) assert client.check_connection() is False @@ -105,7 +105,7 @@ def test_check_connection_returns_false_on_exception(monkeypatch, capsys): """Connection exceptions are caught and converted to False.""" client = PlaidClient("localhost", 8000) - def raise_error(endpoint, payload): + def raise_error(_endpoint, _payload): raise RuntimeError("server unavailable") monkeypatch.setattr(client, "_request_json", raise_error) From 8f40bef9c24e23f6c6818640dbe513ee99bdeb60 Mon Sep 17 00:00:00 2001 From: Felipe Bordeu Date: Wed, 10 Jun 2026 09:54:39 +0930 Subject: [PATCH 08/35] Add Tests ParaView Plugin --- tests/cli/test_paraview_plugin.py | 95 +++++++++++++++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 tests/cli/test_paraview_plugin.py diff --git a/tests/cli/test_paraview_plugin.py b/tests/cli/test_paraview_plugin.py new file mode 100644 index 00000000..b966f476 --- /dev/null +++ b/tests/cli/test_paraview_plugin.py @@ -0,0 +1,95 @@ +"""Tests for the ParaView plugin CLI helper module.""" + +from pathlib import Path +from types import SimpleNamespace + +from plaid.cli import paraview_plugin + + +def test_get_paraview_plugin_path_returns_module_directory(): + """The plugin path points to the directory containing the helper module.""" + plugin_path = paraview_plugin.get_ParaView_plugin_path() + + assert plugin_path == Path(paraview_plugin.__file__).parent + + +def test_convert_wsl_to_win_uses_wslpath(monkeypatch): + """WSL path conversion delegates to wslpath and strips its output.""" + calls = [] + + def fake_run(args, capture_output, text, check): + calls.append( + { + "args": args, + "capture_output": capture_output, + "text": text, + "check": check, + } + ) + return SimpleNamespace(stdout="C:\\Users\\me\\plugin\r\n") + + monkeypatch.setattr(paraview_plugin.subprocess, "run", fake_run) + + converted = paraview_plugin.convert_wsl_to_win("/mnt/c/Users/me/plugin") + + assert converted == "C:\\Users\\me\\plugin" + assert calls == [ + { + "args": ["wslpath", "-w", "/mnt/c/Users/me/plugin"], + "capture_output": True, + "text": True, + "check": True, + } + ] + + +def test_run_paraview_with_plugin_uses_default_executable(monkeypatch): + """Launching ParaView sets plugin-related environment variables.""" + popen_calls = [] + + def fake_popen(args, env): + process = SimpleNamespace(args=args, env=env) + popen_calls.append(process) + return process + + monkeypatch.delenv("PARAVIEW_EXEC", raising=False) + monkeypatch.setattr(paraview_plugin.subprocess, "Popen", fake_popen) + + process = paraview_plugin.run_paraview_with_plugin() + popen_call = popen_calls[0] + + assert process is popen_call + assert popen_call.args == [paraview_plugin.paraview_exec] + assert popen_call.env["PV_PLUGIN_PATH"] == str( + paraview_plugin.get_ParaView_plugin_path() + ) + assert popen_call.env["PARAVIEW_LOG_PLUGIN_VERBOSITY"] == "ON" + + +def test_run_paraview_with_plugin_uses_configured_windows_executable( + monkeypatch, +): + """A WSL-mounted ParaView executable enables WSLENV path propagation.""" + popen_calls = [] + + def fake_popen(args, env): + process = SimpleNamespace(args=args, env=env) + popen_calls.append(process) + return process + + paraview_exec = "/mnt/c/Program Files/ParaView/bin/paraview.exe" + monkeypatch.setenv("PARAVIEW_EXEC", paraview_exec) + monkeypatch.setattr(paraview_plugin.subprocess, "Popen", fake_popen) + + process = paraview_plugin.run_paraview_with_plugin() + popen_call = popen_calls[0] + + assert process is popen_call + assert popen_call.args == [paraview_exec] + assert popen_call.env["PV_PLUGIN_PATH"] == str( + paraview_plugin.get_ParaView_plugin_path() + ) + assert popen_call.env["PARAVIEW_LOG_PLUGIN_VERBOSITY"] == "ON" + assert ( + popen_call.env["WSLENV"] == "PV_PLUGIN_PATH/p:PARAVIEW_LOG_PLUGIN_VERBOSITY/p" + ) From 918203f308d0e90e5a19c14854af3180e1229c97 Mon Sep 17 00:00:00 2001 From: Felipe Bordeu Date: Wed, 10 Jun 2026 09:54:39 +0930 Subject: [PATCH 09/35] basic infra for the plugin --- ParaViewPlugin/PlaidPlugin.py | 144 ++++ .../paraview_plugin/PlaidParaViewPlugin.py | 588 ++++++++++++++ src/plaid/cli/paraview_plugin/__init__.py | 26 +- src/plaid/cli/serve.py | 748 ++++++++++++++++++ src/plaid/utils/cgns_vtk.py | 386 +++++++++ tests/cli/test_serve.py | 163 ++++ 6 files changed, 2052 insertions(+), 3 deletions(-) create mode 100644 ParaViewPlugin/PlaidPlugin.py create mode 100644 src/plaid/cli/paraview_plugin/PlaidParaViewPlugin.py create mode 100644 src/plaid/cli/serve.py create mode 100644 src/plaid/utils/cgns_vtk.py create mode 100644 tests/cli/test_serve.py diff --git a/ParaViewPlugin/PlaidPlugin.py b/ParaViewPlugin/PlaidPlugin.py new file mode 100644 index 00000000..f95a2a68 --- /dev/null +++ b/ParaViewPlugin/PlaidPlugin.py @@ -0,0 +1,144 @@ +# +# This file is subject to the terms and conditions defined in +# file 'LICENSE.txt', which is part of this source code package. +# + +# this files is intended to be used inside paraview as a plugin +# compatible with paraview 5.7+ +import os +import time +import locale +import pickle + + +_startTime = time.time() +debug = bool(os.environ.get("PARAVIEW_LOG_PLUGIN_VERBOSITY", False)) + +if debug: + + def PrintDebug(mes): + import time + + print(mes, time.time() - _startTime) +else: + + def PrintDebug(mes): + pass + + +try: + import numpy as np + + from paraview.util.vtkAlgorithm import smproxy, smproperty, smdomain, smhint + from paraview.util.vtkAlgorithm import VTKPythonAlgorithmBase + from vtkmodules.vtkCommonDataModel import vtkUnstructuredGrid + + PrintDebug("Loading libs") + from Muscat.Bridges.vtkBridge import SetOutputMuscat + + PrintDebug("Loading") + + paraview_plugin_name = "Plaid ParaView Plugin" + paraview_plugin_version = "5.11.1" + + @smproxy.reader( + name="PlaidSampleReader", + label="Plaid Sample Reader", + extensions="pickle", + file_description="pickle ", + ) + class PlaidSampleReader(VTKPythonAlgorithmBase): + def __init__(self): + VTKPythonAlgorithmBase.__init__( + self, nInputPorts=0, nOutputPorts=1, outputType="vtkUnstructuredGrid" + ) + self._filename: Optional[str] = None + self.timeSteps_cache = None # timesteps + self.cache = None # plaid sample + + @smproperty.stringvector(name="FileName") + @smdomain.filelist() + @smhint.filechooser(extensions="pickle", file_description="pickle files") + def SetFileName(self, name): + """Specify filename for the file to read.""" + if self._filename != name: + self._filename = name + self.timeSteps_cache = None + self.cache = None + self.version = 0 + self.Modified() + if name is not None: + self.GetTimestepValues() + + @smproperty.doublevector( + name="TimestepValues", + information_only="1", + si_class="vtkSITimeStepsProperty", + ) + def GetTimestepValues(self): + if self._filename is None or self._filename == "None": + return None + with open(self._filename, "rb") as f: + self.version = pickle.load(f) + if self.version == 0: + self.timeSteps_cache = pickle.load(f) + else: + self.timeSteps_cache, self.cache = pickle.load(f) + + return self.timeSteps_cache + + def RequestInformation(self, request, inInfoVec, outInfoVec): + executive = self.GetExecutive() + outInfo = outInfoVec.GetInformationObject(0) + outInfo.Remove(executive.TIME_STEPS()) + outInfo.Remove(executive.TIME_RANGE()) + + timeSteps = self.GetTimestepValues() + if timeSteps is not None: + for t in timeSteps: + outInfo.Append(executive.TIME_STEPS(), t) + outInfo.Append(executive.TIME_RANGE(), timeSteps[0]) + outInfo.Append(executive.TIME_RANGE(), timeSteps[-1]) + return 1 + + def RequestData(self, request, inInfoVec, outInfoVec): + if self._filename is None: + return 0 + + outInfo = outInfoVec.GetInformationObject(0) + executive = self.GetExecutive() + if outInfo.Has(executive.UPDATE_TIME_STEP()): + time = outInfo.Get(executive.UPDATE_TIME_STEP()) + else: + time = 0 + + # Read pickle files + import pickle + + if self.version == 0: + if self.cache == None: + with open(self._filename, "rb") as f: + # drop version + pickle.load(f) + # drop timevalues + pickle.load(f) + self.cache = pickle.load(f) + cgnsdata = self.cache.get_tree(time=time) + else: + i = self.timeSteps_cache.index(time) + with open(self._filename, "rb") as f: + f.seek(self.cache[i]) + cgnsdata = pickle.load(f) + + from Muscat.Bridges.CGNSBridge import CGNSToMesh + + mesh = CGNSToMesh(cgnsdata, partitionedMesh=False) + SetOutputMuscat(request, inInfoVec, outInfoVec, mesh, tagsAsFields=True) + return 1 + + PrintDebug("Plaid ParaView Plugin Loaded") +except Exception as ex: + print("Error loading Muscat ParaView Plugin") + print("Muscat in the PYTHONPATH ??? ") + if debug: + raise ex diff --git a/src/plaid/cli/paraview_plugin/PlaidParaViewPlugin.py b/src/plaid/cli/paraview_plugin/PlaidParaViewPlugin.py new file mode 100644 index 00000000..6ee53ebd --- /dev/null +++ b/src/plaid/cli/paraview_plugin/PlaidParaViewPlugin.py @@ -0,0 +1,588 @@ +# +# This file is subject to the terms and conditions defined in +# file 'LICENSE.txt', which is part of this source code package. +# +# exec(open("C:/Users/User/paraview/ParaViewPlugin.py","r").read()) +# This file is intended to be used inside ParaView as a plugin +# compatible with ParaView 5.7+ + +import json +import os +import time +from typing import Any, Optional +from urllib import request + +import numpy as np +import vtk +try: + from paraview.util.vtkAlgorithm import ( + VTKPythonAlgorithmBase, + smdomain, + smhint, + smproperty, + smproxy, + ) +except ImportError: + from vtk.util.vtkAlgorithm import VTKPythonAlgorithmBase + +## this inport are in a try because for some cases the plaid library is not available (clien server) +try: + from plaid.utils.cgns_json import cgns_tree_from_json_payload + from plaid.utils.cgns_vtk import CGNSTreeToVtk +except: + pass + +#this line is to inlcude the import to make the plugin selfcontain +#do not modify this line +# ##INCLUDE PLACEHOLDER## + +## utility funcitons +##////////////////////////////////////////////////////////// + +_start_time = time.time() +debug = bool(os.environ.get("PARAVIEW_LOG_PLUGIN_VERBOSITY", True)) + + +def print_debug(message: str) -> None: + """Print a debug message when plugin verbosity is enabled.""" + if debug: + print(message, time.time() - _start_time) + + +print_debug("Loading libs") + +paraview_plugin_name = "Plaid ParaView Plugin" +paraview_plugin_version = "5.11.1" + +def find_closest_numpy(arr, target): + """Find the value in arr that is closest to the target using numpy.""" + # Convert input to array if it isn't one + arr = np.asarray(arr) + # Find the index of the minimum absolute difference + idx = np.abs(arr - target).argmin() + # Return the value at that index + return arr[idx] + + +class PlaidDataSetBase(VTKPythonAlgorithmBase): + """Base class for Plaid dataset readers and clients, providing common properties and caching logic.""" + def __init__( + self, + nInputPorts, + nOutputPorts, + inputType="vtkUnstructuredGrid", + outputType="vtkUnstructuredGrid", + ): + # Correctly initialize the underlying VTK C++ layer + super().__init__( + nInputPorts=nInputPorts, + nOutputPorts=nOutputPorts, + inputType=inputType, + outputType=outputType, + ) + + self.sample_id: int = 0 + self._selected_split: str = "" + self._info_cache : Optional[dict]= None + self._problem_definition_cache : Optional[dict]= None + self._timestep_values_cache = None + self._sample_cache : Optional[dict] = None + + def _CleanCache(self): + self._info_cache = None + self._problem_definition_cache = None + self._timestep_values_cache = None + self._selected_split = "" + self._sample_cache = None + self.Modified() + + @smproperty.stringvector(name="SelectSplit", default_values="", panel_visibility="default", immediate_update="1") + @smdomain.xml(""" + + + + + + """) + def SetSelectedSplit(self, value): + if self._selected_split != value: + self._selected_split = value + self.Modified() + if isinstance(self._selected_split,str): + max_sample_id = self.GetSampleIdRange()[1] + self.sample_id = max(0, min(self.sample_id, max_sample_id)) + self._sample_cache = None + self.Modified() + + @smproperty.intvector(name="SampleIdRangeInfo", information_only="1", panel_visibility="default", immediate_update="1") + def GetSampleIdRange(self): + """Return [min, max] bounds for the SampleId slider.""" + infos = self.GetInfos() + if infos is None or self._selected_split not in infos["num_samples"]: + return (0, 0) + num_samples = int(infos["num_samples"][self._selected_split]) + print_debug(f"GetSampleIdRange {(0, max(0, num_samples - 1))}") + return (0, max(0, num_samples - 1)) + + + @smproperty.stringvector(name="AvailableSplitsInfo", information_only="1") + def GetAvailableSplits(self): + info = self.GetInfos() + if self._selected_split is None and len(info["num_samples"].keys()) : + self.SetSelectedSplit(list(info["num_samples"])[0] ) + print_debug(f"GetAvailableSplits {list(info["num_samples"].keys())}") + return list(info["num_samples"].keys()) + + @smproperty.stringvector(name="ReadOnly", panel_visibility="default", information_only="1", repeatable="1", number_of_elements_per_command="2") + def GetSomeTable(self): + info = self.GetInfos() + print_debug(f"GetSomeTable {['Split Name', 'Nb Samples']+[ [str(k),str(v)] for k, v in info["num_samples"].items() ]}") + return ['Split Name', 'Nb Samples']+[ [str(k),str(v)] for k, v in info["num_samples"].items() ] + + @smproperty.intvector(name="SampleId", default_values="0", panel_visibility="default", immediate_update="1") + @smdomain.xml(\ + """ + + + + + """) + def SetSampleId(self, value): + value = int(value) + max_value = self.GetSampleIdRange()[1] + value = max(0, min(value, max_value)) + if self.sample_id != value: + self.sample_id = value + self.timestep_values_cache = None + self._sample_cache = None + self.Modified() + + def RequestInformation(self, request, in_info_vec, out_info_vec): + executive = self.GetExecutive() + out_info = out_info_vec.GetInformationObject(0) + + time_steps = self.GetTimestepValues() + if len(time_steps) == 0: + return 1 + + out_info.Remove(executive.TIME_STEPS()) + out_info.Remove(executive.TIME_RANGE()) + + if len(time_steps) > 1: + for t in time_steps: + out_info.Append(executive.TIME_STEPS(), t) + out_info.Append(executive.TIME_RANGE(), time_steps[0]) + out_info.Append(executive.TIME_RANGE(), time_steps[-1]) + + print_debug(f" end RequestInformation-----------------------------{time_steps}") + return 1 + + + def RequestData(self, request, in_info_vec, out_info_vec): + out_info = out_info_vec.GetInformationObject(0) + executive = self.GetExecutive() + + if out_info.Has(executive.UPDATE_TIME_STEP()): + requested_time = float(out_info.Get(executive.UPDATE_TIME_STEP())) + else: + #values = self.GetTimestepValues() + #requested_time = float(values[0]) if values else 0.0 + requested_time = 0.0 + + sample_data = self.GetSampleData() + + if sample_data == "None": + return 1 + + + requested_time = find_closest_numpy(np.array(list(sample_data.keys())), requested_time) + cgnstree = sample_data[requested_time] + + new_output= CGNSTreeToVtk(cgnstree) + info = out_info_vec.GetInformationObject(0) + + info.Set(vtk.vtkDataObject.DATA_OBJECT(), new_output) + return 1 + + @smproperty.doublevector( + name="TimestepValues", + information_only="1", + ) + def GetTimestepValues(self): + if (self._timestep_values_cache is None) and (self._selected_split is not '' and self._selected_split is not None ) and self.sample_id > -1: + print_debug(f"{self._timestep_values_cache=}") + print_debug(f"{self._selected_split=}{type(self._selected_split)}") + print_debug(f"{self.sample_id=}{type(self.sample_id)}") + sample_data = self.GetSampleData() + self._timestep_values_cache = [float(t) for t in sample_data.keys()] + + if self._timestep_values_cache is None: + print_debug(f"GetTimestepValues {[0]}") + return [0] + print_debug(f"GetTimestepValues {self._timestep_values_cache}") + return self._timestep_values_cache + + +class PlaidClientBase(PlaidDataSetBase): + def __init__( + self, + nInputPorts, + nOutputPorts, + inputType="vtkUnstructuredGrid", + outputType="vtkUnstructuredGrid", + ): + # Correctly initialize the underlying VTK C++ layer + super().__init__( + nInputPorts=0, + nOutputPorts=1, + inputType=inputType, + outputType=outputType, + ) + self.host: str = "127.0.0.1" + self.port: int = 8000 + + def _CleanCache(self): + super()._CleanCache() + self.Modified() + + @smproperty.stringvector(name="Host", default_values="127.0.0.1") + def SetHost(self, value): + value = str(value) + if self.host != value: + self.host = value + self._CleanCache() + + @smproperty.intvector( + name="Port", default_values=os.environ.get("PLAID_PORT", "8000") + ) + def SetPort(self, value): + value = int(value) + if self.port != value: + self.port = value + self._CleanCache() + + def _request_json( + self, endpoint: str, payload: Optional[dict[str, object]] = None + ) -> dict[str, object]: + + data = json.dumps(payload).encode("utf-8") if payload is not None else None + + req = request.Request( + url=f"http://{self.host}:{self.port}{endpoint}", + data=data, + headers={"Content-Type": "application/json"}, + method="POST", + ) + with request.urlopen(req, timeout=20) as response: + return json.loads(response.read().decode("utf-8")) + + def GetProblemDefinition(self): + if self._problem_definition_cache is None: + self._problem_definition_cache = self._request_json("/problem_definition") + + return self._problem_definition_cache + + def GetInfos(self): + if self._info_cache is None: + self._info_cache = self._request_json("/infos") + return self._info_cache + +print_debug("Loading MaestroExplorer") + +@smproxy.source(name="MaestroExplorer", label="Maestro Explorer") +class MaestroExplorer(PlaidClientBase): + """ParaView source plugin fetching data from Maestro serve endpoints.""" + + def __init__(self): + super().__init__( + nInputPorts=0, + nOutputPorts=1 + ) + + self.timestep_values_cache: list[float] | None = None + self.usePredict: bool = False + self.input_features = "" + + @smproperty.stringvector(name="Host", default_values="127.0.0.1") + def SetHost(self, value): + return super().SetHost(value) + + @smproperty.intvector(name="Port", default_values=os.environ.get("PLAID_PORT", "8000")) + def SetPort(self, value): + return super().SetPort(value) + + @smproperty.stringvector(name="SelectSplit", default_values="", immediate_update="1") + @smdomain.xml(""" + + + + + + """) + def SetSelectedSplit(self, value): + print_debug(f"SetSelectedSplit {value}") + return super().SetSelectedSplit(value) + + @smproperty.intvector(name="SampleIdRangeInfo", information_only="1") + def GetSampleIdRange(self): + return super().GetSampleIdRange() + + @smproperty.stringvector(name="AvailableSplitsInfo", information_only="1") + def GetAvailableSplits(self): + return super().GetAvailableSplits() + + @smproperty.doublevector( + name="TimestepValues", + information_only="1", + ) + def GetTimestepValues(self): + return super().GetTimestepValues() + + # """ + # + # + # + # + + # """ + # @smproperty.xml(""" + # + # + + # + # + # + # + # """) + + + @smproperty.intvector(name="SampleId", default_values="0", immediate_update="1") + @smdomain.xml(\ + """ + + + + + """) + def SetSampleId(self, value): + print_debug(f"SetSampleId {value}") + return super().SetSampleId(value) + + @smproperty.stringvector(name="ReadOnly", panel_visibility="default", information_only="1", repeatable="1", number_of_elements_per_command="2") + def GetSomeTable(self): + return super().GetSomeTable() + + @smproperty.xml(""" + + + + This property indicates if we use the sample or the predict endpoint + + """) + def SetPredict(self, value): + """Set whether to use the /predict endpoint instead of /sample. This is a boolean property. + """ + bool_value = str(value).lower() in ["true", "1"] + if self.usePredict != bool_value: + self.usePredict = bool_value + self._sample_cache = None + self.Modified() + + def GetSampleData(self): + """Fetch sample data for the currently selected split and sample ID, with caching.""" + if self._sample_cache is None: + endpoint = "/predict" if self.usePredict else "/samples" + payload = { + "sample_ids": [self.sample_id], + "split": self._selected_split, + } + + if len(self.input_features): + payload["input_features"] = [json.loads(self.input_features)] + + response = self._request_json( + endpoint, + payload, + ) + sample_payload = response.get("samples", [None])[0].get("trees") + sample_data = {} + + for entry in sample_payload: + time_value = float(entry["time"]) + sample_data[time_value] = cgns_tree_from_json_payload(entry["tree"]) + self._sample_cache = sample_data + + return self._sample_cache + + # @smproperty.xml(""" + # + # + # + # + # + + # + # + # + # """) + # def SetInputFeatures(self, value): + # if value is None: + # value = "" + # value = str(value) + # if self.input_features != value: + # def ensureEncluse(string, start, end): + # string = string.strip() + # if not string.startswith(start): + # string = start + string + # if not string.endswith(end): + # string = string + end + # return string + + # treated_input_features = value.replace("'", '"').strip() + # treated_input_features = value.replace("=", ":").strip() + # clean_treated_input_features = [] + # for line in treated_input_features.splitlines(): + # k, v = line.split(":") + # k = ensureEncluse(k, '"', '"') + # clean_treated_input_features.append(k + ":" + v) + + # treated_input_features = ",".join(clean_treated_input_features) + # treated_input_features = ensureEncluse(treated_input_features, "{", "}") + # self.input_features = treated_input_features + # self.Modified() + + + + + + +# paraview.servermanager.LoadPlugin("/home/fbw/repos/Safran/plaid/src/plaid/cli/paraview_plugin/PlaidParaViewPlugin.py") +try: + # try to load the reader if plaid is locally available + from plaid.storage.reader import load_infos_from_disk, init_from_disk + + + # + #> + + @smproxy.reader( + name="PlaidDatasetReader", + label="Plaid Dataset Reader", + file_description="Directory ", + is_directory="True", + filename_patterns="*" + ) + class PlaidDataSetReader(PlaidDataSetBase): + def __init__(self): + super().__init__( + nInputPorts=0, nOutputPorts=1, outputType="vtkUnstructuredGrid" + ) + self._filename: Optional[str] = '' + self.datasetdict_cache = None + self.converterdict_cache = None + + + + def _CleanCache(self): + super()._CleanCache() + self.datasetdict_cache = None + self.converterdict_cache = None + self._info_cache = None + self._problem_definition_cache = None + self._selected_split = "" + self.Modified() + + @smproperty.stringvector(name="FileName") + @smdomain.filelist() + @smhint.filechooser(extensions="ext", file_description="ext" + " files") + def SetFileName(self, name): + """Specify filename for the file to read.""" + if self._filename != name: + self._filename = name + self._CleanCache() + + + + @smproperty.stringvector(name="SelectSplit", default_values="") + @smdomain.xml(""" + + + + + + """) + def SetSelectedSplit(self, value): + return super().SetSelectedSplit(value) + + @smproperty.intvector(name="SampleIdRangeInfo", information_only="1") + def GetSampleIdRange(self): + return super().GetSampleIdRange() + + @smproperty.stringvector(name="AvailableSplitsInfo", information_only="1") + def GetAvailableSplits(self): + return super().GetAvailableSplits() + + @smproperty.intvector(name="SampleId", default_values="0", immediate_update="1") + @smdomain.xml(\ + """ + + + + + """) + def SetSampleId(self, value): + return super().SetSampleId(value) + + @smproperty.stringvector(name="ReadOnly", panel_visibility="default", information_only="1", repeatable="1", number_of_elements_per_command="2") + def GetSomeTable(self): + return super().GetSomeTable() + + @smproperty.doublevector( + name="TimestepValues", + information_only="1", + ) + def GetTimestepValues(self): + return super().GetTimestepValues() + + def GetInfos(self): + if self._info_cache is not None : + return self._info_cache + + if (self._info_cache is None) and (self._filename is not None and self._filename != "None" ): + self._info_cache = load_infos_from_disk(self._filename) + self.Modified() + else: + return {"num_samples":{}} + + return self._info_cache + + def GetSampleData(self) -> dict[float,list]: + if self._sample_cache is None: + if self.datasetdict_cache is None: + self.datasetdict_cache, self.converterdict_cache = init_from_disk(self._filename) + + self._sample_cache = self.converterdict_cache[self._selected_split].to_plaid(self.datasetdict_cache[self._selected_split], self.sample_id) + return self._sample_cache.data + + + + + print_debug("Reader PlaidSampleReader Loaded") +except ImportError: + pass + +print_debug("Plaid ParaView Plugin Loaded") \ No newline at end of file diff --git a/src/plaid/cli/paraview_plugin/__init__.py b/src/plaid/cli/paraview_plugin/__init__.py index d06a5678..2c53c481 100644 --- a/src/plaid/cli/paraview_plugin/__init__.py +++ b/src/plaid/cli/paraview_plugin/__init__.py @@ -3,14 +3,35 @@ import os import subprocess from pathlib import Path +import tempfile paraview_exec = "paraview" - def get_ParaView_plugin_path(): """Returns the path to the ParaView plugin directory.""" return Path(__file__).parent +def get_ParaView_plugin_path_one_file(): + + plugin_path = Path(__file__).parent / "PlaidParaViewPlugin.py" + plugin_content = open(plugin_path,"r").read() + + import plaid.utils.cgns_json as cgns_json + sample_json_content = open(Path(cgns_json.__file__),"r").read() + + import plaid.utils.cgns_vtk as cgns_vtk + cgns_vtk_content = open(Path(cgns_vtk.__file__),"r").read() + + plugin_content = plugin_content.replace("# ##INCLUDE PLACEHOLDER##",sample_json_content + cgns_vtk_content) + plugin_content = plugin_content.replace("from __future__ import annotations","") + + tmpdir = tempfile.mkdtemp() + file_path = os.path.join(tmpdir, "PlaidParaViewPlugin.py") + + # Write full plugin to the temporary file + with open(file_path, "w") as f: + f.write(plugin_content) + return tmpdir def convert_wsl_to_win(wsl_path: str) -> str: r"""Converts a WSL path (e.g., /mnt/c/Users) to Windows (C:\\Users).""" @@ -19,12 +40,11 @@ def convert_wsl_to_win(wsl_path: str) -> str: ) return result.stdout.strip() - def run_paraview_with_plugin(): """Launches ParaView with environment variables set to load the plugin.""" my_env = os.environ.copy() - my_env["PV_PLUGIN_PATH"] = str(get_ParaView_plugin_path()) + my_env["PV_PLUGIN_PATH"] = str(get_ParaView_plugin_path_one_file()) my_env["PARAVIEW_LOG_PLUGIN_VERBOSITY"] = "ON" current_pv_path = os.environ.get("PARAVIEW_EXEC", paraview_exec) diff --git a/src/plaid/cli/serve.py b/src/plaid/cli/serve.py new file mode 100644 index 00000000..84277b1b --- /dev/null +++ b/src/plaid/cli/serve.py @@ -0,0 +1,748 @@ +"""PLAID data server entry point for ParaView/client integrations. + +This module provides both a CLI entry point and HTTP API entry points. + +CLI entry point +--------------- +- ``main(argv=None)`` (installed command: ``plaid-serve``) +- Arguments: + - ``--host``: bind address (default ``0.0.0.0``) + - ``--port``: bind port (default ``8000``) + - ``ParaViewRun``: if true, launch ParaView plugin and run server in background + +HTTP entry points +----------------- +All dataset API routes are handled by :meth:`_Handler.do_GET` and currently +expect a JSON request body (even though the verb is ``GET``). + +- ``GET /health`` + - Input payload: none + - Output payload: + - ``{"status": "ok"}`` + +- ``GET /splits`` + - Input payload: + - ``dataset`` or ``uri`` (string, required) + - ``split`` (string, optional) + - Output payload: + - ``{"splits": {"": , ...}}`` + +- ``GET /timesteps`` + - Input payload: + - ``dataset`` or ``uri`` (string, required) + - ``split`` (string, optional; required if dataset has multiple splits) + - ``sample_ids`` (list[int], required, non-empty) + - ``include_features`` (list[str], optional; validated but not used) + - Output payload: + - ``{"time_times": [{"sample_id": int, "times": list[float], "count": int}, ...]}`` + +- ``GET /samples`` + - Input payload: + - ``dataset`` or ``uri`` (string, required) + - ``split`` (string, optional; required if dataset has multiple splits) + - ``sample_ids`` (list[int], required, non-empty) + - ``include_features`` (list[str], optional) + - Output payload: + - ``{"samples": [, ...]}`` + +- ``GET /samples_time`` + - Input payload: + - ``dataset`` or ``uri`` (string, required) + - ``split`` (string, optional; required if dataset has multiple splits) + - ``sample_ids`` (list[int], required, must contain exactly one id) + - ``include_features`` (list[str], optional) + - ``time`` (int | float, required) + - Output payload: + - ``{"samples": [], "time": float}`` + + methode to implement +- ``GET /entry_points`` + - Output payload: + - ``{"samples_time": True, `predict`:False...} + + + +Methods currently implemented +----------------------------- +- Implemented: ``GET`` for all above routes. +- Not implemented: ``PUT`` routes. + +Error payloads +-------------- +- Validation errors return ``400`` with ``{"error": ""}``. +- Unexpected errors return ``500`` with + ``{"error": "Internal server error: "}``. + +No Maestro runtime or prediction endpoint is used here. +""" + +from __future__ import annotations + +import argparse +import json +import logging +from dataclasses import dataclass, field +from http import HTTPStatus +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from typing import Any, cast +from urllib.parse import urlparse + +import numpy as np + +from plaid.containers import Sample +from plaid.storage import init_from_disk +from plaid.storage.common.preprocessor import build_sample_dict +from plaid.storage.common.reader import ( + load_infos_from_disk, + load_problem_definitions_from_disk, +) +from plaid.utils.sample_json import sample_to_json_payload + +log = logging.getLogger(__name__) + + +def _to_jsonable(value: object) -> object: + """Convert numpy/scalar values into JSON-serializable structures. + + Args: + value: Input value to serialize. + + Returns: + JSON-compatible Python object. + """ + if isinstance(value, np.ndarray): + return value.tolist() + if isinstance(value, np.generic): + return value.item() + if isinstance(value, dict): + return {str(k): _to_jsonable(v) for k, v in value.items()} + if isinstance(value, list): + return [_to_jsonable(v) for v in value] + if isinstance(value, tuple): + return [_to_jsonable(v) for v in value] + return value + + +def _sample_to_payload( + sample: Sample, + include_features: list[str] | None, +) -> dict[str, object]: + """Serialize a PLAID sample to a JSON-ready payload. + + Args: + sample: PLAID sample to serialize. + include_features: Optional list of feature paths to keep. + + Returns: + Dictionary payload keyed by feature path. + """ + sample_dict, _, _ = build_sample_dict(sample) + serialized = {str(key): _to_jsonable(value) for key, value in sample_dict.items()} + if not include_features: + return serialized + allowed = set(include_features) + return {key: value for key, value in serialized.items() if key in allowed} + + +def _parse_request_payload(handler: BaseHTTPRequestHandler) -> dict[str, object]: + """Parse and validate a JSON request payload from an HTTP handler. + + Args: + handler: Request handler exposing headers and input stream. + + Returns: + Parsed JSON payload. + + Raises: + ValueError: If body is missing, invalid, or not a JSON object. + """ + content_length = int(handler.headers.get("Content-Length", "0")) + if content_length <= 0: + raise ValueError("Request body must be a non-empty JSON object") + + raw = handler.rfile.read(content_length) + payload = json.loads(raw.decode("utf-8")) + if not isinstance(payload, dict): + raise ValueError("Request body must be a JSON object") + return cast(dict[str, object], payload) + + +def _parse_optional_request_payload( + handler: BaseHTTPRequestHandler, +) -> dict[str, object]: + """Parse an optional JSON request payload from an HTTP handler. + + Args: + handler: Request handler exposing headers and input stream. + + Returns: + Parsed JSON object, or an empty dictionary when no body is provided. + + Raises: + ValueError: If a provided body is invalid or not a JSON object. + """ + content_length = int(handler.headers.get("Content-Length", "0")) + if content_length <= 0: + return {} + + raw = handler.rfile.read(content_length) + payload = json.loads(raw.decode("utf-8")) + if not isinstance(payload, dict): + raise ValueError("Request body must be a JSON object") + return cast(dict[str, object], payload) + + +def _parse_dataset_uri(request: dict[str, object]) -> str: + """Extract dataset location from a request payload. + + Supported keys are ``dataset`` and ``uri``. + + Args: + request: Parsed JSON payload. + + Returns: + Dataset location string. + + Raises: + ValueError: If no valid dataset location is provided. + """ + for key in ("dataset", "uri"): + value = request.get(key) + if isinstance(value, str) and value.strip(): + return value.strip() + raise ValueError("dataset path/URI is required (dataset or uri)") + + +def _resolve_dataset_uri( + request: dict[str, object], + default_dataset_uri: str | None, +) -> str: + """Resolve dataset URI from request payload or server default. + + Args: + request: Parsed JSON payload. + default_dataset_uri: Optional dataset configured at server start. + + Returns: + Dataset path/URI string. + + Raises: + ValueError: If no dataset can be resolved. + """ + try: + return _parse_dataset_uri(request) + except ValueError: + if default_dataset_uri is not None and default_dataset_uri.strip(): + return default_dataset_uri.strip() + raise + + +def _parse_split(request: dict[str, object]) -> str | None: + """Extract optional split from request payload. + + Args: + request: Parsed JSON payload. + + Returns: + The split name if provided, else ``None``. + + Raises: + ValueError: If split is present but invalid. + """ + split = request.get("split") + if split is None: + return None + if not isinstance(split, str) or not split.strip(): + raise ValueError("split must be a non-empty string") + return split.strip() + + +def _validate_sample_request( + request: dict[str, object], +) -> tuple[list[int], list[str] | None]: + """Validate and extract common sample request fields. + + Args: + request: Parsed JSON payload. + + Returns: + Tuple ``(sample_ids, include_features)``. + + Raises: + ValueError: If request fields are invalid. + """ + sample_ids = request.get("sample_ids") + include_features = request.get("include_features") + + if not isinstance(sample_ids, list) or not all( + isinstance(sample_id, int) for sample_id in sample_ids + ): + raise ValueError("sample_ids must be a list of integers") + if len(sample_ids) == 0: + raise ValueError("sample_ids must not be empty") + if any(sample_id < 0 for sample_id in sample_ids): + raise ValueError("sample_ids must be non-negative integers") + + if include_features is not None and ( + not isinstance(include_features, list) + or not all(isinstance(feature, str) for feature in include_features) + ): + raise ValueError("include_features must be a list of strings") + + return sample_ids, cast(list[str] | None, include_features) + + +def _restrict_sample_to_time(sample: Sample, time: float | None) -> Sample: + """Restrict a sample to a single time value when requested. + + Args: + sample: Source sample. + time: Requested time value, or ``None`` for full sample. + + Returns: + Original sample if ``time is None`` else a sample containing only that time. + """ + if time is None: + return sample + sample_tmp = type(sample)() + sample_tmp.features.data[time] = sample.features.data[time] + return sample_tmp + + +@dataclass(slots=True) +class _DatasetStore: + """Cached dataset/converter dictionaries for a dataset URI.""" + + datasetdict: dict[str, Any] + converterdict: dict[str, Any] + + +@dataclass(slots=True) +class ServeContext: + """Serving context shared by all HTTP handlers.""" + + default_dataset_uri: str | None = None + stores: dict[str, _DatasetStore] = field(default_factory=dict) + + def resolve_dataset_uri(self, request: dict[str, object]) -> str: + """Resolve dataset URI from a request or the server default. + + Args: + request: Parsed JSON payload. + + Returns: + Dataset path/URI string. + """ + return _resolve_dataset_uri(request, self.default_dataset_uri) + + def _get_store(self, dataset_uri: str) -> _DatasetStore: + """Load and cache a PLAID dataset from disk. + + Args: + dataset_uri: Dataset path/URI provided by the client. + + Returns: + Loaded/cached dataset store. + + Raises: + ValueError: If dataset cannot be loaded. + """ + key = dataset_uri.strip() + if key in self.stores: + return self.stores[key] + + local_path = Path(key) + if not local_path.exists() or not local_path.is_dir(): + raise ValueError( + f"Dataset path does not exist or is not a directory: {key}" + ) + + try: + datasetdict, converterdict = init_from_disk(str(local_path)) + except Exception as exc: # pragma: no cover - defensive path + raise ValueError(f"Failed to load dataset from {key}: {exc}") from exc + + if len(datasetdict) == 0: + raise ValueError(f"Dataset has no splits: {key}") + + store = _DatasetStore(datasetdict=datasetdict, converterdict=converterdict) + self.stores[key] = store + return store + + @staticmethod + def _resolve_split(store: _DatasetStore, split: str | None) -> str: + """Resolve a split key from request. + + Args: + store: Dataset store. + split: Requested split, or ``None``. + + Returns: + Resolved split key. + + Raises: + ValueError: If split is ambiguous or unknown. + """ + available = list(store.datasetdict.keys()) + if split is not None: + if split not in store.datasetdict: + raise ValueError( + f"Unknown split {split!r}; available splits: {sorted(available)}" + ) + return split + + if len(available) == 1: + return available[0] + + raise ValueError( + "split is required when dataset has multiple splits; " + f"available splits: {sorted(available)}" + ) + + def get_splits(self, dataset_uri: str) -> dict[str, int | None]: + """Return available splits and sample counts for a dataset. + + Args: + dataset_uri: Dataset path/URI provided by the client. + + Returns: + Mapping ``split -> count``. + """ + store = self._get_store(dataset_uri) + counts: dict[str, int | None] = {} + for split, ds in store.datasetdict.items(): + try: + counts[split] = len(ds) + except TypeError: + counts[split] = None + return counts + + def get_time_steps( + self, + dataset_uri: str, + split: str | None, + sample_ids: list[int], + ) -> list[dict[str, object]]: + """Return available time-step values for requested samples. + + Args: + dataset_uri: Dataset path/URI. + split: Requested split. + sample_ids: Existing sample IDs. + + Returns: + List of dictionaries with sample id, time values and count. + """ + store = self._get_store(dataset_uri) + split_key = self._resolve_split(store, split) + print(f"get_time_steps : {split_key}") + if split_key is None: + return [ + { + "sample_id": sample_id, + "times": [], + "count": 0, + } + for sample_id in sample_ids + ] + + dataset = store.datasetdict[split_key] + converter = store.converterdict[split_key] + + results: list[dict[str, object]] = [] + for sample_id in sample_ids: + sample = converter.to_plaid(dataset, sample_id) + times = [float(time) for time in sample.get_all_time_values()] + results.append( + { + "sample_id": sample_id, + "times": times, + "count": len(times), + } + ) + return results + + def get_samples( + self, + dataset_uri: str, + split: str | None, + sample_ids: list[int], + include_features: list[str] | None, + time: float | None = None, + ) -> list[dict[str, object]]: + """Return serialized source samples from a PLAID dataset. + + Args: + dataset_uri: Dataset path/URI. + split: Requested split. + sample_ids: Existing sample IDs. + include_features: Optional list of feature paths to keep. + time: Optional specific time value to read. + + Returns: + Serialized sample payloads. + """ + store = self._get_store(dataset_uri) + split_key = self._resolve_split(store, split) + dataset = store.datasetdict[split_key] + converter = store.converterdict[split_key] + + payloads: list[dict[str, object]] = [] + for sample_id in sample_ids: + sample = converter.to_plaid(dataset, sample_id) + payloads.append( + _sample_to_payload( + sample=_restrict_sample_to_time(sample, time), + include_features=include_features, + ) + ) + return payloads + + def get_sample_objects( + self, + dataset_uri: str, + split: str | None, + sample_ids: list[int], + ) -> list[Sample]: + """Return source samples from a PLAID dataset. + + Args: + dataset_uri: Dataset path/URI. + split: Requested split. + sample_ids: Existing sample IDs. + + Returns: + PLAID sample objects. + """ + store = self._get_store(dataset_uri) + split_key = self._resolve_split(store, split) + dataset = store.datasetdict[split_key] + converter = store.converterdict[split_key] + + return [converter.to_plaid(dataset, sample_id) for sample_id in sample_ids] + + @staticmethod + def get_infos(dataset_uri: str) -> dict[str, object]: + """Return dataset infos in Maestro-compatible JSON shape. + + Args: + dataset_uri: Dataset path/URI. + + Returns: + Serialized dataset infos. + """ + return cast(dict[str, object], load_infos_from_disk(dataset_uri).model_dump()) + + @staticmethod + def get_problem_definition( + dataset_uri: str, + name: str | None = None, + ) -> dict[str, object]: + """Return one problem definition in Maestro-compatible JSON shape. + + Args: + dataset_uri: Dataset path/URI. + name: Optional problem definition name to select. + + Returns: + Serialized problem definition. + + Raises: + ValueError: If the requested definition is unavailable. + """ + problem_definitions = load_problem_definitions_from_disk(dataset_uri) + if name is not None: + if name not in problem_definitions: + raise ValueError( + f"Problem definition {name!r} not found; available definitions: " + f"{sorted(problem_definitions)}" + ) + return cast(dict[str, object], problem_definitions[name].model_dump()) + + if "PLAID_benchmark" in problem_definitions: + return cast( + dict[str, object], + problem_definitions["PLAID_benchmark"].model_dump(), + ) + if len(problem_definitions) == 1: + problem_definition = next(iter(problem_definitions.values())) + return cast(dict[str, object], problem_definition.model_dump()) + + first_name = sorted(problem_definitions)[0] + return cast(dict[str, object], problem_definitions[first_name].model_dump()) + + +class _Handler(BaseHTTPRequestHandler): + """HTTP handler for PLAID dataset requests.""" + + def _send_json( + self, + payload: dict[str, object], + status: HTTPStatus = HTTPStatus.OK, + ) -> None: + body = json.dumps(payload).encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + + def do_POST(self) -> None: # noqa: N802 + """Handle Maestro-compatible POST serve API requests.""" + parsed = urlparse(self.path) + + if parsed.path == "/health": + self._send_json({"status": "ok"}) + return + + if parsed.path == "/predict": + self._send_json( + {"error": "Endpoint /predict is not supported by PLAID serve"}, + status=HTTPStatus.NOT_IMPLEMENTED, + ) + return + + if parsed.path not in { + "/samples", + "/problem_definition", + "/infos", + }: + self._send_json({"POST error": "Not Found"}, status=HTTPStatus.NOT_FOUND) + return + + try: + request_payload = _parse_optional_request_payload(self) + server = cast("_ServeHTTPServer", self.server) + dataset_uri = server.context.resolve_dataset_uri(request_payload) + + if parsed.path == "/problem_definition": + problem_definition_name = request_payload.get( + "problem_definition", + request_payload.get("problem_definition_name"), + ) + if problem_definition_name is not None and not isinstance( + problem_definition_name, str + ): + raise ValueError("problem_definition must be a string") + self._send_json( + server.context.get_problem_definition( + dataset_uri, + name=problem_definition_name, + ) + ) + return + + if parsed.path == "/infos": + self._send_json(server.context.get_infos(dataset_uri)) + return + + sample_ids, _ = _validate_sample_request(request_payload) + split = _parse_split(request_payload) + samples = server.context.get_sample_objects( + dataset_uri=dataset_uri, + split=split, + sample_ids=sample_ids, + ) + self._send_json( + {"samples": [sample_to_json_payload(sample) for sample in samples]} + ) + return + + except ValueError as exc: + self._send_json({"error": str(exc)}, status=HTTPStatus.BAD_REQUEST) + except Exception as exc: # pragma: no cover - defensive path + log.exception("Serve API request failed") + self._send_json( + {"error": f"Internal server error: {exc}"}, + status=HTTPStatus.INTERNAL_SERVER_ERROR, + ) + + +class _ServeHTTPServer(ThreadingHTTPServer): + """HTTP server holding shared serving context.""" + + def __init__(self, server_address: tuple[str, int], context: ServeContext): + super().__init__(server_address, _Handler) + self.context = context + + +def _build_parser() -> argparse.ArgumentParser: + """Build CLI argument parser. + + Returns: + Configured argument parser. + """ + parser = argparse.ArgumentParser( + prog="plaid-serve", + description="Run PLAID dataset server for client/ParaView integrations.", + ) + parser.add_argument("--host", default="0.0.0.0", help="Bind address.") + parser.add_argument("--port", type=int, default=8000, help="Bind port.") + parser.add_argument( + "--dataset", + default=None, + help="Default dataset path used by Maestro-compatible POST endpoints.", + ) + parser.add_argument( + "--ParaViewRun", + action="store_true", + help="Run ParaView before the server (path from the PARAVIEW_EXEC env varialbe).", + ) + return parser + + +def main(argv: list[str] | None = None) -> int: + """Run HTTP server for PLAID dataset access. + + Args: + argv: Optional override of ``sys.argv[1:]`` for tests. + + Returns: + Process exit code. + """ + args = _build_parser().parse_args(argv) + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)s %(name)s: %(message)s", + ) + if args.ParaViewRun: + import os + + os.environ["PLAID_PORT"] = str(args.port) + from .paraview_plugin import run_paraview_with_plugin + + process = run_paraview_with_plugin() + else: + process = None + + context = ServeContext(default_dataset_uri=args.dataset) + server = _ServeHTTPServer((str(args.host), int(args.port)), context) + log.info("PLAID data API listening on http://%s:%s", args.host, args.port) + + if process is None: + print("Runing server for ever") + server.serve_forever() + else: + print("Launching the server for paraview") + import threading + + server_thread = threading.Thread(target=server.serve_forever, daemon=True) + server_thread.start() + try: + print("Wainting for paraview to stop") + process.wait() + except: # noqa: E722 + process.kill() + finally: + print("Killing server") + server.shutdown() + server.server_close() + server_thread.join(timeout=5) + + return 0 + + +if __name__ == "__main__": # pragma: no cover - CLI entry + raise SystemExit(main()) diff --git a/src/plaid/utils/cgns_vtk.py b/src/plaid/utils/cgns_vtk.py new file mode 100644 index 00000000..9c78fe0a --- /dev/null +++ b/src/plaid/utils/cgns_vtk.py @@ -0,0 +1,386 @@ +from typing import Any, List, Optional + +import numpy as np + +# Direct CGNS -> VTK conversion tables. These maps deliberately use only CGNS +# element numbers and VTK cell numbers so the converter below does not depend on +CGNSNumberToVtkNumber = { + 2: 1, # NODE -> VTK_VERTEX + 3: 3, # BAR_2 -> VTK_LINE + 4: 21, # BAR_3 -> VTK_QUADRATIC_EDGE + 5: 5, # TRI_3 -> VTK_TRIANGLE + 6: 22, # TRI_6 -> VTK_QUADRATIC_TRIANGLE + 7: 9, # QUAD_4 -> VTK_QUAD + 8: 23, # QUAD_8 -> VTK_QUADRATIC_QUAD + 9: 28, # QUAD_9 -> VTK_BIQUADRATIC_QUAD + 10: 10, # TETRA_4 -> VTK_TETRA + 11: 24, # TETRA_10 -> VTK_QUADRATIC_TETRA + 12: 14, # PYRA_5 -> VTK_PYRAMID + 14: 13, # PENTA_6 -> VTK_WEDGE + 15: 26, # PENTA_15 -> VTK_QUADRATIC_WEDGE + 16: 32, # PENTA_18 -> VTK_BIQUADRATIC_QUADRATIC_WEDGE + 17: 12, # HEXA_8 -> VTK_HEXAHEDRON + 18: 25, # HEXA_20 -> VTK_QUADRATIC_HEXAHEDRON + 19: 29, # HEXA_27 -> VTK_TRIQUADRATIC_HEXAHEDRON + 21: 27, # PYRA_13 -> VTK_QUADRATIC_PYRAMID +} + +CGNSNumberOfNodes = { + 2: 1, + 3: 2, + 4: 3, + 5: 3, + 6: 6, + 7: 4, + 8: 8, + 9: 9, + 10: 4, + 11: 10, + 12: 5, + 13: 14, + 14: 6, + 15: 15, + 16: 18, + 17: 8, + 18: 20, + 19: 27, + 21: 13, +} + +# CGNS and VTK share the same ordering for the linear and most quadratic cells +# used here. The entries below cover the higher-order cells for which Muscat's +# CGNS bridge already documents an ordering difference and VTK supports the cell. +CGNSNumberToVtkPermutation = { + 15: [0, 1, 2, 3, 4, 5, 6, 7, 8, 12, 13, 14, 9, 10, 11], + 16: [0, 1, 2, 3, 4, 5, 6, 7, 8, 12, 13, 14, 9, 10, 11, 15, 16, 17], + 18: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 16, 17, 18, 19, 12, 13, 14, 15], + 19: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 16, 17, 18, 19, 12, 13, 14, 15, 24, 22, 21, 23, 20, 25, 26] +} + + + +def _cgns_children_by_label(node: list, label: str) -> List[list]: + """Return direct children of a CGNS/Python node matching a label.""" + return [child for child in node[2] if len(child) > 3 and child[3] == label] + + +def _cgns_child_by_name(node: list, name: str) -> Optional[list]: + """Return a direct child of a CGNS/Python node by name.""" + for child in node[2]: + if child[0] == name: + return child + return None + + +def _cgns_value_as_string(node: Optional[list]) -> Optional[str]: + """Decode a CGNS character-array node without using CGNS or Muscat helpers.""" + if node is None or node[1] is None: + return None + value = node[1] + if isinstance(value, str): + return value + array = np.asarray(value) + if array.dtype.kind in ["S", "U"]: + return b"".join(np.asarray(array, dtype="|S1").ravel(order="F").tolist()).decode("ascii", errors="ignore").strip("\x00 ") + return str(value) + + +def _import_vtk_for_direct_cgns(): + """Import VTK classes needed by the direct CGNS -> VTK converter.""" + try: # pragma: no cover + from paraview.vtk import ( + vtkCellArray, + vtkMultiBlockDataSet, + vtkPoints, + vtkStructuredGrid, + vtkUnstructuredGrid, + ) + from paraview.vtk.util import numpy_support + except Exception: + from vtkmodules.util import numpy_support + from vtkmodules.vtkCommonCore import vtkPoints + from vtkmodules.vtkCommonDataModel import ( + vtkCellArray, + vtkMultiBlockDataSet, + vtkStructuredGrid, + vtkUnstructuredGrid, + ) + return vtkStructuredGrid, vtkUnstructuredGrid, vtkPoints, vtkCellArray, vtkMultiBlockDataSet, numpy_support + + +def _cgns_zone_points_to_vtk_points(zoneNode: list, physicalDim: int, numpy_support, vtkPoints): + """Read GridCoordinates_t from one CGNS zone and return vtkPoints plus coordinate shape.""" + gridCoordinatesNodes = _cgns_children_by_label(zoneNode, "GridCoordinates_t") + if not gridCoordinatesNodes: + raise ValueError(f"CGNS zone '{zoneNode[0]}' has no GridCoordinates_t child") + + gridCoordinates = gridCoordinatesNodes[0] + xNode = _cgns_child_by_name(gridCoordinates, "CoordinateX") + yNode = _cgns_child_by_name(gridCoordinates, "CoordinateY") + zNode = _cgns_child_by_name(gridCoordinates, "CoordinateZ") + if xNode is None or xNode[1] is None: + raise ValueError(f"CGNS zone '{zoneNode[0]}' has no CoordinateX array") + + x = np.asarray(xNode[1]) + y = np.zeros_like(x) if yNode is None or yNode[1] is None else np.asarray(yNode[1]) + z = np.zeros_like(x) if zNode is None or zNode[1] is None else np.asarray(zNode[1]) + + pointsArray = np.empty((x.size, 3), dtype=np.float64) + pointsArray[:, 0] = x.ravel(order="C") + pointsArray[:, 1] = y.ravel(order="C") + if physicalDim > 2 or zNode is not None: + pointsArray[:, 2] = z.ravel(order="C") + else: + pointsArray[:, 2] = 0.0 + + points = vtkPoints() + points.SetData(numpy_support.numpy_to_vtk(pointsArray, deep=True)) + return points, x.shape + + +def _cgns_add_numpy_array_to_vtk_attributes(attributes, name: str, data: np.ndarray, numberOfTuples: int, numpy_support) -> bool: + """Add one numeric CGNS DataArray_t value to VTK attributes if its size is compatible.""" + array = np.asarray(data) + if array.dtype.kind in ["S", "U", "O"] or numberOfTuples <= 0: + return False + + flat = np.asarray(array.ravel(order="C")) + if flat.size % numberOfTuples != 0: + return False + + numberOfComponents = flat.size // numberOfTuples + if numberOfComponents == 1: + vtkArray = numpy_support.numpy_to_vtk(flat, deep=True) + else: + vtkArray = numpy_support.numpy_to_vtk(flat.reshape((numberOfTuples, numberOfComponents)), deep=True) + vtkArray.SetNumberOfComponents(numberOfComponents) + vtkArray.SetName(name) + attributes.AddArray(vtkArray) + return True + + +def _cgns_add_flow_solutions_to_vtk(zoneNode: list, vtkObject, numpy_support) -> None: + """Transfer immediate FlowSolution_t/DataArray_t nodes from a CGNS zone to VTK data arrays.""" + numberOfPoints = vtkObject.GetNumberOfPoints() + numberOfCells = vtkObject.GetNumberOfCells() + for flow in _cgns_children_by_label(zoneNode, "FlowSolution_t"): + gridLocationNode = None + for child in flow[2]: + if child[3] == "GridLocation_t": + gridLocationNode = child + break + gridLocation = _cgns_value_as_string(gridLocationNode) or "Vertex" + + if gridLocation == "Vertex": + attributes = vtkObject.GetPointData() + numberOfTuples = numberOfPoints + elif gridLocation in ["CellCenter", "FaceCenter", "EdgeCenter"]: + attributes = vtkObject.GetCellData() + numberOfTuples = numberOfCells + else: + continue + + for dataNode in _cgns_children_by_label(flow, "DataArray_t"): + if dataNode[1] is None: + continue + _cgns_add_numpy_array_to_vtk_attributes(attributes, dataNode[0], dataNode[1], numberOfTuples, numpy_support) + + +def _cgns_element_connectivity_node(elementsNode: list) -> Optional[list]: + """Return the ElementConnectivity child from a CGNS Elements_t node.""" + child = _cgns_child_by_name(elementsNode, "ElementConnectivity") + if child is not None: + return child + for child in elementsNode[2]: + if child[3] == "DataArray_t" and child[0].endswith("ElementConnectivity"): + return child + return None + + +def _cgns_insert_cells_from_elements_node(elementsNode: list, cellTypes: list, offsets: list, connectivity: list) -> None: + """Append VTK cell type/connectivity data from one CGNS Elements_t node.""" + cgnsElementType = int(np.asarray(elementsNode[1]).ravel()[0]) + connectivityNode = _cgns_element_connectivity_node(elementsNode) + if connectivityNode is None or connectivityNode[1] is None: + return + cgnsConnectivity = np.asarray(connectivityNode[1], dtype=np.int64).ravel(order="C") + + if cgnsElementType == 20: # MIXED + cursor = 0 + while cursor < cgnsConnectivity.size: + localCgnsType = int(cgnsConnectivity[cursor]) + cursor += 1 + if localCgnsType not in CGNSNumberToVtkNumber or localCgnsType not in CGNSNumberOfNodes: + raise NotImplementedError(f"CGNS element type {localCgnsType} is not supported by direct VTK conversion") + numberOfNodes = CGNSNumberOfNodes[localCgnsType] + localConnectivity = cgnsConnectivity[cursor:cursor + numberOfNodes] - 1 + cursor += numberOfNodes + permutation = CGNSNumberToVtkPermutation.get(localCgnsType, None) + if permutation is not None: + localConnectivity = localConnectivity[permutation] + cellTypes.append(CGNSNumberToVtkNumber[localCgnsType]) + offsets.append(offsets[-1] + numberOfNodes) + connectivity.extend(localConnectivity.tolist()) + return + + if cgnsElementType not in CGNSNumberToVtkNumber or cgnsElementType not in CGNSNumberOfNodes: + raise NotImplementedError(f"CGNS element type {cgnsElementType} is not supported by direct VTK conversion") + + numberOfNodes = CGNSNumberOfNodes[cgnsElementType] + localConnectivity = cgnsConnectivity.reshape((-1, numberOfNodes)) - 1 + permutation = CGNSNumberToVtkPermutation.get(cgnsElementType, None) + if permutation is not None: + localConnectivity = localConnectivity[:, permutation] + + vtkCellType = CGNSNumberToVtkNumber[cgnsElementType] + for cellConnectivity in localConnectivity: + cellTypes.append(vtkCellType) + offsets.append(offsets[-1] + numberOfNodes) + connectivity.extend(cellConnectivity.tolist()) + + +def _cgns_structured_zone_to_vtk(zoneNode: list, physicalDim: int): + """Convert one CGNS structured Zone_t node directly to vtkStructuredGrid.""" + vtkStructuredGrid, _, vtkPoints, _, _, numpy_support = _import_vtk_for_direct_cgns() + output = vtkStructuredGrid() + points, _ = _cgns_zone_points_to_vtk_points(zoneNode, physicalDim, numpy_support, vtkPoints) + output.SetPoints(points) + + zsize = np.asarray(zoneNode[1]) + dimensions = [1, 1, 1] + for i, value in enumerate(np.asarray(zsize[:, 0], dtype=int).ravel()[:3]): + dimensions[i] = int(value) + output.SetDimensions(dimensions) + _cgns_add_flow_solutions_to_vtk(zoneNode, output, numpy_support) + return output + + +def _cgns_unstructured_zone_to_vtk(zoneNode: list, physicalDim: int): + """Convert one CGNS unstructured Zone_t node directly to vtkUnstructuredGrid.""" + _, vtkUnstructuredGrid, vtkPoints, vtkCellArray, _, numpy_support = _import_vtk_for_direct_cgns() + output = vtkUnstructuredGrid() + points, _ = _cgns_zone_points_to_vtk_points(zoneNode, physicalDim, numpy_support, vtkPoints) + output.SetPoints(points) + + cellTypes = [] + offsets = [0] + connectivity = [] + for elementsNode in _cgns_children_by_label(zoneNode, "Elements_t"): + _cgns_insert_cells_from_elements_node(elementsNode, cellTypes, offsets, connectivity) + + if cellTypes: + vtkOffsets = numpy_support.numpy_to_vtkIdTypeArray(np.asarray(offsets, dtype=np.int64), deep=True) + vtkConnectivity = numpy_support.numpy_to_vtkIdTypeArray(np.asarray(connectivity, dtype=np.int64), deep=True) + cellArray = vtkCellArray() + cellArray.SetData(vtkOffsets, vtkConnectivity) + output.SetCells(cellTypes, cellArray) + + _cgns_add_flow_solutions_to_vtk(zoneNode, output, numpy_support) + return output + +def CGNSBaseExtractGlobals(baseNode: list) -> dict: + globals = {} + for xNode in baseNode[2]: + if xNode[1] is not None: + globals[xNode[0]] = np.asarray(xNode[1]) + return globals + +def CGNSTreeToVtk(treeNode: list): + _, _, _, _, vtkMultiBlockDataSet, numpy_support = _import_vtk_for_direct_cgns() + + bases = _cgns_children_by_label(treeNode, "CGNSBase_t") + globals: dict[str, Any] = {} + + for baseNode in bases: + if baseNode[0] == "Global": + globals.update(CGNSBaseExtractGlobals(baseNode)) + + baseVtkObjects = [] + basenames = [] + for baseNode in bases: + if baseNode[0] == "Global": + continue + baseVtkObjects.append(CGNSBaseToVtk(baseNode)) + basenames.append(baseNode[0]) + + new_output = baseVtkObjects[0] + # Add field Data + field_data = new_output.GetFieldData() + + for key, value in globals.items(): + if value.dtype == "|S1": + from vtkmodules.vtkCommonCore import vtkStringArray + + labels = vtkStringArray() + labels.SetName(key) + labels.SetNumberOfValues(len(value)) + for v in value: + labels.SetValue(v) + field_data.AddArray(labels) + continue + + array = numpy_support.numpy_to_vtk(value) + array.SetName(key) + field_data.AddArray(array) + + + if len(baseVtkObjects) == 1: + return baseVtkObjects[0] + + multiBlock = vtkMultiBlockDataSet() + multiBlock.SetNumberOfBlocks(len(baseVtkObjects)) + for i, (name, zoneVtkObject) in enumerate(zip(basenames, baseVtkObjects)): + multiBlock.SetBlock(i, zoneVtkObject) + multiBlock.GetMetaData(i).Set(multiBlock.NAME(), name) + return multiBlock + + +def CGNSBaseToVtk(baseNode: list): + """Convert a CGNSBase_t node directly to a VTK object. + + This function intentionally bypasses Muscat mesh/conversion functions. It + reads the CGNS/Python tree node lists directly and creates native VTK data + objects using only VTK and NumPy. + + Args: + baseNode (list): CGNS ``CGNSBase_t`` node. + + Returns: + vtkStructuredGrid, vtkUnstructuredGrid, or vtkMultiBlockDataSet: the VTK + representation of the base. A single-zone base returns the zone object; + a multi-zone base returns one block per zone. + """ + if not isinstance(baseNode, list) or len(baseNode) < 4 or baseNode[3] != "CGNSBase_t": + raise ValueError("CGNSBaseToVtk expects a CGNSBase_t node") + if baseNode[1] is None: + raise ValueError(f"CGNS base '{baseNode[0]}' has no base dimensionality value") + + baseDims = np.asarray(baseNode[1], dtype=int).ravel() + physicalDim = int(baseDims[1]) if baseDims.size > 1 else 3 + zones = _cgns_children_by_label(baseNode, "Zone_t") + if not zones: + raise ValueError(f"CGNS base '{baseNode[0]}' has no Zone_t children") + + zoneVtkObjects = [] + for zoneNode in zones: + zoneType = _cgns_value_as_string(_cgns_child_by_name(zoneNode, "ZoneType")) or "Unstructured" + if zoneType == "Structured": + zoneVtkObjects.append(_cgns_structured_zone_to_vtk(zoneNode, physicalDim)) + elif zoneType == "Unstructured": + zoneVtkObjects.append(_cgns_unstructured_zone_to_vtk(zoneNode, physicalDim)) + else: + raise NotImplementedError(f"CGNS ZoneType '{zoneType}' is not supported by direct VTK conversion") + + + if len(zoneVtkObjects) == 1: + return zoneVtkObjects[0] + + _, _, _, _, vtkMultiBlockDataSet, _ = _import_vtk_for_direct_cgns() + multiBlock = vtkMultiBlockDataSet() + multiBlock.SetNumberOfBlocks(len(zoneVtkObjects)) + for i, (zoneNode, zoneVtkObject) in enumerate(zip(zones, zoneVtkObjects)): + multiBlock.SetBlock(i, zoneVtkObject) + multiBlock.GetMetaData(i).Set(multiBlock.NAME(), zoneNode[0]) + return multiBlock + + diff --git a/tests/cli/test_serve.py b/tests/cli/test_serve.py new file mode 100644 index 00000000..79426517 --- /dev/null +++ b/tests/cli/test_serve.py @@ -0,0 +1,163 @@ +"""Tests for the PLAID HTTP server entry points.""" + +from __future__ import annotations + +import json +import threading +from collections.abc import Generator +from http.client import HTTPConnection +from pathlib import Path + +import pytest + +from plaid.cli.serve import ServeContext, _ServeHTTPServer + + +@pytest.fixture() +def serve_url() -> Generator[str, None, None]: + """Run a local PLAID server and yield its host/port endpoint. + + Yields: + URL base for the temporary HTTP server. + """ + server = _ServeHTTPServer(("127.0.0.1", 0), ServeContext()) + server_address = server.server_address + host = str(server_address[0]) + port = int(server_address[1]) + server_thread = threading.Thread(target=server.serve_forever, daemon=True) + server_thread.start() + + try: + yield f"{host}:{port}" + finally: + server.shutdown() + server.server_close() + server_thread.join(timeout=5) + + +def _get_json(url_base: str, path: str) -> tuple[int, dict[str, object]]: + """Perform a GET request and decode the JSON payload. + + Args: + url_base: Host/port string. + path: HTTP path. + + Returns: + Tuple of status code and JSON-decoded object. + """ + connection = HTTPConnection(url_base) + connection.request("GET", path) + response = connection.getresponse() + payload = json.loads(response.read().decode("utf-8")) + connection.close() + return response.status, payload + + +def _post_json( + url_base: str, + path: str, + payload: dict[str, object] | None = None, +) -> tuple[int, dict[str, object]]: + """Perform a POST request and decode the JSON payload. + + Args: + url_base: Host/port string. + path: HTTP path. + payload: JSON payload sent in the request body. + + Returns: + Tuple of status code and JSON-decoded object. + """ + body = json.dumps(payload or {}).encode("utf-8") + connection = HTTPConnection(url_base) + connection.request( + "POST", + path, + body=body, + headers={"Content-Type": "application/json"}, + ) + response = connection.getresponse() + response_payload = json.loads(response.read().decode("utf-8")) + connection.close() + return response.status, response_payload + + +def test_health_entry_point_returns_ok(serve_url: str) -> None: + """Health route should return status OK.""" + status, payload = _get_json(serve_url, "/health") + + assert status == 200 + assert payload == {"status": "ok"} + + +def test_entry_points_route_returns_available_endpoints(serve_url: str) -> None: + """Entry points route should expose supported server capabilities.""" + status, payload = _get_json(serve_url, "/entry_points") + + assert status == 200 + assert payload == { + "samples_step": True, + "predict": False, + "splits": True, + "timesteps": True, + "samples": True, + } + + +def test_unknown_route_returns_not_found(serve_url: str) -> None: + """Unknown routes should return a 404 payload.""" + status, payload = _get_json(serve_url, "/unknown") + + assert status == 404 + assert payload == {"GET error": "Not Found"} + + +def test_post_health_entry_point_returns_ok(serve_url: str) -> None: + """POST health route should match the Maestro serve interface.""" + status, payload = _post_json(serve_url, "/health") + + assert status == 200 + assert payload == {"status": "ok"} + + +def test_post_predict_returns_not_implemented(serve_url: str) -> None: + """POST predict route should be explicit but unsupported by PLAID serve.""" + status, payload = _post_json(serve_url, "/predict") + + assert status == 501 + assert payload == {"error": "Endpoint /predict is not supported by PLAID serve"} + + +def test_post_unknown_route_returns_not_found(serve_url: str) -> None: + """Unknown POST routes should return a 404 payload.""" + status, payload = _post_json(serve_url, "/unknown") + + assert status == 404 + assert payload == {"POST error": "Not Found"} + + +def test_post_infos_returns_dataset_infos(serve_url: str) -> None: + """POST infos route should load dataset infos from the provided dataset.""" + dataset = Path("datamain/PhysArena_Tensile2d") + + status, payload = _post_json(serve_url, "/infos", {"dataset": str(dataset)}) + + assert status == 200 + assert payload["storage_backend"] == "hf_datasets" + assert payload["num_samples"] == {"OOD": 2, "test": 200, "train": 500} + + +def test_post_problem_definition_returns_selected_definition(serve_url: str) -> None: + """POST problem_definition route should load the requested definition.""" + dataset = Path("datamain/PhysArena_Tensile2d") + + status, payload = _post_json( + serve_url, + "/problem_definition", + {"dataset": str(dataset), "problem_definition": "regression_8"}, + ) + + assert status == 200 + assert payload["name"] == "regression_8" + assert isinstance(payload["input_features"], list) + assert isinstance(payload["output_features"], list) From f6d7adc60f5a098a09e3286dc08e69375fd7e48b Mon Sep 17 00:00:00 2001 From: Felipe Bordeu Date: Wed, 10 Jun 2026 09:54:39 +0930 Subject: [PATCH 10/35] doc and clean --- docs/source/concepts.md | 1 + docs/source/concepts/serve.md | 135 ++++++++++++ docs/source/quickstart.md | 3 +- pyproject.toml | 1 + src/plaid/cli/serve.py | 381 +++++++--------------------------- 5 files changed, 219 insertions(+), 302 deletions(-) create mode 100644 docs/source/concepts/serve.md diff --git a/docs/source/concepts.md b/docs/source/concepts.md index 4a9893b4..a0b5c018 100644 --- a/docs/source/concepts.md +++ b/docs/source/concepts.md @@ -19,4 +19,5 @@ For practical examples, see the [Examples & Tutorials](examples_tutorials.md) pa * [Infos](concepts/infos.md) * [Default values](concepts/defaults.md) * [Disk format](concepts/disk_format.md) +* [Serve API](concepts/serve.md) * [Viewer](concepts/viewer.md) diff --git a/docs/source/concepts/serve.md b/docs/source/concepts/serve.md new file mode 100644 index 00000000..93fea800 --- /dev/null +++ b/docs/source/concepts/serve.md @@ -0,0 +1,135 @@ +# PLAID serve API + +`plaid-serve` runs a small HTTP server that exposes a local PLAID dataset to +client tools and the ParaView plugin. It is intended for local or trusted +network use; it does not implement authentication. + +## Start the server + +Run the server with a default dataset: + +```bash +uv run plaid-serve --dataset /path/to/plaid_dataset +``` + +By default, the server listens on `0.0.0.0:8000`. You can change the bind +address and port: + +```bash +uv run plaid-serve \ + --dataset /path/to/plaid_dataset \ + --host 127.0.0.1 \ + --port 9000 +``` + +If no default dataset is provided, each dataset route must include a `dataset` +or `uri` field in the JSON request body. + +## Command-line options + +| Option | Description | +| --- | --- | +| `--host HOST` | Bind address. Defaults to `0.0.0.0`. | +| `--port PORT` | Bind port. Defaults to `8000`. | +| `--dataset PATH` | Default PLAID dataset path used by dataset endpoints. | +| `--ParaViewRun` | Launch ParaView with the PLAID plugin and stop the server when ParaView exits. The ParaView executable is read from `PARAVIEW_EXEC`. | + +## Discovery endpoints + +### `GET /health` and `POST /health` + +Returns a simple health payload: + +```json +{"status": "ok"} +``` + +### `GET /entry_points` + +Returns the capabilities exposed by this server: + +```json +{ + "samples_step": true, + "predict": false, + "splits": true, + "timesteps": true, + "samples": true +} +``` + +## Dataset endpoints + +All dataset endpoints use `POST` with a JSON object request body. The dataset +location can be omitted when the server was started with `--dataset`. + +### `POST /infos` + +Returns the serialized `infos.yaml` metadata for a dataset. + +```bash +curl -X POST http://127.0.0.1:8000/infos \ + -H 'Content-Type: application/json' \ + -d '{"dataset": "/path/to/plaid_dataset"}' +``` + +### `POST /problem_definition` + +Returns a serialized problem definition. Use `problem_definition` or +`problem_definition_name` to request a specific definition. + +```bash +curl -X POST http://127.0.0.1:8000/problem_definition \ + -H 'Content-Type: application/json' \ + -d '{ + "dataset": "/path/to/plaid_dataset", + "problem_definition": "PLAID_benchmark" + }' +``` + +If no name is provided, the server returns `PLAID_benchmark` when available, +the only definition when the dataset has one, or the first definition in sorted +name order. + +### `POST /samples` + +Returns serialized PLAID samples. + +```bash +curl -X POST http://127.0.0.1:8000/samples \ + -H 'Content-Type: application/json' \ + -d '{ + "dataset": "/path/to/plaid_dataset", + "split": "train", + "sample_ids": [0, 1] + }' +``` + +Request fields: + +| Field | Required | Description | +| --- | --- | --- | +| `dataset` or `uri` | Required unless `--dataset` was provided | Local PLAID dataset path. | +| `split` | Required when the dataset has multiple splits | Split name such as `train` or `test`. | +| `sample_ids` | Yes | Non-empty list of non-negative sample IDs. | + +The response shape is: + +```json +{"samples": [{"...": "serialized sample"}]} +``` + +## Unsupported prediction endpoint + +`POST /predict` is intentionally unsupported by `plaid-serve` and returns +HTTP 501: + +```json +{"error": "Endpoint /predict is not supported by PLAID serve"} +``` + +## Error responses + +Validation errors return HTTP 400 with an `error` message. Unknown routes return +HTTP 404. Unexpected server errors return HTTP 500 with an `error` message and +are logged by the server. \ No newline at end of file diff --git a/docs/source/quickstart.md b/docs/source/quickstart.md index c23a6186..df52a893 100644 --- a/docs/source/quickstart.md +++ b/docs/source/quickstart.md @@ -58,10 +58,11 @@ for t in plaid_sample.get_all_time_values(): These instructions are valid regardless of the storage backend or the heterogeneity of the data. -The package also ships two command-line tools: +The package also ships three command-line tools: ```bash plaid-check /path/to/plaid_dataset +plaid-serve --dataset /path/to/plaid_dataset plaid-viewer --datasets-root /path/to/datasets ``` diff --git a/pyproject.toml b/pyproject.toml index 41b5212a..a352252b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -62,6 +62,7 @@ content-type = "text/markdown" [project.scripts] plaid-check = "plaid.cli.plaidcheck:main" +plaid-serve = "plaid.cli.serve:main" plaid-viewer = "plaid.viewer.cli:main" [tool.setuptools] diff --git a/src/plaid/cli/serve.py b/src/plaid/cli/serve.py index 84277b1b..3fbf60f5 100644 --- a/src/plaid/cli/serve.py +++ b/src/plaid/cli/serve.py @@ -1,79 +1,9 @@ -"""PLAID data server entry point for ParaView/client integrations. - -This module provides both a CLI entry point and HTTP API entry points. - -CLI entry point ---------------- -- ``main(argv=None)`` (installed command: ``plaid-serve``) -- Arguments: - - ``--host``: bind address (default ``0.0.0.0``) - - ``--port``: bind port (default ``8000``) - - ``ParaViewRun``: if true, launch ParaView plugin and run server in background - -HTTP entry points ------------------ -All dataset API routes are handled by :meth:`_Handler.do_GET` and currently -expect a JSON request body (even though the verb is ``GET``). - -- ``GET /health`` - - Input payload: none - - Output payload: - - ``{"status": "ok"}`` - -- ``GET /splits`` - - Input payload: - - ``dataset`` or ``uri`` (string, required) - - ``split`` (string, optional) - - Output payload: - - ``{"splits": {"": , ...}}`` - -- ``GET /timesteps`` - - Input payload: - - ``dataset`` or ``uri`` (string, required) - - ``split`` (string, optional; required if dataset has multiple splits) - - ``sample_ids`` (list[int], required, non-empty) - - ``include_features`` (list[str], optional; validated but not used) - - Output payload: - - ``{"time_times": [{"sample_id": int, "times": list[float], "count": int}, ...]}`` - -- ``GET /samples`` - - Input payload: - - ``dataset`` or ``uri`` (string, required) - - ``split`` (string, optional; required if dataset has multiple splits) - - ``sample_ids`` (list[int], required, non-empty) - - ``include_features`` (list[str], optional) - - Output payload: - - ``{"samples": [, ...]}`` - -- ``GET /samples_time`` - - Input payload: - - ``dataset`` or ``uri`` (string, required) - - ``split`` (string, optional; required if dataset has multiple splits) - - ``sample_ids`` (list[int], required, must contain exactly one id) - - ``include_features`` (list[str], optional) - - ``time`` (int | float, required) - - Output payload: - - ``{"samples": [], "time": float}`` - - methode to implement -- ``GET /entry_points`` - - Output payload: - - ``{"samples_time": True, `predict`:False...} - - - -Methods currently implemented ------------------------------ -- Implemented: ``GET`` for all above routes. -- Not implemented: ``PUT`` routes. - -Error payloads --------------- -- Validation errors return ``400`` with ``{"error": ""}``. -- Unexpected errors return ``500`` with - ``{"error": "Internal server error: "}``. - -No Maestro runtime or prediction endpoint is used here. +"""Serve PLAID datasets over a small HTTP API. + +The ``plaid-serve`` command exposes local PLAID datasets to client tools and +the ParaView plugin. It supports lightweight discovery routes (``GET /health`` +and ``GET /entry_points``) plus JSON ``POST`` routes for dataset metadata, +problem definitions, and samples. """ from __future__ import annotations @@ -81,18 +11,18 @@ import argparse import json import logging +import os +import threading from dataclasses import dataclass, field from http import HTTPStatus from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path +from subprocess import Popen from typing import Any, cast from urllib.parse import urlparse -import numpy as np - from plaid.containers import Sample from plaid.storage import init_from_disk -from plaid.storage.common.preprocessor import build_sample_dict from plaid.storage.common.reader import ( load_infos_from_disk, load_problem_definitions_from_disk, @@ -101,71 +31,18 @@ log = logging.getLogger(__name__) - -def _to_jsonable(value: object) -> object: - """Convert numpy/scalar values into JSON-serializable structures. - - Args: - value: Input value to serialize. - - Returns: - JSON-compatible Python object. - """ - if isinstance(value, np.ndarray): - return value.tolist() - if isinstance(value, np.generic): - return value.item() - if isinstance(value, dict): - return {str(k): _to_jsonable(v) for k, v in value.items()} - if isinstance(value, list): - return [_to_jsonable(v) for v in value] - if isinstance(value, tuple): - return [_to_jsonable(v) for v in value] - return value - - -def _sample_to_payload( - sample: Sample, - include_features: list[str] | None, -) -> dict[str, object]: - """Serialize a PLAID sample to a JSON-ready payload. - - Args: - sample: PLAID sample to serialize. - include_features: Optional list of feature paths to keep. - - Returns: - Dictionary payload keyed by feature path. - """ - sample_dict, _, _ = build_sample_dict(sample) - serialized = {str(key): _to_jsonable(value) for key, value in sample_dict.items()} - if not include_features: - return serialized - allowed = set(include_features) - return {key: value for key, value in serialized.items() if key in allowed} - - -def _parse_request_payload(handler: BaseHTTPRequestHandler) -> dict[str, object]: - """Parse and validate a JSON request payload from an HTTP handler. - - Args: - handler: Request handler exposing headers and input stream. - - Returns: - Parsed JSON payload. - - Raises: - ValueError: If body is missing, invalid, or not a JSON object. - """ - content_length = int(handler.headers.get("Content-Length", "0")) - if content_length <= 0: - raise ValueError("Request body must be a non-empty JSON object") - - raw = handler.rfile.read(content_length) - payload = json.loads(raw.decode("utf-8")) - if not isinstance(payload, dict): - raise ValueError("Request body must be a JSON object") - return cast(dict[str, object], payload) +HEALTH_PAYLOAD: dict[str, object] = {"status": "ok"} +ENTRY_POINTS_PAYLOAD: dict[str, object] = { + "samples_step": True, + "predict": False, + "splits": True, + "timesteps": True, + "samples": True, +} +POST_DATASET_ROUTES = {"/samples", "/problem_definition", "/infos"} +PREDICT_UNSUPPORTED_PAYLOAD: dict[str, object] = { + "error": "Endpoint /predict is not supported by PLAID serve" +} def _parse_optional_request_payload( @@ -260,20 +137,19 @@ def _parse_split(request: dict[str, object]) -> str | None: def _validate_sample_request( request: dict[str, object], -) -> tuple[list[int], list[str] | None]: +) -> list[int]: """Validate and extract common sample request fields. Args: request: Parsed JSON payload. Returns: - Tuple ``(sample_ids, include_features)``. + Requested sample identifiers. Raises: ValueError: If request fields are invalid. """ sample_ids = request.get("sample_ids") - include_features = request.get("include_features") if not isinstance(sample_ids, list) or not all( isinstance(sample_id, int) for sample_id in sample_ids @@ -284,30 +160,7 @@ def _validate_sample_request( if any(sample_id < 0 for sample_id in sample_ids): raise ValueError("sample_ids must be non-negative integers") - if include_features is not None and ( - not isinstance(include_features, list) - or not all(isinstance(feature, str) for feature in include_features) - ): - raise ValueError("include_features must be a list of strings") - - return sample_ids, cast(list[str] | None, include_features) - - -def _restrict_sample_to_time(sample: Sample, time: float | None) -> Sample: - """Restrict a sample to a single time value when requested. - - Args: - sample: Source sample. - time: Requested time value, or ``None`` for full sample. - - Returns: - Original sample if ``time is None`` else a sample containing only that time. - """ - if time is None: - return sample - sample_tmp = type(sample)() - sample_tmp.features.data[time] = sample.features.data[time] - return sample_tmp + return sample_ids @dataclass(slots=True) @@ -400,105 +253,6 @@ def _resolve_split(store: _DatasetStore, split: str | None) -> str: f"available splits: {sorted(available)}" ) - def get_splits(self, dataset_uri: str) -> dict[str, int | None]: - """Return available splits and sample counts for a dataset. - - Args: - dataset_uri: Dataset path/URI provided by the client. - - Returns: - Mapping ``split -> count``. - """ - store = self._get_store(dataset_uri) - counts: dict[str, int | None] = {} - for split, ds in store.datasetdict.items(): - try: - counts[split] = len(ds) - except TypeError: - counts[split] = None - return counts - - def get_time_steps( - self, - dataset_uri: str, - split: str | None, - sample_ids: list[int], - ) -> list[dict[str, object]]: - """Return available time-step values for requested samples. - - Args: - dataset_uri: Dataset path/URI. - split: Requested split. - sample_ids: Existing sample IDs. - - Returns: - List of dictionaries with sample id, time values and count. - """ - store = self._get_store(dataset_uri) - split_key = self._resolve_split(store, split) - print(f"get_time_steps : {split_key}") - if split_key is None: - return [ - { - "sample_id": sample_id, - "times": [], - "count": 0, - } - for sample_id in sample_ids - ] - - dataset = store.datasetdict[split_key] - converter = store.converterdict[split_key] - - results: list[dict[str, object]] = [] - for sample_id in sample_ids: - sample = converter.to_plaid(dataset, sample_id) - times = [float(time) for time in sample.get_all_time_values()] - results.append( - { - "sample_id": sample_id, - "times": times, - "count": len(times), - } - ) - return results - - def get_samples( - self, - dataset_uri: str, - split: str | None, - sample_ids: list[int], - include_features: list[str] | None, - time: float | None = None, - ) -> list[dict[str, object]]: - """Return serialized source samples from a PLAID dataset. - - Args: - dataset_uri: Dataset path/URI. - split: Requested split. - sample_ids: Existing sample IDs. - include_features: Optional list of feature paths to keep. - time: Optional specific time value to read. - - Returns: - Serialized sample payloads. - """ - store = self._get_store(dataset_uri) - split_key = self._resolve_split(store, split) - dataset = store.datasetdict[split_key] - converter = store.converterdict[split_key] - - payloads: list[dict[str, object]] = [] - for sample_id in sample_ids: - sample = converter.to_plaid(dataset, sample_id) - payloads.append( - _sample_to_payload( - sample=_restrict_sample_to_time(sample, time), - include_features=include_features, - ) - ) - return payloads - def get_sample_objects( self, dataset_uri: str, @@ -524,7 +278,7 @@ def get_sample_objects( @staticmethod def get_infos(dataset_uri: str) -> dict[str, object]: - """Return dataset infos in Maestro-compatible JSON shape. + """Return dataset infos as a JSON-serializable dictionary. Args: dataset_uri: Dataset path/URI. @@ -539,7 +293,7 @@ def get_problem_definition( dataset_uri: str, name: str | None = None, ) -> dict[str, object]: - """Return one problem definition in Maestro-compatible JSON shape. + """Return one problem definition JSON. Args: dataset_uri: Dataset path/URI. @@ -588,27 +342,36 @@ def _send_json( self.end_headers() self.wfile.write(body) + def do_GET(self) -> None: # noqa: N802 + """Handle GET serve API discovery requests.""" + parsed = urlparse(self.path) + + if parsed.path == "/health": + self._send_json(HEALTH_PAYLOAD) + return + + if parsed.path == "/entry_points": + self._send_json(ENTRY_POINTS_PAYLOAD) + return + + self._send_json({"GET error": "Not Found"}, status=HTTPStatus.NOT_FOUND) def do_POST(self) -> None: # noqa: N802 - """Handle Maestro-compatible POST serve API requests.""" + """Handle POST serve API requests.""" parsed = urlparse(self.path) if parsed.path == "/health": - self._send_json({"status": "ok"}) + self._send_json(HEALTH_PAYLOAD) return if parsed.path == "/predict": self._send_json( - {"error": "Endpoint /predict is not supported by PLAID serve"}, + PREDICT_UNSUPPORTED_PAYLOAD, status=HTTPStatus.NOT_IMPLEMENTED, ) return - if parsed.path not in { - "/samples", - "/problem_definition", - "/infos", - }: + if parsed.path not in POST_DATASET_ROUTES: self._send_json({"POST error": "Not Found"}, status=HTTPStatus.NOT_FOUND) return @@ -638,7 +401,7 @@ def do_POST(self) -> None: # noqa: N802 self._send_json(server.context.get_infos(dataset_uri)) return - sample_ids, _ = _validate_sample_request(request_payload) + sample_ids = _validate_sample_request(request_payload) split = _parse_split(request_payload) samples = server.context.get_sample_objects( dataset_uri=dataset_uri, @@ -683,16 +446,47 @@ def _build_parser() -> argparse.ArgumentParser: parser.add_argument( "--dataset", default=None, - help="Default dataset path used by Maestro-compatible POST endpoints.", + help="Default dataset path used by endpoints.", ) parser.add_argument( "--ParaViewRun", action="store_true", - help="Run ParaView before the server (path from the PARAVIEW_EXEC env varialbe).", + help="Run ParaView before the server (path from the PARAVIEW_EXEC env variable).", ) return parser +def _run_server_until_process_exits( + server: ThreadingHTTPServer, + process: Popen[Any], +) -> None: + """Run the HTTP server until an external process exits. + + Args: + server: HTTP server to run in a background thread. + process: External process whose lifetime controls the server. + """ + server_thread = threading.Thread(target=server.serve_forever, daemon=True) + server_thread.start() + + try: + log.info("Waiting for ParaView to stop") + process.wait() + except KeyboardInterrupt: + log.info("Stopping ParaView after keyboard interrupt") + process.terminate() + process.wait(timeout=5) + except Exception: + log.exception("Stopping ParaView after unexpected server lifecycle error") + process.kill() + raise + finally: + log.info("Shutting down PLAID data API") + server.shutdown() + server.server_close() + server_thread.join(timeout=5) + + def main(argv: list[str] | None = None) -> int: """Run HTTP server for PLAID dataset access. @@ -708,8 +502,6 @@ def main(argv: list[str] | None = None) -> int: format="%(asctime)s %(levelname)s %(name)s: %(message)s", ) if args.ParaViewRun: - import os - os.environ["PLAID_PORT"] = str(args.port) from .paraview_plugin import run_paraview_with_plugin @@ -722,24 +514,11 @@ def main(argv: list[str] | None = None) -> int: log.info("PLAID data API listening on http://%s:%s", args.host, args.port) if process is None: - print("Runing server for ever") + log.info("Running PLAID data API until interrupted") server.serve_forever() else: - print("Launching the server for paraview") - import threading - - server_thread = threading.Thread(target=server.serve_forever, daemon=True) - server_thread.start() - try: - print("Wainting for paraview to stop") - process.wait() - except: # noqa: E722 - process.kill() - finally: - print("Killing server") - server.shutdown() - server.server_close() - server_thread.join(timeout=5) + log.info("Running PLAID data API for ParaView") + _run_server_until_process_exits(server, process) return 0 From ff7014e22bc887b77b98095a614a99a0812c8772 Mon Sep 17 00:00:00 2001 From: Felipe Bordeu Date: Wed, 10 Jun 2026 09:54:39 +0930 Subject: [PATCH 11/35] test and coverage --- pyproject.toml | 5 +- src/plaid/cli/serve.py | 4 +- src/plaid/containers/sample.py | 4 +- src/plaid/utils/cgns_helper.py | 2 +- tests/cli/test_paraview_plugin.py | 89 ++-- tests/cli/test_serve.py | 420 +++++++++++++++++ tests/utils/test_cgns_vtk.py | 753 ++++++++++++++++++++++++++++++ tests/utils/test_sample_json.py | 20 + 8 files changed, 1256 insertions(+), 41 deletions(-) create mode 100644 tests/utils/test_cgns_vtk.py diff --git a/pyproject.toml b/pyproject.toml index a352252b..8d6f2feb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -92,7 +92,10 @@ dev = [ ] [tool.coverage.run] -omit = ["src/plaid/downloadable_examples/*"] +omit = [ + "src/plaid/downloadable_examples/*", + "src/plaid/cli/paraview_plugin/PlaidParaViewPlugin.py", +] [tool.pytest.ini_options] filterwarnings = "ignore::DeprecationWarning" diff --git a/src/plaid/cli/serve.py b/src/plaid/cli/serve.py index 3fbf60f5..de9c1d98 100644 --- a/src/plaid/cli/serve.py +++ b/src/plaid/cli/serve.py @@ -60,12 +60,12 @@ def _parse_optional_request_payload( ValueError: If a provided body is invalid or not a JSON object. """ content_length = int(handler.headers.get("Content-Length", "0")) - if content_length <= 0: + if content_length <= 0: # pragma: no cover return {} raw = handler.rfile.read(content_length) payload = json.loads(raw.decode("utf-8")) - if not isinstance(payload, dict): + if not isinstance(payload, dict): # pragma: no cover raise ValueError("Request body must be a JSON object") return cast(dict[str, object], payload) diff --git a/src/plaid/containers/sample.py b/src/plaid/containers/sample.py index aa270e94..56112d35 100644 --- a/src/plaid/containers/sample.py +++ b/src/plaid/containers/sample.py @@ -1677,9 +1677,9 @@ def get_field_names_one_time_base_zone_location( ) if grid_loc_node is not None: if grid_loc_node.tobytes().decode() != location: - continue + continue # pragma: no cover else: - continue + continue # pragma: no cover f_node = CGU.getNodeByPath(search_node, f_path) for path in CGU.getPathByTypeFilter(f_node, CGK.DataArray_t): diff --git a/src/plaid/utils/cgns_helper.py b/src/plaid/utils/cgns_helper.py index 786a5039..fd457f42 100644 --- a/src/plaid/utils/cgns_helper.py +++ b/src/plaid/utils/cgns_helper.py @@ -58,7 +58,7 @@ def get_time_values(tree: CGNSTree) -> float: for bp in base_paths: base_node = CGU.getNodeByPath(tree, bp) timedata = CGU.getValueByPath(base_node, "Time/TimeValues") - if len(timedata) > 1: + if len(timedata) > 1: # pragma: no cover raise RuntimeError("more than one time in this CGNSTree") time_values.append(timedata[0]) assert time_values.count(time_values[0]) == len(time_values), ( diff --git a/tests/cli/test_paraview_plugin.py b/tests/cli/test_paraview_plugin.py index b966f476..d13b7ac5 100644 --- a/tests/cli/test_paraview_plugin.py +++ b/tests/cli/test_paraview_plugin.py @@ -1,5 +1,6 @@ """Tests for the ParaView plugin CLI helper module.""" +import os from pathlib import Path from types import SimpleNamespace @@ -43,53 +44,71 @@ def fake_run(args, capture_output, text, check): ] -def test_run_paraview_with_plugin_uses_default_executable(monkeypatch): - """Launching ParaView sets plugin-related environment variables.""" - popen_calls = [] +def test_get_paraview_plugin_path_one_file_writes_bundled_plugin( + monkeypatch, + tmp_path, +): + """The bundled plugin should include helper modules in a temporary file.""" + monkeypatch.setattr(paraview_plugin.tempfile, "mkdtemp", lambda: str(tmp_path)) + + plugin_directory = paraview_plugin.get_ParaView_plugin_path_one_file() + + plugin_file = Path(plugin_directory) / "PlaidParaViewPlugin.py" + content = plugin_file.read_text() + assert Path(plugin_directory) == tmp_path + assert plugin_file.exists() + assert "# ##INCLUDE PLACEHOLDER##" not in content + assert "def CGNSTreeToVtk" in content + assert "def cgns_tree_to_json_payload" in content + assert "from __future__ import annotations" not in content + + +def test_run_paraview_with_plugin_sets_environment(monkeypatch, tmp_path): + """Launching ParaView should pass plugin-related environment variables.""" + calls = [] def fake_popen(args, env): - process = SimpleNamespace(args=args, env=env) - popen_calls.append(process) - return process + calls.append({"args": args, "env": env}) + return SimpleNamespace(pid=1234) - monkeypatch.delenv("PARAVIEW_EXEC", raising=False) + monkeypatch.setattr( + paraview_plugin, + "get_ParaView_plugin_path_one_file", + lambda: tmp_path, + ) monkeypatch.setattr(paraview_plugin.subprocess, "Popen", fake_popen) + monkeypatch.setenv("PARAVIEW_EXEC", "custom-paraview") process = paraview_plugin.run_paraview_with_plugin() - popen_call = popen_calls[0] - assert process is popen_call - assert popen_call.args == [paraview_plugin.paraview_exec] - assert popen_call.env["PV_PLUGIN_PATH"] == str( - paraview_plugin.get_ParaView_plugin_path() - ) - assert popen_call.env["PARAVIEW_LOG_PLUGIN_VERBOSITY"] == "ON" + assert process.pid == 1234 + assert calls[0]["args"] == ["custom-paraview"] + assert calls[0]["env"]["PV_PLUGIN_PATH"] == str(tmp_path) + assert calls[0]["env"]["PARAVIEW_LOG_PLUGIN_VERBOSITY"] == "ON" + assert os.environ["PARAVIEW_EXEC"] == "custom-paraview" -def test_run_paraview_with_plugin_uses_configured_windows_executable( +def test_run_paraview_with_plugin_sets_wslenv_for_windows_paraview( monkeypatch, + tmp_path, ): - """A WSL-mounted ParaView executable enables WSLENV path propagation.""" - popen_calls = [] - - def fake_popen(args, env): - process = SimpleNamespace(args=args, env=env) - popen_calls.append(process) - return process + """WSL launches should propagate plugin variables to Windows ParaView.""" + calls = [] - paraview_exec = "/mnt/c/Program Files/ParaView/bin/paraview.exe" - monkeypatch.setenv("PARAVIEW_EXEC", paraview_exec) - monkeypatch.setattr(paraview_plugin.subprocess, "Popen", fake_popen) + monkeypatch.setattr( + paraview_plugin, + "get_ParaView_plugin_path_one_file", + lambda: tmp_path, + ) + monkeypatch.setattr( + paraview_plugin.subprocess, + "Popen", + lambda args, env: calls.append({"args": args, "env": env}) or object(), + ) + monkeypatch.setenv("PARAVIEW_EXEC", "/mnt/c/ParaView/bin/paraview.exe") - process = paraview_plugin.run_paraview_with_plugin() - popen_call = popen_calls[0] + paraview_plugin.run_paraview_with_plugin() - assert process is popen_call - assert popen_call.args == [paraview_exec] - assert popen_call.env["PV_PLUGIN_PATH"] == str( - paraview_plugin.get_ParaView_plugin_path() - ) - assert popen_call.env["PARAVIEW_LOG_PLUGIN_VERBOSITY"] == "ON" - assert ( - popen_call.env["WSLENV"] == "PV_PLUGIN_PATH/p:PARAVIEW_LOG_PLUGIN_VERBOSITY/p" + assert calls[0]["env"]["WSLENV"] == ( + "PV_PLUGIN_PATH/p:PARAVIEW_LOG_PLUGIN_VERBOSITY/p" ) diff --git a/tests/cli/test_serve.py b/tests/cli/test_serve.py index 79426517..16bfa157 100644 --- a/tests/cli/test_serve.py +++ b/tests/cli/test_serve.py @@ -7,9 +7,11 @@ from collections.abc import Generator from http.client import HTTPConnection from pathlib import Path +from types import SimpleNamespace import pytest +from plaid.cli import serve from plaid.cli.serve import ServeContext, _ServeHTTPServer @@ -35,6 +37,53 @@ def serve_url() -> Generator[str, None, None]: server_thread.join(timeout=5) +@pytest.fixture() +def sample_serve_url( + monkeypatch, +) -> Generator[tuple[str, list[dict[str, object]]], None, None]: + """Run a local PLAID server with fake sample serialization. + + Yields: + Tuple containing URL base and calls captured by the fake context. + """ + calls: list[dict[str, object]] = [] + + class FakeContext: + def resolve_dataset_uri(self, payload): + calls.append({"method": "resolve_dataset_uri", "payload": payload}) + return str(payload["dataset"]) + + def get_sample_objects(self, dataset_uri, split, sample_ids): + calls.append( + { + "method": "get_sample_objects", + "dataset_uri": dataset_uri, + "split": split, + "sample_ids": sample_ids, + } + ) + return [f"sample-{sample_id}" for sample_id in sample_ids] + + monkeypatch.setattr( + serve, + "sample_to_json_payload", + lambda sample: {"serialized": sample}, + ) + server = _ServeHTTPServer(("127.0.0.1", 0), FakeContext()) + server_address = server.server_address + host = str(server_address[0]) + port = int(server_address[1]) + server_thread = threading.Thread(target=server.serve_forever, daemon=True) + server_thread.start() + + try: + yield f"{host}:{port}", calls + finally: + server.shutdown() + server.server_close() + server_thread.join(timeout=5) + + def _get_json(url_base: str, path: str) -> tuple[int, dict[str, object]]: """Perform a GET request and decode the JSON payload. @@ -82,6 +131,24 @@ def _post_json( return response.status, response_payload +def _post_raw( + url_base: str, + path: str, + body: bytes, +) -> tuple[int, dict[str, object]]: + connection = HTTPConnection(url_base) + connection.request( + "POST", + path, + body=body, + headers={"Content-Type": "application/json"}, + ) + response = connection.getresponse() + response_payload = json.loads(response.read().decode("utf-8")) + connection.close() + return response.status, response_payload + + def test_health_entry_point_returns_ok(serve_url: str) -> None: """Health route should return status OK.""" status, payload = _get_json(serve_url, "/health") @@ -161,3 +228,356 @@ def test_post_problem_definition_returns_selected_definition(serve_url: str) -> assert payload["name"] == "regression_8" assert isinstance(payload["input_features"], list) assert isinstance(payload["output_features"], list) + + +def test_post_samples_rejects_missing_sample_ids(serve_url: str) -> None: + """Sample route should validate request payload fields.""" + status, payload = _post_json( + serve_url, + "/samples", + {"dataset": "datamain/PhysArena_Tensile2d"}, + ) + + assert status == 400 + assert "sample_ids" in str(payload["error"]) + + +def test_post_dataset_route_rejects_invalid_json_body(serve_url: str) -> None: + """Dataset routes should reject invalid JSON request bodies.""" + status, payload = _post_raw(serve_url, "/infos", b"[") + + assert status == 400 + assert "Expecting value" in str(payload["error"]) + + +def test_post_problem_definition_rejects_non_string_name(serve_url: str) -> None: + """Problem definition selector must be a string when provided.""" + status, payload = _post_json( + serve_url, + "/problem_definition", + {"dataset": "datamain/PhysArena_Tensile2d", "problem_definition": 1}, + ) + + assert status == 400 + assert payload == {"error": "problem_definition must be a string"} + + +def test_post_samples_returns_serialized_samples(sample_serve_url) -> None: + """Sample route should resolve samples and serialize them to JSON payloads.""" + url_base, calls = sample_serve_url + + status, payload = _post_json( + url_base, + "/samples", + {"dataset": "memory://dataset", "split": " train ", "sample_ids": [0, 2]}, + ) + + assert status == 200 + assert payload == { + "samples": [{"serialized": "sample-0"}, {"serialized": "sample-2"}] + } + assert calls == [ + { + "method": "resolve_dataset_uri", + "payload": { + "dataset": "memory://dataset", + "split": " train ", + "sample_ids": [0, 2], + }, + }, + { + "method": "get_sample_objects", + "dataset_uri": "memory://dataset", + "split": "train", + "sample_ids": [0, 2], + }, + ] + + +@pytest.mark.parametrize( + ("payload", "expected"), + [ + ({"dataset": " /tmp/data "}, "/tmp/data"), + ({"uri": "s3://bucket"}, "s3://bucket"), + ], +) +def test_parse_dataset_uri_accepts_dataset_or_uri(payload, expected): + """Dataset URI parsing should accept both supported field names.""" + assert serve._parse_dataset_uri(payload) == expected + + +def test_resolve_dataset_uri_uses_default_when_request_has_no_dataset(): + """The serve context should fall back to its configured default dataset.""" + context = ServeContext(default_dataset_uri=" /data/default ") + + assert context.resolve_dataset_uri({}) == "/data/default" + + +def test_resolve_dataset_uri_requires_dataset_without_default(): + """A request needs a dataset URI when the server has no default dataset.""" + with pytest.raises(ValueError, match="dataset path/URI is required"): + ServeContext().resolve_dataset_uri({}) + + +@pytest.mark.parametrize( + ("payload", "expected"), + [({}, None), ({"split": " train "}, "train")], +) +def test_parse_split_accepts_optional_non_empty_string(payload, expected): + """Split parsing should strip valid split names and allow omission.""" + assert serve._parse_split(payload) == expected + + +@pytest.mark.parametrize("split", ["", 1]) +def test_parse_split_rejects_invalid_split_values(split): + """Split parsing should reject non-string and empty split values.""" + with pytest.raises(ValueError, match="split must be a non-empty string"): + serve._parse_split({"split": split}) + + +@pytest.mark.parametrize( + "payload", + [ + {}, + {"sample_ids": []}, + {"sample_ids": [1, "2"]}, + {"sample_ids": [-1]}, + ], +) +def test_validate_sample_request_rejects_invalid_sample_ids(payload): + """Sample requests require a non-empty list of non-negative integers.""" + with pytest.raises(ValueError): + serve._validate_sample_request(payload) + + +def test_validate_sample_request_returns_sample_ids(): + """Valid sample IDs should be returned unchanged.""" + assert serve._validate_sample_request({"sample_ids": [0, 2]}) == [0, 2] + + +def test_serve_context_get_store_loads_and_caches_dataset(monkeypatch, tmp_path): + """Dataset stores should be loaded once and reused by URI.""" + dataset = tmp_path / "dataset" + dataset.mkdir() + calls = [] + + def fake_init_from_disk(path): + calls.append(path) + return {"train": ["raw"]}, { + "train": SimpleNamespace(to_plaid=lambda data, i: (data[i], i)) + } + + monkeypatch.setattr(serve, "init_from_disk", fake_init_from_disk) + context = ServeContext() + + first = context._get_store(str(dataset)) + second = context._get_store(str(dataset)) + + assert first is second + assert calls == [str(dataset)] + assert context.get_sample_objects(str(dataset), "train", [0]) == [("raw", 0)] + + +def test_serve_context_get_store_rejects_missing_dataset(tmp_path): + """Dataset loading should fail before storage initialization for bad paths.""" + with pytest.raises(ValueError, match="does not exist"): + ServeContext()._get_store(str(tmp_path / "missing")) + + +def test_serve_context_get_store_rejects_empty_dataset(monkeypatch, tmp_path): + """Datasets without splits should be rejected.""" + dataset = tmp_path / "dataset" + dataset.mkdir() + monkeypatch.setattr(serve, "init_from_disk", lambda _path: ({}, {})) + + with pytest.raises(ValueError, match="no splits"): + ServeContext()._get_store(str(dataset)) + + +def test_resolve_split_selects_only_split(): + """A single available split can be inferred.""" + store = serve._DatasetStore(datasetdict={"train": []}, converterdict={}) + + assert ServeContext._resolve_split(store, None) == "train" + + +def test_resolve_split_rejects_unknown_or_ambiguous_split(): + """Unknown and ambiguous split requests should produce clear errors.""" + store = serve._DatasetStore(datasetdict={"train": [], "test": []}, converterdict={}) + + with pytest.raises(ValueError, match="Unknown split"): + ServeContext._resolve_split(store, "OOD") + with pytest.raises(ValueError, match="split is required"): + ServeContext._resolve_split(store, None) + + +def test_get_infos_serializes_loaded_infos(monkeypatch): + """Dataset infos should be returned through model_dump.""" + monkeypatch.setattr( + serve, + "load_infos_from_disk", + lambda dataset_uri: SimpleNamespace(model_dump=lambda: {"uri": dataset_uri}), + ) + + assert ServeContext.get_infos("dataset") == {"uri": "dataset"} + + +def test_get_problem_definition_selects_requested_definition(monkeypatch): + """Requested problem definitions should be selected by name.""" + monkeypatch.setattr( + serve, + "load_problem_definitions_from_disk", + lambda _dataset_uri: { + "first": SimpleNamespace(model_dump=lambda: {"name": "first"}), + "second": SimpleNamespace(model_dump=lambda: {"name": "second"}), + }, + ) + + assert ServeContext.get_problem_definition("dataset", "second") == { + "name": "second" + } + with pytest.raises(ValueError, match="not found"): + ServeContext.get_problem_definition("dataset", "missing") + + +def test_get_problem_definition_prefers_benchmark_then_sorted_first(monkeypatch): + """Default problem definition selection should be deterministic.""" + monkeypatch.setattr( + serve, + "load_problem_definitions_from_disk", + lambda _dataset_uri: { + "zeta": SimpleNamespace(model_dump=lambda: {"name": "zeta"}), + "PLAID_benchmark": SimpleNamespace( + model_dump=lambda: {"name": "PLAID_benchmark"} + ), + }, + ) + assert ServeContext.get_problem_definition("dataset") == {"name": "PLAID_benchmark"} + + monkeypatch.setattr( + serve, + "load_problem_definitions_from_disk", + lambda _dataset_uri: { + "zeta": SimpleNamespace(model_dump=lambda: {"name": "zeta"}), + "alpha": SimpleNamespace(model_dump=lambda: {"name": "alpha"}), + }, + ) + assert ServeContext.get_problem_definition("dataset") == {"name": "alpha"} + + +def test_get_problem_definition_returns_only_available_definition(monkeypatch): + """A single problem definition should be selected by default.""" + monkeypatch.setattr( + serve, + "load_problem_definitions_from_disk", + lambda _dataset_uri: { + "only": SimpleNamespace(model_dump=lambda: {"name": "only"}) + }, + ) + + assert ServeContext.get_problem_definition("dataset") == {"name": "only"} + + +def test_run_server_until_process_exits_starts_and_stops_server(): + """The lifecycle helper should shut the server down after ParaView exits.""" + calls = [] + server = SimpleNamespace( + serve_forever=lambda: calls.append("serve_forever"), + shutdown=lambda: calls.append("shutdown"), + server_close=lambda: calls.append("server_close"), + ) + process = SimpleNamespace(wait=lambda: calls.append("wait")) + + serve._run_server_until_process_exits(server, process) + + assert "wait" in calls + assert calls[-2:] == ["shutdown", "server_close"] + + +def test_run_server_until_process_exits_terminates_on_keyboard_interrupt(): + """KeyboardInterrupt should terminate ParaView and still close the server.""" + calls = [] + + def wait(timeout=None): + calls.append(("wait", timeout)) + if timeout is None: + raise KeyboardInterrupt + + server = SimpleNamespace( + serve_forever=lambda: None, + shutdown=lambda: calls.append("shutdown"), + server_close=lambda: calls.append("server_close"), + ) + process = SimpleNamespace( + wait=wait, + terminate=lambda: calls.append("terminate"), + ) + + serve._run_server_until_process_exits(server, process) + + assert "terminate" in calls + assert ("wait", 5) in calls + assert calls[-2:] == ["shutdown", "server_close"] + + +def test_run_server_until_process_exits_kills_process_on_error(): + """Unexpected lifecycle errors should kill ParaView and close the server.""" + calls = [] + + server = SimpleNamespace( + serve_forever=lambda: None, + shutdown=lambda: calls.append("shutdown"), + server_close=lambda: calls.append("server_close"), + ) + process = SimpleNamespace( + wait=lambda: (_ for _ in ()).throw(RuntimeError("boom")), + kill=lambda: calls.append("kill"), + ) + + with pytest.raises(RuntimeError, match="boom"): + serve._run_server_until_process_exits(server, process) + + assert calls == ["kill", "shutdown", "server_close"] + + +def test_main_runs_server_forever(monkeypatch): + """The CLI entry point should create a server and run it without ParaView.""" + calls = [] + + class FakeServer: + def __init__(self, address, context): + calls.append(("init", address, context.default_dataset_uri)) + + def serve_forever(self): + calls.append("serve_forever") + + monkeypatch.setattr(serve, "_ServeHTTPServer", FakeServer) + + assert serve.main(["--host", "127.0.0.1", "--port", "0", "--dataset", "data"]) == 0 + assert calls == [("init", ("127.0.0.1", 0), "data"), "serve_forever"] + + +def test_main_runs_until_paraview_process_exits(monkeypatch): + """ParaView mode should launch the plugin and use process-bound serving.""" + calls = [] + process = object() + + class FakeServer: + def __init__(self, address, context): + calls.append(("init", address, context.default_dataset_uri)) + + monkeypatch.setattr(serve, "_ServeHTTPServer", FakeServer) + monkeypatch.setattr( + serve, + "_run_server_until_process_exits", + lambda server, pv_process: calls.append(("run_until_exit", server, pv_process)), + ) + monkeypatch.setattr( + "plaid.cli.paraview_plugin.run_paraview_with_plugin", + lambda: process, + ) + + assert serve.main(["--port", "8123", "--ParaViewRun"]) == 0 + assert calls[0] == ("init", ("0.0.0.0", 8123), None) + assert calls[1][0] == "run_until_exit" + assert calls[1][2] is process diff --git a/tests/utils/test_cgns_vtk.py b/tests/utils/test_cgns_vtk.py new file mode 100644 index 00000000..c03088ed --- /dev/null +++ b/tests/utils/test_cgns_vtk.py @@ -0,0 +1,753 @@ +"""Tests for direct CGNS-to-VTK conversion helpers.""" + +from __future__ import annotations + +import sys +from types import ModuleType, SimpleNamespace + +import numpy as np +import pytest + +from plaid.utils import cgns_vtk + + +class _FakeVtkArray: + def __init__(self, data): + self.data = np.asarray(data) + self.name = None + self.number_of_components = None + + def SetName(self, name): # noqa: N802 + self.name = name + + def SetNumberOfComponents(self, number_of_components): # noqa: N802 + self.number_of_components = number_of_components + + +class _FakeAttributes: + def __init__(self): + self.arrays = [] + + def AddArray(self, array): # noqa: N802 + self.arrays.append(array) + + +class _FakeFieldData(_FakeAttributes): + pass + + +class _FakePoints: + def __init__(self): + self.data = None + + def SetData(self, data): # noqa: N802 + self.data = data + + +class _FakeCellArray: + def __init__(self): + self.offsets = None + self.connectivity = None + + def SetData(self, offsets, connectivity): # noqa: N802 + self.offsets = offsets + self.connectivity = connectivity + + +class _FakeMetadata: + def __init__(self): + self.values = {} + + def Set(self, key, value): # noqa: N802 + self.values[key] = value + + +class _FakeVtkObject: + def __init__(self): + self.points = None + self.dimensions = None + self.cell_types = None + self.cell_array = None + self.point_data = _FakeAttributes() + self.cell_data = _FakeAttributes() + self.field_data = _FakeFieldData() + + def SetPoints(self, points): # noqa: N802 + self.points = points + + def SetDimensions(self, dimensions): # noqa: N802 + self.dimensions = dimensions + + def SetCells(self, cell_types, cell_array): # noqa: N802 + self.cell_types = cell_types + self.cell_array = cell_array + + def GetNumberOfPoints(self): # noqa: N802 + if self.points is None: + return 0 + return len(self.points.data.data) + + def GetNumberOfCells(self): # noqa: N802 + return 0 if self.cell_types is None else len(self.cell_types) + + def GetPointData(self): # noqa: N802 + return self.point_data + + def GetCellData(self): # noqa: N802 + return self.cell_data + + def GetFieldData(self): # noqa: N802 + return self.field_data + + +class _FakeMultiBlock: + @staticmethod + def NAME(): # noqa: N802 + return "name" + + def __init__(self): + self.blocks = [] + self.metadata = [] + + def SetNumberOfBlocks(self, number_of_blocks): # noqa: N802 + self.blocks = [None] * number_of_blocks + self.metadata = [_FakeMetadata() for _ in range(number_of_blocks)] + + def SetBlock(self, index, block): # noqa: N802 + self.blocks[index] = block + + def GetMetaData(self, index): # noqa: N802 + return self.metadata[index] + + +class _FakeNumpySupport: + @staticmethod + def numpy_to_vtk(data, deep=False): + _ = deep + return _FakeVtkArray(data) + + @staticmethod + def numpy_to_vtkIdTypeArray(data, deep=False): # noqa: N802 + _ = deep + return _FakeVtkArray(data) + + +class _FakeStringArray: + def __init__(self): + self.name = None + self.values = [] + + def SetName(self, name): # noqa: N802 + self.name = name + + def SetNumberOfValues(self, number_of_values): # noqa: N802 + _ = number_of_values + self.values = [] + + def SetValue(self, *args): # noqa: N802 + if len(args) == 1: + self.values.append(args[0]) + return + index, value = args + self.values[index] = value + + +def _patch_fake_vtk_import(monkeypatch): + monkeypatch.setattr( + cgns_vtk, + "_import_vtk_for_direct_cgns", + lambda: ( + _FakeVtkObject, + _FakeVtkObject, + _FakePoints, + _FakeCellArray, + _FakeMultiBlock, + _FakeNumpySupport, + ), + ) + + +def _node(name, value, children=None, label="DataArray_t"): + return [name, value, children or [], label] + + +def test_cgns_child_helpers_find_children_by_label_and_name(): + parent = _node( + "Parent", + None, + [ + _node("Grid", None, label="GridCoordinates_t"), + _node("Flow", None, label="FlowSolution_t"), + ], + label="Zone_t", + ) + + assert cgns_vtk._cgns_children_by_label(parent, "FlowSolution_t") == [parent[2][1]] + assert cgns_vtk._cgns_child_by_name(parent, "Grid") == parent[2][0] + assert cgns_vtk._cgns_child_by_name(parent, "Missing") is None + + +def test_cgns_value_as_string_decodes_supported_values(): + chars = np.array(list("Vertex\x00"), dtype="U1") + + assert cgns_vtk._cgns_value_as_string(None) is None + assert ( + cgns_vtk._cgns_value_as_string(_node("Location", "CellCenter")) == "CellCenter" + ) + assert cgns_vtk._cgns_value_as_string(_node("Location", chars)) == "Vertex" + assert cgns_vtk._cgns_value_as_string(_node("Number", 3)) == "3" + + +def test_cgns_add_numpy_array_to_vtk_attributes_adds_scalar_and_vector_arrays(): + attributes = _FakeAttributes() + + assert cgns_vtk._cgns_add_numpy_array_to_vtk_attributes( + attributes, + "scalar", + np.array([1.0, 2.0]), + 2, + _FakeNumpySupport, + ) + assert cgns_vtk._cgns_add_numpy_array_to_vtk_attributes( + attributes, + "vector", + np.array([[1.0, 2.0], [3.0, 4.0]]), + 2, + _FakeNumpySupport, + ) + assert [array.name for array in attributes.arrays] == ["scalar", "vector"] + assert attributes.arrays[1].number_of_components == 2 + + +@pytest.mark.parametrize( + ("data", "number_of_tuples"), + [(np.array(["a", "b"]), 2), (np.array([1, 2, 3]), 2), (np.array([1]), 0)], +) +def test_cgns_add_numpy_array_to_vtk_attributes_rejects_incompatible_arrays( + data, + number_of_tuples, +): + attributes = _FakeAttributes() + + added = cgns_vtk._cgns_add_numpy_array_to_vtk_attributes( + attributes, + "bad", + data, + number_of_tuples, + _FakeNumpySupport, + ) + + assert not added + assert attributes.arrays == [] + + +def test_cgns_insert_cells_from_elements_node_adds_linear_cells(): + elements = _node( + "Triangles", + np.array([5]), + [_node("ElementConnectivity", np.array([1, 2, 3, 4, 5, 6]))], + label="Elements_t", + ) + cell_types = [] + offsets = [0] + connectivity = [] + + cgns_vtk._cgns_insert_cells_from_elements_node( + elements, + cell_types, + offsets, + connectivity, + ) + + assert cell_types == [5, 5] + assert offsets == [0, 3, 6] + assert connectivity == [0, 1, 2, 3, 4, 5] + + +def test_cgns_insert_cells_from_elements_node_supports_mixed_cells(): + elements = _node( + "Mixed", + np.array([20]), + [_node("ElementConnectivity", np.array([3, 1, 2, 5, 3, 4, 5]))], + label="Elements_t", + ) + cell_types = [] + offsets = [0] + connectivity = [] + + cgns_vtk._cgns_insert_cells_from_elements_node( + elements, + cell_types, + offsets, + connectivity, + ) + + assert cell_types == [3, 5] + assert offsets == [0, 2, 5] + assert connectivity == [0, 1, 2, 3, 4] + + +def test_cgns_insert_cells_from_elements_node_applies_vtk_permutation(): + elements = _node( + "Penta15", + np.array([15]), + [_node("ElementConnectivity", np.arange(1, 16))], + label="Elements_t", + ) + connectivity = [] + + cgns_vtk._cgns_insert_cells_from_elements_node(elements, [], [0], connectivity) + + assert connectivity == [0, 1, 2, 3, 4, 5, 6, 7, 8, 12, 13, 14, 9, 10, 11] + + +def test_cgns_insert_cells_from_elements_node_raises_for_unknown_type(): + elements = _node( + "Unknown", + np.array([99]), + [_node("ElementConnectivity", np.array([1]))], + label="Elements_t", + ) + + with pytest.raises(NotImplementedError, match="99"): + cgns_vtk._cgns_insert_cells_from_elements_node(elements, [], [0], []) + + +def test_cgns_insert_cells_from_elements_node_uses_fallback_connectivity_name(): + elements = _node( + "Triangles", + np.array([5]), + [_node("TrianglesElementConnectivity", np.array([1, 2, 3]))], + label="Elements_t", + ) + cell_types = [] + offsets = [0] + connectivity = [] + + cgns_vtk._cgns_insert_cells_from_elements_node( + elements, + cell_types, + offsets, + connectivity, + ) + + assert cell_types == [5] + assert offsets == [0, 3] + assert connectivity == [0, 1, 2] + + +def test_cgns_insert_cells_from_elements_node_ignores_missing_connectivity(): + cell_types = [] + offsets = [0] + connectivity = [] + + cgns_vtk._cgns_insert_cells_from_elements_node( + _node("NoConnectivity", np.array([5]), [], label="Elements_t"), + cell_types, + offsets, + connectivity, + ) + + assert cell_types == [] + assert offsets == [0] + assert connectivity == [] + + +def test_cgns_insert_cells_from_elements_node_raises_for_unknown_mixed_type(): + elements = _node( + "Mixed", + np.array([20]), + [_node("ElementConnectivity", np.array([99, 1]))], + label="Elements_t", + ) + + with pytest.raises(NotImplementedError, match="99"): + cgns_vtk._cgns_insert_cells_from_elements_node(elements, [], [0], []) + + +def test_cgns_insert_cells_from_elements_node_applies_mixed_permutation(): + elements = _node( + "Mixed", + np.array([20]), + [_node("ElementConnectivity", np.concatenate(([15], np.arange(1, 16))))], + label="Elements_t", + ) + connectivity = [] + + cgns_vtk._cgns_insert_cells_from_elements_node(elements, [], [0], connectivity) + + assert connectivity == [0, 1, 2, 3, 4, 5, 6, 7, 8, 12, 13, 14, 9, 10, 11] + + +def test_cgns_add_flow_solutions_to_vtk_routes_point_and_cell_data(): + point_data = _FakeAttributes() + cell_data = _FakeAttributes() + vtk_object = SimpleNamespace( + GetNumberOfPoints=lambda: 2, + GetNumberOfCells=lambda: 1, + GetPointData=lambda: point_data, + GetCellData=lambda: cell_data, + ) + zone = _node( + "Zone", + None, + [ + _node( + "PointFlow", + None, + [_node("pressure", np.array([1.0, 2.0]))], + label="FlowSolution_t", + ), + _node( + "CellFlow", + None, + [ + _node("GridLocation", "CellCenter", label="GridLocation_t"), + _node("density", np.array([3.0])), + ], + label="FlowSolution_t", + ), + ], + label="Zone_t", + ) + + cgns_vtk._cgns_add_flow_solutions_to_vtk(zone, vtk_object, _FakeNumpySupport) + + assert [array.name for array in point_data.arrays] == ["pressure"] + assert [array.name for array in cell_data.arrays] == ["density"] + + +def test_cgns_add_flow_solutions_to_vtk_skips_unsupported_locations_and_empty_data(): + point_data = _FakeAttributes() + cell_data = _FakeAttributes() + vtk_object = SimpleNamespace( + GetNumberOfPoints=lambda: 1, + GetNumberOfCells=lambda: 1, + GetPointData=lambda: point_data, + GetCellData=lambda: cell_data, + ) + zone = _node( + "Zone", + None, + [ + _node( + "BadLocation", + None, + [ + _node("GridLocation", "Unknown", label="GridLocation_t"), + _node("ignored", np.array([1.0])), + ], + label="FlowSolution_t", + ), + _node( + "NoData", + None, + [_node("empty", None)], + label="FlowSolution_t", + ), + ], + label="Zone_t", + ) + + cgns_vtk._cgns_add_flow_solutions_to_vtk(zone, vtk_object, _FakeNumpySupport) + + assert point_data.arrays == [] + assert cell_data.arrays == [] + + +def test_cgns_zone_points_to_vtk_points_reads_coordinates(): + zone = _node( + "Zone", + None, + [ + _node( + "GridCoordinates", + None, + [ + _node("CoordinateX", np.array([1.0, 2.0])), + _node("CoordinateY", np.array([3.0, 4.0])), + ], + label="GridCoordinates_t", + ) + ], + label="Zone_t", + ) + + points, shape = cgns_vtk._cgns_zone_points_to_vtk_points( + zone, + 2, + _FakeNumpySupport, + _FakePoints, + ) + + assert shape == (2,) + np.testing.assert_allclose(points.data.data, [[1.0, 3.0, 0.0], [2.0, 4.0, 0.0]]) + + +def test_cgns_zone_points_to_vtk_points_requires_coordinates(): + zone = _node("Zone", None, [], label="Zone_t") + + with pytest.raises(ValueError, match="GridCoordinates_t"): + cgns_vtk._cgns_zone_points_to_vtk_points( + zone, + 3, + _FakeNumpySupport, + _FakePoints, + ) + + +def test_cgns_zone_points_to_vtk_points_requires_coordinate_x(): + zone = _node( + "Zone", + None, + [_node("GridCoordinates", None, [], label="GridCoordinates_t")], + label="Zone_t", + ) + + with pytest.raises(ValueError, match="CoordinateX"): + cgns_vtk._cgns_zone_points_to_vtk_points( + zone, + 3, + _FakeNumpySupport, + _FakePoints, + ) + + +def test_cgns_base_extract_globals_returns_non_empty_values(): + base = _node( + "Global", + None, + [_node("labels", np.array([1])), _node("empty", None)], + label="CGNSBase_t", + ) + + globals_ = cgns_vtk.CGNSBaseExtractGlobals(base) + + assert list(globals_) == ["labels"] + np.testing.assert_array_equal(globals_["labels"], np.array([1])) + + +def test_cgns_base_to_vtk_validates_base_node(): + with pytest.raises(ValueError, match="CGNSBase_t"): + cgns_vtk.CGNSBaseToVtk(_node("NotBase", None, label="Zone_t")) + + +def test_cgns_base_to_vtk_dispatches_zone_type(monkeypatch): + structured_zone = _node( + "StructuredZone", + np.array([[2], [1], [1]]), + [_node("ZoneType", "Structured", label="ZoneType_t")], + label="Zone_t", + ) + base = _node("Base", np.array([3, 3]), [structured_zone], label="CGNSBase_t") + structured_result = object() + + monkeypatch.setattr( + cgns_vtk, + "_cgns_structured_zone_to_vtk", + lambda zone, physical_dim: (zone, physical_dim, structured_result), + ) + + assert cgns_vtk.CGNSBaseToVtk(base) == (structured_zone, 3, structured_result) + + +def test_cgns_base_to_vtk_converts_structured_zone(monkeypatch): + _patch_fake_vtk_import(monkeypatch) + zone = _node( + "StructuredZone", + np.array([[2], [1], [1]]), + [ + _node("ZoneType", "Structured", label="ZoneType_t"), + _node( + "GridCoordinates", + None, + [_node("CoordinateX", np.array([1.0, 2.0]))], + label="GridCoordinates_t", + ), + ], + label="Zone_t", + ) + base = _node("Base", np.array([3, 2]), [zone], label="CGNSBase_t") + + output = cgns_vtk.CGNSBaseToVtk(base) + + assert output.dimensions == [2, 1, 1] + np.testing.assert_allclose(output.points.data.data[:, 0], [1.0, 2.0]) + + +def test_cgns_base_to_vtk_converts_unstructured_zone(monkeypatch): + _patch_fake_vtk_import(monkeypatch) + zone = _node( + "UnstructuredZone", + np.array([[3, 1, 0]]), + [ + _node( + "GridCoordinates", + None, + [_node("CoordinateX", np.array([1.0, 2.0, 3.0]))], + label="GridCoordinates_t", + ), + _node( + "Triangles", + np.array([5]), + [_node("ElementConnectivity", np.array([1, 2, 3]))], + label="Elements_t", + ), + ], + label="Zone_t", + ) + base = _node("Base", np.array([3, 3]), [zone], label="CGNSBase_t") + + output = cgns_vtk.CGNSBaseToVtk(base) + + assert output.cell_types == [5] + np.testing.assert_array_equal(output.cell_array.connectivity.data, [0, 1, 2]) + + +def test_cgns_base_to_vtk_returns_multiblock_for_multiple_zones(monkeypatch): + monkeypatch.setattr( + cgns_vtk, "_cgns_unstructured_zone_to_vtk", lambda zone, _dim: zone[0] + ) + _patch_fake_vtk_import(monkeypatch) + zones = [ + _node("ZoneA", np.array([[0]]), label="Zone_t"), + _node("ZoneB", np.array([[0]]), label="Zone_t"), + ] + base = _node("Base", np.array([3, 3]), zones, label="CGNSBase_t") + + output = cgns_vtk.CGNSBaseToVtk(base) + + assert output.blocks == ["ZoneA", "ZoneB"] + assert output.metadata[0].values == {"name": "ZoneA"} + + +def test_cgns_base_to_vtk_rejects_missing_data_and_unknown_zone_type(): + with pytest.raises(ValueError, match="dimensionality"): + cgns_vtk.CGNSBaseToVtk(_node("Base", None, [], label="CGNSBase_t")) + with pytest.raises(ValueError, match="no Zone_t"): + cgns_vtk.CGNSBaseToVtk(_node("Base", np.array([3, 3]), [], label="CGNSBase_t")) + + zone = _node( + "Zone", + np.array([[0]]), + [_node("ZoneType", "Unsupported", label="ZoneType_t")], + label="Zone_t", + ) + with pytest.raises(NotImplementedError, match="Unsupported"): + cgns_vtk.CGNSBaseToVtk( + _node("Base", np.array([3, 3]), [zone], label="CGNSBase_t") + ) + + +def test_cgns_tree_to_vtk_adds_global_field_data(monkeypatch): + _patch_fake_vtk_import(monkeypatch) + vtk_object = _FakeVtkObject() + monkeypatch.setattr(cgns_vtk, "CGNSBaseToVtk", lambda _base: vtk_object) + tree = _node( + "CGNSTree", + None, + [ + _node( + "Global", + None, + [_node("ids", np.array([1, 2, 3]))], + label="CGNSBase_t", + ), + _node("Base", np.array([3, 3]), [], label="CGNSBase_t"), + ], + label="CGNSTree_t", + ) + + output = cgns_vtk.CGNSTreeToVtk(tree) + + assert output is vtk_object + assert [array.name for array in output.field_data.arrays] == ["ids"] + np.testing.assert_array_equal(output.field_data.arrays[0].data, [1, 2, 3]) + + +def test_cgns_tree_to_vtk_adds_global_string_field_data(monkeypatch): + _patch_fake_vtk_import(monkeypatch) + vtk_object = _FakeVtkObject() + monkeypatch.setattr(cgns_vtk, "CGNSBaseToVtk", lambda _base: vtk_object) + + fake_core_module = ModuleType("vtkmodules.vtkCommonCore") + fake_core_module.vtkStringArray = _FakeStringArray + monkeypatch.setitem(sys.modules, "vtkmodules.vtkCommonCore", fake_core_module) + labels = np.array([b"A", b"B"], dtype="|S1") + tree = _node( + "CGNSTree", + None, + [ + _node( + "Global", + None, + [_node("labels", labels)], + label="CGNSBase_t", + ), + _node("Base", np.array([3, 3]), [], label="CGNSBase_t"), + ], + label="CGNSTree_t", + ) + + cgns_vtk.CGNSTreeToVtk(tree) + + string_array = vtk_object.field_data.arrays[0] + assert string_array.name == "labels" + assert string_array.values == [np.bytes_(b"A"), np.bytes_(b"B")] + + +def test_cgns_tree_to_vtk_returns_multiblock_for_multiple_bases(monkeypatch): + _patch_fake_vtk_import(monkeypatch) + outputs = {"BaseA": _FakeVtkObject(), "BaseB": _FakeVtkObject()} + monkeypatch.setattr(cgns_vtk, "CGNSBaseToVtk", lambda base: outputs[base[0]]) + tree = _node( + "CGNSTree", + None, + [ + _node("BaseA", np.array([3, 3]), [], label="CGNSBase_t"), + _node("BaseB", np.array([3, 3]), [], label="CGNSBase_t"), + ], + label="CGNSTree_t", + ) + + output = cgns_vtk.CGNSTreeToVtk(tree) + + assert output.blocks == [outputs["BaseA"], outputs["BaseB"]] + assert output.metadata[1].values == {"name": "BaseB"} + + +def test_import_vtk_for_direct_cgns_uses_vtkmodules_fallback(monkeypatch): + fake_numpy_support = object() + vtkmodules = ModuleType("vtkmodules") + vtkmodules_util = ModuleType("vtkmodules.util") + vtkmodules_util.numpy_support = fake_numpy_support + vtk_common_core = ModuleType("vtkmodules.vtkCommonCore") + vtk_common_core.vtkPoints = "vtkPoints" + vtk_common_data_model = ModuleType("vtkmodules.vtkCommonDataModel") + vtk_common_data_model.vtkCellArray = "vtkCellArray" + vtk_common_data_model.vtkMultiBlockDataSet = "vtkMultiBlockDataSet" + vtk_common_data_model.vtkStructuredGrid = "vtkStructuredGrid" + vtk_common_data_model.vtkUnstructuredGrid = "vtkUnstructuredGrid" + modules = { + "paraview": None, + "paraview.vtk": None, + "vtkmodules": vtkmodules, + "vtkmodules.util": vtkmodules_util, + "vtkmodules.util.numpy_support": fake_numpy_support, + "vtkmodules.vtkCommonCore": vtk_common_core, + "vtkmodules.vtkCommonDataModel": vtk_common_data_model, + } + for name, module in modules.items(): + if module is None: + monkeypatch.delitem(sys.modules, name, raising=False) + else: + monkeypatch.setitem(sys.modules, name, module) + + assert cgns_vtk._import_vtk_for_direct_cgns() == ( + "vtkStructuredGrid", + "vtkUnstructuredGrid", + "vtkPoints", + "vtkCellArray", + "vtkMultiBlockDataSet", + fake_numpy_support, + ) diff --git a/tests/utils/test_sample_json.py b/tests/utils/test_sample_json.py index 023924a0..9baf2ff3 100644 --- a/tests/utils/test_sample_json.py +++ b/tests/utils/test_sample_json.py @@ -8,6 +8,8 @@ from plaid.containers.sample import Sample from plaid.utils.cgns_helper import compare_cgns_trees from plaid.utils.sample_json import ( + _decode_time, + _encode_time, sample_from_json, sample_from_json_payload, sample_to_json, @@ -108,3 +110,21 @@ def test_sample_json_rejects_invalid_payloads(): "trees": [{"time": 0.0}], } ) + + +def test_encode_time_accepts_numpy_numeric_scalars(): + """NumPy scalar time keys should be converted to Python JSON scalars.""" + assert _encode_time(np.int64(2)) == 2 + assert _encode_time(np.float64(2.5)) == 2.5 + + +def test_encode_time_rejects_unsupported_time_key_type(): + """Non-numeric sample time keys cannot be represented in Sample JSON.""" + with pytest.raises(TypeError, match="Unsupported time key type"): + _encode_time("not-numeric") + + +def test_decode_time_rejects_non_numeric_values(): + """Decoded sample time values must be numeric.""" + with pytest.raises(ValueError, match="time entries must be numeric"): + _decode_time("not-numeric") From 07aca42f828ec7957c66dc1d40c2911e651ad417 Mon Sep 17 00:00:00 2001 From: Felipe Bordeu Date: Wed, 10 Jun 2026 09:54:39 +0930 Subject: [PATCH 12/35] coverage --- src/plaid/cli/paraview_plugin/PlaidParaViewPlugin.py | 4 ++-- src/plaid/cli/plaidcheck.py | 4 ++-- src/plaid/storage/common/preprocessor.py | 4 ++-- tests/storage/test_preprocessor.py | 12 ++++++++++++ tests/utils/test_cgns_json.py | 10 ++++++++++ 5 files changed, 28 insertions(+), 6 deletions(-) create mode 100644 tests/storage/test_preprocessor.py diff --git a/src/plaid/cli/paraview_plugin/PlaidParaViewPlugin.py b/src/plaid/cli/paraview_plugin/PlaidParaViewPlugin.py index 6ee53ebd..a1ea1653 100644 --- a/src/plaid/cli/paraview_plugin/PlaidParaViewPlugin.py +++ b/src/plaid/cli/paraview_plugin/PlaidParaViewPlugin.py @@ -25,7 +25,7 @@ except ImportError: from vtk.util.vtkAlgorithm import VTKPythonAlgorithmBase -## this inport are in a try because for some cases the plaid library is not available (clien server) +## this import are in a try because for some cases the plaid library is not available (clien server) try: from plaid.utils.cgns_json import cgns_tree_from_json_payload from plaid.utils.cgns_vtk import CGNSTreeToVtk @@ -33,7 +33,7 @@ pass #this line is to inlcude the import to make the plugin selfcontain -#do not modify this line +#do not modify the next line (see file function get_ParaView_plugin_path_one_file for the use case) # ##INCLUDE PLACEHOLDER## ## utility funcitons diff --git a/src/plaid/cli/plaidcheck.py b/src/plaid/cli/plaidcheck.py index 23288433..d1772f8d 100644 --- a/src/plaid/cli/plaidcheck.py +++ b/src/plaid/cli/plaidcheck.py @@ -136,7 +136,7 @@ def _check_required_layout( for rel in required_paths: p = path / rel if not p.exists(): - report.add("error", "MISSING_PATH", rel, f"Missing file/path path: {rel}") + report.add("error", "MISSING_PATH", rel, f"Missing file/path path: {rel}") # pragma: no cover def _check_numeric_content(value: Any) -> Optional[str]: @@ -449,7 +449,7 @@ def check_dataset( # readable. _check_required_layout(path, report, backend=declared_backend_for_layout) if report.has_errors(): - return report + return report # pragma: no cover # Validate top-level dataset declarations from infos.yaml before calling # init_from_disk(), because storage initialization indexes num_samples by diff --git a/src/plaid/storage/common/preprocessor.py b/src/plaid/storage/common/preprocessor.py index 0803c4eb..f95357f3 100644 --- a/src/plaid/storage/common/preprocessor.py +++ b/src/plaid/storage/common/preprocessor.py @@ -242,7 +242,7 @@ def process_shard( if shard_ids is not None: generator = generator_fn([shard_ids]) # pragma: no cover else: - generator = generator_fn() + generator = generator_fn() # pragma: no cover n_samples = 0 for sample in generator: @@ -479,7 +479,7 @@ def preprocess_splits( if gen_kwargs: first_sample = next(generator_fn([shards_ids_list[0]])) # pragma: no cover else: - first_sample = next(generator_fn()) + first_sample = next(generator_fn()) # pragma: no cover sample_dict, _, _ = build_sample_dict(first_sample) # Determine truly constant paths (same hash across all samples). A split diff --git a/tests/storage/test_preprocessor.py b/tests/storage/test_preprocessor.py new file mode 100644 index 00000000..b87739b5 --- /dev/null +++ b/tests/storage/test_preprocessor.py @@ -0,0 +1,12 @@ +"""Tests for common storage preprocessing helpers.""" + +import numpy as np + +from plaid.storage.common.preprocessor import infer_dtype + + +def test_infer_dtype_detects_single_byte_string_arrays(): + """Byte-array encoded CGNS strings should use the canonical S1 dtype.""" + value = np.array([b"A", b"B"], dtype="S1") + + assert infer_dtype(value) == {"dtype": "S1", "ndim": 1} diff --git a/tests/utils/test_cgns_json.py b/tests/utils/test_cgns_json.py index d2467b7b..51422794 100644 --- a/tests/utils/test_cgns_json.py +++ b/tests/utils/test_cgns_json.py @@ -213,6 +213,16 @@ def test_encode_decode_value_roundtrips_bytes(): assert _decode_value(encoded) == value +def test_decode_value_decodes_nested_lists(): + """List payloads are decoded recursively.""" + encoded_bytes = _encode_value(b"nested bytes") + + assert _decode_value([encoded_bytes, [1, encoded_bytes]]) == [ + b"nested bytes", + [1, b"nested bytes"], + ] + + def test_encode_value_rejects_unsupported_values(): """Unsupported value types raise a TypeError with a clear message.""" with pytest.raises(TypeError, match="Unsupported CGNS value type"): From 15662d7eac54ec2e117fe40bc751d8e6dec34e38 Mon Sep 17 00:00:00 2001 From: Felipe Bordeu Date: Wed, 10 Jun 2026 09:56:58 +0930 Subject: [PATCH 13/35] Potential fix for pull request finding 'File is not always closed' Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> --- src/plaid/cli/paraview_plugin/__init__.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/plaid/cli/paraview_plugin/__init__.py b/src/plaid/cli/paraview_plugin/__init__.py index 2c53c481..696cdac7 100644 --- a/src/plaid/cli/paraview_plugin/__init__.py +++ b/src/plaid/cli/paraview_plugin/__init__.py @@ -14,13 +14,16 @@ def get_ParaView_plugin_path(): def get_ParaView_plugin_path_one_file(): plugin_path = Path(__file__).parent / "PlaidParaViewPlugin.py" - plugin_content = open(plugin_path,"r").read() + with open(plugin_path, "r") as f: + plugin_content = f.read() import plaid.utils.cgns_json as cgns_json - sample_json_content = open(Path(cgns_json.__file__),"r").read() + with open(Path(cgns_json.__file__), "r") as f: + sample_json_content = f.read() import plaid.utils.cgns_vtk as cgns_vtk - cgns_vtk_content = open(Path(cgns_vtk.__file__),"r").read() + with open(Path(cgns_vtk.__file__), "r") as f: + cgns_vtk_content = f.read() plugin_content = plugin_content.replace("# ##INCLUDE PLACEHOLDER##",sample_json_content + cgns_vtk_content) plugin_content = plugin_content.replace("from __future__ import annotations","") From ad5db4438f533410418ea2b86edf813b00d7e2e8 Mon Sep 17 00:00:00 2001 From: Felipe Bordeu Date: Wed, 10 Jun 2026 10:00:04 +0930 Subject: [PATCH 14/35] remove old test plugin --- ParaViewPlugin/PlaidPlugin.py | 144 ---------------------------------- 1 file changed, 144 deletions(-) delete mode 100644 ParaViewPlugin/PlaidPlugin.py diff --git a/ParaViewPlugin/PlaidPlugin.py b/ParaViewPlugin/PlaidPlugin.py deleted file mode 100644 index f95a2a68..00000000 --- a/ParaViewPlugin/PlaidPlugin.py +++ /dev/null @@ -1,144 +0,0 @@ -# -# This file is subject to the terms and conditions defined in -# file 'LICENSE.txt', which is part of this source code package. -# - -# this files is intended to be used inside paraview as a plugin -# compatible with paraview 5.7+ -import os -import time -import locale -import pickle - - -_startTime = time.time() -debug = bool(os.environ.get("PARAVIEW_LOG_PLUGIN_VERBOSITY", False)) - -if debug: - - def PrintDebug(mes): - import time - - print(mes, time.time() - _startTime) -else: - - def PrintDebug(mes): - pass - - -try: - import numpy as np - - from paraview.util.vtkAlgorithm import smproxy, smproperty, smdomain, smhint - from paraview.util.vtkAlgorithm import VTKPythonAlgorithmBase - from vtkmodules.vtkCommonDataModel import vtkUnstructuredGrid - - PrintDebug("Loading libs") - from Muscat.Bridges.vtkBridge import SetOutputMuscat - - PrintDebug("Loading") - - paraview_plugin_name = "Plaid ParaView Plugin" - paraview_plugin_version = "5.11.1" - - @smproxy.reader( - name="PlaidSampleReader", - label="Plaid Sample Reader", - extensions="pickle", - file_description="pickle ", - ) - class PlaidSampleReader(VTKPythonAlgorithmBase): - def __init__(self): - VTKPythonAlgorithmBase.__init__( - self, nInputPorts=0, nOutputPorts=1, outputType="vtkUnstructuredGrid" - ) - self._filename: Optional[str] = None - self.timeSteps_cache = None # timesteps - self.cache = None # plaid sample - - @smproperty.stringvector(name="FileName") - @smdomain.filelist() - @smhint.filechooser(extensions="pickle", file_description="pickle files") - def SetFileName(self, name): - """Specify filename for the file to read.""" - if self._filename != name: - self._filename = name - self.timeSteps_cache = None - self.cache = None - self.version = 0 - self.Modified() - if name is not None: - self.GetTimestepValues() - - @smproperty.doublevector( - name="TimestepValues", - information_only="1", - si_class="vtkSITimeStepsProperty", - ) - def GetTimestepValues(self): - if self._filename is None or self._filename == "None": - return None - with open(self._filename, "rb") as f: - self.version = pickle.load(f) - if self.version == 0: - self.timeSteps_cache = pickle.load(f) - else: - self.timeSteps_cache, self.cache = pickle.load(f) - - return self.timeSteps_cache - - def RequestInformation(self, request, inInfoVec, outInfoVec): - executive = self.GetExecutive() - outInfo = outInfoVec.GetInformationObject(0) - outInfo.Remove(executive.TIME_STEPS()) - outInfo.Remove(executive.TIME_RANGE()) - - timeSteps = self.GetTimestepValues() - if timeSteps is not None: - for t in timeSteps: - outInfo.Append(executive.TIME_STEPS(), t) - outInfo.Append(executive.TIME_RANGE(), timeSteps[0]) - outInfo.Append(executive.TIME_RANGE(), timeSteps[-1]) - return 1 - - def RequestData(self, request, inInfoVec, outInfoVec): - if self._filename is None: - return 0 - - outInfo = outInfoVec.GetInformationObject(0) - executive = self.GetExecutive() - if outInfo.Has(executive.UPDATE_TIME_STEP()): - time = outInfo.Get(executive.UPDATE_TIME_STEP()) - else: - time = 0 - - # Read pickle files - import pickle - - if self.version == 0: - if self.cache == None: - with open(self._filename, "rb") as f: - # drop version - pickle.load(f) - # drop timevalues - pickle.load(f) - self.cache = pickle.load(f) - cgnsdata = self.cache.get_tree(time=time) - else: - i = self.timeSteps_cache.index(time) - with open(self._filename, "rb") as f: - f.seek(self.cache[i]) - cgnsdata = pickle.load(f) - - from Muscat.Bridges.CGNSBridge import CGNSToMesh - - mesh = CGNSToMesh(cgnsdata, partitionedMesh=False) - SetOutputMuscat(request, inInfoVec, outInfoVec, mesh, tagsAsFields=True) - return 1 - - PrintDebug("Plaid ParaView Plugin Loaded") -except Exception as ex: - print("Error loading Muscat ParaView Plugin") - print("Muscat in the PYTHONPATH ??? ") - if debug: - raise ex From 068a287ac4c0e3c452ab15a8abf25e47b114c0d0 Mon Sep 17 00:00:00 2001 From: Felipe Bordeu Date: Wed, 10 Jun 2026 10:01:46 +0930 Subject: [PATCH 15/35] Potential fix for pull request finding 'Commented-out code' Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> --- .../paraview_plugin/PlaidParaViewPlugin.py | 40 +------------------ 1 file changed, 1 insertion(+), 39 deletions(-) diff --git a/src/plaid/cli/paraview_plugin/PlaidParaViewPlugin.py b/src/plaid/cli/paraview_plugin/PlaidParaViewPlugin.py index a1ea1653..2d4713a1 100644 --- a/src/plaid/cli/paraview_plugin/PlaidParaViewPlugin.py +++ b/src/plaid/cli/paraview_plugin/PlaidParaViewPlugin.py @@ -422,45 +422,7 @@ def GetSampleData(self): return self._sample_cache - # @smproperty.xml(""" - # - # - # - # - # - - # - # - # - # """) - # def SetInputFeatures(self, value): - # if value is None: - # value = "" - # value = str(value) - # if self.input_features != value: - # def ensureEncluse(string, start, end): - # string = string.strip() - # if not string.startswith(start): - # string = start + string - # if not string.endswith(end): - # string = string + end - # return string - - # treated_input_features = value.replace("'", '"').strip() - # treated_input_features = value.replace("=", ":").strip() - # clean_treated_input_features = [] - # for line in treated_input_features.splitlines(): - # k, v = line.split(":") - # k = ensureEncluse(k, '"', '"') - # clean_treated_input_features.append(k + ":" + v) - - # treated_input_features = ",".join(clean_treated_input_features) - # treated_input_features = ensureEncluse(treated_input_features, "{", "}") - # self.input_features = treated_input_features - # self.Modified() + From 7474733164de3a2cf32c81f5394f6cae829811bd Mon Sep 17 00:00:00 2001 From: Felipe Bordeu Date: Wed, 10 Jun 2026 10:03:06 +0930 Subject: [PATCH 16/35] Potential fix for pull request finding 'Unused import' Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> --- src/plaid/cli/paraview_plugin/PlaidParaViewPlugin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/plaid/cli/paraview_plugin/PlaidParaViewPlugin.py b/src/plaid/cli/paraview_plugin/PlaidParaViewPlugin.py index 2d4713a1..003ba89d 100644 --- a/src/plaid/cli/paraview_plugin/PlaidParaViewPlugin.py +++ b/src/plaid/cli/paraview_plugin/PlaidParaViewPlugin.py @@ -9,7 +9,7 @@ import json import os import time -from typing import Any, Optional +from typing import Optional from urllib import request import numpy as np From 726af28a60cde90ec0720b4cfe96238d09d17b89 Mon Sep 17 00:00:00 2001 From: Felipe Bordeu Date: Wed, 10 Jun 2026 10:03:28 +0930 Subject: [PATCH 17/35] Potential fix for pull request finding 'Except block handles 'BaseException'' Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> --- src/plaid/cli/paraview_plugin/PlaidParaViewPlugin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/plaid/cli/paraview_plugin/PlaidParaViewPlugin.py b/src/plaid/cli/paraview_plugin/PlaidParaViewPlugin.py index 003ba89d..31a7f70c 100644 --- a/src/plaid/cli/paraview_plugin/PlaidParaViewPlugin.py +++ b/src/plaid/cli/paraview_plugin/PlaidParaViewPlugin.py @@ -29,7 +29,7 @@ try: from plaid.utils.cgns_json import cgns_tree_from_json_payload from plaid.utils.cgns_vtk import CGNSTreeToVtk -except: +except ImportError: pass #this line is to inlcude the import to make the plugin selfcontain From f4a8af30128b75e590a431bf1186add9cda9ba39 Mon Sep 17 00:00:00 2001 From: Felipe Bordeu Date: Wed, 10 Jun 2026 10:06:44 +0930 Subject: [PATCH 18/35] add explanation --- docs/zensical.toml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/zensical.toml b/docs/zensical.toml index fe0685a8..2d528079 100644 --- a/docs/zensical.toml +++ b/docs/zensical.toml @@ -67,6 +67,11 @@ nav = [ { "problem_definition" = "api/problem_definition.md" }, { "cli" = [ { "plaidcheck" = "api/cli/plaidcheck.md" }, + { "serve" = "api/cli/serve.md" }, + { "paraview_plugin" = [ + { "__init__" = "api/cli/paraview_plugin/index.md" }, + { "PlaidParaViewPlugin" = "api/cli/paraview_plugin/PlaidParaViewPlugin.md" }, + ] }, ] }, { "containers" = [ { "sample" = "api/containers/sample.md" }, @@ -111,6 +116,10 @@ nav = [ { "utils" = [ { "base" = "api/utils/base.md" }, { "cgns_helper" = "api/utils/cgns_helper.md" }, + { "cgns_json" = "api/utils/cgns_json.md" }, + { "cgns_vtk" = "api/utils/cgns_vtk.md" }, + { "predict_client" = "api/utils/predict_client.md" }, + { "sample_json" = "api/utils/sample_json.md" }, ] }, { "viewer" = [ { "cache" = "api/viewer/cache.md" }, From 16186c873bfb6ae46807d186844b74ac823bdeb8 Mon Sep 17 00:00:00 2001 From: Felipe Bordeu Date: Wed, 10 Jun 2026 10:07:48 +0930 Subject: [PATCH 19/35] Potential fix for pull request finding 'Empty except' Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> --- src/plaid/cli/paraview_plugin/PlaidParaViewPlugin.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/plaid/cli/paraview_plugin/PlaidParaViewPlugin.py b/src/plaid/cli/paraview_plugin/PlaidParaViewPlugin.py index 31a7f70c..25988214 100644 --- a/src/plaid/cli/paraview_plugin/PlaidParaViewPlugin.py +++ b/src/plaid/cli/paraview_plugin/PlaidParaViewPlugin.py @@ -544,7 +544,7 @@ def GetSampleData(self) -> dict[float,list]: print_debug("Reader PlaidSampleReader Loaded") -except ImportError: - pass +except ImportError as exc: + print_debug(f"PlaidDatasetReader not loaded because optional plaid.storage.reader import failed: {exc}") print_debug("Plaid ParaView Plugin Loaded") \ No newline at end of file From 0df5e3999c2292f883bb4bbaf1312dd111ecd546 Mon Sep 17 00:00:00 2001 From: Felipe Bordeu Date: Wed, 10 Jun 2026 10:09:55 +0930 Subject: [PATCH 20/35] fix --- src/plaid/cli/paraview_plugin/PlaidParaViewPlugin.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/plaid/cli/paraview_plugin/PlaidParaViewPlugin.py b/src/plaid/cli/paraview_plugin/PlaidParaViewPlugin.py index 25988214..317c6f2d 100644 --- a/src/plaid/cli/paraview_plugin/PlaidParaViewPlugin.py +++ b/src/plaid/cli/paraview_plugin/PlaidParaViewPlugin.py @@ -25,11 +25,13 @@ except ImportError: from vtk.util.vtkAlgorithm import VTKPythonAlgorithmBase -## this import are in a try because for some cases the plaid library is not available (clien server) try: from plaid.utils.cgns_json import cgns_tree_from_json_payload from plaid.utils.cgns_vtk import CGNSTreeToVtk except ImportError: + # this import are in a try because for some cases the plaid library is not available (client server) + # inthat case the body of the 2 include are injected into the plugin at run time using the function + # get_ParaView_plugin_path_one_file pass #this line is to inlcude the import to make the plugin selfcontain From da7bd11a20f25a7f6f4dad2b408263dcf825ca62 Mon Sep 17 00:00:00 2001 From: Felipe Bordeu Date: Wed, 10 Jun 2026 12:01:42 +0930 Subject: [PATCH 21/35] update some api --- examples/client_server/SimplePredict.py | 10 +++++++--- src/plaid/cli/paraview_plugin/PlaidParaViewPlugin.py | 2 +- src/plaid/utils/cgns_vtk.py | 5 ++--- src/plaid/utils/predict_client.py | 10 ++++++++++ src/plaid/utils/sample_json.py | 2 +- 5 files changed, 21 insertions(+), 8 deletions(-) diff --git a/examples/client_server/SimplePredict.py b/examples/client_server/SimplePredict.py index 5dbade2b..3bb94a05 100644 --- a/examples/client_server/SimplePredict.py +++ b/examples/client_server/SimplePredict.py @@ -17,7 +17,7 @@ # Import required libraries from typing import Any - +import time import sys import numpy as np from matplotlib import pyplot as plt @@ -39,6 +39,9 @@ pb = plaidserver.problem_definition() print(pb) +infos = plaidserver.infos() +print(infos) + # %% [markdown] # # Load a sample for modification @@ -121,7 +124,7 @@ def cost_fuction(x: Any) -> float : # # Call the predictor for a range of # %% - +stime = time.time() nb_calls = 50 x = np.empty(nb_calls) y = np.empty(nb_calls) @@ -130,6 +133,7 @@ def cost_fuction(x: Any) -> float : x[i] = v #sample.del_global(active_output_features) y[i] = cost_fuction([v]) +print(f"{nb_calls} calls of predict in {time.time()-stime} s") # %% [markdown] # # Plot output @@ -141,5 +145,5 @@ def cost_fuction(x: Any) -> float : plt.ylabel(active_output_feature) plt.title(f"{active_input_feature} vs {active_output_feature}") plt.grid() -plt.show() +#plt.show() # %% diff --git a/src/plaid/cli/paraview_plugin/PlaidParaViewPlugin.py b/src/plaid/cli/paraview_plugin/PlaidParaViewPlugin.py index 317c6f2d..e6edeebb 100644 --- a/src/plaid/cli/paraview_plugin/PlaidParaViewPlugin.py +++ b/src/plaid/cli/paraview_plugin/PlaidParaViewPlugin.py @@ -527,7 +527,7 @@ def GetInfos(self): return self._info_cache if (self._info_cache is None) and (self._filename is not None and self._filename != "None" ): - self._info_cache = load_infos_from_disk(self._filename) + self._info_cache = load_infos_from_disk(self._filename).model_dump() self.Modified() else: return {"num_samples":{}} diff --git a/src/plaid/utils/cgns_vtk.py b/src/plaid/utils/cgns_vtk.py index 9c78fe0a..315a9b24 100644 --- a/src/plaid/utils/cgns_vtk.py +++ b/src/plaid/utils/cgns_vtk.py @@ -310,12 +310,11 @@ def CGNSTreeToVtk(treeNode: list): for key, value in globals.items(): if value.dtype == "|S1": from vtkmodules.vtkCommonCore import vtkStringArray - labels = vtkStringArray() labels.SetName(key) labels.SetNumberOfValues(len(value)) - for v in value: - labels.SetValue(v) + for i,v in enumerate(value): + labels.SetValue(i,v) field_data.AddArray(labels) continue diff --git a/src/plaid/utils/predict_client.py b/src/plaid/utils/predict_client.py index a3c10845..d9e67960 100644 --- a/src/plaid/utils/predict_client.py +++ b/src/plaid/utils/predict_client.py @@ -25,6 +25,7 @@ def __init__(self, host, port): "health": "/health", "predict": "/predict", "problem_definition": "/problem_definition", + "infos": "/infos", "samples": "/samples", } self.protocol = "http" @@ -89,6 +90,15 @@ def problem_definition(self): """ return self._request_json("problem_definition", {}) + + def infos(self): + """Get the infos from the server. + + Returns: + A dictionary containing the infos as provided by the server. + """ + return self._request_json("infos", {}) + def samples(self, sample_ids: list[int], split: str) -> list[Sample]: """Request samples from the server by sample IDs and split. diff --git a/src/plaid/utils/sample_json.py b/src/plaid/utils/sample_json.py index c8bae7d5..b87bd951 100644 --- a/src/plaid/utils/sample_json.py +++ b/src/plaid/utils/sample_json.py @@ -68,7 +68,7 @@ def sample_from_json_payload(payload: dict[str, Any]) -> Sample: from ..containers.sample import Sample - sample = Sample(path=None) + sample = Sample() for entry in trees: if not isinstance(entry, dict): raise ValueError("Each Sample JSON tree entry must be a dictionary") From 9c16793297c50a36a7c0539ff4375e7fe3839cf0 Mon Sep 17 00:00:00 2001 From: Felipe Bordeu Date: Wed, 10 Jun 2026 17:22:53 +0930 Subject: [PATCH 22/35] fix tests --- tests/cli/test_serve.py | 38 +++++++++++++++++++++++------- tests/utils/test_cgns_vtk.py | 3 +-- tests/utils/test_predict_client.py | 3 ++- 3 files changed, 32 insertions(+), 12 deletions(-) diff --git a/tests/cli/test_serve.py b/tests/cli/test_serve.py index 16bfa157..ad355480 100644 --- a/tests/cli/test_serve.py +++ b/tests/cli/test_serve.py @@ -6,7 +6,6 @@ import threading from collections.abc import Generator from http.client import HTTPConnection -from pathlib import Path from types import SimpleNamespace import pytest @@ -203,31 +202,52 @@ def test_post_unknown_route_returns_not_found(serve_url: str) -> None: assert payload == {"POST error": "Not Found"} -def test_post_infos_returns_dataset_infos(serve_url: str) -> None: +def test_post_infos_returns_dataset_infos(monkeypatch, serve_url: str) -> None: """POST infos route should load dataset infos from the provided dataset.""" - dataset = Path("datamain/PhysArena_Tensile2d") + monkeypatch.setattr( + ServeContext, + "get_infos", + staticmethod(lambda dataset_uri: {"dataset_uri": dataset_uri}), + ) - status, payload = _post_json(serve_url, "/infos", {"dataset": str(dataset)}) + status, payload = _post_json(serve_url, "/infos", {"dataset": "memory://dataset"}) assert status == 200 - assert payload["storage_backend"] == "hf_datasets" - assert payload["num_samples"] == {"OOD": 2, "test": 200, "train": 500} + assert payload == {"dataset_uri": "memory://dataset"} -def test_post_problem_definition_returns_selected_definition(serve_url: str) -> None: +def test_post_problem_definition_returns_selected_definition( + monkeypatch, + serve_url: str, +) -> None: """POST problem_definition route should load the requested definition.""" - dataset = Path("datamain/PhysArena_Tensile2d") + calls = [] + + def fake_get_problem_definition(dataset_uri, name=None): + calls.append({"dataset_uri": dataset_uri, "name": name}) + return { + "name": name, + "input_features": [], + "output_features": [], + } + + monkeypatch.setattr( + ServeContext, + "get_problem_definition", + staticmethod(fake_get_problem_definition), + ) status, payload = _post_json( serve_url, "/problem_definition", - {"dataset": str(dataset), "problem_definition": "regression_8"}, + {"dataset": "memory://dataset", "problem_definition": "regression_8"}, ) assert status == 200 assert payload["name"] == "regression_8" assert isinstance(payload["input_features"], list) assert isinstance(payload["output_features"], list) + assert calls == [{"dataset_uri": "memory://dataset", "name": "regression_8"}] def test_post_samples_rejects_missing_sample_ids(serve_url: str) -> None: diff --git a/tests/utils/test_cgns_vtk.py b/tests/utils/test_cgns_vtk.py index c03088ed..e8881402 100644 --- a/tests/utils/test_cgns_vtk.py +++ b/tests/utils/test_cgns_vtk.py @@ -141,8 +141,7 @@ def SetName(self, name): # noqa: N802 self.name = name def SetNumberOfValues(self, number_of_values): # noqa: N802 - _ = number_of_values - self.values = [] + self.values = [None]*number_of_values def SetValue(self, *args): # noqa: N802 if len(args) == 1: diff --git a/tests/utils/test_predict_client.py b/tests/utils/test_predict_client.py index 42687c9f..97f58612 100644 --- a/tests/utils/test_predict_client.py +++ b/tests/utils/test_predict_client.py @@ -43,6 +43,7 @@ def test_plaid_client_initializes_default_configuration(): assert client.endpoints == { "health": "/health", "predict": "/predict", + "infos": "/infos", "problem_definition": "/problem_definition", "samples": "/samples", } @@ -157,7 +158,7 @@ def test_samples_sends_selection_payload_and_decodes_samples( """Sample retrieval sends ids/split and reconstructs all returned samples.""" client = PlaidClient("localhost", 8000) first_sample = sample_with_tree.copy() - second_sample = Sample(path=None) + second_sample = Sample() calls = [] def fake_request_json(endpoint, payload): From bddceb2e2067b5e4bbef44e1b548ae57c6d1e1ef Mon Sep 17 00:00:00 2001 From: Felipe Bordeu Date: Wed, 10 Jun 2026 17:42:46 +0930 Subject: [PATCH 23/35] make ruff happy --- .../paraview_plugin/PlaidParaViewPlugin.py | 62 +++++++++++++------ src/plaid/cli/paraview_plugin/__init__.py | 6 +- src/plaid/utils/cgns_vtk.py | 26 ++++++++ tests/utils/test_predict_client.py | 16 +++++ 4 files changed, 91 insertions(+), 19 deletions(-) diff --git a/src/plaid/cli/paraview_plugin/PlaidParaViewPlugin.py b/src/plaid/cli/paraview_plugin/PlaidParaViewPlugin.py index e6edeebb..792dcd0e 100644 --- a/src/plaid/cli/paraview_plugin/PlaidParaViewPlugin.py +++ b/src/plaid/cli/paraview_plugin/PlaidParaViewPlugin.py @@ -1,11 +1,8 @@ -# -# This file is subject to the terms and conditions defined in -# file 'LICENSE.txt', which is part of this source code package. -# -# exec(open("C:/Users/User/paraview/ParaViewPlugin.py","r").read()) -# This file is intended to be used inside ParaView as a plugin -# compatible with ParaView 5.7+ +"""Plaid ParaView Plugin. +This file is intended to be used inside ParaView as a plugin +compatible with ParaView 5.11+ +""" import json import os import time @@ -14,6 +11,7 @@ import numpy as np import vtk + try: from paraview.util.vtkAlgorithm import ( VTKPythonAlgorithmBase, @@ -107,6 +105,7 @@ def _CleanCache(self): """) def SetSelectedSplit(self, value): + """Set the currently selected data split (e.g., 'training', 'validation', 'test').""" if self._selected_split != value: self._selected_split = value self.Modified() @@ -129,16 +128,18 @@ def GetSampleIdRange(self): @smproperty.stringvector(name="AvailableSplitsInfo", information_only="1") def GetAvailableSplits(self): + """Return a list of available data splits (e.g., 'training', 'validation', 'test') for the current dataset.""" info = self.GetInfos() if self._selected_split is None and len(info["num_samples"].keys()) : self.SetSelectedSplit(list(info["num_samples"])[0] ) - print_debug(f"GetAvailableSplits {list(info["num_samples"].keys())}") + print_debug(f"GetAvailableSplits {list(info['num_samples'].keys())}") return list(info["num_samples"].keys()) @smproperty.stringvector(name="ReadOnly", panel_visibility="default", information_only="1", repeatable="1", number_of_elements_per_command="2") def GetSomeTable(self): + """Return a table of information about the dataset, such as the number of samples in each split.""" info = self.GetInfos() - print_debug(f"GetSomeTable {['Split Name', 'Nb Samples']+[ [str(k),str(v)] for k, v in info["num_samples"].items() ]}") + print_debug(f"GetSomeTable {['Split Name', 'Nb Samples']+[ [str(k),str(v)] for k, v in info['num_samples'].items() ]}") return ['Split Name', 'Nb Samples']+[ [str(k),str(v)] for k, v in info["num_samples"].items() ] @smproperty.intvector(name="SampleId", default_values="0", panel_visibility="default", immediate_update="1") @@ -150,6 +151,7 @@ def GetSomeTable(self): """) def SetSampleId(self, value): + """Set the current sample ID to view, with bounds checking against the available sample range.""" value = int(value) max_value = self.GetSampleIdRange()[1] value = max(0, min(value, max_value)) @@ -159,7 +161,8 @@ def SetSampleId(self, value): self._sample_cache = None self.Modified() - def RequestInformation(self, request, in_info_vec, out_info_vec): + def RequestInformation(self, request, in_info_vec, out_info_vec): # noqa: ARG002 + """Provide time step information to ParaView based on the currently selected sample and split.""" executive = self.GetExecutive() out_info = out_info_vec.GetInformationObject(0) @@ -180,7 +183,8 @@ def RequestInformation(self, request, in_info_vec, out_info_vec): return 1 - def RequestData(self, request, in_info_vec, out_info_vec): + def RequestData(self, request, in_info_vec, out_info_vec): # noqa: ARG002 + """Fetch the CGNS tree for the currently requested time step and convert it to a VTK object for visualization.""" out_info = out_info_vec.GetInformationObject(0) executive = self.GetExecutive() @@ -211,7 +215,8 @@ def RequestData(self, request, in_info_vec, out_info_vec): information_only="1", ) def GetTimestepValues(self): - if (self._timestep_values_cache is None) and (self._selected_split is not '' and self._selected_split is not None ) and self.sample_id > -1: + """Return a list of available time steps for the currently selected sample and split, with caching.""" + if (self._timestep_values_cache is None) and (self._selected_split != '' and self._selected_split is not None ) and self.sample_id > -1: print_debug(f"{self._timestep_values_cache=}") print_debug(f"{self._selected_split=}{type(self._selected_split)}") print_debug(f"{self.sample_id=}{type(self.sample_id)}") @@ -226,6 +231,7 @@ def GetTimestepValues(self): class PlaidClientBase(PlaidDataSetBase): + """Base class for Plaid clients, providing common properties and methods for interacting with a server.""" def __init__( self, nInputPorts, @@ -235,8 +241,8 @@ def __init__( ): # Correctly initialize the underlying VTK C++ layer super().__init__( - nInputPorts=0, - nOutputPorts=1, + nInputPorts=nInputPorts, + nOutputPorts=nOutputPorts, inputType=inputType, outputType=outputType, ) @@ -249,6 +255,7 @@ def _CleanCache(self): @smproperty.stringvector(name="Host", default_values="127.0.0.1") def SetHost(self, value): + """Set the server host address to connect to for fetching dataset information and samples.""" value = str(value) if self.host != value: self.host = value @@ -258,6 +265,7 @@ def SetHost(self, value): name="Port", default_values=os.environ.get("PLAID_PORT", "8000") ) def SetPort(self, value): + """Set the server port to connect to for fetching dataset information and samples.""" value = int(value) if self.port != value: self.port = value @@ -279,12 +287,14 @@ def _request_json( return json.loads(response.read().decode("utf-8")) def GetProblemDefinition(self): + """Fetch the problem definition from the server, with caching.""" if self._problem_definition_cache is None: self._problem_definition_cache = self._request_json("/problem_definition") return self._problem_definition_cache def GetInfos(self): + """Fetch general information about the dataset from the server, with caching.""" if self._info_cache is None: self._info_cache = self._request_json("/infos") return self._info_cache @@ -307,10 +317,12 @@ def __init__(self): @smproperty.stringvector(name="Host", default_values="127.0.0.1") def SetHost(self, value): + """Set the server host address to connect to for fetching dataset information and samples.""" return super().SetHost(value) @smproperty.intvector(name="Port", default_values=os.environ.get("PLAID_PORT", "8000")) def SetPort(self, value): + """Set the server port to connect to for fetching dataset information and samples.""" return super().SetPort(value) @smproperty.stringvector(name="SelectSplit", default_values="", immediate_update="1") @@ -322,15 +334,18 @@ def SetPort(self, value): """) def SetSelectedSplit(self, value): + """Set the currently selected data split (e.g., 'training', 'validation', 'test').""" print_debug(f"SetSelectedSplit {value}") return super().SetSelectedSplit(value) @smproperty.intvector(name="SampleIdRangeInfo", information_only="1") def GetSampleIdRange(self): + """Return [min, max] bounds for the SampleId slider.""" return super().GetSampleIdRange() @smproperty.stringvector(name="AvailableSplitsInfo", information_only="1") def GetAvailableSplits(self): + """Return a list of available data splits (e.g., 'training', 'validation', 'test') for the current dataset.""" return super().GetAvailableSplits() @smproperty.doublevector( @@ -338,6 +353,7 @@ def GetAvailableSplits(self): information_only="1", ) def GetTimestepValues(self): + """Return a list of available time steps for the currently selected sample and split, with caching.""" return super().GetTimestepValues() # """ @@ -371,11 +387,13 @@ def GetTimestepValues(self): """) def SetSampleId(self, value): + """Set the current sample ID to view, with bounds checking against the available sample range.""" print_debug(f"SetSampleId {value}") return super().SetSampleId(value) @smproperty.stringvector(name="ReadOnly", panel_visibility="default", information_only="1", repeatable="1", number_of_elements_per_command="2") def GetSomeTable(self): + """Return a table of information about the dataset, such as the number of samples in each split.""" return super().GetSomeTable() @smproperty.xml(""" @@ -390,8 +408,7 @@ def GetSomeTable(self): """) def SetPredict(self, value): - """Set whether to use the /predict endpoint instead of /sample. This is a boolean property. - """ + """Set whether to use the /predict endpoint instead of /sample. This is a boolean property.""" bool_value = str(value).lower() in ["true", "1"] if self.usePredict != bool_value: self.usePredict = bool_value @@ -434,7 +451,7 @@ def GetSampleData(self): # paraview.servermanager.LoadPlugin("/home/fbw/repos/Safran/plaid/src/plaid/cli/paraview_plugin/PlaidParaViewPlugin.py") try: # try to load the reader if plaid is locally available - from plaid.storage.reader import load_infos_from_disk, init_from_disk + from plaid.storage.reader import init_from_disk, load_infos_from_disk # """) def SetSelectedSplit(self, value): + """Set the currently selected data split (e.g., 'training', 'validation', 'test').""" return super().SetSelectedSplit(value) @smproperty.intvector(name="SampleIdRangeInfo", information_only="1") def GetSampleIdRange(self): + """Return [min, max] bounds for the SampleId slider.""" return super().GetSampleIdRange() @smproperty.stringvector(name="AvailableSplitsInfo", information_only="1") def GetAvailableSplits(self): + """Return a list of available data splits (e.g., 'training', 'validation', 'test') for the current dataset.""" return super().GetAvailableSplits() @smproperty.intvector(name="SampleId", default_values="0", immediate_update="1") @@ -509,10 +530,12 @@ def GetAvailableSplits(self): """) def SetSampleId(self, value): + """Set the current sample ID to view, with bounds checking against the available sample range.""" return super().SetSampleId(value) @smproperty.stringvector(name="ReadOnly", panel_visibility="default", information_only="1", repeatable="1", number_of_elements_per_command="2") def GetSomeTable(self): + """Return a table of information about the dataset, such as the number of samples in each split.""" return super().GetSomeTable() @smproperty.doublevector( @@ -520,9 +543,11 @@ def GetSomeTable(self): information_only="1", ) def GetTimestepValues(self): + """Return a list of available time steps for the currently selected sample and split, with caching.""" return super().GetTimestepValues() def GetInfos(self): + """Fetch general information about the dataset from disk, with caching.""" if self._info_cache is not None : return self._info_cache @@ -535,6 +560,7 @@ def GetInfos(self): return self._info_cache def GetSampleData(self) -> dict[float,list]: + """Fetch sample data for the currently selected split and sample ID from disk, with caching.""" if self._sample_cache is None: if self.datasetdict_cache is None: self.datasetdict_cache, self.converterdict_cache = init_from_disk(self._filename) @@ -549,4 +575,4 @@ def GetSampleData(self) -> dict[float,list]: except ImportError as exc: print_debug(f"PlaidDatasetReader not loaded because optional plaid.storage.reader import failed: {exc}") -print_debug("Plaid ParaView Plugin Loaded") \ No newline at end of file +print_debug("Plaid ParaView Plugin Loaded") diff --git a/src/plaid/cli/paraview_plugin/__init__.py b/src/plaid/cli/paraview_plugin/__init__.py index 696cdac7..cfd17d60 100644 --- a/src/plaid/cli/paraview_plugin/__init__.py +++ b/src/plaid/cli/paraview_plugin/__init__.py @@ -2,8 +2,8 @@ import os import subprocess -from pathlib import Path import tempfile +from pathlib import Path paraview_exec = "paraview" @@ -12,7 +12,11 @@ def get_ParaView_plugin_path(): return Path(__file__).parent def get_ParaView_plugin_path_one_file(): + """Returns the path to a temporary directory containing the plugin as a single file. + all the helper module are included in the plugin file to make it self contained and + avoid the need of copying multiple file in the temporary directory. + """ plugin_path = Path(__file__).parent / "PlaidParaViewPlugin.py" with open(plugin_path, "r") as f: plugin_content = f.read() diff --git a/src/plaid/utils/cgns_vtk.py b/src/plaid/utils/cgns_vtk.py index 315a9b24..e19f625a 100644 --- a/src/plaid/utils/cgns_vtk.py +++ b/src/plaid/utils/cgns_vtk.py @@ -1,3 +1,9 @@ +"""Direct CGNS -> VTK conversion functions. + +This fuction do not require any class of plaid. +Please keep this module free of plaid dependencies to make it usable in +the ParaView plugin without forcing users to install plaid. +""" from typing import Any, List, Optional import numpy as np @@ -279,6 +285,15 @@ def _cgns_unstructured_zone_to_vtk(zoneNode: list, physicalDim: int): return output def CGNSBaseExtractGlobals(baseNode: list) -> dict: + """Extract global fields from a CGNSBase_t node as a dictionary of name -> numpy array. + + Arguments: + baseNode (list): CGNS ``CGNSBase_t`` node. + + Returns: + dict: A dictionary mapping field names to numpy arrays. + + """ globals = {} for xNode in baseNode[2]: if xNode[1] is not None: @@ -286,6 +301,17 @@ def CGNSBaseExtractGlobals(baseNode: list) -> dict: return globals def CGNSTreeToVtk(treeNode: list): + """Convert a full CGNS tree to VTK objects, one per base, and return either the single base or a multi-block of bases. + + Arguments: + treeNode (list): CGNS tree as read by cgns_tree_from_json_payload. + + Returns: + vtkStructuredGrid, vtkUnstructuredGrid, or vtkMultiBlockDataSet: the VTK + representation of the tree. A single-zone base returns the zone object; + a multi-zone base returns one block per zone; a multi-base tree returns + one block per base. + """ _, _, _, _, vtkMultiBlockDataSet, numpy_support = _import_vtk_for_direct_cgns() bases = _cgns_children_by_label(treeNode, "CGNSBase_t") diff --git a/tests/utils/test_predict_client.py b/tests/utils/test_predict_client.py index 97f58612..7985a65b 100644 --- a/tests/utils/test_predict_client.py +++ b/tests/utils/test_predict_client.py @@ -152,6 +152,22 @@ def fake_request_json(endpoint, payload): assert calls == [("problem_definition", {})] +def test_infos_requests_infos_endpoint(monkeypatch): + """Infos requests are delegated to their configured endpoint.""" + client = PlaidClient("localhost", 8000) + expected = {"name": "dataset", "num_samples": {"train": 3}} + calls = [] + + def fake_request_json(endpoint, payload): + calls.append((endpoint, payload)) + return expected + + monkeypatch.setattr(client, "_request_json", fake_request_json) + + assert client.infos() == expected + assert calls == [("infos", {})] + + def test_samples_sends_selection_payload_and_decodes_samples( monkeypatch, sample_with_tree ): From df528cf991c79d1ba2f5b3a3092bcd2b4393e919 Mon Sep 17 00:00:00 2001 From: Felipe Bordeu Date: Wed, 10 Jun 2026 18:03:27 +0930 Subject: [PATCH 24/35] format --- .../paraview_plugin/PlaidParaViewPlugin.py | 176 +++++++++++------- src/plaid/cli/paraview_plugin/__init__.py | 12 +- src/plaid/cli/plaidcheck.py | 4 +- src/plaid/utils/cgns_vtk.py | 163 +++++++++++----- src/plaid/utils/predict_client.py | 1 - tests/utils/test_cgns_vtk.py | 2 +- 6 files changed, 245 insertions(+), 113 deletions(-) diff --git a/src/plaid/cli/paraview_plugin/PlaidParaViewPlugin.py b/src/plaid/cli/paraview_plugin/PlaidParaViewPlugin.py index 792dcd0e..a9a50e8c 100644 --- a/src/plaid/cli/paraview_plugin/PlaidParaViewPlugin.py +++ b/src/plaid/cli/paraview_plugin/PlaidParaViewPlugin.py @@ -3,6 +3,7 @@ This file is intended to be used inside ParaView as a plugin compatible with ParaView 5.11+ """ + import json import os import time @@ -32,8 +33,8 @@ # get_ParaView_plugin_path_one_file pass -#this line is to inlcude the import to make the plugin selfcontain -#do not modify the next line (see file function get_ParaView_plugin_path_one_file for the use case) +# this line is to inlcude the import to make the plugin selfcontain +# do not modify the next line (see file function get_ParaView_plugin_path_one_file for the use case) # ##INCLUDE PLACEHOLDER## ## utility funcitons @@ -54,6 +55,7 @@ def print_debug(message: str) -> None: paraview_plugin_name = "Plaid ParaView Plugin" paraview_plugin_version = "5.11.1" + def find_closest_numpy(arr, target): """Find the value in arr that is closest to the target using numpy.""" # Convert input to array if it isn't one @@ -66,6 +68,7 @@ def find_closest_numpy(arr, target): class PlaidDataSetBase(VTKPythonAlgorithmBase): """Base class for Plaid dataset readers and clients, providing common properties and caching logic.""" + def __init__( self, nInputPorts, @@ -83,10 +86,10 @@ def __init__( self.sample_id: int = 0 self._selected_split: str = "" - self._info_cache : Optional[dict]= None - self._problem_definition_cache : Optional[dict]= None + self._info_cache: Optional[dict] = None + self._problem_definition_cache: Optional[dict] = None self._timestep_values_cache = None - self._sample_cache : Optional[dict] = None + self._sample_cache: Optional[dict] = None def _CleanCache(self): self._info_cache = None @@ -96,7 +99,12 @@ def _CleanCache(self): self._sample_cache = None self.Modified() - @smproperty.stringvector(name="SelectSplit", default_values="", panel_visibility="default", immediate_update="1") + @smproperty.stringvector( + name="SelectSplit", + default_values="", + panel_visibility="default", + immediate_update="1", + ) @smdomain.xml(""" @@ -109,13 +117,18 @@ def SetSelectedSplit(self, value): if self._selected_split != value: self._selected_split = value self.Modified() - if isinstance(self._selected_split,str): + if isinstance(self._selected_split, str): max_sample_id = self.GetSampleIdRange()[1] self.sample_id = max(0, min(self.sample_id, max_sample_id)) self._sample_cache = None self.Modified() - @smproperty.intvector(name="SampleIdRangeInfo", information_only="1", panel_visibility="default", immediate_update="1") + @smproperty.intvector( + name="SampleIdRangeInfo", + information_only="1", + panel_visibility="default", + immediate_update="1", + ) def GetSampleIdRange(self): """Return [min, max] bounds for the SampleId slider.""" infos = self.GetInfos() @@ -125,31 +138,46 @@ def GetSampleIdRange(self): print_debug(f"GetSampleIdRange {(0, max(0, num_samples - 1))}") return (0, max(0, num_samples - 1)) - @smproperty.stringvector(name="AvailableSplitsInfo", information_only="1") def GetAvailableSplits(self): """Return a list of available data splits (e.g., 'training', 'validation', 'test') for the current dataset.""" info = self.GetInfos() - if self._selected_split is None and len(info["num_samples"].keys()) : - self.SetSelectedSplit(list(info["num_samples"])[0] ) + if self._selected_split is None and len(info["num_samples"].keys()): + self.SetSelectedSplit(list(info["num_samples"])[0]) print_debug(f"GetAvailableSplits {list(info['num_samples'].keys())}") return list(info["num_samples"].keys()) - @smproperty.stringvector(name="ReadOnly", panel_visibility="default", information_only="1", repeatable="1", number_of_elements_per_command="2") + @smproperty.stringvector( + name="ReadOnly", + panel_visibility="default", + information_only="1", + repeatable="1", + number_of_elements_per_command="2", + ) def GetSomeTable(self): """Return a table of information about the dataset, such as the number of samples in each split.""" info = self.GetInfos() - print_debug(f"GetSomeTable {['Split Name', 'Nb Samples']+[ [str(k),str(v)] for k, v in info['num_samples'].items() ]}") - return ['Split Name', 'Nb Samples']+[ [str(k),str(v)] for k, v in info["num_samples"].items() ] + print_debug( + f"GetSomeTable {['Split Name', 'Nb Samples'] + [[str(k), str(v)] for k, v in info['num_samples'].items()]}" + ) + return ["Split Name", "Nb Samples"] + [ + [str(k), str(v)] for k, v in info["num_samples"].items() + ] - @smproperty.intvector(name="SampleId", default_values="0", panel_visibility="default", immediate_update="1") - @smdomain.xml(\ + @smproperty.intvector( + name="SampleId", + default_values="0", + panel_visibility="default", + immediate_update="1", + ) + @smdomain.xml( """ - """) + """ + ) def SetSampleId(self, value): """Set the current sample ID to view, with bounds checking against the available sample range.""" value = int(value) @@ -182,7 +210,6 @@ def RequestInformation(self, request, in_info_vec, out_info_vec): # noqa: ARG00 print_debug(f" end RequestInformation-----------------------------{time_steps}") return 1 - def RequestData(self, request, in_info_vec, out_info_vec): # noqa: ARG002 """Fetch the CGNS tree for the currently requested time step and convert it to a VTK object for visualization.""" out_info = out_info_vec.GetInformationObject(0) @@ -191,8 +218,8 @@ def RequestData(self, request, in_info_vec, out_info_vec): # noqa: ARG002 if out_info.Has(executive.UPDATE_TIME_STEP()): requested_time = float(out_info.Get(executive.UPDATE_TIME_STEP())) else: - #values = self.GetTimestepValues() - #requested_time = float(values[0]) if values else 0.0 + # values = self.GetTimestepValues() + # requested_time = float(values[0]) if values else 0.0 requested_time = 0.0 sample_data = self.GetSampleData() @@ -200,11 +227,12 @@ def RequestData(self, request, in_info_vec, out_info_vec): # noqa: ARG002 if sample_data == "None": return 1 - - requested_time = find_closest_numpy(np.array(list(sample_data.keys())), requested_time) + requested_time = find_closest_numpy( + np.array(list(sample_data.keys())), requested_time + ) cgnstree = sample_data[requested_time] - new_output= CGNSTreeToVtk(cgnstree) + new_output = CGNSTreeToVtk(cgnstree) info = out_info_vec.GetInformationObject(0) info.Set(vtk.vtkDataObject.DATA_OBJECT(), new_output) @@ -216,7 +244,11 @@ def RequestData(self, request, in_info_vec, out_info_vec): # noqa: ARG002 ) def GetTimestepValues(self): """Return a list of available time steps for the currently selected sample and split, with caching.""" - if (self._timestep_values_cache is None) and (self._selected_split != '' and self._selected_split is not None ) and self.sample_id > -1: + if ( + (self._timestep_values_cache is None) + and (self._selected_split != "" and self._selected_split is not None) + and self.sample_id > -1 + ): print_debug(f"{self._timestep_values_cache=}") print_debug(f"{self._selected_split=}{type(self._selected_split)}") print_debug(f"{self.sample_id=}{type(self.sample_id)}") @@ -232,6 +264,7 @@ def GetTimestepValues(self): class PlaidClientBase(PlaidDataSetBase): """Base class for Plaid clients, providing common properties and methods for interacting with a server.""" + def __init__( self, nInputPorts, @@ -299,17 +332,16 @@ def GetInfos(self): self._info_cache = self._request_json("/infos") return self._info_cache + print_debug("Loading MaestroExplorer") + @smproxy.source(name="MaestroExplorer", label="Maestro Explorer") class MaestroExplorer(PlaidClientBase): """ParaView source plugin fetching data from Maestro serve endpoints.""" def __init__(self): - super().__init__( - nInputPorts=0, - nOutputPorts=1 - ) + super().__init__(nInputPorts=0, nOutputPorts=1) self.timestep_values_cache: list[float] | None = None self.usePredict: bool = False @@ -320,12 +352,16 @@ def SetHost(self, value): """Set the server host address to connect to for fetching dataset information and samples.""" return super().SetHost(value) - @smproperty.intvector(name="Port", default_values=os.environ.get("PLAID_PORT", "8000")) + @smproperty.intvector( + name="Port", default_values=os.environ.get("PLAID_PORT", "8000") + ) def SetPort(self, value): """Set the server port to connect to for fetching dataset information and samples.""" return super().SetPort(value) - @smproperty.stringvector(name="SelectSplit", default_values="", immediate_update="1") + @smproperty.stringvector( + name="SelectSplit", default_values="", immediate_update="1" + ) @smdomain.xml(""" @@ -377,21 +413,27 @@ def GetTimestepValues(self): # # """) - @smproperty.intvector(name="SampleId", default_values="0", immediate_update="1") - @smdomain.xml(\ + @smdomain.xml( """ - """) + """ + ) def SetSampleId(self, value): """Set the current sample ID to view, with bounds checking against the available sample range.""" print_debug(f"SetSampleId {value}") return super().SetSampleId(value) - @smproperty.stringvector(name="ReadOnly", panel_visibility="default", information_only="1", repeatable="1", number_of_elements_per_command="2") + @smproperty.stringvector( + name="ReadOnly", + panel_visibility="default", + information_only="1", + repeatable="1", + number_of_elements_per_command="2", + ) def GetSomeTable(self): """Return a table of information about the dataset, such as the number of samples in each split.""" return super().GetSomeTable() @@ -442,43 +484,35 @@ def GetSampleData(self): return self._sample_cache - - - - - - # paraview.servermanager.LoadPlugin("/home/fbw/repos/Safran/plaid/src/plaid/cli/paraview_plugin/PlaidParaViewPlugin.py") try: # try to load the reader if plaid is locally available from plaid.storage.reader import init_from_disk, load_infos_from_disk - - # - #> + # + # > @smproxy.reader( name="PlaidDatasetReader", label="Plaid Dataset Reader", file_description="Directory ", is_directory="True", - filename_patterns="*" + filename_patterns="*", ) class PlaidDataSetReader(PlaidDataSetBase): """ParaView reader plugin for reading Plaid datasets from disk.""" + def __init__(self): super().__init__( nInputPorts=0, nOutputPorts=1, outputType="vtkUnstructuredGrid" ) - self._filename: Optional[str] = '' + self._filename: Optional[str] = "" self.datasetdict_cache = None self.converterdict_cache = None - - def _CleanCache(self): super()._CleanCache() self.datasetdict_cache = None @@ -497,8 +531,6 @@ def SetFileName(self, name): self._filename = name self._CleanCache() - - @smproperty.stringvector(name="SelectSplit", default_values="") @smdomain.xml(""" @@ -522,18 +554,25 @@ def GetAvailableSplits(self): return super().GetAvailableSplits() @smproperty.intvector(name="SampleId", default_values="0", immediate_update="1") - @smdomain.xml(\ - """ + @smdomain.xml( + """ - """) + """ + ) def SetSampleId(self, value): """Set the current sample ID to view, with bounds checking against the available sample range.""" return super().SetSampleId(value) - @smproperty.stringvector(name="ReadOnly", panel_visibility="default", information_only="1", repeatable="1", number_of_elements_per_command="2") + @smproperty.stringvector( + name="ReadOnly", + panel_visibility="default", + information_only="1", + repeatable="1", + number_of_elements_per_command="2", + ) def GetSomeTable(self): """Return a table of information about the dataset, such as the number of samples in each split.""" return super().GetSomeTable() @@ -548,31 +587,36 @@ def GetTimestepValues(self): def GetInfos(self): """Fetch general information about the dataset from disk, with caching.""" - if self._info_cache is not None : + if self._info_cache is not None: return self._info_cache - if (self._info_cache is None) and (self._filename is not None and self._filename != "None" ): + if (self._info_cache is None) and ( + self._filename is not None and self._filename != "None" + ): self._info_cache = load_infos_from_disk(self._filename).model_dump() self.Modified() else: - return {"num_samples":{}} + return {"num_samples": {}} return self._info_cache - def GetSampleData(self) -> dict[float,list]: + def GetSampleData(self) -> dict[float, list]: """Fetch sample data for the currently selected split and sample ID from disk, with caching.""" if self._sample_cache is None: if self.datasetdict_cache is None: - self.datasetdict_cache, self.converterdict_cache = init_from_disk(self._filename) + self.datasetdict_cache, self.converterdict_cache = init_from_disk( + self._filename + ) - self._sample_cache = self.converterdict_cache[self._selected_split].to_plaid(self.datasetdict_cache[self._selected_split], self.sample_id) + self._sample_cache = self.converterdict_cache[ + self._selected_split + ].to_plaid(self.datasetdict_cache[self._selected_split], self.sample_id) return self._sample_cache.data - - - print_debug("Reader PlaidSampleReader Loaded") except ImportError as exc: - print_debug(f"PlaidDatasetReader not loaded because optional plaid.storage.reader import failed: {exc}") + print_debug( + f"PlaidDatasetReader not loaded because optional plaid.storage.reader import failed: {exc}" + ) print_debug("Plaid ParaView Plugin Loaded") diff --git a/src/plaid/cli/paraview_plugin/__init__.py b/src/plaid/cli/paraview_plugin/__init__.py index cfd17d60..08da7532 100644 --- a/src/plaid/cli/paraview_plugin/__init__.py +++ b/src/plaid/cli/paraview_plugin/__init__.py @@ -7,10 +7,12 @@ paraview_exec = "paraview" + def get_ParaView_plugin_path(): """Returns the path to the ParaView plugin directory.""" return Path(__file__).parent + def get_ParaView_plugin_path_one_file(): """Returns the path to a temporary directory containing the plugin as a single file. @@ -22,15 +24,19 @@ def get_ParaView_plugin_path_one_file(): plugin_content = f.read() import plaid.utils.cgns_json as cgns_json + with open(Path(cgns_json.__file__), "r") as f: sample_json_content = f.read() import plaid.utils.cgns_vtk as cgns_vtk + with open(Path(cgns_vtk.__file__), "r") as f: cgns_vtk_content = f.read() - plugin_content = plugin_content.replace("# ##INCLUDE PLACEHOLDER##",sample_json_content + cgns_vtk_content) - plugin_content = plugin_content.replace("from __future__ import annotations","") + plugin_content = plugin_content.replace( + "# ##INCLUDE PLACEHOLDER##", sample_json_content + cgns_vtk_content + ) + plugin_content = plugin_content.replace("from __future__ import annotations", "") tmpdir = tempfile.mkdtemp() file_path = os.path.join(tmpdir, "PlaidParaViewPlugin.py") @@ -40,6 +46,7 @@ def get_ParaView_plugin_path_one_file(): f.write(plugin_content) return tmpdir + def convert_wsl_to_win(wsl_path: str) -> str: r"""Converts a WSL path (e.g., /mnt/c/Users) to Windows (C:\\Users).""" result = subprocess.run( @@ -47,6 +54,7 @@ def convert_wsl_to_win(wsl_path: str) -> str: ) return result.stdout.strip() + def run_paraview_with_plugin(): """Launches ParaView with environment variables set to load the plugin.""" my_env = os.environ.copy() diff --git a/src/plaid/cli/plaidcheck.py b/src/plaid/cli/plaidcheck.py index d1772f8d..700fa770 100644 --- a/src/plaid/cli/plaidcheck.py +++ b/src/plaid/cli/plaidcheck.py @@ -136,7 +136,9 @@ def _check_required_layout( for rel in required_paths: p = path / rel if not p.exists(): - report.add("error", "MISSING_PATH", rel, f"Missing file/path path: {rel}") # pragma: no cover + report.add( + "error", "MISSING_PATH", rel, f"Missing file/path path: {rel}" + ) # pragma: no cover def _check_numeric_content(value: Any) -> Optional[str]: diff --git a/src/plaid/utils/cgns_vtk.py b/src/plaid/utils/cgns_vtk.py index e19f625a..2f2bfcee 100644 --- a/src/plaid/utils/cgns_vtk.py +++ b/src/plaid/utils/cgns_vtk.py @@ -4,6 +4,7 @@ Please keep this module free of plaid dependencies to make it usable in the ParaView plugin without forcing users to install plaid. """ + from typing import Any, List, Optional import numpy as np @@ -11,14 +12,14 @@ # Direct CGNS -> VTK conversion tables. These maps deliberately use only CGNS # element numbers and VTK cell numbers so the converter below does not depend on CGNSNumberToVtkNumber = { - 2: 1, # NODE -> VTK_VERTEX - 3: 3, # BAR_2 -> VTK_LINE - 4: 21, # BAR_3 -> VTK_QUADRATIC_EDGE - 5: 5, # TRI_3 -> VTK_TRIANGLE - 6: 22, # TRI_6 -> VTK_QUADRATIC_TRIANGLE - 7: 9, # QUAD_4 -> VTK_QUAD - 8: 23, # QUAD_8 -> VTK_QUADRATIC_QUAD - 9: 28, # QUAD_9 -> VTK_BIQUADRATIC_QUAD + 2: 1, # NODE -> VTK_VERTEX + 3: 3, # BAR_2 -> VTK_LINE + 4: 21, # BAR_3 -> VTK_QUADRATIC_EDGE + 5: 5, # TRI_3 -> VTK_TRIANGLE + 6: 22, # TRI_6 -> VTK_QUADRATIC_TRIANGLE + 7: 9, # QUAD_4 -> VTK_QUAD + 8: 23, # QUAD_8 -> VTK_QUADRATIC_QUAD + 9: 28, # QUAD_9 -> VTK_BIQUADRATIC_QUAD 10: 10, # TETRA_4 -> VTK_TETRA 11: 24, # TETRA_10 -> VTK_QUADRATIC_TETRA 12: 14, # PYRA_5 -> VTK_PYRAMID @@ -57,14 +58,41 @@ # used here. The entries below cover the higher-order cells for which Muscat's # CGNS bridge already documents an ordering difference and VTK supports the cell. CGNSNumberToVtkPermutation = { - 15: [0, 1, 2, 3, 4, 5, 6, 7, 8, 12, 13, 14, 9, 10, 11], - 16: [0, 1, 2, 3, 4, 5, 6, 7, 8, 12, 13, 14, 9, 10, 11, 15, 16, 17], - 18: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 16, 17, 18, 19, 12, 13, 14, 15], - 19: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 16, 17, 18, 19, 12, 13, 14, 15, 24, 22, 21, 23, 20, 25, 26] + 15: [0, 1, 2, 3, 4, 5, 6, 7, 8, 12, 13, 14, 9, 10, 11], + 16: [0, 1, 2, 3, 4, 5, 6, 7, 8, 12, 13, 14, 9, 10, 11, 15, 16, 17], + 18: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 16, 17, 18, 19, 12, 13, 14, 15], + 19: [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 16, + 17, + 18, + 19, + 12, + 13, + 14, + 15, + 24, + 22, + 21, + 23, + 20, + 25, + 26, + ], } - def _cgns_children_by_label(node: list, label: str) -> List[list]: """Return direct children of a CGNS/Python node matching a label.""" return [child for child in node[2] if len(child) > 3 and child[3] == label] @@ -87,7 +115,11 @@ def _cgns_value_as_string(node: Optional[list]) -> Optional[str]: return value array = np.asarray(value) if array.dtype.kind in ["S", "U"]: - return b"".join(np.asarray(array, dtype="|S1").ravel(order="F").tolist()).decode("ascii", errors="ignore").strip("\x00 ") + return ( + b"".join(np.asarray(array, dtype="|S1").ravel(order="F").tolist()) + .decode("ascii", errors="ignore") + .strip("\x00 ") + ) return str(value) @@ -111,10 +143,19 @@ def _import_vtk_for_direct_cgns(): vtkStructuredGrid, vtkUnstructuredGrid, ) - return vtkStructuredGrid, vtkUnstructuredGrid, vtkPoints, vtkCellArray, vtkMultiBlockDataSet, numpy_support - - -def _cgns_zone_points_to_vtk_points(zoneNode: list, physicalDim: int, numpy_support, vtkPoints): + return ( + vtkStructuredGrid, + vtkUnstructuredGrid, + vtkPoints, + vtkCellArray, + vtkMultiBlockDataSet, + numpy_support, + ) + + +def _cgns_zone_points_to_vtk_points( + zoneNode: list, physicalDim: int, numpy_support, vtkPoints +): """Read GridCoordinates_t from one CGNS zone and return vtkPoints plus coordinate shape.""" gridCoordinatesNodes = _cgns_children_by_label(zoneNode, "GridCoordinates_t") if not gridCoordinatesNodes: @@ -144,7 +185,9 @@ def _cgns_zone_points_to_vtk_points(zoneNode: list, physicalDim: int, numpy_supp return points, x.shape -def _cgns_add_numpy_array_to_vtk_attributes(attributes, name: str, data: np.ndarray, numberOfTuples: int, numpy_support) -> bool: +def _cgns_add_numpy_array_to_vtk_attributes( + attributes, name: str, data: np.ndarray, numberOfTuples: int, numpy_support +) -> bool: """Add one numeric CGNS DataArray_t value to VTK attributes if its size is compatible.""" array = np.asarray(data) if array.dtype.kind in ["S", "U", "O"] or numberOfTuples <= 0: @@ -158,7 +201,9 @@ def _cgns_add_numpy_array_to_vtk_attributes(attributes, name: str, data: np.ndar if numberOfComponents == 1: vtkArray = numpy_support.numpy_to_vtk(flat, deep=True) else: - vtkArray = numpy_support.numpy_to_vtk(flat.reshape((numberOfTuples, numberOfComponents)), deep=True) + vtkArray = numpy_support.numpy_to_vtk( + flat.reshape((numberOfTuples, numberOfComponents)), deep=True + ) vtkArray.SetNumberOfComponents(numberOfComponents) vtkArray.SetName(name) attributes.AddArray(vtkArray) @@ -189,7 +234,9 @@ def _cgns_add_flow_solutions_to_vtk(zoneNode: list, vtkObject, numpy_support) -> for dataNode in _cgns_children_by_label(flow, "DataArray_t"): if dataNode[1] is None: continue - _cgns_add_numpy_array_to_vtk_attributes(attributes, dataNode[0], dataNode[1], numberOfTuples, numpy_support) + _cgns_add_numpy_array_to_vtk_attributes( + attributes, dataNode[0], dataNode[1], numberOfTuples, numpy_support + ) def _cgns_element_connectivity_node(elementsNode: list) -> Optional[list]: @@ -203,7 +250,9 @@ def _cgns_element_connectivity_node(elementsNode: list) -> Optional[list]: return None -def _cgns_insert_cells_from_elements_node(elementsNode: list, cellTypes: list, offsets: list, connectivity: list) -> None: +def _cgns_insert_cells_from_elements_node( + elementsNode: list, cellTypes: list, offsets: list, connectivity: list +) -> None: """Append VTK cell type/connectivity data from one CGNS Elements_t node.""" cgnsElementType = int(np.asarray(elementsNode[1]).ravel()[0]) connectivityNode = _cgns_element_connectivity_node(elementsNode) @@ -216,10 +265,15 @@ def _cgns_insert_cells_from_elements_node(elementsNode: list, cellTypes: list, o while cursor < cgnsConnectivity.size: localCgnsType = int(cgnsConnectivity[cursor]) cursor += 1 - if localCgnsType not in CGNSNumberToVtkNumber or localCgnsType not in CGNSNumberOfNodes: - raise NotImplementedError(f"CGNS element type {localCgnsType} is not supported by direct VTK conversion") + if ( + localCgnsType not in CGNSNumberToVtkNumber + or localCgnsType not in CGNSNumberOfNodes + ): + raise NotImplementedError( + f"CGNS element type {localCgnsType} is not supported by direct VTK conversion" + ) numberOfNodes = CGNSNumberOfNodes[localCgnsType] - localConnectivity = cgnsConnectivity[cursor:cursor + numberOfNodes] - 1 + localConnectivity = cgnsConnectivity[cursor : cursor + numberOfNodes] - 1 cursor += numberOfNodes permutation = CGNSNumberToVtkPermutation.get(localCgnsType, None) if permutation is not None: @@ -229,8 +283,13 @@ def _cgns_insert_cells_from_elements_node(elementsNode: list, cellTypes: list, o connectivity.extend(localConnectivity.tolist()) return - if cgnsElementType not in CGNSNumberToVtkNumber or cgnsElementType not in CGNSNumberOfNodes: - raise NotImplementedError(f"CGNS element type {cgnsElementType} is not supported by direct VTK conversion") + if ( + cgnsElementType not in CGNSNumberToVtkNumber + or cgnsElementType not in CGNSNumberOfNodes + ): + raise NotImplementedError( + f"CGNS element type {cgnsElementType} is not supported by direct VTK conversion" + ) numberOfNodes = CGNSNumberOfNodes[cgnsElementType] localConnectivity = cgnsConnectivity.reshape((-1, numberOfNodes)) - 1 @@ -249,7 +308,9 @@ def _cgns_structured_zone_to_vtk(zoneNode: list, physicalDim: int): """Convert one CGNS structured Zone_t node directly to vtkStructuredGrid.""" vtkStructuredGrid, _, vtkPoints, _, _, numpy_support = _import_vtk_for_direct_cgns() output = vtkStructuredGrid() - points, _ = _cgns_zone_points_to_vtk_points(zoneNode, physicalDim, numpy_support, vtkPoints) + points, _ = _cgns_zone_points_to_vtk_points( + zoneNode, physicalDim, numpy_support, vtkPoints + ) output.SetPoints(points) zsize = np.asarray(zoneNode[1]) @@ -263,20 +324,30 @@ def _cgns_structured_zone_to_vtk(zoneNode: list, physicalDim: int): def _cgns_unstructured_zone_to_vtk(zoneNode: list, physicalDim: int): """Convert one CGNS unstructured Zone_t node directly to vtkUnstructuredGrid.""" - _, vtkUnstructuredGrid, vtkPoints, vtkCellArray, _, numpy_support = _import_vtk_for_direct_cgns() + _, vtkUnstructuredGrid, vtkPoints, vtkCellArray, _, numpy_support = ( + _import_vtk_for_direct_cgns() + ) output = vtkUnstructuredGrid() - points, _ = _cgns_zone_points_to_vtk_points(zoneNode, physicalDim, numpy_support, vtkPoints) + points, _ = _cgns_zone_points_to_vtk_points( + zoneNode, physicalDim, numpy_support, vtkPoints + ) output.SetPoints(points) cellTypes = [] offsets = [0] connectivity = [] for elementsNode in _cgns_children_by_label(zoneNode, "Elements_t"): - _cgns_insert_cells_from_elements_node(elementsNode, cellTypes, offsets, connectivity) + _cgns_insert_cells_from_elements_node( + elementsNode, cellTypes, offsets, connectivity + ) if cellTypes: - vtkOffsets = numpy_support.numpy_to_vtkIdTypeArray(np.asarray(offsets, dtype=np.int64), deep=True) - vtkConnectivity = numpy_support.numpy_to_vtkIdTypeArray(np.asarray(connectivity, dtype=np.int64), deep=True) + vtkOffsets = numpy_support.numpy_to_vtkIdTypeArray( + np.asarray(offsets, dtype=np.int64), deep=True + ) + vtkConnectivity = numpy_support.numpy_to_vtkIdTypeArray( + np.asarray(connectivity, dtype=np.int64), deep=True + ) cellArray = vtkCellArray() cellArray.SetData(vtkOffsets, vtkConnectivity) output.SetCells(cellTypes, cellArray) @@ -284,6 +355,7 @@ def _cgns_unstructured_zone_to_vtk(zoneNode: list, physicalDim: int): _cgns_add_flow_solutions_to_vtk(zoneNode, output, numpy_support) return output + def CGNSBaseExtractGlobals(baseNode: list) -> dict: """Extract global fields from a CGNSBase_t node as a dictionary of name -> numpy array. @@ -300,6 +372,7 @@ def CGNSBaseExtractGlobals(baseNode: list) -> dict: globals[xNode[0]] = np.asarray(xNode[1]) return globals + def CGNSTreeToVtk(treeNode: list): """Convert a full CGNS tree to VTK objects, one per base, and return either the single base or a multi-block of bases. @@ -336,11 +409,12 @@ def CGNSTreeToVtk(treeNode: list): for key, value in globals.items(): if value.dtype == "|S1": from vtkmodules.vtkCommonCore import vtkStringArray + labels = vtkStringArray() labels.SetName(key) labels.SetNumberOfValues(len(value)) - for i,v in enumerate(value): - labels.SetValue(i,v) + for i, v in enumerate(value): + labels.SetValue(i, v) field_data.AddArray(labels) continue @@ -348,7 +422,6 @@ def CGNSTreeToVtk(treeNode: list): array.SetName(key) field_data.AddArray(array) - if len(baseVtkObjects) == 1: return baseVtkObjects[0] @@ -375,7 +448,11 @@ def CGNSBaseToVtk(baseNode: list): representation of the base. A single-zone base returns the zone object; a multi-zone base returns one block per zone. """ - if not isinstance(baseNode, list) or len(baseNode) < 4 or baseNode[3] != "CGNSBase_t": + if ( + not isinstance(baseNode, list) + or len(baseNode) < 4 + or baseNode[3] != "CGNSBase_t" + ): raise ValueError("CGNSBaseToVtk expects a CGNSBase_t node") if baseNode[1] is None: raise ValueError(f"CGNS base '{baseNode[0]}' has no base dimensionality value") @@ -388,14 +465,18 @@ def CGNSBaseToVtk(baseNode: list): zoneVtkObjects = [] for zoneNode in zones: - zoneType = _cgns_value_as_string(_cgns_child_by_name(zoneNode, "ZoneType")) or "Unstructured" + zoneType = ( + _cgns_value_as_string(_cgns_child_by_name(zoneNode, "ZoneType")) + or "Unstructured" + ) if zoneType == "Structured": zoneVtkObjects.append(_cgns_structured_zone_to_vtk(zoneNode, physicalDim)) elif zoneType == "Unstructured": zoneVtkObjects.append(_cgns_unstructured_zone_to_vtk(zoneNode, physicalDim)) else: - raise NotImplementedError(f"CGNS ZoneType '{zoneType}' is not supported by direct VTK conversion") - + raise NotImplementedError( + f"CGNS ZoneType '{zoneType}' is not supported by direct VTK conversion" + ) if len(zoneVtkObjects) == 1: return zoneVtkObjects[0] @@ -407,5 +488,3 @@ def CGNSBaseToVtk(baseNode: list): multiBlock.SetBlock(i, zoneVtkObject) multiBlock.GetMetaData(i).Set(multiBlock.NAME(), zoneNode[0]) return multiBlock - - diff --git a/src/plaid/utils/predict_client.py b/src/plaid/utils/predict_client.py index d9e67960..b7a8fce7 100644 --- a/src/plaid/utils/predict_client.py +++ b/src/plaid/utils/predict_client.py @@ -90,7 +90,6 @@ def problem_definition(self): """ return self._request_json("problem_definition", {}) - def infos(self): """Get the infos from the server. diff --git a/tests/utils/test_cgns_vtk.py b/tests/utils/test_cgns_vtk.py index e8881402..43bc4f23 100644 --- a/tests/utils/test_cgns_vtk.py +++ b/tests/utils/test_cgns_vtk.py @@ -141,7 +141,7 @@ def SetName(self, name): # noqa: N802 self.name = name def SetNumberOfValues(self, number_of_values): # noqa: N802 - self.values = [None]*number_of_values + self.values = [None] * number_of_values def SetValue(self, *args): # noqa: N802 if len(args) == 1: From 7d1a024a174868414f8df35fd0ab3745efaa9b47 Mon Sep 17 00:00:00 2001 From: Felipe Bordeu Date: Fri, 12 Jun 2026 14:44:30 +0930 Subject: [PATCH 25/35] Predict -> Process --- .../paraview_plugin/PlaidParaViewPlugin.py | 28 +++++++++---------- src/plaid/cli/serve.py | 10 +++---- .../{predict_client.py => process_client.py} | 22 +++++++-------- tests/cli/test_serve.py | 10 +++---- tests/utils/test_predict_client.py | 18 ++++++------ 5 files changed, 44 insertions(+), 44 deletions(-) rename src/plaid/utils/{predict_client.py => process_client.py} (85%) diff --git a/src/plaid/cli/paraview_plugin/PlaidParaViewPlugin.py b/src/plaid/cli/paraview_plugin/PlaidParaViewPlugin.py index a9a50e8c..f5f5be13 100644 --- a/src/plaid/cli/paraview_plugin/PlaidParaViewPlugin.py +++ b/src/plaid/cli/paraview_plugin/PlaidParaViewPlugin.py @@ -41,7 +41,7 @@ ##////////////////////////////////////////////////////////// _start_time = time.time() -debug = bool(os.environ.get("PARAVIEW_LOG_PLUGIN_VERBOSITY", True)) +debug = bool(os.environ.get("PARAVIEW_LOG_PLUGIN_VERBOSITY", False)) def print_debug(message: str) -> None: @@ -333,18 +333,18 @@ def GetInfos(self): return self._info_cache -print_debug("Loading MaestroExplorer") +print_debug("Loading PlaidExplorer") -@smproxy.source(name="MaestroExplorer", label="Maestro Explorer") -class MaestroExplorer(PlaidClientBase): - """ParaView source plugin fetching data from Maestro serve endpoints.""" +@smproxy.source(name="PlaidExplorer", label="Plaid Explorer") +class PlaidExplorer(PlaidClientBase): + """ParaView source plugin fetching data from Plaid serve endpoints.""" def __init__(self): super().__init__(nInputPorts=0, nOutputPorts=1) self.timestep_values_cache: list[float] | None = None - self.usePredict: bool = False + self.useProcess: bool = False self.input_features = "" @smproperty.stringvector(name="Host", default_values="127.0.0.1") @@ -439,28 +439,28 @@ def GetSomeTable(self): return super().GetSomeTable() @smproperty.xml(""" - - This property indicates if we use the sample or the predict endpoint + This property indicates if we use the sample or the process endpoint """) - def SetPredict(self, value): - """Set whether to use the /predict endpoint instead of /sample. This is a boolean property.""" + def SetProcess(self, value): + """Set whether to use the /process endpoint instead of /sample. This is a boolean property.""" bool_value = str(value).lower() in ["true", "1"] - if self.usePredict != bool_value: - self.usePredict = bool_value + if self.useProcess != bool_value: + self.useProcess = bool_value self._sample_cache = None self.Modified() def GetSampleData(self): """Fetch sample data for the currently selected split and sample ID, with caching.""" if self._sample_cache is None: - endpoint = "/predict" if self.usePredict else "/samples" + endpoint = "/process" if self.useProcess else "/samples" payload = { "sample_ids": [self.sample_id], "split": self._selected_split, diff --git a/src/plaid/cli/serve.py b/src/plaid/cli/serve.py index de9c1d98..9af7085d 100644 --- a/src/plaid/cli/serve.py +++ b/src/plaid/cli/serve.py @@ -34,14 +34,14 @@ HEALTH_PAYLOAD: dict[str, object] = {"status": "ok"} ENTRY_POINTS_PAYLOAD: dict[str, object] = { "samples_step": True, - "predict": False, + "process": False, "splits": True, "timesteps": True, "samples": True, } POST_DATASET_ROUTES = {"/samples", "/problem_definition", "/infos"} -PREDICT_UNSUPPORTED_PAYLOAD: dict[str, object] = { - "error": "Endpoint /predict is not supported by PLAID serve" +PROCESS_UNSUPPORTED_PAYLOAD: dict[str, object] = { + "error": "Endpoint /process is not supported by PLAID serve" } @@ -364,9 +364,9 @@ def do_POST(self) -> None: # noqa: N802 self._send_json(HEALTH_PAYLOAD) return - if parsed.path == "/predict": + if parsed.path == "/process": self._send_json( - PREDICT_UNSUPPORTED_PAYLOAD, + PROCESS_UNSUPPORTED_PAYLOAD, status=HTTPStatus.NOT_IMPLEMENTED, ) return diff --git a/src/plaid/utils/predict_client.py b/src/plaid/utils/process_client.py similarity index 85% rename from src/plaid/utils/predict_client.py rename to src/plaid/utils/process_client.py index b7a8fce7..0446bcd0 100644 --- a/src/plaid/utils/predict_client.py +++ b/src/plaid/utils/process_client.py @@ -1,4 +1,4 @@ -"""Class to use the /predict capability of a server.""" +"""Class to use the /process capability of a server.""" import json from typing import Any @@ -9,21 +9,21 @@ class PlaidClient: - """Client for making requests to a PLAID prediction server.""" + """Client for making requests to a PLAID process server.""" def __init__(self, host, port): - """Initialize a prediction server client. + """Initialize a process server client. Args: - host: Hostname or IP address of the prediction server. - port: Port number used by the prediction server. + host: Hostname or IP address of the process server. + port: Port number used by the process server. """ self.host = host self.port = port self.endpoints = { "health": "/health", - "predict": "/predict", + "process": "/process", "problem_definition": "/problem_definition", "infos": "/infos", "samples": "/samples", @@ -66,20 +66,20 @@ def check_connection(self) -> bool: print(f"Connection check failed: {e}") return False - def predict(self, sample: Sample) -> Sample: - """Send a sample to the predict endpoint and return the predicted sample. + def process(self, sample: Sample) -> Sample: + """Send a sample to the process endpoint and return the processed sample. The input sample is converted to a JSON payload, sent to the server, and the response is converted back to a Sample. Args: - sample: A Sample object containing the input data for prediction. + sample: A Sample object containing the input data for process task. Returns: - A Sample object containing the predicted output from the server. + A Sample object containing the processed output from the server. """ payload: dict[str, Any] = {"sample": sample_to_json_payload(sample)} - response = self._request_json("predict", payload) + response = self._request_json("process", payload) return sample_from_json_payload(response["samples"][0]) def problem_definition(self): diff --git a/tests/cli/test_serve.py b/tests/cli/test_serve.py index ad355480..f28ef554 100644 --- a/tests/cli/test_serve.py +++ b/tests/cli/test_serve.py @@ -163,7 +163,7 @@ def test_entry_points_route_returns_available_endpoints(serve_url: str) -> None: assert status == 200 assert payload == { "samples_step": True, - "predict": False, + "process": False, "splits": True, "timesteps": True, "samples": True, @@ -179,7 +179,7 @@ def test_unknown_route_returns_not_found(serve_url: str) -> None: def test_post_health_entry_point_returns_ok(serve_url: str) -> None: - """POST health route should match the Maestro serve interface.""" + """POST health route should match the Plaid serve interface.""" status, payload = _post_json(serve_url, "/health") assert status == 200 @@ -187,11 +187,11 @@ def test_post_health_entry_point_returns_ok(serve_url: str) -> None: def test_post_predict_returns_not_implemented(serve_url: str) -> None: - """POST predict route should be explicit but unsupported by PLAID serve.""" - status, payload = _post_json(serve_url, "/predict") + """POST process route should be explicit but unsupported by PLAID serve.""" + status, payload = _post_json(serve_url, "/process") assert status == 501 - assert payload == {"error": "Endpoint /predict is not supported by PLAID serve"} + assert payload == {"error": "Endpoint /process is not supported by PLAID serve"} def test_post_unknown_route_returns_not_found(serve_url: str) -> None: diff --git a/tests/utils/test_predict_client.py b/tests/utils/test_predict_client.py index 7985a65b..5b3dc46a 100644 --- a/tests/utils/test_predict_client.py +++ b/tests/utils/test_predict_client.py @@ -1,11 +1,11 @@ -"""Tests for the PLAID prediction HTTP client.""" +"""Tests for the PLAID procession HTTP client.""" import json from types import SimpleNamespace from plaid.containers.sample import Sample from plaid.utils.cgns_helper import compare_cgns_trees -from plaid.utils.predict_client import PlaidClient +from plaid.utils.process_client import PlaidClient from plaid.utils.sample_json import sample_to_json_payload @@ -42,7 +42,7 @@ def test_plaid_client_initializes_default_configuration(): assert client.timeout == 100 assert client.endpoints == { "health": "/health", - "predict": "/predict", + "process": "/process", "infos": "/infos", "problem_definition": "/problem_definition", "samples": "/samples", @@ -58,7 +58,7 @@ def fake_urlopen(req, timeout): calls.append(SimpleNamespace(req=req, timeout=timeout)) return _FakeResponse(response_payload) - monkeypatch.setattr("plaid.utils.predict_client.request.urlopen", fake_urlopen) + monkeypatch.setattr("plaid.utils.process_client.request.urlopen", fake_urlopen) client = PlaidClient("example.test", 1234) result = client._request_json("health", {"ping": True}) @@ -116,10 +116,10 @@ def raise_error(_endpoint, _payload): assert "Connection check failed: server unavailable" in capsys.readouterr().out -def test_predict_sends_sample_payload_and_decodes_first_sample( +def test_process_sends_sample_payload_and_decodes_first_sample( monkeypatch, sample_with_tree ): - """Prediction serializes one sample and returns the first sample in response.""" + """Process serializes one sample and returns the first sample in response.""" client = PlaidClient("localhost", 8000) response_sample = sample_with_tree.copy() calls = [] @@ -130,10 +130,10 @@ def fake_request_json(endpoint, payload): monkeypatch.setattr(client, "_request_json", fake_request_json) - predicted = client.predict(sample_with_tree) + processed = client.process(sample_with_tree) - assert calls == [("predict", {"sample": sample_to_json_payload(sample_with_tree)})] - _assert_same_sample_content(response_sample, predicted) + assert calls == [("process", {"sample": sample_to_json_payload(sample_with_tree)})] + _assert_same_sample_content(response_sample, processed) def test_problem_definition_requests_problem_definition_endpoint(monkeypatch): From b36d30605a6f1714a2d0c3d618de2ec949feda88 Mon Sep 17 00:00:00 2001 From: Felipe Bordeu Date: Thu, 18 Jun 2026 10:23:47 +0930 Subject: [PATCH 26/35] Clean remaining predict and update documentation --- docs/source/concepts/serve.md | 147 ++++++++++++++---- docs/zensical.toml | 2 +- .../{SimplePredict.py => SimpleProcess.py} | 14 +- src/plaid/cli/serve.py | 5 +- tests/cli/test_serve.py | 7 +- 5 files changed, 128 insertions(+), 47 deletions(-) rename examples/client_server/{SimplePredict.py => SimpleProcess.py} (90%) diff --git a/docs/source/concepts/serve.md b/docs/source/concepts/serve.md index 93fea800..40e05acc 100644 --- a/docs/source/concepts/serve.md +++ b/docs/source/concepts/serve.md @@ -1,8 +1,10 @@ # PLAID serve API `plaid-serve` runs a small HTTP server that exposes a local PLAID dataset to -client tools and the ParaView plugin. It is intended for local or trusted -network use; it does not implement authentication. +client tools and the ParaView plugin. The current server is a **read-only data +server**: it serves dataset metadata, problem definitions, and existing samples. +The server is intended for local or trusted-network use. It does not implement +authentication, authorization, or TLS. ## Start the server @@ -12,8 +14,8 @@ Run the server with a default dataset: uv run plaid-serve --dataset /path/to/plaid_dataset ``` -By default, the server listens on `0.0.0.0:8000`. You can change the bind -address and port: +By default, the server listens on `0.0.0.0:8000`. Use `--host` and `--port` to +change the bind address and port: ```bash uv run plaid-serve \ @@ -22,8 +24,8 @@ uv run plaid-serve \ --port 9000 ``` -If no default dataset is provided, each dataset route must include a `dataset` -or `uri` field in the JSON request body. +If no default dataset is provided, each dataset request must include a dataset +location in the JSON body using either `dataset` or `uri`. ## Command-line options @@ -31,8 +33,21 @@ or `uri` field in the JSON request body. | --- | --- | | `--host HOST` | Bind address. Defaults to `0.0.0.0`. | | `--port PORT` | Bind port. Defaults to `8000`. | -| `--dataset PATH` | Default PLAID dataset path used by dataset endpoints. | -| `--ParaViewRun` | Launch ParaView with the PLAID plugin and stop the server when ParaView exits. The ParaView executable is read from `PARAVIEW_EXEC`. | +| `--dataset PATH` | Default local PLAID dataset directory used by dataset endpoints. | +| `--ParaViewRun` | Launch ParaView with the PLAID plugin, set `PLAID_PORT` to the selected port, and stop the server when ParaView exits. The ParaView executable is read from `PARAVIEW_EXEC`. | + +## Request conventions + +Dataset endpoints use `POST` with a JSON object body. The server resolves the +dataset path in this order: + +1. the request `dataset` field; +2. the request `uri` field; +3. the `--dataset` value configured when the server was started. + +The current implementation loads datasets from local directories with +`plaid.storage.init_from_disk`. A dataset path must exist and be a directory. +Loaded datasets are cached by path for subsequent sample requests. ## Discovery endpoints @@ -44,28 +59,29 @@ Returns a simple health payload: {"status": "ok"} ``` +This route is used by `plaid.utils.process_client.PlaidClient.check_connection()`. + ### `GET /entry_points` Returns the capabilities exposed by this server: ```json { - "samples_step": true, - "predict": false, - "splits": true, - "timesteps": true, + "problem_definition": true, + "process": false, + "infos": true, "samples": true } ``` -## Dataset endpoints +`process: false` means that `plaid-serve` does not provide a processing or +prediction backend. It can still be used to retrieve dataset samples. -All dataset endpoints use `POST` with a JSON object request body. The dataset -location can be omitted when the server was started with `--dataset`. +## Dataset endpoints ### `POST /infos` -Returns the serialized `infos.yaml` metadata for a dataset. +Returns the serialized dataset `infos.yaml` metadata. ```bash curl -X POST http://127.0.0.1:8000/infos \ @@ -73,10 +89,19 @@ curl -X POST http://127.0.0.1:8000/infos \ -d '{"dataset": "/path/to/plaid_dataset"}' ``` +When the server was started with `--dataset`, the request body can be empty: + +```bash +curl -X POST http://127.0.0.1:8000/infos \ + -H 'Content-Type: application/json' \ + -d '{}' +``` + ### `POST /problem_definition` -Returns a serialized problem definition. Use `problem_definition` or -`problem_definition_name` to request a specific definition. +Returns one serialized problem definition from the dataset. Use either +`problem_definition` or `problem_definition_name` to request a specific +definition. ```bash curl -X POST http://127.0.0.1:8000/problem_definition \ @@ -87,9 +112,11 @@ curl -X POST http://127.0.0.1:8000/problem_definition \ }' ``` -If no name is provided, the server returns `PLAID_benchmark` when available, -the only definition when the dataset has one, or the first definition in sorted -name order. +If no name is provided, the server selects a definition deterministically: + +1. `PLAID_benchmark`, when available; +2. the only definition, when the dataset contains exactly one; +3. the first definition in sorted name order. ### `POST /samples` @@ -109,27 +136,83 @@ Request fields: | Field | Required | Description | | --- | --- | --- | -| `dataset` or `uri` | Required unless `--dataset` was provided | Local PLAID dataset path. | -| `split` | Required when the dataset has multiple splits | Split name such as `train` or `test`. | -| `sample_ids` | Yes | Non-empty list of non-negative sample IDs. | +| `dataset` or `uri` | Required unless `--dataset` was provided | Local PLAID dataset directory. Values are stripped of surrounding whitespace. | +| `split` | Required when the dataset has multiple splits | Split name such as `train`, `test`, or `OOD`. Values are stripped of surrounding whitespace. If the dataset has exactly one split, the split can be omitted. | +| `sample_ids` | Yes | Non-empty list of non-negative integer sample IDs. | The response shape is: ```json -{"samples": [{"...": "serialized sample"}]} +{ + "samples": [ + {"...": "serialized sample"} + ] +} +``` + +The sample payloads use the same JSON representation as +`plaid.utils.sample_json.sample_to_json_payload`. + +## Python client usage + +`plaid.utils.process_client.PlaidClient` can query the read-only endpoints when +the server was started with `--dataset`: + +```python +from plaid.utils.process_client import PlaidClient + +client = PlaidClient(host="localhost", port=8000) + +if client.check_connection(): + infos = client.infos() + problem_definition = client.problem_definition() + sample = client.samples( + sample_ids=[0], + split=problem_definition["training_split"][0], + )[0] ``` -## Unsupported prediction endpoint +The same client also has a `process(sample)` method for servers that implement +`POST /process`, but `plaid-serve` itself intentionally does not implement that +operation. + +## ParaView usage + +`plaid-serve --ParaViewRun` starts ParaView with the PLAID plugin and keeps the +HTTP server alive until ParaView exits. The plugin reads the connection port +from `PLAID_PORT` and can retrieve `/infos`, `/problem_definition`, and +`/samples` from the server. + +The plugin also exposes a "Process" toggle for servers that implement +`/process`. Leave this toggle disabled when using the built-in `plaid-serve` +data server. + +## Unsupported processing endpoint -`POST /predict` is intentionally unsupported by `plaid-serve` and returns -HTTP 501: +`POST /process` is intentionally unsupported by `plaid-serve` and returns HTTP +501: ```json -{"error": "Endpoint /predict is not supported by PLAID serve"} +{"error": "Endpoint /process is not supported by PLAID serve"} ``` ## Error responses -Validation errors return HTTP 400 with an `error` message. Unknown routes return -HTTP 404. Unexpected server errors return HTTP 500 with an `error` message and -are logged by the server. \ No newline at end of file +Validation errors return HTTP 400 with an `error` message. Typical validation +errors include missing dataset paths, invalid JSON bodies, missing or invalid +`sample_ids`, unknown splits, and non-string problem-definition names. + +Unknown `GET` routes return: + +```json +{"GET error": "Not Found"} +``` + +Unknown `POST` routes return: + +```json +{"POST error": "Not Found"} +``` + +Unexpected server errors return HTTP 500 with an `error` message and are logged +by the server. diff --git a/docs/zensical.toml b/docs/zensical.toml index b8b0fe9f..37a0ff55 100644 --- a/docs/zensical.toml +++ b/docs/zensical.toml @@ -118,7 +118,7 @@ nav = [ { "cgns_helper" = "api/utils/cgns_helper.md" }, { "cgns_json" = "api/utils/cgns_json.md" }, { "cgns_vtk" = "api/utils/cgns_vtk.md" }, - { "predict_client" = "api/utils/predict_client.md" }, + { "process_client" = "api/utils/process_client.md" }, { "sample_json" = "api/utils/sample_json.md" }, ] }, { "viewer" = [ diff --git a/examples/client_server/SimplePredict.py b/examples/client_server/SimpleProcess.py similarity index 90% rename from examples/client_server/SimplePredict.py rename to examples/client_server/SimpleProcess.py index 3bb94a05..f209de2a 100644 --- a/examples/client_server/SimplePredict.py +++ b/examples/client_server/SimpleProcess.py @@ -23,10 +23,10 @@ from matplotlib import pyplot as plt from plaid import Sample -from plaid.utils.predict_client import PlaidClient +from plaid.utils.process_client import PlaidClient # %% [markdown] -# # Connecion to the prediction server +# # Connecion to the plaid server # %% plaidserver = PlaidClient(host="localhost", port=8000) @@ -72,7 +72,7 @@ # %% -#create a const function to encapsulate the prediction +#create a const function to encapsulate the process call print(f"{input_features=}") active_input_feature = input_features[0].strip("Global/") print(f"{active_input_feature=}") @@ -100,7 +100,7 @@ def cost_fuction(x: Any) -> float : for f,v in zip([active_input_feature], x): sample.update_features_by_path("Global/"+f,v) - # 2) Then send the sample for evaluation/prediction and recover the sample + # 2) Then send the sample for evaluation/process and recover the sample for f in output_features : if f.startswith("Global"): global_name = f.strip("Global/") @@ -108,7 +108,7 @@ def cost_fuction(x: Any) -> float : sample.del_global(global_name) else: sample.del_feature_by_path(f) - response: Sample = plaidserver.predict(sample) + response: Sample = plaidserver.process(sample) # 3) evaluate the cost function output: float = response.get_global(active_output_feature) @@ -121,7 +121,7 @@ def cost_fuction(x: Any) -> float : print(cost_fuction([-45])) # %% [markdown] -# # Call the predictor for a range of +# # Call the process for a range of # %% stime = time.time() @@ -133,7 +133,7 @@ def cost_fuction(x: Any) -> float : x[i] = v #sample.del_global(active_output_features) y[i] = cost_fuction([v]) -print(f"{nb_calls} calls of predict in {time.time()-stime} s") +print(f"{nb_calls} calls of process in {time.time()-stime} s") # %% [markdown] # # Plot output diff --git a/src/plaid/cli/serve.py b/src/plaid/cli/serve.py index 9af7085d..a5a95c98 100644 --- a/src/plaid/cli/serve.py +++ b/src/plaid/cli/serve.py @@ -33,10 +33,9 @@ HEALTH_PAYLOAD: dict[str, object] = {"status": "ok"} ENTRY_POINTS_PAYLOAD: dict[str, object] = { - "samples_step": True, "process": False, - "splits": True, - "timesteps": True, + "problem_definition": True, + "infos": True, "samples": True, } POST_DATASET_ROUTES = {"/samples", "/problem_definition", "/infos"} diff --git a/tests/cli/test_serve.py b/tests/cli/test_serve.py index f28ef554..6dff5936 100644 --- a/tests/cli/test_serve.py +++ b/tests/cli/test_serve.py @@ -162,10 +162,9 @@ def test_entry_points_route_returns_available_endpoints(serve_url: str) -> None: assert status == 200 assert payload == { - "samples_step": True, "process": False, - "splits": True, - "timesteps": True, + "problem_definition": True, + "infos": True, "samples": True, } @@ -186,7 +185,7 @@ def test_post_health_entry_point_returns_ok(serve_url: str) -> None: assert payload == {"status": "ok"} -def test_post_predict_returns_not_implemented(serve_url: str) -> None: +def test_post_process_returns_not_implemented(serve_url: str) -> None: """POST process route should be explicit but unsupported by PLAID serve.""" status, payload = _post_json(serve_url, "/process") From 43efad1d312295284ae25ffab47317daafedaea7 Mon Sep 17 00:00:00 2001 From: Felipe Bordeu Date: Thu, 18 Jun 2026 10:36:54 +0930 Subject: [PATCH 27/35] example --- examples/fem_with_gauss_point_data.py | 290 ++++++++++++++++++++++++++ 1 file changed, 290 insertions(+) create mode 100644 examples/fem_with_gauss_point_data.py diff --git a/examples/fem_with_gauss_point_data.py b/examples/fem_with_gauss_point_data.py new file mode 100644 index 00000000..214944f4 --- /dev/null +++ b/examples/fem_with_gauss_point_data.py @@ -0,0 +1,290 @@ +# --- +# jupyter: +# jupytext: +# formats: ipynb,py:percent +# text_representation: +# extension: .py +# format_name: percent +# format_version: '1.3' +# jupytext_version: 1.17.3 +# kernelspec: +# display_name: Python 3 +# language: python +# name: python3 +# --- + +# %% [markdown] +# # Example of converting user data into PLAID +# +# This code provides an example for converting user data into the PLAID (Physics Informed AI Datamodel) format. + +# %% +from pathlib import Path +import matplotlib.pyplot as plt +import numpy as np +import pickle + +from Muscat.Bridges.CGNSBridge import MeshToCGNS, CGNSToMesh +from Muscat.MeshTools import MeshCreationTools as MCT + +from plaid import Sample + +from Muscat.TestData import GetTestDataPath + +ut_file_path = Path(GetTestDataPath())/ 'UtExample'/ 'cube.ut' +print(ut_file_path) + +from Muscat.IO.UtReader import UtReader + +reader = UtReader() +reader.SetFileName(ut_file_path) +reader.ReadMetaData() +times = reader.GetAvailableTimes() +print(times) +#%% +# we know the mesh does not change, load the mesh only ones +reader.SetTimeToRead(times[0]) +mesh = reader.Read() +#print(mesh) +#exit() +#%% clean mesh +mesh.nodeFields = {} +for etag in mesh.elements.GetTagsNames(): + print(etag) + for el in mesh.elements: + if etag in el.tags: + el.tags.RenameTag(etag, "el_"+etag) +print(mesh) + +cgns_tree = MeshToCGNS(mesh) +#print(CGNSToMesh(cgns_tree)) +#from Muscat.MeshTools.MeshTools import IsClose +#IsClose(mesh, CGNSToMesh(MeshToCGNS(mesh))) +#exit() +print(reader.node) +print(reader.integ) +print(reader.time) +sample = Sample() +for step_data in reader.time: + print(step_data) + t = step_data[4] + mesh_i = mesh.View() + reader.SetTimeToRead(t) + reader.atIntegrationPoints = False + cgns_tree = MeshToCGNS(mesh) + + + sample.features.add_tree(cgns_tree, time=t) + sample.set_default_time(t) + + for node_field in reader.node: + data = reader.ReadField(fieldname= node_field) + sample.add_field(node_field, data, location="Vertex") + + for integ_field in reader.integ: + data = reader.ReadField(fieldname= integ_field) + sample.add_field(integ_field, data, location="Vertex") + + reader.atIntegrationPoints = True + + from Muscat.IO.ZsetTools import GetIntegrationRuleForZsetMesh + from Muscat.Bridges.CGNSBridge import MuscatToCGNSNames + mesh_quadrature = GetIntegrationRuleForZsetMesh(mesh) + idToName = {} + ruleIdByName = {} + rules = {} + + for i, (k,v) in enumerate(mesh_quadrature.items()): + #print(i,k,v) + name = f"zset{str(k).strip("ElementType.")}_IntRule" + idToName[i] = name + ruleIdByName[name] = i + rules[name] = { + "element_type": MuscatToCGNSNames[k], + "reference_space": "Parametric", + "integration_name": "Zset", + "parametric_integration_points": np.asarray(v.points, order='F'), + "weights": v.weights, + } + + from Muscat.Bridges.CGNSBridge import AddIntegrationRuleCollection, AddIntegrationPointFlowSolution + AddIntegrationRuleCollection( + sample.features.get_base(), + collectionName="IntegrationGaussZset", + idToName=idToName, + rules=rules, + ) + + for integ_field in reader.integ: + data = reader.ReadField(fieldname= integ_field) + + from Muscat.MeshContainers.Filters.FilterObjects import ElementFilter + bulk_filter = ElementFilter(dimensionality=mesh.GetElementsDimensionality()) + from Muscat.FE.Fields.IPField import IPField + ipf = IPField(name=integ_field,mesh=mesh,rule = mesh_quadrature) + ipf.Allocate() + ipf.SetDataFromNumpy(data, bulk_filter) + + offset = ipf.GetFlattenOffset(bulk_filter) + vals = ipf.Flatten(bulk_filter) + + nCells = ipf.mesh.GetNumberOfElements(dim=ipf.mesh.GetElementsDimensionality()) + itgIds = np.zeros(nCells, dtype=np.int32) + for selection in bulk_filter(ipf.mesh): + ruleName = f"zset{str(selection.elementType).strip("ElementType.")}_IntRule" + itgIds[selection.GetSelectionSlice()] = ruleIdByName[ruleName] + + AddIntegrationPointFlowSolution( + sample.features.get_zone(), + flowName=f"{ipf.name}_IntegrationPointFields", + dataArrays={ipf.name: vals}, + itgPointStartOffset=offset, + itgRulesPath=f"/{sample.resolve_base()}/IntegrationGaussZset", + itgRulesIds=itgIds, + ) + +print(sample) + +from plaid.storage import save_to_disk + +keys = list(sample.features.data.keys()) +values = list(sample.features.data.values()) + + +sample.features.data = {} + +def sample_constructor(i: int): + sample.features.data = {} + sample.features.data = {keys[i]:values[i]} + return sample + +for backend in ["cgns"]:#, "hf_datasets"]:#,"zarr"]: + save_to_disk( + output_folder=f"output_dataset_with_gauss_{backend}", + sample_constructor=sample_constructor, + ids={"train": [0]},# np.arange(len(keys))}, + backend=backend, # or "hf_datasets" or "cgns" + overwrite=True, + ) + + +from plaid.storage import init_from_disk + + +datasetdict, converterdict = init_from_disk( + local_dir = "output_dataset_with_gauss_cgns", + splits = ["train"] +) +sample0 = converterdict["train"].to_plaid(datasetdict["train"], 0) +sample.features.data = {keys[0]:values[0]} + +print(mesh) +mesh2 = CGNSToMesh(sample0.features.data[0.0]) +print(mesh2) +print(set(mesh.nodesTags.keys()) - set(mesh2.nodesTags.keys())) + +mesh3 = CGNSToMesh(MeshToCGNS(mesh)) +print(mesh3) +print(set(mesh.nodesTags.keys()) - set(mesh3.nodesTags.keys())) +exit() + +with open("sample0", "w") as f: + f.write('"""from the backend"""') + with open("sample", "w") as f2: + f2.write('"""from User"""') + def pprint(a,b,offset=0): + if isinstance(a,list): + a_names = ((n[0],n[3]) for n in a) + b_names = ((n[0],n[3]) for n in b) + + + for n in (set(a_names) | set(b_names)): + a_result = list(filter(lambda u: (u[0],u[3])== n, a)) + b_result = list(filter(lambda u: (u[0],u[3])== n, b)) + + + if len(a_result) < 1 : + print (f"error {n} in a ") + f.write(" "*offset+str(n)+ " not found in a \n") + f2.write(" "*offset+str(b_result)+ " found in b \n") + continue + if len(b_result) < 1: + print(b_result) + f.write(" "*offset+str(a_result[0])+ " found in a \n") + f2.write(" "*offset+str(n)+ " not found in b \n") + + print( f"error {n} in b ") + continue + f.write(" "*offset+str(a_result[0][0])+ "\n") + f.write(" "*offset+str(a_result[0][1])+ "\n") + f.write(" "*offset+str(a_result[0][3])+ "\n") + + f2.write(" "*offset+str(b_result[0][0])+ "\n") + f2.write(" "*offset+str(b_result[0][1])+ "\n") + f2.write(" "*offset+str(b_result[0][3])+ "\n") + + + pprint(a_result[0][2],b_result[0][2], offset+2) + else: + f.write(" "*offset+str(a)+ "\n") + f2.write(" "*offset+str(b)+ "\n") + #for i in a : + # f.write(str(i)) + #for i in b : + # f2.write(str(i)) + pprint([sample0.features.data[0.0]],[sample.features.data[0.0]]) +print("cone") +exit() + + +for f in sample.get_all_features_identifiers_by_type("field"): + + field = sample.get_field(f, "IntegrationPoint") + if field is not None: + print(f, field.shape) + +version = 1 +import pickle +with open("fromUt.pickle", "wb") as f: + if version == 0: + pickle.dump(0,f) + pickle.dump(sample.features.get_all_time_values(),f) + pickle.dump(sample,f) + + if version == 1: + timesteps = sample.features.get_all_time_values() + sizes = np.empty(len(timesteps)+1,dtype=int) + pickle.dump(1,f) + init = f.tell() + pickle.dump((timesteps,sizes),f) + for i in range(len(timesteps)): + sizes[i] = f.tell() + data = sample.features.data[timesteps[i]] + pickle.dump(data,f) + sample.features.data = None + sizes[-1] = f.tell() + pickle.dump(sample,f) + f.seek(init) + pickle.dump((timesteps,sizes),f) + +if version == 0: + with open("fromUt.pickle", "rb") as f: + # drop version + pickle.load(f) + # drop timevalues + pickle.load(f) + res = pickle.load(f) + +if version == 1: + with open("fromUt.pickle", "rb") as f: + print("version", pickle.load(f)) + timestaps , offsets = pickle.load(f) + data = {} + for i,(t,off) in enumerate(zip(timestaps,offsets)): + f.seek(off) + data[t] = pickle.load(f) + f.seek(offsets[-1]) + res = pickle.load(f) + res.features.data = data + + From 91fa86cfaf3d7b0be200afaded9965da3f0af8bf Mon Sep 17 00:00:00 2001 From: Felipe Bordeu Date: Thu, 18 Jun 2026 10:36:54 +0930 Subject: [PATCH 28/35] ip example --- examples/fem_with_gauss_point_data.py | 508 ++++++++++++++------------ 1 file changed, 267 insertions(+), 241 deletions(-) diff --git a/examples/fem_with_gauss_point_data.py b/examples/fem_with_gauss_point_data.py index 214944f4..74d56c68 100644 --- a/examples/fem_with_gauss_point_data.py +++ b/examples/fem_with_gauss_point_data.py @@ -6,285 +6,311 @@ # extension: .py # format_name: percent # format_version: '1.3' -# jupytext_version: 1.17.3 +# jupytext_version: 1.19.3 # kernelspec: -# display_name: Python 3 +# display_name: Python 3 (ipykernel) # language: python # name: python3 # --- # %% [markdown] -# # Example of converting user data into PLAID +# # Example of converting user data into PLAID with integration point data # # This code provides an example for converting user data into the PLAID (Physics Informed AI Datamodel) format. +# %% [markdown] +# ## Imports + # %% from pathlib import Path -import matplotlib.pyplot as plt import numpy as np -import pickle from Muscat.Bridges.CGNSBridge import MeshToCGNS, CGNSToMesh from Muscat.MeshTools import MeshCreationTools as MCT +from Muscat.TestData import GetTestDataPath +from Muscat.IO.UtReader import UtReader +from Muscat.Bridges.CGNSBridge import AddIntegrationRuleCollection, AddIntegrationPointFlowSolution, AddMuscatIPField +from Muscat.IO.ZsetTools import GetIntegrationRuleForZsetMesh +from Muscat.Bridges.CGNSBridge import MuscatToCGNSNames +from Muscat.MeshContainers.Filters.FilterObjects import ElementFilter +from Muscat.FE.Fields.IPField import IPField +from Muscat.MeshContainers import ElementsDescription as ED +from Muscat.Bridges.CGNSBridge import ExtractIPField from plaid import Sample +from plaid.storage import save_to_disk +from plaid.storage import init_from_disk -from Muscat.TestData import GetTestDataPath -ut_file_path = Path(GetTestDataPath())/ 'UtExample'/ 'cube.ut' -print(ut_file_path) +# %% +def main() -> None: -from Muscat.IO.UtReader import UtReader -reader = UtReader() -reader.SetFileName(ut_file_path) -reader.ReadMetaData() -times = reader.GetAvailableTimes() -print(times) -#%% -# we know the mesh does not change, load the mesh only ones -reader.SetTimeToRead(times[0]) -mesh = reader.Read() -#print(mesh) -#exit() -#%% clean mesh -mesh.nodeFields = {} -for etag in mesh.elements.GetTagsNames(): - print(etag) - for el in mesh.elements: - if etag in el.tags: - el.tags.RenameTag(etag, "el_"+etag) -print(mesh) - -cgns_tree = MeshToCGNS(mesh) -#print(CGNSToMesh(cgns_tree)) -#from Muscat.MeshTools.MeshTools import IsClose -#IsClose(mesh, CGNSToMesh(MeshToCGNS(mesh))) -#exit() -print(reader.node) -print(reader.integ) -print(reader.time) -sample = Sample() -for step_data in reader.time: - print(step_data) - t = step_data[4] - mesh_i = mesh.View() - reader.SetTimeToRead(t) - reader.atIntegrationPoints = False + # %% + ut_file_path = Path(GetTestDataPath())/ 'UtExample'/ 'cube.ut' + print(ut_file_path) + + reader = UtReader() + reader.SetFileName(ut_file_path) + reader.ReadMetaData() + times = reader.GetAvailableTimes() + print(times) +# %% [markdown] +# # ## We know the mesh does not change, load the mesh only ones + + # %% + reader.SetTimeToRead(times[0]) + mesh = reader.Read() + print(mesh) + +# %% [markdown] +# # ## CGNS does not support nodes Tags and Elements tags with the same name + + # %% + mesh.nodeFields = {} + for etag in mesh.elements.GetTagsNames(): + + for el in mesh.elements: + if etag in el.tags: + el.tags.RenameTag(etag, "el_"+etag) + print(f"rename tag {etag} -> {'el_'+etag} ") + + print(mesh) + saved_mesh = mesh.View() + # removing string field + mesh.elemFields = {} + + +# %% [markdown] +# # ## Convert mesh to cgns tree + + # %% cgns_tree = MeshToCGNS(mesh) + #print(CGNSToMesh(cgns_tree)) + +# %% [markdown] +# # ## Data in the .ut file + + # %% + print(reader.node) + print(reader.integ) + print(reader.time[:,-1]) + +# %% [markdown] +# # ## Loop over the time steps and inject the mesh + + # %% + sample = Sample() + for step_data in reader.time: + t = step_data[4] + print(t) + mesh_i = mesh.View() + reader.SetTimeToRead(t) + reader.atIntegrationPoints = False + cgns_tree = MeshToCGNS(mesh) - sample.features.add_tree(cgns_tree, time=t) - sample.set_default_time(t) + sample.add_tree(cgns_tree, time=t) + sample.set_default_time(t) + print(sample) - for node_field in reader.node: - data = reader.ReadField(fieldname= node_field) - sample.add_field(node_field, data, location="Vertex") - for integ_field in reader.integ: - data = reader.ReadField(fieldname= integ_field) - sample.add_field(integ_field, data, location="Vertex") + # %% + sample.get_field_names(time=0) +# %% [markdown] +# # ## Loop over the time steps and inject vertex fields + + # %% + reader.atIntegrationPoints = False + for step_data in reader.time: + t = step_data[4] + print(t) + reader.SetTimeToRead(t) + + sample.set_default_time(t) + for node_field in reader.node: + data = reader.ReadField(fieldname= node_field) + sample.add_field(node_field, data, location="Vertex") + + for integ_field in reader.integ: + data = reader.ReadField(fieldname= integ_field) + sample.add_field(integ_field, data, location="Vertex") + print(sample) + +# %% [markdown] +# # ## Loop over the fields and inject integration point data + + # %% + mesh_i = mesh.View() reader.atIntegrationPoints = True - from Muscat.IO.ZsetTools import GetIntegrationRuleForZsetMesh - from Muscat.Bridges.CGNSBridge import MuscatToCGNSNames - mesh_quadrature = GetIntegrationRuleForZsetMesh(mesh) - idToName = {} - ruleIdByName = {} - rules = {} - - for i, (k,v) in enumerate(mesh_quadrature.items()): - #print(i,k,v) - name = f"zset{str(k).strip("ElementType.")}_IntRule" - idToName[i] = name - ruleIdByName[name] = i - rules[name] = { - "element_type": MuscatToCGNSNames[k], - "reference_space": "Parametric", - "integration_name": "Zset", - "parametric_integration_points": np.asarray(v.points, order='F'), - "weights": v.weights, - } - - from Muscat.Bridges.CGNSBridge import AddIntegrationRuleCollection, AddIntegrationPointFlowSolution - AddIntegrationRuleCollection( - sample.features.get_base(), - collectionName="IntegrationGaussZset", - idToName=idToName, - rules=rules, - ) - - for integ_field in reader.integ: - data = reader.ReadField(fieldname= integ_field) - - from Muscat.MeshContainers.Filters.FilterObjects import ElementFilter - bulk_filter = ElementFilter(dimensionality=mesh.GetElementsDimensionality()) - from Muscat.FE.Fields.IPField import IPField - ipf = IPField(name=integ_field,mesh=mesh,rule = mesh_quadrature) - ipf.Allocate() - ipf.SetDataFromNumpy(data, bulk_filter) - - offset = ipf.GetFlattenOffset(bulk_filter) - vals = ipf.Flatten(bulk_filter) - - nCells = ipf.mesh.GetNumberOfElements(dim=ipf.mesh.GetElementsDimensionality()) - itgIds = np.zeros(nCells, dtype=np.int32) - for selection in bulk_filter(ipf.mesh): - ruleName = f"zset{str(selection.elementType).strip("ElementType.")}_IntRule" - itgIds[selection.GetSelectionSlice()] = ruleIdByName[ruleName] - - AddIntegrationPointFlowSolution( - sample.features.get_zone(), - flowName=f"{ipf.name}_IntegrationPointFields", - dataArrays={ipf.name: vals}, - itgPointStartOffset=offset, - itgRulesPath=f"/{sample.resolve_base()}/IntegrationGaussZset", - itgRulesIds=itgIds, - ) - -print(sample) + # keep track of fields for the check at the end of the file + ipdata = {} + allipfs = {} + mesh_quadrature = GetIntegrationRuleForZsetMesh(saved_mesh) -from plaid.storage import save_to_disk + for step_data in reader.time: + t = step_data[4] + print(t) + reader.SetTimeToRead(t) + sample.set_default_time(t) -keys = list(sample.features.data.keys()) -values = list(sample.features.data.values()) + ipfs = [] + ipdata2 = {} + ipdata[t] = ipdata2 + for integ_field in reader.integ: + data = reader.ReadField(fieldname= integ_field, time = t) + bulk_filter = ElementFilter(dimensionality=mesh.GetElementsDimensionality()) + ipf = IPField(name=integ_field,mesh=mesh,rule = mesh_quadrature) + ipf.Allocate() + ipf.SetDataFromNumpy(data, bulk_filter) + ipdata2[integ_field] = data + ipfs.append(ipf) + ## User can export the integration point positions as ip fields + AddMuscatIPField(sample.get_tree(time=t), ipfs, exportLocationsPositions=True) -sample.features.data = {} + allipfs[t] = {i.name:i for i in ipfs} -def sample_constructor(i: int): - sample.features.data = {} - sample.features.data = {keys[i]:values[i]} - return sample -for backend in ["cgns"]:#, "hf_datasets"]:#,"zarr"]: - save_to_disk( - output_folder=f"output_dataset_with_gauss_{backend}", - sample_constructor=sample_constructor, - ids={"train": [0]},# np.arange(len(keys))}, - backend=backend, # or "hf_datasets" or "cgns" - overwrite=True, - ) + # %% + print(sample) + #sample.show_tree() +# %% [markdown] +# # ## Store one step per sample -from plaid.storage import init_from_disk + # %% + keys = list(sample.data.keys()) + values = list(sample.data.values()) + def sample_constructor(i: int): + temp_sample = Sample() + temp_sample.data = {keys[i]:values[i]} + return temp_sample -datasetdict, converterdict = init_from_disk( - local_dir = "output_dataset_with_gauss_cgns", - splits = ["train"] -) -sample0 = converterdict["train"].to_plaid(datasetdict["train"], 0) -sample.features.data = {keys[0]:values[0]} - -print(mesh) -mesh2 = CGNSToMesh(sample0.features.data[0.0]) -print(mesh2) -print(set(mesh.nodesTags.keys()) - set(mesh2.nodesTags.keys())) - -mesh3 = CGNSToMesh(MeshToCGNS(mesh)) -print(mesh3) -print(set(mesh.nodesTags.keys()) - set(mesh3.nodesTags.keys())) -exit() - -with open("sample0", "w") as f: - f.write('"""from the backend"""') - with open("sample", "w") as f2: - f2.write('"""from User"""') - def pprint(a,b,offset=0): - if isinstance(a,list): - a_names = ((n[0],n[3]) for n in a) - b_names = ((n[0],n[3]) for n in b) - - - for n in (set(a_names) | set(b_names)): - a_result = list(filter(lambda u: (u[0],u[3])== n, a)) - b_result = list(filter(lambda u: (u[0],u[3])== n, b)) - - - if len(a_result) < 1 : - print (f"error {n} in a ") - f.write(" "*offset+str(n)+ " not found in a \n") - f2.write(" "*offset+str(b_result)+ " found in b \n") - continue - if len(b_result) < 1: - print(b_result) - f.write(" "*offset+str(a_result[0])+ " found in a \n") - f2.write(" "*offset+str(n)+ " not found in b \n") - - print( f"error {n} in b ") - continue - f.write(" "*offset+str(a_result[0][0])+ "\n") - f.write(" "*offset+str(a_result[0][1])+ "\n") - f.write(" "*offset+str(a_result[0][3])+ "\n") - - f2.write(" "*offset+str(b_result[0][0])+ "\n") - f2.write(" "*offset+str(b_result[0][1])+ "\n") - f2.write(" "*offset+str(b_result[0][3])+ "\n") - - - pprint(a_result[0][2],b_result[0][2], offset+2) - else: - f.write(" "*offset+str(a)+ "\n") - f2.write(" "*offset+str(b)+ "\n") - #for i in a : - # f.write(str(i)) - #for i in b : - # f2.write(str(i)) - pprint([sample0.features.data[0.0]],[sample.features.data[0.0]]) -print("cone") -exit() - - -for f in sample.get_all_features_identifiers_by_type("field"): - - field = sample.get_field(f, "IntegrationPoint") - if field is not None: - print(f, field.shape) - -version = 1 -import pickle -with open("fromUt.pickle", "wb") as f: - if version == 0: - pickle.dump(0,f) - pickle.dump(sample.features.get_all_time_values(),f) - pickle.dump(sample,f) - - if version == 1: - timesteps = sample.features.get_all_time_values() - sizes = np.empty(len(timesteps)+1,dtype=int) - pickle.dump(1,f) - init = f.tell() - pickle.dump((timesteps,sizes),f) - for i in range(len(timesteps)): - sizes[i] = f.tell() - data = sample.features.data[timesteps[i]] - pickle.dump(data,f) - sample.features.data = None - sizes[-1] = f.tell() - pickle.dump(sample,f) - f.seek(init) - pickle.dump((timesteps,sizes),f) - -if version == 0: - with open("fromUt.pickle", "rb") as f: - # drop version - pickle.load(f) - # drop timevalues - pickle.load(f) - res = pickle.load(f) - -if version == 1: - with open("fromUt.pickle", "rb") as f: - print("version", pickle.load(f)) - timestaps , offsets = pickle.load(f) - data = {} - for i,(t,off) in enumerate(zip(timestaps,offsets)): - f.seek(off) - data[t] = pickle.load(f) - f.seek(offsets[-1]) - res = pickle.load(f) - res.features.data = data + for backend in ["cgns","hf_datasets"]:#,"zarr" for the moment zarr froze for unkwnon reason during reading + print(backend + "--------------------------------------------") + save_to_disk( + output_folder=f"output_dataset_with_gauss_{backend}_per_step", + sample_constructor=sample_constructor, + ids={"train": list(range(len(keys)))}, + backend=backend, + overwrite=True, + num_proc=1 + ) + + +# %% [markdown] +# # ## Store one time varing sample + +# %% + + def sample_constructor(i: int): + if i > 0: + raise + return sample + + for backend in ["cgns"]:# ,"hf_datasets","zarr"do not support 1 sample dataset + print(backend + "--------------------------------------------") + save_to_disk( + output_folder=f"output_dataset_with_gauss_{backend}_one_sample", + sample_constructor=sample_constructor, + ids={"train": [0]}, + backend=backend, + overwrite=True, + ) + + +# %% [markdown] +# # ## Reload Data from disk and verify the integration point data is the same + +# %% clean mesh + + for backend in ["cgns","hf_datasets"]:#,"zarr" + datasetdict, converterdict = init_from_disk(local_dir = f"output_dataset_with_gauss_{backend}_per_step") + + for i, t in enumerate(keys): + print(f"Working on backend {backend}, time {t}") + sample_back = converterdict["train"].to_plaid(datasetdict["train"], i) + print(1) + mesh_back = CGNSToMesh(sample_back.get_tree(time=t)) + print(2) + for f in sample_back.get_field_names(location="IntegrationPoint", time=t): + print(f) + field = sample_back.get_field(f, "IntegrationPoint", time=t) + if f not in ipdata[t]: + check0 = np.allclose(field.shape, ipdata[t][f[0:-5]].shape) + check1 = "Na" + check2 = "Na" + else: + ipField_back = ExtractIPField(sample_back.get_tree(time=t), mesh_back, f) + check0 = np.allclose(field.shape, ipdata[t][f].shape) + check1 = np.allclose(field,ipdata[t][f]) + check2 = np.allclose(ipField_back.data[ED.Hexahedron_8],allipfs[t][f].data[ED.Hexahedron_8] ) + + if not (check0 and check1 and check2) : + print(f, check0, check1, check2 ) + raise + + print(sample) + print(sample_back) + print("Done") + + +# %% [markdown] +# # ## Recover position of the integration Points + + # %% + eto11 =sample_back.get_field('eto11',"IntegrationPoint",time=keys[-1]) + eto11_posx =sample_back.get_field('eto11_posx',"IntegrationPoint",time=keys[-1]) + eto11_posy =sample_back.get_field('eto11_posy',"IntegrationPoint",time=keys[-1]) + eto11_posz =sample_back.get_field('eto11_posz',"IntegrationPoint",time=keys[-1]) + + + # %% + import pyvista as pv + + point_cloud = pv.PolyData(np.vstack((eto11_posx,eto11_posy,eto11_posz)).T) + + point_cloud["eto11"] = eto11 + print(point_cloud) + plotter = pv.Plotter() + plotter.add_mesh(point_cloud, scalars="eto11", style="points", point_size=10.0) + plotter.show(jupyter_backend='static') + + # %% + import plotly.graph_objects as go + fig = go.Figure(data=[go.Scatter3d( + x=eto11_posx, y=eto11_posy, z=eto11_posz, + mode='markers', + marker=dict( + size=8, + color=eto11, # Pass numeric array here + colorscale='Viridis', # Choose color palette (capitalized) + colorbar=dict(title="Values"), # Shows the color legend side-bar + ) + )]) + + fig.show() + renderer="notebook" + + # %% + from plaid.utils.cgns_vtk import CGNSTreeToVtk + vtkmesh = CGNSTreeToVtk(sample_back.get_tree(time= keys[-1])) + #print(vtkmesh) + + # %% + pv.set_jupyter_backend('static') + pl = pv.Plotter() + pl.add_mesh(vtkmesh, scalars="U1", show_edges=True) + pl.show() + +# %% + +# %% +if __name__ == "__main__": + main() From 5e921c5ab757cc7354fd3b41f1419c68f2e75a94 Mon Sep 17 00:00:00 2001 From: Felipe Bordeu Date: Thu, 18 Jun 2026 10:41:07 +0930 Subject: [PATCH 29/35] Potential fix for pull request finding 'Unused local variable' Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> --- examples/fem_with_gauss_point_data.py | 1 - 1 file changed, 1 deletion(-) diff --git a/examples/fem_with_gauss_point_data.py b/examples/fem_with_gauss_point_data.py index 74d56c68..ea2e2cd0 100644 --- a/examples/fem_with_gauss_point_data.py +++ b/examples/fem_with_gauss_point_data.py @@ -142,7 +142,6 @@ def main() -> None: # # ## Loop over the fields and inject integration point data # %% - mesh_i = mesh.View() reader.atIntegrationPoints = True # keep track of fields for the check at the end of the file From dd13cc74c5d314fd5c80670ca042c08dec45e49c Mon Sep 17 00:00:00 2001 From: Felipe Bordeu Date: Thu, 18 Jun 2026 10:41:23 +0930 Subject: [PATCH 30/35] Potential fix for pull request finding 'Unused import' Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> --- examples/fem_with_gauss_point_data.py | 1 - 1 file changed, 1 deletion(-) diff --git a/examples/fem_with_gauss_point_data.py b/examples/fem_with_gauss_point_data.py index ea2e2cd0..6f27c2e1 100644 --- a/examples/fem_with_gauss_point_data.py +++ b/examples/fem_with_gauss_point_data.py @@ -31,7 +31,6 @@ from Muscat.IO.UtReader import UtReader from Muscat.Bridges.CGNSBridge import AddIntegrationRuleCollection, AddIntegrationPointFlowSolution, AddMuscatIPField from Muscat.IO.ZsetTools import GetIntegrationRuleForZsetMesh -from Muscat.Bridges.CGNSBridge import MuscatToCGNSNames from Muscat.MeshContainers.Filters.FilterObjects import ElementFilter from Muscat.FE.Fields.IPField import IPField from Muscat.MeshContainers import ElementsDescription as ED From 66903be489c56948e75125dfbd8ae5abaa42edf9 Mon Sep 17 00:00:00 2001 From: Felipe Bordeu Date: Thu, 18 Jun 2026 10:42:35 +0930 Subject: [PATCH 31/35] Potential fix for pull request finding 'Variable defined multiple times' Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> --- examples/fem_with_gauss_point_data.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/examples/fem_with_gauss_point_data.py b/examples/fem_with_gauss_point_data.py index 6f27c2e1..4742d780 100644 --- a/examples/fem_with_gauss_point_data.py +++ b/examples/fem_with_gauss_point_data.py @@ -84,8 +84,7 @@ def main() -> None: # # ## Convert mesh to cgns tree # %% - cgns_tree = MeshToCGNS(mesh) - #print(CGNSToMesh(cgns_tree)) + #print(CGNSToMesh(MeshToCGNS(mesh))) # %% [markdown] # # ## Data in the .ut file From a66ed438e29759abf7660cbe0b76bfb86f1a94ee Mon Sep 17 00:00:00 2001 From: Felipe Bordeu Date: Thu, 18 Jun 2026 10:42:57 +0930 Subject: [PATCH 32/35] Potential fix for pull request finding 'Variable defined multiple times' Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> --- examples/fem_with_gauss_point_data.py | 1 - 1 file changed, 1 deletion(-) diff --git a/examples/fem_with_gauss_point_data.py b/examples/fem_with_gauss_point_data.py index 4742d780..8024adc7 100644 --- a/examples/fem_with_gauss_point_data.py +++ b/examples/fem_with_gauss_point_data.py @@ -102,7 +102,6 @@ def main() -> None: for step_data in reader.time: t = step_data[4] print(t) - mesh_i = mesh.View() reader.SetTimeToRead(t) reader.atIntegrationPoints = False cgns_tree = MeshToCGNS(mesh) From 4a00f60a314afb0bac80544dfa3fe990948033aa Mon Sep 17 00:00:00 2001 From: Felipe Bordeu Date: Thu, 18 Jun 2026 10:43:23 +0930 Subject: [PATCH 33/35] Potential fix for pull request finding 'Unused import' Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> --- examples/fem_with_gauss_point_data.py | 1 - 1 file changed, 1 deletion(-) diff --git a/examples/fem_with_gauss_point_data.py b/examples/fem_with_gauss_point_data.py index 8024adc7..4e5d4fcc 100644 --- a/examples/fem_with_gauss_point_data.py +++ b/examples/fem_with_gauss_point_data.py @@ -26,7 +26,6 @@ import numpy as np from Muscat.Bridges.CGNSBridge import MeshToCGNS, CGNSToMesh -from Muscat.MeshTools import MeshCreationTools as MCT from Muscat.TestData import GetTestDataPath from Muscat.IO.UtReader import UtReader from Muscat.Bridges.CGNSBridge import AddIntegrationRuleCollection, AddIntegrationPointFlowSolution, AddMuscatIPField From 3c2a5dec675a11ea55927c584f7c8f48f347caab Mon Sep 17 00:00:00 2001 From: Felipe Bordeu Date: Thu, 18 Jun 2026 10:43:38 +0930 Subject: [PATCH 34/35] Potential fix for pull request finding 'Unused import' Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> --- examples/fem_with_gauss_point_data.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/fem_with_gauss_point_data.py b/examples/fem_with_gauss_point_data.py index 4e5d4fcc..5ad9cd5a 100644 --- a/examples/fem_with_gauss_point_data.py +++ b/examples/fem_with_gauss_point_data.py @@ -28,7 +28,7 @@ from Muscat.Bridges.CGNSBridge import MeshToCGNS, CGNSToMesh from Muscat.TestData import GetTestDataPath from Muscat.IO.UtReader import UtReader -from Muscat.Bridges.CGNSBridge import AddIntegrationRuleCollection, AddIntegrationPointFlowSolution, AddMuscatIPField +from Muscat.Bridges.CGNSBridge import AddIntegrationPointFlowSolution, AddMuscatIPField from Muscat.IO.ZsetTools import GetIntegrationRuleForZsetMesh from Muscat.MeshContainers.Filters.FilterObjects import ElementFilter from Muscat.FE.Fields.IPField import IPField From a6edea3987e87ed78324d7d251a66acc45c4e4fe Mon Sep 17 00:00:00 2001 From: Felipe Bordeu Date: Thu, 18 Jun 2026 10:44:20 +0930 Subject: [PATCH 35/35] Potential fix for pull request finding 'Unused local variable' Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> --- examples/fem_with_gauss_point_data.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/fem_with_gauss_point_data.py b/examples/fem_with_gauss_point_data.py index 5ad9cd5a..2546eedb 100644 --- a/examples/fem_with_gauss_point_data.py +++ b/examples/fem_with_gauss_point_data.py @@ -289,8 +289,8 @@ def sample_constructor(i: int): ) )]) - fig.show() - renderer="notebook" + renderer = "notebook" + fig.show(renderer=renderer) # %% from plaid.utils.cgns_vtk import CGNSTreeToVtk