Skip to content

Commit 0ba779e

Browse files
committed
Adapt fetched OPTIMADE entries and accept scalar supercell multipliers
1 parent 16453d5 commit 0ba779e

6 files changed

Lines changed: 190 additions & 3 deletions

File tree

‎src/httk/atomistic/structure_like.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
| structure_record.FundamentalDomainStructureRecord
2727
| structure_record.ASUStructureRecord
2828
| asu_structure.FundamentalDomainStructure
29+
| httk.core.OptimadeResource
2930
| compat.ASEAtomsProtocol
3031
| tuple[httk.core.VectorLike, httk.core.VectorLike, Any]
3132
| list[Any]

‎src/httk/atomistic/supercell.py‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,10 @@ class SupercellResult:
6060
cubicity_score: SurdScalar
6161

6262

63-
def _integer_transformation(transformation: VectorLike) -> tuple[FracVector, int]:
63+
def _integer_transformation(transformation: VectorLike | int) -> tuple[FracVector, int]:
64+
if isinstance(transformation, int) and not isinstance(transformation, bool):
65+
value = _positive_integer(transformation, "supercell multiplier")
66+
return FracVector.create([[value, 0, 0], [0, value, 0], [0, 0, value]]), value**3
6467
matrix = FracVector.create(transformation).simplify()
6568
if matrix.dim != (3, 3):
6669
raise ValueError(f"a supercell transformation must be 3x3, got {matrix.dim}")
@@ -183,7 +186,7 @@ def _replicated_assemblies(
183186

184187
def build_supercell(
185188
structure: StructureLike,
186-
transformation: VectorLike,
189+
transformation: VectorLike | int,
187190
*,
188191
max_sites: int | None = DEFAULT_MAX_SITES,
189192
) -> SupercellResult:

‎src/httk/atomistic/vasp_structures.py‎

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,11 @@
1616
from collections.abc import Callable, Mapping
1717
from typing import Any
1818

19-
from httk.core import SurdScalar, SurdVector, exactmath, load
19+
from httk.core import OptimadeResource, SurdScalar, SurdVector, exactmath, load
2020

2121
from ._vector_guards import to_surdscalar
2222
from .cell import Cell
23+
from .optimade_structure import OptimadeStructure
2324
from .sites import Sites
2425
from .species import Species
2526
from .structure import Structure
@@ -113,6 +114,22 @@ def structure_from_poscar(data: Mapping[str, Any]) -> Structure:
113114
return Structure(cell, sites, species, species_at_sites)
114115

115116

117+
def structure_from_optimade_payload(data: Mapping[str, Any]) -> OptimadeStructure:
118+
"""Build an :class:`~httk.atomistic.OptimadeStructure` from a tagged payload."""
119+
if not isinstance(data, Mapping):
120+
raise ValueError(f"structure_from_optimade_payload expected a mapping, got {type(data).__name__}.")
121+
fmt = data.get("format")
122+
if fmt != "optimade-entry":
123+
raise ValueError(f"structure_from_optimade_payload expected an 'optimade-entry' mapping, got format={fmt!r}.")
124+
resource = data.get("resource")
125+
if not isinstance(resource, OptimadeResource):
126+
raise ValueError(
127+
"structure_from_optimade_payload expected an OptimadeResource in 'resource', "
128+
f"got {type(resource).__name__}."
129+
)
130+
return OptimadeStructure(resource)
131+
132+
116133
def _structure_from_cif(data: Mapping[str, Any]) -> Structure:
117134
"""The full unit cell of a loaded CIF, expanded from its asymmetric unit."""
118135
from .cif_structures import asu_structures_from_cif

‎src/httk/registry/atomistic/__init__.py‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,8 @@
77
adapter="httk.atomistic.vasp_structures:structure_from_payload",
88
formats=("cif", "vasp-poscar"),
99
)
10+
register_format_adapter(
11+
name="atomistic-structures",
12+
adapter="httk.atomistic.vasp_structures:structure_from_optimade_payload",
13+
formats=("optimade-entry",),
14+
)

