From 9369a0b0152aa28ef56008743e56cd6b837cc75f Mon Sep 17 00:00:00 2001 From: Matthew Evans Date: Tue, 28 Jul 2026 23:09:07 +0100 Subject: [PATCH 1/2] Add models for `wyckoff_positions` --- openapi/openapi.json | 17 +++++++++++++++ optimade/models/structures.py | 41 +++++++++++++++++++++++++++++++++++ tests/server/test_client.py | 2 +- 3 files changed, 59 insertions(+), 1 deletion(-) diff --git a/openapi/openapi.json b/openapi/openapi.json index 42c8ea332..168eee136 100644 --- a/openapi/openapi.json +++ b/openapi/openapi.json @@ -5559,6 +5559,23 @@ "x-optimade-queryable": "optional", "x-optimade-support": "optional" }, + "wyckoff_positions": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Wyckoff Positions", + "description": "A list of Wyckoff symbols of sites (where values for sites are specified with the same order of the property `cartesian_site_positions` and/or `fractional_site_positions`).\n\n- **Type**: list of strings\n\n- **Requirements/Conventions**:\n - **Support**: OPTIONAL support in implementations, i.e., MAY be `null`.\n - **Query**: Support for queries on this property is OPTIONAL.\n If supported, filters MAY support only a subset of comparison operators.\n - MUST have length equal to the number of sites in the structure (first dimension of the list properties `cartesian_site_positions` and/or `fractional_site_positions`).\n - If provided, MUST list a single letter (`a`-`z` or `\u03b1`) Wyckoff position for each site in the structure according to the International Tables for Crystallography vol. A (IUCr, 2016).\n\n- **Bibliographic References**:\n IUCr (2016). International Tables for Crystallography vol. A. Space-group Symmetry, Ed. M. I. Aroyo, 6-th edition. Chichester, John Wiley & Sons.", + "x-optimade-queryable": "optional", + "x-optimade-support": "optional" + }, "structure_features": { "items": { "$ref": "#/components/schemas/StructureFeatures" diff --git a/optimade/models/structures.py b/optimade/models/structures.py index 88833a6d7..05132d7af 100644 --- a/optimade/models/structures.py +++ b/optimade/models/structures.py @@ -1075,6 +1075,27 @@ class StructureResourceAttributes(EntryResourceAttributes): ), ] = None + wyckoff_positions: Annotated[ + list[str] | None, + OptimadeField( + description="""A list of Wyckoff symbols of sites (where values for sites are specified with the same order of the property `cartesian_site_positions` and/or `fractional_site_positions`). + +- **Type**: list of strings + +- **Requirements/Conventions**: + - **Support**: OPTIONAL support in implementations, i.e., MAY be `null`. + - **Query**: Support for queries on this property is OPTIONAL. + If supported, filters MAY support only a subset of comparison operators. + - MUST have length equal to the number of sites in the structure (first dimension of the list properties `cartesian_site_positions` and/or `fractional_site_positions`). + - If provided, MUST list a single letter (`a`-`z` or `α`) Wyckoff position for each site in the structure according to the International Tables for Crystallography vol. A (IUCr, 2016). + +- **Bibliographic References**: + IUCr (2016). International Tables for Crystallography vol. A. Space-group Symmetry, Ed. M. I. Aroyo, 6-th edition. Chichester, John Wiley & Sons.""", + support=SupportLevel.OPTIONAL, + queryable=SupportLevel.OPTIONAL, + ), + ] = None + structure_features: Annotated[ list[StructureFeatures], OptimadeField( @@ -1369,6 +1390,26 @@ def validate_species_at_sites(self) -> "StructureResourceAttributes": return self + @model_validator(mode="after") + def validate_wyckoff_positions(self) -> "StructureResourceAttributes": + if self.wyckoff_positions is None: + return self + + if self.nsites and len(self.wyckoff_positions) != self.nsites: + raise ValueError( + f"Number of wyckoff_positions (value: {len(self.wyckoff_positions)}) " + f"MUST equal number of sites (value: {self.nsites})" + ) + + for wyckoff_position in self.wyckoff_positions: + if not re.fullmatch(r"[a-zα]", wyckoff_position): + raise ValueError( + "Each entry in wyckoff_positions MUST be a single letter " + f"(a-z or α), but found {wyckoff_position!r}" + ) + + return self + @field_validator("species", mode="after") @classmethod def validate_species(cls, value: list[Species] | None) -> list[Species] | None: diff --git a/tests/server/test_client.py b/tests/server/test_client.py index 4a6522633..3ab374868 100644 --- a/tests/server/test_client.py +++ b/tests/server/test_client.py @@ -493,7 +493,7 @@ def test_list_properties( results = cli.list_properties("structures") for database in results: - assert len(results[database]) == 32, str(results[database]) + assert len(results[database]) == 33, str(results[database]) results = cli.search_property("structures", "site") for database in results: From 4286f77d8bb11339f1fa82e8cbe5d95a30b67186 Mon Sep 17 00:00:00 2001 From: Matthew Evans Date: Thu, 6 Aug 2026 09:50:19 +0100 Subject: [PATCH 2/2] Add test cases and proper regex validation of Wyckoff positions --- openapi/openapi.json | 3 ++- optimade/models/structures.py | 11 ++--------- optimade/models/types.py | 3 +++ optimade/models/utils.py | 1 + tests/models/test_data/test_good_structures.json | 6 ++++-- tests/models/test_structures.py | 8 ++++++++ 6 files changed, 20 insertions(+), 12 deletions(-) diff --git a/openapi/openapi.json b/openapi/openapi.json index 168eee136..451844855 100644 --- a/openapi/openapi.json +++ b/openapi/openapi.json @@ -5563,7 +5563,8 @@ "anyOf": [ { "items": { - "type": "string" + "type": "string", + "pattern": "^[a-z\u03b1]$" }, "type": "array" }, diff --git a/optimade/models/structures.py b/optimade/models/structures.py index 05132d7af..cf96bb8fb 100644 --- a/optimade/models/structures.py +++ b/optimade/models/structures.py @@ -6,7 +6,7 @@ from pydantic import BaseModel, BeforeValidator, Field, field_validator, model_validator from optimade.models.entries import EntryResource, EntryResourceAttributes -from optimade.models.types import ChemicalSymbol, SymmetryOperation +from optimade.models.types import ChemicalSymbol, SymmetryOperation, WyckoffSymbol from optimade.models.utils import ( ANONYMOUS_ELEMENTS, CHEMICAL_FORMULA_REGEXP, @@ -1076,7 +1076,7 @@ class StructureResourceAttributes(EntryResourceAttributes): ] = None wyckoff_positions: Annotated[ - list[str] | None, + list[WyckoffSymbol] | None, OptimadeField( description="""A list of Wyckoff symbols of sites (where values for sites are specified with the same order of the property `cartesian_site_positions` and/or `fractional_site_positions`). @@ -1401,13 +1401,6 @@ def validate_wyckoff_positions(self) -> "StructureResourceAttributes": f"MUST equal number of sites (value: {self.nsites})" ) - for wyckoff_position in self.wyckoff_positions: - if not re.fullmatch(r"[a-zα]", wyckoff_position): - raise ValueError( - "Each entry in wyckoff_positions MUST be a single letter " - f"(a-z or α), but found {wyckoff_position!r}" - ) - return self @field_validator("species", mode="after") diff --git a/optimade/models/types.py b/optimade/models/types.py index d081db51a..0b23570fa 100644 --- a/optimade/models/types.py +++ b/optimade/models/types.py @@ -8,6 +8,7 @@ EXTENDED_CHEMICAL_SYMBOLS_PATTERN, SEMVER_PATTERN, SYMMETRY_OPERATION_REGEXP, + WYCKOFF_SYMBOL_REGEXP, ) __all__ = ("ChemicalSymbol", "SemanticVersion") @@ -16,6 +17,8 @@ SymmetryOperation = Annotated[str, Field(pattern=SYMMETRY_OPERATION_REGEXP)] +WyckoffSymbol = Annotated[str, Field(pattern=WYCKOFF_SYMBOL_REGEXP)] + ElementSymbol = Annotated[str, Field(pattern=ELEMENT_SYMBOLS_PATTERN)] SemanticVersion = Annotated[ diff --git a/optimade/models/utils.py b/optimade/models/utils.py index ba0b25ac8..0823849ee 100644 --- a/optimade/models/utils.py +++ b/optimade/models/utils.py @@ -238,6 +238,7 @@ def reduce_formula(formula: str) -> str: CHEMICAL_FORMULA_REGEXP = r"(^$)|^([A-Z][a-z]?([2-9]|[1-9]\d+)?)+$" SYMMETRY_OPERATION_REGEXP = r"^([-+]?[xyz]([-+][xyz])?([-+](1/2|[12]/3|[1-3]/4|[1-5]/6))?|[-+]?(1/2|[12]/3|[1-3]/4|[1-5]/6)([-+][xyz]([-+][xyz])?)?),([-+]?[xyz]([-+][xyz])?([-+](1/2|[12]/3|[1-3]/4|[1-5]/6))?|[-+]?(1/2|[12]/3|[1-3]/4|[1-5]/6)([-+][xyz]([-+][xyz])?)?),([-+]?[xyz]([-+][xyz])?([-+](1/2|[12]/3|[1-3]/4|[1-5]/6))?|[-+]?(1/2|[12]/3|[1-3]/4|[1-5]/6)([-+][xyz]([-+][xyz])?)?)$" HM_SYMBOL_REGEXP = r"^(P|I|F|A|B|C|R)(\s+\d+|\s+[a-z]+|\s+\d+/[a-z]+|\s+\d+/\d+|\s+-\d*|\s+\d+/m|\s+[a-z]+/m)*$" +WYCKOFF_SYMBOL_REGEXP = r"^[a-zα]$" def _generate_symmetry_operation_regex(): diff --git a/tests/models/test_data/test_good_structures.json b/tests/models/test_data/test_good_structures.json index 3d8a8dfdf..998058715 100644 --- a/tests/models/test_data/test_good_structures.json +++ b/tests/models/test_data/test_good_structures.json @@ -29,7 +29,8 @@ "group_probabilities": [0.3, 0.5, 0.2] } ], - "structure_features": ["assemblies"] + "structure_features": ["assemblies"], + "wyckoff_positions": ["a", "a", "b"] }, { "task_id": "db/1234567", @@ -61,7 +62,8 @@ "group_probabilities": [0.3, 0.5, 0.2] } ], - "structure_features": ["assemblies"] + "structure_features": ["assemblies"], + "wyckoff_positions": ["a", "b", "b"] }, { "task_id": "db/1234567", diff --git a/tests/models/test_structures.py b/tests/models/test_structures.py index bf8c43463..7681a6c84 100644 --- a/tests/models/test_structures.py +++ b/tests/models/test_structures.py @@ -228,6 +228,14 @@ def test_bad_structures( {"optimization_type": "not_sure"}, "Input should be 'experimental', 'hybrid', 'global', 'local', 'none', 'indeterminate' or 'other'", ), + ( + {"wyckoff_positions": ["1a", "2b", "3c"]}, + "3 validation errors for StructureResource\nattributes.wyckoff_positions.0\n String should match pattern '^[a-zα]$'", + ), + ( + {"wyckoff_positions": ["a", "b", "C"]}, + "1 validation error for StructureResource\nattributes.wyckoff_positions.2\n String should match pattern '^[a-zα]$'", + ), )