diff --git a/src/presa_mano/clients/open3dmodel_cache.py b/src/presa_mano/clients/open3dmodel_cache.py new file mode 100644 index 0000000..29e5113 --- /dev/null +++ b/src/presa_mano/clients/open3dmodel_cache.py @@ -0,0 +1,45 @@ +"""In-memory LRU cache of parsed STL meshes, keyed by recipe_id + etag.""" +from __future__ import annotations + +from collections import OrderedDict +from dataclasses import dataclass + +import numpy as np + +from ..perception.stl_sampler import parse_stl_bytes +from .open3dmodel import Open3DModelClient + + +@dataclass(frozen=True) +class RecipeMesh: + vertices: np.ndarray # (M, 3) float64, mm in model frame + triangles: np.ndarray # (T, 3) int32 + etag: str + + +class RecipeMeshCache: + """LRU keyed by recipe_id. On hit, validates the etag against the client + and re-fetches if it changed. Singleton-friendly: thread-safe with a + simple lock is NOT implemented (presa-mano is single-process; if + concurrency becomes a need, wrap the OrderedDict ops in a lock).""" + + def __init__(self, client: Open3DModelClient, capacity: int = 8): + self._client = client + self._capacity = max(1, int(capacity)) + self._store: OrderedDict[str, RecipeMesh] = OrderedDict() + + def get(self, recipe_id: str, client: Open3DModelClient | None = None + ) -> RecipeMesh: + c = client or self._client + body, etag = c.get_stl_bytes(recipe_id) + cached = self._store.get(recipe_id) + if cached is not None and cached.etag == etag: + self._store.move_to_end(recipe_id) + return cached + verts, tris = parse_stl_bytes(body) + mesh = RecipeMesh(vertices=verts, triangles=tris, etag=etag) + self._store[recipe_id] = mesh + self._store.move_to_end(recipe_id) + while len(self._store) > self._capacity: + self._store.popitem(last=False) + return mesh diff --git a/tests/test_recipe_mesh_cache.py b/tests/test_recipe_mesh_cache.py new file mode 100644 index 0000000..7786eb4 --- /dev/null +++ b/tests/test_recipe_mesh_cache.py @@ -0,0 +1,101 @@ +"""Unit tests for RecipeMeshCache.""" +from __future__ import annotations + +from unittest.mock import MagicMock + +import numpy as np +import pytest + +from presa_mano.clients.open3dmodel_cache import RecipeMesh, RecipeMeshCache + + +def _verts_tris(): + verts = np.array([[0.,0.,0.],[1.,0.,0.],[0.,1.,0.]]) + tris = np.array([[0,1,2]], dtype=np.int32) + return verts, tris + + +def _mock_client(stl_bytes_by_id: dict[str, tuple[bytes, str]]): + """Mock client whose get_stl_bytes returns the registered (bytes, etag) + for each recipe_id, and counts calls.""" + m = MagicMock() + m._call_count = {} + def _get(recipe_id: str): + m._call_count[recipe_id] = m._call_count.get(recipe_id, 0) + 1 + return stl_bytes_by_id[recipe_id] + m.get_stl_bytes.side_effect = _get + return m + + +def test_cache_miss_then_hit(monkeypatch): + verts, tris = _verts_tris() + monkeypatch.setattr( + "presa_mano.clients.open3dmodel_cache.parse_stl_bytes", + lambda b: (verts, tris), + ) + client = _mock_client({"r1": (b"STL1", "etag-A")}) + cache = RecipeMeshCache(client=client, capacity=4) + + m1 = cache.get("r1") + assert isinstance(m1, RecipeMesh) + np.testing.assert_array_equal(m1.vertices, verts) + np.testing.assert_array_equal(m1.triangles, tris) + assert m1.etag == "etag-A" + assert client._call_count["r1"] == 1 + + # secondo accesso: la cache valida l'etag (refetch dei bytes, sempre) + # ma evita il parse -> identity preserved sull'oggetto RecipeMesh. + m2 = cache.get("r1") + assert m2 is m1 + assert client._call_count["r1"] == 2 + + +def test_cache_etag_change_refetches(monkeypatch): + verts, tris = _verts_tris() + monkeypatch.setattr( + "presa_mano.clients.open3dmodel_cache.parse_stl_bytes", + lambda b: (verts, tris), + ) + client = MagicMock() + seq = iter([(b"STL_OLD", "etag-A"), (b"STL_NEW", "etag-B")]) + client.get_stl_bytes.side_effect = lambda rid: next(seq) + cache = RecipeMeshCache(client=client, capacity=4) + cache.get("r1") + m2 = cache.get("r1") + assert m2.etag == "etag-B" + assert client.get_stl_bytes.call_count == 2 + + +def test_cache_lru_evicts_oldest(monkeypatch): + verts, tris = _verts_tris() + monkeypatch.setattr( + "presa_mano.clients.open3dmodel_cache.parse_stl_bytes", + lambda b: (verts, tris), + ) + client = _mock_client({ + f"r{i}": (f"S{i}".encode(), f"e{i}") for i in range(10) + }) + cache = RecipeMeshCache(client=client, capacity=3) + cache.get("r1"); cache.get("r2"); cache.get("r3") + # r1 e' il piu' vecchio + cache.get("r4") # evict r1 + cache.get("r1") # miss -> re-fetch (call_count va a 2) + assert client._call_count["r1"] == 2 + + +def test_cache_get_uses_overrideable_client(monkeypatch): + """`get(recipe_id, client=other)` permette di passare un client fresco + (utile quando PUT /api/config cambia base_url).""" + verts, tris = _verts_tris() + monkeypatch.setattr( + "presa_mano.clients.open3dmodel_cache.parse_stl_bytes", + lambda b: (verts, tris), + ) + default_client = _mock_client({"r1": (b"S1", "e1")}) + override_client = _mock_client({"r1": (b"S2", "e2")}) + cache = RecipeMeshCache(client=default_client, capacity=2) + + m = cache.get("r1", client=override_client) + assert m.etag == "e2" + assert default_client._call_count.get("r1", 0) == 0 + assert override_client._call_count["r1"] == 1