Skip to content

Commit 0f3096a

Browse files
authored
Custom props (#77)
* fixing the __repr__ and __str__ for the properties. * Adding set_custom and remove_custom methods. This will work for StructureBuilder, as they are in the setter_mixin.py
1 parent 3b5d9ca commit 0f3096a

File tree

8 files changed

+152
-82
lines changed

8 files changed

+152
-82
lines changed

docs/source/how_to/creation_mutation.md

Lines changed: 0 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -108,18 +108,6 @@ Number of sites: 4
108108
Kinds: {'Fe1', 'O1'}
109109
```
110110

111-
**Advantages of kinds-based format:**
112-
- More compact for structures with many equivalent atoms
113-
- Explicit grouping of atoms with same properties
114-
- Matches the internal storage format used by AiiDA
115-
- Useful when migrating from legacy `orm.StructureData`
116-
117-
**When to use:**
118-
- Structures with many atoms of the same type and properties
119-
- Converting from legacy AiiDA format
120-
- When you already have kind information from calculations
121-
- Large structures where compactness matters
122-
123111
### From ASE
124112

125113
Convert ASE Atoms objects to StructureData:

docs/source/how_to/define_custom.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,3 +27,9 @@ structure_dict = {
2727
structure = StructureData(**structure_dict)
2828
print(structure.properties.custom["my_special_property"])
2929
```
30+
31+
To add a custom properties on an already initialised `StructureBuilder`, you can directly define `structure.properties.custom` or use the `set_custom` method.
32+
In the latter case, if `structure.properties.custom` is already defined, it will be updated with the new values provided in the `set_custom` method, but other keys will not be removed.
33+
34+
To delete a set of defined custom properties, use the `remove_custom` method, passing as input a list of keys (strings). To remove the entire custom dictionary, you
35+
can call the same method without specifying any input parameter.

docs/source/tutorials/1_first_structure.md

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -84,20 +84,18 @@ Information on the supported properties can be obtained by using the `get_suppor
8484

8585
```python
8686
# Get all supported properties
87-
supported = structure.get_supported_properties()
87+
supported = StructureData.get_supported_properties()
8888
print(f"Supported properties: {supported}")
89+
```
90+
91+
In a similar way, it is possible to see the properties which are defined for a given `StructureData` instance:
8992

93+
```python
9094
# Get defined properties in this structure
9195
defined = structure.get_defined_properties()
9296
print(f"Defined properties: {defined}")
9397
```
9498

95-
**Output:**
96-
```
97-
Supported properties: {'global': {'custom', 'tot_magnetization', 'sites', 'cell', 'hubbard', 'tot_charge', 'pbc'}, 'site': {'magnetization', 'kind_name', 'symbol', 'mass', 'magmom', 'position', 'weight', 'charge'}}
98-
Defined properties: {'positions', 'symbols', 'masses', 'sites', 'cell', 'kind_names', 'pbc'}
99-
```
100-
10199
## Modifying Structures
102100

103101
For modifications, use `StructureBuilder` (which is the mutable non-AiiDA version of the `StructureData`):

src/aiida_atomistic/data/structure/models.py

Lines changed: 14 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,13 @@ def freeze_sites(cls, v):
122122
return freeze_nested(v)
123123
return v
124124

125+
@field_validator('custom', mode='after')
126+
def freeze_custom(cls, v):
127+
"""Freeze the list of sites if the structure is immutable."""
128+
if not cls._mutable and v is not None:
129+
return freeze_nested(v)
130+
return v
131+
125132
# computed properties
126133
@computed_field
127134
def cell_volume(self) -> float:
@@ -314,65 +321,12 @@ def kinds(self) -> list[Kind]:
314321
return FrozenList(kinds_list)
315322

316323
def __repr__(self) -> str:
317-
"""Return a concise string representation of the structure."""
318-
# Basic info
319-
nsites = len(self.sites)
320-
formula = self.formula
321-
322-
# PBC info
323-
pbc_dims = sum(self.pbc)
324-
if pbc_dims == 3:
325-
pbc_str = "3D"
326-
elif pbc_dims == 2:
327-
pbc_str = "2D"
328-
elif pbc_dims == 1:
329-
pbc_str = "1D"
330-
else:
331-
pbc_str = "0D"
332-
333-
# Cell volume
334-
volume = self.cell_volume
335-
336-
parts = [
337-
f"formula: {formula}",
338-
f"sites: {nsites}",
339-
f"dimensionality: {pbc_str}",
340-
f"V={volume:.2f} A^3"
341-
]
342-
343-
# Add magnetic info if present
344-
if self.tot_magnetization is not None:
345-
parts.append(f"tot_mag={self.tot_magnetization:.2f}")
346-
elif any(s.magnetization is not None or s.magmom is not None for s in self.sites):
347-
parts.append("magnetic")
348-
349-
# Add charge info if present
350-
if self.tot_charge is not None:
351-
parts.append(f"tot_charge={self.tot_charge:.2f}")
352-
elif any(s.charge is not None for s in self.sites):
353-
parts.append("charged")
354-
355-
# Add alloy/vacancy info
356-
if self.is_alloy:
357-
parts.append("alloy")
358-
if self.has_vacancies:
359-
parts.append("vacancies")
360-
361-
# First line with summary
362-
repr_str = f" | {', '.join(parts)} |"
363-
364-
# Add sites info (limit to first 5 sites to avoid too long representations)
365-
max_sites_to_show = 5
366-
if nsites > 0:
367-
repr_str += "\n Sites:"
368-
for i, site in enumerate(self.sites):
369-
if i >= max_sites_to_show:
370-
repr_str += f"\n ... (+{nsites - max_sites_to_show} more sites)"
371-
break
372-
repr_str += f"\n {site}"
373-
repr_str += "\n"
374-
375-
return repr_str
324+
from pprint import pformat
325+
pformatted = pformat(self.model_dump())
326+
return f"StructureModel({pformatted})"
327+
328+
def __str__(self):
329+
return self.__repr__()
376330

377331
class MutableStructureModel(StructureBaseModel):
378332
"""
@@ -432,5 +386,5 @@ def freeze_pbc(cls, v):
432386
def __setattr__(self, key, value):
433387
# Customizing the exception message when trying to mutate attributes
434388
if key in self.model_fields:
435-
raise ValueError("The AiiDA `StructureData` is immutable. You can create a mutable copy of it using its `get_value` method.")
389+
raise ValueError("The AiiDA `StructureData` is immutable. You can create a mutable copy of it using its `to_builder` method.")
436390
super().__setattr__(key, value)

src/aiida_atomistic/data/structure/setter_mixin.py

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,6 @@ def clear_sites(self,):
162162
del self.properties.sites
163163
return
164164

165-
166165
def remove_property(self, property_name):
167166
"""Clear the given property."""
168167

@@ -257,3 +256,27 @@ def set_kind_names(self, value: list):
257256
for site, kind_name in zip(self.properties.sites, value):
258257
site.kind_name = kind_name
259258
return
259+
260+
def set_custom(self, value: dict):
261+
"""Set the custom properties."""
262+
if not self.properties.custom:
263+
self.properties.custom = {}
264+
self.properties.custom.update(value)
265+
return
266+
267+
def remove_custom(self, keys: t.List[str] = None):
268+
"""Remove the custom properties.
269+
270+
If keys is None, remove all custom properties.
271+
If keys is provided, remove only the specified keys.
272+
"""
273+
274+
if not self.properties.custom:
275+
return
276+
if keys:
277+
for key in keys:
278+
if key in self.properties.custom:
279+
del self.properties.custom[key]
280+
else:
281+
self.remove_property('custom')
282+
return

src/aiida_atomistic/data/structure/structure.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,13 +67,16 @@ def to_builder(self) -> 'StructureBuilder':
6767

6868
def __repr__(self) -> str:
6969
"""Return a concise string representation of the structure."""
70+
71+
from aiida_atomistic.data.structure.utils import get_structure_repr
72+
7073
# Build UUID string without calling super().__repr__() to avoid recursion
7174
if self.is_stored:
7275
uuid_str = f'<{self.__class__.__name__}: uuid: {self.uuid} (pk: {self.pk})>'
7376
else:
7477
uuid_str = f'<{self.__class__.__name__}: uuid: {self.uuid} (unstored)>'
7578

76-
prop_repr_str = self.properties.__repr__()
79+
prop_repr_str = get_structure_repr(self)
7780
return uuid_str + f'\n {prop_repr_str.replace("ImmutableStructureModel","")}'
7881

7982
def __str__(self) -> str:
@@ -113,7 +116,10 @@ def to_aiida(self) -> 'StructureData':
113116

114117
def __repr__(self) -> str:
115118
"""Return a concise string representation of the structure."""
116-
prop_repr_str = self.properties.__repr__()
119+
120+
from aiida_atomistic.data.structure.utils import get_structure_repr
121+
122+
prop_repr_str = get_structure_repr(self)
117123
return super().__repr__() + f'\n {prop_repr_str.replace("MutableStructureModel","")}'
118124

119125
def __str__(self) -> str:

src/aiida_atomistic/data/structure/utils.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -936,3 +936,65 @@ def build_sites_from_expanded_properties(expanded):
936936
structure_dict["sites"] = sites
937937

938938
return structure_dict
939+
940+
def get_structure_repr(structure):
941+
"""Return a concise string representation of the structure."""
942+
943+
# Basic info
944+
nsites = len(structure.properties.sites)
945+
formula = structure.properties.formula
946+
947+
# PBC info
948+
pbc_dims = sum(structure.properties.pbc)
949+
if pbc_dims == 3:
950+
pbc_str = "3D"
951+
elif pbc_dims == 2:
952+
pbc_str = "2D"
953+
elif pbc_dims == 1:
954+
pbc_str = "1D"
955+
else:
956+
pbc_str = "0D"
957+
958+
# Cell volume
959+
volume = structure.properties.cell_volume
960+
961+
parts = [
962+
f"formula: {formula}",
963+
f"sites: {nsites}",
964+
f"dimensionality: {pbc_str}",
965+
f"V={volume:.2f} A^3"
966+
]
967+
968+
# Add magnetic info if present
969+
if structure.properties.tot_magnetization is not None:
970+
parts.append(f"tot_mag={structure.properties.tot_magnetization:.2f}")
971+
elif any(s.magnetization is not None or s.magmom is not None for s in structure.properties.sites):
972+
parts.append("magnetic")
973+
974+
# Add charge info if present
975+
if structure.properties.tot_charge is not None:
976+
parts.append(f"tot_charge={structure.properties.tot_charge:.2f}")
977+
elif any(s.charge is not None for s in structure.properties.sites):
978+
parts.append("charged")
979+
980+
# Add alloy/vacancy info
981+
if structure.properties.is_alloy:
982+
parts.append("alloy")
983+
if structure.properties.has_vacancies:
984+
parts.append("vacancies")
985+
986+
# First line with summary
987+
repr_str = f" | {', '.join(parts)} |"
988+
989+
# Add sites info (limit to first 5 sites to avoid too long representations)
990+
max_sites_to_show = 5
991+
if nsites > 0:
992+
repr_str += "\n Sites:"
993+
for i, site in enumerate(structure.properties.sites):
994+
if i >= max_sites_to_show:
995+
repr_str += f"\n ... (+{nsites - max_sites_to_show} more sites)"
996+
break
997+
repr_str += f"\n {site}"
998+
repr_str += "\n"
999+
1000+
return repr_str

tests/data/test_property_setters.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -341,3 +341,36 @@ def test_convert_to_immutable_after_remove(self):
341341
# Verify charges are not present
342342
assert immutable.properties.charges is None
343343
assert 'charges' not in immutable.get_defined_properties()
344+
345+
class TestCustomPropertySettersRemovers:
346+
"""Test setter and remover methods for custom properties in StructureBuilder."""
347+
348+
def test_set_then_remove_charges(self):
349+
"""Test setting charges and then removing them."""
350+
structure = StructureBuilder(
351+
cell=[[3.0, 0, 0], [0, 3.0, 0], [0, 0, 3.0]],
352+
pbc=[True, True, True],
353+
sites=[
354+
{"symbol": "Fe", "position": [0, 0, 0]},
355+
{"symbol": "O", "position": [1.5, 1.5, 1.5]},
356+
],
357+
custom = {'first_custom_property': 'Hello'}
358+
)
359+
360+
361+
# Initially only one custom property
362+
assert structure.properties.custom == {'first_custom_property': 'Hello'}
363+
## testing also for StructureData
364+
structuredata = structure.to_aiida()
365+
assert structuredata.properties.custom == {'first_custom_property': 'Hello'}
366+
367+
# Set another property
368+
structure.set_custom({'second_custom_property': "World"})
369+
assert structure.properties.custom == {'first_custom_property': 'Hello', 'second_custom_property': 'World'}
370+
371+
# Remove custom properties: first only one, then the whole dictionary
372+
structure.remove_custom(['first_custom_property'])
373+
assert structure.properties.custom == {'second_custom_property': 'World'}
374+
375+
structure.remove_custom()
376+
assert structure.properties.custom is None

0 commit comments

Comments
 (0)