‎tests/test_fetch_integration.py‎

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
"""End-to-end URL and local-file structure adaptation tests."""
2+
3+
import io
4+
import json
5+
import urllib.request
6+
from email.message import Message
7+
from fractions import Fraction
8+
from pathlib import Path
9+
from typing import Any
10+
11+
import httk.core
12+
import pytest
13+
from httk.core import OptimadeResource, load_entry_type_schema
14+
15+
from httk.atomistic import OptimadeStructure, UnitcellStructureView, build_supercell
16+
17+
pytest.importorskip("httk.io")
18+
19+
_STRUCTURES_ID = "https://schemas.optimade.org/defs/v1.3/entrytypes/optimade/structures"
20+
21+
22+
class _Response(io.BytesIO):
23+
headers = Message()
24+
25+
26+
def _entry_and_info() -> tuple[str, str]:
27+
schema = load_entry_type_schema(_STRUCTURES_ID)
28+
properties = {
29+
remote_name: {"$id": schema.properties[property_name].definition_id}
30+
for remote_name, property_name in {
31+
"remote_lattice": "lattice_vectors",
32+
"remote_fractional": "fractional_site_positions",
33+
"remote_species": "species",
34+
"remote_site_species": "species_at_sites",
35+
"remote_dimensions": "dimension_types",
36+
}.items()
37+
}
38+
entry = {
39+
"data": {
40+
"id": "fixture-1",
41+
"type": "structures",
42+
"attributes": {
43+
"remote_lattice": [[2, 0, 0], [0, 3, 0], [0, 0, 4]],
44+
"remote_fractional": [[0, 0, 0], [1 / 2, 1 / 2, 1 / 2]],
45+
"remote_species": [
46+
{"name": "Na", "chemical_symbols": ["Na"], "concentration": [1]},
47+
{"name": "Cl", "chemical_symbols": ["Cl"], "concentration": [1]},
48+
],
49+
"remote_site_species": ["Na", "Cl"],
50+
"remote_dimensions": [1, 1, 1],
51+
},
52+
}
53+
}
54+
return json.dumps(entry), json.dumps({"data": {"properties": properties}})
55+
56+
57+
def _mock_http(monkeypatch: pytest.MonkeyPatch, responses: dict[str, str]) -> None:
58+
real_urlopen = urllib.request.urlopen
59+
60+
def fake_urlopen(url: Any, *, timeout: float | None) -> _Response:
61+
if url in responses:
62+
return _Response(responses[url].encode())
63+
return real_urlopen(url, timeout=timeout) # type: ignore[return-value]
64+
65+
monkeypatch.setattr(urllib.request, "urlopen", fake_urlopen)
66+
67+
68+
def _optimade_urls() -> tuple[str, str]:
69+
entry_url = "https://example.test/v1/structures/fixture-1"
70+
return entry_url, "https://example.test/v1/info/structures"
71+
72+
73+
def test_fetch_optimade_adapts_quartet_and_supercell(monkeypatch: pytest.MonkeyPatch) -> None:
74+
entry_url, info_url = _optimade_urls()
75+
entry, info = _entry_and_info()
76+
_mock_http(monkeypatch, {entry_url: entry, info_url: info})
77+
78+
fetched = httk.core.fetch(entry_url)
79+
80+
assert isinstance(fetched, OptimadeStructure)
81+
assert fetched.cell.basis.to_fractions_approx() == [[Fraction(2), 0, 0], [0, Fraction(3), 0], [0, 0, Fraction(4)]]
82+
assert fetched.sites.reduced_coords.to_fractions() == [[0, 0, 0], [Fraction(1, 2), Fraction(1, 2), Fraction(1, 2)]]
83+
assert tuple(species.name for species in fetched.species) == ("Na", "Cl")
84+
assert fetched.species_at_sites == ("Na", "Cl")
85+
86+
supercell = build_supercell(fetched, 2).structure
87+
assert len(supercell.sites) == 16
88+
assert supercell.cell.basis.to_fractions_approx() == [[4, 0, 0], [0, 6, 0], [0, 0, 8]]
89+
90+
91+
def test_fetch_optimade_raw_and_view_preserve_resource(monkeypatch: pytest.MonkeyPatch) -> None:
92+
entry_url, info_url = _optimade_urls()
93+
entry, info = _entry_and_info()
94+
_mock_http(monkeypatch, {entry_url: entry, info_url: info})
95+
96+
raw = httk.core.fetch(entry_url, raw=True)
97+
fetched = httk.core.fetch(entry_url)
98+
99+
assert raw["format"] == "optimade-entry"
100+
assert isinstance(raw["resource"], OptimadeResource)
101+
assert isinstance(fetched, OptimadeStructure)
102+
assert UnitcellStructureView(fetched).unwrap() is fetched.resource
103+
104+
105+
def test_load_poscar_and_fetch_file_cif_end_to_end(tmp_path: Path) -> None:
106+
poscar = tmp_path / "POSCAR"
107+
poscar.write_text(
108+
"""He cell
109+
1.0
110+
2.0 0.0 0.0
111+
0.0 3.0 0.0
112+
0.0 0.0 4.0
113+
He
114+
1
115+
Direct
116+
0.0 0.0 0.0
117+
""",
118+
encoding="utf-8",
119+
)
120+
loaded = httk.core.load(str(poscar))
121+
supercell = build_supercell(loaded, 2).structure
122+
assert len(supercell.sites) == 8
123+
assert supercell.cell.basis.to_fractions_approx() == [[4, 0, 0], [0, 6, 0], [0, 0, 8]]
124+
125+
cif = tmp_path / "same.cif"
126+
cif.write_text(
127+
"""data_same
128+
_cell_length_a 2
129+
_cell_length_b 3
130+
_cell_length_c 4
131+
_cell_angle_alpha 90
132+
_cell_angle_beta 90
133+
_cell_angle_gamma 90
134+
_space_group_IT_number 1
135+
_space_group_name_H-M_alt 'P 1'
136+
loop_
137+
_space_group_symop_operation_xyz
138+
'x,y,z'
139+
loop_
140+
_atom_site_label
141+
_atom_site_type_symbol
142+
_atom_site_fract_x
143+
_atom_site_fract_y
144+
_atom_site_fract_z
145+
_atom_site_occupancy
146+
He1 He 0 0 0 1
147+
""",
148+
encoding="utf-8",
149+
)
150+
assert httk.core.fetch(cif.as_uri()) == httk.core.load(str(cif))

‎tests/test_supercell.py‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,12 @@ def test_invalid_transformations_are_rejected(transformation: object, message: s
9494
build_supercell(_binary_structure(), transformation)
9595

9696

97+
@pytest.mark.parametrize("scalar", [True, False])
98+
def test_scalar_supercell_rejects_bool(scalar: bool) -> None:
99+
with pytest.raises(ValueError, match="3x3"):
100+
build_supercell(_binary_structure(), scalar)
101+
102+
97103
def test_a_singular_source_cell_is_rejected_clearly() -> None:
98104
"""Lazy views accept bad geometry until an operation needs the basis.
99105
@@ -129,6 +135,11 @@ def test_site_limit_is_checked_before_materialization() -> None:
129135
)
130136

131137

138+
def test_scalar_supercell_uses_cubic_multiplier_for_site_limit() -> None:
139+
with pytest.raises(ValueError, match=r"16 sites .* max_sites=15"):
140+
build_supercell(_binary_structure(), 2, max_sites=15)
141+
142+
132143
def test_precision_bounds_are_transformed_conservatively() -> None:
133144
structure = Structure(
134145
cell=Cell([[1, 0, 0], [0, 1, 0], [0, 0, 1]], precision=F(1, 1000)),

0 commit comments

Comments
 (0)